carlos / platform Public

Merge pull request #37 from carlosframework/ledger-primitive

Open-ledgers primitive: hash-chained ledger streams, console API writes, fail-closed edge serving
Paul Campbell 3b8e32384ab10e7cb6920fe91a5225a44aebcdf2 parent 0cd9162parent a57bbe9
25 files changed, +4333 −19
  • apimode.go +119 −0
  • apimode_test.go +600 −0
  • cli.go +331 −0
  • docs/superpowers/specs/2026-08-03-open-ledgers-design.md +70 −11
  • internal/console/apiledger.go +381 −0
  • internal/console/apiledger_test.go +728 −0
  • internal/console/authcli.go +9 −0
  • internal/edge/edge.go +18 −1
  • internal/edge/status.go +260 −5
  • internal/edge/status_test.go +678 −0
  • internal/ledger/append.go +109 −0
  • internal/ledger/append_test.go +169 −0
  • internal/ledger/blob.go +28 −0
  • internal/ledger/blob_test.go +37 −0
  • internal/ledger/ledger.go +84 −0
  • internal/ledger/ledger_test.go +37 −0
  • internal/ledger/manifest.go +98 −0
  • internal/ledger/manifest_test.go +168 −0
  • internal/ledger/verify.go +99 −0
  • internal/ledger/verify_test.go +136 −0
  • internal/release/qualified_test.go +3 −2
  • internal/release/scopedpromote_test.go +5 −0
  • internal/release/store.go +71 −0
  • internal/release/store_test.go +87 −0
  • main.go +8 −0
diff --git a/apimode.go b/apimode.go
index 6145af533..0f22b2679 100644
--- a/apimode.go
+++ b/apimode.go
@@ -29,6 +29,8 @@ import (
"path/filepath"
"sort"
"time"
+
+ "github.com/carlosframework/platform/internal/ledger"
)
// apiSession is the resolved API mode: which console, as whom. console and
@@ -359,6 +361,123 @@ func (s *apiSession) channels(ctx context.Context, account, app string) ([]apiCh
return out, nil
}
+// ledgerAppend asks the console to CAS-append one entry to name's chain,
+// stamping writer from the caller's own authenticated identity server-side
+// (never client-supplied — apiledger.go). A 503 here is contention (too many
+// writers accruing the same chain at once, ledger.ErrBusy) — retry-worthy,
+// which is exactly what apiErrorFromBody's json-first decode surfaces, since
+// ledgerStoreError answers it with a real "{"error": "..."}" body, not a bare
+// status.
+func (s *apiSession) ledgerAppend(ctx context.Context, account, app, name string, payload json.RawMessage) (ledger.Appended, error) {
+ var out ledger.Appended
+ status, body, err := s.postJSON(ctx, apiPath(account, app, "/ledgers/"+url.PathEscape(name)+"/append"),
+ map[string]json.RawMessage{"payload": payload})
+ if err != nil {
+ return out, err
+ }
+ if status != http.StatusOK {
+ return out, apiErrorFromBody(status, body)
+ }
+ return out, json.Unmarshal(body, &out)
+}
+
+// ledgerHead reads name's HEAD.json through the console. Its 404 is answered
+// with a dedicated message rather than apiErrorFromBody's generic membership
+// text: an unknown LEDGER under an app the caller does belong to is a
+// different, much more common case ("nothing appended yet") than "check
+// -account and -app", and deserves to say so.
+func (s *apiSession) ledgerHead(ctx context.Context, account, app, name string) (ledger.Head, error) {
+ var out ledger.Head
+ status, body, err := s.getJSON(ctx, apiPath(account, app, "/ledgers/"+url.PathEscape(name)+"/head"))
+ if err != nil {
+ return out, err
+ }
+ if status == http.StatusNotFound {
+ return out, fmt.Errorf("no %q ledger yet for this app", name)
+ }
+ if status != http.StatusOK {
+ return out, apiErrorFromBody(status, body)
+ }
+ return out, json.Unmarshal(body, &out)
+}
+
+// ledgerList reads an app's ledger names and its publish manifest through
+// the console. The raw body is handed back rather than a decoded struct:
+// {"manifest": Manifest|null, "ledgers": [...]} is the caller's shape to
+// interpret (a null manifest means "never published" — a caller-side zero
+// value would erase that distinction), and the CLI print path decodes it
+// itself right where it decides what to show a human.
+func (s *apiSession) ledgerList(ctx context.Context, account, app string) ([]byte, error) {
+ status, body, err := s.getJSON(ctx, apiPath(account, app, "/ledgers"))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ return body, nil
+}
+
+// ledgerPublish adds (or, with public:false, removes) named ledgers from the
+// set an app serves publicly. The console applies it as a read-modify-write
+// on the stored manifest, so the request says what to CHANGE and only the
+// response says what is public NOW — a set that may well name ledgers this
+// call never mentioned. That resulting manifest is the return value: without
+// it the CLI could only echo the caller's own argument back at them, which
+// is exactly how someone comes to believe they unpublished a chain they
+// didn't.
+func (s *apiSession) ledgerPublish(ctx context.Context, account, app string, m ledger.Manifest) (ledger.Manifest, error) {
+ status, body, err := s.postJSON(ctx, apiPath(account, app, "/ledgers/publish"), m)
+ if err != nil {
+ return ledger.Manifest{}, err
+ }
+ if status != http.StatusOK {
+ return ledger.Manifest{}, apiErrorFromBody(status, body)
+ }
+ var out ledger.Manifest
+ if err := json.Unmarshal(body, &out); err != nil {
+ return ledger.Manifest{}, fmt.Errorf("ledger publish: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+// ledgerBlob posts raw bytes (not JSON) to the ledger's blobs door — a
+// content-addressed, create-only upload (ledger.PutBlob; a re-post of the
+// same bytes is a no-op, matching ship's artifact semantics). It follows
+// getJSON's own request plumbing exactly — same base URL field, same bearer
+// header (apimode.go:212-230) — but with http.MethodPost and a
+// bytes.Reader body in place of GET/nil, since the wire body here is the
+// blob's raw bytes, never JSON.
+func (s *apiSession) ledgerBlob(ctx context.Context, account, app, name string, body []byte) (string, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
+ s.c.base+apiPath(account, app, "/ledgers/"+url.PathEscape(name)+"/blobs"), bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ if s.c.token != "" {
+ req.Header.Set("Authorization", "Bearer "+s.c.token)
+ }
+ res, err := s.c.httpc.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+ respBody, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if err != nil {
+ return "", err
+ }
+ if res.StatusCode != http.StatusOK {
+ return "", apiErrorFromBody(res.StatusCode, respBody)
+ }
+ var out struct {
+ SHA256 string `json:"sha256"`
+ }
+ if err := json.Unmarshal(respBody, &out); err != nil {
+ return "", fmt.Errorf("ledger blob: unreadable response: %w", err)
+ }
+ return out.SHA256, nil
+}
+
// shipTimeout bounds ship's own context — generous on purpose. The console
// gives itself up to 10 minutes to read the request body and 2 minutes to
// store it (internal/console/apicli.go), well past the 30s absolute Timeout
diff --git a/apimode_test.go b/apimode_test.go
index 88d5c7cb0..8a05cecfe 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -20,6 +20,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/ledger"
"github.com/carlosframework/platform/internal/release"
)
@@ -1241,3 +1242,602 @@ func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) {
}
checkNoRegistry()
}
+
+// ---- Task 6: carlos ledger — apiSession methods and the CLI verbs --------
+
+// TestAPISessionLedgerAppend pins the wire shape handleAPILedgerAppend
+// (internal/console/apiledger.go) requires: bearer auth, POST to
+// .../ledgers/<name>/append, body {"payload": <verbatim>}.
+func TestAPISessionLedgerAppend(t *testing.T) {
+ var gotAuth, gotPath, gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotAuth = r.Header.Get("Authorization")
+ gotPath = r.URL.Path
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ json.NewEncoder(w).Encode(ledger.Appended{Key: "ledgers/acct/app/carbon/entries/x.json", SHA256: strings.Repeat("a", 64), Count: 3})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ a, err := s.ledgerAppend(context.Background(), "acct", "app", "carbon", json.RawMessage(`{"n":1}`))
+ if err != nil {
+ t.Fatalf("ledgerAppend: %v", err)
+ }
+ if gotAuth != "Bearer cli-token" {
+ t.Fatalf("Authorization = %q, want Bearer cli-token", gotAuth)
+ }
+ if gotPath != "/api/cli/apps/acct/app/ledgers/carbon/append" {
+ t.Fatalf("path = %q", gotPath)
+ }
+ if !strings.Contains(gotBody, `"payload":{"n":1}`) {
+ t.Fatalf("request body = %q, want the payload carried verbatim", gotBody)
+ }
+ if a.Count != 3 {
+ t.Fatalf("Count = %d, want 3", a.Count)
+ }
+}
+
+// TestAPISessionLedgerAppendBusy pins ledgerStoreError's contention status:
+// a 503 with a JSON "error" body must surface that text, not a generic
+// status-only message — it is what tells a caller "retry", not "stop".
+func TestAPISessionLedgerAppendBusy(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ json.NewEncoder(w).Encode(map[string]string{"error": "the ledger is busy right now — try again in a moment"})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.ledgerAppend(context.Background(), "acct", "app", "carbon", json.RawMessage(`{"n":1}`))
+ if err == nil || !strings.Contains(err.Error(), "busy") {
+ t.Fatalf("ledgerAppend 503 error = %v, want it to surface the busy message", err)
+ }
+}
+
+// TestAPISessionLedgerHeadNotFound pins ledgerHead's DEDICATED 404 message —
+// deliberately not apiErrorFromBody's generic "-account/-app" text, since an
+// unknown LEDGER under an app the caller does belong to is a different, far
+// more common case ("nothing appended yet").
+func TestAPISessionLedgerHeadNotFound(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.ledgerHead(context.Background(), "acct", "app", "carbon")
+ if err == nil {
+ t.Fatal("ledgerHead against a 404 must error")
+ }
+ if !strings.Contains(err.Error(), `"carbon"`) {
+ t.Fatalf("error = %q, want it to name the ledger", err)
+ }
+ if strings.Contains(err.Error(), "-account") {
+ t.Fatalf("error = %q, must NOT fall back to the generic membership message", err)
+ }
+}
+
+func TestAPISessionLedgerHead(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(ledger.Head{Schema: 1, UpdatedAt: "2026-08-03T00:00:00Z", HeadKey: "k", HeadSHA256: strings.Repeat("b", 64), Count: 5})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ h, err := s.ledgerHead(context.Background(), "acct", "app", "carbon")
+ if err != nil {
+ t.Fatalf("ledgerHead: %v", err)
+ }
+ if h.Count != 5 || h.HeadKey != "k" {
+ t.Fatalf("head = %+v", h)
+ }
+}
+
+// TestAPISessionLedgerList pins ledgerList's raw-body contract: the caller
+// (runLedgerList) decodes {"manifest","ledgers"} itself, so a null manifest
+// survives round-trip as nil rather than a caller-side zero value that would
+// erase the "never published" distinction.
+func TestAPISessionLedgerList(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"manifest": nil, "ledgers": []string{"carbon"}})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ body, err := s.ledgerList(context.Background(), "acct", "app")
+ if err != nil {
+ t.Fatalf("ledgerList: %v", err)
+ }
+ var res struct {
+ Manifest *ledger.Manifest `json:"manifest"`
+ Ledgers []string `json:"ledgers"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if res.Manifest != nil {
+ t.Fatalf("manifest = %+v, want nil", res.Manifest)
+ }
+ if len(res.Ledgers) != 1 || res.Ledgers[0] != "carbon" {
+ t.Fatalf("ledgers = %v, want [carbon]", res.Ledgers)
+ }
+}
+
+// TestAPISessionLedgerPublish: publish is read-modify-write server-side, so
+// the answer is the RESULTING manifest — the set that is public now, which
+// may name ledgers this request never mentioned. A method that discarded the
+// body would leave the CLI with nothing honest to print.
+func TestAPISessionLedgerPublish(t *testing.T) {
+ var gotPath, gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ json.NewEncoder(w).Encode(ledger.Manifest{
+ Schema: 1, Public: true, Ledgers: []string{"prompts", "carbon"}, Title: "T"})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ m, err := s.ledgerPublish(context.Background(), "acct", "app",
+ ledger.Manifest{Public: true, Ledgers: []string{"carbon"}, Title: "T"})
+ if err != nil {
+ t.Fatalf("ledgerPublish: %v", err)
+ }
+ if !m.Public || len(m.Ledgers) != 2 || m.Ledgers[1] != "carbon" {
+ t.Fatalf("ledgerPublish = %+v, want the server's RESULTING manifest, not the request", m)
+ }
+ if gotPath != "/api/cli/apps/acct/app/ledgers/publish" {
+ t.Fatalf("path = %q", gotPath)
+ }
+ if !strings.Contains(gotBody, `"public":true`) || !strings.Contains(gotBody, `"carbon"`) {
+ t.Fatalf("body = %q", gotBody)
+ }
+}
+
+// TestAPISessionLedgerBlob pins ledgerBlob's request plumbing — the one
+// method that hand-builds its request rather than going through
+// getJSON/postJSON, because the wire body is raw bytes, not JSON. Same base
+// URL and bearer header as getJSON, POST instead of GET, a bytes.Reader body
+// instead of nil.
+func TestAPISessionLedgerBlob(t *testing.T) {
+ var gotAuth, gotPath string
+ var gotBody []byte
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotAuth = r.Header.Get("Authorization")
+ gotPath = r.URL.Path
+ gotBody, _ = io.ReadAll(r.Body)
+ json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(gotBody)})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ sha, err := s.ledgerBlob(context.Background(), "acct", "app", "carbon", []byte("corpus"))
+ if err != nil {
+ t.Fatalf("ledgerBlob: %v", err)
+ }
+ if gotAuth != "Bearer cli-token" {
+ t.Fatalf("Authorization = %q, want Bearer cli-token", gotAuth)
+ }
+ if gotPath != "/api/cli/apps/acct/app/ledgers/carbon/blobs" {
+ t.Fatalf("path = %q", gotPath)
+ }
+ if string(gotBody) != "corpus" {
+ t.Fatalf("request body = %q, want the raw bytes verbatim", gotBody)
+ }
+ if want := ledger.SHA256Hex([]byte("corpus")); sha != want {
+ t.Fatalf("sha256 = %q, want %q", sha, want)
+ }
+}
+
+// TestAPISessionLedgerBlobError pins ledgerBlob's error path — a non-200
+// status decodes through apiErrorFromBody exactly like every other method
+// here, even though the request itself was raw bytes, not JSON.
+func TestAPISessionLedgerBlobError(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusRequestEntityTooLarge)
+ json.NewEncoder(w).Encode(map[string]string{"error": "blob exceeds the 4 MiB cap"})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.ledgerBlob(context.Background(), "acct", "app", "carbon", []byte("x"))
+ if err == nil || !strings.Contains(err.Error(), "4 MiB cap") {
+ t.Fatalf("ledgerBlob error = %v, want the server's 413 message surfaced", err)
+ }
+}
+
+// TestLedgerVerbsOfferNoRegistryFlag: -registry names the local database
+// that resolves an account in DIRECT-bucket mode, and ledgerAPISession
+// refuses direct mode outright (spec §2.3). A ledger verb that advertised
+// -registry would be offering a mode it will then refuse — and the flag
+// would be dead besides, since nothing on this path opens a registry (the
+// requireNoRegistryTouch assertions elsewhere in this file are the other
+// half of that proof).
+func TestLedgerVerbsOfferNoRegistryFlag(t *testing.T) {
+ fs := flag.NewFlagSet("ledger", flag.ContinueOnError)
+ ledgerAccountFlag(fs)
+ if fs.Lookup("account") == nil {
+ t.Fatal("ledger verbs must still take -account")
+ }
+ if f := fs.Lookup("registry"); f != nil {
+ t.Fatalf("ledger verbs register -registry (%q) — direct-bucket mode is refused, not configured", f.Usage)
+ }
+}
+
+// ---- Task 6: the ledger verbs wired to resolveMode / public HTTPS --------
+
+// TestLedgerAppendVerbAPIMode pins runLedgerAppend's console wiring: it
+// reads the entry from a file argument, validates it client-side (already
+// smoke-tested via `go run . ledger append` — invalid JSON never reaches
+// this server, see the task report), and posts through the console.
+func TestLedgerAppendVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ var reqCount int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/carbon/append" {
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ var in struct {
+ Payload json.RawMessage `json:"payload"`
+ }
+ json.NewDecoder(r.Body).Decode(&in)
+ if string(in.Payload) != `{"n":1}` {
+ t.Errorf("payload = %s, want {\"n\":1}", in.Payload)
+ }
+ json.NewEncoder(w).Encode(ledger.Appended{Key: "k", SHA256: strings.Repeat("c", 64), Count: 7})
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ dir := t.TempDir()
+ entryPath := filepath.Join(dir, "entry.json")
+ if err := os.WriteFile(entryPath, []byte(`{"n":1}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runLedgerAppend([]string{"-app", "titogo", "-account", apiTestAccount, "carbon", entryPath})
+
+ out := buf.String()
+ if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want it to contain %q", out, want)
+ }
+ if !strings.Contains(out, "appended to carbon") || !strings.Contains(out, "#7") {
+ t.Fatalf("stdout = %q, want the appended line naming the ledger and count 7", out)
+ }
+ if !strings.Contains(out, strings.Repeat("c", 12)) {
+ t.Fatalf("stdout = %q, want the sha's first 12 hex chars", out)
+ }
+ if got := atomic.LoadInt32(&reqCount); got != 1 {
+ t.Fatalf("server got %d requests, want 1", got)
+ }
+ checkNoRegistry()
+}
+
+// TestLedgerAppendVerbReadsStdin pins the "-" convention: the entry argument
+// "-" reads the payload from stdin rather than treating "-" as a filename.
+func TestLedgerAppendVerbReadsStdin(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ var gotPayload string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var in struct {
+ Payload json.RawMessage `json:"payload"`
+ }
+ json.NewDecoder(r.Body).Decode(&in)
+ gotPayload = string(in.Payload)
+ json.NewEncoder(w).Encode(ledger.Appended{Key: "k", SHA256: strings.Repeat("d", 64), Count: 1})
+ }))
+ defer ts.Close()
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := w.WriteString(`{"n":2}`); err != nil {
+ t.Fatal(err)
+ }
+ w.Close()
+ origStdin := os.Stdin
+ os.Stdin = r
+ t.Cleanup(func() { os.Stdin = origStdin })
+
+ buf := captureStdout(t)
+ runLedgerAppend([]string{"-app", "titogo", "-account", apiTestAccount, "carbon", "-"})
+
+ if gotPayload != `{"n":2}` {
+ t.Fatalf("payload the server received = %q, want the stdin content", gotPayload)
+ }
+ if !strings.Contains(buf.String(), "appended to carbon") {
+ t.Fatalf("stdout = %q", buf.String())
+ }
+}
+
+// TestLedgerHeadVerbAPIMode pins the one deliberately-different output shape
+// in this file: head prints ONLY the HEAD JSON (no "using ... as ..."
+// banner line) so a script can pipe it straight into `jq`.
+func TestLedgerHeadVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/carbon/head" {
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(ledger.Head{
+ Schema: 1, UpdatedAt: "2026-08-03T00:00:00Z",
+ HeadKey: "ledgers/x/HEAD.json", HeadSHA256: strings.Repeat("e", 64), Count: 9,
+ })
+ }))
+ defer ts.Close()
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runLedgerHead([]string{"-app", "titogo", "-account", apiTestAccount, "carbon"})
+
+ out := buf.String()
+ if strings.Contains(out, "using ") {
+ t.Fatalf("stdout = %q, head must print ONLY the HEAD JSON, no banner line", out)
+ }
+ var h ledger.Head
+ if err := json.Unmarshal([]byte(out), &h); err != nil {
+ t.Fatalf("head output is not valid JSON: %v (%q)", err, out)
+ }
+ if h.Count != 9 {
+ t.Fatalf("head.Count = %d, want 9", h.Count)
+ }
+}
+
+// Note: runLedgerHead's 404 path ends in log.Fatal(err), which exits the
+// process — untestable in-process, matching this file's existing convention
+// (captureStdout's own doc comment above: "log.Fatal paths are untouched by
+// this seam on purpose"). The error text itself, which is what a human
+// actually reads, is pinned at the apiSession layer instead, by
+// TestAPISessionLedgerHeadNotFound above.
+
+func TestLedgerListVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers" {
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "manifest": map[string]any{"schema": 1, "public": true, "ledgers": []string{"carbon"}, "title": "Titogo"},
+ "ledgers": []string{"carbon", "prompts"},
+ })
+ }))
+ defer ts.Close()
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runLedgerList([]string{"-app", "titogo", "-account", apiTestAccount})
+
+ out := buf.String()
+ if !strings.Contains(out, `published "Titogo"`) || !strings.Contains(out, "carbon") {
+ t.Fatalf("stdout = %q, want the published-manifest line naming the title and ledgers", out)
+ }
+ if !strings.Contains(out, "prompts") {
+ t.Fatalf("stdout = %q, want the prompts ledger listed even though it isn't in the manifest", out)
+ }
+}
+
+// TestLedgerListVerbUnpublished pins the null-manifest case: "never
+// published" must read as its own message, not an empty/zero-value table.
+func TestLedgerListVerbUnpublished(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"manifest": nil, "ledgers": []string{"carbon"}})
+ }))
+ defer ts.Close()
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runLedgerList([]string{"-app", "titogo", "-account", apiTestAccount})
+
+ out := buf.String()
+ if !strings.Contains(out, "not published") {
+ t.Fatalf("stdout = %q, want a not-published message for a null manifest", out)
+ }
+ if !strings.Contains(out, "carbon") {
+ t.Fatalf("stdout = %q, want the carbon ledger still listed", out)
+ }
+}
+
+func TestLedgerPublishVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ var gotPath, gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ // The server's answer names a ledger this call never mentioned:
+ // publish is additive, so the resulting set is bigger than the ask.
+ json.NewEncoder(w).Encode(ledger.Manifest{
+ Schema: 1, Public: true, Ledgers: []string{"prompts", "carbon"}, Title: "Titogo"})
+ }))
+ defer ts.Close()
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runLedgerPublish([]string{"-app", "titogo", "-account", apiTestAccount, "-title", "Titogo", "carbon"})
+
+ if gotPath != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/publish" {
+ t.Fatalf("path = %q", gotPath)
+ }
+ if !strings.Contains(gotBody, `"public":true`) || !strings.Contains(gotBody, `"carbon"`) || !strings.Contains(gotBody, `"Titogo"`) {
+ t.Fatalf("publish body = %q", gotBody)
+ }
+ // The line reports the RESULTING public set, not the request's argument
+ // — printing "carbon now public" while prompts is also public is how a
+ // caller ends up believing they unpublished something they didn't.
+ if !strings.Contains(buf.String(), "published titogo: public ledgers now [prompts carbon]") {
+ t.Fatalf("stdout = %q, want the resulting public set", buf.String())
+ }
+}
+
+// TestLedgerPublishVerbUnpublish pins -public=false's own message (an
+// unpublish is not the same act as a publish, and must not read like one),
+// and that it too reports what is left public rather than what was removed.
+func TestLedgerPublishVerbUnpublish(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ var gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ json.NewEncoder(w).Encode(ledger.Manifest{
+ Schema: 1, Public: true, Ledgers: []string{"prompts"}, Title: "Titogo"})
+ }))
+ defer ts.Close()
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runLedgerPublish([]string{"-app", "titogo", "-account", apiTestAccount, "-public=false", "carbon"})
+
+ if !strings.Contains(gotBody, `"public":false`) {
+ t.Fatalf("publish body = %q, want public:false", gotBody)
+ }
+ if !strings.Contains(buf.String(), "unpublished titogo: public ledgers now [prompts]") {
+ t.Fatalf("stdout = %q, want what is still public", buf.String())
+ }
+}
+
+// TestLedgerPublishVerbUnpublishLast: when the last ledger goes, the line
+// says so plainly rather than printing an empty bracket.
+func TestLedgerPublishVerbUnpublishLast(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(ledger.Manifest{Schema: 1, Public: false, Title: "Titogo"})
+ }))
+ defer ts.Close()
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runLedgerPublish([]string{"-app", "titogo", "-account", apiTestAccount, "-public=false", "carbon"})
+
+ if !strings.Contains(buf.String(), "unpublished titogo: no public ledgers") {
+ t.Fatalf("stdout = %q, want the emptied-set message", buf.String())
+ }
+}
+
+func TestLedgerBlobVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/prompts/blobs" {
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ b, _ := io.ReadAll(r.Body)
+ json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(b)})
+ }))
+ defer ts.Close()
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "transcript.txt")
+ if err := os.WriteFile(path, []byte("corpus bytes"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runLedgerBlob([]string{"-app", "titogo", "-account", apiTestAccount, "prompts", path})
+
+ want := ledger.SHA256Hex([]byte("corpus bytes"))
+ if !strings.Contains(buf.String(), want) {
+ t.Fatalf("stdout = %q, want it to contain the uploaded blob's sha256 %q", buf.String(), want)
+ }
+}
+
+// TestLedgerBlobVerbMultipleFiles pins that "blob <ledger> <file>..." takes
+// every trailing file argument, one upload per file.
+func TestLedgerBlobVerbMultipleFiles(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ var reqCount int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ b, _ := io.ReadAll(r.Body)
+ json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(b)})
+ }))
+ defer ts.Close()
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ dir := t.TempDir()
+ pathA := filepath.Join(dir, "a.txt")
+ pathB := filepath.Join(dir, "b.txt")
+ if err := os.WriteFile(pathA, []byte("aaa"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(pathB, []byte("bbb"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runLedgerBlob([]string{"-app", "titogo", "-account", apiTestAccount, "prompts", pathA, pathB})
+
+ out := buf.String()
+ if !strings.Contains(out, ledger.SHA256Hex([]byte("aaa"))) || !strings.Contains(out, ledger.SHA256Hex([]byte("bbb"))) {
+ t.Fatalf("stdout = %q, want both files' sha256", out)
+ }
+ if got := atomic.LoadInt32(&reqCount); got != 2 {
+ t.Fatalf("server got %d requests, want 2 (one per file)", got)
+ }
+}
+
+// TestLedgerVerifyVerb pins runLedgerVerify's own wiring: -url trimmed and
+// handed to ledger.HTTPGetter, -account treated as a bare sqid (never
+// resolved), and the printed message. Deliberately does NOT call
+// useTempCredentials/clearDeploymentEnv/loginTo — the whole point of
+// "verify -url" is that it needs no credential and no console session at
+// all, and this test proves it by never configuring either.
+func TestLedgerVerifyVerb(t *testing.T) {
+ dir := t.TempDir()
+ st := release.NewDirStore(dir)
+ fixedNow := func(i int) func() time.Time {
+ return func() time.Time { return time.Date(2026, 8, 3, 0, i, 0, 0, time.UTC) }
+ }
+ for i := range 2 {
+ if _, err := ledger.Append(context.Background(), st, "bab", "platform", "carbon", "w",
+ json.RawMessage(`{"i":1}`), fixedNow(i)); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _, err := st.Get(r.Context(), strings.TrimPrefix(r.URL.Path, "/"))
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Write(b)
+ }))
+ defer ts.Close()
+
+ buf := captureStdout(t)
+ // Trailing slash on -url, deliberately, to pin strings.TrimRight.
+ runLedgerVerify([]string{"-app", "platform", "-account", "bab", "-url", ts.URL + "/", "carbon"})
+
+ if !strings.Contains(buf.String(), "verified: 2 entries") {
+ t.Fatalf("stdout = %q, want the verified line", buf.String())
+ }
+}
diff --git a/cli.go b/cli.go
index fdccb2676..3594d88b7 100644
--- a/cli.go
+++ b/cli.go
@@ -9,11 +9,13 @@ import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
+ "encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
+ "net/http"
"os"
"path/filepath"
"regexp"
@@ -23,6 +25,7 @@ import (
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/ledger"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
@@ -1129,6 +1132,334 @@ func runRollback(args []string) {
fmt.Fprintf(stdout, "rolled back %s %s\n", *app, fs.Arg(0))
}
+// ledgerUsage lists every `carlos ledger` sub-verb — printed for a bare
+// `carlos ledger`, an unrecognized sub, and `-h`, matching RunAuth/
+// RunAccounts/RunEnv's own bare/unknown/-h behaviour (auth.go, accounts.go,
+// appconfig_cli.go): "-h" is never special-cased inside a sub's own
+// flag.FlagSet, it just isn't a recognized args[0] here either, so it falls
+// through to this same text.
+const ledgerUsage = `usage: carlos ledger <command>
+
+commands:
+ append -app <a> [-account <acct>] <ledger> <entry.json|->
+ append one JSON entry to the chain —
+ reads the file, or stdin with "-";
+ validated as JSON before it is sent
+ head -app <a> [-account <acct>] <ledger>
+ print the chain's HEAD.json verbatim
+ list -app <a> [-account <acct>]
+ list this app's ledger names and its
+ publish manifest (what's public, if
+ anything)
+ publish -app <a> [-account <acct>] [-public=true|false] [-title <t>] <ledger>...
+ add these ledgers to what this app
+ serves publicly (additive: the ones
+ already public stay public, and an
+ omitted -title keeps the stored one);
+ -public=false removes just these,
+ taking the app private only when
+ nothing is left
+ blob -app <a> [-account <acct>] <ledger> <file>...
+ upload one or more content-addressed
+ blobs (create-only; re-upload of
+ identical bytes is a no-op)
+ verify -app <a> -account <sqid> -url <https://status-host> <ledger>
+ walk a PUBLISHED chain over plain
+ HTTPS and re-hash every entry — needs
+ NO credentials, so anyone can check a
+ published ledger. -account here is the
+ account SQID, not a name: the public
+ status-host path is keyed by sqid.
+
+Every sub above except "verify -url" goes through the console API — this
+terminal never touches bucket credentials directly (spec §2.3,
+docs/superpowers/specs/2026-08-03-open-ledgers-design.md). [-console
+<origin>] picks which logged-in console when more than one is configured.
+`
+
+// RunLedger dispatches the ledger sub-verbs.
+func RunLedger(args []string) {
+ if len(args) == 0 {
+ fmt.Fprint(os.Stderr, ledgerUsage)
+ os.Exit(1)
+ }
+ switch args[0] {
+ case "append":
+ runLedgerAppend(args[1:])
+ case "head":
+ runLedgerHead(args[1:])
+ case "list":
+ runLedgerList(args[1:])
+ case "publish":
+ runLedgerPublish(args[1:])
+ case "blob":
+ runLedgerBlob(args[1:])
+ case "verify":
+ runLedgerVerify(args[1:])
+ default:
+ fmt.Fprint(os.Stderr, ledgerUsage)
+ os.Exit(1)
+ }
+}
+
+// ledgerAPISession resolves this terminal's console session for a ledger
+// sub-verb that writes or reads through the console (every sub except
+// `verify -url`, which never calls this at all). Unlike promote/channels/
+// rollback/ship, ledgers have no direct-bucket mode to fall back to — spec
+// §2.3 is unconditional: a contributor machine gets no bucket credentials,
+// full stop. resolveMode returning (nil, nil) means CARLOS_DEPLOYMENT_BUCKET
+// or _DIR is set, i.e. direct mode is what this terminal is configured for —
+// which for every OTHER release verb is a valid path to take, but for ledger
+// is refused here rather than silently reinterpreted or left for a caller
+// to nil-dereference.
+func ledgerAPISession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("carlos ledger always goes through the console API — no bucket credentials in a terminal " +
+ "(spec §2.3); run carlos auth login -console <url>, or unset CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR")
+ }
+ return sess
+}
+
+// ledgerAccountFlag registers the ONE account flag a ledger verb has, in
+// place of accountFlags. accountFlags also registers -registry, the local
+// registry database that resolves an account name in DIRECT-bucket mode —
+// and ledgerAPISession refuses direct mode outright (spec §2.3: no bucket
+// credentials in a terminal). A -registry flag on these verbs is therefore
+// dead: nothing here ever opens a registry, and offering the flag advertises
+// a mode that does not exist. The account is resolved server-side, by the
+// console, against the caller's own memberships — which is why -account
+// takes a sqid OR a name here and needs no local database to do it.
+func ledgerAccountFlag(fs *flag.FlagSet) *string {
+ return fs.String("account", "ops", "account (sqid or name) the app belongs to — resolved by the console")
+}
+
+func runLedgerAppend(args []string) {
+ fs := flag.NewFlagSet("ledger append", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ acctRaw := ledgerAccountFlag(fs)
+ consoleFlag := fs.String("console", "", "console origin to append through (default: the only logged-in one)")
+ fs.Parse(args)
+ if *app == "" || fs.NArg() != 2 {
+ log.Fatal("usage: carlos ledger append -app <a> [-account <acct>] <ledger> <entry.json|->")
+ }
+ name, src := fs.Arg(0), fs.Arg(1)
+ if !ledger.ValidName(name) {
+ log.Fatalf("ledger append: invalid ledger name %q", name)
+ }
+
+ // Read and validate the payload BEFORE ever dialing the console — a
+ // malformed entry is the caller's mistake to see immediately, not a
+ // round trip and a server-side 400.
+ var raw []byte
+ var err error
+ if src == "-" {
+ raw, err = io.ReadAll(os.Stdin)
+ } else {
+ raw, err = os.ReadFile(src)
+ }
+ if err != nil {
+ log.Fatalf("ledger append: %v", err)
+ }
+ if !json.Valid(raw) {
+ log.Fatalf("ledger append: %s is not valid JSON", src)
+ }
+
+ sess := ledgerAPISession(*consoleFlag)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ a, err := sess.ledgerAppend(context.Background(), *acctRaw, *app, name, json.RawMessage(raw))
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Fprintf(stdout, "appended to %s: entry #%d (sha %s)\n", name, a.Count, a.SHA256[:12])
+}
+
+// runLedgerHead prints HEAD.json verbatim — no "using ... as ..." banner
+// line first, unlike every other sub here, so the output stays valid JSON a
+// script can pipe straight into `jq` rather than a human-readable line plus
+// a JSON blob.
+func runLedgerHead(args []string) {
+ fs := flag.NewFlagSet("ledger head", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ acctRaw := ledgerAccountFlag(fs)
+ consoleFlag := fs.String("console", "", "console origin to read through (default: the only logged-in one)")
+ fs.Parse(args)
+ if *app == "" || fs.NArg() != 1 {
+ log.Fatal("usage: carlos ledger head -app <a> [-account <acct>] <ledger>")
+ }
+ name := fs.Arg(0)
+ if !ledger.ValidName(name) {
+ log.Fatalf("ledger head: invalid ledger name %q", name)
+ }
+
+ sess := ledgerAPISession(*consoleFlag)
+ h, err := sess.ledgerHead(context.Background(), *acctRaw, *app, name)
+ if err != nil {
+ log.Fatal(err)
+ }
+ b, err := json.MarshalIndent(h, "", " ")
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Fprintln(stdout, string(b))
+}
+
+func runLedgerList(args []string) {
+ fs := flag.NewFlagSet("ledger list", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ acctRaw := ledgerAccountFlag(fs)
+ consoleFlag := fs.String("console", "", "console origin to read through (default: the only logged-in one)")
+ fs.Parse(args)
+ if *app == "" {
+ log.Fatal("usage: carlos ledger list -app <a> [-account <acct>]")
+ }
+
+ sess := ledgerAPISession(*consoleFlag)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ body, err := sess.ledgerList(context.Background(), *acctRaw, *app)
+ if err != nil {
+ log.Fatal(err)
+ }
+ var res struct {
+ Manifest *ledger.Manifest `json:"manifest"`
+ Ledgers []string `json:"ledgers"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ log.Fatalf("ledger list: unreadable response: %v", err)
+ }
+
+ switch {
+ case res.Manifest == nil:
+ fmt.Fprintln(stdout, "not published — nothing is public for this app yet")
+ case res.Manifest.Public:
+ title := res.Manifest.Title
+ if title == "" {
+ title = *app
+ }
+ fmt.Fprintf(stdout, "published %q: %s\n", title, strings.Join(res.Manifest.Ledgers, ", "))
+ default:
+ fmt.Fprintln(stdout, "unpublished (public=false)")
+ }
+ if len(res.Ledgers) == 0 {
+ fmt.Fprintln(stdout, "no ledgers yet")
+ return
+ }
+ w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
+ fmt.Fprintln(w, "LEDGER")
+ for _, n := range res.Ledgers {
+ fmt.Fprintln(w, n)
+ }
+ w.Flush()
+}
+
+func runLedgerPublish(args []string) {
+ fs := flag.NewFlagSet("ledger publish", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ acctRaw := ledgerAccountFlag(fs)
+ public := fs.Bool("public", true, "serve these ledgers publicly (-public=false unpublishes)")
+ title := fs.String("title", "", "display title for the published set")
+ consoleFlag := fs.String("console", "", "console origin to publish through (default: the only logged-in one)")
+ fs.Parse(args)
+ if *app == "" || fs.NArg() == 0 {
+ log.Fatal("usage: carlos ledger publish -app <a> [-account <acct>] [-public=true|false] [-title <t>] <ledger>...")
+ }
+ names := fs.Args()
+ for _, n := range names {
+ if !ledger.ValidName(n) {
+ log.Fatalf("ledger publish: invalid ledger name %q", n)
+ }
+ }
+
+ sess := ledgerAPISession(*consoleFlag)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ m := ledger.Manifest{Public: *public, Ledgers: names, Title: *title}
+ got, err := sess.ledgerPublish(context.Background(), *acctRaw, *app, m)
+ if err != nil {
+ log.Fatal(err)
+ }
+ // Report the RESULTING public set, never the argument: publish is
+ // additive and unpublish removes by name, so the app's public set after
+ // this call is something only the server can say. Echoing `names` here
+ // would tell a caller who published `prompts` that prompts is what is
+ // public — when carbon is public too, or, on an unpublish, when carbon is
+ // what is left.
+ verb := "published"
+ if !*public {
+ verb = "unpublished"
+ }
+ if len(got.Ledgers) == 0 {
+ fmt.Fprintf(stdout, "%s %s: no public ledgers\n", verb, *app)
+ return
+ }
+ fmt.Fprintf(stdout, "%s %s: public ledgers now [%s]\n", verb, *app, strings.Join(got.Ledgers, " "))
+}
+
+func runLedgerBlob(args []string) {
+ fs := flag.NewFlagSet("ledger blob", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ acctRaw := ledgerAccountFlag(fs)
+ consoleFlag := fs.String("console", "", "console origin to upload through (default: the only logged-in one)")
+ fs.Parse(args)
+ if *app == "" || fs.NArg() < 2 {
+ log.Fatal("usage: carlos ledger blob -app <a> [-account <acct>] <ledger> <file>...")
+ }
+ name := fs.Arg(0)
+ if !ledger.ValidName(name) {
+ log.Fatalf("ledger blob: invalid ledger name %q", name)
+ }
+ files := fs.Args()[1:]
+
+ sess := ledgerAPISession(*consoleFlag)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ for _, path := range files {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ log.Fatalf("ledger blob: %v", err)
+ }
+ if len(data) > ledger.MaxBlob {
+ log.Fatalf("ledger blob: %s is %d bytes, exceeds the %d byte cap", path, len(data), ledger.MaxBlob)
+ }
+ sha, err := sess.ledgerBlob(context.Background(), *acctRaw, *app, name, data)
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Fprintf(stdout, "%s: %s\n", path, sha)
+ }
+}
+
+// runLedgerVerify never calls resolveMode/ledgerAPISession — verifying a
+// published chain needs no console session and no credential at all (spec
+// §2.5): it is a plain HTTPS walk against the public status-host, which is
+// the entire point of publishing one. -account here is the account SQID,
+// not a name — the public path is keyed by sqid (apimode.go's apiPath, by
+// contrast, accepts either, because the console resolves a name against the
+// caller's own memberships; there is no caller and no membership check
+// here).
+func runLedgerVerify(args []string) {
+ fs := flag.NewFlagSet("ledger verify", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ account := fs.String("account", "", "account SQID (not name) — the public status-host path is keyed by sqid")
+ urlBase := fs.String("url", "", "public status-host base URL, e.g. https://status.example.com — no credentials needed")
+ fs.Parse(args)
+ if *app == "" || *account == "" || *urlBase == "" || fs.NArg() != 1 {
+ log.Fatal("usage: carlos ledger verify -app <a> -account <sqid> -url <https://status-host> <ledger>")
+ }
+ name := fs.Arg(0)
+ if !ledger.ValidName(name) {
+ log.Fatalf("ledger verify: invalid ledger name %q", name)
+ }
+
+ g := ledger.HTTPGetter(http.DefaultClient, strings.TrimRight(*urlBase, "/"))
+ res, err := ledger.Verify(context.Background(), g, *account, *app, name)
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Fprintf(stdout, "verified: %d entries, head %s\n", res.Count, res.HeadSHA256[:12])
+}
+
// defaultGrantTTL is how long a scoped key lives when the operator does not
// say: a quarter. Short enough that a forgotten builder key stops working
// while someone still remembers what it was for, long enough that rotating it
diff --git a/docs/superpowers/specs/2026-08-03-open-ledgers-design.md b/docs/superpowers/specs/2026-08-03-open-ledgers-design.md
index 329ca6329..ada8a5bc9 100644
--- a/docs/superpowers/specs/2026-08-03-open-ledgers-design.md
+++ b/docs/superpowers/specs/2026-08-03-open-ledgers-design.md
@@ -56,7 +56,7 @@ two tenants can both run a `blog`).
```
ledgers/<account-sqid>/<app>/<ledger>/entries/<ts-compact>-<sha12>.json
ledgers/<account-sqid>/<app>/<ledger>/HEAD.json
-ledgers/<account-sqid>/<app>/<ledger>/blobs/<sha256>[.<ext>] (§2.4)
+ledgers/<account-sqid>/<app>/<ledger>/blobs/<sha256> (§2.4)
ledgers/<account-sqid>/<app>/ledgers.json (§4.1)
```
@@ -76,8 +76,21 @@ Entry wire shape (adopted from Eleven's stream design, one addition):
`HEAD.json`: `{"updated_at", "head_key", "head_sha256", "count"}`.
-The chain hashes canonicalized JSON (sorted keys, no insignificant
-whitespace) — same canonicalization both when writing and when verifying.
+The chain hashes the **stored entry bytes verbatim** — no canonicalization,
+at write or at verify. The writer marshals the entry once, hashes exactly
+those bytes, and stores exactly those bytes; a verifier re-hashes the bytes
+exactly as fetched and compares. This is deliberate, and it is a change from
+the design's first draft (which specified sorted-key canonical JSON both
+sides): a canonicalization bug is a hash-break bug, and every verifier in
+every language would have to reproduce the same canonical form to agree.
+Verbatim bytes make the contract trivially reimplementable — `sha256(the
+bytes you downloaded)` — and remove the whole class.
+
+The blob key carries no extension: the edge derives content type from the
+object's SHAPE, never from a suffix (`internal/edge/status.go`'s
+`ledgerShape`), because a tenant can name a blob whose sha happens to end
+`.json`.
+
The first entry has `prev_key: ""`, `prev_sha256: ""`. `schema` is new
relative to Eleven's shape and follows this repo's wire-shape rule: **all
future changes are additive**; anything written before a change must still
@@ -100,10 +113,28 @@ Contributor machines get **no bucket credentials**. `carlos ledger append`
calls the console's CLI API (the PR #25 pattern, `internal/console/apicli.go`):
```
-POST /api/cli/ledger/append {account, app, ledger, payload}
-GET /api/cli/ledger/head ?account=&app=&ledger=
+POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append {payload}
+GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head
+POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs (raw bytes)
+GET /api/cli/apps/{account}/{app}/ledgers
+POST /api/cli/apps/{account}/{app}/ledgers/publish {public, ledgers, title}
```
+The account/app pair is in the PATH, not the body — the shape every other
+CLI API route in `internal/console/apicli.go` already uses, so
+`apiAccountApp` is the one gate and the byte-identical 404 comes for free.
+Consequence: **`publish` and `head` are effectively reserved ledger names**,
+being literal segments in this URL space (Go 1.22's mux prefers a literal
+over a `{ledger}` wildcard). Both remain valid ledger names and are writable
+and readable at their own sub-routes; no new route may ever be added where
+the two would collide.
+
+`publish` is **additive per ledger**: it adds the named ledgers to the app's
+published set and leaves the rest alone, and `-public=false` removes the
+named ones, flipping `public` off only when the set empties (§4.1). The
+response is the RESULTING manifest, because after a read-modify-write only
+the server can say what is public now.
+
Auth is the same session/token the CLI already holds for ship/promote;
account/app resolution goes through the existing `apiAccountApp` check —
one auth+membership gate, byte-identical 404 for "doesn't exist" and "not
@@ -137,8 +168,8 @@ repo tamper-evidence mechanism — the chain *is* the tamper evidence.
carlos ledger append -app <a> [-account <acct>] <ledger> <entry.json|->
carlos ledger blob -app <a> <ledger> <file>...
carlos ledger head -app <a> <ledger>
-carlos ledger list [-app <a>]
-carlos ledger verify -app <a> <ledger> # walks the chain, re-hashes
+carlos ledger list -app <a> # v1: -app required
+carlos ledger verify -app <a> -account <sqid> -url <base> <ledger>
carlos ledger check -app <a> # freshness/owed report, §7
```
@@ -147,14 +178,28 @@ base URL (`-url https://status.carlosframework.com`) so **anyone** can verify
a published chain without CARLOS credentials — an open ledger you cannot
independently verify is a press release.
+**v1 narrowing (plan-sanctioned):** `verify` is `-url`-only — there is no
+bucket-reading mode, matching §2.3's "no credentials in a terminal", and its
+`-account` takes a SQID because the public path is keyed by sqid with no
+console to resolve a name against. `list` requires `-app`; the
+account-wide listing is deferred. `check` (§7) is not in the first slice.
+Verification also pins the walk to the ledger under test: every entry must
+name that ledger and every key followed must live under its own `entries/`
+prefix, so a chain cannot graft another ledger's history onto itself and
+still verify clean.
+
### 2.6 Serving
The edge's `Kind=="status"` route (`internal/edge/status.go`) currently
serves a fixed allowlisted key→path map from the `status/` prefix with a 60s
cache and stale-serves-while-store-hiccups. It gains one prefix rule:
`GET /ledgers/<sqid>/<app>/...` maps to the same bucket key **iff** the
-ledger opt-in manifest (§4.1) marks that app public — checked from a cached
-copy on the same 60s tick, fail-closed (no manifest → 404). JSON and blobs
+ledger opt-in manifest (§4.1) marks that app public AND, for everything
+below the app level, names that ledger in its `ledgers` array — the array is
+the per-ledger allowlist, not merely an index, because an account accrues
+chains privately and publishing is per ledger by name (an empty array
+publishes nothing but `ledgers.json` itself). Checked from a cached copy on
+the same 60s tick, fail-closed (no manifest → 404). JSON and blobs
only, plus the static ledger pages (§4.3) which ship inside the status
site's existing page set. No new timers, no publish train: an appended entry
is publicly readable on the next cache tick. The fleet renderer's lease and
@@ -279,13 +324,27 @@ explicit choice, recorded where the publisher reads. A per-app manifest at
{"schema": 1, "public": true, "ledgers": ["carbon", "changelog"], "title": "Kass"}
```
-written via `carlos ledger publish -app <a>` (and unpublished by flipping
-`public`). No manifest, or `public: false` → the edge serves nothing for
+written via `carlos ledger publish -app <a> <ledger>...`, which is
+**additive**: the named ledgers join the set, the rest stay as they were, and
+an omitted `-title` keeps the stored title. `-public=false <ledger>...`
+removes just those ledgers, flipping `public` off only when the set empties.
+The manifest is a set accrued across separate publish calls, so the write is
+a read-modify-write with CAS on the manifest's etag — two contributors
+publishing two ledgers at once end with both published, not with whichever
+was slower. No manifest, or `public: false` → the edge serves nothing for
that app (§2.6), and writes still work (an account can accrue privately
before going public — but §6's rule stands: once public, history is public;
going private again stops serving but does not unpublish the versioned
bucket's past).
+**Unpublishing is not a recall.** Flipping `public` off stops the edge
+serving within ≤60s (one cache tick), but every entry and blob already
+fetched went out with `Cache-Control: public, max-age=31536000, immutable` —
+browsers and CDNs may hold it for a year, and anyone who walked the chain
+has the bytes. Publication of chain content is effectively irreversible;
+§6's posture ("over-withholding is recoverable; publication is not") is the
+operative rule, and `public: false` is a tap, not an eraser.
+
### 4.2 Authz
Member-level, via the console API only (§2.3). The `ops` account's ledgers
diff --git a/internal/console/apiledger.go b/internal/console/apiledger.go
new file mode 100644
index 000000000..d6f32a48e
--- /dev/null
+++ b/internal/console/apiledger.go
@@ -0,0 +1,381 @@
+// The CLI's ledger API: the ONLY write path to ledgers/ from outside a
+// box. Contributor machines hold no bucket credentials — the console
+// performs the CAS append server-side and stamps the writer from the
+// authenticated identity. Member-level, same gate as promote: publishing
+// an app's ledger is ordinary member work.
+//
+// Every route here begins with apiAccountApp, so an unknown account and one
+// the caller doesn't belong to are the same 404 on the WRITE doors too, and
+// none of them consult the manifest's public flag — opt-in gates serving,
+// not writing (spec §2.3).
+//
+// `publish` and `head` are effectively reserved ledger names: they are
+// literal path segments in this API's URL space, and Go 1.22's mux prefers a
+// literal segment over a {ledger} wildcard. ValidName allows both, so a
+// ledger so named can still be written and read at its own sub-routes, but
+// nothing in this API should ever grow a route where the two would collide.
+// Acceptable: they are our own API's words.
+//
+// Spec: docs/superpowers/specs/2026-08-03-open-ledgers-design.md §2.3.
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/ledger"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ledgerStoreTimeout bounds every store round trip these handlers make, the
+// same 15s its siblings in apicli.go use: a wedged bucket must cost one
+// request 15 seconds, not a console goroutine forever. Each handler derives
+// its own context from the request's, so a client that hangs up still
+// cancels immediately — this only adds a ceiling.
+const ledgerStoreTimeout = 15 * time.Second
+
+// ledgerWriteAllowed is the write budget every ledger WRITE door passes, in
+// the same shape and with the same key as promote/rollback/new-account: the
+// principal a write budget belongs to is the PERSON, not the terminal they
+// happen to be typing on. The two GET doors below are unlimited, matching
+// their read-only siblings (channels, apps).
+func (s *Server) ledgerWriteAllowed(w http.ResponseWriter, addr string) bool {
+ if !s.cli.write.allow(addr, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return false
+ }
+ return true
+}
+
+// ledgerStoreError answers a failure that is NOT the caller's fault. Two
+// things matter here. First, contention is retry-worthy and a broken bucket
+// is not, so they must not share a status: a 503 tells a CLI to try again,
+// a 500 tells it to stop. Second, the store's own error text — bucket names,
+// key paths, AWS request ids — never reaches the client; it goes to the log,
+// where the operator who can act on it will see it.
+func (s *Server) ledgerStoreError(w http.ResponseWriter, what string, err error) {
+ log.Printf("console: %s: %v", what, err)
+ if errors.Is(err, ledger.ErrBusy) || errors.Is(err, release.ErrCASConflict) {
+ jsonError(w, http.StatusServiceUnavailable,
+ "the ledger is busy right now — try again in a moment")
+ return
+ }
+ jsonError(w, http.StatusInternalServerError, "could not reach the ledger store")
+}
+
+func (s *Server) handleAPILedgerAppend(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.ledgerWriteAllowed(w, addr) {
+ return
+ }
+ name := r.PathValue("ledger")
+ if !ledger.ValidName(name) {
+ jsonError(w, http.StatusBadRequest, "invalid ledger name")
+ return
+ }
+ // Only the payload is read off the wire: there is no field on this
+ // shape by which a caller could name a writer other than themselves.
+ var in struct {
+ Payload json.RawMessage `json:"payload"`
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, ledger.MaxPayload+8<<10)
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ var tooBig *http.MaxBytesError
+ if errors.As(err, &tooBig) {
+ jsonError(w, http.StatusRequestEntityTooLarge, "payload exceeds the 64 KiB cap")
+ return
+ }
+ jsonError(w, http.StatusBadRequest, "malformed body — want {\"payload\": ...}")
+ return
+ }
+ if len(in.Payload) == 0 {
+ jsonError(w, http.StatusBadRequest, "missing payload")
+ return
+ }
+ // The cap, checked HERE so an over-cap payload is the caller's 413 and
+ // not swept into ledger.Append's generic error return below. A body
+ // under MaxBytesReader's allowance can still carry an over-cap payload:
+ // the reader bounds the request, this bounds the entry.
+ if len(in.Payload) > ledger.MaxPayload {
+ jsonError(w, http.StatusRequestEntityTooLarge, "payload exceeds the 64 KiB cap")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout)
+ defer cancel()
+ a, err := ledger.Append(ctx, s.cfg.Releases, acct.Sqid, app, name, addr,
+ in.Payload, time.Now)
+ if err != nil {
+ // Every caller-caused failure has been answered above — an invalid
+ // name, a payload over the cap, a body that isn't JSON. What's left
+ // is contention or a broken store, neither of which is the caller's
+ // fault and neither of which they should read raw.
+ s.ledgerStoreError(w, fmt.Sprintf("ledger append %s/%s/%s", acct.Sqid, app, name), err)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(a)
+}
+
+func (s *Server) handleAPILedgerHead(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ name := r.PathValue("ledger")
+ if !ledger.ValidName(name) {
+ jsonError(w, http.StatusBadRequest, "invalid ledger name")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout)
+ defer cancel()
+ b, _, err := s.cfg.Releases.Get(ctx, ledger.HeadKey(acct.Sqid, app, name))
+ if errors.Is(err, os.ErrNotExist) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ s.ledgerStoreError(w, fmt.Sprintf("ledger head %s/%s/%s", acct.Sqid, app, name), err)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(b)
+}
+
+// handleAPILedgerBlob takes raw bytes. It is rate-limited on the write
+// budget and hard-bounded at 4 MiB by MaxBytesReader, and deliberately does
+// NOT take ship's semaphore: a blob is 64× smaller than a ship's cap, so a
+// bounded 4 MiB read is not the unbounded-buffer hazard shipSem exists for,
+// and sharing that two-slot semaphore would let blob traffic starve the
+// deploy path.
+func (s *Server) handleAPILedgerBlob(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.ledgerWriteAllowed(w, addr) {
+ return
+ }
+ name := r.PathValue("ledger")
+ if !ledger.ValidName(name) {
+ jsonError(w, http.StatusBadRequest, "invalid ledger name")
+ return
+ }
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, ledger.MaxBlob))
+ if err != nil {
+ var tooBig *http.MaxBytesError
+ if errors.As(err, &tooBig) {
+ jsonError(w, http.StatusRequestEntityTooLarge, "blob exceeds the 4 MiB cap")
+ return
+ }
+ jsonError(w, http.StatusBadRequest, "could not read the request body")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout)
+ defer cancel()
+ sha, err := ledger.PutBlob(ctx, s.cfg.Releases, acct.Sqid, app, name, body)
+ if err != nil {
+ s.ledgerStoreError(w, fmt.Sprintf("ledger blob %s/%s/%s", acct.Sqid, app, name), err)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"sha256": sha})
+}
+
+func (s *Server) handleAPILedgerList(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout)
+ defer cancel()
+ m, err := ledger.ReadManifest(ctx, s.cfg.Releases, acct.Sqid, app)
+ var manifest *ledger.Manifest
+ switch {
+ case err == nil:
+ manifest = &m
+ case errors.Is(err, os.ErrNotExist):
+ // no manifest yet — null in the response
+ default:
+ s.ledgerStoreError(w, fmt.Sprintf("ledger manifest %s/%s", acct.Sqid, app), err)
+ return
+ }
+ // The ledger names are the immediate child directories of the app's
+ // prefix — one delimiter-aware listing, whose cost is the number of
+ // chains rather than the total length of their histories. A flat List
+ // here would walk every entry and every blob ever written under this
+ // app just to learn a handful of names. Blobs live INSIDE a chain's
+ // directory, so the children are exactly the chain names; ledgers.json
+ // is a file at this level, and ListDirs never returns files.
+ prefix := "ledgers/" + acct.Sqid + "/" + app + "/"
+ dirs, err := s.cfg.Releases.ListDirs(ctx, prefix)
+ if err != nil {
+ s.ledgerStoreError(w, fmt.Sprintf("ledger list %s/%s", acct.Sqid, app), err)
+ return
+ }
+ var names []string
+ for _, d := range dirs {
+ n := strings.TrimSuffix(strings.TrimPrefix(d, prefix), "/")
+ if n == "" {
+ continue
+ }
+ names = append(names, n)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"manifest": manifest, "ledgers": names})
+}
+
+// maxLedgerTitle bounds Manifest.Title, the one member-supplied string on
+// this API that is served verbatim to the public (ledgers.json, and from
+// there whatever page renders it). 120 bytes is a display title's worth; the
+// cap is here, at the write door, because that is the only place a bound
+// holds for every reader that will ever fetch the object.
+const maxLedgerTitle = 120
+
+// maxLedgerCount bounds Manifest.Ledgers. Publishing is additive
+// read-modify-write, which removed the implicit bound the 8 KiB request-body
+// cap used to give when a publish stored the request's manifest wholesale:
+// names now ACCUMULATE across calls, and ledgers.json is member-supplied
+// content that is served publicly at the edge, held in the edge's in-memory
+// cache, and scanned linearly on every single ledger request (the allowlist
+// gate). 64 published chains per app is far past any real app and keeps that
+// per-request scan trivially short. Like the title's cap, it lives at the
+// write door, because that is the only place a bound holds for every reader
+// that will ever fetch the object.
+const maxLedgerCount = 64
+
+// errTooManyLedgers is how the publish apply below REFUSES from inside
+// ledger.UpdateManifest's CAS loop: the cap can only be judged against the
+// stored set, which the loop may re-read, so the check has to live where the
+// decision is made and travel back out as an error the handler recognises.
+// Its text is the client-facing message — it names the cap and nothing else,
+// so there is no path by which store internals reach a caller through it.
+var errTooManyLedgers = fmt.Errorf("too many public ledgers (max %d)", maxLedgerCount)
+
+// handleAPILedgerPublish is READ-MODIFY-WRITE, per ledger. The manifest is a
+// SET of published ledgers accrued across separate publish calls, and the
+// edge reads that set as an ALLOWLIST (internal/edge/status.go) — so a
+// handler that stored the request's manifest wholesale would silently
+// unpublish every chain the caller didn't name in this one call, while
+// answering success. Hence:
+//
+// - public:true — add the named ledgers to the stored set (order kept, no
+// duplicates), set public, and replace the title only if the request
+// carries one. Publishing a second chain is not a request to erase the
+// app's title.
+// - public:false — remove the named ledgers. public flips off only when
+// the set empties, so unpublishing one of two chains leaves the other
+// serving. The title survives: unpublishing is not forgetting.
+//
+// The CAS is ledger.UpdateManifest's, which re-reads and re-applies on a
+// lost race rather than re-putting a stale copy — two contributors
+// publishing two chains at once must end with both published.
+//
+// The response is the RESULTING manifest, not an empty ack: the caller's
+// request said what to change, and only the answer can say what is public
+// now.
+func (s *Server) handleAPILedgerPublish(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.ledgerWriteAllowed(w, addr) {
+ return
+ }
+ var in ledger.Manifest
+ r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ jsonError(w, http.StatusBadRequest, "malformed manifest")
+ return
+ }
+ for _, n := range in.Ledgers {
+ if !ledger.ValidName(n) {
+ jsonError(w, http.StatusBadRequest, "invalid ledger name "+n)
+ return
+ }
+ }
+ title := strings.TrimSpace(in.Title)
+ if len(title) > maxLedgerTitle {
+ jsonError(w, http.StatusBadRequest,
+ fmt.Sprintf("title too long (max %d bytes)", maxLedgerTitle))
+ return
+ }
+
+ // An unpublish against an app with no manifest is a no-op success: there
+ // is nothing to take private, and conjuring a public:false manifest for
+ // an app that never published one would be a write with no meaning.
+ ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout)
+ defer cancel()
+ if !in.Public {
+ if _, err := ledger.ReadManifest(ctx, s.cfg.Releases, acct.Sqid, app); errors.Is(err, os.ErrNotExist) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(ledger.Manifest{})
+ return
+ }
+ }
+
+ m, err := ledger.UpdateManifest(ctx, s.cfg.Releases, acct.Sqid, app, func(m *ledger.Manifest) error {
+ if in.Public {
+ m.Public = true
+ for _, n := range in.Ledgers {
+ if !slices.Contains(m.Ledgers, n) {
+ m.Ledgers = append(m.Ledgers, n)
+ }
+ }
+ // The cap is checked HERE, on the manifest this attempt read, and
+ // on the RESULT — not on the request. Only the stored set knows
+ // how many names are already there, and UpdateManifest re-reads
+ // and re-applies on a lost race, so a check made once outside
+ // this closure could be stepped over by two publishers racing.
+ // Refusing from inside aborts before the Put: nothing is written.
+ if len(m.Ledgers) > maxLedgerCount {
+ return errTooManyLedgers
+ }
+ if title != "" {
+ m.Title = title
+ }
+ return nil
+ }
+ m.Ledgers = slices.DeleteFunc(m.Ledgers, func(n string) bool {
+ return slices.Contains(in.Ledgers, n)
+ })
+ if len(m.Ledgers) == 0 {
+ m.Public = false
+ }
+ return nil
+ })
+ // A refusal is the CALLER's fault and must not be laundered through
+ // ledgerStoreError, which would log it and answer 500: this is the one
+ // error out of UpdateManifest that a client can act on, and the only one
+ // whose text is ours to show.
+ if errors.Is(err, errTooManyLedgers) {
+ jsonError(w, http.StatusBadRequest, errTooManyLedgers.Error())
+ return
+ }
+ if err != nil {
+ s.ledgerStoreError(w, fmt.Sprintf("ledger publish %s/%s", acct.Sqid, app), err)
+ return
+ }
+ // Flipping an app's ledgers public is exactly the kind of act the audit
+ // trail exists for, and the ONLY one on this API that changes what the
+ // world can see — the same trail promote, rollback and ship write to.
+ // The line records the RESULTING state, which is what a reader of the
+ // trail needs to know; the request alone would not say it.
+ s.audit(acct, app, addr, "ledger-publish",
+ "set ledgers public="+strconv.FormatBool(m.Public)+
+ " ["+strings.Join(m.Ledgers, " ")+"] (cli)")
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(m)
+}
diff --git a/internal/console/apiledger_test.go b/internal/console/apiledger_test.go
new file mode 100644
index 000000000..bdd218192
--- /dev/null
+++ b/internal/console/apiledger_test.go
@@ -0,0 +1,728 @@
+package console
+
+// The ledger CLI API (apiledger.go): the ONLY write path to ledgers/ from
+// outside a box. The properties that matter, in the order they'd hurt if
+// lost: the writer stamped on an entry is the AUTHENTICATED identity and
+// nothing a client sent, every route is member-gated through apiAccountApp
+// (so an unknown account and someone else's are the identical 404), and the
+// caps — 64 KiB payload, 4 MiB blob, a validated ledger name — are enforced
+// before anything touches the store.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/ledger"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// postBody is postJSON's raw-body cousin: these tests send bodies that are
+// deliberately not round-trippable through json.Marshal (an oversize
+// payload, a body carrying a field the server must ignore), and read the
+// response as bytes so it can be decoded into a typed struct.
+func postBody(t *testing.T, u, bearer, body string) (int, []byte) {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodPost, u, strings.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ out, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return res.StatusCode, out
+}
+
+// ledgerFixture is a console with titogo claimed in the ops account and a
+// MEMBER (not owner) token for it — member-level is exactly the gate this
+// API grants, the same one promote uses.
+func ledgerFixture(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "alice@keymail.dev", code)
+}
+
+// TestAPILedgerAppendAndHead: an append round-trips through HEAD, and the
+// writer on the stored entry is the token's identity — never anything the
+// client put in the body (the request below tries to set one).
+func TestAPILedgerAppendAndHead(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-append")
+
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token,
+ `{"payload":{"kg_central":1},"writer":"attacker@example.com"}`)
+ if status != http.StatusOK {
+ t.Fatalf("append = %d %s", status, out)
+ }
+ var a ledger.Appended
+ if err := json.Unmarshal(out, &a); err != nil {
+ t.Fatalf("append body %s: %v", out, err)
+ }
+ if a.Count != 1 || a.Key == "" || a.SHA256 == "" {
+ t.Fatalf("append = %+v, want count 1 with a key and sha", a)
+ }
+
+ status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/head", token)
+ if status != http.StatusOK {
+ t.Fatalf("head = %d %s", status, body)
+ }
+ var h ledger.Head
+ if err := json.Unmarshal([]byte(body), &h); err != nil {
+ t.Fatalf("head body %s: %v", body, err)
+ }
+ if h.HeadKey != a.Key || h.HeadSHA256 != a.SHA256 || h.Count != 1 {
+ t.Fatalf("head = %+v, want it to name the appended entry %+v", h, a)
+ }
+
+ // The writer is the token's identity, stamped server-side. The body's
+ // own "writer" field must have gone nowhere.
+ eb, _, err := fx.rel.Get(context.Background(), a.Key)
+ if err != nil {
+ t.Fatalf("stored entry %s: %v", a.Key, err)
+ }
+ var e ledger.Entry
+ if err := json.Unmarshal(eb, &e); err != nil {
+ t.Fatal(err)
+ }
+ if e.Writer != "alice@keymail.dev" {
+ t.Fatalf("entry writer = %q, want the authenticated identity alice@keymail.dev (%+v)", e.Writer, e)
+ }
+ if bytes.Contains(eb, []byte("attacker@example.com")) {
+ t.Fatalf("stored entry carries a client-supplied writer: %s", eb)
+ }
+}
+
+// TestAPILedgerAuthz: no token is the same 401 every CLI door gives, and a
+// non-member — whether the account is one they don't belong to or an app it
+// never claimed — gets the byte-identical 404 apiAccountApp already
+// produces, on the WRITE routes too.
+func TestAPILedgerAuthz(t *testing.T) {
+ fx, _ := ledgerFixture(t, "code-ledger-authz")
+
+ status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", "",
+ `{"payload":1}`)
+ if status != http.StatusUnauthorized {
+ t.Fatalf("append with no token = %d, want 401", status)
+ }
+
+ // (a) member of nothing, probing a real account+app.
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-ledger-eve")
+ statusA, bodyA := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", eve,
+ `{"payload":1}`)
+ if statusA != http.StatusNotFound {
+ t.Fatalf("append as non-member = %d, want 404", statusA)
+ }
+ // (b) member of bab, but an app it never claimed.
+ if err := fx.st.AddMember(OpsAccountID, "frank@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ frank := cliToken(t, fx, "frank@keymail.dev", "code-ledger-frank")
+ statusB, bodyB := postBody(t, fx.ts.URL+"/api/cli/apps/bab/ghost-app/ledgers/carbon/append", frank,
+ `{"payload":1}`)
+ if statusB != http.StatusNotFound {
+ t.Fatalf("append against an unclaimed app = %d, want 404", statusB)
+ }
+ if !bytes.Equal(bodyA, bodyB) {
+ t.Fatalf("the two refusals are not byte-identical:\n(a) %q\n(b) %q", bodyA, bodyB)
+ }
+ if string(bodyA) != "404 page not found\n" {
+ t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", bodyA)
+ }
+
+ // Nothing was written by any of the refused calls.
+ if _, _, err := fx.rel.Get(context.Background(), ledger.HeadKey("bab", "titogo", "carbon")); err == nil {
+ t.Fatalf("a ledger HEAD exists after only refused appends, want none")
+ }
+
+ // The read routes refuse identically.
+ if st, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", eve); st != http.StatusNotFound || body != "404 page not found\n" {
+ t.Fatalf("list as non-member = %d %q, want the identical 404", st, body)
+ }
+ if st, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/head", eve); st != http.StatusNotFound || body != "404 page not found\n" {
+ t.Fatalf("head as non-member = %d %q, want the identical 404", st, body)
+ }
+}
+
+// TestAPILedgerRejects: a name that isn't a ledger name, and a payload over
+// the 64 KiB cap, are both refused before anything lands in the store.
+func TestAPILedgerRejects(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-rejects")
+
+ // Bad ledger name in the path — uppercase and a space.
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/Not%20Valid/append", token,
+ `{"payload":1}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("bad ledger name = %d %s, want 400", status, out)
+ }
+ // A traversal attempt in the name segment, which would otherwise become
+ // bucket key segments.
+ status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/..%2f..%2fetc/append", token,
+ `{"payload":1}`)
+ if status != http.StatusBadRequest && status != http.StatusNotFound {
+ t.Fatalf("traversal ledger name = %d %s, want a refusal", status, out)
+ }
+
+ // Oversized payload: over the cap, but under MaxBytesReader's limit, so
+ // this is the handler's own arithmetic refusing it, not the reader's.
+ big := `{"payload":"` + strings.Repeat("x", ledger.MaxPayload) + `"}`
+ status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, big)
+ if status != http.StatusBadRequest && status != http.StatusRequestEntityTooLarge {
+ t.Fatalf("oversize payload = %d %s, want 400 or 413", status, out)
+ }
+ // Far oversized: MaxBytesReader cuts this one off.
+ huge := `{"payload":"` + strings.Repeat("x", ledger.MaxPayload+64<<10) + `"}`
+ status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, huge)
+ if status != http.StatusBadRequest && status != http.StatusRequestEntityTooLarge {
+ t.Fatalf("far-oversize payload = %d %s, want 400 or 413", status, out)
+ }
+ // A body with no payload at all.
+ status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, `{}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("missing payload = %d %s, want 400", status, out)
+ }
+
+ // None of the refusals created a chain.
+ if _, _, err := fx.rel.Get(context.Background(), ledger.HeadKey("bab", "titogo", "carbon")); err == nil {
+ t.Fatalf("carbon HEAD exists after only refused appends, want none")
+ }
+}
+
+// TestAPILedgerPublishAndList: the opt-in manifest round-trips, and the
+// listing names both the manifest and the ledgers that actually exist —
+// derived from the HEAD keys in the bucket, not from the manifest.
+func TestAPILedgerPublishAndList(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-publish")
+
+ // A write happens with NO manifest present: opt-in gates serving, not
+ // writing (spec §2.3).
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token,
+ `{"payload":{"n":1}}`); status != http.StatusOK {
+ t.Fatalf("append before any publish = %d %s, want 200 (opt-in gates serving, not writing)", status, out)
+ }
+
+ // Before publishing, the manifest reads back null and the ledger is
+ // still listed.
+ status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token)
+ if status != http.StatusOK {
+ t.Fatalf("list before publish = %d %s", status, body)
+ }
+ var pre struct {
+ Manifest *ledger.Manifest `json:"manifest"`
+ Ledgers []string `json:"ledgers"`
+ }
+ if err := json.Unmarshal([]byte(body), &pre); err != nil {
+ t.Fatalf("list body %s: %v", body, err)
+ }
+ if pre.Manifest != nil {
+ t.Fatalf("manifest before publish = %+v, want null", pre.Manifest)
+ }
+ if len(pre.Ledgers) != 1 || pre.Ledgers[0] != "carbon" {
+ t.Fatalf("ledgers before publish = %v, want [carbon]", pre.Ledgers)
+ }
+
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token,
+ `{"public":true,"ledgers":["carbon"],"title":"Titogo"}`)
+ if status != http.StatusOK {
+ t.Fatalf("publish = %d %s", status, out)
+ }
+
+ // A second entry and a blob under the same chain: the listing is
+ // delimiter-aware, so neither the entries/ nor the blobs/ directory may
+ // surface as a ledger name, however much history accrues.
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token,
+ `{"payload":{"n":2}}`); status != http.StatusOK {
+ t.Fatalf("second append = %d %s", status, out)
+ }
+ if _, err := fx.rel.Put(context.Background(),
+ ledger.BlobKey("bab", "titogo", "carbon", strings.Repeat("a", 64)), []byte("x"), ""); err != nil {
+ t.Fatal(err)
+ }
+
+ status, body = getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token)
+ if status != http.StatusOK || !strings.Contains(body, `"carbon"`) {
+ t.Fatalf("list = %d %s, want carbon among the ledgers", status, body)
+ }
+ var post struct {
+ Manifest *ledger.Manifest `json:"manifest"`
+ Ledgers []string `json:"ledgers"`
+ }
+ if err := json.Unmarshal([]byte(body), &post); err != nil {
+ t.Fatalf("list body %s: %v", body, err)
+ }
+ if post.Manifest == nil || !post.Manifest.Public || post.Manifest.Title != "Titogo" ||
+ len(post.Manifest.Ledgers) != 1 || post.Manifest.Ledgers[0] != "carbon" {
+ t.Fatalf("manifest after publish = %+v, want the published one", post.Manifest)
+ }
+ if post.Manifest.Schema != ledger.Schema {
+ t.Fatalf("manifest schema = %d, want %d (stamped server-side)", post.Manifest.Schema, ledger.Schema)
+ }
+ if len(post.Ledgers) != 1 || post.Ledgers[0] != "carbon" {
+ t.Fatalf("ledgers = %v, want exactly [carbon] — entries/ and blobs/ are not ledgers", post.Ledgers)
+ }
+
+ // Publishing is a visibility change, so it leaves an audit line naming
+ // who did it, the flag, and the ledgers — the same trail promote,
+ // rollback and ship write to.
+ events, err := fx.st.Events(OpsAccountID, "titogo", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var pub *Event
+ for i := range events {
+ if events[i].Action == "ledger-publish" {
+ pub = &events[i]
+ }
+ }
+ if pub == nil {
+ t.Fatalf("events = %v, want a ledger-publish row", events)
+ }
+ if pub.Address != "alice@keymail.dev" {
+ t.Fatalf("ledger-publish address = %q, want the authenticated identity", pub.Address)
+ }
+ if !strings.Contains(pub.Detail, "public=true") || !strings.Contains(pub.Detail, "carbon") ||
+ !strings.Contains(pub.Detail, "(cli)") {
+ t.Fatalf("ledger-publish detail = %q, want the flag, the ledgers and the CLI marker", pub.Detail)
+ }
+
+ // A manifest naming a ledger that isn't a ledger name is refused.
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token,
+ `{"public":true,"ledgers":["Not Valid"]}`); status != http.StatusBadRequest {
+ t.Fatalf("publish with a bad ledger name = %d %s, want 400", status, out)
+ }
+}
+
+// publishLedgers posts one publish call and decodes the RESULTING manifest
+// the handler answers with — the shape a CLI needs in order to tell the user
+// what is public now rather than what they just asked for.
+func publishLedgers(t *testing.T, fx *consoleFixture, token, body string) (int, ledger.Manifest) {
+ t.Helper()
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, body)
+ var m ledger.Manifest
+ if status == http.StatusOK {
+ if err := json.Unmarshal(out, &m); err != nil {
+ t.Fatalf("publish body %s: %v", out, err)
+ }
+ }
+ return status, m
+}
+
+// TestAPILedgerPublishIsAdditive is the defect this handler was written
+// around: an app's manifest is a SET of published ledgers accrued over
+// separate publish calls, and the edge reads that set as a per-ledger
+// ALLOWLIST. A publish that replaced the whole manifest would silently
+// unpublish every ledger the caller didn't name in this one call — while the
+// CLI printed a success line — and would make publishing two ledgers
+// impossible one call at a time.
+func TestAPILedgerPublishIsAdditive(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-additive")
+
+ status, m := publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"],"title":"Titogo"}`)
+ if status != http.StatusOK {
+ t.Fatalf("first publish = %d", status)
+ }
+ if !m.Public || len(m.Ledgers) != 1 || m.Ledgers[0] != "carbon" || m.Title != "Titogo" {
+ t.Fatalf("first publish = %+v", m)
+ }
+
+ // Publishing a SECOND ledger keeps the first, and an omitted title keeps
+ // the stored one (a caller publishing one more chain is not asking to
+ // erase the app's title).
+ status, m = publishLedgers(t, fx, token, `{"public":true,"ledgers":["prompts"]}`)
+ if status != http.StatusOK {
+ t.Fatalf("second publish = %d", status)
+ }
+ if len(m.Ledgers) != 2 || m.Ledgers[0] != "carbon" || m.Ledgers[1] != "prompts" {
+ t.Fatalf("second publish = %+v, want [carbon prompts] in order", m.Ledgers)
+ }
+ if m.Title != "Titogo" {
+ t.Fatalf("title = %q, want the stored title preserved when the request omits one", m.Title)
+ }
+
+ // Re-publishing one that is already public is idempotent — no duplicate.
+ if _, m = publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"]}`); len(m.Ledgers) != 2 {
+ t.Fatalf("re-publish = %+v, want no duplicate", m.Ledgers)
+ }
+ // A non-empty title replaces the stored one.
+ if _, m = publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"],"title":"Tito"}`); m.Title != "Tito" {
+ t.Fatalf("title = %q, want the new one", m.Title)
+ }
+
+ // The store agrees with what the handler reported.
+ stored, err := ledger.ReadManifest(context.Background(), fx.rel, "bab", "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(stored.Ledgers) != 2 || stored.Title != "Tito" || !stored.Public {
+ t.Fatalf("stored manifest = %+v, want it to match the reported state", stored)
+ }
+}
+
+// TestAPILedgerUnpublishRemovesOne: unpublish is the mirror — it removes the
+// named ledgers and flips public off ONLY when nothing is left. Unpublishing
+// one of two chains must not take the other one dark.
+func TestAPILedgerUnpublishRemovesOne(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-unpublish")
+
+ if status, _ := publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon","prompts"],"title":"T"}`); status != http.StatusOK {
+ t.Fatalf("publish = %d", status)
+ }
+
+ status, m := publishLedgers(t, fx, token, `{"public":false,"ledgers":["carbon"]}`)
+ if status != http.StatusOK {
+ t.Fatalf("unpublish = %d", status)
+ }
+ if !m.Public {
+ t.Fatalf("unpublish of one of two = %+v, want public still true", m)
+ }
+ if len(m.Ledgers) != 1 || m.Ledgers[0] != "prompts" {
+ t.Fatalf("unpublish left %v, want [prompts]", m.Ledgers)
+ }
+
+ // Unpublishing the last one empties the list AND flips public off, so
+ // the app's own ledgers.json index stops serving too.
+ status, m = publishLedgers(t, fx, token, `{"public":false,"ledgers":["prompts"]}`)
+ if status != http.StatusOK {
+ t.Fatalf("final unpublish = %d", status)
+ }
+ if m.Public || len(m.Ledgers) != 0 {
+ t.Fatalf("final unpublish = %+v, want public:false and an empty list", m)
+ }
+ if m.Title != "T" {
+ t.Fatalf("title = %q, want it kept — unpublishing is not forgetting", m.Title)
+ }
+}
+
+// TestAPILedgerUnpublishWithoutManifest: unpublishing an app that never
+// published is a no-op success, not a 404 and not a manifest conjured out of
+// nothing.
+func TestAPILedgerUnpublishWithoutManifest(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-unpublish-none")
+
+ status, m := publishLedgers(t, fx, token, `{"public":false,"ledgers":["carbon"]}`)
+ if status != http.StatusOK {
+ t.Fatalf("unpublish with no manifest = %d, want 200", status)
+ }
+ if m.Public || len(m.Ledgers) != 0 {
+ t.Fatalf("= %+v, want the zero manifest", m)
+ }
+ if _, _, err := fx.rel.Get(context.Background(), ledger.ManifestKey("bab", "titogo")); err == nil {
+ t.Fatal("a no-op unpublish wrote a manifest, want none")
+ }
+}
+
+// TestAPILedgerPublishTitleCap: the title is member-supplied text served
+// publicly at ledgers.json, so it is bounded at the write door — the same
+// place every other public-facing input on this API is bounded.
+func TestAPILedgerPublishTitleCap(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-title")
+
+ long := `{"public":true,"ledgers":["carbon"],"title":"` + strings.Repeat("t", 121) + `"}`
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, long)
+ if status != http.StatusBadRequest {
+ t.Fatalf("oversize title = %d %s, want 400", status, out)
+ }
+ if !strings.Contains(string(out), "title too long") {
+ t.Fatalf("refusal = %s, want it to name the cap", out)
+ }
+ if _, _, err := fx.rel.Get(context.Background(), ledger.ManifestKey("bab", "titogo")); err == nil {
+ t.Fatal("a refused publish wrote a manifest")
+ }
+
+ // Exactly at the cap is fine, and surrounding whitespace is trimmed
+ // rather than counted.
+ at := strings.Repeat("t", 120)
+ status, m := publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"],"title":" `+at+` "}`)
+ if status != http.StatusOK {
+ t.Fatalf("title at the cap = %d, want 200", status)
+ }
+ if m.Title != at {
+ t.Fatalf("title = %q, want it trimmed to the %d-byte value", m.Title, len(at))
+ }
+}
+
+// TestAPILedgerPublishCountCap: the manifest became read-modify-write, which
+// removed the implicit bound the 8 KiB request cap used to give a whole-value
+// replace — names now ACCUMULATE. ledgers.json is member-supplied content
+// served publicly at the edge, held in the edge's in-memory cache and
+// linearly scanned on every ledger request, so the set is bounded at the
+// write door, on the RESULTING manifest, like every other public-facing input
+// here.
+func TestAPILedgerPublishCountCap(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-count")
+
+ names := make([]string, 0, maxLedgerCount)
+ for i := range maxLedgerCount {
+ names = append(names, fmt.Sprintf("l%d", i))
+ }
+ full, err := json.Marshal(map[string]any{"public": true, "ledgers": names, "title": "T"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ status, m := publishLedgers(t, fx, token, string(full))
+ if status != http.StatusOK {
+ t.Fatalf("publish of %d ledgers = %d, want 200 (the cap is a ceiling, not a fence below it)", maxLedgerCount, status)
+ }
+ if len(m.Ledgers) != maxLedgerCount {
+ t.Fatalf("published %d ledgers, want %d", len(m.Ledgers), maxLedgerCount)
+ }
+
+ // The one that would step over is refused as the caller's fault, and
+ // nothing new is written: not the name, not the title that rode with it.
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token,
+ `{"public":true,"ledgers":["overflow"],"title":"nope"}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("publish over the cap = %d %s, want 400", status, out)
+ }
+ if !strings.Contains(string(out), "too many public ledgers") {
+ t.Fatalf("refusal = %s, want it to name the cap", out)
+ }
+ stored, err := ledger.ReadManifest(context.Background(), fx.rel, "bab", "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(stored.Ledgers) != maxLedgerCount || slices.Contains(stored.Ledgers, "overflow") {
+ t.Fatalf("stored manifest = %+v, want the refused publish to have written nothing", stored)
+ }
+ if stored.Title != "T" {
+ t.Fatalf("title = %q, want the refused publish to have left it alone", stored.Title)
+ }
+
+ // Re-publishing one that is ALREADY on the list at the cap is fine — the
+ // bound is on the resulting set, and that set does not grow.
+ if status, m := publishLedgers(t, fx, token, `{"public":true,"ledgers":["l0"]}`); status != http.StatusOK ||
+ len(m.Ledgers) != maxLedgerCount {
+ t.Fatalf("idempotent re-publish at the cap = %d %+v, want 200 and no growth", status, m.Ledgers)
+ }
+
+ // And the way back down is never blocked: an app that hit the cap must
+ // still be able to unpublish.
+ status, m = publishLedgers(t, fx, token, `{"public":false,"ledgers":["l0"]}`)
+ if status != http.StatusOK {
+ t.Fatalf("unpublish at the cap = %d, want 200", status)
+ }
+ if len(m.Ledgers) != maxLedgerCount-1 || slices.Contains(m.Ledgers, "l0") {
+ t.Fatalf("unpublish at the cap left %v", m.Ledgers)
+ }
+}
+
+// TestAPILedgerHeadMissing: a ledger that was never written is the same
+// stdlib 404 as an app you can't see — there is nothing to distinguish.
+func TestAPILedgerHeadMissing(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-head-missing")
+
+ status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/nothing-here/head", token)
+ if status != http.StatusNotFound {
+ t.Fatalf("head of an unwritten ledger = %d %q, want 404", status, body)
+ }
+}
+
+// TestAPILedgerBlob: raw bytes upload content-addressed, the returned sha
+// is what the bytes hash to, and a re-upload of the same bytes is the same
+// answer.
+func TestAPILedgerBlob(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-blob")
+
+ req, err := http.NewRequest(http.MethodPost,
+ fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/prompts/blobs",
+ bytes.NewReader([]byte("corpus")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ raw, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("blob = %d %s", res.StatusCode, raw)
+ }
+ var out struct {
+ SHA256 string `json:"sha256"`
+ }
+ if err := json.Unmarshal(raw, &out); err != nil {
+ t.Fatalf("blob body %s: %v", raw, err)
+ }
+ want := ledger.SHA256Hex([]byte("corpus"))
+ if out.SHA256 != want {
+ t.Fatalf("blob sha256 = %q, want %q (computed from the bytes sent)", out.SHA256, want)
+ }
+ stored, _, err := fx.rel.Get(context.Background(), ledger.BlobKey("bab", "titogo", "prompts", want))
+ if err != nil {
+ t.Fatalf("stored blob: %v", err)
+ }
+ if !bytes.Equal(stored, []byte("corpus")) {
+ t.Fatalf("stored blob = %q, want the bytes sent", stored)
+ }
+
+ // A bad ledger name in the path refuses before the body is read.
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/Not%20Valid/blobs", token,
+ "corpus"); status != http.StatusBadRequest {
+ t.Fatalf("blob with a bad ledger name = %d %s, want 400", status, out)
+ }
+}
+
+// TestAPILedgerWritesAreRateLimited: every WRITE door here passes the same
+// write budget its promote/rollback/new-account siblings do, keyed on the
+// address. Without it, the one API that appends an object per call is the
+// one API with no bound on how fast a token can fill a bucket.
+func TestAPILedgerWritesAreRateLimited(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-ratelimit")
+
+ limited := false
+ for i := 0; i < cliWriteBurst+3; i++ {
+ status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token,
+ `{"payload":{"n":1}}`)
+ if status == http.StatusTooManyRequests {
+ limited = true
+ break
+ }
+ }
+ if !limited {
+ t.Fatalf("append was never rate-limited in %d attempts", cliWriteBurst+3)
+ }
+
+ // The budget is shared across the write doors — an exhausted address
+ // cannot simply switch verb. (Same limiter, same key: this is what
+ // proves publish and blobs pass it too, not just append.)
+ if status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token,
+ `{"public":true,"ledgers":["carbon"]}`); status != http.StatusTooManyRequests {
+ t.Fatalf("publish on an exhausted budget = %d, want 429", status)
+ }
+ req, err := http.NewRequest(http.MethodPost,
+ fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/prompts/blobs", bytes.NewReader([]byte("corpus")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res.Body.Close()
+ if res.StatusCode != http.StatusTooManyRequests {
+ t.Fatalf("blob on an exhausted budget = %d, want 429", res.StatusCode)
+ }
+
+ // The READ doors are unlimited, like their read-only siblings — an
+ // exhausted write budget must not blind a CLI to what it already wrote.
+ if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token); status != http.StatusOK {
+ t.Fatalf("list on an exhausted write budget = %d %s, want 200", status, body)
+ }
+ if status, _ := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/head", token); status != http.StatusOK {
+ t.Fatalf("head on an exhausted write budget = %d, want 200", status)
+ }
+}
+
+// casStore loses every HEAD race, the way a chain under more concurrent
+// writers than the retry loop can outlast behaves.
+type casStore struct{ release.Store }
+
+func (c casStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if strings.HasSuffix(key, "/HEAD.json") || strings.HasSuffix(key, "/ledgers.json") {
+ return "", release.ErrCASConflict
+ }
+ return c.Store.Put(ctx, key, body, ifMatch)
+}
+
+// brokenStore is a bucket the console cannot reach, whose error text is
+// exactly the kind that must never reach a client.
+type brokenStore struct{ release.Store }
+
+const brokenStoreErr = "AccessDenied: s3://carlos-prod-secret-bucket/ledgers request id ABC123"
+
+func (b brokenStore) Put(context.Context, string, []byte, string) (string, error) {
+ return "", errors.New(brokenStoreErr)
+}
+
+func (b brokenStore) ListDirs(context.Context, string) ([]string, error) {
+ return nil, errors.New(brokenStoreErr)
+}
+
+// TestAPILedgerStoreFailureStatuses: a failure that is not the caller's
+// fault must not be reported as if it were. Contention is retry-worthy
+// (503), a broken bucket is not (500), and neither answer may echo the
+// store's own error text — bucket names and request ids are for the log.
+func TestAPILedgerStoreFailureStatuses(t *testing.T) {
+ t.Run("contention is 503", func(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-busy")
+ fx.srv.cfg.Releases = casStore{fx.rel}
+
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token,
+ `{"payload":{"n":1}}`)
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("append under total contention = %d %s, want 503", status, out)
+ }
+ if !strings.Contains(string(out), "try again") {
+ t.Fatalf("contention error = %s, want it to invite a retry", out)
+ }
+ if strings.Contains(string(out), "CAS") || strings.Contains(string(out), "HEAD.json") {
+ t.Fatalf("contention error leaks store internals: %s", out)
+ }
+ // Publish's own lost race is the same retry-worthy answer.
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token,
+ `{"public":true,"ledgers":["carbon"]}`); status != http.StatusServiceUnavailable {
+ t.Fatalf("publish under total contention = %d %s, want 503", status, out)
+ }
+ })
+
+ t.Run("a broken bucket is 500 and says nothing", func(t *testing.T) {
+ fx, token := ledgerFixture(t, "code-ledger-broken")
+ fx.srv.cfg.Releases = brokenStore{fx.rel}
+
+ type answer struct {
+ what string
+ status int
+ body string
+ }
+ appendStatus, appendBody := postBody(t,
+ fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, `{"payload":{"n":1}}`)
+ publishStatus, publishBody := postBody(t,
+ fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, `{"public":true,"ledgers":["carbon"]}`)
+ listStatus, listBody := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token)
+ cases := []answer{
+ {"append", appendStatus, string(appendBody)},
+ {"publish", publishStatus, string(publishBody)},
+ {"list", listStatus, listBody},
+ }
+
+ for _, c := range cases {
+ if c.status != http.StatusInternalServerError {
+ t.Errorf("%s against a broken bucket = %d %s, want 500", c.what, c.status, c.body)
+ }
+ if strings.Contains(c.body, "AccessDenied") ||
+ strings.Contains(c.body, "carlos-prod-secret-bucket") ||
+ strings.Contains(c.body, "ABC123") {
+ t.Errorf("%s leaked the store's error text to the client: %s", c.what, c.body)
+ }
+ }
+ })
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 2ebb76b69..122cec00e 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -178,6 +178,15 @@ func (s *Server) registerCLIRoutes() {
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", s.handleAPIPromote)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/rollback", s.handleAPIRollback)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", s.handleAPIShip)
+ // The ledger API (apiledger.go) — the only write path to ledgers/ from
+ // outside a box. The literal `publish` segment is kept adjacent to the
+ // {ledger} wildcards for readability; the mux prefers it regardless of
+ // registration order.
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/ledgers", s.handleAPILedgerList)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/publish", s.handleAPILedgerPublish)
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head", s.handleAPILedgerHead)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append", s.handleAPILedgerAppend)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs", s.handleAPILedgerBlob)
}
// clientIP is who the rate limiter counts against.
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 8c5372eb7..1dc161356 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -318,7 +318,20 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Cache check happens before the hibernation branch below — a hit must
// never reach the activator, since avoiding pointless wakes for
// bot/crawler/repeat traffic is the entire point of this cache.
- cacheable := e.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead)
+ //
+ // Kind=="status" is exempt from the shared cache in both directions: the
+ // status host caches for itself (status.go's per-key store cache) and its
+ // /ledgers/ objects carry a long immutable Cache-Control so browsers and
+ // CDNs cache them. Admitting them HERE would be a different thing
+ // entirely — this LRU is process-global and shared across every host, and
+ // a public ledger chain is unbounded in length, so an anonymous walker
+ // could evict every other tenant's cached responses at will. An evicted
+ // entry for a hibernating route means the next request wakes the instance
+ // instead of being answered from cache, which is the invariant this cache
+ // exists to hold: the status host caches for itself; its responses never
+ // enter the shared response cache.
+ cacheable := e.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
+ up.route.Kind != "status"
var key CacheKey
if cacheable {
key = CacheKey{Host: host, Method: r.Method, Path: r.URL.Path, RawQuery: r.URL.RawQuery}
@@ -405,6 +418,10 @@ func (e *Edge) cacheMaxBody() int {
//
// The stored TTL is clamped to DefaultCacheMaxTTL so a lost purge always
// self-heals within a bounded window.
+//
+// One refusal is NOT made here because it has to be made earlier, where the
+// route is still in hand: Kind=="status" responses are never admitted at
+// all. See the cacheable computation in ServeHTTP for why.
func (e *Edge) maybeStore(key CacheKey, rec *recordingResponseWriter) {
if e.cache == nil || !rec.cacheable || rec.truncated {
return
diff --git a/internal/edge/status.go b/internal/edge/status.go
index 812602b85..f7ab59944 100644
--- a/internal/edge/status.go
+++ b/internal/edge/status.go
@@ -6,19 +6,34 @@
// publish cycle (no signed, versioned channel pointer to resolve), so there
// is no "which version" question — just "read the key, cache it briefly,
// serve it, and don't fall over when the bucket hiccups."
+//
+// The same host also publishes open ledgers (internal/ledger) under
+// /ledgers/<sqid>/<app>/..., path-for-key, gated per app by that app's
+// opt-in manifest — see serveLedger.
package edge
import (
"context"
+ "encoding/json"
+ "errors"
"log"
"net/http"
+ "os"
"path"
+ "slices"
+ "strings"
"sync"
"time"
+ "github.com/carlosframework/platform/internal/ledger"
"github.com/carlosframework/platform/internal/release"
)
+// ledgerPrefix is the one path prefix on the status host that is NOT the
+// fixed three-object menu: open ledgers (internal/ledger) are served from
+// here, path-for-key, gated per app by that app's opt-in manifest.
+const ledgerPrefix = "/ledgers/"
+
// statusCacheTTL is how long a fetched status object is trusted before the
// handler re-reads the store — mirrors staticRecheck's "trust briefly, then
// recheck" tradeoff, tuned loose because the backing data only changes every
@@ -75,23 +90,75 @@ type statusHandler struct {
mu sync.Mutex
cache map[string]statusCached
+ // misses is the negative half of the ledger gate: manifest key → when
+ // the "not servable" decision was taken. See markMiss.
+ misses map[string]time.Time
}
+// statusMissCacheMax bounds the negative cache. It exists because the
+// positive cache's bound — "get only records an entry after a SUCCESSFUL
+// store read, so only objects that exist can grow it" — is exactly the
+// property a negative cache gives up: the prober picks the account and app
+// segments, so the set of manifest keys they can name is unbounded. 1024
+// apps' worth of decisions is far more than any real deployment publishes
+// and costs a few tens of KiB; past it the map is CLEARED rather than
+// evicted one entry at a time, because there is no access pattern here worth
+// approximating with an LRU — the honest read of a full map is "this is
+// being probed", and a probe's entries are the ones worth dropping.
+const statusMissCacheMax = 1024
+
// newStatusHandler builds the handler for one status route. store is shared
// with the static serving class (see EnableStatic's doc comment) — the
// status page has no per-route config of its own (no app, no channel), so
// unlike newStaticHandler this takes no registry.Route.
func newStatusHandler(store release.Store) *statusHandler {
return &statusHandler{
- store: store,
- ttl: statusCacheTTL,
- now: time.Now,
- cache: make(map[string]statusCached),
+ store: store,
+ ttl: statusCacheTTL,
+ now: time.Now,
+ cache: make(map[string]statusCached),
+ misses: make(map[string]time.Time),
+ }
+}
+
+// missFresh reports whether the "not servable" decision for this manifest
+// key was taken recently enough to reuse.
+func (h *statusHandler) missFresh(key string) bool {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ at, ok := h.misses[key]
+ return ok && h.now().Sub(at) < h.ttl
+}
+
+// markMiss records that an app is not servable — no manifest, an unparseable
+// one, or public:false — for the same TTL the positive cache trusts a fetched
+// object. Publishing an app therefore takes effect within TWO ticks worst
+// case (~2×TTL), one typically: the two caches are stamped independently, so
+// a public:false manifest read at t=0 can still be serving from the positive
+// cache at t=59s and stamp a NEGATIVE entry then, which outlives it. Revoking
+// has the same shape from the other side. Both are bounded and small; the
+// alternative — invalidating across the two caches — would buy a second off a
+// 5-minute-cadence page.
+//
+// The bound is the point: see statusMissCacheMax.
+func (h *statusHandler) markMiss(key string) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if len(h.misses) >= statusMissCacheMax {
+ if _, ok := h.misses[key]; !ok {
+ clear(h.misses)
+ }
}
+ h.misses[key] = h.now()
}
func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- rt, ok := statusRoutes[path.Clean(r.URL.Path)]
+ p := path.Clean(r.URL.Path)
+ if strings.HasPrefix(p, ledgerPrefix) {
+ h.serveLedger(w, r, p)
+ return
+ }
+ rt, ok := statusRoutes[p]
if !ok {
http.NotFound(w, r)
return
@@ -106,6 +173,194 @@ func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Write(body)
}
+// serveLedger maps /ledgers/<sqid>/<app>/... 1:1 onto the bucket key — that
+// identity is what lets `carlos ledger verify -url` walk a public chain over
+// HTTPS with the very keys the store uses, no URL scheme to keep in sync.
+//
+// Everything here is fail-closed: the ONLY way to get a body out of this
+// handler is an app whose manifest reads, parses, says public:true, and —
+// for anything below the app level — names that ledger in its Ledgers list.
+// No manifest, an unreadable one, an unparseable one, public:false, or a
+// ledger the manifest doesn't name all end in the same plain 404 as an app
+// that was never created: a caller must not be able to probe which apps
+// exist, let alone which of their ledgers do. A missing object under a
+// public app is a 404 too: the 503 "nothing honest left to serve" contract
+// above belongs to the fleet page's own three objects, which are the page,
+// not arbitrary reads.
+//
+// Manifest.Ledgers is read as an ALLOWLIST, not as a convenience index: an
+// account accrues ledgers privately (the console writes chains whether or
+// not anything is published), so "the app is public" cannot be allowed to
+// mean "every chain it has ever written is public". Publishing is per
+// ledger, by name; an empty list publishes nothing but the index itself.
+//
+// Ledger objects fall into two cache classes, because unlike the fleet
+// page's three fixed objects a public ledger has unboundedly many:
+//
+// - ledgers.json and HEAD.json are MUTABLE and few — one manifest per
+// public app, one HEAD per ledger — so they ride h.get's shared 60s
+// cache. Revoking public therefore takes at most one TTL, and a cached
+// public manifest keeps serving through a store outage exactly like any
+// other cached object (get's stale-on-error half). That map cannot be
+// grown by a stranger probing random sqids: get only writes an entry
+// AFTER a successful store read (see its tail), so it is bounded by what
+// actually exists. The REFUSALS a prober generates are remembered
+// separately, in the explicitly bounded negative cache (markMiss), so a
+// probe costs a bucket GET at most once per TTL per app rather than once
+// per request.
+// - entries/*.json and blobs/* are IMMUTABLE and unbounded in number
+// (an entry per append, blobs up to ledger.MaxBlob each), so they are
+// NOT cached in this process at all: a direct store read plus a
+// year-long immutable Cache-Control, which puts the caching in browsers
+// and CDNs where the memory isn't ours. That is deliberate: a public
+// chain walker must not be able to pin arbitrary edge memory by simply
+// reading every entry of a public ledger.
+func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p string) {
+ // path.Clean above already collapsed dot and empty segments; re-check
+ // them anyway, because this is the one place where a public request
+ // chooses the bucket key and "the cleaner surely handled it" is how
+ // traversal bugs get written. Note ServeHTTP sees the DECODED path, so
+ // %2e%2e arrives here as ".." and dies on this check (or, once Clean
+ // has resolved it out of the /ledgers/ prefix entirely, never reaches
+ // this function at all).
+ parts := strings.Split(strings.TrimPrefix(p, "/"), "/") // ["ledgers", sqid, app, ...]
+ if len(parts) < 4 {
+ http.NotFound(w, r)
+ return
+ }
+ for _, seg := range parts {
+ if seg == "" || seg == "." || seg == ".." {
+ http.NotFound(w, r)
+ return
+ }
+ }
+ obj, ok := ledgerShape(parts[3:])
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ sqid, app := parts[1], parts[2]
+
+ // The negative half of the gate, checked BEFORE the store: a miss is
+ // never recorded by get (it only caches successful reads), and
+ // status-kind responses never enter the shared response cache, so
+ // without this every probe at a made-up account/app costs one bucket GET
+ // — and the prober writes the path.
+ mkey := ledger.ManifestKey(sqid, app)
+ if h.missFresh(mkey) {
+ http.NotFound(w, r)
+ return
+ }
+ mb, _, err := h.get(r.Context(), mkey, "application/json")
+ if err != nil {
+ // Only a genuine not-found is REMEMBERED. That is what the negative
+ // cache is for: a prober names manifests that do not exist, and the
+ // store answers os.ErrNotExist (release.Store's contract). Any other
+ // error is a blip — a throttle, an expired credential — and stamping
+ // it would take a live public app dark for the full TTL because the
+ // bucket hiccuped once while this key's positive cache happened to be
+ // cold. This request still 404s (fail-closed: nothing readable said
+ // public); the next one retries.
+ if errors.Is(err, os.ErrNotExist) {
+ h.markMiss(mkey)
+ }
+ http.NotFound(w, r) // fail-closed: no readable manifest = not public
+ return
+ }
+ var m ledger.Manifest
+ if err := json.Unmarshal(mb, &m); err != nil || !m.Public {
+ h.markMiss(mkey)
+ http.NotFound(w, r)
+ return
+ }
+ // obj.ledger is "" only for ledgers.json, the app's OWN published index,
+ // which the app is publishing by setting public:true. Everything else
+ // names a ledger and must be on the allowlist.
+ if obj.ledger != "" && !slices.Contains(m.Ledgers, obj.ledger) {
+ http.NotFound(w, r)
+ return
+ }
+
+ key := strings.TrimPrefix(p, "/")
+ ctype := obj.ctype
+ // Sniffing is what would turn an opaque blob into something a browser
+ // executes in this origin's context; the ledger's bytes are arbitrary
+ // tenant content, so pin the declared type on every response, both
+ // classes.
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+
+ if obj.immutable {
+ // Uncached class: straight to the store, and let the caller cache
+ // it forever — an entry key commits to its own bytes (the ts-sha
+ // key) and a blob key IS its sha256, so neither can ever change
+ // meaning. A store error here is the same plain 404 as a key that
+ // was never written; nothing is logged, for the same reason the
+ // rest of this handler logs nothing.
+ body, _, err := h.store.Get(r.Context(), key)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", ctype)
+ w.Header().Set("Cache-Control", ledgerImmutableCacheControl)
+ w.Write(body)
+ return
+ }
+
+ body, ctype, err := h.get(r.Context(), key, ctype)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", ctype)
+ w.Write(body)
+}
+
+// ledgerImmutableCacheControl is what entries and blobs are served with: a
+// year, immutable. Content-addressed keys make that safe, and it is what
+// keeps the caching of an unbounded object set in browsers and CDNs instead
+// of this process's memory. ledgers.json and HEAD.json deliberately get NO
+// Cache-Control — they are the mutable head of the chain and their freshness
+// is the 60s server-side TTL's job, not a client's.
+const ledgerImmutableCacheControl = "public, max-age=31536000, immutable"
+
+// ledgerObject is everything the path shape alone decides about a servable
+// ledger object — before any manifest is consulted.
+type ledgerObject struct {
+ // ctype comes from the SHAPE, never from the key's suffix: a blob is
+ // named by its sha256 and a tenant may well upload one whose sha ends
+ // ".json". Blobs are opaque bytes, and the shape is what knows that.
+ ctype string
+ // immutable selects the cache class documented on serveLedger.
+ immutable bool
+ // ledger is the ledger name this object belongs to, or "" for the
+ // app-level index (ledgers.json) — the allowlist gate keys off it.
+ ledger string
+}
+
+// ledgerShape is the object-shape allowlist inside one app's ledger subtree,
+// given the path segments AFTER ledgers/<sqid>/<app>/. The ledger primitive
+// writes exactly four shapes (internal/ledger/ledger.go's key builders) and
+// the edge publishes exactly those: living under a public app's prefix is
+// not, by itself, a reason to hand an object to the internet. Same allowlist
+// spirit as statusRoutes, one level down. One switch decides shape, content
+// type, cache class and owning ledger together, so those four cannot drift
+// apart as shapes are added.
+func ledgerShape(rest []string) (ledgerObject, bool) {
+ const jsonCT, blobCT = "application/json", "application/octet-stream"
+ switch {
+ case len(rest) == 1 && rest[0] == "ledgers.json": // ledgers/<sqid>/<app>/ledgers.json
+ return ledgerObject{ctype: jsonCT}, true
+ case len(rest) == 2 && rest[1] == "HEAD.json": // .../<ledger>/HEAD.json
+ return ledgerObject{ctype: jsonCT, ledger: rest[0]}, true
+ case len(rest) == 3 && rest[1] == "entries" && strings.HasSuffix(rest[2], ".json"):
+ return ledgerObject{ctype: jsonCT, immutable: true, ledger: rest[0]}, true
+ case len(rest) == 3 && rest[1] == "blobs": // .../<ledger>/blobs/<sha>
+ return ledgerObject{ctype: blobCT, immutable: true, ledger: rest[0]}, true
+ }
+ return ledgerObject{}, false
+}
+
// get implements the cache contract documented on statusHandler: fresh cache
// hit wins outright (no store touch at all — that's what bounds read
// volume); otherwise it tries the store, falling back to a stale cached copy
diff --git a/internal/edge/status_test.go b/internal/edge/status_test.go
new file mode 100644
index 000000000..76cd05c80
--- /dev/null
+++ b/internal/edge/status_test.go
@@ -0,0 +1,678 @@
+// Tests for statusHandler's /ledgers/ branch (status.go). The three fleet
+// routes are exercised end-to-end through the Edge in edge_test.go
+// (TestStatusRoute*); the ledger branch has no per-route config of its own,
+// so these drive the handler directly — same fake-clock/tiny-TTL seam.
+package edge
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/ledger"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// seedLedger writes one real chained entry, one blob and the app's opt-in
+// manifest THROUGH the ledger package, so the keys under test are byte-for-
+// byte the keys production writes (the whole point of the 1:1 URL↔key map).
+func seedLedger(t *testing.T, st release.Store, public bool) (entryKey, blobSHA string) {
+ t.Helper()
+ ctx := context.Background()
+ a, err := ledger.Append(ctx, st, "bab", "platform", "carbon", "w",
+ json.RawMessage(`{"n":1}`),
+ func() time.Time { return time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC) })
+ if err != nil {
+ t.Fatalf("append: %v", err)
+ }
+ sha, err := ledger.PutBlob(ctx, st, "bab", "platform", "carbon", []byte("blobby"))
+ if err != nil {
+ t.Fatalf("put blob: %v", err)
+ }
+ if err := ledger.WriteManifest(ctx, st, "bab", "platform",
+ ledger.Manifest{Public: public, Ledgers: []string{"carbon"}, Title: "CARLOS"}); err != nil {
+ t.Fatalf("write manifest: %v", err)
+ }
+ return a.Key, sha
+}
+
+func getStatus(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "http://status.test"+path, nil))
+ return rr
+}
+
+// TestLedgerServingPublic: with public:true, every object the ledger package
+// writes is fetchable at the path that equals its bucket key, with the
+// content type a browser (and `carlos ledger verify -url`) expects.
+func TestLedgerServingPublic(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ entryKey, blobSHA := seedLedger(t, st, true)
+ h := newStatusHandler(st)
+
+ // [contentType, cacheControl]: the mutable pair carries no Cache-Control
+ // (freshness is the handler's own 60s TTL); the content-addressed pair
+ // carries the immutable year, because it is cached by clients and CDNs
+ // instead of in this process.
+ for path, want := range map[string][2]string{
+ "/ledgers/bab/platform/ledgers.json": {"application/json", ""},
+ "/ledgers/bab/platform/carbon/HEAD.json": {"application/json", ""},
+ "/" + entryKey: {"application/json", ledgerImmutableCacheControl},
+ "/ledgers/bab/platform/carbon/blobs/" + blobSHA: {"application/octet-stream", ledgerImmutableCacheControl},
+ } {
+ rr := getStatus(t, h, path)
+ if rr.Code != http.StatusOK {
+ t.Errorf("GET %s = %d, want 200", path, rr.Code)
+ continue
+ }
+ if ct := rr.Header().Get("Content-Type"); ct != want[0] {
+ t.Errorf("GET %s Content-Type = %q, want %q", path, ct, want[0])
+ }
+ if cc := rr.Header().Get("Cache-Control"); cc != want[1] {
+ t.Errorf("GET %s Cache-Control = %q, want %q", path, cc, want[1])
+ }
+ // Ledger bytes are arbitrary tenant content served from the status
+ // host's own origin: every shape must refuse content sniffing.
+ if xcto := rr.Header().Get("X-Content-Type-Options"); xcto != "nosniff" {
+ t.Errorf("GET %s X-Content-Type-Options = %q, want nosniff", path, xcto)
+ }
+ }
+ if body := getStatus(t, h, "/ledgers/bab/platform/carbon/blobs/"+blobSHA).Body.String(); body != "blobby" {
+ t.Errorf("blob body = %q, want %q", body, "blobby")
+ }
+}
+
+// TestLedgerServingFailClosed: every way of NOT being a published public
+// object answers the same plain 404 — a non-public app must be
+// indistinguishable from an app that does not exist.
+func TestLedgerServingFailClosed(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ entryKey, blobSHA := seedLedger(t, st, false) // chain exists, manifest says NOT public
+ // An app whose manifest exists but is unparseable must also fail closed.
+ if _, err := st.Put(context.Background(), ledger.ManifestKey("bab", "broken"),
+ []byte("{not json"), ""); err != nil {
+ t.Fatalf("seed broken manifest: %v", err)
+ }
+ h := newStatusHandler(st)
+
+ for _, path := range []string{
+ "/ledgers/bab/platform/carbon/HEAD.json",
+ "/ledgers/bab/platform/ledgers.json",
+ "/" + entryKey,
+ "/ledgers/bab/platform/carbon/blobs/" + blobSHA,
+ "/ledgers/bab/broken/ledgers.json", // manifest unparseable
+ "/ledgers/nope/nothing/carbon/HEAD.json", // no manifest at all
+ "/ledgers/bab/platform/carbon/../../secrets", // traversal shape
+ "/ledgers/bab", // too-short path
+ "/ledgers/bab/platform", // too-short path
+ "/ledgers/", // bare prefix
+ } {
+ rr := getStatus(t, h, path)
+ if rr.Code != http.StatusNotFound {
+ t.Errorf("GET %s = %d, want 404", path, rr.Code)
+ }
+ }
+
+ // Byte-identical bodies: the private app and the app that was never
+ // created must not be told apart by their 404s.
+ private := getStatus(t, h, "/ledgers/bab/platform/ledgers.json")
+ absent := getStatus(t, h, "/ledgers/nope/nothing/ledgers.json")
+ if private.Body.String() != absent.Body.String() {
+ t.Errorf("404 bodies differ: private %q vs absent %q", private.Body.String(), absent.Body.String())
+ }
+}
+
+// appendTo adds one entry to another ledger of the same app, so a test can
+// have a published and an unpublished chain side by side.
+func appendTo(t *testing.T, st release.Store, name string) string {
+ t.Helper()
+ a, err := ledger.Append(context.Background(), st, "bab", "platform", name, "w",
+ json.RawMessage(`{"n":2}`),
+ func() time.Time { return time.Date(2026, 8, 3, 22, 0, 0, 0, time.UTC) })
+ if err != nil {
+ t.Fatalf("append %s: %v", name, err)
+ }
+ return a.Key
+}
+
+// TestLedgerAllowlistIsPerLedger: Manifest.Ledgers is an allowlist, not an
+// index. An account accrues chains privately, so public:true on the app must
+// not publish every chain it has ever written — only the ones it names.
+func TestLedgerAllowlistIsPerLedger(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ seedLedger(t, st, true) // manifest publishes "carbon" only
+ ironEntry := appendTo(t, st, "iron")
+ ironSHA, err := ledger.PutBlob(context.Background(), st, "bab", "platform", "iron", []byte("ironblob"))
+ if err != nil {
+ t.Fatalf("put iron blob: %v", err)
+ }
+ h := newStatusHandler(st)
+
+ if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK {
+ t.Fatalf("published ledger = %d, want 200", rr.Code)
+ }
+ absent := getStatus(t, h, "/ledgers/nope/nothing/carbon/HEAD.json")
+ for _, path := range []string{
+ "/ledgers/bab/platform/iron/HEAD.json",
+ "/" + ironEntry,
+ "/ledgers/bab/platform/iron/blobs/" + ironSHA,
+ } {
+ rr := getStatus(t, h, path)
+ if rr.Code != http.StatusNotFound {
+ t.Errorf("unpublished ledger GET %s = %d, want 404", path, rr.Code)
+ continue
+ }
+ if rr.Body.String() != absent.Body.String() {
+ t.Errorf("GET %s 404 body %q differs from an absent app's %q",
+ path, rr.Body.String(), absent.Body.String())
+ }
+ }
+
+ // An empty list publishes nothing but the app's own index.
+ if err := ledger.WriteManifest(context.Background(), st, "bab", "platform",
+ ledger.Manifest{Public: true, Ledgers: nil, Title: "CARLOS"}); err != nil {
+ t.Fatalf("rewrite manifest: %v", err)
+ }
+ h2 := newStatusHandler(st) // fresh handler: don't wait out the 60s TTL
+ if rr := getStatus(t, h2, "/ledgers/bab/platform/ledgers.json"); rr.Code != http.StatusOK {
+ t.Errorf("ledgers.json with an empty list = %d, want 200 (the app's own index)", rr.Code)
+ }
+ for _, path := range []string{
+ "/ledgers/bab/platform/carbon/HEAD.json",
+ "/ledgers/bab/platform/iron/HEAD.json",
+ } {
+ if rr := getStatus(t, h2, path); rr.Code != http.StatusNotFound {
+ t.Errorf("GET %s with an empty allowlist = %d, want 404", path, rr.Code)
+ }
+ }
+}
+
+// TestLedgerBlobContentTypeComesFromShape: a blob is named by its sha256 and
+// nothing stops a tenant uploading one whose name ends ".json" — the content
+// type must come from the path SHAPE, not from a suffix probe on the key.
+func TestLedgerBlobContentTypeComesFromShape(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ seedLedger(t, st, true)
+ if _, err := st.Put(context.Background(),
+ "ledgers/bab/platform/carbon/blobs/deadbeef.json", []byte(`{"not":"json-typed"}`), ""); err != nil {
+ t.Fatalf("seed blob: %v", err)
+ }
+ rr := getStatus(t, newStatusHandler(st), "/ledgers/bab/platform/carbon/blobs/deadbeef.json")
+ if rr.Code != http.StatusOK {
+ t.Fatalf("blob = %d, want 200", rr.Code)
+ }
+ if ct := rr.Header().Get("Content-Type"); ct != "application/octet-stream" {
+ t.Errorf("blob Content-Type = %q, want application/octet-stream", ct)
+ }
+}
+
+// TestLedgerServingNeverLeavesPrefix: a public manifest buys access to that
+// app's own ledger subtree and nothing else — no encoded traversal reaches a
+// key outside it.
+func TestLedgerServingNeverLeavesPrefix(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ seedLedger(t, st, true)
+ if _, err := st.Put(context.Background(), "status/fleet.json", []byte(`{"fleet":true}`), ""); err != nil {
+ t.Fatalf("seed fleet: %v", err)
+ }
+ if _, err := st.Put(context.Background(), "apps/bab/platform/config.json", []byte(`{"secret":true}`), ""); err != nil {
+ t.Fatalf("seed app config: %v", err)
+ }
+ h := newStatusHandler(st)
+
+ for _, path := range []string{
+ "/ledgers/bab/platform/%2e%2e/%2e%2e/%2e%2e/status/fleet.json",
+ "/ledgers/bab/platform/../../../apps/bab/platform/config.json",
+ "/ledgers/bab/platform/carbon/%2e%2e/%2e%2e/%2e%2e/%2e%2e/apps/bab/platform/config.json",
+ "/ledgers/bab/platform//../../apps/bab/platform/config.json",
+ } {
+ rr := getStatus(t, h, path)
+ if rr.Code == http.StatusOK {
+ t.Errorf("GET %s = 200 %q — escaped the app's ledger subtree", path, rr.Body.String())
+ }
+ }
+}
+
+// TestLedgerServingOnlyLedgerShapes: even inside a public app's own subtree
+// the handler serves only the four object shapes the ledger primitive
+// defines; anything else that happens to be under the prefix stays private.
+func TestLedgerServingOnlyLedgerShapes(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ seedLedger(t, st, true)
+ ctx := context.Background()
+ for _, key := range []string{
+ "ledgers/bab/platform/notes.txt",
+ "ledgers/bab/platform/other.json",
+ "ledgers/bab/platform/carbon/notes.txt",
+ "ledgers/bab/platform/carbon/entries/raw.bin",
+ "ledgers/bab/platform/carbon/blobs/deep/nested",
+ } {
+ if _, err := st.Put(ctx, key, []byte("private"), ""); err != nil {
+ t.Fatalf("seed %s: %v", key, err)
+ }
+ }
+ h := newStatusHandler(st)
+ for _, key := range []string{
+ "ledgers/bab/platform/notes.txt",
+ "ledgers/bab/platform/other.json",
+ "ledgers/bab/platform/carbon/notes.txt",
+ "ledgers/bab/platform/carbon/entries/raw.bin",
+ "ledgers/bab/platform/carbon/blobs/deep/nested",
+ } {
+ if rr := getStatus(t, h, "/"+key); rr.Code != http.StatusNotFound {
+ t.Errorf("GET /%s = %d, want 404", key, rr.Code)
+ }
+ }
+}
+
+// TestLedgerServingMissingObjectIs404: under a public app, an object that
+// isn't there is a 404 — the 503 "nothing honest left to serve" contract
+// belongs to the fleet page's own three objects, not to ledger reads.
+func TestLedgerServingMissingObjectIs404(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ seedLedger(t, st, true)
+ h := newStatusHandler(st)
+ for _, path := range []string{
+ "/ledgers/bab/platform/nosuch/HEAD.json",
+ "/ledgers/bab/platform/carbon/entries/20260101T000000Z-deadbeefcafe.json",
+ "/ledgers/bab/platform/carbon/blobs/" + ledger.SHA256Hex([]byte("absent")),
+ } {
+ if rr := getStatus(t, h, path); rr.Code != http.StatusNotFound {
+ t.Errorf("GET %s = %d, want 404", path, rr.Code)
+ }
+ }
+}
+
+// cacheKeys snapshots the handler's in-memory cache under its own lock.
+func cacheKeys(h *statusHandler) map[string]bool {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ keys := make(map[string]bool, len(h.cache))
+ for k := range h.cache {
+ keys[k] = true
+ }
+ return keys
+}
+
+// TestLedgerCacheClasses is the memory-safety pin: a public ledger has
+// unboundedly many entries and blobs (blobs up to 4 MiB), so serving them
+// must NOT put anything in the handler's never-evicting cache — otherwise
+// anyone on the internet could pin arbitrary edge memory just by walking a
+// public chain. Only the mutable, bounded set (one ledgers.json per public
+// app, one HEAD.json per ledger) is cached.
+func TestLedgerCacheClasses(t *testing.T) {
+ dir := t.TempDir()
+ st := release.NewDirStore(dir)
+ entryKey, blobSHA := seedLedger(t, st, true)
+ h := newStatusHandler(st)
+
+ entryPath, blobPath := "/"+entryKey, "/ledgers/bab/platform/carbon/blobs/"+blobSHA
+ if rr := getStatus(t, h, entryPath); rr.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d, want 200", entryPath, rr.Code)
+ }
+ if rr := getStatus(t, h, blobPath); rr.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d, want 200", blobPath, rr.Code)
+ }
+ keys := cacheKeys(h)
+ if keys[entryKey] || keys["ledgers/bab/platform/carbon/blobs/"+blobSHA] {
+ t.Errorf("immutable objects were cached in-process: %v", keys)
+ }
+ // The gate itself is cached — that read is what bounds the map.
+ if want := ledger.ManifestKey("bab", "platform"); !keys[want] {
+ t.Errorf("manifest %s not cached: %v", want, keys)
+ }
+ if len(keys) != 1 {
+ t.Errorf("cache holds %d keys, want only the manifest: %v", len(keys), keys)
+ }
+
+ // Serving HEAD.json adds exactly one more key (the mutable class).
+ if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK {
+ t.Fatalf("GET HEAD.json = %d, want 200", rr.Code)
+ }
+ if keys = cacheKeys(h); len(keys) != 2 || !keys[ledger.HeadKey("bab", "platform", "carbon")] {
+ t.Errorf("after HEAD.json the cache holds %v, want manifest + HEAD", keys)
+ }
+
+ // A miss never grows the map: probing sqids that do not exist must not
+ // be a way to allocate memory in the edge (get only records an entry
+ // after a SUCCESSFUL store read).
+ for i, p := range []string{
+ "/ledgers/nope/nothing/ledgers.json",
+ "/ledgers/nope/nothing/carbon/HEAD.json",
+ "/ledgers/bab/platform/carbon/entries/20260101T000000Z-000000000000.json",
+ } {
+ if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound {
+ t.Fatalf("probe %d (%s) = %d, want 404", i, p, rr.Code)
+ }
+ }
+ if after := cacheKeys(h); len(after) != 2 {
+ t.Errorf("probing grew the cache to %v", after)
+ }
+
+ // And because entries are not cached at all, removing the backing
+ // object is visible immediately — no stale copy is held anywhere in
+ // this process, even though the manifest gate is still cached.
+ if err := os.Remove(filepath.Join(dir, filepath.FromSlash(entryKey))); err != nil {
+ t.Fatalf("remove entry: %v", err)
+ }
+ if rr := getStatus(t, h, entryPath); rr.Code != http.StatusNotFound {
+ t.Errorf("removed entry = %d, want 404 (nothing cached in-process)", rr.Code)
+ }
+}
+
+// countingStore records how many times each key is read, so a test can
+// assert what a request COST rather than only what it answered.
+type countingStore struct {
+ release.Store
+ mu sync.Mutex
+ gets map[string]int
+}
+
+func newCountingStore(st release.Store) *countingStore {
+ return &countingStore{Store: st, gets: make(map[string]int)}
+}
+
+func (c *countingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ c.mu.Lock()
+ c.gets[key]++
+ c.mu.Unlock()
+ return c.Store.Get(ctx, key)
+}
+
+func (c *countingStore) count(key string) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.gets[key]
+}
+
+// TestLedgerManifestMissesAreNegativeCached: status-kind routes are exempt
+// from the shared response cache, and get() only records an entry after a
+// SUCCESSFUL read — so without a negative cache every anonymous request to
+// /ledgers/<random>/<random>/x/HEAD.json costs exactly one bucket GET, and a
+// stranger with a loop is billing the deployment one S3 request per packet.
+// The decision "no readable public manifest here" is cached for the same 60s
+// the positive answer is.
+func TestLedgerManifestMissesAreNegativeCached(t *testing.T) {
+ st := newCountingStore(release.NewDirStore(t.TempDir()))
+ h := newStatusHandler(st)
+ h.ttl = 50 * time.Millisecond
+ fakeNow := time.Now()
+ h.now = func() time.Time { return fakeNow }
+
+ key := ledger.ManifestKey("nope", "nothing")
+ for i := range 5 {
+ if rr := getStatus(t, h, "/ledgers/nope/nothing/carbon/HEAD.json"); rr.Code != http.StatusNotFound {
+ t.Fatalf("probe %d = %d, want 404", i, rr.Code)
+ }
+ }
+ if got := st.count(key); got != 1 {
+ t.Fatalf("%s was read %d times for 5 probes, want 1 (the miss must be cached)", key, got)
+ }
+
+ // Different objects under the SAME app share the one decision — the
+ // gate is the app's manifest, so walking an app's key space is not a way
+ // around it.
+ for _, p := range []string{
+ "/ledgers/nope/nothing/ledgers.json",
+ "/ledgers/nope/nothing/carbon/entries/20260101T000000Z-000000000000.json",
+ "/ledgers/nope/nothing/carbon/blobs/" + ledger.SHA256Hex([]byte("x")),
+ } {
+ if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound {
+ t.Fatalf("GET %s = %d, want 404", p, rr.Code)
+ }
+ }
+ if got := st.count(key); got != 1 {
+ t.Fatalf("%s was read %d times, want still 1", key, got)
+ }
+
+ // Past the TTL the decision is re-taken — publishing an app takes effect
+ // within one tick, exactly like the positive cache's staleness bound.
+ fakeNow = fakeNow.Add(51 * time.Millisecond)
+ if rr := getStatus(t, h, "/ledgers/nope/nothing/carbon/HEAD.json"); rr.Code != http.StatusNotFound {
+ t.Fatalf("post-TTL probe = %d, want 404", rr.Code)
+ }
+ if got := st.count(key); got != 2 {
+ t.Fatalf("%s read %d times after the TTL expired, want 2", key, got)
+ }
+
+ // A non-public app is the same cached decision: the manifest read that
+ // answered it is itself cached, so neither costs a second GET.
+ seedLedger(t, st, false)
+ pkey := ledger.ManifestKey("bab", "platform")
+ before := st.count(pkey)
+ for range 3 {
+ if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusNotFound {
+ t.Fatalf("private app = %d, want 404", rr.Code)
+ }
+ }
+ if got := st.count(pkey) - before; got > 1 {
+ t.Fatalf("a private app cost %d manifest reads for 3 probes, want at most 1", got)
+ }
+}
+
+// flakyStore fails every read while armed, with an error that is NOT
+// not-found — a bucket blip, a throttle, an expired credential — the class of
+// failure that resolves on its own.
+type flakyStore struct {
+ release.Store
+ mu sync.Mutex
+ broken bool
+}
+
+func (f *flakyStore) breakIt(b bool) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.broken = b
+}
+
+func (f *flakyStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ f.mu.Lock()
+ broken := f.broken
+ f.mu.Unlock()
+ if broken {
+ return nil, "", errors.New("Throttling: slow down")
+ }
+ return f.Store.Get(ctx, key)
+}
+
+// TestLedgerTransientStoreErrorIsNotNegativeCached: the negative cache exists
+// to make PROBING cheap to refuse, and a probe's manifest is not-found. A
+// transient store error is a different thing entirely: caching it would pin a
+// public app dark for the full TTL because the bucket hiccuped once while the
+// cache happened to be cold — the exact dishonesty the positive cache's
+// stale-on-error half exists to avoid, reintroduced from the other side. The
+// blipped request still 404s (fail-closed: nothing readable said public), but
+// the decision is not remembered, so the next request retries.
+func TestLedgerTransientStoreErrorIsNotNegativeCached(t *testing.T) {
+ base := release.NewDirStore(t.TempDir())
+ st := &flakyStore{Store: base}
+ seedLedger(t, base, true)
+ h := newStatusHandler(st)
+
+ // Cold cache + broken store: 404, because nothing readable says public.
+ st.breakIt(true)
+ if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusNotFound {
+ t.Fatalf("during the blip = %d, want 404", rr.Code)
+ }
+ h.mu.Lock()
+ n := len(h.misses)
+ h.mu.Unlock()
+ if n != 0 {
+ t.Fatalf("a transient store error stamped %d negative-cache entries, want 0", n)
+ }
+
+ // The store recovers well inside the 60s TTL: the very next request must
+ // serve, not sit behind a cached refusal.
+ st.breakIt(false)
+ if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK {
+ t.Fatalf("after the store recovered = %d, want 200 (the blip must not be negatively cached)", rr.Code)
+ }
+}
+
+// TestLedgerMissCacheIsBounded: the attacker picks the account and app path
+// segments, so the set of distinct manifest keys a prober can name is
+// unbounded — a negative cache that grew with it would be the memory
+// exhaustion the immutable classes are deliberately uncached to avoid,
+// reintroduced through the back door.
+func TestLedgerMissCacheIsBounded(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ h := newStatusHandler(st)
+ for i := range statusMissCacheMax * 3 {
+ p := fmt.Sprintf("/ledgers/acct%d/app%d/carbon/HEAD.json", i, i)
+ if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound {
+ t.Fatalf("probe %d = %d, want 404", i, rr.Code)
+ }
+ }
+ h.mu.Lock()
+ n := len(h.misses)
+ h.mu.Unlock()
+ if n > statusMissCacheMax {
+ t.Fatalf("negative cache holds %d entries, over the %d bound", n, statusMissCacheMax)
+ }
+ // The positive cache is untouched by any of it: nothing was ever read.
+ if keys := cacheKeys(h); len(keys) != 0 {
+ t.Fatalf("probing populated the positive cache: %v", keys)
+ }
+}
+
+// TestLedgerServingStaleManifestHonored pins the interaction with get()'s
+// stale-on-error half: the manifest is just another cached object, so once a
+// public manifest has been read, a store outage keeps the already-cached
+// objects serving (the alternative — 404ing a public ledger because the
+// bucket blipped — is the same dishonesty the status page's cache exists to
+// avoid). An object that was never cached still 404s: stale caching, not
+// fabrication.
+func TestLedgerServingStaleManifestHonored(t *testing.T) {
+ dir := t.TempDir()
+ st := release.NewDirStore(dir)
+ entryKey, _ := seedLedger(t, st, true)
+ h := newStatusHandler(st)
+ h.ttl = 20 * time.Millisecond
+ fakeNow := time.Now()
+ h.now = func() time.Time { return fakeNow }
+
+ first := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json")
+ if first.Code != http.StatusOK {
+ t.Fatalf("first fetch = %d, want 200", first.Code)
+ }
+
+ // Break the store entirely and age both cached objects past the TTL.
+ if err := os.RemoveAll(dir); err != nil {
+ t.Fatalf("break store: %v", err)
+ }
+ fakeNow = fakeNow.Add(21 * time.Millisecond)
+
+ stale := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json")
+ if stale.Code != http.StatusOK || stale.Body.String() != first.Body.String() {
+ t.Fatalf("stale fetch = %d %q, want 200 %q", stale.Code, stale.Body.String(), first.Body.String())
+ }
+
+ // The entry was never cached: a stale-but-public manifest does not
+ // conjure a body for it.
+ if rr := getStatus(t, h, "/"+entryKey); rr.Code != http.StatusNotFound {
+ t.Errorf("uncached entry during outage = %d, want 404", rr.Code)
+ }
+}
+
+// statusEdgeWithCache wires a full Edge (Host dispatch, shared response
+// cache) in front of one status route — testEdgeWithStore's shape, but
+// returning the *Edge so a test can look inside e.cache.
+func statusEdgeWithCache(t *testing.T) (release.Store, *Edge) {
+ t.Helper()
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "status.test", Kind: "status", Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ store := release.NewDirStore(filepath.Join(t.TempDir(), "store"))
+ _, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatalf("generate key: %v", err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatalf("parse public key: %v", err)
+ }
+ e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache"))
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ if err := e.Reload(); err != nil {
+ t.Fatalf("Reload: %v", err)
+ }
+ return store, e
+}
+
+// TestStatusResponsesNeverEnterSharedCache: the shared response cache is
+// process-global and shared across every host, and a public ledger chain is
+// unbounded in length — so a stranger walking one must not be able to admit
+// (and thereby evict) anything in it. An evicted entry for a hibernating
+// route is a wake that the cache existed to prevent. The client-facing
+// immutable Cache-Control still goes out: browsers and CDNs do the caching.
+func TestStatusResponsesNeverEnterSharedCache(t *testing.T) {
+ store, e := statusEdgeWithCache(t)
+ entryKey, blobSHA := seedLedger(t, store, true)
+ if _, err := store.Put(context.Background(), "status/fleet.json", []byte(`{"ok":true}`), ""); err != nil {
+ t.Fatalf("seed fleet: %v", err)
+ }
+
+ for _, path := range []string{
+ "/" + entryKey,
+ "/ledgers/bab/platform/carbon/blobs/" + blobSHA,
+ "/ledgers/bab/platform/carbon/HEAD.json",
+ "/ledgers/bab/platform/ledgers.json",
+ "/fleet.json",
+ } {
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test"+path, nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d, want 200", path, rec.Code)
+ }
+ if _, ok := e.cache.get(CacheKey{Host: "status.test", Method: "GET", Path: path}); ok {
+ t.Errorf("GET %s entered the shared edge response cache", path)
+ }
+ }
+
+ // The header a client sees is unchanged — only the edge's own admission
+ // is refused.
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test/"+entryKey, nil))
+ if cc := rec.Header().Get("Cache-Control"); cc != ledgerImmutableCacheControl {
+ t.Errorf("entry Cache-Control = %q, want %q", cc, ledgerImmutableCacheControl)
+ }
+}
+
+// TestLedgerBranchLeavesFleetRoutesAlone: the /ledgers/ prefix branch must
+// not intercept the three fleet paths, which keep their own 503-when-nothing-
+// cached contract.
+func TestLedgerBranchLeavesFleetRoutesAlone(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ seedLedger(t, st, true)
+ h := newStatusHandler(st)
+ for _, path := range []string{"/", "/fleet.json", "/status.css"} {
+ if rr := getStatus(t, h, path); rr.Code != http.StatusServiceUnavailable {
+ t.Errorf("GET %s = %d, want 503 (nothing published)", path, rr.Code)
+ }
+ }
+ if _, err := st.Put(context.Background(), "status/fleet.json", []byte(`{"ok":true}`), ""); err != nil {
+ t.Fatalf("seed fleet: %v", err)
+ }
+ rr := getStatus(t, h, "/fleet.json")
+ if rr.Code != http.StatusOK || rr.Body.String() != `{"ok":true}` {
+ t.Errorf("GET /fleet.json = %d %q, want 200 fleet body", rr.Code, rr.Body.String())
+ }
+ if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
+ t.Errorf("GET /fleet.json Content-Type = %q", ct)
+ }
+}
diff --git a/internal/ledger/append.go b/internal/ledger/append.go
new file mode 100644
index 000000000..536575451
--- /dev/null
+++ b/internal/ledger/append.go
@@ -0,0 +1,109 @@
+package ledger
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type Appended struct {
+ Key string `json:"key"`
+ SHA256 string `json:"sha256"`
+ Count int `json:"count"`
+}
+
+// appendAttempts bounds the CAS retry loop. Conflicts are rare (two
+// writers accruing the same app's ledger in the same moment); four
+// attempts outlasts any plausible burst without ever spinning.
+const appendAttempts = 4
+
+// ErrBusy marks the one Append failure that is neither the caller's fault
+// nor a broken store: too many writers accruing the same chain at once, so
+// every CAS attempt lost. A caller that retries later succeeds — which is
+// exactly what a caller cannot work out from an error STRING, hence a
+// sentinel to match with errors.Is.
+var ErrBusy = errors.New("ledger: append contention")
+
+// Append validates, chains, and writes one entry, advancing HEAD with CAS.
+// A lost HEAD race re-reads and re-chains (up to 4 attempts). An entry
+// object orphaned by a lost race is harmless: nothing references it, and
+// Verify walks only the chain.
+func Append(ctx context.Context, st release.Store, sqid, app, name, writer string,
+ payload json.RawMessage, now func() time.Time) (Appended, error) {
+ if !ValidName(name) {
+ return Appended{}, fmt.Errorf("ledger: invalid ledger name %q", name)
+ }
+ if len(payload) > MaxPayload {
+ return Appended{}, fmt.Errorf("ledger: payload %d bytes exceeds cap %d", len(payload), MaxPayload)
+ }
+ if !json.Valid(payload) {
+ return Appended{}, fmt.Errorf("ledger: payload is not valid JSON")
+ }
+
+ var lastErr error
+ for range appendAttempts {
+ headKey := HeadKey(sqid, app, name)
+ hb, etag, err := st.Get(ctx, headKey)
+ var head Head
+ exists := err == nil
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ // First entry: prev fields stay empty, HEAD is create-only.
+ case err != nil:
+ return Appended{}, err
+ default:
+ if err := json.Unmarshal(hb, &head); err != nil {
+ return Appended{}, fmt.Errorf("ledger: %s corrupt: %w", headKey, err)
+ }
+ }
+
+ ts := now().UTC().Format(time.RFC3339)
+ e := Entry{
+ Schema: Schema, Ledger: name, Writer: writer, TS: ts,
+ Payload: payload, PrevKey: head.HeadKey, PrevSHA256: head.HeadSHA256,
+ }
+ eb, err := json.Marshal(e)
+ if err != nil {
+ return Appended{}, err
+ }
+ sha := SHA256Hex(eb)
+ key := EntryKey(sqid, app, name, ts, sha)
+ if _, err := st.Put(ctx, key, eb, ""); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ // Same second, same bytes-shape collision: retry re-reads
+ // HEAD and produces a differently-chained entry.
+ lastErr = err
+ continue
+ }
+ return Appended{}, err
+ }
+
+ nh := Head{Schema: Schema, UpdatedAt: ts, HeadKey: key, HeadSHA256: sha, Count: head.Count + 1}
+ nhb, err := json.Marshal(nh)
+ if err != nil {
+ return Appended{}, err
+ }
+ ifMatch := ""
+ if exists {
+ ifMatch = etag
+ }
+ if _, err := st.Put(ctx, headKey, nhb, ifMatch); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ // Lost the race: another writer advanced HEAD. Our entry
+ // object is now an unreferenced orphan — harmless in a
+ // versioned bucket; Verify walks only the chain. Re-read
+ // and re-chain.
+ lastErr = err
+ continue
+ }
+ return Appended{}, err
+ }
+ return Appended{Key: key, SHA256: sha, Count: nh.Count}, nil
+ }
+ return Appended{}, fmt.Errorf("ledger: append lost %d CAS races: %w: %w", appendAttempts, ErrBusy, lastErr)
+}
diff --git a/internal/ledger/append_test.go b/internal/ledger/append_test.go
new file mode 100644
index 000000000..3438c8847
--- /dev/null
+++ b/internal/ledger/append_test.go
@@ -0,0 +1,169 @@
+package ledger
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func fixedNow() time.Time { return time.Date(2026, 8, 3, 21, 4, 0, 0, time.UTC) }
+
+func TestAppendFirstAndSecond(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+
+ a1, err := Append(ctx, st, "bab", "platform", "carbon", "paul@keymail.dev",
+ json.RawMessage(`{"n":1}`), fixedNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // HEAD points at the entry, count 1, and the stored bytes hash to
+ // the recorded sha.
+ hb, _, err := st.Get(ctx, HeadKey("bab", "platform", "carbon"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var h Head
+ if err := json.Unmarshal(hb, &h); err != nil {
+ t.Fatal(err)
+ }
+ if h.HeadKey != a1.Key || h.Count != 1 || h.Schema != Schema {
+ t.Fatalf("head = %+v, appended = %+v", h, a1)
+ }
+ eb, _, err := st.Get(ctx, a1.Key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if SHA256Hex(eb) != a1.SHA256 || h.HeadSHA256 != a1.SHA256 {
+ t.Fatal("stored bytes do not hash to the recorded sha")
+ }
+ var e1 Entry
+ if err := json.Unmarshal(eb, &e1); err != nil {
+ t.Fatal(err)
+ }
+ if e1.PrevKey != "" || e1.PrevSHA256 != "" || e1.Writer != "paul@keymail.dev" {
+ t.Fatalf("first entry = %+v", e1)
+ }
+
+ // Second append chains to the first.
+ later := func() time.Time { return fixedNow().Add(time.Minute) }
+ a2, err := Append(ctx, st, "bab", "platform", "carbon", "paul@keymail.dev",
+ json.RawMessage(`{"n":2}`), later)
+ if err != nil {
+ t.Fatal(err)
+ }
+ eb2, _, _ := st.Get(ctx, a2.Key)
+ var e2 Entry
+ if err := json.Unmarshal(eb2, &e2); err != nil {
+ t.Fatal(err)
+ }
+ if e2.PrevKey != a1.Key || e2.PrevSHA256 != a1.SHA256 || a2.Count != 2 {
+ t.Fatalf("second entry does not chain: %+v (count %d)", e2, a2.Count)
+ }
+}
+
+func TestAppendRejects(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if _, err := Append(ctx, st, "bab", "platform", "Not A Name", "w",
+ json.RawMessage(`{}`), fixedNow); err == nil {
+ t.Fatal("bad ledger name accepted")
+ }
+ big := json.RawMessage(`"` + string(make([]byte, MaxPayload)) + `"`)
+ if _, err := Append(ctx, st, "bab", "platform", "carbon", "w", big, fixedNow); err == nil {
+ t.Fatal("oversized payload accepted")
+ }
+ if _, err := Append(ctx, st, "bab", "platform", "carbon", "w",
+ json.RawMessage(`{not json`), fixedNow); err == nil {
+ t.Fatal("invalid JSON payload accepted")
+ }
+}
+
+// casOnceStore makes the FIRST head Put lose its race, as if another
+// writer advanced HEAD between our read and our write.
+type casOnceStore struct {
+ release.Store
+ tripped bool
+}
+
+func (c *casOnceStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if !c.tripped && key == HeadKey("bab", "platform", "carbon") {
+ c.tripped = true
+ // Simulate the race: a competing append lands first.
+ if _, err := Append(ctx, c.Store, "bab", "platform", "carbon", "rival",
+ json.RawMessage(`{"rival":true}`), fixedNow); err != nil {
+ return "", err
+ }
+ return "", release.ErrCASConflict
+ }
+ return c.Store.Put(ctx, key, body, ifMatch)
+}
+
+func TestAppendRetriesLostRace(t *testing.T) {
+ st := &casOnceStore{Store: release.NewDirStore(t.TempDir())}
+ ctx := context.Background()
+ later := func() time.Time { return fixedNow().Add(time.Minute) }
+ a, err := Append(ctx, st, "bab", "platform", "carbon", "me",
+ json.RawMessage(`{"mine":true}`), later)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if a.Count != 2 {
+ t.Fatalf("count = %d, want 2 (rival's entry then ours)", a.Count)
+ }
+ eb, _, err := st.Get(ctx, a.Key)
+ if errors.Is(err, os.ErrNotExist) || err != nil {
+ t.Fatal(err)
+ }
+ var e Entry
+ if err := json.Unmarshal(eb, &e); err != nil {
+ t.Fatal(err)
+ }
+ if e.PrevKey == "" {
+ t.Fatal("retried entry did not re-chain onto the rival's head")
+ }
+}
+
+// alwaysCASStore loses every HEAD race — the shape of a chain under more
+// concurrent writers than the retry loop can outlast.
+type alwaysCASStore struct{ release.Store }
+
+func (a alwaysCASStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if strings.HasSuffix(key, "/HEAD.json") {
+ return "", release.ErrCASConflict
+ }
+ return a.Store.Put(ctx, key, body, ifMatch)
+}
+
+// TestAppendExhaustionIsErrBusy: losing every race is neither the caller's
+// fault nor a broken store — it is retry-worthy, and a caller must be able
+// to tell WITHOUT matching on error text, because callers that match on text
+// break the first time the sentence changes.
+func TestAppendExhaustionIsErrBusy(t *testing.T) {
+ st := alwaysCASStore{Store: release.NewDirStore(t.TempDir())}
+ _, err := Append(context.Background(), st, "bab", "platform", "carbon", "me",
+ json.RawMessage(`{"n":1}`), fixedNow)
+ if err == nil {
+ t.Fatal("Append succeeded against a store that loses every HEAD race")
+ }
+ if !errors.Is(err, ErrBusy) {
+ t.Fatalf("exhaustion err = %v, want it to wrap ErrBusy", err)
+ }
+ // The underlying cause is still reachable — the sentinel adds to the
+ // chain, it does not replace it.
+ if !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("exhaustion err = %v, want it to still wrap ErrCASConflict", err)
+ }
+ // A caller-caused failure must NOT look busy.
+ _, err = Append(context.Background(), st, "bab", "platform", "Not Valid", "me",
+ json.RawMessage(`{"n":1}`), fixedNow)
+ if err == nil || errors.Is(err, ErrBusy) {
+ t.Fatalf("invalid-name err = %v, want a non-ErrBusy failure", err)
+ }
+}
diff --git a/internal/ledger/blob.go b/internal/ledger/blob.go
new file mode 100644
index 000000000..746bc127f
--- /dev/null
+++ b/internal/ledger/blob.go
@@ -0,0 +1,28 @@
+package ledger
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func PutBlob(ctx context.Context, st release.Store, sqid, app, name string, body []byte) (string, error) {
+ if !ValidName(name) {
+ return "", fmt.Errorf("ledger: invalid ledger name %q", name)
+ }
+ if len(body) > MaxBlob {
+ return "", fmt.Errorf("ledger: blob %d bytes exceeds cap %d", len(body), MaxBlob)
+ }
+ sha := SHA256Hex(body)
+ if _, err := st.Put(ctx, BlobKey(sqid, app, name, sha), body, ""); err != nil {
+ // Content-addressed: an existing object with this key IS this
+ // content. A create-only conflict means it's already there.
+ if errors.Is(err, release.ErrCASConflict) {
+ return sha, nil
+ }
+ return "", err
+ }
+ return sha, nil
+}
diff --git a/internal/ledger/blob_test.go b/internal/ledger/blob_test.go
new file mode 100644
index 000000000..5c1818b8c
--- /dev/null
+++ b/internal/ledger/blob_test.go
@@ -0,0 +1,37 @@
+package ledger
+
+import (
+ "context"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestPutBlobIdempotent(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ body := []byte("redacted corpus bytes")
+ sha1, err := PutBlob(ctx, st, "bab", "platform", "prompts", body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sha2, err := PutBlob(ctx, st, "bab", "platform", "prompts", body)
+ if err != nil {
+ t.Fatalf("re-upload of identical content errored: %v", err)
+ }
+ if sha1 != sha2 || sha1 != SHA256Hex(body) {
+ t.Fatalf("sha mismatch: %s %s", sha1, sha2)
+ }
+ got, _, err := st.Get(ctx, BlobKey("bab", "platform", "prompts", sha1))
+ if err != nil || string(got) != string(body) {
+ t.Fatalf("stored blob: %q %v", got, err)
+ }
+}
+
+func TestPutBlobRejectsOversize(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ if _, err := PutBlob(context.Background(), st, "bab", "platform", "prompts",
+ make([]byte, MaxBlob+1)); err == nil {
+ t.Fatal("oversized blob accepted")
+ }
+}
diff --git a/internal/ledger/ledger.go b/internal/ledger/ledger.go
new file mode 100644
index 000000000..47d1f23ad
--- /dev/null
+++ b/internal/ledger/ledger.go
@@ -0,0 +1,84 @@
+// Package ledger — the open-ledger stream primitive: append-only,
+// hash-chained public JSON entries in the deployment bucket, keyed by
+// (account, app) exactly as the fleet page groups routes. The chain
+// hashes STORED entry bytes verbatim: the writer marshals once and the
+// hash commits to exactly those bytes, so verification needs no
+// canonicalization pass in any language.
+// Spec: docs/superpowers/specs/2026-08-03-open-ledgers-design.md §2.
+package ledger
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "regexp"
+ "strings"
+)
+
+const (
+ Schema = 1
+ MaxPayload = 64 << 10
+ MaxBlob = 4 << 20
+)
+
+type Entry struct {
+ Schema int `json:"schema"`
+ Ledger string `json:"ledger"`
+ Writer string `json:"writer"`
+ TS string `json:"ts"`
+ Payload json.RawMessage `json:"payload"`
+ PrevKey string `json:"prev_key"`
+ PrevSHA256 string `json:"prev_sha256"`
+}
+
+type Head struct {
+ Schema int `json:"schema"`
+ UpdatedAt string `json:"updated_at"`
+ HeadKey string `json:"head_key"`
+ HeadSHA256 string `json:"head_sha256"`
+ Count int `json:"count"`
+}
+
+type Manifest struct {
+ Schema int `json:"schema"`
+ Public bool `json:"public"`
+ Ledgers []string `json:"ledgers"`
+ Title string `json:"title"`
+}
+
+var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
+
+func ValidName(s string) bool {
+ return nameRe.MatchString(s)
+}
+
+func Prefix(sqid, app, name string) string {
+ return "ledgers/" + sqid + "/" + app + "/" + name + "/"
+}
+
+func HeadKey(sqid, app, name string) string {
+ return Prefix(sqid, app, name) + "HEAD.json"
+}
+
+func ManifestKey(sqid, app string) string {
+ return "ledgers/" + sqid + "/" + app + "/ledgers.json"
+}
+
+func BlobKey(sqid, app, name, sha string) string {
+ return Prefix(sqid, app, name) + "blobs/" + sha
+}
+
+// tsCompact strips the separators RFC3339 puts in a UTC timestamp so entry
+// keys sort chronologically as plain strings.
+func tsCompact(ts string) string {
+ return strings.NewReplacer("-", "", ":", "").Replace(ts)
+}
+
+func EntryKey(sqid, app, name, ts, sha string) string {
+ return Prefix(sqid, app, name) + "entries/" + tsCompact(ts) + "-" + sha[:12] + ".json"
+}
+
+func SHA256Hex(b []byte) string {
+ sum := sha256.Sum256(b)
+ return hex.EncodeToString(sum[:])
+}
diff --git a/internal/ledger/ledger_test.go b/internal/ledger/ledger_test.go
new file mode 100644
index 000000000..9bba8214c
--- /dev/null
+++ b/internal/ledger/ledger_test.go
@@ -0,0 +1,37 @@
+package ledger
+
+import "testing"
+
+func TestValidName(t *testing.T) {
+ for _, ok := range []string{"carbon", "a", "prompt-registry", "x9"} {
+ if !ValidName(ok) {
+ t.Errorf("ValidName(%q) = false, want true", ok)
+ }
+ }
+ for _, bad := range []string{"", "Carbon", "-x", "a_b", "a/b", "café",
+ "0123456789012345678901234567890123456789012345678901234567890123x"} {
+ if ValidName(bad) {
+ t.Errorf("ValidName(%q) = true, want false", bad)
+ }
+ }
+}
+
+func TestKeys(t *testing.T) {
+ if got, want := HeadKey("bab", "platform", "carbon"),
+ "ledgers/bab/platform/carbon/HEAD.json"; got != want {
+ t.Errorf("HeadKey = %q, want %q", got, want)
+ }
+ if got, want := ManifestKey("bab", "platform"),
+ "ledgers/bab/platform/ledgers.json"; got != want {
+ t.Errorf("ManifestKey = %q, want %q", got, want)
+ }
+ if got, want := BlobKey("bab", "platform", "prompts", "abc123"),
+ "ledgers/bab/platform/prompts/blobs/abc123"; got != want {
+ t.Errorf("BlobKey = %q, want %q", got, want)
+ }
+ sha := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+ if got, want := EntryKey("bab", "platform", "carbon", "2026-08-03T21:04:00Z", sha),
+ "ledgers/bab/platform/carbon/entries/20260803T210400Z-0123456789ab.json"; got != want {
+ t.Errorf("EntryKey = %q, want %q", got, want)
+ }
+}
diff --git a/internal/ledger/manifest.go b/internal/ledger/manifest.go
new file mode 100644
index 000000000..60c54a8c9
--- /dev/null
+++ b/internal/ledger/manifest.go
@@ -0,0 +1,98 @@
+package ledger
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func ReadManifest(ctx context.Context, st release.Store, sqid, app string) (Manifest, error) {
+ b, _, err := st.Get(ctx, ManifestKey(sqid, app))
+ if err != nil {
+ return Manifest{}, err
+ }
+ var m Manifest
+ if err := json.Unmarshal(b, &m); err != nil {
+ return Manifest{}, err
+ }
+ return m, nil
+}
+
+// WriteManifest replaces the whole manifest: read-etag-then-Put with one
+// retry on a lost race, last-writer-wins (the putOverwrite pattern,
+// internal/status/fleet.go). It is the right call for a caller that HAS the
+// whole intended value — a seeded fixture, a box-side tool writing the set
+// it just computed. A caller changing one ledger's publication wants
+// UpdateManifest instead: replacing the whole value there unpublishes
+// everything the caller didn't happen to name.
+func WriteManifest(ctx context.Context, st release.Store, sqid, app string, m Manifest) error {
+ _, err := UpdateManifest(ctx, st, sqid, app, func(cur *Manifest) error {
+ *cur = m
+ return nil
+ })
+ return err
+}
+
+// manifestAttempts bounds UpdateManifest's CAS retry loop, matching
+// appendAttempts: publish contention is two contributors publishing the same
+// app in the same moment, and four attempts outlast that without spinning.
+const manifestAttempts = 4
+
+// UpdateManifest is the manifest's read-modify-write door: it reads the
+// stored manifest (a missing one is the ZERO value, not an error — the first
+// publish of an app creates it), hands it to apply, and CASes the result
+// back on the etag it read. A lost race re-READS and re-APPLIES rather than
+// re-putting a stale copy, which is the whole point: the manifest is a SET
+// accrued across separate publish calls, so "carbon and prompts published at
+// the same moment" must end with both, not with whichever writer was slower.
+//
+// Contention that outlasts the retries surfaces as release.ErrCASConflict
+// (the console maps it to a retry-worthy 503) — never as a silent no-op.
+//
+// apply may REFUSE: an error from it aborts the attempt BEFORE the Put and is
+// returned untouched, so nothing is written. That is what lets a caller
+// enforce a rule the request alone cannot decide — a cap on the RESULTING set
+// — on whatever manifest each attempt actually read, including the re-read
+// after a lost race, rather than on a snapshot that a concurrent publisher
+// has already moved past.
+func UpdateManifest(ctx context.Context, st release.Store, sqid, app string,
+ apply func(*Manifest) error) (Manifest, error) {
+ key := ManifestKey(sqid, app)
+ var lastErr error
+ for range manifestAttempts {
+ var m Manifest
+ b, etag, err := st.Get(ctx, key)
+ ifMatch := etag
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ ifMatch = "" // create-only: no manifest yet
+ case err != nil:
+ return Manifest{}, err
+ default:
+ if err := json.Unmarshal(b, &m); err != nil {
+ return Manifest{}, err
+ }
+ }
+
+ if err := apply(&m); err != nil {
+ return Manifest{}, err
+ }
+ m.Schema = Schema
+ body, err := json.Marshal(m)
+ if err != nil {
+ return Manifest{}, err
+ }
+ if _, err := st.Put(ctx, key, body, ifMatch); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ lastErr = err
+ continue
+ }
+ return Manifest{}, err
+ }
+ return m, nil
+ }
+ return Manifest{}, lastErr
+}
diff --git a/internal/ledger/manifest_test.go b/internal/ledger/manifest_test.go
new file mode 100644
index 000000000..d7313defa
--- /dev/null
+++ b/internal/ledger/manifest_test.go
@@ -0,0 +1,168 @@
+package ledger
+
+import (
+ "context"
+ "errors"
+ "os"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestManifestRoundTrip(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if _, err := ReadManifest(ctx, st, "bab", "platform"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("missing manifest: %v, want os.ErrNotExist", err)
+ }
+ m := Manifest{Schema: Schema, Public: true, Ledgers: []string{"carbon"}, Title: "CARLOS"}
+ if err := WriteManifest(ctx, st, "bab", "platform", m); err != nil {
+ t.Fatal(err)
+ }
+ got, err := ReadManifest(ctx, st, "bab", "platform")
+ if err != nil || !got.Public || got.Title != "CARLOS" || len(got.Ledgers) != 1 {
+ t.Fatalf("%+v %v", got, err)
+ }
+ // Overwrite flips public off — WriteManifest must replace, not create-only.
+ m.Public = false
+ if err := WriteManifest(ctx, st, "bab", "platform", m); err != nil {
+ t.Fatal(err)
+ }
+ got, _ = ReadManifest(ctx, st, "bab", "platform")
+ if got.Public {
+ t.Fatal("manifest overwrite did not take")
+ }
+}
+
+// TestUpdateManifestReadModifyWrite: the manifest is a SET of published
+// ledgers accrued over many separate publish calls, so the write path that
+// serves those calls must read-modify-write. A whole-value replace here is
+// what silently unpublishes an app's other ledgers.
+func TestUpdateManifestReadModifyWrite(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+
+ // Missing manifest is the zero value, not an error: the first publish
+ // of an app creates it.
+ m, err := UpdateManifest(ctx, st, "bab", "platform", func(m *Manifest) error {
+ m.Public = true
+ m.Ledgers = append(m.Ledgers, "carbon")
+ m.Title = "CARLOS"
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("first update: %v", err)
+ }
+ if !m.Public || m.Schema != Schema || m.Title != "CARLOS" || !slices.Equal(m.Ledgers, []string{"carbon"}) {
+ t.Fatalf("first update = %+v, want the created manifest", m)
+ }
+
+ // A second update sees the first one's state.
+ m, err = UpdateManifest(ctx, st, "bab", "platform", func(m *Manifest) error {
+ m.Ledgers = append(m.Ledgers, "prompts")
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("second update: %v", err)
+ }
+ if !slices.Equal(m.Ledgers, []string{"carbon", "prompts"}) || m.Title != "CARLOS" {
+ t.Fatalf("second update = %+v, want carbon preserved beside prompts", m)
+ }
+ stored, err := ReadManifest(ctx, st, "bab", "platform")
+ if err != nil || !slices.Equal(stored.Ledgers, []string{"carbon", "prompts"}) {
+ t.Fatalf("stored = %+v %v, want what the update returned", stored, err)
+ }
+}
+
+// conflictOnce loses the first ledgers.json CAS race to a writer that landed
+// a DIFFERENT manifest — the shape that tells a retry apart from a re-apply.
+// A loop that retried with its own stale copy would erase "iron".
+type conflictOnce struct {
+ release.Store
+ lost bool
+}
+
+func (c *conflictOnce) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if strings.HasSuffix(key, "/ledgers.json") && !c.lost {
+ c.lost = true
+ if _, err := c.Store.Put(ctx, key, []byte(`{"schema":1,"public":true,"ledgers":["iron"],"title":"Other"}`), ifMatch); err != nil {
+ return "", err
+ }
+ return "", release.ErrCASConflict
+ }
+ return c.Store.Put(ctx, key, body, ifMatch)
+}
+
+// TestUpdateManifestCASReapplies: on a lost race the update must re-READ and
+// re-APPLY, not re-Put its stale copy — otherwise two contributors publishing
+// two ledgers at once is a lost update, and the loser never learns.
+func TestUpdateManifestCASReapplies(t *testing.T) {
+ st := &conflictOnce{Store: release.NewDirStore(t.TempDir())}
+ m, err := UpdateManifest(context.Background(), st, "bab", "platform", func(m *Manifest) error {
+ m.Public = true
+ if !slices.Contains(m.Ledgers, "carbon") {
+ m.Ledgers = append(m.Ledgers, "carbon")
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("update: %v", err)
+ }
+ if !st.lost {
+ t.Fatal("the fake never forced a CAS conflict")
+ }
+ if !slices.Equal(m.Ledgers, []string{"iron", "carbon"}) {
+ t.Fatalf("after a lost race = %+v, want the winner's iron preserved beside carbon", m)
+ }
+ if m.Title != "Other" {
+ t.Fatalf("title = %q, want the winner's title preserved", m.Title)
+ }
+}
+
+// TestUpdateManifestSurfacesLostRaces: contention that outlasts the retries
+// is reported, never swallowed — the console maps it to a retry-worthy 503.
+func TestUpdateManifestSurfacesLostRaces(t *testing.T) {
+ st := alwaysConflict{release.NewDirStore(t.TempDir())}
+ if _, err := UpdateManifest(context.Background(), st, "bab", "platform", func(m *Manifest) error {
+ m.Public = true
+ return nil
+ }); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("update under total contention = %v, want a CAS conflict", err)
+ }
+}
+
+// TestUpdateManifestApplyRefusalWritesNothing: apply is where a caller
+// enforces a rule only the STORED manifest can decide (the console's cap on
+// how many ledgers an app may publish). A refusal has to abort before the Put
+// — a loop that wrote first and reported the refusal afterwards would store
+// exactly the state it just called illegal.
+func TestUpdateManifestApplyRefusalWritesNothing(t *testing.T) {
+ ctx := context.Background()
+ st := release.NewDirStore(t.TempDir())
+ if err := WriteManifest(ctx, st, "bab", "platform",
+ Manifest{Public: true, Ledgers: []string{"carbon"}}); err != nil {
+ t.Fatal(err)
+ }
+ refused := errors.New("no")
+ if _, err := UpdateManifest(ctx, st, "bab", "platform", func(m *Manifest) error {
+ m.Ledgers = append(m.Ledgers, "prompts")
+ return refused
+ }); !errors.Is(err, refused) {
+ t.Fatalf("update = %v, want apply's own error returned untouched", err)
+ }
+ stored, err := ReadManifest(ctx, st, "bab", "platform")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Equal(stored.Ledgers, []string{"carbon"}) {
+ t.Fatalf("stored = %+v, want the refused change never written", stored)
+ }
+}
+
+type alwaysConflict struct{ release.Store }
+
+func (a alwaysConflict) Put(context.Context, string, []byte, string) (string, error) {
+ return "", release.ErrCASConflict
+}
diff --git a/internal/ledger/verify.go b/internal/ledger/verify.go
new file mode 100644
index 000000000..97cd9debb
--- /dev/null
+++ b/internal/ledger/verify.go
@@ -0,0 +1,99 @@
+package ledger
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type Getter func(ctx context.Context, key string) ([]byte, error)
+
+func StoreGetter(st release.Store) Getter {
+ return func(ctx context.Context, key string) ([]byte, error) {
+ b, _, err := st.Get(ctx, key)
+ return b, err
+ }
+}
+
+func HTTPGetter(client *http.Client, base string) Getter {
+ return func(ctx context.Context, key string) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/"+key, nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("ledger: GET %s: %s", key, resp.Status)
+ }
+ return io.ReadAll(io.LimitReader(resp.Body, MaxBlob+1))
+ }
+}
+
+type VerifyResult struct {
+ Count int
+ HeadSHA256 string
+}
+
+// Verify walks HEAD back to the genesis entry, re-hashing every stored
+// byte. It proves: HEAD's sha matches the head entry's bytes, every
+// prev_sha256 matches the previous entry's bytes, the walk terminates at
+// prev_key "", and the walked length equals HEAD's count.
+func Verify(ctx context.Context, get Getter, sqid, app, name string) (VerifyResult, error) {
+ hb, err := get(ctx, HeadKey(sqid, app, name))
+ if err != nil {
+ return VerifyResult{}, err
+ }
+ var head Head
+ if err := json.Unmarshal(hb, &head); err != nil {
+ return VerifyResult{}, fmt.Errorf("ledger: HEAD corrupt: %w", err)
+ }
+
+ // The walk follows keys the CHAIN supplies (HEAD.json's head_key, then
+ // each entry's prev_key), so those keys are exactly as trustworthy as the
+ // bytes under verification. Pinning them to this ledger's own entries/
+ // prefix is what stops a chain from grafting another ledger's history —
+ // or any other object in the bucket — onto its own and still verifying
+ // clean: every hash would agree, because the hashes are only ever
+ // checked against what the chain itself claims.
+ want := Prefix(sqid, app, name) + "entries/"
+
+ key, wantSHA := head.HeadKey, head.HeadSHA256
+ walked := 0
+ for key != "" {
+ if walked >= head.Count {
+ return VerifyResult{}, fmt.Errorf("ledger: chain longer than HEAD count %d", head.Count)
+ }
+ if !strings.HasPrefix(key, want) {
+ return VerifyResult{}, fmt.Errorf("ledger: %s: chain leaves ledger %q — key is outside %s", key, name, want)
+ }
+ eb, err := get(ctx, key)
+ if err != nil {
+ return VerifyResult{}, fmt.Errorf("ledger: %s: %w", key, err)
+ }
+ if got := SHA256Hex(eb); got != wantSHA {
+ return VerifyResult{}, fmt.Errorf("ledger: %s: sha %s, chain says %s — bytes were altered", key, got, wantSHA)
+ }
+ var e Entry
+ if err := json.Unmarshal(eb, &e); err != nil {
+ return VerifyResult{}, fmt.Errorf("ledger: %s corrupt: %w", key, err)
+ }
+ if e.Ledger != name {
+ return VerifyResult{}, fmt.Errorf("ledger: %s: entry names ledger %q, walking %q", key, e.Ledger, name)
+ }
+ key, wantSHA = e.PrevKey, e.PrevSHA256
+ walked++
+ }
+ if walked != head.Count {
+ return VerifyResult{}, fmt.Errorf("ledger: walked %d entries, HEAD count %d", walked, head.Count)
+ }
+ return VerifyResult{Count: walked, HeadSHA256: head.HeadSHA256}, nil
+}
diff --git a/internal/ledger/verify_test.go b/internal/ledger/verify_test.go
new file mode 100644
index 000000000..4139c2fab
--- /dev/null
+++ b/internal/ledger/verify_test.go
@@ -0,0 +1,136 @@
+package ledger
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func chainOf(t *testing.T, st release.Store, n int) {
+ t.Helper()
+ for i := range n {
+ at := func() time.Time { return fixedNow().Add(time.Duration(i) * time.Minute) }
+ if _, err := Append(context.Background(), st, "bab", "platform", "carbon",
+ "w", json.RawMessage(`{"i":`+string(rune('0'+i))+`}`), at); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestVerifyGoodChain(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ chainOf(t, st, 3)
+ res, err := Verify(context.Background(), StoreGetter(st), "bab", "platform", "carbon")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Count != 3 {
+ t.Fatalf("count = %d, want 3", res.Count)
+ }
+}
+
+func TestVerifyDetectsTamper(t *testing.T) {
+ dir := t.TempDir()
+ st := release.NewDirStore(dir)
+ chainOf(t, st, 3)
+ // Flip one byte in the OLDEST entry's stored file, behind the store's
+ // back — exactly the mutation a hash chain exists to catch.
+ keys, err := st.List(context.Background(), Prefix("bab", "platform", "carbon")+"entries/")
+ if err != nil || len(keys) != 3 {
+ t.Fatalf("entries: %v %v", keys, err)
+ }
+ p := filepath.Join(dir, filepath.FromSlash(keys[0]))
+ b, err := os.ReadFile(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b[len(b)-2] ^= 1
+ if err := os.WriteFile(p, b, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Verify(context.Background(), StoreGetter(st), "bab", "platform", "carbon"); err == nil {
+ t.Fatal("tampered chain verified clean")
+ }
+}
+
+// TestVerifyPinsTheWalkToItsOwnLedger: a verifier follows prev_key, and
+// prev_key is bytes the chain itself supplies — so a chain that points its
+// walk at ANOTHER ledger's entries (or at an object outside the ledger
+// prefix entirely) must fail, not quietly borrow that object's history and
+// report a clean verification. The count and the hashes can all be made to
+// agree; what cannot be forged is the entry's own ledger name and the prefix
+// its keys live under.
+func TestVerifyPinsTheWalkToItsOwnLedger(t *testing.T) {
+ ctx := context.Background()
+ st := release.NewDirStore(t.TempDir())
+ // A real two-entry chain in "iron", whose genesis we will graft onto a
+ // hand-built "carbon" chain.
+ if _, err := Append(ctx, st, "bab", "platform", "iron", "w",
+ json.RawMessage(`{"i":0}`), fixedNow); err != nil {
+ t.Fatal(err)
+ }
+ ib, _, err := st.Get(ctx, HeadKey("bab", "platform", "iron"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var ih Head
+ if err := json.Unmarshal(ib, &ih); err != nil {
+ t.Fatal(err)
+ }
+
+ // carbon's single entry chains to iron's genesis: every hash checks out,
+ // the walk terminates, and the count matches.
+ e := Entry{Schema: Schema, Ledger: "carbon", Writer: "w", TS: "2026-08-03T21:00:00Z",
+ Payload: json.RawMessage(`{"i":1}`), PrevKey: ih.HeadKey, PrevSHA256: ih.HeadSHA256}
+ eb, err := json.Marshal(e)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sha := SHA256Hex(eb)
+ key := EntryKey("bab", "platform", "carbon", e.TS, sha)
+ if _, err := st.Put(ctx, key, eb, ""); err != nil {
+ t.Fatal(err)
+ }
+ hb, err := json.Marshal(Head{Schema: Schema, UpdatedAt: e.TS, HeadKey: key, HeadSHA256: sha, Count: 2})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.Put(ctx, HeadKey("bab", "platform", "carbon"), hb, ""); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = Verify(ctx, StoreGetter(st), "bab", "platform", "carbon")
+ if err == nil {
+ t.Fatal("a chain walking into another ledger's entries verified clean")
+ }
+ if !strings.Contains(err.Error(), "carbon") {
+ t.Fatalf("error = %v, want it to name the ledger the walk left", err)
+ }
+}
+
+func TestVerifyOverHTTP(t *testing.T) {
+ dir := t.TempDir()
+ st := release.NewDirStore(dir)
+ chainOf(t, st, 2)
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _, err := st.Get(r.Context(), strings.TrimPrefix(r.URL.Path, "/"))
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Write(b)
+ }))
+ defer ts.Close()
+ res, err := Verify(context.Background(), HTTPGetter(ts.Client(), ts.URL), "bab", "platform", "carbon")
+ if err != nil || res.Count != 2 {
+ t.Fatalf("res=%+v err=%v", res, err)
+ }
+}
diff --git a/internal/release/qualified_test.go b/internal/release/qualified_test.go
index 50fe5071c..03dd8ebbf 100644
--- a/internal/release/qualified_test.go
+++ b/internal/release/qualified_test.go
@@ -23,8 +23,9 @@ func (e *erroringStore) Get(context.Context, string) ([]byte, string, error) {
func (e *erroringStore) Put(context.Context, string, []byte, string) (string, error) {
return "", e.err
}
-func (e *erroringStore) List(context.Context, string) ([]string, error) { return nil, e.err }
-func (e *erroringStore) Delete(context.Context, string) error { return e.err }
+func (e *erroringStore) List(context.Context, string) ([]string, error) { return nil, e.err }
+func (e *erroringStore) ListDirs(context.Context, string) ([]string, error) { return nil, e.err }
+func (e *erroringStore) Delete(context.Context, string) error { return e.err }
// legacyTripwire fails the test if anything asks the store for a key or
// prefix under one app's LEGACY unqualified prefix. It is how "the runtime
diff --git a/internal/release/scopedpromote_test.go b/internal/release/scopedpromote_test.go
index b9ced5060..fe89f1767 100644
--- a/internal/release/scopedpromote_test.go
+++ b/internal/release/scopedpromote_test.go
@@ -29,6 +29,11 @@ func (s trapStore) List(_ context.Context, prefix string) ([]string, error) {
return nil, nil
}
+func (s trapStore) ListDirs(_ context.Context, prefix string) ([]string, error) {
+ s.t.Fatalf("out-of-scope promote listed %s — it must refuse before touching the store", prefix)
+ return nil, nil
+}
+
func (s trapStore) Delete(_ context.Context, key string) error {
s.t.Fatalf("out-of-scope promote DELETED %s — the scope check is not a gate", key)
return nil
diff --git a/internal/release/store.go b/internal/release/store.go
index 094c63427..f8b5cbd7d 100644
--- a/internal/release/store.go
+++ b/internal/release/store.go
@@ -40,6 +40,14 @@ type Store interface {
Get(ctx context.Context, key string) ([]byte, string, error) // body, etag
Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) // new etag
List(ctx context.Context, prefix string) ([]string, error)
+ // ListDirs returns the immediate child "directories" of prefix — the
+ // delimiter-aware listing, so a caller that only wants the names one
+ // level down does not pay for every object beneath them. Each result is
+ // a FULL prefix ending in "/" (S3 CommonPrefixes semantics), never a
+ // bare name. A prefix that does not end in "/" is treated as though it
+ // did, so the two implementations answer identically. Objects at the
+ // level itself (files, not directories) are never included.
+ ListDirs(ctx context.Context, prefix string) ([]string, error)
Delete(ctx context.Context, key string) error
}
@@ -158,6 +166,42 @@ func (d *DirStore) List(_ context.Context, prefix string) ([]string, error) {
return out, err
}
+// ListDirs is DirStore's delimiter-aware listing: one directory read, no
+// walk. A missing directory lists empty, like a bucket prefix with nothing
+// under it. Only directories are returned, so a file sitting at this level
+// (ledgers.json among them) is never mistaken for a child prefix.
+func (d *DirStore) ListDirs(_ context.Context, prefix string) ([]string, error) {
+ if prefix != "" && !strings.HasSuffix(prefix, "/") {
+ prefix += "/"
+ }
+ dir := d.root
+ if trimmed := strings.TrimSuffix(prefix, "/"); trimmed != "" {
+ p, err := d.path(trimmed)
+ if err != nil {
+ return nil, err
+ }
+ dir = p
+ }
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ ents, err := os.ReadDir(dir)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var out []string
+ for _, e := range ents {
+ if !e.IsDir() {
+ continue
+ }
+ out = append(out, prefix+e.Name()+"/")
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
func (d *DirStore) Delete(_ context.Context, key string) error {
p, err := d.path(key)
if err != nil {
@@ -297,6 +341,33 @@ func (s *S3Store) List(ctx context.Context, prefix string) ([]string, error) {
return out, nil
}
+// ListDirs is the Delimiter:"/" listing: S3 rolls everything below one level
+// up into a CommonPrefix, so the response is bounded by the number of child
+// prefixes rather than by the total object count beneath them. Objects AT
+// this level come back in Contents, which this deliberately ignores.
+func (s *S3Store) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ if prefix != "" && !strings.HasSuffix(prefix, "/") {
+ prefix += "/"
+ }
+ var out []string
+ p := awss3.NewListObjectsV2Paginator(s.Client, &awss3.ListObjectsV2Input{
+ Bucket: &s.Bucket,
+ Prefix: &prefix,
+ Delimiter: aws.String("/"),
+ })
+ for p.HasMorePages() {
+ page, err := p.NextPage(ctx)
+ if err != nil {
+ return nil, err
+ }
+ for _, cp := range page.CommonPrefixes {
+ out = append(out, aws.ToString(cp.Prefix))
+ }
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
func (s *S3Store) Delete(ctx context.Context, key string) error {
_, err := s.Client.DeleteObject(ctx, &awss3.DeleteObjectInput{
Bucket: aws.String(s.Bucket),
diff --git a/internal/release/store_test.go b/internal/release/store_test.go
index a8c2bd78c..a7a5f7726 100644
--- a/internal/release/store_test.go
+++ b/internal/release/store_test.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
+ "strings"
"testing"
"time"
)
@@ -112,6 +113,65 @@ func TestDirStoreDelete(t *testing.T) {
}
}
+// TestDirStoreListDirs: the delimiter-aware listing returns the immediate
+// child prefixes and nothing else — not the objects beneath them (which is
+// the whole point: cost bounded by the number of children, not by their
+// histories), and not the FILES sitting at the same level, which is what
+// keeps a ledger's ledgers.json out of its own list of ledgers.
+func TestDirStoreListDirs(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ for _, k := range []string{
+ "ledgers/bab/titogo/carbon/HEAD.json",
+ "ledgers/bab/titogo/carbon/entries/20260803T000000Z-aaaaaaaaaaaa.json",
+ "ledgers/bab/titogo/carbon/entries/20260803T000001Z-bbbbbbbbbbbb.json",
+ "ledgers/bab/titogo/carbon/blobs/deadbeef",
+ "ledgers/bab/titogo/prompts/HEAD.json",
+ "ledgers/bab/titogo/ledgers.json", // a FILE at this level
+ "ledgers/bab/otherapp/elsewhere/HEAD.json",
+ } {
+ if _, err := s.Put(ctx, k, []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ got, err := s.ListDirs(ctx, "ledgers/bab/titogo/")
+ if err != nil {
+ t.Fatalf("ListDirs: %v", err)
+ }
+ want := []string{"ledgers/bab/titogo/carbon/", "ledgers/bab/titogo/prompts/"}
+ if len(got) != len(want) {
+ t.Fatalf("ListDirs = %v, want %v (no files, no grandchildren)", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("ListDirs = %v, want %v", got, want)
+ }
+ }
+ // A prefix without the trailing slash answers identically — the two
+ // implementations must not disagree about that.
+ bare, err := s.ListDirs(ctx, "ledgers/bab/titogo")
+ if err != nil || len(bare) != len(want) || bare[0] != want[0] {
+ t.Fatalf("ListDirs without a trailing slash = %v, %v; want %v", bare, err, want)
+ }
+ // One level up sees the apps, not the chains under them.
+ up, err := s.ListDirs(ctx, "ledgers/bab/")
+ if err != nil || len(up) != 2 || up[0] != "ledgers/bab/otherapp/" || up[1] != "ledgers/bab/titogo/" {
+ t.Fatalf("ListDirs one level up = %v, %v", up, err)
+ }
+ // A prefix with nothing under it lists empty, like an empty bucket
+ // prefix — not an error.
+ empty, err := s.ListDirs(ctx, "ledgers/nosuch/")
+ if err != nil || len(empty) != 0 {
+ t.Fatalf("ListDirs of a missing prefix = %v, %v; want empty and no error", empty, err)
+ }
+ // The root lists the top-level prefixes.
+ root, err := s.ListDirs(ctx, "")
+ if err != nil || len(root) != 1 || root[0] != "ledgers/" {
+ t.Fatalf("ListDirs of the root = %v, %v; want [ledgers/]", root, err)
+ }
+}
+
// TestS3StoreLive exercises the production store against a real bucket —
// only when CARLOS_TEST_S3_BUCKET is set; the type otherwise just compiles.
func TestS3StoreLive(t *testing.T) {
@@ -152,4 +212,31 @@ func TestS3StoreLive(t *testing.T) {
if err != nil || len(keys) != 1 || keys[0] != key {
t.Fatalf("List = %v, %v; want [%s]", keys, err, key)
}
+
+ // ListDirs against the same live bucket: the object written above sits
+ // one level below its parent prefix, so the parent's parent must see the
+ // child PREFIX (CommonPrefixes) and not the object itself.
+ parent := key[:strings.LastIndex(key, "/")+1] // .../<nanos>/
+ grandparent := parent[:strings.LastIndex(parent[:len(parent)-1], "/")+1]
+ dirs, err := s.ListDirs(ctx, grandparent)
+ if err != nil {
+ t.Fatalf("ListDirs: %v", err)
+ }
+ found := false
+ for _, d := range dirs {
+ if d == parent {
+ found = true
+ }
+ if d == key {
+ t.Fatalf("ListDirs returned an OBJECT key %q, want only child prefixes", d)
+ }
+ }
+ if !found {
+ t.Fatalf("ListDirs(%q) = %v, want %q among them", grandparent, dirs, parent)
+ }
+ // And at the object's own level there are no child prefixes at all.
+ leaf, err := s.ListDirs(ctx, parent)
+ if err != nil || len(leaf) != 0 {
+ t.Fatalf("ListDirs(%q) = %v, %v; want empty (the only child is an object)", parent, leaf, err)
+ }
}
diff --git a/main.go b/main.go
index eee1262a7..1ad7f2335 100644
--- a/main.go
+++ b/main.go
@@ -41,6 +41,8 @@ func main() {
runChannels(args)
case "rollback":
runRollback(args)
+ case "ledger":
+ RunLedger(args)
case "auth":
RunAuth(args)
case "accounts":
@@ -98,6 +100,12 @@ commands:
ship | promote | channels publish and promote releases (-account <sqid|name>)
promote climbs the ladder canary -> edge -> beta
-> stable; -hotfix is the recorded bypass
+ ledger append | head | list | publish | blob | verify —
+ the open-ledger primitive (carlos ledger -h for
+ the full usage); every sub but "verify -url"
+ goes through the console API, never bucket
+ credentials — verify -url needs no credentials
+ at all
release-keygen mint the deployment's root signing key, or with
-scope <rungs> -name <label> [-expires <dur>] a
SCOPED key that can only sign those rungs