--- title: "ADR 0012 — A note open in an editor has exactly one writer, and it is the Durable Object" type: decision tags: [signpost, adr, decision] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:39:23.850Z" --- # ADR 0012 — A note open in an editor has exactly one writer, and it is the Durable Object **Status** Accepted · **Date** 2026-08-15 · **Supersedes** nothing · **Relates to** ADR 0005 (write serialisation), ADR 0003 (source-preserving Markdown), ADR 0009 (permission model), PRD phase 5, non-negotiable 3 (one write path) ## Context Phase 5 shipped a server with no product. `NoteDoc` — `apps/worker/src/durable/note-doc.ts` — holds a Yjs `Y.Text` of a note's Markdown, speaks it to hibernating WebSockets, and folds it back into R2 through `CommitService` on a debounce, one commit per contributing author. `useCollaboration` is the browser half, 907 lines with 21 tests, and until now **it was imported by no screen**. The reason it was not mounted is one unanswered question, recorded in `docs/STATUS.md`: the note screen already has a save path (`useSaveNote` → `PATCH /api/notes/{permalink}`) and the Durable Object has another, so somebody has to decide which one owns a note that is open in an editor. Three things can touch one note at the same instant: a person typing in a browser, an agent writing through MCP, and a git pull. The object already absorbs the second and third by polling R2 every `EXTERNAL_POLL_MS` (10 s) and merging before it commits. What was never decided is the first. Invariant I1 does not settle it. Both paths go through `CommitService`, so both are legal writes; what they are not is a single *decider of the bytes*. That distinction is the whole of this ADR. ### What two deciders actually costs, in order of how bad it is Take a note at `content_sha` `S0`, open in a browser with a live session. 1. **Provenance is silently reassigned.** Alice and Bob are both in the document. Bob presses ⌘S. `PATCH` sends *Bob's whole local source*, which by then contains Alice's paragraph, and commits it as one change by Bob. `planNoteWrite` writes a provenance row for every block id that is new to the note, so Alice's paragraph gets Bob's name on it. This is precisely the failure the object's per-author replay was built to prevent (`note-doc.ts`, "THE HARD PART"), arriving through a door the object cannot see. Nothing breaks, no test goes red, and the provenance table is lying — the shape of defect class the project has confirmed 63 times. 2. **The person is told a lie they cannot act on.** The debounce fires at t+2 s and commits `S0 → S1`. The person presses ⌘S at t+3 s holding `expected_sha: S0`. The Worker answers 409 and the screen says "somebody else wrote first; reload and reapply". Nobody else wrote. Their own document wrote, and reloading returns `S1`, which already contains their edit. A conflict banner for a save that had already succeeded teaches people to stop reading conflict banners. 3. **Duplicate commits.** Even when the browser consumes the `saved` frame and re-bases on `S1`, the `PATCH` commits bytes the object also holds as pending rows; the object absorbs the write as external, replays its own rows, and produces the same text again. `CommitService` has an `outcome: "unchanged"` path that keeps this from being a data bug, so it costs an audit row and an index job rather than a corruption. It is still work done twice for one keystroke. ## Decision **A note has exactly one writer at a time. Opening a live editing session transfers that writer to the `NoteDoc` Durable Object; the session ending hands it back to the screen.** Concretely, in three rules. ### 1. While the session owns the note, the screen does not `PATCH` Ownership is a function of one value the screen already has, `CollabStatus`: | Status | Who writes | Why | | --- | --- | --- | | `idle` | the screen, by `PATCH` with `expected_sha` | There is no session. Read mode, and any browser where the socket is switched off | | `connecting` | the object | The socket is opening and the local edits are already in the CRDT; a `PATCH` here would race the first sync | | `live` | the object | The debounce is the save | | `offline` | the object | `PATCH` travels the same dead network. The edits are in `y-indexeddb` and go out on reconnect, which is what the status line already promises | | `refused` | the screen, by `PATCH` | The session will not come back. Somebody holding unsaved bytes must have a way to write them, and the button returns saying so | The Save button does not disappear under object ownership — a keystroke that does nothing is worse than either option, and `⌘S saves` is already printed in the editor footer. It changes meaning: it asks the object to flush now, over the socket, as a `flush` frame. That is not a second write path. It is a request to the one writer to stop waiting. ### 2. An external commit merges into the open document. It never invalidates the session Rejected: expire the session, or refuse to merge, when the note changes underneath. The document is a CRDT over the exact bytes R2 stores. An external write arrives as the minimal `TextSplice` between what the document holds and what the bucket now holds, so the agent's paragraph and the person's half-finished sentence both survive. Invalidating the session has only two possible endings — discard the person's buffer, or make them reconcile by hand — and the first is the data loss this design exists to prevent while the second is the job a CRDT is for. It would also contradict the product: PRD phase 5's third manual gate is *"trigger an agent write via MCP while the note is open — it appears live"*. ### 3. The merge is applied **and** shown. Never silently, never on a click Rejected: hold the incoming change and ask the person to accept it. An agent's write is already committed to R2 before this document hears about it — it went through `CommitService` at the other door. So "reject" is not a thing the interface can honestly offer; there is nothing to reject, only something to *revert*, which is a different action with a different name and lives in the version list. And a merge that waits for a click leaves the tab showing stale bytes while its status line claims to be live, which is the exact "you think you are connected and you are not" failure `Collaborators.tsx` was written against. So the change lands, and the screen says so in words that name the door it came through, and offers the diff of what arrived — computed in the browser from the bytes it held a moment ago, so it costs no request and is available even when the person cannot read the audit log. **No edit is lost without a person seeing it happen.** The three ways an edit could go missing are each answered: a concurrent human edit merges (CRDT), an external commit merges and is announced (rule 3), and a refused commit — a note grown past `MAX_MARKDOWN_LENGTH`, a lone surrogate pasted in — keeps every pending row, marks the document blocked, and broadcasts a `refusal` frame that the screen shows as an alert with its fix. ## Consequences - `useSaveNote` stops being the note screen's only save. It keeps its `PATCH`, and the screen calls it only when it owns the note. One `dirty` state becomes two: "the screen holds bytes R2 does not" and "the object holds bytes R2 does not". The second is shown as "Saving" then "Saved", not as a button. - The frame protocol gains one client-to-server type, `flush` (8). It carries no payload. The object's `readOnly` participants are refused it exactly as they are refused an update. - **Read mode still opens no socket.** A reader costs nothing and a socket per read would be a Durable Object wake per read (PRODUCT.md §7.3). The consequence is honest and worth writing down: a note being *read* does not update live; a note being *edited* does. The one manual gate about agent writes is therefore performed in write mode. - **If the Durable Object is unavailable but the API is not, an open editor cannot save.** Status sits at `offline`, the bytes are in `y-indexeddb`, and the person is told the connection dropped. Handing ownership back to `PATCH` on a repeated failure was considered and rejected for this release: the two deciders would then be reintroduced precisely when the system is least able to reconcile them. The mitigation available today is the flag below. - `?collab=off`, or `localStorage["signpost.collab"] = "off"`, forces `idle` and restores the phase 4 behaviour exactly. It is an operator kill switch that needs no deploy, not a rollout flag. ## What was rejected | Option | Why not | | --- | --- | | Keep both writers; make `PATCH` win by refreshing `expected_sha` from the `saved` frame | Fixes the false 409 and none of the provenance reassignment, which is the serious one. Bob's save still carries Alice's paragraph under Bob's name | | Route `CommitService` calls *through* the object for every write to an open note — MCP included | The strongest form of "one writer", and too much for this change: it makes the object a required participant in the MCP write path, so an object that is slow to wake becomes latency on `remember`, and an object that fails becomes a failed agent write. The polling absorb already converges; `noteWrittenElsewhere()` is the seam that makes it instant when this is revisited | | An external commit invalidates the session and forces a reload | Discards the person's unsaved buffer, or asks them to merge by hand. Both are worse than what a CRDT already does correctly, and it breaks phase 5's own manual gate | | Show the incoming merge and apply it on acceptance | The write is already in R2; there is nothing to accept. Meanwhile the tab shows bytes it knows are stale while claiming to be live | | Lock the note against external writes while somebody has it open | Turns an agent write into a refusal that depends on whether a person happens to have a tab open, which is unpredictable from the agent's side and unexplainable from the person's. It also makes an abandoned tab a denial of service on the knowledge base | | Make the Save button flush over a new REST route rather than a socket frame | A second authenticated door, a second CSRF surface and a second permission resolution, for a message the already-authenticated socket to the exact object can carry in one byte |