← Notifications

Data model

notifications · workzone

Five tables in core: channels, routes, personal preferences, an event journal, and delivery. One dispatcher writes the event, splits it across two addressing models and journals delivery — the mechanics live in the dispatcher, the feed views live in Web App and Admin Panel.

1 Tables Channels · routes · personal preferences · feed · delivery.
notification_channels where we deliver
seed: in_app · email
id BigInteger PK
kind Text NOT NULLCHECK channel type · → Slack — a preset
preset Text NULLCHECK webhook payload format · NULL for built-ins
name Text NOT NULL channel pill label in the matrix
url_enc Text NULL webhook endpoint · write-only, AES-256-GCM · NULL for built-ins
secret_enc Text NULL HMAC secret for body signing (generic) · write-only, AES-256-GCM · optional, NULL for built-ins and Slack
is_builtin Boolean NOT NULLDEFAULT true for in_app / email · undeletable
enabled Boolean NOT NULLDEFAULTCHECK false = channel paused: routes intact, delivery held · in_app — CHECK always true
last_test_ok Boolean NULL result of the “Test” button · NULL = never tested
last_test_at DateTime(tz) NULL
meta JSONB NOT NULLDEFAULT preset specifics (Slack channel, headers)
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger
notification_routes type × channel matrix · defaults
FK → notification_channels UNIQUE(event_type, channel_id)
id BigInteger PK
event_type Text NOT NULLUNIQUE*CHECK category · → Matrix
channel_id BigInteger FK→channelsIDX matrix column
enabled Boolean NOT NULLDEFAULTCHECK channel pill in the matrix · orthogonal to channel pause · → Lock
locked Boolean NOT NULLDEFAULT in-app × security · sync — true: this route cannot be disabled
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger
notification_prefs personal narrowing · profile
FK → users UNIQUE(user_id, event_type)
id BigInteger PK
user_id BigInteger FK→usersIDX any user · users
event_type Text NOT NULLUNIQUE*CHECK category
in_app_enabled Boolean NOT NULLDEFAULT personal in-app toggle per type · free opt-out
email_enabled Boolean NOT NULL personal email toggle per type · the value at INSERT is written by code from the type catalog, not a column DEFAULT (personal types are opt-in false)
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger · no row = catalog default
notifications event · in-app feed · audit
journal · series dedup within a window
id BigInteger PK
event_type Text NOT NULLCHECK category · catalog
severity Text NOT NULLCHECK severity chip · orthogonal to type
target_user_id BigInteger FK→usersNULLIDX recipient of a targeted event (agent · account) · NULL = broadcast by role · → Two models
title Text NOT NULL title · i18n key (agent.run_failed), not a rendered string
body Text NULL body · i18n key · NULL = title only
title_params JSONB NOT NULLDEFAULT substitution params for title / body ({agent:"Sales watch"}) · rendered in the reader’s language
source Text NULL source module (harvester · auth · agent_engine · knowledge_store · admin)
source_ref Text NULL deep link to an entity (agent/7 · run/42 · source/17)
meta JSONB NOT NULLDEFAULT action payload (Discovery: container for “into scope”)
dedup_key Text NULLIDX series collapse key · NULL = not deduped · → Throttling
dedup_count Integer NOT NULLDEFAULT series counter “×N” · grows on a repeat within the window
last_seen_at DateTime(tz) NULL timestamp of the latest event in the series · compared against the dedup window
created_at DateTime(tz) DEFAULT now() · first event of the series
updated_at DateTime(tz) DEFAULT now() + trigger · mutates on a dedup increment
notification_deliveries per-channel delivery · read-state
FK → notifications · channels · users UNIQUE(notification_id, channel_id, user_id) UNIQUE(notification_id, channel_id) WHERE user_id IS NULL
id BigInteger PK
notification_id BigInteger FK→notificationsUNIQUE*IDX which event · delivery idempotency key
channel_id BigInteger FK→channelsNULLIDX in_app · email · webhook · webhook channel deleted → NULL, delivery row intact (audit)
user_id BigInteger FK→usersNULLIDX personal recipient (in_app / email) · NULL for webhook
state Text NOT NULLCHECK delivery status · read only for in_app
error Text NULL reason for failed
sent_at DateTime(tz) NULL send timestamp
read_at DateTime(tz) NULL in_app: when read · unread = NULL
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger · state mutates
Two addressing models — split by one column, target_user_id → Dispatcher
broadcast target_user_id = NULL
Recipientsa role slice: users with owner/admin and status=active, computed by query — promote someone to Admin → they’re a recipient, revoke the role → they drop out
Typessync · security · budget · system · discovery
Webhookyes — delivery has user_id = NULL
targeted target_user_id = N
Recipientone user, any — including a member
Typesagent (run, governance) · account (role, temporary password)
Webhookno — personal types don’t go over webhooks
The column is additive: old types write NULL and follow the previous path, the targeted branch is a single condition in the dispatcher; deliveries.user_id is already per-user, so the delivery schema didn’t change. There is no separate recipients table — personal narrowing lives in notification_prefs (per-user × type, flags in_app and email). We deliberately keep no external human recipient — that role is played by a webhook channel, not a recipient row outside the role system (personal settings).
Slack — a webhook preset, not a separate entity → Channels screen
A single notification_channels holds all channels — the “Slack” and “any tool” scenarios converge into one table, and the notification_routes matrix references channels uniformly by channel_id.
  • in_app · email — is_builtin rows from the migration: undeletable, in_app can’t be disabled
  • webhook — an ordinary row with a preset (slack · generic) that selects the payload format; Slack has no dedicated column
  • webhook — a broadcast-only channel: it has user_id = NULL, personal types don’t fan out over it
  • these are outbound alerts into a channel — the conversational Slack bot is a separate entity: inbound and personal
Channel default lives in the type catalog; email for user types is opt-in → Catalog
per-channel delivery = routes.enabled ∧ (prefs.<channel>_enabled or the catalog default) — one formula for broadcast and targeted
platform
sync · security · budget · system · discovery → in-app + email
agent · account
in-app only; email opt-in — the user turns it on in their profile
With no notification_prefs row, the value falls back to the type catalog default, not a global “both on”. So a member sees the notification bell by default but receives no emails until they choose to.
What cannot be disabled centrally
One route is guaranteed — in-app for security and sync: a critical type is always visible at least in the feed, so a single admin can’t leave the organization without a signal.
  • Lock: the in_app × security and in_app × sync routes are seeded locked=true; CHECK (enabled OR NOT locked) prevents disabling them
  • The level is organizational, in-app only: email and webhook for these types, like all channels for other types, stay free
  • Personal opt-out is always available (notification_prefs); agent · account are not held by the lock
Event is a journal, delivery is state
  • notifications — a journal, ~append-only; the only mutation is series dedup within a window (same dedup_keydedup_count++, last_seen_at), immutable past the window
  • Text is i18n keys title · body + title_params, rendered in the reader’s language, not a frozen string
  • notification_deliveries — state (queued → sent → read); the in-app feed = notifications ⋈ own deliveries, unread = read_at IS NULL
  • Deletion: event → cascades deliveries; user → their personal rows (the broadcast fact stays intact); webhook channel → channel_id = NULL (audit lives on)
The dispatcher and event sources — in the mechanics.
2 Migration Creates the tables and seeds channels and routes.
alembic/versions/NNN_core_notifications.py
Creates the five tables in dependency order: notification_channelsnotification_routes · notification_prefs · notificationsnotification_deliveries.
  • INSERT the two built-in channels (is_builtin: in_app, email)
  • Seeds the full routes matrix — a row for every type × built-in channel cell (enabled=true); webhook for the targeted types agent · account is not seeded
  • The in_app × security and in_app × sync cells — locked=true (what cannot be disabled)
  • downgrade() drops the tables in reverse order