← Notifications

Dispatcher

notifications · workzone

How an event from any module reaches its recipient: a single dispatcher assembles the event, splits it across two addressing models and channels, and journals delivery. The schema of channels, routes and the feed lives in the data model; the settings screen lives in Admin Panel.

1 Dispatcher source → addressing → channels → delivery
1 Source a module calls the dispatcher in core
2 Event a notifications row
3 Addressing broadcast / targeted
4 Fan-out route × channel → deliveries
5 Delivery in-app instant + push · email/webhook queued
A source raises the event, doesn’t send it itself
Modules know nothing about email or Slack. On a failure, a threshold or a governance action they call the dispatcher in core — it writes the notifications row and drives delivery from there. Raising an event and delivering it are separate concerns; there are no cycles between modules.
  • Writers: Harvester · Auth · Agent Engine · Knowledge Store · Admin — the full list is in the catalog below.
  • Row: type · severity · source · entity reference · target_user_id (when targeted).
Two addressing models — by event type → The target_user_id column
The event type carries its own addressing model; the dispatcher branches on a single condition — whether target_user_id is set. The old admin types always write NULL — their path is untouched.
Broadcast
target_user_id IS NULL
Recipients are a role slice (owner / admin, status=active). Platform-level signals for the organization.
sync security budget system discovery
Targeted
target_user_id = X
The sole recipient is a specific user (an agent’s owner, the subject of a record). Any role, including member.
agent account
Fan-out across channels via routes → Matrix → Cache & Workers
The dispatcher takes the notification_routes for this type and, for each enabled route, creates a notification_deliveries row.
  • Route enabled=true — otherwise the type doesn’t go over that channel. There is no separate “type switch”: with every route off, the type goes to no one.
  • Channel enabled=false is skipped entirely — a tested webhook can be kept in reserve.
  • Lock: in_app × security and in_app × sync are locked=true — a critical type can’t be zeroed out, it’s always visible in the feed.
  • Personal channels (in-app, email) are also checked against the recipient’s settings.
in-appwritten immediately and signals pub/sub — the notification bell updates instantly (push); the nudge reaches the client over the platform live-updates stream.
email · webhookqueued to SAQ — retries and throttling, so a burst doesn’t swamp delivery.
Recipients minus personal opt-out → Personal settings
Recipients aren’t stored as a list — the dispatcher computes them on the fly and removes anyone who opted out.
  • Broadcast → all users with role owner / admin, a dynamic status = active slice, not a manual list.
  • Targeted → one recipient, target_user_id.
  • Personal opt-out: each recipient is checked against notification_prefs (in_app_enabled, email_enabled) per type; with no row, the catalog default applies (email is off for personal types, opt-in).
  • No locks at the personal level — you can unsubscribe from any type; the organizational lock holds in-app security / sync only at the organization level (what cannot be disabled).
  • A webhook has no person — it’s a shared team endpoint; personal settings don’t apply, and targeted events don’t go over it.
Dedup and throttling
Same-kind events within a window collapse into one notification with a growing counter — not N emails for every failure.
  • Dedup key: the specific event (not the event_type category) + the affected entity (source_ref); for targeted — plus the recipient.
  • Kept apart: “source went down” and “failure series” stay distinct notifications.
  • Window and thresholds are fixed in v1; per-instance fine-tuning is v2.
Webhook payload — by channel preset → Channels
The channel preset selects the POST body shape: Slack gets its own message format, generic gets neutral JSON for any receiver (a custom bot, n8n, Teams via Power Automate Workflow):
{ // generic preset — neutral JSON "event": "sync.failure_series", "severity": "critical", "title": "Jira source: 5 failures in a row", "source": "harvester", "source_ref": "source/42", "ts": "2026-05-27T14:30:00Z" }
  • Endpoint and secret (url_enc, secret_enc) — write-only: not returned in the UI or in exports.
  • “Test” sends this same body with a dummy event.
  • Only broadcast goes over webhooks — targeted events are personal and don’t fan out over a team channel.
2 Event catalog what raises a notification · by type
Admin events broadcast → to administrators
Event Source Severity Condition
Sync
Run failure series harvester critical several sync_runs.state=failed in a row for a source
Source went down harvester critical source probe → last_probe_status ≠ ok (health error)
Run with losses harvester warning succeeded with error_count > 0 (some in DLQ)
DLQ building up v2 harvester warning dead-letter backlog grows past a threshold
Security
Password brute-force auth critical ≥ 10 failed logins per account / 15 min
Role change auth critical users.role changed (owner / admin / member)
API key expiring auth warning 7 days before expires_at
Last Owner v2 auth warning only one Owner left in the system — lockout risk
Budget
AI spend threshold admin critical SUM(cost) for the month > ai_monthly_budget
System
Provider unavailable admin critical provider connection check → error (health, not spend)
Service degraded / down v2 admin critical postgres / redis / n8n not responding normally
Backup failed knowledge_store critical a scheduled KS snapshot ended in an error
Curation Pass failed knowledge_store warning curation_runs.state=failed
Re-embedding overran v2 knowledge_store warning the run after a model change took longer than expected
Storage near limit v2 admin warning PG / pgvector approaching their limit
Discovery
New container v2 harvester info an object outside the source’s allowed scope — with an “into scope” action
General events targeted → to a user
Event Source Severity Condition
Agent · targeted → owner · governance
Run failed agent_engine warning agent_runs.state=failed (reason error / iteration_cap / stale)
Weekly budget exhausted agent_engine info agent_runs.state=skipped, reason=budget_exceeded
Agent paused by admin agent_engine info agents.admin_pausedtrue
Agent model removed agent_engine warning agents.model_idNULL (model removed from the list)
Run completed successfully v2 agent_engine info run output delivery — deferred along with delivery into the chat thread
Account · targeted → user
Role changed by admin auth info audit_log of a role change (meta old→new)
Temporary password issued auth warning users.must_change_passwordtrue (admin reset)