This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/docs/DESIGN-0.20.0.md
2026-03-01 12:40:15 +00:00

30 KiB
Raw Blame History

DESIGN-0.20.0 — Notifications + @mention Routing + Multi-model

Status: Complete (Phases 13 delivered 2026-03-01)
Depends on: v0.19.2 (Projects complete), EventBus + WebSocket hub (v0.9.x), channel_models table (v0.16.0 schema)
Prerequisite for: v0.23.0 (Multi-Participant Channels — notifications become the backbone for participant events)


Overview

v0.20.0 delivers two complementary features:

  1. Notifications — Persistent, user-targeted notification infrastructure with real-time WebSocket push and optional email transport. This is the event backbone for all future collaboration features (multi-participant channels, workflow assignment queues, presence).

  2. @mention Routing + Multi-model — Channels can host multiple AI models. Users @mention a model (or user, for future multi-participant) to direct a message. The completion handler fans out to the addressed model(s). This builds on the existing channel_models table and ChannelModel store methods that have been in the schema since v0.16.0 but were never surfaced in UI.


Phasing

Phase Scope Migration Est. Effort
1 Notifications core (in-app) 007_v0200_notifications.sql Medium-large
2 @mention parsing + multi-model routing None (uses existing channel_models) Medium
3 Email transport + user notification preferences 008_v0200_notification_prefs.sql Medium

Each phase is a merge-ready deliverable. Phase 1 is the critical path — 2 and 3 can be reordered or deferred if needed.


Phase 1 — Notifications Core (in-app)

Data Model

-- Postgres: 007_v0200_notifications.sql
CREATE TABLE IF NOT EXISTS notifications (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    type            VARCHAR(50) NOT NULL,       -- e.g. 'role.fallback', 'kb.processing', 'grant.changed', 'project.invite'
    title           VARCHAR(255) NOT NULL,
    body            TEXT DEFAULT '',
    resource_type   VARCHAR(50),                -- 'channel', 'knowledge_base', 'project', 'team', etc.
    resource_id     UUID,                       -- nullable — links notification to a navigable entity
    is_read         BOOLEAN DEFAULT false,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_notifications_user_unread ON notifications(user_id, is_read, created_at DESC);
CREATE INDEX idx_notifications_user_created ON notifications(user_id, created_at DESC);
-- SQLite: 006_v0200_notifications.sql
CREATE TABLE IF NOT EXISTS notifications (
    id              TEXT PRIMARY KEY,
    user_id         TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    type            TEXT NOT NULL,
    title           TEXT NOT NULL,
    body            TEXT DEFAULT '',
    resource_type   TEXT,
    resource_id     TEXT,
    is_read         INTEGER DEFAULT 0,
    created_at      TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_user_created ON notifications(user_id, created_at DESC);

Design notes:

  • No updated_at — notifications are immutable once created (only is_read flips).
  • type is a free-form string, not an enum. New notification sources don't require migration. Convention: domain.action (e.g. role.fallback, kb.ready, grant.added, project.invite).
  • resource_type + resource_id enable click-to-navigate. Frontend maps these to routes (e.g. resource_type=channelselectChat(resource_id)).
  • No FK on resource_id — the referenced entity may be deleted; notification persists as historical record.

Store Interface

// server/store/interfaces.go — addition to Stores
type NotificationStore interface {
    Create(ctx context.Context, n *models.Notification) error
    ListByUser(ctx context.Context, userID string, limit, offset int, unreadOnly bool) ([]models.Notification, int, error) // returns items + total count
    MarkRead(ctx context.Context, id, userID string) error
    MarkAllRead(ctx context.Context, userID string) error
    Delete(ctx context.Context, id, userID string) error
    UnreadCount(ctx context.Context, userID string) (int, error)
}

Both Postgres and SQLite implementations follow the established pattern. ListByUser returns paginated results with a total count for the unread badge. The userID parameter on MarkRead/Delete prevents cross-user access.

Model

// server/models/models_notification.go
type Notification struct {
    ID           string    `json:"id" db:"id"`
    UserID       string    `json:"user_id" db:"user_id"`
    Type         string    `json:"type" db:"type"`
    Title        string    `json:"title" db:"title"`
    Body         string    `json:"body" db:"body"`
    ResourceType string    `json:"resource_type,omitempty" db:"resource_type"`
    ResourceID   string    `json:"resource_id,omitempty" db:"resource_id"`
    IsRead       bool      `json:"is_read" db:"is_read"`
    CreatedAt    time.Time `json:"created_at" db:"created_at"`
}

API Endpoints

GET    /api/v1/notifications              — list (paginated, ?unread_only=true, ?limit=20&offset=0)
GET    /api/v1/notifications/unread-count  — { "count": 5 }
PATCH  /api/v1/notifications/:id/read     — mark single read
POST   /api/v1/notifications/mark-all-read — mark all read for user
DELETE /api/v1/notifications/:id          — delete single

All endpoints are user-scoped (auth middleware injects user_id). No admin override — notifications are personal.

Notification Service

Centralized creation + dispatch. All notification sources go through this service, never insert directly.

// server/notifications/service.go
type Service struct {
    store store.NotificationStore
    hub   *events.Hub
}

func (s *Service) Notify(ctx context.Context, n *models.Notification) error {
    // 1. Persist
    if err := s.store.Create(ctx, n); err != nil {
        return err
    }

    // 2. Real-time push via WebSocket (if user is online)
    s.hub.SendToUser(n.UserID, events.Event{
        Label:   "notification.new",
        Payload: events.MustJSON(n),
        Ts:      time.Now().UnixMilli(),
    })

    return nil
}

// NotifyMany fans out to multiple users (e.g. team-wide grant change).
func (s *Service) NotifyMany(ctx context.Context, userIDs []string, template models.Notification) error {
    for _, uid := range userIDs {
        n := template
        n.ID = store.NewID()
        n.UserID = uid
        _ = s.Notify(ctx, &n) // best-effort; log errors, don't fail batch
    }
    return nil
}

EventBus Route Table Addition

// server/events/types.go — add to routeTable
"notification.new":    DirToClient,   // targeted via Hub.SendToUser, not room-based
"notification.read":   DirToClient,   // badge sync across tabs

Initial Notification Sources

Wire these into existing code paths. Each source creates a Notification via Service.Notify():

Source Type Trigger Point Title Template
Role fallback role.fallback capabilities/resolver.go (already emits EventBus) "Model fallback: {primary} → {fallback}"
KB processing complete kb.ready knowledge_bases.go → after indexing pipeline "Knowledge base '{name}' ready ({n} chunks)"
KB processing error kb.error knowledge_bases.go → on embedding/chunking failure "Knowledge base '{name}' indexing failed"
Group membership changed grant.changed groups.go → AddMember / RemoveMember "You were added to group '{name}'"
Project invite project.invite Future (v0.23.0+) — stub the type now

Implementation order: role.fallback first (already emits an EventBus event — just subscribe in notification service), then kb.ready/kb.error (high user value), then grant.changed.

Frontend — Notification UI

Bell icon (header bar, right of model selector):

┌─────────────────────────────────────────────────┐
│ ☰  Chat Switchboard    [model ▾]  🔔③  👤 Jeff  │
└─────────────────────────────────────────────────┘
  • 🔔 with .notification-badge (red circle, count) when unreadCount > 0.
  • Badge shows count capped at 9+.
  • On click: toggle notification dropdown.

Notification dropdown:

┌──────────────────────────────────────┐
│  Notifications           Mark all ✓  │
├──────────────────────────────────────┤
│ ● KB "Sales Docs" ready (3 chunks)  │
│   2 minutes ago                      │
├──────────────────────────────────────┤
│ ○ Model fallback: gpt-4 → gpt-3.5  │
│   1 hour ago                         │
├──────────────────────────────────────┤
│ ○ Added to group "Engineering"       │
│   Yesterday                          │
├──────────────────────────────────────┤
│          View all →                  │
└──────────────────────────────────────┘
  • = unread, = read. Unread items have subtle background highlight.
  • Click item: mark read + navigate to resource_type/resource_id (open chat, open KB in admin, etc.).
  • "Mark all ✓" → POST /notifications/mark-all-read → clear badge.
  • "View all →" opens full notification list in side panel (registered with PanelRegistry, same pattern as Notes/Preview/Project).
  • Dropdown max-height: 400px, scrollable. Shows latest 10 items.
  • Click-outside / Escape to close.

File: src/js/notifications.js (new module, ~300-400 lines)

WebSocket handler:

Events.on('notification.new', (payload) => {
    App.notifications.unshift(payload);
    App.unreadNotificationCount++;
    renderNotificationBadge();

    // Optional: toast for high-priority notification types
    if (['kb.error', 'role.fallback'].includes(payload.type)) {
        UI.toast(payload.title, 'warning');
    }
});

Startup: GET /notifications/unread-count on app init → set badge. Lazy-load full list on first dropdown open.

Files Changed (Phase 1)

New files:

  • server/notifications/service.go — notification service
  • server/handlers/notifications.go — API handlers
  • server/store/postgres/notification.go — Postgres store
  • server/store/sqlite/notification.go — SQLite store
  • server/models/models_notification.go — model struct
  • server/database/migrations/007_v0200_notifications.sql — Postgres migration
  • server/database/migrations/sqlite/006_v0200_notifications.sql — SQLite migration
  • src/js/notifications.js — frontend notification UI

Modified files:

  • server/store/interfaces.go — add NotificationStore to Stores
  • server/events/types.go — add notification.* routes
  • server/main.go (or server/routes.go) — wire notification handler + service
  • server/handlers/knowledge_bases.go — emit kb.ready / kb.error notifications
  • server/handlers/groups.go — emit grant.changed notifications
  • server/capabilities/resolver.go — emit role.fallback notification (subscribe to existing EventBus event)
  • src/js/app.js — init notification module, add bell to header
  • src/js/events.js — add notification.* to known labels (documentation only, wildcard already works)
  • src/css/style.css — notification badge, dropdown, panel styles
  • index.html — notification bell element in header
  • Integration tests — notification CRUD + WebSocket delivery

Checklist (Phase 1)

  • Migration: notifications table (Postgres + SQLite)
  • Model: Notification struct
  • Store: NotificationStore interface + both implementations
  • Service: notifications.Service with Notify() / NotifyMany()
  • Handlers: 5 notification endpoints
  • EventBus: notification.new + notification.read in route table
  • Wire: notification service into knowledge_bases, groups, capabilities/resolver
  • Frontend: notifications.js module
  • Frontend: bell icon + unread badge in header
  • Frontend: notification dropdown (list, mark read, navigate)
  • Frontend: notification panel (full list, registered with PanelRegistry)
  • Frontend: WebSocket handler for real-time push
  • Frontend: toast for high-priority notification types
  • Tests: notification store CRUD (both dialects)
  • Tests: notification handler integration tests
  • Tests: WebSocket delivery (via existing hub test pattern)

Phase 2 — @mention Parsing + Multi-model Routing

Concept

Today, each channel has one active model (set via model selector or persona). The channel_models table (schema 001) already supports multiple models per channel — it's just never been populated with more than one, and the completion handler ignores it.

Phase 2 activates this table:

  1. Users can add models to a channel (up to N, configurable, default 5).
  2. Messages can contain @mentions that route to specific models.
  3. The completion handler fans out to mentioned model(s), producing one assistant response per model.
  4. Without @mention, the default channel model responds (backward compatible).

@mention Syntax

@claude-3-opus What do you think about this approach?
@gpt-4 Can you review the code above?
Hey @claude-3-opus and @gpt-4, compare your approaches.

Mentions resolve against the display_name of channel_models entries for the current channel. Resolution is case-insensitive, whitespace-normalized, with longest-match-first to handle model names that are substrings of others.

Why display_name, not model_id? Model IDs are composite strings like anthropic/claude-3-opus-20240229 — terrible UX. display_name is user-settable when adding a model to the channel, defaulting to a short friendly name derived from the model catalog (e.g. "Claude 3 Opus", "GPT-4").

Mention Parser

// server/mentions/parser.go

type Mention struct {
    Raw       string // "@claude-3-opus" as written
    Name      string // "claude-3-opus" (normalized, no @)
    Start     int    // byte offset in message content
    End       int    // byte offset end
    Resolved  *models.ChannelModel // nil if unresolved
}

// Parse extracts @mentions from message content and resolves them
// against the channel's model roster.
func Parse(content string, roster []models.ChannelModel) []Mention

Parser rules:

  • @ followed by one or more non-whitespace characters.
  • Greedy: @claude-3-opus-20240229 matches the full string, not just @claude.
  • Resolution: case-insensitive match against display_name with hyphens/spaces normalized.
  • Unresolved mentions are left as-is in the message (no error, no routing).
  • Mentions at any position in the message (start, middle, end).

Frontend-side autocomplete: When user types @ in the chat input, show a dropdown of channel models (fetched from GET /channels/:id/models). Selecting inserts the @display_name token. This reuses the [[wikilink autocomplete pattern from CM6 (v0.17.3) adapted for the plain textarea/CM6 chat input.

Completion Handler Changes

Current flow (simplified):

user message → resolve model → one completion → one assistant message

New flow:

user message → parse @mentions → resolve target model(s) → fan out completions → N assistant messages
// In completion.go, after persisting user message:

// 1. Load channel model roster
roster, _ := h.stores.GetModels(ctx, channel.ID)

// 2. Parse mentions
mentions := mentions.Parse(req.Content, roster)

// 3. Determine target models
var targets []models.ChannelModel
if len(mentions) > 0 {
    // Deduplicate resolved mentions
    seen := map[string]bool{}
    for _, m := range mentions {
        if m.Resolved != nil && !seen[m.Resolved.ID] {
            targets = append(targets, *m.Resolved)
            seen[m.Resolved.ID] = true
        }
    }
}
if len(targets) == 0 {
    // No mentions or none resolved → use default channel model (current behavior)
    targets = []models.ChannelModel{defaultModelFromRequest(req, channel, roster)}
}

// 4. Fan out: one completion per target model
for _, target := range targets {
    // Each gets its own assistant message with model attribution
    go h.completeForModel(ctx, c, req, channel, messages, target)
}

Sequential vs parallel: Start with sequential fan-out (simpler error handling, predictable message ordering). Parallel is a future optimization — the bottleneck is provider latency, not local compute.

Streaming: Each model's response streams independently. Frontend receives SSE events tagged with model info:

{"event": "delta", "model": "claude-3-opus", "model_display": "Claude 3 Opus", "content": "..."}

Frontend renders each model's response in a separate message bubble with a model attribution label.

Multi-model Channel UI

"Add model" button in the model selector area (chat header):

┌──────────────────────────────────────────────────────┐
│  Channel: Project Discussion                          │
│  Models: [Claude 3 Opus ✕] [GPT-4 ✕]  [+ Add model] │
└──────────────────────────────────────────────────────┘
  • Model pills show display_name, click to remove.
  • [+ Add model] opens a dropdown of available models (filtered by user's accessible provider configs), with a "Display name" input field.
  • Default model indicated with a subtle star/highlight (the one that responds without @mention).
  • Click a model pill to set it as default.

API endpoints:

GET    /api/v1/channels/:id/models          — list channel models
POST   /api/v1/channels/:id/models          — add model to channel
DELETE /api/v1/channels/:id/models/:modelId  — remove model
PATCH  /api/v1/channels/:id/models/:modelId  — update display_name, set default, system_prompt

These are mostly wrappers around the existing SetModel/GetModels store methods, with additional CRUD.

Message Attribution

Assistant messages from multi-model channels get a visual attribution:

┌─────────────────────────────────────────┐
│ Claude 3 Opus                           │
│ I think the recursive approach is...    │
├─────────────────────────────────────────┤
│ GPT-4                                   │
│ I'd suggest an iterative solution...    │
└─────────────────────────────────────────┘

The model field on messages already stores the model ID. Frontend renders a label above multi-model assistant messages using the display_name from the channel model roster.

Files Changed (Phase 2)

New files:

  • server/mentions/parser.go — @mention parser
  • server/mentions/parser_test.go — parser unit tests
  • server/handlers/channel_models.go — channel model CRUD endpoints

Modified files:

  • server/handlers/completion.go — fan-out logic, mention parsing integration
  • server/handlers/channels.go — wire channel model routes
  • server/store/interfaces.go — extend ChannelStore with DeleteModel, UpdateModel (if not already present)
  • server/store/postgres/channel.go — additional model CRUD
  • server/store/sqlite/channel.go — same
  • src/js/chat.js — model pills UI, @mention autocomplete, multi-model message rendering
  • src/js/api.js — channel model API methods
  • src/css/style.css — model pill styling, attribution labels

Checklist (Phase 2)

  • Mention parser: mentions.Parse() with case-insensitive resolution
  • Parser tests: edge cases (no mentions, unresolved, multiple, overlapping names)
  • Channel model CRUD handlers (4 endpoints)
  • Store: DeleteModel, UpdateModel for both dialects
  • Completion handler: mention extraction → target resolution → fan-out
  • SSE streaming: model attribution in delta events
  • Frontend: model pills in chat header
  • Frontend: add/remove model UI
  • Frontend: @mention autocomplete in chat input
  • Frontend: model attribution labels on assistant messages
  • Frontend: multi-stream rendering (sequential responses, distinct bubbles)
  • Integration tests: multi-model completion fan-out
  • Integration tests: channel model CRUD
  • Backward compat: channels with no channel_models rows work exactly as today

Phase 3 — Email Transport + Notification Preferences

User Notification Preferences

-- Postgres: 008_v0200_notification_prefs.sql
CREATE TABLE IF NOT EXISTS notification_preferences (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    type        VARCHAR(50) NOT NULL,           -- notification type or '*' for default
    in_app      BOOLEAN DEFAULT true,
    email       BOOLEAN DEFAULT false,
    UNIQUE(user_id, type)
);
-- SQLite: 007_v0200_notification_prefs.sql
CREATE TABLE IF NOT EXISTS notification_preferences (
    id          TEXT PRIMARY KEY,
    user_id     TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    type        TEXT NOT NULL,
    in_app      INTEGER DEFAULT 1,
    email       INTEGER DEFAULT 0,
    UNIQUE(user_id, type)
);

Resolution chain: Specific type preference → user's * default → system default (in_app=true, email=false).

SMTP Configuration

Admin settings (stored in platform_settings JSONB, same pattern as existing admin config):

{
    "notifications": {
        "email_enabled": false,
        "smtp_host": "",
        "smtp_port": 587,
        "smtp_user": "",
        "smtp_password": "",            // encrypted via vault
        "smtp_from": "noreply@chat.example.com",
        "smtp_tls": true,
        "instance_name": "Chat Switchboard",
        "digest_enabled": false,
        "digest_interval": "daily"      // "hourly" | "daily" | "weekly"
    }
}

Implementation: server/notifications/email.go — wraps net/smtp with TLS support. Template rendering via Go html/template (no external dependency). Two templates per notification type: HTML + plaintext fallback.

Email Service

// server/notifications/email.go
type EmailTransport struct {
    config  SMTPConfig
    vault   *crypto.KeyResolver // decrypt SMTP password
}

func (t *EmailTransport) Send(ctx context.Context, to, subject, htmlBody, textBody string) error

Notification Service Updates

The Service.Notify() method gains a preference check:

func (s *Service) Notify(ctx context.Context, n *models.Notification) error {
    prefs := s.resolvePrefs(ctx, n.UserID, n.Type)

    if prefs.InApp {
        s.store.Create(ctx, n)
        s.hub.SendToUser(n.UserID, ...)
    }

    if prefs.Email && s.email != nil {
        user, _ := s.stores.GetUser(ctx, n.UserID)
        if user.Email != "" {
            s.email.Send(ctx, user.Email, n.Title, renderHTML(n), renderText(n))
        }
    }

    return nil
}

Digest Mode

Low-priority notifications (e.g. grant.changed) can batch into a periodic email digest:

  • Background goroutine checks on interval (hourly/daily).
  • Queries notifications WHERE is_read = false AND created_at > last_digest_at.
  • Groups by user, renders a single digest email per user.
  • Marks digested notifications with a digested_at timestamp (add column in this migration).

Deferred if complexity is too high for v0.20.0. The core value is in-app + individual email. Digest is nice-to-have.

User Preferences UI

Settings → Notifications:

┌────────────────────────────────────────────────┐
│  Notification Preferences                       │
├────────────────────────────────────────────────┤
│  Type                    In-App    Email        │
│  ─────────────────────   ───────   ─────       │
│  Model fallback          [✓]       [ ]         │
│  KB processing           [✓]       [✓]         │
│  Group membership        [✓]       [ ]         │
│  Default (all others)    [✓]       [ ]         │
└────────────────────────────────────────────────┘

Admin Settings UI

Admin panel → System → Notifications:

  • Enable/disable email transport globally.
  • SMTP configuration form (host, port, user, password, from address, TLS toggle).
  • Test email button (sends to current admin's email).
  • Digest toggle + interval selector.

Files Changed (Phase 3)

New files:

  • server/notifications/email.go — SMTP transport
  • server/notifications/templates.go — HTML/text email templates
  • server/notifications/digest.go — digest aggregation (if not deferred)
  • server/store/postgres/notification_prefs.go — preference store
  • server/store/sqlite/notification_prefs.go — same
  • server/database/migrations/008_v0200_notification_prefs.sql
  • server/database/migrations/sqlite/007_v0200_notification_prefs.sql

Modified files:

  • server/notifications/service.go — preference resolution, email dispatch
  • server/store/interfaces.goNotificationPreferenceStore
  • server/handlers/notifications.go — preference CRUD endpoints
  • server/handlers/admin.go — SMTP config in admin settings
  • src/js/admin-handlers.js — notification admin UI
  • src/js/app.js — user notification preferences in settings modal

Checklist (Phase 3)

  • Migration: notification_preferences table (both dialects)
  • Store: NotificationPreferenceStore interface + implementations
  • Handlers: preference CRUD endpoints (GET/PUT per type)
  • Notification service: preference resolution chain
  • Email transport: SMTP with TLS, template rendering
  • Email templates: HTML + plaintext for each notification type
  • Admin UI: SMTP config + test email button
  • User UI: notification preferences in Settings
  • Optional: digest mode (background goroutine + batched email) — deferred
  • Tests: preference resolution chain
  • Tests: email transport (mock SMTP)

Cross-cutting Concerns

Retention / Cleanup

Notifications accumulate. Add a cleanup policy:

  • Default: retain 90 days, configurable via admin settings (notifications.retention_days).
  • Cleanup: background goroutine (daily) deletes WHERE created_at < NOW() - retention.
  • Same pattern as compaction scanner — register in server/main.go startup.

Notification Count Performance

The unread badge polls on app init, then stays current via WebSocket. No periodic polling. Badge syncs across tabs via notification.read event (one tab marks read → other tabs update badge).

SQLite Considerations

  • No LISTEN/NOTIFY — WebSocket push uses Hub.SendToUser() directly (already in-process, no PG dependency).
  • datetime('now') for created_at (standard SQLite pattern).
  • INTEGER for booleans (standard).

Mobile

  • Notification bell fits in mobile header (replace one of the existing icons or use hamburger menu).
  • Notification dropdown becomes full-screen overlay on mobile (same pattern as other dropdowns).
  • @mention autocomplete: same touch-friendly dropdown pattern as wikilink autocomplete.

Security

  • Notifications are user-scoped. No cross-user access via API (every query includes user_id from auth context).
  • SMTP password encrypted at rest via vault (same pattern as API keys in v0.9.4).
  • Email transport validates smtp_host against SSRF allowlist (same pattern as url_fetch).
  • @mention parsing sanitized — no injection via display names (names are alphanumeric + hyphens only, validated on channel_models write).

Dependency Map

Phase 1: Notifications Core
    ├── notifications table (migration)
    ├── NotificationStore (postgres + sqlite)
    ├── Notification Service (create + dispatch)
    ├── EventBus route (notification.new)
    ├── API endpoints (5)
    ├── Frontend: bell + dropdown + panel
    └── Wire: KB, groups, resolver → service

Phase 2: @mention + Multi-model        ← can start in parallel with Phase 1
    ├── mentions/parser.go
    ├── channel_models CRUD endpoints   ← table already exists
    ├── completion handler fan-out
    ├── Frontend: model pills + autocomplete
    └── Frontend: multi-stream rendering

Phase 3: Email + Preferences           ← depends on Phase 1
    ├── notification_preferences table (migration)
    ├── SMTP transport
    ├── Email templates
    ├── Preference resolution in Service
    └── Admin + User settings UI

Phases 1 and 2 have no code-level dependency on each other — they touch different files and can be developed in parallel or in either order. Phase 3 strictly depends on Phase 1 (extends the notification service).


Version Bump

  • VERSION: 0.19.20.20.0 after Phase 2 merge (or after Phase 1 if shipping incrementally as 0.20.0-rc1).
  • Changelog entry after each phase merge.
  • No breaking API changes — all additions.

What This Unblocks

  • v0.23.0 Multi-Participant Channels: Notifications become the backbone for participant join/leave, new message alerts, and assignment queue events.
  • v0.25.0 Workflow Engine: Stage transition notifications, assignment notifications, completion webhooks all route through the notification service.
  • v0.22.0 Smart Routing: Multi-model channels demonstrate the fan-out pattern that routing policies will generalize.