Skip to content

Commit eec81be

Browse files
xuyushun441-sysos-zhuangclaude
authored
docs(adr): ADR-0030 notification platform convergence (supersedes 0012) (#1432)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 64a1ced commit eec81be

3 files changed

Lines changed: 182 additions & 1 deletion

File tree

docs/adr/0012-notification-platform.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0012: Messaging Platform — Outbound Notifications on a Generalized Outbox
22

3-
**Status**: Draft (2026-05-25 · scope-revised 2026-05-26)
3+
**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.
44
**Authors**: Platform team — surfaced from GitHub Issue #1292 ("[P0] notification: no outbound notification channel")
55
**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
66
**Sibling**: [ADR-0013 — Bidirectional Messaging & Conversational Channels](./0013-bidirectional-messaging.md)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# ADR-0030 — Notification Platform Convergence (single ingress, layered pipeline)
2+
3+
**Status**: Proposed (2026-06-01)
4+
**Supersedes / refines**: [ADR-0012 — Notification Platform](./0012-notification-platform.md) (Draft)
5+
**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)
6+
**Build spec**: [docs/design/notification-platform-convergence.md](../design/notification-platform-convergence.md)
7+
8+
## Context — the drift
9+
10+
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):
11+
12+
- **Two objects both act as a per-user in-app inbox, and they don't connect.**
13+
- `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`).
14+
- `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).
15+
- Net effect: `notify`-based flows (and any future channel) never reach the UI bell; collaboration notifications never flow through any pipeline.
16+
- The ADR's middle layers (delivery outbox, subscription/preference, templates, receipts) were **never built**.
17+
18+
Root principle that was violated: **producers write per-user inbox rows directly, with no single ingress and no pipeline.**
19+
20+
## Decision
21+
22+
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:
23+
24+
> **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.
25+
26+
### Target object model
27+
28+
| Layer | Object | Role |
29+
|---|---|---|
30+
| 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.** |
31+
| 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 |
32+
| L4 Delivery (outbox) | `sys_notification_delivery` | one row per (event × recipient × channel); state machine `pending→sent→failed/dead`; retry/backoff/dedup; the durable spine |
33+
| 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` |
34+
| Cross-cutting | `sys_notification_template` (topic×channel×locale) | rendering, with generic fallback to `title`/`body` |
35+
36+
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.
37+
38+
### Resolved design decisions (per architect recommendation — confirm before P0)
39+
40+
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.
41+
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.
42+
3. **Audience resolution reuses the platform's existing expression/sharing resolver** (`role:` / `owner_of:` …) rather than a bespoke notifications resolver.
43+
4. **Preference model**: admin-set global defaults + per-user override + mandatory topics that bypass preferences.
44+
5. **`sys_inbox_message` is retained** as the L5 in-app materialization (it is already correct for that role).
45+
46+
### Low-code platform requirements this unlocks
47+
48+
- Declarative topic catalog via `defineTopic()` — app builders register notification types as **metadata**.
49+
- Per-user notification **preferences UI** (user×topic×channel) and **templates** become first-class, Studio-configurable objects.
50+
- Reliable delivery (outbox + retry + `dedup_key`) survives record-change storms.
51+
- Multi-channel (in-app, email, push, webhook, Slack/Teams) without re-plumbing.
52+
53+
## Phased delivery (each phase is correct-by-construction, not a stopgap)
54+
55+
P0 establishes the correct seams; later phases only add layers — no rework.
56+
57+
- **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.*
58+
- **P1 — Reliable delivery**: `sys_notification_delivery` outbox + retry/dedup; `RecipientResolver` owns recipient/email resolution (move the channel-level email→id fallback up here).
59+
- **P2 — Subscription + preference**: preference objects + Studio config UI + mandatory topics.
60+
- **P3 — Channels + templates + digest**: email/push/webhook channels, `sys_notification_template`, digest / quiet-hours middleware.
61+
62+
Acceptance criteria per phase live in the build spec.
63+
64+
## Consequences
65+
66+
- **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.
67+
- **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).
68+
- **ADR-0012** is marked superseded by this ADR; its 5-layer model is retained and realized, not discarded.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Design / Build Spec — Notification Platform Convergence
2+
3+
**Decision**: [ADR-0030 — Notification Platform Convergence](../adr/0030-notification-platform-convergence.md)
4+
**Refines/realizes**: [ADR-0012](../adr/0012-notification-platform.md) · **Channels on connectors**: [ADR-0022](../adr/0022-connectors-vs-messaging-channels.md)
5+
**Audience**: the implementing agent. This is the executable spec; ADR-0030 holds the *why*.
6+
7+
---
8+
9+
## 0. The governing rule
10+
11+
> **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*.
12+
13+
Current violations to remove:
14+
- `plugin-audit/src/audit-writers.ts` writes `sys_notification` directly (`@mention`, assignment) — re-route to `emit()`.
15+
- `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.
16+
17+
---
18+
19+
## 1. Current state (verified 2026-06-01)
20+
21+
| Object | Owner | Fields | Written by | Read by |
22+
|---|---|---|---|---|
23+
| `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`) |
24+
| `sys_inbox_message` | service-messaging | `user_id`,`topic`,`title`,`body_md`,`severity`,`action_url`,`read` | `notify` node → `MessagingService.emit` → inbox channel | **nothing** (0 readers) |
25+
26+
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`).
27+
28+
---
29+
30+
## 2. Target object model (schemas)
31+
32+
> 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.
33+
34+
**L2 `sys_notification` (re-modeled → event; one row per `emit`)**
35+
- `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`.
36+
- 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).
37+
- Index: `(topic, created_at)`, `(dedup_key)`.
38+
39+
**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`.
40+
**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.
41+
42+
**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)`.
43+
44+
**L5 materialization**
45+
- `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.)
46+
- `sys_email` (email log), `sys_user_device` (push tokens) — later phases.
47+
**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.
48+
49+
**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`.
50+
51+
---
52+
53+
## 3. `emit()` contract
54+
55+
```ts
56+
interface EmitInput {
57+
topic: string; // e.g. 'task.assigned', 'collab.mention', 'project.budget_approval'
58+
audience: Audience; // role:x | owner_of:{object,id} | team:x | user ids | emails
59+
payload: Record<string, unknown>; // template inputs (title/body/url/actor/source/...)
60+
severity?: 'info'|'warning'|'critical';
61+
dedupKey?: string; // idempotency within a topic window
62+
source?: { object: string; id: string };
63+
actorId?: string;
64+
}
65+
// 1) write L2 sys_notification (idempotent on dedupKey)
66+
// 2) resolve audience → recipient user ids (RecipientResolver; email→id here) [P1]
67+
// 3) preference filter per (user, topic, channel); mandatory topics bypass [P2]
68+
// 4) write L4 sys_notification_delivery rows (pending) [P1]
69+
// 5) dispatch each delivery to its channel; channel materializes (L5)
70+
NotificationService.emit(input: EmitInput): Promise<{ notificationId: string }>;
71+
```
72+
73+
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.
74+
75+
---
76+
77+
## 4. Phased delivery (each phase ships independently; no rework)
78+
79+
### P0 — Seams (framework + objectui) — **the critical first phase**
80+
**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.
81+
- [ ] `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.)
82+
- [ ] 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).
83+
- [ ] Add `sys_notification_receipt` object; inbox channel writes a `delivered` receipt; mark-read updates it.
84+
- [ ] `inbox-channel.ts`: write `sys_inbox_message` with `notification_id`; drop the local `read` flag (use receipt). Keep email→id fallback until P1.
85+
- [ ] Re-route `plugin-audit/src/audit-writers.ts` collaboration writers → `emit(topic:'collab.mention'|'collab.assignment', audience, payload:{actor,source,title,body,url})`.
86+
- [ ] **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.
87+
- **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.
88+
89+
### P1 — Reliable delivery
90+
- [ ] `sys_notification_delivery` outbox + dispatcher (state machine, retry/backoff, dead-letter, `dedup_key`).
91+
- [ ] `RecipientResolver` (reuse platform sharing/CEL resolver): `role:`/`owner_of:`/`team:`/ids/emails → user ids. Move inbox channel's email→id fallback here.
92+
- **Acceptance**: a failed channel send retries and is observable on the delivery row; duplicate `emit` with same `dedupKey` is idempotent.
93+
94+
### P2 — Subscription + preference
95+
- [ ] `sys_notification_subscription` + `sys_notification_preference` objects + Studio config UI; mandatory-topic bypass; defaults model (admin global + user override).
96+
- **Acceptance**: a user muting a topic/channel stops receiving it; mandatory topics still deliver.
97+
98+
### P3 — Channels + templates + digest
99+
- [ ] email/push/webhook/Slack channels on connectors (ADR-0022); `sys_notification_template` (topic×channel×locale) + renderer; digest / quiet-hours middleware.
100+
- **Acceptance**: same `emit` reaches inbox + email per the user's prefs, rendered from a template; digest batches.
101+
102+
---
103+
104+
## 5. Open decisions (ADR-0030 recommends; confirm before P0)
105+
1. `sys_notification` **rename/re-model in place + migration** (recommended) vs new `sys_notification_event`.
106+
2. Read-state in **`sys_notification_receipt`** (recommended) vs on `sys_inbox_message`.
107+
3. Audience resolution **reuses existing sharing/CEL resolver** (recommended).
108+
4. Preference defaults: **admin global + user override + mandatory bypass** (recommended).
109+
5. `sys_inbox_message` **retained** as L5 in-app materialization (recommended).
110+
111+
## 6. Risks
112+
- 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).
113+
- Don't reintroduce direct inbox writes in any producer — enforce the single-ingress rule in review.

0 commit comments

Comments
 (0)