| 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. |