diff --git a/docs/DESIGN-0.12.0.md b/docs/DESIGN-0.12.0.md
deleted file mode 100644
index 98f8c4b..0000000
--- a/docs/DESIGN-0.12.0.md
+++ /dev/null
@@ -1,1304 +0,0 @@
-# v0.12.0 — File Handling + Vision (Design)
-
-## Overview
-
-File input into chat — table stakes for serious use. Image uploads for
-vision-capable models, document uploads for text extraction into context,
-and the storage backend that v0.14.0 (Knowledge Bases) and v0.15.0
-(Compaction snapshots) will reuse.
-
-Also picks up stragglers deferred from earlier releases.
-
-**Depends on:** v0.11.0 (extension foundation — complete).
-**Reused by:** v0.14.0 (KBs), v0.15.0 (compaction), v0.22.0 (exports).
-
----
-
-## Stragglers (from v0.9.4 deferred)
-
-Items deferred during vault work that were never scheduled:
-
-### `switchboard vault rekey` CLI command
-Re-encrypts all global/team API keys when `ENCRYPTION_KEY` is rotated.
-Personal BYOK keys are unaffected (keyed to UEK, not env var).
-
-Implementation: standalone CLI entrypoint that reads old + new env vars,
-iterates `api_configs` and `team_providers` where `key_scope IN ('global',
-'team')`, decrypts with old key, re-encrypts with new key, updates in a
-transaction.
-
-### Admin UI: encryption status indicator
-Badge in admin Settings showing whether `ENCRYPTION_KEY` is set and how
-many keys are encrypted vs. plaintext (migration stragglers). Single
-`GET /admin/storage/status` endpoint.
-
-### Per-chat model/preset persistence (server-side)
-Currently localStorage bandaid from v0.10.2 — doesn't roam across devices.
-
-**Fix:** Store `last_selector_id` in `channels.settings` JSONB on each
-completion success. On chat selection, resolve:
-`channel.settings.last_selector_id` → match against available
-models/presets → fall back to `channel.model` → global default.
-
-No migration needed (`channels.settings` JSONB column already exists).
-Single `PATCH /channels/:id` with settings merge on completion success.
-
----
-
-## Track 1: Storage Backend
-
-### Environment Variables
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `STORAGE_BACKEND` | (auto) | `pvc` or `s3`. If not set: `pvc` when `STORAGE_PATH` is writable, disabled otherwise. |
-| `STORAGE_PATH` | `/data/storage` | Mount point for PVC backend. Also scratch dir for extraction with S3. |
-| `STORAGE_CLASS` | — | K8s only. Gitea CI variable → PVC manifest. Value: `cephfs` (RWX for multi-pod). |
-| `S3_ENDPOINT` | — | S3-compatible endpoint (e.g. `http://minio:9000`). Required when `STORAGE_BACKEND=s3`. |
-| `S3_BUCKET` | — | Bucket name. Must exist before first start. |
-| `S3_ACCESS_KEY` | — | S3 access key ID. |
-| `S3_SECRET_KEY` | — | S3 secret access key. |
-| `S3_REGION` | `us-east-1` | AWS region. |
-| `S3_PREFIX` | — | Optional key prefix for shared buckets (e.g. `switchboard/`). |
-| `S3_FORCE_PATH_STYLE` | `true` | Path-style URLs. Required for MinIO, Ceph RGW, most self-hosted. |
-| `EXTRACTION_MODE` | `inline` | `inline` (in-process, unified image) or `sidecar` (K8s, shared PVC). |
-| `EXTRACTION_CONCURRENCY` | `3` | Max concurrent extraction jobs. Caps LibreOffice memory usage. |
-
-Auto-detection when `STORAGE_BACKEND` is not set:
-
-```
-STORAGE_PATH writable → pvc (implicit)
-STORAGE_PATH missing → file features disabled
- admin panel shows "Storage not configured"
-```
-
-When `STORAGE_BACKEND=pvc` is explicit, fail startup if path is not
-writable (fail-safe, same pattern as `ENCRYPTION_KEY` enforcement).
-
-S3 backend: reads `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`,
-`S3_REGION`, `S3_PREFIX`, `S3_FORCE_PATH_STYLE` from env vars. Uses minio-go v7.
-Works with MinIO, Ceph RGW, AWS S3. Same interface as PVC — all handlers are
-backend-agnostic. PVC still required as scratch dir for extraction queue.
-
-### Config Addition
-
-```go
-// In config.go
-StorageBackend string // "pvc", "s3", or "" (auto-detect)
-StoragePath string // mount point for PVC backend
-ExtractionMode string // "inline" or "sidecar"
-ExtractionConcurrency int // max concurrent extraction jobs
-```
-
-### Interface
-
-```go
-// server/storage/storage.go
-package storage
-
-import (
- "context"
- "io"
-)
-
-// ObjectStore is the abstraction for blob storage.
-// Implementations: PVC (filesystem), S3 (minio-go v7).
-type ObjectStore interface {
- // Put writes data to the given key. Creates parent dirs as needed.
- Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
-
- // Get returns a reader for the given key. Caller must close.
- // Returns ErrNotFound if key does not exist.
- Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error)
-
- // Delete removes the object at key. No error if already gone.
- Delete(ctx context.Context, key string) error
-
- // DeletePrefix removes all objects under the given prefix.
- // Used for channel deletion (bulk cleanup).
- DeletePrefix(ctx context.Context, prefix string) error
-
- // Exists checks if an object exists at key without reading it.
- Exists(ctx context.Context, key string) (bool, error)
-
- // Healthy returns nil if the backend is operational.
- Healthy(ctx context.Context) error
-}
-
-var ErrNotFound = errors.New("storage: object not found")
-```
-
-### PVC Implementation
-
-`server/storage/pvc.go` — ~100 lines. Thin wrapper around `os.*`:
-
-```go
-type PVCStore struct {
- basePath string // e.g. "/data/storage"
-}
-
-func NewPVC(basePath string) (*PVCStore, error) {
- // Validate basePath is writable (create test file, remove)
- // Create top-level subdirs: attachments/
-}
-```
-
-Key → filesystem path: `filepath.Join(basePath, key)`. The key itself
-provides all the directory structure.
-
-### Filesystem Layout
-
-```
-/data/storage/ ← STORAGE_PATH mount point
-└── attachments/
- └── {channel_id}/
- └── {attachment_id}_{filename}
-```
-
-**Channel-first** because:
-- Channel deletion → `DeletePrefix("attachments/{channel_id}/")` — one call.
-- User deletion cascades to owned channels → same cleanup path.
-- Group chats / multi-participant channels: all files in one place
- regardless of who uploaded them.
-- Quota enforcement uses PG (`SUM(size_bytes) WHERE user_id = $1`),
- not filesystem walks.
-
-Future subdirs at the `STORAGE_PATH` level (not this release):
-- `knowledge-bases/` — v0.14.0
-- `compaction/` — v0.15.0
-- `exports/` — TBD
-
-### K8s Manifest (PVC)
-
-```yaml
-# k8s/storage-pvc.yaml
-apiVersion: v1
-kind: PersistentVolumeClaim
-metadata:
- name: switchboard-storage
- namespace: switchboard
-spec:
- accessModes: [ReadWriteMany] # CephFS RWX — multi-pod safe
- storageClassName: "${STORAGE_CLASS}"
- resources:
- requests:
- storage: 10Gi # configurable per deployment
-```
-
-Backend deployment addition:
-
-```yaml
-# In k8s/backend.yaml
-volumes:
- - name: storage
- persistentVolumeClaim:
- claimName: switchboard-storage
-containers:
- - name: backend
- volumeMounts:
- - name: storage
- mountPath: /data/storage
- env:
- - name: STORAGE_BACKEND
- value: "pvc"
- - name: STORAGE_PATH
- value: "/data/storage"
-```
-
-### Docker Compose
-
-```yaml
-# In docker-compose.yml
-services:
- switchboard:
- volumes:
- - ./data/storage:/data/storage
- environment:
- - STORAGE_PATH=/data/storage
-```
-
-### Admin Status Endpoint
-
-`GET /admin/storage/status`
-
-```json
-{
- "backend": "pvc",
- "path": "/data/storage",
- "healthy": true,
- "total_files": 142,
- "total_bytes": 52428800,
- "configured": true
-}
-```
-
-When storage is not configured, file upload endpoints return 503 with
-`{"error": "File storage not configured"}`. Frontend hides upload UI
-based on a capabilities flag in the boot payload.
-
----
-
-## Track 2: Attachments Schema + API
-
-### Migration 007_attachments.sql
-
-```sql
--- ── Attachments ────────────────────────────
-CREATE TABLE IF NOT EXISTS attachments (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
- user_id UUID NOT NULL REFERENCES users(id),
- message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
- filename VARCHAR(255) NOT NULL,
- content_type VARCHAR(127) NOT NULL,
- size_bytes BIGINT NOT NULL,
- storage_key TEXT NOT NULL,
- extracted_text TEXT,
- metadata JSONB DEFAULT '{}'::jsonb,
- created_at TIMESTAMPTZ DEFAULT NOW()
-);
-
--- Access pattern: always join through channel for auth
-CREATE INDEX idx_attachments_channel ON attachments(channel_id);
--- Quota pattern: sum by user
-CREATE INDEX idx_attachments_user_size ON attachments(user_id);
--- Message association: find attachments for a message
-CREATE INDEX idx_attachments_message ON attachments(message_id);
-```
-
-Notes on columns:
-- `message_id` is nullable — attachment is uploaded before the message
- is persisted. Updated after message creation. If message is deleted,
- attachment persists (belongs to channel, not message).
-- `storage_key` is the relative path within `STORAGE_PATH`, e.g.
- `attachments/{channel_id}/{attachment_id}_{filename}`.
-- `extracted_text` is populated asynchronously for PDFs/DOCX. NULL for
- images. Used for context injection into completions.
-- `metadata` JSONB holds dimensions (images), page count (PDFs),
- extraction status, thumbnails, etc.
-
-### Store Layer
-
-```go
-// server/store/attachments.go
-type AttachmentStore interface {
- Create(ctx context.Context, a *models.Attachment) error
- GetByID(ctx context.Context, id string) (*models.Attachment, error)
- GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error)
- GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error)
- SetMessageID(ctx context.Context, id, messageID string) error
- Delete(ctx context.Context, id string) error
- DeleteByChannel(ctx context.Context, channelID string) ([]string, error) // returns storage_keys for cleanup
- UserUsageBytes(ctx context.Context, userID string) (int64, error)
-}
-```
-
-### API Endpoints
-
-```
-POST /api/v1/channels/:id/attachments Upload file
-GET /api/v1/attachments/:id Attachment metadata
-GET /api/v1/attachments/:id/download Download file content
-DELETE /api/v1/attachments/:id Delete attachment
-GET /api/v1/channels/:id/attachments List channel attachments
-```
-
-### Upload Flow
-
-```
-Client Backend Storage
- │ │ │
- ├── POST multipart ────────►│ │
- │ (file + channel_id) │ │
- │ ├── Auth check │
- │ ├── Channel membership ─────│
- │ │ verify │
- │ ├── Size limit check │
- │ ├── MIME type validation │
- │ ├── Generate attachment_id │
- │ ├── PUT ───────────────────►│
- │ │ key: att/{ch}/{id}_{fn} │
- │ ├── INSERT attachments row │
- │ │ │
- │◄── 201 { attachment } ────┤ │
-```
-
-The attachment is created *before* the message is sent. The frontend
-holds the attachment ID(s), includes them in the completion request,
-and the backend links them to the persisted message after creation.
-
-### Download Flow (Access Control)
-
-```
-GET /api/v1/attachments/:id/download
-
- → SELECT a.*, c.id, c.user_id
- FROM attachments a
- JOIN channels c ON a.channel_id = c.id
- WHERE a.id = $1
-
- → Verify: requester is channel owner
- (future RBAC: OR requester is channel participant
- OR requester is team member with channel access
- OR requester is admin with audit log)
-
- → Stream file from ObjectStore with:
- Content-Type: {content_type}
- Content-Disposition: attachment; filename="{filename}"
- Content-Length: {size_bytes}
-```
-
-**Critical:** Files are NEVER served directly from nginx or any static
-file path. Every byte goes through the Go backend with auth checks.
-No pre-signed URLs, no direct filesystem exposure.
-
-### Size & Type Limits
-
-Configured via global settings (see **Global Settings** section below).
-Enforced server-side on upload — frontend mirrors limits for UX but the
-backend is authoritative.
-
-Validation order on upload:
-1. `storage_allowed_types` — reject disallowed MIME types
-2. `storage_max_file_size` — reject oversized individual files
-3. `storage_max_upload_size` — reject if request total exceeds limit
-4. `storage_max_attachments_per_message` — reject if too many files
-5. `storage_user_quota_bytes` — reject if user quota exceeded (future)
-
-MIME type is detected server-side (`http.DetectContentType` + extension
-validation), not trusted from the client `Content-Type` header.
-
----
-
-## Track 3: Multimodal Message Assembly
-
-### Provider Message Polymorphism
-
-Currently `providers.Message.Content` is a plain string. Vision requires
-content arrays with image parts. The change is additive — no existing
-callsites break.
-
-```go
-// In server/providers/provider.go
-
-// ContentPart represents one part of a multimodal message.
-type ContentPart struct {
- Type string `json:"type"` // "text", "image_url"
- Text string `json:"text,omitempty"` // for type="text"
- ImageURL *ImageURL `json:"image_url,omitempty"` // for type="image_url"
-}
-
-type ImageURL struct {
- URL string `json:"url"` // "data:image/jpeg;base64,..." or URL
- Detail string `json:"detail,omitempty"` // "auto", "low", "high"
-}
-
-type Message struct {
- Role string `json:"role"`
- Content string `json:"content"`
-
- // Multimodal content. When non-nil, providers use this instead of Content.
- ContentParts []ContentPart `json:"content_parts,omitempty"`
-
- // ... existing tool fields unchanged
-}
-```
-
-**Resolution rule:** if `ContentParts` is non-nil, providers build their
-wire format from it. If nil, they use `Content` (string) as today. This
-means every existing codepath works unchanged until an attachment is
-involved.
-
-### Provider Wire Format Mapping
-
-**OpenAI / OpenRouter / Venice** (OpenAI-compatible):
-
-```go
-// In openai.go — message conversion
-if len(m.ContentParts) > 0 {
- // Content becomes array of objects
- oaiMsg.Content = nil // clear string content
- oaiMsg.ContentArray = toOpenAIContentParts(m.ContentParts)
-}
-```
-
-OpenAI wire format:
-```json
-{
- "role": "user",
- "content": [
- {"type": "text", "text": "What's in this image?"},
- {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
- ]
-}
-```
-
-The `openaiMessage` struct needs a `ContentArray` field with custom JSON
-marshaling — when `ContentArray` is set, marshal content as the array;
-otherwise marshal as string. This is the standard OpenAI multimodal
-format.
-
-**Anthropic:**
-
-```go
-// In anthropic.go — message conversion
-if len(m.ContentParts) > 0 {
- antMsg.Content = toAnthropicContentBlocks(m.ContentParts)
-}
-```
-
-Anthropic wire format:
-```json
-{
- "role": "user",
- "content": [
- {"type": "text", "text": "What's in this image?"},
- {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "..."}}
- ]
-}
-```
-
-The `anthropicContentBlock` struct gets new fields:
-
-```go
-type anthropicContentBlock struct {
- // ... existing fields ...
- // Image support
- Source *anthropicImageSource `json:"source,omitempty"`
-}
-
-type anthropicImageSource struct {
- Type string `json:"type"` // "base64"
- MediaType string `json:"media_type"` // "image/jpeg", "image/png", etc.
- Data string `json:"data"` // base64-encoded image
-}
-```
-
-### Image Delivery: Base64 Inline
-
-For v0.12.0, all images are sent as base64-encoded data inline in the
-API request. No pre-signed URLs.
-
-**Rationale:** The target audience is government/enterprise, often in
-air-gapped or restricted networks. Pre-signed URLs require the provider's
-API servers to reach the storage endpoint — impossible in disconnected
-environments. Base64 works everywhere.
-
-**Trade-off:** Larger request payloads. Acceptable for v0.12.0 — images
-are typically <10MB each and context windows are large. URL-based
-delivery can be an optimization in a future release for cloud deployments.
-
-### Completion Handler Changes
-
-```go
-// In completion.go — after loading conversation, before building provReq
-
-// If message has attachments, build multimodal content
-if len(req.AttachmentIDs) > 0 {
- parts := []providers.ContentPart{
- {Type: "text", Text: req.Content},
- }
- for _, attID := range req.AttachmentIDs {
- att, err := h.store.Attachments().GetByID(c, attID)
- // ... verify att.channel_id matches ...
-
- if isImageType(att.ContentType) {
- // Verify vision capability
- if !caps.Vision {
- c.JSON(400, gin.H{"error": "Selected model does not support image input"})
- return
- }
-
- // Read from storage, base64 encode
- reader, _, _, _ := h.objectStore.Get(c, att.StorageKey)
- data, _ := io.ReadAll(reader)
- reader.Close()
- b64 := base64.StdEncoding.EncodeToString(data)
- dataURL := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64)
-
- parts = append(parts, providers.ContentPart{
- Type: "image_url",
- ImageURL: &providers.ImageURL{URL: dataURL, Detail: "auto"},
- })
- } else if att.ExtractedText != nil && *att.ExtractedText != "" {
- // Document: inject extracted text as context
- parts = append(parts, providers.ContentPart{
- Type: "text",
- Text: fmt.Sprintf("[Document: %s]\n%s", att.Filename, *att.ExtractedText),
- })
- } else {
- // Extraction not yet complete or failed — include filename only
- status := att.Metadata["extraction_status"]
- parts = append(parts, providers.ContentPart{
- Type: "text",
- Text: fmt.Sprintf("[Attached file: %s (extraction %s)]", att.Filename, status),
- })
- }
- }
-
- // Use ContentParts instead of Content string
- userMsg := providers.Message{
- Role: "user",
- ContentParts: parts,
- }
- messages = append(messages, userMsg)
-} else {
- // Existing text-only path — unchanged
- messages = append(messages, providers.Message{
- Role: "user",
- Content: req.Content,
- })
-}
-```
-
-**Frontend blocks send only during active upload** — the send button
-stays disabled while any staged attachment is in `uploading` state
-(bytes still in flight). Once the file is on disk and the attachment
-row exists in PG, send is enabled regardless of extraction status.
-
-The completion handler checks `extraction_status` at request time:
-- `complete` → inject extracted text as context
-- anything else → inject filename-only placeholder
-
-This avoids blocking users on slow extractions while still providing
-extracted context when available. For images (no extraction), the
-attachment is immediately usable.
-
-### Capability Gating
-
-Images are only assembled into content parts when the resolved model
-has `capabilities.vision == true`. If a user attaches an image to a
-non-vision model:
-
-- Documents (PDF, DOCX, TXT): always allowed — extracted text is
- injected as a text content part. No vision needed.
-- Images to non-vision model: backend returns 400 with
- `{"error": "Selected model does not support image input"}`.
-- Frontend disables image upload button when selected model lacks vision.
-
-### Text Extraction (Documents)
-
-#### License Constraint
-
-The project is Apache 2.0. Libraries with AGPL/GPL copyleft (e.g.
-unipdf) are incompatible — their license would infect the codebase.
-Pure Go PDF libraries under permissive licenses (`ledongthuc/pdf`,
-`pdfcpu`) handle simple cases but choke on complex layouts, scanned
-PDFs, and encrypted documents.
-
-#### Approach: Tiered Extraction
-
-**Tier 1 — Native Go (no external deps, handles ~80% of uploads):**
-
-| Format | Library | License |
-|--------|---------|---------|
-| TXT, Markdown, CSV | `io.ReadAll` | — |
-| PDF (simple) | `pdfcpu` | Apache 2.0 |
-| DOCX | `fumiama/go-docx` | MIT |
-
-**Tier 2 — LibreOffice Headless (handles everything else):**
-
-For PDF with complex layouts, ODT, ODS, ODP, RTF, DOC (legacy), XLS,
-and any format Tier 1 can't handle — shell out to LibreOffice headless:
-
-```bash
-libreoffice --headless --convert-to txt:Text /tmp/input.odt --outdir /tmp/
-```
-
-LibreOffice handles the full Office family:
-- OpenDocument: `.odt`, `.ods`, `.odp`
-- Microsoft: `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`
-- PDF (including scanned with basic OCR via hunspell)
-- RTF, HTML, and dozens more
-
-#### Extraction Queue (Filesystem-Backed)
-
-Extraction state lives on the PVC, not in memory. Pod restarts don't
-lose queue state. The queue is a directory convention:
-
-```
-/data/storage/
-├── attachments/ ← completed uploads (served to clients)
-└── processing/ ← extraction queue (internal only)
- └── {attachment_id}/
- ├── input ← original file (copy or hardlink)
- ├── status.json ← queue state + metadata
- ├── output.txt ← extracted text (when complete)
- └── thumb.jpg ← thumbnail (when complete)
-```
-
-**status.json:**
-
-```json
-{
- "state": "pending",
- "queued_at": "2026-02-25T14:30:00Z",
- "started_at": null,
- "completed_at": null,
- "content_type": "application/pdf",
- "filename": "report.pdf",
- "error": null
-}
-```
-
-States: `pending` → `processing` → `complete` | `failed`
-
-**Worker loop (runs in both inline and sidecar mode):**
-
-```
-1. Scan processing/ for dirs with status.state == "pending"
- (ordered by queued_at — FIFO)
-2. Respect concurrency cap (semaphore, default 3)
-3. Pick job → flip state to "processing"
-4. Tier 1: try native Go extraction
- ├── success → write output.txt, generate thumb, flip "complete"
- └── fail or unsupported →
-5. Tier 2: LibreOffice headless
- ├── success → write output.txt, generate thumb, flip "complete"
- └── fail → flip "failed", write error
-6. Copy results to PG:
- ├── UPDATE attachments SET extracted_text = ...,
- │ metadata = jsonb_set(metadata, '{extraction_status}', '"complete"')
- └── Thumbnail storage_key written to metadata
-7. Clean up processing/{id}/ dir
-```
-
-**In inline mode** (Unified image): the worker is a goroutine pool in
-the Go backend, scanning the queue directory on a 2-second tick.
-
-**In sidecar mode** (K8s): the sidecar runs the same loop as a
-standalone process. When extraction completes, it writes `output.txt`
-and `thumb.jpg` to the processing dir and flips status to `complete`.
-The Go backend polls the status file and copies results to PG.
-
-**Crash recovery:** On startup, scan for `state == "processing"` (stale
-from a crash). Reset to `pending` for re-processing. This is why the
-queue is on the filesystem — nothing is lost.
-
-**Concurrency:** Capped by semaphore. Default 3 concurrent extractions.
-Configurable via `EXTRACTION_CONCURRENCY` env var. LibreOffice is
-memory-hungry on complex docs — the cap prevents OOM on burst uploads.
-
-**Frontend queue awareness:**
-
-```
-Attachment status:
- uploading → progress bar
- pending → "Queued (2 ahead)" ← position from queue scan
- processing → "Extracting..." ← spinner
- complete → ✓ (ready)
- failed → ⚠ (send still enabled, model sees filename only)
-```
-
-The frontend polls `GET /api/v1/attachments/:id` every 2s for
-non-terminal states. The response includes `extraction_status` and
-`queue_position` (0 = currently processing, N = N jobs ahead).
-
-Send is blocked while any attachment is `uploading`. Once all are at
-least `pending`, send is enabled — the extraction results will be
-available by the time the model processes the request (optimistic), or
-the model sees the filename-only fallback (acceptable degradation).
-
-**Correction to earlier design:** Send is blocked only during `uploading`
-phase. Once the file is on disk and queued, the user can send. The
-completion handler checks extraction status at request time and uses
-whatever is available.
-
-#### Extraction Metadata in PG
-
-Attachment `metadata` JSONB tracks status (mirrored from queue):
-
-```json
-{
- "extraction_status": "complete",
- "extraction_error": null,
- "queue_position": 0,
- "page_count": 12,
- "dimensions": {"width": 1920, "height": 1080},
- "thumbnail_key": "attachments/{channel_id}/{id}_thumb.jpg",
- "thumb_dimensions": {"width": 280, "height": 147}
-}
-```
-
-States: `pending` → `processing` → `complete` | `failed` | `unavailable`
-
-#### LibreOffice Deployment Strategy
-
-**Two deployment modes, matching existing Docker architecture:**
-
-| Deployment | LibreOffice location | Reason |
-|-----------|---------------------|--------|
-| **Unified** (docker-compose, dev) | Baked into image | Single container, simplicity wins |
-| **K8s** (backend + frontend split) | Sidecar container | Separate scaling, lean backend image, independent version pinning |
-
-**Unified Dockerfile addition:**
-
-```dockerfile
-# In Dockerfile (unified)
-RUN apt-get update && apt-get install -y --no-install-recommends \
- libreoffice-core libreoffice-writer libreoffice-calc \
- && rm -rf /var/lib/apt/lists/*
-```
-
-**K8s sidecar (backend.yaml addition):**
-
-```yaml
-# In k8s/backend.yaml
-containers:
- - name: backend
- image: switchboard-backend:0.12.0
- volumeMounts:
- - name: storage
- mountPath: /data/storage
- env:
- - name: STORAGE_BACKEND
- value: "pvc"
- - name: STORAGE_PATH
- value: "/data/storage"
- - name: EXTRACTION_MODE
- value: "sidecar" # "inline" for unified, "sidecar" for k8s
-
- - name: extractor
- image: switchboard-extractor:0.12.0 # minimal image: alpine + libreoffice
- volumeMounts:
- - name: storage
- mountPath: /data/storage
- resources:
- requests:
- memory: "256Mi"
- limits:
- memory: "1Gi" # LibreOffice can spike on complex docs
-
-volumes:
- - name: storage
- persistentVolumeClaim:
- claimName: switchboard-storage
-```
-
-The sidecar image is small and purpose-built: Alpine + LibreOffice +
-a watcher script. No Go code, no API surface. Upgrades to LibreOffice
-are a tag bump on the sidecar image — backend image untouched.
-
-**Communication:** Filesystem-only via the shared PVC. No gRPC, no
-HTTP between containers. See Extraction Queue below.
-
-#### Allowed Types (Updated)
-
-```json
-{
- "storage_allowed_types": [
- "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml",
- "application/pdf",
- "text/plain", "text/markdown", "text/csv",
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- "application/vnd.openxmlformats-officedocument.presentationml.presentation",
- "application/vnd.oasis.opendocument.text",
- "application/vnd.oasis.opendocument.spreadsheet",
- "application/vnd.oasis.opendocument.presentation",
- "application/rtf",
- "application/msword",
- "application/vnd.ms-excel"
- ]
-}
-```
-
-### Thumbnail Generation
-
-Thumbnails are generated **asynchronously** alongside text extraction
-as part of the same post-upload processing pipeline.
-
-#### Image Thumbnails
-
-Use `golang.org/x/image/draw` (BSD, stdlib-adjacent) for resizing:
-
-```go
-// Resize to max 280px on longest edge, preserve aspect ratio
-// Output: JPEG at 80% quality
-// Stored at: attachments/{channel_id}/{attachment_id}_thumb.jpg
-```
-
-No external dependency. Pure Go. Fast enough for on-upload generation.
-
-#### Document Thumbnails
-
-When LibreOffice is available, generate a first-page preview:
-
-```bash
-# Convert first page to PDF, then to image
-libreoffice --headless --convert-to pdf /tmp/input.docx --outdir /tmp/
-# Then use Go image libraries to render first page
-# Or: convert to PNG directly via LibreOffice draw filter
-libreoffice --headless --convert-to png /tmp/input.docx --outdir /tmp/
-```
-
-For PDFs specifically, `pdfcpu` can extract page images under Apache 2.0.
-
-When LibreOffice is not available, documents get a generic file-type
-icon (handled in CSS, no thumbnail row needed).
-
-#### Storage
-
-Thumbnails stored alongside originals with `_thumb` suffix:
-
-```
-/data/storage/attachments/{channel_id}/
-├── {id}_report.pdf ← original
-├── {id}_report.pdf_thumb.jpg ← thumbnail
-├── {id}_photo.jpg ← original
-└── {id}_photo.jpg_thumb.jpg ← thumbnail
-```
-
-Thumbnail URL returned in attachment metadata:
-
-```json
-{
- "thumbnail_key": "attachments/{channel_id}/{id}_photo.jpg_thumb.jpg",
- "dimensions": {"width": 1920, "height": 1080},
- "thumb_dimensions": {"width": 280, "height": 147}
-}
-```
-
-Download endpoint: `GET /api/v1/attachments/:id/thumbnail` — same
-channel-scoped auth check as the full download.
-
----
-
-## Track 4: Frontend
-
-### Input Area Changes
-
-The `
` gets an attachment button and a preview
-strip:
-
-```
-┌──────────────────────────────────────────────┐
-│ ┌──────────┐ ┌──────────┐ │ ← attachment preview strip
-│ │ image.png│ │ report.pdf│ │ (visible only when files
-│ │ ✕ │ │ ✕ │ │ are staged)
-│ └──────────┘ └──────────┘ │
-├──────────────────────────────────────────────┤
-│ 📎 │ Send a message... │ ■ │ ▶ │ ← existing input + new clip btn
-└──────────────────────────────────────────────┘
-```
-
-### Upload Interactions
-
-| Trigger | Behavior |
-|---------|----------|
-| 📎 button click | File picker dialog (filtered by allowed types) |
-| Paste (Ctrl+V) binary | Auto-attach: screenshot/image → image attachment ({uuid}.{ext}) |
-| Paste (Ctrl+V) large text | Auto-attach: text > threshold → text file ({uuid}.txt) |
-| Paste (Ctrl+V) small text | Normal paste into textarea (below threshold) |
-| Drag-and-drop on chat area | Auto-attach file(s) as attachments (max 5 per message) |
-
-#### Smart Paste (Auto-Attach)
-
-Paste events are intercepted and routed automatically — no prompts, no
-user decisions. The clipboard content determines the behavior:
-
-**Detection logic:**
-
-```
-Clipboard paste event
- │
- ├── Contains files/images (binary)?
- │ └── YES → auto-attach each item
- │ ├── Image (screenshot, copied image) → upload as image attachment
- │ ├── File (dragged from OS) → upload as file attachment
- │ └── Filename: {uuid}.{ext} (ext from MIME type)
- │
- ├── Contains text?
- │ ├── Length ≤ threshold → normal paste into textarea
- │ └── Length > threshold → auto-attach as text file
- │ ├── Upload as attachment (content_type: text/plain)
- │ ├── Filename: {uuid}.txt
- │ └── Textarea stays empty (or keeps existing draft)
- │
- └── Empty → no-op
-```
-
-**Binary detection:** The `paste` event's `clipboardData.items` array
-carries type information. Items with `type.startsWith('image/')` or
-any non-text type are binary — always auto-attached. Text items
-(`text/plain`, `text/html`) are checked against the threshold.
-
-**UUID filenames:** All auto-attached pastes get UUID-based filenames.
-No timestamps, no `pasted-text.txt` collision issues. The original
-content type and paste source are captured in attachment metadata:
-
-```json
-{
- "source": "clipboard",
- "original_type": "image/png",
- "paste_length": 4200
-}
-```
-
-**Threshold:** `storage_paste_to_file_chars` global setting (default:
-2000 characters, admin-configurable). Setting to 0 disables text
-auto-attach (binary pastes still auto-attach regardless).
-
-**UX flow for a screenshot paste:**
-1. User hits Ctrl+V with a screenshot on clipboard
-2. Image appears instantly in the attachment preview strip (uploading...)
-3. Upload completes → thumbnail shown with checkmark
-4. User types their question, hits send
-5. Image goes to vision model as base64
-
-**UX flow for a large log paste:**
-1. User copies 5,000 chars of server logs, hits Ctrl+V
-2. Text file chip appears in attachment preview strip: `📄 {uuid}.txt (4.9 KB)`
-3. Extraction runs (trivial for plain text — near-instant)
-4. User types "what's wrong with these logs?", hits send
-5. Log content injected as extracted text context
-
-No prompts, no dialogs, no decisions. Paste just works.
-
-### State Management
-
-Staged attachments live in `App.stagedAttachments[]` — an array of
-`{id, filename, contentType, sizeBytes, previewUrl, extractionStatus}`
-populated after the upload API returns. On send, attachment IDs are
-included in the completion request body. On cancel/clear, staged
-attachments are deleted via the API.
-
-**Upload lifecycle per attachment:**
-
-```
-uploading → uploaded/queued → extracting → ready | failed
-```
-
-- `uploading`: progress bar visible, **send disabled**.
-- `uploaded/queued`: file on disk, extraction queued. Queue position
- shown: "Queued (2 ahead)". **Send enabled** — extraction will
- complete before or during model processing, or the model gets
- the filename-only fallback.
-- `extracting`: spinner on thumbnail. Send remains enabled.
-- `ready`: extraction complete (or image — no extraction needed).
- Green checkmark.
-- `failed`: extraction failed. Warning icon on thumbnail. Send still
- enabled — model sees `[Attached file: X (extraction failed)]`.
-
-**Key principle:** Send is blocked **only during active upload** (bytes
-in flight). Once the file is on disk and the attachment row exists,
-the user can send. This prevents the frustrating case where a user
-types their message while a 50-page PDF extracts for 30 seconds.
-
-**Polling:** Frontend polls `GET /api/v1/attachments/:id` every 2s for
-any attachment with `extractionStatus == "pending" | "processing"`.
-Stops when all settle. WebSocket notification is a future optimization.
-Polling updates the queue position indicator and thumbnail status icon.
-
-### Message Rendering
-
-Attachments on sent/received messages render inline:
-
-- **Images:** Thumbnail (from `_thumb.jpg`) with click-to-expand
- (lightbox or side panel). Rendered in the message bubble below/above
- the text.
-- **Documents:** File chip with icon, filename, size, and download link.
- `📄 report.pdf (2.3 MB) ⬇`
-- **Pasted text files:** Same as documents — `📄 {uuid}.txt (4.9 KB) ⬇`.
- Metadata `source: "clipboard"` available for future UX refinement.
-
-Attachment data comes from `messages.metadata.attachment_ids` →
-batch-fetched on conversation load via
-`GET /channels/:id/attachments`.
-
-### Capability-Aware UI
-
-The frontend already knows model capabilities from the model selector.
-
-**Images are always auto-attached regardless of current model** — the
-user might switch models before sending. When the selected model has
-`vision: false`:
-
-- Image attachments show a subtle indicator: `👁️🗨️` icon dimmed with
- tooltip "Current model doesn't support images"
-- File picker filters to documents only, but paste/drop still accepts
- images (user can switch models)
-- The 📎 button tooltip says "Attach document" instead of "Attach file"
-- On send with image + non-vision model: backend returns 400 with
- `"Selected model does not support image input — switch to a vision
- model or remove image attachments"`
-- Document uploads (PDF, DOCX, ODT, etc.) always work — text
- extraction doesn't require vision
-
-When storage is not configured (boot payload flag), the 📎 button is
-hidden entirely and all paste/drop auto-attach handlers are bypassed.
-
----
-
-## Privacy & Access Control Model
-
-### Principle
-
-**Attachments have no access identity of their own. They inherit access
-from their parent channel.** The channel membership check is the single
-gate for all attachment operations.
-
-### Access Matrix (v0.12.0 — Current Auth Model)
-
-| Channel type | Who can upload | Who can download | Delete |
-|---|---|---|---|
-| Personal DM | Channel owner | Channel owner | Owner |
-| (Future) Team channel | Team members with access | Team members with access | Uploader or team admin |
-| (Future) Group chat | Channel participants | Channel participants | Uploader or channel owner |
-| (Future) Workflow | Assigned participants | Assigned participants | Stage rules |
-
-### Enforcement Points
-
-Every attachment endpoint performs channel-scoped authorization:
-
-```go
-// Pseudocode — every handler follows this pattern
-func (h *Handler) downloadAttachment(c *gin.Context) {
- att := h.store.Attachments().GetByID(c, attID)
- channel := h.store.Channels().GetByID(c, att.ChannelID)
-
- // THE GATE: is the requester authorized for this channel?
- if channel.UserID != requesterID {
- c.JSON(403, gin.H{"error": "access denied"})
- return
- }
-
- // Future RBAC (v0.20.0): replace the above with:
- // if !h.rbac.CanAccess(requesterID, channel.ID, "attachment:read") {
- // c.JSON(403, ...)
- // }
-
- h.objectStore.Get(c, att.StorageKey) // → stream to client
-}
-```
-
-### Threat Mitigations
-
-| Threat | Mitigation |
-|--------|-----------|
-| ID guessing / enumeration | Every download joins through channel + verifies requester membership. UUIDs are non-sequential. |
-| Direct filesystem access | Files never served by nginx. All bytes go through Go backend with auth. No static file routes for storage path. |
-| Cross-team data leak | Channel membership is the boundary. HR channel attachments are only accessible to HR channel participants. |
-| Admin overreach | v0.12.0: admin can access (consistent with existing admin trust model). Audit-logged. v0.20.0: RBAC with team-scoped admin. |
-| Metadata leak | Channel attachment list endpoint also requires channel membership. No global search across attachments. |
-| Storage key prediction | Keys include UUID attachment_id — not guessable from channel_id + filename alone. |
-| Upload as attack vector | MIME type validated against allowlist. File size enforced. Content-Type set from server-side detection, not client header. |
-
-### Consistency with Existing Scoped Resources
-
-The attachment access pattern is identical to the pattern used for all
-scoped data in the system:
-
-```
-Resource Scope Access Gate
-──────────────────────────────────────────────────
-Notes (personal) user_id user_id = requester
-Notes (team) team_id requester ∈ team members
-Attachments channel_id requester ∈ channel (owner/participant)
-Usage data user/team scoped queries
-API keys user/org vault (UEK) / env-derived key
-```
-
-The row knows its scope. The handler enforces the boundary. The query
-filters by it.
-
-### Future RBAC Hooks (v0.20.0)
-
-The v0.12.0 access checks are written as explicit `if` checks that
-can be trivially replaced by a centralized RBAC call:
-
-```go
-// v0.12.0
-if channel.UserID != requesterID { deny }
-
-// v0.20.0
-if !rbac.Can(requesterID, channel.ID, permission) { deny }
-```
-
-The schema, storage layer, and API surface don't change. Only the
-authorization check function gets swapped.
-
----
-
-## Implementation Order
-
-```
-Phase 1: Storage Backend
- ├── Config additions (STORAGE_BACKEND, STORAGE_PATH, EXTRACTION_MODE)
- ├── ObjectStore interface
- ├── PVC implementation (~100 lines)
- ├── Startup validation (writable check, health)
- ├── K8s manifest (PVC + volume mount + STORAGE_CLASS)
- ├── Docker-compose volume mount
- ├── Admin status endpoint
- └── Boot payload: storage_configured flag
-
-Phase 2: Attachments CRUD + Extraction Pipeline
- ├── Migration 007_attachments.sql
- ├── models.Attachment struct
- ├── AttachmentStore (PG CRUD)
- ├── handlers/attachments.go (upload, download, delete, list, thumbnail)
- ├── Channel-scoped access checks on all endpoints
- ├── MIME validation + size enforcement (server-side detection)
- ├── Filesystem cleanup on channel delete (CASCADE + DeletePrefix)
- ├── Extraction queue (filesystem-backed):
- │ ├── processing/ directory convention + status.json
- │ ├── Worker loop with semaphore (default 3 concurrent)
- │ ├── Tier 1: native Go (pdfcpu, go-docx, io.ReadAll)
- │ ├── Tier 2: LibreOffice headless (inline or sidecar)
- │ ├── Crash recovery (reset stale "processing" → "pending")
- │ └── PG metadata mirror (extraction_status, queue_position)
- ├── Thumbnail generation (images: Go resize, docs: LibreOffice)
- ├── Unified Dockerfile: add libreoffice-core
- ├── Sidecar Dockerfile: alpine + libreoffice + watcher
- └── K8s backend.yaml: sidecar container definition
-
-Phase 3: Multimodal Message Assembly
- ├── ContentPart + ImageURL types in providers/provider.go
- ├── OpenAI message conversion (content array marshaling)
- ├── Anthropic message conversion (image source blocks)
- ├── OpenRouter / Venice (inherit OpenAI path)
- ├── Completion handler: attachment → base64 → content parts
- ├── Completion handler: extraction_status check at request time
- ├── Capability gating (vision check before image assembly)
- ├── Document text injection (extracted_text → text content part)
- └── Usage logging for multimodal requests
-
-Phase 4: Frontend
- ├── Attachment button (📎) in input area
- ├── File picker with type filtering (capability-aware)
- ├── Smart paste handler:
- │ ├── Binary detection (images, files → auto-attach)
- │ ├── Large text detection (> threshold → auto-attach as {uuid}.txt)
- │ ├── Small text → normal textarea paste
- │ └── UUID filename generation
- ├── Drag-and-drop on chat area
- ├── Staged attachment preview strip with status indicators:
- │ ├── Upload progress bar
- │ ├── Queue position ("Queued (2 ahead)")
- │ ├── Extraction spinner
- │ ├── Ready checkmark / failed warning
- │ └── Send blocked only during active upload
- ├── Extraction status polling (2s interval, stops at terminal state)
- ├── Attachment rendering in messages (thumbnails + doc chips)
- ├── Image lightbox / side panel expand
- ├── Capability-aware UI (hide/filter by vision support)
- ├── Storage-not-configured state (hide upload UI)
- └── Admin Storage panel (status, orphan count, cleanup button)
-
-Phase 5: Stragglers
- ├── vault rekey CLI command
- ├── Admin encryption status indicator
- └── Per-chat model/preset persistence (server-side)
-
-Phase 6: Admin Cleanup
- ├── POST /admin/storage/cleanup endpoint
- ├── Orphan detection query (message_id IS NULL, age > 24h)
- ├── Admin Storage panel: orphan count + reclaimable space card
- └── "Run Cleanup Now" button
-```
-
-### Testing
-
-- Integration tests: upload → send message with attachment → verify
- multimodal completion request → verify download auth
-- Access control tests: upload to channel A, attempt download from
- user who owns channel B → 403
-- Storage backend tests: put/get/delete/deletePrefix round-trip
-- Extraction tests: PDF/DOCX/TXT → extracted_text populated
-- Frontend tests: staged attachments state, capability gating
-
----
-
-## Migration Summary
-
-| Migration | Tables/Columns |
-|-----------|---------------|
-| 007_attachments.sql | `attachments` table with indexes |
-
-No changes to existing tables. The `messages.metadata` JSONB carries
-`attachment_ids` without schema changes. The `channels.settings` JSONB
-carries `last_selector_id` without schema changes.
-
----
-
-## Decisions Made
-
-1. **Text extraction** — Tiered: native Go (pdfcpu Apache 2.0, go-docx
- MIT) for common cases, LibreOffice headless for everything else
- including ODF formats. No AGPL dependencies.
-
-2. **Thumbnails** — Yes. Generated async post-upload. Images via Go
- stdlib (`golang.org/x/image/draw`). Documents via LibreOffice
- first-page render. Stored alongside originals with `_thumb` suffix.
-
-3. **Max attachments per message** — 5 files (admin-configurable).
-
-4. **Extraction timing** — Async with filesystem-backed queue. Upload
- returns immediately, extraction runs in background via worker loop
- with concurrency cap. Frontend polls for status. Send blocked only
- during active upload (bytes in flight), not during extraction.
-
-5. **Smart paste** — Automatic, no prompts. Binary clipboard items
- (screenshots, images) always auto-attach. Text exceeding threshold
- (default 2000 chars) auto-attaches as `{uuid}.txt`. Below threshold
- pastes normally into textarea. UUID filenames, MIME detection from
- clipboard data. Zero user decisions.
-
-## Global Settings (New)
-
-| Key | Default | Description |
-|-----|---------|-------------|
-| `storage_max_file_size` | `10485760` (10MB) | Per-file upload limit |
-| `storage_max_upload_size` | `52428800` (50MB) | Per-request total limit |
-| `storage_allowed_types` | *(see Allowed Types)* | MIME type allowlist |
-| `storage_max_attachments_per_message` | `5` | Max files per message |
-| `storage_paste_to_file_chars` | `2000` | Paste-to-file threshold (0=disabled) |
-| `storage_user_quota_bytes` | `0` | Per-user quota (0=unlimited, future enforcement) |
-
-## Decisions Made (Continued)
-
-6. **Sidecar base image** — Debian slim. Rock-solid LibreOffice packages,
- ~80MB larger than Alpine but no flaky package issues.
-
-7. **Queue persistence** — Dual: filesystem `status.json` is source of
- truth (crash recovery, `ls` debuggability), mirrored to PG metadata
- for API serving. Worth the bookkeeping for operational transparency.
-
-## Open Questions
-
-*(None remaining — all design decisions resolved.)*
-
-## Orphan Cleanup (Admin Action)
-
-Attachments uploaded but never associated with a message (user closes
-tab, browser crash, abandoned upload) accumulate as orphans.
-
-**v0.12.0: manual admin action with "Run Now" button.**
-
-```
-POST /admin/storage/cleanup
- → Scan attachments WHERE message_id IS NULL
- AND created_at < NOW() - interval '24 hours'
- → Delete PG rows + storage files + processing dirs
- → Return { deleted: 3, freed_bytes: 15728640 }
-```
-
-**Admin Storage panel card:**
-
-```
-┌─ Orphan Files ──────────────────────────────┐
-│ 3 orphaned attachments (15 MB reclaimable) │
-│ │
-│ Files not linked to any message for >24hrs │
-│ │
-│ [Run Cleanup Now] │
-└─────────────────────────────────────────────┘
-```
-
-The 24-hour grace period prevents cleaning up files that are simply
-in the middle of a long compose session.
-
-**Future (v0.22.0 task runner):** This becomes a scheduled task running
-daily. The admin button stays as an on-demand override. The endpoint
-is identical — the scheduler just calls it on a cron.
diff --git a/docs/DESIGN-0.13.1.md b/docs/DESIGN-0.13.1.md
deleted file mode 100644
index 722137e..0000000
--- a/docs/DESIGN-0.13.1.md
+++ /dev/null
@@ -1,231 +0,0 @@
-# DESIGN-0.13.1 — Web Search + URL Fetch + Tool Toggle
-
-## Overview
-
-Built-in `web_search` and `url_fetch` tools using the existing tool framework (v0.11.0),
-plus a chat-bar tools toggle menu so users can enable/disable tool categories per-session.
-
-Depends on: tool framework (v0.11.0), admin panel (v0.13.0).
-
----
-
-## 1. Tool Categories
-
-Add a `Category` field to `ToolDef` so tools self-declare their group.
-The frontend uses categories for the toggle menu; the backend ignores them.
-
-| Category | Tools | Default |
-|-----------|---------------------------------------------|---------|
-| search | `web_search`, `url_fetch` | ON |
-| notes | `note_create`, `note_search`, `note_update`, `note_list` | ON |
-| utilities | `datetime`, `calculator` | ON |
-| browser | (extension-defined: `js_eval`, `regex_test`)| ON |
-
-```go
-type ToolDef struct {
- Name string `json:"name"`
- DisplayName string `json:"display_name,omitempty"` // human-readable label for UI
- Description string `json:"description"`
- Parameters json.RawMessage `json:"parameters"`
- Category string `json:"category,omitempty"` // NEW
-}
-```
-
-## 2. Per-Request Tool Filtering
-
-### Request field
-```json
-{ "disabled_tools": ["web_search", "url_fetch"] }
-```
-
-### Backend filter
-`buildToolDefs()` skips tools whose name appears in `disabled_tools`.
-Zero API changes — the field is optional, empty = all tools enabled.
-
-### Frontend state
-`localStorage` key: `cs-disabled-tools` → JSON array of tool names.
-The tools toggle menu flips categories on/off which maps to individual tool names.
-
-## 3. web_search Tool
-
-### Search Provider Interface
-```go
-type SearchProvider interface {
- Search(ctx context.Context, query string, maxResults int) ([]SearchResult, error)
- Name() string
-}
-
-type SearchResult struct {
- Title string `json:"title"`
- URL string `json:"url"`
- Snippet string `json:"snippet"`
-}
-```
-
-### DuckDuckGo Provider (default — no API key)
-Uses the DuckDuckGo HTML search endpoint. No rate-limit key required.
-Falls back gracefully if blocked (returns error result to LLM, doesn't crash).
-
-### SearXNG Provider (self-hosted)
-Configurable endpoint + optional API key. JSON API (`/search?format=json`).
-Good fit for airgapped deployments.
-
-### Admin Config
-Stored in `global_config` table (existing):
-- `search_provider`: `"duckduckgo"` | `"searxng"` (default: `"duckduckgo"`)
-- `search_endpoint`: SearXNG URL (only used when provider=searxng)
-- `search_max_results`: `5` (default)
-
-Admin UI: System → Settings section (new "Search" subsection).
-
-### Tool Schema
-```json
-{
- "name": "web_search",
- "description": "Search the web for current information. Returns titles, URLs, and snippets.",
- "category": "search",
- "parameters": {
- "type": "object",
- "properties": {
- "query": { "type": "string", "description": "Search query" },
- "max_results": { "type": "integer", "description": "Max results (1-10, default 5)" }
- },
- "required": ["query"]
- }
-}
-```
-
-## 4. url_fetch Tool
-
-HTTP GET with content extraction. Reuses the text extraction pattern
-from file handling (v0.12.0) where possible.
-
-### Behavior
-1. HTTP GET with reasonable timeout (15s) and User-Agent
-2. Follow redirects (max 3)
-3. Extract readable text content (strip HTML tags, scripts, styles)
-4. Truncate to ~8000 chars to avoid flooding the context
-5. Return title + extracted text + metadata
-
-### Safety
-- Respect robots.txt? No — the LLM is fetching on behalf of the user, not crawling.
-- Block private/internal IPs (10.x, 192.168.x, 127.x, ::1) to prevent SSRF.
-- Configurable domain allowlist/blocklist in admin settings (future).
-
-### Tool Schema
-```json
-{
- "name": "url_fetch",
- "description": "Fetch and extract readable text content from a URL.",
- "category": "search",
- "parameters": {
- "type": "object",
- "properties": {
- "url": { "type": "string", "description": "URL to fetch" },
- "max_length": { "type": "integer", "description": "Max content length in chars (default 8000)" }
- },
- "required": ["url"]
- }
-}
-```
-
-## 5. Frontend: Tools Toggle Menu
-
-### UI Position
-Chat input bar, left side, next to the attach button. Wrench/tool icon.
-
-```
-┌──────────────────────────────────────┐
-│ 📎 🔧 [message input...] [Send]│
-└──────────────────────────────────────┘
- ▲
- ┌────────────────┐
- │ ☑ Web Search │
- │ ☑ Notes │
- │ ☑ Utilities │
- │ ☑ Extensions │
- └────────────────┘
-```
-
-### Behavior
-- Click tool icon → popup above (using existing popup-menu pattern)
-- Each category row has a toggle checkbox + expand chevron
-- **Category toggle**: master on/off for all tools in the category
-- **Expand chevron**: shows individual tool rows indented below
-- **Tri-state checkbox**: if some tools in a category are disabled, category shows indeterminate (─)
-- Toggling a category adds/removes all its tool names from `cs-disabled-tools`
-- Toggling an individual tool adds/removes just that name
-- State persists across page reloads via localStorage
-- Disabled tools sent as `disabled_tools` array in completion request
-- Tool icon shows visual indicator when any category is disabled
-- If model doesn't support tool_calling, icon is hidden/grayed
-
-```
-┌────────────────────────┐
-│ ☑ 🔍 Web Search › │ ← click › to expand
-│ ☑ Search │
-│ ☑ Fetch URL │
-│ ☑ 📝 Notes › │
-│ ☐ 🧮 Utilities › │ ← category off = all children off
-│ ☑ 🧩 Extensions › │
-└────────────────────────┘
-```
-
-### Tool Manifest
-Frontend needs to know the category mapping and display names.
-
-**API endpoint** — `GET /api/v1/tools` returns `[{name, display_name, category, description}]`
-The frontend caches this on login. `display_name` provides human-readable labels
-(e.g. "Search" instead of "web_search", "Create" instead of "note_create").
-
-### New API Endpoint
-```
-GET /api/v1/tools → [{name, description, category}]
-```
-Returns server-side tools + browser extension tool schemas.
-Used by frontend to build the toggle menu dynamically.
-
-## 6. File Structure
-
-### New backend files
-- `server/tools/websearch.go` — web_search tool + search provider interface
-- `server/tools/urlfetch.go` — url_fetch tool
-- `server/tools/search/duckduckgo.go` — DuckDuckGo provider
-- `server/tools/search/searxng.go` — SearXNG provider
-- `server/tools/search/provider.go` — interface + registry
-
-### Modified backend files
-- `server/tools/types.go` — add Category to ToolDef
-- `server/tools/registry.go` — add AllDefinitionsFiltered(disabled)
-- `server/handlers/completion.go` — accept disabled_tools, filter in buildToolDefs
-- `server/handlers/messages.go` — same filtering
-- `server/handlers/routes.go` — add GET /api/v1/tools endpoint
-- `server/tools/datetime.go` — add Category: "utilities"
-- `server/tools/calculator.go` — add Category: "utilities"
-- `server/tools/notes.go` — add Category: "notes"
-
-### New/modified frontend files
-- `src/index.html` — tools toggle button in input-wrap
-- `src/css/styles.css` — tools popup styling
-- `src/js/chat.js` — send disabled_tools in completion, wire toggle
-- `src/js/api.js` — add disabled_tools param, add getTools() method
-
-## 7. Phased Delivery
-
-**Phase 1: Backend tools + filtering**
-- ToolDef.Category, disabled_tools filtering
-- web_search + url_fetch tools
-- DuckDuckGo provider
-- GET /api/v1/tools endpoint
-- Tests
-
-**Phase 2: Frontend toggle**
-- Tools toggle popup menu
-- localStorage persistence
-- Completion request wiring
-- CSS
-
-**Phase 3: Admin config + SearXNG**
-- Search provider settings in admin panel
-- SearXNG provider implementation
-- Domain filtering
diff --git a/docs/DESIGN-0.14.0.md b/docs/DESIGN-0.14.0.md
deleted file mode 100644
index da86917..0000000
--- a/docs/DESIGN-0.14.0.md
+++ /dev/null
@@ -1,845 +0,0 @@
-# DESIGN-0.14.0 — Knowledge Bases
-
-## Overview
-
-RAG (Retrieval-Augmented Generation) for Chat Switchboard. Users upload
-documents into named knowledge bases, the backend chunks and embeds them
-via the embedding model role (v0.10.0), stores vectors in pgvector, and a
-`kb_search` tool lets the LLM pull relevant context at completion time.
-
-Depends on: embedding model role (v0.10.0), file storage (v0.12.0),
-tool framework (v0.11.0), admin panel (v0.13.0).
-
----
-
-## 0. Pre-Req Fix: Embedding Model Selection
-
-The embedding role dropdown is broken — `filterModels()` in
-`ui-primitives.js` filters by `model_type === 'embedding'`, but many
-providers don't report a type for embedding models (defaults to `'chat'`
-and gets filtered out). This blocks KB setup entirely.
-
-**Fix (ship before or with Phase 1):**
-
-1. **Manual model ID entry** — add a text input fallback alongside the
- dropdown. If the dropdown shows no results for the selected provider,
- display an editable text field pre-populated with the `model_id` from
- the saved binding (if any). Users type the model ID directly
- (e.g. `text-embedding-3-small`). The save handler accepts either
- dropdown selection or manual text entry.
-
-2. **Tolerant type filter** — change `filterModels()` to include models
- where `model_type` is empty/null/undefined when the role's type filter
- is `'embedding'`. Embedding models are rare enough that showing
- untyped models alongside typed ones is better than showing nothing.
-
-```javascript
-// Before (strict — breaks when model_type missing)
-(m.model_type || 'chat') === typeFilter
-
-// After (tolerant for embedding role)
-typeFilter === 'embedding'
- ? (!m.model_type || m.model_type === 'embedding')
- : (m.model_type || 'chat') === typeFilter
-```
-
-3. **Combo UI** — the slot renderer gets a hybrid dropdown+input:
-
-```
-┌──────────────────────────────────────────────┐
-│ Provider: [OpenAI ▾] │
-│ Model: [text-embedding-3-small ▾] [✎] │
-│ ↑ toggle │
-│ [text-embedding-3-small ] │ ← manual entry (shown on ✎ click)
-└──────────────────────────────────────────────┘
-```
-
-The pencil icon toggles between dropdown and text input. If the dropdown
-has zero options after provider change, auto-switch to manual entry.
-Saved bindings with model IDs not in the dropdown auto-show in manual
-mode on load.
-
----
-
-## 1. Schema — Migration `008_knowledge_bases.sql`
-
-### pgvector Extension
-
-```sql
-CREATE EXTENSION IF NOT EXISTS vector;
-```
-
-Requires `pgvector` installed on the PostgreSQL server. The migration
-checks for the extension and fails with a clear error if unavailable,
-rather than silently degrading. The README/deployment docs will note the
-dependency.
-
-**Dimension handling:** Embedding dimensions vary by model
-(OpenAI ada-002 = 1536, text-embedding-3-small = 1536,
-text-embedding-3-large = 3072, many open-source = 768 or 1024).
-The dimension is stored per KB and the vector column uses the max
-supported (3072), with vectors zero-padded or truncated at insert time.
-Alternative: use `halfvec` for storage efficiency. We go with a single
-fixed column at 3072 and document the tradeoff.
-
-> **Decision:** 3072 is generous but future-proof. If storage becomes
-> an issue, we add a `dimensions` column to `knowledge_bases` and
-> migrate to per-dimension indexes later. For v0.14.0, KISS.
-
-### Tables
-
-```sql
--- ── Knowledge Bases ──────────────────────────
-
-CREATE TABLE knowledge_bases (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- name TEXT NOT NULL,
- description TEXT NOT NULL DEFAULT '',
- scope TEXT NOT NULL DEFAULT 'global', -- global, team, personal
- owner_id UUID, -- user_id (personal) or NULL
- team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
- embedding_config JSONB NOT NULL DEFAULT '{}', -- snapshot: { provider_config_id, model_id, dimensions }
-
- -- Stats (denormalized, updated on ingest)
- document_count INT NOT NULL DEFAULT 0,
- chunk_count INT NOT NULL DEFAULT 0,
- total_bytes BIGINT NOT NULL DEFAULT 0,
-
- status TEXT NOT NULL DEFAULT 'active', -- active, processing, error
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-
- CONSTRAINT kb_scope_check CHECK (
- (scope = 'global' AND owner_id IS NULL) OR
- (scope = 'team' AND team_id IS NOT NULL) OR
- (scope = 'personal' AND owner_id IS NOT NULL)
- )
-);
-
-CREATE INDEX idx_kb_scope ON knowledge_bases(scope);
-CREATE INDEX idx_kb_owner ON knowledge_bases(owner_id) WHERE owner_id IS NOT NULL;
-CREATE INDEX idx_kb_team ON knowledge_bases(team_id) WHERE team_id IS NOT NULL;
-
--- ── KB Documents ─────────────────────────────
--- Metadata row per uploaded document. Blobs live in ObjectStore
--- under key: kb/{kb_id}/{doc_id}_{filename}
-
-CREATE TABLE kb_documents (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
- filename TEXT NOT NULL,
- content_type TEXT NOT NULL,
- size_bytes BIGINT NOT NULL,
- storage_key TEXT NOT NULL, -- ObjectStore key
- extracted_text TEXT, -- full extracted text (for re-chunking)
- chunk_count INT NOT NULL DEFAULT 0,
- status TEXT NOT NULL DEFAULT 'pending', -- pending, chunking, embedding, ready, error
- error TEXT,
- uploaded_by UUID NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-
-CREATE INDEX idx_kbdoc_kb ON kb_documents(kb_id);
-
--- ── KB Chunks ────────────────────────────────
--- Chunked text + embedding vector for similarity search.
-
-CREATE TABLE kb_chunks (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
- document_id UUID NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE,
- chunk_index INT NOT NULL, -- ordinal within document
- content TEXT NOT NULL, -- chunk text
- token_count INT NOT NULL DEFAULT 0,
- embedding vector(3072), -- pgvector column
- metadata JSONB NOT NULL DEFAULT '{}', -- { page, section, heading, ... }
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-
-CREATE INDEX idx_kbchunk_kb ON kb_chunks(kb_id);
-CREATE INDEX idx_kbchunk_doc ON kb_chunks(document_id);
-
--- IVFFlat index for similarity search
--- (created after initial data load; needs rows to train)
--- Deferred: created by ingest handler after first batch
--- CREATE INDEX idx_kbchunk_embedding ON kb_chunks
--- USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
-
--- ── Channel-KB Links ─────────────────────────
--- Which KBs are active for a given channel.
-
-CREATE TABLE channel_knowledge_bases (
- channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
- kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
- enabled BOOLEAN NOT NULL DEFAULT true,
- added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- PRIMARY KEY (channel_id, kb_id)
-);
-```
-
-### Scope Rules
-
-| Scope | Visible to | Created by | Embedding cost |
-|----------|------------------------|-------------------|-----------------|
-| global | All users | Admin | Org provider |
-| team | Team members | Team admin | Team provider |
-| personal | Owner only | BYOK user | User's own key |
-
-Personal KBs follow the same BYOK pattern as personal providers — the
-user's UEK decrypts their embedding provider key. Embedding costs are
-logged to usage_log with `role = 'embedding'`.
-
----
-
-## 2. Chunking Pipeline
-
-### Strategy: Recursive Character Splitter
-
-Start simple, iterate. Recursive splitting with configurable chunk size
-and overlap. No semantic chunking in v0.14.0 (requires embedding every
-boundary candidate — expensive, diminishing returns for a first pass).
-
-```go
-// server/knowledge/chunker.go
-
-type ChunkConfig struct {
- ChunkSize int // target chars per chunk (default 1000)
- ChunkOverlap int // overlap chars between chunks (default 200)
- Separators []string // split hierarchy: ["\n\n", "\n", ". ", " "]
-}
-
-type Chunk struct {
- Content string
- Index int // ordinal in document
- TokenCount int // estimated token count
- Metadata map[string]any // page, heading context
-}
-
-func SplitText(text string, cfg ChunkConfig) []Chunk
-```
-
-**Default config:** 1000 chars, 200 overlap (~250 tokens per chunk).
-These defaults are stored in `knowledge_bases.embedding_config` and
-overridable per-KB via the admin UI. The chunker tries separators in
-order: paragraph breaks first, then line breaks, then sentences, then
-words.
-
-### Text Extraction Reuse
-
-Documents uploaded to KBs use the same extraction pipeline as
-attachments (v0.12.0). For inline-extractable types (TXT, MD, CSV),
-text is read directly. For sidecar types (PDF, DOCX), the extraction
-queue processes them. The extracted text is stored in
-`kb_documents.extracted_text` for re-chunking if config changes.
-
----
-
-## 3. Embedding Pipeline
-
-### Flow
-
-```
-Upload → Extract Text → Chunk → Embed (batch) → Store vectors
-```
-
-### Batch Embedding
-
-The embedding role's `Embed()` method already accepts `[]string` input.
-Chunks are batched in groups of 100 (or fewer, respecting provider token
-limits). Each batch is a single API call.
-
-```go
-// server/knowledge/embedder.go
-
-type Embedder struct {
- roleResolver *roles.Resolver
-}
-
-// EmbedChunks generates embeddings for a slice of chunks.
-// Uses the embedding role with the resolution chain:
-// personal → team → global (same as other role consumers).
-func (e *Embedder) EmbedChunks(ctx context.Context, userID string,
- teamID *string, chunks []Chunk) ([][]float64, error)
-```
-
-### Dimension Normalization
-
-The embedding response includes raw vectors. If the vector dimension
-is less than 3072 (the column width), zero-pad. If greater, truncate
-(unlikely but defensive). Store the actual dimension in
-`knowledge_bases.embedding_config.dimensions` so search queries can
-account for it.
-
-> **Note:** Mixing embedding models within a KB is invalid — vectors
-> from different models aren't comparable. The `embedding_config` is
-> snapshotted at KB creation. Changing the embedding model requires
-> re-embedding all documents (explicit admin action).
-
----
-
-## 4. Ingestion Flow
-
-No background job system exists yet (that's v0.15.0 compaction).
-Ingestion is **synchronous with progress polling** — same pattern as
-the extraction queue (v0.12.0).
-
-### API Endpoints
-
-```
-POST /api/v1/knowledge-bases — Create KB
-GET /api/v1/knowledge-bases — List (scoped)
-GET /api/v1/knowledge-bases/:id — Get KB details
-PUT /api/v1/knowledge-bases/:id — Update name/description
-DELETE /api/v1/knowledge-bases/:id — Delete KB + all docs/chunks
-
-POST /api/v1/knowledge-bases/:id/documents — Upload document(s)
-GET /api/v1/knowledge-bases/:id/documents — List documents
-DELETE /api/v1/knowledge-bases/:id/documents/:docId — Delete document + chunks
-
-GET /api/v1/knowledge-bases/:id/documents/:docId/status — Poll ingestion status
-
-POST /api/v1/knowledge-bases/:id/rebuild — Re-chunk + re-embed all docs
-
-POST /api/v1/knowledge-bases/:id/search — Direct search (debug/admin)
-```
-
-Admin endpoints mirror the pattern under `/api/v1/admin/knowledge-bases/`
-for global KBs. Team KBs under `/api/v1/teams/:teamId/knowledge-bases/`.
-Personal KBs under the base path (scoped by auth).
-
-### Upload + Ingest Sequence
-
-```
-Client Server
- │ │
- ├── POST /kb/:id/documents ───► │ Save blob to ObjectStore
- │ (multipart/form-data) │ Create kb_documents row (status=pending)
- │ │ Return { document_id, status: "pending" }
- │ │
- │ │── goroutine: ingestDocument()
- │ │ ├─ Extract text (inline or queue)
- │ │ ├─ status → "chunking"
- │ │ ├─ Chunk text
- │ │ ├─ status → "embedding"
- │ │ ├─ Batch embed chunks
- │ │ ├─ INSERT kb_chunks with vectors
- │ │ ├─ Update KB stats
- │ │ └─ status → "ready"
- │ │
- ├── GET /kb/:id/docs/:d/status► │ Return current status + progress
- │ (poll every 2s) │ { status, chunk_count, error }
- ◄───────────────────────────── │
-```
-
-The goroutine is fire-and-forget per document. The status column on
-`kb_documents` is the progress indicator. Frontend polls until
-`status = 'ready'` or `status = 'error'`.
-
-**Concurrency:** A per-process semaphore (channel of size 3) limits
-concurrent ingestion goroutines. Additional uploads queue behind the
-semaphore. This prevents overwhelming the embedding provider with
-parallel batch requests.
-
----
-
-## 5. Search — `kb_search` Tool
-
-### Tool Definition
-
-```go
-// server/tools/kbsearch.go
-
-var kbSearchDef = ToolDef{
- Name: "kb_search",
- DisplayName: "Knowledge Base",
- Description: "Search knowledge bases for relevant information. " +
- "Returns text passages from uploaded documents that match the query.",
- Category: "knowledge",
- Parameters: JSONSchema(map[string]interface{}{
- "query": Prop("string", "Search query — use natural language"),
- "max_results": map[string]interface{}{
- "type": "integer",
- "description": "Maximum results to return (1-20, default 5)",
- },
- }, []string{"query"}),
-}
-```
-
-### Execution
-
-```go
-func (t *KBSearchTool) Execute(ctx context.Context,
- execCtx ExecutionContext, argsJSON string) (string, error) {
-
- // 1. Parse args
- // 2. Get active KBs for this channel
- // (from channel_knowledge_bases where enabled = true)
- // PLUS personal KBs owned by user (always available)
- // 3. Embed the query using the embedding role
- // 4. Similarity search across all active KB chunks
- // 5. Format results with source attribution
-}
-```
-
-### Similarity Query
-
-```sql
-SELECT c.content, c.metadata, d.filename, kb.name as kb_name,
- 1 - (c.embedding <=> $1::vector) AS similarity
-FROM kb_chunks c
-JOIN kb_documents d ON c.document_id = d.id
-JOIN knowledge_bases kb ON c.kb_id = kb.id
-WHERE c.kb_id = ANY($2) -- active KB IDs
- AND 1 - (c.embedding <=> $1) > $3 -- similarity threshold (0.3 default)
-ORDER BY c.embedding <=> $1
-LIMIT $4; -- max_results
-```
-
-### Result Format
-
-```json
-{
- "results": [
- {
- "content": "Chunk text here...",
- "source": "quarterly-report.pdf",
- "kb": "Q4 Reports",
- "similarity": 0.87,
- "metadata": { "page": 12 }
- }
- ],
- "query": "revenue growth Q4",
- "searched_kbs": ["Q4 Reports", "Financial Data"]
-}
-```
-
-### ExecutionContext Extension
-
-The `kb_search` tool needs access to the store layer and role resolver,
-which the current `ExecutionContext` doesn't provide. Two options:
-
-**Option A: Expand ExecutionContext** — add optional fields:
-
-```go
-type ExecutionContext struct {
- UserID string
- ChannelID string
- // v0.14.0 additions
- Stores *store.Stores // nil for tools that don't need it
- RoleResolver *roles.Resolver // nil for tools that don't need it
- TeamID *string // user's team (for role resolution)
-}
-```
-
-**Option B: Closure injection** — the tool struct captures dependencies
-at registration time:
-
-```go
-type kbSearchTool struct {
- stores store.Stores
- roleResolver *roles.Resolver
-}
-
-func init() {
- // Deferred registration — called from main.go after stores init
-}
-```
-
-**Decision: Option B (closure injection).** It's the same pattern the
-notes tool already uses (it captures the NoteStore). The `kb_search`
-tool is registered after stores and role resolver init in `main.go`,
-not via `init()`. Add a `RegisterLate()` function or explicit call.
-
----
-
-## 6. Context Injection
-
-When `kb_search` returns results, they flow through the existing tool
-call loop in `stream_loop.go` — no special injection needed. The tool
-result becomes a `role: "tool"` message in the conversation, and the
-LLM sees the retrieved context naturally.
-
-### Automatic vs. Tool-Based
-
-Two approaches to getting KB context into the conversation:
-
-| Approach | Pros | Cons |
-|----------|------|------|
-| **Tool-based** (v0.14.0) | LLM decides when to search; transparent; works with existing tool loop | Extra round-trip; LLM might not search when it should |
-| **Auto-inject** (future) | Always available; no tool call overhead | Wastes tokens if not needed; opaque to user |
-
-**v0.14.0: Tool-based only.** The LLM calls `kb_search` when it needs
-information. Auto-injection (pre-pend top-K results to every prompt) is
-a v0.15.x consideration when we have compaction to manage context
-budget.
-
-### System Prompt Hint
-
-When KBs are active on a channel, append a hint to the system prompt:
-
-```
-You have access to the following knowledge bases:
-- "Q4 Reports" (3 documents)
-- "Engineering Wiki" (12 documents)
-Use the kb_search tool to find relevant information from these sources.
-```
-
-This nudges the model to use the tool without auto-injecting content.
-
----
-
-## 7. Notes Embedding
-
-Notes already have full-text search via PostgreSQL `tsvector`. Adding
-vector search means notes can be found semantically, not just by keyword.
-
-**Approach:** Treat notes as single-chunk documents. On note
-create/update, embed the full note text and store in a new column:
-
-```sql
-ALTER TABLE notes ADD COLUMN embedding vector(3072);
-```
-
-The `note_search` tool (already exists) gains an optional
-`semantic: true` parameter that switches from `tsvector` to vector
-similarity search. Both results can be merged and deduplicated.
-
-**Deferred to Phase 3** — notes embedding is additive and independent
-of the core KB pipeline. Ship KB search first, add note embeddings
-after the pipeline is proven.
-
----
-
-## 8. Per-Channel KB Toggle
-
-### UI: Chat Sidebar
-
-A "Knowledge" button in the chat header or input bar (next to the tools
-toggle). Click opens a popup listing available KBs with toggle switches.
-
-```
-┌─────────────────────────────────┐
-│ 📚 Knowledge Bases │
-│ │
-│ ☑ Q4 Reports (3 docs) │
-│ ☑ Engineering Wiki (12 docs) │
-│ ☐ HR Policies (5 docs) │
-│ │
-│ Manage KBs... │
-└─────────────────────────────────┘
-```
-
-### State
-
-Active KBs per channel stored in `channel_knowledge_bases`. When a user
-toggles a KB on/off for a channel, the frontend calls:
-
-```
-PUT /api/v1/channels/:id/knowledge-bases
-Body: { kb_ids: ["uuid1", "uuid2"] }
-```
-
-The completion handler reads active KBs to populate the system prompt
-hint and to scope `kb_search` results.
-
-### Visibility Rules
-
-A user can toggle KBs they have access to:
-- **Global KBs** — all users
-- **Team KBs** — team members
-- **Personal KBs** — owner only
-
-The list endpoint returns all KBs the user can see, with `enabled`
-status per channel.
-
----
-
-## 9. Store Interface
-
-```go
-// server/store/interfaces.go — additions
-
-// =========================================
-// KNOWLEDGE BASE STORE
-// =========================================
-
-type KnowledgeBaseStore interface {
- // KB CRUD
- Create(ctx context.Context, kb *models.KnowledgeBase) error
- GetByID(ctx context.Context, id string) (*models.KnowledgeBase, error)
- Update(ctx context.Context, id string, fields map[string]interface{}) error
- Delete(ctx context.Context, id string) error
-
- // Scoped listing
- ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error)
- ListGlobal(ctx context.Context) ([]models.KnowledgeBase, error)
- ListForTeam(ctx context.Context, teamID string) ([]models.KnowledgeBase, error)
- ListPersonal(ctx context.Context, userID string) ([]models.KnowledgeBase, error)
-
- // Documents
- CreateDocument(ctx context.Context, doc *models.KBDocument) error
- GetDocument(ctx context.Context, id string) (*models.KBDocument, error)
- ListDocuments(ctx context.Context, kbID string) ([]models.KBDocument, error)
- UpdateDocumentStatus(ctx context.Context, id string, status string, err *string) error
- DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) // returns for storage cleanup
-
- // Chunks
- InsertChunks(ctx context.Context, chunks []models.KBChunk) error
- DeleteChunksForDocument(ctx context.Context, documentID string) error
- SimilaritySearch(ctx context.Context, kbIDs []string, queryVec []float64,
- threshold float64, limit int) ([]models.KBSearchResult, error)
-
- // Channel links
- SetChannelKBs(ctx context.Context, channelID string, kbIDs []string) error
- GetChannelKBs(ctx context.Context, channelID string) ([]models.ChannelKB, error)
- GetActiveKBIDs(ctx context.Context, channelID string, userID string,
- teamIDs []string) ([]string, error) // enabled + user has access
-
- // Stats
- UpdateStats(ctx context.Context, kbID string) error // recount from chunks
-}
-```
-
-Add `KnowledgeBases KnowledgeBaseStore` to the `Stores` struct.
-
----
-
-## 10. Models
-
-```go
-// server/models/models.go — additions
-
-type KnowledgeBase struct {
- ID string `json:"id" db:"id"`
- Name string `json:"name" db:"name"`
- Description string `json:"description" db:"description"`
- Scope string `json:"scope" db:"scope"`
- OwnerID *string `json:"owner_id,omitempty" db:"owner_id"`
- TeamID *string `json:"team_id,omitempty" db:"team_id"`
- EmbeddingConfig map[string]interface{} `json:"embedding_config" db:"embedding_config"`
- DocumentCount int `json:"document_count" db:"document_count"`
- ChunkCount int `json:"chunk_count" db:"chunk_count"`
- TotalBytes int64 `json:"total_bytes" db:"total_bytes"`
- Status string `json:"status" db:"status"`
- CreatedAt time.Time `json:"created_at" db:"created_at"`
- UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
-}
-
-type KBDocument struct {
- ID string `json:"id" db:"id"`
- KBID string `json:"kb_id" db:"kb_id"`
- Filename string `json:"filename" db:"filename"`
- ContentType string `json:"content_type" db:"content_type"`
- SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
- StorageKey string `json:"storage_key" db:"storage_key"`
- ExtractedText *string `json:"extracted_text,omitempty" db:"extracted_text"`
- ChunkCount int `json:"chunk_count" db:"chunk_count"`
- Status string `json:"status" db:"status"`
- Error *string `json:"error,omitempty" db:"error"`
- UploadedBy string `json:"uploaded_by" db:"uploaded_by"`
- CreatedAt time.Time `json:"created_at" db:"created_at"`
- UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
-}
-
-type KBChunk struct {
- ID string `json:"id" db:"id"`
- KBID string `json:"kb_id" db:"kb_id"`
- DocumentID string `json:"document_id" db:"document_id"`
- ChunkIndex int `json:"chunk_index" db:"chunk_index"`
- Content string `json:"content" db:"content"`
- TokenCount int `json:"token_count" db:"token_count"`
- Embedding []float64 `json:"-" db:"embedding"` // not serialized to API
- Metadata map[string]interface{} `json:"metadata" db:"metadata"`
- CreatedAt time.Time `json:"created_at" db:"created_at"`
-}
-
-type KBSearchResult struct {
- Content string `json:"content"`
- Filename string `json:"source"`
- KBName string `json:"kb"`
- Similarity float64 `json:"similarity"`
- Metadata map[string]interface{} `json:"metadata,omitempty"`
-}
-
-type ChannelKB struct {
- KBID string `json:"kb_id" db:"kb_id"`
- KBName string `json:"kb_name" db:"name"`
- Enabled bool `json:"enabled" db:"enabled"`
- DocCount int `json:"document_count" db:"document_count"`
-}
-```
-
----
-
-## 11. File Structure
-
-### New Go Packages
-
-```
-server/
-├── knowledge/ # NEW — chunking + embedding logic
-│ ├── chunker.go # recursive text splitter
-│ ├── chunker_test.go
-│ ├── embedder.go # batch embed via role resolver
-│ └── ingest.go # orchestrator: extract → chunk → embed → store
-├── handlers/
-│ ├── knowledge_bases.go # NEW — KB CRUD + document upload + search
-│ └── ... (existing)
-├── store/
-│ └── postgres/
-│ ├── knowledge_bases.go # NEW — KnowledgeBaseStore impl
-│ └── ... (existing)
-├── tools/
-│ ├── kbsearch.go # NEW — kb_search tool
-│ └── ... (existing)
-├── database/
-│ └── migrations/
-│ └── 008_knowledge_bases.sql # NEW
-└── models/
- └── models.go # additions (KB types)
-```
-
-### New/Modified Frontend Files
-
-```
-src/js/
-├── ui-admin.js # MODIFIED — KB management section in admin panel
-├── ui-primitives.js # MODIFIED — embedding dropdown fix, manual entry
-├── chat.js # MODIFIED — KB toggle popup, system prompt hint
-├── api.js # MODIFIED — KB API methods
-└── settings-handlers.js # MODIFIED — personal KB management
-```
-
-### Go Dependencies
-
-```
-go get github.com/pgvector/pgvector-go # pgvector type support for lib/pq
-```
-
----
-
-## 12. Admin Panel Integration
-
-KBs live under **AI → Knowledge Bases** in the admin panel (v0.13.0).
-
-### Admin View
-
-```
-┌──────────────────────────────────────────────────────┐
-│ AI → Knowledge Bases │
-│ │
-│ [+ New Knowledge Base] │
-│ │
-│ ┌────────────────────────────────────────────────┐ │
-│ │ Q4 Reports global 3 docs ready │ │
-│ │ Engineering Wiki global 12 docs ready │ │
-│ │ Onboarding Materials team:Eng 5 docs ready │ │
-│ └────────────────────────────────────────────────┘ │
-│ │
-│ ── Selected: Q4 Reports ────────────────────────── │
-│ │
-│ Name: [Q4 Reports ] │
-│ Description: [Quarterly financial reports ] │
-│ Scope: [Global ▾] Team: [— ▾] │
-│ │
-│ Embedding: OpenAI / text-embedding-3-small │
-│ Chunk size: [1000] Overlap: [200] │
-│ │
-│ Documents: │
-│ ┌────────────────────────────────────────────────┐ │
-│ │ quarterly-report-q4.pdf 2.1 MB 42 chunks ✓ │ │
-│ │ revenue-breakdown.xlsx 340 KB 12 chunks ✓ │ │
-│ │ market-analysis.docx 1.8 MB processing... │ │
-│ └────────────────────────────────────────────────┘ │
-│ [📎 Upload Documents] [🔄 Rebuild All] [🗑 Delete] │
-└──────────────────────────────────────────────────────┘
-```
-
-### Personal KB (Settings)
-
-BYOK users see a "Knowledge Bases" section in Settings → Model Roles
-(or a new Settings tab). Same UI, scoped to personal KBs using the
-user's embedding role override.
-
-### Team KB (Team Management)
-
-Team admins see a "Knowledge Bases" tab in the team management modal.
-Same UI pattern, scoped to the team.
-
----
-
-## 13. Phased Delivery
-
-### Phase 1: Schema + Chunker + Embed Fix
-
-**Goal:** Database ready, chunking works, embedding dropdown fixed.
-
-- [ ] `008_knowledge_bases.sql` migration (pgvector + tables)
-- [ ] `go get pgvector-go`, update `go.mod`
-- [ ] `knowledge/chunker.go` — recursive text splitter + tests
-- [ ] Embedding dropdown fix in `ui-primitives.js` (tolerant filter + manual entry)
-- [ ] `KnowledgeBaseStore` interface + postgres implementation (CRUD only, no vector ops)
-- [ ] `KnowledgeBase`, `KBDocument`, `KBChunk` model types
-- [ ] Wire `Stores.KnowledgeBases` in main.go
-
-### Phase 2: Ingestion Pipeline
-
-**Goal:** Documents can be uploaded, chunked, and embedded end-to-end.
-
-- [ ] `knowledge/embedder.go` — batch embed via role resolver
-- [ ] `knowledge/ingest.go` — orchestrator (extract → chunk → embed → store)
-- [ ] `handlers/knowledge_bases.go` — KB CRUD endpoints
-- [ ] Document upload with async ingestion goroutine
-- [ ] Status polling endpoint
-- [ ] `SimilaritySearch()` in store (pgvector query)
-- [ ] Admin panel: KB management section (AI → Knowledge Bases)
-- [ ] IVFFlat index creation (manual or after first batch)
-
-### Phase 3: kb_search Tool + Channel Integration
-
-**Goal:** LLMs can search KBs during conversations.
-
-- [ ] `tools/kbsearch.go` — kb_search tool (late registration pattern)
-- [ ] Per-channel KB toggle (channel_knowledge_bases)
-- [ ] System prompt KB hint injection in completion handler
-- [ ] KB toggle popup in chat UI
-- [ ] Frontend: channel KB management
-- [ ] Category "knowledge" in tools toggle menu
-
-### Phase 4: Polish + Notes
-
-**Goal:** Team/personal scoping works, notes get vectors.
-
-- [ ] Team KB endpoints + team management UI tab
-- [ ] Personal KB endpoints + settings UI
-- [ ] KB rebuild endpoint (re-chunk + re-embed)
-- [ ] Notes embedding column + semantic note search
-- [ ] Audit logging for KB operations
-- [ ] Usage tracking for embedding calls (role = 'embedding')
-- [ ] Integration tests
-
----
-
-## 14. Risks + Mitigations
-
-| Risk | Impact | Mitigation |
-|------|--------|------------|
-| pgvector not installed on PG host | Migration fails, blocks deploy | Clear error message in migration; add to deployment docs/Dockerfile |
-| Embedding provider rate limits | Ingestion stalls on large uploads | Semaphore + exponential backoff; batch size tuning |
-| Dimension mismatch (model change) | Search returns garbage | Snapshot model in `embedding_config`; warn if role model ≠ KB model |
-| Large documents (100+ pages) | Memory pressure during chunking | Stream text extraction; chunk incrementally |
-| Vector storage size | DB bloat with many KBs | Monitor with stats endpoint; future: HNSW index for better perf |
-| Embedding dropdown broken | Can't configure embedding role at all | Phase 1 priority — ship fix before KB features |
-
----
-
-## 15. Future (Not v0.14.0)
-
-- **Auto-injection:** Pre-pend top-K results to system prompt (v0.15.x, needs compaction for context budget)
-- **Hybrid search:** Combine vector similarity with full-text `tsvector` search, re-rank
-- **Semantic chunking:** Use embedding distance to detect topic boundaries
-- **HNSW index:** Better query performance than IVFFlat for large datasets
-- **Web scraping source:** Ingest URLs as KB documents (natural extension of url_fetch)
-- **Scheduled re-indexing:** Periodic rebuild when source documents are updated
-- **KB sharing:** Cross-team KB access grants
diff --git a/docs/DESIGN-0.16.0.md b/docs/DESIGN-0.16.0.md
deleted file mode 100644
index 1be0703..0000000
--- a/docs/DESIGN-0.16.0.md
+++ /dev/null
@@ -1,795 +0,0 @@
-# DESIGN-0.16.0 — User Groups + Resource Grants
-
-## Overview
-
-The missing access-control primitive. Teams define organizational structure
-(horizontal visibility). Roles define vertical permissions (admin/user).
-**Groups** are pure access-control lists that decouple resource access from
-team membership.
-
-Today, resource visibility follows a rigid scope model: `global` (everyone),
-`team` (team members), `personal` (owner only). This breaks down when:
-- A KB should be visible to members from two different teams.
-- A Persona should be accessible by a cross-functional working group.
-- A future Project needs participants from multiple teams.
-
-Groups solve this by adding a fourth access path — grant-by-group — without
-changing the existing scope model. Existing `global/team/personal` access
-continues to work unchanged. Groups are additive.
-
-Depends on: teams (v0.10.0), knowledge bases (v0.14.0).
-
-**Design principle: don't break the scope model.** Groups extend it. Every
-existing query that filters by `scope + owner_id + team_id` remains valid.
-Group membership is a parallel access path checked alongside scope, not a
-replacement for it.
-
----
-
-## 1. Schema
-
-Migration: `010_groups.sql`
-
-### 1.1 Groups Table
-
-```sql
--- =========================================
--- USER GROUPS
--- =========================================
-
-CREATE TABLE IF NOT EXISTS groups (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- name VARCHAR(100) NOT NULL,
- description TEXT NOT NULL DEFAULT '',
- scope VARCHAR(20) NOT NULL DEFAULT 'global'
- CHECK (scope IN ('global', 'team')),
- team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
- created_by UUID NOT NULL REFERENCES users(id),
- created_at TIMESTAMPTZ DEFAULT NOW(),
- updated_at TIMESTAMPTZ DEFAULT NOW(),
-
- -- Global groups: team_id must be NULL
- -- Team groups: team_id must be set
- CONSTRAINT groups_scope_team CHECK (
- (scope = 'global' AND team_id IS NULL) OR
- (scope = 'team' AND team_id IS NOT NULL)
- )
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope
- ON groups(name, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'));
- -- Unique name within scope (global names unique globally,
- -- team names unique within team)
-
-COMMENT ON TABLE groups IS
- 'Access-control groups. Decouple resource visibility from team membership.';
-COMMENT ON COLUMN groups.scope IS
- 'global: admin-managed, can span teams. team: team-admin-managed, team-internal.';
-```
-
-### 1.2 Group Members Table
-
-```sql
-CREATE TABLE IF NOT EXISTS group_members (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- added_by UUID NOT NULL REFERENCES users(id),
- added_at TIMESTAMPTZ DEFAULT NOW(),
-
- UNIQUE(group_id, user_id)
-);
-
-CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
-CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
-```
-
-### 1.3 Resource Grants Table
-
-```sql
--- =========================================
--- RESOURCE GRANTS
--- =========================================
-
-CREATE TABLE IF NOT EXISTS resource_grants (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- resource_type VARCHAR(30) NOT NULL
- CHECK (resource_type IN ('persona', 'knowledge_base')),
- resource_id UUID NOT NULL,
- grant_scope VARCHAR(20) NOT NULL DEFAULT 'team_only'
- CHECK (grant_scope IN ('team_only', 'global', 'groups')),
- granted_groups UUID[] NOT NULL DEFAULT '{}',
- created_by UUID NOT NULL REFERENCES users(id),
- created_at TIMESTAMPTZ DEFAULT NOW(),
- updated_at TIMESTAMPTZ DEFAULT NOW(),
-
- -- Only one grant row per resource
- UNIQUE(resource_type, resource_id)
-);
-
-CREATE INDEX IF NOT EXISTS idx_resource_grants_resource
- ON resource_grants(resource_type, resource_id);
-CREATE INDEX IF NOT EXISTS idx_resource_grants_groups
- ON resource_grants USING gin(granted_groups);
- -- GIN index for UUID[] containment queries (&&, @>)
-
-COMMENT ON TABLE resource_grants IS
- 'Controls cross-team access to resources (personas, KBs, future: projects).';
-COMMENT ON COLUMN resource_grants.grant_scope IS
- 'team_only: existing team scope behavior. global: all authenticated users. groups: specific group list.';
-COMMENT ON COLUMN resource_grants.granted_groups IS
- 'UUID array of group IDs. Only meaningful when grant_scope = groups.';
-```
-
-### 1.4 Triggers
-
-```sql
--- Auto-update updated_at
-CREATE TRIGGER groups_updated_at
- BEFORE UPDATE ON groups
- FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-
-CREATE TRIGGER resource_grants_updated_at
- BEFORE UPDATE ON resource_grants
- FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-```
-
-### Design Decision: UUID[] vs Junction Table
-
-The roadmap specifies `granted_groups UUID[]` on `resource_grants`. This is
-the right call for this use case:
-
-- A resource has exactly one grant row (UNIQUE constraint).
-- The group list is always read/written as a unit (replace-all semantics).
-- Postgres `UUID[] && ARRAY[...]::uuid[]` with a GIN index is fast for
- "does this resource grant overlap with the user's groups?" queries.
-- Avoids a many-to-many junction table that would complicate the common
- path (checking access) for marginal normalization benefit.
-
-A junction table (`resource_grant_groups`) would be warranted if we needed
-per-group config on grants (e.g., read-only vs read-write per group). We
-don't — the grant is binary (access or no access). If that changes, we
-can migrate the array to a junction table later.
-
----
-
-## 2. Models
-
-### 2.1 Group Models
-
-```go
-// ── Group Constants ────────────────────────
-
-const (
- GroupScopeGlobal = "global"
- GroupScopeTeam = "team"
-)
-
-// ── Groups ─────────────────────────────────
-
-type Group struct {
- ID string `json:"id" db:"id"`
- Name string `json:"name" db:"name"`
- Description string `json:"description" db:"description"`
- Scope string `json:"scope" db:"scope"`
- TeamID *string `json:"team_id,omitempty" db:"team_id"`
- CreatedBy string `json:"created_by" db:"created_by"`
- CreatedAt time.Time `json:"created_at" db:"created_at"`
- UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
-
- // Computed fields (not stored in groups table)
- MemberCount int `json:"member_count,omitempty"`
- TeamName string `json:"team_name,omitempty"`
-}
-
-type GroupPatch struct {
- Name *string `json:"name,omitempty"`
- Description *string `json:"description,omitempty"`
-}
-
-type GroupMember struct {
- ID string `json:"id" db:"id"`
- GroupID string `json:"group_id" db:"group_id"`
- UserID string `json:"user_id" db:"user_id"`
- AddedBy string `json:"added_by" db:"added_by"`
- AddedAt string `json:"added_at" db:"added_at"`
- // Joined from users table
- Username string `json:"username,omitempty"`
- DisplayName string `json:"display_name,omitempty"`
- Email string `json:"email,omitempty"`
-}
-```
-
-### 2.2 Resource Grant Models
-
-```go
-// ── Resource Grant Constants ───────────────
-
-const (
- ResourceTypePersona = "persona"
- ResourceTypeKnowledgeBase = "knowledge_base"
- // Future: "project"
-
- GrantScopeTeamOnly = "team_only"
- GrantScopeGlobal = "global"
- GrantScopeGroups = "groups"
-)
-
-// ResourceGrant controls cross-team access to a resource.
-type ResourceGrant struct {
- ID string `json:"id" db:"id"`
- ResourceType string `json:"resource_type" db:"resource_type"`
- ResourceID string `json:"resource_id" db:"resource_id"`
- GrantScope string `json:"grant_scope" db:"grant_scope"`
- GrantedGroups []string `json:"granted_groups" db:"granted_groups"` // UUID[]
- CreatedBy string `json:"created_by" db:"created_by"`
- CreatedAt time.Time `json:"created_at" db:"created_at"`
- UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
-}
-```
-
----
-
-## 3. Store Interfaces
-
-### 3.1 GroupStore
-
-```go
-// =========================================
-// GROUP STORE
-// =========================================
-
-type GroupStore interface {
- // CRUD
- Create(ctx context.Context, g *models.Group) error
- GetByID(ctx context.Context, id string) (*models.Group, error)
- Update(ctx context.Context, id string, patch models.GroupPatch) error
- Delete(ctx context.Context, id string) error
-
- // Listing
- ListAll(ctx context.Context) ([]models.Group, error) // admin view
- ListForTeam(ctx context.Context, teamID string) ([]models.Group, error)
- ListForUser(ctx context.Context, userID string) ([]models.Group, error) // groups I'm in
-
- // Members
- AddMember(ctx context.Context, groupID, userID, addedBy string) error
- RemoveMember(ctx context.Context, groupID, userID string) error
- ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error)
- IsMember(ctx context.Context, groupID, userID string) (bool, error)
-
- // Bulk lookups (for access resolution)
- GetUserGroupIDs(ctx context.Context, userID string) ([]string, error)
-}
-```
-
-### 3.2 ResourceGrantStore
-
-```go
-// =========================================
-// RESOURCE GRANT STORE
-// =========================================
-
-type ResourceGrantStore interface {
- // Upsert: one grant row per resource (replace semantics)
- Set(ctx context.Context, grant *models.ResourceGrant) error
- Get(ctx context.Context, resourceType, resourceID string) (*models.ResourceGrant, error)
- Delete(ctx context.Context, resourceType, resourceID string) error
-
- // Access check: does user have group-based access to this resource?
- UserHasGroupAccess(ctx context.Context, userID, resourceType, resourceID string) (bool, error)
-}
-```
-
-### 3.3 Wire into Stores Bundle
-
-```go
-type Stores struct {
- // ... existing fields ...
- Groups GroupStore
- ResourceGrants ResourceGrantStore
-}
-```
-
----
-
-## 4. Access Resolution
-
-### 4.1 The Problem
-
-Today, access checks look like this (Persona example):
-
-```sql
--- PersonaStore.ListForUser
-WHERE scope = 'global'
- OR (scope = 'personal' AND created_by = $1)
- OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $1))
- OR (scope = 'personal' AND is_shared = true)
-```
-
-KB access follows the same pattern in `KnowledgeBaseStore.ListForUser` and
-the handler's `userCanAccess()` method.
-
-Groups add a fourth access path: "I'm in a group that has been granted
-access to this resource."
-
-### 4.2 Design: Parallel Check, Not Query Rewrite
-
-We could embed the group check into every `ListForUser` query. That would
-mean every listing query joins against `resource_grants` and `group_members`,
-adding complexity to every scope-aware query in the system.
-
-Instead: **keep existing scope queries as-is, add group-granted resources
-as a separate UNION.** This is cleaner, backward-compatible, and doesn't
-slow down the common path for users without group-based access.
-
-### 4.3 Updated Persona Listing
-
-```go
-// PersonaStore.ListForUser — updated
-func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models.Persona, error) {
- rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
- -- Existing scope-based access (unchanged)
- SELECT %s FROM personas WHERE is_active = true AND (
- scope = 'global'
- OR (scope = 'personal' AND created_by = $1)
- OR (scope = 'team' AND owner_id IN (
- SELECT team_id FROM team_members WHERE user_id = $1
- ))
- OR (scope = 'personal' AND is_shared = true)
- )
-
- UNION
-
- -- Group-based access (new)
- SELECT %s FROM personas p
- WHERE p.is_active = true
- AND p.id IN (
- SELECT rg.resource_id FROM resource_grants rg
- WHERE rg.resource_type = 'persona'
- AND (
- rg.grant_scope = 'global'
- OR (rg.grant_scope = 'groups' AND rg.granted_groups && (
- SELECT ARRAY_AGG(gm.group_id) FROM group_members gm
- WHERE gm.user_id = $1
- )::uuid[])
- )
- )
-
- ORDER BY scope, name
- `, personaCols, personaCols), userID)
- // ...
-}
-```
-
-The `&&` operator is the Postgres array overlap operator — returns true if
-the two arrays share any element. The GIN index on `granted_groups` makes
-this efficient.
-
-### 4.4 Updated KB Listing
-
-Same pattern applied to `KnowledgeBaseStore.ListForUser`:
-
-```go
-func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) {
- // ... existing scope-based query ...
-
- // Add UNION for group-granted KBs
- q += `
- UNION
- SELECT id, name, description, scope, owner_id, team_id, embedding_config,
- document_count, chunk_count, total_bytes, status, created_at, updated_at
- FROM knowledge_bases kb
- WHERE kb.id IN (
- SELECT rg.resource_id FROM resource_grants rg
- WHERE rg.resource_type = 'knowledge_base'
- AND (
- rg.grant_scope = 'global'
- OR (rg.grant_scope = 'groups' AND rg.granted_groups && (
- SELECT ARRAY_AGG(gm.group_id) FROM group_members gm
- WHERE gm.user_id = $1
- )::uuid[])
- )
- )`
-
- q += ` ORDER BY name`
- return queryKBs(ctx, q, args...)
-}
-```
-
-### 4.5 Updated UserCanAccess
-
-The KB handler's `userCanAccess()` method also needs updating:
-
-```go
-func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID string, teamIDs []string) bool {
- // Existing scope-based check (unchanged)
- switch kb.Scope {
- case "global":
- return true
- case "personal":
- if kb.OwnerID != nil && *kb.OwnerID == userID {
- return true
- }
- case "team":
- if kb.TeamID != nil {
- for _, tid := range teamIDs {
- if tid == *kb.TeamID {
- return true
- }
- }
- }
- }
-
- // New: check group-based grants
- hasAccess, _ := h.stores.ResourceGrants.UserHasGroupAccess(
- context.Background(), userID, models.ResourceTypeKnowledgeBase, kb.ID)
- return hasAccess
-}
-```
-
-The `PersonaStore.UserCanAccess` method gets the same treatment.
-
-### 4.6 GetActiveKBIDs (Channel KB Access)
-
-The `GetActiveKBIDs` query (used by the completion handler to determine which
-KBs to search) also needs the group path:
-
-```sql
--- Add to the WHERE clause:
-OR kb.id IN (
- SELECT rg.resource_id FROM resource_grants rg
- WHERE rg.resource_type = 'knowledge_base'
- AND (
- rg.grant_scope = 'global'
- OR (rg.grant_scope = 'groups' AND rg.granted_groups && (
- SELECT ARRAY_AGG(gm.group_id) FROM group_members gm
- WHERE gm.user_id = $2
- )::uuid[])
- )
-)
-```
-
-### 4.7 Performance Note: User Group IDs Cache
-
-The `ARRAY_AGG(gm.group_id)` subquery appears in every access check. For
-hot paths (listing, access checks), we can pre-fetch the user's group IDs
-once per request and pass them through:
-
-```go
-// In middleware or handler setup:
-groupIDs, _ := stores.Groups.GetUserGroupIDs(ctx, userID)
-
-// Then in queries, pass as a parameter instead of subquery:
-// ... AND rg.granted_groups && $N::uuid[]
-```
-
-This trades a subquery for a pre-fetched array parameter. Worth doing in
-phase 2 if access checks show up in profiling. For v0.16.0, the subquery
-approach is simpler and correct.
-
----
-
-## 5. Handlers
-
-### 5.1 GroupHandler
-
-```go
-type GroupHandler struct {
- stores store.Stores
-}
-
-func NewGroupHandler(s store.Stores) *GroupHandler {
- return &GroupHandler{stores: s}
-}
-```
-
-**Admin endpoints (global groups):**
-
-| Method | Path | Handler | Auth |
-|--------|------|---------|------|
-| GET | `/admin/groups` | ListAllGroups | admin |
-| POST | `/admin/groups` | CreateGlobalGroup | admin |
-| PUT | `/admin/groups/:id` | UpdateGroup | admin |
-| DELETE | `/admin/groups/:id` | DeleteGroup | admin |
-| GET | `/admin/groups/:id/members` | ListGroupMembers | admin |
-| POST | `/admin/groups/:id/members` | AddGroupMember | admin |
-| DELETE | `/admin/groups/:id/members/:userId` | RemoveGroupMember | admin |
-
-**Team endpoints (team-scoped groups):**
-
-| Method | Path | Handler | Auth |
-|--------|------|---------|------|
-| GET | `/teams/:teamId/groups` | ListTeamGroups | team member |
-| POST | `/teams/:teamId/groups` | CreateTeamGroup | team admin |
-| PUT | `/teams/:teamId/groups/:id` | UpdateTeamGroup | team admin |
-| DELETE | `/teams/:teamId/groups/:id` | DeleteTeamGroup | team admin |
-| GET | `/teams/:teamId/groups/:id/members` | ListTeamGroupMembers | team member |
-| POST | `/teams/:teamId/groups/:id/members` | AddTeamGroupMember | team admin |
-| DELETE | `/teams/:teamId/groups/:id/members/:userId` | RemoveTeamGroupMember | team admin |
-
-**User endpoint:**
-
-| Method | Path | Handler | Auth |
-|--------|------|---------|------|
-| GET | `/groups/mine` | MyGroups | authenticated |
-
-### 5.2 Resource Grant Endpoints
-
-Grant management is inline on the resource endpoints, not a separate handler.
-
-**On Personas:**
-
-| Method | Path | Handler | Auth |
-|--------|------|---------|------|
-| GET | `/presets/:id/grants` | GetPersonaGrants | owner or admin |
-| PUT | `/presets/:id/grants` | SetPersonaGrants | owner or admin |
-
-**On Knowledge Bases:**
-
-| Method | Path | Handler | Auth |
-|--------|------|---------|------|
-| GET | `/knowledge-bases/:id/grants` | GetKBGrants | owner or admin |
-| PUT | `/knowledge-bases/:id/grants` | SetKBGrants | owner or admin |
-
-**Request/response format:**
-
-```json
-// PUT /api/v1/presets/:id/grants
-{
- "grant_scope": "groups",
- "granted_groups": ["uuid-1", "uuid-2"]
-}
-
-// GET /api/v1/presets/:id/grants
-{
- "grant_scope": "groups",
- "granted_groups": [
- { "id": "uuid-1", "name": "Engineering" },
- { "id": "uuid-2", "name": "Design" }
- ]
-}
-```
-
-The GET response enriches group UUIDs with names for display. The PUT
-accepts bare UUIDs.
-
----
-
-## 6. Routes (main.go)
-
-```go
-// ── Groups ──────────────────────────────
-
-// User: my groups
-protected.GET("/groups/mine", groupH.MyGroups)
-
-// Team-scoped groups
-teamScoped.GET("/groups", groupH.ListTeamGroups)
-teamScoped.POST("/groups", groupH.CreateTeamGroup) // RequireTeamAdmin
-teamScoped.PUT("/groups/:groupId", groupH.UpdateTeamGroup) // RequireTeamAdmin
-teamScoped.DELETE("/groups/:groupId", groupH.DeleteTeamGroup) // RequireTeamAdmin
-teamScoped.GET("/groups/:groupId/members", groupH.ListTeamGroupMembers)
-teamScoped.POST("/groups/:groupId/members", groupH.AddTeamGroupMember) // RequireTeamAdmin
-teamScoped.DELETE("/groups/:groupId/members/:userId", groupH.RemoveTeamGroupMember) // RequireTeamAdmin
-
-// Admin: global groups
-admin.GET("/groups", groupH.ListAllGroups)
-admin.POST("/groups", groupH.CreateGlobalGroup)
-admin.PUT("/groups/:id", groupH.UpdateGroup)
-admin.DELETE("/groups/:id", groupH.DeleteGroup)
-admin.GET("/groups/:id/members", groupH.ListGroupMembers)
-admin.POST("/groups/:id/members", groupH.AddGroupMember)
-admin.DELETE("/groups/:id/members/:userId", groupH.RemoveGroupMember)
-
-// ── Resource Grants ─────────────────────
-
-// Persona grants (add to existing preset routes)
-protected.GET("/presets/:id/grants", personaH.GetGrants)
-protected.PUT("/presets/:id/grants", personaH.SetGrants)
-
-// KB grants (add to existing KB routes)
-protected.GET("/knowledge-bases/:id/grants", kbH.GetGrants)
-protected.PUT("/knowledge-bases/:id/grants", kbH.SetGrants)
-```
-
----
-
-## 7. Audit Logging
-
-All group and grant operations log to the existing audit system:
-
-| Action | Resource Type | Details |
-|--------|--------------|---------|
-| `group.create` | `group` | name, scope, team_id |
-| `group.update` | `group` | changed fields |
-| `group.delete` | `group` | name |
-| `group.member.add` | `group` | user_id |
-| `group.member.remove` | `group` | user_id |
-| `resource_grant.set` | `persona` / `knowledge_base` | grant_scope, group count |
-| `resource_grant.delete` | `persona` / `knowledge_base` | — |
-
-Uses the existing `AuditStore.Log` method — no new infrastructure.
-
----
-
-## 8. Frontend
-
-### 8.1 Admin Panel: Groups Section
-
-New tab in the admin panel (alongside Users, Teams, Models, Settings, etc.):
-
-**Groups list view:**
-- Table: Name, Scope, Team (if team-scoped), Members count, Actions
-- "Create Group" button → modal with name, description, scope selector
-- Click row → member management (add/remove users, search by username)
-
-**Team admin panel:**
-- New "Groups" tab under team settings
-- Same list/CRUD pattern, scoped to team
-
-### 8.2 Grant Picker Component
-
-Reusable component for Persona and KB create/edit forms:
-
-```
-┌─ Access ──────────────────────────────────┐
-│ ○ Team Only (default) │
-│ ○ All Users │
-│ ○ Specific Groups │
-│ ┌────────────────────────────────────┐ │
-│ │ [x] Engineering │ │
-│ │ [x] Design │ │
-│ │ [ ] Marketing │ │
-│ │ [ ] Leadership │ │
-│ └────────────────────────────────────┘ │
-└────────────────────────────────────────────┘
-```
-
-- Only shown for team-scoped and global-scoped resources (personal resources
- are always owner-only).
-- "Team Only" = existing behavior (no `resource_grants` row, or row with
- `grant_scope = 'team_only'`).
-- "All Users" = `grant_scope = 'global'`.
-- "Specific Groups" = `grant_scope = 'groups'` + multi-select group picker.
-- Group list loaded from `/api/v1/groups/mine` (user's groups) for team
- admins, `/api/v1/admin/groups` for system admins.
-
-### 8.3 User's Group Badge
-
-The model selector and KB list already show scope badges (`global`, `team`,
-`personal`). For group-granted resources, show a "group" badge to distinguish
-from team-native access.
-
----
-
-## 9. Backward Compatibility
-
-### 9.1 No Breaking Changes
-
-- All existing scope-based queries remain valid.
-- Resources without a `resource_grants` row behave exactly as before.
-- The `resource_grants` table is opt-in: only populated when an admin or
- team admin explicitly sets grants on a resource.
-- `persona_grants` (tool/KB/API grants ON a persona) is unrelated to
- `resource_grants` (access grants TO a persona). Different concepts,
- different tables. No rename needed.
-
-### 9.2 Migration Safety
-
-- New tables only (no ALTER on existing tables).
-- No data migration required.
-- Rollback: drop the three new tables.
-
-### 9.3 Naming Clarity
-
-To avoid confusion between the existing `persona_grants` table (which
-controls what tools/KBs a Persona has access TO) and the new
-`resource_grants` table (which controls which users have access TO the
-Persona):
-
-| Table | Purpose | Example |
-|-------|---------|---------|
-| `persona_grants` | What a Persona can do (tools, KBs, APIs) | "CodeBot can use calculator" |
-| `resource_grants` | Who can use a resource (users via groups) | "Engineering group can use CodeBot" |
-
-The naming is intentionally distinct. No rename of `persona_grants` is needed.
-
----
-
-## 10. Implementation Phases
-
-### Phase 1: Groups Entity (backend)
-- [ ] Migration `010_groups.sql`
-- [ ] Models: `Group`, `GroupPatch`, `GroupMember`
-- [ ] Store: `GroupStore` interface + Postgres implementation
-- [ ] Handler: `GroupHandler` with admin + team admin + user endpoints
-- [ ] Routes: wire into `main.go`
-- [ ] Audit logging for group operations
-- [ ] Integration tests: CRUD, membership, scoping
-
-### Phase 2: Resource Grants (backend)
-- [ ] Models: `ResourceGrant` + constants
-- [ ] Store: `ResourceGrantStore` interface + Postgres implementation
-- [ ] Grant endpoints on PersonaHandler and KBHandler
-- [ ] Update `PersonaStore.ListForUser` with UNION for group access
-- [ ] Update `PersonaStore.UserCanAccess` with group check
-- [ ] Update `KnowledgeBaseStore.ListForUser` with UNION for group access
-- [ ] Update `KBHandler.userCanAccess` with group check
-- [ ] Update `KnowledgeBaseStore.GetActiveKBIDs` with group path
-- [ ] Audit logging for grant operations
-- [ ] Integration tests: grant CRUD, access resolution, cross-team visibility
-
-### Phase 3: Frontend
-- [ ] Admin panel: Groups section (CRUD, member management)
-- [ ] Team admin panel: Groups tab
-- [ ] Grant picker component (shared between Persona and KB forms)
-- [ ] `/groups/mine` endpoint for user's group list
-- [ ] "Group" scope badge on model selector / KB list
-- [ ] Update Persona create/edit form with grant picker
-- [ ] Update KB create/edit form with grant picker
-
----
-
-## 11. Testing Plan
-
-### Integration Tests (extend `handlers/integration_test.go`)
-
-**Group lifecycle:**
-1. Admin creates global group → verify in list
-2. Admin adds users to group → verify membership
-3. Team admin creates team group → verify scoping (not visible to other teams)
-4. Remove member → verify no longer in group
-5. Delete group → verify CASCADE cleans up members
-
-**Resource grants:**
-1. Team admin creates a KB with `grant_scope = 'groups'`, grants to a group
-2. User in the group can see the KB in `ListForUser` → verify access
-3. User NOT in the group cannot see the KB → verify denied
-4. User in group can search the KB via `kb_search` tool → verify access
-5. Remove user from group → verify KB no longer visible
-6. Change grant from `groups` to `global` → verify all users can access
-7. Delete grant → verify KB reverts to team-only visibility
-
-**Backward compatibility:**
-1. Resources with no `resource_grants` row → same behavior as before
-2. Existing scope queries → unchanged results
-3. Personal resources → never grant-accessible (grant picker hidden)
-
-### Edge Cases
-- User belongs to 0 groups → `ARRAY_AGG` returns NULL → no match (safe)
-- Group with 0 members → grant exists but no one matches (correct)
-- Delete a group that's referenced in `granted_groups` → array still contains
- the UUID but no `group_members` rows match → effectively revoked (correct).
- Cleanup: periodic job or ON DELETE trigger to scrub stale UUIDs from arrays.
- For v0.16.0, stale UUIDs are harmless (they just don't match anything).
-
----
-
-## 12. Future Considerations
-
-### v0.17.0 — Persona-KB Binding
-Resource grants enable the access control layer for Persona-KB binding.
-When a Persona has bound KBs, the Persona's grants implicitly grant search
-access to those KBs. The `resource_grants` table already supports this —
-the Persona grant controls who can use the Persona, and Persona-KB binding
-controls which KBs are searchable through it.
-
-### v0.19.0 — Projects
-Projects will use the same `resource_grants` pattern:
-`resource_type = 'project'`. No schema changes needed.
-
-### v0.24.0 — Full RBAC
-OIDC claim → group mapping will auto-sync external IdP groups into the
-`groups` table. The infrastructure built here becomes the foundation for
-enterprise RBAC.
-
-### Stale Group UUID Cleanup
-When a group is deleted, its UUID may linger in `resource_grants.granted_groups`
-arrays. This is harmless (dead UUIDs never match any `group_members` rows)
-but untidy. Options for a future cleanup:
-- Trigger on `groups` DELETE that scrubs the UUID from all `granted_groups` arrays.
-- Periodic background job.
-- Cleanup on next grant SET (when the admin edits the grant).
-
-For v0.16.0, we'll add a note in the delete handler response if the group
-is referenced in any grants, warning the admin.
diff --git a/docs/DESIGN-0.17.0.md b/docs/DESIGN-0.17.0.md
deleted file mode 100644
index 15a488f..0000000
--- a/docs/DESIGN-0.17.0.md
+++ /dev/null
@@ -1,184 +0,0 @@
-# DESIGN-0.17.0 — Persona-KB Binding + Enterprise KB Mode
-
-## Overview
-
-Personas become **gateways** to knowledge. In enterprise deployments,
-users don't interact with KBs directly — they talk to Personas that
-have KBs attached. This is the core differentiator for enterprise use.
-
-Depends on: knowledge bases (v0.14.0), user groups (v0.16.0).
-
-**Design principle: Personas carry context, not users.** When a user
-selects a Persona with bound KBs, the KB context flows automatically —
-no manual KB selection, no toggle management, no confusion about which
-KBs are in scope. Admins curate the KB↔Persona relationships; users
-just pick a Persona.
-
----
-
-## 1. Schema
-
-### `persona_knowledge_bases` (new join table)
-
-```sql
-CREATE TABLE persona_knowledge_bases (
- persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
- kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
- auto_search BOOLEAN NOT NULL DEFAULT false,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- PRIMARY KEY (persona_id, kb_id)
-);
-```
-
-`auto_search` controls whether the KB is searched automatically on every
-message (top-K results prepended to context) or only via explicit
-`kb_search` tool calls. Default `false` = tool-only.
-
-### `knowledge_bases.discoverable` (new column)
-
-```sql
-ALTER TABLE knowledge_bases ADD COLUMN discoverable BOOLEAN NOT NULL DEFAULT true;
-```
-
-When `false`, the KB does not appear in user-facing listings
-(`ListDiscoverableKBs`). It remains searchable through Persona bindings.
-
-### `platform_policies.kb_direct_access` (new row)
-
-Seeded as `'true'` (permissive default). When set to `'false'`, the
-channel KB popup is hidden — users access KBs exclusively through
-Persona bindings.
-
----
-
-## 2. Store Layer
-
-### `PersonaStore` additions
-
-- `SetKBs(ctx, personaID, kbIDs, autoSearch)` — UPSERT join table
-- `GetKBs(ctx, personaID)` — returns `[]PersonaKB` with KB metadata
-
-### `KnowledgeBaseStore` additions
-
-- `ListDiscoverable(ctx, userID, teamIDs)` — KBs where `discoverable=true`
- and user has access via ownership, team, or global scope
-- `SetDiscoverable(ctx, kbID, discoverable)` — toggle visibility
-- `UpdateDocumentStorageKey(ctx, docID, key)` — moved from raw SQL
-
----
-
-## 3. Completion Pipeline
-
-### KB scoping in `BuildKBHint`
-
-When a Persona has bound KBs, `BuildKBHint` merges them with any
-channel-attached KBs:
-
-1. Load channel KBs (existing path)
-2. Load Persona KBs via `PersonaStore.GetKBs()`
-3. Union the two sets (deduplicated by KB ID)
-4. Build the hint string with listing
-
-### Persona ID threading
-
-The `personaID` is threaded through the completion handler methods:
-`Complete()` → `streamCompletion(…, personaID)` / `syncCompletion(…, personaID)`
-→ `loadConversation(…, personaID)`.
-
-The `ExecutionContext` for tool calls carries the Persona ID so
-`kb_search` can auto-include Persona-bound KBs in its search scope.
-
----
-
-## 4. Role Fallback Alerts (issue #69)
-
-When a role's primary provider fails:
-
-1. **Attempt fallback** — existing behavior, unchanged.
-2. **Emit event** — `role.fallback` on EventBus (DirToClient).
-3. **Cooldown** — 5-minute per-role TTL prevents flooding.
-4. **Admin toast** — frontend listens for `role.fallback` events,
- shows warning/error toast to admin users only.
-
-### Resolver changes
-
-```go
-type Resolver struct {
- stores store.Stores
- vault *crypto.KeyResolver
- bus *events.Bus
- mu sync.Mutex
- cooldown map[string]time.Time // role → last alert time
-}
-```
-
-`WithBus(bus)` builder method. Nil-safe — if no bus is attached,
-alerts are suppressed (log only).
-
----
-
-## 5. Security Fixes
-
-### ResolvePreset bypass
-
-`ResolvePreset()` in `presets.go` used raw SQL that skipped the
-`resource_grants` table added in v0.16.0. A Persona accessible only
-via group grant would fail at completion time. Fixed to use
-`PersonaStore.GetByID()` + `UserCanAccess()`.
-
-### KB create scope authorization
-
-`POST /api/v1/knowledge-bases` now enforces:
-- `scope=global` → requires admin role
-- `scope=team` → requires team admin role for the target team
-- `scope=personal` → open to all authenticated users
-
----
-
-## 6. Frontend
-
-### KB Picker (`persona-kb.js`)
-
-Standalone component: `renderPersonaKBPicker(container, opts)` → control
-object with `getValues()`, `setValues()`, `clear()`, `refresh()`.
-
-Options: `scope` ('admin'|'team'|'personal'), `teamId`, `prefix`.
-
-Shows checkboxes for available KBs with doc/chunk counts. Selected KBs
-show an "Auto-inject" toggle for the `auto_search` flag.
-
-### Integration points
-
-- Admin preset form: KB picker below the form, load/save bindings on
- edit/create via `/api/v1/admin/presets/:id/knowledge-bases`.
-- Team preset form: same pattern via team API prefix.
-- User preset form: shows discoverable KBs only.
-- Admin settings: `kb_direct_access` toggle checkbox.
-
-### Fallback alert
-
-Admin users receive `UI.toast()` notifications when `role.fallback`
-events arrive via WebSocket.
-
----
-
-## 7. API Endpoints (new)
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/api/v1/presets/:id/knowledge-bases` | List KBs bound to a persona |
-| PUT | `/api/v1/presets/:id/knowledge-bases` | Set persona-KB bindings |
-| GET | `/api/v1/admin/presets/:id/knowledge-bases` | Admin: list persona KBs |
-| PUT | `/api/v1/admin/presets/:id/knowledge-bases` | Admin: set persona-KB bindings |
-| GET | `/api/v1/knowledge-bases-discoverable` | List discoverable KBs for user |
-| PUT | `/api/v1/knowledge-bases/:id/discoverable` | Toggle KB discoverability |
-
----
-
-## 8. Migration
-
-Single migration file: `002_v017_persona_kb.sql`
-
-- Creates `persona_knowledge_bases` table
-- Adds `discoverable` column to `knowledge_bases`
-- Seeds `kb_direct_access` platform policy (default `'true'`)
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index d12b1bc..3d5b602 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -17,1192 +17,129 @@ Features have real dependencies. This ordering respects them.
```
v0.9.x Stability + Quick UX Wins ✅
- │
-v0.9.3 Content Visibility & Block Controls ✅
- │
-v0.9.4 API Key Encryption + Vault ✅
- │
-v0.10.0 Model Roles (utility + embedding) ✅
- + Usage Tracking
- │
-v0.10.2 Summarize & Continue ✅
- │
-v0.10.3 Frontend Refactor ✅
- │
-v0.10.4 Model Type Pipeline + Role Fixes ✅
- │
-v0.10.5 UI Primitives + Extension Surfaces ✅
- │
-v0.11.0 Extension Foundation (browser tier) ✅
- │
- ┌───────┴──────────────┐
- │ │
-v0.12.0 File Handling v0.13.0 Admin Panel
- + Vision ✅ Refactor (fullscreen) ✅
- │ │
- │ v0.13.1 Web Search
- │ + url_fetch ✅
- │ │
- └───────┬──────────────┘
- │
-v0.14.0 Knowledge Bases ✅ (embedding role + file storage + pgvector)
- │
-v0.15.0 Compaction ✅ (utility role + background job)
- │
-v0.15.1 Context Recall Tools ✅ (attachment_recall, conversation_search)
- │
- ┌───────┴──────────────┐
- │ │
-v0.16.0 User Groups v0.17.0 Persona-KB Binding
- + Resource Grants ✅ + Enterprise KB Mode
- │ + QOL / UX Wins
- └───────┬──────────────┘
- │
- ┌───────┴──────────────┐
- │ │
-v0.17.1 SQLite Backend ✅ v0.17.2 CodeMirror 6 ✅
-v0.17.3 Notes Graph + Wikilinks ✅
- (dual DB) (editor bundle, chat
- │ input, ext editor)
- └───────┬──────────────┘
- │
-v0.18.0 Memory ✅ (user + persona scopes, review pipeline)
- │
-v0.18.1 Side Panel Architecture ✅ (single-slot, ctx.ui primitives, mermaid refactor)
- │
-v0.19.0 Projects / Workspaces ✅ (project groups, KB resolution, drag-drop sidebar)
- │
-v0.19.1 Active Project + System Prompt + Detail Panel ✅
- │
-v0.19.2 Project Persona Default + Archive + Reorder ✅
- │
-v0.20.0 Notifications + @mention Routing + Multi-model ✅
- │
-v0.21.0 Workspace Storage Primitive ✅
- │
- ┌───────┴──────────────┐
- │ │
-v0.21.1 Workspace ✅ v0.21.3 Surface Infra ✅
- Tools + Bindings + REPL (parallel)
- │ │
-v0.21.2 Workspace ✅ v0.21.5 Editor Surface ✅
- Indexing + Search (Development Mode)
- │ │
-v0.21.4 Git ✅ v0.21.6 Editor Surface ✅
- Integration + Document Output
- └───────┬──────────────┘
- │
-v0.21.7 Bugfix: scanJSON driver buffer aliasing ✅
- │
-v0.22.0 Provider Health + Capability Overrides ✅
- + Workspace Pane Refactor (FE)
- │
-v0.22.1 Provider Extensions (declarative config) ✅
- │
-v0.22.2 Routing Policies + Fallback Chains ✅
- │
-v0.22.3 Provider Admin UI + Deferred Polish ✅
- │
-v0.23.0 Multi-Participant Channels + Presence
- │
-v0.24.0 Auth Strategy (mTLS/OIDC) + Full RBAC + Group permissions
- │
- ┌───────┴──────────────┐
- │ │
-v0.25.0 Workflow v0.26.0 Tasks /
- Engine Autonomous Agents
- (team-owned, (service channels,
- staged processes, scheduler, unattended
- human + AI collab) execution)
+ │
+v0.10.x Providers + Models + Roles ✅
+ │
+v0.11.x Tool Framework + Extension Base ✅
+ │
+v0.12.x File Storage + Vision ✅
+ │
+v0.13.x Web Tools + Toggle ✅
+ │
+v0.14.x Knowledge Bases (RAG) ✅
+ │
+v0.15.x Auto-Compaction ✅
+ │
+v0.16.x Groups + Resource Grants ✅
+ │
+v0.17.x Personas + Notes/CM6 + SQLite ✅
+ │
+v0.18.x Memory System ✅
+ │
+v0.19.x Projects/Workspaces ✅
+ │
+v0.20.x Surface System ✅
+ │
+v0.21.x Admin UX + Routing ✅
+ │
+v0.22.x Routing Policies + Provider Chains ✅
+ │
+v1.0 Stability + Multi-Tenant ⏳
```
----
-
## Completed Releases
-> Full details for all completed versions are in [CHANGELOG.md](../CHANGELOG.md).
-
-### ✅ v0.9.0 — Schema Consolidation + BYOK
-21 migrations → single schema, store layer abstraction, persona-as-trust-boundary,
-BYOK with auto-fetch, composite model IDs, journey integration tests.
-
-### ✅ v0.9.1 — Capability Architecture + Docs
-Removed static known model table. Resolution chain: catalog → heuristic only.
-Frontend relies solely on backend for capabilities. Docs rewrite.
-
-### ✅ v0.9.2 — Quick UX Wins + Hardening
-Collapsible code blocks, HTML preview, token counter, context warning, proxy
-interception detection, environment injection, team admin audit scoping.
-
-### ✅ v0.9.3 — Content Visibility & Block Controls
-Button-driven code collapse, always-rendered thinking blocks, tool call
-persistence in history, Notes panel → unified side panel, streaming refactor
-(`streamWithToolLoop`), regenerate fix.
-
-### ✅ v0.9.4 — API Key Encryption + Vault
-Two-tier AES-256-GCM: env-var key for global/team, per-user UEK (Argon2id)
-for personal BYOK. `server/crypto/` package. Migration 003. Startup backfill.
-DOMPurify strict allowlist (XSS fix).
-
-### ✅ v0.10.0 — Model Roles + Usage Tracking + Vault Debt
-Named role slots (utility, embedding) with fallback. `Provider.Embed()` interface.
-`usage_log` + `model_pricing` tables. Streaming token capture. Vault debt:
-UEK re-wrap on password change, destruction on admin reset.
-
-### ✅ v0.10.1 — Polish + UX
-Admin system prompt injection, Personas tab, Team Management modal extraction,
-preview pane enhancements, code download, personal usage scoped to BYOK.
-
-### ✅ v0.10.2 — Summarize & Continue + Role Cleanup
-User-triggered conversation compaction via utility role. Summary as tree node
-with boundary metadata. BYOK role overrides (personal → team → global).
-Utility rate limiting. Generation role removed.
-
-### ✅ v0.10.3 — Frontend Refactor
-2 monolith files → 13 domain-scoped files (~544 lines avg). Zero features,
-no function renames. Vanilla JS, no build step.
-See [REFACTOR-0.10.3.md](REFACTOR-0.10.3.md).
-
-### ✅ v0.10.4 — Model Type Pipeline + Role Fixes
-`model_type` (chat/embedding/image) captured end-to-end from provider API
-through catalog to frontend. Role dropdowns filter by type.
-
-### ✅ v0.10.5 — UI Primitives + Extension Surfaces
-Shared `Providers`/`Roles` registries, 6 consolidated render primitives,
-`showConfirm()` replacing native dialogs, `.popup-menu` CSS primitive.
-Removed ~400 lines of duplication.
-
-### ✅ v0.11.0 — Extension Foundation (Browser Tier)
-Full extension lifecycle: manifest, loader, scoped `ctx.*` API, browser tool
-bridge via WebSocket. Custom renderer pipeline. 2 server tools (calculator,
-datetime), 6 built-in browser extensions (Mermaid, KaTeX, CSV, Diff, JS
-Sandbox, Regex). See [EXTENSIONS.md](EXTENSIONS.md).
-
-### ✅ v0.12.0 — File Handling + Vision
-S3/PVC storage backend, image/file upload (📎, drag-drop, paste), multimodal
-message assembly, text extraction pipeline (PDF/DOCX/XLSX/PPTX/ODT/RTF),
-vault CLI (`rekey`/`status`), per-chat model persistence.
-
-### ✅ v0.13.0 — Admin Panel Refactor
-12-tab modal → fullscreen admin panel. 4 categories (People, AI, System,
-Monitoring) × section sidebar. URL-based routing, responsive, banner-aware.
-CSS design token cleanup.
-
-### ✅ v0.13.1 — Web Search + URL Fetch
-`web_search` + `url_fetch` tools. Search provider abstraction (DuckDuckGo,
-SearXNG). Tool categories with per-tool toggle UI in chat bar.
-
-### ✅ v0.14.0 — Knowledge Bases
-RAG: upload → chunk → embed (pgvector) → `kb_search` tool. Channel KB toggle.
-Team/personal KB scopes. Notes semantic search. Admin panel KB management.
-See [DESIGN-0.14.0.md](DESIGN-0.14.0.md).
-
-### ✅ v0.15.0 — Compaction
-Background scanner with configurable thresholds. Automatic summarization via
-utility role. Per-channel opt-in/out. Context budget guard rail (80% ceiling).
-
-### ✅ v0.15.1 — Context Recall Tools
-`attachment_recall` (list + read, channel-scoped), `conversation_search`
-(full-text via `plainto_tsquery`). Token estimator attachment awareness.
-
-### ✅ v0.16.0 — User Groups + Resource Grants
-Groups (global/team-scoped ACLs) decoupled from team membership. Three-way
-resource grants (team_only/global/groups) for Personas and KBs. Grant picker
-UI. Schema consolidation: 9 migrations → single `001_v016_schema.sql`.
-
----
-
-## v0.17.0 — Persona-KB Binding + Enterprise KB Mode + QOL ✅
-
-Personas become **gateways** to knowledge. In enterprise deployments,
-users don't interact with KBs directly — they talk to Personas that
-have KBs attached. Plus quality-of-life improvements: chat rename,
-token display, state persistence, utility model auto-naming.
-
-Depends on: knowledge bases (v0.14.0), user groups (v0.16.0).
-
-**Persona-KB Binding**
-- [x] `persona_knowledge_bases` table: `persona_id`, `kb_id`, `auto_search` (bool — always search vs tool-only)
-- [x] When a channel uses a Persona with bound KBs, `kb_search` is auto-scoped to those KBs
-- [x] No user toggle needed — Persona carries its own KB context
-- [x] Channel KB toggle becomes "additional KBs" on top of Persona-provided ones
-- [x] Persona system prompt auto-includes KB listing hint (reuses `BuildKBHint` pattern)
-- [x] Admin/team admin UI: KB picker on Persona create/edit form
-
-**Enterprise KB Mode**
-- [x] `discoverable` flag on knowledge_bases: `true` (default — appears in user's KB popup) or `false` (hidden, only accessible through Persona binding)
-- [x] Hidden KBs don't appear in `GET /api/v1/knowledge-bases-discoverable` for non-admins
-- [x] Hidden KBs still searchable by `kb_search` tool when accessed through a Persona
-- [x] Admin panel: toggle discoverability per KB
-- [x] Platform policy: `kb_direct_access` — when `false`, disables channel KB popup entirely (strict enterprise mode)
-
-**Access Control Integration**
-- [x] Persona grants (v0.16.0 groups) control who can *use* a Persona
-- [x] KB grants control who can *manage* a KB (upload/delete docs)
-- [x] Persona-KB binding grants implicit *search* access through the Persona
-- [x] Users who can use a Persona can search its KBs, even if they can't see those KBs directly
-
-**Team Admin Workflow**
-- [x] Team admin creates KB → uploads documents → marks non-discoverable
-- [x] Team admin creates Persona → binds KBs → sets access to Team Only or specific Groups
-- [x] Team members see the Persona in their preset list, use it, get KB-powered answers
-- [x] Team members never see or manage the underlying KBs
-
-**Technical Debt (carried forward)**
-- [x] **Security**: KB create handler authorization — team/global scope verified
-- [x] **Test hygiene**: stripped DIAG diagnostics from `TestGroupBasedPersonaAccess`
-- [x] **Architecture**: `KnowledgeBaseStore.UpdateDocumentStorageKey()` — moved to store layer
-- [x] **UX**: Embedding dropdown fix — tolerant type filter, manual model ID fallback, auto-switch on empty _(already implemented in `ui-primitives.js`)_
-- [x] **Minor**: Paste-to-file character threshold — synced from backend `PublicSettings` (`storage.paste_to_file_chars`), admin-configurable, default 2000
-
-**Role Fallback Alerts** ([#69](https://git.gobha.me/xcaliber/chat-switchboard/issues/69))
-- [x] Audit log entry on fallback fire: `action = "role.fallback"` with primary/fallback model IDs and error string
-- [x] `role.fallback` WebSocket event via EventBus (`PublishAsync`, zero latency impact on completion path)
-- [x] In-memory cooldown on `Resolver` (per-role, 5-minute TTL) to deduplicate repeated fallback alerts
-- [x] Admin UI: persistent alert banner on `role.fallback` event (persists until dismissed)
-- [x] Wire `*events.Bus` into `roles.NewResolver` via `.WithBus()` builder
-
-**QOL / UX Wins**
-- [x] **Chat rename**: inline edit on sidebar item — dblclick title → input → blur/Enter saves via `API.updateChannel(id, { title })`
-- [x] **Chat token count**: display `Tokens.estimateConversation()` in model bar, update on `selectChat` and after each message
-- [x] **State restore on refresh**: persist `App.currentChatId` to `sessionStorage` on select, restore on load
-- [x] **Utility model auto-naming**: after first assistant response, background utility role request → `POST /channels/:id/generate-title` → re-render sidebar. Fallback to truncation when no utility model configured. Debounced via `_autoNamePending` set
-- [x] **SW extension cache fix**: exclude `/extensions/` from service worker fetch handler
-- [x] **Mermaid renderer**: promoted enhanced version (v2.0 — pan/zoom, SVG/PNG export, source copy) to `extensions/builtin/mermaid-renderer/`
-
----
-
-## v0.17.1 — SQLite Backend ✅
-
-Dual-database support. Every subsequent schema change is developed
-against both Postgres and SQLite from day one — no retrofit. SQLite
-enables single-binary deployment for dev, demo, edge, and single-user.
-
-Depends on: v0.17.0 DB debt cleanup (store layer is sole DB interface).
-
-**Store Layer**
-- [x] 19 SQLite store implementations covering all domain interfaces
-- [x] `DB_DRIVER` env var: `postgres` (default) | `sqlite`
-- [x] Separate migration files per driver (`migrations/sqlite/`)
-- [x] WAL mode + single-writer connection pooling for SQLite concurrency
-- [x] CI matrix: full handler integration tests against both Postgres and SQLite
-
-**Vector Search**
-- [x] App-level cosine similarity in Go (pure Go, no CGO/sqlite-vec required)
-- [x] Embeddings stored as JSON text in `kb_chunks.embedding` column
-- [x] Full feature parity with pgvector: `SimilaritySearch`, `InsertChunks`
-- [x] Graceful: adequate performance for SQLite-scale deployments (single-user, not millions of chunks)
-
-**Schema Compatibility**
-- [x] UUID generation: application-side `store.NewID()` (Go `uuid.New()`)
-- [x] JSONB → JSON text columns with `ToJSON()`/`ScanJSON()` helpers
-- [x] `UUID[]` arrays → JSON arrays with `ToJSONArray()`/`ScanJSONArray()`
-- [x] `TIMESTAMPTZ` → SQLite `TEXT` with `datetime('now')`
-- [x] `ON CONFLICT excluded.*` pattern (works in both dialects)
-- [x] `INSERT RETURNING id` → separate query pattern for SQLite
-
-**Test Infrastructure**
-- [x] `dialectSQL()` converts `$N` → `?` and strips `::jsonb` at runtime
-- [x] `database.PH(n)` returns dialect-appropriate placeholder
-- [x] `database.TruncateAll()` dialect-aware (DELETE + PRAGMA vs TRUNCATE CASCADE)
-- [x] Generic provider config: `PROVIDER`/`PROVIDER_KEY`/`PROVIDER_URL` (replaces `VENICE_API_KEY`)
-- [x] Model selection prefers non-reasoning models in live tests
-
-**What SQLite mode skips** (feature-gated, not broken):
-- [x] `LISTEN/NOTIFY` — WebSocket event fan-out uses in-process EventBus (already works)
-- [x] Full-text search `tsvector` — conversation_search uses LIKE fallback
-
----
-
-## v0.17.2 — CodeMirror 6 Integration ✅
-
-Rich editor infrastructure for the frontend. CM6 is ESM-native; the build
-step runs in Docker (esbuild → single IIFE bundle). No change to dev
-experience for non-editor code — the rest of `src/js/` stays vanilla.
-
-Depends on: nothing (frontend-only). Prerequisite for: extension surfaces
-(v0.21.5 editor surface). See [DESIGN-CM6.md](DESIGN-CM6.md) for full spec.
-
-**Build Pipeline**
-- [x] `src/editor/` directory: `package.json`, `build.mjs`, `index.mjs`, language/theme modules
-- [x] `scripts/build-editor.sh`: shared build script for both Dockerfiles and local dev
-- [x] New Docker stage (`cm6-build`): `npm ci` + `node build.mjs` → `vendor/codemirror/`
-- [x] Both `Dockerfile.frontend` and `Dockerfile` (unified) use the shared build script
-- [x] Bundle output: `codemirror.bundle.js` (~295KB min, ~90KB gzip)
-- [x] Graceful degradation: all integration points check `window.CM` — falls back to `