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