Status: Accepted (2026-06-01) — P0–P3b2 shipped (P3b-2 digest collapse landed); cross-repo objectui cut-over remains. See § Implementation status & remaining work. Supersedes / refines: ADR-0012 — Notification Platform (Draft) Related: ADR-0019 — Approval as a Flow Node, ADR-0022 — Connectors vs Messaging Channels Build spec: docs/design/notification-platform-convergence.md
ADR-0012 proposed a correct 5-layer notification platform (Event → Notification → Subscription/Preference → Delivery/Outbox → Inbox/Receipt). Only M1-minimal shipped, and it drifted from the design. Verified state (2026-06-01):
- Two objects both act as a per-user in-app inbox, and they don't connect.
sys_notification(platform-objects):recipient_id(lookup user),type,title,body,source_object/id,url,actor_id/name,is_read,read_at. Written directly by the collaboration/audit plugin (@mention, assignment). Read by the Console bell / notification center (objectuiAppHeader,InboxPopover). This is the de-facto inbox — but it is not ADR-0012's Layer-2 event (it has notopic/payload/dedup_key/severity).sys_inbox_message(service-messaging):user_id,topic,title,body_md,severity,action_url,read. Written by thenotifyflow node →MessagingService.emit→ inbox channel. Read by nothing — zero readers across framework and objectui (confirmed by grep).
- Net effect:
notify-based flows (and any future channel) never reach the UI bell; collaboration notifications never flow through any pipeline. - The ADR's middle layers (delivery outbox, subscription/preference, templates, receipts) were never built.
Root principle that was violated: producers write per-user inbox rows directly, with no single ingress and no pipeline.
Do not merge the two tables into one. Un-conflate them back into the ADR-0012 layered model and realize the pipeline, governed by one rule:
Single ingress. Every notification producer — the flow
notifynode, collaboration@mention, record assignment, approval requests, system alerts — calls exactly one API:NotificationService.emit({ topic, audience, payload, severity, dedupKey, source, actor }). No producer writes an inbox/materialization row directly. The in-app inbox is a materialization of delivery, not a thing producers write.
| Layer | Object | Role |
|---|---|---|
| L2 Event | sys_notification (re-modeled to event) |
one row per emit: topic, payload(json), severity, dedup_key (idempotency), source_object/id, actor_id, created_at. No recipient, no read-state. |
| L3 Resolve + Preference | sys_notification_subscription, sys_notification_preference |
audience expansion (role: / owner_of:record / team: / explicit ids; email→id resolved here), user×topic×channel toggles, quiet-hours, digest; mandatory topics bypass |
| L4 Delivery (outbox) | sys_notification_delivery |
one row per (event × recipient × channel); state machine pending→sent→failed/dead; retry/backoff/dedup; the durable spine |
| L5 Materialize + Receipt | sys_inbox_message (in-app), sys_email, sys_user_device (push), connector dispatch (webhook/Slack); sys_notification_receipt |
each channel renders delivery into a consumable artifact; the bell reads sys_inbox_message; read/clicked/dismissed live in sys_notification_receipt |
| Cross-cutting | sys_notification_template (topic×channel×locale) |
rendering, with generic fallback to title/body |
The in-app inbox is one channel among peers (email/push/webhook). The architecture treats channels as plugins on connectors (ADR-0022) from day one, even when only the inbox channel is implemented, so the seams don't grow crooked again.
sys_notification→ rename/re-model to the event in place, with data migration (one-step, no lingering deprecated table) rather than introducing a parallelsys_notification_event. Its inbox semantics move down: recipient/read →sys_inbox_message+ receipt;actor/source→ event columns/payload.- Read-state lives in
sys_notification_receipt(per recipient×channel), not onsys_inbox_message— so cross-channel read semantics ("clicked the email → mark inbox read") are reachable later. - Audience resolution reuses the platform's existing expression/sharing resolver (
role:/owner_of:…) rather than a bespoke notifications resolver. - Preference model: admin-set global defaults + per-user override + mandatory topics that bypass preferences.
sys_inbox_messageis retained as the L5 in-app materialization (it is already correct for that role).
- Declarative topic catalog via
defineTopic()— app builders register notification types as metadata. - Per-user notification preferences UI (user×topic×channel) and templates become first-class, Studio-configurable objects.
- Reliable delivery (outbox + retry +
dedup_key) survives record-change storms. - Multi-channel (in-app, email, push, webhook, Slack/Teams) without re-plumbing.
P0 establishes the correct seams; later phases only add layers — no rework.
- P0 — Seams: extract
emit()single ingress; routenotify+ collaboration through it; UI bell readssys_inbox_message; re-modelsys_notificationto the event (with migration). Outcome: the bell lights up for all sources and the model is correct. - P1 — Reliable delivery:
sys_notification_deliveryoutbox + retry/dedup;RecipientResolverowns recipient/email resolution (move the channel-level email→id fallback up here). - P2 — Subscription + preference: preference objects + Studio config UI + mandatory topics.
- P3 — Channels + templates + digest: email/push/webhook channels,
sys_notification_template, digest / quiet-hours middleware.
Acceptance criteria per phase live in the build spec.
- Positive: one governed ingress; the bell reflects every producer; reliability, preferences, multi-channel, templates all become reachable incrementally; matches mature notification platforms and low-code expectations.
- Cost: P0 spans framework + objectui (UI bell re-points to
sys_inbox_message) and requires asys_notificationdata migration. This is a multi-phase investment (ADR-0012 already flagged the full platform as such). - ADR-0012 is marked superseded by this ADR; its 5-layer model is retained and realized, not discarded.
As of 2026-06-01 the framework side of the pipeline is largely built and merged. The detailed cut-over runbook and per-item notes live in docs/handoff/adr-0030-notification-convergence.md.
| Phase | What landed | PR |
|---|---|---|
| P0 — Seams | Single ingress MessagingService.emit(EmitInput); sys_notification re-modeled to the L2 event; sys_notification_receipt; sys_inbox_message materialization (+notification_id, read-state moved to receipt); notify node + collaboration @mention/assignment routed through emit(); idempotent migrateSysNotificationToEvent. |
#1434 |
| P1 — Reliable delivery | sys_notification_delivery outbox + NotificationDispatcher (claim/retry/backoff/dead-letter, partitioned, cluster-lock); RecipientResolver (role:/team:/owner_of:/email→id). |
#1441 |
| P2 — Subscription + preference | sys_notification_preference (user×topic×channel, admin-global * defaults + per-user override, wildcards) + sys_notification_subscription; PreferenceResolver wired into emit() (most-specific-wins, mandatory bypass, fail-open). |
#1444 |
| P3a — email channel + templates | createEmailChannel (delegates transport to the email service per ADR-0022); sys_notification_template (topic×channel×locale) + declarative {{ payload.x }} renderer with payload.title/body fallback. |
#1449 |
| P3b-1 — quiet-hours | Deferred dispatch on the outbox (EnqueueDeliveryInput.notBefore → nextAttemptAt); quietHoursDeferral() (tz/HH:MM, overnight-aware); critical bypass. |
#1453 |
| P3b-2 — digest | PreferenceResolver consumes the digest field (daily/weekly) → digestDeferral() defers to the next window and tags the target; digest_key on sys_notification_delivery (partition-keyed so a window's rows co-locate); INotificationOutbox.claimDigest() drains batched rows whole while normal claim() skips them; the dispatcher's digest pass collapses each (recipient, channel, window) group into one renderDigest() message under the partition lock. critical/mandatory bypass. |
this PR |
| Startup | messaging is foundational: in ALWAYS_ON_CAPABILITIES (CLI) and auto-loaded when audit is required (cloud capability-loader). |
(in #1434) |
1. P3b-2 — Digest — done (this PR). PreferenceResolver batches digest
recipients to the next window; deliveries carry digest_key; the dispatcher
collapses same-(recipient, channel, window) rows into one rendered message.
Deferred sub-items not in this cut: timezone fallback to a sys_user field
(digest windows currently use quiet_hours.tz → UTC), MJML digest emails, and a
configurable daily send-hour (windows flush at local midnight / Monday 00:00).
2. Cross-repo objectui cut-over (the user-facing delivery — separate objectui repo).
- Repoint the Console bell (
AppHeader/InboxPopover/record views) fromsys_notificationtosys_inbox_message(themineview), joiningsys_notification_receiptfor read-state. - Add the mark-read write path: a receipt-upsert REST route / action keyed on
(notification_id, user_id, channel)(the framework has the receipt object +deliveredwrites, but nothing flips it toreadyet). Repoint the SDKclient.notifications.*helpers to the receipt. - Run
migrateSysNotificationToEventduring the cut-over so historical bell rows carry over. Sequence: ship back-end → run migration → flip UI (runbook in the handoff doc).
3. Incremental channels & low-code surface (same MessagingChannel seam — each gets retry/outbox for free).
- push (
sys_user_device+ APNs/FCM), webhook (reuse theplugin-webhooksoutbox rather than a redundant channel), Slack notification channel (enterprise-tier: identity mappingsys_channel_user_link+ OAuth;send()delegates to the existingconnector-slackper ADR-0022 — the rawconnector_actionSlack path already works today). defineTopic()declarative topic catalog (Studio discoverability for topics / templates / preferences) — the low-code backbone.- Subscription-driven fan-out: expand a topic's
sys_notification_subscriptionprincipals when a producer emits without an explicit audience (object exists; expansion not wired). - MJML compilation for email (P3a treats
mjmlformat as raw HTML). - Quiet-hours tz fallback to a
sys_usertimezone field (currentlyquiet_hours.tz→ UTC).
4. Hardening / hygiene.
- Make event dedup race-safe: a unique index on
sys_notification.dedup_key+ graceful conflict handling (today it's a non-transactional check-then-insert on a non-unique index — best-effort). - Retention/pruning for the
sys_notificationevent log (everyemitwrites a row; high-frequency periodic flows grow it unbounded). - Regenerate
packages/platform-objects/src/apps/translations/*.generated.ts(stalesys_notificationfield labels from before the re-model — harmless but drifted). - CI infra: the intermittent
@objectstack/specsubpath build-order race (pnpm -r builddependents typecheck before spec's dist DTS is flushed) flakes Build/Test Core; tightening the build-dependency ordering would stop the repeated re-kicks.