WebSocket topic bus
The row’s topic (for example page:<id>, run:<id>, or an invalidation channel) is
published to every WebSocket client subscribed to it. Open pages fold the echoed command;
list views receive an invalidation and re-fetch.
Subspace has exactly one change-propagation mechanism. Any committed transaction that
changes durable state appends a row to ops.outbox, and a single dispatcher fans those rows
out to two audiences: WebSocket topics (so open pages update live) and in-process
consumers (the derived-state engines). Everything reactive in the product, live editing,
search indexing, the OKF dump, table recompute, event triage,
mail and calendar sync, the agent engine, notifications,
rides this one feed.
The write path commits an outbox row inside the same transaction as the change it describes,
then a trigger fires NOTIFY subspace, <seq>. A single dispatcher task holds one reserved
Postgres connection just for LISTEN. The postgres.js pool sets aside this connection so a
Gmail backfill or a burst of keystrokes can never starve the propagation path.
The notification payload is only the sequence number, never the change itself. That keeps the notification tiny and, more importantly, means a dropped or coalesced notification cannot lose data.
On each wake-up (a NOTIFY, or a periodic safety poll), the dispatcher reads ops.outbox
rows past its cursor, not just the row named in the payload. So the mechanism is:
Commit appends a row
The write transaction appends ops.outbox(seq, ts, topic, kind, ref) and commits.
Notify wakes the dispatcher
A trigger fires NOTIFY with the new seq. If that notification is ever missed, the next
one (or the safety poll) still triggers a read.
Read everything new
The dispatcher selects all outbox rows with seq greater than its cursor and processes
them in order. A missed notify is recovered by the cursor; nothing is lost across a
restart.
This is why a server can crash mid-fan-out and lose nothing: the cursor in ops.cursors only
advances after a row is durably handled, so on reboot the dispatcher simply reads from where
it left off.
For each outbox row, the dispatcher does two things:
WebSocket topic bus
The row’s topic (for example page:<id>, run:<id>, or an invalidation channel) is
published to every WebSocket client subscribed to it. Open pages fold the echoed command;
list views receive an invalidation and re-fetch.
In-process consumers
Each registered consumer sees the row and advances its own ops.cursors row
independently. A slow consumer never holds up a fast one, and each consumer is either
idempotent or fully rebuildable from base tables.
The ops.cursors table has one row per consumer (consumer PK, seq). Because every consumer
tracks its own progress, adding a new consumer is just registering a function and giving it a
cursor; it back-fills from wherever you point its cursor and then keeps pace.
The dispatcher drives a roster of consumers, each turning “something committed” into derived state or deferred work:
| Consumer | What it does |
|---|---|
| Search indexer | Chunks changed nodes, files, mail, and memory, embeds them, and upserts the pgvector index (see search) |
| OKF exporter | Rewrites changed pages to their Markdown files on disk (see the OKF bridge) |
| Recompute | Walks the deps closure of a change and recomputes table formulas and custom functions in topological order (see tables and formulas) |
| Triage | Classifies new events by severity and routes them (see events and triage) |
| Mail / calendar sync | Reacts to sync events and folds provider changes into the graph (see mail, calendar) |
| Agent engine | Resumes runs on approval and question notices, and sweeps for orphaned runs (see the agent engine) |
| Notifications | Delivers severity-routed alerts to desktop and mobile (see notifications) |
| Blob replicator | Uploads each new content-addressed blob to off-machine storage within seconds |
| Retention | Prunes outbox, events, command log, and run history past their windows |
The scheduler, reminders, and inbox all build on this same feed.
A consumer that can call an external API or spawn work needs guardrails so one bad row cannot wedge the whole feed:
Because derived consumers (indexer, exporter, recompute) are rebuildable, a consumer that
falls too far behind can be reset and replayed from base tables. subspace reindex does
exactly this for the search arms.
The outbox is not only a live feed; it is how the system schedules durable work that must survive a restart. An agent that pauses for approval, a follow-up nudge scheduled for three days out, or a blob waiting to replicate all live as rows plus cursors, never as in-memory timers. When the process restarts, the deferred work is still there and the relevant consumer picks it back up.