Skip to content
GitHub
Platform

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.


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.


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.

ChannelNotificationChannelTransport
In-appin_appSupabase Realtime (notifications table)
EmailemailEmail delivery (supports digest aggregation)
SlackslackSlack Web API via SlackChannelAdapter
WebhookwebhookGeneric HTTP POST with JSON payload

  1. Trigger — An event handler (e.g., OnScanCompletedHandler) or service calls NotificationService.notify() with a workspace ID, event type, title, and body.

  2. Persist — The notification is always written to the notifications table via NotificationRepo. This ensures in-app delivery regardless of other channel outcomes.

  3. Resolve preferencesNotificationPreferenceRepo looks up the workspace’s preference for the given event_type. If no preference exists, only in-app delivery occurs.

  4. 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,
)

Each workspace configures notification routing per event type via NotificationPreference:

FieldTypeDescription
workspace_idUUIDOwning workspace
event_typestrEvent type (e.g., agent_insight, approval_request, scan_completed)
channelslist[NotificationChannel]Which channels to deliver to (default: [in_app])
enabledboolMaster toggle for this event type
digest_frequencyDigestFrequencyimmediate, 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.


The EmailDigestService in spectral.platform.application.notifications.email_digest aggregates pending notifications into digest emails. It supports three frequencies:

FrequencyBehavior
immediateEmail sent per event (no aggregation)
dailyAggregated into a daily digest
weeklyAggregated into a weekly digest

The digest service is called by the worker scheduling loop. For each workspace member:

  1. Fetches unread notifications from NotificationRepo
  2. Filters to notifications where the event type’s preference has email in its channels and matches the requested digest_frequency
  3. Builds an EmailDigest with 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)

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.

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 direction
workspace = binding.get_workspace("C01234") # -> UUID
channel = binding.get_channel(ws_id) # -> "C01234"

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_ts is stored as the channel_ref in ConversationChannelBinding.
  • 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.

SlackAuthMapper resolves Slack users to Spectral user accounts via email matching. When a message arrives from Slack:

  1. The mapper calls SlackClient.get_user_info() to fetch the Slack user’s email
  2. The email is resolved to a Spectral (user_id, scopes) pair via the user resolver
  3. RBAC scopes are validated before the agent processes the message

This ensures that Slack interactions respect the same permission model as the web dashboard.

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).


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.

{
"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).

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.

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.

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).