Decision: ADR-0030 — Notification Platform Convergence Refines/realizes: ADR-0012 · Channels on connectors: ADR-0022 Audience: the implementing agent. This is the executable spec; ADR-0030 holds the why.
Status (2026-06-01): P0, P1, P2, P3a, P3b-1 (quiet-hours), and P3b-2 (digest collapse) are shipped (PRs #1434/#1441/#1444/#1449/#1453 plus the digest follow-up). The §4 checklists below are the original plan — for the current done/remaining breakdown see ADR-0030 § Implementation status & remaining work and docs/handoff/adr-0030-notification-convergence.md. Remaining: the cross-repo objectui bell cut-over + mark-read write path, incremental channels, topic catalog, and hardening items.
Single ingress. Every producer calls
NotificationService.emit(...). No producer writes a per-user inbox/materialization row directly. The in-app inbox is a materialization of delivery.
Current violations to remove:
plugin-audit/src/audit-writers.tswritessys_notificationdirectly (@mention, assignment) — re-route toemit().service-messaginginbox channel writessys_inbox_messagedirectly from thenotifynode — keep the channel, but it must run after the pipeline (event → resolve → deliver), not as the producer.
| Object | Owner | Fields | Written by | Read by |
|---|---|---|---|---|
sys_notification |
platform-objects | recipient_id,type,title,body,source_object/id,url,actor_id/name,is_read,read_at |
collaboration/audit (direct) | Console bell (objectui AppHeader/InboxPopover/RecordDetailView) |
sys_inbox_message |
service-messaging | user_id,topic,title,body_md,severity,action_url,read |
notify node → MessagingService.emit → inbox channel |
nothing (0 readers) |
The shipped sys_notification is mis-modeled: it is a per-user inbox, not ADR-0012's Layer-2 event (no topic/payload/dedup_key/severity).
Names follow ADR-0012. Owners: events/delivery/preference/template/receipt →
service-messaging;sys_inbox_messagestays inservice-messaging.sys_user_device/sys_emailper their channel plugins.
L2 sys_notification (re-modeled → event; one row per emit)
id,topic(text, indexed),payload(json),severity(info|warning|critical),dedup_key(text, unique-ish per topic+window, nullable),source_object,source_id,actor_id(lookup sys_user, nullable),organization_id,created_at.- Remove:
recipient_id,is_read,read_at,type,actor_name,url,title,body(move title/body into templates or payload; actor_name derivable from actor_id). - Index:
(topic, created_at),(dedup_key).
L3 sys_notification_subscription — who is subscribed to a topic (system-wide / role / explicit). id, topic, principal (role:x/user:id/team:x), created_at.
L3 sys_notification_preference — id, user_id, topic, channel, enabled (bool), digest (none|daily|weekly), quiet_hours (json), unique (user_id, topic, channel). Mandatory topics bypass.
L4 sys_notification_delivery (outbox) — id, notification_id (FK L2), recipient_id (sys_user), channel, status (pending|in_flight|success|failed|dead|suppressed), attempts, next_attempt_at, partition_key, error, created_at, updated_at. Indexes: (status, next_attempt_at), (notification_id).
L5 materialization
sys_inbox_message(in-app channel output) — keep.id,user_id,notification_id(FK),delivery_id(FK),topic,title,body_md,severity,action_url,created_at. (Dropread— read-state moves to receipt; see §Decisions.)sys_email(email log),sys_user_device(push tokens) — later phases. L5sys_notification_receipt—id,delivery_id(FK) or(notification_id,user_id,channel),state(delivered|read|clicked|dismissed),at. The bell's read-state lives here.
Cross-cutting sys_notification_template — id, topic, channel, locale, version, subject/body (MJML for email, md/json for others), compiled_html (cache). Generic fallback to payload.title/payload.body.
interface EmitInput {
topic: string; // e.g. 'task.assigned', 'collab.mention', 'project.budget_approval'
audience: Audience; // role:x | owner_of:{object,id} | team:x | user ids | emails
payload: Record<string, unknown>; // template inputs (title/body/url/actor/source/...)
severity?: 'info'|'warning'|'critical';
dedupKey?: string; // idempotency within a topic window
source?: { object: string; id: string };
actorId?: string;
}
// 1) write L2 sys_notification (idempotent on dedupKey)
// 2) resolve audience → recipient user ids (RecipientResolver; email→id here) [P1]
// 3) preference filter per (user, topic, channel); mandatory topics bypass [P2]
// 4) write L4 sys_notification_delivery rows (pending) [P1]
// 5) dispatch each delivery to its channel; channel materializes (L5)
NotificationService.emit(input: EmitInput): Promise<{ notificationId: string }>;Channels implement the existing MessagingChannel seam (ADR-0012 §2); transports sit on connectors (ADR-0022). The in-app inbox channel writes sys_inbox_message + a delivered receipt.
Goal: one ingress; UI reads the materialization; sys_notification becomes the event; read-state in receipt. After P0 the bell lights up for every producer and the model is correct.
-
service-messaging: refactorMessagingService.emitto theEmitInputcontract; write L2sys_notification(event) first, then fan to channels. (P0 may resolve recipients inline; outbox is P1.) - Re-model
sys_notificationobject (packages/platform-objects/src/audit/sys-notification.object.ts) to the event schema (§2) + data migration for existing rows (split recipient/read →sys_inbox_message+ receipt). - Add
sys_notification_receiptobject; inbox channel writes adeliveredreceipt; mark-read updates it. -
inbox-channel.ts: writesys_inbox_messagewithnotification_id; drop the localreadflag (use receipt). Keep email→id fallback until P1. - Re-route
plugin-audit/src/audit-writers.tscollaboration writers →emit(topic:'collab.mention'|'collab.assignment', audience, payload:{actor,source,title,body,url}). - objectui (
packages/app-shell/src/layout/AppHeader.tsx,InboxPopover.tsx): pollsys_inbox_message(join receipt for read-state) instead ofsys_notification; mark-read PATCH → receipt endpoint; "view all" route updated. - Acceptance: reassign / mark-done / @mention all produce a bell entry via
emit();sys_notificationrows carry no recipient/read; read toggling persists to receipt;sys_inbox_messagehas 0 direct writers besides the inbox channel.
-
sys_notification_deliveryoutbox + dispatcher (state machine, retry/backoff, dead-letter,dedup_key). -
RecipientResolver(reuse platform sharing/CEL resolver):role:/owner_of:/team:/ids/emails → user ids. Move inbox channel's email→id fallback here. - Acceptance: a failed channel send retries and is observable on the delivery row; duplicate
emitwith samededupKeyis idempotent.
-
sys_notification_subscription+sys_notification_preferenceobjects + Studio config UI; mandatory-topic bypass; defaults model (admin global + user override). - Acceptance: a user muting a topic/channel stops receiving it; mandatory topics still deliver.
- email/push/webhook/Slack channels on connectors (ADR-0022);
sys_notification_template(topic×channel×locale) + renderer; digest / quiet-hours middleware. - Acceptance: same
emitreaches inbox + email per the user's prefs, rendered from a template; digest batches.
sys_notificationrename/re-model in place + migration (recommended) vs newsys_notification_event.- Read-state in
sys_notification_receipt(recommended) vs onsys_inbox_message. - Audience resolution reuses existing sharing/CEL resolver (recommended).
- Preference defaults: admin global + user override + mandatory bypass (recommended).
sys_inbox_messageretained as L5 in-app materialization (recommended).
- P0 is cross-repo (framework + objectui) and migrates a live, UI-depended object — sequence the migration + UI cut-over carefully (ship object/back-end first behind a read that tolerates both shapes, then flip the UI).
- Don't reintroduce direct inbox writes in any producer — enforce the single-ingress rule in review.