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