carlos / platform Public

spec + plan: open ledgers — status.carlosframework.com's full ledger set on a platform primitive (#31)

* spec: open ledgers — a platform primitive, and the CARLOS ledger set

Design for status.carlosframework.com's full ledger set (Carbon,
Contributors, Prompts, Access, Roadmap, Milestones — no Money, no
Music) built on a first-class hash-chained ledger-stream primitive any
CARLOS app can use. Writes go through the console API (no bucket creds
on contributor machines), serving extends the status edge route behind
a StatusNamed-style per-app opt-in, and Eleven's redaction semantics
carry over whole as the normative section — detector chain, fail-closed
rebuild, personal budgets, tombstone vocabulary, mandatory
centralised-service withholding. Includes the /end-session skill
(distributed via carlosframework/skills) and Haiku prompt
summarisation. Approved in discussion (Paul, 2026-08-03).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* plan: open-ledgers primitive — chain library, console API, CLI verbs, edge serving

Plan 1 of the spec's rollout: internal/ledger (types/keys, CAS append,
verify over store or public HTTPS, blobs, opt-in manifest), the console
ledger API as the only outside write path, carlos ledger CLI verbs, and
/ledgers/ serving on the status host fail-closed behind the manifest.
Follow-on plans named for pages, accruals, the redaction engine, the
access collector, and the end-session skill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 114255c8b3c7216b36f8f5be1c304bd1645868ff parent cc93302
2 files changed, +2298 −0
  • docs/superpowers/plans/2026-08-03-open-ledgers-primitive.md +1739 −0
  • docs/superpowers/specs/2026-08-03-open-ledgers-design.md +559 −0
diff --git a/docs/superpowers/plans/2026-08-03-open-ledgers-primitive.md b/docs/superpowers/plans/2026-08-03-open-ledgers-primitive.md
new file mode 100644
index 000000000..444b1997c
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-03-open-ledgers-primitive.md
@@ -0,0 +1,1739 @@
+# Open Ledgers Primitive Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** The hash-chained ledger-stream primitive from
+`docs/superpowers/specs/2026-08-03-open-ledgers-design.md` §2/§4 — chain
+library, console API write path, `carlos ledger` CLI verbs, and edge
+serving behind the per-app opt-in manifest.
+
+**Architecture:** Append-only chained JSON entries in the deployment bucket
+under `ledgers/<sqid>/<app>/<ledger>/`, written CAS-on-HEAD through the
+existing `release.Store`; writes reach the bucket only via the console CLI
+API (member-authorized by `apiAccountApp`); the edge's `Kind=="status"`
+handler gains a `/ledgers/` prefix gated fail-closed on the app's
+`ledgers.json` manifest. The chain hashes **stored entry bytes verbatim**
+(no canonicalization pass at verify time — the writer marshals once, the
+hash commits to exactly those bytes).
+
+**Tech Stack:** Go stdlib + existing repo packages only (`internal/release`
+for the store, `internal/console` for the API, `internal/edge` for
+serving). No new dependencies.
+
+**This is Plan 1 of the spec's rollout (§9).** Follow-on plans, in order,
+each its own document: (2) status-site ledger pages + Roadmap/Milestones
+chains + discovery index, (3) Carbon + Contributors accrual, (4) the
+prompt-registry port with the §6 redaction engine + Haiku, (5) the SSM
+access collector (after its research task), (6) the `/end-session` skill PR
+to `carlosframework/skills`.
+
+## Global Constraints
+
+- **Wire shapes are additive forever**: every published JSON carries
+ `"schema": 1`; anything written before a change must still parse.
+- All names on the wire: ledger names match `^[a-z0-9][a-z0-9-]{0,63}$`.
+- Payload cap: **64 KiB** per entry payload; blob cap **4 MiB** per upload.
+- Ledgers are keyed `(account-sqid, app)`, never app name alone.
+- Writes stamp `writer` from the **authenticated identity server-side**,
+ never from client input.
+- Build gates before any commit: `go build ./... && go vet ./... &&
+ go test ./... -count=1` all clean; `gofmt -l` clean for touched files.
+ Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`
+ (disable the sandbox if the toolchain reports "read-only file system").
+- Commit messages: area-prefixed (`ledger:`, `console:`, `edge:`, `cli:`),
+ ending with `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
+
+---
+
+### Task 1: Ledger types, keys, and validation
+
+**Files:**
+- Create: `internal/ledger/ledger.go`
+- Test: `internal/ledger/ledger_test.go`
+
+**Interfaces:**
+- Consumes: nothing (leaf package; imports only stdlib).
+- Produces (later tasks rely on these exact names):
+
+```go
+package ledger
+
+const (
+ Schema = 1
+ MaxPayload = 64 << 10 // bytes, one entry's payload
+ MaxBlob = 4 << 20 // bytes, one blob upload
+)
+
+type Entry struct {
+ Schema int `json:"schema"`
+ Ledger string `json:"ledger"`
+ Writer string `json:"writer"`
+ TS string `json:"ts"` // RFC3339, always UTC
+ 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"`
+}
+
+func ValidName(s string) bool // ^[a-z0-9][a-z0-9-]{0,63}$
+func Prefix(sqid, app, name string) string // "ledgers/<sqid>/<app>/<name>/"
+func HeadKey(sqid, app, name string) string // Prefix + "HEAD.json"
+func ManifestKey(sqid, app string) string // "ledgers/<sqid>/<app>/ledgers.json"
+func BlobKey(sqid, app, name, sha string) string // Prefix + "blobs/<sha>"
+func EntryKey(sqid, app, name, ts, sha string) string // Prefix + "entries/<ts-compact>-<sha[:12]>.json"
+func SHA256Hex(b []byte) string
+```
+
+`EntryKey`'s `ts-compact` strips `-` and `:` from the RFC3339 ts
+(`2026-08-03T21:04:00Z` → `20260803T210400Z`), so keys sort
+chronologically under `List`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+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)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/ledger/ -run 'TestValidName|TestKeys' -count=1`
+Expected: FAIL (package does not exist yet / undefined functions).
+
+- [ ] **Step 3: Write minimal implementation**
+
+```go
+// 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
+)
+
+// (Entry, Head, Manifest structs exactly as in the Produces block above.)
+
+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[:])
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/ledger/ -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/ledger/ledger.go internal/ledger/ledger_test.go
+git commit -m "ledger: types, keys, and name validation for the open-ledger stream
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: Append — CAS on HEAD
+
+**Files:**
+- Create: `internal/ledger/append.go`
+- Test: `internal/ledger/append_test.go`
+
+**Interfaces:**
+- Consumes: Task 1's types/keys; `release.Store`, `release.ErrCASConflict`,
+ `release.NewDirStore` (`internal/release/store.go` — `Get` returns
+ `(body, etag, error)` with not-found wrapping `os.ErrNotExist`; `Put`
+ with `ifMatch ""` is create-only, non-empty replace-only).
+- Produces:
+
+```go
+type Appended struct {
+ Key string `json:"key"`
+ SHA256 string `json:"sha256"`
+ Count int `json:"count"`
+}
+
+// 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)
+```
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package ledger
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "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")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/ledger/ -run TestAppend -count=1`
+Expected: FAIL — `undefined: Append`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```go
+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
+
+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", appendAttempts, lastErr)
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/ledger/ -count=1`
+Expected: PASS (all Task 1 + Task 2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/ledger/append.go internal/ledger/append_test.go
+git commit -m "ledger: Append — chained entries advancing HEAD by CAS
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: Verify — walk and re-hash the chain
+
+**Files:**
+- Create: `internal/ledger/verify.go`
+- Test: `internal/ledger/verify_test.go`
+
+**Interfaces:**
+- Consumes: Tasks 1–2.
+- Produces:
+
+```go
+// Getter abstracts "fetch one key's bytes" so Verify runs identically
+// over a release.Store (StoreGetter) or a public HTTPS base URL
+// (HTTPGetter) — an open ledger anyone can verify without credentials.
+type Getter func(ctx context.Context, key string) ([]byte, error)
+
+func StoreGetter(st release.Store) Getter
+func HTTPGetter(client *http.Client, base string) Getter // GET base+"/"+key
+
+type VerifyResult struct {
+ Count int
+ HeadSHA256 string
+}
+
+func Verify(ctx context.Context, get Getter, sqid, app, name string) (VerifyResult, error)
+```
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+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")
+ }
+}
+
+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)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/ledger/ -run TestVerify -count=1`
+Expected: FAIL — `undefined: Verify` (and `StoreGetter`, `HTTPGetter`).
+
+- [ ] **Step 3: Write minimal implementation**
+
+```go
+package ledger
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "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)
+ }
+
+ 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)
+ }
+ 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)
+ }
+ 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
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/ledger/ -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/ledger/verify.go internal/ledger/verify_test.go
+git commit -m "ledger: Verify — re-hash the chain from HEAD to genesis, store or HTTP
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: Blobs and the opt-in manifest
+
+**Files:**
+- Create: `internal/ledger/blob.go`
+- Create: `internal/ledger/manifest.go`
+- Test: `internal/ledger/blob_test.go`, `internal/ledger/manifest_test.go`
+
+**Interfaces:**
+- Consumes: Tasks 1–2.
+- Produces:
+
+```go
+// PutBlob stores body content-addressed. Create-only; an ErrCASConflict
+// means the identical content is already there (same sha, same bytes) —
+// success, idempotent, matching Ship's artifact semantics.
+func PutBlob(ctx context.Context, st release.Store, sqid, app, name string, body []byte) (sha string, err error)
+
+// ReadManifest returns (Manifest{}, nil-wrapped os.ErrNotExist) when absent
+// — callers treat missing as "not public".
+func ReadManifest(ctx context.Context, st release.Store, sqid, app string) (Manifest, error)
+
+// WriteManifest read-etag-then-Puts (last-writer-wins with one retry, the
+// putOverwrite pattern from internal/status/fleet.go).
+func WriteManifest(ctx context.Context, st release.Store, sqid, app string, m Manifest) error
+```
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// blob_test.go
+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")
+ }
+}
+```
+
+```go
+// manifest_test.go
+package ledger
+
+import (
+ "context"
+ "errors"
+ "os"
+ "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")
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/ledger/ -run 'TestPutBlob|TestManifest' -count=1`
+Expected: FAIL — undefined functions.
+
+- [ ] **Step 3: Write minimal implementations**
+
+```go
+// blob.go
+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
+}
+```
+
+```go
+// manifest.go
+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 is read-etag-then-Put with one retry on a lost race —
+// the manifest is small owner-intent state where last-writer-wins is
+// correct (the putOverwrite pattern, internal/status/fleet.go).
+func WriteManifest(ctx context.Context, st release.Store, sqid, app string, m Manifest) error {
+ m.Schema = Schema
+ body, err := json.Marshal(m)
+ if err != nil {
+ return err
+ }
+ key := ManifestKey(sqid, app)
+ for attempt := 0; ; attempt++ {
+ _, etag, err := st.Get(ctx, key)
+ ifMatch := etag
+ if errors.Is(err, os.ErrNotExist) {
+ ifMatch = ""
+ } else if err != nil {
+ return err
+ }
+ _, err = st.Put(ctx, key, body, ifMatch)
+ if err == nil {
+ return nil
+ }
+ if !errors.Is(err, release.ErrCASConflict) || attempt >= 1 {
+ return err
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/ledger/ -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/ledger/blob.go internal/ledger/manifest.go \
+ internal/ledger/blob_test.go internal/ledger/manifest_test.go
+git commit -m "ledger: content-addressed blobs and the per-app opt-in manifest
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: Console API — the only write path from outside
+
+**Files:**
+- Create: `internal/console/apiledger.go`
+- Modify: `internal/console/authcli.go:176-180` (add routes beside the
+ existing `/api/cli/apps/...` registrations)
+- Test: `internal/console/apiledger_test.go`
+
+**Interfaces:**
+- Consumes: `s.apiAccountApp(w, r)` (`apicli.go:41` — returns
+ `(addr string, acct *Account, app string, ok bool)`, PathValue segments
+ `{account}`/`{app}`, byte-identical 404 for unknown/not-yours),
+ `jsonError(w, status, msg)` (`authcli.go:239`), `s.cfg.Releases`
+ (`release.Store`, `server.go:124`), and all of Tasks 1–4.
+- Produces the HTTP surface Task 6's CLI calls:
+
+```
+POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append
+ body: {"payload": <any JSON ≤ 64KiB>}
+ 200: {"key","sha256","count"} (ledger.Appended)
+GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head
+ 200: the HEAD.json object 404: no such chain
+POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs
+ body: raw bytes ≤ 4MiB → 200: {"sha256"}
+GET /api/cli/apps/{account}/{app}/ledgers
+ 200: {"manifest": <ledgers.json or null>, "ledgers": ["carbon", ...]}
+POST /api/cli/apps/{account}/{app}/ledgers/publish
+ body: {"public": bool, "ledgers": [...], "title": "..."} → 200: {}
+```
+
+Writer is stamped from `addr` server-side. Membership is member-level —
+exactly what `apiAccountApp` already grants (it's the same gate promote
+uses). Writes do NOT check the public flag (spec §2.3: opt-in gates
+serving, not writing).
+
+- [ ] **Step 1: Write the failing test**
+
+Follow `apicli_test.go`'s fixture pattern exactly (`fx := newFixture(t)`
+style — read `internal/console/apicli_test.go:1-110` first and reuse its
+helpers for server, token, and account/app seeding; the fixture already
+seeds account `bab` with app `titogo` and a member token).
+
+```go
+package console
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/ledger"
+)
+
+// postJSONBody: small local helper mirroring apicli_test.go's getJSON but
+// for POSTs with a body and a bearer token; add it beside the existing
+// helpers if none exists yet.
+
+func TestAPILedgerAppendAndHead(t *testing.T) {
+ fx := newFixture(t) // whatever apicli_test.go's fixture constructor is named
+ token := fx.memberToken(t)
+
+ status, out := postJSONBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append",
+ token, `{"payload":{"kg_central":1}}`)
+ if status != http.StatusOK {
+ t.Fatalf("append: %d %s", status, out)
+ }
+ var a ledger.Appended
+ if err := json.Unmarshal(out, &a); err != nil || a.Count != 1 {
+ t.Fatalf("%s %v", out, err)
+ }
+
+ status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/head", token)
+ if status != http.StatusOK {
+ t.Fatalf("head: %d %s", status, out)
+ }
+ var h ledger.Head
+ if err := json.Unmarshal(out, &h); err != nil || h.HeadKey != a.Key {
+ t.Fatalf("%s %v", out, err)
+ }
+
+ // The writer is the token's identity, not anything the client sent.
+ eb, _, err := fx.releases.Get(t.Context(), a.Key) // fx's release.Store
+ if err != nil {
+ t.Fatal(err)
+ }
+ var e ledger.Entry
+ if err := json.Unmarshal(eb, &e); err != nil {
+ t.Fatal(err)
+ }
+ if e.Writer == "" || strings.Contains(string(out), `"writer_override"`) {
+ t.Fatalf("writer not stamped server-side: %+v", e)
+ }
+}
+
+func TestAPILedgerAuthz(t *testing.T) {
+ fx := newFixture(t)
+ // No token → 401; someone else's account/app → the byte-identical 404
+ // apiAccountApp already produces (assert both bodies equal, the
+ // apicli_test.go TestAPIIdentical404s pattern).
+ status, _ := postJSONBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append",
+ "", `{"payload":1}`)
+ if status != http.StatusUnauthorized {
+ t.Fatalf("no token: %d", status)
+ }
+}
+
+func TestAPILedgerRejects(t *testing.T) {
+ fx := newFixture(t)
+ token := fx.memberToken(t)
+ // Bad ledger name in the path.
+ status, _ := postJSONBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/Not%20Valid/append",
+ token, `{"payload":1}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("bad name: %d", status)
+ }
+ // Oversized payload.
+ big := `{"payload":"` + strings.Repeat("x", ledger.MaxPayload) + `"}`
+ status, _ = postJSONBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append",
+ token, big)
+ if status != http.StatusBadRequest && status != http.StatusRequestEntityTooLarge {
+ t.Fatalf("oversize: %d", status)
+ }
+}
+
+func TestAPILedgerPublishAndList(t *testing.T) {
+ fx := newFixture(t)
+ token := fx.memberToken(t)
+ postJSONBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append",
+ token, `{"payload":{"n":1}}`)
+ status, _ := postJSONBody(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", status)
+ }
+ status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token)
+ if status != http.StatusOK || !bytes.Contains(out, []byte(`"carbon"`)) {
+ t.Fatalf("list: %d %s", status, out)
+ }
+}
+
+func TestAPILedgerBlob(t *testing.T) {
+ fx := newFixture(t)
+ token := fx.memberToken(t)
+ req, _ := http.NewRequest(http.MethodPost,
+ fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/prompts/blobs",
+ bytes.NewReader([]byte("corpus")))
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := fx.ts.Client().Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("blob: %d", resp.StatusCode)
+ }
+}
+```
+
+(Adjust fixture/helper names to what `apicli_test.go` actually defines —
+the test author reads that file first; the assertions above are the
+contract.)
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/console/ -run TestAPILedger -count=1`
+Expected: FAIL — 404s (routes not registered) / undefined helpers.
+
+- [ ] **Step 3: Write the handlers and register routes**
+
+`internal/console/apiledger.go`:
+
+```go
+// 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.
+// Spec: docs/superpowers/specs/2026-08-03-open-ledgers-design.md §2.3.
+package console
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/ledger"
+)
+
+func (s *Server) handleAPILedgerAppend(w http.ResponseWriter, r *http.Request) {
+ addr, 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
+ }
+ 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
+ }
+ a, err := ledger.Append(r.Context(), s.cfg.Releases, acct.Sqid, app, name, addr,
+ in.Payload, time.Now)
+ if err != nil {
+ log.Printf("console: ledger append %s/%s/%s: %v", acct.Sqid, app, name, err)
+ jsonError(w, http.StatusBadRequest, err.Error())
+ 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
+ }
+ b, _, err := s.cfg.Releases.Get(r.Context(), ledger.HeadKey(acct.Sqid, app, name))
+ if errors.Is(err, os.ErrNotExist) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "could not read ledger head")
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(b)
+}
+
+func (s *Server) handleAPILedgerBlob(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
+ }
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, ledger.MaxBlob))
+ if err != nil {
+ jsonError(w, http.StatusRequestEntityTooLarge, "blob exceeds the 4 MiB cap")
+ return
+ }
+ sha, err := ledger.PutBlob(r.Context(), s.cfg.Releases, acct.Sqid, app, name, body)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ 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
+ }
+ m, err := ledger.ReadManifest(r.Context(), 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:
+ jsonError(w, http.StatusInternalServerError, "could not read ledger manifest")
+ return
+ }
+ // Distinct ledger names, derived from the HEAD keys under this app.
+ keys, err := s.cfg.Releases.List(r.Context(), "ledgers/"+acct.Sqid+"/"+app+"/")
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "could not list ledgers")
+ return
+ }
+ var names []string
+ for _, k := range keys {
+ if !strings.HasSuffix(k, "/HEAD.json") {
+ continue
+ }
+ rest := strings.TrimPrefix(k, "ledgers/"+acct.Sqid+"/"+app+"/")
+ names = append(names, strings.TrimSuffix(rest, "/HEAD.json"))
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"manifest": manifest, "ledgers": names})
+}
+
+func (s *Server) handleAPILedgerPublish(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ 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
+ }
+ }
+ if err := ledger.WriteManifest(r.Context(), s.cfg.Releases, acct.Sqid, app, in); err != nil {
+ log.Printf("console: ledger publish %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusInternalServerError, "could not write manifest")
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte("{}"))
+}
+```
+
+Registration in `authcli.go`, directly under the line registering
+`POST /api/cli/apps/{account}/{app}/releases` (line ~180):
+
+```go
+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)
+```
+
+(`/ledgers/publish` must be registered — Go 1.22 mux prefers the literal
+`publish` segment over `{ledger}` wildcards, so ordering is not
+load-bearing, but keep the literal route adjacent for readability. A
+ledger named `publish` is unreachable by construction; `ValidName` allows
+it, so document in the handler comment that `publish` and `head` are
+effectively reserved words — acceptable, they're our own API's names.)
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/console/ -count=1`
+Expected: PASS (new tests and every pre-existing console test).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/apiledger.go internal/console/apiledger_test.go internal/console/authcli.go
+git commit -m "console: ledger CLI API — append/head/blobs/list/publish, member-gated
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: `carlos ledger` CLI verbs
+
+**Files:**
+- Modify: `cli.go` (subcommand dispatch — follow how `carlos channels`
+ dispatches and prints; usage text lives beside the other verbs)
+- Modify: `apimode.go` (add `apiSession` methods beside
+ `channels`/`promote`, using the existing `getJSON`/`postJSON`/`apiPath`
+ helpers at `apimode.go:167-265`)
+- Test: extend whatever test file covers CLI verb plumbing if one exists
+ (check `cli_test.go`); the HTTP behaviours are already covered by Task
+ 5's console tests and Task 3's `TestVerifyOverHTTP`, so CLI-side tests
+ cover argument validation only.
+
+**Interfaces:**
+- Consumes: `resolveMode(consoleFlag)` → `*apiSession` (`apimode.go:59`),
+ `apiPath(account, app, tail)` (`apimode.go:171`),
+ `(s *apiSession).getJSON/postJSON` (`apimode.go:212,237`);
+ `ledger.Verify`, `ledger.HTTPGetter`, `ledger.Appended`, `ledger.Head`.
+- Produces (user-facing surface):
+
+```
+carlos ledger append -app <a> [-account <acct>] <ledger> <entry.json|->
+carlos ledger head -app <a> [-account <acct>] <ledger>
+carlos ledger list -app <a> [-account <acct>]
+carlos ledger publish -app <a> [-account <acct>] [-public=true|false] [-title <t>] <ledger>...
+carlos ledger blob -app <a> [-account <acct>] <ledger> <file>...
+carlos ledger verify -app <a> -account <acct> -url <https://status-host> <ledger>
+```
+
+- [ ] **Step 1: Add `apiSession` methods**
+
+```go
+// apimode.go — beside channels/promote/rollback/ship.
+
+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)
+}
+
+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)
+}
+
+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
+}
+
+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) — a small direct http.Request
+// following ship's bearer/URL plumbing, body ≤ ledger.MaxBlob.
+func (s *apiSession) ledgerBlob(ctx context.Context, account, app, name string, body []byte) (string, error) {
+ // build request against s's base URL + apiPath(account, app,
+ // "/ledgers/"+url.PathEscape(name)+"/blobs"), bearer auth as
+ // getJSON does, decode {"sha256"}.
+}
+```
+
+(The `ledgerBlob` body follows exactly how `getJSON` builds its request —
+same base-URL field, same bearer header; read `apimode.go:212-236` and
+mirror it with `http.MethodPost` and a `bytes.Reader`.)
+
+- [ ] **Step 2: Add the `carlos ledger` dispatch in cli.go**
+
+Follow the existing verb dispatch shape (find where `"channels"` is
+dispatched in `cli.go` and add `"ledger"` beside it). The subcommand
+handler:
+
+```go
+// runLedger dispatches carlos ledger <sub> ... Every sub except verify
+// -url talks to the console API (no bucket credentials in a terminal —
+// spec §2.3). verify -url needs no credentials at all: it walks the
+// public chain over HTTPS, so ANYONE can check a published ledger.
+func runLedger(args []string) error {
+ if len(args) < 1 {
+ return errors.New(ledgerUsage)
+ }
+ sub, rest := args[0], args[1:]
+ fs := flag.NewFlagSet("ledger "+sub, flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ account := fs.String("account", "", "account sqid or name (default: sole membership)")
+ urlBase := fs.String("url", "", "verify only: public status-host base URL")
+ public := fs.Bool("public", true, "publish only: serve these ledgers publicly")
+ title := fs.String("title", "", "publish only: display title")
+ consoleFlag := fs.String("console", "", "console origin override")
+ fs.Parse(rest)
+ // ... per-sub: resolve session via resolveMode(*consoleFlag) (skip
+ // for verify -url), read entry JSON from file-or-stdin for append,
+ // print results as the other verbs do (aligned text, not raw JSON,
+ // except `head` which prints the head JSON verbatim).
+ // verify: g := ledger.HTTPGetter(http.DefaultClient, strings.TrimRight(*urlBase, "/"))
+ // res, err := ledger.Verify(ctx, g, *account, *app, name)
+ // → "verified: N entries, head <sha12>" or the error.
+}
+```
+
+For `append`, the entry argument `-` reads stdin; anything else is a file
+path. Validate the payload parses as JSON client-side before sending (the
+server re-checks).
+
+- [ ] **Step 3: Build and run the full suite**
+
+Run: `go build ./... && go vet ./... && go test ./... -count=1`
+Expected: all clean. Also run `gofmt -l cli.go apimode.go internal/ledger internal/console` → no output.
+
+- [ ] **Step 4: Smoke the verbs against a local console**
+
+If the repo has an existing local-console harness (check for a dev target
+or test main), exercise `carlos ledger append/head/list` against it;
+otherwise rely on the console tests (Task 5) plus `-h` output review:
+`go run . ledger -h` must list every sub with the usage above.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cli.go apimode.go
+git commit -m "cli: carlos ledger — append/head/list/publish/blob via the console API, verify over public HTTPS
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 7: Edge serving — `/ledgers/` behind the opt-in manifest
+
+**Files:**
+- Modify: `internal/edge/status.go` (extend `statusHandler`)
+- Test: `internal/edge/status_test.go` (extend — read the existing tests
+ first; they already model the fake-clock/tiny-TTL pattern per
+ `status.go:70-74`)
+
+**Interfaces:**
+- Consumes: `statusHandler.get(ctx, key, ctype)` (`status.go:113` — the
+ cache contract: fresh-hit, stale-on-error, 503 only when nothing
+ cached), `ledger.ManifestKey`, `ledger.Manifest`.
+- Produces: public GETs on the status host:
+
+```
+/ledgers/<sqid>/<app>/ledgers.json the opt-in manifest itself
+/ledgers/<sqid>/<app>/<ledger>/HEAD.json
+/ledgers/<sqid>/<app>/<ledger>/entries/<...>.json
+/ledgers/<sqid>/<app>/<ledger>/blobs/<sha> application/octet-stream
+```
+
+All 404 unless the app's manifest exists, parses, and has
+`"public": true` — checked through the same 60s cache, **fail-closed**
+(manifest unreadable = not public). The URL path maps 1:1 onto the bucket
+key, which is what makes `carlos ledger verify -url` work unchanged.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package edge
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/ledger"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func seedLedger(t *testing.T, st release.Store, public bool) string {
+ t.Helper()
+ a, err := ledger.Append(context.Background(), 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.Fatal(err)
+ }
+ if err := ledger.WriteManifest(context.Background(), st, "bab", "platform",
+ ledger.Manifest{Public: public, Ledgers: []string{"carbon"}, Title: "CARLOS"}); err != nil {
+ t.Fatal(err)
+ }
+ return a.Key
+}
+
+func TestLedgerServingPublic(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ entryKey := seedLedger(t, st, true)
+ h := newStatusHandler(st)
+
+ for _, path := range []string{
+ "/ledgers/bab/platform/ledgers.json",
+ "/ledgers/bab/platform/carbon/HEAD.json",
+ "/" + entryKey,
+ } {
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
+ if rr.Code != http.StatusOK {
+ t.Errorf("GET %s = %d, want 200", path, rr.Code)
+ }
+ if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
+ t.Errorf("GET %s Content-Type = %q", path, ct)
+ }
+ }
+}
+
+func TestLedgerServingFailClosed(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ seedLedger(t, st, false) // chain exists, manifest says NOT public
+ h := newStatusHandler(st)
+
+ for _, path := range []string{
+ "/ledgers/bab/platform/carbon/HEAD.json",
+ "/ledgers/bab/platform/ledgers.json",
+ "/ledgers/nope/nothing/carbon/HEAD.json", // no manifest at all
+ "/ledgers/bab/platform/carbon/../../secrets", // traversal shape
+ "/ledgers/bab", // too-short path
+ } {
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
+ if rr.Code != http.StatusNotFound {
+ t.Errorf("GET %s = %d, want 404", path, rr.Code)
+ }
+ }
+}
+
+func TestLedgerServingNeverLeavesPrefix(t *testing.T) {
+ st := release.NewDirStore(t.TempDir())
+ seedLedger(t, st, true)
+ // A public manifest must still not open ANY key outside this app's
+ // own ledgers/ subtree.
+ if _, err := st.Put(context.Background(), "status/fleet.json", []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ h := newStatusHandler(st)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
+ "/ledgers/bab/platform/%2e%2e/%2e%2e/%2e%2e/status/fleet.json", nil))
+ if rr.Code == http.StatusOK {
+ t.Fatal("escaped the app's ledger subtree")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/edge/ -run TestLedgerServing -count=1`
+Expected: FAIL — currently every `/ledgers/...` path 404s via the
+`statusRoutes` map miss, so `TestLedgerServingPublic` fails (the
+fail-closed cases pass trivially; that's fine — the positive case drives
+the change).
+
+- [ ] **Step 3: Implement**
+
+Extend `ServeHTTP` in `status.go` — the fixed map stays for the three
+fleet objects; `/ledgers/` gets a prefix branch:
+
+```go
+func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ p := path.Clean(r.URL.Path)
+ if strings.HasPrefix(p, "/ledgers/") {
+ h.serveLedger(w, r, p)
+ return
+ }
+ rt, ok := statusRoutes[p]
+ // ... unchanged ...
+}
+
+// serveLedger maps /ledgers/<sqid>/<app>/... 1:1 onto the bucket key —
+// that identity is what lets `carlos ledger verify -url` walk the public
+// chain with the same keys the store uses. Fail-closed: no readable
+// manifest with public:true → 404, indistinguishable from "no such app".
+func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p string) {
+ // path.Clean above already collapsed dot segments; require the
+ // canonical shape and refuse anything that still looks escaped.
+ parts := strings.Split(strings.TrimPrefix(p, "/"), "/") // ["ledgers", sqid, app, ...]
+ if len(parts) < 4 || parts[1] == "" || parts[2] == "" {
+ http.NotFound(w, r)
+ return
+ }
+ for _, seg := range parts {
+ if seg == "" || seg == "." || seg == ".." {
+ http.NotFound(w, r)
+ return
+ }
+ }
+ sqid, app := parts[1], parts[2]
+
+ mb, _, err := h.get(r.Context(), ledger.ManifestKey(sqid, app), "application/json")
+ if err != nil {
+ http.NotFound(w, r) // fail-closed: unreadable manifest = not public
+ return
+ }
+ var m ledger.Manifest
+ if err := json.Unmarshal(mb, &m); err != nil || !m.Public {
+ http.NotFound(w, r)
+ return
+ }
+
+ key := strings.TrimPrefix(p, "/")
+ ctype := "application/json"
+ if !strings.HasSuffix(key, ".json") {
+ ctype = "application/octet-stream" // blobs
+ }
+ body, ctype, err := h.get(r.Context(), key, ctype)
+ if err != nil {
+ http.NotFound(w, r) // a missing entry is a 404, not a 503 — the
+ return // page-level 503 contract is for the fleet page's own objects
+ }
+ w.Header().Set("Content-Type", ctype)
+ w.Write(body)
+}
+```
+
+Note the cache reuse: the manifest ride the same `h.get` 60s TTL, so
+flipping `public` false takes at most one TTL to take effect — same
+latency contract as every other status object. Add
+`"github.com/carlosframework/platform/internal/ledger"` and
+`"strings"`/`"encoding/json"` to imports.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/edge/ -count=1`
+Expected: PASS — new tests and all pre-existing edge tests (the three
+fleet routes must be entirely unaffected).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/edge/status.go internal/edge/status_test.go
+git commit -m "edge: serve /ledgers/ on the status host, fail-closed behind the opt-in manifest
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 8: Full sweep and docs touch
+
+**Files:**
+- Modify: `CLAUDE.md` (one line in the build/test section is NOT needed —
+ no build change; instead add `carlos ledger` to any CLI verb list the
+ repo docs keep, if one exists — grep `docs/` for "carlos promote" to
+ find the inventory doc and add `ledger` beside it)
+- No code files.
+
+- [ ] **Step 1: Full gates**
+
+Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/ledger internal/console internal/edge cli.go apimode.go`
+Expected: all clean, gofmt prints nothing.
+
+- [ ] **Step 2: Grep for the CLI inventory doc and update it**
+
+Run: `grep -rln "carlos promote" docs/ --include="*.md" | head -3`
+Add a `carlos ledger` line matching the document's existing style wherever
+the verb set is enumerated (skip silently if no such inventory exists —
+do not invent a new doc).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add -A
+git commit -m "docs: carlos ledger joins the CLI verb inventory
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+## Self-review notes (already applied)
+
+- **Spec coverage (Plan 1 scope = spec §2 + §4.1/§4.2):** §2.1 layout →
+ Task 1; §2.2 CAS → Task 2; §2.5 verify incl. `-url` → Tasks 3+6; §2.4
+ blobs → Task 4; §2.3 console API + member authz + server-stamped writer
+ + size caps → Task 5; §4.1 opt-in manifest → Tasks 4+5; §2.6 serving
+ fail-closed → Task 7. Deliberately NOT here (later plans): pages and
+ discovery index (§4.3/§4.4 — Plan 2), every accrual tool (§3, §5),
+ redaction engine (§6), skill (§7), Haiku (§8), infra tofu (§9.7).
+- **Reserved words:** `publish` and `head` collide with wildcard routes;
+ documented in Task 5 as effectively reserved ledger names.
+- **Type consistency:** `Appended{Key,SHA256,Count}`, `Head{HeadKey,...}`,
+ `Getter`, `ManifestKey` names match across Tasks 2/3/5/6/7.
diff --git a/docs/superpowers/specs/2026-08-03-open-ledgers-design.md b/docs/superpowers/specs/2026-08-03-open-ledgers-design.md
new file mode 100644
index 000000000..329ca6329
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-03-open-ledgers-design.md
@@ -0,0 +1,559 @@
+# 🤖 Open Ledgers — a platform primitive, and the CARLOS ledger set
+
+**Status:** approved in discussion (Paul, 2026-08-03); this document is the
+written form for review.
+
+CARLOS publishes its own working record — carbon, contributors, prompts,
+access, roadmap, milestones — at `status.carlosframework.com`, and the
+mechanism that publishes it is a **first-class platform primitive** that any
+CARLOS-deployed app can use to publish its own open ledgers. This is the
+"layer on later as separate specs" extension the fleet-status-page design
+(`2026-08-01-fleet-status-page-design.md`) explicitly reserved space for.
+
+The prior art is Eleven's status site (`elevenmessenger/messenger`,
+`web/status/` + `tools/` + `deploy/status-publish.sh`, operator doc
+`docs/status.md`). Its ledger *content* rules are excellent and carry over
+verbatim (§6). Its *plumbing* is the thing this spec replaces: a commit →
+publish-script → scp/s3-sync train with a documented history of upload-list
+drift, three-step manual accruals, and freshness checks that pass silently
+while the real work is owed. Eleven itself already built the escape hatch —
+the hash-chained append-only "ledger stream" (its issue #1224) — and used it
+for exactly one feed. This spec promotes that shape to the platform.
+
+## 1. Scope
+
+**In:** a generic ledger-stream primitive (`internal/ledger`, bucket layout,
+CLI verbs, console-API write path, edge serving); the CARLOS deployment's own
+ledgers — Carbon, Contributors, Prompts, Access, Roadmap, Milestones — on the
+existing status host; the full Eleven redaction semantics as normative rules;
+an `/end-session` skill distributed via `carlosframework/skills`; Haiku
+summarisation of the prompt ledger.
+
+**Out (v1):**
+
+- **No Money group.** CARLOS accepts no payments today; the section does not
+ exist rather than existing empty. When payments arrive, Eleven's books/
+ economics rules (aggregated income only, itemized own-spend in provider
+ billing currency) are the template — a future spec.
+- **No Music ledger.** Eleven-specific; the primitive can carry it if ever
+ wanted.
+- **No per-app minted-domain serving.** Consumer app ledgers serve from the
+ deployment's status host (§4.4); serving them additionally at the app's own
+ `<app>.<sqid>.<apps-domain>` is deferred.
+- **No general platform cron.** Accrual tools run where the data lives
+ (contributor machines, boxes with existing timers); this spec adds no new
+ scheduling machinery beyond one box-side collector timer (§5.4).
+
+## 2. The primitive: ledger streams
+
+A **ledger** is an append-only, hash-chained sequence of public JSON entries
+in the deployment bucket, keyed by account and app — never app name alone
+(`internal/status/fleet.go` groups by `(account, app)` for the same reason:
+two tenants can both run a `blog`).
+
+### 2.1 Bucket layout
+
+```
+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>/ledgers.json (§4.1)
+```
+
+Entry wire shape (adopted from Eleven's stream design, one addition):
+
+```json
+{
+ "schema": 1,
+ "ledger": "carbon",
+ "writer": "paul@keymail.dev",
+ "ts": "2026-08-03T21:04:00Z",
+ "payload": { ... },
+ "prev_key": "ledgers/bab/platform/carbon/entries/20260801T...-ab12cd34ef56.json",
+ "prev_sha256": "..."
+}
+```
+
+`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 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
+parse.
+
+### 2.2 Writes are CAS on HEAD
+
+Append = `Put` the entry create-only (`ifMatch: ""` → If-None-Match:*), then
+advance `HEAD.json` with `ifMatch: <etag read before building the entry>`.
+A lost race returns `release.ErrCASConflict`; the writer re-reads HEAD,
+re-chains, retries. This is the existing `release.Store` contract
+(`internal/release/store.go`) doing exactly what it already does for channel
+pointers — no new storage machinery. Entries are immutable once written;
+there is no delete verb (the bucket is versioned; publication is forever —
+that fact drives the redaction posture in §6).
+
+### 2.3 Who may write: the console API, not bucket credentials
+
+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=
+```
+
+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
+yours". **Membership level: member**, mirroring "member can deploy/promote/
+rollback" — publishing an app's ledger is ordinary member work, not
+owner-only. The console performs the CAS append server-side (it already
+holds bucket write access), stamps `writer` from the authenticated identity
+(never client-supplied), and enforces the payload size cap (64 KiB per
+entry; larger material goes to blobs, §2.4). Writes do not require the
+public opt-in — an account can accrue privately before publishing (§4.1);
+only *serving* is gated.
+
+Box-side collectors (§5.4) write through the same code path in-process
+(`internal/ledger` directly against the store) — the API is for machines
+that hold no bucket role.
+
+### 2.4 Blobs
+
+Some ledgers carry corpus files, not rows — the prompt registry's redacted
+transcripts and summary files. These are content-addressed objects under
+`blobs/<sha256>`, uploaded via `POST /api/cli/ledger/blob` (create-only CAS;
+idempotent re-upload is a no-op, matching Ship's artifact semantics), and
+referenced from entry payloads by hash. The chain therefore commits to the
+corpus: replacing a published transcript is detectable because its hash is
+in a chained entry. This subsumes Eleven's separate hash-ledger-in-another-
+repo tamper-evidence mechanism — the chain *is* the tamper evidence.
+
+### 2.5 CLI verbs
+
+```
+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 check -app <a> # freshness/owed report, §7
+```
+
+`verify` needs only public reads and must also work against a plain HTTPS
+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.
+
+### 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
+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
+its "no needles ever" pipeline are untouched; the monitoring `metrics/` and
+`alerts/` prefixes remain unreachable from any public path.
+
+### 2.7 What is deliberately not here
+
+- **No signing of entries.** Release/channel signing exists because pointers
+ command boxes to run code. A ledger commands nothing; its integrity story
+ is the public hash chain plus bucket versioning. A signed-ledger variant
+ can layer on later without a wire change (additive fields).
+- **No renderer service.** Pages are static HTML + JS that walk the chain
+ client-side. The one Go renderer in this area (fleet page) stays as-is.
+
+## 3. The CARLOS ledger set
+
+CARLOS's own ledgers publish under the ops account (`bab`), app `platform`,
+on `status.carlosframework.com`. Nav groups (extending the fleet page's
+masthead): **Operations** (Fleet — existing), **Ledgers** (Carbon,
+Contributors, Prompts, Access), **Plan** (Roadmap, Milestones). No Money
+group (§1). Eleven's Hardening/Security operations pages are not in this
+spec's scope; they layer on the same way later if wanted.
+
+### 3.1 Carbon
+
+Eleven's schema verbatim: `entries[]` of `{period_start, period_end, source,
+basis: "measured"|"extrapolated", sessions, output_tokens,
+fresh_input_tokens, cache_read_tokens, kg_low, kg_central, kg_high, note}`,
+factors block `factors_mg_co2e_per_output_token: {low: 0.1, central: 0.55,
+high: 3}`, a `totals` block, and the `not_counted` honesty string. `source`
+is an anonymous hashed name-pair label, never a hostname. Each accrual run
+appends one entry per period; the page sums the chain.
+
+Accrual: `carlos ledger carbon accrue` (a subcommand of the tool in §5.1)
+reads token usage numbers from local Claude transcripts — usage sums only,
+never content — computes the period entry, shows it, appends on confirm.
+Eleven's documented race ("two long-lived branches accruing the same
+window") dissolves: CAS on HEAD makes concurrent accruals collide loudly
+instead of silently double-counting, and the second writer re-reads and
+re-periods.
+
+### 3.2 Contributors
+
+Eleven's schema verbatim: `{updated_at, repo, metric, contributors:[{id,
+kind: "human"|"ai", name, bio, commits, url, avatar, hidden?}]}` — with
+`repo` covering the four CARLOS repos (platform, rastrillo, website,
+skills), aggregated per contributor across them. Humans from `gh api` commit
+authors; AI from `Co-Authored-By` trailers with an `@anthropic.com` email.
+`"hidden": true` self-hide honored at generation time. Refresh is
+`carlos ledger contributors accrue` — periodic, not session-gated; staleness
+costs nothing (no pruning window), so it gets a `check` line but no nag hook.
+
+### 3.3 Prompts
+
+The full Eleven prompt-registry model, ported (§5.1): per-contributor
+corpus — `contributors.json` manifest, per-slug `index.json`, `prompts.json`,
+`<session>.jsonl` (redacted transcript), `<session>.sum.json` (reply gists) —
+published as blobs (§2.4) with each publish appending one chain entry
+carrying the corpus manifest hash. Publishing a machine's registry is **that
+contributor's choice**; the tool reads only the machine it runs on; nothing
+auto-collects anyone's transcripts.
+
+Redaction is §6 in full — detector chain, fail-closed rebuild, budgets,
+tombstones. Summarisation is §8 (Haiku).
+
+### 3.4 Access
+
+**CARLOS boxes have no SSH** — admin access is SSM Session Manager only
+(`infra/modules/region-host`). So the access ledger records **SSM sessions**,
+with Eleven's published shape kept intact:
+
+- Automatic rows: `{id, operator, start, end, connections, source}` —
+ metadata only, published after the session has been quiet ≥20 minutes.
+ `operator` maps from the AWS principal via a committed
+ `ledger-operators.json` (the analogue of Eleven's fingerprint→name map);
+ `source` is a coarse word (`ssm`), never an IP.
+- Human why-entries: PR-reviewed files in-repo,
+ `docs/ledgers/access/YYYY-MM-DD-<slug>.json` — `{date, operator, window,
+ why, refs}` — merged with the automatic rows by operator + window overlap,
+ exactly Eleven's join. The why-entry bar carries verbatim: **what, not
+ how**; never a credential, email, procedure, or defence map; security-
+ sensitive sessions publish as `"why": "(withheld: security)"` — the access
+ stays on the record, only the reason is withheld; silence renders as
+ **"no summary recorded"**, which is the enforcement.
+
+**Named research task (plan, not spec):** the collector's source — SSM
+session history via CloudTrail or the SSM API — must be validated against
+the real flagship account (event latency, session-end visibility, principal
+attribution) before the collector is built. The published shape above is
+fixed regardless of which source wins. Self-hosted deployments that do run
+SSH can implement the same shape from sshd journals later; not in scope.
+
+### 3.5 Roadmap
+
+Curated, not generated: `docs/ledgers/roadmap.json` in-repo, seeded from
+`docs/superpowers/plans/2026-08-01-roadmap.md` and the specs inventory —
+items `{title, state: "planned"|"building"|"shipped"|"deferred", note,
+refs}`. Edits ride ordinary PRs; a publish appends the file's new hash to
+the roadmap chain (so the public page's history of roadmap *changes* is
+itself a ledger). Forward-looking honesty rule: roadmap items are
+intentions, and the page says so.
+
+### 3.6 Milestones
+
+Mostly generated: `carlos ledger milestones accrue` collects git tags
+(rastrillo v0.1.0…v0.5.0 and platform tags as they appear) and merged PRs
+across the four repos into dated `{date, repo, title, ref}` entries, with a
+small curated overlay file for milestones that aren't a tag or PR (first
+real deploy, first self-hosted deployment, first external app). Append-only
+by nature — a milestone, once published, happened.
+
+## 4. Consumer apps: the same primitive, opt-in
+
+### 4.1 Opt-in
+
+Public exposure follows the `StatusNamed` precedent — the app's own
+explicit choice, recorded where the publisher reads. A per-app manifest at
+`ledgers/<sqid>/<app>/ledgers.json`:
+
+```json
+{"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
+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).
+
+### 4.2 Authz
+
+Member-level, via the console API only (§2.3). The `ops` account's ledgers
+are writable by operators like any other app the ops account owns — no
+special path.
+
+### 4.3 Pages
+
+The status site ships generic ledger pages: an index per app (reads
+`ledgers.json`), a chain browser per ledger, and typed renderers for the
+schemas CARLOS itself uses (carbon, contributors, prompts, access, roadmap,
+milestones). A consumer app using one of those schemas gets the typed page;
+any other ledger gets the generic chain browser. Styling follows the fleet
+page's existing assets (`internal/status/assets/`), not the marketing
+site's.
+
+### 4.4 Discovery
+
+`GET /ledgers/` on the status host lists public apps' ledger indexes (from
+the opt-in manifests, cached on the 60s tick).
+
+## 5. Tooling: Go, in this repo
+
+### 5.1 One accrual tool
+
+**Decision (Paul, 2026-08-03): Go port, not Eleven's Node scripts.** All
+accrual logic lives in this repo — `internal/ledger` (chain, canonical
+JSON, verify), `internal/ledger/prompts` (registry: parse, redact,
+summarise, build), `internal/ledger/carbon`, etc. — surfaced as
+`carlos ledger <ledger> accrue` subcommands. One language, the repo's test
+conventions, and the redaction engine gets the contract-test treatment
+(§6.5). Eleven's `.mjs` tools are the reference implementation for
+behaviour, not a dependency.
+
+### 5.2 The prompt-registry port
+
+The heaviest item, ported faithfully from `tools/prompt-registry.mjs`:
+transcript discovery, redaction (§6), corpus build, summary caching
+(append-only transcripts → cache keys never go stale; cached summaries
+**re-pass through current redaction rules** so a category added later
+reaches summaries cached before it existed), fail-closed post-build re-scan,
+and the blob upload + chain append replacing Eleven's manual `aws s3 sync` +
+cross-repo hash commit.
+
+### 5.3 Freshness: `check` tests the real thing
+
+`carlos ledger check` (and the SessionStart hooks, §7) reports **owed
+work**, not file age:
+
+- carbon: unaccrued transcript days, with an explicit warning as days
+ approach the local transcript prune horizon (~1 week — a missed week is
+ lost and must be extrapolated by hand; the check exists to make that
+ never-silent).
+- prompts: sessions present locally but **absent or unsummarised** in the
+ published corpus — closing Eleven's documented gap where a recently
+ published corpus with skipped summaries passes silently.
+- access: sessions in the collector feed with no matching why-entry.
+- exit code is non-zero when anything is owed, so hooks and CI can see it.
+
+### 5.4 The one box-side collector
+
+The access collector (§3.4) runs on the flagship as a systemd timer beside
+the existing status timers, writing through `internal/ledger` in-process.
+It reads session *metadata* only — never commands, output, or content — and
+client-identifying detail dies in the collector (only the coarse source
+word survives). Deliberately standalone from the fleet-status renderer,
+keeping Eleven's hard-won "one collector's change never gates another"
+separation.
+
+## 6. Redaction semantics — normative, carried from Eleven
+
+These rules are lifted whole from Eleven's `docs/status.md` privacy section
+and `tools/prompt-registry.mjs`, adapted only where CARLOS's shape differs.
+They are **non-negotiable** and enforced in code wherever code can hold
+them. The governing fact: the bucket is versioned and public — **everything
+published is public, forever**. Over-withholding is recoverable;
+publication is not.
+
+### 6.1 The aggregation boundary (all feeds except prompts)
+
+- Aggregates only: counts, sums, band words, booleans. A field must be an
+ aggregate over the whole fleet / all contributors, or it doesn't ship.
+- **No hostnames, ever** — including canaries. Machine-derived labels are
+ whimsical hashed name-pairs (`ada-stargazing`), stable per machine, never
+ reversible to hostname, IP, or machine id.
+- Band words, not needles, for anything capacity-shaped: the inputs that
+ pick the word die in the generator.
+- Coarse regions only (continent + country), operator-set; unset is
+ omitted, never guessed.
+- Contributors: public GitHub identity + self-written bio only; never
+ emails; anyone listed can hide themselves.
+- Carbon: token usage *numbers* and kg estimates only; never per-session
+ data, never content.
+- Security-shaped facts (if ever published here): status + count, never the
+ name of anything unpatched.
+- A source that can't be honestly resolved is **omitted, never zeroed or
+ guessed**.
+
+### 6.2 The prompt registry's different bar: credentials-only
+
+The registry publishes transcripts **on purpose** — a contributor's own
+working record, whole, as provenance. Hostnames and ops narrative stay
+(instance hostnames are public record via Certificate Transparency);
+secrets never do. Every redaction is an in-place marker
+`⟦redacted: <category>⟧` with a published per-category reason.
+
+Detector chain, in order (structural blocks first, entropy backstop last):
+
+1. `claude-local-md` — gitignored local runbook content, excluded wholesale
+ (CARLOS: `CLAUDE.local.md` and any gitignored ops/runbook file), with
+ sentinel phrases re-checked post-build
+2. `private-key` — PEM blocks, AGE secret keys
+3. `binary-payload` — long base64 runs (can't be text-audited for secrets)
+4. `api-key` — known key formats (Anthropic `sk-ant-…`, Stripe, GitHub
+ `ghp/gho/ghu/ghs/ghr_…`/`github_pat_…`, Slack `xox…`, AWS `AKIA/ASIA…`)
+5. `jwt` — signed bearer token shape
+6. `url-token` — credential-bearing URL parameter or fragment
+7. `email` — addresses stay out of the public ledger
+8. `auth-header` — Authorization / X-Api-Key / X-Auth-Token values
+9. `env-secret` — assignments named `*TOKEN|SECRET|PASSWORD|PASSWD|
+ CREDENTIALS?|APIKEY|API_KEY|PRIVATE_KEY|KEY`
+10. `kv-secret` — password/token/secret/credential fields in structured
+ output
+11. `high-entropy` — long mixed-case+digit runs, redacted fail-closed
+
+**The build is fail-closed:** the tool re-scans its own output with the
+identical detector set and refuses to emit the corpus on any surviving hit.
+Nothing partial is ever left for an upload step to grab.
+
+### 6.3 Personal budgets and session tombstones
+
+- **Personal redaction budget** (`--personal <file>`): literal strings or
+ `/regex/`, one per line (a `/regex/` line also matches itself literally,
+ so a path-shaped literal is never silently reinterpreted as a pattern).
+ Published as `⟦redacted: personal⟧`, no justification owed, **counted per
+ session like every other category — the public count is the budget's
+ honesty mechanism.**
+- **Session escape hatches** (`--sessions <file>`): `withhold <id> <reason>`
+ publishes a tombstone (date, prompt count, reason — no content);
+ `omit <id>` leaves only the aggregate omitted count. Reason vocabulary,
+ verbatim: `personal-safety` (sharing would cause or attract harm to a
+ person), `security` (would reveal secrets whose exposure could cause
+ harm), `third-party` (the session centres on an identifiable person who
+ didn't opt into the ledger), `beyond-intent` (consequences beyond
+ revealing the human's intent — the thing transparency is for).
+- **One case is mandatory: centralised-service security.** A session whose
+ content could compromise the platform's crown jewels is withheld *whole* —
+ credential redaction is not enough when the narrative itself maps the
+ defences. CARLOS's list: the console, the edge, the host agent, the
+ deployment and data buckets, the signing keys and their SSM paths, and
+ the flagship/Tito boxes themselves. The accrual flow includes a sweep for
+ hits against this list before every publish; the threshold stays
+ deliberately conservative.
+
+### 6.4 Access-ledger bar
+
+Metadata only, publish-on-quiet; the collector never reads commands or
+content; client addresses die in the collector. Why-entries: what, not how;
+never a credential or email; security-sensitive sessions tombstone as
+`(withheld: security)` rather than vanish — unexplained access and
+protected access must render differently.
+
+### 6.5 Enforcement
+
+- The redaction engine and every generator get **contract tests** in the
+ fleet-status tradition (`internal/status/dryrun.go`): fixture inputs
+ containing planted hostnames, emails, per-payer ids, and every key format
+ must never reach output; the tests fail the build if one does.
+- `carlos ledger <l> accrue -dry-run <dir>` builds the full publish set
+ against fixtures with no network, mirroring `carlos status -dry-run`.
+- Adding a field to any aggregate feed requires updating the contract test
+ first — the test is the published-shape allowlist, not a mirror.
+
+## 7. /end-session
+
+A new skill in `carlosframework/skills` — `carlos:end-session` — modelled
+directly on Eleven's (`.claude/skills/end-session/SKILL.md` there), since
+that repo is the live distribution channel today (rastrillo's §12 skill
+scaffolding, when built, will install it into scaffolded apps
+automatically; nothing here waits on that).
+
+Shape (ported, CARLOS-flavoured):
+
+- **Gate A — work finished & pushed:** tasks done or explicitly deferred;
+ working trees clean; unpushed commits pushed (scoped to this session's
+ branches); this session's PRs merged or stated-waiting; stale worktrees
+ removed; never mutate the shared main checkout.
+- **Gate B — ledgers accrued:** `carlos ledger check` is the single source
+ of owed-ness (§5.3): access why-entry if this session touched a box;
+ carbon accrual if owed; prompt corpus build+summarise+publish if owed.
+- **Posture:** check and report first; complete mechanical parts only with
+ the user's OK; **never** auto-merge, auto-deploy, or auto-publish; never
+ echo secrets or capability links. Ends with the owed/clear table
+ (✅ / ⚠️ owed / — n/a per line).
+
+SessionStart hooks in this repo's `.claude/settings.json` run
+`carlos ledger check` in nag mode (fast, non-blocking, `|| true`), so a
+session opens knowing what it owes — with §5.3's guarantee that "owed"
+means real owed work, not file age.
+
+Because the skill ships in the public skills repo, a consumer app that
+installs the CARLOS plugin gets the same session discipline for **its own**
+ledgers — the skill reads `carlos ledger check` against whatever app the
+repo it runs in deploys.
+
+## 8. Haiku summarisation
+
+The prompt ledger's summaries are produced by **Haiku**
+(`claude-haiku-4-5`), exactly as Eleven does it:
+
+- **Session lines:** ≤90 chars, built only from that session's **redacted**
+ prompts (never raw transcripts, never replies); if the session references
+ a GitHub issue/PR URL, the line ends with the single most relevant one
+ verbatim (rendered as a titled link; the URL doesn't count toward the
+ limit).
+- **Reply gists:** ≤80-char one-liners for substantial assistant replies,
+ batched per call.
+- Direct Messages API call with `ANTHROPIC_API_KEY` when present; without a
+ key the build falls back to first-line text — **the tool never requires
+ AI**. Summaries cache across runs; cached summaries re-pass through
+ current redaction rules (§5.2).
+- The accrual flow may equally dispatch Haiku subagents over the redacted
+ build output (the documented Eleven practice); either path feeds the same
+ cache and the same fail-closed rebuild.
+
+## 9. Rollout
+
+1. **Primitive first:** `internal/ledger` (chain, canonical JSON, CAS
+ append, verify) + console API routes + `carlos ledger`
+ append/head/list/verify + edge serving behind the opt-in manifest.
+ Contract tests from day one.
+2. **First real chains:** Roadmap + Milestones (curated/generated — no
+ redaction machinery needed) publish to `status.carlosframework.com`,
+ proving the end-to-end path; status masthead gains the nav groups.
+3. **Carbon + Contributors:** accrual subcommands + typed pages.
+4. **Prompts:** the registry port with the full §6 engine, Haiku
+ summarisation, blob publishing. The largest slice; lands last of the
+ ledgers.
+5. **Access:** SSM feasibility research task, then the collector +
+ why-entry flow + operators map.
+6. **/end-session** skill PR to `carlosframework/skills` + SessionStart
+ hooks here (can land alongside 3–4).
+7. Flagship wiring per the fleet-status spec's own rollout pattern: the
+ `status.carlosframework.com` record and public-prefix policy already
+ exist for `status/`; the tofu change extending the public prefix to
+ `ledgers/` under the same conditions lands in the private infra repo.
+
+Each step is additive; nothing touches the fleet page's existing pipeline.
+
+## 10. Open questions
+
+1. **SSM session-history feasibility** (§3.4) — research task in the plan;
+ the published shape is fixed either way.
+2. **Carbon factors** — Eleven's mg-CO₂e-per-output-token factors are
+ adopted as-is for comparability; revisiting them (or citing a newer
+ methodology) is content work, not design work.
+3. **Consumer schema registry** — whether typed pages for consumer-invented
+ ledger schemas are ever accepted upstream (v1: CARLOS's schemas typed,
+ everything else generic browser).
+
+## 11. Provenance
+
+Designed 2026-08-03 from three research passes over: Eleven's status system
+(`elevenmessenger/messenger` — `docs/status.md`, `web/status/`, `tools/`,
+`deploy/`, the end-session skill, and the issue-#1224 ledger-stream
+mechanism this spec generalises); this repo's fleet-status-page spec and
+implementation, `release.Store`, scoped-signing, multi-account, custom-
+domains, monitoring (#23) and CLI-console-API (#25) work; and the
+`carlosframework/skills` repo. Approved in discussion by Paul the same day:
+Go port over Node adoption; SSM research as a named plan task; status-host
+serving only in v1; no Money section; Music left out.