carlos / platform Public

cli: carlos ledger — append/head/list/publish/blob via the console API, verify over public HTTPS

Adds the six carlos-ledger sub-verbs (spec 2026-08-03-open-ledgers-design.md
§2.5): append/head/list/publish/blob go through the console API only, exactly
as ship/promote/channels/rollback do in API mode — no bucket credentials in a
terminal. verify -url never resolves a session at all; it walks a published
chain over plain HTTPS via ledger.Verify/HTTPGetter, so anyone can check a
published ledger with no credential.

main.go's top-level switch is where "channels"/"promote"/etc are actually
dispatched (not cli.go, despite the task brief's assumption), so the new
"ledger" case and its usage() line land there too, alongside RunLedger and
the six runLedger* verbs in cli.go and the five ledger apiSession methods in
apimode.go.

Tests extend apimode_test.go, the file's existing pattern for this kind of
plumbing (there is no cli_test.go): apiSession-level tests pin each new
method's wire shape against an httptest fixture, and verb-level tests pin
runLedgerAppend/Head/List/Publish/Blob/Verify's console wiring, stdout
output, and (for verify) that it needs zero credentials or console session.
log.Fatal usage-error paths stay untested, matching this file's own stated
convention (captureStdout's doc comment).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5b970b8ec8f85547cede9ad8fe23a44f097a1d83 parent 660b249
4 files changed, +968 −0
  • apimode.go +111 −0
  • apimode_test.go +545 −0
  • cli.go +304 −0
  • main.go +8 −0
diff --git a/apimode.go b/apimode.go
index 6145af533..5bb5048db 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,115 @@ 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 sets which of an app's ledgers are served publicly (and
+// under what title) through the console. There is no noop/created
+// distinction to report — handleAPILedgerPublish answers every success with
+// the same "{}" body — so this stays error-only, like rollback.
+func (s *apiSession) ledgerPublish(ctx context.Context, account, app string, m ledger.Manifest) error {
+ status, body, err := s.postJSON(ctx, apiPath(account, app, "/ledgers/publish"), m)
+ if err != nil {
+ return err
+ }
+ if status != http.StatusOK {
+ return apiErrorFromBody(status, body)
+ }
+ return 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..7f28e7757 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,547 @@ 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)
+ }
+}
+
+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)
+ w.Write([]byte("{}"))
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ if err := s.ledgerPublish(context.Background(), "acct", "app",
+ ledger.Manifest{Public: true, Ledgers: []string{"carbon"}, Title: "T"}); err != nil {
+ t.Fatalf("ledgerPublish: %v", err)
+ }
+ 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)
+ }
+}
+
+// ---- 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)
+ w.Write([]byte("{}"))
+ }))
+ 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)
+ }
+ if !strings.Contains(buf.String(), "published titogo: carbon now public") {
+ t.Fatalf("stdout = %q", 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).
+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)
+ w.Write([]byte("{}"))
+ }))
+ 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: carbon no longer public") {
+ t.Fatalf("stdout = %q", 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..f89a99d79 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,307 @@ 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>...
+ set which ledgers this app serves
+ publicly, and under what title
+ (-public defaults to true; publish
+ -public=false unpublishes)
+ 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
+}
+
+func runLedgerAppend(args []string) {
+ fs := flag.NewFlagSet("ledger append", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ acctRaw, _ := accountFlags(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, _ := accountFlags(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, _ := accountFlags(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, _ := accountFlags(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}
+ if err := sess.ledgerPublish(context.Background(), *acctRaw, *app, m); err != nil {
+ log.Fatal(err)
+ }
+ if *public {
+ fmt.Fprintf(stdout, "published %s: %s now public\n", *app, strings.Join(names, ", "))
+ } else {
+ fmt.Fprintf(stdout, "unpublished %s: %s no longer public\n", *app, strings.Join(names, ", "))
+ }
+}
+
+func runLedgerBlob(args []string) {
+ fs := flag.NewFlagSet("ledger blob", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ acctRaw, _ := accountFlags(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/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