23.1 Overview
Notifications connect history events to the people who need to act on them. The domain author declares which events trigger which messages, who receives them, through which channels, and what happens if nobody responds.
The notification engine is event-driven. It subscribes to history events on the message bus. When a matching event fires, the engine resolves recipients, interpolates templates, checks throttle windows, dispatches to channel plugins, and logs delivery. No custom code, no hook scripts, no controller wiring.
Prerequisite
Notifications require the History of Change system (§21). Every notification is triggered by a history event. Without history events, there is nothing to notify about.
Architecture
Record mutation
→ History event fires (§21)
→ Notification engine matches event to notification definitions
→ Resolves recipients from roles, fields, static addresses
→ Interpolates templates with event data
→ Checks throttle window
→ Dispatches to channel plugins
→ Logs delivery
→ Schedules escalation chain (if declared)
23.2 Declaration
Notifications are declared in the notifications: block of a resource:
resources:
Ticket:
history:
events:
resolved:
on: transition
transition: resolve
capture: [resolution, assigneeName]
label: "Resolved"
severity: success
notifications:
ticketResolved:
on: history
event: resolved
channels: [email]
recipients:
field: reporterId
template:
subject: "Ticket resolved — {title}"
body: |
Your ticket **{title}** has been resolved by {assigneeName}.
Resolution: {resolution}
If this does not resolve your issue, you can reopen the ticket.
priority: normal
Notification Properties
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
on | string | yes | — | Trigger source: history (reacts to history events) or schedule (time-based) |
event | string | if on: history | — | History event name to react to |
type | string | no | "instant" | instant (immediate send) or digest (batched on schedule) |
channels | string[] | yes | — | Delivery channels: email, slack, sms, push, webhook |
recipients | object | yes | — | Who receives the notification |
template | object | yes | — | Message content with interpolation |
priority | string | no | "normal" | low, normal, high, urgent, critical |
throttle | string | no | "none" | Minimum interval between sends for the same record: 1h, 24h, none |
condition | string | no | "" | AEL expression — notification sends only when true |
escalation | array | no | [] | Time-based escalation chain with recipient overrides |
23.3 Recipient Resolution
Recipients are resolved from multiple sources and deduplicated:
recipients:
roles: [operationsManager, admin]
field: assignedToId
additional: [departmentChairId, piId]
static: ["compliance@company.com"]
exclude:
field: originatorId
| Key | Type | Description |
|---|---|---|
roles | string[] | All users with these roles (resolved via auth system) |
field | string | The user referenced by this field on the record |
additional | string[] | Additional user-reference fields on the record |
static | string[] | Hardcoded addresses (email, phone, webhook URL) |
exclude.field | string | Don’t notify the user referenced by this field (prevents self-notification) |
Resolution Order
- Role recipients — query users by role from the auth system
- Field recipients — read the user ID from the record field, resolve contact info
- Additional recipients — same as field, for multiple user-reference fields
- Static recipients — used directly as addresses
- Deduplication — merge all recipients, remove duplicates by user ID
- Exclusion — remove the user referenced by the
exclude.field
Contact Resolution
Contact information is resolved from the user’s profile: email for the email channel, phone for SMS, slackId for Slack. If a user lacks the required contact field for a channel, that channel is silently skipped for that user.
23.4 Template Interpolation
Templates support {fieldName} interpolation from the history event’s captured data plus special variables:
template:
subject: "Inspection overdue — {assetTag}"
body: |
Asset **{assetTag}** ({name}) inspection was due on {nextInspectionDue}.
Criticality: {criticality}
Location: {location}
Please schedule the inspection immediately.
— {$actor.name}
Available Variables
| Variable | Source | Description |
|---|---|---|
{fieldName} | History event captured data | Any field from the capture array |
{$actor.name} | Request identity | Name of the user who triggered the event |
{$actor.email} | Request identity | Email of the triggering user |
{$record.field} | Full record | Any field on the record (not just captured fields) |
{$now} | System | Current timestamp |
{$date} | System | Current date |
{$appUrl} | Configuration | Application base URL |
{$recordUrl} | Computed | Direct link: {$appUrl}/{resource}/{id} |
Markdown Support
Template bodies support Markdown formatting. Channel plugins render Markdown appropriately for their medium — HTML for email, Slack mrkdwn for Slack, plain text for SMS.
23.5 Channels
Channel Configuration
Channel credentials are configured at the domain level in the channels: block:
domain:
channels:
email:
provider: smtp
host: ${SMTP_HOST}
port: ${SMTP_PORT:587}
username: ${SMTP_USER}
password: ${SMTP_PASS}
from: "noreply@company.com"
fromName: "Operations Platform"
slack:
provider: slack
webhookUrl: ${SLACK_WEBHOOK_URL}
defaultChannel: "#alerts"
channelOverrides:
critical: "#critical-alerts"
urgent: "#urgent-alerts"
sms:
provider: twilio
accountSid: ${TWILIO_SID}
authToken: ${TWILIO_TOKEN}
fromNumber: ${TWILIO_FROM}
webhook:
provider: http
url: ${WEBHOOK_URL}
headers:
Authorization: "Bearer ${WEBHOOK_TOKEN}"
format: json
Environment variables (${VAR} or ${VAR:default}) keep secrets out of the YAML.
Channel Plugins
Each channel is a Kalos plugin implementing the INotificationChannel interface. The platform ships with four built-in channel plugins:
| Channel | Provider | Transport |
|---|---|---|
email | smtp | SMTP with TLS |
slack | slack | Webhook POST |
sms | twilio | Twilio REST API |
webhook | http | HTTP POST with configurable headers |
Additional channels can be added as plugins without modifying the notification engine.
Channel Selection Per Notification
Each notification declares which channels to use. Multiple channels can be specified — the notification is dispatched to all of them:
channels: [email, slack, sms]
The priority field MAY influence channel behavior. Slack notifications can route to different channels based on priority (via channelOverrides). SMS may be reserved for urgent and critical priorities.
23.6 Priority
| Priority | Use For | Behavior |
|---|---|---|
low | Informational updates | May be batched in digests |
normal | Standard notifications | Delivered immediately |
high | Important alerts | Delivered immediately, may route to priority channels |
urgent | Time-sensitive actions | Delivered immediately, overrides user preferences |
critical | Safety/regulatory | Delivered immediately, overrides ALL preferences, cannot be muted |
critical priority overrides user notification preferences. A user who has disabled email notifications will still receive a critical notification. This ensures safety-critical and regulatory notifications are never suppressed.
23.7 Throttle and Deduplication
throttle: 24h
Prevents the same notification from being sent for the same record within the throttle window. “Same notification” is defined as the same notification name + same record ID.
If an asset’s inspection stays overdue and the system re-evaluates daily, the notification is sent once per 24 hours rather than on every evaluation.
| Value | Behavior |
|---|---|
none | No throttle — send every time the event fires |
1h | At most one send per hour per record |
24h | At most one send per day per record |
| Any duration | Parsed as a duration string |
throttle: none is appropriate for critical alerts that MUST fire every time. throttle: 24h is appropriate for recurring reminders.
23.8 Escalation Chains
Escalation chains are time-based sequences. Each tier fires if the triggering condition has not been resolved within the specified interval:
notifications:
sarDeadlineEscalation:
on: history
event: sarDeadlineApproaching
channels: [email, slack]
recipients:
field: assignedAnalystId
template:
subject: "SAR filing deadline in 5 days — {caseNumber}"
body: "Case **{caseNumber}** SAR filing deadline: {sarFilingDeadline}."
priority: high
escalation:
- after: 24h
channels: [email, slack, sms]
recipients:
roles: [bsaOfficer]
template:
subject: "ESCALATION — SAR deadline in 4 days — {caseNumber}"
body: "Case {caseNumber} SAR has not been filed. Deadline: {sarFilingDeadline}."
priority: urgent
- after: 48h
channels: [email, sms]
recipients:
roles: [chiefComplianceOfficer]
template:
subject: "FINAL ESCALATION — SAR deadline in 3 days — {caseNumber}"
body: "URGENT: Case {caseNumber} SAR remains unfiled. Regulatory violation imminent."
priority: critical
Escalation Tier Properties
| Property | Type | Required | Description |
|---|---|---|---|
after | string | yes | Duration after the initial notification: 24h, 48h, 7d |
channels | string[] | no | Override channels for this tier (inherits from parent if omitted) |
recipients | object | no | Override recipients for this tier (inherits from parent if omitted) |
template | object | no | Override template for this tier (inherits from parent if omitted) |
priority | string | no | Override priority for this tier |
Resolution Cancels Escalation
The runtime checks whether the triggering condition is still active before sending each escalation tier. “Resolved” means the triggering record has transitioned to a state that no longer matches the original event condition.
If the SAR is filed (case transitions to sar_filed), the escalation chain is cancelled. The runtime re-evaluates the original event’s condition against the current record state before sending each tier.
23.9 Digest Notifications
Digest notifications aggregate events over a time window and send a summary on a schedule:
notifications:
dailyOperationsSummary:
type: digest
schedule: "daily at 07:00"
channels: [email]
recipients:
roles: [operationsManager, plantManager]
query:
resource: "*"
history: true
filter: "severity == 'error' || severity == 'warning'"
since: 24h
template:
subject: "Daily Operations Summary — {$date}"
body: |
## Events in the last 24 hours
**Errors:** {errorCount}
**Warnings:** {warningCount}
### Critical Events
{#each errors}
- [{event}] {label} — {createdAt}
{/each}
emptyBehavior: skip
Digest Properties
| Property | Type | Description |
|---|---|---|
type | "digest" | Identifies this as a digest notification |
schedule | string | When to send: daily at 07:00, weekly on Monday at 09:00 |
query | object | Which events to aggregate: resource, filter, since |
emptyBehavior | string | skip (don’t send if no matching events) or send (always send) |
Template Iteration
Digest templates support {#each collection}...{/each} for iterating over matched events. Computed variables {errorCount} and {warningCount} are available automatically.
23.10 Conditional Notifications
Notifications can include an AEL condition expression. The notification sends only when the condition evaluates to true:
notifications:
highValueOrderAlert:
on: history
event: created
condition: "$record.orderTotal > 10000"
channels: [email, slack]
recipients:
roles: [financeManager]
template:
subject: "High-value order placed — ${orderTotal}"
body: "Order {$record.id} with total ${orderTotal} requires review."
The condition is evaluated after the event fires but before recipient resolution and dispatch. This allows a single history event to trigger notifications selectively based on record data.
23.11 Notification Log
Every notification dispatch is recorded in the {domain}_notifications_log table:
| Column | Type | Description |
|---|---|---|
id | uuid | Log entry identifier |
notificationName | string | Notification definition name |
channel | string | Channel used: email, slack, sms, webhook |
recipientId | uuid | User who received the notification (null for static addresses) |
recipientAddress | string | Address used (email, phone, webhook URL) |
subject | string | Interpolated subject |
status | string | sent, failed, throttled, skipped |
recordId | uuid | Triggering record |
eventName | string | History event that triggered the notification |
priority | string | Notification priority |
sentAt | datetime | When the notification was dispatched |
error | text | Error message if delivery failed |
Notification Log API
GET /api/v1/{domain}/notifications/log
?channel=email&status=failed&since=2026-05-01
Query the delivery log to audit notification history, identify delivery failures, and verify compliance notification delivery.
23.12 Notifications and Workflows
Notifications and workflows (§25) both react to history events. The difference:
| Notifications | Workflows | |
|---|---|---|
| Purpose | Deliver messages to humans | Orchestrate actions across resources |
| Output | Email, Slack, SMS, webhook | Create, update, transition records |
| Scope | Single event → single message | Multi-step process across resources |
They coexist and compose: a workflow step triggers a transition, which produces a history event, which fires a notification. Each layer is independent. A workflow step MAY also include a notify action for one-off messages within a process, distinct from the standalone notification declarations.
End of Chapter 23.
Next: Chapter 24 — Declarative Scheduled Tasks