carlos / platform Public

WIP claim Plan 14 track E checkpoint 14 (not for merge): E1 durable unit-restart consumption journal (component, not yet wired)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev ab75e3094274f2b5d57be2bb1d5eff0d8fd7cf52 parent 58e9c6c
4 files changed, +992 −1
  • internal/provision/testdata/plan14-root-reads-e.tsv +24 −1
  • internal/release/plan14_root_restart_process_test.go +156 −0
  • internal/release/plan14_root_restart_test.go +466 −0
  • internal/release/rootrestart_history.go +346 −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	333.606s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.988s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.298s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.382s
ok  	github.com/carlosframework/platform/internal/activator	227.915s
ok  	github.com/carlosframework/platform/internal/agentsock	0.017s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.415s
ok  	github.com/carlosframework/platform/internal/apperr	0.073s
ok  	github.com/carlosframework/platform/internal/applog	0.142s
ok  	github.com/carlosframework/platform/internal/awsinv	0.005s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.557s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.011s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.711s
ok  	github.com/carlosframework/platform/internal/changelog	0.321s
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.482s
?   	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.737s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.008s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.005s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.108s
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.088s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.009s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.120s
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.015s
ok  	github.com/carlosframework/platform/internal/geoip	0.029s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.101s
ok  	github.com/carlosframework/platform/internal/host	100.276s
ok  	github.com/carlosframework/platform/internal/hostactors	0.446s
ok  	github.com/carlosframework/platform/internal/hostbox	2.203s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.487s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.005s
ok  	github.com/carlosframework/platform/internal/hostexec	4.929s
ok  	github.com/carlosframework/platform/internal/hostfence	0.042s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.544s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.014s
ok  	github.com/carlosframework/platform/internal/hostguard	0.966s
ok  	github.com/carlosframework/platform/internal/hostops	1.433s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.002s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.438s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.142s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	1.812s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.722s
ok  	github.com/carlosframework/platform/internal/instancefs	0.004s
ok  	github.com/carlosframework/platform/internal/instancekey	0.016s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.008s
ok  	github.com/carlosframework/platform/internal/layout	1.407s
ok  	github.com/carlosframework/platform/internal/lease	9.453s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.019s
ok  	github.com/carlosframework/platform/internal/ledger	0.011s
ok  	github.com/carlosframework/platform/internal/mail	0.165s
ok  	github.com/carlosframework/platform/internal/maintenance	0.082s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.528s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.064s
ok  	github.com/carlosframework/platform/internal/metrics	0.004s
ok  	github.com/carlosframework/platform/internal/moves	20.927s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.784s
ok  	github.com/carlosframework/platform/internal/objstore	0.164s
ok  	github.com/carlosframework/platform/internal/ops	0.156s
ok  	github.com/carlosframework/platform/internal/park	0.189s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	13.323s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.007s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.080s
ok  	github.com/carlosframework/platform/internal/relay	7.238s
ok  	github.com/carlosframework/platform/internal/release	20.250s
ok  	github.com/carlosframework/platform/internal/rollout	3.797s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.006s
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.296s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.597s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.699s
ok  	github.com/carlosframework/platform/internal/status	0.039s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.258s
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.005s
ok  	github.com/carlosframework/platform/internal/version	0.001s
ok  	github.com/carlosframework/platform/internal/vet	1.576s
=== 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/13 23:18:38 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE3A0A6360895D, 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.021s
minio contract: PASS
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv
index cc05eeca3..a93789c81 100644
--- a/internal/provision/testdata/plan14-root-reads-e.tsv
+++ b/internal/provision/testdata/plan14-root-reads-e.tsv
@@ -55,7 +55,7 @@ internal/release/signedhead.go StageSignedHead authority E1 Canonical route is s
internal/rootstate/link_linux.go ReadTrustedLink authority A,E1 Checks the full root-owned parent chain before descriptor-based live-link reading. Fixture: TestPlan14TrustedLiveLink; privileged acceptance remains required. file:Open@156 90f8e8de4e2c71f07b5d749cbe4bb1c1dfa426576a82138823449f56fc9ec5ab
internal/rootstate/link_linux.go directory.readLink authority A,E1 Pins the symlink inode with O_PATH and checks owner/type/link count before readlinkat. Fixture: TestPlan14TrustedLiveLink. file:Fstat@356,file:Openat@183,file:Readlinkat@554 a8559fe63dce48830916004ecfe7fbbe5d419568c7daf7a3073cf1b5bb12c3d7
@coverage internal/host d2d36da953f0ca69559cbfe67b919e1cf2a66419c67b8c247177b3da8c7cdbdf
-@coverage internal/release 2536a8c84d34aa2b798cb8af551ed488ea9ce8023d4d2383db4db5c120f08a8b
+@coverage internal/release 3bc50bded64402b272fe913562fce6f79828338e2356387a9bdc600961fc7153
@coverage internal/rootstate 6338f74a6b2059cf77cf478bd6111b37b441eb9d65a309464c378359d4bdac05
internal/release/roothistory_status.go RootHistory.MigrationStatus authority E1 Exact completion binds the accepted authorization digest from durable private history to enrolled-root signed readback. Pending originals, changed evidence and canonical sources cannot supply retirement completion. Fixtures: TestPlan14HistoryReconstructionCompletion, TestPlan14HistoryRetirementCompletion, TestPlan14ReadReconstructionCompletion, TestPlan14ReadRetirementCompletion, TestPlan14RecoveryRetirementProductionCallback, TestPlan14RootHistoryReconstructionCrash, TestPlan14HistoryRetirementProcessCrash. file:Read@1062,file:Read@3891 f0ab7ad2d2d70f8c22ebfd575f5a1c6931f7eba30ccdba80ad43d0efffc88154
internal/host/legacyrestart.go restartEffectFailed authority B3 E8 merge ruling 5: optional identity failures preserve the same restart outcome; no second restart; real not-up takes main rollback. Fixtures: TestPlan14ERecoveryRestartOutcome, TestPlan14ERecoveryBindingRefusalKeepsPromotedFix, TestPlan14ERecoveryNotUpPrecedesBinding. b68bb65884a14fe0cf78a65f56ded4ea41584bba99af9195827d1ea191b340a9
@@ -128,3 +128,26 @@ internal/host/proofroot.go proofRoot.mintAndLog authority E1 E13: signed recover
internal/host/proofroot.go type proofRoot authority E1 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. e3a4fd0a9546176f2bcc278c3aa3152344eb352686558da8a5b06152501c0de6
internal/release/roothistory_recovery_policy.go RootHistory.RetireD1 authority E1 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. 1dfff4e199ac8bede5a31d83a2519325f6610ddd35da9d5a1de61fe600b3f079
internal/release/roothistory_recovery_policy.go RootHistory.recoveryDenials authority E1 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. file:Read@164,file:Read@847 e1ea25a10a50fbaf0d41be9a154ee946e4b6cd21aa41549dc9afebb154415845
+internal/release/rootrestart_history.go RootRestarts.read authority E1 E14 restart journal: exact canonical request/target snapshot, durable before-invocation intent, no ambiguous replay, explicit completeness and completion. Component only; production target/dispatch wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartPendingSurvivesRollover, TestPlan14RootRestartAmbiguousDoesNotReplay, TestPlan14RootRestartVisibleSync, TestPlan14RootRestartProcessCrash. file:Read@192 a8fe6dd99c66b4108ec4d8fdd01efe2159cf14864cc2745f97494a9f67b8dea1
+internal/release/rootrestart_history.go RestartBinding.valid authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. b608b5bbe70237f47692d0d6676c0446ac1cc89e347b0daef9e20a22ad7ff358
+internal/release/rootrestart_history.go RestartTarget.valid authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. 786d3657f47744039db90dc99b6deee6ed5f4df46f99f637234b5438ba65e781
+internal/release/rootrestart_history.go RootRestarts.Begin authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. a5422eff1c1d80de2722f1fa38bbe4546ae7ce902fc08c3bb91f491ce59b0c25
+internal/release/rootrestart_history.go RootRestarts.Complete authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. 1f7712e8669f236e0812e283cc53885e155eaff11245ccebd16bcf64eaa62057
+internal/release/rootrestart_history.go RootRestarts.Fail authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. 56700e2f74e9388c9c6b220409144eeb5cb6d2d4167183321f6b4c7b3a518976
+internal/release/rootrestart_history.go RootRestarts.Finish authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. e7bbf64f66fad83ec9dfc9ceeddea33471a1dc0bee4b8dfc51300707824ccbaf
+internal/release/rootrestart_history.go RootRestarts.Observe authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. 579c2bf485397f9b92af45ebdadef4d12ee578fe2d62cf67d86caeaba6291b7d
+internal/release/rootrestart_history.go RootRestarts.write authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. 5662c7cd8187d35d219d0b0bb8c35b7bd58a3c8d1ee6dba191e689b86c83b6bf
+internal/release/rootrestart_history.go const RootRestartAbsent authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. 4fa28a12e70362c534e8ee3f64453532892916b3493472cbfceba8a08cf0e7a8
+internal/release/rootrestart_history.go const RootRestartsDir authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. ba0b782590acde66acbb0f384f350f9c5694f2122ee9b86c4395492e0c6f8d0c
+internal/release/rootrestart_history.go restartIntentID authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. aacb5f49c58287fe73047c9d2573a925981fdce55bc487ffc986903d1d86495c
+internal/release/rootrestart_history.go restartValue authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. 19cafc0d5767d30fe327f6b5c7156700191377a6ceee1d33f0a4de24bc9116b2
+internal/release/rootrestart_history.go rootRestartKey authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. 7a23de26ff5cbab57bd476c726aff1db03d4b55849ff67064b42f481d6022c77
+internal/release/rootrestart_history.go rootRestartRecord.advance authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. 5553558ec18268c2f21c724a7bcf3cbd4799f153891113a8a149f5020a35cf97
+internal/release/rootrestart_history.go type RestartBinding authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. 9cbfcb1179228d976f9e7c71fd4073bce0e3452a6f10d80d51e4bcc88a6c8f13
+internal/release/rootrestart_history.go type RestartIntent authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. 0ae5a917de8fde551e9c7cdbb4ee8bcdee89ea0e2095cefe885113de5e5f9ace
+internal/release/rootrestart_history.go type RestartProgress authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. df01081800a08065cbdfb1ae7511b3bce6daef1e65b788719090f9d4e8286ea6
+internal/release/rootrestart_history.go type RestartSnapshot authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. afe89555708f5aaa81cbd85a79dd90b7c1b4f41dcf6df0d45a78b93938d7af51
+internal/release/rootrestart_history.go type RestartTarget authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. e6c4efe4bb3c901f32010c1fd35af52501bd94c7a7f39e33ce3177d04a2273b0
+internal/release/rootrestart_history.go type RootRestarts authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. ae897b5a0d3f198ce8fbb4785167e9bb949a48be6b7c386d4b9710f38eee8a69
+internal/release/rootrestart_history.go type rootRestartRecord authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. c047024b6ac2b684e65d8728e338896c65712f531cb853596cb74c855c435b1e
+internal/release/rootrestart_history.go validRootRestart authority E1 E14 restart consumption component. Root-only canonical inputs under release exclusion; production wiring remains pending. Fixtures: TestPlan14RootRestartConsumption, TestPlan14RootRestartSelection, TestPlan14RootRestartReadMustSync, TestPlan14RootRestartProcessCrash. 2298397712698e769cf455413f949dc7fd4fede1edf84b04ca8c5aaf0bdada86
diff --git a/internal/release/plan14_root_restart_process_test.go b/internal/release/plan14_root_restart_process_test.go
new file mode 100644
index 000000000..31e6b7840
--- /dev/null
+++ b/internal/release/plan14_root_restart_process_test.go
@@ -0,0 +1,156 @@
+package release
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+)
+
+type restartCrashStore struct {
+ *healthyRetryStore
+ boundary string
+}
+
+func (s *restartCrashStore) Write(name string, raw []byte) error {
+ var r rootRestartRecord
+ if err := json.Unmarshal(raw, &r); err != nil {
+ return err
+ }
+ stage := "baseline"
+ if r.Pending != nil {
+ stage = "pending"
+ if r.Pending.Targets[0].Before != "" {
+ stage = "intent"
+ }
+ if r.Pending.Targets[0].After != "" {
+ stage = "completed"
+ }
+ } else if r.ScopeID == "request-1" {
+ stage = "consumed"
+ }
+ crash := s.boundary == stage || (s.boundary == "intent-visible" && stage == "intent")
+ if crash && s.boundary == "intent-visible" {
+ s.fail = 1
+ }
+ err := s.healthyRetryStore.Write(name, raw)
+ if crash {
+ if err != nil && !errors.Is(err, s.fault) {
+ return err
+ }
+ os.Exit(74)
+ }
+ return err
+}
+
+func TestPlan14RootRestartProcessCrash(t *testing.T) {
+ if boundary := os.Getenv("PLAN14_RESTART_CRASH"); boundary != "" {
+ _, _, b, in := rootRestartFixture(t)
+ dir := os.Getenv("PLAN14_RESTART_DIR")
+ disk := &healthyRetryStore{dir: dir, fault: errors.New("injected directory sync failure")}
+ h := &RootRestarts{Store: &restartCrashStore{healthyRetryStore: disk, boundary: boundary}}
+ if boundary == "baseline" {
+ h.Observe(b, in)
+ t.Fatal("baseline crash not reached")
+ }
+ in = nextRootRestart(in)
+ p, err := h.Observe(b, in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ target := p.Targets[0].Target
+ if err := h.Begin(b, p.ID, target, "boot-a/old"); err != nil {
+ t.Fatal(err)
+ }
+ // This is the fake unit's effect. The process exits without running defers.
+ if err := os.WriteFile(filepath.Join(dir, "invocation"), []byte("boot-a/new"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if boundary == "effect" {
+ os.Exit(74)
+ }
+ if err := h.Complete(b, p.ID, target, "boot-a/new"); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.Finish(b, p.ID); err != nil {
+ t.Fatal(err)
+ }
+ t.Fatal("crash boundary not reached")
+ }
+ for _, boundary := range []string{"baseline", "pending", "intent", "intent-visible", "effect", "completed", "consumed"} {
+ t.Run(boundary, func(t *testing.T) {
+ h, s, b, in := rootRestartFixture(t)
+ if boundary != "baseline" {
+ if _, err := h.Observe(b, in); err != nil {
+ t.Fatal(err)
+ }
+ }
+ cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestPlan14RootRestartProcessCrash$", "-test.count=1")
+ cmd.Dir = s.dir
+ cmd.Env = append(os.Environ(), "TMPDIR="+s.dir, "PLAN14_RESTART_CRASH="+boundary, "PLAN14_RESTART_DIR="+s.dir)
+ out, err := cmd.CombinedOutput()
+ var exit *exec.ExitError
+ if !errors.As(err, &exit) || exit.ExitCode() != 74 {
+ t.Fatalf("child: %v\n%s", err, out)
+ }
+ h = &RootRestarts{Store: &healthyRetryStore{dir: s.dir}}
+ if boundary == "baseline" {
+ if p, err := h.Observe(b, in); err != nil || p != nil {
+ t.Fatalf("baseline replay: %+v %v", p, err)
+ }
+ return
+ }
+ in = nextRootRestart(in)
+ p, err := h.Observe(b, in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if boundary == "consumed" {
+ if p != nil {
+ t.Fatal("consumed request replayed")
+ }
+ return
+ }
+ if p == nil {
+ t.Fatal("lost pending request")
+ }
+ target := p.Targets[0].Target
+ if boundary == "pending" {
+ if err := h.Begin(b, p.ID, target, "boot-a/old"); err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ if err := h.Begin(b, p.ID, target, "boot-a/old"); err == nil {
+ t.Fatal("crashed attempt dispatched twice")
+ }
+ invocation, err := os.ReadFile(filepath.Join(s.dir, "invocation"))
+ if boundary == "intent" || boundary == "intent-visible" {
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("pre-effect crash: %v", err)
+ }
+ if err := h.Complete(b, p.ID, target, "boot-a/old"); err == nil {
+ t.Fatal("ambiguous attempt consumed")
+ }
+ if err := h.Finish(b, p.ID); err == nil {
+ t.Fatal("unfinished attempt consumed")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := h.Complete(b, p.ID, target, string(invocation)); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.Finish(b, p.ID); err != nil {
+ t.Fatal(err)
+ }
+ if p, err := h.Observe(b, in); err != nil || p != nil {
+ t.Fatalf("recovered request replayed: %+v %v", p, err)
+ }
+ })
+ }
+}
diff --git a/internal/release/plan14_root_restart_test.go b/internal/release/plan14_root_restart_test.go
new file mode 100644
index 000000000..42503e8ff
--- /dev/null
+++ b/internal/release/plan14_root_restart_test.go
@@ -0,0 +1,466 @@
+package release
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func rootRestartFixture(t *testing.T) (*RootRestarts, *rootRestartSyncStore, RestartBinding, RestartSnapshot) {
+ t.Helper()
+ s := &rootRestartSyncStore{healthyRetryStore: &healthyRetryStore{dir: t.TempDir(), fault: errors.New("injected directory sync failure")}}
+ b := RestartBinding{Box: "box-a", Account: "bab", App: "console"}
+ in := RestartSnapshot{Complete: true, AppID: "initial", AppBody: []byte(`{"schema":1}`), Scopes: ScopedRestartGeneration{Schema: 1}, Targets: []RestartTarget{
+ {Host: "one.example", Source: "record-one", Installation: "installation-one", Unit: "one.service", Environment: "prod"},
+ {Host: "two.example", Source: "record-two", Installation: "installation-two", Unit: "two.service", Environment: "test"},
+ }}
+ return &RootRestarts{Store: s}, s, b, in
+}
+
+func nextRootRestart(in RestartSnapshot) RestartSnapshot {
+ in.Scopes.Requests = []RestartRequest{{ID: "request-1", RequestedAt: "2026-10-13T12:00:00Z", Scope: RestartScope{Kind: RestartScopeEnvironment, Value: "prod"}}}
+ return in
+}
+
+func TestPlan14RootRestartConsumption(t *testing.T) {
+ for _, attack := range []string{"delete", "rename", "forge"} {
+ t.Run(attack, func(t *testing.T) {
+ h, _, b, in := rootRestartFixture(t)
+ if p, err := h.Observe(b, in); err != nil || p != nil {
+ t.Fatalf("baseline: %v %v", p, err)
+ }
+ in = nextRootRestart(in)
+ p, err := h.Observe(b, in)
+ if err != nil || p == nil || len(p.Targets) != 1 || p.Targets[0].Target.Host != "one.example" {
+ t.Fatalf("exact scope: %+v %v", p, err)
+ }
+ legacy := filepath.Join(t.TempDir(), "restart-marks.json")
+ if err := os.WriteFile(legacy, []byte(`{"schema":1,"apps":{},"scopes":{"bab|console":"request-1"}}`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ switch attack {
+ case "delete":
+ err = os.Remove(legacy)
+ case "rename":
+ err = os.Rename(filepath.Dir(legacy), filepath.Dir(legacy)+"-moved")
+ t.Cleanup(func() { os.RemoveAll(filepath.Dir(legacy) + "-moved") })
+ case "forge":
+ err = os.WriteFile(legacy, []byte(`{"schema":1,"apps":{},"scopes":{}}`), 0600)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ target := p.Targets[0].Target
+ if err := h.Begin(b, p.ID, target, "boot-a/invocation-a"); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.Complete(b, p.ID, target, "boot-a/invocation-b"); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.Finish(b, p.ID); err != nil {
+ t.Fatal(err)
+ }
+ if p, err := h.Observe(b, in); err != nil || p != nil {
+ t.Fatalf("replayed after %s: %+v %v", attack, p, err)
+ }
+ })
+ }
+}
+
+func TestPlan14RootRestartPendingSurvivesRollover(t *testing.T) {
+ h, _, b, in := rootRestartFixture(t)
+ if _, err := h.Observe(b, in); err != nil {
+ t.Fatal(err)
+ }
+ in = nextRootRestart(in)
+ p, err := h.Observe(b, in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // A held target is not begun. Neither a new request nor vanished routes may erase it.
+ in.Targets = nil
+ in.AppID = "later"
+ in.Scopes.Requests = []RestartRequest{{ID: "rolled-over", Scope: RestartScope{Kind: RestartScopeHost, Value: "other.example"}}}
+ again, err := h.Observe(b, in)
+ if err != nil || !reflect.DeepEqual(p, again) {
+ t.Fatalf("pending work changed: %+v %v", again, err)
+ }
+ if err := h.Complete(b, p.ID, p.Targets[0].Target, "unrelated"); err == nil {
+ t.Fatal("unperformed request consumed")
+ }
+}
+
+func TestPlan14RootRestartAmbiguousDoesNotReplay(t *testing.T) {
+ h, _, b, in := rootRestartFixture(t)
+ if _, err := h.Observe(b, in); err != nil {
+ t.Fatal(err)
+ }
+ in = nextRootRestart(in)
+ p, err := h.Observe(b, in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ target := p.Targets[0].Target
+ if err := h.Begin(b, p.ID, target, "boot-a/invocation-a"); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.Begin(b, p.ID, target, "boot-a/invocation-a"); err == nil {
+ t.Fatal("ambiguous attempt granted a second dispatch")
+ }
+ for _, change := range []string{"same-invocation", "empty", "source", "installation", "unit", "request"} {
+ t.Run(change, func(t *testing.T) {
+ target := target
+ id := p.ID
+ after := "boot-a/invocation-b"
+ switch change {
+ case "same-invocation":
+ after = "boot-a/invocation-a"
+ case "empty":
+ after = ""
+ case "source":
+ target.Source = "changed"
+ case "installation":
+ target.Installation = "changed"
+ case "unit":
+ target.Unit = "foreign.service"
+ case "request":
+ id = "other"
+ }
+ if err := h.Complete(b, id, target, after); err == nil {
+ t.Fatal("ambiguous or foreign evidence consumed request")
+ }
+ })
+ }
+ if err := h.Complete(b, p.ID, target, "boot-a/invocation-b"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPlan14RootRestartVisibleSync(t *testing.T) {
+ for _, boundary := range []string{"baseline", "pending", "begin", "complete"} {
+ t.Run(boundary, func(t *testing.T) {
+ h, s, b, in := rootRestartFixture(t)
+ if boundary == "baseline" {
+ s.failAt = s.writes + 1
+ if _, err := h.Observe(b, in); !errors.Is(err, s.fault) {
+ t.Fatalf("baseline: %v", err)
+ }
+ }
+ if _, err := h.Observe(b, in); err != nil {
+ t.Fatal(err)
+ }
+ in = nextRootRestart(in)
+ if boundary == "pending" {
+ s.failAt = s.writes + 2
+ if _, err := h.Observe(b, in); !errors.Is(err, s.fault) {
+ t.Fatalf("pending: %v", err)
+ }
+ }
+ p, err := h.Observe(b, in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ target := p.Targets[0].Target
+ if boundary == "begin" {
+ s.failAt = s.writes + 2
+ if err := h.Begin(b, p.ID, target, "boot-a/invocation-a"); !errors.Is(err, s.fault) {
+ t.Fatalf("begin: %v", err)
+ }
+ if err := h.Begin(b, p.ID, target, "boot-a/invocation-a"); err == nil {
+ t.Fatal("visible intent dispatched twice")
+ }
+ return
+ }
+ if err := h.Begin(b, p.ID, target, "boot-a/invocation-a"); err != nil {
+ t.Fatal(err)
+ }
+ if boundary == "complete" {
+ s.failAt = s.writes + 2
+ if err := h.Complete(b, p.ID, target, "boot-a/invocation-b"); !errors.Is(err, s.fault) {
+ t.Fatalf("complete: %v", err)
+ }
+ }
+ if err := h.Complete(b, p.ID, target, "boot-a/invocation-b"); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.Finish(b, p.ID); err != nil {
+ t.Fatal(err)
+ }
+ if p, err := h.Observe(b, in); err != nil || p != nil {
+ t.Fatalf("completion not durable: %+v %v", p, err)
+ }
+ })
+ }
+}
+
+func TestPlan14RootRestartStrictState(t *testing.T) {
+ for _, change := range []string{"schema", "box", "complete", "revision", "extra", "duplicate", "trailing", "null"} {
+ t.Run(change, func(t *testing.T) {
+ h, s, b, in := rootRestartFixture(t)
+ h.Observe(b, in)
+ raw, err := s.Read(rootRestartKey(b))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var r rootRestartRecord
+ if err := json.Unmarshal(raw, &r); err != nil {
+ t.Fatal(err)
+ }
+ switch change {
+ case "schema":
+ r.Schema++
+ case "box":
+ r.Binding.Box = "other"
+ case "complete":
+ r.Complete = 0
+ case "revision":
+ r.Revision = 0
+ }
+ raw, _ = json.Marshal(r)
+ switch change {
+ case "extra":
+ raw = append(raw[:len(raw)-1], []byte(`,"extra":true}`)...)
+ case "duplicate":
+ raw = append(raw[:len(raw)-1], []byte(`,"schema":1}`)...)
+ case "trailing":
+ raw = append(raw, []byte(` {}`)...)
+ case "null":
+ raw = []byte(`null`)
+ }
+ if err := s.Write(rootRestartKey(b), raw); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := h.Observe(b, nextRootRestart(in)); err == nil {
+ t.Fatal("invalid state granted consumption")
+ }
+ })
+ }
+}
+
+type rootRestartSyncStore struct {
+ *healthyRetryStore
+ writes int
+ failAt int
+}
+
+func (s *rootRestartSyncStore) Write(name string, raw []byte) error {
+ s.writes++
+ if s.writes == s.failAt {
+ s.fail = 1
+ }
+ return s.healthyRetryStore.Write(name, raw)
+}
+
+func TestPlan14RootRestartSelection(t *testing.T) {
+ for _, kind := range []string{"app", "host", "environment", "unknown", "retry", "hibernate", "rollover", "incomplete", "absent", "duplicate-target", "duplicate-request"} {
+ t.Run(kind, func(t *testing.T) {
+ h, _, b, in := rootRestartFixture(t)
+ if kind == "absent" {
+ in.AppID = RootRestartAbsent
+ in.AppBody = nil
+ }
+ if _, err := h.Observe(b, in); err != nil {
+ t.Fatal(err)
+ }
+ in = nextRootRestart(in)
+ want := 1
+ switch kind {
+ case "app", "absent":
+ in.AppID = "new-app-request"
+ in.AppBody = []byte(`{"requested_at":"2026-10-13T12:00:00Z"}`)
+ want = 2
+ case "host":
+ in.Scopes.Requests[0].Scope = RestartScope{Kind: RestartScopeHost, Value: "two.example"}
+ case "unknown":
+ in.Scopes.Requests[0].Scope = RestartScope{Kind: "future-selector", Value: "prod"}
+ want = 0
+ case "retry":
+ in.Scopes.Requests[0].Scope = RestartScope{Kind: RestartScopeRetry, Value: "one.example"}
+ want = 0
+ case "hibernate":
+ in.Targets[0].Hibernate = true
+ want = 0
+ case "rollover":
+ if p, err := h.Observe(b, RestartSnapshot{Complete: true, AppID: in.AppID, AppBody: in.AppBody, Scopes: in.Scopes}); err != nil || p != nil {
+ t.Fatalf("first empty target request: %+v %v", p, err)
+ }
+ in.Scopes.Requests[0].ID = "new-position"
+ want = 0
+ case "incomplete":
+ in.Complete = false
+ case "duplicate-target":
+ in.Targets = append(in.Targets, in.Targets[0])
+ case "duplicate-request":
+ in.Scopes.Requests = append(in.Scopes.Requests, in.Scopes.Requests[0])
+ }
+ p, err := h.Observe(b, in)
+ if kind == "incomplete" || kind == "duplicate-target" || kind == "duplicate-request" {
+ if err == nil {
+ t.Fatal("invalid census/request accepted")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want == 0 {
+ if p != nil {
+ t.Fatalf("unexpected restart: %+v", p)
+ }
+ return
+ }
+ if p == nil || len(p.Targets) != want {
+ t.Fatalf("targets: %+v", p)
+ }
+ if kind == "host" && p.Targets[0].Target.Host != "two.example" {
+ t.Fatal("host selection widened")
+ }
+ if kind == "app" && string(p.AppBody) != string(in.AppBody) {
+ t.Fatal("lost exact app request")
+ }
+ if err := h.Finish(b, p.ID); err == nil {
+ t.Fatal("unperformed targets consumed")
+ }
+ if err := h.Begin(b, p.ID, p.Targets[0].Target, "old"); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.Complete(b, p.ID, p.Targets[0].Target, "new"); err != nil {
+ t.Fatal(err)
+ }
+ if want == 2 {
+ if err := h.Finish(b, p.ID); err == nil {
+ t.Fatal("partial target set consumed")
+ }
+ }
+ })
+ }
+}
+
+func TestPlan14RootRestartFailureIsTerminal(t *testing.T) {
+ h, _, b, in := rootRestartFixture(t)
+ if _, err := h.Observe(b, in); err != nil {
+ t.Fatal(err)
+ }
+ in = nextRootRestart(in)
+ p, err := h.Observe(b, in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ target := p.Targets[0].Target
+ if err := h.Fail(b, p.ID, target); err == nil {
+ t.Fatal("undispatched request consumed as failure")
+ }
+ if err := h.Begin(b, p.ID, target, "before"); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.Fail(b, p.ID, target); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.Begin(b, p.ID, target, "before"); err == nil {
+ t.Fatal("terminal failure retried")
+ }
+ if err := h.Complete(b, p.ID, target, "after"); err == nil {
+ t.Fatal("terminal failure changed to success")
+ }
+ if err := h.Finish(b, p.ID); err != nil {
+ t.Fatal(err)
+ }
+ if p, err := h.Observe(b, in); err != nil || p != nil {
+ t.Fatalf("failure replayed: %+v %v", p, err)
+ }
+ in.Scopes.Requests = append(in.Scopes.Requests, RestartRequest{ID: "retry-as-new-request", Scope: RestartScope{Kind: RestartScopeHost, Value: target.Host}})
+ if p, err := h.Observe(b, in); err != nil || p == nil {
+ t.Fatalf("fresh explicit request refused: %+v %v", p, err)
+ }
+}
+
+func TestPlan14RootRestartReadMustSync(t *testing.T) {
+ for _, step := range []string{"observe", "begin", "complete", "finish"} {
+ t.Run(step, func(t *testing.T) {
+ h, s, b, in := rootRestartFixture(t)
+ if _, err := h.Observe(b, in); err != nil {
+ t.Fatal(err)
+ }
+ in = nextRootRestart(in)
+ p, err := h.Observe(b, in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ target := p.Targets[0].Target
+ if step == "complete" || step == "finish" {
+ if err := h.Begin(b, p.ID, target, "before"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if step == "finish" {
+ if err := h.Complete(b, p.ID, target, "after"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ s.failAt = s.writes + 1
+ switch step {
+ case "observe":
+ _, err = h.Observe(b, in)
+ case "begin":
+ err = h.Begin(b, p.ID, target, "before")
+ case "complete":
+ err = h.Complete(b, p.ID, target, "after")
+ case "finish":
+ err = h.Finish(b, p.ID)
+ }
+ if !errors.Is(err, s.fault) {
+ t.Fatalf("%s granted without durable read: %v", step, err)
+ }
+ })
+ }
+}
+
+func TestPlan14RootRestartPendingStateTampering(t *testing.T) {
+ for _, change := range []string{"remove-target", "append-target", "source", "selector", "body", "id", "completed-without-begin", "failed-without-begin"} {
+ t.Run(change, func(t *testing.T) {
+ h, s, b, in := rootRestartFixture(t)
+ if _, err := h.Observe(b, in); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := h.Observe(b, nextRootRestart(in)); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := s.Read(rootRestartKey(b))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var r rootRestartRecord
+ if err := json.Unmarshal(raw, &r); err != nil {
+ t.Fatal(err)
+ }
+ switch change {
+ case "remove-target":
+ r.Pending.Targets = nil
+ case "append-target":
+ r.Pending.Targets = append(r.Pending.Targets, RestartProgress{Target: in.Targets[1]})
+ case "source":
+ r.Pending.Targets[0].Target.Source = "other"
+ case "selector":
+ r.Pending.Requests[0].Scope.Value = "test"
+ case "body":
+ r.Pending.AppBody = []byte("other")
+ case "id":
+ r.Pending.ID = "other"
+ case "completed-without-begin":
+ r.Pending.Targets[0].After = "after"
+ case "failed-without-begin":
+ r.Pending.Targets[0].Failed = true
+ }
+ raw, _ = json.Marshal(r)
+ if err := s.Write(rootRestartKey(b), raw); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := h.Observe(b, nextRootRestart(in)); err == nil {
+ t.Fatal("changed pending authority accepted")
+ }
+ })
+ }
+}
diff --git a/internal/release/rootrestart_history.go b/internal/release/rootrestart_history.go
new file mode 100644
index 000000000..8a6c45ca7
--- /dev/null
+++ b/internal/release/rootrestart_history.go
@@ -0,0 +1,346 @@
+package release
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "os"
+ "slices"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/controlname"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+const RootRestartAbsent = "\x00absent"
+
+const RootRestartsDir = "/var/lib/carlos-release/private/restarts"
+
+// Inputs to this journal come from root's canonical providers. Callers hold
+// release exclusion across observation, dispatch and completion. No service
+// marks, registry rows or service RPC payloads establish these inputs.
+type RootRestarts struct{ Store rootstate.Store }
+
+type RestartBinding struct {
+ Box string `json:"box"`
+ Account string `json:"account"`
+ App string `json:"app"`
+}
+
+type RestartTarget struct {
+ Host string `json:"host"`
+ Source string `json:"source"`
+ Installation string `json:"installation"`
+ Unit string `json:"unit"`
+ Environment string `json:"environment"`
+ Hibernate bool `json:"hibernate"`
+}
+
+// Complete means root has enumerated the canonical local target set, including
+// held installations. A held route remains a target; the dispatch gate waits.
+type RestartSnapshot struct {
+ Complete bool
+ AppID string
+ AppBody []byte
+ Scopes ScopedRestartGeneration
+ Targets []RestartTarget
+}
+
+type RestartIntent struct {
+ ID string `json:"id"`
+ AppID string `json:"app_id"`
+ AppBody []byte `json:"app_body,omitempty"`
+ ScopeID string `json:"scope_id"`
+ Requests []RestartRequest `json:"requests"`
+ Targets []RestartProgress `json:"targets"`
+}
+
+type RestartProgress struct {
+ Target RestartTarget `json:"target"`
+ Before string `json:"before,omitempty"`
+ After string `json:"after,omitempty"`
+ Failed bool `json:"failed,omitempty"`
+}
+
+type rootRestartRecord struct {
+ Schema int `json:"schema"`
+ Binding RestartBinding `json:"binding"`
+ Revision uint64 `json:"revision"`
+ Complete uint64 `json:"complete"`
+ AppID string `json:"app_id"`
+ ScopeID string `json:"scope_id"`
+ Pending *RestartIntent `json:"pending,omitempty"`
+}
+
+func restartValue(s string) bool {
+ return s != "" && len(s) <= 4096 && !strings.ContainsAny(s, "\x00\n\r")
+}
+func (b RestartBinding) valid() bool {
+ return restartValue(b.Box) && ValidAccount(b.Account) && ValidApp(b.App)
+}
+func (t RestartTarget) valid() bool {
+ return controlname.ValidHostShape(t.Host) && restartValue(t.Source) && restartValue(t.Installation) && registry.ValidUnitName(t.Unit) && len(t.Environment) <= 4096
+}
+func rootRestartKey(b RestartBinding) string {
+ raw, _ := json.Marshal(b)
+ sum := sha256.Sum256(raw)
+ return hex.EncodeToString(sum[:]) + ".json"
+}
+func restartIntentID(p RestartIntent) string {
+ p.ID = ""
+ p.Targets = slices.Clone(p.Targets)
+ for i := range p.Targets {
+ p.Targets[i].Before = ""
+ p.Targets[i].After = ""
+ p.Targets[i].Failed = false
+ }
+ raw, _ := json.Marshal(p)
+ sum := sha256.Sum256(raw)
+ return hex.EncodeToString(sum[:])
+}
+func validRootRestart(r rootRestartRecord, b RestartBinding) bool {
+ if !b.valid() || r.Schema != 1 || r.Binding != b || r.Revision == 0 || r.Complete != r.Revision || !(restartValue(r.AppID) || r.AppID == RootRestartAbsent) || !(restartValue(r.ScopeID) || r.ScopeID == RestartScopeNone) {
+ return false
+ }
+ p := r.Pending
+ if p == nil {
+ return true
+ }
+ if !restartValue(p.ID) || !(restartValue(p.AppID) || p.AppID == RootRestartAbsent) || !(restartValue(p.ScopeID) || p.ScopeID == RestartScopeNone) || len(p.Requests) > historyLimit || len(p.Targets) > historyLimit || (p.AppID == r.AppID && p.ScopeID == r.ScopeID) {
+ return false
+ }
+ seen := map[string]bool{}
+ for _, v := range p.Targets {
+ if (v.Failed && (v.Before == "" || v.After != "")) || !v.Target.valid() || seen[v.Target.Host] || (v.Before != "" && !restartValue(v.Before)) || (v.After != "" && (!restartValue(v.After) || v.Before == "" || v.Before == v.After)) {
+ return false
+ }
+ seen[v.Target.Host] = true
+ }
+ seen = map[string]bool{}
+ for _, q := range p.Requests {
+ if !restartValue(q.ID) || seen[q.ID] {
+ return false
+ }
+ seen[q.ID] = true
+ }
+ copy := *p
+ copy.Targets = slices.Clone(p.Targets)
+ return p.ID == restartIntentID(copy)
+}
+func (h *RootRestarts) read(b RestartBinding) (rootRestartRecord, error) {
+ var r rootRestartRecord
+ if h == nil || h.Store == nil || !b.valid() {
+ return r, os.ErrPermission
+ }
+ raw, err := h.Store.Read(rootRestartKey(b))
+ if err != nil {
+ return r, err
+ }
+ if len(raw) > rootstate.MaxRecord || strictCanonicalJSON(raw, &r) != nil || !validRootRestart(r, b) {
+ return r, os.ErrPermission
+ }
+ // A rename visible to this process may still need its directory synced.
+ if err := h.Store.Write(rootRestartKey(b), raw); err != nil {
+ return rootRestartRecord{}, err
+ }
+ return r, nil
+}
+func (h *RootRestarts) write(r rootRestartRecord) error {
+ if !validRootRestart(r, r.Binding) {
+ return os.ErrPermission
+ }
+ raw, err := json.Marshal(r)
+ if err != nil {
+ return err
+ }
+ if len(raw) > rootstate.MaxRecord {
+ return os.ErrPermission
+ }
+ return h.Store.Write(rootRestartKey(r.Binding), raw)
+}
+func (r *rootRestartRecord) advance() error {
+ if r.Revision == ^uint64(0) {
+ return os.ErrPermission
+ }
+ r.Revision++
+ r.Complete = r.Revision
+ return nil
+}
+
+// Observe baselines only an absent root record. Corruption is unavailable.
+// Pending work is returned before considering rollover or a changed inventory.
+// The caller must verify target binding and admission again before Begin.
+func (h *RootRestarts) Observe(b RestartBinding, in RestartSnapshot) (*RestartIntent, error) {
+ r, err := h.read(b)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ if err == nil && r.Pending != nil {
+ return r.Pending, nil
+ }
+ if !in.Complete || !(restartValue(in.AppID) || in.AppID == RootRestartAbsent) || in.Scopes.Schema != 1 || len(in.Scopes.Requests) > historyLimit || len(in.Targets) > historyLimit {
+ return nil, os.ErrPermission
+ }
+ seen := map[string]bool{}
+ for _, target := range in.Targets {
+ if !target.valid() || seen[target.Host] {
+ return nil, os.ErrPermission
+ }
+ seen[target.Host] = true
+ }
+ seen = map[string]bool{}
+ for _, q := range in.Scopes.Requests {
+ if !restartValue(q.ID) || q.ID == RestartScopeNone || seen[q.ID] {
+ return nil, os.ErrPermission
+ }
+ seen[q.ID] = true
+ }
+ if errors.Is(err, os.ErrNotExist) {
+ r = rootRestartRecord{Schema: 1, Binding: b, Revision: 1, Complete: 1, AppID: in.AppID, ScopeID: in.Scopes.Newest()}
+ return nil, h.write(r)
+ }
+ if r.AppID == in.AppID && r.ScopeID == in.Scopes.Newest() {
+ return nil, nil
+ }
+ requests, found := in.Scopes.PendingSince(r.ScopeID)
+ if !found {
+ requests = nil
+ }
+ p := RestartIntent{AppID: in.AppID, ScopeID: in.Scopes.Newest(), Requests: slices.Clone(requests)}
+ if r.AppID != in.AppID {
+ p.AppBody = slices.Clone(in.AppBody)
+ }
+ targets := slices.Clone(in.Targets)
+ slices.SortFunc(targets, func(a, b RestartTarget) int { return strings.Compare(a.Host, b.Host) })
+ for _, target := range targets {
+ selected := r.AppID != in.AppID
+ for _, q := range requests {
+ yes, known := q.Scope.Selects(registry.Route{Host: target.Host, Environment: target.Environment})
+ selected = selected || (known && yes)
+ }
+ if selected && !target.Hibernate {
+ p.Targets = append(p.Targets, RestartProgress{Target: target})
+ }
+ }
+ if err := r.advance(); err != nil {
+ return nil, err
+ }
+ if len(p.Targets) == 0 {
+ r.AppID, r.ScopeID = p.AppID, p.ScopeID
+ return nil, h.write(r)
+ }
+ p.ID = restartIntentID(p)
+ r.Pending = &p
+ if err := h.write(r); err != nil {
+ return nil, err
+ }
+ return &p, nil
+}
+
+// Begin grants one dispatch only. Even an identical replay refuses, since a
+// crash after intent persistence cannot prove whether systemd saw the call.
+func (h *RootRestarts) Begin(b RestartBinding, id string, target RestartTarget, before string) error {
+ r, err := h.read(b)
+ if err != nil {
+ return err
+ }
+ if r.Pending == nil || r.Pending.ID != id || !restartValue(before) {
+ return os.ErrPermission
+ }
+ for i := range r.Pending.Targets {
+ p := &r.Pending.Targets[i]
+ if p.Target != target {
+ continue
+ }
+ if p.Before != "" {
+ return os.ErrPermission
+ }
+ p.Before = before
+ if err := r.advance(); err != nil {
+ return err
+ }
+ return h.write(r)
+ }
+ return os.ErrPermission
+}
+
+// Complete accepts only a freshly root-verified successor invocation on the
+// exact captured binding. An ambiguous or failed observation leaves pending
+// work intact. Repeating the identical completion only retries durability.
+func (h *RootRestarts) Complete(b RestartBinding, id string, target RestartTarget, after string) error {
+ r, err := h.read(b)
+ if err != nil {
+ return err
+ }
+ if r.Pending == nil || r.Pending.ID != id || !restartValue(after) {
+ return os.ErrPermission
+ }
+ for i := range r.Pending.Targets {
+ p := &r.Pending.Targets[i]
+ if p.Target != target {
+ continue
+ }
+ if p.Failed || p.Before == "" || p.Before == after || (p.After != "" && p.After != after) {
+ return os.ErrPermission
+ }
+ p.After = after
+ if err := r.advance(); err != nil {
+ return err
+ }
+ return h.write(r)
+ }
+ return os.ErrPermission
+}
+
+// Finish advances consumption only when every journaled target has evidence.
+// It is separate from Complete so a failed completion write can be retried
+// without losing the request identity used to verify that retry.
+func (h *RootRestarts) Finish(b RestartBinding, id string) error {
+ r, err := h.read(b)
+ if err != nil {
+ return err
+ }
+ if r.Pending == nil || r.Pending.ID != id {
+ return os.ErrPermission
+ }
+ for _, p := range r.Pending.Targets {
+ if p.After == "" && !p.Failed {
+ return os.ErrPermission
+ }
+ }
+ r.AppID, r.ScopeID = r.Pending.AppID, r.Pending.ScopeID
+ r.Pending = nil
+ if err := r.advance(); err != nil {
+ return err
+ }
+ return h.write(r)
+}
+
+// Fail records a definite dispatch failure observed by the dispatching root
+// process. Recovery must not infer it from missing invocation evidence.
+func (h *RootRestarts) Fail(b RestartBinding, id string, target RestartTarget) error {
+ r, err := h.read(b)
+ if err != nil {
+ return err
+ }
+ if r.Pending == nil || r.Pending.ID != id {
+ return os.ErrPermission
+ }
+ for i := range r.Pending.Targets {
+ p := &r.Pending.Targets[i]
+ if p.Target != target {
+ continue
+ }
+ if p.Before == "" || p.After != "" {
+ return os.ErrPermission
+ }
+ p.Failed = true
+ if err := r.advance(); err != nil {
+ return err
+ }
+ return h.write(r)
+ }
+ return os.ErrPermission
+}