--- title: "ADR 0005 — How concurrent writes to one note are serialised" type: decision tags: [signpost, adr, decision] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:37:57.847Z" --- # ADR 0005 — How concurrent writes to one note are serialised **Status** Accepted · **Date** 2026-08-09 · **Supersedes** nothing · **Relates to** PRD §3.7 (I1, I2, I3), non-negotiable 1 ## Context An adversarial pass found nine defects in `CommitService`. They read as nine bugs and they are one: **the write path was not serialisable.** Every one of them is a place where two facts that had to be decided together were decided at two different moments, in two different stores, with no ordering between them. The nine, grouped by the moment that was wrong: | Where the ordering broke | What it produced | | --- | --- | | The canonical R2 file was written **before** the D1 transaction decided who won | a no-op commit overwrote a concurrent save (ground-truth data loss); a losing writer left its bytes at the canonical key | | The compare-and-swap guard compared **values** rather than asking how many rows changed | two byte-identical commits both reported `updated`, both ran the whole transaction, both wrote an audit row | | The swap tested `content_sha` but not `deleted_at` | an update racing a delete silently revived the tombstone, permanently unresolving every inbound edge, and the delete surfaced a raw `StorageError` | | Relations were resolved by a `select` **before** the transaction | an edge resolved to a note that was deleted in between, and nothing ever swept it back | | A splice read `notes.content_sha` and then read the canonical file | an ordinary race was reported as `content-diverged` — "something outside CommitService wrote to the bucket" — which is terminal, so the splice was dropped | | Provenance was keyed on a content-derived block id and outlived its block | a block written by an agent over MCP was reported as written by a named human in the web editor | | `normalizeStoredPermalink` was applied once on write and twice on read, and was not idempotent | a note stored at an address no read resolved; two live notes sharing one canonical file; a delete removing the other note's Markdown | | `ProjectContext` carried the raw `D1Database` and `R2Bucket` | `const store = ctx.bucket` defeated all four layers of invariant I1 at once | The last one is not a race, but it belongs here: it is why none of the others could be prevented by "every write goes through the one path" — the one path was not the only path. Two properties of the runtime constrain every possible answer: - **D1 has no multi-statement transaction with rollback.** `db.batch()` is atomic as a unit; two awaited statements are two transactions. A compare-and-swap therefore has to be a conditional `UPDATE … WHERE` **inside** the batch, with the abort expressed in SQL. A read-then-write across two awaits is not a compare-and-swap. - **R2 has no transaction at all**, and no conditional put on content. Two stores can be *ordered*; they cannot be made atomic. ## Decision **Optimistic concurrency, with the note row as the serialisation point, and a strict ordering between the two stores.** Four parts, each closing one class: ### 1. The compare-and-swap is real, and its guard asks the database ```sql update notes set … where id = ? and content_sha = ? and deleted_at is … -- the whole row state read insert into notes (id, …) select null, … where changes() = 0 -- abort if the swap lost ``` The guard runs immediately after the swap, inside the same batch. `changes()` reports the row count of the most recently completed statement on the connection; when the swap matched nothing it is zero, the select yields a row of nulls, `notes.id` is `NOT NULL`, and D1 rolls the whole batch back. Measured in workerd against real D1, not assumed: `changes()` is 1 for a swap that matched even when it wrote identical values, 0 when it matched nothing or when the row was missing, and the guard aborts every following statement in the batch. The old guard asked "does the row look like what I was about to write?". That is false exactly when the winner wrote the same bytes in the same millisecond — the case that a double-clicked save, a retried request, two ingest workers, or an MCP retry all produce. Asking how many rows changed is the only question whose answer cannot coincide. The swap now restates the **whole** row state the commit read, `deleted_at` included, because a deletion does not change `content_sha`. ### 2. The blob is written first, the canonical file last, and only by the winner `blobs/{sha}` is immutable and addressed by its own content, so it is safe to write before anyone knows who wins: a blob no note references is inert, and writing it first is what keeps the content recoverable from the bucket alone. `notes/{folder}/{slug}.md` is mutable and *is* the product, so it is written after the transaction commits, by the writer whose swap took. `publishCanonical` does it and **converges** rather than assuming: each round re-reads the note row, compares it against the sha R2 stored for the object, and writes what **D1 names** — from `blobs/` when the bytes in hand are not it. A writer whose put lands out of order repairs it on its own way out; a writer that lost publishes nothing; a no-op commit repairs to the committed version rather than to the copy it happens to be carrying. That ordering also deletes a mechanism: nothing has to compensate the canonical write when the transaction fails, because at that point nothing has written it. ### 3. Everything two facts depend on is decided inside the transaction Relation resolution moved from a `select` before the batch into a statement inside it: ```sql update relations set to_note_id = (select id from notes where permalink = relations.to_permalink and deleted_at is null), resolved = case when exists (…) then 1 else 0 end where from_note_id = ? ``` Both `is the target live?` and `write resolved` now happen in one serialised transaction, so a tombstone can land before it or after it and never between. The `CommitResult` reports the edges as they were actually stored, read back after the commit, rather than as they were planned. ### 4. What cannot be raced is made structurally impossible instead - **Provenance** is keyed `(note_id, block_id)`, and rows for blocks a commit removed are deleted in the same transaction. A block id derived from content can legitimately recur in another note; keyed on the block id alone, `ON CONFLICT DO NOTHING` handed the second note the first author's name, door, timestamp and verification state. - **Permalink normalisation** is idempotent (`f(f(x)) === f(x)`, property-tested), shares its extension stripping with the R2 key builder so the address and the file it names cannot drift, and is applied at exactly one point in the write path — `commit()`. - **`ProjectContext` no longer carries a writable handle.** `ctx.db` and `ctx.bucket` are read-only in the type *and* at runtime; the raw bindings live in a module-private `WeakMap` and come back out only through `writableBindings(ctx, token)`. Aliasing cannot defeat that, because an alias copies the narrow type. The static scan gained an alias pattern as defence in depth. **Amended.** The runtime half was first written as a `Proxy` trapping `get` and `has`, and that was not enough twice over. `Object.getOwnPropertyDescriptor(ctx.db, "$client").value` and `…(ctx.db, "session").value.client` both reached the raw `D1Database` — `[[GetOwnProperty]]` and `[[OwnPropertyKeys]]` were untrapped, and `PropertyDescriptor.value` is typed `any` by `lib.es5.d.ts`, so the escape typechecked and linted clean. And `all` was on the allowlist as "a read", but `db.all()` is `D1Database.prepare().all()` and runs whatever it is handed: ``ctx.db.all(sql`delete from audit_log`)`` emptied the append-only log. What is on the context now is a frozen, null-prototype object carrying only bound `select` and `selectDistinct` — no target to reflect on, and no statement runner at all. `services/fts.ts` expresses its FTS5 `MATCH` through the query builder, which is structurally incapable of emitting anything but a `SELECT`. - **Fan-out is bounded before it is paid for.** `MAX_MARKDOWN_LENGTH` bounds characters, and characters are not work: a legal 988,899-character note of 100,000 wikilinks took **258 seconds** in one commit, 105 of them inside `parseMarkdown` alone, because CommonMark's inline resolution is quadratic in the number of unresolved delimiters in a paragraph. A request killed mid-commit is worse than one refused — the blob is in R2, no D1 row names it, and the retry repeats it — so `MAX_INLINE_MARKERS` is checked before the parse (linear, 25 ms over a megabyte) and `MAX_BLOCKS_PER_NOTE`, `MAX_RELATIONS_PER_NOTE` and `MAX_CHUNKS_PER_NOTE` immediately after it, before the first digest, the first query and the first byte written. - **A permalink must be an address, not only a legal R2 key.** `validateObjectKey` deliberately permits `# ? & = % + : @`, and `packages/core/src/ref.ts` forbids exactly `/[\s#?*\\]/` in a segment. In the gap, `permalink: "backend/auth-decision#blk_deadbeef"` committed successfully and every audit row it wrote named an `sp://` ref that parses as a *block of somebody else's note*. `permalinkSchema` now requires that the stored address round-trips through `parseRef` back to a note ref naming itself. ## Alternatives considered ### A per-note Durable Object (`NoteDoc`) as a serialisation point — **rejected for now** `wrangler.jsonc` already declares `NoteDoc`, and a DO is a genuine single-threaded critical section: addressed by `idFromName(permalink)`, every commit for one note would queue behind the last. It would make the whole class of interleaving impossible rather than merely handled, and it is the straightforwardly correct answer to "two writers, one note". It was rejected on four grounds, in order of weight: 1. **It does not remove the need for any of the above.** A DO gives mutual exclusion between *callers of the DO*. It gives no atomicity between D1 and R2, so the publication ordering in §2 is still required; and D1 is shared with the index queue and, from phase 3, with route handlers, so the in-batch compare-and-swap in §1 is still the only thing that makes a write safe against a writer that did not go through the DO. The DO would sit on top of a correct write path, not instead of one. 2. **Cost on the path the product is built for.** A DO is placed in one location and every write to that note pays a round trip to it. Ingesting ten thousand notes means ten thousand DO cold starts; a self-hosted instance in Europe writing to a DO placed in the US pays that latency on every save. The knowledge base is write-light and read-heavy, and the contention this ADR is about is two writers, not two hundred. 3. **Error fidelity across the RPC boundary.** `CommitError` carries a `code` and a `fix`, and phase 3's route handlers key on them. Errors crossing a DO RPC boundary arrive as plain `Error`s, so every refusal would have to be enveloped and reconstructed by hand — a second, parallel error contract to keep in step with the first. 4. **It pre-empts a design that is not made yet.** `NoteDoc` is phase 5's Yjs document for multiplayer editing. Overloading it as the commit lock now fixes decisions about its lifecycle, its storage and its addressing before the feature that needs them exists. The door stays open. If phase 5 makes `NoteDoc` the front door for editing anyway, routing `CommitService.commit` through it becomes cheap — and the compare-and-swap below it stays, because it is what protects against everything that is not the DO. ### Serialising on a lock row in D1 — rejected `update locks set holder = ? where note_id = ? and holder is null` is the same compare-and-swap this ADR already does, plus a row to leak when a Worker is evicted mid-commit, plus a lease to expire. The note row is already the lock and cannot be leaked. ### Leaving the canonical write first and compensating harder — rejected That was the previous design. The compensation is what could not be made correct: undoing a write in a store with no transaction means reading, comparing and writing again, and every one of those steps is another window. Writing the mutable key only after the winner is known removes the window instead of guarding it. ## Consequences - An uncontended commit costs one extra `head` and one extra `select` (the publisher's convergence check) and one fewer `get` and conditional `put` (the compensation it replaces). - A commit that loses the swap is refused with `CommitConflictError`, and is retried up to `MAX_COMMIT_ATTEMPTS` when the caller did not pin a sha. A splice retried this way re-applies itself to the winner's content, which is the property splice exists for. - A canonical file that disagrees with D1 is now a *classified* condition: the bytes are either a version this service committed — which `blobs/` proves, so it is a race and is retried — or bytes nobody committed, which is the only case still reported as writing to the bucket outside the write path. - Every test that wrote through `ctx.db` had to say so, and now imports `testDb` from `test/support/writable.ts`. That is a fixture writing to storage, and it should look different from the code under test. - Race tests are cheap to write: `test/support/interleave.ts` provides `tick`, `interleave` and `sweep`, and a race suite asserts a property at *every* offset rather than at the one that reproduced the bug.