# SDD Design — Notification Center

**Change ID:** `notification-center`  
**Phase:** design  
**Date:** 2026-05-29  
**Owner:** Gentle AI  
**Scope:** Cross-cutting (backend + frontend)  
**Est. Lines:** ~1,800 (recommend 4 chained PRs)  
**Depends on:** `container-monitoring-notifications` (event bus, SSE, lifecycle hooks)  

---

## 1. Component Architecture

### 1.1 NotificationService — Backend Singleton

**Pattern:** Module-level singleton, instantiated once at import time and shared across request handlers and event producers.

**Responsibilities:**
- Insert per-user `Notification` rows into the database.
- Filter all queries by `user_id`; enforce strict ownership isolation.
- Exclude `dismissed_at IS NOT NULL` rows from list queries.
- Return paginated results ordered by `created_at DESC`.
- Apply `notification_mute_categories` filtering at the service layer so muted categories are never returned.

**Class:**
```python
# apps/api/src/services/notification_service.py

class NotificationService:
    """Singleton notification persistence service."""

    async def create_notification(
        self,
        session: AsyncSession,
        user_id: uuid.UUID,
        *,
        category: str,
        severity: str,
        title: str,
        message: str | None = None,
        source_type: str | None = None,
        source_id: uuid.UUID | None = None,
        metadata: dict | None = None,
    ) -> Notification: ...

    async def list_notifications(
        self,
        session: AsyncSession,
        user_id: uuid.UUID,
        *,
        limit: int = 20,
        offset: int = 0,
        unread_only: bool = False,
        mute_categories: list[str] | None = None,
    ) -> tuple[list[Notification], int]: ...

    async def get_unread_count(
        self,
        session: AsyncSession,
        user_id: uuid.UUID,
    ) -> int: ...

    async def mark_read(
        self,
        session: AsyncSession,
        notification_id: uuid.UUID,
        user_id: uuid.UUID,
    ) -> Notification: ...

    async def mark_all_read(
        self,
        session: AsyncSession,
        user_id: uuid.UUID,
    ) -> int: ...

    async def dismiss(
        self,
        session: AsyncSession,
        notification_id: uuid.UUID,
        user_id: uuid.UUID,
    ) -> None: ...
```

**Why pass `AsyncSession` explicitly:**
- Event producers (lifecycle hooks, health monitor) may hold their own DB session. The service performs a single atomic INSERT/UPDATE inside that session rather than opening a nested transaction. The caller is responsible for commit.

---

### 1.2 Notification API Router — FastAPI

**Pattern:** Standard FastAPI `APIRouter`, mounted in `main.py` under `/notifications`.

**Responsibilities:**
- Derive `current_user` from the existing auth dependency.
- Validate query parameters (`limit` capped at 100).
- Delegate to `NotificationService`; return Pydantic response models.
- Return `404` when a notification does not exist or is owned by a different user.
- Read `notification_mute_categories` from `UserConfig` and pass it to `list_notifications`.

**Router:**
```python
# apps/api/src/api/notifications.py

router = APIRouter(prefix="/notifications", tags=["notifications"])

@router.get("", response_model=NotificationListResponse)
async def list_notifications(
    limit: int = Query(20, ge=1, le=100),
    offset: int = Query(0, ge=0),
    unread_only: bool = Query(False),
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_async_session),
) -> NotificationListResponse: ...

@router.get("/unread", response_model=UnreadCountResponse)
async def get_unread_count(
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_async_session),
) -> UnreadCountResponse: ...

@router.patch("/{id}/read", response_model=NotificationItem)
async def mark_notification_read(
    id: uuid.UUID,
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_async_session),
) -> NotificationItem: ...

@router.post("/mark-all-read", response_model=MarkAllReadResponse)
async def mark_all_read(
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_async_session),
) -> MarkAllReadResponse: ...

@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
async def dismiss_notification(
    id: uuid.UUID,
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_async_session),
) -> None: ...
```

---

### 1.3 Event Producer Integration

**Lifecycle Hooks** (`apps/api/src/services/lifecycle_hooks.py`):
- After `publish_lifecycle_event()` publishes the raw event to `InstanceEventBus`, call `notification_service.create_notification(...)` with:
  - `user_id = tool_instance.owner_id`
  - `category = "instance"`
  - `severity` mapped from event type (`info` for created/started/stopped/restarted/deleted, `error` for error)
  - `title` derived from event type (e.g., "Container started")
  - `message` from payload message
  - `source_type = "tool_instances"`, `source_id = tool_instance.id`
- Wrap the service call in `try/except`; on failure log with `correlation_id` and continue.

**Health Monitor** (`apps/api/src/services/health_monitor.py`):
- After detecting a state change and publishing `instance.health_changed` or `instance.error`, call `notification_service.create_notification(...)` with:
  - `user_id = tool_instance.owner_id`
  - `category = "health"` for health changes, `"instance"` for errors
  - `severity` mapped from new status (`error` for crash, `warning` for unhealthy, `info` for recovery)
- Wrap the service call in `try/except`; on failure log with `correlation_id` and continue.

**Important:** Notification creation is **fire-and-forget** from the event producer perspective. The event pipeline (bus publish, audit row insert) must never be blocked by a notification service failure.

---

### 1.4 Frontend Components

#### NotificationProvider

**Pattern:** React Context + Provider, modeled after `EventProvider` (`apps/web/src/state/events.tsx`).

**Responsibilities:**
- Maintain `notifications: NotificationItem[]` and `unreadCount: number` in state.
- Run two independent polling intervals:
  - Unread count: every 15 seconds.
  - Notification list: every 30 seconds (suppressed when dropdown is open to avoid double-fetch).
- Provide mutation functions (`markRead`, `markAllRead`, `dismiss`) with optimistic updates.
- Pause all polling when the document is hidden (`document.hidden`).
- Immediately refresh the list when the dropdown opens.

**Location:** `apps/web/src/state/notifications.tsx`

#### useNotifications Hook

**Pattern:** Consumer hook, modeled after `useEvents()` (`apps/web/src/hooks/use-events.ts`).

**Responsibilities:**
- Expose `notifications`, `unreadCount`, `isLoading`, `error`.
- Expose `markRead(id)`, `markAllRead()`, `dismiss(id)`, `refreshList()`.
- Handle optimistic state updates before API call resolves.
- Surface errors silently (log to console) without throwing.

**Location:** `apps/web/src/hooks/use-notifications.ts`

#### NotificationCenter Component

**Pattern:** Controlled dropdown panel mounted in `AppShell` header-actions.

**Responsibilities:**
- Render a bell icon (Phosphor `Bell`) with an unread count badge.
- Manage `isOpen` state; open on bell click, close on outside click or `Escape` key.
- Render a scrollable dropdown panel positioned below the bell, right-aligned.
- Render a list of `NotificationItem` components.
- Show empty state when the list is empty.
- Render a footer with "Mark all as read" action.
- Call `refreshList()` immediately when opening.
- Hide entirely when `isMobileTerminal` is true.

**Location:** `apps/web/src/components/notification-center.tsx`

#### NotificationItem Component

**Pattern:** Presentational row component.

**Responsibilities:**
- Display severity icon (mapped from `severity` to Phosphor icon).
- Display `title` and relative timestamp (e.g., "2m ago").
- Render "Mark read" and "Dismiss" action buttons.
- Apply visual distinction for unread rows (e.g., bolder text, accent border, background tint).
- Apply reduced opacity for read rows.

**Props interface:**
```typescript
interface NotificationItemProps {
  notification: NotificationItem;
  onMarkRead: (id: string) => void;
  onDismiss: (id: string) => void;
}
```

**Location:** `apps/web/src/components/notification-item.tsx`

---

### 1.5 Toast Coordination — EventToastBridge

**Pattern:** Modify existing `EventToastBridge` (`apps/web/src/components/event-toast-bridge.tsx`) to consult notification preferences before emitting toasts.

**Responsibilities:**
- Read `userConfig.notification_toast_level` and `userConfig.notification_mute_categories` from the existing user config context.
- Before showing a toast for an SSE event:
  1. Skip if `toastLevel === "none"`.
  2. Skip if `toastLevel === "errors"` and event severity is not `"error"`.
  3. Skip if event category is in `muteCategories`.
- The toast bridge **does not** suppress the backend notification creation; it only controls frontend toast surfacing.
- To avoid duplicate perception: if a toast is shown for an event that also generated a notification, the user sees the toast (ephemeral) and later sees the same item in the center (persistent). This is acceptable because the toast provides immediate attention while the center provides history.

**Location:** `apps/web/src/components/event-toast-bridge.tsx`

---

## 2. File Structure

### New Files

| File | Purpose |
|------|---------|
| `apps/api/src/models/notification.py` | SQLAlchemy `Notification` model with indexes and FK. |
| `apps/api/src/services/notification_service.py` | `NotificationService` singleton with create/list/count/read/dismiss methods. |
| `apps/api/src/api/notifications.py` | FastAPI router: `GET /notifications`, `GET /notifications/unread`, `PATCH /{id}/read`, `POST /mark-all-read`, `DELETE /{id}`. |
| `apps/api/alembic/versions/2026_05_29_add_notifications_table.py` | Alembic migration creating `notifications` table, FK, and two indexes. |
| `apps/web/src/components/notification-center.tsx` | Bell icon + dropdown panel with list, empty state, footer actions, outside-click/Escape close. |
| `apps/web/src/components/notification-item.tsx` | Single notification row: severity icon, title, relative time, mark-read/dismiss actions. |
| `apps/web/src/state/notifications.tsx` | `NotificationProvider`: polling logic (15s unread / 30s list), optimistic mutations, visibility pause. |
| `apps/web/src/hooks/use-notifications.ts` | `useNotifications()` hook exposing state and mutation callbacks. |
| `apps/web/src/utils/icons.ts` | Add `"bell"` to `IconName` union and `iconRegistry` mapping to `PhosphorIcons.Bell`. |

### Modified Files

| File | Purpose |
|------|---------|
| `apps/api/src/main.py` | Import and register `notifications_router`; import `Notification` model for Alembic autogenerate. |
| `apps/api/src/models/__init__.py` | Export `Notification` for Alembic autogenerate. |
| `apps/api/src/api/__init__.py` | Export `notifications_router` (if barrel file exists). |
| `apps/api/src/services/lifecycle_hooks.py` | After publishing raw event, call `NotificationService.create_notification` with `owner_id` as `user_id`. Wrap in try/except. |
| `apps/api/src/services/health_monitor.py` | After state change detection, call `NotificationService.create_notification` with `owner_id` as `user_id`. Wrap in try/except. |
| `apps/web/src/components/app-shell.tsx` | Mount `<NotificationCenter />` inside `header-actions`; hide when `isMobileTerminal` is true. |
| `apps/web/src/components/event-toast-bridge.tsx` | Read `notification_toast_level` and `notification_mute_categories` from user config; skip toasts based on preference hierarchy. |
| `apps/web/src/styles.css` | Add `.notification-dropdown`, `.notification-item`, `.notification-item--unread`, `.notification-badge`, `.notification-empty` utility classes. |

---

## 3. Interface Design

### 3.1 NotificationService Method Signatures

```python
# apps/api/src/services/notification_service.py

from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from datetime import datetime
from typing import Any

class NotificationService:
    async def create_notification(
        self,
        session: AsyncSession,
        user_id: uuid.UUID,
        *,
        category: str,
        severity: str,
        title: str,
        message: str | None = None,
        source_type: str | None = None,
        source_id: uuid.UUID | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> Notification: ...

    async def list_notifications(
        self,
        session: AsyncSession,
        user_id: uuid.UUID,
        *,
        limit: int = 20,
        offset: int = 0,
        unread_only: bool = False,
        mute_categories: list[str] | None = None,
    ) -> tuple[list[Notification], int]:
        """Returns (items, total_count)."""
        ...

    async def get_unread_count(
        self,
        session: AsyncSession,
        user_id: uuid.UUID,
    ) -> int: ...

    async def mark_read(
        self,
        session: AsyncSession,
        notification_id: uuid.UUID,
        user_id: uuid.UUID,
    ) -> Notification: ...

    async def mark_all_read(
        self,
        session: AsyncSession,
        user_id: uuid.UUID,
    ) -> int:
        """Returns number of rows updated."""
        ...

    async def dismiss(
        self,
        session: AsyncSession,
        notification_id: uuid.UUID,
        user_id: uuid.UUID,
    ) -> None: ...
```

### 3.2 API Endpoint Request/Response Schemas (Pydantic)

```python
# apps/api/src/api/notifications.py

from pydantic import BaseModel, Field
import uuid
from datetime import datetime

class NotificationItem(BaseModel):
    id: uuid.UUID
    user_id: uuid.UUID
    category: str
    severity: str
    title: str
    message: str | None
    source_type: str | None
    source_id: uuid.UUID | None
    metadata: dict
    read_at: datetime | None
    dismissed_at: datetime | None
    created_at: datetime

    class Config:
        from_attributes = True

class NotificationListResponse(BaseModel):
    items: list[NotificationItem]
    total: int
    limit: int
    offset: int

class UnreadCountResponse(BaseModel):
    count: int

class MarkAllReadResponse(BaseModel):
    marked_count: int
```

### 3.3 Frontend: useNotifications() Return Type

```typescript
// apps/web/src/hooks/use-notifications.ts

export interface NotificationItem {
  id: string;
  user_id: string;
  category: string;
  severity: "info" | "warning" | "error" | "success";
  title: string;
  message: string | null;
  source_type: string | null;
  source_id: string | null;
  metadata: Record<string, unknown>;
  read_at: string | null;
  dismissed_at: string | null;
  created_at: string;
}

export interface UseNotificationsReturn {
  notifications: NotificationItem[];
  unreadCount: number;
  isLoading: boolean;
  error: Error | null;
  markRead: (id: string) => Promise<void>;
  markAllRead: () => Promise<void>;
  dismiss: (id: string) => Promise<void>;
  refreshList: () => Promise<void>;
}

export function useNotifications(): UseNotificationsReturn {
  // Consumes NotificationContext
}
```

### 3.4 NotificationItem Props

```typescript
// apps/web/src/components/notification-item.tsx

export interface NotificationItemProps {
  notification: NotificationItem;
  onMarkRead: (id: string) => void;
  onDismiss: (id: string) => void;
}
```

### 3.5 Toast Coordination: Preference Checks Before Showing Toast

```typescript
// apps/web/src/components/event-toast-bridge.tsx

interface UserNotificationConfig {
  notification_toast_level: "all" | "errors" | "none";
  notification_mute_categories: string[];
}

function shouldShowToast(
  event: InstanceEventPayload,
  config: UserNotificationConfig,
): boolean {
  if (config.notification_toast_level === "none") return false;

  const severity = mapEventToSeverity(event);
  if (config.notification_toast_level === "errors" && severity !== "error") {
    return false;
  }

  const category = mapEventToCategory(event);
  if (config.notification_mute_categories.includes(category)) {
    return false;
  }

  return true;
}
```

---

## 4. Data Flow

### 4.1 Event Producer → NotificationService → DB → REST API → Frontend Polling

```
HealthMonitor / LifecycleHook
    │
    ├──► Publish raw event to InstanceEventBus
    │         │
    │         ▼
    │    SSE Stream ──► Frontend (toast bridge)
    │
    └──► NotificationService.create_notification(user_id=owner_id, ...)
              │
              ▼
         DB INSERT INTO notifications
              │
              ▼
    Frontend polling (every 15s unread / 30s list)
              │
              ├──► GET /notifications/unread ──► Badge count update
              │
              └──► GET /notifications ──► Dropdown list update
```

**Key invariants:**
- The notification row is created **independent** of the SSE stream. A user who is offline will still have notifications waiting when they return.
- The `NotificationService` call happens **after** the raw event is published, so event bus delivery is never blocked by DB write latency.
- Event producers wrap the service call in `try/except`; a notification insert failure is logged but does not break the event pipeline.

### 4.2 User Action → PATCH/POST/DELETE → Optimistic UI Update → API Call → State Refresh

```
User clicks "Mark read" on a notification row
    │
    ▼
NotificationItem calls onMarkRead(id)
    │
    ▼
useNotifications.markRead(id)
    │
    ├──► Optimistic: update local state → set read_at, decrement unreadCount
    │
    ├──► API: PATCH /notifications/{id}/read
    │         │
    │         ├──► 200 OK → no further state change (already optimistic)
    │         │
    │         └──► 404/500 → rollback optimistic update, log error
    │
    └──► Next polling cycle refreshes full list as reconciliation
```

**Optimistic update rules:**
- `markRead`: Immediately set `read_at = now()` on the local item and decrement `unreadCount` by 1.
- `markAllRead`: Immediately set `read_at = now()` on all items and set `unreadCount = 0`.
- `dismiss`: Immediately remove the item from the local array; if it was unread, decrement `unreadCount`.
- On API failure, revert to the pre-mutation state.

### 4.3 Toast Flow: SSE Event → Preference Check → Toast Decision

```
InstanceEventBus publishes event
    │
    ▼
EventContext receives payload
    │
    ▼
EventToastBridge.onEvent(payload)
    │
    ├──► Map event type → category, severity
    │
    ├──► Read userConfig.notification_toast_level
    │         │
    │         ├──► "none" → skip
    │         ├──► "errors" and severity != "error" → skip
    │         └──► "all" → continue
    │
    ├──► Read userConfig.notification_mute_categories
    │         │
    │         ├──► category in mute list → skip
    │         └──► not muted → continue
    │
    └──► Show toast via toast system
              │
              ▼
         Notification row already exists (created independently)
```

**Duplicate avoidance:**
- The toast and the notification center are **intentionally independent surfaces**. A user may see a toast for an event and also see the same event in the center later.
- The toast is ephemeral (3–10s); the center is persistent. This is by design, not a bug.
- No deduplication between toast and center is required because they serve different purposes (alert vs. history).

---

## 5. State Machine

### 5.1 Notification Lifecycle

```
                +------------------+
                │     unread       │
                │  (read_at IS NULL)
                +--------+---------+
                         │
           ┌─────────────┼─────────────┐
           │             │             │
           ▼             ▼             ▼
    +-----------+  +-----------+  +-----------+
    │ mark read │  │ mark all  │  │  dismiss  │
    │  (PATCH)  │  │   read    │  │  (DELETE) │
    +-----+-----+  │  (POST)   │  +-----+-----+
          │        +-----+-----+        │
          │              │              │
          ▼              ▼              ▼
    +-----------+  +-----------+  +-----------+
    │    read   │  │    read   │  │ dismissed │
    │(read_at   │  │(read_at   │  │(dismissed_│
    │  set)     │  │  set)     │  │  at set)  │
    +-----------+  +-----------+  +-----------+
          │              │              │
          │              │              │
          └──────────────┴──────────────┘
                         │
                         ▼
                  +-----------+
                  │  absent   │
                  │ from list │
                  │  queries  │
                  +-----------+
```

**Transitions:**

| From | Action | To | DB Change |
|------|--------|----|-----------|
| `unread` | Mark read | `read` | `read_at = now()` |
| `unread` | Mark all read | `read` | `read_at = now()` on all unread rows |
| `unread` | Dismiss | `dismissed` | `dismissed_at = now()` |
| `read` | Dismiss | `dismissed` | `dismissed_at = now()` |
| `dismissed` | — | `absent` | Excluded from all list queries |

### 5.2 Badge State

The badge state is a **derived value** from `unreadCount`, which is polled every 15 seconds.

```
Badge state
    │
    ├──► unreadCount === 0 → badge hidden
    │
    └──► unreadCount > 0  → badge visible, text = unreadCount
                              (cap display at "99+" if count > 99)
```

**Badge color:** Use the existing `nav-badge` CSS class (red background, white text) for consistency with the session-card health badges.

---

## 6. Database Schema

### 6.1 Table: `notifications`

```sql
CREATE TABLE notifications (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    category VARCHAR(32) NOT NULL,
    severity VARCHAR(16) NOT NULL,
    title VARCHAR(255) NOT NULL,
    message TEXT,
    source_type VARCHAR(64),
    source_id UUID,
    metadata JSONB NOT NULL DEFAULT '{}',
    read_at TIMESTAMPTZ,
    dismissed_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_notifications_user_created_at
    ON notifications(user_id, created_at DESC);

CREATE INDEX idx_notifications_user_unread
    ON notifications(user_id, read_at)
    WHERE read_at IS NULL;
```

**SQLAlchemy model:**
```python
# apps/api/src/models/notification.py

import uuid
from datetime import datetime
from typing import Any

from sqlalchemy import ForeignKey, String, Text, DateTime, JSON
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func

from .base import Base, UUIDPrimaryKeyMixin

class Notification(UUIDPrimaryKeyMixin, Base):
    __tablename__ = "notifications"

    user_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        ForeignKey("users.id", ondelete="CASCADE"),
        nullable=False,
        index=True,
    )
    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 | None] = mapped_column(Text, nullable=True)
    source_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
    source_id: Mapped[uuid.UUID | None] = mapped_column(
        UUID(as_uuid=True), nullable=True
    )
    metadata: Mapped[dict[str, Any]] = mapped_column(
        JSONB, nullable=False, 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
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
    )
```

### 6.2 Migration

**File:** `apps/api/alembic/versions/2026_05_29_add_notifications_table.py`

**Dependency:** Depends on the current Alembic `head` at time of creation.

```python
"""Add notifications table

Revision ID: <generated>
Revises: <current_head>
Create Date: 2026-05-29

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = "<generated>"
down_revision = "<current_head>"
branch_labels = None
depends_on = None


def upgrade() -> None:
    op.create_table(
        "notifications",
        sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
        sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column("category", sa.String(length=32), nullable=False),
        sa.Column("severity", sa.String(length=16), nullable=False),
        sa.Column("title", sa.String(length=255), nullable=False),
        sa.Column("message", sa.Text(), nullable=True),
        sa.Column("source_type", sa.String(length=64), nullable=True),
        sa.Column("source_id", postgresql.UUID(as_uuid=True), nullable=True),
        sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'"), nullable=False),
        sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("dismissed_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
        sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
        sa.PrimaryKeyConstraint("id"),
    )
    op.create_index("idx_notifications_user_created_at", "notifications", ["user_id", sa.text("created_at DESC")])
    op.create_index(
        "idx_notifications_user_unread",
        "notifications",
        ["user_id", "read_at"],
        postgresql_where=sa.text("read_at IS NULL"),
    )


def downgrade() -> None:
    op.drop_index("idx_notifications_user_unread", table_name="notifications")
    op.drop_index("idx_notifications_user_created_at", table_name="notifications")
    op.drop_table("notifications")
```

---

## 7. Polling Strategy

### 7.1 Intervals

| Endpoint | Interval | Condition |
|----------|----------|-----------|
| `GET /notifications/unread` | 15 seconds | Always while authenticated |
| `GET /notifications` | 30 seconds | Only while dropdown is **closed** |
| `GET /notifications` | Immediate | When dropdown is **opened** |

### 7.2 Tab Visibility Handling

When `document.visibilityState === "hidden"` (user switches tabs or minimizes):
- Pause all polling intervals using `clearInterval`.
- Resume polling when `visibilityState === "visible"` using `document.addEventListener("visibilitychange", ...)`. 
- On resume, immediately fire both polls to catch up, then restart intervals.

**Rationale:** Prevents unnecessary server load from background tabs and reduces battery usage on mobile.

### 7.3 Authentication State

- When the user logs out, stop all polling and clear notification state.
- When the user logs in, start polling immediately (do not wait for the first interval).

### 7.4 Dropdown Open Behavior

```
User clicks bell
    │
    ├──► clearInterval(listPollInterval)
    │
    ├──► set isOpen = true
    │
    ├──► immediate GET /notifications
    │         │
    │         └──► update state
    │
    └──► on dropdown close: restart 30s list poll, immediate GET /notifications/unread
```

### 7.5 Error Resilience

- If a poll request fails (network error, 5xx), log to console and schedule the next poll normally.
- Do not display error UI for polling failures; the user should not be interrupted.
- If a poll returns 401, stop all polling (user session expired).

---

## 8. Toast Coordination

### 8.1 Preference Hierarchy

Preferences are stored in `UserConfig.config` JSON blob:

```json
{
  "notification_mute_categories": ["instance"],
  "notification_toast_level": "all"
}
```

**Evaluation order (first match wins):**

1. **Mute categories:** If the event's mapped `category` is in `notification_mute_categories`, suppress the toast entirely.
2. **Toast level:**
   - `"none"` → suppress all toasts.
   - `"errors"` → only show toasts when `severity === "error"`.
   - `"all"` → show all toasts (default).

### 8.2 Event-to-Category/Severity Mapping

```typescript
// apps/web/src/components/toast-rules.ts (extended)

function mapEventToCategory(event: InstanceEventPayload): string {
  if (event.event.startsWith("instance.")) return "instance";
  if (event.event.startsWith("health.")) return "health";
  return "system";
}

function mapEventToSeverity(event: InstanceEventPayload): "info" | "warning" | "error" | "success" {
  switch (event.event) {
    case "instance.error":
    case "health.error":
      return "error";
    case "instance.health_changed":
      return event.status === "unhealthy" ? "warning" : "success";
    case "instance.created":
    case "instance.started":
    case "instance.stopped":
    case "instance.restarted":
    case "instance.deleted":
      return "info";
    default:
      return "info";
  }
}
```

### 8.3 Avoiding Duplicate Toast + Center Entry

**There is no deduplication requirement** between toast and center entry. The two surfaces serve different purposes:

| Surface | Purpose | Lifetime |
|---------|---------|----------|
| Toast | Immediate attention grabber | 3–10 seconds |
| Center | Persistent history and review | Until dismissed |

Both can show the same event. This is acceptable because:
- The toast auto-dismisses quickly.
- The user may miss the toast and rely on the center.
- The user may see the toast and later want to reference details in the center.

**Future enhancement (Phase 3):** Add `notification_id` to toast metadata so clicking the toast can open the center and scroll to the corresponding row.

---

## 9. Testing Strategy

### 9.1 Backend: Service Unit Tests

**File:** `tests/unit/test_notification_service.py`

| Test | Description |
|------|-------------|
| `test_create_notification` | Call `create_notification` with all fields; assert row inserted with correct values, `read_at` NULL, `dismissed_at` NULL. |
| `test_list_notifications_orders_by_created_at_desc` | Insert 3 rows; assert list returns newest first. |
| `test_list_notifications_excludes_dismissed` | Insert 2 rows, dismiss 1; assert only 1 returned. |
| `test_list_notifications_unread_only` | Insert 1 read, 1 unread; assert `unread_only=True` returns 1. |
| `test_list_notifications_mute_categories` | Insert rows with categories `instance` and `system`; pass `mute_categories=["instance"]`; assert only `system` returned. |
| `test_get_unread_count` | Insert 5 rows, 2 unread; assert count is 2. |
| `test_get_unread_count_excludes_dismissed` | Insert 1 unread dismissed; assert count is 0. |
| `test_mark_read_sets_read_at` | Call `mark_read`; assert `read_at` is not NULL. |
| `test_mark_read_wrong_owner_raises` | User A creates notification; User B calls `mark_read`; assert 404-equivalent exception. |
| `test_mark_all_read_affects_only_caller` | User A has 3 unread, User B has 2; mark all for A; assert A=0 unread, B=2 unread. |
| `test_dismiss_sets_dismissed_at` | Call `dismiss`; assert `dismissed_at` is not NULL. |
| `test_dismiss_wrong_owner_raises` | User A creates notification; User B calls `dismiss`; assert 404-equivalent exception. |

**Fixtures needed:**
- `notification_service`: fresh `NotificationService()` instance.
- `db_session`: async SQLAlchemy session with rollback after each test.
- `user_a`, `user_b`: test user rows inserted in session.

### 9.2 Backend: API Integration Tests

**File:** `tests/integration/test_notifications_api.py`

| Test | Description |
|------|-------------|
| `test_list_requires_auth` | `GET /notifications` without auth → `401`. |
| `test_list_returns_only_own_notifications` | Create notification for user A; user B requests list → not in response. |
| `test_list_pagination` | Create 25 notifications; `limit=10&offset=10` → items length 10, total 25. |
| `test_unread_count_endpoint` | Create 3 unread; `GET /notifications/unread` → `{count: 3}`. |
| `test_mark_read_endpoint` | Create unread; `PATCH /notifications/{id}/read` → `200`, `read_at` set. |
| `test_mark_read_404_for_other_user` | Create notification for user A; user B PATCH → `404`. |
| `test_mark_all_read_endpoint` | Create 4 unread; `POST /notifications/mark-all-read` → `{marked_count: 4}`. |
| `test_dismiss_endpoint` | Create notification; `DELETE /notifications/{id}` → `204`; subsequent list excludes it. |
| `test_dismiss_404_for_other_user` | User B DELETE user A's notification → `404`. |
| `test_mute_categories_filter_in_list` | Set user config `mute_categories=["instance"]`; create instance + system notifications; `GET /notifications` returns only system. |

### 9.3 Frontend: Component Tests

**File:** `apps/web/src/components/notification-center.test.tsx`

| Test | Description |
|------|-------------|
| `renders bell icon` | Mount with provider; assert bell icon visible. |
| `shows badge when unread count > 0` | Provider state has `unreadCount=3`; assert badge text is "3". |
| `hides badge when unread count is 0` | Provider state has `unreadCount=0`; assert badge not in document. |
| `opens dropdown on bell click` | Simulate click; assert dropdown panel visible. |
| `closes dropdown on outside click` | Open dropdown; click outside; assert panel not visible. |
| `closes dropdown on Escape` | Open dropdown; fire `Escape` key; assert panel not visible. |
| `renders empty state when no notifications` | List empty; assert empty state text visible. |
| `renders notification items` | List has 2 items; assert 2 `NotificationItem` components rendered. |
| `calls markAllRead on footer button click` | Simulate "Mark all as read" click; assert mock called. |
| `refreshes list immediately on open` | Open dropdown; assert `refreshList` mock called. |

**File:** `apps/web/src/components/notification-item.test.tsx`

| Test | Description |
|------|-------------|
| `displays title and relative time` | Render with sample data; assert title and time visible. |
| `applies unread styling when read_at is null` | `read_at=null`; assert unread CSS class present. |
| `applies read styling when read_at is set` | `read_at=iso-string`; assert read CSS class present. |
| `calls onMarkRead when mark read clicked` | Simulate click; assert callback with correct id. |
| `calls onDismiss when dismiss clicked` | Simulate click; assert callback with correct id. |

### 9.4 Frontend: Hook Tests

**File:** `apps/web/src/hooks/use-notifications.test.ts`

| Test | Description |
|------|-------------|
| `returns notifications from context` | Mock provider value; assert hook returns same array. |
| `returns unreadCount from context` | Mock provider value; assert hook returns correct count. |
| `optimistically updates on markRead` | Call `markRead`; assert local state updated before API resolves. |
| `reverts optimistic update on markRead failure` | Mock API rejection; assert state reverted to original. |
| `optimistically updates on dismiss` | Call `dismiss`; assert item removed and count decremented. |
| `reverts optimistic update on dismiss failure` | Mock API rejection; assert item restored and count restored. |
| `calls refreshList when invoked` | Mock API; assert `GET /notifications` called. |

### 9.5 Frontend: Toast Bridge Tests

**File:** `apps/web/src/components/event-toast-bridge.test.tsx`

| Test | Description |
|------|-------------|
| `shows toast when level is all and category not muted` | Config `{level:"all",mute:[]}`; assert toast shown. |
| `suppresses toast when level is none` | Config `{level:"none"}`; assert no toast. |
| `suppresses info toast when level is errors` | Config `{level:"errors"}`; event severity `info`; assert no toast. |
| `shows error toast when level is errors` | Config `{level:"errors"}`; event severity `error`; assert toast shown. |
| `suppresses toast when category is muted` | Config `{level:"all",mute:["instance"]}`; category `instance`; assert no toast. |

### 9.6 End-to-End Test

**File:** `tests/e2e/notification-center.spec.ts` (Playwright)

| Test | Description |
|------|-------------|
| `container error creates notification and increments badge` | Trigger `instance.error` via API; within 15s assert badge shows `1`; open dropdown; assert error notification visible. |
| `mark read clears badge` | Open dropdown; click mark read; assert badge hidden. |
| `dismiss removes from list` | Open dropdown; click dismiss; assert notification not in list; refresh page; assert still absent. |
| `mark all read clears all` | Create 3 unread; click "Mark all as read"; assert badge hidden; assert all rows styled as read. |
| `toast respects preferences` | Set toast level to `"none"`; trigger event; assert no toast appears. |

---

## 10. Rollout Plan

| PR | Contents | Estimated Lines | Review Risk |
|----|----------|-----------------|-------------|
| **PR 1: Backend core** | Migration, model, `NotificationService`, API router, registration in `main.py`, unit + integration tests | ~600 | Medium |
| **PR 2: Backend integration** | Wire `lifecycle_hooks.py` and `health_monitor.py`; extend `UserConfig` schema docs; event producer tests | ~250 | Low |
| **PR 3: Frontend core** | Icon, `NotificationProvider`, `useNotifications`, `NotificationCenter`, `NotificationItem`, styles, `AppShell` integration, component + hook tests | ~700 | Medium |
| **PR 4: Toast coordination** | Update `EventToastBridge` + `toast-rules.ts`; preference UI wiring; bridge + preference tests | ~250 | Low |

**Dependency order:** PR 1 → PR 2 → PR 3 → PR 4. PR 3 depends on PR 1/2 backend APIs. PR 4 depends on PR 3 UI.

---

## 11. Decisions

| ID | Decision | Rationale |
|----|----------|-----------|
| D1 | `NotificationService` accepts `AsyncSession` instead of managing its own | Keeps service lightweight and avoids nested transaction issues when called from event producers that already hold a session. |
| D2 | Soft-delete via `dismissed_at` | Preserves audit history; allows future "recently dismissed" or admin analytics features. |
| D3 | Partial index on `read_at IS NULL` | Unread count is queried every 15 seconds per active user; partial index keeps query small and fast. |
| D4 | Poll instead of SSE for Phase 1 | Avoids redesigning SSE multiplexing for per-user streams. REST polling is simpler, cache-friendly, and sufficient for MVP. |
| D5 | No deduplication between toast and center | The surfaces serve different purposes (alert vs. history). Both showing the same event is acceptable. |
| D6 | `UserConfig` JSON blob for preferences | Matches existing pattern (theme, editor, git identity). No schema migration needed when adding keys. |
| D7 | Pause polling on `document.hidden` | Reduces server load from background tabs and improves battery life on mobile. |
| D8 | `category` and `severity` stored as strings, not enums | Extensible without Alembic migrations when new sources introduce categories. |
| D9 | Event producers call `NotificationService` directly, not via EventBus | Makes the dependency visible and avoids hidden side effects. The bus remains transport for raw events only. |
| D10 | Cap badge display at "99+" | Prevents layout shift if a user accumulates an extreme number of unread notifications. |
