Notification System
The notification subsystem delivers operator-side updates (override-pattern signal triage notifications, version-publication acknowledgements) and approval requests through multiple channels — in-app, email, Slack, and webhooks. It is preference-driven: each domain configures which event types go to which channels, with in-app as the baseline that always fires.
Architecture
Section titled “Architecture”NotificationService │ ├── NotificationRepo (persist all notifications) ├── NotificationPreferenceRepo (resolve channel preferences) │ └── NotificationDispatcher[] (per-channel delivery) ├── InAppAdapter (Supabase Realtime) ├── SlackChannelAdapter (Slack Web API) ├── EmailAdapter (email delivery) └── WebhookAdapter (generic HTTP POST)The NotificationService in spectral.platform.application.notifications is the central dispatcher.
It accepts a notification request, persists it (in-app is always the baseline), resolves the
domain’s channel preferences, and dispatches to each configured channel adapter.
Channel Protocol
Section titled “Channel Protocol”All channel adapters implement the NotificationDispatcher protocol:
class NotificationDispatcher(Protocol): @property def channel(self) -> NotificationChannel: ...
async def send(self, notification: Notification) -> None: ...The protocol is defined in the application layer (spectral.platform.application.notifications).
Infrastructure adapters implement it without the application layer knowing about Slack, email,
or webhook specifics. This follows the dependency inversion principle described in the
Architecture page.
Supported channels
Section titled “Supported channels”| Channel | NotificationChannel | Transport |
|---|---|---|
| In-app | in_app | Supabase Realtime (notifications table) |
email | Email delivery (supports digest aggregation) | |
| Slack | slack | Slack Web API via SlackChannelAdapter |
| Webhook | webhook | Generic HTTP POST with JSON payload |
Notification Flow
Section titled “Notification Flow”-
Trigger — An event handler (e.g.,
OnWorldModelVersionPublishedHandler) or service callsNotificationService.notify()with a domain ID, event type, title, and body. -
Persist — The notification is always written to the
notificationstable viaNotificationRepo. This ensures in-app delivery regardless of other channel outcomes. -
Resolve preferences —
NotificationPreferenceRepolooks up the domain’s preference for the givenevent_type. If no preference exists, only in-app delivery occurs. -
Dispatch — For each channel in the resolved preference, the service calls the matching
NotificationDispatcher.send(). Each dispatch is individually error-isolated — a Slack failure does not prevent email delivery.
service = NotificationService( notification_repo=repo, preference_repo=pref_repo, dispatchers=[in_app_adapter, slack_adapter, email_adapter],)
await service.notify( domain_id=domain_id, event_type="world_model_version_published", title="New world model version published", body="A new version of your world model has been published.", priority=NotificationPriority.normal,)Notification Preferences
Section titled “Notification Preferences”Each domain configures notification routing per event type via NotificationPreference:
| Field | Type | Description |
|---|---|---|
domain_id | UUID | Owning domain |
event_type | str | Event type (e.g., version_published, approval_request, override_pattern_aggregated) |
channels | list[NotificationChannel] | Which channels to deliver to (default: [in_app]) |
enabled | bool | Master toggle for this event type |
digest_frequency | DigestFrequency | immediate, daily, or weekly — applies to the email channel only; in-app, Slack, and webhook adapters ignore this field and always deliver per-event |
When enabled is False or no preference exists, only in-app delivery occurs. This ensures
customers always have a baseline notification in the dashboard.
Email Digest Infrastructure
Section titled “Email Digest Infrastructure”The EmailDigestService in spectral.platform.application.notifications.email_digest aggregates pending
notifications into digest emails. It supports three frequencies:
| Frequency | Behavior |
|---|---|
immediate | Email sent per event (no aggregation) |
daily | Aggregated into a daily digest |
weekly | Aggregated into a weekly digest |
Digest generation
Section titled “Digest generation”The digest service is called by the worker scheduling loop. For each domain member:
- Fetches unread notifications from
NotificationRepo - Filters to notifications where the event type’s preference has
emailin its channels and matches the requesteddigest_frequency - Builds an
EmailDigestwith a formatted subject and summary text
The EmailDigest object produces delivery-ready content:
- Subject:
"Spectral Daily Digest — 5 notifications"(count and frequency-aware) - Summary: Plain-text list of notifications with priority markers (
!for high/urgent)
Slack Integration
Section titled “Slack Integration”The Slack adapter (spectral.platform.infrastructure.notifications.channels.slack_adapter) provides bidirectional
communication: the agent can send messages and approval requests to Slack, and customers can
interact with the agent through Slack threads.
Domain-to-Channel Binding
Section titled “Domain-to-Channel Binding”SlackChannelBinding maps Slack channels to Spectral domains. Each domain binds to
one Slack channel, configured during Slack app installation:
binding = SlackChannelBinding()binding.bind(slack_channel_id="C01234", domain_id=domain_id)
# Resolve in either directiondomain = binding.get_domain("C01234") # -> UUIDchannel = binding.get_channel(domain_id) # -> "C01234"Conversation threading
Section titled “Conversation threading”Conversations map to Slack threads:
- New conversation — A new message is posted to the domain’s Slack channel (becomes the
thread parent). The returned
thread_tsis stored as thechannel_refinConversationChannelBinding. - Subsequent messages — Replies are posted to the existing thread via
thread_ts. - Multi-channel — A conversation can span channels (start in Slack, continue in web) because the conversation model is channel-agnostic.
Auth mapping
Section titled “Auth mapping”SlackAuthMapper resolves Slack users to Spectral user accounts via email matching. When a
message arrives from Slack:
- The mapper calls
SlackClient.get_user_info()to fetch the Slack user’s email - The email is resolved to a Spectral
(user_id, scopes)pair via the user resolver - RBAC scopes are validated before the agent processes the message
This ensures that Slack interactions respect the same permission model as the web dashboard.
Approval requests
Section titled “Approval requests”The Slack adapter supports interactive approval requests with Approve/Reject buttons:
await adapter.send_approval_request( domain_id=domain_id, approval_id=approval.id, action_description="Approve rule candidate for enshrinement", thread_ts=thread_ts,)This renders a Slack Block Kit message with agent_approve and agent_reject action buttons
that map to the AgentApproval entity (see Domain Model).
Webhook Integration
Section titled “Webhook Integration”The webhook adapter (spectral.platform.infrastructure.notifications.channels.webhook_adapter) provides a generic
HTTP POST channel for customers integrating Spectral notifications into systems other than email or
Slack (e.g., PagerDuty, Discord, custom in-house dashboards). Each domain configures one webhook URL;
the adapter posts a JSON payload to that URL for each notification event the
preference routes to the webhook channel.
Payload shape
Section titled “Payload shape”{ "event_type": "version_published", "org_id": "<org_uuid>", "domain_id": "<domain_uuid>", "notification_id": "<notification_uuid>", "title": "New world model version published", "body": "A new version of your world model has been published.", "priority": "normal", "delivered_at": "<ISO-8601 timestamp>", "links": { "world_model_card": "https://app.runspectral.com/orgs/<org_id>/domains/<domain_id>/world-model-card" }}Angle-bracket placeholders mark where the dispatcher substitutes per-event values (the literal JSON sent to the webhook contains concrete UUIDs and URLs).
Authentication and integrity
Section titled “Authentication and integrity”Each delivery carries an X-Spectral-Signature HMAC-SHA256 header signed with a per-domain
secret rotated through the same secrets-management discipline as other domain credentials (see
Secrets management). Receivers verify the signature against
the raw request body before trusting payload contents.
Retry semantics
Section titled “Retry semantics”Webhook delivery uses the same DLQ/retry envelope as other notification dispatches per ADR-054 — exponential backoff for HTTP 5xx and transient network errors, terminal failure (and DLQ entry) for HTTP 4xx and persistent timeouts. Domain-scoped failure ratios surface to operators through the platform observability stack.
Security considerations
Section titled “Security considerations”Webhook URLs are validated at registration time: only https:// schemes accepted; private-network
ranges (RFC 1918, link-local, loopback) rejected. The adapter does not follow redirects. Failed
deliveries do not retain customer-data payloads beyond the standard outbox/DLQ retention window
(see Data retention).
See also
Section titled “See also”- Architecture — dependency-inversion pattern that places adapters at the infrastructure layer
- Domain Model —
AgentApprovalentity referenced by Slack approval flows - Event substrate — the
NotificationDispatcherretry envelope and DLQ semantics - Data retention — webhook delivery payload retention
- Secrets management — HMAC secret rotation