Accepted
ADR 0018 — Block model schema and wire protocol
The block discriminated union, Portable Text on the wire, per-block versioning, and namespaced types
A Cusp document is an ordered array of blocks. The renderer, the admin editor, the DocumentSession Durable Object, the documents repository, the version-diff visitor, the headless API serialiser, and the import path all need one agreed shape for what a block is. This is that shape.
Block shape
Every block is a discriminated union on its type field. Three fields are common to every block: a client-assigned ULID id, the namespaced type string, and v — the schema version of that specific block type. The discriminator lives in src/shared/blocks/types.ts so the Worker, the admin SPA, and the render templates import the same definition without crossing a build boundary.
The eleven core block types are:
- core/heading — level 1 to 6 plus a Portable Text text field and an optional anchor.
- core/paragraph — a Portable Text text field and optional alignment.
- core/list — bulleted or numbered, with items carrying their own Portable Text and optional nested children.
- core/quote — Portable Text text and an optional citation.
- core/image — a media ID reference (never inline image data), alt text, optional caption, alignment, and link.
- core/gallery — ordered media-ID items with a grid, masonry, or carousel layout.
- core/embed — a provider and canonical URL with a cached oEmbed response and aspect-ratio hint.
- core/code — verbatim source (not Portable Text, so whitespace is preserved), optional language, filename, and line-number flag.
- core/columns — two to four columns, each carrying a nested block array (one level of nesting only).
- core/separator — an optional visual style.
- core/button — a plain-text label, an href, an optional style, and link target.
Nesting is deliberately shallow. Only core/columns embeds a block array, and only one level deep. List items recurse but with their own narrower item shape, not the full block union. This keeps the persisted document walkable in a single flat pass when the renderer or the diff visitor needs every block ID.
Portable Text on the wire
Inside every text-shaped field the wire format is Portable Text — the same structured shape Sanity ships. The v1 subset uses inline marks for strong, emphasis, code, underline, and strikethrough, plus links carried as out-of-band mark definitions. No custom annotation types and no inline embedded blocks.
TipTap is the rich-text editor inside text blocks, and the TipTap-to-Portable-Text mapping is lossless for everything in the subset. Portable Text was chosen over a Cusp-specific shape for concrete reasons: off-the-shelf renderers exist in every major frontend ecosystem for the headless API, the round-trip with TipTap is well-trodden, and a single small walker renders every text field across all eleven blocks.
Storing raw HTML or Markdown in the database was rejected: HTML is a sanitisation hazard at every edge, and Markdown loses inline-mark fidelity and varies across flavours.
Per-block versioning
Each block carries its own v. A migration that changes core/image from v1 to v2 touches only image blocks; every other block keeps its version. Per-block rather than per-document versioning is correct because a real document mixes blocks of different types and ages, and plugin-contributed blocks version independently of the core.
Migration is lazy on read for additive and renaming changes — a single migrateBlock helper fills defaults and accepts old field names, bumping v only on the next write, with no bulk pass. Field-type changes or restructuring use a workflow that walks documents in pages and writes each back through the versioned-write helper. Unknown block types never hard-error: the renderer and the editor both show a defensive placeholder, so a removed plugin or a foreign import never breaks an entire document.
Wire protocol for updates
The DocumentSession Durable Object moves four base operation kinds: insert, update, delete, and move, each addressed by a stable block ID rather than an index. This ADR pins what an update's patch contains: a shallow merge of top-level block fields, restricted to fields of the block's existing type.
An update replaces named top-level fields; it never deep-merges. Changing the text of a paragraph sends the entire Portable Text array for that field. The block's type, id, and v are immutable through update — changing a block's type is a separate operation. Shallow merge was chosen over JSON Patch (overkill for the whole-field replaces the editor actually produces) and over JSON Merge Patch (its null-as-deletion semantics conflict with fields that meaningfully accept null).
Shallow merge has a real payoff for concurrent editing: two editors changing different fields of the same block — one sets alignment, the other replaces text — both apply cleanly in arrival order. Two editors changing the same field is last-write-wins, mitigated by the soft block lock carried in awareness state.
Namespaced types
Block type values are namespaced strings of the form scope slash name. The core namespace is reserved for the eleven built-in blocks; the install workflow rejects any theme or plugin manifest that tries to register a core type. Themes and plugins contribute blocks under their own manifest-ID namespace, which makes collisions structurally impossible without any central registry. The renderer dispatches on type through a single switch, falling back to the registry for non-core types and to the unknown-block placeholder when nothing handles a type.
Consequences
One concrete shape serves the renderer, the diff visitor, the headless serialiser, and the import path. Per-block versioning means schema evolution avoids coordinated big-bang migrations. Portable Text gives the headless API free renderers across ecosystems. The shallow-merge update protocol lets concurrent edits to different fields of one block both stick. Namespaced types give themes and plugins a safe extension point. The cost is eleven render paths plus a Portable Text walker, and the discipline of keeping the inline-mark vocabulary small against the Worker bundle budget.
The four base ops are extended by three compound ops — split, merge, and transform-type — defined in the op-protocol additions.
Read the op-protocol additions