Skip to content

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.

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?}).

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.

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:

  1. Tag routing

    An inbox capture that resolved a #tag to a target page routes straight to that page’s inbox, silently. This never touches the classifier.

  2. Your fallback rules

    Every level’s fallbackRules are checked, most severe level first. The first match wins and routing happens immediately.

  3. 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.

  4. 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.

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.

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 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.