30 KiB
DESIGN-0.20.0 — Notifications + @mention Routing + Multi-model
Status: Complete (Phases 1–3 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:
-
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).
-
@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_modelstable andChannelModelstore 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 (onlyis_readflips). typeis 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_idenable click-to-navigate. Frontend maps these to routes (e.g.resource_type=channel→selectChat(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) whenunreadCount > 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 serviceserver/handlers/notifications.go— API handlersserver/store/postgres/notification.go— Postgres storeserver/store/sqlite/notification.go— SQLite storeserver/models/models_notification.go— model structserver/database/migrations/007_v0200_notifications.sql— Postgres migrationserver/database/migrations/sqlite/006_v0200_notifications.sql— SQLite migrationsrc/js/notifications.js— frontend notification UI
Modified files:
server/store/interfaces.go— addNotificationStoretoStoresserver/events/types.go— addnotification.*routesserver/main.go(orserver/routes.go) — wire notification handler + serviceserver/handlers/knowledge_bases.go— emitkb.ready/kb.errornotificationsserver/handlers/groups.go— emitgrant.changednotificationsserver/capabilities/resolver.go— emitrole.fallbacknotification (subscribe to existing EventBus event)src/js/app.js— init notification module, add bell to headersrc/js/events.js— addnotification.*to known labels (documentation only, wildcard already works)src/css/style.css— notification badge, dropdown, panel stylesindex.html— notification bell element in header- Integration tests — notification CRUD + WebSocket delivery
Checklist (Phase 1)
- Migration:
notificationstable (Postgres + SQLite) - Model:
Notificationstruct - Store:
NotificationStoreinterface + both implementations - Service:
notifications.ServicewithNotify()/NotifyMany() - Handlers: 5 notification endpoints
- EventBus:
notification.new+notification.readin route table - Wire: notification service into knowledge_bases, groups, capabilities/resolver
- Frontend:
notifications.jsmodule - 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:
- Users can add models to a channel (up to N, configurable, default 5).
- Messages can contain @mentions that route to specific models.
- The completion handler fans out to mentioned model(s), producing one assistant response per model.
- 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-20240229matches the full string, not just@claude. - Resolution: case-insensitive match against
display_namewith 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 parserserver/mentions/parser_test.go— parser unit testsserver/handlers/channel_models.go— channel model CRUD endpoints
Modified files:
server/handlers/completion.go— fan-out logic, mention parsing integrationserver/handlers/channels.go— wire channel model routesserver/store/interfaces.go— extendChannelStorewithDeleteModel,UpdateModel(if not already present)server/store/postgres/channel.go— additional model CRUDserver/store/sqlite/channel.go— samesrc/js/chat.js— model pills UI, @mention autocomplete, multi-model message renderingsrc/js/api.js— channel model API methodssrc/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,UpdateModelfor 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_modelsrows 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_attimestamp (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 transportserver/notifications/templates.go— HTML/text email templatesserver/notifications/digest.go— digest aggregation (if not deferred)server/store/postgres/notification_prefs.go— preference storeserver/store/sqlite/notification_prefs.go— sameserver/database/migrations/008_v0200_notification_prefs.sqlserver/database/migrations/sqlite/007_v0200_notification_prefs.sql
Modified files:
server/notifications/service.go— preference resolution, email dispatchserver/store/interfaces.go—NotificationPreferenceStoreserver/handlers/notifications.go— preference CRUD endpointsserver/handlers/admin.go— SMTP config in admin settingssrc/js/admin-handlers.js— notification admin UIsrc/js/app.js— user notification preferences in settings modal
Checklist (Phase 3)
- Migration:
notification_preferencestable (both dialects) - Store:
NotificationPreferenceStoreinterface + 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.gostartup.
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 usesHub.SendToUser()directly (already in-process, no PG dependency). datetime('now')forcreated_at(standard SQLite pattern).INTEGERfor 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_idfrom auth context). - SMTP password encrypted at rest via vault (same pattern as API keys in v0.9.4).
- Email transport validates
smtp_hostagainst SSRF allowlist (same pattern asurl_fetch). - @mention parsing sanitized — no injection via display names (names are alphanumeric + hyphens only, validated on
channel_modelswrite).
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.2→0.20.0after Phase 2 merge (or after Phase 1 if shipping incrementally as0.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.