From edb2042f859f655b4f921b941436f8fb83e00f6c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:10:37 +0800 Subject: [PATCH] docs(adr): ADR-0030 notification platform convergence (supersedes 0012) + build spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the decision to converge the two drifted, overlapping inbox objects (sys_notification, sys_inbox_message) by un-conflating them back into ADR-0012's layered pipeline under a single `emit()` ingress — rather than merging into one table. - docs/adr/0030-notification-platform-convergence.md — the decision: drift finding + evidence, single-ingress rule, target object model (L2 event / L3 resolve+preference / L4 delivery outbox / L5 materialize+receipt), resolved design decisions, phased plan P0–P3. - docs/design/notification-platform-convergence.md — the executable build spec: table schemas, `emit()` contract, per-phase tasks with file/package targets + acceptance criteria, migration sequencing, open decisions, risks. - ADR-0012 status line marked "Superseded by ADR-0030" (body left intact as the historical draft). Handoff artifact for the agent implementing the notification work; no code changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/adr/0012-notification-platform.md | 2 +- .../0030-notification-platform-convergence.md | 68 +++++++++++ .../notification-platform-convergence.md | 113 ++++++++++++++++++ 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0030-notification-platform-convergence.md create mode 100644 docs/design/notification-platform-convergence.md diff --git a/docs/adr/0012-notification-platform.md b/docs/adr/0012-notification-platform.md index 796ccb252..db64800ed 100644 --- a/docs/adr/0012-notification-platform.md +++ b/docs/adr/0012-notification-platform.md @@ -1,6 +1,6 @@ # ADR-0012: Messaging Platform — Outbound Notifications on a Generalized Outbox -**Status**: Draft (2026-05-25 · scope-revised 2026-05-26) +**Status**: Draft (2026-05-25 · scope-revised 2026-05-26) · **Superseded by [ADR-0030](./0030-notification-platform-convergence.md)** (2026-06-01) — the M1 implementation drifted (two conflated inbox objects); ADR-0030 records the correction and realizes this layered model. **Authors**: Platform team — surfaced from GitHub Issue #1292 ("[P0] notification: no outbound notification channel") **Consumers**: `@objectstack/spec` (new `messaging/` domain), `@objectstack/service-messaging` (new), `@objectstack/plugin-notification-inbox` (new), `@objectstack/plugin-notification-email` (replaces `plugin-email`), `@objectstack/plugin-notification-webhook` (extracted from `plugin-webhooks`), `@objectstack/plugin-notification-push` (new), every flow that has a `notify` node, every template that ships notification rules **Sibling**: [ADR-0013 — Bidirectional Messaging & Conversational Channels](./0013-bidirectional-messaging.md) diff --git a/docs/adr/0030-notification-platform-convergence.md b/docs/adr/0030-notification-platform-convergence.md new file mode 100644 index 000000000..c023f950e --- /dev/null +++ b/docs/adr/0030-notification-platform-convergence.md @@ -0,0 +1,68 @@ +# ADR-0030 — Notification Platform Convergence (single ingress, layered pipeline) + +**Status**: Proposed (2026-06-01) +**Supersedes / refines**: [ADR-0012 — Notification Platform](./0012-notification-platform.md) (Draft) +**Related**: [ADR-0019 — Approval as a Flow Node](./0019-approval-as-flow-node.md), [ADR-0022 — Connectors vs Messaging Channels](./0022-connectors-vs-messaging-channels.md) +**Build spec**: [docs/design/notification-platform-convergence.md](../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. diff --git a/docs/design/notification-platform-convergence.md b/docs/design/notification-platform-convergence.md new file mode 100644 index 000000000..9f4cc2bca --- /dev/null +++ b/docs/design/notification-platform-convergence.md @@ -0,0 +1,113 @@ +# Design / Build Spec — Notification Platform Convergence + +**Decision**: [ADR-0030 — Notification Platform Convergence](../adr/0030-notification-platform-convergence.md) +**Refines/realizes**: [ADR-0012](../adr/0012-notification-platform.md) · **Channels on connectors**: [ADR-0022](../adr/0022-connectors-vs-messaging-channels.md) +**Audience**: the implementing agent. This is the executable spec; ADR-0030 holds the *why*. + +--- + +## 0. The governing rule + +> **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.ts` writes `sys_notification` directly (`@mention`, assignment) — re-route to `emit()`. +- `service-messaging` inbox channel writes `sys_inbox_message` directly from the `notify` node — keep the channel, but it must run **after** the pipeline (event → resolve → deliver), not as the producer. + +--- + +## 1. Current state (verified 2026-06-01) + +| 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`). + +--- + +## 2. Target object model (schemas) + +> Names follow ADR-0012. Owners: events/delivery/preference/template/receipt → `service-messaging`; `sys_inbox_message` stays in `service-messaging`. `sys_user_device`/`sys_email` per 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`. (Drop `read` — read-state moves to receipt; see §Decisions.) +- `sys_email` (email log), `sys_user_device` (push tokens) — later phases. +**L5 `sys_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`. + +--- + +## 3. `emit()` contract + +```ts +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; // 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. + +--- + +## 4. Phased delivery (each phase ships independently; no rework) + +### P0 — Seams (framework + objectui) — **the critical first phase** +**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`: refactor `MessagingService.emit` to the `EmitInput` contract; write L2 `sys_notification` (event) first, then fan to channels. (P0 may resolve recipients inline; outbox is P1.) +- [ ] Re-model `sys_notification` object (`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_receipt` object; inbox channel writes a `delivered` receipt; mark-read updates it. +- [ ] `inbox-channel.ts`: write `sys_inbox_message` with `notification_id`; drop the local `read` flag (use receipt). Keep email→id fallback until P1. +- [ ] Re-route `plugin-audit/src/audit-writers.ts` collaboration writers → `emit(topic:'collab.mention'|'collab.assignment', audience, payload:{actor,source,title,body,url})`. +- [ ] **objectui** (`packages/app-shell/src/layout/AppHeader.tsx`, `InboxPopover.tsx`): poll `sys_inbox_message` (join receipt for read-state) instead of `sys_notification`; mark-read PATCH → receipt endpoint; "view all" route updated. +- **Acceptance**: reassign / mark-done / @mention all produce a bell entry via `emit()`; `sys_notification` rows carry no recipient/read; read toggling persists to receipt; `sys_inbox_message` has 0 direct writers besides the inbox channel. + +### P1 — Reliable delivery +- [ ] `sys_notification_delivery` outbox + 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 `emit` with same `dedupKey` is idempotent. + +### P2 — Subscription + preference +- [ ] `sys_notification_subscription` + `sys_notification_preference` objects + 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. + +### P3 — Channels + templates + digest +- [ ] email/push/webhook/Slack channels on connectors (ADR-0022); `sys_notification_template` (topic×channel×locale) + renderer; digest / quiet-hours middleware. +- **Acceptance**: same `emit` reaches inbox + email per the user's prefs, rendered from a template; digest batches. + +--- + +## 5. Open decisions (ADR-0030 recommends; confirm before P0) +1. `sys_notification` **rename/re-model in place + migration** (recommended) vs new `sys_notification_event`. +2. Read-state in **`sys_notification_receipt`** (recommended) vs on `sys_inbox_message`. +3. Audience resolution **reuses existing sharing/CEL resolver** (recommended). +4. Preference defaults: **admin global + user override + mandatory bypass** (recommended). +5. `sys_inbox_message` **retained** as L5 in-app materialization (recommended). + +## 6. Risks +- 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.