ADR: 0030 — Notification Platform Convergence Build spec: notification-platform-convergence.md Status of this handoff: Framework P0–P3b2 shipped. The objectui (Console bell) cut-over, mark-read write path, incremental channels, topic catalog, and hardening remain. Date: 2026-06-01.
The single-ingress seam and the correct layered model are now in place. Every
producer goes through NotificationService.emit(EmitInput); no producer writes
a per-user inbox row directly.
packages/services/service-messaging/src/messaging-service.ts
- New public contract
EmitInput(topic,audience,payload,severity,dedupKey,source,actorId,organizationId,channels). emit()now: (1) writes the L2sys_notificationevent (idempotent ondedupKey), (2) resolves the audience to recipients (inline for explicit ids/emails;role:/team:/owner_of:are forwarded but deferred to P1), (3) fans out(channel × recipient)deliveries. Returns{ notificationId, deduped, deliveries, delivered, failed }.- The service now takes a
getData()so it can persist the event.
packages/platform-objects/src/audit/sys-notification.object.ts
- Now the event:
topic,payload(json),severity,dedup_key,source_object,source_id,actor_id,created_at. Indexes on(topic, created_at),(dedup_key),(source_object, source_id). - Removed:
recipient_id,is_read,read_at,type,title,body,url,actor_name, plus themark_read/mark_unreadactions and the recipient-filtered list views. New admin views:recent,by_topic.
sys_inbox_message(.../objects/inbox-message.object.ts): addednotification_id+delivery_idFKs; droppedread(read-state lives in the receipt now); added aminelist view (the user inbox).- New
sys_notification_receipt(.../objects/notification-receipt.object.ts): the read-state spine, keyed(notification_id, user_id, channel), statedelivered|read|clicked|dismissed. The inbox channel writes adeliveredreceipt on materialization (best-effort). inbox-channel.ts: writesnotification_id+organization_id, noreadflag, and thedeliveredreceipt. Email→id fallback kept (moves up to theRecipientResolverin P1).
- Flow
notifynode (service-automation/.../notify-node.ts): maps config →EmitInput(title/body/url ride inpayload). - Collaboration (
plugin-audit/src/audit-writers.ts):@mention→emit('collab.mention'), assignment →emit('collab.assignment'), both with adedupKey. No more directsys_notificationwrites. The plugin resolves themessagingservice lazily at hook time (audit-plugin.ts).
packages/metadata/src/migrations/migrate-sys-notification-to-event.ts
(exported from @objectstack/metadata/migrations). Splits each legacy
sys_notification inbox row into sys_inbox_message + a receipt, rewrites the
row to the event shape, and clears the legacy columns. Idempotent; reports
not_applicable on fresh installs.
messaging-service, inbox-channel, messaging-service-plugin, notify-node,
and the migration all have updated/added coverage. All green.
The bell read sys_notification.{recipient_id, is_read, title, body, …}. Those
fields no longer exist. Until objectui is updated, the bell will be empty /
error. Do the objectui cut-over and the data migration together.
AppHeader.tsx/InboxPopover.tsx: pollsys_inbox_messagefiltered byuser_id = {current_user}(theminelist view), ordered bycreated_atdesc — instead ofsys_notification.- Read-state: join/read
sys_notification_receiptfor the row's state (readvsdelivered). The unread badge = inbox rows with noread/clickedreceipt. - Mark-read: PATCH the receipt (
state: 'read',at) keyed by(notification_id, user_id, channel:'inbox')— not the inbox row. (A small REST/endpoint to upsert a receipt may be needed; see P0 follow-up below.) - "View all" / notification center route: point at
sys_inbox_message(mine) instead ofsys_notification. RecordDetailViewand any othersys_notificationreaders: same repoint.
- Deploy this framework change (objects + emit + producers). New notifications
now land in
sys_inbox_message+ receipts. - Run
migrateSysNotificationToEvent({ driver, data })to carry existing notifications intosys_inbox_message+ receipts. - Deploy the objectui bell repoint.
(Step order tolerates a brief window where new rows exist but the UI hasn't flipped — the inbox is being populated the whole time.)
-
Messaging is now foundational (auto-on). Collaboration notifications require the messaging pipeline (with no
messagingservice registered,@mention/assignment are skipped + warned, like thenotifynode). Two seams guarantee it loads:objectstack serve:messagingis inServe.ALWAYS_ON_CAPABILITIES(packages/cli/src/commands/serve.ts) — every non-minimalpreset starts it.- Cloud / per-project kernels (
capability-loader.ts): no always-on slate, so the loader now expandsrequiresto addmessagingwheneverauditis present. Artifacts requiringaudittherefore get the pipeline automatically.
--preset minimal(CLI) and artifacts that require neitherauditnormessagingopt out — collaboration notifications then no-op by design. -
Dedup is best-effort in P0.
emit()idempotency is a non-transactional check-then-insert andsys_notification.dedup_keyis a non-unique index, so a concurrent duplicateemitwith the samededupKeycan still produce two events. Robust, race-safe dedup is part of the P1 outbox (durable spine + unique dedup). AssignmentdedupKeys are scoped by the record's write-version (updated_at) so re-assignments aren't permanently suppressed. -
Event-log growth. Every
emit()writes onesys_notificationevent row. High-frequency periodicnotifyflows accumulate rows unbounded; retention / pruning is a P1+ concern (the event log is the durable audit of what was sent). -
No mark-read write path yet — required for the objectui cut-over. P0 added the receipt object +
deliveredwrites, but nothing transitions a receipt toread/clicked/dismissed. The bell's mark-read therefore needs a small write ingress (a receipt-upsert REST route or ansys_inbox_messageaction keyed on(notification_id, user_id, channel)), landed together with the objectui bell repoint. The SDKclient.notifications.markRead/list({read})helpers target the oldsys_notificationread-state and must be repointed to the receipt at the same time. Until then read-state is write-less (every row shows as unread). Decide: tail of P0 (with objectui) vs P1. -
Translations:
packages/platform-objects/src/apps/translations/*.generated.tsstill carry the oldsys_notificationfield labels (is_read, etc.). Harmless (unused) but should be regenerated. -
Audience selectors
role:/team:/owner_of:are accepted byemit()but not yet expanded — they resolve to zero recipients until the P1RecipientResolver. Today's producers only pass explicit ids/emails, so this is latent, not active.
- P0 — Seams: ✅ shipped (#1434). Single ingress, event re-model, receipt,
producers routed through
emit(). (objectui bell cut-over + mark-read write path still pending — see above.) - P1 — Reliable delivery: ✅ shipped (#1441).
sys_notification_deliveryoutbox +NotificationDispatcher(state machine, retry/backoff, dead-letter);RecipientResolverownsrole:/owner_of:/team:/email→id (the inbox channel's email→id fallback moved up). So the audience-selector caveat above is now resolved when a data engine is present. - P2 — Subscription + preference: ✅ shipped.
sys_notification_preference(per user×topic×channel toggle, admin-global*defaults + per-user override, wildcards) +sys_notification_subscription;PreferenceResolverwired intoemit()(most-specific-wins, mandatory-topic bypass, fail-open); both objects contributed to the Setup Configuration nav.- Follow-ups: subscription-driven fan-out (expand a topic's subscribers when
a producer emits without an explicit audience) is schema-only so far;
digest/quiet_hoursfields exist but the batching middleware is P3.
- Follow-ups: subscription-driven fan-out (expand a topic's subscribers when
a producer emits without an explicit audience) is schema-only so far;
- P3 — Channels + templates + digest: split into slices.
- P3a — email channel + templates: ✅ shipped.
createEmailChannel(delegates transport to theemailservice per ADR-0022) +sys_notification_template(topic×channel×locale) +{{ payload.x }}renderer with genericpayload.title/bodyfallback. Sameemitnow reaches inbox + email per prefs. - P3b-1 — quiet-hours: ✅ shipped. Deferred dispatch on the P1 outbox —
EnqueueDeliveryInput.notBefore→ the row's initialnextAttemptAt; the dispatcher already skips not-yet-due pending rows.PreferenceResolverreadsquiet_hoursoff a channel-wildcard row and computes the window end (quietHoursDeferral, HH:MM in tz, overnight-aware). critical bypasses. (tz currently fromquiet_hours.tz→ UTC;sys_usertz fallback is a follow-up.) - P3b-2 — digest: ✅ shipped.
PreferenceResolverdefers digest items to the next window, deliveries carrydigest_key, normal claims skip digest rows, and the dispatcher collapses same-(recipient, channel, window)rows into one rendered message under the partition lock. critical/mandatory bypass. - Deferred (same seam, incremental): Slack stays a connector
(
connector-slackships the raw API path today); a Slack notification channel needs identity mapping (sys_channel_user_link) + OAuth and is enterprise-tier (ADR-0022). push needssys_user_device+ APNs/FCM. webhook should reuse the existingplugin-webhooksoutbox rather than a redundant channel. MJML compilation for email (P3a treatsmjmlformat as raw HTML).defineTopic()declarative topic catalog (Studio discoverability for topics/templates/preferences).
- P3a — email channel + templates: ✅ shipped.