From ba2d5f41936b29d25f885ab6af5c27bc04e6acfc Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 9 Jul 2026 18:44:49 +0530 Subject: [PATCH] docs(spec): notifications design (engine in IIOS + Web Push, per-thread mute) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approved design: IIOS notification engine (PresenceService, NotificationProjector with policy/presence/mute gates, swappable NotificationPort → Web Push VAPID, subscription + participant.muted data) + host-app experience (service worker, registerPush, mute toggle, deep-link). Trigger policy: DMs always, group only on @mention or reply-to-you. Phased: frontend quick win → IIOS engine → SDK/app. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-09-notifications-design.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-09-notifications-design.md diff --git a/docs/superpowers/specs/2026-07-09-notifications-design.md b/docs/superpowers/specs/2026-07-09-notifications-design.md new file mode 100644 index 0000000..27bd9dd --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-notifications-design.md @@ -0,0 +1,156 @@ +# Notifications — Design Spec + +> Push/desktop notifications for the IIOS chat app (and, by reuse, any host app on IIOS). +> Engine in IIOS (generic, governed, reusable); experience in the host app. Spans two +> repos: **iios** (engine) and **chat-web** (SDK + app). + +**Status:** approved design (2026-07-09) · next step: implementation plan. + +--- + +## 1. Goal + +Deliver notifications to a user **when the conversation isn't in front of them** — including +when the app/tab is **closed** (Web Push) — without notifying them about the chat they're +actively viewing. Build the reusable **notification engine** in IIOS so every app on the +platform (chat, support SDK, …) inherits it. + +## 2. Locked decisions + +| Decision | Choice | +|---|---| +| **Scope** | The IIOS **engine + Web Push** (works when the app is closed), phased. | +| **Trigger policy** | **DMs always** push; **group** messages push **only on @mention** (or a reply to you). Otherwise: unread badge + inbox only. Never for the thread you're actively viewing. | +| **Preferences (v1)** | **Per-thread mute** only. A muted thread sends no push (badge still updates). Quiet hours / global mute / per-level are deferred. | +| **Delivery** | A swappable **NotificationPort**; the v1 adapter is **Web Push (VAPID)**. Email/FCM are later adapters. | +| **Ownership** | Engine (decide/deliver/store) = IIOS. Experience (render/tap/service-worker) = host app/SDK. | + +## 3. Architecture + +*Engine in IIOS, experience in the host app.* A notification is generic — "inform an actor +about an interaction" — so **no chat vocabulary enters the kernel**; DM-vs-group stays the +opaque `membership` thread attribute that **policy** reads (consistent with reactions, +mentions, media). + +``` +message.sent (outbox event) + │ + ▼ +NotificationProjector ── gate 1: policy (DM? or in a group: @mentioned OR a reply to you?) + │ ── gate 2: presence (connected AND viewing that thread? → skip) + │ ── gate 3: mute (thread muted for this actor? → skip) + ▼ +NotificationPort.deliver(subscription, payload) ← Web Push (VAPID) adapter + ▼ +browser push service → service worker → OS notification → tap → open thread +``` + +## 4. Components (IIOS engine) + +### 4.1 PresenceService +The `/message` gateway already tracks connected sockets and the thread each has joined +(`open_thread` = socket.io room join). PresenceService answers **"is actor A currently +viewing thread T?"**. Backed by Redis (reuses the existing socket.io Redis adapter) so it +holds across replicas. On disconnect, presence is cleared. + +### 4.2 NotificationProjector +Reacts to `message.sent` (same idempotent-consumer pattern as `InboxProjector`; it may live +alongside it). For each recipient participant (not the sender), runs the three gates in §3, +then dispatches to each of the actor's active subscriptions. Idempotent per event id. + +**Gate 1 (policy) detail:** DM (`membership='dm'`) → eligible. Group → eligible only if the +recipient is in the message's `mentions[]` **or** the message's `parentInteractionId` points +to a message that recipient authored (a reply to them). Otherwise → not eligible (badge/inbox +only). + +### 4.3 NotificationPort + WebPushDelivery +`interface NotificationPort { deliver(sub, payload): Promise<'sent'|'gone'|'failed'> }`. +`WebPushDelivery` signs with VAPID and POSTs to the subscription endpoint. A `'gone'` +(HTTP 404/410) result → the projector **prunes** that subscription row. Swappable to +email/FCM later without touching the projector. + +### 4.4 Payload +Built from **generic** fields: `{ title: senderName, body: text snippet (or "sent an +attachment"), tag: threadId, data: { threadId, interactionId } }`. No chat-specific logic in +the kernel; `data.threadId` drives the app's deep-link. + +## 5. Data (IIOS Postgres, tenant-scoped) + +- **`IiosNotificationSubscription`** (new) — `{ id, scopeId, actorId, kind: 'webpush', + endpoint (unique), p256dh, auth, userAgent?, createdAt, lastSeenAt }`. `@@index([actorId])`. +- **Mute** — a `muted Boolean @default(false)` column on **`IiosThreadParticipant`** (per + actor+thread; already the join row). +- **Notification record / feed** — **reuse `IiosInboxItem`** (already the feed; unread = + OPEN, read = DONE). No new "notifications" table. +- **VAPID keys** — env only (`VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`), + generated once; private key is a secret, public key is served to clients. + +## 6. Endpoints (IIOS) + +| Method | Path | Body | Purpose | +|---|---|---|---| +| GET | `/v1/notifications/vapid-public-key` | — | public key the client needs to subscribe | +| POST | `/v1/notifications/subscribe` | `{kind, endpoint, keys:{p256dh, auth}, userAgent?}` | store/refresh a push subscription for the caller | +| DELETE | `/v1/notifications/subscribe` | `{endpoint}` | remove a subscription | +| POST | `/v1/threads/:id/mute` / `/unmute` | — | set/clear the caller's `muted` flag on the thread | + +All Bearer-authed and tenant-scoped (subscriptions/mutes belong to the caller's actor+scope). + +## 7. SDK + app (host experience) + +### 7.1 SDK (chat-web `lib/notifications.ts`; belongs in `@insignia/iios-kernel-client`) +- `registerPush()` — request `Notification` permission → register the service worker → + `PushManager.subscribe({ applicationServerKey: })` → POST the subscription. +- `unregisterPush()`, `mute(threadId)` / `unmute(threadId)`. +- **`sw.js`** (service worker) — `push` event → `showNotification(title, {body, tag, data})`; + `notificationclick` → focus an existing tab or open the app, deep-linked to `data.threadId`. + +### 7.2 App (chat-web) +- **Phase 1 (no backend):** on socket `message` while `document.hidden` **and** not viewing + that thread → `new Notification(...)`; tab-title `(N)` unread from the conversation list; + optional sound. Presence-gated client-side. +- **Phase 3:** an "Enable notifications" prompt (calls `registerPush`), a 🔕 **mute** toggle in + the conversation header, and deep-link handling when a notification is tapped. + +## 8. Error handling + +- **Presence unknown** (Redis blip) → treat as **absent** (better to notify than silently + miss). Mute + policy are still enforced. +- **Mute** is **fail-closed** — if the mute read fails, do **not** send (respect the intent). +- **Dead subscription** (web-push 404/410) → delete the row; a re-subscribe re-adds it. +- **VAPID unset** → the engine logs "push disabled" and no-ops; the app is unaffected + (Phase-1 desktop notifications still work). +- Delivery is best-effort/at-least-once; the client de-dupes visible notifications by `tag`. + +## 9. Testing + +- **Unit (projector):** DM→deliver · group-no-mention→skip · group-@mention→deliver · + present-in-thread→skip · muted→skip · sender-never-notified. +- **Unit (adapter/store):** subscription CRUD; prune on 410; WebPushDelivery with a mocked + transport. +- **Unit (presence):** connected+viewing vs connected-elsewhere vs disconnected. +- **e2e:** subscribe → send while the recipient is absent → delivery invoked; send while the + recipient is viewing the thread → not invoked; mute → not invoked. + +## 10. Generic-safety + +The projector consumes generic `message.sent` events + the opaque `mentions[]` list; DM-vs- +group is read from the opaque `membership` attribute (in policy, not kernel branching). The +payload is built from generic fields. Grep must show no `'dm'`/`'group'` literals in the +notification engine — same guardrail as the rest of IIOS. + +## 11. Phasing (drives the plan) + +1. **Phase 1 — frontend quick win (chat-web only):** desktop notification while unfocused + + tab-title unread + client-side presence gate. Demoable immediately, zero backend. +2. **Phase 2 — IIOS engine (TDD):** PresenceService · `IiosNotificationSubscription` + + `participant.muted` migration · endpoints · NotificationPort + WebPushDelivery · + NotificationProjector (3 gates + prune). Full suite green. +3. **Phase 3 — SDK + app:** `sw.js` + `registerPush` + enable-prompt · mute toggle · + deep-link on click. e2e verified. + +## 12. Out of scope (deferred) + +Quiet hours · global mute · per-thread level (all/mentions/none) · email + mobile (FCM/APNs) +adapters · notification bell/feed UI beyond the existing Inbox · rich/actionable +notifications (reply-from-notification) · digest/batching.