--- title: "ADR 0011 — A version number is derived from the history, never stored" type: decision tags: [signpost, adr, decision] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:39:09.961Z" --- # ADR 0011 — A version number is derived from the history, never stored **Status** Accepted · **Date** 2026-08-14 · **Supersedes** nothing · **Relates to** ADR 0003 (source-preserving Markdown), ADR 0005 (write serialisation), PRD §3.5, non-negotiable 1 ## Context The demo on `rush-skills/link-prototype` gives every node an integer `version`, increments it on write, and shows "v3" in its interface. The main product has nothing of the kind: a version is addressed only by its SHA-256, `listVersions` pages the `blobs/` prefix, and `GET /api/history` returned a list with no number on it. The question the study set was whether that `version` is a real concept the main product lacks or `content_sha` under another name. It is both, in different parts. **`expected_sha` is `content_sha` renamed, and weaker.** The demo's `shortHash` (`src/store.ts:1353`) is a hand-rolled FNV-shaped function producing 12 hex characters — 48 bits, not 96 — where ours is the SHA-256 that also addresses the blob. As a concurrency token ours is strictly better and there is nothing to take. **The ordinal is a real concept, and it is not a concurrency token.** "v3" is what a person says out loud. A sha is correct and unspeakable. Every history screen, every diff header and every restore confirmation wants the short number, and until now none of them could have one. Two traps in the demo's version of it, both worth not inheriting: - **It is assigned twice.** `n` is set when a proposal is written (`store.ts:665`) and set again when it is accepted (`:738`), so the number a reviewer saw is not the number the note ends up with. - **It is a second source of truth.** The counter lives in the node row; the bodies live elsewhere. Nothing keeps them in step, and the double assignment is what happens when they drift. Against that, non-negotiable 1: *if D1, Vectorize and the app all vanish, the R2 bucket plus a git clone still contain everything.* A counter stored only in D1 does not survive that. The ordered list of a note's versions does: `blobs/{sha}` with `committedAt`, `permalink` and `source` in custom metadata is exactly the history, and `listVersions` already sorts it. ## Decision **`n` is the position in the content-addressed history, oldest first, starting at 1, computed at read time from the list already being returned.** One field on `versionSchema`, one `index + 1` in `routes/api/history.ts`. No column, no migration, no write-path change. Three properties follow and all three are documented on the schema field rather than left to be discovered: 1. **`n` counts distinct contents, not commits.** R2 stores byte-identical content once, so a note edited A → B → A has two objects and the third commit is `n: 1` again. That is the deduplication the whole content-addressed design exists for. The count of *commits* is a different question and `GET /api/audit?target=sp://…` is where it is answered. 2. **`n` is never a concurrency token.** `expected_sha` is, because it names the exact bytes. Two writers can both hold "v3" and mean different things; they cannot both hold the same sha and mean different things. 3. **`n` is not stable across a retention policy.** Nothing expires blobs today. If anything ever does, the ordinals shift, and a stored counter would then disagree with the bucket rather than move with it — which is an argument for deriving it, not against. ## Consequences - A person can say "restore v2" and mean something, and the API can render it. That was the whole gap. - The number is always consistent with the bucket, because it *is* the bucket, so there is no reconciliation to write and no drift to detect. - `listVersions` pages the whole `blobs/` prefix, so `n` costs what the version list already costs. That is fine at self-hosted scale and is stated in `storage/blobs.ts`; it is the wrong thing to put on a hot path, and nothing hot uses it. - A note whose history is genuinely long makes the list long. Paging it is future work and the ordinal is compatible with it — the number is the absolute position, not the position in the page. ## What was rejected | Option | Why not | | --- | --- | | `notes.version_n`, incremented in the existing compare-and-swap | A second source of truth for a fact the bucket already holds; does not survive the loss of D1, which non-negotiable 1 requires; the demo's own copy drifts | | Assign `n` at propose time, as the demo does | The number a reviewer sees would not be the number the note gets. Assign at land time or not at all — and "not at all" is available here because the landing itself is what orders the blobs | | Make `n` the concurrency token | An ordinal cannot name bytes. `expected_sha` already does the job and is 256 bits of it | | Count commits instead of contents, from `audit_log` | Correct for "how many times was this written", wrong for "which version am I looking at" — two commits can produce the same bytes, and a diff between them is empty. Both numbers exist; each is on the endpoint that owns the question |