Skip to content

Notifications as First-Class Ontology

Notifications are the canonical proof of Atelier's "declare the interesting 20%, the platform provides the 80%" thesis applied to civic apps. Before Epic #33, adding a notification was a three-place code change (a template file, a trigger call site, and a REGISTRY entry); after it, a notification is an ontology row an operator authors — an action's side-effect emits a named event, ontology-stored notification_rule rows subscribe to it, notification_template rows render it, and a single Temporal workflow fans delivery out per rule. The runtime dispatch path never reads code config: dispatcher._resolve_dispatch_rules reads notification_rule rows over HTTP (admin-api/app/notifications/dispatcher.py), and the in-code REGISTRY (admin-api/app/notifications/registry.py) survives only as a greenfield seed source. This subsystem is where the Execution plane (see The Action Engine) hands off to a declarative Surface plane for messaging, and where Martha is not the backend — admin-bff runs its own NotificationWorkflow on its own queue, deliberately separate from the CloudEvents path to Martha.

Data model

NamePurposePlaneKey fields
notification_eventCatalog of named notification opportunities; the FK target of every rule. Engineering-authored, read-only in UI (D5).Vocabulary (system)code (unique slug), label, payload_schema (drives filter+template autocomplete), is_active; rel source_action. control_plane:true, tenant_scoped:true
notification_templateSubject + Jinja2 body of a message; supports extends-inheritance and per-org override.Templatecode, subject (str.format), body_html/body_text (Jinja2 TEXT, not FileField — D1), available_vars; rels extends, organization. Base layout has code='base'
notification_ruleThe subscription: when event X matches filter, send template Y to resolver Z on channels C, optionally org-scoped.Templatename, filter ({field,op,value}), recipients_resolver (string, required), channels (json array), order, is_active; rels event (req), template (req), organization (drives D3), recipient_rule (modern path). forkable:true
notification_recipient_ruleDeclarative recipient logic (#46) — replaces the code-string resolver for new rows.Templatekind in {custom_function, entity_field, role, group, subscribers, segment}, function_key, path+follow[], role_code/role_scope, group_code
action_side_effectThe action's declared output; bridges action to event.Templatekey, event_type (STRING — the field runtime reads), when_parameter/when_equals (action-param gate), order; rels action_type (req), event (FK added 4a, unused at runtime)
notification_subscriptionCitizen preference rows (#241); the subscribers kind reads these.Tenantuser_id (server-stamped KC sub), email, filters (wildcard matcher), is_active. OWN-scoped
notification_deliveryPer-recipient send ledger (#241 S2), written after each channel attempt.Tenantevent_code, channel, status, recipient_user_id/recipient_email, entity_id, workflow_id, dedup_key (unique)
admin_api.notificationsIn-app tray rows persisted by the persist_inapp activity.BFF-Postgresuser_id, event_type, entity_id, message, read
REGISTRY (NotificationConfig)SEED-ONLY in-code bootstrap; runtime never reads it.Runtime (code)dict event_code to {channels, template, subject, recipients_resolver}, ~19 entries

How it's declared

The operator path is two steps (CLAUDE.md note 43; docs/admin/notifications-and-actions.md). Step 1 — emit: in the action editor at /settings/actions/<id> → Effects tab → New side effect → pick a notification_event from the catalog (auto-generates key=emit_<code>). This writes an action_side_effect row. Step 2 — subscribe: at /settings/notification-rules → New rule, choosing Event (locked after create), optional Filter (10-op {field,op,value} builder), Template, Recipients resolver (fed by GET /notifications/resolvers), Channels chips (GET /notifications/channels), Scope (org — blank = global), Order, Active. Templates are authored at /settings/notification-templates with subject, body_html/body_text in a Jinja2 CodeMirror, an extends select, a "Clone for org" override button, and a live sandboxed preview via POST /notifications/templates/preview (admin-api/app/routers/notifications.py). Events themselves are read-only everywhere (D5) — operators subscribe, they don't invent the vocabulary.

The declarative alternative is a provisioning sheet notification block (the "A6" pattern): a vertical sheet declares side-effect emits and notification_rule rows directly (e.g. sheet_d2/d4/d6/a1). Direct entity-proxy editing at /entities/<type_code> is the bulk-edit fallback. See The Action Engine for how action_side_effect attaches to an action, and The Provisioning Sheets & Bootstrap for the import lane.

How it's provisioned

Entity TYPES are declared in admin-api/provisioning/sheets/sheet_00_platform.yaml (notification_event at :1715 with control_plane:true+tenant_scoped:true; notification_rule at :1758 forkable:true; notification_template at :423; notification_recipient_rule at :487) and imported into the Vocabulary plane (system). Worked-example DATA rows are seeded by seed_catalog.seed_notification_catalog (admin-api/app/notifications/seed_catalog.py): event rows land under X-Tenant-Id: system while templates + rules + recipient_rules land under X-Tenant-Id: template_municipality (the fork source). Rules therefore carry cross-tenant FKs (a template_municipality rule to a system event), which the ontology accepts at the JSON layer. The seed is idempotent (find-or-create), topologically orders the base template before leaves, and backfills action_side_effect.event FK + payload_schema on pre-existing rows. The one-shot full-catalog fork copies the forkable template-plane rows into each tenant with FKs rewired; the tenant admin then edits THEIR copies through the identical editors. notification_subscription/notification_delivery are pure runtime tenant rows (no seed).

Extension points (the growth vector)

  • New notification, no code (operator): declare a side-effect emit on an existing action for an existing event, then author a notification_rule — the 2-step path.
  • New event (engineering): something must emit it (code) + add a notification_event row (seed/sheet); events are intentionally not operator-creatable (D5) to prevent dead events.
  • New recipient resolver (engineering): add @register_function(category='notification_recipient') in resolvers.py — auto-surfaces in GET /notifications/resolvers and the rule picker.
  • New recipient without Python: a notification_recipient_rule of kind entity_field/role/group/subscribers/segment (#46).
  • New channel (engineering): register under category='notification_channel' in channels.py AND implement the Temporal activity adapter (see SMS gap below).
  • Per-tenant override: "Clone for org" makes an org-scoped template; an org-scoped rule suppresses the global rule for that event in that tenant (D3).
  • Declarative for new verticals: author rules/emits in a sheet's notification block.

Invariants

  • The runtime dispatcher reads notification_rule rows, never REGISTRY; REGISTRY/templates-on-disk are seed-only.
  • Rule lookup MUST be tenant-scoped: scoped_headers derives X-Tenant-Id from entity.tenant_id (dispatcher.py:209-210). Without it the ontology returns every tenant's rows and global-within-tenant rules fan out cross-tenant (live-caught: phase_opened to ~17 deliveries on a clean tenant).
  • Filter-drop precedes the tenant partition (dispatcher.py:243-256): a non-matching tenant rule cannot suppress a matching global rule. NULL/empty filter = always match.
  • D3 precedence is suppress-not-add: if ANY org-scoped rule matches, only tenant rules dispatch; else global rules.
  • Workflow ID notification.{event}.{rule_id}.{entity_id}.{edit_event_id} (dispatcher.py:145) gives natural exactly-once dedup per (event, rule, entity, edit); each rule gets its own workflow.
  • Delivery ledger rows are written AFTER the channel attempt (gotcha 26); dedup_key is retry-stable and includes recipient KIND so a sub and an email never collide; the unique constraint turns the insert into get_or_create (gotcha 41).
  • Dispatch never raises into the action critical path — every layer fail-soft; a tenant-less payload fail-CLOSES subscribers/segment fan-out to avoid cross-tenant leakage.
  • render_templates must pass result_type=RenderOutput or rendered.subject AttributeErrors (Temporal returns a raw dict otherwise).
  • When a recipient_rule FK exists but its row is missing, soft-skip to empty — do NOT fall back to the legacy string (masking an intentional delete).

Gaps & open edges

  • Runtime uses the string, not the FK. executor.py:491 dispatches on effect.event_type; the dispatcher re-resolves the event by code. The notification_event FK added to action_side_effect in slice 4a is dead at runtime — section 4f's intent (FK replaces string) never completed. Both columns coexist; the string is load-bearing.
  • Event lookup is unscoped. notification_event is declared tenant_scoped:true but seeded only into system, and the dispatcher looks it up with plain headers (dispatcher.py:212-214), not scoped_headers. Works only because events live solely in system; a latent inconsistency if a tenant ever gets its own event rows.
  • recipients_resolver is required despite being legacy. Every rule must carry a possibly-vestigial resolver string even when the modern recipient_rule FK is set; no migration retires it.
  • SMS is selectable but undelivered. sms is registered in channels.py but the workflow only branches on email/inapp/ws — selecting sms silently delivers nothing.
  • Two gating surfaces. when_parameter/when_equals stayed on action_side_effect (an action-param gate) alongside notification_rule.filter (a payload gate); operators must reason about both.
  • Silent recipient drops. role/group kinds depend on UMS REST membership and return empty on failure with only a log line — a misconfigured UMS silently drops notifications with no operator-visible signal.
  • The in-app tray diverges from the thesis. admin_api.notifications is BFF-local Postgres, not an ontology entity, and is not tenant-forked through the catalog fork.
  • Seed/runtime drift risk. REGISTRY + templates/notifications/* remain as seed-only bootstrap with no reconciler against live ontology rows.
  • Overrides aren't auto-linked. Cloning a template for an org does NOT repoint rules — a documented fat-finger trap.

Atelier — Platform Specification. Internal canonical reference.