Skip to content

Latest commit

 

History

History
122 lines (91 loc) · 9.3 KB

File metadata and controls

122 lines (91 loc) · 9.3 KB

Design / Build Spec — Notification Platform Convergence

Decision: ADR-0030 — Notification Platform Convergence Refines/realizes: ADR-0012 · Channels on connectors: ADR-0022 Audience: the implementing agent. This is the executable spec; ADR-0030 holds the why.

Status (2026-06-01): P0, P1, P2, P3a, P3b-1 (quiet-hours), and P3b-2 (digest collapse) are shipped (PRs #1434/#1441/#1444/#1449/#1453 plus the digest follow-up). The §4 checklists below are the original plan — for the current done/remaining breakdown see ADR-0030 § Implementation status & remaining work and docs/handoff/adr-0030-notification-convergence.md. Remaining: the cross-repo objectui bell cut-over + mark-read write path, incremental channels, topic catalog, and hardening items.


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_preferenceid, 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_receiptid, 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_templateid, 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

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<string, unknown>;    // 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.