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).
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
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
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 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.
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_paused → true |
| Agent model removed |
agent_engine |
warning |
agents.model_id → NULL (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_password → true (admin reset) |