Accepted

ADR 0019 — Op-protocol additions for the block editor

Split, merge, and transform-type as first-class collaborative operations, plus the pinned awareness and acknowledgement envelopes

The four base operations — insert, update, delete, and move — cover most editing, but three common editor actions reveal gaps the moment you trace a real keystroke. Pressing Enter splits a paragraph; pressing Backspace at the start of a block merges it into the previous one; the slash menu transforms a paragraph into a heading, list, quote, or code block. This decision adds three first-class operation kinds and pins the awareness and acknowledgement envelopes the block editor depends on.

Three new first-class operations

Each new kind earns first-class status for atomic broadcast, identity preservation, or both. Decomposing them into existing operations would let a peer briefly observe a half-applied state, and would force the version-diff visitor to guess the editor's intent from paired operations.

  • split — splits a block at a point inside its primary text field. The operation carries both halves explicitly: a patch truncating the source to its prefix, and a fresh suffix block inserted immediately after. The Durable Object applies both atomically, so peers never see the prefix without the suffix.
  • merge — absorbs one block into another. The operation names the block being removed, the block that absorbs it, and the merged field shape for the target. The target keeps its identity, so the diff visitor reads it as a single semantic merge rather than an unrelated update and delete.
  • transformType — changes a block's type in place. It carries the complete new block, whose id must equal the block being transformed. Unlike update this is a wholesale replacement, not a shallow merge, because a heading and a paragraph have different field sets. The block id is preserved so the diff visitor can surface the change as a type change.

The editor is the authority on text-shape carryover — paragraph to heading carries the text forward, paragraph to code concatenates spans into source, paragraph to image discards text after a confirm. The Durable Object only enforces id stability; it never inspects the Portable Text shape, which keeps the primary-text field opaque to the DO.

Operations considered and rejected

List-item and column reflow operations were rejected as first-class: lists and columns are each a single block whose array field is replaced wholesale through update. A format operation for inline marks was rejected because marks live inside Portable Text — the editor sends the new Portable Text array through update.text, and promoting format would couple the DO to the rich-text shape. A generic replace was rejected for muddying the diff visitor's read of intent.

Op envelope and acknowledgement

Each operation carries a client-generated ULID opId used for acknowledgement matching and idempotent dedup — a retried operation with a seen opId acknowledges without re-applying. The Durable Object assigns a per-document serverSeq on accept, a monotonically increasing sequence returned in the acknowledgement and added to every broadcast. The editor applies peer operations eagerly without reordering; serverSeq is an escape hatch for richer client-side reconciliation, present now so the wire shape need not change later. Maximum operation size is 64 KiB serialised; larger operations are rejected and the editor prompts the user to split the source.

Two new error codes accompany the new operations: a merge whose target block is gone, and a transform-type whose new block id does not match the block being transformed. Both are named specifically so the editor can show a clear reject message rather than a generic schema error.

Awareness envelope

Awareness is broadcast-only and never persisted. The pinned shape carries the connection's client ID, the server-set user ID and display name, a colour deterministically derived from the user ID for paint stability, the cursor and selection positions, the soft block-lock claim, and a last-seen timestamp.

Cursor and selection positions are expressed as Portable Text key paths — references to the stable block key, span key, and character offset — never as numeric array indices, because indices shift under concurrent edits and would make a peer's cursor jump. Selections carry an anchor and a focus, and may span blocks.

  • Stale-presence TTL — peers drop a peer from the UI after 15 seconds without a fresh emit. The editor heartbeats its current state every 5 seconds even when idle, so a foreground tab stays visible. The DO does not enforce the TTL; it is a peer-side UI rule covering tabs that hold the socket but freeze their timers.
  • Active-block claim — the block someone is editing is a soft hint, not a hard lock. The DO does not arbitrate overlapping claims; the editor renders advisory "someone is editing this block" indicators and may offer a take-over affordance.

Conflict resolution

Because the actor model serialises every accepted operation, conflict means semantic conflict — both operations are well-formed, but the second sees state shaped by the first. The canonical answers are last-write-wins shapes consistent with the base update rules:

  • Same block, different fields — both edits stick (shallow merge).
  • Same block, same field — last-write-wins, mitigated by the soft block lock.
  • Concurrent splits or merges of the same block — apply in arrival order; the rare result is surprising but not destructive, and the block lock makes it vanishingly unlikely in practice.
  • Merge whose source is already deleted — the target update still applies and the redundant deletion is a no-op. Merge whose target is gone — rejected, and the editor reverts its optimistic merge and keeps the source text in place. No data loss.
  • Any operation against an already-deleted block — a no-op at the DO; it does not error, matching the defensive stance of the base operations.

No CRDT

None of the new operations introduce a state-merge problem the actor model cannot serialise. The last-write-wins resolutions are the same shape as the base same-field rule and are acceptable for the same reason — the soft block lock plus UX mitigation. True character-level concurrent editing inside a single span remains the one deferred case that would ever justify a CRDT; nothing here moves Cusp closer to needing one.

Compatibility with the DO lifecycle

There is no lifecycle change. All seven operation kinds serialise into the same block array. The flush is unchanged — it writes the materialised array through the documents repository on the same idle, operation-count, and on-disconnect triggers. A split counts as one operation for flush accounting, the same as an insert. The serverSeq counter adds one field to the meta record, resetting to one only if storage is fully evicted, since the D1 version history is the across-eviction continuity store.

Read the DocumentSession decision