diff --git a/VERSION b/VERSION
index 6ca8061..022a033 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.27.5
\ No newline at end of file
+0.28.0
\ No newline at end of file
diff --git a/docs/ICD-API.md b/docs/ICD-API.md
deleted file mode 100644
index e417a89..0000000
--- a/docs/ICD-API.md
+++ /dev/null
@@ -1,2621 +0,0 @@
-# ICD-API — Chat Switchboard Backend API Contract
-
-**Version:** 0.23.0
-**Updated:** 2026-03-04
-**Audience:** Frontend developers, integrators, API consumers
-
-> **Organization principle:** Each section is one domain — the complete story.
-> Every endpoint that touches Knowledge Bases is in §5, every endpoint that
-> touches Personas is in §4, etc. Admin and user endpoints live together in
-> their domain section. Cross-cutting platform admin (user management, global
-> settings, audit) lives in §16.
-
----
-
-## Table of Contents
-
-1. [Conventions](#1-conventions)
-2. [Auth](#2-auth)
-3. [Channels & Conversations](#3-channels--conversations)
-4. [Personas](#4-personas)
-5. [Knowledge Bases](#5-knowledge-bases)
-6. [Notes](#6-notes)
-7. [Workspaces & Git](#7-workspaces--git)
-8. [Projects](#8-projects)
-9. [Memory](#9-memory)
-10. [Providers & Routing](#10-providers--routing)
-11. [Models & Preferences](#11-models--preferences)
-12. [Notifications](#12-notifications)
-13. [Extensions](#13-extensions)
-14. [User Profile & Settings](#14-user-profile--settings)
-15. [Teams & Access Control](#15-teams--access-control)
-16. [Platform Administration](#16-platform-administration)
-17. [Export & Utility](#17-export--utility)
-17b. [Files & Storage](#17b-files--storage)
-18. [WebSocket Protocol](#18-websocket-protocol)
-19. [Appendix: Enums](#19-appendix-enums)
-
----
-
-## 1. Conventions
-
-### Base URL
-
-```
-{scheme}://{host}{BASE_PATH}/api/v1
-```
-
-`BASE_PATH` is empty by default. In path-routed K8s deployments (e.g.
-`/staging/`), all routes shift accordingly. The frontend reads `BASE_PATH`
-from a `` tag injected by the Go template engine.
-
-### Authentication
-
-JWT bearer tokens. Every request to a `protected` or `admin` route requires:
-
-```
-Authorization: Bearer {access_token}
-```
-
-**Access token:** HS256 JWT, 15-minute TTL. Claims: `user_id`, `email`,
-`role`, `exp`, `iat`, `jti`.
-
-**Refresh token:** Opaque, DB-stored, 7-day TTL. Used to obtain new
-access tokens without re-login.
-
-**WebSocket fallback:** `?token={access_token}` query parameter when
-the `Authorization` header isn't available.
-
-**Cookie sync:** On every token save/refresh, the frontend writes
-`sb_token` as a cookie. Go template page routes read this cookie via
-`AuthOrRedirect` middleware for server-rendered surfaces.
-
-### Authorization Tiers
-
-| Tier | Middleware | Who |
-|------|-----------|-----|
-| Public | none | Anyone (health, login, register, public settings) |
-| Authenticated | `Auth()` | Any logged-in user |
-| Team Admin | `RequireTeamAdmin()` | Admin of the specific team |
-| Platform Admin | `RequireAdmin()` | Users with `role = "admin"` |
-
-### Error Envelope
-
-All errors return:
-
-```json
-{ "error": "human-readable message" }
-```
-
-Standard HTTP status codes: 400 (bad request), 401 (not authenticated),
-403 (not authorized), 404 (not found), 409 (conflict), 500 (server error),
-502 (upstream provider failure).
-
-### Pagination Envelope
-
-Endpoints that paginate return:
-
-```json
-{
- "data": [...],
- "page": 1,
- "per_page": 50,
- "total": 142,
- "total_pages": 3
-}
-```
-
-Query params: `?page=1&per_page=50`. Not all list endpoints paginate —
-some return `{ "data": [...] }` or a bare array in a named key.
-
-### ID Format
-
-All resource IDs are UUIDv4 strings, generated application-side via
-`store.NewID()` (Go `uuid.New()`). This ensures compatibility across
-both Postgres and SQLite backends.
-
-### Timestamps
-
-ISO 8601 with timezone: `"2025-06-15T14:30:00Z"`. Stored as
-`TIMESTAMPTZ` (Postgres) or `TEXT` (SQLite).
-
----
-
-## 2. Auth
-
-Four endpoints. No token required for any of them.
-
-### Register
-
-```
-POST /auth/register
-```
-
-```json
-{
- "username": "jdoe",
- "password": "...",
- "email": "jdoe@example.com", // optional
- "display_name": "Jane Doe" // optional
-}
-```
-
-Gated by `allow_registration` policy. Returns same shape as Login.
-
-### Login
-
-```
-POST /auth/login
-```
-
-```json
-{ "username": "jdoe", "password": "..." }
-```
-
-**Response:**
-
-```json
-{
- "access_token": "eyJ...",
- "refresh_token": "opaque-string",
- "user": {
- "id": "uuid",
- "username": "jdoe",
- "email": "jdoe@example.com",
- "display_name": "Jane Doe",
- "role": "user",
- "avatar_url": "/api/v1/profile/avatar?v=1234",
- "created_at": "...",
- "last_login": "..."
- }
-}
-```
-
-### Refresh
-
-```
-POST /auth/refresh
-```
-
-```json
-{ "refresh_token": "opaque-string" }
-```
-
-Returns new `access_token` and `refresh_token`. Old refresh token is
-invalidated (rotation).
-
-### Logout
-
-```
-POST /auth/logout
-```
-
-```json
-{ "refresh_token": "opaque-string" }
-```
-
-Revokes the refresh token. Returns `{ "message": "logged out" }`.
-
----
-
-## 3. Channels & Conversations
-
-The **channel** is the universal conversation container. Messages,
-tool activity, attachments, and KB bindings all hang off a channel.
-Channels support multiple participants — users, personas, and
-sessions — making them the foundation for collaborative and
-multi-model workflows.
-
-### 3.1 Channel CRUD
-
-**List channels** — paginated, sorted by last activity.
-
-```
-GET /channels?page=1&per_page=50
-```
-
-Returns pagination envelope. Each channel:
-
-```json
-{
- "id": "uuid",
- "title": "My Chat",
- "type": "direct|group|workflow",
- "description": "",
- "model": "claude-sonnet-4-20250514",
- "provider_config_id": "uuid|null",
- "system_prompt": "",
- "project_id": "uuid|null",
- "folder": "string|null",
- "tags": ["tag1", "tag2"],
- "participant_count": 1,
- "created_at": "...",
- "updated_at": "..."
-}
-```
-
-**Channel types:**
-
-| Type | Description |
-|------|-------------|
-| `direct` | Single-user chat (default, backward compatible) |
-| `group` | Multi-user/multi-model collaborative channel |
-| `workflow` | Staged channel driven by workflow definitions |
-
-**Create channel:**
-
-```
-POST /channels
-```
-
-```json
-{
- "title": "New Chat",
- "type": "direct",
- "description": "",
- "model": "claude-sonnet-4-20250514",
- "provider_config_id": "uuid",
- "system_prompt": "",
- "folder": null,
- "tags": []
-}
-```
-
-On create, the authenticated user is automatically added as a
-participant with `role: "owner"`. If `model` and `provider_config_id`
-are provided, the channel model roster (`channel_models`) is
-auto-populated with an initial entry.
-
-**Get channel:**
-
-```
-GET /channels/:id
-```
-
-Returns single channel object.
-
-**Update channel:**
-
-```
-PUT /channels/:id
-```
-
-Accepts partial updates — only fields present in the body are changed.
-Same field set as create.
-
-**Delete channel:**
-
-```
-DELETE /channels/:id
-```
-
-Cascades: deletes messages, attachments, channel_models, channel_kbs.
-
-### 3.2 Message Tree
-
-Switchboard uses a **tree** model for messages, not a flat list. Each
-message has a `parent_id` (NULL for root). Editing creates a sibling
-branch; regeneration creates an alternative sibling. The **cursor**
-tracks which child is "active" at each branch point.
-
-**Get active path** — the primary endpoint for loading chat history:
-
-```
-GET /channels/:id/path
-```
-
-Returns the messages along the active branch from root to leaf,
-following the cursor at each fork. This is what the UI renders.
-
-```json
-{
- "messages": [
- {
- "id": "uuid",
- "channel_id": "uuid",
- "parent_id": "uuid|null",
- "role": "user|assistant|system|tool",
- "content": "...",
- "model": "claude-sonnet-4-20250514|null",
- "provider_config_id": "uuid|null",
- "participant_id": "uuid|null",
- "participant_type": "user|persona|session|null",
- "thinking": "...|null",
- "tool_calls": [...],
- "tool_results": [...],
- "attachments": [...],
- "has_siblings": true,
- "sibling_index": 0,
- "sibling_count": 2,
- "created_at": "..."
- }
- ]
-}
-```
-
-`participant_id` and `participant_type` identify who authored the
-message. For `user` messages, this is the authenticated user. For
-`assistant` messages, this is the persona (if set) or null for raw
-model responses. Existing messages without participants return null.
-
-**List all messages** — includes all branches, flat:
-
-```
-GET /channels/:id/messages
-```
-
-Legacy/debug endpoint. Returns every message in the channel regardless
-of branch. Not used by the standard frontend.
-
-**Create message:**
-
-```
-POST /channels/:id/messages
-```
-
-```json
-{
- "role": "user",
- "content": "Hello",
- "parent_id": "uuid|null",
- "attachments": ["attachment-uuid-1"]
-}
-```
-
-**Edit message** — creates a sibling at the same level:
-
-```
-POST /channels/:id/messages/:msgId/edit
-```
-
-```json
-{ "content": "Revised question" }
-```
-
-Creates a new message with the same `parent_id` as the original.
-Automatically updates the cursor to point to the new sibling.
-
-**Regenerate** — creates an alternative assistant response:
-
-```
-POST /channels/:id/messages/:msgId/regenerate
-```
-
-```json
-{
- "model": "claude-sonnet-4-20250514",
- "provider_config_id": "uuid"
-}
-```
-
-Creates a new empty assistant message as a sibling of `msgId`,
-then streams the completion into it. The cursor is updated.
-
-**Update cursor** — switch to a different branch:
-
-```
-PUT /channels/:id/cursor
-```
-
-```json
-{
- "message_id": "uuid",
- "child_id": "uuid"
-}
-```
-
-Sets which child is active at the `message_id` branch point.
-Subsequent `GET /channels/:id/path` will follow the new branch.
-
-**List siblings** — all children of a message's parent:
-
-```
-GET /channels/:id/messages/:msgId/siblings
-```
-
-Returns `{ "siblings": [...] }` with abbreviated message objects
-(id, role, content preview, created_at).
-
-### 3.3 Completions & Streaming
-
-The single most complex endpoint. Sends a message to an LLM provider
-and streams the response via Server-Sent Events.
-
-```
-POST /chat/completions
-```
-
-**Request:**
-
-```json
-{
- "channel_id": "uuid",
- "message": "User's message text",
- "model": "claude-sonnet-4-20250514",
- "provider_config_id": "uuid",
- "persona_id": "uuid|null",
- "parent_id": "uuid|null",
- "tools_enabled": true,
- "tool_ids": ["web_search", "calculator"],
- "attachments": ["attachment-uuid"],
- "extra_body": {}
-}
-```
-
-`channel_id` is required.
-
-`extra_body` is a freeform JSON object merged into the provider request
-(provider-specific parameters like `temperature`, `reasoning`, etc.).
-
-**Participant scoping:** The requesting user must be a participant in
-the channel. Messages are attributed to the authenticated user (or
-session). In `group` channels, all participants see all messages in
-real time via WebSocket.
-
-**Persona resolution:** The primary completion path. If `persona_id`
-is set (explicitly or via `@mention` resolution from message content),
-the handler loads the persona's system prompt, model, provider config,
-and KB bindings. Per-message `model`/`provider_config_id` override the
-persona's defaults. In multi-persona channels, `@mention` in the
-message content is parsed to resolve the target persona from the
-channel's participant list.
-
-**Routing resolution:** After config resolution, the routing policy
-evaluator (`evaluateRouting()`) may redirect to a different provider
-based on active policies (see §10.4). The winning provider config's
-credentials are loaded and used for the actual API call.
-
-**SSE Stream Format:**
-
-The response is `Content-Type: text/event-stream`. Events:
-
-```
-data: {"content":"Hello"}
-
-data: {"content":" world"}
-
-data: {"reasoning":"Let me think..."}
-
-event: tool_use
-data: {"id":"call_1","name":"web_search","input":{"query":"..."}}
-
-event: tool_result
-data: {"id":"call_1","content":"Search results..."}
-
-data: {"content":"Based on the search..."}
-
-event: finish
-data: {"message_id":"uuid","model":"claude-sonnet-4-20250514","usage":{"input_tokens":150,"output_tokens":42}}
-
-data: [DONE]
-```
-
-| Event | `event:` field | Payload |
-|-------|---------------|---------|
-| Content delta | _(unnamed)_ | `{ "content": "text" }` |
-| Reasoning delta | _(unnamed)_ | `{ "reasoning": "text" }` |
-| Tool invocation | `tool_use` | `{ "id", "name", "input" }` |
-| Tool result | `tool_result` | `{ "id", "content" }` |
-| Stream complete | `finish` | `{ "message_id", "model", "usage" }` |
-| End sentinel | _(unnamed)_ | `[DONE]` (literal string) |
-| Error mid-stream | `error` | `{ "error": "message" }` |
-
-**Tool cycle:** Tool use and tool result events can repeat up to
-`max_tool_iterations` (configured server-side). The handler executes
-tools, feeds results back to the model, and continues streaming.
-
-**Response headers:**
-
-```
-X-Switchboard-Provider: providerID/configID
-X-Switchboard-Route: policy-name (when routing policy is active)
-```
-
-**List available tools:**
-
-```
-GET /tools
-```
-
-Returns `{ "tools": [...] }` where each tool has `name`, `description`,
-`parameters` (JSON Schema), and `category`.
-
-### 3.4 Multi-Model Roster (Raw Access)
-
-The primary way to add AI to a channel is by adding a **persona as a
-participant** (§3.7). Adding a persona automatically populates the
-channel model roster with the persona's underlying model.
-
-For the rare case where a user needs raw model access without a
-persona wrapper, the `channel_models` table provides direct model
-roster management. This is the 1% escape hatch — most users should
-never need it.
-
-```
-GET /channels/:id/models → { "models": [...] }
-POST /channels/:id/models ← { "model": "...", "provider_config_id": "...", "display_name": "..." }
-PATCH /channels/:id/models/:modelId ← { "display_name": "..." }
-DELETE /channels/:id/models/:modelId
-```
-
-Each roster entry:
-
-```json
-{
- "id": "uuid",
- "channel_id": "uuid",
- "model": "claude-sonnet-4-20250514",
- "provider_config_id": "uuid",
- "display_name": "Claude",
- "is_default": true,
- "persona_id": "uuid|null",
- "created_at": "..."
-}
-```
-
-`persona_id`: set when this roster entry was auto-created by adding a
-persona participant. Null for manually-added raw models.
-
-**`@mention` resolution:** When a user types `@` in a channel, the
-autocomplete shows persona participants first (by display name), then
-raw model roster entries. The target of a `@mention` is always
-resolved to a specific `provider_config_id` + `model` pair — never
-an ambiguous bare model name.
-
-### 3.5 Channel Utilities
-
-**Summarize channel** (compaction):
-
-```
-POST /channels/:id/summarize
-```
-
-Triggers conversation summarization via the utility model role.
-Returns `{ "message": "summarized", "summary_id": "uuid" }`.
-
-**Generate title:**
-
-```
-POST /channels/:id/generate-title
-```
-
-Uses the utility model to auto-generate a title from the first
-exchange. Returns `{ "title": "Generated Title" }`.
-
-### 3.6 Files
-
-All binary content associated with channels — user uploads, tool-
-generated artifacts, system files — lives in the unified `files`
-table backed by the ObjectStore (see §X). The old `attachments`
-table and routes are removed.
-
-**Upload (user):**
-
-```
-POST /channels/:id/files
-Content-Type: multipart/form-data
-```
-
-Field: `file`. Sets `origin: "user_upload"`. Returns the file object.
-
-**Create (tool output):**
-
-```
-POST /files
-```
-
-```json
-{
- "channel_id": "uuid",
- "message_id": "uuid",
- "filename": "generated_image.png",
- "content_type": "image/png",
- "display_hint": "inline",
- "metadata": { "tool_name": "image_generation" }
-}
-```
-
-Body: multipart `file` field or base64 `data` field.
-Sets `origin: "tool_output"`. Returns file object.
-
-**List by channel:**
-
-```
-GET /channels/:id/files?origin=user_upload
-```
-
-Returns `{ "files": [...] }`. Filterable by `origin`, `content_type`.
-
-**List by message:**
-
-```
-GET /messages/:id/files → { "files": [...] }
-```
-
-How the frontend discovers generated artifacts to render inline.
-
-**List by user (file manager):**
-
-```
-GET /files?page=1&per_page=50 → { "files": [...], "total": N }
-```
-
-All files owned by the authenticated user, paginated.
-
-**Get metadata:**
-
-```
-GET /files/:id
-```
-
-```json
-{
- "id": "uuid",
- "channel_id": "uuid",
- "message_id": "uuid|null",
- "user_id": "uuid",
- "origin": "user_upload|tool_output|system",
- "filename": "report.pdf",
- "content_type": "application/pdf",
- "size_bytes": 1048576,
- "display_hint": "inline|download|thumbnail",
- "extracted_text": "...|null",
- "metadata": {},
- "created_at": "...",
- "updated_at": "..."
-}
-```
-
-**Download:**
-
-```
-GET /files/:id/download
-```
-
-Returns the file with appropriate `Content-Type` and
-`Content-Disposition` headers.
-
-**Thumbnail:**
-
-```
-GET /files/:id/thumbnail
-```
-
-**Delete:**
-
-```
-DELETE /files/:id
-```
-
-Deletes metadata + blob. Channel deletion cascades via FK +
-`DeletePrefix`.
-
-### 3.7 Channel Participants
-
-The participant system is how users and AI interact in a channel.
-**Personas are the primary way to add AI** — adding a persona as a
-participant brings its full identity (name, avatar, system prompt,
-model, provider config, KB bindings) into the channel. This is the
-99% path. Raw model access without a persona is available via the
-model roster (§3.4) for power users.
-
-Every channel has at least one participant (the owner). `direct`
-channels have exactly one user participant and optionally one or more
-persona participants. `group` and `workflow` channels support multiple
-participants of mixed types.
-
-**List participants:**
-
-```
-GET /channels/:id/participants
-```
-
-Returns `{ "participants": [...] }`:
-
-```json
-{
- "id": "uuid",
- "channel_id": "uuid",
- "participant_type": "user|persona|session",
- "participant_id": "uuid",
- "role": "owner|member|observer",
- "display_name": "Jane Doe",
- "avatar_url": "...|null",
- "joined_at": "..."
-}
-```
-
-**Participant types:**
-
-| Type | Description |
-|------|-------------|
-| `user` | Authenticated user. `participant_id` = `users.id` |
-| `persona` | AI persona added to channel. `participant_id` = `personas.id`. Brings model, system prompt, KB bindings |
-| `session` | Anonymous/session participant (workflow intake). `participant_id` = session token |
-
-**Participant roles:**
-
-| Role | Capabilities |
-|------|-------------|
-| `owner` | Full control: add/remove participants, delete channel, all member capabilities |
-| `member` | Send messages, trigger completions, view history |
-| `observer` | Read-only: view messages, no send |
-
-**Add participant:**
-
-```
-POST /channels/:id/participants
-```
-
-```json
-{
- "participant_type": "user|persona",
- "participant_id": "uuid",
- "role": "member"
-}
-```
-
-Requires `owner` role in the channel. Adding a `persona` participant:
-- Makes the persona available as an `@mention` target
-- Adds the persona's model to the channel model roster automatically
-- Scopes the persona's KB bindings into the channel's KB resolution chain
-- The persona's avatar and display name appear in the participant list
-
-**Update participant role:**
-
-```
-PATCH /channels/:id/participants/:participantId
-```
-
-```json
-{ "role": "observer" }
-```
-
-**Remove participant:**
-
-```
-DELETE /channels/:id/participants/:participantId
-```
-
-Cannot remove the last owner. Removing a `persona` participant also
-removes its auto-created model roster entry.
-
-**Completion routing:** When `@PersonaName` appears in message
-content, the completion handler resolves to that persona's model,
-provider config, and system prompt. When no `@mention` is present in
-a multi-persona channel, the channel's default persona (first added)
-handles the response. The `persona_id` field in the completion
-request (§3.3) can also be set explicitly to override `@mention`
-resolution.
-
-**Backward compatibility:** Existing `direct` channels that predate
-the participant system are auto-migrated on first access — a single
-`user` participant with `role: "owner"` is created from the channel's
-legacy `user_id` column.
-
-### 3.8 Presence
-
-Real-time presence for channel participants. Delivered via WebSocket
-to all subscribers of the channel room.
-
-**Who's here:**
-
-```
-GET /channels/:id/presence
-```
-
-Returns `{ "participants": [{ "participant_id", "display_name", "status", "last_seen" }] }`.
-
-`status`: `online`, `idle`, `offline`.
-
-**Typing indicators** are sent via WebSocket (see §18.4). The typing
-event payload includes `participant_id` and `participant_type` so the
-UI can distinguish between human and AI typing.
-
----
-
-## 4. Personas
-
-A **Persona** is the unit of access control and AI identity: system
-prompt + model + provider config + KB bindings + grant scoping. Users
-interact with Personas, not raw provider configs. Admins curate which
-Personas are available; team admins create team-scoped Personas.
-
-**Model inheritance:** A Persona inherits all properties of its
-underlying model. This includes context window size, max output
-tokens, supported capabilities (thinking, tools, vision, streaming),
-input/output pricing, and provider status. When the frontend displays
-a Persona, it resolves the underlying model's capabilities and shows
-the same pills/badges (e.g. thinking, tools, 200k context) that the
-raw model would show. The Persona adds identity (name, avatar, system
-prompt, KB bindings) on top of the model's capabilities — it never
-masks or reduces them.
-
-Three scopes, three route namespaces, one domain:
-
-| Scope | Routes | Who manages |
-|-------|--------|-------------|
-| Personal | `GET/POST/PUT/DELETE /personas` | The user |
-| Team | `GET/POST/PUT/DELETE /teams/:teamId/personas` | Team admin |
-| Global (admin) | `GET/POST/PUT/DELETE /admin/personas` | Platform admin |
-
-All three return the same Persona object shape:
-
-```json
-{
- "id": "uuid",
- "name": "Code Reviewer",
- "description": "Reviews code for bugs and style",
- "system_prompt": "You are a senior code reviewer...",
- "model": "claude-sonnet-4-20250514",
- "provider_config_id": "uuid|null",
- "scope": "personal|team|global",
- "owner_id": "uuid|null",
- "is_default": false,
- "is_active": true,
- "avatar_url": "/api/v1/personas/uuid/avatar?v=123",
- "inherited": {
- "context_window": 200000,
- "max_output_tokens": 8192,
- "supports_vision": true,
- "supports_tools": true,
- "supports_thinking": true,
- "supports_streaming": true,
- "input_price_per_m": 3.00,
- "output_price_per_m": 15.00,
- "provider_status": "healthy|degraded|down|null"
- },
- "created_at": "...",
- "updated_at": "..."
-}
-```
-
-The `inherited` block is populated by the backend from the resolved
-model capabilities at response time — it is not stored on the persona.
-If the underlying model's capabilities change (e.g. provider upgrades
-context window), the persona inherits the new values automatically.
-The frontend uses `inherited` to render capability pills and context
-size badges identical to the raw model.
-
-### 4.1 Personal Personas
-
-Gated by `allow_user_personas` policy.
-
-```
-GET /personas → { "personas": [...] }
-POST /personas ← { "name", "description", "system_prompt", "model", ... }
-PUT /personas/:id ← partial update
-DELETE /personas/:id
-```
-
-### 4.2 Team Personas
-
-```
-GET /teams/:teamId/personas → { "personas": [...] }
-POST /teams/:teamId/personas ← same shape
-PUT /teams/:teamId/personas/:id
-DELETE /teams/:teamId/personas/:id
-```
-
-### 4.3 Admin (Global) Personas
-
-```
-GET /admin/personas → { "personas": [...] }
-POST /admin/personas ← same shape
-PUT /admin/personas/:id
-DELETE /admin/personas/:id
-```
-
-### 4.4 Persona KB Bindings
-
-A Persona can have Knowledge Bases bound to it. When a channel uses
-that Persona, the bound KBs are automatically scoped into `kb_search`
-tool calls. Users never see or manage the underlying KBs directly —
-they talk to the Persona.
-
-**Get bindings:**
-
-```
-GET /personas/:id/knowledge-bases → { "data": [KB objects] }
-GET /teams/:teamId/personas/:id/knowledge-bases → { "data": [...] }
-GET /admin/personas/:id/knowledge-bases → { "data": [...] }
-```
-
-**Set bindings** (replace all):
-
-```
-PUT /personas/:id/knowledge-bases
-PUT /teams/:teamId/personas/:id/knowledge-bases
-PUT /admin/personas/:id/knowledge-bases
-```
-
-```json
-{
- "kb_ids": ["kb-uuid-1", "kb-uuid-2"],
- "auto_search": {
- "kb-uuid-1": true,
- "kb-uuid-2": false
- }
-}
-```
-
-`auto_search`: when `true`, the KB is always searched (prepended to
-context). When `false`, it's available via the `kb_search` tool but
-not auto-injected.
-
-### 4.5 Persona Avatars
-
-```
-POST /personas/:id/avatar ← multipart/form-data (field: "avatar")
-DELETE /personas/:id/avatar
-POST /admin/personas/:id/avatar
-DELETE /admin/personas/:id/avatar
-```
-
-### 4.6 Resource Grants
-
-See §15.3 for the grant system. Personas use grants to control who can
-see and use them beyond their base scope.
-
----
-
-## 5. Knowledge Bases
-
-The **Knowledge Base** (KB) is the RAG system: upload documents → chunk
-→ embed (pgvector / app-level cosine for SQLite) → search via
-`kb_search` tool during completions.
-
-### 5.1 KB CRUD
-
-```
-GET /knowledge-bases → { "data": [KB objects] }
-POST /knowledge-bases ← { "name", "description", "scope", "team_id" }
-GET /knowledge-bases/:id → KB object
-PUT /knowledge-bases/:id ← partial update
-DELETE /knowledge-bases/:id
-```
-
-KB object:
-
-```json
-{
- "id": "uuid",
- "name": "Product Docs",
- "description": "Internal product documentation",
- "scope": "personal|team|global",
- "owner_id": "uuid|null",
- "team_id": "uuid|null",
- "discoverable": true,
- "embedding_model": "text-embedding-3-small",
- "chunk_size": 512,
- "chunk_overlap": 50,
- "document_count": 12,
- "created_at": "...",
- "updated_at": "..."
-}
-```
-
-### 5.2 Documents
-
-**Upload:**
-
-```
-POST /knowledge-bases/:id/documents
-Content-Type: multipart/form-data
-```
-
-Field: `file`. Supported: PDF, DOCX, TXT, MD, HTML, CSV, XLSX, PPTX.
-Returns the document object with `status: "pending"`.
-
-**List:**
-
-```
-GET /knowledge-bases/:id/documents
-```
-
-Returns `{ "data": [document objects] }`.
-
-**Status** (poll during processing):
-
-```
-GET /knowledge-bases/:id/documents/:docId/status
-```
-
-Status progression: `pending → chunking → embedding → ready | error`.
-
-**Delete:**
-
-```
-DELETE /knowledge-bases/:id/documents/:docId
-```
-
-### 5.3 Search
-
-```
-POST /knowledge-bases/:id/search
-```
-
-```json
-{
- "query": "How do I configure SSO?",
- "limit": 5
-}
-```
-
-Returns `{ "results": [{ "content", "score", "document_id", "metadata" }] }`.
-
-### 5.4 Rebuild
-
-Re-chunks and re-embeds all documents:
-
-```
-POST /knowledge-bases/:id/rebuild
-```
-
-### 5.5 Channel KB Bindings
-
-A channel can have KBs explicitly bound to it (in addition to any KBs
-coming from the active Persona or parent Project).
-
-**Get:**
-
-```
-GET /channels/:id/knowledge-bases
-```
-
-Returns `{ "data": [ChannelKB objects] }`:
-
-```json
-{
- "kb_id": "uuid",
- "kb_name": "Product Docs",
- "enabled": true,
- "document_count": 12
-}
-```
-
-**Set** (replace all):
-
-```
-PUT /channels/:id/knowledge-bases
-```
-
-```json
-{ "kb_ids": ["kb-uuid-1", "kb-uuid-2"] }
-```
-
-### 5.6 Discoverable KBs
-
-The `discoverable` flag controls whether a KB appears in the user's KB
-picker. Non-discoverable KBs are hidden from direct access but still
-searchable through Persona bindings (enterprise KB mode).
-
-**List discoverable** (for KB picker UI):
-
-```
-GET /knowledge-bases-discoverable
-```
-
-Returns `{ "data": [KB objects] }` — only KBs the user can see
-(personal + team + discoverable global).
-
-**Toggle discoverability** (admin-enforced in handler):
-
-```
-PUT /knowledge-bases/:id/discoverable
-```
-
-```json
-{ "discoverable": false }
-```
-
-### 5.7 KB Resolution Chain
-
-When a completion fires, KBs are resolved in priority order:
-
-```
-Persona KBs → Project KBs → Channel KBs → Personal KBs
-```
-
-The `BuildKBHint` function assembles all applicable KBs into the
-system prompt, and the `kb_search` tool searches across all of them.
-
-### 5.8 Persona KB Bindings
-
-See §4.4. Persona-KB binding is the primary enterprise mechanism for
-controlled KB access.
-
----
-
-## 6. Notes
-
-**Obsidian-style** knowledge graph with `[[wikilinks]]`, backlinks,
-graph visualization, and folder organization. Notes are user-scoped
-(personal notebook). Future: channel-scoped notes for workflow
-artifacts.
-
-### 6.1 CRUD
-
-```
-GET /notes → paginated list of noteListItem
-POST /notes ← { "title", "content", "folder" }
-GET /notes/:id → full note object
-PUT /notes/:id ← { "title", "content", "folder" }
-DELETE /notes/:id
-```
-
-Note object:
-
-```json
-{
- "id": "uuid",
- "user_id": "uuid",
- "title": "Meeting Notes",
- "content": "# Meeting Notes\n\nDiscussed [[Project Alpha]]...",
- "folder": "work",
- "source_message_id": "uuid|null",
- "created_at": "...",
- "updated_at": "..."
-}
-```
-
-`source_message_id` links a note back to the chat message that
-created it (provenance for AI-generated notes).
-
-`noteListItem` is the same but with `content` truncated or omitted.
-
-### 6.2 Search
-
-**Full-text search:**
-
-```
-GET /notes/search?q=project+alpha
-```
-
-Uses `plainto_tsquery` (Postgres) or `LIKE` fallback (SQLite).
-Returns `{ "data": [searchResult objects] }` with match highlights.
-
-**Title search** (lightweight, for wikilink autocomplete):
-
-```
-GET /notes/search-titles?q=proj
-```
-
-Returns `{ "data": [{ "id", "title" }] }`.
-
-### 6.3 Graph
-
-**Full graph topology:**
-
-```
-GET /notes/graph
-```
-
-```json
-{
- "nodes": [{ "id": "uuid", "title": "Note Title" }],
- "edges": [{ "source": "uuid", "target": "uuid" }],
- "unresolved": [{ "source": "uuid", "target_title": "Missing Note" }]
-}
-```
-
-Edges are directed: source contains `[[target_title]]`. Unresolved
-edges have a `target_title` but no matching note (dangling wikilink).
-When a note with that title is created, the edge auto-resolves.
-
-**Backlinks** (who links to this note):
-
-```
-GET /notes/:id/backlinks
-```
-
-Returns `{ "data": [note objects] }` — all notes containing
-`[[this note's title]]`.
-
-### 6.4 Folders
-
-```
-GET /notes/folders
-```
-
-Returns `{ "folders": ["work", "personal", "archive"] }` — distinct
-folder values across all user notes.
-
-### 6.5 Bulk Operations
-
-```
-POST /notes/bulk-delete
-```
-
-```json
-{ "ids": ["uuid-1", "uuid-2"] }
-```
-
----
-
-## 7. Workspaces & Git
-
-The **Workspace** is a virtual filesystem for the editor surface.
-Each workspace has a root directory, file operations, optional Git
-integration, and full-text indexing for code search.
-
-### 7.1 Workspace CRUD
-
-```
-GET /workspaces → { "data": [...] }
-POST /workspaces ← { "name", "owner_type", "owner_id" }
-GET /workspaces/:id → workspace object
-PATCH /workspaces/:id ← partial update
-DELETE /workspaces/:id
-```
-
-Workspace object:
-
-```json
-{
- "id": "uuid",
- "name": "my-project",
- "owner_type": "user|project",
- "owner_id": "uuid",
- "status": "active|archived",
- "storage_bytes": 1048576,
- "file_count": 42,
- "created_at": "...",
- "updated_at": "..."
-}
-```
-
-### 7.2 File Operations
-
-All paths are relative to the workspace root.
-
-```
-GET /workspaces/:id/files?path=/ → { "files": [entries] }
-GET /workspaces/:id/files/read?path=/a.md → { "content": "...", "size": 123 }
-PUT /workspaces/:id/files/write ← { "path": "/a.md", "content": "..." }
-DELETE /workspaces/:id/files/delete ← { "path": "/a.md" }
-POST /workspaces/:id/files/mkdir ← { "path": "/src" }
-```
-
-File entry:
-
-```json
-{
- "name": "main.go",
- "path": "/src/main.go",
- "is_dir": false,
- "size": 4096,
- "modified": "..."
-}
-```
-
-### 7.3 Archive
-
-**Download** (tar.gz of entire workspace):
-
-```
-GET /workspaces/:id/archive/download
-```
-
-**Upload** (restore from tar.gz):
-
-```
-POST /workspaces/:id/archive/upload
-Content-Type: multipart/form-data
-```
-
-### 7.4 Reconcile, Stats, Index
-
-**Reconcile** (sync filesystem state with DB metadata):
-
-```
-POST /workspaces/:id/reconcile
-```
-
-**Stats:**
-
-```
-GET /workspaces/:id/stats
-```
-
-Returns `{ "file_count", "storage_bytes", "indexed_files", ... }`.
-
-**Index status:**
-
-```
-GET /workspaces/:id/index-status
-```
-
-Returns indexing progress for full-text search.
-
-### 7.5 Git Integration
-
-All Git operations are workspace-scoped.
-
-```
-POST /workspaces/:id/git/clone ← { "url", "branch", "credential_id" }
-POST /workspaces/:id/git/pull
-POST /workspaces/:id/git/push
-GET /workspaces/:id/git/status → { "files": [{ "path", "status" }] }
-GET /workspaces/:id/git/diff → { "diff": "..." }
-POST /workspaces/:id/git/commit ← { "message", "files": [...] }
-GET /workspaces/:id/git/log → { "commits": [...] }
-GET /workspaces/:id/git/branches → { "branches": [...], "current": "main" }
-POST /workspaces/:id/git/checkout ← { "branch": "feature-x" }
-```
-
-### 7.6 Git Credentials
-
-User-owned, encrypted credential storage for Git operations.
-Encrypted with the user's UEK (same as BYOK keys — admins cannot
-recover).
-
-```
-POST /git-credentials ← { "name", "auth_type", "token"|"username"+"password"|"ssh_key" }
-GET /git-credentials → { "data": [GitCredentialSummary] }
-DELETE /git-credentials/:id
-```
-
-`auth_type`: `https_pat`, `https_basic`, or `ssh_key`.
-
-Summary response (never exposes encrypted data):
-
-```json
-{
- "id": "uuid",
- "name": "GitHub PAT",
- "auth_type": "https_pat",
- "created_at": "..."
-}
-```
-
----
-
-## 8. Projects
-
-**Projects** are organizational containers that group channels,
-knowledge bases, notes, and files. They follow the scope model
-(personal, team, global).
-
-### 8.1 Project CRUD
-
-```
-GET /projects → { "data": [...] }
-POST /projects ← { "name", "description", "scope", "team_id", "persona_id", "system_prompt" }
-GET /projects/:id → project object
-PUT /projects/:id ← partial update
-DELETE /projects/:id
-```
-
-Project object:
-
-```json
-{
- "id": "uuid",
- "name": "Q3 Research",
- "description": "...",
- "scope": "personal|team|global",
- "owner_id": "uuid",
- "team_id": "uuid|null",
- "persona_id": "uuid|null",
- "system_prompt": "...|null",
- "is_archived": false,
- "channel_count": 5,
- "created_at": "...",
- "updated_at": "..."
-}
-```
-
-### 8.2 Channel Association
-
-```
-GET /projects/:id/channels → { "data": [channel objects] }
-POST /projects/:id/channels ← { "channel_id": "uuid" }
-DELETE /projects/:id/channels/:channelId
-PUT /projects/:id/channels/reorder ← { "channel_ids": ["uuid1", "uuid2"] }
-```
-
-A channel can only belong to one project. `POST` performs an atomic
-move if the channel is already in a different project.
-
-### 8.3 KB Association
-
-```
-GET /projects/:id/knowledge-bases → { "data": [KB objects] }
-POST /projects/:id/knowledge-bases ← { "kb_id": "uuid" }
-DELETE /projects/:id/knowledge-bases/:kbId
-```
-
-KBs bound to a project are automatically available to every channel
-in that project (see §5.7 resolution chain).
-
-### 8.4 Note Association
-
-```
-GET /projects/:id/notes → { "data": [note objects] }
-POST /projects/:id/notes ← { "note_id": "uuid" }
-DELETE /projects/:id/notes/:noteId
-```
-
-### 8.5 Project Files
-
-```
-GET /projects/:id/files → { "files": [...] }
-POST /projects/:id/files ← multipart/form-data
-```
-
-Project-level file uploads (distinct from workspace files and channel
-attachments).
-
-### 8.6 Admin Project Management
-
-```
-GET /admin/projects → { "data": [...] }
-DELETE /admin/projects/:id
-```
-
-Cross-instance visibility for platform admins. BYOK personal projects
-remain private (admin can delete but not read content).
-
----
-
-## 9. Memory
-
-**Long-term memory** extracted from conversations. The system
-identifies facts, preferences, and context about the user and stores
-them for injection into future conversations.
-
-### 9.1 User Memory
-
-```
-GET /memories → { "data": [memory objects] }
-GET /memories/count → { "count": 42 }
-PUT /memories/:id ← { "content": "updated text" }
-DELETE /memories/:id
-POST /memories/:id/approve → approve a pending memory
-POST /memories/:id/reject → reject a pending memory
-```
-
-Memory object:
-
-```json
-{
- "id": "uuid",
- "user_id": "uuid",
- "persona_id": "uuid|null",
- "scope": "user|persona",
- "content": "User prefers Go for backend work",
- "source_channel_id": "uuid",
- "status": "pending|approved|rejected",
- "confidence": 0.85,
- "created_at": "...",
- "updated_at": "..."
-}
-```
-
-`scope`: `user` memories apply across all conversations. `persona`
-memories are specific to interactions with that Persona.
-
-### 9.2 Admin Memory Review
-
-```
-GET /admin/memories/pending → { "data": [memory objects with user info] }
-POST /admin/memories/bulk-approve ← { "ids": ["uuid1", "uuid2"] }
-```
-
-Platform admin can review and bulk-approve pending memories across
-all users.
-
----
-
-## 10. Providers & Routing
-
-The **multi-provider** system. One or more LLM providers are configured,
-each with their own API keys, endpoints, and model catalogs. The routing
-layer decides which provider handles each request.
-
-### 10.1 User BYOK Provider Configs
-
-Gated by `allow_user_byok` policy. Personal API keys are encrypted with
-the user's UEK (per-user encryption key, Argon2id-derived). Platform
-admins cannot recover personal keys.
-
-```
-GET /api-configs → { "configs": [safeConfig objects] }
-POST /api-configs ← { "name", "provider", "endpoint", "api_key", ... }
-GET /api-configs/:id → safeConfig
-PUT /api-configs/:id ← partial update (api_key optional)
-DELETE /api-configs/:id
-```
-
-`safeConfig` — API keys are **never** returned:
-
-```json
-{
- "id": "uuid",
- "name": "My OpenAI",
- "provider": "openai",
- "endpoint": "https://api.openai.com/v1",
- "model_default": "gpt-4o",
- "scope": "personal",
- "owner_id": "uuid",
- "is_active": true,
- "has_key": true,
- "config": {},
- "headers": {},
- "settings": {},
- "created_at": "..."
-}
-```
-
-**List models for a user config:**
-
-```
-GET /api-configs/:id/models
-```
-
-Returns `{ "models": [catalog entries] }`.
-
-**Fetch/sync models from provider API:**
-
-```
-POST /api-configs/:id/models/fetch
-```
-
-Calls the provider's model list API, upserts into the local catalog.
-
-### 10.2 Admin Global Provider Configs
-
-```
-GET /admin/configs → { "configs": [configWithKey objects] }
-POST /admin/configs ← { "name", "provider", "endpoint", "api_key", "config", "headers", "settings", "is_private" }
-PUT /admin/configs/:id ← partial update
-DELETE /admin/configs/:id
-```
-
-`configWithKey` is the same as `safeConfig` but comes from
-`ListGlobal` — still redacts API keys, just adds the `has_key` flag.
-
-Admin configs use the `ENCRYPTION_KEY` env var (not per-user UEK).
-`is_private`: when true, the config is available for admin-created
-Personas but not directly selectable by users.
-
-### 10.3 Model Catalog (Admin)
-
-The catalog is populated by fetching from provider APIs and stores
-model metadata (capabilities, context window, pricing).
-
-```
-GET /admin/models → { "models": [catalog entries] }
-PUT /admin/models/:id ← { "visibility", "display_name", ... }
-PUT /admin/models/bulk ← { "provider_config_id", "visibility" }
-DELETE /admin/models/:id
-POST /admin/models/fetch ← { "provider_config_id": "uuid|empty" }
-```
-
-**Fetch** with empty `provider_config_id` syncs ALL active global
-providers. Returns:
-
-```json
-{
- "message": "models synced",
- "added": 5,
- "updated": 12,
- "total": 47
-}
-```
-
-Or for multi-provider fetch: `{ "added", "updated", "total", "errors": [...] }`.
-
-**Bulk visibility** sets all models for a provider (or all models
-globally if no `provider_config_id`) to the specified visibility
-(`enabled`, `disabled`, `team`).
-
-### 10.4 Provider Health
-
-Real-time health tracking per provider config. The health accumulator
-records success/failure/latency on every completion, flushes to DB
-every 60 seconds.
-
-**Get all:**
-
-```
-GET /admin/providers/health
-```
-
-Returns `{ "data": [ProviderHealth objects] }`:
-
-```json
-{
- "provider_config_id": "uuid",
- "provider_config_name": "OpenAI Production",
- "status": "healthy|degraded|down",
- "error_rate": 0.02,
- "avg_latency_ms": 450,
- "timeout_rate": 0.01,
- "rate_limit_count": 3,
- "last_check": "...",
- "last_error": "...|null"
-}
-```
-
-**Get single:**
-
-```
-GET /admin/providers/:id/health
-```
-
-**Auto-disable:** After `PROVIDER_AUTO_DISABLE_THRESHOLD` consecutive
-"down" windows (default: 3), the provider is automatically deactivated.
-
-### 10.5 Capability Overrides
-
-Admin can override any model capability detected by the catalog or
-heuristic layer.
-
-```
-GET /admin/capability-overrides → { "data": [...] }
-GET /admin/models/:id/capabilities → capabilities for one model
-PUT /admin/models/:id/capabilities ← { "field": "value" }
-DELETE /admin/models/:id/capabilities/:overrideId
-```
-
-Override fields: `supports_vision`, `supports_tools`, `supports_thinking`,
-`context_window`, `max_output_tokens`, etc.
-
-### 10.6 Routing Policies
-
-Policy-based request routing. Evaluated after model/config resolution,
-before provider dispatch.
-
-**Admin CRUD:**
-
-```
-GET /admin/routing/policies → { "data": [...] }
-GET /admin/routing/policies/:id → policy object
-POST /admin/routing/policies ← { "name", "scope", "team_id", "priority", "policy_type", "config", "is_active" }
-PUT /admin/routing/policies/:id
-DELETE /admin/routing/policies/:id
-```
-
-Policy object:
-
-```json
-{
- "id": "uuid",
- "name": "Prefer Anthropic",
- "scope": "global|team",
- "team_id": "uuid|null",
- "priority": 10,
- "policy_type": "provider_prefer|team_route|cost_limit|model_alias",
- "config": {},
- "is_active": true
-}
-```
-
-| Policy type | Config | Behavior |
-|------------|--------|----------|
-| `provider_prefer` | `{ "providers": ["cfg-1", "cfg-2"] }` | Ordered fallback list |
-| `team_route` | `{ "providers": ["cfg-1"] }` | Restrict team to specific providers |
-| `cost_limit` | `{ "max_cost_per_request": 0.50 }` | Heuristic cost cap |
-| `model_alias` | `{ "alias": "fast", "target_model": "...", "target_config": "..." }` | Alias → provider+model rewrite |
-
-**Dry-run test:**
-
-```
-POST /admin/routing/test
-```
-
-```json
-{
- "model": "claude-sonnet-4-20250514",
- "user_id": "uuid",
- "team_id": "uuid|null"
-}
-```
-
-Returns the ranked candidate list with health status for each.
-
-### 10.7 Provider Types
-
-Registry of supported provider types with metadata.
-
-```
-GET /admin/provider-types
-```
-
-Returns `{ "types": [...] }` with name, display name, default endpoint,
-profile schema, and supported features per type.
-
----
-
-## 11. Models & Preferences
-
-What models are available to the current user and how they control
-visibility.
-
-### 11.1 Enabled Models
-
-The primary endpoint for populating model selectors:
-
-```
-GET /models/enabled
-```
-
-Returns `{ "models": [UserModel objects] }`:
-
-```json
-{
- "id": "composite-id",
- "model_id": "claude-sonnet-4-20250514",
- "provider_config_id": "uuid",
- "provider_config_name": "Anthropic",
- "provider": "anthropic",
- "display_name": "Claude Sonnet 4",
- "model_type": "chat|embedding|image",
- "context_window": 200000,
- "max_output_tokens": 8192,
- "supports_vision": true,
- "supports_tools": true,
- "supports_thinking": true,
- "supports_streaming": true,
- "input_price_per_m": 3.00,
- "output_price_per_m": 15.00,
- "provider_status": "healthy|degraded|down|null",
- "scope": "global|team|personal",
- "source": "catalog|heuristic",
- "is_persona": false,
- "persona_id": "uuid|null",
- "persona_scope": "global|team|personal|null",
- "persona_avatar": "url|null",
- "persona_team_name": "string|null"
-}
-```
-
-The capabilities on each model are resolved through the three-tier
-chain: catalog DB → heuristic inference → admin overrides (see §10.5).
-
-When `is_persona` is `true`, the capability fields (`context_window`,
-`max_output_tokens`, `supports_*`, pricing, `provider_status`) are
-inherited from the persona's underlying model. The frontend renders
-the same capability pills and badges for a persona as for its raw
-model — the persona adds identity, not capability restrictions.
-
-### 11.2 User Model Preferences
-
-Users can hide models they don't want to see and set per-model
-defaults. Preferences are keyed on the **composite identity**
-`provider_config_id:model_id` — the same model from different
-providers can have independent visibility.
-
-```
-GET /models/preferences → { "preferences": [PreferenceEntry objects] }
-PUT /models/preferences ← { "model_id": "...", "provider_config_id": "uuid", "hidden": true }
-POST /models/preferences/bulk ← { "entries": [{ "model_id": "...", "provider_config_id": "uuid", "hidden": true }] }
-```
-
-PreferenceEntry:
-
-```json
-{
- "model_id": "grok-4.1-fast",
- "provider_config_id": "uuid",
- "hidden": true,
- "preferred_temperature": 0.7,
- "preferred_max_tokens": 4096,
- "sort_order": 0
-}
-```
-
-**Identity rule:** `provider_config_id` is required on write. The same
-bare `model_id` from two different provider configs (e.g. global Venice
-vs personal BYOK Venice) are independent preference entries. The
-frontend composite ID format is `{provider_config_id}:{model_id}`.
-
-**Persona preferences:** Personas are not in this table. A persona's
-visibility is controlled by the grant system (§15.3) and the
-`is_active` flag, not by model preferences.
-
----
-
-## 12. Notifications
-
-Real-time notification infrastructure with WebSocket delivery and
-optional email transport.
-
-### 12.1 CRUD
-
-```
-GET /notifications → paginated list
-GET /notifications/unread-count → { "count": 5 }
-PATCH /notifications/:id/read → mark one as read
-POST /notifications/mark-all-read → mark all as read
-DELETE /notifications/:id
-```
-
-Notification object:
-
-```json
-{
- "id": "uuid",
- "user_id": "uuid",
- "type": "role.fallback|memory.extracted|system.announcement|...",
- "title": "Role Fallback Triggered",
- "body": "Primary model unavailable, using fallback",
- "metadata": {},
- "read": false,
- "created_at": "..."
-}
-```
-
-### 12.2 Preferences
-
-Users control per-type delivery:
-
-```
-GET /notifications/preferences → { "preferences": [...] }
-PUT /notifications/preferences/:type ← { "in_app": true, "email": false }
-DELETE /notifications/preferences/:type → reset to default
-```
-
-Resolution: specific type pref → user wildcard `*` pref → system
-default (`in_app=true`, `email=false`).
-
----
-
-## 13. Extensions
-
-Plugin system with three tiers: Browser JS (client-side), Starlark
-sandbox (server-side, future), Sidecar containers (server-side, future).
-Currently only Browser tier is implemented.
-
-### 13.1 User Extensions
-
-```
-GET /extensions → { "extensions": [...] }
-POST /extensions/:id/settings ← { "enabled": true, "config": {...} }
-GET /extensions/:id/manifest → full manifest.json
-GET /extensions/tools → { "tools": [tool schema objects] }
-```
-
-### 13.2 Admin Extension Management
-
-```
-GET /admin/extensions → { "extensions": [...] }
-POST /admin/extensions ← { manifest + script content }
-PUT /admin/extensions/:id ← updated manifest/script
-DELETE /admin/extensions/:id
-```
-
-### 13.3 Asset Serving
-
-```
-GET /extensions/:id/assets/*path
-```
-
-Public (no auth required). Serves static assets (icons, CSS, JS)
-from extension bundles.
-
----
-
-## 14. User Profile & Settings
-
-### 14.1 Profile
-
-```
-GET /profile → profileResponse
-PUT /profile ← { "display_name", "email" }
-```
-
-```json
-{
- "id": "uuid",
- "username": "jdoe",
- "email": "jdoe@example.com",
- "display_name": "Jane Doe",
- "role": "user|admin",
- "avatar_url": "...",
- "created_at": "...",
- "last_login": "..."
-}
-```
-
-### 14.2 Avatar
-
-```
-POST /profile/avatar ← multipart/form-data (field: "avatar")
-DELETE /profile/avatar
-```
-
-### 14.3 Password
-
-```
-POST /profile/password
-```
-
-```json
-{
- "current_password": "...",
- "new_password": "..."
-}
-```
-
-On password change, the UEK is re-wrapped with the new password
-(BYOK keys remain accessible without re-encryption).
-
-### 14.4 App Settings
-
-```
-GET /settings → { "settings": {...} }
-PUT /settings ← { "theme", "editor_keybindings", ... }
-```
-
-User-level preferences (theme, keybindings, default model, etc.).
-
----
-
-## 15. Teams & Access Control
-
-### 15.1 My Teams
-
-```
-GET /teams/mine → { "teams": [...] }
-```
-
-Returns teams the current user is a member of.
-
-### 15.2 Team Administration
-
-Team-admin-scoped routes (require `RequireTeamAdmin` middleware):
-
-**Team CRUD (platform admin):**
-
-```
-GET /admin/teams → { "teams": [...] }
-POST /admin/teams
-GET /admin/teams/:id
-PUT /admin/teams/:id
-DELETE /admin/teams/:id
-```
-
-**Members:**
-
-```
-GET /admin/teams/:id/members → { "members": [...] }
-POST /admin/teams/:id/members ← { "user_id", "role" }
-PUT /admin/teams/:id/members/:memberId ← { "role" }
-DELETE /admin/teams/:id/members/:memberId
-```
-
-**Team-scoped routes** (team admin, not platform admin):
-
-```
-GET /teams/:teamId/members
-POST /teams/:teamId/members ← { "user_id", "role" }
-PUT /teams/:teamId/members/:memberId
-DELETE /teams/:teamId/members/:memberId
-```
-
-**Team Providers:**
-
-```
-GET /teams/:teamId/providers → { "configs": [...] }
-POST /teams/:teamId/providers ← { "name", "provider", "endpoint", "api_key", ... }
-PUT /teams/:teamId/providers/:id
-DELETE /teams/:teamId/providers/:id
-GET /teams/:teamId/providers/:id/models → { "models": [...] }
-```
-
-**Team Models:**
-
-```
-GET /teams/:teamId/models → { "models": [...] }
-```
-
-Available models for this team (global + team-scoped).
-
-**Team Personas:** See §4.2.
-
-**Team Roles:**
-
-```
-GET /teams/:teamId/roles → { "roles": [...] }
-PUT /teams/:teamId/roles/:role ← { "permissions": {...} }
-DELETE /teams/:teamId/roles/:role
-```
-
-**Team Audit:**
-
-```
-GET /teams/:teamId/audit → paginated audit log
-GET /teams/:teamId/audit/actions → { "actions": [...] } (distinct action types)
-```
-
-**Team Usage:**
-
-```
-GET /teams/:teamId/usage → { "totals": {...}, "results": [...] }
-```
-
-### 15.3 Groups & Resource Grants
-
-Groups are ACL containers that decouple access from team membership.
-A resource grant controls who can see a Persona or KB beyond its base
-scope.
-
-**My Groups:**
-
-```
-GET /groups/mine → { "groups": [...] }
-```
-
-**Admin Group CRUD:**
-
-```
-GET /admin/groups → { "groups": [...] }
-POST /admin/groups ← { "name", "scope", "team_id" }
-GET /admin/groups/:id
-PUT /admin/groups/:id
-DELETE /admin/groups/:id
-```
-
-**Group Members:**
-
-```
-GET /admin/groups/:id/members → { "members": [...] }
-POST /admin/groups/:id/members ← { "user_id" }
-DELETE /admin/groups/:id/members/:userId
-```
-
-**Resource Grants:**
-
-```
-GET /admin/grants/:type/:id → { "grant": {...} }
-PUT /admin/grants/:type/:id ← { "grant_type": "team_only|global|groups", "group_ids": [...] }
-DELETE /admin/grants/:type/:id
-```
-
-`:type` is `persona` or `kb`. `:id` is the resource ID.
-
-Grant types:
-
-| `grant_type` | Visibility |
-|--------------|-----------|
-| `team_only` | Only the owning team |
-| `global` | All authenticated users |
-| `groups` | Members of specified groups |
-
----
-
-## 16. Platform Administration
-
-Cross-cutting admin operations that don't belong to a specific domain.
-
-### 16.1 User Management
-
-```
-GET /admin/users → { "users": [...] }
-POST /admin/users ← { "username", "password", "email", "role" }
-PUT /admin/users/:id/role ← { "role": "admin|user" }
-PUT /admin/users/:id/active ← { "active": false }
-DELETE /admin/users/:id
-POST /admin/users/:id/reset-password ← { "new_password": "..." }
-POST /admin/users/:id/vault/reset → destroys user's UEK (BYOK keys lost)
-```
-
-### 16.2 Global Settings & Policies
-
-Settings are key-value pairs in `global_settings`. Policies are
-boolean flags that gate features.
-
-```
-GET /admin/settings → all settings
-GET /admin/settings/:key → single setting
-PUT /admin/settings/:key ← { "value": ... }
-```
-
-The handler auto-detects: if the value is a string and the key is a
-known policy name, it writes to the `policies` table. Otherwise it
-writes to `global_config` as JSON.
-
-**Public settings** (non-admin, for FE bootstrapping):
-
-```
-GET /settings/public
-```
-
-```json
-{
- "banner": { "enabled": true, "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff", "position": "both" },
- "branding": { "instance_name": "Switchboard", "logo_url": "...", "tagline": "..." },
- "has_admin_prompt": true,
- "storage_configured": true,
- "paste_to_file_chars": 2000,
- "policies": {
- "allow_registration": "true",
- "allow_user_byok": "true",
- "allow_user_personas": "true"
- }
-}
-```
-
-### 16.3 Banner Configuration
-
-The environment banner is stored as a single `global_config` entry
-under the key `"banner"`. It's a simple object:
-
-```json
-{
- "enabled": true,
- "text": "DEVELOPMENT",
- "position": "both|top|bottom",
- "bg": "#007a33",
- "fg": "#ffffff"
-}
-```
-
-Set via `PUT /admin/settings/banner` with `{ "value": { ... } }`.
-The admin UI provides a color picker, position selector, and preset
-dropdown. The Go template base layout reads the banner from
-`PageData` and renders top/bottom strips with CSS custom properties.
-
-### 16.4 Platform Stats
-
-```
-GET /admin/stats
-```
-
-```json
-{
- "users": 42,
- "channels": 156,
- "messages": 12847
-}
-```
-
-### 16.5 Storage & Vault
-
-**Storage status:**
-
-```
-GET /admin/storage/status → { "backend", "healthy", "file_count", "total_bytes", ... }
-GET /admin/storage/orphans → { "count": 3 }
-POST /admin/storage/cleanup → removes orphaned blobs
-GET /admin/storage/extraction → extraction queue status
-```
-
-**Vault:**
-
-```
-GET /admin/vault/status → { "encryption_key_set", "user_vaults_count", ... }
-```
-
-### 16.6 Audit Log
-
-```
-GET /admin/audit?page=1&per_page=50&action=user.create&actor_id=uuid
-GET /admin/audit/actions → { "actions": ["user.create", "policy.update", ...] }
-```
-
-Audit entry:
-
-```json
-{
- "id": "uuid",
- "actor_id": "uuid",
- "action": "user.create",
- "resource_type": "user",
- "resource_id": "uuid",
- "metadata": {},
- "created_at": "..."
-}
-```
-
-### 16.7 Usage & Pricing
-
-```
-GET /admin/usage → { "totals", "results" }
-GET /admin/usage/teams/:id → team-specific usage
-GET /admin/usage/users/:id → user-specific usage
-GET /admin/pricing → { "data": [...] }
-PUT /admin/pricing ← { "provider", "model", "input_per_m", "output_per_m" }
-DELETE /admin/pricing/:provider/:model
-```
-
-**Personal usage** (non-admin):
-
-```
-GET /usage → { "totals", "results" }
-```
-
-Scoped to the user's BYOK provider usage only.
-
-### 16.8 Roles (Platform)
-
-```
-GET /admin/roles → { "roles": [...] }
-GET /admin/roles/:role
-PUT /admin/roles/:role ← { "permissions": {...} }
-POST /admin/roles/:role/test ← test role permissions
-```
-
-### 16.9 Email Test
-
-```
-POST /admin/notifications/test-email
-```
-
-```json
-{ "to": "admin@example.com" }
-```
-
-Sends a test email using the configured SMTP settings.
-
----
-
-## 17. Export & Utility
-
-### 17.1 Health Check
-
-```
-GET /health → { "status": "ok" }
-```
-
-Public, no auth. Also available at `/api/v1/health`.
-
-### 17.2 Export
-
-Convert markdown to PDF or DOCX via pandoc:
-
-```
-POST /export
-```
-
-```json
-{
- "content": "# My Document\n\nBody text...",
- "format": "pdf|docx",
- "filename": "my-document"
-}
-```
-
-Returns the binary file with appropriate Content-Type. Requires
-`pandoc` in the container (available in the unified and backend
-Docker images).
-
----
-
-## 17b. Files & Storage
-
-All binary content in Chat Switchboard flows through a single
-**ObjectStore** abstraction backed by PVC (filesystem) or S3. All
-file metadata lives in one `files` table. The old `attachments`
-table is dropped.
-
-### 17b.1 Storage Backend
-
-| Operation | Description |
-|-----------|-------------|
-| `Put(key, reader, size, contentType)` | Store a blob |
-| `Get(key)` → reader, size, contentType | Retrieve a blob |
-| `Delete(key)` | Remove a blob |
-| `DeletePrefix(prefix)` | Bulk remove (channel deletion) |
-| `Exists(key)` | Check without reading |
-| `Healthy()` | Backend health check |
-| `Stats()` | Aggregate metrics |
-
-Backends: `pvc` (`STORAGE_PATH=/data/storage`) or `s3`
-(`S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`).
-
-### 17b.2 Key Namespacing
-
-| Prefix | Pattern | What |
-|--------|---------|------|
-| `attachments/` | `attachments/{channel_id}/{file_id}_{filename}` | User uploads + AI outputs (legacy prefix retained for existing blobs) |
-| `kb/` | `kb/{kb_id}/{doc_id}_{filename}` | Knowledge base documents |
-| `exports/` | `exports/{user_id}/{export_id}_{filename}` | Chat/data exports |
-| `workspace/` | `workspace/{workspace_id}/{path}` | Workspace files |
-
-### 17b.3 File Object
-
-```sql
-CREATE TABLE files (
- id UUID PRIMARY KEY,
- channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
- message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
- user_id UUID REFERENCES users(id) ON DELETE SET NULL,
- project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
- origin TEXT NOT NULL DEFAULT 'user_upload'
- CHECK (origin IN ('user_upload','tool_output','system')),
- filename TEXT NOT NULL,
- content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
- size_bytes BIGINT NOT NULL DEFAULT 0,
- storage_key TEXT NOT NULL,
- display_hint TEXT NOT NULL DEFAULT 'download'
- CHECK (display_hint IN ('inline','download','thumbnail')),
- extracted_text TEXT,
- metadata JSONB DEFAULT '{}',
- created_at TIMESTAMPTZ DEFAULT NOW(),
- updated_at TIMESTAMPTZ DEFAULT NOW()
-);
-```
-
-**Origin:**
-
-| Value | Who creates | Examples |
-|-------|-------------|---------|
-| `user_upload` | User via upload UI | PDF, image, doc attached to message |
-| `tool_output` | Tool execution during completion | DALL-E image, video gen, code artifact |
-| `system` | Platform | Export, avatar stored as file |
-
-**Display hint:**
-
-| Value | Frontend behavior |
-|-------|-------------------|
-| `inline` | Render in message stream (images, video, audio, HTML) |
-| `download` | Filename + size + download button |
-| `thumbnail` | Thumbnail preview, click to expand |
-
-### 17b.4 Tool Output Flow
-
-1. Tool executes (image gen, video render, etc.)
-2. Tool writes blob via `ObjectStore.Put`
-3. Tool creates file record: `POST /files` with `origin: "tool_output"`, `message_id` = current assistant message
-4. Tool result includes `file_id` reference
-5. WebSocket emits `file.created` event:
-
-```json
-{
- "event": "file.created",
- "channel_id": "uuid",
- "message_id": "uuid",
- "file": { ...file object... }
-}
-```
-
-6. Frontend renders inline based on `display_hint` + `content_type`
-
-The event fires during streaming so the user sees the artifact
-before the completion finishes.
-
-### 17b.5 Content Type → Display Hint Defaults
-
-| Content Type | Default Hint | Rendering |
-|-------------|-------------|-----------|
-| `image/*` | `inline` | `
` in message, lightbox on click |
-| `video/*` | `inline` | `