WIP claim Plan 14 track E checkpoint 1 (not for merge): E1 root release-ledger primitives and tests
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+712
−1
internal/provision/testdata/plan14-root-reads-merge.tsv+2 −1internal/release/plan14_root_history_process_test.go+95 −0internal/release/plan14_root_history_test.go+361 −0internal/release/roothistory.go+254 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.014s
? 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 354.677s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 4.853s
ok github.com/carlosframework/platform/cmd/moveprobe 93.217s
ok github.com/carlosframework/platform/internal/activator 230.081s
ok github.com/carlosframework/platform/internal/agentsock 0.026s
ok github.com/carlosframework/platform/internal/alerting 0.010s
ok github.com/carlosframework/platform/internal/analytics 0.048s
ok github.com/carlosframework/platform/internal/appconfig 2.955s
ok github.com/carlosframework/platform/internal/apperr 0.142s
ok github.com/carlosframework/platform/internal/applog 0.281s
ok github.com/carlosframework/platform/internal/awsinv 0.006s
ok github.com/carlosframework/platform/internal/boxchoice 0.006s
ok github.com/carlosframework/platform/internal/boxid 0.619s
? 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.012s
ok github.com/carlosframework/platform/internal/caps 0.019s
ok github.com/carlosframework/platform/internal/certwarm 0.003s
ok github.com/carlosframework/platform/internal/cgroup 0.211s
ok github.com/carlosframework/platform/internal/changelog 0.456s
ok github.com/carlosframework/platform/internal/clamd 0.059s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 369.684s
? 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.402s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.012s
ok github.com/carlosframework/platform/internal/dbverify 0.006s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.009s
ok github.com/carlosframework/platform/internal/domains 2.378s
ok github.com/carlosframework/platform/internal/doorkey 0.007s
ok github.com/carlosframework/platform/internal/economics 0.006s
ok github.com/carlosframework/platform/internal/edge 28.429s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.030s
ok github.com/carlosframework/platform/internal/fleetapi 0.175s
ok github.com/carlosframework/platform/internal/gate 1.665s
ok github.com/carlosframework/platform/internal/genselect 0.030s
ok github.com/carlosframework/platform/internal/geo 0.014s
ok github.com/carlosframework/platform/internal/geoip 0.066s
ok github.com/carlosframework/platform/internal/host 93.554s
ok github.com/carlosframework/platform/internal/hostactors 0.935s
ok github.com/carlosframework/platform/internal/hostbox 3.156s
ok github.com/carlosframework/platform/internal/hostclaim 1.176s
ok github.com/carlosframework/platform/internal/hostevidence 0.013s
ok github.com/carlosframework/platform/internal/hostexec 0.586s
ok github.com/carlosframework/platform/internal/hostfence 0.128s
ok github.com/carlosframework/platform/internal/hostfiles 11.741s
ok github.com/carlosframework/platform/internal/hostfloor 0.032s
ok github.com/carlosframework/platform/internal/hostguard 0.490s
ok github.com/carlosframework/platform/internal/hostops 2.386s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.003s
ok github.com/carlosframework/platform/internal/hostquarantine 0.011s
ok github.com/carlosframework/platform/internal/hostrelocate 0.920s
ok github.com/carlosframework/platform/internal/hostresolve 0.372s
ok github.com/carlosframework/platform/internal/hostwrite 0.012s
ok github.com/carlosframework/platform/internal/infraview 0.012s
ok github.com/carlosframework/platform/internal/instancectl 1.722s
ok github.com/carlosframework/platform/internal/instanceexec 2.879s
ok github.com/carlosframework/platform/internal/instancefs 0.011s
ok github.com/carlosframework/platform/internal/instancekey 0.011s
ok github.com/carlosframework/platform/internal/instanceupdates 0.028s
ok github.com/carlosframework/platform/internal/layout 2.242s
ok github.com/carlosframework/platform/internal/lease 9.658s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.021s
ok github.com/carlosframework/platform/internal/ledger 0.024s
ok github.com/carlosframework/platform/internal/mail 0.348s
ok github.com/carlosframework/platform/internal/maintenance 0.122s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.860s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.182s
ok github.com/carlosframework/platform/internal/metrics 0.012s
ok github.com/carlosframework/platform/internal/moves 24.261s
ok github.com/carlosframework/platform/internal/moves/archive 1.395s
ok github.com/carlosframework/platform/internal/objstore 0.389s
ok github.com/carlosframework/platform/internal/ops 0.233s
ok github.com/carlosframework/platform/internal/park 0.487s
ok github.com/carlosframework/platform/internal/privatefile 0.006s
ok github.com/carlosframework/platform/internal/provision 20.212s
ok github.com/carlosframework/platform/internal/publicrelease 0.025s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.243s
ok github.com/carlosframework/platform/internal/relay 7.284s
ok github.com/carlosframework/platform/internal/release 23.250s
ok github.com/carlosframework/platform/internal/rollout 3.993s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.008s
ok github.com/carlosframework/platform/internal/rootparticipation 0.008s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
ok github.com/carlosframework/platform/internal/s3compat 0.008s
ok github.com/carlosframework/platform/internal/safefile 0.011s
ok github.com/carlosframework/platform/internal/schedule 3.160s
ok github.com/carlosframework/platform/internal/sim/hostlife 21.483s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.008s
ok github.com/carlosframework/platform/internal/status 0.061s
ok github.com/carlosframework/platform/internal/steering 0.010s
ok github.com/carlosframework/platform/internal/stopevidence 0.472s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.007s
ok github.com/carlosframework/platform/internal/termfence 0.009s
ok github.com/carlosframework/platform/internal/tunnelclient 0.013s
ok github.com/carlosframework/platform/internal/unixdial 0.015s
ok github.com/carlosframework/platform/internal/updatewords 0.005s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.851s
=== 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.023s
=== RUN TestT4StorageProbeMinio
2026/10/13 09:20:18 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE0C4B2F0D804E, 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-merge.tsv b/internal/provision/testdata/plan14-root-reads-merge.tsv| index 883b9672b..a64d0fad8 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-merge.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-merge.tsv |
| @@ -264,7 +264,7 @@ internal/sim/hostlife/mutate.sh external legacy accepted-legacy accepted-legacy; |
| @coverage internal/moves 5218cf9bd31f6347ae49a595a32455f441b1718ecfa0a9559f99036d32f56498 |
| @coverage internal/provision a5c7116ee1a063ecdbe8bd5ea1832370ce66960ab13cc7afd34c0973dd21b457 |
| @coverage internal/registry bb6028d50884545209c11e65131dc3389a0991c9a94a0954965e0df4b82b3232 |
| -@coverage internal/release a1836b7c685b543641bd6184d95cc541a3ee754986c57f6d3394f78fafc260d0 |
| +@coverage internal/release e10b520234ebb15d522104a3835701bd015d23b73082de5d0947ebe6006b4397 |
| @coverage internal/sim/hostlife eaac101ea7970c74fee621e5cd7515ec7e4907bc5874c65eab86666af0f7dc61 |
| @coverage internal/status d5ef42fe4dc2cb72d1ce5e133e2c95a3e85960a222eb424f00b259b961b8d976 |
| @coverage internal/updatewords 93bb83474bad5d20281c34e72fadf56e2847e9a7eee2385a78f46a11fc39a3b6 |
| @@ -284,3 +284,4 @@ internal/rootparticipation/ledger.go Ledger.EndUpdate authority D D3 removes onl |
| internal/rootparticipation/ledger.go Ledger.idle authority D D3 enumerates root records and requires settled obligations plus an independently supplied launch/kernel census. file:Read@429 b7b27590041bac6bec3de245b7fe20da7965cb2a9e52725c888f8a83313ff9f3 |
| internal/hostexec/rootclient.go requestIdentity authority D D1 supplies collision-resistant request correlation only; root authenticates peer and authority independently. Production client integration pending. file:Read@73 2acec351fdc4ab671e65b4a9bb5017aeb094fbf78fad8e34d713e48a2846c158 |
| internal/hostexec/rootserving.go RootBroker.servingView authority D D1 reads only completed root census and settled root participation under transaction exclusion, rechecks claim authority and peer identity, and returns no lease or token. Production client selection is pending. file:Read@441 9a57720834b3d7c661854196cf0ed1261c643673b5ba900c38987a162f5de0bb |
| +internal/release/roothistory.go RootHistory.read authority E1 Root-owned per-binding refusal/program completeness and attempt state; strict decode and exact durable recommit before authority reads. Production consumer integration remains pending. file:Read@183 7ad6f4711ce92da81c19dfe5d7740bb0058b6689afcde5a89c34b9a2491a82a3 |
diff --git a/internal/release/plan14_root_history_process_test.go b/internal/release/plan14_root_history_process_test.go| new file mode 100644 |
| index 000000000..a03f0726d |
| --- /dev/null |
| +++ b/internal/release/plan14_root_history_process_test.go |
| @@ -0,0 +1,95 @@ |
| +package release |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +type historyCrashStore struct { |
| + *healthyRetryStore |
| + boundary string |
| +} |
| + |
| +func (s *historyCrashStore) Write(name string, raw []byte) error { |
| + var r historyRecord |
| + if err := json.Unmarshal(raw, &r); err != nil { |
| + return err |
| + } |
| + reached := r.Attempt != nil && r.Attempt.ID == "crash-attempt" |
| + if s.boundary == "settled" { |
| + reached = r.Attempt == nil && len(r.Refusals.Values) == 1 |
| + } |
| + if reached && s.boundary == "intent-visible" { |
| + s.fail = 1 |
| + } |
| + err := s.healthyRetryStore.Write(name, raw) |
| + if reached { |
| + if err != nil && !(s.boundary == "intent-visible" && errors.Is(err, s.fault)) { |
| + return err |
| + } |
| + os.Exit(73) |
| + } |
| + return err |
| +} |
| + |
| +func TestPlan14RootHistoryProcessCrash(t *testing.T) { |
| + b := HistoryBinding{Host: proofHost, Account: "bab", App: "console"} |
| + a := HistoryAttempt{ID: "crash-attempt", Version: "new", Source: "record-etag", Started: time.Date(2026, 10, 13, 0, 0, 0, 0, time.UTC)} |
| + if boundary := os.Getenv("PLAN14_HISTORY_CRASH"); boundary != "" { |
| + dir := os.Getenv("PLAN14_HISTORY_DIR") |
| + disk := &healthyRetryStore{dir: dir, fault: errors.New("injected directory sync failure")} |
| + h := &RootHistory{Store: &historyCrashStore{healthyRetryStore: disk, boundary: boundary}} |
| + if boundary == "settled" { |
| + if err := h.Settle(b, a.ID, &Refusal{Host: b.Host, App: b.App, Version: a.Version, RefusedAt: a.Started, Reason: "failed"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else if err := h.Begin(b, a); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Fatal("crash boundary not reached") |
| + } |
| + for _, boundary := range []string{"intent-durable", "intent-visible", "settled"} { |
| + t.Run(boundary, func(t *testing.T) { |
| + h, s, b := rootHistoryFixture(t) |
| + if boundary == "settled" { |
| + if err := h.Begin(b, a); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestPlan14RootHistoryProcessCrash$") |
| + cmd.Dir = t.TempDir() |
| + cmd.Env = append(os.Environ(), "PLAN14_HISTORY_CRASH="+boundary, "PLAN14_HISTORY_DIR="+s.dir) |
| + out, err := cmd.CombinedOutput() |
| + var exit *exec.ExitError |
| + if !errors.As(err, &exit) || exit.ExitCode() != 73 { |
| + t.Fatalf("child: %v\n%s", err, out) |
| + } |
| + h = &RootHistory{Store: &healthyRetryStore{dir: s.dir}} |
| + r, err := h.read(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if boundary == "settled" { |
| + if r.Attempt != nil || len(r.Refusals.Values) != 1 { |
| + t.Fatalf("lost completed refusal: %+v", r) |
| + } |
| + } else { |
| + if r.Attempt == nil || *r.Attempt != a { |
| + t.Fatalf("lost pending attempt: %+v", r) |
| + } |
| + blocked, err := h.Blocks(b, &ChannelPointer{Version: "other"}) |
| + if err == nil || !blocked { |
| + t.Fatal("crashed attempt allowed forward work") |
| + } |
| + } |
| + if _, err := os.Stat(filepath.Join(s.dir, historyKey(b))); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/release/plan14_root_history_test.go b/internal/release/plan14_root_history_test.go| new file mode 100644 |
| index 000000000..9fedede24 |
| --- /dev/null |
| +++ b/internal/release/plan14_root_history_test.go |
| @@ -0,0 +1,361 @@ |
| +package release |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func rootHistoryFixture(t *testing.T) (*RootHistory, *healthyRetryStore, HistoryBinding) { |
| + t.Helper() |
| + s := &healthyRetryStore{dir: t.TempDir(), fault: errors.New("injected sync failure")} |
| + b := HistoryBinding{Host: proofHost, Account: "bab", App: "console"} |
| + h := &RootHistory{Store: s} |
| + r := historyRecord{Schema: 1, Binding: b, Revision: 1, Programs: historyPrograms{Revision: 1, Complete: true, Values: []string{"old/program"}}, Refusals: historyRefusals{Revision: 1, Complete: true}} |
| + if err := h.write(r); err != nil { |
| + t.Fatal(err) |
| + } |
| + return h, s, b |
| +} |
| + |
| +func TestPlan14RootHistoryMissingIsUnknown(t *testing.T) { |
| + h, s, b := rootHistoryFixture(t) |
| + if err := os.Remove(filepath.Join(s.dir, historyKey(b))); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := h.Programs(b); err == nil { |
| + t.Fatal("missing program history granted fresh installation") |
| + } |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("missing refusal history granted permission") |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryRejectsMalformed(t *testing.T) { |
| + h, s, b := rootHistoryFixture(t) |
| + good, err := s.Read(historyKey(b)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, attack := range []string{"schema", "identity", "revision", "programs", "refusals", "trailing", "extra"} { |
| + t.Run(attack, func(t *testing.T) { |
| + var r historyRecord |
| + if err := json.Unmarshal(good, &r); err != nil { |
| + t.Fatal(err) |
| + } |
| + var raw []byte |
| + switch attack { |
| + case "schema": |
| + r.Schema = 2 |
| + case "identity": |
| + r.Binding.Account = "bap" |
| + case "revision": |
| + r.Refusals.Revision = r.Revision + 1 |
| + case "programs": |
| + r.Programs.Complete = false |
| + case "refusals": |
| + r.Refusals.Complete = false |
| + } |
| + raw, _ = json.Marshal(r) |
| + if attack == "trailing" { |
| + raw = append(raw, []byte(" {}")...) |
| + } |
| + if attack == "extra" { |
| + raw = append(raw[:len(raw)-1], []byte(`,"allow":true}`)...) |
| + } |
| + if err := s.Write(historyKey(b), raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + if attack != "refusals" { |
| + if _, err := h.Programs(b); err == nil { |
| + t.Fatal("accepted unavailable program history") |
| + } |
| + } |
| + if attack != "programs" { |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("accepted unavailable refusal history") |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryLegacyTampering(t *testing.T) { |
| + for _, attack := range []string{"delete", "rename", "forge"} { |
| + t.Run(attack, func(t *testing.T) { |
| + h, _, b := rootHistoryFixture(t) |
| + legacy := t.TempDir() |
| + at := time.Date(2026, 10, 13, 0, 0, 0, 0, time.UTC) |
| + r := Refusal{Schema: 1, Host: b.Host, App: b.App, Version: "new", PointerPromotedAt: at, RefusedAt: at, Reason: "failed"} |
| + if err := WriteRefusal(legacy, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := RecordProgram(legacy, b.Host, "foreign/program"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.AddRefusal(b, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch attack { |
| + case "delete": |
| + if err := os.Remove(filepath.Join(RefusalDir(legacy), b.Host+".json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "rename": |
| + if err := os.Rename(legacy, legacy+"-old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(legacy + "-old") }) |
| + if err := os.Mkdir(legacy, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "forge": |
| + r.Recovered = true |
| + r.Version = "old" |
| + if err := WriteRefusal(legacy, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + programs, err := h.Programs(b) |
| + if err != nil || len(programs) != 1 || programs[0] != "old/program" { |
| + t.Fatalf("program exception from legacy: %v %v", programs, err) |
| + } |
| + refs, err := h.Refusals(b) |
| + if err != nil || len(refs) != 1 || refs[0].Version != "new" { |
| + t.Fatalf("refusal lost: %v %v", refs, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryVisibleWriteNeedsSync(t *testing.T) { |
| + h, s, b := rootHistoryFixture(t) |
| + r := Refusal{Schema: 1, Host: b.Host, App: b.App, Version: "new", RefusedAt: time.Now(), Reason: "failed"} |
| + h.Store = &historyFailNewStore{healthyRetryStore: s, remaining: 2} |
| + if err := h.AddRefusal(b, r); !errors.Is(err, s.fault) { |
| + t.Fatalf("write: %v", err) |
| + } |
| + if _, err := h.Refusals(b); !errors.Is(err, s.fault) { |
| + t.Fatalf("visible unsynced state authorized read: %v", err) |
| + } |
| + refs, err := h.Refusals(b) |
| + if err != nil || len(refs) != 1 { |
| + t.Fatalf("durable retry: %v %v", refs, err) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryRefusalKinds(t *testing.T) { |
| + for _, kind := range []string{RefusalNotUp, RefusalWrongProgram, RefusalNotProven} { |
| + t.Run("kind="+kind, func(t *testing.T) { |
| + h, _, b := rootHistoryFixture(t) |
| + at := time.Date(2026, 10, 13, 0, 0, 0, 0, time.UTC) |
| + r := Refusal{Schema: 1, Kind: kind, Host: b.Host, App: b.App, Version: "new", PointerPromotedAt: at, RefusedAt: at, Reason: "failed"} |
| + if err := h.AddRefusal(b, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + blocked, err := h.Blocks(b, &ChannelPointer{Version: "new", PromotedAt: at.Add(time.Second)}) |
| + if err != nil || blocked != (kind != RefusalNotUp) { |
| + t.Fatalf("retry clears wrong kind: blocked=%v err=%v", blocked, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryAttemptSurvivesReopen(t *testing.T) { |
| + h, s, b := rootHistoryFixture(t) |
| + a := HistoryAttempt{ID: "attempt-1", Version: "new", Source: "record-etag", Started: time.Now().UTC()} |
| + if err := h.Begin(b, a); err != nil { |
| + t.Fatal(err) |
| + } |
| + h = &RootHistory{Store: s} |
| + if err := h.Begin(b, HistoryAttempt{ID: "attempt-2", Version: "new", Source: "record-etag", Started: a.Started}); err == nil { |
| + t.Fatal("unresolved attempt retried") |
| + } |
| + if err := h.Begin(b, a); err == nil { |
| + t.Fatal("pending intent replay granted another forward attempt") |
| + } |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("pending attempt asserted a complete mint refusal list") |
| + } |
| + if err := h.Settle(b, "attempt-2", nil); err == nil { |
| + t.Fatal("wrong attempt settled") |
| + } |
| + if err := h.Settle(b, a.ID, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +type historyFailNewStore struct { |
| + *healthyRetryStore |
| + remaining int |
| +} |
| + |
| +func (s *historyFailNewStore) Write(name string, raw []byte) error { |
| + var r historyRecord |
| + if err := json.Unmarshal(raw, &r); err != nil { |
| + return err |
| + } |
| + if len(r.Refusals.Values) > 0 && s.remaining > 0 { |
| + s.fail = 1 |
| + s.remaining-- |
| + } |
| + return s.healthyRetryStore.Write(name, raw) |
| +} |
| + |
| +func TestPlan14RootHistoryCategoryIsolation(t *testing.T) { |
| + h, _, b := rootHistoryFixture(t) |
| + r, err := h.read(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r.Refusals.Complete = false |
| + if err := h.write(r); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := h.Programs(b); err != nil { |
| + t.Fatalf("known programs lost: %v", err) |
| + } |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("unknown refusals allowed") |
| + } |
| + if err := h.Begin(b, HistoryAttempt{ID: "a", Version: "new", Source: "record", Started: time.Now()}); err == nil { |
| + t.Fatal("incomplete category allowed an attempt") |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryDenyImportUnknown(t *testing.T) { |
| + h, s, b := rootHistoryFixture(t) |
| + if err := os.Remove(filepath.Join(s.dir, historyKey(b))); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := Refusal{Host: b.Host, App: b.App, Version: "new", RefusedAt: time.Now(), Reason: "failed", Recovered: true} |
| + if err := h.AddRefusal(b, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + saved, err := h.read(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if saved.Programs.Complete || saved.Refusals.Complete || len(saved.Refusals.Values) != 1 { |
| + t.Fatalf("import invented completeness: %+v", saved) |
| + } |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("legacy recovery asserted absence") |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryOverflowQuarantines(t *testing.T) { |
| + h, _, b := rootHistoryFixture(t) |
| + for i := 0; i < historyLimit; i++ { |
| + r := Refusal{Host: b.Host, App: b.App, Version: fmt.Sprint(i), RefusedAt: time.Now(), Reason: "failed"} |
| + if err := h.AddRefusal(b, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + extra := Refusal{Host: b.Host, App: b.App, Version: "overflow", RefusedAt: time.Now(), Reason: "failed"} |
| + if err := h.AddRefusal(b, extra); err == nil { |
| + t.Fatal("overflow succeeded") |
| + } |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("overflow retained completeness and dropped a deny") |
| + } |
| + r, err := h.read(b) |
| + if err != nil || len(r.Refusals.Values) != historyLimit { |
| + t.Fatalf("lost retained denies: %v %v", len(r.Refusals.Values), err) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryRejectsCrossBinding(t *testing.T) { |
| + h, s, b := rootHistoryFixture(t) |
| + raw, err := s.Read(historyKey(b)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, field := range []string{"host", "account", "app"} { |
| + other := b |
| + switch field { |
| + case "host": |
| + other.Host = "other.example" |
| + case "account": |
| + other.Account = "bap" |
| + case "app": |
| + other.App = "other" |
| + } |
| + if err := s.Write(historyKey(other), raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := h.Programs(other); err == nil { |
| + t.Fatalf("reused another %s's programs", field) |
| + } |
| + if _, err := h.Refusals(other); err == nil { |
| + t.Fatalf("reused another %s's refusals", field) |
| + } |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryDuplicateRefusal(t *testing.T) { |
| + h, _, b := rootHistoryFixture(t) |
| + r := Refusal{Host: b.Host, App: b.App, Version: "new", RefusedAt: time.Now(), Reason: "failed"} |
| + if err := h.AddRefusal(b, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.AddRefusal(b, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + refs, err := h.Refusals(b) |
| + if err != nil || len(refs) != 1 { |
| + t.Fatalf("duplicate deny changed revision/content: %d %v", len(refs), err) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryOversizedDenyQuarantines(t *testing.T) { |
| + h, _, b := rootHistoryFixture(t) |
| + r := Refusal{Host: b.Host, App: b.App, Version: "new", RefusedAt: time.Now(), Reason: strings.Repeat("x", rootstate.MaxRecord)} |
| + if err := h.AddRefusal(b, r); err == nil { |
| + t.Fatal("oversized deny succeeded") |
| + } |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("oversized deny silently discarded") |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryDuplicateJSONRefused(t *testing.T) { |
| + h, s, b := rootHistoryFixture(t) |
| + raw, err := s.Read(historyKey(b)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw = bytes.Replace(raw, []byte(`"complete":true`), []byte(`"complete":false,"complete":true`), 1) |
| + if err := s.Write(historyKey(b), raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := h.Programs(b); err == nil { |
| + t.Fatal("duplicate complete field accepted") |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryMissingTargetRefuses(t *testing.T) { |
| + h, _, b := rootHistoryFixture(t) |
| + for _, p := range []*ChannelPointer{nil, {}} { |
| + if blocked, err := h.Blocks(b, p); err == nil || !blocked { |
| + t.Fatal("missing target granted permission") |
| + } |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryPendingCannotMint(t *testing.T) { |
| + h, _, b := rootHistoryFixture(t) |
| + if err := h.Begin(b, HistoryAttempt{ID: "pending", Version: "new", Source: "etag", Started: time.Now()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("pending update asserted a complete mint refusal list") |
| + } |
| +} |
diff --git a/internal/release/roothistory.go b/internal/release/roothistory.go| new file mode 100644 |
| index 000000000..10fa132fd |
| --- /dev/null |
| +++ b/internal/release/roothistory.go |
| @@ -0,0 +1,254 @@ |
| +package release |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "slices" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +const RootHistoryDir = "/var/lib/carlos-release/private/history" |
| +const historyLimit = 256 |
| + |
| +type HistoryBinding struct { |
| + Host string `json:"host"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| +} |
| + |
| +type HistoryAttempt struct { |
| + ID string `json:"id"` |
| + Version string `json:"version"` |
| + Source string `json:"source"` |
| + Started time.Time `json:"started"` |
| +} |
| + |
| +type historyPrograms struct { |
| + Revision uint64 `json:"revision"` |
| + Complete bool `json:"complete"` |
| + Values []string `json:"values"` |
| +} |
| + |
| +type historyRefusals struct { |
| + Revision uint64 `json:"revision"` |
| + Complete bool `json:"complete"` |
| + Values []Refusal `json:"values"` |
| +} |
| + |
| +type historyRecord struct { |
| + Schema int `json:"schema"` |
| + Binding HistoryBinding `json:"binding"` |
| + Revision uint64 `json:"revision"` |
| + Programs historyPrograms `json:"programs"` |
| + Refusals historyRefusals `json:"refusals"` |
| + Attempt *HistoryAttempt `json:"attempt,omitempty"` |
| +} |
| + |
| +// Callers hold the root release exclusion across reads, decisions and effects. |
| +// A store is supplied only by root construction, never by a service request. |
| +type RootHistory struct { |
| + Store rootstate.Store |
| +} |
| + |
| +func historyKey(b HistoryBinding) string { |
| + sum := sha256.Sum256([]byte(b.Account + "\x00" + b.App + "\x00" + b.Host)) |
| + return hex.EncodeToString(sum[:]) + ".json" |
| +} |
| + |
| +func (b HistoryBinding) valid() bool { |
| + return ValidAccount(b.Account) && ValidApp(b.App) && b.Host != "" && len(b.Host) <= 253 && !strings.ContainsAny(b.Host, "/\\\x00\n\r") |
| +} |
| + |
| +func validHistory(r historyRecord, b HistoryBinding) bool { |
| + if !b.valid() || r.Schema != 1 || r.Binding != b || r.Revision == 0 || |
| + r.Programs.Revision == 0 || r.Programs.Revision > r.Revision || r.Refusals.Revision == 0 || r.Refusals.Revision > r.Revision || |
| + len(r.Programs.Values) > historyLimit || len(r.Refusals.Values) > historyLimit { |
| + return false |
| + } |
| + seen := map[string]bool{} |
| + for _, p := range r.Programs.Values { |
| + if p == "" || len(p) > 4096 || strings.ContainsAny(p, "\x00\n\r") || seen[p] { |
| + return false |
| + } |
| + seen[p] = true |
| + } |
| + for _, v := range r.Refusals.Values { |
| + if v.Schema != 1 || v.Host != b.Host || v.App != b.App || v.Version == "" || v.RefusedAt.IsZero() || v.MoveOp != "" { |
| + return false |
| + } |
| + if v.Kind != RefusalNotUp && v.Kind != RefusalWrongProgram && v.Kind != RefusalNotProven { |
| + return false |
| + } |
| + } |
| + if a := r.Attempt; a != nil && (a.ID == "" || a.Source == "" || a.Version == "" || a.Started.IsZero()) { |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +func (h *RootHistory) read(b HistoryBinding) (historyRecord, error) { |
| + var r historyRecord |
| + if h == nil || h.Store == nil || !b.valid() { |
| + return r, os.ErrPermission |
| + } |
| + raw, err := h.Store.Read(historyKey(b)) |
| + if err != nil { |
| + return r, err |
| + } |
| + if len(raw) > rootstate.MaxRecord { |
| + return r, os.ErrPermission |
| + } |
| + if strictCanonicalJSON(raw, &r) != nil || !validHistory(r, b) { |
| + return historyRecord{}, os.ErrPermission |
| + } |
| + // A preceding process may have seen rename succeed and directory sync fail. |
| + // Recommit the exact bytes before using either absence or an exception. |
| + if err := h.Store.Write(historyKey(b), raw); err != nil { |
| + return historyRecord{}, err |
| + } |
| + return r, nil |
| +} |
| + |
| +func (h *RootHistory) write(r historyRecord) error { |
| + if h == nil || h.Store == nil || !validHistory(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(historyKey(r.Binding), raw) |
| +} |
| + |
| +func (h *RootHistory) Programs(b HistoryBinding) ([]string, error) { |
| + r, err := h.read(b) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if !r.Programs.Complete { |
| + return nil, os.ErrPermission |
| + } |
| + return slices.Clone(r.Programs.Values), nil |
| +} |
| + |
| +func (h *RootHistory) Refusals(b HistoryBinding) ([]Refusal, error) { |
| + r, err := h.read(b) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if !r.Refusals.Complete || r.Attempt != nil { |
| + return nil, os.ErrPermission |
| + } |
| + return slices.Clone(r.Refusals.Values), nil |
| +} |
| + |
| +func (h *RootHistory) AddRefusal(b HistoryBinding, v Refusal) error { |
| + r, err := h.read(b) |
| + if errors.Is(err, os.ErrNotExist) && h != nil && h.Store != nil && b.valid() { |
| + r = historyRecord{Schema: 1, Binding: b, Revision: 1, |
| + Programs: historyPrograms{Revision: 1}, Refusals: historyRefusals{Revision: 1}} |
| + } else if err != nil { |
| + return err |
| + } |
| + v.Schema = 1 |
| + for _, old := range r.Refusals.Values { |
| + if old.RefusedAt.Equal(v.RefusedAt) && old.PointerPromotedAt.Equal(v.PointerPromotedAt) { |
| + old.RefusedAt, old.PointerPromotedAt = v.RefusedAt, v.PointerPromotedAt |
| + } |
| + if old == v { |
| + return nil |
| + } |
| + } |
| + if r.Revision == ^uint64(0) { |
| + return os.ErrPermission |
| + } |
| + r.Revision++ |
| + r.Refusals.Revision = r.Revision |
| + next := r |
| + next.Refusals.Values = append(slices.Clone(r.Refusals.Values), v) |
| + raw, marshalErr := json.Marshal(next) |
| + if marshalErr != nil { |
| + return marshalErr |
| + } |
| + if len(next.Refusals.Values) > historyLimit || len(raw) > rootstate.MaxRecord { |
| + r.Refusals.Complete = false |
| + return errors.Join(os.ErrPermission, h.write(r)) |
| + } |
| + return h.write(next) |
| +} |
| + |
| +// This pre-stage check blocks D1. D2 and D3 are reconsidered only after their |
| +// independent program and byte checks, so a new retry timestamp cannot clear them. |
| +func (h *RootHistory) Blocks(b HistoryBinding, p *ChannelPointer) (bool, error) { |
| + if p == nil || p.Version == "" { |
| + return true, os.ErrPermission |
| + } |
| + r, err := h.read(b) |
| + if err != nil { |
| + return true, err |
| + } |
| + if !r.Refusals.Complete || r.Attempt != nil { |
| + return true, os.ErrPermission |
| + } |
| + for _, v := range r.Refusals.Values { |
| + if p != nil && v.Version == p.Version && (v.Kind != RefusalNotUp || v.Blocks(p)) { |
| + return true, nil |
| + } |
| + } |
| + return false, nil |
| +} |
| + |
| +func (h *RootHistory) Begin(b HistoryBinding, a HistoryAttempt) error { |
| + r, err := h.read(b) |
| + if err != nil { |
| + return err |
| + } |
| + if !r.Refusals.Complete || !r.Programs.Complete { |
| + return os.ErrPermission |
| + } |
| + if r.Attempt != nil { |
| + return os.ErrPermission |
| + } |
| + if r.Revision == ^uint64(0) { |
| + return os.ErrPermission |
| + } |
| + r.Revision++ |
| + r.Attempt = &a |
| + return h.write(r) |
| +} |
| + |
| +// Only independently checked completion may settle an intent. Callers cannot |
| +// infer completion from a live link or registry stamp. |
| +func (h *RootHistory) Settle(b HistoryBinding, id string, refusal *Refusal) error { |
| + r, err := h.read(b) |
| + if err != nil { |
| + return err |
| + } |
| + if r.Attempt == nil || r.Attempt.ID != id { |
| + return os.ErrPermission |
| + } |
| + if refusal != nil { |
| + v := *refusal |
| + v.Schema = 1 |
| + if v.Version != r.Attempt.Version { |
| + return os.ErrPermission |
| + } |
| + r.Refusals.Values = append(r.Refusals.Values, v) |
| + } |
| + if r.Revision == ^uint64(0) { |
| + return os.ErrPermission |
| + } |
| + r.Revision++ |
| + r.Refusals.Revision = r.Revision |
| + r.Attempt = nil |
| + return h.write(r) |
| +} |