Notification System
The notification subsystem delivers agent insights, scan results, and approval requests through multiple channels — in-app, email, Slack, and webhooks. It is preference-driven: each workspace 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
workspace’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.,
OnScanCompletedHandler) or service callsNotificationService.notify()with a workspace 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 workspace’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( workspace_id=ws_id, event_type="agent_insight", title="Scan results available", body="Your latest scan completed with a 'go' verdict.", priority=NotificationPriority.normal,)Notification Preferences
Section titled “Notification Preferences”Each workspace configures notification routing per event type via NotificationPreference:
| Field | Type | Description |
|---|---|---|
workspace_id | UUID | Owning workspace |
event_type | str | Event type (e.g., agent_insight, approval_request, scan_completed) |
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 workspace 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.
Workspace-to-Channel Binding
Section titled “Workspace-to-Channel Binding”SlackWorkspaceBinding maps Slack channels to Spectral workspaces. Each workspace binds to
one Slack channel, configured during Slack app installation:
binding = SlackWorkspaceBinding()binding.bind(slack_channel_id="C01234", workspace_id=ws_id)
# Resolve in either directionworkspace = binding.get_workspace("C01234") # -> UUIDchannel = binding.get_channel(ws_id) # -> "C01234"Conversation threading
Section titled “Conversation threading”Conversations map to Slack threads:
- New conversation — A new message is posted to the workspace’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( workspace_id=ws_id, approval_id=approval.id, action_description="Apply changeset v5 to production templates", 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). Workspaces configure one webhook URL
per workspace; 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": "agent_insight", "workspace_id": "<workspace_uuid>", "notification_id": "<notification_uuid>", "title": "Scan results available", "body": "Your latest scan completed with a 'go' verdict.", "priority": "normal", "delivered_at": "<ISO-8601 timestamp>", "links": { "scan": "https://app.runspectral.com/workspaces/<workspace_id>/scans/<scan_id>" }}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-workspace
secret rotated through the same secrets-management discipline as other workspace 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. Workspace-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