# SDD Proposal — Notification Center **Change ID:** `notification-center` **Status:** Draft **Date:** 2026-05-29 --- ## 1. Problem Statement The current notification surface is limited to ephemeral toasts driven by an unfiltered SSE stream. Users face three critical gaps: 1. **No persistence** — If a user is offline, reloads the page, or dismisses a toast, the event is gone forever. There is no way to review what happened while they were away. 2. **No scoping** — The SSE endpoint broadcasts all instance events to every authenticated user. Users receive toasts for containers they do not own, creating noise and potential information leakage. 3. **No lifecycle or control** — Toasts auto-dismiss with no read/unread state, no dismissal history, and no user preferences to mute categories or suppress toast pop-ups. These gaps make the system unsuitable for any asynchronous, user-specific, or high-signal communication such as health alerts, system maintenance notices, or future billing events. --- ## 2. Goals | # | Goal | Success Measure | |---|------|-----------------| | G1 | **Persistent, per-user notification store** backed by a new database table. Notifications survive page reloads, browser restarts, and session changes. | 100 % of notifications created for a user are retrievable after a full browser close + reopen. | | G2 | **Per-user filtering** — Users only see notifications scoped to their user_id. | Zero cross-user notification leakage in API responses. | | G3 | **Read/unread/dismiss lifecycle** with REST endpoints and optimistic UI updates. | Users can mark individual or all notifications read, and dismiss unwanted entries; state persists on refresh. | | G4 | **Notification center UI** — Bell icon in the top-right AppShell header with a dropdown panel listing recent notifications. | Bell is visible on desktop; dropdown renders within 200 ms of click; accessible via keyboard. | | G5 | **Unread count badge** — Red badge on the bell icon reflecting the real-time unread count. | Badge count matches GET /notifications/unread within one polling interval. | | G6 | **Modular notification sources** — Any backend module can call a central NotificationService to create user-scoped notifications without touching instance events directly. | A new source (e.g., a future billing module) can emit notifications by adding a single service call. | | G7 | **Toast coordination** — The existing toast system respects user preferences and avoids duplicate surfacing when a notification is already in the center. | No user sees both a toast and a center entry for the same backend event unless they explicitly re-open the center. | | G8 | **User preferences** — Mute categories and toast-level settings stored in UserConfig. | Preference changes take effect immediately without a server restart. | --- ## 3. Non-Goals | # | Non-Goal | Rationale | |---|----------|-----------| | NG1 | **Real-time SSE for notifications in Phase 1** | Will use REST polling (30 s list / 15 s unread) to ship faster. A dedicated notifications/stream SSE is a fast-follow (Phase 3). | | NG2 | **Push notifications / WebHooks / Email** | Out of scope for this change. The architecture must not block these later, but no transport work is included now. | | NG3 | **Multi-worker event-bus scaling** | InstanceEventBus remains an in-memory singleton. The NotificationService interface is designed so a future Redis-backed queue can slot in without consumer changes. | | NG4 | **Team / group-scoped notifications** | Notifications are 1-to-1 user_id only. Mentioning or broadcasting to teams is future work. | | NG5 | **Mobile-specific notification UI (bottom sheet)** | The bell will be hidden on isMobileTerminal. A mobile-native bottom-sheet variant is a future polish item. | | NG6 | **Rich-text or markdown bodies** | title and message are plain strings. No formatting engine is introduced. | --- ## 4. User Stories | ID | Story | Acceptance Criteria | |----|-------|---------------------| | US-1 | **As a** user, **I want** to see a bell icon with an unread count in the header **so that** I know when something needs my attention. | Bell renders in header-actions; badge shows unread count; count updates on poll. | | US-2 | **As a** user, **I want** to click the bell and see a list of recent notifications **so that** I can catch up on events I missed. | Dropdown opens; lists last 20 notifications; shows title, relative time, severity icon; empty state when none exist. | | US-3 | **As a** user, **I want** to mark a notification as read **so that** the badge count decreases and the UI reflects my attention. | Clicking a row or its Mark read action updates read_at; badge decrements; row styling changes. | | US-4 | **As a** user, **I want** to dismiss a notification **so that** it no longer appears in my list. | Dismiss removes the row from the list and sets dismissed_at; does not affect other users. | | US-5 | **As a** user, **I want** to Mark all as read **so that** I can clear my inbox quickly. | Footer button marks all unread notifications read; badge resets to zero; list styling updates. | | US-6 | **As a** user, **I want** notification preferences (mute categories, toast level) **so that** I control noise. | Settings panel or modal exposes checkboxes / select for mute categories and toast level; saves to UserConfig. | | US-7 | **As a** backend developer, **I want** to emit a notification from any module with one function call **so that** I do not rebuild plumbing each time. | NotificationService.create_notification(...) is importable anywhere; auto-scopes to user_id. | | US-8 | **As a** user, **I want** container error events to appear as notifications **so that** I can review them later even if I missed the toast. | instance.error events from HealthMonitor / lifecycle_hooks generate a Notification row for the owner. | --- ## 5. Proposed Solution ### 5.1 Backend #### New Data Model ```python # apps/api/src/models/notification.py class Notification(Base): __tablename__ = "notifications" id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid4) user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False) category: Mapped[str] = mapped_column(String(32), nullable=False) severity: Mapped[str] = mapped_column(String(16), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False) message: Mapped[str] = mapped_column(Text, nullable=True) source_type: Mapped[str | None] = mapped_column(String(64), nullable=True) source_id: Mapped[UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) metadata: Mapped[dict] = mapped_column(JSON, default=dict) read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) dismissed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True, nullable=False ) ``` **Indexes:** - (user_id, created_at DESC) — fast list queries - (user_id, read_at) WHERE read_at IS NULL — fast unread count (partial index) #### New Service ```python # apps/api/src/services/notification_service.py class NotificationService: async def create_notification( self, user_id: UUID, category: str, severity: str, title: str, message: str | None = None, source_type: str | None = None, source_id: UUID | None = None, metadata: dict | None = None ) -> Notification: ... async def list_notifications( self, user_id: UUID, *, limit: int = 20, offset: int = 0, unread_only: bool = False ) -> list[Notification]: ... async def get_unread_count(self, user_id: UUID) -> int: ... async def mark_read(self, notification_id: UUID, user_id: UUID) -> Notification: ... async def mark_all_read(self, user_id: UUID) -> int: ... async def dismiss(self, notification_id: UUID, user_id: UUID) -> None: ... ``` The service is instantiated as a module-level singleton and imported by event producers. #### New API Router - GET /notifications — list (paginated, supports ?unread_only=true) - GET /notifications/unread — returns { "count": int } - PATCH /notifications/{id}/read — mark single read - POST /notifications/mark-all-read — mark all read - DELETE /notifications/{id} — dismiss (soft-delete by setting dismissed_at) All endpoints enforce user_id == current_user.id at the service layer. #### Event-Bus Integration - lifecycle_hooks.py and health_monitor.py call notification_service.create_notification(...) with user_id=tool_instance.owner_id after publishing the raw event. - No changes to InstanceEventBus itself; the notification service is a consumer, not bus middleware. #### Preferences Extension Extend UserConfig.config JSON schema with two new keys: - notification_mute_categories: string[] — categories the user does not want to see at all. - notification_toast_level: "all" | "errors" | "none" — default is "all". ### 5.2 Frontend #### New / Modified Components | Component | Purpose | |-----------|---------| | notification-center.tsx | Bell icon + dropdown panel. Manages open/close state, outside-click close, keyboard Escape. | | notification-item.tsx | Single row: severity icon, title, relative time, mark-read/dismiss actions. | | notification-provider.tsx | React context: holds list, unread count, polling logic (30 s / 15 s), mutations with optimistic updates. | | use-notifications.ts | Hook exposing notifications, unreadCount, markRead, markAllRead, dismiss, isLoading. | | app-shell.tsx | Mount NotificationCenter inside header-actions; hide when isMobileTerminal. | | event-toast-bridge.tsx | Read userConfig.notification_toast_level before emitting a toast. Skip toast if level is "none" or event severity is below threshold. | | icons.ts | Register "bell" pointing to PhosphorIcons.Bell. | | styles.css | Add .notification-dropdown, .notification-item, .notification-badge utilities. | #### Toast Coordination Logic 1. Backend event triggers NotificationService.create_notification() (always happens). 2. EventToastBridge receives the SSE event. 3. Bridge checks userConfig.notification_toast_level: - If "none": never toast. - If "errors": only toast when severity is "error". - If "all": toast as before. 4. Bridge also checks if the event category is in notification_mute_categories; if so, skip toast. 5. The notification row is always created on the backend regardless of frontend preferences; filtering happens at read time and in the bridge. #### Polling Strategy - Notification list: GET /notifications every 30 seconds while the dropdown is closed; refresh immediately when opened. - Unread count: GET /notifications/unread every 15 seconds. - Intervals are configurable constants in the provider. --- ## 6. Key Decisions | Decision | Rationale | |----------|-----------| | **Soft-delete via dismissed_at instead of hard DELETE** | Preserves audit history and allows future features such as "Recently dismissed" or admin analytics. | | **Partial index on read_at IS NULL** | Unread count is queried frequently; a partial index keeps it small and fast even as the table grows. | | **Poll instead of SSE for Phase 1** | Avoids redesigning the SSE multiplexing logic and lets us ship the full UI and backend in one PR. SSE follow-up is isolated. | | **Plain-text title/message** | Avoids introducing a markdown parser or HTML sanitization dependency. Rich content can be a future enhancement. | | **UserConfig JSON blob for preferences** | Matches existing pattern (theme, editor, git identity). No schema migration needed when adding keys. | | **No middleware in InstanceEventBus** | Producers (lifecycle_hooks, health_monitor) explicitly call the notification service. This makes the dependency visible and avoids hidden side effects in the bus. | | **Category + severity enums stored as strings** | Simple, human-readable, and extensible without Alembic migrations when a new source introduces a category. | --- ## 7. Risks | Risk | Likelihood | Impact | Mitigation | |------|------------|--------|------------| | **High write volume on notifications table** | Medium | High | Add partial indexes from day one; monitor write throughput; shard or archive old rows (e.g., auto-dismiss after 90 days) if volume becomes problematic. | | **Cross-user data leakage in API** | Low | Critical | Enforce user_id filter in every service method; add integration tests that attempt to read another user’s notification and assert 404. | | **Polling overhead at scale** | Medium | Medium | Poll intervals are conservative; unread count endpoint is a single COUNT query with a partial index. SSE fast-follow eliminates polling. | | **Mobile layout absence** | Low | Low | Bell is hidden on isMobileTerminal. Mobile bottom-sheet is a future non-goal. | | **No reusable dropdown component** | Medium | Medium | Build a minimal positioned panel inside notification-center.tsx using a ref + useEffect for outside click; extract to a design-system component only after it stabilizes. | | **Notification service called before DB commit** | Medium | Medium | Ensure lifecycle_hooks commits the parent transaction (instance_events insert) before calling the notification service, or wrap both in the same unit of work. | --- ## 8. Acceptance Criteria ### Backend - [ ] Alembic migration creates the notifications table with correct columns, FK, and indexes. - [ ] GET /notifications returns only rows where user_id matches the authenticated user, ordered by created_at DESC. - [ ] GET /notifications/unread returns the exact count of rows where read_at IS NULL for the authenticated user. - [ ] PATCH /notifications/{id}/read sets read_at and returns the updated row; 404 if not owned by caller. - [ ] POST /notifications/mark-all-read sets read_at on all unread rows for the caller; returns count affected. - [ ] DELETE /notifications/{id} sets dismissed_at; row no longer appears in list queries. - [ ] HealthMonitor and lifecycle_hooks generate notifications scoped to the tool instance owner. ### Frontend - [ ] Bell icon renders in AppShell header-actions on desktop. - [ ] Unread count badge updates within 15 seconds of a new notification. - [ ] Dropdown opens on bell click, closes on outside click or Escape. - [ ] Notification list shows title, relative time, severity icon; unread rows are visually distinct. - [ ] Mark read and Dismiss actions update UI optimistically and persist after refresh. - [ ] Mark all as read clears the badge and updates all visible rows. - [ ] Empty state message shown when no notifications exist. - [ ] Toast bridge respects notification_toast_level and notification_mute_categories. ### Integration - [ ] End-to-end test: trigger an instance.error event → verify notification row created → verify badge increments → verify toast appears (or not) based on preference → mark read → verify badge clears. --- ## 9. Effort Estimate + PR Breakdown ### PR 1 — Backend Core (~2 days) **Scope:** Migration, model, service, API router, registration in main.py. **Files:** - alembic/versions/..._add_notifications.py - apps/api/src/models/notification.py - apps/api/src/services/notification_service.py - apps/api/src/api/notifications.py - apps/api/src/main.py **Tests:** Service unit tests, API integration tests (ownership, pagination, mark-all-read). ### PR 2 — Backend Integration (~1 day) **Scope:** Wire lifecycle_hooks and HealthMonitor to call NotificationService; add preferences to UserConfig schema. **Files:** - apps/api/src/services/lifecycle_hooks.py - apps/api/src/services/health_monitor.py - apps/api/src/models/user_config.py (schema docs / validation) **Tests:** End-to-end event-to-notification creation tests. ### PR 3 — Frontend Core (~2 days) **Scope:** Icon, provider, hook, notification-center component, item component, styles, app-shell integration. **Files:** - apps/web/src/utils/icons.ts - apps/web/src/state/notifications.tsx - apps/web/src/hooks/use-notifications.ts - apps/web/src/components/notification-center.tsx - apps/web/src/components/notification-item.tsx - apps/web/src/components/app-shell.tsx - apps/web/src/styles.css **Tests:** Component render tests, hook behavior tests, optimistic update tests. ### PR 4 — Toast Coordination + Preferences UI (~1 day) **Scope:** Update EventToastBridge; add preference controls (inside existing settings modal or new section); connect to UserConfig API. **Files:** - apps/web/src/components/event-toast-bridge.tsx - apps/web/src/components/toast-rules.ts (if toast level logic lives here) - Settings / preferences component (TBD based on existing UI) **Tests:** Bridge logic tests, preference persistence tests. ### Total Estimated Effort: ~6 engineering days **Sequence:** PR 1 and PR 2 can be stacked (2 before 3). PR 3 depends on PR 1/2. PR 4 depends on PR 3. --- ## 10. Rollback Plan 1. **Database:** The migration is additive (new table + indexes). Rolling back requires a single Alembic downgrade that drops the notifications table. No existing tables are modified. 2. **Frontend:** If the UI causes performance or layout issues, remove the NotificationCenter mount from app-shell.tsx. The rest of the codebase is unaffected. 3. **Backend API:** If the router causes issues, unregister it in main.py. The underlying service and table can remain safely. 4. **Event producers:** If notification creation causes errors, the explicit service call in lifecycle_hooks and health_monitor can be wrapped in a try/except log-and-continue block so that event publishing is never blocked.