Skip to content

The command model

Nothing in Subspace mutates the graph directly. Every change, a keystroke, an indent, a rename, an agent’s edit, is a command run through a pure reducer named @subspace/kb-core. The exact same reducer function runs on the client (optimistically, for an instant response) and on the server (authoritatively, inside one Postgres transaction). There is no dual implementation to keep in sync, and no server round trip on the typing path.

This page explains the reducer, the command envelope, how order is represented, and the convergence protocol that keeps every open window in agreement.

kb-core.apply(doc, cmd) takes a document and a command and returns a new document. Two properties make everything else possible:

  • Pure. It is a plain function of its inputs with no I/O, so the client and server get identical results from identical inputs. It is exhaustively unit-tested with table-driven specs, one row per command in the vocabulary.
  • Total. An inapplicable command (editing a node that no longer exists, for example) returns the document unchanged plus a rejected marker rather than throwing. Totality is what lets a client apply a command hopefully and recover cleanly if the server disagrees.

The command vocabulary covers node lifecycle (create, edit, move, indent, outdent, delete), element payloads (a code cell’s outputs, a table’s cells), and page-level operations (rename, merge). Crucially, the reducer emits inverses: applying a command can produce the command that would undo it.

Every command travels inside a Zod-validated envelope defined in @subspace/schema. The client mints it and the server validates it at the trust boundary:

id  uuid

Client-minted command id. It is UNIQUE in the command log, so a retried mutation deduplicates instead of applying twice.

device_id  uuid

Which paired device issued the command. Every command and event carries its origin device.

page_id  uuid

The page the command targets. The server takes a per-page advisory lock on this id.

type  string

The command name from the vocabulary (create-after, edit-text, move-node, and so on).

payload  jsonb

The command’s typed arguments, validated by the schema for that type.

// what the client sends to trpc.kb.command.mutate
{
id: "b1f0…", // client-minted, UNIQUE for retry dedup
device_id: "d3a9…",
page_id: "9c2e…",
type: "create-after",
payload: { after: "node-7", text: "New bullet", rank: "a0V" }
}

Sibling order under a parent is a fractional rank key, a short string that sorts lexically. To insert a node between two siblings, the client mints a key that sorts strictly between their two keys, so an insert or a reorder touches one node’s rank, never a renumbering of the whole list. This keeps move and indent commands small and makes concurrent inserts from two windows compose without collision.

Node text is the source of truth. After a text edit, kb-core tokenizes the text for [[links]], #tags, and ((embeds)), and resolves each reference:

  1. Exact title match

    A reference resolves to a page whose title matches exactly.

  2. Alias match

    Failing that, it resolves against the page’s aliases (an old title kept after a rename).

  3. Implicit create

    Failing both, the referenced page is created implicitly into the unfiled directory, so a link never dangles.

The same pass maintains the index rows that other features read: links (which power backlinks, queried by destination), labels (the cash: $610,000 labeled-value syntax, addressable as [[Page]]:cash), and deps (dataflow edges for table formulas and custom functions). Because tokenization is part of the reducer, these indexes update in the same transaction as the text edit. See links, tags, and embeds for the authoring side.

Applying commands optimistically alone would let windows diverge, so each client keeps two pieces of state per open page:

  • serverDoc, the fold of the page’s command log up to the last server-sequenced command.
  • pending[], the client’s own commands that the server has not yet echoed back.

The document you actually see rendered is always reduce(serverDoc, pending).

  1. Apply locally

    Typing runs kb-core.apply against the rendered doc immediately and appends the command to pending. No round trip, so the cursor never stalls.

  2. Commit on the server

    trpc.kb.command.mutate sends the envelope. The server runs the same reducer in one transaction, stamps the page’s next sequence number, and appends the command log and an outbox row.

  3. Echo and rebase

    The committed command echoes on the page:<id> topic carrying its server seq. The client applies it to serverDoc, drops it from pending if the id is its own, and replays the remaining pending commands on top. This is a rebase, not a raw echo: every window folds the same ordered log, so windows converge and the pending overlay drains to zero.

Because the server’s per-page seq is stamped inside the write transaction, it is gapless, and every window folds the same ordered log. That gives three clean rules:

  • Convergence is last-write-wins in server order. Concurrent edits to the same node resolve by server arrival order, not operational transform. A keystroke can lose a race, but the audited command log makes any loss recoverable. This is a deliberate tradeoff in favor of a simple, total reducer.
  • A rejection rolls back cleanly. If the server rejects a mutation, the client drops that command from pending and shows a toast. Nothing is left half-applied because the reducer is total.
  • A seq gap forces a re-fetch. If a client sees the page sequence jump (a missed echo), it re-fetches the page rather than guessing. Non-document surfaces (the task queue, the mail list) receive invalidation messages instead of commands, and TanStack Query re-fetches.