Skip to content

Latest commit

 

History

History
136 lines (106 loc) · 12.3 KB

File metadata and controls

136 lines (106 loc) · 12.3 KB

ADR-0030 — Notification Platform Convergence (single ingress, layered pipeline)

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

Context — the drift

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 (objectui AppHeader, InboxPopover). This is the de-facto inbox — but it is not ADR-0012's Layer-2 event (it has no topic/payload/dedup_key/severity).
    • sys_inbox_message (service-messaging): user_id, topic, title, body_md, severity, action_url, read. Written by the notify flow 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.

Decision

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 notify node, 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.

Target object model

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.

Resolved design decisions (per architect recommendation — confirm before P0)

  1. sys_notification → rename/re-model to the event in place, with data migration (one-step, no lingering deprecated table) rather than introducing a parallel sys_notification_event. Its inbox semantics move down: recipient/read → sys_inbox_message + receipt; actor/source → event columns/payload.
  2. Read-state lives in sys_notification_receipt (per recipient×channel), not on sys_inbox_message — so cross-channel read semantics ("clicked the email → mark inbox read") are reachable later.
  3. Audience resolution reuses the platform's existing expression/sharing resolver (role: / owner_of: …) rather than a bespoke notifications resolver.
  4. Preference model: admin-set global defaults + per-user override + mandatory topics that bypass preferences.
  5. sys_inbox_message is retained as the L5 in-app materialization (it is already correct for that role).

Low-code platform requirements this unlocks

  • 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.

Phased delivery (each phase is correct-by-construction, not a stopgap)

P0 establishes the correct seams; later phases only add layers — no rework.

  • P0 — Seams: extract emit() single ingress; route notify + collaboration through it; UI bell reads sys_inbox_message; re-model sys_notification to the event (with migration). Outcome: the bell lights up for all sources and the model is correct.
  • P1 — Reliable delivery: sys_notification_delivery outbox + retry/dedup; RecipientResolver owns 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.

Consequences

  • 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 a sys_notification data 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.

Implementation status & remaining work

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.

Shipped (merged to main)

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.notBeforenextAttemptAt); 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)

Remaining work (handed off to a follow-up agent)

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) from sys_notification to sys_inbox_message (the mine view), joining sys_notification_receipt for 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 + delivered writes, but nothing flips it to read yet). Repoint the SDK client.notifications.* helpers to the receipt.
  • Run migrateSysNotificationToEvent during 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 the plugin-webhooks outbox rather than a redundant channel), Slack notification channel (enterprise-tier: identity mapping sys_channel_user_link + OAuth; send() delegates to the existing connector-slack per ADR-0022 — the raw connector_action Slack 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_subscription principals when a producer emits without an explicit audience (object exists; expansion not wired).
  • MJML compilation for email (P3a treats mjml format as raw HTML).
  • Quiet-hours tz fallback to a sys_user timezone field (currently quiet_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_notification event log (every emit writes a row; high-frequency periodic flows grow it unbounded).
  • Regenerate packages/platform-objects/src/apps/translations/*.generated.ts (stale sys_notification field labels from before the re-model — harmless but drifted).
  • CI infra: the intermittent @objectstack/spec subpath build-order race (pnpm -r build dependents typecheck before spec's dist DTS is flushed) flakes Build/Test Core; tightening the build-dependency ordering would stop the repeated re-kicks.