carlos / platform Public

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>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev fdbc9785de2a79acc38911f83adb1b806bb8a456 parent 0d9bdef
4 files changed, +712 −1
  • internal/provision/testdata/plan14-root-reads-merge.tsv +2 −1
  • internal/release/plan14_root_history_process_test.go +95 −0
  • internal/release/plan14_root_history_test.go +361 −0
  • internal/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)
+}