carlos / platform Public

spec: open ledgers — record what was built where the design lagged it

Five places where the implementation deliberately diverged from the written
design and the spec still described the intention:

- §2.1 the chain hashes stored entry bytes VERBATIM, no canonicalization at
  write or verify. A canonicalization bug is a hash-break bug, and every
  verifier in every language would have to reproduce the same canonical form
  to agree; sha256(the bytes you downloaded) is trivially reimplementable.
- §2.1 blob keys carry no extension — the edge derives content type from the
  object's shape, never from a suffix a tenant can choose.
- §2.3 the real route surface (/api/cli/apps/{account}/{app}/ledgers/...),
  the account/app-in-path consequence that `publish` and `head` are
  effectively reserved ledger names, and publish's additive semantics.
- §2.5 the v1 narrowing: verify is -url-only with a sqid -account, list
  requires -app, check is not in the first slice; plus verify's walk pinning.
- §4.1/§2.6 publish is additive and unpublish removes one ledger; and
  unpublishing is not a recall — serving stops within 60s, but entries and
  blobs already fetched carry a year of immutable Cache-Control and cannot
  be called back. Publication of chain content is effectively irreversible.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3a90c302ba7050b305430828627a33a59ce0c87e parent b630793
1 file changed, +64 −9
  • docs/superpowers/specs/2026-08-03-open-ledgers-design.md +64 −9
diff --git a/docs/superpowers/specs/2026-08-03-open-ledgers-design.md b/docs/superpowers/specs/2026-08-03-open-ledgers-design.md
index 9e879d7ba..ada8a5bc9 100644
--- a/docs/superpowers/specs/2026-08-03-open-ledgers-design.md
+++ b/docs/superpowers/specs/2026-08-03-open-ledgers-design.md
@@ -56,7 +56,7 @@ two tenants can both run a `blog`).
```
ledgers/<account-sqid>/<app>/<ledger>/entries/<ts-compact>-<sha12>.json
ledgers/<account-sqid>/<app>/<ledger>/HEAD.json
-ledgers/<account-sqid>/<app>/<ledger>/blobs/<sha256>[.<ext>] (§2.4)
+ledgers/<account-sqid>/<app>/<ledger>/blobs/<sha256> (§2.4)
ledgers/<account-sqid>/<app>/ledgers.json (§4.1)
```
@@ -76,8 +76,21 @@ Entry wire shape (adopted from Eleven's stream design, one addition):
`HEAD.json`: `{"updated_at", "head_key", "head_sha256", "count"}`.
-The chain hashes canonicalized JSON (sorted keys, no insignificant
-whitespace) — same canonicalization both when writing and when verifying.
+The chain hashes the **stored entry bytes verbatim** — no canonicalization,
+at write or at verify. The writer marshals the entry once, hashes exactly
+those bytes, and stores exactly those bytes; a verifier re-hashes the bytes
+exactly as fetched and compares. This is deliberate, and it is a change from
+the design's first draft (which specified sorted-key canonical JSON both
+sides): a canonicalization bug is a hash-break bug, and every verifier in
+every language would have to reproduce the same canonical form to agree.
+Verbatim bytes make the contract trivially reimplementable — `sha256(the
+bytes you downloaded)` — and remove the whole class.
+
+The blob key carries no extension: the edge derives content type from the
+object's SHAPE, never from a suffix (`internal/edge/status.go`'s
+`ledgerShape`), because a tenant can name a blob whose sha happens to end
+`.json`.
+
The first entry has `prev_key: ""`, `prev_sha256: ""`. `schema` is new
relative to Eleven's shape and follows this repo's wire-shape rule: **all
future changes are additive**; anything written before a change must still
@@ -100,10 +113,28 @@ Contributor machines get **no bucket credentials**. `carlos ledger append`
calls the console's CLI API (the PR #25 pattern, `internal/console/apicli.go`):
```
-POST /api/cli/ledger/append {account, app, ledger, payload}
-GET /api/cli/ledger/head ?account=&app=&ledger=
+POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append {payload}
+GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head
+POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs (raw bytes)
+GET /api/cli/apps/{account}/{app}/ledgers
+POST /api/cli/apps/{account}/{app}/ledgers/publish {public, ledgers, title}
```
+The account/app pair is in the PATH, not the body — the shape every other
+CLI API route in `internal/console/apicli.go` already uses, so
+`apiAccountApp` is the one gate and the byte-identical 404 comes for free.
+Consequence: **`publish` and `head` are effectively reserved ledger names**,
+being literal segments in this URL space (Go 1.22's mux prefers a literal
+over a `{ledger}` wildcard). Both remain valid ledger names and are writable
+and readable at their own sub-routes; no new route may ever be added where
+the two would collide.
+
+`publish` is **additive per ledger**: it adds the named ledgers to the app's
+published set and leaves the rest alone, and `-public=false` removes the
+named ones, flipping `public` off only when the set empties (§4.1). The
+response is the RESULTING manifest, because after a read-modify-write only
+the server can say what is public now.
+
Auth is the same session/token the CLI already holds for ship/promote;
account/app resolution goes through the existing `apiAccountApp` check —
one auth+membership gate, byte-identical 404 for "doesn't exist" and "not
@@ -137,8 +168,8 @@ repo tamper-evidence mechanism — the chain *is* the tamper evidence.
carlos ledger append -app <a> [-account <acct>] <ledger> <entry.json|->
carlos ledger blob -app <a> <ledger> <file>...
carlos ledger head -app <a> <ledger>
-carlos ledger list [-app <a>]
-carlos ledger verify -app <a> <ledger> # walks the chain, re-hashes
+carlos ledger list -app <a> # v1: -app required
+carlos ledger verify -app <a> -account <sqid> -url <base> <ledger>
carlos ledger check -app <a> # freshness/owed report, §7
```
@@ -147,6 +178,16 @@ base URL (`-url https://status.carlosframework.com`) so **anyone** can verify
a published chain without CARLOS credentials — an open ledger you cannot
independently verify is a press release.
+**v1 narrowing (plan-sanctioned):** `verify` is `-url`-only — there is no
+bucket-reading mode, matching §2.3's "no credentials in a terminal", and its
+`-account` takes a SQID because the public path is keyed by sqid with no
+console to resolve a name against. `list` requires `-app`; the
+account-wide listing is deferred. `check` (§7) is not in the first slice.
+Verification also pins the walk to the ledger under test: every entry must
+name that ledger and every key followed must live under its own `entries/`
+prefix, so a chain cannot graft another ledger's history onto itself and
+still verify clean.
+
### 2.6 Serving
The edge's `Kind=="status"` route (`internal/edge/status.go`) currently
@@ -283,13 +324,27 @@ explicit choice, recorded where the publisher reads. A per-app manifest at
{"schema": 1, "public": true, "ledgers": ["carbon", "changelog"], "title": "Kass"}
```
-written via `carlos ledger publish -app <a>` (and unpublished by flipping
-`public`). No manifest, or `public: false` → the edge serves nothing for
+written via `carlos ledger publish -app <a> <ledger>...`, which is
+**additive**: the named ledgers join the set, the rest stay as they were, and
+an omitted `-title` keeps the stored title. `-public=false <ledger>...`
+removes just those ledgers, flipping `public` off only when the set empties.
+The manifest is a set accrued across separate publish calls, so the write is
+a read-modify-write with CAS on the manifest's etag — two contributors
+publishing two ledgers at once end with both published, not with whichever
+was slower. No manifest, or `public: false` → the edge serves nothing for
that app (§2.6), and writes still work (an account can accrue privately
before going public — but §6's rule stands: once public, history is public;
going private again stops serving but does not unpublish the versioned
bucket's past).
+**Unpublishing is not a recall.** Flipping `public` off stops the edge
+serving within ≤60s (one cache tick), but every entry and blob already
+fetched went out with `Cache-Control: public, max-age=31536000, immutable` —
+browsers and CDNs may hold it for a year, and anyone who walked the chain
+has the bytes. Publication of chain content is effectively irreversible;
+§6's posture ("over-withholding is recoverable; publication is not") is the
+operative rule, and `public: false` is a tap, not an eraser.
+
### 4.2 Authz
Member-level, via the console API only (§2.3). The `ops` account's ledgers