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
| Name | Purpose | Plane | Key fields |
|---|---|---|---|
notification_event | Catalog 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_template | Subject + Jinja2 body of a message; supports extends-inheritance and per-org override. | Template | code, subject (str.format), body_html/body_text (Jinja2 TEXT, not FileField — D1), available_vars; rels extends, organization. Base layout has code='base' |
notification_rule | The subscription: when event X matches filter, send template Y to resolver Z on channels C, optionally org-scoped. | Template | name, 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_rule | Declarative recipient logic (#46) — replaces the code-string resolver for new rows. | Template | kind in {custom_function, entity_field, role, group, subscribers, segment}, function_key, path+follow[], role_code/role_scope, group_code |
action_side_effect | The action's declared output; bridges action to event. | Template | key, 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_subscription | Citizen preference rows (#241); the subscribers kind reads these. | Tenant | user_id (server-stamped KC sub), email, filters (wildcard matcher), is_active. OWN-scoped |
notification_delivery | Per-recipient send ledger (#241 S2), written after each channel attempt. | Tenant | event_code, channel, status, recipient_user_id/recipient_email, entity_id, workflow_id, dedup_key (unique) |
admin_api.notifications | In-app tray rows persisted by the persist_inapp activity. | BFF-Postgres | user_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_eventrow (seed/sheet); events are intentionally not operator-creatable (D5) to prevent dead events. - New recipient resolver (engineering): add
@register_function(category='notification_recipient')inresolvers.py— auto-surfaces inGET /notifications/resolversand the rule picker. - New recipient without Python: a
notification_recipient_ruleof kind entity_field/role/group/subscribers/segment (#46). - New channel (engineering): register under
category='notification_channel'inchannels.pyAND 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_rulerows, neverREGISTRY;REGISTRY/templates-on-disk are seed-only. - Rule lookup MUST be tenant-scoped:
scoped_headersderivesX-Tenant-Idfromentity.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_openedto ~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_keyis 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/segmentfan-out to avoid cross-tenant leakage. render_templatesmust passresult_type=RenderOutputorrendered.subjectAttributeErrors (Temporal returns a raw dict otherwise).- When a
recipient_ruleFK 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:491dispatches oneffect.event_type; the dispatcher re-resolves the event by code. Thenotification_eventFK added toaction_side_effectin 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_eventis declaredtenant_scoped:truebut seeded only intosystem, and the dispatcher looks it up with plainheaders(dispatcher.py:212-214), notscoped_headers. Works only because events live solely insystem; a latent inconsistency if a tenant ever gets its own event rows. recipients_resolveris required despite being legacy. Every rule must carry a possibly-vestigial resolver string even when the modernrecipient_ruleFK is set; no migration retires it.- SMS is selectable but undelivered.
smsis registered inchannels.pybut the workflow only branches onemail/inapp/ws— selectingsmssilently delivers nothing. - Two gating surfaces.
when_parameter/when_equalsstayed onaction_side_effect(an action-param gate) alongsidenotification_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.notificationsis 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.