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