Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/adr-0030-notification-convergence-p0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
"@objectstack/service-messaging": minor
"@objectstack/platform-objects": minor
"@objectstack/plugin-audit": minor
"@objectstack/service-automation": minor
"@objectstack/metadata": minor
"@objectstack/cli": patch
"@objectstack/runtime": patch
---

ADR-0030 P0 (framework) — converge notifications onto a single ingress and the
layered model. Every producer now publishes through
`NotificationService.emit(EmitInput)`; the in-app inbox is a materialization of
delivery, not a row producers write.

**Single ingress (`@objectstack/service-messaging`) — breaking**
- `MessagingService.emit` takes the new `EmitInput` contract (`topic` /
`audience` / `payload` / `severity` / `dedupKey` / `source` / `actorId` /
`organizationId` / `channels`) instead of the flat `Notification` shape. It
writes the L2 `sys_notification` event (idempotent on `dedupKey`), resolves the
audience, then fans out; it returns `{ notificationId, deduped, deliveries,
delivered, failed }`.
- New `sys_notification_receipt` object — the read-state spine
(`delivered|read|clicked|dismissed`), keyed `(notification_id, user_id,
channel)`. The inbox channel writes a `delivered` receipt on materialization.
- `sys_inbox_message`: adds `notification_id` / `delivery_id`, **drops `read`**
(read-state moved to the receipt), adds the user `mine` list view.

**Event re-model (`@objectstack/platform-objects`) — breaking**
- `sys_notification` is re-modeled from a per-user inbox into the L2 **event**
(`topic`, `payload`, `severity`, `dedup_key`, `source_*`, `actor_id`). Removes
`recipient_id` / `is_read` / `read_at` / `type` / `title` / `body` / `url` /
`actor_name` and the inbox actions/views. App-nav: the account inbox points at
`sys_inbox_message`; Setup shows the notification event log.

**Producers routed through `emit()`**
- `@objectstack/service-automation`: the `notify` node maps its config to
`EmitInput`.
- `@objectstack/plugin-audit`: collaboration `@mention` → `collab.mention` and
assignment → `collab.assignment` (both with a `dedupKey`); no more direct
`sys_notification` writes. Collaboration notifications now require
`MessagingServicePlugin` (they degrade to a warn otherwise).

**Migration (`@objectstack/metadata`)**
- Idempotent `migrateSysNotificationToEvent` splits legacy `sys_notification`
inbox rows into `sys_inbox_message` + receipts and rewrites the event row.

**Startup (`@objectstack/cli`, `@objectstack/runtime`)**
- `messaging` is now a foundational capability. On `objectstack serve` it is
added to `ALWAYS_ON_CAPABILITIES` (every non-`minimal` preset starts it); on
cloud per-project kernels the capability loader expands `requires` to add
`messaging` whenever `audit` is present. This keeps collaboration `@mention` /
assignment notifications (which now flow through the pipeline) working out of
the box on both paths. `--preset minimal` opts out.

The Console bell repoint (objectui) and phases P1–P3 are tracked in
`docs/handoff/adr-0030-notification-convergence.md`.
155 changes: 155 additions & 0 deletions docs/handoff/adr-0030-notification-convergence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Handoff — ADR-0030 Notification Convergence (P0 framework side)

**ADR**: [0030 — Notification Platform Convergence](../adr/0030-notification-platform-convergence.md)
**Build spec**: [notification-platform-convergence.md](../design/notification-platform-convergence.md)
**Status of this handoff**: P0 **framework side** shipped. The **objectui** (Console bell) cut-over and phases P1–P3 remain. Date: 2026-06-01.

---

## What shipped in this repo (framework)

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.

### Single ingress — `MessagingService.emit(EmitInput)`
`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 **L2 `sys_notification` event** (idempotent on
`dedupKey`), (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.

### L2 event — `sys_notification` re-modeled (destructive)
`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 the `mark_read`/`mark_unread` actions and the
recipient-filtered list views. New admin views: `recent`, `by_topic`.

### L5 materialization + receipt
- `sys_inbox_message` (`.../objects/inbox-message.object.ts`): added
`notification_id` + `delivery_id` FKs; **dropped `read`** (read-state lives in
the receipt now); added a `mine` list view (the user inbox).
- **New** `sys_notification_receipt` (`.../objects/notification-receipt.object.ts`):
the read-state spine, keyed `(notification_id, user_id, channel)`, state
`delivered|read|clicked|dismissed`. The inbox channel writes a `delivered`
receipt on materialization (best-effort).
- `inbox-channel.ts`: writes `notification_id` + `organization_id`, no `read`
flag, and the `delivered` receipt. Email→id fallback kept (moves up to the
`RecipientResolver` in P1).

### Producers re-routed through `emit()`
- **Flow `notify` node** (`service-automation/.../notify-node.ts`): maps config →
`EmitInput` (title/body/url ride in `payload`).
- **Collaboration** (`plugin-audit/src/audit-writers.ts`): `@mention` →
`emit('collab.mention')`, assignment → `emit('collab.assignment')`, both with
a `dedupKey`. No more direct `sys_notification` writes. The plugin resolves the
`messaging` service lazily at hook time (`audit-plugin.ts`).

### Data migration (not auto-run)
`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.

### Tests
`messaging-service`, `inbox-channel`, `messaging-service-plugin`, `notify-node`,
and the migration all have updated/added coverage. All green.

---

## ⚠️ Breaking change — Console bell (objectui, separate repo)

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

### objectui changes required (`app-shell`)
1. **`AppHeader.tsx` / `InboxPopover.tsx`**: poll **`sys_inbox_message`** filtered
by `user_id = {current_user}` (the `mine` list view), ordered by `created_at`
desc — instead of `sys_notification`.
2. **Read-state**: join/read `sys_notification_receipt` for the row's state
(`read` vs `delivered`). The unread badge = inbox rows with no `read`/`clicked`
receipt.
3. **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.)
4. **"View all" / notification center route**: point at `sys_inbox_message`
(`mine`) instead of `sys_notification`.
5. `RecordDetailView` and any other `sys_notification` readers: same repoint.

### Cut-over sequence (avoid a blank bell)
1. Deploy this framework change (objects + emit + producers). New notifications
now land in `sys_inbox_message` + receipts.
2. Run `migrateSysNotificationToEvent({ driver, data })` to carry existing
notifications into `sys_inbox_message` + receipts.
3. 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.)

---

## Behavior notes / watch-outs

- **Messaging is now foundational (auto-on).** Collaboration notifications
require the messaging pipeline (with no `messaging` service registered,
`@mention`/assignment are skipped + warned, like the `notify` node). Two seams
guarantee it loads:
- `objectstack serve`: `messaging` is in `Serve.ALWAYS_ON_CAPABILITIES`
(`packages/cli/src/commands/serve.ts`) — every non-`minimal` preset starts it.
- Cloud / per-project kernels (`capability-loader.ts`): no always-on slate, so
the loader now expands `requires` to add `messaging` whenever `audit` is
present. Artifacts requiring `audit` therefore get the pipeline automatically.

`--preset minimal` (CLI) and artifacts that require neither `audit` nor
`messaging` opt out — collaboration notifications then no-op by design.

- **Dedup is best-effort in P0.** `emit()` idempotency is a non-transactional
check-then-insert and `sys_notification.dedup_key` is a non-unique index, so a
concurrent duplicate `emit` with the same `dedupKey` can still produce two
events. Robust, race-safe dedup is part of the **P1 outbox** (durable spine +
unique dedup). Assignment `dedupKey`s are scoped by the record's write-version
(`updated_at`) so re-assignments aren't permanently suppressed.

- **Event-log growth.** Every `emit()` writes one `sys_notification` event row.
High-frequency periodic `notify` flows 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 + `delivered` writes, but nothing transitions a receipt to
`read`/`clicked`/`dismissed`. The bell's mark-read therefore needs a small
write ingress (a receipt-upsert REST route or an `sys_inbox_message` action
keyed on `(notification_id, user_id, channel)`), landed **together with** the
objectui bell repoint. The SDK `client.notifications.markRead/list({read})`
helpers target the old `sys_notification` read-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.ts`
still carry the old `sys_notification` field labels (`is_read`, etc.). Harmless
(unused) but should be regenerated.
- **Audience selectors** `role:`/`team:`/`owner_of:` are accepted by `emit()` but
not yet expanded — they resolve to zero recipients until the P1
`RecipientResolver`. Today's producers only pass explicit ids/emails, so this is
latent, not active.

---

## Remaining phases (from the build spec)

- **P1 — Reliable delivery**: `sys_notification_delivery` outbox + dispatcher
(state machine, retry/backoff, dead-letter, `dedup_key`); `RecipientResolver`
(reuse sharing/CEL resolver) owning `role:`/`owner_of:`/`team:`/email→id. Move
the inbox channel's email→id fallback up here.
- **P2 — Subscription + preference**: `sys_notification_subscription` +
`sys_notification_preference` objects + Studio config UI; mandatory-topic
bypass; admin-global + per-user-override defaults.
- **P3 — Channels + templates + digest**: email/push/webhook/Slack channels on
connectors (ADR-0022); `sys_notification_template` (topic×channel×locale) +
renderer; digest / quiet-hours middleware.
12 changes: 9 additions & 3 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,16 +163,22 @@ export default class Serve extends Command {
* Capabilities auto-added to every app's `requires` for every preset
* EXCEPT `minimal`. These form the foundation that every server-side
* runtime expects to exist (background work, settings persistence,
* transactional mail, file uploads). Apps may still list these in
* `requires:` explicitly — duplicates are de-duped.
* transactional mail, file uploads, notifications). Apps may still list
* these in `requires:` explicitly — duplicates are de-duped.
*
* `messaging` is foundational because, post-ADR-0030, notifications flow
* through a single ingress (`NotificationService.emit`): collaboration
* `@mention` / assignment (plugin-audit) and the `notify` flow node deliver
* via the messaging pipeline, and the Console bell reads its materialization
* (`sys_inbox_message`). Without it those notifications silently no-op.
*
* Opt out: `objectstack serve --preset minimal`.
*
* Cloud / multi-environment hosts (which live in a separate distribution)
* mirror this list on their per-project kernels.
*/
static readonly ALWAYS_ON_CAPABILITIES: readonly string[] = Object.freeze([
'queue', 'job', 'cache', 'settings', 'email', 'storage', 'sharing',
'queue', 'job', 'cache', 'settings', 'email', 'storage', 'sharing', 'messaging',
]);

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/metadata/src/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,8 @@ export {
addSysMetadataOverlayIndex,
type AddSysMetadataOverlayIndexResult,
} from './add-sys-metadata-overlay-index.js';
export {
migrateSysNotificationToEvent,
type SysNotificationMigrationResult,
type SysNotificationMigrationOptions,
} from './migrate-sys-notification-to-event.js';
Loading