Chapter 23 · Volume III — The Intelligence Layer

Declarative Notifications

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

PropertyTypeRequiredDefaultDescription
onstringyesTrigger source: history (reacts to history events) or schedule (time-based)
eventstringif on: historyHistory event name to react to
typestringno"instant"instant (immediate send) or digest (batched on schedule)
channelsstring[]yesDelivery channels: email, slack, sms, push, webhook
recipientsobjectyesWho receives the notification
templateobjectyesMessage content with interpolation
prioritystringno"normal"low, normal, high, urgent, critical
throttlestringno"none"Minimum interval between sends for the same record: 1h, 24h, none
conditionstringno""AEL expression — notification sends only when true
escalationarrayno[]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
KeyTypeDescription
rolesstring[]All users with these roles (resolved via auth system)
fieldstringThe user referenced by this field on the record
additionalstring[]Additional user-reference fields on the record
staticstring[]Hardcoded addresses (email, phone, webhook URL)
exclude.fieldstringDon’t notify the user referenced by this field (prevents self-notification)

Resolution Order

  1. Role recipients — query users by role from the auth system
  2. Field recipients — read the user ID from the record field, resolve contact info
  3. Additional recipients — same as field, for multiple user-reference fields
  4. Static recipients — used directly as addresses
  5. Deduplication — merge all recipients, remove duplicates by user ID
  6. 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

VariableSourceDescription
{fieldName}History event captured dataAny field from the capture array
{$actor.name}Request identityName of the user who triggered the event
{$actor.email}Request identityEmail of the triggering user
{$record.field}Full recordAny field on the record (not just captured fields)
{$now}SystemCurrent timestamp
{$date}SystemCurrent date
{$appUrl}ConfigurationApplication base URL
{$recordUrl}ComputedDirect 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:

ChannelProviderTransport
emailsmtpSMTP with TLS
slackslackWebhook POST
smstwilioTwilio REST API
webhookhttpHTTP 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

PriorityUse ForBehavior
lowInformational updatesMay be batched in digests
normalStandard notificationsDelivered immediately
highImportant alertsDelivered immediately, may route to priority channels
urgentTime-sensitive actionsDelivered immediately, overrides user preferences
criticalSafety/regulatoryDelivered 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.

ValueBehavior
noneNo throttle — send every time the event fires
1hAt most one send per hour per record
24hAt most one send per day per record
Any durationParsed 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

PropertyTypeRequiredDescription
afterstringyesDuration after the initial notification: 24h, 48h, 7d
channelsstring[]noOverride channels for this tier (inherits from parent if omitted)
recipientsobjectnoOverride recipients for this tier (inherits from parent if omitted)
templateobjectnoOverride template for this tier (inherits from parent if omitted)
prioritystringnoOverride 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

PropertyTypeDescription
type"digest"Identifies this as a digest notification
schedulestringWhen to send: daily at 07:00, weekly on Monday at 09:00
queryobjectWhich events to aggregate: resource, filter, since
emptyBehaviorstringskip (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:

ColumnTypeDescription
iduuidLog entry identifier
notificationNamestringNotification definition name
channelstringChannel used: email, slack, sms, webhook
recipientIduuidUser who received the notification (null for static addresses)
recipientAddressstringAddress used (email, phone, webhook URL)
subjectstringInterpolated subject
statusstringsent, failed, throttled, skipped
recordIduuidTriggering record
eventNamestringHistory event that triggered the notification
prioritystringNotification priority
sentAtdatetimeWhen the notification was dispatched
errortextError 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:

NotificationsWorkflows
PurposeDeliver messages to humansOrchestrate actions across resources
OutputEmail, Slack, SMS, webhookCreate, update, transition records
ScopeSingle event → single messageMulti-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