carlos / platform Public

WIP claim Plan 14 track E checkpoint 12 (not for merge): E1 continued

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev d5279238481c46ba671102c45e7b8d8616e3ec77 parent 62e5b92
10 files changed, +484 −23
  • internal/historyreadback/completion.go +66 −0
  • internal/historyreadback/plan14_completion_test.go +161 −0
  • internal/host/plan14_history_retirement_test.go +57 −0
  • internal/provision/testdata/plan14-root-reads-e.tsv +10 −7
  • internal/release/plan14_history_completion_test.go +109 −0
  • internal/release/plan14_history_retirement_process_test.go +24 −0
  • internal/release/plan14_root_reconstruction_test.go +20 −0
  • internal/release/roothistory_reconstruct.go +5 −4
  • internal/release/roothistory_retirement.go +14 −9
  • internal/release/roothistory_status.go +18 −3

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	340.093s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.105s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.270s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.378s
ok  	github.com/carlosframework/platform/internal/activator	227.889s
ok  	github.com/carlosframework/platform/internal/agentsock	0.015s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.022s
ok  	github.com/carlosframework/platform/internal/appconfig	2.313s
ok  	github.com/carlosframework/platform/internal/apperr	0.075s
ok  	github.com/carlosframework/platform/internal/applog	0.133s
ok  	github.com/carlosframework/platform/internal/awsinv	0.003s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.936s
?   	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.014s
ok  	github.com/carlosframework/platform/internal/certwarm	0.004s
ok  	github.com/carlosframework/platform/internal/cgroup	0.700s
ok  	github.com/carlosframework/platform/internal/changelog	0.294s
ok  	github.com/carlosframework/platform/internal/clamd	0.055s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	363.634s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	6.542s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.002s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.133s
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	25.992s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.008s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.122s
ok  	github.com/carlosframework/platform/internal/gate	1.656s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
ok  	github.com/carlosframework/platform/internal/geo	0.016s
ok  	github.com/carlosframework/platform/internal/geoip	0.029s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.102s
ok  	github.com/carlosframework/platform/internal/host	100.070s
ok  	github.com/carlosframework/platform/internal/hostactors	0.290s
ok  	github.com/carlosframework/platform/internal/hostbox	2.113s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.510s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.007s
ok  	github.com/carlosframework/platform/internal/hostexec	4.880s
ok  	github.com/carlosframework/platform/internal/hostfence	0.036s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.554s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.016s
ok  	github.com/carlosframework/platform/internal/hostguard	0.977s
ok  	github.com/carlosframework/platform/internal/hostops	1.430s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.004s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.003s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.444s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.136s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.003s
ok  	github.com/carlosframework/platform/internal/infraview	0.003s
ok  	github.com/carlosframework/platform/internal/instancectl	1.812s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.706s
ok  	github.com/carlosframework/platform/internal/instancefs	0.021s
ok  	github.com/carlosframework/platform/internal/instancekey	0.018s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.023s
ok  	github.com/carlosframework/platform/internal/layout	1.445s
ok  	github.com/carlosframework/platform/internal/lease	9.436s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.006s
ok  	github.com/carlosframework/platform/internal/ledger	0.017s
ok  	github.com/carlosframework/platform/internal/mail	0.158s
ok  	github.com/carlosframework/platform/internal/maintenance	0.078s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.546s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.077s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	20.993s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.759s
ok  	github.com/carlosframework/platform/internal/objstore	0.178s
ok  	github.com/carlosframework/platform/internal/ops	0.158s
ok  	github.com/carlosframework/platform/internal/park	0.202s
ok  	github.com/carlosframework/platform/internal/privatefile	0.007s
ok  	github.com/carlosframework/platform/internal/provision	13.408s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.008s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.002s
ok  	github.com/carlosframework/platform/internal/relay	7.232s
ok  	github.com/carlosframework/platform/internal/release	21.103s
ok  	github.com/carlosframework/platform/internal/rollout	3.752s
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.007s
ok  	github.com/carlosframework/platform/internal/rootstate	0.025s
ok  	github.com/carlosframework/platform/internal/s3compat	0.004s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.292s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.636s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.801s
ok  	github.com/carlosframework/platform/internal/status	0.040s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.167s
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.006s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.562s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.022s
=== RUN   TestT4StorageProbeMinio
2026/10/13 19:20:06 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE2D062A01D291, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.021s
minio contract: PASS
diff --git a/internal/historyreadback/completion.go b/internal/historyreadback/completion.go
new file mode 100644
index 000000000..151270d8b
--- /dev/null
+++ b/internal/historyreadback/completion.go
@@ -0,0 +1,66 @@
+package historyreadback
+
+import (
+ "context"
+ "os"
+ "slices"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ReadReconstruction verifies the exact accepted authorization from fresh,
+// enrolled-root readback. It does not certify fresh installation or retire
+// independent recovery obligations carried in the returned status.
+func ReadReconstruction(ctx context.Context, d hostactors.Directory, q Request, g release.HistoryReconstruction, now time.Time) (Receipt, error) {
+ r, err := ReadStatus(ctx, d, q, now)
+ if err != nil {
+ return Receipt{}, err
+ }
+ s := r.Status
+ accepted := digest(g)
+ if !validHex(accepted, 32) || g.Schema != 1 || g.Kind != "carlos-release-history" || g.Operation == "" || g.IssuedAt.IsZero() || g.Account != q.Scope.Binding.Account || g.App != q.Scope.Binding.App || g.Identity != s.Identity || g.Revision == 0 || g.Revision == ^uint64(0) || s.Revision != g.Revision+1 || s.Pending || s.LastReconstructionDigest != accepted || !s.AppComplete || s.AppRevision <= g.AppRevision || s.BakeComplete == 0 || s.BakeRevision <= g.BakeRevision || len(g.Hosts) == 0 || len(g.Hosts) > 256 {
+ return Receipt{}, os.ErrPermission
+ }
+ seen := map[string]bool{}
+ for _, expected := range g.Hosts {
+ if seen[expected.Host] {
+ return Receipt{}, os.ErrPermission
+ }
+ seen[expected.Host] = true
+ i := slices.IndexFunc(s.Hosts, func(h release.HistoryHostStatus) bool { return h.Host == expected.Host })
+ if i < 0 || s.Hosts[i].Source != expected.Source || s.Hosts[i].Revision <= expected.Revision || !s.Hosts[i].RefusalStatus.Complete {
+ return Receipt{}, os.ErrPermission
+ }
+ }
+ if !seen[q.Scope.Binding.Host] {
+ return Receipt{}, os.ErrPermission
+ }
+ return r, nil
+}
+
+// ReadRetirement verifies one effective retirement. Unrelated restrictions
+// remain in the receipt and must not be treated as cleared by this result.
+func ReadRetirement(ctx context.Context, d hostactors.Directory, q Request, g release.HistoryRecoveryRetirement, now time.Time) (Receipt, error) {
+ r, err := ReadStatus(ctx, d, q, now)
+ if err != nil {
+ return Receipt{}, err
+ }
+ s := r.Status
+ accepted := digest(g)
+ if !validHex(accepted, 32) || g.Schema != 1 || g.Kind != "carlos-release-recovery-retirement" || g.Operation == "" || g.IssuedAt.IsZero() || g.Binding != q.Scope.Binding || g.Identity != s.Identity || s.Pending || s.RecoverySources == nil || !s.RecoverySources.Known || !validHex(g.OutcomeDigest, 32) || !validHex(g.VerifiedDigest, 32) || g.AttemptID == "" || g.VerifiedID == "" || g.AttemptID == g.VerifiedID {
+ return Receipt{}, os.ErrPermission
+ }
+ for _, pending := range s.RecoverySources.Unresolved {
+ if pending.Binding == g.Binding && (pending.Attempt.ID == g.AttemptID || pending.Attempt.ID == g.VerifiedID) {
+ return Receipt{}, os.ErrPermission
+ }
+ }
+ for _, h := range s.Hosts {
+ if h.Host == g.Binding.Host && h.Source == g.Source && slices.Contains(h.RetiredRecoveries, g.AttemptID) && h.RetirementDigests[g.AttemptID] == accepted && h.RecoveryDigests[g.AttemptID] == g.OutcomeDigest && h.RecoveryDigests[g.VerifiedID] == g.VerifiedDigest {
+ return r, nil
+ }
+ }
+ return Receipt{}, os.ErrPermission
+}
diff --git a/internal/historyreadback/plan14_completion_test.go b/internal/historyreadback/plan14_completion_test.go
new file mode 100644
index 000000000..645fdc113
--- /dev/null
+++ b/internal/historyreadback/plan14_completion_test.go
@@ -0,0 +1,161 @@
+package historyreadback
+
+import (
+ "errors"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestPlan14ReadReconstructionCompletion(t *testing.T) {
+ for _, fault := range []string{"valid", "invalid serialization", "replacement", "delivery only", "different operation", "different replacement", "different bounds", "namespace", "revision", "pending", "source", "scope", "unknown app", "unknown host", "unknown bake", "missing digest", "signature", "old challenge"} {
+ t.Run(fault, func(t *testing.T) {
+ f := newFixture(t)
+ g := release.HistoryReconstruction{Schema: 1, Kind: "carlos-release-history", Identity: f.status.Identity, Revision: 1, Operation: "reconstruct", IssuedAt: f.now, Account: f.scope.Binding.Account, App: f.scope.Binding.App, Hosts: []release.HistoryReconstructionHost{f.status.Hosts[0].HistoryReconstructionHost}}
+ if fault == "replacement" {
+ g.Replaces = strings.Repeat("d", 64)
+ }
+ f.status.Revision = 2
+ f.status.AppComplete, f.status.AppRevision, f.status.BakeComplete, f.status.BakeRevision = true, 1, 1, 1
+ f.status.Hosts[0].Revision = 1
+ f.status.Hosts[0].RefusalStatus.Complete = true
+ f.status.LastReconstructionDigest = digest(g)
+ switch fault {
+ case "invalid serialization":
+ g.IssuedAt = time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC)
+ f.status.LastReconstructionDigest = ""
+ f.status.Hosts[0].RetirementDigests = nil
+ case "different operation":
+ g.Operation = "other"
+ case "different replacement":
+ g.Replaces = strings.Repeat("e", 64)
+ case "different bounds":
+ g.Production = true
+ case "namespace":
+ f.status.Identity = strings.Repeat("f", 64)
+ case "revision":
+ f.status.Revision++
+ case "pending":
+ f.status.Pending = true
+ case "source":
+ f.status.Hosts[0].Source = "changed"
+ case "scope":
+ g.Account = "xyz"
+ f.status.LastReconstructionDigest = digest(g)
+ case "unknown app":
+ f.status.AppComplete = false
+ case "unknown host":
+ f.status.Hosts[0].RefusalStatus.Complete = false
+ case "unknown bake":
+ f.status.BakeComplete = 0
+ case "missing digest":
+ f.status.LastReconstructionDigest = ""
+ }
+ q, err := RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fault != "delivery only" {
+ if err := Respond(t.Context(), f.directory, f.scope, f.key, f.now, f.snapshot); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if fault == "signature" {
+ r := Receipt{Schema: 1, Kind: "carlos-release-history-receipt", RequestDigest: digest(q), ObservedAt: f.now, Status: f.status}
+ wire, err := release.SignRootEnvelope(f.signer, r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := publish(t.Context(), f.directory.Store, ResponseKey(q), wire); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if fault == "old challenge" {
+ q, err = RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ _, err = ReadReconstruction(t.Context(), f.directory, q, g, f.now)
+ if fault == "valid" || fault == "replacement" {
+ if err != nil {
+ t.Fatal(err)
+ }
+ } else if err == nil {
+ t.Fatal("unproven reconstruction reported complete")
+ }
+ })
+ }
+}
+
+func TestPlan14ReadRetirementCompletion(t *testing.T) {
+ for _, fault := range []string{"valid", "invalid serialization", "different operation", "different outcome", "different witness", "missing digest", "missing retired", "unknown originals", "pending original", "unrelated original", "pending reconstruction", "source", "namespace", "scope", "delivery only"} {
+ t.Run(fault, func(t *testing.T) {
+ f := newFixture(t)
+ g := release.HistoryRecoveryRetirement{Schema: 1, Kind: "carlos-release-recovery-retirement", Identity: f.status.Identity, Operation: "retire", IssuedAt: f.now, Binding: f.scope.Binding, Source: "canonical", AttemptID: "failed", OutcomeDigest: strings.Repeat("d", 64), VerifiedID: "good", VerifiedDigest: strings.Repeat("e", 64)}
+ f.status.RecoverySources = &release.HistoryRecoverySources{Known: true}
+ f.status.Hosts[0].RetiredRecoveries = []string{g.AttemptID}
+ f.status.Hosts[0].RetirementDigests = map[string]string{g.AttemptID: digest(g)}
+ f.status.Hosts[0].RecoveryDigests = map[string]string{g.AttemptID: g.OutcomeDigest, g.VerifiedID: g.VerifiedDigest}
+ switch fault {
+ case "invalid serialization":
+ g.IssuedAt = time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC)
+ f.status.LastReconstructionDigest = ""
+ f.status.Hosts[0].RetirementDigests = nil
+ case "different operation":
+ g.Operation = "other"
+ case "different outcome":
+ g.OutcomeDigest = strings.Repeat("f", 64)
+ case "different witness":
+ g.VerifiedDigest = strings.Repeat("f", 64)
+ case "missing digest":
+ f.status.Hosts[0].RetirementDigests = nil
+ case "missing retired":
+ f.status.Hosts[0].RetiredRecoveries = nil
+ case "unknown originals":
+ f.status.RecoverySources.Known = false
+ case "pending original", "unrelated original":
+ id := g.AttemptID
+ if fault == "unrelated original" {
+ id = "unrelated"
+ }
+ f.status.RecoverySources.Unresolved = []release.HistoryRecovery{{Binding: g.Binding, Attempt: release.HistoryAttempt{ID: id}}}
+ case "pending reconstruction":
+ f.status.Pending = true
+ case "source":
+ f.status.Hosts[0].Source = "changed"
+ case "namespace":
+ f.status.Identity = strings.Repeat("f", 64)
+ case "scope":
+ g.Binding.Host = "other.example"
+ f.status.Hosts[0].RetirementDigests[g.AttemptID] = digest(g)
+ }
+ q, err := RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fault != "delivery only" {
+ if err := Respond(t.Context(), f.directory, f.scope, f.key, f.now, f.snapshot); err != nil {
+ t.Fatal(err)
+ }
+ }
+ receipt, err := ReadRetirement(t.Context(), f.directory, q, g, f.now)
+ if fault == "valid" || fault == "unrelated original" {
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fault == "unrelated original" && len(receipt.Status.RecoverySources.Unresolved) != 1 {
+ t.Fatal("lost unrelated obligation")
+ }
+ } else if err == nil {
+ t.Fatal("unproven retirement reported complete")
+ }
+ if fault == "delivery only" && !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("unexpected missing response: %v", err)
+ }
+ })
+ }
+}
diff --git a/internal/host/plan14_history_retirement_test.go b/internal/host/plan14_history_retirement_test.go
index ce9422ffc..b1ea6f3c8 100644
--- a/internal/host/plan14_history_retirement_test.go
+++ b/internal/host/plan14_history_retirement_test.go
@@ -8,6 +8,9 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/historyreadback"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostfloor"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/rootstate"
)
@@ -54,6 +57,17 @@ func TestPlan14RecoveryRetirementProductionCallback(t *testing.T) {
projection := &rollbackMemory{files: map[string][]byte{}}
now := time.Now().UTC()
maintenance := rootHistoryMaintenance{history: h, store: e.store, source: source, checks: c, pub: func() (ed25519.PublicKey, error) { return signingPub, nil }, projection: func() (rootstate.Store, error) { return projection, nil }, now: func() time.Time { return now }}
+ directory, responder, scope := completionReadbackFixture(t, e.store, signingPub, key, b, now)
+ maintenance.readback = responder.publish
+ challenge := func() historyreadback.Request {
+ t.Helper()
+ q, err := historyreadback.RequestStatus(t.Context(), directory, release.RootSigner(key), scope, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return q
+ }
+ q := challenge()
deps := boundRootHistory(h, source.resolve, e.root.obs)
deps.Reconstruct = maintenance.run
if err := deps.Reconstruct(t.Context(), b); err == nil {
@@ -112,14 +126,33 @@ func TestPlan14RecoveryRetirementProductionCallback(t *testing.T) {
if err := deps.Reconstruct(t.Context(), b); err == nil {
t.Fatal("signed reconstruction erased ambiguity")
}
+ receipt, err := historyreadback.ReadReconstruction(t.Context(), directory, q, grant, now)
+ if err != nil {
+ t.Fatal("completed reconstruction lacks exact readback", err)
+ }
+ if len(receipt.Status.RecoverySources.Unresolved) != 1 {
+ t.Fatal("reconstruction cleared original ambiguity")
+ }
+ other := grant
+ other.Operation = "not-accepted"
+ if _, err := historyreadback.ReadReconstruction(t.Context(), directory, q, other, now); err == nil {
+ t.Fatal("different authorization reported complete")
+ }
retirement := release.HistoryRecoveryRetirement{Schema: 1, Kind: "carlos-release-recovery-retirement", Identity: view.Identity, Operation: "retire-exact-ambiguity", IssuedAt: now, Binding: b, Source: view.Hosts[0].Source, AttemptID: old.ID, OutcomeDigest: view.Hosts[0].RecoveryDigests[old.ID], VerifiedID: good.ID, VerifiedDigest: view.Hosts[0].RecoveryDigests[good.ID]}
if err := release.PublishHistoryRecoveryRetirement(t.Context(), e.store, release.RootSigner(key), retirement); err != nil {
t.Fatal(err)
}
+ q = challenge()
+ if _, err := historyreadback.ReadRetirement(t.Context(), directory, q, retirement, now); err == nil {
+ t.Fatal("delivery counted as completion")
+ }
before := len(u.effects)
if err := deps.Reconstruct(t.Context(), b); err != nil {
t.Fatal(err)
}
+ if _, err := historyreadback.ReadRetirement(t.Context(), directory, q, retirement, now); err != nil {
+ t.Fatal("retirement lacks exact readback", err)
+ }
if refs, err := c.mintRefusals(h, b); err != nil || len(refs) != 0 {
t.Fatalf("exact authorized retirement blocked mint: %+v %v", refs, err)
}
@@ -145,9 +178,13 @@ func TestPlan14RecoveryRetirementProductionCallback(t *testing.T) {
if _, err := c.mintRefusals(h, b); err == nil {
t.Fatal("retirement consumed a pending original")
}
+ q = challenge()
if err := deps.Reconstruct(t.Context(), b); err == nil {
t.Fatal("maintenance consumed a pending original")
}
+ if _, err := historyreadback.ReadRetirement(t.Context(), directory, q, retirement, now); err == nil {
+ t.Fatal("readback consumed pending original")
+ }
raw, err = projection.Read(release.HistoryStatusKey(b.Account, b.App))
if err != nil {
t.Fatal(err)
@@ -225,3 +262,23 @@ func (s *historyIntentFailure) Write(name string, raw []byte) error {
}
return nil
}
+
+func completionReadbackFixture(t *testing.T, store release.Store, pub ed25519.PublicKey, key ed25519.PrivateKey, b release.HistoryBinding, now time.Time) (hostactors.Directory, historyReadbackPublisher, historyreadback.Scope) {
+ t.Helper()
+ _, evidenceKey, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ enrollment := hostfloor.Enrollment{Box: "box", Enrollment: strings.Repeat("e", 32), InstanceID: "i-box"}
+ directory := hostactors.Directory{Store: store, PublicKey: pub, Bucket: "bucket"}
+ roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Boxes: map[string]hostactors.Box{"box": {Enrollment: enrollment.Enrollment, Namespace: "platform", BoxID: "box", Label: "box", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", CensusPublicKey: evidenceKey.Public().(ed25519.PublicKey)}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
+ wire, err := release.SignRootEnvelope(release.RootSigner(key), roster)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.Put(t.Context(), hostactors.Key, wire, ""); err != nil {
+ t.Fatal(err)
+ }
+ responder := historyReadbackPublisher{directory: directory, enrollment: enrollment, key: func() (ed25519.PrivateKey, error) { return evidenceKey, nil }, now: func() time.Time { return now }}
+ return directory, responder, historyreadback.Scope{Bucket: "bucket", Box: enrollment.Box, Enrollment: enrollment.Enrollment, Binding: b}
+}
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv
index 14c9b9923..20d1743e9 100644
--- a/internal/provision/testdata/plan14-root-reads-e.tsv
+++ b/internal/provision/testdata/plan14-root-reads-e.tsv
@@ -46,7 +46,7 @@ internal/release/roothistory_pass.go passConfig.recheckHistory authority E1 E1 c
internal/release/roothistory_pass.go passConfig.recordRefusal authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. registry:Host@151,whole:Route@241 298ca88f7dc02be7779aaa527990b4e4548c1fe5498fba0272a6e7041b236354
internal/release/roothistory_pass.go type RootHistoryPass authority B3,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:Prepare@215 c90889be3f234547b634149feceb82515c03de58598912758152f39604f23274
internal/release/roothistory_reconstruct.go RootHistory.Reconstruct authority E1 Signed replacement binds the exact pending digest and current sources/revisions; old intended refusals and hold bounds remain restrictive. Fixtures: TestPlan14ReconstructionReplacement, TestPlan14ReconstructionReplacementCrash. f5deeb978cef7024adf551c9ac6ef2fce4de88b2d5392affdf3313bc82df0d16
-internal/release/roothistory_reconstruct.go RootHistory.ReconstructionStatus authority E1 E1 signed root-history reconstruction; exact namespace identity, revisions and canonical sources; journaled completion retains denies and stronger bounds. Tests: TestPlan14RootHistoryReconstruction, TestPlan14RootHistoryReconstructionCrash, TestPlan14RootHistoryReconstructionDeliveryPass. file:Read@189 f2da2a6c97bc40bf972f1346b578c47334585053d85244c9ba6b63c45f0570a3
+internal/release/roothistory_reconstruct.go RootHistory.ReconstructionStatus 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@189 611a42b0f940b753e22a99266e6aae3384173233f717f3bdef0f38c80dcb00d5
internal/release/roothistory_reconstruct.go RootHistory.reconstructionImages authority E1 E1 signed root-history reconstruction; exact namespace identity, revisions and canonical sources; journaled completion retains denies and stronger bounds. Tests: TestPlan14RootHistoryReconstruction, TestPlan14RootHistoryReconstructionCrash, TestPlan14RootHistoryReconstructionDeliveryPass. file:Read@1190,file:Read@226,file:Read@2946,file:Read@629 cb47b84dd9bbb94a253aca2fa2c95ceaa75ddfa6453c78ece1c1b432e977e79a
internal/release/roothistory_reconstruct.go RootHistory.reconstructionState authority E1 E1 signed root-history reconstruction; exact namespace identity, revisions and canonical sources; journaled completion retains denies and stronger bounds. Tests: TestPlan14RootHistoryReconstruction, TestPlan14RootHistoryReconstructionCrash, TestPlan14RootHistoryReconstructionDeliveryPass. file:Read@178 7c53af838e0dc7ec7ad6ae5341da983bde442288126e9eea21f80d6a62fc478a
internal/release/roothistory_reconstruct.go RootHistory.resumeReconstruction authority E1 E1 signed root-history reconstruction; exact namespace identity, revisions and canonical sources; journaled completion retains denies and stronger bounds. Tests: TestPlan14RootHistoryReconstruction, TestPlan14RootHistoryReconstructionCrash, TestPlan14RootHistoryReconstructionDeliveryPass. file:Read@915 39d8949054462cf53823e4b6fc802a08349bbac79750f6beb5b90095880925b8
@@ -55,9 +55,9 @@ 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 6cd891a68b42e2fe9c44ff3712ebd24b67b4d1602adb5a67605bdd121eda97d4
-@coverage internal/release 2b58eb82843de5240966627f4982bbe26db3872a9126e7e9a145459663ccbd07
+@coverage internal/release 9417f6a8ddd23071820003d222a0c27915295d099496d87216db5a04a5f0bde7
@coverage internal/rootstate 6338f74a6b2059cf77cf478bd6111b37b441eb9d65a309464c378359d4bdac05
-internal/release/roothistory_status.go RootHistory.MigrationStatus authority E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. file:Read@1062 fdd21d4a9823eab7937fb900655f8e7d342cf226f66e5da526ca8b6285845bf2
+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
internal/host/legacyrestart.go legacyRootRestart authority B3,G E9 merge ruling 6: E effects require a census; no-census recovery delegates to main or records evidence only. Fixtures: TestPlan14ERecoveryNoCensus, TestPlan14ERecoveryNoCensusResume, TestR2ERecoveryFlagWithoutCensus, TestPlan14ERecoveryRestartOutcome. Census-backed recovery retains captured authority and outcome precedence. 5fd48683abd82664baa77c2ddc7528f92f557b04586225d64b4a6e77147df931
internal/host/recoveryrefusals.go recoveryHistoryOutcome authority E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. ab25e7fa0cca603c50cfee8862c669c674e714b5a8a263748257ce26e1495491
@@ -84,7 +84,7 @@ internal/release/roothistory_outcomes.go type HistoryRecovery authority E1 Exact
internal/release/roothistory_outcomes.go type historyRecoveryRecord authority E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. 49deb93476b73ca13e9133916babba5ded70b0e01dc793a1a252b9eee07d5a15
internal/release/roothistory_retirement.go RootHistory.RecoveryRetired authority E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. 70b4fd04aa52687f9c4f140b8ddd345c26e9aca78e325e4f093a39a93d8dec67
internal/release/roothistory_retirement.go RootHistory.RetireRecovery authority E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. 3bc3d947862f05d7407179d896646aa68ab61c9a3d861086709724869b32e3b7
-internal/release/roothistory_retirement.go RootHistory.recoveryRetired authority E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. file:Read@114 e862a761df28742d9bb468740b7ab3b239085177d0404aacd5c50eb4920acd2a
+internal/release/roothistory_retirement.go RootHistory.recoveryRetired 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. 8a25353fae605dcc105cb9d42513f004f2e7478f76b59483bb0288b285d6a212
internal/release/roothistory_retirement.go historyRecoveryDigest authority E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. 92010ae7a24d9bcdfa237da83c2e832926c8244c6df9d2f697833ac69671f9e4
internal/release/roothistory_retirement.go historyRetirementKey authority E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. 9e32f1cc354ae4921dd12e2a103208891dabba66594b8fa15d8782e391dd0540
internal/release/roothistory_retirement.go retirementEvidence authority E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. 98a3c84eede1e1d039d42db42a61f167877b0bbf8a5f45cea384a2941281c81c
@@ -93,7 +93,7 @@ internal/release/roothistory_retirement.go validHistoryRetirement authority E1 E
internal/release/roothistory_status.go HistoryStatusKey authority E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. 302a4b007238395e23f3649bac42ad5c68c9b9727bbce54fc775e75da7e0af5e
internal/release/roothistory_status.go RootHistory.ProjectMigrationStatus authority E1 Challenge-bound release-history readback authenticates the deployment request and active enrolled box key, rereads private history with original recovery restrictions, and rechecks roster/fence state. No status grants completeness or changes service effects. Fixtures: TestPlan14HistoryReadback, TestPlan14HistoryReadbackAuthority, TestPlan14HistoryReadbackReadRace, TestPlan14HistoryReadbackReenrollment, TestPlan14HistoryReadbackRequestRefusal, TestPlan14HistoryReadbackProduction, TestPlan14HistoryReadbackProcessCrash. aa46b9876a485c18a790975804d9da01beae059f3f5cdf62b507c447c8914757
internal/release/roothistory_status.go const RootHistoryStatusDir authority E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. c7fda64b13809b5d5b9c61e055aa2e5cc47fc018eaf0ee6a62a0b37408990142
-internal/release/roothistory_status.go type HistoryHostStatus authority E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. 4cfaff43ce1651d564797ccfc41e0b17dfdefda033d03d68416d6cc6361e983d
+internal/release/roothistory_status.go type HistoryHostStatus authority E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. f0f202f04aa982d2867ef9e3d456c342d092abce81546cbdbd185049273f5899
internal/release/roothistory_status.go type HistoryMigrationStatus authority E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. dcf1ce27171203061f7b7877a80e3fdcf8ac964309f0fcc47282a7f719f0a32d
internal/release/roothistory_status.go type HistoryRecoverySources diagnostic E1 Root journal source availability and unresolved attempts remain explicit in advisory status; no projection grants permission. Fixture: TestPlan14RecoveryRetirementProductionCallback. ff307b83963f45fdefe91b61699f090bd8e3f5e7ec539cec4b4bca51956ad07f
internal/host/namedrecovery_pass.go namedUnitRecovery.passOption authority B3,E1 Production recovery wrapper preserves main exit status for per-host failures; only remaining named routes can probe the census for history selection. Ordinary routes make no census or binding-store reads. Fixtures: TestPlan14ERecoveryOrdinaryPass, TestPlan14Fix4AdoptPerHostFailureExitsZero, TestPlan14Fix5MainEquivalence. registry:Host@282,registry:Host@506,registry:Unit@459,registry:Unit@476,whole:Route@419,whole:Route@451 36ef7a4bf960574b35ea896c72612ce9b4a8ad7d2c249eec16f796344b5dd37e
@@ -116,5 +116,8 @@ internal/historyreadback/readback.go validStatus authority E1 Challenge-bound re
internal/host/roothistory_readback.go historyReadbackPublisher.publish authority E1 Challenge-bound release-history readback authenticates the deployment request and active enrolled box key, rereads private history with original recovery restrictions, and rechecks roster/fence state. No status grants completeness or changes service effects. Fixtures: TestPlan14HistoryReadback, TestPlan14HistoryReadbackAuthority, TestPlan14HistoryReadbackReadRace, TestPlan14HistoryReadbackReenrollment, TestPlan14HistoryReadbackRequestRefusal, TestPlan14HistoryReadbackProduction, TestPlan14HistoryReadbackProcessCrash. file:Read@187 fbd23ac261c8affa9fbed38277d0fe88fa3e56d1dde1d3af5eb0839d2635d00f
internal/host/roothistory_readback.go publishRootHistoryReadback authority E1 Challenge-bound release-history readback authenticates the deployment request and active enrolled box key, rereads private history with original recovery restrictions, and rechecks roster/fence state. No status grants completeness or changes service effects. Fixtures: TestPlan14HistoryReadback, TestPlan14HistoryReadbackAuthority, TestPlan14HistoryReadbackReadRace, TestPlan14HistoryReadbackReenrollment, TestPlan14HistoryReadbackRequestRefusal, TestPlan14HistoryReadbackProduction, TestPlan14HistoryReadbackProcessCrash. da8fc3789d39285f28722603c91a5549e77034d1ebb17e839b16b860bd75a425
internal/host/roothistory_readback.go type historyReadbackPublisher authority E1 Challenge-bound release-history readback authenticates the deployment request and active enrolled box key, rereads private history with original recovery restrictions, and rechecks roster/fence state. No status grants completeness or changes service effects. Fixtures: TestPlan14HistoryReadback, TestPlan14HistoryReadbackAuthority, TestPlan14HistoryReadbackReadRace, TestPlan14HistoryReadbackReenrollment, TestPlan14HistoryReadbackRequestRefusal, TestPlan14HistoryReadbackProduction, TestPlan14HistoryReadbackProcessCrash. 0112fc221bb6f41afe48ca3775063fb2138886da6872a141a0e5b1b589f2f43e
-internal/release/roothistory_status.go RootHistory.MigrationStatusWithSources authority E1 Challenge-bound release-history readback authenticates the deployment request and active enrolled box key, rereads private history with original recovery restrictions, and rechecks roster/fence state. No status grants completeness or changes service effects. Fixtures: TestPlan14HistoryReadback, TestPlan14HistoryReadbackAuthority, TestPlan14HistoryReadbackReadRace, TestPlan14HistoryReadbackReenrollment, TestPlan14HistoryReadbackRequestRefusal, TestPlan14HistoryReadbackProduction, TestPlan14HistoryReadbackProcessCrash. d458cd512378ad6a01f8cca796f06de78f47dd14976ef0df43fbd8d2fafa162a
-@coverage internal/historyreadback 56767f301f27780aec784b34abf9c4e44fd461b5f782c393c3825a4a419c7139
+internal/release/roothistory_status.go RootHistory.MigrationStatusWithSources 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. b21c4f2d6ce984632726f7bd1a2d8d876764794a5e937075424ae15fdd97452e
+@coverage internal/historyreadback 4ef0528aa6c49e6d32e53f84456680b279d52313a69fdf9aaaceead54af2a8e8
+internal/historyreadback/completion.go ReadReconstruction 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. 9f64ca3108a374d26c0f690cd62e3984f25d237175b75a4529a945fda41db5ee
+internal/historyreadback/completion.go ReadRetirement 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. 051995729c46c84bc96346ca6098c68a4b1317c1d3a2f25655b4d730079534b8
+internal/release/roothistory_retirement.go RootHistory.recoveryRetirementDigest 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@125 4bfcfe969b2230a094ecdf7f4d7653f8bf9f46c10f3e0419a054c0384c2971c8
diff --git a/internal/release/plan14_history_completion_test.go b/internal/release/plan14_history_completion_test.go
new file mode 100644
index 000000000..2a5bcd727
--- /dev/null
+++ b/internal/release/plan14_history_completion_test.go
@@ -0,0 +1,109 @@
+package release
+
+import (
+ "encoding/json"
+ "os"
+ "testing"
+)
+
+func TestPlan14HistoryReconstructionCompletion(t *testing.T) {
+ for _, boundary := range []string{"before", "intent", "complete", "unsynced"} {
+ t.Run(boundary, func(t *testing.T) {
+ h, s, b, pub, wire, now := reconstructionCrashFixture(t)
+ var g HistoryReconstruction
+ if err := VerifyRootEnvelope(pub, wire, &g); err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := json.Marshal(g)
+ resolve := func(HistoryBinding) (string, error) { return "canonical", nil }
+ if boundary != "before" {
+ if boundary == "intent" {
+ h.Store = &reconstructionCrashStore{healthyRetryStore: s, stage: "intent", fail: true}
+ }
+ err := h.Reconstruct(pub, wire, b.Account, b.App, now, resolve)
+ if (boundary == "intent") != (err != nil) {
+ t.Fatalf("reconstruct: %v", err)
+ }
+ }
+ h = &RootHistory{Store: s}
+ if boundary == "unsynced" {
+ s.fail = 1
+ }
+ view, err := h.MigrationStatus(b, resolve)
+ if boundary == "unsynced" {
+ if err == nil {
+ t.Fatal("unsynced completion published")
+ }
+ view, err = h.MigrationStatus(b, resolve)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := ""
+ if boundary == "complete" || boundary == "unsynced" {
+ want = reconstructionDigest(raw)
+ }
+ if view.LastReconstructionDigest != want {
+ t.Fatalf("completion=%q want %q", view.LastReconstructionDigest, want)
+ }
+ })
+ }
+}
+
+func TestPlan14HistoryRetirementCompletion(t *testing.T) {
+ for _, fault := range []string{"valid", "missing", "unsynced", "changed outcome", "pending original", "changed source"} {
+ t.Run(fault, func(t *testing.T) {
+ h, s, old, _, g, pub, key := retirementFixture(t)
+ if fault != "missing" {
+ if err := retireForTest(h, g, pub, key); err != nil {
+ t.Fatal(err)
+ }
+ }
+ raw, _ := json.Marshal(g)
+ if fault == "changed outcome" {
+ old.Verified = true
+ if err := h.RetainRecovery(old); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if fault == "unsynced" {
+ h.Store = &retirementSyncStore{Store: s, fail: true}
+ }
+ source := "canonical"
+ if fault == "changed source" {
+ source = "changed"
+ }
+ sources := HistoryRecoverySources{Known: true}
+ if fault == "pending original" {
+ old.Settled = false
+ sources.Unresolved = []HistoryRecovery{old}
+ }
+ view, err := h.MigrationStatusWithSources(old.Binding, func(HistoryBinding) (string, error) { return source, nil }, sources)
+ if fault == "unsynced" {
+ if err == nil {
+ t.Fatal("unsynced retirement published")
+ }
+ h.Store = s
+ view, err = h.MigrationStatusWithSources(old.Binding, func(HistoryBinding) (string, error) { return source, nil }, sources)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := ""
+ if fault == "valid" || fault == "unsynced" {
+ want = reconstructionDigest(raw)
+ }
+ if len(view.Hosts) != 1 || view.Hosts[0].RetirementDigests[old.Attempt.ID] != want {
+ t.Fatalf("status=%+v want %q", view, want)
+ }
+ if fault == "pending original" {
+ if len(view.RecoverySources.Unresolved) != 1 {
+ t.Fatal("lost original")
+ }
+ }
+ if _, err := s.Read(historyRecoveryKey(old)); os.IsNotExist(err) {
+ t.Fatal("deleted retained outcome")
+ }
+ })
+ }
+}
diff --git a/internal/release/plan14_history_retirement_process_test.go b/internal/release/plan14_history_retirement_process_test.go
index 685397e33..525d317d3 100644
--- a/internal/release/plan14_history_retirement_process_test.go
+++ b/internal/release/plan14_history_retirement_process_test.go
@@ -77,6 +77,21 @@ func TestPlan14HistoryRetirementProcessCrash(t *testing.T) {
}
fault := &retirementSyncStore{Store: s, fail: boundary == "visible"}
h := &RootHistory{Store: fault}
+ view, statusErr := h.MigrationStatus(old.Binding, func(HistoryBinding) (string, error) { return "canonical", nil })
+ if boundary == "visible" {
+ if statusErr == nil {
+ t.Fatal("unsynced retirement completion after crash")
+ }
+ } else {
+ expected := ""
+ if boundary == "durable" {
+ accepted, _ := json.Marshal(g)
+ expected = reconstructionDigest(accepted)
+ }
+ if statusErr != nil || view.Hosts[0].RetirementDigests[old.Attempt.ID] != expected {
+ t.Fatalf("crash completion: %+v %v", view, statusErr)
+ }
+ }
refs, err := h.Refusals(old.Binding)
if boundary == "visible" {
if err == nil {
@@ -85,6 +100,15 @@ func TestPlan14HistoryRetirementProcessCrash(t *testing.T) {
fault.fail = false
refs, err = h.Refusals(old.Binding)
}
+ view, statusErr = h.MigrationStatus(old.Binding, func(HistoryBinding) (string, error) { return "canonical", nil })
+ expected := ""
+ if boundary != "before" {
+ accepted, _ := json.Marshal(g)
+ expected = reconstructionDigest(accepted)
+ }
+ if statusErr != nil || view.Hosts[0].RetirementDigests[old.Attempt.ID] != expected {
+ t.Fatalf("repaired completion: %+v %v", view, statusErr)
+ }
want := 0
if boundary == "before" {
want = 1
diff --git a/internal/release/plan14_root_reconstruction_test.go b/internal/release/plan14_root_reconstruction_test.go
index f6f1de88a..48bb041db 100644
--- a/internal/release/plan14_root_reconstruction_test.go
+++ b/internal/release/plan14_root_reconstruction_test.go
@@ -175,6 +175,22 @@ func TestPlan14RootHistoryReconstructionCrash(t *testing.T) {
if !errors.As(err, &exit) || exit.ExitCode() != 83 {
t.Fatalf("child: %v %s", err, out)
}
+ var accepted HistoryReconstruction
+ if err := VerifyRootEnvelope(pub, wire, &accepted); err != nil {
+ t.Fatal(err)
+ }
+ acceptedRaw, _ := json.Marshal(accepted)
+ view, err := h.MigrationStatus(b, resolve)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := ""
+ if stage == "complete" {
+ want = reconstructionDigest(acceptedRaw)
+ }
+ if view.LastReconstructionDigest != want {
+ t.Fatal("crash reported wrong completion")
+ }
if stage != "complete" {
if _, err := h.Refusals(b); err == nil {
t.Fatal("partial reconstruction granted refusal completeness")
@@ -195,6 +211,10 @@ func TestPlan14RootHistoryReconstructionCrash(t *testing.T) {
if err := h.ResumeReconstruction(resolve); err != nil {
t.Fatal(err)
}
+ view, err = h.MigrationStatus(b, resolve)
+ if err != nil || view.LastReconstructionDigest != reconstructionDigest(acceptedRaw) || view.Pending {
+ t.Fatalf("resume completion: %+v %v", view, err)
+ }
if _, err := h.AppRefusals(b.Account, b.App); err != nil {
t.Fatal(err)
}
diff --git a/internal/release/roothistory_reconstruct.go b/internal/release/roothistory_reconstruct.go
index f8cab93e6..c248b0d6b 100644
--- a/internal/release/roothistory_reconstruct.go
+++ b/internal/release/roothistory_reconstruct.go
@@ -48,9 +48,10 @@ type HistoryReconstructionHold struct {
}
type HistoryReconstructionStatus struct {
- Identity string `json:"identity"`
- Revision uint64 `json:"revision"`
- Pending bool `json:"pending"`
+ LastReconstructionDigest string `json:"last_reconstruction_digest,omitempty"`
+ Identity string `json:"identity"`
+ Revision uint64 `json:"revision"`
+ Pending bool `json:"pending"`
}
type reconstructionImage struct {
@@ -123,7 +124,7 @@ func (h *RootHistory) ReconstructionStatus() (HistoryReconstructionStatus, error
if err != nil {
return HistoryReconstructionStatus{}, err
}
- return HistoryReconstructionStatus{Identity: s.Identity, Revision: s.Revision, Pending: s.Pending != nil}, nil
+ return HistoryReconstructionStatus{Identity: s.Identity, Revision: s.Revision, Pending: s.Pending != nil, LastReconstructionDigest: s.Last}, nil
}
func (h *RootHistory) reconstructionReady() error {
diff --git a/internal/release/roothistory_retirement.go b/internal/release/roothistory_retirement.go
index e97653770..a222e5849 100644
--- a/internal/release/roothistory_retirement.go
+++ b/internal/release/roothistory_retirement.go
@@ -112,32 +112,37 @@ func (h *RootHistory) RetireRecovery(pub ed25519.PublicKey, wire []byte, b Histo
return h.Store.Write(historyRetirementKey(HistoryRecovery{Binding: b, Attempt: HistoryAttempt{ID: g.AttemptID}}), raw)
}
-func (h *RootHistory) recoveryRetired(r HistoryRecovery, outcomes []HistoryRecovery) (bool, error) {
+func (h *RootHistory) recoveryRetirementDigest(r HistoryRecovery, outcomes []HistoryRecovery) (string, error) {
raw, err := h.Store.Read(historyRetirementKey(r))
if errors.Is(err, os.ErrNotExist) {
- return false, nil
+ return "", nil
}
if err != nil {
- return false, err
+ return "", err
}
var g HistoryRecoveryRetirement
if len(raw) > rootstate.MaxRecord || strictCanonicalJSON(raw, &g) != nil || !validHistoryRetirement(g) || g.Binding != r.Binding || g.AttemptID != r.Attempt.ID {
- return false, os.ErrPermission
+ return "", os.ErrPermission
}
state, err := h.reconstructionState()
if err != nil {
- return false, err
+ return "", err
}
if state.Pending != nil || state.Identity != g.Identity {
- return false, os.ErrPermission
+ return "", os.ErrPermission
}
if historyRecoveryDigest(r) != g.OutcomeDigest || !retirementEvidence(g, outcomes) {
- return false, nil
+ return "", nil
}
if err := h.Store.Write(historyRetirementKey(r), raw); err != nil {
- return false, err
+ return "", err
}
- return true, nil
+ return reconstructionDigest(raw), nil
+}
+
+func (h *RootHistory) recoveryRetired(r HistoryRecovery, outcomes []HistoryRecovery) (bool, error) {
+ digest, err := h.recoveryRetirementDigest(r, outcomes)
+ return digest != "", err
}
func (h *RootHistory) RecoveryRetired(r HistoryRecovery) (bool, error) {
diff --git a/internal/release/roothistory_status.go b/internal/release/roothistory_status.go
index 7f4150415..a1eb1a840 100644
--- a/internal/release/roothistory_status.go
+++ b/internal/release/roothistory_status.go
@@ -13,6 +13,7 @@ import (
const RootHistoryStatusDir = "/var/lib/carlos-release/history-status"
type HistoryHostStatus struct {
+ RetirementDigests map[string]string `json:"retirement_digests,omitempty"`
HistoryReconstructionHost
Programs historyPrograms `json:"programs"`
RefusalStatus historyRefusals `json:"refusal_status"`
@@ -153,22 +154,35 @@ func (h *RootHistory) MigrationStatus(b HistoryBinding, resolve func(HistoryBind
var recoveries []HistoryRecovery
digests := map[string]string{}
var retired []string
+ retirementDigests := map[string]string{}
for _, r := range outcomes {
if r.Binding == binding {
recoveries = append(recoveries, r)
digests[r.Attempt.ID] = historyRecoveryDigest(r)
if state.Pending == nil {
- ok, err := h.recoveryRetired(r, outcomes)
+ digest, err := h.recoveryRetirementDigest(r, outcomes)
if err != nil {
return out, err
}
- if ok {
+ if digest != "" {
+ retirementRaw, err := h.Store.Read(historyRetirementKey(r))
+ var grant HistoryRecoveryRetirement
+ if err != nil {
+ return out, err
+ }
+ if reconstructionDigest(retirementRaw) != digest || strictCanonicalJSON(retirementRaw, &grant) != nil {
+ return out, os.ErrPermission
+ }
+ if grant.Source != source {
+ continue
+ }
+ retirementDigests[r.Attempt.ID] = digest
retired = append(retired, r.Attempt.ID)
}
}
}
}
- out.Hosts = append(out.Hosts, HistoryHostStatus{Recoveries: recoveries, RecoveryDigests: digests, RetiredRecoveries: retired, HistoryReconstructionHost: HistoryReconstructionHost{Host: host, Source: source, Revision: record.Revision, Refusals: record.Refusals.Values}, Programs: record.Programs, RefusalStatus: record.Refusals, Attempt: record.Attempt})
+ out.Hosts = append(out.Hosts, HistoryHostStatus{RetirementDigests: retirementDigests, Recoveries: recoveries, RecoveryDigests: digests, RetiredRecoveries: retired, HistoryReconstructionHost: HistoryReconstructionHost{Host: host, Source: source, Revision: record.Revision, Refusals: record.Refusals.Values}, Programs: record.Programs, RefusalStatus: record.Refusals, Attempt: record.Attempt})
}
for _, host := range out.Hosts {
source, err := resolve(HistoryBinding{Host: host.Host, Account: b.Account, App: b.App})
@@ -211,6 +225,7 @@ func (h *RootHistory) MigrationStatusWithSources(b HistoryBinding, resolve func(
effective.Unresolved = append(effective.Unresolved, r)
for i := range view.Hosts {
if view.Hosts[i].Host == r.Binding.Host {
+ delete(view.Hosts[i].RetirementDigests, r.Attempt.ID)
view.Hosts[i].RetiredRecoveries = slices.DeleteFunc(view.Hosts[i].RetiredRecoveries, func(id string) bool { return id == r.Attempt.ID })
}
}