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.
The pure, total reducer
Section titled “The pure, total reducer”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
rejectedmarker 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.
The command envelope
Section titled “The command envelope”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" }}Fractional rank keys
Section titled “Fractional rank keys”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.
The tokenizer and link resolution
Section titled “The tokenizer and link resolution”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:
-
Exact title match
A reference resolves to a page whose title matches exactly.
-
Alias match
Failing that, it resolves against the page’s aliases (an old title kept after a rename).
-
Implicit create
Failing both, the referenced page is created implicitly into the
unfileddirectory, 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.
Optimistic apply and rebase
Section titled “Optimistic apply and rebase”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).
-
Apply locally
Typing runs
kb-core.applyagainst the rendered doc immediately and appends the command topending. No round trip, so the cursor never stalls. -
Commit on the server
trpc.kb.command.mutatesends 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. -
Echo and rebase
The committed command echoes on the
page:<id>topic carrying its serverseq. The client applies it toserverDoc, drops it frompendingif 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.
Convergence, rejection, and gaps
Section titled “Convergence, rejection, and gaps”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
pendingand shows a toast. Nothing is left half-applied because the reducer is total. - A
seqgap 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.