Files
iios/docs/superpowers/specs/2026-07-09-notifications-design.md
T
maaz519 ba2d5f4193 docs(spec): notifications design (engine in IIOS + Web Push, per-thread mute)
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) <noreply@anthropic.com>
2026-07-09 18:44:49 +05:30

8.2 KiB

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 / feedreuse 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: <vapid public> }) → 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.