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>
4 files changed,
+992
−1
internal/provision/testdata/plan14-root-reads-e.tsv+24 −1internal/release/plan14_root_restart_process_test.go+156 −0internal/release/plan14_root_restart_test.go+466 −0internal/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 |
| +} |