Events & triage
ops.events is the spine underneath the rest of the ops spine: mail
arriving, an inbox capture, a scheduler fire, an agent
finishing a run, all of it lands as a row here, and the event-triage engine decides how
loud that row should be. Nothing in Subspace pages you directly, every interruption is
mediated by a severity level you configured yourself.
The events table
Section titled “The events table”A producer, mail sync, the inbox capture endpoint, the scheduler, an agent tool, inserts
its ops.events row (id, ts, type, payload) in the same Postgres transaction
as the state change it describes. That row is the only thing the event-triage engine
reads, so an event can never exist without the change it reports having actually
committed, and it can never be lost between the two.
Once triage decides a route, it stamps the same row: severity (the level name) and
route (a small JSON object: {route, level, decidedBy, provisional?}).
Severity levels
Section titled “Severity levels”Configure levels at /settings/triage. Each row has:
| Field | Purpose |
|---|---|
name |
The level’s label (silent, normal, important, ultra are seeded) |
prompt |
The classifier instruction used when no rule matches |
route |
Where a hit at this level goes: page-inbox, queue, queue+notify, or ultra |
fallbackRules |
Deterministic matchers evaluated before any classifier call |
disclosure |
The notification body template: source-count or preview |
Routes read as:
| Route | Effect |
|---|---|
page-inbox |
Silent. The item’s node moves into an inbox, either the workspace inbox or, for a #tagged capture, that page’s own inbox. No task queue row, no notification. |
queue |
Files a row into the task queue’s Up next section. No notification. |
queue+notify |
Files the same task queue row and also fires a notification. |
ultra |
Files the task queue row and fires a notification that pierces focus and no-comms modes. |
fallbackRules are deterministic matchers, {eventTypes?, keywords?, senders?}, ANDed
within one rule, checked against the most severe level first. The seeded defaults show
the shape: important matches the keywords soc2 or deadline; ultra matches
outage. A rule hit skips the classifier call entirely, so a keyword you trust never
depends on a model being available or fast.
The triage engine
Section titled “The triage engine”Triage runs as an outbox consumer over the events topic, so it reacts to every commit
without polling. For each event it evaluates, in order:
-
Tag routing
An inbox capture that resolved a
#tagto a target page routes straight to that page’s inbox, silently. This never touches the classifier. -
Your fallback rules
Every level’s
fallbackRulesare checked, most severe level first. The first match wins and routing happens immediately. -
Built-in event-type defaults
A short list of event types (reminders due, schedule fires, meeting ends, research bookkeeping) has a deterministic route baked in, independent of your rules.
-
The classifier
Only if nothing above matched: one cheap-model call classifies the event against your levels’ prompts, raced against a 5 second timeout.
The classify call sits behind a provider seam. Under the fake-provider posture it is a
scripted matcher (urgent/outage keywords resolve to ultra, idea:/fyi to
silent, everything else to the first queue-routed level). In a real deployment it is
one AI SDK generateText call against the model named by SUBSPACE_MODEL (default
claude-sonnet-5), instructed to reply with only a level name: an exact (case-insensitive)
match wins, a reply that merely contains a level name still resolves to it, and anything
else falls through to the queue default rather than dropping the event.
When the classifier is slow or down
Section titled “When the classifier is slow or down”If the call fails or the 5 second timeout wins the race, the event routes immediately to
the queue-default level and is stamped provisional: true, it is never left unrouted.
Provisional events sit in a backlog; the next successful classify call also drains a
batch of that backlog (re-stamping each one, and filing the notification it was owed if
the corrected level upgrades the route). Because ultra and fallbackRules decisions
never go through this path, ultra delivery never waits on a third-party API.
One commit per decision
Section titled “One commit per decision”Routing is one Postgres transaction: the severity/route stamp, any node move (an ordinary audited command, not a side channel), the task queue row, and the notification outbox row all commit together. A crash mid-decision cannot leave a half-routed event.
Mail rules override the classifier
Section titled “Mail rules override the classifier”Mail rules can pin a severity level directly on a rule. When a rule
with a severity matches an incoming message, that level applies to the message’s
mail-received event deterministically, the same short-circuit fallback rules get,
without waiting on the classifier. If the classifier already routed the event first, a
matching rule can still upgrade it (a downgrade never unsends a notification already
owed), but a decision a rule already made is never overridden by a later classifier
result.