Move the following audited-and-implemented changes into openspec/changes/archive/2026-06-12-completed-changes-archive/: - backend-frontend-refactoring - config-profile-git-mounts - config-profile-includes-ui - config-profile-multi-repo-mounts - container-monitoring-notifications - git-mount-url-validation - home-path-expansion - mobile-terminal-ux - mount-specificity-ordering - notification-center - persistent-terminal-sessions - session-list-overhaul - ssh-key-mounting - terminal-fullscreen-unified-header - tool-session-progress-and-updates Also regenerated .pi-map*.md files for openspec/changes so the remaining active changes (multi-session-terminal-ux, reorganize-long-files, working-copies, workspace-first-ui) reflect the new layout.
18 KiB
Notification Center Specification
Purpose
Provide a persistent, per-user notification store with a REST API, a frontend notification center UI, and user-scoped preferences for category muting and toast suppression. Notifications are created by backend event producers (lifecycle hooks, health monitor) and surfaced to users through a bell icon dropdown, an unread count badge, and coordinated toast behavior.
Assumption: This specification introduces the "Notification Center" as a new domain. No canonical spec exists for notifications; this is a full new domain spec.
Non-Functional Requirements
| ID | Requirement |
|---|---|
| NFR-1 | Performance: The GET /notifications/unread endpoint MUST respond in less than 10 milliseconds at p99 under normal load, backed by a partial index on read_at IS NULL. |
| NFR-2 | Security: The API MUST enforce that every notification row is scoped to exactly one user_id; no endpoint MUST return or mutate a notification belonging to a different user. |
| NFR-3 | Scalability: The notifications table MUST support high write volume from event producers without blocking reads; writes from NotificationService.create_notification MUST be independent of event producer transactions. |
| NFR-4 | Availability: Notification creation failures in event producers MUST be caught, logged, and MUST NOT block the original event pipeline (lifecycle hooks, health monitor). |
Requirements
Requirement: R1 — Notification data model
The system MUST provide a Notification SQLAlchemy model backed by a notifications table with the following columns:
id—UUID, primary key, defaultgen_random_uuid().user_id—UUID, foreign key tousers.id,NOT NULL, indexed.category—VARCHAR(32),NOT NULL(e.g.,instance,system,health,security).severity—VARCHAR(16),NOT NULL(e.g.,info,warning,error,success).title—VARCHAR(255),NOT NULL.message—TEXT, nullable.source_type—VARCHAR(64), nullable (e.g.,tool_instances).source_id—UUID, nullable (e.g., the related tool instance UUID).metadata—JSONB,NOT NULL DEFAULT '{}', stores unstructured extra data.read_at—TIMESTAMPTZ, nullable, indexed.dismissed_at—TIMESTAMPTZ, nullable.created_at—TIMESTAMPTZ,NOT NULL DEFAULT now(), indexed.
Indexes:
idx_notifications_user_created_aton(user_id, created_at DESC).idx_notifications_user_unreadon(user_id, read_at)WHEREread_at IS NULL(partial index).
Foreign key: user_id references users.id with ON DELETE CASCADE.
Migration: alembic/versions/YYYY_MM_DD_HHMMSS_add_notifications_table.py.
Scenario: SC-DB-1 — Migration creates table and indexes
- GIVEN the Alembic migration runs successfully,
- WHEN inspecting the database schema,
- THEN the
notificationstable exists with all columns, the foreign key, and the two indexes including the partial index.
Requirement: R2 — NotificationService
The system MUST provide a NotificationService class with the following methods:
create_notification(user_id, category, severity, title, message=None, source_type=None, source_id=None, metadata=None)— inserts a row and returns theNotification.list_notifications(user_id, *, limit=20, offset=0, unread_only=False)— returns notifications scoped touser_id, ordered bycreated_at DESC, excluding rows wheredismissed_at IS NOT NULL.get_unread_count(user_id)— returns the count of rows whereuser_idmatches andread_at IS NULLanddismissed_at IS NULL.mark_read(notification_id, user_id)— setsread_at = now()on the matching row; returns the updatedNotification.mark_all_read(user_id)— setsread_at = now()on all rows whereuser_idmatches andread_at IS NULL; returns the number of rows updated.dismiss(notification_id, user_id)— setsdismissed_at = now()on the matching row.
All methods MUST filter by user_id so that no user can access another user's notifications.
Scenario: SC-SVC-1 — Create notification
- GIVEN a valid
user_idand notification payload, - WHEN
create_notificationis called, - THEN a row is inserted with all provided fields,
read_atisNULL,dismissed_atisNULL, and the row is returned.
Scenario: SC-SVC-2 — List excludes dismissed
- GIVEN two notifications for the same user, one dismissed and one not,
- WHEN
list_notificationsis called, - THEN only the non-dismissed notification is returned.
Scenario: SC-SVC-3 — Unread count query uses partial index
- GIVEN 100 notifications for a user, 30 unread,
- WHEN
get_unread_countis executed, - THEN the query plan MUST use the partial index
idx_notifications_user_unread.
Scenario: SC-SVC-4 — Cross-user isolation
- GIVEN a notification owned by user A,
- WHEN user B calls
mark_read,dismiss, orlist_notifications, - THEN user B MUST NOT see or affect user A's notification.
Requirement: R3 — REST API endpoints
The system MUST expose a FastAPI router mounted at /notifications with the following endpoints. All endpoints require authentication and derive current_user.id from the auth dependency.
GET /notifications
Query parameters:
limit— integer, optional, default20, maximum100.offset— integer, optional, default0.unread_only— boolean, optional, defaultfalse.
Response 200 OK:
{
"items": [
{
"id": "uuid",
"user_id": "uuid",
"category": "string",
"severity": "string",
"title": "string",
"message": "string | null",
"source_type": "string | null",
"source_id": "uuid | null",
"metadata": {},
"read_at": "iso-datetime | null",
"dismissed_at": "iso-datetime | null",
"created_at": "iso-datetime"
}
],
"total": 0,
"limit": 20,
"offset": 0
}
GET /notifications/unread
Response 200 OK:
{
"count": 0
}
PATCH /notifications/{id}/read
Path parameter: id — UUID.
Response 200 OK — returns the updated notification object (same schema as list item).
POST /notifications/mark-all-read
Response 200 OK:
{
"marked_count": 0
}
DELETE /notifications/{id}
Path parameter: id — UUID.
Performs a soft delete by setting dismissed_at.
Response 204 No Content.
Scenario: SC-API-1 — List with pagination and unread_only filter
- GIVEN 5 notifications, 2 unread, for the authenticated user,
- WHEN
GET /notifications?unread_only=true&limit=2is called, - THEN the response contains exactly the 2 unread notifications, ordered by
created_at DESC.
Scenario: SC-API-2 — Mark single read updates read_at
- GIVEN an unread notification owned by the caller,
- WHEN
PATCH /notifications/{id}/readis called, - THEN the response has
read_atset to a non-null ISO datetime.
Scenario: SC-API-3 — Mark all read affects only caller
- GIVEN user A has 3 unread notifications and user B has 2 unread notifications,
- WHEN user A calls
POST /notifications/mark-all-read, - THEN the response
marked_countis3, and user B's notifications remain unread.
Scenario: SC-API-4 — Dismiss removes from list
- GIVEN an unread notification owned by the caller,
- WHEN
DELETE /notifications/{id}is called, - THEN the endpoint returns
204, and a subsequentGET /notificationsno longer includes the dismissed row.
Requirement: R4 — Event producers create notifications
The system MUST ensure that lifecycle_hooks.py and health_monitor.py call NotificationService.create_notification after publishing the raw event, using ToolInstance.owner_id as the user_id.
The notification MUST be created regardless of frontend preferences; filtering happens at read time and in the toast bridge.
Scenario: SC-PROD-1 — Container error creates notification
- GIVEN a running container owned by user U,
- WHEN the health monitor detects a crash and publishes
instance.error, - THEN a notification row is created for user U with
category="instance",severity="error", andsource_type="tool_instances".
Scenario: SC-PROD-2 — Lifecycle event creates notification
- GIVEN a tool instance owned by user U,
- WHEN a lifecycle hook publishes
instance.started, - THEN a notification row is created for user U with
category="instance"andseverity="info".
Scenario: SC-PROD-3 — Notification failure does not block event pipeline
- GIVEN
NotificationService.create_notificationraises an exception, - WHEN a lifecycle hook or health monitor publishes an event,
- THEN the exception is caught and logged, the original event is still published, and the health monitor poll loop continues.
Requirement: R5 — Frontend notification center component
The system MUST provide a <NotificationCenter /> component mounted inside the AppShell header-actions area on desktop (hidden when isMobileTerminal is true).
The component MUST:
- Render a bell icon (Phosphor
Bell). - Display an unread count badge when
unreadCount > 0. - Open a dropdown panel on bell click.
- Close the dropdown on outside click or
Escapekey press. - Render a scrollable list of recent notifications inside the panel.
- Show an empty state when no notifications exist.
- Provide a "Mark all as read" action in the panel footer.
Each notification row MUST display:
- A severity icon mapped from
severity. - The
title. - A relative timestamp derived from
created_at. - "Mark read" and "Dismiss" actions.
Unread rows MUST be visually distinct from read rows.
Scenario: SC-UI-1 — Bell renders with badge
- GIVEN the user has 3 unread notifications,
- WHEN the AppShell header is rendered,
- THEN the bell icon is visible and the badge displays
3.
Scenario: SC-UI-2 — Dropdown opens and lists notifications
- GIVEN the user has notifications,
- WHEN the user clicks the bell icon,
- THEN the dropdown opens and lists up to the default limit of notifications with title, relative time, and severity icon.
Scenario: SC-UI-3 — Empty state
- GIVEN the user has zero notifications,
- WHEN the dropdown opens,
- THEN an empty state message is shown (e.g., "No notifications").
Requirement: R6 — Frontend polling
The system MUST poll the notification endpoints at the following intervals while the user is authenticated:
GET /notifications/unreadevery 15 seconds to update the badge count.GET /notificationsevery 30 seconds to refresh the list.
When the dropdown is opened, the list MUST be refreshed immediately regardless of the polling timer.
Scenario: SC-POLL-1 — Badge updates on new notification
- GIVEN the badge shows
0, - WHEN a new unread notification is created on the backend,
- THEN the badge updates to
1within 15 seconds (one polling interval).
Scenario: SC-POLL-2 — List refreshes on open
- GIVEN the dropdown is closed and a new notification arrives,
- WHEN the user opens the dropdown,
- THEN the list is fetched immediately and includes the new notification.
Requirement: R7 — Toast coordination respecting user preferences
The system MUST update EventToastBridge to check user notification preferences before showing a toast for an SSE event.
The bridge MUST:
- Skip the toast entirely if
notification_toast_levelis"none". - Skip the toast if the event's mapped
severityis below the threshold:"errors"level: only show toasts forseverity="error".
- Skip the toast if the event's
categoryis present innotification_mute_categories.
The notification row on the backend is still created; the bridge only controls toast surfacing.
Scenario: SC-TOAST-1 — Toast level "none" suppresses all toasts
- GIVEN
notification_toast_levelis"none", - WHEN an
instance.errorevent arrives via SSE, - THEN no toast is shown.
Scenario: SC-TOAST-2 — Toast level "errors" suppresses info/warning
- GIVEN
notification_toast_levelis"errors", - WHEN an
instance.startedevent (severityinfo) arrives via SSE, - THEN no toast is shown; an
instance.errorevent still produces a toast.
Scenario: SC-TOAST-3 — Muted category suppresses toast
- GIVEN
notification_mute_categoriescontains["instance"]andnotification_toast_levelis"all", - WHEN an
instance.errorevent arrives via SSE, - THEN no toast is shown for that event.
Requirement: R8 — User preferences in UserConfig
The system MUST extend the UserConfig JSON config blob with two new keys:
notification_mute_categories—string[], default[]. Categories listed here are excluded fromlist_notificationsresults and suppress toasts for matching events.notification_toast_level—"all" | "errors" | "none", default"all".
The list_notifications service method MUST filter out rows whose category is in the caller's notification_mute_categories.
Preference changes MUST take effect immediately without a server restart.
Scenario: SC-PREF-1 — Muted category excluded from list
- GIVEN
notification_mute_categoriescontains["instance"]and the user has instance and system notifications, - WHEN
GET /notificationsis called, - THEN the response contains only system notifications; instance notifications are omitted.
Scenario: SC-PREF-2 — Preference change is immediate
- GIVEN
notification_toast_levelis"all", - WHEN the user changes it to
"none"and saves the preference, - THEN the next SSE event does not produce a toast.
Error Handling
EH-1: Notification not found or not owned
If a PATCH /notifications/{id}/read or DELETE /notifications/{id} request targets a notification that does not exist or is owned by a different user, the endpoint MUST return 404 Not Found. The response body SHOULD include a detail message: "Notification not found".
EH-2: Invalid category or severity
If NotificationService.create_notification is called with a category or severity value that does not conform to the project's allowed set, the service SHOULD raise a validation error (e.g., ValueError), and the caller SHOULD log it without blocking the event pipeline.
EH-3: Service exceptions in event producers
lifecycle_hooks.py and health_monitor.py MUST wrap NotificationService.create_notification calls in a try/except block. On exception, the error MUST be logged with correlation_id, and the original event publishing MUST continue.
EH-4: Polling failure
If a polling request (GET /notifications or GET /notifications/unread) fails on the frontend, the error MUST be silently logged (not thrown as an unhandled exception), and the next polling cycle MUST proceed on schedule.
Scenarios (Acceptance Criteria Summary)
| ID | Scenario |
|---|---|
| SC-1 | Container error → notification created for owner → badge increments. A health monitor crash detection creates a notification for the instance owner; within one 15-second poll cycle, the frontend badge increments. |
| SC-2 | User clicks bell → dropdown opens → shows unread notifications. Clicking the bell renders the dropdown panel with unread rows visually distinct. |
| SC-3 | User marks notification read → badge decrements → row styling changes. Clicking "Mark read" or the row triggers PATCH /notifications/{id}/read; the badge count decreases by one; the row styling updates to the read state. |
| SC-4 | User dismisses notification → row removed → persists on refresh. Clicking "Dismiss" triggers DELETE /notifications/{id}; the row is removed from the list; on page reload the row remains absent. |
| SC-5 | User clicks "mark all read" → badge resets to 0. Clicking "Mark all as read" triggers POST /notifications/mark-all-read; the badge shows 0; all visible rows transition to the read state. |
| SC-6 | User sets toast level to "none" → no toast shown for new events. Changing notification_toast_level to "none" prevents the EventToastBridge from showing any toast for incoming SSE events. |
| SC-7 | User mutes "instance" category → no instance notifications in list. Adding "instance" to notification_mute_categories removes instance notifications from GET /notifications and suppresses instance toasts. |
API Contract Reference
Request / Response Schemas
NotificationItem:
| Field | Type | Nullable |
|---|---|---|
| id | UUID string | no |
| user_id | UUID string | no |
| category | string (max 32) | no |
| severity | string (max 16) | no |
| title | string (max 255) | no |
| message | string | yes |
| source_type | string (max 64) | yes |
| source_id | UUID string | yes |
| metadata | object | no (default {}) |
| read_at | ISO 8601 datetime | yes |
| dismissed_at | ISO 8601 datetime | yes |
| created_at | ISO 8601 datetime | no |
NotificationListResponse:
| Field | Type |
|---|---|
| items | NotificationItem[] |
| total | integer |
| limit | integer |
| offset | integer |
UnreadCountResponse:
| Field | Type |
|---|---|
| count | integer |
MarkAllReadResponse:
| Field | Type |
|---|---|
| marked_count | integer |
Endpoints Summary
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /notifications |
Required | List notifications with pagination and unread_only filter. |
| GET | /notifications/unread |
Required | Returns { count: int } for the authenticated user. |
| PATCH | /notifications/{id}/read |
Required | Marks a single notification read. |
| POST | /notifications/mark-all-read |
Required | Marks all unread notifications read for the caller. |
| DELETE | /notifications/{id} |
Required | Soft-deletes (dismisses) a single notification. |