# Changelog

## [0.37.17.0] — 2026-03-24

### Summary

Workspaces — workspace-first file storage for projects. Project files route
through the workspace FS subsystem (auto-created on first upload) instead of
the flat objStore path. Tree browser UI with nested folders, archive support
(zip/tar.gz upload and download), file actions (download, delete, mkdir), and
upload improvements (progress counter, archive detection, quota errors).

Architecture decision: `files` table + objStore = ephemeral message attachments;
Workspace FS = persistent file collections (tree, dirs, archive, quota, indexing).
"Save to workspace" bridge from chat files deferred to v0.37.18.

Also: extension track continuation scheduled as v0.39.x in roadmap, two new
design docs committed (multi-file Starlark, extension connections & libraries).

### Added

- **Workspace-backed project files:** `ensureProjectWorkspace()` auto-creates a
  workspace (`owner_type=project`) on first file upload, sets `project.workspace_id`.
  All project file operations route through workspace FS instead of objStore.
  (`server/handlers/files.go`)
- **File download:** `GET /projects/:id/files/download?path=` streams raw file
  content with `Content-Disposition: attachment`. (`server/handlers/files.go`)
- **File delete:** `DELETE /projects/:id/files?path=` with optional `recursive`
  param for directories. (`server/handlers/files.go`)
- **Create directory:** `POST /projects/:id/files/mkdir` accepts path via JSON
  body or query param. (`server/handlers/files.go`)
- **Archive upload:** `POST /projects/:id/archive/upload` extracts .zip/.tar.gz
  into project workspace. Returns `files_extracted` count.
  (`server/handlers/files.go`)
- **Archive download:** `GET /projects/:id/archive/download?format=zip` bundles
  all project files into a zip/tar.gz. (`server/handlers/files.go`)
- **File browser UI:** Tree browser with nested folders (expand/collapse),
  type icons, size display, on-hover delete button. Replaces the flat file list
  from v0.37.16. (`src/js/sw/surfaces/projects/detail.js`)
- **File action bar:** "New folder" and "Download all" buttons above the file
  tree when files exist. (`detail.js`)
- **Upload improvements:** Progress counter ("Uploading 3 of 7…"), archive
  detection (`.zip`/`.tar.gz` → confirm extract dialog), specific error
  messages (413 = quota exceeded). (`detail.js`)
- **SDK methods:** `deleteFile`, `mkdir`, `uploadArchive` added to
  `sw.api.projects` domain. `files()` and `uploadFile()` updated to support
  path param. (`src/js/sw/sdk/api-domains.js`)
- **File browser CSS:** 120 lines — file-row, file-chevron, file-icon,
  file-name, file-meta, file-delete, dropzone, file-actions-bar.
  (`src/css/sw-projects-surface.css`)
- **DESIGN-EXT-CONNECTIONS-LIBRARIES.md:** Extension connections (scoped
  credentials) and library packages (shared code + data via `lib.load()`).
  Three implementation phases. (`docs/`)
- **Extension track v0.39.x:** Roadmap updated — v0.39.0 multi-file Starlark,
  v0.39.1 connections, v0.39.2 libraries, v0.39.3 full composition.
  (`docs/ROADMAP.md`)

### Changed

- **Project file list response:** Now returns `WorkspaceFile` objects (with
  `path`, `is_directory`, `content_type`, `size_bytes`) instead of `File` model
  objects. `GET /projects/:id/files` supports `?path=` and `?recursive=` params.
- **FileHandler constructor:** Gains `SetWorkspaceFS()` method for workspace
  injection. Project file routes use dedicated `projFileH` instance with wfs
  attached. (`server/main.go`)
- **ICD updated:** `docs/ICD/projects.md` — expanded Project Files section with
  all 7 endpoints, request/response schemas, error codes.
- **OpenAPI updated:** `server/static/openapi.yaml` — 5 new paths for project
  file operations (download, delete, mkdir, archive upload/download).

### Known Limitations

- **User default workspace:** Not yet implemented. Personal "My Files" deferred
  to v0.37.18.
- **Tool output persistence:** `origin=tool_output` auto-save for LLM-generated
  artifacts deferred to v0.37.18.
- **Save to workspace bridge:** "Save" action to promote ephemeral chat files
  to workspace storage deferred to v0.37.18.
- **Workspace unique constraint:** Race guard on concurrent workspace creation
  relies on DB error handling (re-read on duplicate key). Migration 014 for
  explicit `UNIQUE(owner_type, owner_id)` not yet added.

---

## [0.37.16.0] — 2026-03-24

### Summary

Projects Surface. Card-grid list + two-column detail view with inline ChatPane,
context menu (color/archive/delete), sidebar sections (description, instructions,
KBs, notes, files), star toggle, deep-linking, and search/sort. UserMenu in header
on both views. Backend was already complete (16 protected + 2 admin endpoints,
18 SDK methods); this changeset delivers the full Preact frontend.

### Added

- **Projects list view:** Card grid with search filter, activity/name sort,
  "+ New project" via `sw.prompt` dialog, UserMenu in header left-of-title.
  (`src/js/sw/surfaces/projects/list.js`, `src/css/sw-projects-surface.css`)
- **Projects detail view:** Two-column layout — left panel for conversations
  (inline ChatPane, "+ New" channel creation, channel list with back nav),
  right sidebar for description/instructions textareas with save, collapsible
  Knowledge Bases/Notes/Files sections with "+" picker dropdowns.
  (`src/js/sw/surfaces/projects/detail.js`)
- **Context menu:** "..." button opens dropdown with Rename, 8 color swatches,
  Archive/Unarchive toggle, Delete with confirm dialog. All actions persist
  via `PUT /api/v1/projects/:id`. (`detail.js`)
- **Star toggle:** Optimistic ☆↔★ toggle, persists in `settings.starred`.
  (`detail.js`)
- **Deep-linking:** `/projects/:id` loads detail view directly via
  `window.__PROJECT_ID__` template injection. Invalid IDs gracefully fall
  back to list view. (`src/js/sw/surfaces/projects/index.js`,
  `server/pages/loaders.go`, `server/pages/templates/surfaces/projects.html`)
- **Surface registration:** `/projects` route with `:id` param, nginx proxy,
  docker-entrypoint FE route, conditional CSS/JS loading in `base.html`.
  (`server/pages/pages.go`, `nginx.conf`, `docker-entrypoint-fe.sh`,
  `server/pages/templates/base.html`)

### Fixed

- **Picker section expand:** `openPicker()` now auto-expands the collapsed
  section when "+" is clicked, so the picker dropdown is visible without
  manually toggling the chevron first. (`detail.js`)

### Changed

- **UserMenu in header (list view):** Moved from bottom footer to header row,
  left of "Projects" title. Consistent with detail view placement. Removed
  `.sw-projects__list-footer` CSS class. (`list.js`, `sw-projects-surface.css`)

### Known Limitations

- **File upload:** UI is wired (dropzone + click handler) but backend returns
  "failed to save file metadata" — requires workspace storage layer. File
  upload (archive upload/download) is required for MVP; deferred to v0.37.17
  (Workspaces). Git integration is optional/post-MVP.
- **Conversation names:** Channel list shows raw UUID instead of friendly name
  (channels created as type `direct` have no name field).

---

## [0.37.15.0] — 2026-03-23

### Summary

Workflow Ownership & Lifecycle (CS1–CS5). Adds instance cancel with assignment
cascade, assignment unclaim/reassign/cancel operations, full FE API domain
wiring for stage CRUD and assignments, team-admin workflows rewrite with
tabbed layout (Workflows + Stage Editor | Assignments Queue | Instance Monitor),
and settings assignments view replacing the read-only workflow list.

### Added

- **Instance cancel:** `POST /channels/:id/workflow/cancel` sets
  `workflow_status='cancelled'` and cascades to all open assignments. Auth:
  instance owner, team admin, or global admin. Team-scoped variant at
  `POST /teams/:teamId/workflows/monitor/instances/:channelId/cancel`.
  (`server/handlers/workflow_instances.go`, `server/store/*/channel.go`)
- **Assignment unclaim:** `POST /workflow-assignments/:id/unclaim` returns
  claimed assignment to unassigned. Auth: claimer or team admin.
  (`server/handlers/workflow_assignments.go`, `server/store/*/workflows.go`)
- **Assignment reassign:** `POST /workflow-assignments/:id/reassign` changes
  `assigned_to` on claimed assignment. Auth: team admin.
  (`server/handlers/workflow_assignments.go`, `server/store/*/workflows.go`)
- **Assignment cancel:** `POST /workflow-assignments/:id/cancel` cancels a
  single assignment. Auth: team admin.
  (`server/handlers/workflow_assignments.go`, `server/store/*/workflows.go`)
- **FE API domains:** `workflowAssignments` domain (mine, claim, unclaim,
  complete, reassign, cancel, comment). Team domain additions: assignments,
  workflowInstances, cancelWorkflowInstance, stage CRUD (workflowStages,
  createWorkflowStage, updateWorkflowStage, deleteWorkflowStage,
  reorderWorkflowStages). (`src/js/sw/sdk/api-domains.js`)
- **Team-admin workflows tabs:** Tabbed layout (Workflows | Assignments |
  Monitor). Workflows tab includes inline stage editor with add/edit/delete.
  Assignments tab shows claimed + available queue with claim/release/complete
  actions and WS live updates. Monitor tab shows active instances with cancel.
  (`src/js/sw/surfaces/team-admin/workflows.js`)
- **Settings assignments:** Replaces read-only workflow list with personal
  assignment queue across all teams. Shows claimed and available assignments
  with claim/release/open/complete actions.
  (`src/js/sw/surfaces/settings/workflows.js`)

### Changed

- Settings sidebar label "Workflows" → "Assignments".
  (`src/js/sw/surfaces/settings/index.js`)

---

## [0.37.14.23] — 2026-03-23

### Summary

Extension surface user menu fix + roadmap workflow integration. The ICD Runner
(and all extension surfaces) now mount the Preact UserMenu via `sw.userMenu()`
instead of relying on the dead Go template hydration.

### Fixed

- **Extension surface "? User" label:** extension template included static
  `{{template "user-menu"}}` which rendered placeholder HTML ("? User") since
  the legacy hydration script was removed in v0.37.13. Removed the dead
  template inclusion. (`server/pages/templates/surfaces/extension.html`)
- **Extension surface user menu click does nothing:** ICD Runner never called
  `sw.userMenu()` to mount the Preact UserMenu component. Added topbar with
  `sw.userMenu(topbar, { flyout: 'down' })` in `renderShell`. Also uses
  `sw.auth.user` instead of dead `window.__USER__` for header display.
  (`packages/icd-test-runner/js/ui.js`, `packages/icd-test-runner/js/main.js`)

### Changed

- **Roadmap:** added v0.37.15 reference to `DESIGN-0.37.15.md` workflow design
  doc, added v0.38.x Workflow Product Maturity section referencing
  `ROADMAP-0.38.md`. Trimmed completed version descriptions.

---

## [0.37.14.22] — 2026-03-23

### Summary

Thinking tags persistence + deleteMessage endpoint. Two features that close
out the v0.37.14 pane audit. Also fixes an S9 latent bug (messages paginated
response not unwrapped) and a SQLite NULL scan crash in GetByID.

### Added

- **deleteMessage server endpoint** — `DELETE /channels/:id/messages/:msgId`
  with soft-delete, channel access check, and `message.deleted` WS broadcast
  to other participants. (`server/handlers/messages.go`, `server/main.go`)
- **`message.deleted` event type** — registered as `DirToClient` for
  cross-tab message removal. (`server/events/types.go`)
- **`deleteMessage` SDK method** — `rc.del(...)` in channels namespace.
  (`src/js/sw/sdk/api-domains.js`)
- **`message.deleted` WS handler** — filters by active channel, removes
  message from state. (`src/js/sw/components/chat-pane/use-chat.js`)
- **`onDelete` wired in ChatPane** — delete button now visible on all
  messages. (`src/js/sw/components/chat-pane/index.js`)
- **`extractThinking` / `hydrateThinking` helpers** — parse `<think>` blocks
  from message content on the load path (switchChannel, loadMore, WS
  message.created). (`src/js/sw/components/chat-pane/use-chat.js`)

### Fixed

- **Thinking tags lost on refresh:** `<think>` content rendered correctly
  during streaming (via SSE `reasoning_content`) but spilled as raw text
  after page reload. Now extracted on the load path and rendered in the
  collapsible `<details>` block. Frontend-only fix, no DB migration.
- **S9 messages unwrap bug:** messages endpoint returns paginated response
  with 5 sibling keys, so `_unwrap` preserves the full object. `resp || []`
  produced an object, not an array. Changed to `resp.data || resp || []`.
- **SQLite NULL scan crash:** `GetByID` fails on user messages where `model`
  and `tokens_used` are NULL (struct uses `string`/`int`, not pointers).
  Delete handler now uses a direct `EXISTS` query instead.

---

## [0.37.14.21] — 2026-03-23

### Summary

Double-unwrap cleanup + API envelope normalization. Eliminated 93 defensive
`resp.data || resp || []` patterns across the frontend by fixing `_unwrap` to
preserve sibling fields and normalizing all Go handlers to use `{data: [...]}`
standard envelopes. Three latent bugs fixed (roles, provider types, audit).

### Changed

- `src/js/sw/sdk/rest-client.js` — `_unwrap` now only strips `{data: [...]}` when
  `data` is the sole key (`Object.keys(json).length === 1`). Responses with siblings
  (e.g. `{data: [...], total: N}`) pass through intact.
- **15 Go handlers** normalized from named keys (`users`, `configs`, `models`,
  `channels`, `participants`, `tools`, `folders`, `messages`, `values`, `packages`)
  to standard `{data: [...]}` envelope:
  - `server/handlers/admin.go` — ListUsers, ListGlobalConfigs, ListModelConfigs,
    ListArchivedChannels
  - `server/handlers/apiconfigs.go` — ListConfigs, ListModels
  - `server/handlers/channel_models.go` — List, Add, Update, Delete
  - `server/handlers/participants.go` — List, Add, Update, Remove
  - `server/handlers/presence.go` — SearchUsers
  - `server/handlers/completion.go` — ListTools
  - `server/handlers/folders.go` — List
  - `server/handlers/notes.go` — ListFolders
  - `server/handlers/messages.go` — GetActivePath, SetCursor
  - `server/handlers/teams.go` — ListAvailableModels
  - `server/handlers/team_providers.go` — ListTeamProviderModels
  - `server/handlers/packages.go` — UpdatePackageSettings
  - `server/handlers/package_registry.go` — BrowseRegistry
  - `server/handlers/roles.go` — ListRoles (bare map → wrapped in `{data: map}`)
- **~45 frontend files** — simplified all double-unwrap patterns:
  - Sole-key endpoints (unwrapped): `resp || []`
  - Sibling endpoints (not unwrapped): `resp.data || []`, `resp.total || 0`
  - Roles map: `r.data || {}` + `Object.entries()` for iteration
- Go integration + live provider tests updated for new envelope keys
- Frontend contract + policy-gating tests updated

### Fixed

- **Admin → Roles always empty:** `ListRoles` returned a raw map; `r.roles`
  was undefined on it. Now wrapped in `{data: map}`, frontend iterates via
  `Object.entries(roles)`.
- **Admin → Provider Types dropdown always empty:** `GetProviderTypes` returned
  `{data: types}`, `_unwrap` stripped it to bare array, `t.types` was undefined.
  Now correctly uses `t || []`.
- **Admin → Audit entries always empty:** `_unwrap` stripped `{data, total}` to
  just the array, losing `total`. Now preserved because of sibling-aware `_unwrap`.

### Design Notes

- **Envelope contract:** All list endpoints now return `{data: [...]}` or
  `{data: [...], ...metadata}`. No more `{users: [...]}`, `{models: [...]}`, etc.
- **`_unwrap` contract:** sole-key `{data: [...]}` → bare array;
  sibling keys present → full object (frontend accesses `.data` and siblings).
- **Editor package skipped:** `packages/editor/js/main.js` uses `API._get` which
  bypasses `_unwrap`; defensive patterns intentionally preserved.

---

## [0.37.14] — 2026-03-22

### Summary

Scorched Earth IV + Pane Audit — fourth dead-code purge. 4 files deleted (~−1,672 lines).
The old global layer (`sb.js`, `events.js`, `switchboard-sdk.js`, `workflow-surfaces.js`)
is fully removed. Two survivors (`debug.js`, `repl.js`) cleaned of all old-layer dependencies
and now stand alone with direct `window.*` exports.

### Removed

- `src/js/sb.js` (133 LOC) — global action registry (`window.sb`); onclick handlers
  in base.html rewritten to direct function calls
- `src/js/events.js` (361 LOC) — event bus + WebSocket bridge (`window.Events`);
  WebSocket auth called deleted `API` global; new SDK (`sw/sdk/events.js`) is the
  sole event system
- `src/js/switchboard-sdk.js` (~697 LOC) — composition layer (`window.Switchboard`);
  `window.sw` was immediately overwritten by Preact SDK `boot()`; most methods
  referenced deleted globals (`API`, `UI`)
- `src/js/workflow-surfaces.js` (481 LOC) — workflow stage surface registry; loaded
  deleted `ui-primitives.js` as dependency (already broken at runtime since v0.37.13)
- `base.html`: removed 3 script tags, removed `Switchboard.init()` block
  (including dead `handleLogout` referencing deleted `Events`/`API`)

### Changed

- `src/js/debug.js` — replaced `sb.ns()`/`sb.register()` with direct `window.*`
  exports; replaced dead `API`/`App`/`Events`/`Extensions` state snapshot with
  Preact SDK `window.sw` introspection; diagnostics use `sw.auth.token` and
  `sw.events.connected`; fixed `copyDebugLog` referencing deleted `UI.toast`
- `src/js/repl.js` — replaced `sb.ns()` with `window.REPL`; admin gate uses
  `window.sw.auth.user.role` instead of deleted `API.user.role`; injected globals
  reduced to `sw` + `DebugLog`; removed dead event label tab-completion
- `server/pages/templates/base.html` — 11 `sb.call()` onclick handlers rewritten
  to direct function calls
- `server/pages/templates/workflow.html` — broken custom surface loader replaced
  with graceful error message
- `src/sw.js` — removed 3 entries from SHELL_FILES (sb.js, events.js,
  switchboard-sdk.js)
- `packages/icd-test-runner/js/tier-sdk.js` — boot tests updated from
  `Switchboard.init()` to Preact SDK `boot()`; identity tests use `sw.auth.user`;
  theme test uses `sw.emit()`; removed extension compat shim test
- `src/js/sw/sdk/index.js` — SDK version marker updated to 0.37.14

### Design Notes

- **2 old JS files survive:** `debug.js` (debug modal + diagnostics) and `repl.js`
  (admin REPL console). Both are standalone with zero old-layer dependencies.
  Scheduled for Preact rewrite at v0.37.17.
- **Cumulative scorched earth:** 53 files deleted, ~−19,382 lines across four
  passes (v0.37.10, v0.37.12, v0.37.13, v0.37.14).

---

## [0.37.13] — 2026-03-22

### Summary

Scorched Earth III — third dead-code purge. 5 files deleted (−2,269 net lines).
The massive `ui-primitives.js` (1,133 LOC) is gone; its few remaining consumers
(`debug.js`, `switchboard-sdk.js`) received inlined replacements. Theme management
migrated from the global `Theme` object to the Preact SDK's `createTheme` module.
SDK slimmed by removing 6 dead factory wrappers.

### Removed

- `src/js/ui-primitives.js` (1,133 LOC) — `esc()`, `createComponentRegistry()`,
  `componentMixin()`, `Providers`, `Roles`, `showConfirm()`, `showPrompt()`,
  `openModal()`, `closeModal()`, `Theme`, render helpers. All consumers deleted
  or inlined.
- `src/js/code-editor.js` (362 LOC) — tabbed CodeMirror 6 editor, zero consumers
- `src/js/file-tree.js` (290 LOC) — workspace file browser widget, zero consumers
- `src/js/user-menu.js` (206 LOC) — vanilla flyout menu; all surfaces use
  Preact `sw/shell/user-menu.js`
- `src/js/app-state.js` (139 LOC) — global `window.App` state; Preact surfaces
  use local state hooks; `debug.js` state snapshot already guards with `typeof`

### Changed

- `src/js/debug.js` — inlined `openModal()`, `closeModal()`, `showConfirm()`,
  `_escHtml()` (~44 LOC) to replace dependency on deleted `ui-primitives.js`
- `src/js/switchboard-sdk.js` — removed 6 dead factory wrappers (`sw.notes()`,
  `sw.panels()`, `sw.fileTree()`, `sw.codeEditor()`, `sw.layout()`,
  `sw.userMenu()` + `_hydrateUserMenu()`); replaced global `Theme` with
  `import { createTheme } from './sw/sdk/theme.js'`; inlined `esc()` and
  `openModal`/`closeModal` helpers; net −213 LOC
- `server/pages/templates/base.html` — removed 5 script tags (`app-state.js`,
  `ui-primitives.js`, `user-menu.js`, `file-tree.js`, `code-editor.js`);
  consolidated removal comments
- `src/sw.js` — removed 5 entries from `SHELL_FILES` cache manifest

### Design Notes

- **6 old JS files survive:** `sb.js` (debug modal onclick handlers),
  `events.js` (extension bridge), `switchboard-sdk.js` (extension compatibility),
  `debug.js` + `repl.js` (active tooling), `workflow-surfaces.js` (deferred to
  Preact rewrite). Targeted for removal as extensions migrate.
- **`user-menu.css` kept:** extension surface template still renders server-side
  user-menu HTML; CSS provides styling for the placeholder.
- **Cumulative scorched earth:** 49 files deleted, ~−17,710 lines across three
  passes (v0.37.10, v0.37.12, v0.37.13).

---

## [0.37.12] — 2026-03-22

### Summary

Scorched Earth II — second dead-code purge. 23 files deleted (~2,600 lines).
All six core surfaces are confirmed Preact-only; old vanilla JS, CSS, and
admin templates removed. Service worker cache manifest overhauled.

### Removed

- `src/js/ui-format.js` — old markdown renderer (replaced by `sw/components/chat-pane/markdown.js`)
- `src/js/virtual-scroll.js` — only consumer was deleted `chat.js`
- `src/js/model-selector.js` — Preact ChatPane has its own `ModelSelector`
- `src/js/drag-resize.js` — only consumer was deleted `PaneContainer`
- `src/js/pages-splash.js` — login is Preact since v0.37.5
- `src/js/task-sidebar.js` — no template loads this
- `src/js/workflow-api.js` — new SDK has `sw.api.workflows.*`
- `src/js/workflow-queue.js` — references deleted `App.chats`
- `src/js/workflow-monitor.js` — ES module export, never imported
- `src/css/splash.css` — replaced by `sw-login.css`
- `src/css/styles.css` — 13-line decomposition comment from v0.22.9
- `src/css/persona-kb.css` — old admin/settings UI
- `src/css/notification-prefs.css` — old settings UI
- `src/css/memory.css` — old admin/settings UI
- `src/css/channel-models.css` — old chat autocomplete
- `server/pages/templates/admin/` — 8 dead templates (overview, users,
  models, providers, settings, teams, roles, routing); admin is Preact
  since v0.37.6

### Changed

- `server/pages/templates/base.html` — removed 7 script/link tags:
  `splash.css`, `marked.min.js`, `purify.min.js`, `ui-format.js`,
  `virtual-scroll.js`, `model-selector.js`, `drag-resize.js`.
  `marked`/`purify` now loaded only by surfaces that need them (chat, notes).
- `src/sw.js` — replaced entire `SHELL_FILES` array; removed 17 stale
  entries referencing files deleted in v0.37.10, added all `sw-*.css` files
- `server/pages/pages.go` — removed `templates/admin/*.html` from
  `//go:embed` directive and `ParseFS` call

### Design Notes

- **11 old JS files survive:** `sb.js`, `app-state.js`, `events.js`,
  `ui-primitives.js`, `user-menu.js`, `file-tree.js`, `code-editor.js`,
  `switchboard-sdk.js`, `debug.js`, `repl.js`, `workflow-surfaces.js`.
  These provide globals consumed by extension surfaces (`Events.on()`,
  `Theme.resolved()`, `sw.fileTree()`) and the debug modal (`sb.call()`).
  Targeted for removal when extensions migrate to Preact SDK.
- **`workflow-surfaces.js` deferred:** still loaded by `workflow.html`
  standalone page; removed when workflow surfaces rebuilt (v0.37.13).

---

## [0.37.11] — 2026-03-22

### Summary

Notes surface built as full Preact surface, replacing the v0.37.9 template
mount. Sidebar with folder tree, tag cloud, search, and UserMenu. NotesPane
runs `standalone=false` — the surface sidebar owns filter controls. Cross-surface
fixes: Menu primitive CSS was never loaded (now added to base.html), Avatar
initials fixed for single-word names.

### New

- `src/js/sw/surfaces/notes/index.js` — NotesSurface root, self-mounts at `#notes-mount`
- `src/js/sw/surfaces/notes/use-workspace.js` — sidebar toggle + active note (sessionStorage)
- `src/js/sw/surfaces/notes/use-sidebar.js` — folders, tags, search, WS live updates
- `src/js/sw/surfaces/notes/sidebar.js` — search input, folder/tag sections, UserMenu footer
- `src/js/sw/surfaces/notes/sidebar-folders.js` — collapsible folder tree with counts
- `src/js/sw/surfaces/notes/sidebar-tags.js` — collapsible tag cloud with active state
- `src/js/sw/surfaces/notes/notes-workspace.js` — workspace header + NotesPane
- `src/css/sw-notes-surface.css` — surface layout (sidebar + workspace + mobile responsive)

### Changed

- `src/js/sw/components/notes-pane/index.js` — handleRef expanded with `setFolder`,
  `setTagFilter`, `setSearchQuery`, `getFolder`, `getTagFilter`; threads `standalone`
  prop to NoteList
- `src/js/sw/components/notes-pane/note-list.js` — `standalone=false` hides search row,
  folder dropdown, tag cloud (surface sidebar owns these)
- `src/js/sw/primitives/avatar.js` — single-word names show first letter only
  ("admin" → "A" not "AD"); multi-word unchanged ("John Doe" → "JD")
- `server/pages/templates/surfaces/notes.html` — full rewrite: crash catcher +
  `#notes-mount` + Preact boot (mirrors chat.html pattern)
- `server/pages/templates/base.html` — added `sw-primitives.css` link (Menu primitive
  was unstyled on all surfaces); added `css-notes` conditional
- `src/css/surfaces.css` — deleted `.surface-notes*` rules (9 lines, replaced by
  `sw-notes-surface.css`)

### Design Notes

- **Sidebar ↔ NotesPane coordination:** write-only via handleRef. Sidebar sets
  folder/tag/search on the kit; kit reloads its own list. No bidirectional sync
  needed because `standalone=false` hides the kit's own filter controls.
- **Menu primitive bug:** `sw-primitives.css` was never loaded in `base.html`.
  The Menu component (`sw-menu__item`) had no hover, no cursor, no background.
  Chat surface masked this by using its own `.sw-chat-surface__context-menu`
  styles. Fixed globally — all surfaces now get proper Menu styles.
- **Kit enhancements deferred:** markdown toolbar, wikilink hover preview,
  breadcrumbs, split view, templates, slash commands, graph minimap all moved
  to v0.37.14 pane audit.

### Cumulative Layer Status

| Layer | Version | Status |
|-------|---------|--------|
| Preact+htm vendor | v0.37.2 | ✅ |
| Layer 0: Primitives | v0.37.11 | ✅ (Menu CSS fix, Avatar fix) |
| Layer 1: SDK | v0.37.10 | ✅ (8 modules) |
| Layer 1.5: ChatPane | v0.37.10 | ✅ (13 files) |
| Layer 1.5: NotesPane | v0.37.11 | ✅ (10 files, standalone prop) |
| Layer 2: Shell | v0.37.4 | ✅ (5 components) |
| Surface: Login | v0.37.5 | ✅ |
| Surface: Settings | v0.37.7 | ✅ |
| Surface: Admin | v0.37.6 | ✅ |
| Surface: Team Admin | v0.37.7 | ✅ |
| Surface: Chat | v0.37.10 | ✅ |
| Surface: Notes | v0.37.11 | ✅ |

## [0.37.10] — 2026-03-21

### Summary

Scorched earth rebuild of the Chat surface. The old 16-file imperative SPA
(~11,500 lines of `app.js`, `chat.js`, `ui-core.js`, `projects-ui.js`, etc.)
is replaced by a focused Preact surface (8 files) composing the ChatPane kit.
ChatPane kit upgraded with error display, stop generation, regenerate, code
block copy, markdown toolbar, emoji picker, and message pagination. Features
that don't belong on the chat surface (projects, KB management, extensions,
notifications) are deferred — each earns its way back as its own surface or
shell-level feature.

32 files changed. +774 / −13,615. **Net: −12,841 lines.**

### New

- **Chat Surface** (`src/js/sw/surfaces/chat/`) — 8 files:
  - `ChatSurface` root — sidebar + workspace + ToastContainer + DialogStack
  - `useWorkspace` — active conversation coordinator, sessionStorage restore
  - `useSidebar` — channels, chats, folders data; search filter; WS live
    updates; CRUD (rename, delete, move-to-folder, create folder)
  - `Sidebar` — search bar, "New Chat" button, collapsible Channels section,
    collapsible Chats section with folder grouping, context menus, desktop
    collapse toggle
  - `SidebarChannels` — DMs + named channels, unread badges
  - `SidebarChats` — personal AI chats, folder grouping, context menus
    (rename, delete, move to folder)
  - `ChatWorkspace` — workspace header (sidebar toggle, tools button) +
    ChatPane in `standalone=false` mode (surface manages navigation)
  - `ToolsPopup` — scrollable category-based tool toggles with persistence,
    `useTools` hook loads from API
- **ChatPane Kit enhancements** (4 new components in `chat-pane/`):
  - `CodeBlock` — language badge + copy button, code block extraction from
    rendered markdown HTML
  - `MessageActions` — per-message toolbar: regenerate, copy text, delete.
    Appears on hover/focus
  - `MarkdownToolbar` — bold, italic, code, code block, link, bullet list,
    numbered list, heading, blockquote. Wraps selection with markdown syntax.
    Keyboard shortcuts: Ctrl+B, Ctrl+I, Ctrl+E
  - `EmojiPicker` — categorized grid (7 categories), search, recently used
    (localStorage). Inserts at cursor position
- **ChatPane Kit upgrades** (6 modified files):
  - `index.js` — error banner rendered from `error` state, stop button wiring,
    new kit re-exports
  - `use-chat.js` — `regenerate(msgId)`, `deleteMessage(msgId)`, `loadMore()`
    pagination, `hasMore`/`loadingMore` state
  - `message-list.js` — "Load older messages" pagination button, scroll
    position preservation on prepend, ARIA `role="log"`, regenerate/delete
    wiring per message
  - `message-bubble.js` — CodeBlock rendering with copy+language badge,
    MessageActions toolbar (regenerate/copy/delete), relative timestamps
  - `message-input.js` — MarkdownToolbar, EmojiPicker, stop button (replaces
    send during streaming), ARIA labels, keyboard format shortcuts
  - `markdown.js` — `marked.setOptions()` with GFM+breaks, task list checkbox
    rendering via custom renderer
- **UserMenu auto-surface-filtering** — reads `body[data-surface]`, auto-
  includes links to all other surfaces (Notes, extensions), filters out the
  current surface. Default `handleSelect` navigates via `location.href`.
  Every surface gets the same menu minus itself.
- **`sw-chat-surface.css`** — sidebar + workspace flex layout, responsive
  mobile overlay, sidebar collapse, tools popup, context menus (~600 lines)

### Deleted

**16 JS files (~11,500 lines):**
- `src/js/app.js` (589) — SPA bootstrap, sidebar, init
- `src/js/api.js` (995) — REST client, auth, token management
- `src/js/chat.js` (1,344) — message rendering, streaming, CM6 input
- `src/js/chat-pane.js` (160) — legacy ChatPane wrapper
- `src/js/channel-models.js` (437) — channel model management UI
- `src/js/extensions.js` (506) — extension panel, package management
- `src/js/files.js` (857) — file upload/staging UI
- `src/js/knowledge-ui.js` (556) — knowledge base toggle popup
- `src/js/notifications.js` (431) — notification bell + dropdown
- `src/js/pages.js` (349) — admin page rendering
- `src/js/pane-container.js` (589) — split pane resize manager
- `src/js/panels.js` (454) — side panel registration + lifecycle
- `src/js/projects-ui.js` (2,090) — project sidebar section
- `src/js/tokens.js` (167) — token count display
- `src/js/tools-toggle.js` (245) — tools toggle popup
- `src/js/ui-core.js` (1,699) — core UI: sidebar, messages, context menus

**5 CSS files (~1,650 lines):**
- `src/css/chat.css` (648) — old chat area, sidebar, workspace, input
- `src/css/chat-pane.css` — legacy chat pane styles
- `src/css/panels.css` — side panel styles
- `src/css/pane-container.css` — split pane layout
- `src/css/notifications.css` — notification bell + dropdown

### Changed

- `chat.html` — rewritten from ~387-line SPA scaffold to ~50-line Preact boot
  (same pattern as settings/admin/team-admin surfaces)
- `base.html` — removed `<script>` tags for deleted JS files (api.js,
  ui-core.js, pages.js, chat-pane.js, pane-container.js), removed `<link>`
  tags for deleted CSS files (chat.css, panels.css, pane-container.css,
  chat-pane.css)
- `user-menu.js` — auto-surface-filtering via `body[data-surface]`, default
  navigation handler, `extraItems` prop for caller-specific menu additions
- `sw-chat-pane.css` — added styles for code block (header, language badge,
  copy button), emoji picker (tabs, grid, search), markdown toolbar, error
  banner, stop button, load-more pagination, message footer (time + actions),
  message action buttons, task list checkboxes
- `server/version.go` — `0.37.9` → `0.37.10`
- `VERSION` — `0.37.9` → `0.37.10`

### Design Notes

- **Surface manages navigation, ChatPane renders** — ChatPane runs in
  `standalone=false` mode. The surface sidebar handles chat/channel selection;
  ChatPane receives `channelId` as a prop and just renders the conversation.
  No duplicate history dropdowns.
- **UserMenu is self-sufficient** — every surface imports `<UserMenu>` and
  gets auto-filtered surface links, RBAC-gated admin items, and navigation
  handling. No per-surface routing boilerplate.
- **Features deferred by design** — Projects, KB management, extension panels,
  notification panel don't belong on the chat surface. Each earns its way back
  as its own surface or shell-level feature in v0.37.12+.
- **Preview panel deferred** — the old iframe preview was fragile. Needs design
  discussion for proper implementation.
- **Emoji picker is a gag for the release** — but also legitimately useful for
  channels. Categorized grid with recents, ~200 emoji across 7 categories.

### Known Issues

- **Tools popup empty** — requires LLM providers configured (API keys). Works
  as designed; tools list populates when providers are active.
- **ES module caching** — internal module imports use bare relative paths
  (`./sidebar.js`) with no version query. Browser module cache persists across
  page loads. In dev, requires version bump or browser restart to pick up
  changes. Production is unaffected (fresh loads on version bump).
- **base.html legacy scripts** — `sb.js`, `app-state.js`, `events.js`,
  `switchboard-sdk.js` still loaded globally for extension surface compat.
  Full base.html cleanup deferred to post-v0.37.13.

## [0.37.9] — 2026-03-21

### Summary

Preact NotesPane kit — Obsidian-grade composable note components replacing
the old imperative `note-panel.js`, `note-graph.js`, and `notes.js` (~1,630
lines deleted). Every piece independently importable. `NotesPane` is the
default assembly; surfaces import individual pieces for custom layouts.

### New

- **NotesPane kit** (`src/js/sw/components/notes-pane/`) — 10 files:
  - `NotesPane` — default assembly: list + editor + reader + graph + quick switcher
  - `useNotes(opts)` — state machine (views, CRUD, pagination, search, folders,
    sort, selection, backlinks, daily notes, wikilink navigation, pinning)
  - `NoteList` — toolbar, search, folder/sort filters, tag cloud, pinned section,
    selection bar, load-more pagination
  - `NoteListItem` — single note row with title, time, preview, folder, tags, pin
  - `NoteEditor` — title/folder/tags/content with CM6 integration, word/char count,
    Ctrl+S save, Esc cancel, folder datalist autocomplete
  - `NoteReader` — rendered markdown with wikilink chips, transclusion embeds,
    sticky outline sidebar, daily note nav, backlinks panel, unlinked mentions
  - `NoteGraph` — canvas force-directed graph with focus mode (single-click =
    focus neighbors, double-click = open note), ghost node click → create,
    folder colors, pan/zoom/drag, orphan toggle
  - `renderNoteMarkdown` — marked+DOMPurify with wikilink chip rendering,
    transclusion blocks, heading extraction for outline
  - `SaveToNoteDialog` — create-new or append-to-existing, independently
    importable by ChatPane or any surface
  - `QuickSwitcher` — Cmd+K/Ctrl+K overlay: fuzzy title search, keyboard nav
    (↑↓ Enter Esc), recent notes (localStorage), create-on-enter
- **`sw.notesPane(container, opts)`** — SDK mount helper, lazy-imports Preact
  NotesPane, returns imperative handle
- **`sw-notes-pane.css`** — component styles with `sw-notes-pane` prefix (~500 lines)
- **Obsidian-grade features:**
  - Wikilinks (`[[Title]]`, `[[Title|Display]]`) → clickable chips
  - Transclusions (`![[Title]]`) → inline embedded content (1-level)
  - Quick Switcher (Ctrl+K) with fuzzy search + create-on-enter
  - Outline sidebar (sticky, right side, scroll-to-heading)
  - Daily notes with auto-template + prev/next day navigation
  - Pinned notes (localStorage) at top of list
  - Tag cloud with clickable filter chips
  - Unlinked mentions (note titles in content not yet wikilinked)
  - Graph focus mode (click = focus neighbors, walk the graph)
  - Ghost node click → create missing note
  - Word/character count in editor
  - Keyboard shortcuts (Ctrl+S, Ctrl+N, Ctrl+D, Ctrl+K, Esc)

### Deleted

- `src/js/notes.js` (155 lines) — backward-compat shim
- `src/js/note-panel.js` (976 lines) — old imperative NotePanel
- `src/js/note-graph.js` (501 lines) — old canvas graph
- `src/css/panels.css` — all `.note-*`, `.notes-*`, `.wikilink-*`,
  `.transclusion-*`, `.save-note-*` rules stripped
- `server/pages/templates/surfaces/chat.html` — `notes.js` script tag removed
- `server/pages/templates/base.html` — `note-panel.js` + `note-graph.js`
  script tags removed

### Changed

- `notes.html` — rewritten: Preact vendor globals + SDK `boot()` +
  `sw.notesPane()` mount (same pattern as settings/admin surfaces)
- `base.html` — added `sw-notes-pane.css` stylesheet link
- `switchboard-sdk.js` — `sw.notes()` redirects to `sw.notesPane()` with
  deprecation warning
- `app.js` — removed `_initNotesListeners()` + `_registerNotesPanel()` calls
- `ui-format.js` — `_openNoteFromTool()` made no-op (dies in v0.37.10)
- `ui-core.js` — note tool-call link handler made no-op
- `server/version.go` — version `0.37.8` → `0.37.9`
- `VERSION` — `0.37.8` → `0.37.9`
- `src/js/sw/sdk/index.js` — `sw.notesPane()` helper, `sw._sdk` → `0.37.9`

### Design Notes

- **Kit of Parts** — same composability pattern as ChatPane. Any surface can
  import `NoteList` alone, or `NoteEditor` + `NoteReader`, or the full assembly.
- **handleRef pattern** — same as ChatPane (no `forwardRef`/compat).
- **Graph interaction model** — single click = focus (show 1-hop neighbors),
  double click = open note. Walk the graph by clicking through focused nodes.
  Ghost nodes (unresolved wikilinks) create on click via `navigateToLink`.
- **Outline sidebar** — sticky right pane when note has >2 headings; falls back
  to stacked layout on mobile (<600px).

### Known Issues (minor, pre-tag)

- Light mode: some notes-pane styles still reference dark theme defaults.
  Needs CSS variable audit for `--bg-surface`, `--input-bg` in light theme.

---

## [0.37.8] — 2026-03-21

### Summary

Preact ChatPane kit — composable chat components replacing the old
imperative `chat-pane.js` standalone mode. Every piece (hooks,
components, utilities) independently importable. `ChatPane` is the
default assembly; surfaces import individual pieces for custom layouts.

### New

- **ChatPane kit** (`src/js/sw/components/chat-pane/`) — 9 files:
  - `ChatPane` — default assembly: header + messages + input
  - `useChat(opts)` — channel state machine (messages, send/receive,
    model selection, channel CRUD)
  - `useStream()` — SSE ReadableStream parser with rAF-batched updates
  - `MessageList` — scrollable messages with auto-scroll-to-bottom
  - `MessageBubble` — single message with role styling + markdown
  - `MessageInput` — auto-resize textarea, Enter-to-send, handleRef
  - `ModelSelector` — model dropdown from `sw.api.models.enabled()`
  - `ChatHistory` — channel history select + new-chat button
  - `renderMarkdown` — `marked` + `DOMPurify` wrapper with fallback
- **`sw.chatPane(container, opts)`** — SDK mount helper, lazy-imports
  Preact ChatPane, returns imperative handle
- **`sw-chat-pane.css`** — component styles with `sw-` prefix convention
- **Layer 1.5: Components** — `src/js/sw/components/` directory
  established for reusable compound components (between SDK and surfaces)

### Changed

- `switchboard-sdk.js` — `sw.chat()` now prefers `window.sw.chatPane()`
  when new SDK is loaded, falls back to legacy `ChatPane.mount()`
- `chat-pane.js` — stripped standalone mode (446 → 160 lines). Removed
  `mount()`, `_initStandalone()`, and all streaming/model/history logic.
  Retained DOM-binding `create()` for legacy chat surface (`app.js`)
- `server/version.go` — version `0.37.6` → `0.37.8`
- `VERSION` — `0.37.6` → `0.37.8`
- `src/js/sw/sdk/index.js` — `sw.chatPane()` helper, `sw._sdk` → `0.37.8`
- `server/pages/templates/base.html` — added `sw-chat-pane.css` link

### Design Notes

- **Kit of Parts + Default Assembly** — every hook and component is
  independently importable. Future Chat surface (v0.37.10) will import
  `useChat`, `useStream`, `MessageList`, `MessageInput` individually
  with a completely different layout.
- **handleRef pattern** — core Preact only exposes `{ h, render }`;
  `forwardRef` requires `preact/compat`. Components use a `handleRef`
  prop pattern instead: caller passes `{ current: null }`, component
  populates with imperative methods via `useEffect`.

---

## [0.37.7] — 2026-03-21

### Summary

Scorched earth: all legacy admin JS removed. Team Admin becomes a
standalone surface. Settings bridge sections replaced with native Preact
components. Chat surface stripped of all admin/settings script
dependencies. 16 legacy JS files deleted (~5,400+ lines).

### New

- **Team Admin surface** (`src/js/sw/surfaces/team-admin/`) — standalone
  at `/team-admin`, 10 native Preact sections:
  - `TeamAdminSurface` — root with team picker (multi-team admins),
    tab sidebar nav, lazy `import()` per section
  - `MembersSection` — list, add, change role, remove via
    `sw.api.teams.members/addMember/updateMember/removeMember`
  - `ProvidersSection` — CRUD provider configs, card layout, model sync
  - `PersonasSection` — CRUD personas with KB bindings
  - `KnowledgeSection` — read-only team KB list
  - `GroupsSection` — read-only team groups list
  - `WorkflowsSection` — full CRUD + publish, stage viewer
  - `TasksSection` — CRUD + run/kill, schedule display
  - `SettingsSection` — team name/description form
  - `UsageSection` — stat cards + usage-by-day table
  - `ActivitySection` — paginated audit log with filters
- **7 native settings sections** (replace all bridge sections):
  - `WorkflowsSection` — user's visible workflows (read-only)
  - `TasksSection` — user tasks with run/stop actions
  - `GitKeysSection` — SSH credential management
  - `DataSection` — data export + account deletion (danger zone)
  - `KnowledgeSection` — personal KB CRUD with document upload
  - `MemorySection` — memory list with inline edit, status badges
  - `NotificationsSection` — toggle-based notification preferences
- **SDK additions** (`api-domains.js`):
  - `teams.workflows/createWorkflow/getWorkflow/updateWorkflow/
    deleteWorkflow/publishWorkflow` — team-scoped workflow CRUD
  - `teams.tasks/createTask/updateTask/deleteTask/runTask/killTask` —
    team-scoped task CRUD
  - `teams.knowledgeBases` — team KB list
  - `teams.update` — team settings update
  - `teams.updatePersona` — team persona update
  - `git.credentials.list/create/del` — git SSH credentials
  - `dataPortability.exportMe/deleteAccount` — data portability
- **Go template** — `server/pages/templates/surfaces/team-admin.html`
  (Preact mount + vendor boot, no legacy modals)
- **Surface registration** — `team-admin` surface registered in
  `pages.go`, `loaders.go`, `base.html` template dispatch, `nginx.conf`
  proxy rule

### Changed

- `server/version.go` — `0.37.6` → `0.37.7`
- `VERSION` — `0.37.6` → `0.37.7`
- `settings/index.js` — removed `BridgeSection` import, emptied
  `bridgeSections` object, added 7 native section modules to
  `sectionModules`, removed bridge rendering ternary, added Knowledge
  Bases / Memory / Notifications / Data & Privacy to `NAV_ITEMS`
- `chat.js` — menu handlers navigate to `/admin/users`,
  `/team-admin/members`, `/settings/general` instead of opening modals.
  Removed `UI.initAppearance()` call (was in deleted settings-handlers).
- `app.js` — removed `_initSettingsListeners()` and
  `_initAdminListeners()` calls
- `chat.html` — stripped 5 legacy admin script tags (`ui-settings.js`,
  `ui-admin.js`, `settings-handlers.js`, `admin-handlers.js`,
  `workflow-admin.js`) + `memory-ui.js`, `persona-kb.js`,
  `notification-prefs.js`. Removed team admin modal HTML (~110 lines).
- `admin.html` — removed 7 legacy modal overlays (~130 lines of HTML):
  createUserModal, resetPwModal, approveUserModal, createTeamModal,
  createGroupModal, providerFormModal, personaFormModal
- `settings.html` — removed 9 bridge loader script tags, updated
  docstring to reflect full Preact (no bridges)
- `base.html` — added `team-admin` surface template + scripts dispatch
- `nginx.conf` — added `/team-admin` proxy rule to Go backend

### Deleted

- `ui-admin.js` (1,690 lines) — old admin panel, team admin modal,
  ADMIN_LOADERS
- `ui-settings.js` (867 lines) — team management functions
- `admin-handlers.js` (~900 lines) — admin event listeners
- `settings-handlers.js` (908 lines) — settings save/load, bridge
  loaders
- `admin-scaffold.js` (367 lines) — old admin DOM scaffolding
- `admin-packages.js` — old package management UI
- `broadcast-admin.js` — old broadcast admin UI
- `task-admin.js` — old task admin UI
- `task-settings.js` — old task settings UI
- `workflow-admin.js` — old workflow admin builder
- `workflow-team-admin.js` — old team workflow builder
- `memory-ui.js` — old memory management UI
- `notification-prefs.js` — old notification preferences UI
- `persona-kb.js` — old persona KB binding UI
- `data-portability.js` — old data export/import UI
- `git-credentials-ui.js` — old git credentials UI
- `bridge-section.js` — settings bridge wrapper (no longer needed)

### Retained (chat surface still uses)

- `knowledge-ui.js` — `KnowledgeUI.onChatChanged()` called by chat.js
  for channel KB popup. Dies in v0.37.10 (chat surface rebuild).
- `channel-models.js` — `ChannelModels.*` used by chat.js for @mention
  autocomplete. Dies in v0.37.10.
- `notifications.js` — `Notifications.init()` called by app.js for
  notification badge/list. Dies in v0.37.10.

## [0.37.5] — 2026-03-21

### Summary

Login + Settings surface rebuild (Phase 3 begins). First surfaces
converted from server-rendered Go templates + imperative DOM JS to
Preact component trees consuming the v0.37.3 SDK (`sw.api.*`,
`sw.auth.*`, `sw.theme.*`).

### New

- **Login surface** (`src/js/sw/surfaces/login/`) — 5 Preact components:
  - `LoginSurface` — root, auth mode switching (builtin/oidc/mtls),
    OIDC callback hash handler
  - `Hero` — left panel with branding, feature pills, version display
  - `LoginForm` — username/password, calls `sw.api.auth.login()`
  - `RegisterForm` — registration flow with validation, pending approval
  - `SSOPanel` — OIDC redirect + mTLS continue button
- **Settings surface** (`src/js/sw/surfaces/settings/`) — 12 files:
  - `SettingsSurface` — left nav + section routing via `__SECTION__`
  - `GeneralSection` — model select, system prompt, max tokens,
    temperature slider, thinking toggle. Uses `sw.api.models.enabled()`
    + `sw.api.profile.settings()`
  - `AppearanceSection` — theme toggle (light/dark/system) via
    `sw.theme.set()`, UI scale + message font size sliders
  - `ProfileSection` — avatar (upload/remove), display name, email,
    password change. Uses `sw.api.profile.*`
  - `ModelsSection` — searchable model table with visibility toggles.
    Uses `sw.api.models.enabled()` + `sw.api.models.setPref()`
  - `TeamsSection` — read-only team membership list via
    `sw.api.teams.mine()`
  - `BridgeSection` — thin Preact wrapper for deferred sections that
    call old JS loaders (Tasks, Git Keys, Workflows, Data & Privacy)
- **`sw.toast()`** — imperative toast API on the SDK. Dynamic import
  from primitives. `sw.emit('toast', { message, variant })` event
  listener for surface-agnostic toast firing.
- **Nav gating** — Personas tab hidden when `allow_user_personas`
  policy is false. BYOK section (Providers, Roles, Usage) hidden when
  `allow_user_byok` is false. Fetched from `sw.api.admin.settings.public()`.
- **ToastContainer + DialogStack** mounted in settings surface root
  (not via shell `<App>`, since surfaces render standalone).
- **`sw-login.css`** — login-specific styles extracted from inline
  `<style>` in old Go template.

### Changed

- `server/version.go` — version `0.36.0` → `0.37.5`
- `VERSION` — `0.36.0` → `0.37.5`
- `server/pages/templates/login.html` — stripped all inline CSS/JS,
  minimal shell with `<div id="login-mount">`, vendor + SDK + surface
  boot script chain. Injects `__AUTH_MODE__`, `__REGISTRATION_OPEN__`,
  `__BANNER__`, `__VERSION__`, `__ENVIRONMENT__`.
- `server/pages/templates/surfaces/settings.html` — server-rendered DOM
  replaced with `<div id="settings-mount">`. Bridge section old JS
  script tags retained for deferred sections. Injects `__SECTION__`,
  `__PAGE_DATA__`.
- `src/js/sw/sdk/index.js` — `sw.toast` wiring (dynamic import),
  `sw._sdk` bumped to `'0.37.5'`. `?v={{.Version}}` on SDK + surface
  imports for cache busting (vendor imports stay bare to avoid
  dual-instance).

### Known Issues (must resolve before tag)

These are tracked for future changesets — every feature must work by
the time we reach v0.37.# tag.

1. **Bridge sections stuck on "Loading..."** — Workflows and Git Keys
   bridge sections render their mount `<div>` but the old JS loaders
   (`loadTeamWorkflows`, `_loadSettingsGitKeys`) depend on globals
   (`UI`, `API`, `App`) from `base.html`'s old script chain that aren't
   loaded on the new Preact settings surface. **Resolution:** rewrite
   these as full Preact components in a future changeset, or ensure the
   old globals are available via a compatibility shim.

2. **Tasks bridge works, Data & Privacy bridge works** — these old JS
   files load successfully because their loaders use the `sw.*` SDK or
   simpler DOM patterns that don't depend on the old `UI`/`API` globals.

3. **Personas section** — nav gating works (hidden when policy disabled).
   The section component itself is thin (list + create stub). Full
   persona CRUD with KB bindings deferred.

4. **BYOK sections** — Providers, Roles, Usage nav items correctly
   hidden when `allow_user_byok` is false. Not yet tested with BYOK
   enabled (requires admin enabling the policy + configuring a provider).

5. **`pages.js` not deleted** — still contains admin surface functions
   (`Pages.saveRole`, `Pages.saveProvider`, etc.) used by the admin
   surface (v0.37.6). Login/settings functions in `pages.js` are now
   dead code but kept until admin surface rewrite.

6. **`ui-settings.js` + `settings-handlers.js` not yet deleted** — the
   old code is superseded by the Preact surface but some bridge sections
   may still reference helpers from these files. Will delete after
   confirming no remaining references in a dedicated cleanup pass.

### Deleted

- (Deferred — old settings JS files retained until bridge sections are
  fully replaced. See Known Issues #6.)

## [0.36.0] — 2026-03-20

### Summary

Full OpenAPI 3.0.3 specification covering all API domains. Expands the
hand-curated spec from v0.33.0 (12 endpoints) to 296 paths / 216
operations across all ICD-documented domains. Documents Bearer JWT,
mTLS, and OIDC auth modes. Adds reusable schemas, error responses,
and pagination parameters.

### New

- **OpenAPI spec expanded to 296 paths** — up from 12 endpoints.
  Covers: Auth (builtin + OIDC), Profile & Settings, Channels (CRUD,
  participants, models, KB bindings, presence, folders, typing,
  summarize, title generation), Messages (tree walk, edit, regenerate,
  siblings, cursor), Completions (streaming + non-streaming), Files
  (upload, download, metadata, message files), Personas (3-scope CRUD,
  avatars, KB bindings, tool grants, persona groups), Models (enabled
  list, preferences, bulk), Providers (BYOK CRUD, model sync), Notes
  (CRUD, search, graph, backlinks, bulk delete), Knowledge Bases
  (CRUD, documents, search, rebuild, discoverable), Memory (CRUD,
  approve/reject, count, compact), Projects (CRUD, channels, KBs,
  notes, files, reorder), Workspaces (CRUD, file ops, archive, git),
  Git Operations (clone, pull, push, status, diff, commit, log,
  branches, checkout), Git Credentials, Tasks (CRUD, runs, kill),
  Workflows (CRUD, stages, publish, versions, instances, advance,
  reject), Workflow Assignments (list, claim, complete, comment),
  Workflow Forms (template, submit, visitor entry), Notifications
  (CRUD, preferences, unread count), Data Portability (export, import,
  ChatGPT import, GDPR delete), Webhook triggers, Health probes,
  Metrics, WebSocket tickets.
- **29 reusable component schemas** — AuthResponse, AuthUser,
  UserProfile, Channel, Message, Participant, FileObject, ToolDef,
  CompletionRequest/Response, TokenUsage, PersonaInput,
  ProviderConfigInput, ProviderHealth, ErrorResponse, MessageResponse.
- **Standardized error responses** — 400, 401, 403, 404, 409, 429
  with $ref reuse across all paths.
- **Security scheme documentation** — bearerAuth (JWT, 15m TTL) and
  mtlsAuth (mutual TLS via proxy headers).
- **Reusable parameters** — ResourceID, Page, PerPage for consistent
  pagination across all list endpoints.
- **25 domain tags** pre-declared for Swagger UI navigation.
- **AuthResponse schema corrected** — `access_token` (was incorrectly
  `token`), added `token_type`, `expires_in`, `handle`, `auth_source`.
- **Login endpoint corrected** — field is `login` (accepts username
  or email), not `username`.

### Changed

- `server/static/openapi.yaml` — complete rewrite (643 → 11845 lines)

## [0.35.1] — 2026-03-20

### Summary

Data Portability FE Wiring: connects the v0.34.0 backend (export,
import, ChatGPT import, GDPR delete, backup CronJob) to the frontend.
New Settings → Data & Privacy section. Admin team export button.

### New

- **Settings → Data & Privacy section** — new nav link and full-page
  section with four features:
  - **Download My Data** — streams `GET /export/me` as `.switchboard`
    zip blob download (conversations, notes, memories, projects, files,
    settings, usage).
  - **Import Data** — file picker for `.switchboard` / `.zip` archives,
    uploads to `POST /import/me` with import/skip/error counts in toast.
  - **Import from ChatGPT** — file picker for ChatGPT export zips,
    uploads to `POST /import/chatgpt`, maps conversations to direct
    chats.
  - **Delete My Account** — danger zone with password confirmation,
    calls `DELETE /me`, shows anonymized username, clears tokens, and
    redirects to `/login`.
- **Admin team export button** — 📦 icon button on each team row in
  Admin → Teams. Calls `GET /admin/teams/:id/export` and downloads
  `.switchboard` archive.

### New files

- `src/js/data-portability.js` — Settings section renderer + admin
  team export helper.

### Changed

- `settings.html` — added nav link, section title, content area,
  script tag, and dynamic section dispatch for `data`.
- `ui-admin.js` — added export button to team list action cells.

## [0.35.0] — 2026-03-19

### Summary

Workflow Product: transforms the workflow engine from a linear
stage-runner into a product-grade automation tool. Conditional routing,
progressive forms, Starlark data pipeline, structured review with
comments, and a monitoring dashboard with SLA tracking.

### New

- **Conditional routing engine** — `transition_rules.conditions[]`
  evaluated against accumulated `stage_data`. Supports eq/neq/gt/lt/
  gte/lte/in/contains/exists operators. First match wins; no match
  falls back to ordinal+1. Backward compatible.
- **`workflow_route` tool** — persona-callable tool for AI-triggered
  routing to named stages. Supports forward and backward jumps (loop
  stages). Records routing decisions in `stage_data._route_history_latest`.
- **Starlark `workflow.route()`** — extension-driven routing for
  confidence-based escalation patterns.
- **`on_advance` hook** — Starlark entry point fires synchronously
  after stage transition. Can enrich/transform `stage_data` or reject
  the transition. Configured via `transition_rules.on_advance`.
- **Progressive forms** — `form_template.fieldsets[]` for multi-step
  forms with next/back navigation and step indicators.
- **Conditional fields** — `FormField.condition` (when/op/value)
  controls field visibility. Server-side validation skips hidden fields.
- **Workflow branding** — `accent_color`, `logo_url`, `tagline` now
  applied to workflow visitor pages via CSS custom properties.
- **Structured review** — side-by-side layout with data card + actions
  panel. Keyboard shortcuts: Ctrl+Enter approve, Ctrl+Shift+Enter reject.
- **Review comments** — `POST /workflow-assignments/:id/comment` appends
  reviewer notes to the assignment's `review_comments` JSONB array.
- **Monitoring dashboard** — admin endpoints for active instances,
  stage funnels, and stale detection. SLA computed from
  `stage_entered_at + sla_seconds` vs NOW().
- **SLA tracking** — `sla_seconds` column on workflow_stages,
  `stage_entered_at` on channels. Dashboard shows remaining time
  with green/yellow/red indicators.

### Schema

- `workflow_stages.sla_seconds INTEGER` (018 modified in-place)
- `channels.stage_entered_at TIMESTAMPTZ/TEXT` (005 modified in-place)
- `workflow_assignments.review_comments JSONB/TEXT DEFAULT '[]'` (018)
- Index `idx_channels_workflow_active` (005)
- DB rebuild required (wipe data directory)

### New files

- `server/workflow/routing.go` — conditional routing engine
- `server/handlers/workflow_hooks.go` — on_advance hook firing
- `server/handlers/workflow_monitor.go` — monitoring dashboard
- `src/js/workflow-monitor.js` — admin monitoring frontend
- `packages/icd-test-runner/js/crud/workflow-product.js` — E2E tests

## [0.33.0] — 2026-03-19

### Summary

Observability: structured logging, Prometheus metrics, OpenAPI docs,
Grafana dashboards, alerting rules, and a built-in admin monitoring
dashboard.  Operate the platform without reading Go source code.

## [0.34.0] — 2026-03-19

### Summary

Data Portability: bulk export/import for user and team data, GDPR
"download my data" and "delete my data" with cascade + audit trail,
ChatGPT conversation import, and Kubernetes backup/restore CronJob
manifests.

### New

- **User data export** (`GET /api/v1/export/me`) — streams a
  `.switchboard` zip archive containing all user-scoped entities:
  channels, messages, notes, note links, memories, projects, project
  associations, workspaces, workspace files, file attachments (blobs),
  folders, user model settings, notification preferences, usage entries,
  persona groups, and persona group members. Manifest includes entity
  counts and scope metadata. Sensitive fields (password hashes, vault
  keys, provider config IDs) stripped via `export.Sanitize*` functions.
  File blobs capped at 100 MB per file, 500 MB total archive, 10K files.
- **User data import** (`POST /api/v1/import/me`) — accepts
  `.switchboard` zip upload, validates manifest and format version,
  imports entities in FK-dependency order with UUID dedup (skip if ID
  exists). User ID remapping for cross-instance portability (source
  user → current user). Returns imported/skipped counts and error list.
- **ChatGPT import** (`POST /api/v1/import/chatgpt`) — accepts ChatGPT
  export zip containing `conversations.json`. Maps ChatGPT conversation
  format (alternating author roles, message parts) into Switchboard
  channels + messages. Handles `system`, `user`, `assistant`, and
  `tool` roles. Title becomes channel name.
- **GDPR account deletion** (`DELETE /api/v1/me`) — requires explicit
  `"confirm": "DELETE"` + password verification. Prevents deleting the
  last admin. Cascade: soft-deletes user data (messages anonymized,
  channels/notes/memories/projects deleted), revokes tokens, deletes
  personal provider configs, anonymizes user record to
  `deleted-user-{hash}`. Full audit trail.
- **Team data export** (`GET /api/v1/admin/teams/:id/export`) — admin
  endpoint streaming a `.switchboard` archive of all team-scoped
  entities: channels, messages, members, personas, persona KBs,
  knowledge bases, KB documents, workflows, workflow versions/stages,
  groups, group members, resource grants, and projects. Sanitizes
  personas (strips provider binding), workflows (strips webhook
  secrets).
- **Team data import** (`POST /api/v1/admin/teams/:id/import`) — admin
  endpoint accepting `.switchboard` archive for team data restoration.
- **Backup/restore CronJob** — Helm chart templates for scheduled
  `pg_dump` backups. `cronjob-backup.yaml` runs on configurable
  schedule (default: daily at 02:00), writes gzip-compressed dump to
  PVC with configurable retention (default: 7 backups). Optional S3
  offload. `pvc-backup.yaml` for backup storage. Gated by
  `backup.enabled` in `values.yaml`.

### New files

- `server/export/format.go` — `.switchboard` archive format: zip
  with `manifest.json`, `data/*.json` entity files, `files/` blob
  directory. `ArchiveWriter` with size tracking, `ArchiveReader`
  with manifest validation.
- `server/export/entities.go` — per-entity sanitization: `SanitizeUser`
  (strips password hash, vault fields), `SanitizeChannels` (strips
  provider config), `SanitizeMessages`, `SanitizeChannelModels`,
  `SanitizeUserModelSettings`, `SanitizeUsageEntries`,
  `SanitizeWorkspaces` (strips git creds), `SanitizePersonas`,
  `SanitizeWorkflows` (strips webhook secrets).
- `server/export/chatgpt.go` — ChatGPT `conversations.json` parser.
  `ParseChatGPTExport` reads zip, finds `conversations.json`,
  maps to `ChatGPTConversation` structs with `ChatGPTMessage`
  (author role, content parts, create_time).
- `server/handlers/export_data.go` — `DataExportHandler` with
  `ExportMyData` and `ExportTeam` endpoints.
- `server/handlers/import_data.go` — `DataImportHandler` with
  `ImportMyData`, `ImportTeam`, and `ImportChatGPT` endpoints.
  FK-dependency-ordered import with dedup.
- `server/handlers/gdpr.go` — `GDPRHandler` with `DeleteMyAccount`.
- `server/store/postgres/export.go` — `ExportStore` Postgres
  implementation: 25+ batch-read queries for user/team export,
  import methods with `ON CONFLICT DO NOTHING`, `SoftDeleteUserData`,
  `AnonymizeUser`, `DeleteUserTokens`, `CountActiveAdmins`.
- `server/store/sqlite/export.go` — `ExportStore` SQLite
  implementation with identical interface.
- `chart/templates/cronjob-backup.yaml` — Kubernetes CronJob for
  scheduled pg_dump with gzip compression and retention cleanup.
- `chart/templates/pvc-backup.yaml` — PersistentVolumeClaim for
  backup storage (gated by `backup.persistence.enabled`).

### Schema

- No new migrations — uses existing tables only.
- `ExportStore` added to `store.Stores` struct.

### Changed

- `store/interfaces.go` — added `ExportStore` interface with 25+
  methods for batch export reads, import writes, and GDPR operations.
- `chart/values.yaml` — added `backup` section with `enabled`,
  `schedule`, `retention`, `persistence`, and `s3Offload` config.
- `main.go` — wired `DataExportHandler`, `DataImportHandler`,
  `GDPRHandler` routes under authenticated and admin groups.

### New

- **Structured logging (`slog`)** — `LOG_FORMAT=json|text`,
  `LOG_LEVEL=debug|info|warn|error`.  JSON handler for production,
  text handler for development.  Request ID, method, path, status,
  latency, client IP, and user ID on every request log line.
- **Request ID middleware** — `X-Request-Id` UUID header generated per
  request (or forwarded from upstream).  Propagated through completion
  chain as correlation ID.
- **Prometheus `/metrics` endpoint** — `switchboard_http_requests_total`,
  `switchboard_http_request_duration_seconds`, `switchboard_websocket_connections`,
  `switchboard_completion_tokens_total`, `switchboard_completions_total`,
  `switchboard_completion_duration_seconds`, `switchboard_provider_status`,
  `switchboard_db_open_connections` (+ `_in_use`, `_idle`, `_wait_count`,
  `_wait_duration`), `switchboard_task_executions_total`,
  `switchboard_sandbox_executions_total`.  Uses `c.FullPath()` for
  path patterns (no cardinality explosion).
- **OpenAPI 3.0.3 spec** — hand-curated `openapi.yaml` covering auth,
  channels, completions, health, and admin API groups.  Served at
  `/api/docs/openapi.yaml`.
- **Swagger UI** — `/api/docs` renders interactive API browser via CDN.
  System-respecting light/dark mode with WCAG AA 4.5:1+ contrast ratios.
- **Grafana dashboard** — `switchboard-overview.json` with request rate,
  latency percentiles, provider health, token usage, DB pool, Go runtime.
- **PrometheusRule alerting** — OOM recovery, provider down, pool
  exhaustion, high error rate, task failure spike.
- **ServiceMonitor** — Helm template for Prometheus Operator discovery
  (gated: `monitoring.serviceMonitor.enabled`).
- **Admin monitoring dashboard** — `GET /api/v1/admin/dashboard`
  aggregates provider health, 24h usage totals, DB pool stats, WS
  connections, Go runtime (goroutines, heap, sys, GC count, Go version),
  storage status, and recent errors.  Auto-refreshes every 30s.
- **ICD observability tests** — 6 tests covering `/metrics`,
  `/api/docs`, `/api/docs/openapi.yaml`, `X-Request-Id` generation,
  `X-Request-Id` passthrough, and admin dashboard.

### Changed

- **`middleware/logging.go`** — rewritten from `gin.Logger` wrapper to
  `slog.Info` with structured fields.  `SkipPaths` still honored.
- **`config/config.go`** — added `LogFormat`, `LogLevel` fields with
  `LOG_FORMAT`, `LOG_LEVEL` env var loading.
- **`events/ws.go`** — WebSocket connect/disconnect now updates
  `switchboard_websocket_connections` gauge.
- **`health/accumulator.go`** — flush cycle updates
  `switchboard_provider_status` gauge per provider config.
- **`handlers/completion.go`** — instrumented with token counters,
  completion latency histogram, and status counter.
- **Dockerfile** — Go 1.22 → Go 1.23 (required by `prometheus/client_golang`).
- **`nginx.conf`** — added `location = /metrics` proxy rule.
- **Admin monitoring tab** — landing page changed from Usage to Dashboard.
- **Helm `values.yaml`** — added `monitoring` section (serviceMonitor,
  grafanaDashboard, prometheusRule — all `enabled: false` by default)
  and `logging.format`, `logging.level`.

## [0.32.0] — 2026-03-19

### Summary

Multi-Replica HA: run 2+ backend replicas across nodes for node-level
availability.  All shared mutable state moves from in-process memory to
Postgres — task scheduler uses `FOR UPDATE SKIP LOCKED` for atomic
claim (no leader election), WebSocket tickets and rate limit counters
become PG tables, and `SendToUser` is replaced by bus-routed
`PublishToUser` for cross-pod event delivery.  New `/healthz/ready`
and `/healthz/live` probe endpoints.  Helm defaults to 2 replicas with
pod anti-affinity.

### New

- **`FOR UPDATE SKIP LOCKED` task scheduler** — every replica polls;
  PG serializes task handoff atomically.  `ClaimDueTask` replaces
  `ListDue`.  `CreateRunExclusive` conditional insert prevents
  double-execution.  Startup jitter (0–15s) staggers replicas.
- **PG ticket store** — `ws_tickets` table replaces in-memory
  `sync.Map`.  Atomic `DELETE ... RETURNING` for single-use validation.
  30s TTL, reaper piggybacked on scheduler poll tick.
- **PG rate limiter** — `rate_limit_counters` table with fixed-window
  `INSERT ON CONFLICT DO UPDATE`.  Fail-open policy: DB errors allow
  the request rather than blocking auth.  Cleanup piggybacked on
  scheduler poll tick.
- **Cross-pod WebSocket delivery** — `Hub.PublishToUser` routes events
  through the bus → `pg_broadcast` → remote pod `publishLocal`.
  `TargetUserID` field on `Event` for targeted filtering.
  `tool.result.*` routing changed to `DirBoth` so browser tool results
  cross pods for `WaitFor`.
- **Health probes** — `/healthz/ready` (PG ping, 2s timeout) and
  `/healthz/live` (process alive, no deps).
- **Helm HA** — `backend.replicaCount: 2`, pod anti-affinity
  (`preferredDuringScheduling`, `kubernetes.io/hostname`), readiness
  and liveness probes pointed at new healthz endpoints.
- **Schema `020_ha.sql`** — `ws_tickets` and `rate_limit_counters`
  tables (PG + SQLite).

### Changed

- **`SendToUser` → `PublishToUser`** — 18 call sites across 10 files
  migrated to bus-routed delivery for cross-pod reach.
- **`ListDue` removed from `TaskStore`** — replaced by `ClaimDueTask`
  (atomic claim) + `CreateRunExclusive` (belt-and-suspenders guard).
- **Rate limiter middleware** — rewritten to accept `store.RateLimitStore`
  instead of managing in-memory state.  Constructor signature changed:
  `NewRateLimiter(store, rate, burst)`.
- **`tool.result.*` routing** — `DirFromClient` → `DirBoth` to enable
  cross-pod `WaitFor`.  WS subscriber explicitly filters `tool.result`
  to prevent re-sending to clients.

### Removed

- **`events/tickets.go`** — in-memory `TicketStore` replaced by
  `store.TicketStore` (PG/SQLite) + `TicketValidatorAdapter`.
- **In-memory rate limiter** — visitor map and cleanup goroutine
  replaced by PG-backed store.

### Fixed

- **Team workflow route registration** — v0.31.2 team-scoped workflow
  CRUD routes (`/teams/:teamId/workflows`) were missing from `main.go`
  route registration.  12 routes added to `teamScoped` group.

## [0.31.2] — 2026-03-19

### Summary

Team Workflow Self-Service: team admins can now create, edit, publish,
and delete workflows scoped to their team without requiring platform
admin access.  New `/teams/:teamId/workflows` route group with full
CRUD, stage management, and publish.  New "Workflows" tab in the
team admin panel with visual stage builder.  Team slugs enable clean
public workflow URLs (`/w/engineering/intake` instead of UUID).

### New

- **Team-scoped workflow routes** — full CRUD under
  `/teams/:teamId/workflows` behind `RequireTeamAdmin`, with ownership
  guards verifying `workflow.team_id` matches the path parameter.
  Stage CRUD, reorder, publish, and version read all team-scoped.
- **Team admin Workflows tab** — 9th tab in the team admin modal.
  List, create, edit, delete workflows.  Visual stage builder with
  persona picker (team personas), form builder, drag-and-drop reorder.
  Publish with version display.
- **Team slugs** — `slug` column on teams table, auto-generated from
  name on create/update.  Workflow public URLs now use team slug
  (`/w/engineering/intake`) instead of UUID.  `GetBySlug` store method
  with fallback resolution in workflow entry + page renderer.
- **Team-scoped workflow API methods** — `API.listTeamWorkflows()`,
  `createTeamWorkflow()`, `updateTeamWorkflow()`, etc. in workflow-api.js.
- **`force_team_id` injection** — `WorkflowHandler.Create` now checks
  for `force_team_id` context key (same pattern as `CreateTeamTask`).

### Fixed

- **Auth rate limiter** — burst lowered from 8 to 5 to ensure rate
  limiting triggers within 10 rapid sequential requests.
- **Package settings assertions** — ICD runner now checks
  `response.values` instead of top-level keys (matches handler envelope).
- **Provider SSE parser** — ICD runner now captures `reasoning_content`
  deltas (DeepSeek/reasoning models) in addition to `content` deltas.
- **Team workflow version test** — captures `version_number` from
  publish response instead of hardcoding `1`.

## [0.31.1] — 2026-03-18

### Summary

SDK Exercise Surface: fixes 4 SDK bugs (flyout unification, overflow
clipping, menu system split, ChatPane standalone gap), then proves them
fixed with a dashboard package exercising every `sw.*` primitive with
zero component CSS overrides.

### Fixed

- **Flyout unification** — 3 competing CSS systems (layout.css,
  user-menu.css, primitives.css) consolidated into `.sw-menu-flyout`
  in primitives.css. UserMenu now shares the same flyout class and
  `data-position` attribute as `sw.menu()`.
- **Flyout positioning** — `position:fixed` escape hatch for flyouts
  inside `overflow:hidden` extension surface containers. Shared
  `_positionFlyout` helper wired into both `sw.menu()` and
  `sw.userMenu()`.
- **ChatPane standalone** — `ChatPane.mount()` with `standalone: true`
  now provides complete chat (textarea, model selector, history,
  streaming, channel creation). Editor package slimmed ~220 lines.
- **Flyout text contrast** — menu items used `--text-2` (#9898a8)
  against `--bg-elevated` (#42424e), giving ~2.8:1 contrast ratio.
  Bumped to `--text` (#e8e8ed) for ~5.5:1 (WCAG AA compliant).

### New

- **Dashboard `.pkg`** — `packages/dashboard/` exercises all 14 SDK
  primitives: userMenu, menu, toolbar, tabs, dropdown, chat, notes,
  modal, confirm, toast, on/emit, api, user/isAdmin, theme.
- **E2E tests** — `crud/dashboard-package.js` — install, settings,
  export/import round-trip (7 tests).

## [0.31.0] — 2026-03-18

### Summary

Editor package: the code editor surface is now an installable `.pkg`
file (type `full`, tier `browser`). Zero platform special-casing —
validates the entire v0.28.7–v0.30.2 extension stack. The editor
package lives in `packages/editor/` and is installed via admin UI
upload. Core editor surface, template, CSS, JS, and data loader removed.

### New

- **Editor `.pkg`** — `packages/editor/` contains `manifest.json`,
  `js/main.js`, and `css/main.css`. Type `full`, tier `browser`.
  JS-only rendering replaces server-rendered Go template partials.
  Dynamic script loading for CodeMirror bundle and ChatPane.
  Route: `/s/editor?ws=<id>`.
- **Editor settings** — manifest declares `font_size`, `tab_size`,
  `word_wrap` settings configurable via admin packages UI.
- **UI state persistence** — open tabs, active tab stored in
  localStorage per user per workspace (debounced 2s save).
- **E2E tests** — `crud/editor-package.js` — install, type/tier
  verification, settings CRUD, export/import round-trip, core
  removal verification.

### Removed

- `surface-editor` Go template (`server/pages/templates/surfaces/editor.html`)
- `editor-surface.js` and `editor-surface.css` (replaced by package assets)
- `EditorPageData` struct and `editorLoader` function
- Hardcoded `/editor` sidebar link (now rendered by extension nav loop)
- `/editor` nginx location rule (served via `/s/editor`)
- Editor entry from `registerCoreSurfaces()` and `extensionNavItems()` skip

### Changed

- `SeedSurfaces()` now cleans up the old editor core surface row to
  prevent broken nav links before the package is installed.

## [0.30.2] — 2026-03-18

### Summary

Workflow packages: workflows can be exported as `.pkg` bundles and
imported on other instances. Custom stage surfaces load from installed
packages via `surface_pkg_id`. Starlark `workflow.*` module provides
sandbox access to workflow state. ICD test suite hardened (auth rate
limiter, vault UEK pre-warm). 600 tests, 0 failures.

### New

- **Workflow package export/import** — `GET /admin/workflows/:id/export`
  bundles a workflow definition + stages as a downloadable `.pkg` ZIP.
  `POST /admin/packages/install` with `type: "workflow"` recreates
  workflow definition + stages from the manifest. Round-trip tested in
  ICD suite.
- **Custom stage surfaces** — `surface_pkg_id` column on `workflow_stages`
  links a stage to an installed package's JS surface. Template loads
  the package's `main.js` via dynamic script import and mounts via
  `WorkflowSurfaces.mount()`. Falls back to built-in mode rendering
  when no custom surface is assigned.
- **Starlark `workflow` module** — sandbox builtins: `workflow.get_definition`,
  `workflow.get_stage_data`, `workflow.advance`, `workflow.reject`.
  Gated by `workflow.access` permission.
- **`sw.workflow` SDK namespace** — `mount()`, `registerSurface()`,
  `getContext()`, `advance()`, `reject()` for frontend surface authors.
- **Admin workflow builder** — visual stage editor with DnD reorder,
  form field builder (8 types), export/import buttons, surface package
  selector. No JSON editing required.
- **ICD test fixes** — auth rate limiter burst 30→8 (transport test
  expects 429 on 10th rapid login). Vault UEK pre-warm on startup via
  variadic `ProbeAndRepairVault(*crypto.UEKCache)`.

### Changed

- `workflow_stages.surface_pkg_id` column added (migration 018, PG + SQLite)
- `packages.source` CHECK widened to include `'registry'` (migration 016)
- Auth rate limiter: `(5, 30)` → `(5, 8)` — do not increase burst above 9
- ICD test count: 579 → 600 (with Venice BYOK configured)

---

## [0.29.2] — 2026-03-17

### Summary

DB extensions and server-side tool execution. Starlark packages declare
namespaced database tables via `db_tables` in the manifest — created on
install, dropped on uninstall. A structured Starlark `db` module
(`query/insert/update/delete/view/list_tables`) provides namespaced
access to extension-owned tables and column-allowlisted platform views.
Server-side tool execution wires the `on_tool_call` entry point into the
completion tool loop: extensions declare `tools` in their manifest, the
tool loop dispatches matched calls to the sandbox. Three changesets
(CS0–CS3) plus docs (CS4).

### New

- **`db` Starlark module** — structured API for extension-owned tables.
  Requires `db.read` permission (query/view/list_tables) or `db.write`
  (insert/update/delete). Tables namespaced as `ext_{pkg_slug}_{name}`.
  - `db.query(table, filters=None, order=None, limit=100)` → list of dicts
  - `db.insert(table, row_dict)` → inserted row dict (auto-generates `id`)
  - `db.update(table, id, partial_dict)` → True
  - `db.delete(table, id)` → True
  - `db.list_tables()` → list of logical table names for this package
  - `db.view(view_name, filters=None, limit=100)` → list of dicts
    (allowed views: `"users"`, `"channels"`)
- **`db_tables` manifest key** — declare extension-owned tables with
  typed columns and indexes. Tables created on install, dropped on
  uninstall.
  ```json
  {
    "db_tables": {
      "logs": {
        "columns": {"message": "text", "user_id": "text", "count": "int"},
        "indexes": [["user_id"]]
      }
    }
  }
  ```
  Supported column types: `text`, `int`/`integer`, `real`/`float`,
  `bool`/`boolean`, `timestamp`. Auto-columns: `id TEXT PRIMARY KEY`,
  `created_at` (dialect-correct default). Dialect-correct DDL for both
  PG (`TIMESTAMPTZ`, `BOOLEAN`) and SQLite (`TEXT`, `INTEGER`).
- **Platform views** — column-allowlisted read-only views over platform
  tables accessible via `db.view(...)`:
  - `ext_view_users`: `id`, `display_name`, `email`
  - `ext_view_channels`: `id`, `name`, `type`, `team_id`
- **`ext_data_tables` catalog** — tracks logical→physical table mappings
  per package. Powers `db.list_tables()` and uninstall cleanup.
- **Server-side tool execution** — starlark extensions declare `tools`
  in the manifest. `BuildToolDefs` includes starlark-tier active package
  tools alongside server tools. `CoreToolLoop` dispatches matched calls
  to `executeExtensionTool` which calls the `on_tool_call` entry point.
  ```json
  {
    "tools": [{
      "name": "search_logs",
      "description": "Search extension log entries",
      "parameters": {
        "type": "object",
        "properties": {"query": {"type": "string"}},
        "required": ["query"]
      }
    }]
  }
  ```
  Entry point:
  ```python
  def on_tool_call(call):
      # call = {"tool_name": "...", "tool_call_id": "...", "arguments": {...}}
      if call["tool_name"] == "search_logs":
          rows = db.query("logs", filters={"user_id": call["arguments"]["user_id"]})
          return {"results": rows}
      return {"error": "unknown tool"}
  ```
- **`BuildExtToolMap`** — pre-loads `map[toolName]*PackageRegistration`
  per request to avoid DB lookup on each tool dispatch.
- **`ExtDataStore`** interface + implementations (`postgres`, `sqlite`) —
  `RegisterTable`, `ListTables`, `DeletePackageTables`.
- **`db.read` and `db.write` permissions** — active in this release
  (previously listed as "future").

### Changed

- `LoopConfig` gains `Runner *sandbox.Runner` and
  `ExtTools map[string]*store.PackageRegistration` for extension tool
  dispatch. All callers updated: `streamCompletion`, `syncCompletion`,
  multi-model path, `messages.go` regen, scheduler executor.
- `BuildToolDefs` (resolve.go) now includes starlark-tier active package
  tools in addition to browser-tier tools.
- `streamWithToolLoop` and `streamModelResponse` accept runner and
  extTools parameters.
- `CompletionHandler` gains `SetRunner(r *sandbox.Runner)` and
  `buildExtToolMap` helpers.
- `AdminInstallExtension` and `AdminUninstallExtension` call
  `CreateExtTables`/`DropExtTables` for schema lifecycle.
- `InstallPackage` and `DeletePackage` (packages.go) call the same
  schema lifecycle hooks.
- Migration `016_packages.sql` extended with `ext_data_tables` catalog
  table, `idx_ext_data_tables_pkg` index, and `ext_view_users`/
  `ext_view_channels` platform views.
- `Runner.SetDB(db, isPostgres)` wires the DB handle for `buildModules`.
  `main.go` calls `starlarkRunner.SetDB` at startup.
- Extension permissions table in docs updated: `db.read` and `db.write`
  now listed as `v0.29.2` (previously "future").
- Extension tiers in enums.md updated: `starlark` now implemented.

---

## [0.29.1] — 2026-03-17

### Summary

API extensions. Starlark packages serve custom JSON endpoints via
`api_routes` manifest key, with SSRF-safe outbound HTTP, LLM provider
access via BYOK chain, capability-based routing, and config-file
provider types. Five changesets (CS0–CS4).

### New

- **Extension API routes** (`/s/:slug/api/*path`) — Starlark packages
  declare routes in `manifest.api_routes` and implement `on_request(req)`
  entry point. Supports exact and prefix path matching, wildcard methods,
  and boolean shorthand. JWT-authenticated.
- **HTTP outbound module** (`http`) — `http.get/post/put/delete/request`
  builtins with SSRF protection (private/loopback IP blocking via
  `net.Dialer.Control`), manifest-based allowlist/blocklist
  (`network_access.allow/block`), redirect chain validation, 1 MB
  response cap, 10 s timeout. Requires `api.http` permission.
- **Provider module** (`provider`) — `provider.complete(messages, model?,
  max_tokens?, temperature?)` makes synchronous LLM calls via BYOK chain.
  `requires_provider` manifest key with optional `provider_config_id` pin
  and `model` default. Requires `provider.complete` permission.
- **`RunContext`** — per-invocation state (user_id, channel_id) passed
  through `CallEntryPoint` for user-scoped modules. All callers updated:
  API routes pass JWT user, task executor passes owner, filters pass nil.
- **`ProviderResolver` interface** — decouples sandbox from handlers
  package (avoids circular dependency). `ProviderResolverAdapter` in
  handlers bridges to `ResolveProviderConfig` + provider registry.
- **`capability_match` routing policy** — filters candidates by model
  capabilities (`required_caps: ["tool_calling", "vision"]`) with
  optional `prefer_cheapest` sort by output price. Forward-compatible
  with unknown capability names.
- **Config-file provider types** — `PROVIDER_TYPES_FILE` env var points
  to JSON array of custom provider type definitions. Registers
  OpenAI-compatible endpoints (Ollama, LiteLLM, vLLM) at startup
  without code deploy. Built-in IDs are protected from override.
- **`provider.complete` permission** — new extension permission constant
  for LLM completion access from Starlark scripts.

### Changed

- `Runner.ExecPackage` and `Runner.CallEntryPoint` signatures gain
  `*RunContext` parameter. All callers updated (breaking internal API,
  no external impact).
- `Runner.buildModules` receives manifest for module-specific config
  (http reads `network_access`, provider reads `requires_provider`).
- Routing policy admin handler validation switch includes
  `capability_match`.
- `PolicyConfig` gains `RequiredCaps` and `PreferCheapest` fields.
- Routing `Context` gains `Capabilities` map for capability-match
  evaluation.

### Deferred

- **Server-side tool execution in completion handler** →  v0.29.2.
  `provider.complete()` covers bare completions; full tool loop
  (extensions defining tools that execute server-side during chat
  completions) requires tool registry integration with the sandbox,
  which aligns with the DB extensions scope.

## [0.29.0] — 2026-03-17

### Summary

Starlark sandbox + permission model. Server-side extension runtime
with pre-completion filter chain, permission-gated modules, and
admin review workflow. Six changesets (CS0–CS5) on top of Phase 0
store cleanup (12 changesets, CS0–CS7b).

Phase 0 migrated ~242 raw SQL calls into the store interface layer,
giving the Starlark sandbox a clean API surface. Phase 1 builds the
runtime: sandboxed eval loop, permission model, secrets/notifications
modules, task executor integration, and pre-completion filter chain
with extension discovery.

**DB rebuild required.** Migration 016 rewritten in-place (adds
`status` column to `packages`, `extension_permissions` table).

### New

- **Pre-completion filter chain** (`server/filters/`) — composable
  `PreCompletionFilter` interface with ordered execution, error
  isolation, and logging. `Chain.Register()` / `Chain.Execute()`.
  Built-in filters use order 0–99; extension filters use 100+.
- **KB auto-inject filter** — `KBInjectFilter` (order 10) replaces
  inline `BuildKBHint()`. Scans persona, project, channel, and
  personal KBs. Reference implementation for the filter model.
- **Starlark sandbox** (`server/sandbox/`) — `go.starlark.net`
  interpreter with step limits (1M ops default), context timeout,
  captured print output, disabled `load()`. `MakeModule()` helper
  for exposing Go functions to Starlark scripts.
- **Sandbox runner** — `Runner.ExecPackage()` loads script from
  manifest `_starlark_script`, assembles module set from granted
  permissions. `CallEntryPoint()` for event-driven invocation.
- **Permission model** — `extension_permissions` table tracks
  declared capabilities from package manifests. Admin grant/revoke
  controls which modules the sandbox injects at runtime. Lifecycle:
  `install → pending_review → grant-all → active`, revoke →
  `suspended`. Auto-transitions on grant/revoke.
- **Extension permission constants** — `secrets.read`,
  `notifications.send`, `filters.pre_completion`, `db.read`,
  `db.write`, `api.http`. Validated against manifest declarations.
- **Secrets module** — `secrets.get(key)` / `secrets.list()`.
  Per-package key-value store backed by GlobalConfig
  (`ext_secrets:{packageID}`). Admin CRUD endpoints.
- **Notifications module** — `notifications.send(user_id, title,
  body?, type?)`. Wraps platform notification service. Extensions
  cannot send email or bypass user preferences.
- **Starlark filter** — `StarlarkFilter` bridges the filter chain
  to extension scripts. Calls `on_pre_completion(ctx)`, parses
  return `[{"role": "system", "content": "..."}]`.
- **Starlark filter discovery** — `DiscoverStarlarkFilters` scans
  active packages with `filters.pre_completion` grant at startup.
- **`task_type: "starlark"`** — executor `executeStarlark` loads
  package by `system_function` (package ID), calls `on_run()`
  entry point. Output to channel/webhook/run record.
- **Extension secrets admin** — `GET/PUT/DELETE
  /admin/extensions/:id/secrets`. GET returns keys only (not values).
- **Extension permission admin** — `GET .../permissions`,
  `GET .../review`, `POST .../grant`, `POST .../revoke`,
  `POST .../grant-all`.
- **ICD runner packaging tier** — 18 tests: permission lifecycle
  (install → pending_review → grant → active → revoke → suspended),
  secrets CRUD, invalid permission rejection.

### Changed

- **`BuildKBHint` deprecated** — replaced by `KBInjectFilter` in
  the pre-completion filter chain. Kept for backward compatibility;
  removal scheduled for v0.30.0.
- **`packages` table** — `status` column added (`active`,
  `pending_review`, `suspended`). `PackageStore.SetStatus()` method.
- **`CompletionHandler`** — `filterChain` field + `SetFilterChain()`
  setter. Filter chain runs between system prompts and message history.
- **`Executor`** — `runner` field + `SetRunner()`. Starlark dispatch
  branch between action and prompt types.
- **`AdminInstallExtension`** — calls `SyncManifestPermissions()`
  after package creation. Parses `"permissions"` from manifest.
- **Starlark task validation** — `task.starlark` RBAC gate enforced.
  `system_function` required (holds package ID). Package must exist
  and be starlark tier.

### Phase 0 — Store Cleanup

- **~242 raw SQL calls eliminated** — every handler, tool, and
  middleware file now goes through the store interface.
- **Constructor signature changes** —
  `enforcePrivateProviderPolicy(ctx, stores, ...)`,
  `ResolveProviderConfig(stores, vault, ...)`,
  `NewChannelHandler(stores)`.
- **30+ new store methods** across NoteStore, MessageStore, FileStore,
  ProjectStore, TeamStore, ProviderStore, CatalogStore,
  GlobalConfigStore, AuditStore, ChannelStore.

### Fixed

- **`API._delete` alias** — `delete` is a JS reserved word; added
  `_delete` as alias for `_del` on the API client.
- **`safeString` nil handling** — returns Go `nil`, not `""`.
- **nil JSONMap → PG jsonb** — `ToolCalls`/`Metadata` initialized
  to `models.JSONMap{}` before store calls.
- **`GetByID` vs `GetParentAndRole`** — cursor update uses
  `GetParentAndRole` (matches original raw SQL deleted_at filter).

## [0.28.8] — 2026-03-15

### Summary

ICD Green Board. Close out all pre-existing ICD test failures before
v0.29.0 feature work. Gate: 543/543 (100%) on the ICD runner.

Three changesets: provider sync + infrastructure resilience (cs0),
CORS lockdown + WebSocket origin validation (cs1), WebSocket ticket
exchange (cs2).

### Fixed

- **Batched `UpsertFromSync`** — catalog sync rewritten as a single
  transaction: one SELECT to identify existing models, N prepared
  `INSERT ON CONFLICT DO UPDATE` statements, one COMMIT. Previously
  each model was N×2 auto-committed queries (SELECT + INSERT/UPDATE),
  producing ~300 round-trips and WAL flushes on a typical Venice sync.
  Both PG and SQLite stores rewritten. Root cause of cascading 502s:
  the write burst starved the connection pool and caused Traefik to
  see stale keepalive connections on subsequent requests.
- **DB connection lifecycle** — `SetConnMaxLifetime(5m)` and
  `SetConnMaxIdleTime(1m)` added to the Postgres pool. Previously
  connections lived forever — stale TCP after PG restart or network
  blip would fail silently until a query hit the dead connection.
- **HTTP transport pool** — `ProviderConfig.Client()` now returns a
  shared `*http.Client` from a `sync.Map` keyed by `(ProxyMode,
  ProxyURL)`. Previously every outbound provider call allocated a
  fresh `http.Transport`, leaking idle connections until GC.
- **Traefik retry middleware** — new `Middleware` CRD (2 attempts,
  100ms initial interval) applied via ingress annotation. Retries on
  502 and connection reset. Both `k8s/` CI manifests and Helm chart
  templates updated. CI pipeline renders and applies the middleware
  before the ingress resource.
- **Provider sync timeout** — `syncProviderModels` now wraps the
  outbound provider HTTP call with a configurable timeout (default 30s,
  `PROVIDER_SYNC_TIMEOUT` env var). Previously used no timeout, causing
  Gin handler timeouts and cascading 502s from the reverse proxy when
  upstream providers (especially Venice) were slow.
- **504 on upstream timeout** — `POST /admin/models/fetch` and
  `POST /api-configs/:id/models/fetch` now return 504 with structured
  `{"error": "upstream timeout: <provider>"}` instead of letting the
  reverse proxy manufacture a 502 from a broken connection.
- **ICD runner provider tier resilience** — `models/fetch` calls retry
  once on 502/504 with 2-second backoff. Eliminates cascading test
  failures caused by transient upstream timeouts.
- **WebSocket CheckOrigin** — `upgrader.CheckOrigin` now validates the
  `Origin` header against `CORS_ALLOWED_ORIGINS` instead of
  unconditionally returning `true`. Connections from unlisted origins
  are rejected at upgrade time.
- **CORS startup warning** — if `CORS_ALLOWED_ORIGINS` is explicitly
  set to `*`, a warning is logged at startup recommending restriction
  for production deployments.

### New

- **WebSocket ticket exchange** — `POST /api/v1/ws/ticket` returns a
  single-use opaque ticket (128-bit random hex, 30s TTL). Client
  connects with `?ticket=<opaque>` instead of `?token=<jwt>`. Ticket
  is validated and deleted atomically (single-use). Background reaper
  removes expired unclaimed tickets every 15 seconds. This eliminates
  JWT exposure in server logs, proxy logs, and browser history.
- **`WsAuth` middleware** — dedicated WebSocket auth middleware with
  three-path priority: `?ticket=` (preferred), `?token=` (legacy,
  logs deprecation warning), `Authorization` header (non-browser).
- **`TicketStore`** — in-memory ticket store with `Issue()`,
  `Validate()`, `Stop()`, and automatic TTL reaping.
- **`GetAllowedOrigins()`** — exported CORS origin resolver for use
  by WebSocket hub and other subsystems.
- **Traefik retry Middleware CRD** — `k8s/middleware-retry.yaml` and
  `chart/templates/middleware-retry.yaml`. Configurable via
  `ingress.retry.attempts` and `ingress.retry.initialInterval` in
  Helm values.

### Changed

- **`events.NewHub(bus, allowedOrigins)`** — Hub constructor now takes
  the resolved CORS origin string. The upgrader's `CheckOrigin` is
  configured once at construction time.
- **`events.js` `_doConnect()`** — now async. Fetches a ticket via
  `POST /api/v1/ws/ticket` before building the WebSocket URL. Falls
  back to legacy `?token=` if ticket exchange fails (pre-v0.28.8
  server compatibility).
- **WebSocket route** — `GET /ws` now uses `WsAuth` middleware instead
  of generic `Auth`. Legacy `?token=` still works but logs deprecation.
- **`ProviderConfig.Client()`** — returns shared client from pool
  instead of allocating per call.

### Docs

- **`docs/ICD/websocket.md`** — ticket exchange protocol documented
  (preferred auth, legacy deprecation, origin validation).
- **`docs/ARCHITECTURE.md`** — expanded CORS configuration section
  (environment behavior, production guidance, WebSocket interaction).
- **`docs/ROADMAP.md`** — v0.28.x Tier 2 bucket eliminated (items
  relocated to pinned versions: v0.29.1, v0.30.0, v0.30.1). Pre-1.0
  Code Quality Sweeps section deleted (relocated to v0.29.0 Phase 0
  with CI-enforced gate). Zero unscheduled items remain outside TBD.

## [0.28.7] — 2026-03-15

### Summary

Unified packaging. The `surface_registry` and `extensions` tables are
replaced by a single `packages` table. Surfaces, extensions, and combined
"full" packages install through one endpoint (`POST /admin/packages/install`)
with one archive format (`.pkg`). Task RBAC pre-positions the `task.starlark`
permission gate for v0.29.0.

**DB rebuild required.** Migrations 012 and 016 are rewritten in-place.
Extension surfaces must be re-installed after upgrade. Builtin extensions
and core surfaces re-seed automatically.

### New

- **Unified `.pkg` archive format** — zip containing `manifest.json` +
  static assets (js/, css/, assets/). Manifest `type` field determines
  behavior: `surface` (routable page), `extension` (hooks/tools/pipes),
  `full` (both). Missing `type` defaults to `surface` for backward
  compatibility with `.surface` archives.
- **`packages` table** — replaces `surface_registry` + `extensions`.
  Text slug PKs, unified schema with type/version/tier/scope columns.
  `package_user_settings` replaces `extension_user_settings`.
- **`POST /admin/packages/install`** — unified install endpoint.
  Validates type-specific requirements (extensions need tools/pipes/hooks,
  full packages need both route and extension behavior). Accepts `.pkg`,
  `.surface`, `.zip`.
- **Admin packages section** — replaces separate Surfaces + Extensions
  sections. Type badges, filter by type, install upload area.
- **`/admin/surfaces/*` aliases** — all surface admin endpoints retained
  as aliases pointing to the packages handlers. No URL-breaking change.
- **`task.starlark` permission** — pre-positioned in `AllPermissions`.
  Handler rejects `task_type: "starlark"` with 400 referencing v0.29.0.
  Not granted to any group, not exposed in admin UI until v0.29.0.

### Changed

- **`SeedBuiltinExtensions` → `SeedBuiltinPackages`** — writes to
  `packages` table with `source='builtin'`, `type='extension'`,
  `is_system=true`. Same idempotent version-aware logic.
- **`Stores.Surfaces` + `Stores.Extensions` → `Stores.Packages`** —
  single `PackageStore` interface (15 methods). Compiler-enforced
  migration across all handlers, pages, and completion pipeline.
- **Static asset disk path** — `/data/surfaces/` → `/data/packages/`.
  URL path `/surfaces/:id/*path` retained for stability.
- **Extension runtime** — `ListForUser`, `GetUserSettings`,
  `SetUserSettings`, `ServeExtensionAsset`, `ListBrowserToolSchemas`
  all read from `packages` table. Same API contract, new backing store.

### Removed

- **`extensions` table** (migration 012) — replaced by `packages`.
- **`surface_registry` table** (migration 016) — replaced by `packages`.
- **`extension_user_settings` table** — replaced by `package_user_settings`.
- **`ExtensionStore` interface** — subsumed by `PackageStore`.
- **`SurfaceRegistryStore` interface** — subsumed by `PackageStore`.
- **`surfaces/` repo directory** — renamed to `packages/`.
- **`admin-surfaces.js`** — replaced by `admin-packages.js`.

## [0.28.6] — 2026-03-15

### Summary

Infrastructure release. Virtual scroll for long conversations, Helm chart
for Kubernetes deployment, system task type (Go function registry replacing
ad-hoc goroutines), admin broadcast notifications, server-side SSH key
generation for git credentials, task webhook trigger UI, and model
preference dedup fix.

KB auto-inject pipe filter deferred to v0.29.0 — will be built as the
reference Go implementation of the server-side filter model alongside
Starlark, ensuring the architecture is right and performant before
user code runs in it.

### New

- **Virtual scroll** — viewport-windowed message rendering for long
  conversations (100+ messages). IntersectionObserver-based sentinel
  triggers prepend chunks of 40 as user scrolls up. DOM capped at ~80
  nodes regardless of conversation length. Streaming messages always
  rendered. Transparent fallback for short conversations.
- **Helm chart** — `chart/` directory with full Kubernetes deployment.
  `helm install switchboard ./chart` replaces raw manifests. Configures
  backend + frontend deployments, services, ingress (Traefik), PVC,
  ConfigMap, Secret. All `config.go` env vars exposed as values.
- **System task type** — `task_type: "system"` with Go function registry.
  Built-in functions: `session_cleanup`, `staleness_check`,
  `retention_sweep`, `health_prune`. Admin creates task, picks function
  from dropdown, sets cron schedule. Executor calls registered Go function
  instead of LLM. Replaces ad-hoc goroutine background jobs with visible,
  configurable, auditable tasks. Permanent track — not replaced by Starlark.
  `GET /admin/system-functions` returns available functions.
- **Admin broadcast** — `POST /admin/notifications/broadcast` sends
  `system.announcement` notification to all active users via `NotifyMany`.
  Emits `system.broadcast` WebSocket event. Admin UI compose form with
  title, message, level selector (info/warning/critical).
- **Git credential key generation** — `POST /git-credentials/generate`
  creates ED25519 SSH keypair server-side. Private key vault-encrypted,
  never exposed. Public key + SHA256 fingerprint returned for user to add
  to git host. Optional `persona_id` for per-persona commit attribution.
  `GET /git-credentials/:id/public-key` for clipboard copy. Settings UI
  section with generate, list, copy, delete.
- **Task webhook trigger UI** — schedule selector gains "Webhook trigger"
  option. Trigger URL displayed with copy button after creation. Link
  button on webhook tasks in list view. `webhook_secret` field for HMAC
  signing. Task-to-task chaining documented (Task A webhook_url → Task B
  trigger URL).

### Fixed

- **Model preferences NULL-in-UNIQUE** — `provider_config_id` column on
  `user_model_settings` changed to `NOT NULL`. The `UNIQUE(user_id,
  model_id, provider_config_id)` constraint silently allowed duplicates
  when the column was NULL. Handler already enforced `binding:"required"`
  — schema now matches.
- **ICD runner shape** — `S.modelPreference` gains `provider_config_id`
  field (smoke tier shape assertion).

### Changed

- `ListActiveUserIDs` added to `UserStore` interface (PG + SQLite).
- `NotifTypeAnnouncement` constant added to notification types.
- Task model gains `system_function` field. Task stores updated
  (taskColumns, scanTask, Create) in both PG and SQLite.
- ICD runner version bumped. New tests: model prefs (8), broadcast (3),
  git credentials (6), system tasks (4).

### Docs

- `notifications.md` — broadcast endpoint, `system.broadcast` WS event.
- `admin.md` — broadcast cross-reference.
- `workspaces.md` — `/generate`, `/public-key` endpoints, new fields.
- `tasks.md` — system task type, function registry, chaining pattern,
  `GET /admin/system-functions`.



## [0.28.5] — 2026-03-14

### Summary

Frontend SDK + pipe/filter pipeline. `switchboard-sdk.js` provides a
single-object composition layer (`sw`) over the 15+ platform globals.
Three-stage pipe/filter pipeline (pre-send, post-receive stream,
post-render) formalizes the ad-hoc extension hook model into composable,
priority-ordered, scope-aware transform chains. Backward-compatible
compat shim for existing `ctx.renderers.register()` extensions. ICD
runner gains `sdk` test tier (36 tests).

### New

- **`switchboard-sdk.js`** — SDK core. `Switchboard.init()` returns
  `sw` singleton with identity (`sw.user`, `sw.isAdmin`), REST client
  (`sw.api.get/post/put/del/stream`), events (`sw.on/once/off/emit`),
  theme (`sw.theme.current/mode/set/on`), UI primitives
  (`sw.toast/confirm/modal`), and component wrappers (`sw.chat()`).
- **Pipe/filter pipeline** — three composable stages:
  - `sw.pipe.pre(priority, fn, opts)` — pre-send: transform user message
    before LLM request. Runs on both `sendMessage()` and `regenerateMessage()`.
    Context includes `regenerate` flag and `regenerateMessageId`.
  - `sw.pipe.stream(priority, fn, opts)` — post-receive: transform each
    SSE chunk during streaming. Sync-only for performance.
  - `sw.pipe.render(priority, fn, opts)` — post-render: transform rendered
    DOM after DOMPurify. Replaces `runExtensionPostRender()` delegation.
- **Filter scoping** — filters declare optional `scope: { channelType: [...] }`
  to restrict execution to specific channel types. Scoped-out filters are
  skipped with zero overhead.
- **Pipeline introspection** — `sw.pipe.list()` returns all registered
  filters by stage with priority, source, scope, call count, avgMs, errors.
- **Extension compat shim** — `ctx.renderers.register(name, { type: 'post' })`
  auto-shims into `sw.pipe.render()`. Existing block renderers (mermaid,
  katex, csv, diff) continue working through the unchanged `formatMessage()`
  path. Shimmed renderers appear in `sw.pipe.list()`.
- **ICD runner `sdk` tier** — 36 tests: boot, identity, REST client,
  events, theme, pipe registration, execution ordering, scoping, halt
  semantics, error isolation, compat shim, introspection.

### Changed

- `base.html` — SDK script tag added after component scripts. Universal
  init block simplified to `Switchboard.init()` call. UserMenu band-aid
  (cs15) absorbed into SDK `_hydrateUserMenu()`.
- `app.js` — `startApp()` calls `Switchboard.init()` (idempotent).
- `chat.js` — `sendMessage()` and `regenerateMessage()` run pre-send
  pipe. New `_buildPreSendContext()` helper.
- `ui-core.js` — `streamResponse()` SSE loop runs stream pipe per chunk.
- `ui-format.js` — `runExtensionPostRender()` delegates to render pipe
  with fallback to `Extensions.runPostRenderers()`.
- `extensions.js` — `_registerRenderer()` shims `type: 'post'` renderers
  into `sw.pipe.render()`.
- ICD runner version bumped to 0.28.5.0, `sdk` tier added.



## [0.28.4] — 2026-03-14

### Summary

Security tier red team. 58 adversarial tests added to the ICD runner
exposing 5 bugs (2× P0, 2× P1, 1× P2), all fixed in this release.
Auth middleware rewritten with a user-status cache so deactivation and
role changes take effect within 30 seconds. `surfaces/` directory
added to the repo with a build script for packaging extension surfaces.

### Security Fixes

- **[P0] Deactivated user JWT accepted.** Auth middleware validated
  signature + expiry only — never checked `is_active` in the database.
  A deactivated user's existing JWT continued working until natural
  expiry (15 min). Fix: `middleware/auth.go` now performs a cached DB
  lookup (`UserStatusCache`, 30 s TTL) on every request. Deactivated
  users receive 401 within seconds.
- **[P0] Demoted admin JWT retained admin access.** The `role` context
  value was read from JWT claims, not the database. Demoting an admin
  via `PUT /admin/users/:id/role` had no effect until the user's token
  expired. Fix: role is now resolved from the DB on every request via
  the same cached lookup. `RequireAdmin()` reads the live role.
- **[P1] Null byte in path parameter → 500.** Gin passed `%00` through
  to the database layer, causing a scan error. Fix: new
  `ValidatePathParams()` middleware rejects null bytes with 400.
- **[P1] 4096-char path parameter → 500.** Same root cause — oversized
  params reached the DB. Fix: `ValidatePathParams()` rejects params
  longer than 255 characters with 400.
- **[P2] CORS `Access-Control-Allow-Origin: *` in all environments.**
  Fix: production mode now defaults to same-origin only. Override with
  `CORS_ALLOWED_ORIGINS=https://app.example.com` (comma-separated).
  Development mode retains `*`.

### Added

- **ICD Security Tier** — 58 adversarial tests across 6 domains:
  auth-boundary (8), cross-tenant (20), input-validation (18),
  session (6), escalation (7), transport (3). Red "Security" button
  in the runner UI. Requires fixtures.
- **`UserStatusCache`** (`middleware/auth.go`) — per-user `{isActive,
  role, fetchedAt}` cache with 30 s TTL. Shared by `Auth()`,
  `AuthOrSession()`, and `AuthOrRedirect()`. Cache eviction on
  `UpdateUserRole` and `ToggleUserActive` via callback.
- **`ValidatePathParams()` middleware** (`middleware/validate.go`) —
  rejects null bytes and >255-char path params before they reach
  handlers.
- **`surfaces/` directory** — source tree for extension surfaces with
  `build.sh` that packages each subdirectory into `dist/<n>.surface`.
  `hello-dashboard` and `icd-test-runner` moved from loose archives
  into version-controlled source.
- **`surfaces/README.md`** — structure docs, build instructions,
  conventions.
- ICD runner: `safeDelete()` now retries 2× with 500 ms backoff on
  502/503, swallows cleanup errors with `console.warn` instead of
  throwing.
- ICD runner: `assertDenied()` / `assertRawDenied()` helpers treat
  502 as INCONCLUSIVE instead of CRITICAL.

### Changed

- `middleware.Auth()` signature: `(cfg)` → `(cfg, users, cache)`.
- `middleware.AuthOrSession()` signature: `(cfg, stores)` →
  `(cfg, stores, cache)`.
- `middleware.AuthOrRedirect()` signature: `(cfg)` →
  `(cfg, users, cache)`.
- `middleware.CORS()` signature: `()` → `(cfg)`.
- `AdminHandler` gains `OnUserChanged(func(userID string))` — wired
  to `cache.Evict` in `main.go`.
- 10 test files updated for new middleware signatures.
- `.gitignore`: added `*.surface` under build artifacts.
- ICD runner manifest bumped to `0.28.4.0`.

### Removed

- `hello-dashboard.surface` at repo root (replaced by
  `surfaces/hello-dashboard/` source directory).

### Verified (not bugs)

- Cross-tenant isolation: 20/20 — notes, channels, messages, tasks,
  workspaces, projects, BYOK configs, memories, team providers/
  personas/members all properly isolated.
- SQL injection: 7 payloads across 3 search endpoints — all
  parameterized.
- XSS storage: 3 round-trip tests — no server crashes (frontend
  sanitizes via DOMPurify).
- Escalation: self-promote, cross-role create, team-admin →
  platform-admin, user_id substitution — all blocked.
- JWT tampering + alg=none: both rejected.
- Refresh token rotation + revocation: both working.
- Auth rate limiting: 429 after burst.
- Unauthenticated access: all 6 endpoints return 401.

### Known

- `[P2]` WebSocket `?token=` query parameter exposes JWT in server
  logs and browser history. Informational — consider ticket exchange
  pattern post-1.0.
- Cross-visitor session isolation: enforced by `session_auth.go`
  channel binding, but no E2E test yet (requires public_link workflow
  setup — defer to visitor E2E milestone).

## [0.28.3.5] — 2026-03-14

### Summary

Frontend decomposition Phase 4: ES module conversion. IIFE wrappers
removed from all 47 JS files, `<script type="module">` applied across
all templates. `sb.js` and vendor libs stay as classic scripts to
guarantee availability before modules execute.

### Changed

#### Phase 4 — IIFE Removal
- Removed `(function() { 'use strict'; ... })();` wrappers from 45
  standard-pattern JS files (3 lines per file: open, strict, close).
- Removed `'use strict';` from 2 arrow-IIFE files (`knowledge-ui.js`,
  `tools-toggle.js`) — arrow IIFE retained (const binding pattern,
  harmless in module scope).
- Removed leftover `'use strict';` from `persona-kb.js` (not caught
  by the main script due to blank line before directive).
- Inner IIFEs preserved: `projects-ui.js` (visibility timer),
  `ui-primitives.js` (Providers/Roles factory constructors).
- All 48 JS files pass `node --check` syntax validation.

#### Phase 4 — Template Module Conversion
- All `<script src=".../js/*.js">` tags converted to
  `<script type="module" src="...">` across 6 template files:
  `base.html`, `surfaces/chat.html`, `surfaces/admin.html`,
  `surfaces/editor.html`, `surfaces/notes.html`,
  `surfaces/settings.html`.
- Classic scripts retained for: `sb.js` (must be globally available
  before any module), vendor libs (`marked.min.js`, `purify.min.js`,
  `codemirror.bundle.js`), early theme script, `__BASE__`/`__VERSION__`
  hydration.
- 3 inline scripts in `base.html` converted to `type="module"`:
  `API.loadTokens()`, Theme/appearance init + `handleLogout` fallback,
  UserMenu hydration.
- `handleLogout` fallback: added `sb.register('handleLogout', handleLogout)`
  since function is now module-scoped (no longer on `window`).
- UserMenu hydration: `handleLogout()` → `sb.call('handleLogout')`,
  `openDebugModal()` → `sb.call('openDebugModal')`.
- Extension surface `main.js` → `type="module"`.
- DOMContentLoaded inline scripts in surface templates stay classic
  (safe — modules execute before DOMContentLoaded fires).

### Fixed
- CI: frontend test harness (`helpers.js`) now loads `sb.js` into VM
  context before other source files. Fixes 55 extension test failures
  (`ReferenceError: sb is not defined` at `events.js:334`).
- `extensions.test.js`, `extensions-builtin.test.js`: `loadExtensions()`
  prepends `sb.js` load. Result: 219/219 tests pass.

### Not Changed
- `sb.register()`/`sb.ns()` dual-write to `window[name]` stays —
  cross-file JS references (`API.listProjects()`, `UI.toast()`) still
  go through `window.*`. Removal requires `import`/`export` statements
  (Phase 5, future).
- `login.html`, `workflow.html`, `workflow-landing.html` unchanged
  (standalone pages, no `sb.js`).

## [0.28.3.3] — 2026-03-14

### Summary

CI fix: frontend test harness missing `sb.js` in VM context. All 55
extension test failures were the same root cause — `events.js` calls
`sb.ns('Events', Events)` but the test VM never loaded `sb.js`.

### Fixed
- `helpers.js`: `loadAppModules()` now loads `sb.js` before `app-state.js`
  (defensive — not currently broken but would fail if any passing test
  switched to using `loadAppModules` with source files that call `sb.*`).
- `extensions.test.js`: `loadExtensions()` loads `sb.js` before `events.js`.
- `extensions-builtin.test.js`: same fix.

**Result:** 219/219 tests pass (was 164/219).

## [0.28.3.2] — 2026-03-14

### Summary

Frontend decomposition Phase 3b: Go template `onclick` → `sb.call()`
migration. All server-rendered onclick handlers in templates that load
`sb.js` (via `base.html`) now route through the action registry. This
clears the template gate for Phase 4 (ES module conversion).

### Changed

#### Phase 3b — Go Template onclick → sb.call()
- Converted 72 `onclick="fn(args)"` handlers across 10 Go template
  files to use `sb.call('fn', args)` or `sb.callEvent(event, 'fn', args)`.
- Templates converted: `base.html` (10), `surfaces/chat.html` (28),
  `surfaces/admin.html` (14), `surfaces/settings.html` (3),
  `admin/providers.html` (5), `admin/users.html` (3),
  `admin/roles.html` (1), `admin/routing.html` (3),
  `admin/teams.html` (4), `admin/settings.html` (1).
- 4 onclick handlers intentionally left unconverted (inline DOM
  manipulation or Go template variable in element ID):
  - `this.parentElement.style.display='none'` (crash banner dismiss)
  - `event.stopPropagation()` (bare container click guard)
  - `document.getElementById('settingsTeamAddMember').style.display='none'`
  - `document.getElementById('{{.FieldName}}Input').click()`
- 3 standalone pages excluded (no `sb.js`): `login.html`,
  `workflow.html`, `workflow-landing.html`.
- Defensive `typeof` guards in `settings.html` onclick handlers
  (e.g. `if(typeof UI!=='undefined')UI.saveAppearance?.()`) replaced
  with clean `sb.call()` — registry silently logs unresolved actions.
- `sb.js` comments updated: template gate cleared, dual-write removal
  deferred to Phase 4 (cross-file JS references still use `window.*`).

## [0.28.3.1] — 2026-03-14

### Summary

Frontend decomposition Phases 2 + 3. Phase 2: onclick→data-action
delegation migration (143 → 5 unconvertible). Phase 3: `sb.js` action
registry replacing all `window.*` exports with centralized
`sb.register()`/`sb.ns()` dispatch.

### Added

#### Action Registry — `sb.js` (Phase 3)
- New file `src/js/sb.js`, loaded first in `base.html`.
- `sb.register(name, fn)` — register standalone action, dual-writes to
  `window[name]` for backward compat until ES modules land.
- `sb.ns(name, obj)` — register namespace object (API, UI, App, etc.),
  methods accessible via dot notation: `sb.resolve('UI.copyMessage')`.
- `sb.resolve(name)` — centralized action resolution for flat names
  and dot-notation namespace methods. Falls back to `window[name]`.
- `sb.call(name, ...args)` — template bridge for Go onclick handlers.
- `sb.callEvent(event, name, ...args)` — event passthrough variant.
- `sb.list()` — introspection: list all registered actions/namespaces.
- `sb.has(name)` — check if action is registered.

### Changed

#### Phase 2 — onclick→data-action Delegation
- 138 of 143 dynamic inline onclick handlers converted to `data-action`
  + `data-args` attributes with centralized event delegation.
- `_uiDispatch(container)`: idempotent delegated click handler wired on
  `document.body`. Supports `data-args` (JSON), `data-pass-event`
  (context menu positioning), `data-pass-el` (checkbox state).
- `_ctxDispatch(container)`: idempotent delegated handler for context
  menus and panel buttons (auto-dismisses menu after action).
- Wrapper functions for compound expressions: `_toggleArchivedProjects`,
  `_submitEditFromDOM`, `_copyCodeBlock`, `_openNoteFromTool`,
  `_toggleNoteSelectCb`, `_noteItemClick`.
- 5 unconvertible onclick remain (self-referencing DOM removal,
  computed onclick from variable).

**Files converted (onclick):** `projects-ui.js` (26→0), `ui-core.js`
(25→1), `ui-admin.js` (28→1), `ui-format.js` (10→0),
`notifications.js` (8→0), `notes.js` (8→0), `channel-models.js` (5→0),
`ui-settings.js` (8→0), `files.js` (3→0), `admin-handlers.js` (2→0),
`app.js` (4→1), `workflow-queue.js` (6→0), `workflow-admin.js` (5→0),
`task-settings.js` (1→0), `task-sidebar.js` (3→1).

#### Phase 3 — Action Registry Migration
- 234 top-level `window.X = X` exports across 35 files converted to
  `sb.register('X', X)` or `sb.ns('X', X)`. 28 namespace objects,
  206 standalone functions.
- 14 in-body `window.X = function` assignments across 6 files
  (`settings-handlers.js`, `task-admin.js`, `task-settings.js`,
  `task-sidebar.js`, `workflow-admin.js`, `workflow-queue.js`)
  converted to `sb.register('X', function)` or local const + `sb.ns()`.
- `_uiDispatch` and `_ctxDispatch` dispatch updated to use
  `sb.resolve()` instead of `window[]` walking.
- `base.html` loads `sb.js` before `app-state.js`.
- `sb.register/sb.ns` dual-writes to `window.*` for backward
  compatibility — this side-effect is removed when files convert to
  ES module imports (Phase 4).

### Metrics

| | v0.28.3 | v0.28.3.1 |
|--|---------|-----------|
| Dynamic inline onclick | 143 | 5 (unconvertible) |
| data-action delegated | 0 | 163 |
| `window.*` exports | 248 | 1 (debug fetch patch) |
| `sb.register` calls | 0 | 220 |
| `sb.ns` calls | 0 | 30 |
| Action resolution | `window[]` | `sb.resolve()` centralized |


## [0.28.3] — 2026-03-13

### Summary

ICD audit close-out and straggler sweep. Rolls in the remaining v0.28.2
items (websocket doc, auth/enums reconciliation) since the 0.28.3 branch
was cut before the final v0.28.2 changeset landed.

### Changed

#### WebSocket ICD (`websocket.md`) — Full Rewrite
- Event envelope field corrected: `"event"` not `"type"` (matches Go
  struct json tag `json:"event"`).
- Room subscribe/unsubscribe documented as **planned, not implemented**.
  `JoinRoom`/`LeaveRoom` exist as stubs but no client wiring exists;
  all delivery uses `Hub.SendToUser()`.
- Added payload shape documentation for 11 event types:
  `message.created` (user + assistant variants), `typing.start`,
  `typing.stop`, `typing.user`, `user.presence`, `user.mentioned`,
  `notification.new`, `notification.read`, `workflow.assigned`,
  `workflow.claimed`, `workflow.advanced`, `workflow.completed`,
  `role.fallback`, `tool.call.*`.
- Routing table rewritten with accurate Direction, Delivery mechanism,
  and labels matching `events.routeTable` in code.
- Noted that `workflow.claimed`, `workflow.advanced`, `workflow.completed`
  use room-scoped `Bus.Publish()` which does not reach WebSocket clients
  (rooms not wired). Forward-looking plumbing only.

#### Auth ICD (`auth.md`)
- Login/Register response shape corrected: added `token_type` ("Bearer")
  and `expires_in` (900), removed phantom fields (`avatar_url`,
  `created_at`, `last_login`) that `generateTokens` does not emit.
- Register documents the admin-approval path (201 with message when
  `is_active` is false).

#### Enums ICD (`enums.md`)
- Task run statuses: added missing `queued` status (present in DB CHECK
  constraint, was omitted from enum doc).

#### Presence Status Reconciliation
- DB CHECK constraint: `online`, `away`, `offline`.
- Runtime behavior: hub emits only `online` and `offline`. `away` is
  reserved for future idle detection.
- Documented this gap in both `websocket.md` and `enums.md` rather than
  adding dead code or removing the DB constraint.

#### Housekeeping
- `VERSION` bumped to 0.28.3.
- `CHANGELOG.md` updated with entries for 0.28.0 through 0.28.3.
- `ROADMAP.md` updated: v0.28.2 marked complete, v0.28.3 scope adjusted.

### ICD Runner

No runner changes — websocket events are not testable via HTTP. All
existing tests remain at 469/469 (100%).

### Fixed

#### WebSocket Workflow Event Delivery (cs1)
- `workflow.advanced`, `workflow.completed`, `workflow.claimed` were
  emitted via room-scoped `Bus.Publish()`, but the room subscription
  system is not wired (no client-side subscribe/unsubscribe). These
  events **never reached WebSocket clients**.
- Converted all three to `SendToUser()` per channel participant,
  matching the pattern used by `message.created` and
  `workflow.assigned`.
- `workflow.claimed` payload gains `channel_id` field (needed to look
  up participants; also useful for frontend navigation).
- `workflow_assignments.go`: consolidated the duplicate
  `channel_id` query (was queried separately for WS and notification).

### Added

#### JS Dependency Audit (cs2)
- `docs/JS-DEPENDENCY-AUDIT.md`: full map of the frontend codebase.
  47 files, 25K lines, 431 top-level globals, 143 dynamic onclick
  handlers. Documents the complete script load order per surface,
  core global dependency graph (`App` → 20 files, `API` → 36 files,
  `UI` → 32 files, `Events` → 9 files), IIFE vs bare global
  classification, inline handler inventory, cross-file call graph,
  and a 4-phase decomposition strategy (core extract → onclick
  migration → ES modules → template handler shim).

#### Core Four IIFE Extraction (cs3)
- `api.js`: Wrapped in IIFE. `BASE` and `_storageKey` are now private
  (were leaking as implicit globals). Single export: `window.API`.
- `events.js`: Wrapped in IIFE. Replaced `_storageKey` localStorage
  coupling with `API.accessToken` (eliminates cross-file private
  dependency). Single export: `window.Events`.
- `app-state.js`: Wrapped in IIFE. `resolveCapabilities()` now private.
  Exports: `window.App`, `window.fetchModels`.
- `ui-primitives.js`: Wrapped in IIFE. `updateTabArrows()` now private
  (zero external callers). 17 explicit `window.*` exports with manifest
  comment. Previously all 18 declarations leaked as implicit globals.

#### Tier 2 IIFE Extraction (cs4)
- `ui-format.js`: Wrapped in IIFE. 11 explicit exports (7 cross-file +
  4 onclick handlers), 9 privatized (`_highlightMentions`, `_formatMarked`,
  `_formatBasic`, `_unwrapMarkdownFence`, `_decodeHTML`, `_looksLikeHTML`,
  `_hasPreviewContent`, `_extractLastHTMLBlock`, `_livePreviewTimer`).
- `ui-core.js`: Wrapped in IIFE. 3 exports (`UI`, `renderPersonaForm`,
  `toggleSummarizedHistory`), 3 privatized (`avatarHTML`,
  `assistantAvatarURI`, `_isSummaryMessage`).
- `pages.js`: Wrapped in IIFE. 2 exports (`Pages`, `_val`), 6 privatized
  (`_pageData`, `_show`, `_hide`, `_toast`, `_parseJSON`, `_api`).

#### Tier 3 IIFE Extraction — Leaf Modules (cs5)
- 11 files wrapped: `channel-models.js`, `chat-pane.js`, `notifications.js`,
  `model-selector.js`, `file-tree.js`, `tokens.js`, `code-editor.js`,
  `note-editor.js`, `drag-resize.js`, `pane-container.js`, `user-menu.js`.
- 13 total explicit exports across all 11 files.
- 17 functions privatized (e.g. `_shortName`, `_timeAgo`, `_ftFileIcon`,
  `_ceFileIcon`, `_ceDetectLanguage`, `dismissContextWarning`, `_clientPos`,
  10 PaneContainer internals).

#### Small File IIFE Wrap (cs6)
- 7 files: `admin-surfaces.js`, `extensions.js`, `memory-ui.js`,
  `notification-prefs.js`, `persona-kb.js`, `repl.js`, `ui-settings.js`.
- `ui-settings.js` has no standalone exports (extends `UI` via
  `Object.assign`); IIFE scopes its closure only.
- 3 functions privatized (`_loadSurfaceList`, `_debounce`,
  `savePersonaKBs` — zero external callers).

#### Chat + App Entry Point IIFE Wrap (cs7)
- `chat.js`: Wrapped in IIFE. 22 explicit exports (ChatInput + 21
  functions), 7 privatized (`_typingTimer`, `_emitTyping`,
  `_saveChatModel`, `_restoreChatModel`, `_cleanStaleChatModel`,
  `updateChatTokenCount`, `_autoNamePending`).
- `app.js`: Wrapped in IIFE. 6 exports (`handleLogin`, `handleRegister`,
  `handleLogout`, `switchAuthTab`, `startApp`, `initBanners`),
  13 privatized. `init()` self-invokes via `DOMContentLoaded` — not
  exported. `handleLogout` overrides the base.html fallback on chat,
  editor, and notes surfaces.

#### Clean Tier 4 Wraps (cs8)
- `settings-handlers.js`: Wrapped in IIFE. 11 explicit exports + 4
  self-assigned `window.*` functions (preserved from original). 13
  privatized.
- `note-graph.js`: Wrapped in IIFE. 6 exports, 17 privatized.
- `debug.js`: Wrapped in IIFE. 8 exports (all onclick-referenced from
  base.html template), 1 privatized.
- `panels.js`: Wrapped in IIFE. 6 exports, 4 privatized.

#### Heavy Page IIFE Wraps — All Remaining (cs9)
- `files.js`: 18 exports (15 cross-file + 3 onclick), 18 privatized.
- `admin-handlers.js`: 30 exports (28 cross-file + 2 onclick), 10
  privatized. Previously misclassified as IIFE — was actually bare
  globals.
- `notes.js`: 12 exports (9 cross-file + 3 onclick), 31 privatized.
- `ui-admin.js`: 3 exports (`ADMIN_SECTIONS`, `ADMIN_LABELS`,
  `ADMIN_LOADERS`), 1 privatized. Also extends `UI` via
  `Object.assign` (28 onclick handlers are UI methods, not standalone).
- `projects-ui.js`: 48 exports (29 cross-file + 19 onclick), 30
  privatized. Largest file in the codebase (78 top-level globals
  reduced to 48 explicit exports).

**Frontend decomposition Phase 1 complete.** All 47 JS files are now
IIFE-wrapped with explicit `window.*` exports. Zero implicit globals
remain. The `_storageKey` cross-file coupling between `api.js` and
`events.js` is eliminated. ~168 functions privatized across the
codebase.


## [0.28.2] — 2026-03-13

### Summary

Full ICD audit across all remaining domains. Every ICD document traced
against Go handlers, stores (PG + SQLite), and the ICD test runner.
Eight changesets (cs0–cs7), 469/469 runner tests passing.

### Added

#### Go Integration Tests
- `projects_test.go`: 14 tests — CRUD shapes, 201 status, envelope,
  admin list, auth, isolation.
- `workspace_test.go`: 11 tests — list empty/data envelope, shape,
  root_path not exposed, user isolation, GET by ID, 404, 403, git
  credentials empty envelope, auth required.
- `profile_test.go`: GET profile shape, PUT profile, duplicate email
  409, settings envelope, password change (success, wrong current 401,
  too short 400), avatar delete.
- Knowledge tests: document status polling, document delete, update
  empty body 400, permission denial.

#### ICD Runner Tests (v0.28.2.4)
- Projects: rewritten from 9 → 19 tests (full CRUD shapes,
  channel/KB/note association lifecycles, files shape, isolation,
  admin list).
- Knowledge: document status, document delete, permission, team-scoped
  KB creation.
- Profile: all 7 endpoints exercised.
- Workspaces: envelope, shape, isolation, git operations.
- Notifications: envelope, preferences, type enum.

### Fixed

#### P0 — Security
- `SetDiscoverable` on knowledge bases had no authorization check.
  Added `loadAndAuthorize` + owner/admin gate + audit log (cs2).

#### P0 — Response Shape
- `GET /settings` returned bare object → wrapped in
  `{"settings": {...}}` (cs5).
- `GET /workspaces` returned bare array → `{"data": [...]}` (cs6).
- `GET /git-credentials` returned bare array → `{"data": [...]}` (cs6).
- `GET .../git/log` returned bare array → `{"data": [...]}` + nil
  slice guard (cs6).
- `ListByProject` files returned `{"files": null}` → `{"files": []}` (cs7).
- `GET /notifications/preferences` missing `"preferences"` key in
  envelope (cs0).

#### P0 — Data Integrity
- `ListTeamProviderModels` response struct missing `Type` field —
  embedding models indistinguishable from chat models, causing Venice
  400 on completion (cs7).

#### Notification System
- Implemented `memory.extracted` notification (was missing hook in
  memory extractor) (cs0).
- Implemented `user.mentioned` persisted notification (was WS-only) (cs0).
- Implemented `workflow.claimed` persisted notification (was WS-only) (cs0).
- Removed dead `NotifTypeProjectInvite` constant (cs0).

#### ICD Runner (v0.28.2.4)
- SSE parser: extract content from OpenAI-format
  `choices[0].delta.content` — was checking top-level `evt.content`.
  All three provider tiers (global, team, BYOK) were affected (cs7).
- `pickCheapestChat` excludes embedding/image models by type and ID
  pattern (cs7).
- Model ID references use `(m.model_id || m.id)` fallback for team
  provider models (cs7).

### Changed

#### ICD Documents Corrected
- `notifications.md`: object shape, query params, response envelopes,
  WS events. Type enum synced (removed aspirational, added implemented).
- `knowledge.md`: KB object shape (6 field mismatches), search envelope
  (`data` not `results`), search result fields, status progression
  (`extracting` step), file type support, auth annotations.
- `profile.md`: avatar API (multipart → JSON base64), response shapes
  for all 7 endpoints, auth annotations, field table. Added
  `last_login_at` to `profileResponse` + dialect-safe time scan.
- `workspaces.md`: workspace object shape (`indexing_enabled`, `git_*`,
  `total_bytes` not `storage_bytes`, `owner_type` full enum), file
  entry shape, git status/log/commit shapes, archive format, auth
  annotations.
- `projects.md`: full rewrite — 6 categories of drift fixed (ghost
  fields removed, 6 missing fields added, create/update shapes
  corrected, association objects documented, `omitempty` on computed
  counts noted).
- `notes.md`: verified clean.
- `memory.md`: verified clean (audited v0.28.0).

#### Dead Code Removal
- `ListGlobal`/`ListForTeam` on KnowledgeBaseStore — interface + PG +
  SQLite (cs4).
- `CreateKB` moved team role check from raw `database.DB.QueryRow` to
  `stores.Teams.IsTeamAdmin`; removed `database` import (cs4).
- Stale `/avatar` route aliases removed from integration test harness (cs5).
- `ListDiscoverableKBs` normalized to use `toKBResponse()` (cs3).


## [0.28.1] — 2026-03-12

### Summary

Surfaces ICD audit — first domain audit pass. Corrected 6 ICD
discrepancies and hardened the surface install path.

### Fixed
- ICD `surfaces.md` corrected: field name, archive format, response
  shape (6 discrepancies).
- Surface ID slug validation + `extractableRelPath` install hardening
  (path traversal defense).

### Added
- 19 E2E surface CRUD tests in ICD runner (install, enable/disable,
  delete, error paths).
- 22 handler-level tests + 14 store-level tests (PG + SQLite).

### Changed
- CI timeout increased 8m → 12m for PG integration tests.


## [0.28.0] — 2026-03-12

### Summary

Platform polish arc kickoff. Memory ICD audit — first domain to complete
the full trace-and-test cycle.

### Changed
- `memory.md` ICD audited and verified: object shape, scopes
  (`user`/`persona`/`persona_user`), statuses
  (`active`/`pending_review`/`archived`), all endpoints, admin review,
  AI tools, background extraction, memory injection.
- 13/13 ICD runner tests passing.


## [0.27.5] — 2026-03-11

### Summary

Team tasks — team members can view team-scoped tasks, team admins can
create and manage them. Settings and sidebar surfaces show team tasks
alongside personal tasks with team attribution badges.

### Added

#### Team Task Routes
- `GET /api/v1/teams/:teamId/tasks` — list team tasks (any team member).
- `POST /api/v1/teams/:teamId/tasks` — create team-scoped task (team admin).
- `PUT/DELETE/run/kill` on team-scoped task routes (team admin).

#### Access Control Helpers
- `canAccessTask()` — owner, system admin, or team member for team tasks.
- `canMutateTask()` — owner, system admin, or team admin for team tasks.
- Both used across Get, Update, Delete, ListRuns, RunNow, KillRun.

#### Frontend
- Settings Tasks section fetches team tasks via `/teams/mine` + per-team
  `/teams/:id/tasks`. Shows team badge on team-scoped tasks.
- Sidebar Tasks section includes active team tasks with `[TeamName]` label.

### Fixed
- `ListRuns` handler now checks task access (was unauthenticated — any
  user with a task ID could read run history).



## [0.27.4] — 2026-03-11

### Summary

Personal tasks — the user-facing task experience. Settings surface gains a
Tasks section with CRUD, schedule builder (presets + custom cron + timezone),
and 5 starter templates. Chat sidebar gains a Tasks section showing active
tasks with status indicators and run-now buttons. Executor gains note output
mode and BYOK provider enforcement.

### Added

#### Settings → Tasks Section (`task-settings.js`)
- **Task list** with name, schedule, timezone, last/next run, status badges.
  Pause/resume toggle, run-now, and delete buttons per task.
- **Create task form:** name, model, prompt editor, schedule builder (6
  presets + custom cron), timezone (defaults to browser), output mode
  (channel/note/webhook), budget overrides, notification toggles.
- **5 starter templates:** Morning News Digest, Daily Standup Prep, Weekly
  Project Summary, Stock Watchlist Check, Research Digest. Click to
  pre-populate the create form.

#### Tasks Sidebar Section (`task-sidebar.js`)
- New collapsible sidebar section (after Workflows/Queue).
- Shows active tasks with status indicators: ✓ (last run succeeded),
  ◷ (scheduled). Click opens the task's output channel.
- Per-task ▶ run-now button.
- Auto-refreshes on chat list update.

#### BYOK Provider Enforcement
- `tasks.personal_require_byok` global config key (default: false). When
  true, personal-scope tasks fail with a clear error if no BYOK provider
  is available — prevents fallthrough to org providers.
- Admin configuration UI in admin Tasks → Configuration tab.

#### Note Output Mode
- `output_mode: "note"` now functional. Task executor creates a note
  (title: "{task name} — {timestamp}", tags: ["task-output"]) instead of
  a message in the service channel. Good for structured reports.

### Changed
- `taskutil.TaskConfig` gains `PersonalRequireBYOK` field.
- `executor.go` persistence section rewritten as `output_mode` switch
  (channel/note/webhook).
- `task-admin.js` config tab gains "Require BYOK for Personal" toggle.
- `ui-core.js` `renderChatList` refreshes `TaskSidebar` alongside
  `WorkflowQueue`.
- `settings.html` gains Tasks nav link and panel mount point.
- `chat.html` includes `task-sidebar.js`.



## [0.27.3] — 2026-03-10

### Summary

Task chaining — webhooks for external integration, AI-spawnable sub-tasks,
and workflow-to-workflow completion triggers. Also fixes a bug where the
`workflow_advance` tool didn't fire `on_complete` chains.

### Added

#### Webhook Delivery (`server/webhook/`)
- **`webhook.Deliver(url, secret, payload)`** — POST with HMAC-SHA256 signing.
  3 retries with exponential backoff (1s, 5s, 25s), 10s timeout per attempt.
  `X-Switchboard-Signature` header for verification.
- **`webhook.GenerateSecret()`** — 32-byte hex random secret, auto-generated
  on task creation when `webhook_url` is set.
- **`webhook.Payload`** struct: task_id, task_name, workflow_id, channel_id,
  status, completed_at, output, stage_data, error.

#### Task Webhooks
- Task executor fires webhook on completion, failure, and budget breach.
  Runs in a goroutine — non-blocking.
- `webhook_secret` column on tasks table (migration 027).
- Secret auto-generated in task handler `Create` when `webhook_url` is provided.

#### Workflow Webhooks
- `webhook_url` and `webhook_secret` columns on workflows table (migration 027).
- Webhook delivered on workflow completion (both handler and tool paths).

#### `task_create` Tool
- AI-invocable tool that spawns sub-tasks from workflow/team contexts.
- **Predicates:** available in workflow channels and team channels only.
  Blocked in service channels (prevents recursive task spawning).
- **Scope inheritance:** created tasks inherit parent channel's team scope.
- **Depth limit:** service channel check prevents tasks creating tasks.
- Parameters: name, prompt, schedule (cron or "once"), persona, model,
  max_tokens, system_prompt. Validates cron before creation.

#### Workflow `on_complete` Chaining Extraction
- `TriggerWorkflowOnComplete()` extracted to `tools/workflow.go` as shared
  function. Used by both HTTP handler and `workflow_advance` tool.
- Handles both `on_complete` chain config and webhook delivery.

### Fixed
- **`workflow_advance` tool completion path** now triggers `on_complete`
  chaining. Previously only the HTTP `Advance` endpoint fired chains —
  AI-driven stage advancement silently skipped them.

### Changed
- `Workflow` model gains `WebhookURL`, `WebhookSecret` fields.
- `WorkflowPatch` gains `WebhookURL` field.
- `Task` model gains `WebhookSecret` field.
- `workflow_instances.go` `triggerOnComplete` method replaced with
  one-line delegation to shared `tools.TriggerWorkflowOnComplete()`.



## [0.27.2] — 2026-03-10

### Summary

Task execution — the scheduler can now actually run completions. Headless
executor wires tasks into the full completion pipeline (provider resolution,
tool execution, budget enforcement, notifications). Admin panel gains a Tasks
section for CRUD, run history, kill switch, and global task configuration.

### Added

#### Core Tool Loop Extraction
- **`CoreToolLoop` + `LoopSink` interface.** Single canonical implementation of
  multi-round LLM completion with tool execution. Replaces three duplicated
  implementations (SSE streaming, multi-model streaming, sync JSON). Budget
  enforcement built in: `MaxTokens`, `MaxToolCalls`, `MaxRounds`.
- **`LoopResult.BudgetExceeded`** field tracks which limit was hit. Callers
  get budget status without inspecting loop internals.
- **Four sink implementations:** `sseSink` (interactive SSE), `sseModelSink`
  (multi-model SSE), `accumSink` (sync JSON), `HeadlessSink` (scheduler).

#### Provider Resolution Extraction
- **`ResolveProviderConfig()`** standalone function. Resolution chain:
  explicit config → channel config → owner's personal → global. Shared by
  completion handler and task executor.
- **`BuildToolDefs()`** standalone function. Server-registered tools filtered
  by context predicates + browser extension tools + persona tool grant allowlist.
- **`FilterToolDefsByGrants()`** for task-level tool grant narrowing.

#### Task Executor (`scheduler/executor.go`)
- **Headless completion pipeline.** Resolves provider, builds messages + tools,
  runs `CoreToolLoop` with `Streaming: false`, persists assistant response in
  service channel, updates run record with tokens/tools/wall-clock, sends
  owner notification.
- **Budget enforcement.** `max_tokens`, `max_tool_calls` from task definition.
  Wall clock via `context.WithTimeout`. Budget breach → `budget_exceeded`
  status + mandatory owner notification.
- **Provider routing.** Task `provider_config_id` → channel → owner's
  personal BYOK → team → global. Full vault decryption path.
- **Tool grants.** Persona grants + task-level grants applied as second-pass
  allowlist. No browser tools in headless mode.

#### Task Configuration (`taskutil/`)
- **`LoadTaskConfig()`** reads from `global_settings` key `"tasks"`. Fields:
  `enabled`, `allow_personal`, `max_concurrent`, `default_max_tokens`,
  `default_max_tool_calls`, `default_max_wall_clock`.
- **`ApplyDefaults()`** fills zero-value budget fields on new tasks.
- **Full cron parsing** via `robfig/cron/v3`. Replaces hand-rolled
  `parseDailyCron`. Supports 5-field cron + descriptors (`@hourly`, `@daily`).
- **`ValidateCron()`** pre-create validation rejects invalid expressions.

#### Permissions + Global Config
- **`task.create`** permission — gates task create/update/delete/run routes.
- **`task.admin`** permission — for future admin-level task management.
- **Global config keys:** `tasks.enabled` (kill switch), `tasks.allow_personal`,
  `tasks.max_concurrent` (scheduler poll limit).

#### Admin Panel
- **Tasks section** under Workflows category. Two tabs: task list + configuration.
- **Task list:** all tasks across users with status, schedule, run count, next
  run time. Action buttons: run now, view history, delete.
- **Run history:** drill-down per task showing start/end times, tokens used,
  tool calls, wall clock, status badge, error messages.
- **Kill switch:** cancel active run from run history view.
- **Configuration tab:** toggle tasks enabled/personal, set max concurrent and
  default budget ceilings. Writes to `global_settings` key `"tasks"`.

#### Routes
- `POST /api/v1/tasks/:id/kill` — cancel active run (user + admin)
- `POST /api/v1/admin/tasks/:id/run` — admin trigger
- `POST /api/v1/admin/tasks/:id/kill` — admin kill
- `DELETE /api/v1/admin/tasks/:id` — admin delete

### Changed
- **`stream_loop.go`** rewritten from 559 lines to 181 — thin wrappers over
  `CoreToolLoop`. Identical behavior, zero duplication.
- **`completion.go`** `syncCompletion` rewritten as wrapper. `resolveConfig`
  and `buildToolDefs` delegate to standalone functions. `-237 lines`.
- **Scheduler startup** moved from early init to after hub + notification
  service initialization. Executor needs both.
- **`scheduler.New()`** signature: `New(stores) → New(stores, executor)`.
  Executor is optional (nil = v0.27.1 behavior).
- **Cron computation** in scheduler and task handler now uses `robfig/cron/v3`
  via `taskutil.NextRunFromSchedule`. Create and update validate schedule
  before persisting.

### Dependencies
- Added `github.com/robfig/cron/v3 v3.0.1`



# Changelog Additions — v0.26.0

## [0.26.0] — 2026-03-10

### Summary

Workflow engine — team-owned staged processes with AI intake, visitor-facing
branded entry, human assignment queue, and admin builder UI. Channels become
execution contexts that move through defined stages, driven by personas and
monitored by team members.

### Added

#### Workflow Definitions (v0.26.1)
- **Workflow CRUD.** `POST/GET/PATCH/DELETE /api/v1/workflows` with name, slug
  (auto-generated from name if omitted), description, entry_mode
  (`authenticated` | `public_link`), is_active flag, branding JSONB, retention
  JSONB. `UNIQUE(team_id, slug)` with partial index for global workflows.
- **Stage CRUD.** `POST/GET/PUT/DELETE /api/v1/workflows/:id/stages/:sid`.
  Stages have name, ordinal, persona_id, system_prompt, form_template (JSONB),
  history_mode (`full` | `summary` | `fresh`), assignment_team_id.
- **Stage reorder.** `PATCH /api/v1/workflows/:id/stages/reorder` — transactional
  ordinal update from ordered_ids array.
- **Publish + versioning.** `POST /api/v1/workflows/:id/publish` creates an
  immutable `workflow_versions` snapshot (stages + persona tool grants).
  `GET /api/v1/workflows/:id/versions/:version` retrieves any published version.
  Duplicate publish returns 409. Schema: `workflows`, `workflow_stages`,
  `workflow_versions` (migration 023).
- **Permission gating.** Workflow create/update/delete requires `workflow.create`
  permission (existing permission constant).

#### Workflow Instances + Stage Transitions (v0.26.2)
- **Start instance.** `POST /api/v1/workflows/:id/start` creates a workflow
  channel, pins the published version, binds stage 0 persona, sets
  allow_anonymous based on entry_mode. Returns channel_id + stage info.
- **Advance/reject/status.** `POST /channels/:id/workflow/advance` (with
  optional form data merge), `POST /channels/:id/workflow/reject` (with
  required reason → system message), `GET /channels/:id/workflow/status`.
- **Workflow columns on channels.** `workflow_id`, `workflow_version`,
  `current_stage`, `stage_data` (JSONB), `workflow_status` (enum:
  active/completed/stale/cancelled), `last_activity_at` (migration 024).
- **Staleness sweep.** Background goroutine (1h tick) marks active workflow
  instances as `stale` when `last_activity_at` exceeds `WORKFLOW_STALE_HOURS`
  (default: 72, env-configurable, 0 to disable).
- **Stage data merge.** Dialect-safe (Go-side JSON merge, no Postgres `||`
  operator). Data accumulated across stages in `stage_data` column.

#### Visitor Experience (v0.26.3)
- **Workflow landing page.** `GET /w/:id/:slug` — public surface with branded
  layout (accent color, logo URL, tagline from workflow branding JSONB),
  persona name/icon, "Start" button, session resume link.
- **Visitor start flow.** `POST /api/v1/workflow-entry/:scope/:slug` — creates
  channel + anonymous session, binds stage 0 persona, sets session cookie,
  returns redirect to `/w/:channelId` (existing chat surface).
- **DenyVisitor tool scoping.** Workspace, git, memory tools already use
  `DenyVisitor` predicate (shipped in v0.26.0 foundation).

#### AI Intake + Assignment Queue (v0.26.4)
- **`workflow_advance` tool.** AI-callable tool with `RequireWorkflow`
  predicate — only available inside workflow channels. Persona calls this when
  form data is collected. Triggers same transition as handler advance.
  Accepts `data` (JSON object) + `summary` (string for stage note title).
- **Form template injection.** Completion handler injects current stage's
  `form_template` as a system prompt when `channelType == "workflow"`. Tells
  the persona what fields to collect and when to call `workflow_advance`.
- **Stage notes.** `CreateWorkflowStageNote()` persists collected form data as
  channel-scoped notes (title: "Stage N: {summary}", content: JSON).
- **Assignment queue.** `workflow_assignments` table (migration 025): channel,
  stage, team, assigned_to, status (unassigned/claimed/completed), timestamps.
  Created automatically when advancing to a stage with `assignment_team_id`.
- **Assignment endpoints.** `GET /teams/:teamId/assignments` (team queue),
  `GET /workflow-assignments/mine` (user's claimed), `POST /workflow-assignments/:id/claim`
  (optimistic lock), `POST /workflow-assignments/:id/complete`.

#### Workflow Builder UI (v0.26.5)
- **Admin workflows section.** New "Workflows" category tab in admin surface.
  List view with name, slug, entry mode, active status, version. Detail view
  with editable fields, stage list, add/edit/delete stages, form template JSON
  editor, publish button, public URL display.
- **Queue sidebar section.** "Queue" section in chat sidebar (after Channels).
  Shows user's claimed assignments with badge count. Click to navigate to
  workflow channel. Complete button per assignment.
- **Workflow API methods.** `workflow-api.js` extends global API object with
  full workflow CRUD, stage management, publish, instance lifecycle, and
  assignment queue operations.
- **Workflow CSS.** Stage row styling (draggable, ordinal badges), queue
  sidebar items, team queue panel, admin badges.

#### Foundation (v0.26.0)
- **Session cleanup job.** Background goroutine deletes expired anonymous
  sessions with no messages. `SESSION_EXPIRY_DAYS` env var (default: 30).
- **ExecutionContext TeamID wiring.** `teamID` propagated through completion,
  stream_loop, and messages handlers into `ExecutionContext`.
- **Stale code cleanup.** Surface delete now removes static assets. Fixed
  stale pricing comment. Removed deprecated `AllDefinitionsFiltered()`.
- **`workflow.create` permission.** Already in permission constants.

### Fixed
- **Gin route wildcard panic.** `/w/:id` (chat) and `/w/:scope/:slug` (landing)
  used different param names at the same path depth — Gin panics on registration.
  Fixed: landing route uses `/w/:id/:slug` (same param name, disambiguated by
  depth). API visitor start moved to `/api/v1/workflow-entry/:scope/:slug`.
- **SQLite surface registry nil panic.** `SeedSurfaces()`, `IsSurfaceEnabled()`,
  and `EnabledSurfaceIDs()` dereferenced nil Surfaces store on SQLite (no
  migration 022). Added nil guards with graceful fallbacks.

### Migration Notes
- **DB wipe required.** Migrations 023-025 add new tables/columns. Fresh
  `chat_switchboard_dev` DB recommended for dev deployments.
- **New env vars:** `SESSION_EXPIRY_DAYS` (default 30), `WORKFLOW_STALE_HOURS`
  (default 72). Both optional.
- **Frontend files:** 3 new JS files (`workflow-api.js`, `workflow-admin.js`,
  `workflow-queue.js`), 1 new CSS file (`workflow.css`). Templates updated
  to include them.

### How to Create a Workflow

1. **Admin → Workflows** tab → "+ New" → enter name
2. **Edit** the workflow: set description, entry mode, toggle Active
3. **Add stages** with names, persona assignments, form templates (JSON),
   history mode
4. **Publish** to create an immutable version snapshot
5. **Share** the public URL (`/w/{scope}/{slug}`) for visitor intake, or
   start instances via API (`POST /workflows/:id/start`)

### Known Gaps (v0.27.0)
- Team-scoped workflow management UI (team admins can only see workflows via
  admin panel, not the team settings surface)
- Drag-and-drop stage reorder in builder UI
- `on_complete` workflow chaining (column exists, nullable, not wired)
- Workflow retention policies (column exists, not enforced)
- Stage persona auto-switch in chat UI (backend binds persona, frontend
  doesn't reflect stage transition yet)


## [0.25.4] — 2026-03-09

### Added
- **Admin settings export/import.** Export button downloads versioned JSON envelope (`_type`, `_version`, `_exported_at`, `_switchboard_version`, `settings`, `policies`). Import validates envelope, shows confirmation with key counts and metadata, writes each key via `adminUpdateSetting()`. Sensitive key warning on export.
- **Workspace rename/delete.** Workspace picker rows show ✏ rename and 🗑 delete buttons. `API.deleteWorkspace(id)` method added (backend `DELETE /workspaces/:id` already existed). Confirmation dialog warns about permanent file deletion.
- **Bulk model visibility.** Settings → Models page shows "X visible, Y hidden of Z total" with **Show All** / **Hide All** buttons.
- **Multi-provider live test failover.** `LIVE_PROVIDERS=venice,openrouter` env var. `tryCompletion()` walks providers until one responds 200. `setupAllProviders()` tolerates individual setup failures. CI no longer fails on transient upstream 503s.

### Fixed
- **Admin surface: 6 audit fixes.** Stray `>` in back button, dual-bind race on back button, hardcoded People nav flash, `closeAdmin()` ignores return URL, `openAdminSection()` pushes history, Storage section empty (missing `files.js` script).
- **Banner overlapping surface content.** Body changed to `display:flex;flex-direction:column;height:100vh`. Surface is `flex:1;min-height:0`. Banners get `flex-shrink:0`. Replaced fragile `calc(100vh - ...)` with proper flex distribution.
- **Scaling architecture.** `#surfaceInner` wrapper in `base.html` — generic scale target via `transform:scale()`. No surface-specific selectors. Banners stay unscaled. Extension surfaces get scaling for free.
- **Group permissions 500.** Postgres `UpdateBuilder.SetNull()` incremented `argIdx` without adding an arg — positional placeholders misaligned after first NULL column. SQLite version was already correct.
- **BYOK/Personas toggles not working.** Admin saves to policies store (`allow_user_byok`), settings loader read from `GlobalConfig` (`user_providers.enabled`). Two different stores, two different keys. Fixed to read from `Policies.GetAll()`.
- **Stale settings modal HTML.** 225 lines of orphaned modal body content from pre-surfaces migration rendered directly in chat page flow (General tab with `display:block` always visible below chat input). Removed along with old admin panel modal (~320 lines total).
- **Team admin modal transparent.** `class="modal-content modal-lg"` → `class="modal"`. Also fixed save-to-note modal in `notes.js`. Zero `modal-content` references remain project-wide.
- **Team admin modal layout.** Duplicate `display` property (`display:none;...;display:flex`) made content always visible. Moved flex to CSS class `.team-admin-content`.
- **Team persona create button missing.** Template lacked `settingsTeamAddPersonaBtn` button and `settingsTeamAddPersona` form container that JS expected.
- **Settings surface handler wiring.** `_initSettingsListeners()` early-returned on missing `settingsModel` (only on General section), killing all handlers below including BYOK provider form, model visibility, team admin add-member/provider/persona. Restructured: settings features under `data-surface` guard, team admin features unconditional with `?.`.
- **Settings surface missing `App.policies` and `App.models`.** On chat surface, `app.js` populates these. Settings surface called wrong API (`getSettings()` returns user prefs, not policies). Fixed to `getPublicSettings()` + `fetchModels()`.
- **Model Roles section empty.** Template had no `userRolesConfig` container for the roles section. `loadUserRoles()` early-returned on BYOK disabled or no personal providers with zero feedback. Added proper template section with notice text.
- **User settings NULL/null corruption.** `users.settings` column: SQL NULL → Postgres `COALESCE` fix. JSON `null` literal → `NULLIF(settings, 'null')` + `jsonb_typeof` guard. Prevents `null || {...} → [null, {...}]` array concatenation. Both `GetSettings` and `UpdateSettings` fixed for Postgres and SQLite.
- **`null.model_roles` crash.** Fresh users have NULL settings → `API.getSettings()` returned null → `settings.model_roles` threw. Added `|| {}` fallback on all callsites.
- **Project files 403 for admins.** `ListByProject`, `UploadToProject`, and `loadAndAuthorize` ownership checks had no admin bypass. Added `c.GetString("role") != "admin"` guard.

### Migration Notes
- **No new migrations.** All fixes are handler/template/JS changes.
- **Data fix required.** Users with corrupted settings (JSON array or null literal) need manual cleanup — see release notes.

## [0.25.3] — 2026-03-09

### Fixed
- **Theme system mode broken.** `Theme.set('system')` called `removeAttribute('data-theme')` → `:root` CSS hardcoded dark → system always showed dark. Now resolves OS preference via `matchMedia`, sets `data-theme` to resolved value, registers listener for OS changes.
- **Resize bar jump on click.** `onStart` now pins inline width/minWidth immediately after measuring via `getBoundingClientRect()`. Prevents snap-back during open-transition.
- **Debug modal no background/scroll.** `class="modal-content modal-lg"` → `class="modal modal-wide"`. Modal body restructured as flex column with proper overflow.
- **Settings/admin back button.** Stashes `document.referrer` in sessionStorage, nav links use `location.replace()`, back button reads stashed URL.
- **Profile save errors.** Wrong element ID (`settingsDisplayName` → `profileDisplayName`), wrong API routes (`/users/me` → `/profile`, `/auth/password` → `/profile/password`).
- **Profile data empty.** `getUserContext()` only populated ID and Role from JWT. Now fetches full user record.
- **Scale slider range.** `max="120"` → `max="175"`. Commit on mouse release, not live on input.

## [0.25.2] — 2026-03-09

### Added
- **Drag-resize utility.** `drag-resize.js` — single primitive for workspace handle and pane split handles. Full-viewport overlay prevents iframes/CM6 from swallowing mousemove. Touch support. Transitions killed during drag.

## [0.25.1] — 2026-03-09

### Fixed
- Minor surface integration fixes and CSS corrections from v0.25.0 deployment.

## [0.25.0] — 2026-03-08

### Added
- **Dynamic surfaces architecture.** Go template engine (`server/pages/`) with `base.html` shell, surface-specific templates, per-surface script/CSS blocks. Surface manifest registry with `SeedSurfaces()` for DB persistence.
- **Surface registry.** `surface_registry` table. Admin can enable/disable surfaces. Core surfaces: chat, admin, settings, editor, notes.
- **Component extraction.** UserMenu, ModelSelector, FileTree, CodeEditor, NoteEditor extracted as standalone components with Go template partials + JS hydration.
- **Pane container.** `pane-container.js` / `pane-container.css` — multi-pane layout with resizable splits. Used by editor surface.
- **Admin surface.** Full-page admin at `/admin/:section` replacing the old modal. Category tabs (People, AI, Routing, System, Monitoring), left nav, section-specific scaffolding via `admin-scaffold.js`.
- **Settings surface.** Full-page settings at `/settings/:section` replacing the old modal. Section-specific templates with server-rendered content areas.
- **Editor surface rebuild.** File tree pane + code editor pane + chat assist pane. Surface manifest declares three panes with resizable splits.
- **`base.html` template shell.** Banner → `#surface` → banner layout. Early theme flash prevention. Universal init block (`Theme.init()` + `UI.restoreAppearance()`). Common scripts: `app-state.js`, `api.js`, `events.js`, `ui-primitives.js`, `ui-core.js`.
- **Persona tool grants.** `renderPersonaForm()` gains tool grants section — "All tools" checkbox with categorized tool checklist. `loadToolList()` fetches from `/api/v1/tools`. `loadToolGrants()` reads persona-specific grants.
- **`window.__PAGE_DATA__`** injection from surface data loaders. `window.__USER__` from authenticated user context.

### Changed
- **Surface navigation.** `UI.openSettings()` and `UI.openAdmin()` navigate to full-page surfaces instead of opening modals.
- **CSS decomposition.** Monolithic `styles.css` split into: `variables.css`, `layout.css`, `primitives.css`, `modals.css`, `chat.css`, `panels.css`, `surfaces.css`, `splash.css`, `pane-container.css`, `chat-pane.css`, `user-menu.css`, `tool-grants.css`, `admin-surfaces.css`.

### Migration Notes
- **Migration 022:** `surface_registry` table (UUID PK, unique surface_id, is_enabled, is_core, created_at).
## [0.24.3] — 2026-03-07

### Added
- **Anonymous session participants.** Unauthenticated visitors can interact with workflow channels via ephemeral session identities. `session_participants` table stores channel-scoped session tokens with auto-generated display names ("Visitor #N"). Two entry paths: cookie-based (default, random token in `sb_session` cookie, 30-day expiry) and mTLS-based (cert fingerprint as stable session identity).
- **`AuthOrSession` middleware.** Tries JWT auth first (Authorization header + `sb_token` cookie), falls back to session cookie for workflow channels. Validates channel is `type=workflow` with `allow_anonymous=true` before creating a session. Sets `auth_type=session` in context with `session_id` and `channel_id`.
- **Session-scoped API routes.** `POST /api/v1/w/:id/completions`, `POST /api/v1/w/:id/messages`, `GET /api/v1/w/:id/messages` — all under `AuthOrSession`. Session participants can send messages and trigger completions within their bound channel only.
- **Workflow entry page.** `GET /w/:id` renders a standalone chat UI via Go template (`workflow.html`). Minimal layout: channel title/description header, message list, input box, session identity footer. No sidebar, no settings, no navigation — purpose-built for anonymous intake.
- **`SessionStore` interface.** `Create`, `GetByToken`, `GetByID`, `ListForChannel`, `CountForChannel`, `Delete`. Both Postgres and SQLite implementations.
- **`SessionParticipant` model.** `ID`, `SessionToken`, `ChannelID`, `DisplayName`, `Fingerprint`, `CreatedAt`. Added as channel participant with `participant_type=session`, `role=visitor`.
- **`channels.allow_anonymous` flag.** Boolean column (default false) gating session access. Middleware enforces: only `type=workflow` channels with `allow_anonymous=true` accept session auth.
- **Session-aware handlers.** `CreateMessage`, `ListMessages`, and `Complete` all check `isSessionAuth()` and enforce channel scope via `sessionCanAccessChannel()`. Session participants bypass user-level budget and model allowlist checks (they use the workflow channel's allocated resources). `Complete` falls back to URL param for `channel_id` on workflow API routes.
- **Handler helpers.** `isSessionAuth(c)` and `sessionCanAccessChannel(c, channelID)` in `handlers/channels.go` for consistent session identity checks.

### Migration Notes
- **Migration 021:** Creates `session_participants` table (UUID PK, unique session_token, FK to channels, display_name, fingerprint). Adds `allow_anonymous` boolean column to `channels`. Both Postgres and SQLite.
- **No new env vars.** mTLS session path reuses existing `AUTH_MODE` and `X-SSL-Client-Fingerprint` header.

### What Doesn't Ship
- Workflow definitions and stage transitions (v0.25.0)
- Session-to-user promotion ("create an account from your session")
- Session expiry and cleanup (future housekeeping job)

## [0.24.2] — 2026-03-07

### Added
- **Fine-grained permission system.** 12 permission constants using `domain.action` convention (`model.use`, `model.select_any`, `kb.read`, `kb.write`, `kb.create`, `channel.create`, `channel.invite`, `persona.create`, `persona.manage`, `workflow.create`, `admin.view`, `token.unlimited`). `AllPermissions` slice for UI rendering and validation.
- **Permission resolution.** `auth.ResolvePermissions()` computes effective permissions as the union of the Everyone group plus all explicitly assigned group memberships. Admin users bypass checks entirely.
- **`RequirePermission()` middleware.** Gin middleware with per-request permission caching — computed at most once regardless of how many permission gates are chained. `GetResolvedPermissions()` helper for conditional logic in handlers.
- **Permission-gated routes.** `POST /personas` requires `persona.create`. `PUT/DELETE /personas/:id` requires `persona.manage`. `POST /knowledge-bases` requires `kb.create`. `POST /knowledge-bases/:id/documents` requires `kb.write`. `POST /channels` requires `channel.create`. `POST /channels/:id/participants` requires `channel.invite`.
- **Token budgets.** Per-group daily and monthly token ceilings stored as BIGINT columns on groups. `auth.ResolveTokenBudget()` returns the most restrictive budget across all memberships. Pre-flight enforcement in the completion handler — queries `usage_log` for current period totals, returns 429 when exceeded. BYOK bypass: personal-scope providers skip budget checks entirely.
- **Model access control.** Per-group `allowed_models` JSONB column (NULL = unrestricted). `auth.ResolveModelAllowlist()` unions all group allowlists — any group with NULL means unrestricted. Enforced in both the completion handler (403 on disallowed model) and the model list endpoint (filters response so users only see permitted models).
- **Everyone group.** Implicit group with stable well-known ID (`00000000-...0001`), `source=system`, seeded in migration 020. All authenticated users receive its permissions without explicit membership. Editable in admin — replaces the previously planned `DefaultUserPerms` / `global_config` approach. `source=system` guard prevents deletion (returns 400 from the store layer).
- **Admin permissions endpoints.** `GET /api/v1/admin/permissions` returns all valid permission strings. `GET /api/v1/admin/users/:id/permissions` returns effective resolved permissions for a user.
- **Admin group permissions UI.** Group detail view expanded with three new sections: permission checklist (checkbox per permission with description), token budget fields (daily/monthly, empty = unlimited), and model allowlist multi-select (deduped by model_id, all-checked = unrestricted). Single save button sends all fields in one PUT.
- **API methods.** `adminListPermissions()`, `adminGetUserPermissions(userId)`.

### Fixed
- **OIDC group creation compile error.** `auth/oidc.go` line 412 passed `string` to `*string` field (`Group.CreatedBy`). Fixed to `&createdBy`.

### Migration Notes
- **Migration 020:** Adds `permissions` (JSONB, default `'[]'`), `token_budget_daily` (BIGINT), `token_budget_monthly` (BIGINT), `allowed_models` (JSONB) columns to `groups` table. Extends `groups.source` CHECK to include `system`. Drops `NOT NULL` on `created_by` (NULL for system-seeded groups). Seeds the Everyone group with `model.use`, `kb.read`, `channel.create` permissions.
- Both Postgres and SQLite migrations included.
- **No new env vars.**

## [0.24.1] — 2026-03-07

### Added
- **mTLS auth provider.** `server/auth/mtls.go` — authenticates via reverse proxy headers (`X-SSL-Client-DN`, `X-SSL-Client-Verify`, `X-SSL-Client-Fingerprint`). Parses RFC 2253 distinguished names, auto-provisions users from cert CN, uses fingerprint as stable external identity. Configurable via `MTLS_HEADER_DN`, `MTLS_HEADER_VERIFY`, `MTLS_DEFAULT_ROLE` env vars.
- **OIDC auth provider.** `server/auth/oidc.go` — OpenID Connect with authorization code flow. JWKS key caching with auto-refresh on rotation, RSA token validation, split-horizon issuer support (`OIDC_ISSUER_URL` for backend, `OIDC_EXTERNAL_ISSUER_URL` for browser). Claim extraction for `preferred_username`, `email`, `name`, `groups`, `realm_access.roles`. Role mapping via configurable `OIDC_ADMIN_ROLE`. Group sync: adds/removes OIDC-sourced group memberships on every login based on IdP groups claim.
- **OIDC login flow.** `GET /api/v1/auth/oidc/login` redirects to IdP. `GET /api/v1/auth/oidc/callback` exchanges code for tokens, validates, auto-provisions user, issues internal JWT, redirects to login page with base64-encoded token fragment. Login page JS picks up `#oidc_result=...`, saves to localStorage, redirects to app.
- **Login page SSO button.** Template adapts by `AUTH_MODE`: builtin shows username/password form, OIDC shows "Sign in with SSO" button, mTLS shows certificate prompt.
- **Keycloak integration test environment.** `docker-compose-keycloak.yml` extends base compose with Keycloak 26 + pre-configured realm. `ci/keycloak-realm.json` includes switchboard client, group mapper, two test users (alice/user, bob/admin), two groups (engineering, leads). One command: `docker compose -f docker-compose.yml -f docker-compose-keycloak.yml up`.
- **`QArgs()` dialect adapter.** Handles Postgres `$N` placeholder reuse for SQLite — expands args to match positional `?` placeholders. Wrapper functions `database.QueryRow()`, `database.Query()`, `database.Exec()` (+ Context variants) replace `database.DB.Query(database.Q(...))` pattern with automatic arg expansion.
- **`QArgs` unit tests.** 5 tests covering reused placeholders, no-reuse passthrough, ILIKE rewrite, high placeholder numbers, Postgres no-op.
- **Channel list regression test.** `TestIntegration_ChannelListWithTypeFilter` exercises `types=direct,group` query path that previously returned 500 on SQLite.

### Fixed
- **SQLite `_time_format` DSN parameter.** Removed `_time_format=2006-01-02T15:04:05Z` from `modernc.org/sqlite` connection string — this parameter is `mattn/go-sqlite3`-only and caused `database ping: unknown _time_format` on startup.
- **SQLite store wiring.** `main.go` store initialization now uses `sqliteStore.NewStores()` when `DB_DRIVER=sqlite` instead of always using `postgres.NewStores()`. Fixes nil pointer panic in health accumulator goroutine.
- **nginx resolver for localhost.** Changed `set $backend http://localhost:8080` to `http://127.0.0.1:8080` — IP literal doesn't require DNS resolution inside container.
- **nginx extension asset routing.** Changed `location /api/` to `location ^~ /api/` — prevents `location ~* \.(js)$` regex from intercepting `/api/v1/extensions/.../main.js` requests.
- **Channel list 500 on SQLite.** Count query uses `user_id = $1 OR ... participant_id = $1` — Postgres allows reusing `$1` but SQLite's `?` is positional. Fixed with `QArgs` arg expansion.
- **OIDC group sync FK constraint.** `findOrCreateOIDCGroup` now receives the provisioning user's ID as `created_by`, satisfying the `groups.created_by REFERENCES users(id)` foreign key.

### Migration Notes
- **Migration 019:** `oidc_auth_state` table (ephemeral OIDC flow state), `groups.source` column (`manual`/`oidc`).
- **New env vars:** 10 mTLS (`MTLS_*`) + 11 OIDC (`OIDC_*`) + `AUTH_MODE` extended to accept `mtls`/`oidc`.

## [0.24.0] — 2026-03-06

### Added
- **Auth provider abstraction.** New `server/auth/` package with `Provider` interface: `Authenticate()`, `Register()`, `SupportsRegistration()`, `Mode()`. Three modes defined: `builtin` (implemented), `mtls` (v0.24.1), `oidc` (v0.24.1). `AUTH_MODE` env var selects provider at startup. mTLS/OIDC fail-fast with clear error if selected before implementation lands.
- **Builtin provider.** `auth.BuiltinProvider` extracts login/register logic from `handlers/auth.go` into the `Provider` interface. Identical behavior to v0.23.2 — reads `{login, password}` from request body, validates bcrypt hash, returns user with plaintext password as `VaultHint` for UEK unlock.
- **User handles.** `handle` column on `users` table — unique, auto-generated from username via `models.HandleFromName()`. Used as the `@mention` identifier for human users. Editable. `UniqueHandle()` appends `-2`, `-3` suffixes on collision (same pattern as persona handles).
- **Auth source tracking.** `auth_source` column on `users` table (`builtin`/`mtls`/`oidc`). `external_id` column for IdP subject IDs or cert fingerprints. Composite unique index on `(auth_source, external_id)`.
- **`GetByHandle()`** and **`GetByExternalID()`** on `UserStore` interface — both Postgres and SQLite implementations.
- **Design document.** `docs/DESIGN-0.24.0.md` — full spec for v0.24.0–v0.24.3 (auth abstraction, mTLS/OIDC, permissions, anonymous sessions). All open questions resolved.

### Changed
- **Auth handler refactored.** `AuthHandler` now holds a `provider auth.Provider` field. `Login()` delegates to `provider.Authenticate()`, then handles vault unlock and JWT generation. `Register()` delegates to `provider.Register()`. `generateTokens()` response includes `handle` and `auth_source`.
- **`BootstrapAdmin()` / `SeedUsers()`** backfill `handle` for existing users on startup (idempotent — skipped if handle already set). New users created with `auth_source=builtin` and auto-generated handle.
- **@mention resolution uses handles.** `resolveMention()` steps 3–4 now query `LOWER(handle)` instead of `LOWER(username)`. Backend and frontend aligned.
- **User search includes handles.** `GET /api/v1/users/search` returns `handle` field and filters on handle alongside username and display_name.
- **Autocomplete matches on handle.** `channel-models.js` filters user candidates by handle, username, and display name. The `@mention` token inserted into the input is the user's handle (not username).
- **User store queries updated.** All `SELECT` statements in both Postgres and SQLite user stores include `auth_source`, `external_id`, `handle`. Unified `scanOneUser` helper in Postgres, `scanOne` in SQLite — eliminates per-method scan block duplication.
- **User list API.** `List()` response includes `auth_source` and `handle` for all users.

### Migration Notes
- **DB wipe required.** Migration 018 adds three columns to `users`. Existing users backfilled with `auth_source='builtin'` and handle derived from username.
- **New columns:** `users.auth_source`, `users.external_id`, `users.handle`.
- **New indexes:** `idx_users_handle` (unique), `idx_users_external_id` (unique composite, partial).

## [0.23.2] — 2026-03-06

### Added
- **Unified active conversation.** `App.activeConversation = { id, type }` replaces `App.currentChatId` and `App.currentChannelId`. All code paths (send, stream, model bar, session restore, sidebar highlight) keyed off `App.activeId`. `setActive(id, type)` method, `activeType` getter, `getActiveChat()` helper.
- **Group leader default response.** When a `type=group` channel receives a message without an @mention, the leader persona responds. Leader resolved from `persona_group_members WHERE is_leader` via channel participants.
- **`@all` fan-out.** `@all` in message content routes to every persona participant. Depth-1 only — responses from @all do not trigger further chains.
- **Human message attribution.** Messages from other human participants show their avatar and display name instead of "You". Persona attribution verified for loaded history in channel context.
- **Channel lifecycle.** Archive action via context menu (sets `is_archived`, `archived_at`). Delete gated by `channel_retention.mode` policy (`flexible` allows delete, `retain` forces archive-only). Admin purge with `purge_after_days` floor. Retention config keys in `global_config`.
- **Participant mutation guards.** DMs block removal below 2 human participants. Groups block removal of last persona (returns 400 with clear error).
- **Channel context banner.** Slim bar below chat header showing ai_mode, topic, and routing hints. Adapts by conversation type (DM: partner name + @mention hint; Group: leader/all routing; Channel: ai_mode + topic).
- **`NotifyUserMention` via WebSocket.** Replaced broken gin context interface cast with direct `hub.SendToUser()`. Delivers `user.mentioned` event with channel_id, from_user, content preview.
- **User mention notification toast.** `user.mentioned` WebSocket event handler shows toast with content preview and increments unread badge.

### Fixed
- **Channel persistence through refresh.** `loadChannels()` read `resp.channels` but `ListChannels` returns paginated response with `data` key. Channels vanished on reload.
- **Folder drag-and-drop.** Three compounding issues: `folderId` never mapped in `loadChats()`, folder groups had no drag handlers, no unfiled drop zone. All fixed.
- **DM creation 404.** Route didn't exist. Added `GET /api/v1/users/search?q=` endpoint. Rewrote DM creation UI from text input to lazy search modal.
- **Channel ⋯ menu inconsistency.** Replaced ✕ delete button with ⋯ hover button matching folder pattern.
- **Folder ⋯ button reflow.** `display:none/block` caused arrow shift on hover. Changed to `visibility:hidden/visible`.
- **Folder delete spill.** Now shows three-button modal: "Keep chats" / "Delete chats too" / Cancel.
- **Unread subquery dependency.** Query used `last_read_message_id` (migration 017) which may not exist. Rewrote to use `last_read_at` (migration 005).
- **Settings Models section.** Template section fell through to generic div. Added dedicated template section.
- **`u.avatar` → `u.avatar_url`** in `ListMessages` JOIN and `treepath/path.go` `resolveSenderInfo()`.

### Migration Notes
- **Migration 017:** `last_read_message_id` column on `channel_participants`.

## [0.23.1] — 2026-03-05

### Added
- **Conversation type taxonomy.** Five types: `direct` (1:1 AI chat), `dm` (human-to-human), `group` (multi-participant), `channel` (named persistent space), `workflow` (staged process, deferred). Channel type constraint extended.
- **`ai_mode` on channels.** `auto` (AI responds to every message), `mention_only` (AI silent unless @mentioned), `off` (AI disabled). Completion handler checks `ai_mode` before dispatching.
- **Three-section sidebar.** Projects → Channels → Chats. Each section independently collapsible with localStorage persistence. Channels sorted by last activity. Chats keep existing time-grouped behavior.
- **Folder system.** `chat_folders` table with full CRUD. Drag chats into/out of folders. Folder context menu: rename, delete. `folder_id` column on channels.
- **DM plumbing.** `resolveMention()` extended to resolve users (exact + prefix on username, self-mention blocked). User @mention skips AI, delivers notification. DM creation flow with user search.
- **Presence heartbeat.** `user_presence` table. `POST /presence/heartbeat` (30s upsert), `GET /presence?users=...` (90s threshold). Client heartbeat interval with visibility pause/resume. Presence WebSocket events.
- **Channel participant CRUD.** `channel_participants` handler: list, add, update role, remove. Auto-created on channel creation. DM participants auto-added from `req.Participants`.
- **Persona groups wired.** CRUD endpoints for `persona_groups` and `persona_group_members`. `is_leader` flag on members.
- **`topic` column** on channels for header display.
- **User search endpoint.** `GET /api/v1/users/search?q=` — returns id, username, display_name for active users (max 20, excludes caller).

### Changed
- **`CreateChannel` accepts `type` field** (default `direct`). `ListChannels` supports `?types=dm,channel` multi-filter.
- **Channel sidebar rendering.** `renderChannelsSection()` shows # for channels, person icon for DMs. Online dots from presence data. Unread badges.
- **Chat list filtering.** `renderChatList` excludes `type=channel` and `type=dm` — only direct/group chats appear in the Chats section.

### Migration Notes
- **Migration 016:** `ai_mode`, `topic` on channels. `user_presence` table. Channel type constraint extended to include `dm`, `channel`.

## [0.23.0] — 2026-03-05

### Added
- **@mention routing.** Type `@persona-handle` or `@model-id` in any chat to route the completion to a specific persona or model. Works in all chats — no roster or group setup required. Resolution order: persona handle (exact, then prefix) → model catalog (exact, then prefix). Backend `resolveMention()` does direct DB lookup against `personas.handle` and `model_catalog.model_id`.
- **Persona handles.** Every persona gets a unique `handle` field (e.g. `veronica-sharpe`) auto-generated from the name. Editable in the persona form. Stored in `personas.handle` with a unique index. Used as the @mention identifier — no spaces, no ambiguity.
- **@mention autocomplete.** Typing `@` in the chat input shows a filterable popup of all enabled models and personas with avatars, display names, `@handle` hints, and provider info. Works in any chat. Matches against handle, model ID, and display name. Arrow keys + Enter/Tab to select, Escape to dismiss.
- **@mention pill rendering.** @mentions in message content render as styled accent-colored pills. Handles and display names both highlighted. Skipped inside `<code>` and `<pre>` blocks.
- **AI-to-AI chaining.** When a persona response contains an @mention of another persona, a follow-up completion fires automatically. Delivered via WebSocket (`message.created` event). Chain depth capped at 5. Self-mention blocked. Uses the same `resolveMention()` as user @mentions.
- **Participant list injection.** System prompt includes a list of available @mentionable personas so LLMs know who they can invoke. Injected in `loadConversation()` after memory hints. Excludes the current persona (no self-mention). Empty when no other personas exist (zero overhead on 1:1 chats).
- **Context boundaries.** When switching personas via @mention, a system message is injected before the user's message telling the target persona to ignore previous personas' styles. When a non-persona model responds after persona messages exist in history, a boundary tells it to use its own natural style.
- **Provider proxy support.** `provider_configs` table gains `proxy_mode` (system/direct/custom) and `proxy_url` columns. Provider HTTP clients use `proxy_mode` to configure transport: `system` = env-based (`HTTP_PROXY`), `direct` = no proxy, `custom` = explicit URL. All four providers (Anthropic, OpenAI, OpenRouter, Venice) updated.
- **Persona groups schema.** `persona_groups` and `persona_group_members` tables added (migration 004). Structural foundation for saved roster templates — CRUD and FE not yet implemented.
- **Per-provider model preferences.** `user_model_settings` unique key widened to `(user_id, model_id, provider_config_id)`. Same model from different providers gets independent visibility toggles. Frontend uses composite keys throughout.
- **Channel participants.** `channel_participants` table with polymorphic `participant_type` (user/persona/session), `role` (owner/member/observer). CRUD endpoints: list, add, update role, remove. Auto-created on channel creation.
- **Persona avatar resolution.** Assistant messages resolve persona portraits through channel roster → participant data → App.models lookup. Streaming messages also resolve avatars.
- **Save message to note.** Per-message "Note" button (visible on hover) opens a modal to create a new note or append to an existing note from any message. Supports text selection — selected text is saved instead of full message.
- **Group chat creation.** "Group Chat" option in New Chat dropdown. Persona picker with handles, scope badges, and model info. Creates channel + adds persona participants.
- **Chat type indicators.** Sidebar shows 👥 for group chats, ⚙ for workflow channels.

### Changed
- **Channel models constraint.** `channel_models` unique constraint split into two partial indexes: raw models keyed on `(channel_id, model_id, provider_config_id) WHERE persona_id IS NULL`, persona entries keyed on `(channel_id, persona_id) WHERE persona_id IS NOT NULL`. Two personas on the same underlying model now get separate roster entries.
- **Channel models queries.** `GetModels` and `GetModelByID` in both Postgres and SQLite stores now JOIN `personas` table to carry handle data alongside display name and persona ID.
- **User message alignment.** User bubbles use `flex: initial` with `max-width: 85%` to shrink-wrap content and right-align within the centered 768px column.
- **Channel list loading.** `loadChats()` no longer filters by `type=direct` — all channel types (direct, group, workflow) load on refresh.
- **Autocomplete CSS.** Replaced legacy CSS variable names (`--bg-primary`, `--text-secondary`, etc.) with current theme variables (`--bg-surface`, `--bg-raised`, `--text-3`, etc.) across all `channel-models.css`.
- **Persona form.** Added @mention handle field with auto-generation from name, monospace styling, and edit-locks (manual edit stops auto-gen). Handle included in `getValues()`, `setValues()`, and `clearForm()`.
- **Model dropdown.** Persona entries show `@handle` hint next to display name in accent color monospace.
- **Completion chain.** Rewritten to use `resolveMention()` directly instead of roster-based mention parsing. No roster, participant list, or channel_models dependency. Same function for user→LLM and LLM→LLM routing.

### Fixed
- **Notes modal visibility.** `_showSaveToNoteModal` now creates overlay with `modal-overlay active` class (was missing `active`, modal was invisible).
- **Single @mention routing.** Single-target @mentions no longer reload conversation (which caused duplicate user messages that broke Anthropic's API). System prompt swapped in-place on existing messages array.

### Migration Notes
- **DB wipe required.** Migrations 003, 004, and 005 modified. No upgrade path from previous schema — drop and recreate dev DB.
- **New columns:** `personas.handle`, `provider_configs.proxy_mode`, `provider_configs.proxy_url`.
- **New tables:** `persona_groups`, `persona_group_members`, `channel_participants`.
- **New indexes:** `idx_personas_handle` (unique), `idx_channel_models_raw` (partial), `idx_channel_models_persona` (partial).

## [0.22.8] — 2026-03-04

### Changed
- **Naming cleanup: preset → persona.** Unified domain terminology across the entire codebase. Routes `/presets` → `/personas` (user, team, admin). JSON response keys, request fields, Go struct fields, handler names, JS functions, CSS classes, DOM IDs, template strings, display text — all consistently use "persona". File renamed: `presets.go` → `personas.go`. Dual JSON keys (`"personas"` + `"presets"`) collapsed to single `"personas"` key. Config key `user_presets` → `user_personas`. Banner color presets intentionally left as "presets" (different domain concept).
- **Naming cleanup: APIConfigID → ProviderConfigID.** All Go structs and JSON fields now use `provider_config_id` consistently.
- **Removed aliases.** `chat_id` field removed from completion handler. `/models` route alias removed (use `/models/enabled`).
- **Shared app-state.js.** Extracted `App` state object, `fetchModels()`, and `resolveCapabilities()` from chat-only `app.js` into new `app-state.js` loaded by `base.html` for all surfaces. Settings and admin surfaces now have access to `App` state for model dropdowns, settings, and user context.
- **Auth tokens loaded for all surfaces.** `API.loadTokens()` now runs in `base.html` inline script so settings, admin, editor, and notes surfaces can make authenticated API calls.
- **Script loading consolidated in base.html.** `ui-core.js` moved from per-surface loads to `base.html`. `ui-primitives-additions.js` (never loaded by any surface) now loaded in `base.html`. All surfaces get the complete shared stack: app-state → api → events → ui-primitives → ui-primitives-additions → ui-core → pages.

### Added
- **Settings surface functions.** `UI.loadGeneralSettings()` populates the general settings form (model dropdown, system prompt, temperature, max tokens, thinking toggle) with server data. `UI.saveAppearance()` persists theme, scale, and font size. `UI.loadTeamsSettings()` renders team membership on the settings teams tab.
- **Design documentation.** `docs/DESIGN-SURFACES.md` (four-layer surface architecture, extension hooks, trust model), `docs/ICD-API.md` (domain-organized API contract, 240 routes), `docs/ICD-AUDIT.md` (cross-reference audit with resolution status).

## [0.22.7] — 2026-03-03

### Added
- **Theme system.** New `theme.css` provides complete light/dark theming via CSS custom properties with `[data-theme]` attribute switching. Variables cover backgrounds, surfaces, text, accents, borders, shadows, and component-specific tokens. System preference auto-detection via `prefers-color-scheme` media query. Old variables (`--bg-primary`, `--text-primary`) bridged via fallback chains (`var(--bg, var(--bg-primary, #0e0e10))`) — zero breakage of existing CSS.
- **ChatPane component.** New `chat-pane.js` provides a mountable, self-contained chat pane factory replacing the hardcoded singleton pattern. `ChatPane.create(opts)` returns an instance with `renderMessages()`, `appendChunk()`, `finalizeStream()`, `appendTyping()`, `removeTyping()`, `scrollToBottom()`, `showWelcome()`, `clear()`, `getInputValue()`, `setInputValue()`, `focusInput()`, `destroy()`. Lookup via `ChatPane.get(id)`, `ChatPane.forChannel(channelId)`, `ChatPane.active()`. Each instance owns its own DOM elements and channel binding.
- **Chat pane template component.** New `components/chat-pane.html` Go template partial: `{{template "chat-pane" dict "ID" "main"}}` renders a complete chat scaffold with messages container, input bar, model selector, send button, and toolbar mount points. Used by editor surface assist pane.
- **Splash/login surface.** Login page rewritten from minimal form to split-panel splash layout: animated grid canvas on hero side, tabbed auth card (Login + Register) on right. Registration form includes avatar upload, display name, email. Powered by new `pages-splash.js` module with `Pages.initSplash()`, grid animation, and `POST /api/v1/auth/register` + `PUT /api/v1/users/me/avatar` integration.
- **UI primitives additions.** New `ui-primitives-additions.js` extends the shared primitive library: `Toast` (show/success/error/warning/info with auto-dismiss stacking), `renderBadge()` (6 color variants), `renderIcon()` (20+ SVG icon set), `renderIconBtn()` (icon button factory with active state), `Theme` (init/set/get/resolved/renderToggle with localStorage persistence), `showConfirmDialog()` (enhanced with variant/callbacks), `mountAvatarUpload()` (file input with preview).
- **Settings BYOK gate.** Settings surface nav now conditionally shows "My Providers", "Model Roles", and "Usage" tabs only when BYOK is enabled. Server-side gate via `{{if .Data.BYOKEnabled}}` reads `GlobalConfig` key `user_providers.enabled`. Includes BYOK status indicator in nav footer with UEK encryption notice.
- **Settings User Personas gate.** Settings surface conditionally shows "My Personas" tab when user-created personas are enabled. Server-side gate via `{{if .Data.UserPersonasEnabled}}` reads `GlobalConfig` key `user_presets.enabled`.
- **Settings Models tab.** New "Models" nav link in settings surface for user model visibility preferences.
- **Editor assist pane.** Editor surface now includes a `chat-pane` template component for the right-side assist pane with split handle, wired up via `ChatPane.create()` on DOM ready.

### Changed
- **`base.html` template.** Added `data-theme` attribute on `<html>` element for theme system. Google Fonts preconnect + DM Sans / JetBrains Mono stylesheet. `theme.css` loaded before `styles.css`. New shared script tags for `ui-primitives-additions.js` and `chat-pane.js` (available on all surfaces). Added `window.__SETTINGS__` global from `PageData.SurfaceSettings`.
- **`PageData` struct** (`pages.go`). New fields: `Theme` (string), `SurfaceSettings` (any, serialized to `__SETTINGS__`), `InstanceName`, `LogoURL`, `Tagline`, `RegistrationOpen`. `Render()` defaults `Theme` to `"dark"` when unset.
- **`RenderLogin()`** (`pages.go`). Now calls `loadBranding()` and `isRegistrationOpen()` to populate splash/login template fields from `GlobalConfig` keys `branding.*` and `registration.enabled`.
- **`SettingsPageData`** (`loaders.go`). Added `BYOKEnabled` and `UserPersonasEnabled` boolean fields. `settingsLoader()` reads from `GlobalConfig` keys `user_providers.enabled` and `user_presets.enabled`.
- **`settings.html` template.** Reorganized nav: base tabs (General, Appearance, Models, My Presets) always visible; BYOK section (My Providers, Model Roles, Usage) gated; User Personas gated; then Knowledge, Memory, Notifications, Teams. Added `my-personas` to dynamic section loaders.
- **`editor.html` template.** Added `chat-pane` component in right-side assist pane with split handle and `ChatPane.create()` initialization.

### New Files
- `src/css/theme.css` — 305 lines: CSS custom properties, shared components, surface layouts
- `src/js/chat-pane.js` — 162 lines: mountable chat pane factory
- `src/js/ui-primitives-additions.js` — 199 lines: Toast, Badge, Icons, Theme, confirm, avatar
- `src/js/pages-splash.js` — 154 lines: splash init, login, register, grid canvas
- `server/pages/templates/components/chat-pane.html` — 23 lines: reusable template partial

## [0.22.6] — 2026-03-03

### Fixed
- **Split FE/BE deployment: page route proxying.** Frontend container now supports `BACKEND_URL` env var. When set, the entrypoint generates nginx `proxy_pass` blocks for page routes (`/`, `/login`, `/chat/:id`, `/admin/*`, `/editor/*`, `/notes/*`, `/settings/*`) so Go template rendering works in k8s split-image deployments. When unset, behavior is unchanged (SPA fallback). Both `BASE_PATH=""` and `BASE_PATH="/prefix"` variants supported.
- **CI test timeout.** All `go test` commands now include `-timeout 8m` to prevent indefinite hangs from runner resource contention or race-detector compilation stalls. Previously used Go's 10-minute default with no goroutine dump on timeout.

### Removed
- **`router.js` (322 lines).** Client-side hash router fully replaced by server-side page routes. All `Router.*` call sites in `chat.js` replaced with `history.replaceState()` for URL updates. All `typeof Router` guards in `app.js` removed.
- **`surfaces.js` (368 lines).** Client-side surface registry replaced by Go template surface architecture. Extension API stubs (`surfaces.register`, `surfaces.activate`, etc.) now log deprecation warnings. `surfaces.getCurrent()` returns `window.__SURFACE__` from server-rendered page data.
- **`index.html` SPA shell (1,296 lines → 16 lines).** Original monolithic SPA entry point replaced with a minimal redirect page. All real routes now served by Go templates; `index.html` only serves as nginx `try_files` fallback for unknown paths.
- **`editor-mode.js` old Surfaces paths (~260 lines).** Removed `_register()`, `openDirect()`, `_unregister()`, `_activate()`, `_deactivate()`, `_embedChat()`, `_returnChat()`, and full `check()`/`checkStartup()` implementations. Only `mountServerRendered()` remains as the entry point. `check()` and `checkStartup()` kept as no-ops for backward compat with `projects-ui.js` callers.
- **SPA-only entrypoint code path.** `docker-entrypoint-fe.sh` now requires `BACKEND_URL` (fails fast if unset). Removed dead `%%BASE_HREF%%`, `%%ENVIRONMENT%%`, `%%BRANDING_JSON%%` injection into index.html. Removed branding config file loading (branding now handled by Go templates).

### Changed
- `docker-entrypoint-fe.sh`: Simplified from 227 → 184 lines. Requires `BACKEND_URL`. Removed SPA-only fallback path.
- `nginx.conf` (unified container): Consolidated repeated `proxy_set_header` blocks. Uses `$backend` variable. Page routes proxy to Go backend for template rendering.
- `base.html`: Added `__ENV__` and `__BRANDING__` globals for JS compatibility (previously injected by index.html sed).
- `server/pages/pages.go`: Added `Environment` field to `PageData`, populated from config.
- `extensions.js`: `ui.replace()`, `ui.restore()`, and `surfaces.*` API methods now log deprecation warnings instead of calling removed Surfaces system.
- `app.js`: Removed `Surfaces.init()`, `EditorMode.checkStartup()`, and `Router.init()` calls. Simplified to direct `sessionStorage` chat restore.
- `chat.js`: Replaced `Router.update()` calls with `history.replaceState()` for URL-bar sync on chat selection/creation.
- `repl.js`: Removed `surfaces.js` import reference.

## [0.22.5] — 2026-03-03

### Added
- **Go template engine.** Server-side HTML rendering via `html/template` with `//go:embed` for compiled-in templates. Custom `FuncMap` with helpers (`roleFilterType`, `toJSON`, `hasPrefix`, `dict`). CSP nonce generation per request. Replaces monolithic `index.html` + client-side DOM construction with composable, server-rendered surfaces.
- **Surface architecture.** Each page route renders a dedicated surface template that owns its full layout below the classification banner. Surfaces compose from reusable components (`model-select`, `team-select`, `file-upload`) without sharing CSS layout rules. Banner height handled via CSS custom properties (`--banner-top-h`, `--banner-bot-h`, `--surface-h`).
- **Chat surface.** Server-rendered shell at `/` and `/chat/:chatID`. Go template renders banner + layout + script tags; existing JS builds DOM inside the container. Bridge pattern preserves all current chat functionality.
- **Admin surface.** Full admin panel at `/admin/:section` with 5 category tabs (People, AI, Routing, System, Monitoring) and section sidebar. Server-rendered pages for providers, models, teams, users, and settings. Hybrid fallback for JS-loaded sections (groups, presets, knowledge, memory, health, capabilities, extensions, storage, usage, audit, stats).
- **Editor surface.** Server-rendered layout shell at `/editor/:wsId` with proper CSS height ownership. `mountServerRendered()` method on `EditorMode` bypasses the Surfaces registry for Go template mode. Dedicated CSS owns full viewport below banners — no layout collision with other surfaces. **(Fixes bugs #4, #5: editor layout collapse)**
- **Notes surface.** Server-rendered layout at `/notes/:noteId` with notes-main + notes-assist split. Boot script calls `openNotes()` in standalone mode. Responsive: assist pane hidden on mobile.
- **Settings surface.** Full-page settings at `/settings/:section` replacing modal-based settings. Left nav with all sections (general, appearance, providers, models, presets, roles, knowledge, memory, notifications, usage). General and Appearance sections server-rendered with form controls; dynamic sections populated by existing JS.
- **Login page.** Standalone Go template at `/login` with cookie-based auth. Sets `redirect_after_login` cookie for post-login navigation.
- **`AuthOrRedirect` middleware.** Cookie-based JWT validation for page routes. Reads `sb_token` cookie, redirects to `/login` on missing/invalid token (instead of 401 JSON). `RequireAdminPage()` helper aborts with 403 for non-admin users. Skips auth entirely in unmanaged mode.
- **Cookie-based auth sync.** `api.js` `saveTokens()` now sets `sb_token` cookie alongside localStorage on every token save/refresh. `clearTokens()` clears the cookie. Bridges API auth (Bearer header) with page auth (cookie).
- **Reusable `model-select` component.** Go template partial renders `<select>` with models filtered by type. Server passes filtered list; client-side cascade handler re-filters from `window.__PAGE_DATA__.models` on provider change. **(Fixes bug #1: admin model roles missing embedding models)**
- **Reusable `team-select` component.** Go template partial renders team dropdown with pre-populated options from server data loader. **(Fixes bug #2: routing policy team scope as text field)**
- **Reusable `file-upload` component.** Go template partial for drop zone + file picker. Used by editor surface.
- **Editor file upload.** Upload button in editor toolbar + drag-and-drop on file tree with visual feedback. `API.uploadWorkspaceFile()` sends File objects as raw body to existing `WriteFile` handler (binary-safe, respects workspace quota). Auto-opens single uploaded text files. **(Fixes bug #3: unable to upload files to a project)**
- **Page route data loaders.** Each surface registers a loader that pre-fetches exactly what its template needs: `chatLoader`, `adminLoader` (with section-specific data), `editorLoader`, `notesLoader`, `settingsLoader`. No over-fetching, no client-side fetch waterfall.
- **Admin provider CRUD.** Server-rendered provider table with add/edit form, type dropdown, sync button. Client-side handlers for create, update, delete, sync operations.
- **Admin model table.** Server-rendered catalog table with client-side search + type/provider filters.
- **Admin user management.** Server-rendered user table with search, role edit, enable/disable toggles.
- **Admin settings page.** Registration policy, permissions, banner config, and system prompt — all server-rendered with save handlers.
- **`pages.js` client handlers.** Role CRUD, routing policy CRUD, provider/model/team/user management, settings save — all working against existing API endpoints with proper token resolution.

### Changed
- `nginx.conf`: Added page route proxy blocks (`/`, `/login`, `/chat`, `/admin`, `/editor`, `/notes`, `/settings`) before SPA fallback. Static assets still served directly by nginx. Removed `/legacy` proxy block.
- `server/main.go`: Template engine init, `pages.SetVersion()`, route wiring for all surfaces with auth middleware groups. Removed `/legacy` fallback route.
- `src/js/api.js`: `saveTokens()` / `clearTokens()` sync `sb_token` cookie. New `uploadWorkspaceFile()` method for binary file upload to workspaces.
- `src/js/editor-mode.js`: Upload button + hidden file input in header. Drag-and-drop on file tree container with `.drag-over` visual state. `_uploadFiles()` method with text-file auto-open. `mountServerRendered()` for Go template boot path.
- `src/css/editor-mode.css`: `.drag-over` style for file tree drop target.

### Fixed
- **Bug #1**: Admin model roles now show embedding models. Server-side `roleFilterType()` sets `FilterType="embedding"` for the embedding role; `model-select` component filters by `model_type`.
- **Bug #2**: Routing policy team scope uses proper dropdown instead of free-text field. `adminLoader` pre-fetches team list; `team-select` component renders options.
- **Bug #3**: Editor file upload works. Upload button + drag-and-drop + `uploadWorkspaceFile()` API method.
- **Bug #4**: Editor chat assist pane is functional. Server-rendered layout with dedicated mount points.
- **Bug #5**: Editor and chat pane no longer squashed. Each surface owns its full viewport height via dedicated CSS — no shared flex container conflicts.

### Removed
- `/legacy` route and nginx proxy block. All page routes are now server-rendered.

## [0.22.4] — 2026-03-02

### Added
- **Rate limit tracking.** Provider health windows now track HTTP 429 / rate limit events separately from general errors. New `rate_limit_count` column on `provider_health` table. `RecordRateLimit()` method on health accumulator. Completion handler and stream loop classify `HTTP 429` / `rate limit` / `Too Many Requests` errors automatically.
- **Auto-disable policy.** Providers that report "down" status for N consecutive hourly windows are automatically deactivated. Configurable via `PROVIDER_AUTO_DISABLE_THRESHOLD` env var (default: 3, set to 0 to disable). `AutoDisabler` interface on health store, `checkAutoDisable()` runs on each flush cycle.
- **Tool health tracking.** Built-in tools (web_search, url_fetch, etc.) now record success/error/latency to `tool_health` table via the health accumulator. `RecordToolSuccess()` / `RecordToolError()` methods. Both tool execution sites in stream loop instrumented. `ToolHealthWindow` and `ToolHealthSummary` models.
- **Search provider health tracking.** web_search and url_fetch tool calls are now included in the tool health pipeline with per-invocation latency and error recording.
- **Project-specific file uploads.** `POST /api/v1/projects/:id/files` (multipart upload) and `GET /api/v1/projects/:id/files` (list). `project_id` column on `attachments` table. Files are stored under `projects/{id}/` prefix. Project access verified via ownership or team membership. Files queued for text extraction if enabled.
- **Workspace settings UI.** Git configuration section in project settings panel: remote URL, branch, credential selector. Appears when a workspace is bound. Saves via `PATCH /api/v1/workspaces/:id`. `updateWorkspace()` and `listGitCredentials()` API client methods.
- **PDF/DOCX export via pandoc.** New `POST /api/v1/export` endpoint converts markdown content to PDF or DOCX using pandoc. Editor export dropdown now includes "Export as PDF" and "Export as DOCX" options. Graceful error handling when pandoc is unavailable.
- **Project files UI.** File list and upload button in project settings panel. Shows file names and sizes. Multi-file upload support.
- **API client methods.** `projectUploadFile`, `projectListFiles`, `exportDocument`, `updateWorkspace`, `listGitCredentials`.

### Changed
- `health/accumulator.go`: Added `rateLimitCount` to bucket, `RecordRateLimit()`, `RecordToolSuccess()`, `RecordToolError()`, `flushTools()`, `checkAutoDisable()`, `SetAutoDisable()`, `AutoDisabler` interface, `toolBucket` struct. `Store` interface extended with `UpsertToolWindow()`, `ListAllToolCurrent()`.
- `handlers/completion.go`: `HealthRecorder` interface extended with `RecordRateLimit()`, `RecordToolSuccess()`, `RecordToolError()`. `recordHealth()` classifies HTTP 429 errors.
- `handlers/stream_loop.go`: Both tool execution sites now record tool health. `recordHealthFn()` classifies rate limit errors.
- `models/models.go`: `RateLimitCount` field on `ProviderHealthWindow`. `ToolHealthWindow`, `ToolHealthSummary` types. `ErrorCount`, `RateLimitCount`, `TimeoutCount` fields on `ProviderHealthSummary`. `ProjectID` field on `Attachment`.
- `store/postgres/health.go`: All queries updated for `rate_limit_count`. `UpsertToolWindow()`, `ListAllToolCurrent()`, `DeactivateProvider()` methods.
- `store/sqlite/health.go`: Full rewrite with rate_limit_count, tool health, and auto-disable support.
- `store/postgres/attachment.go`: `project_id` in cols, scan, create, and new `GetByProject()`.
- `store/sqlite/attachment.go`: Same project_id additions.
- `store/interfaces.go`: `GetByProject()` on `AttachmentStore`.
- `config/config.go`: `ProviderAutoDisableThreshold` field + env var loading.
- `main.go`: SQLite health store branching, auto-disable wiring, export handler route, project file routes.
- `handlers/health_admin.go`: Health summary includes error_count, rate_limit_count, timeout_count.
- `src/js/ui-admin.js`: Health dashboard cards show "Rate Limits" count with warning color.
- `src/js/projects-ui.js`: Git settings section, project files section, file upload handler.
- `src/js/editor-mode.js`: PDF and DOCX export menu items and handler.
- `src/js/api.js`: 5 new API client methods.

### Database
- Migration 015 (Postgres) / 014 (SQLite): `rate_limit_count` column on `provider_health`, `tool_health` table, `project_id` column on `attachments`.

## [0.22.3] — 2026-03-02

### Added
- **Provider admin UI: "Routing" category.** New admin category with three sections: Health, Routing, and Capabilities. Accessible from admin panel sidebar navigation.
- **Health dashboard.** Admin section showing per-provider status badges, request counts, error rates, average/max latency, timeout counts, and last error messages. Refresh button for live updates. CSS grid layout with responsive cards.
- **Routing policy builder.** Full CRUD UI for routing policies: create/edit form with name, priority, type (provider_prefer/team_route/cost_limit/model_alias), scope (global/team), team ID, JSON config editor, active toggle. List view with edit/enable/disable/delete actions.
- **Routing dry-run test panel.** Input model + user ID, evaluates against all active policies with live health status, displays ranked candidates with status badges and selection reasoning.
- **Capability override viewer.** Table listing all model capability overrides with model ID, provider config, field, value, and delete button. Explains auto-detection fallback when no overrides exist.
- **`provider_status` on models/enabled response.** `GET /api/v1/models/enabled` now includes `provider_status` field ("healthy"/"degraded"/"down"/"unknown") per model from live health data.
- **`ModelHandler` with health enrichment.** Extracted model listing into dedicated `handlers/capabilities.go` with `SetHealthStore()` for health status injection. Builds health map from current hourly windows.
- **`GET /api/v1/admin/capability-overrides` endpoint.** Returns all capability overrides across all models (admin view).
- **API client methods.** `adminGetAllProviderHealth`, `adminListRoutingPolicies`, `adminGetRoutingPolicy`, `adminCreateRoutingPolicy`, `adminUpdateRoutingPolicy`, `adminDeleteRoutingPolicy`, `adminTestRouting`, `adminListCapabilityOverrides`, `adminDeleteModelCapability`.

### Changed
- `src/js/ui-admin.js`: New "routing" admin category with health/routing/capabilities sections. `ADMIN_SECTIONS`, `ADMIN_LABELS`, `ADMIN_LOADERS` updated. Added `loadAdminHealth()`, `loadAdminRouting()`, `loadAdminCapabilities()`, `_showRoutingForm()`, `_toggleRoutingPolicy()`, `_deleteRoutingPolicy()`, `_runRoutingTest()`, `_deleteCapOverride()`.
- `src/js/api.js`: 9 new API client methods for health, routing, and capability admin endpoints.
- `src/index.html`: New admin category button for "Routing". Three new `admin-section-content` divs: `adminHealthTab`, `adminRoutingTab`, `adminCapabilitiesTab`.
- `src/css/styles.css`: `.admin-health-grid` and `.admin-health-card` styles.
- `models/models.go`: `ProviderStatus` field on `UserModel` struct.
- `handlers/capabilities.go`: Extracted `ModelHandler` from inline handlers. `ListEnabledModels` enriches response with provider health status. `ResolveModelCaps` canonical capability resolver.
- `main.go`: `ModelHandler` created with `SetHealthStore()` wiring. Capability override admin routes registered.

## [0.22.2] — 2026-03-02

### Added
- **Routing policy engine.** Rules-based request routing evaluated between "what model was requested" and "which provider serves it." `routing/` package with evaluator, fallback runner, and type-safe policy model.
  - **`provider_prefer`**: Ordered list of preferred provider configs — fallback to others if preferred are unavailable.
  - **`team_route`**: Restrict a team to only use specific provider configs (team-scoped access control).
  - **`cost_limit`**: Filter out providers above a heuristic cost threshold per request.
  - **`model_alias`**: Map alias names ("fast", "smart") to specific provider+model pairs.
- **Health-aware routing.** Candidates sorted by health status (healthy > degraded > unknown > down). `skip_down` flag removes down providers from consideration when healthy alternatives exist. All-down graceful degradation: keeps candidates rather than returning empty.
- **Fallback runner.** `routing.RunWithFallback()` tries candidates in order with configurable retry depth. Logs each failure and tries the next candidate. Returns the winning candidate and updated decision with fallback depth.
- **`routing_policies` table.** New migration (Postgres 014, SQLite 013) with scope (global/team), priority ordering, JSONB config, and active flag. Index on `(is_active, priority)` for efficient policy loading.
- **Routing policy admin CRUD.** Full REST API for policy management:
  - `GET /api/v1/admin/routing/policies` — list all policies
  - `GET /api/v1/admin/routing/policies/:id` — get single policy
  - `POST /api/v1/admin/routing/policies` — create policy (validates type, scope, team_id)
  - `PUT /api/v1/admin/routing/policies/:id` — update policy
  - `DELETE /api/v1/admin/routing/policies/:id` — delete policy
- **Dry-run routing test.** `POST /api/v1/admin/routing/test` accepts model + user_id + team_ids, evaluates active policies against all global provider configs with live health status, returns ranked candidates and the routing decision.
- **`X-Switchboard-Provider` response header.** Every completion response includes `providerID/configID` for routing observability.
- **`routing_decision` column on `usage_log`.** JSONB field for recording which policy matched, fallback depth, and total candidates evaluated.
- **`RoutingPolicyStore` interface** with Postgres and SQLite implementations. Supports Create, Update, Delete, GetByID, ListActive, ListAll, ListForTeam.
- **12 evaluator tests.** Coverage: no policies, provider_prefer ordering, team_route filtering (including wrong-team no-op), model_alias rewrite, health skip-down, health sorting, all-down graceful degradation, priority ordering, empty candidates, inactive policy skip.

### Changed
- `models/models.go`: Added `RoutingPolicy` struct and `RoutingDecision` field on `UsageEntry`.
- `store/interfaces.go`: Added `RoutingPolicyStore` interface and `RoutingPolicies` field on `Stores`.
- `store/postgres/stores.go`, `store/sqlite/stores.go`: Wired `RoutingPolicyStore` into factory.
- `handlers/completion.go`: Added `router`, `healthStore` fields. `HealthStatusQuerier` interface. `evaluateRouting()` method builds candidates from accessible configs, queries health status, runs evaluator, reloads credentials on config switch. `X-Switchboard-Provider`, `X-Switchboard-Route`, `X-Switchboard-Fallback` response headers.
- `main.go`: Creates `routing.Evaluator`, wires into completion handler via `SetRoutingEvaluator()` and `SetHealthStore()`. Registers 6 admin routing routes.

### Database
- Postgres: `server/database/migrations/014_v0222_routing.sql`
- SQLite: `server/database/migrations/sqlite/013_v0222_routing.sql`

## [0.22.1] — 2026-03-02

### Added
- **Provider profile schemas.** Each provider type declares its configurable settings with types, defaults, validation constraints, and dependency rules. Built-in schemas for openai, anthropic, venice, openrouter. Unknown types fall back to the openai schema. `providers/profile.go`.
- **Provider hooks (pre-request / post-stream).** Declarative request/response transforms driven by `provider_configs.settings` JSONB. Replaces any need for hardcoded provider switch statements in the completion path. `providers/hooks.go`.
  - **OpenAI:** `system_prompt_prefix`, `frequency_penalty`, `presence_penalty` → injected into ExtraBody.
  - **Anthropic:** `extended_thinking` + `thinking_budget` → injects `thinking` config into wire JSON, sets `anthropic-beta` header, clears temperature.
  - **Venice:** `enable_thinking`, `enable_web_search`, `include_venice_system_prompt` → builds `venice_parameters` in ExtraBody.
  - **OpenRouter:** `route`, `require_parameters` → serialized into `X-Provider-Preferences` header.
- **`ExtraBody` on `CompletionRequest`.** Provider hooks write arbitrary key-value pairs that get merged into the wire-format JSON via `mergeExtraBody()` in both OpenAI and Anthropic `doRequest` methods. Keeps the canonical request type clean while supporting provider-specific extensions.
- **Anthropic extended thinking stream support.** `content_block_delta` with `type=thinking_delta` now routes to the `Reasoning` field on `StreamEvent`, matching the existing `reasoning_content` path used by OpenAI-compatible providers.
- **Provider type registry.** `providers.RegisterType()` combines provider implementation + metadata (name, description, default endpoint, profile schema). `providers.ListTypes()` returns all registered types.
- **`GET /api/v1/admin/provider-types` endpoint.** Returns metadata and profile schemas for all registered provider types. Used by admin UI to render provider creation forms and show available settings per type.
- **Preset setting overrides.** `MergePresetSettings()` merges persona-level overrides onto provider-level settings, respecting `ProviderOnly` fields that cannot be changed at the preset level.
- **Comprehensive tests.** `providers/hooks_test.go`: 17 tests covering all four hook implementations, profile schemas, GetHooks, MergePresetSettings, mergeExtraBody, nil safety.

### Changed
- `providers/registry.go`: `Init()` now uses `RegisterType()` with full metadata instead of bare `Register()`. `ProviderTypeMeta` struct with ID, name, description, default endpoint, profile schema.
- `providers/provider.go`: `CompletionRequest.ExtraBody` field (json:"-"). `mergeExtraBody()` helper function. `Model` struct unchanged.
- `providers/openai.go`: `doRequest()` merges ExtraBody into wire JSON before HTTP dispatch.
- `providers/anthropic.go`: `doRequest()` merges ExtraBody + sets `anthropic-beta` header when thinking is enabled. Stream processing handles `thinking_delta` content block deltas. Wire type gains `Thinking` field.
- `handlers/completion.go`: `PreRequest` hook invoked at all three dispatch paths (multi-model, streaming, sync).
- `handlers/stream_loop.go`: `PostStreamEvent` hook invoked after each streaming event in both `streamWithToolLoop` and `streamModelResponse`.
- `handlers/health_admin.go`: Added `GetProviderTypes` handler.
- `main.go`: `GET /api/v1/admin/provider-types` route registered.

## [0.22.0] — 2026-03-02

### Added
- **Provider health tracking.** In-memory accumulator records success/error/timeout per provider config, flushes hourly buckets to `provider_health` table every 60s. Status derivation: healthy (<5% errors), degraded (5–25%), down (>25%). Background prune removes data older than 7 days.
- **Health admin endpoints.** `GET /api/v1/admin/providers/:id/health` returns per-provider summary + hourly windows. `GET /api/v1/admin/providers/health` returns current-hour status for all providers.
- **Capability admin overrides.** `capability_overrides` table supports per-provider or global overrides for any model capability field. Three-tier resolution chain: catalog → heuristic → admin override (highest priority).
- **Capability override endpoints.** `GET /api/v1/admin/models/:id/capabilities` with source annotations (catalog/heuristic/override). `PUT` to set, `DELETE` to remove, `GET /api/v1/admin/capability-overrides` to list all.
- **Workspace pane layout.** Frontend architecture replaces `.chat-area` + `.side-panel` with `.workspace` flex container. Primary and secondary panes are independent with drag-resize handle between them. Surfaces declare layout preferences via `primary`, `secondary`, `secondaryOpts` fields.

### Fixed
- **`scanJSON` driver buffer aliasing** (v0.21.7 bugfix). `safe_json.go` now copies the `[]byte` from the database driver instead of aliasing the slice header. Fixes intermittent `invalid character` errors on multi-row JSON responses caused by driver buffer reuse between `rows.Next()` calls.
- **`CreateChannel` RETURNING path** now uses `scanJSON(&ch.Settings)` instead of bare `&ch.Settings` scan.
- **`UpdateChannel` settings validation** — `json.Valid()` check before JSONB merge, returns 400 on invalid JSON instead of storing corrupt data.

### Changed
- `capabilities/intrinsic.go`: `ResolveIntrinsic()` takes `[]models.CapabilityOverride` as third parameter. `applyOverrides()` flips bool fields and sets int fields.
- `handlers/completion.go`: `HealthRecorder` interface, `recordHealth()` called after every `ChatCompletion` call.
- `handlers/stream_loop.go`: `streamWithToolLoop` and `streamModelResponse` accept `configID` + `HealthRecorder` params. Health recorded on error, timeout, and success paths.
- `store/interfaces.go`: `CapOverrides CapabilityOverrideStore` added to `Stores` struct.
- `models/models.go`: `ProviderStatus`, `ProviderHealthWindow`, `ProviderHealthSummary`, `CapabilityOverride` types.
- `main.go`: Health accumulator lifecycle (start/stop/prune), admin route registration, startup log includes health status.
- `panels.js`: Simplified to single `_active` panel in workspace secondary pane. Removed `_dualMode`, `_splitRatio`, `_secondary`.
- `surfaces.js`: Layout declarations (`primary`, `secondary`, `secondaryOpts`) in surface registration.
- `editor-mode.js`: Layout declarations for editor surface.
- `index.html`: Workspace container structure, removed dual-view split button.
- `styles.css`: Workspace pane CSS grid, handle, responsive rules.
- `app.js`: `_initWorkspaceResize()` replaces `_initSidePanelResize()` + `_initDualDivider()`.
- `ui-settings.js`: Zoom selector targets updated class names.

### Database
- Migration 013 (Postgres) / 012 (SQLite): `provider_health` table (hourly bucketed metrics), `capability_overrides` table (admin corrections).

## [0.21.6] — 2026-03-01

### Added
- **Editor surface: document features merged in.** Article mode (outline, export, word count, focus mode) folded into the single Editor surface rather than shipping as a separate surface. One surface, two concerns: code files get tabs + tree, text files get word count + export. Chat panel on the right with show/hide toggle.
- **New file button** — Visible in both the file tree header (+ icon) and the editor toolbar ("+ New"). Creates files with smart defaults (markdown files get `# Title` scaffold).
- **Export dropdown** — Download file, Export as HTML (via marked.js + DOMPurify), Copy to clipboard. Available from editor toolbar for any open file.
- **Chat panel toggle** — Show/hide the right-side AI chat pane. Editor left pane expands to fill when chat is hidden. Toggle button in toolbar with active state indicator.
- **Word count + reading time** in status bar for text files (markdown, txt, rst, adoc). ~230 wpm calculation.
- **Save indicator** in status bar — shows "● Modified" with warning color for unsaved files.
- **Git status indicators in editor file tree** (closing v0.21.5 deferred) — Tree rows show M/A/U badges with color coding (modified=yellow, added=green, untracked=italic gray, deleted=red strikethrough).
- **Auto-save on tab/mode switch in editor surface** (closing v0.21.5 deferred) — Modified files auto-save when switching tabs or deactivating the editor surface.
- **Ctrl+Shift+F workspace search** (closing v0.21.5 deferred) — Maps to Quick Open file finder.
- **`SEED_PROVIDERS` env var** — Auto-creates global providers on startup from CSV format `provider:api_key[:name]`. Supports openai, anthropic, openrouter, venice, mistral, groq, together, fireworks, deepseek, perplexity with auto-filled endpoints. Idempotent. Blocked in production.
- **Mode selector labels** — Surface buttons in sidebar show icon + label text ("Chat", "Editor"). Labels hide when sidebar collapsed.
- **Hash Router (`router.js`)** — URL-driven direct-to-surface navigation:
  - `#/chat` → default chat view
  - `#/chat/ch_abc123` → open specific chat (bookmarkable)
  - `#/editor` → editor surface (auto-picks workspace from project, or shows picker)
  - `#/editor/ws_abc123` → editor with specific workspace
  Browser back/forward works. Replaces `sessionStorage` chat restore. Workspace picker overlay when navigating without a workspace. Auto-selects if only one exists.
- **`openDirect(wsId)`** method on EditorMode — bypasses `check()` flow, registers surface directly with a given workspace ID. Used by Router for hash-based navigation.

### Fixed
- **`chat.switched` / `chat.created` events never emitted** — `selectChat()`, `newChat()`, and `sendMessage()` now emit these events. This was blocking EditorMode from detecting workspace bindings on channel switch.
- **`workspace_id` missing from channel responses** — Added to all SELECT/RETURNING/Scan paths for both SQLite and Postgres. Frontend chat objects now include `workspace_id`.
- **EditorMode.check() optimization** — Checks local `App.chats` data before API calls.
- **`defaultEndpoints` redeclared** — Renamed to `seedDefaultEndpoints` in `seed_providers.go` to avoid collision with `live_provider_test.go`.

### Removed
- **Article surface (`article-mode.js`, `article-mode.css`)** — Merged into Editor. Document features (export, word count, focus mode) are now part of the unified Editor surface. Article-specific AI tools (suggest_outline, expand_section) deferred to v0.22+ as editor extensions.

### Changed
- `editor-mode.js`: Rebuilt header with New/Export/ChatToggle buttons. File tree has header with + button. Chat panel show/hide. Word count + save indicator in status bar. `_createNewFile()`, `_exportActiveFile()`, `_toggleChat()` methods. `openDirect()` for Router.
- `editor-mode.css`: Header labels, export dropdown, chat toggle, file tree header, save indicator, word count styles.
- `chat.js`: Event emissions, `workspace_id` in chat objects, hash sync via `Router.update()`.
- `app.js`: Router initialization replaces sessionStorage restore.
- `projects-ui.js`: Project list mapping includes `workspace_id`.
- `channels.go`: `workspace_id` in all query/scan paths.
- `surfaces.js`: Updated comment (removed article reference).
- `styles.css`: Mode selector layout, router picker overlay.
- `index.html`: Removed `article-mode.css`/`article-mode.js`, added `router.js`.
- `config.go`: `SeedProviders` field.
- `main.go`: `SeedProviders()` call.
- `seed_providers.go`: `seedDefaultEndpoints` (renamed from `defaultEndpoints`).
- `ROADMAP.md`: Article→Editor merge documented. Go template pages added under v0.25.0.

## [0.21.5] — 2026-03-01

### Added
- **Editor Surface (Development Mode).** IDE-like experience built as a v0.21.3 surface consuming workspace primitives. Activates automatically when a channel or project has a bound workspace. Three-pane layout: file tree (sidebar), code editor (CM6), and AI chat panel with resizable split.
- **`editor-mode.js`** — Full editor surface module. File tree backed by `workspace_ls` API with nested directory expansion, file icons by extension, and context menus. Tab bar with modified indicators, close buttons, and multi-file editing. CM6 integration with language auto-detection (20+ languages) and textarea fallback when CM6 bundle unavailable.
- **Split pane layout** with draggable resize handle. Editor (left) and AI chat (right) share the main region. Chat DOM borrowed from Surfaces saved fragments — real conversation history and input preserved across mode switches.
- **Quick Open** (`Ctrl/Cmd+P`): Fuzzy file finder overlay using workspace file index. `Ctrl/Cmd+S` save, `Ctrl/Cmd+W` close tab.
- **Live editor updates**: When AI tool calls modify workspace files (`workspace_write`, `workspace_patch`), the server emits `workspace.file.changed` via WebSocket. If the file is open and unmodified in the editor, content refreshes automatically.
- **Status bar**: Current file path, language mode, git branch display.
- **`editor-mode.css`**: Complete styling for all editor components with dark theme support, mobile responsive (stacked layout at ≤768px).
- **Workspace API methods** on frontend `API` module: `getWorkspace`, `listWorkspaceFiles`, `readWorkspaceFile`, `writeWorkspaceFile`, `deleteWorkspaceFile`, `mkdirWorkspace`, `getWorkspaceGitStatus`, `getWorkspaceGitBranches`.
- **`Surfaces.getSavedFragment()` / `putSavedFragment()`** — Cross-surface DOM sharing. Allows the editor surface to borrow chat DOM into its split pane without cloning.
- **`workspace.file.` event routing** added to server event route table (`DirToClient`).
- **Workspace management UI**: Create, list, and bind workspaces from the frontend. Project settings panel gains a Workspace section with dropdown picker and "New" button. Chat context menu gains "Set workspace…" option for per-channel override. Both paths support creating new workspaces inline.
- **`GET /workspaces`** — List all workspaces accessible to the current user (user-owned + team-owned).
- **`workspace_id` in channel API**: `updateChannelRequest` accepts `workspace_id` for binding/unbinding. `channelResponse` and both list/get queries now include `workspace_id`.

### Changed
- `stream_loop.go`: Emits `workspace.file.changed` event after successful `workspace_write`/`workspace_patch` tool calls.
- `index.html`: Added `editor-mode.css` stylesheet and `editor-mode.js` script.
- `channels.go`: `channelResponse` includes `workspace_id`, list/get queries select it, update accepts it.
- `workspaces.go`: Added `List` handler for `GET /workspaces`.
- `main.go`: Added `GET /workspaces` route.
- `projects-ui.js`: Workspace section in project panel, workspace picker in chat context menu.
- `styles.css`: Workspace row layout in project panel.

## [0.21.4] — 2026-03-01

### Added
- **Git Integration.** Workspaces can now track a git remote. Operations use `os/exec` calling the `git` binary (no CGO, no Go git libraries). Credentials are vault-encrypted (AES-256-GCM) using the same pattern as BYOK provider configs.
- **`git_credentials` table.** Postgres + SQLite migrations. Stores encrypted PAT tokens, HTTP basic auth, and SSH private keys. Columns: id, user_id, name, auth_type, encrypted_data, nonce, created_at.
- **Workspace git columns.** `git_remote_url`, `git_branch`, `git_credential_id`, `git_last_sync` added to `workspaces` table via migration.
- **`workspace.GitOps`** — exec-based git operations: Clone, Pull, Push, Status, Diff, Commit, Log, BranchList, Checkout. Credential injection via temporary `GIT_ASKPASS` scripts (PAT), `.git-credentials` files (basic auth), or `GIT_SSH_COMMAND` (SSH keys). Temp files securely wiped after each operation.
- **Post-operation hooks.** Clone, pull, and checkout automatically trigger workspace reconcile (filesystem → DB sync) and re-index changed files through the v0.21.2 indexing pipeline.
- **5 git tools** for LLM use: `git_status`, `git_diff`, `git_commit`, `git_log`, `git_branch`. Filtered out when no workspace is bound. Each validates git remote configuration before execution.
- **9 git API endpoints:** `POST clone`, `POST pull`, `POST push`, `GET status`, `GET diff`, `POST commit`, `GET log`, `GET branches`, `POST checkout` — all under `/api/v1/workspaces/:id/git/`.
- **3 credential endpoints:** `POST /api/v1/git-credentials` (create), `GET /api/v1/git-credentials` (list user-scoped summaries), `DELETE /api/v1/git-credentials/:id` (delete). Encrypted data never exposed in API responses.
- **`GitCredentialStore` interface** + Postgres/SQLite implementations. Create, GetByID, ListByUser, Delete with user-scoped authorization.
- **Security.** `file://` and local path URLs rejected in clone. Error output sanitized to strip credential-containing lines. `GIT_TERMINAL_PROMPT=0` set on all operations to prevent interactive prompts.

### Changed
- `WorkspaceStore` interface: added `SetGitLastSync()` method + both implementations.
- `models.Workspace`: added git tracking fields. `models.WorkspacePatch`: added git patch fields.
- Completion handler: git tools disabled alongside workspace tools when no workspace bound.
- `store.Stores` struct: added `GitCredentials` field.
- Postgres/SQLite `NewStores()`: wires `GitCredentialStore`.

## [0.21.3] — 2026-03-01

### Added
- **Surface Registry (`surfaces.js`).** Mode-switching system that allows extensions to register "surfaces" (UI modes) that take over named regions of the interface. DOM nodes are detached and preserved in `DocumentFragment`s during mode switches — not destroyed — ensuring CM6 editor instances and other stateful components survive transitions.
- **Surface region attributes.** Four `data-surface-region` attributes on `index.html` containers: `surface-header` (model bar), `surface-main` (chat messages), `surface-footer` (input area), `sidebar-content` (search + chat history). Extensions swap these regions via `ctx.ui.replace()` / `ctx.ui.restore()`.
- **Mode selector.** Appears in the sidebar (`#modeSelectorWrap`) when ≥1 extension surface is registered beyond the default chat. Shows icon buttons for each mode with active state highlighting.
- **Extension context API.** `ctx.surfaces.register()`, `ctx.surfaces.unregister()`, `ctx.surfaces.activate()`, `ctx.surfaces.getCurrent()` on the scoped extension context. `ctx.ui.replace()` and `ctx.ui.restore()` for region DOM management.
- **Surface events.** `surface.activated`, `surface.deactivated`, `surface.registered`, `surface.unregistered` on the EventBus (all `localOnly`).
- **REPL console (`repl.js`).** Fourth tab in the debug modal (Ctrl+Shift+L). `AsyncFunction` wrapper for top-level `await`. Injected globals: `API`, `Events`, `Extensions`, `Surfaces`, `DebugLog`, `Panels`, `UI`, plus `$()`, `$$()`, `sleep()` helpers. Features: collapsible JSON pretty-printing, red errors with expandable stack traces, command history via ↑/↓ (persisted to sessionStorage), tab-completion on live object graphs and EventBus labels, multi-line input (Shift+Enter), admin-gated (admin role OR `?debug=1` URL param).
- **CSS.** Mode selector styles (`.mode-selector`, `.mode-btn`, active/hover states). REPL styles (output formatting, value-type color coding, collapsible JSON, input prompt).

### Changed
- `extensions.js` context builder: added `ctx.surfaces` namespace and `ctx.ui.replace()` / `ctx.ui.restore()` methods.
- `app.js` startup: initializes `Surfaces.init()` before `Extensions.loadAll()`, and `REPL.init()` after auth confirmation.
- `index.html`: added `data-surface-region` attributes, mode selector container, REPL tab in debug modal, script tags for `surfaces.js` and `repl.js`.
- `EXTENSIONS.md` §6: updated with implementation details, actual API signatures, and event catalog.

## [0.21.2] — 2026-03-01

### Added
- **Workspace Indexing Pipeline.** Background text chunking and embedding for workspace files, reusing the existing `knowledge.SplitText` chunker and `knowledge.Embedder` for vector generation. Indexable file types include 40+ source code extensions (Go, Rust, Python, TypeScript, Perl, etc.) plus all `text/*` MIME types. Code-aware chunking uses 1500-character chunks with function/class/type boundary separators (`\n\nfunc `, `\n\nfn `, `\n\nclass `, `\n\ndef `, `\n\ntype `, `\n\nsub `, `\n\npub `, `\n\nimpl `), falling back to paragraph and line breaks.
- **Content-addressed skip.** SHA256 computed during `WriteFile` is compared against the prior hash before triggering re-indexing. Unchanged files skip the chunk + embed pipeline entirely, making archive extracts and bulk writes efficient.
- **`workspace_chunks` table.** New table (Postgres + SQLite migrations) storing text chunks with vector embeddings per workspace file. Postgres uses sequential scan with pgvector `<=>` cosine operator (no IVFFlat/HNSW index — 3072-dim embeddings exceed the 2000-dim index limit; sequential scan is adequate at workspace scale). SQLite uses app-level cosine in Go. Columns: workspace_id, file_id, chunk_index, content, token_count, embedding, metadata (JSONB with line_start, language).
- **`workspace_files` additions.** `index_status` column (`pending`, `indexing`, `ready`, `error`, `skipped`) and `chunk_count` for tracking indexing progress per file. `workspaces` table gains `indexing_enabled` boolean (default true).
- **`WorkspaceStore` chunk methods.** Four new store interface methods implemented for both Postgres and SQLite: `InsertChunks` (batch insert with pgvector cast or JSON text), `DeleteChunksByFile`, `SimilaritySearch` (pgvector `<=>` operator or app-level cosine), `UpdateFileIndexStatus`.
- **`workspace.Indexer`.** Background indexing orchestrator with configurable concurrency semaphore. Single-file indexing (`IndexFile`) triggered on `WriteFile` when content changes. Batch indexing (`IndexBatch`) triggered after `ExtractArchive`. Async goroutines with 5-minute timeout per file. Embedding usage tracked via `Embedder.LogUsage`.
- **`workspace_search` tool.** Semantic search across workspace files via natural language query. Embeds the query, runs cosine similarity against workspace chunks, returns file paths, content snippets, relevance scores, and approximate line numbers. Optional `file_pattern` glob filtering (e.g. `*.go`, `src/*.ts`) applied post-search. Top-K capped at 20.
- **Indexer integration.** `workspace.FS.SetIndexer()` hooks the indexer into the write path. `WriteFile` triggers single-file indexing on content change. `ExtractArchive` triggers batch indexing for all extracted files. Both use the workspace owner's identity for embedding provider resolution.
- **Configuration.** `WORKSPACE_INDEXING_ENABLED` env var (global kill switch, default true). `WORKSPACE_INDEX_CONCURRENCY` env var (default 2). Per-workspace `indexing_enabled` toggle via `WorkspacePatch`. Indexing gracefully degrades when no embedding role is configured.
- **Index status endpoint.** `GET /api/v1/workspaces/:id/index-status` returns aggregated indexing progress: per-status file counts (pending, indexing, ready, error, skipped), total chunk count, and workspace indexing_enabled flag.

### Changed
- `workspace_files` scanner updated in both Postgres and SQLite stores to include `index_status` and `chunk_count` columns.
- `workspaces` scanner updated to include `indexing_enabled` column.
- `WorkspacePatch` model extended with `IndexingEnabled` field.
- `WorkspaceToolNames()` now includes `workspace_search` in the disabled set when no workspace is bound.
- `RegisterWorkspaceSearchTool()` added for late registration of the search tool (requires embedder).

## [0.21.1] — 2026-03-01

### Added
- **Workspace Tools.** Six AI-callable tools in the `workspace` category: `workspace_ls` (list files with content type/size), `workspace_read` (text content with 50KB soft limit, binary detection), `workspace_write` (create/overwrite with auto-mkdir), `workspace_rm` (delete with recursive guard), `workspace_mv` (move/rename with auto-mkdir), `workspace_patch` (find/replace operations, each match must be unique, 1MB file limit). Late registration via `RegisterWorkspaceTools()` after stores + FS init. `WorkspaceToolNames()` returns the full list for filtering.
- **Channel Workspace Binding.** `channels.workspace_id` nullable FK column (Postgres + SQLite migration 010/009). Channels with a bound workspace get workspace tools auto-injected into the completion tool set.
- **Project Workspace Binding.** `projects.workspace_id` nullable FK column. Projects can bind a workspace shared by all channels in the project.
- **Workspace Resolution Chain.** `ChannelStore.ResolveWorkspaceID()` resolves the effective workspace for a channel: channel `workspace_id` takes priority, falling back to the project's `workspace_id` via `project_channels` join. Both Postgres and SQLite implementations. Returns empty string when no workspace is bound.
- **Tool Injection in Completion Handler.** `buildToolDefs()` accepts `workspaceID` parameter; when empty, all workspace tool names are added to the disabled set. `ExecutionContext.WorkspaceID` field carries the resolved workspace ID through tool execution and streaming.
- **Workspace resolution wired into streaming.** Both `CompletionHandler` and `streamWithToolLoop` resolve the workspace ID from the channel and pass it through to tool execution contexts.

### Deferred
- Frontend workspace UI (channel settings toggle, project Files tab, chat bar workspace indicator) deferred to a future release.
- Archive-to-workspace upload flow (detect archive MIME + workspace binding → extract to workspace) deferred.

## [0.21.0] — 2026-03-01

### Added
- **Workspace Storage Primitive.** Platform-level file storage bound to users, projects, channels, or teams via polymorphic owner model. Dual-layer architecture: PVC filesystem (source of truth) with DB metadata index (queryable cache). Workspaces support configurable quotas, status lifecycle (active/archived/deleting), and owner-based authorization inheritance.
- **Workspace data model.** Two new tables: `workspaces` (polymorphic owner_type/owner_id, root_path, max_bytes quota, status) and `workspace_files` (path, MIME type, size, sha256, is_directory). Unique index on (workspace_id, path) enables upsert-on-conflict for file metadata sync. Migrations for both Postgres and SQLite.
- **`workspace.FS` package.** Filesystem operations layer with security-first design: atomic writes (temp file + rename with SHA256 computed via tee reader), path traversal guards (cleanPath normalization + absPath containment validation), symlink rejection on write targets. Operations: ReadFile, WriteFile, DeleteFile (with recursive guard), Mkdir, Stat, ListDir, Tree, Reconcile (filesystem→DB drift sync).
- **Archive operations.** Extract zip and tar.gz archives into workspaces with bomb protection: 10K file limit, 100MB single file cap, workspace quota enforcement during extraction. Common-prefix stripping handles GitHub-style `project-name/` wrapper directories. CreateArchive packages workspaces into downloadable zip or tar.gz.
- **Content type detection.** Extension-based MIME detection covering 40+ source code types (Go, Rust, Python, TypeScript, etc.) with `http.DetectContentType` sniffing fallback for unknown extensions.
- **`WorkspaceStore` interface.** Full CRUD for workspaces, file index operations (upsert, delete, delete-by-prefix, get, list with recursive/non-recursive modes), ownership lookup (GetByOwner, ListByOwner), and aggregate stats. Postgres and SQLite implementations.
- **Workspace API.** 15 new endpoints under `/api/v1/workspaces`: workspace CRUD (create, get, update, delete), file operations (list, read, write, delete, mkdir), archive management (upload with extraction, download), reconcile (FS→DB sync), stats. Owner-based authorization: user workspaces require self, channel workspaces require channel owner, project workspaces require project member, team workspaces require team member.
- **Unit tests.** Path cleaning (13 cases including traversal, dotfiles, whitespace), absPath traversal detection, MIME detection (14 extensions), unsafe path filtering (7 cases), common prefix detection (5 cases), write/read round-trip with mock store, delete verification, mkdir with index sync.

## [0.20.0] — 2026-03-01

### Added
- **Notifications Core (Phase 1)**: Persistent, user-targeted notification infrastructure with real-time WebSocket delivery. `notifications` table (Postgres + SQLite), `NotificationStore` with paginated queries, `Service.Notify()` / `NotifyMany()` for centralized creation and dispatch. Five API endpoints: list (paginated, filterable), unread count, mark read, mark all read, delete. Bell icon in header bar with unread count badge (capped at 9+). Notification dropdown (latest 10, grouped, click-to-navigate via `resource_type`/`resource_id`). Full notification panel registered with `PanelRegistry`. WebSocket push via `notification.new` event with toast for high-priority types (`kb.error`, `role.fallback`). Background cleanup goroutine (configurable retention, default 90 days). Initial sources: `role.fallback` (via EventBus subscription), `kb.ready`/`kb.error` (knowledge base processing), `grant.changed` (group membership).
- **@mention Parsing + Multi-model Routing (Phase 2)**: Channels support multiple AI models with @mention-based routing. `mentions.Parse()` extracts @mentions from message content, resolves against channel model roster (case-insensitive, longest-match-first, trailing punctuation tolerant). Completion handler fans out sequentially to mentioned model(s), producing one assistant response per target. Without @mention, default channel model responds (fully backward compatible). Channel model CRUD: 4 new endpoints for add/remove/update/list. Frontend: model pills in chat header, @mention autocomplete (CM6 `mentionCompletion` extension with roster-backed suggestions), model attribution labels on multi-model responses. Message `model_display` field for human-readable attribution. SSE streaming tagged with model info per response.
- **Email Transport + Notification Preferences (Phase 3)**: SMTP email delivery via `EmailTransport` supporting implicit TLS (port 465) and STARTTLS (port 587). Multipart MIME messages (HTML + plaintext) with branded templates using Go `html/template`. `notification_preferences` table with three-tier resolution: specific type → user wildcard `*` → system default (in_app=true, email=false). Three preference API endpoints (list, set with partial patch, delete). Admin SMTP configuration in settings (host, port, user, password, from address, TLS mode) with test email endpoint. User notification preferences UI in Settings → Notifications tab with per-type in-app/email checkboxes. Async email delivery (goroutine with 30s timeout, failures logged non-blocking).
- **Gin Release Mode**: Backend now automatically sets `gin.SetMode(gin.ReleaseMode)` when `ENVIRONMENT=production`, suppressing per-request access logs for health checks and reducing log noise. `GIN_MODE` env var also added to K8s backend deployment as explicit override.

### Fixed
- **Model preferences 500 on every page load**: `GET /api/v1/models/preferences` returned HTTP 500 due to `NULL` values in `user_model_settings.hidden` and `sort_order` columns failing Go `sql.Scan()` into non-pointer types. Root cause: the `Set` upsert passed `NULL` for unset patch fields, bypassing `DEFAULT false`/`DEFAULT 0` on INSERT. Fixed with `COALESCE` in both SELECT (read path) and INSERT VALUES (write path) for Postgres and SQLite. Includes data-fix SQL for existing NULL rows.

## [0.19.2] — 2026-02-28

### Added
- **Project Persona Default**: Bind a persona to a project via the detail panel. All chats in the project inherit the persona's model, parameters, and system prompt as a fallback when no explicit preset is selected. Resolution chain: explicit request → project persona → none.
- **Project Archive Toggle**: Archive/unarchive projects from the detail panel. Archived projects hidden from sidebar by default with "Show archived (N)" toggle. Dimmed visual treatment when shown.
- **Channel Reorder**: Right-click a chat within a project for "Move up" / "Move down" options. Server-persisted positions via `project_channels.position`, loaded on startup, maintained across moves.

## [0.19.1] — 2026-02-28

### Added
- **Active Project**: Pin a project as active; new chats auto-assign to it. Persists across reloads via localStorage. Visual indicator (📌 + accent border) in sidebar.
- **Project System Prompt**: Per-project instructions stored in `projects.settings` JSONB. Injected between persona/channel prompt and KB hint during completion. Merge semantics on update (preserves other settings keys).
- **Project Detail Panel**: Side panel (via PanelRegistry) for managing project system prompt, KB bindings, and notes. Accessible from project ⋯ menu → "Project settings".
- Enriched `ListKBs` and `ListNotes` responses with JOIN-sourced `name`/`title` fields for display in the project panel.

### Changed
- `ProjectPatch` model now accepts `settings` field for partial settings merge.
- Project context menu expanded: "Pin as active", "Project settings", plus existing rename/color/delete.

## [0.19.0] — 2026-02-28

### Added
- **Projects / Workspaces.** Organizational containers that group related
  conversations, knowledge bases, and notes into a single workspace.
  Projects provide a scope-aware organizational layer above individual
  channels, with support for personal, team, and global visibility.
- **Project data model.** Four new tables: `projects` (with scope, owner,
  team, color, icon, settings JSONB), `project_channels` (ordered
  membership with UNIQUE constraint), `project_knowledge_bases` (with
  auto_search flag), and `project_notes`. Channels gain a denormalized
  `project_id` FK for efficient filtering.
- **Project API.** 17 new endpoints under `/api/v1/projects`: full CRUD,
  channel add/remove/list/reorder, KB add/remove/list, note
  add/remove/list. Access checks enforce owner-or-team-member visibility
  with owner-only delete.
- **Project KB resolution.** Knowledge bases bound to a project are
  automatically available to all channels within that project. Resolution
  chain extended: Persona KBs → **Project KBs** → Channel KBs →
  Personal KBs. Both `BuildKBHint` (system prompt injection) and
  `kbsearch` (tool-time retrieval) updated.
- **Note auto-association.** Notes created from a channel that belongs to
  a project are automatically added to that project's note collection.
- **Sidebar project groups.** Projects appear as collapsible groups in the
  sidebar above the existing time-based "Recent" section. Each group shows
  a color dot, chat count, and an options menu (⋯) for rename, color
  picker, and delete.
- **Drag-and-drop.** Drag chat items between project groups or back to
  Recent. Visual feedback with outline and background highlight on valid
  drop targets.
- **Right-click context menu.** Right-click any chat to move it to a
  project, remove it from its current project, or create a new project
  and assign in one step.
- **New Project button.** Added to the New Chat split-button dropdown for
  quick project creation.
- **Channel project_id filter.** `GET /api/v1/channels` accepts
  `?project_id=<uuid>` to filter by project, or `?project_id=none` for
  unassigned channels. Response includes `project_id` field.

### Changed
- `renderChatList()` rewritten to support project grouping while
  preserving the original time-based layout when no projects exist.
  Chat items now include `draggable`, `oncontextmenu`, and drag event
  handlers.
- Channel response struct, SELECT queries, and scan calls updated across
  `ListChannels`, `GetChannel`, and `CreateChannel` (both Postgres and
  SQLite paths) to include `project_id`.
- `resource_grants` CHECK constraint extended to include `'project'` as a
  valid resource type.
- `db-validate.sh` updated: former "Dropped tables" assertions for
  `projects` and `project_channels` replaced with positive checks for all
  four project tables plus `channels.project_id` column check.
- Test helper truncation list updated with project junction tables.

### Fixed
- **JSON corruption defense** (hotfix carry-forward from 0.18.2):
  `SafeJSON` wrapper with `scanJSON` and `scanTags` hardened helpers that
  replace bare `json.RawMessage` / `pq.Array` scanning. Prevents
  `encoding/json: invalid character` panics from NULL or empty columns.
- **Service worker** (hotfix carry-forward): `chrome-extension://` URL
  filtering to avoid opaque response cache errors.

## [0.18.1] — 2026-02-28

### Added
- **Side panel architecture.** Complete rewrite of the side panel system
  from shared-tab layout to independent single-slot panels. Any action
  (preview, notes, diagram pop-out) fills the slot, replacing whatever
  was there — no tabs, no association between panel types.
- **Panel registry** (`PanelRegistry`): named panels with independent
  open/close state, scroll/state preservation across switches, keyboard
  shortcuts (Ctrl+\ cycle, Ctrl+Shift+\ toggle dual-view).
- **Dual-view mode**: two panels side-by-side via CSS grid with a
  drag-adjustable split ratio (0.2–0.8 range, 6px divider handle).
  Toggle button in header actions area alongside fullscreen and close.
- **Live HTML preview**: streaming responses update the preview iframe
  in real-time (500ms debounce). Extracts the last fenced HTML block
  from partial content and renders it as the response streams in.
- **Extension pop-out**: `⧉` button on rendered extension blocks
  (mermaid, KaTeX, etc.) clones the content into the preview iframe
  and opens the side panel.
- **Extension UI primitives** (`ctx.ui`): seven methods exposed to
  browser extensions through the scoped extension context:
  - `toast(msg, type)` — toast notifications via `UI.toast()`
  - `openPreview(html)` — load HTML into side panel preview iframe
  - `isDark()` — theme detection without DOM sniffing
  - `isMobile()` — viewport width check (≤768px)
  - `isPanelOpen()` — side panel container visibility
  - `confirm(msg, opts)` — modal confirm dialog (Promise\<boolean>)
  - `createMenu(anchor, opts)` — popup menu (unchanged from stub)
- **Mermaid context-aware expand**: single `⛶` button replaces the
  previous two-button (pop-out + fullscreen) approach. When the side
  panel is open, expand pops the diagram into it; when closed, expand
  goes fullscreen.
- **Mermaid fullscreen close button**: 40px circular close button
  overlaid top-right in fullscreen mode, 48px on mobile. Fixes the
  previous Escape-key-only exit which was unusable on touch devices.
- **Mobile side panel**: swipe navigation between panels (80px
  threshold, 1.5× horizontal-to-vertical ratio), tap-to-close overlay,
  responsive auto-collapse of dual mode on narrow viewports, enlarged
  touch targets for all panel controls.
- **Side panel header label**: simple text label showing the active
  panel name, replacing the previous tab bar UI.

### Changed
- Side panel model changed from tabbed (Preview + Notes tabs visible
  simultaneously) to single-slot (one panel fills the space, actions
  replace it). No user-selectable tabs — content is entirely
  action-driven.
- Dual-view toggle button moved from tab bar (dynamically injected)
  to static header actions area next to fullscreen and close.
- Mermaid extension refactored to use `ctx.ui` primitives exclusively.
  Zero direct references to `UI.*`, `PanelRegistry.*`, or DOM class
  sniffing for theme detection. Extension remains fully self-contained
  in `extensions/builtin/mermaid-renderer/`.
- Mermaid source copy uses `ctx.ui.toast()` instead of inline button
  text swap. Theme detection uses `ctx.ui.isDark()` instead of manual
  `document.body.classList.contains('dark-theme')` check.
- Side panel outer resize minimum bumped to 480px in dual mode (vs
  280px single).

### Removed
- Tab bar UI (`.side-panel-tabs`, `.side-panel-tab`, tab rendering
  logic). Panels no longer have user-selectable tabs.
- Separate pop-out and fullscreen buttons in mermaid toolbar (collapsed
  into single context-aware expand button).

## [0.18.0] — 2026-02-28

### Added
- **Memory system.** Long-term memory across conversations with scope-aware
  isolation. Three memory scopes: `user` (personal facts/preferences),
  `persona` (shared across all users of a Persona), and `persona_user`
  (per-user within a Persona context, e.g. tutoring progress per student).
  Memories persist across channels and are injected into the system prompt
  at completion time with scope priority (persona_user > persona > user).
- **Memory tools.** Two new LLM-callable tools:
  - `memory_save` — LLM explicitly stores a fact with key/value/confidence.
    Scope-aware: saves to the active scope for the current Persona context.
  - `memory_recall` — LLM queries stored facts relevant to current context.
    Merges results from applicable scopes with semantic search via embeddings
    and keyword fallback.
- **Automatic memory extraction.** Background scanner finds conversations
  with sufficient new activity, sends them to the utility model role for
  fact extraction, and stores results as `pending_review` memories.
  Configurable extraction prompt per Persona (e.g. "extract FAQ-worthy
  Q&A pairs"). Scanner runs on a configurable interval with concurrency
  control. Global kill switch via admin settings.
- **Memory review pipeline.** Extracted memories start in `pending_review`
  status. Admin panel shows pending review queue with bulk approve. Users
  can approve/reject/edit their own pending memories from the Settings →
  Memory tab.
- **User memory management.** Settings → Memory tab shows active/pending
  memory counts, filterable/searchable memory list with inline edit and
  delete. Status filter (active, pending_review, archived). Approve All
  button for batch operations on pending memories.
- **Admin memory controls.** Admin panel → Memory section shows system-wide
  pending review queue. Memory extraction toggle in admin Settings panel
  (`memory_extraction_enabled`). Bulk approve endpoint for batch review.
- **Hybrid semantic recall.** Memory recall supports both keyword search
  and vector similarity (cosine distance). Postgres uses `<=>` operator
  with pgvector; SQLite computes cosine similarity in Go with app-level
  vector loading. Results are merged and deduplicated.
- **Memory injection at completion time.** `BuildMemoryHint()` loads
  relevant memories and formats them as a system prompt section, injected
  after the knowledge base hint. Context-budget aware with configurable
  character limit. Supports embedding-based relevance filtering when the
  user's latest message is available.
- **Persona memory configuration.** Personas gain `memory_enabled` (bool)
  and `memory_extraction_prompt` (text) fields. When memory is enabled on
  a Persona, conversations with that Persona contribute to persona-scoped
  memory extraction. Custom extraction prompts allow specialization
  (e.g. helpdesk FAQ extraction vs. tutoring progress tracking).
- **Database migrations.** Four new migration files (Postgres + SQLite):
  - `004_v0180_memories.sql` / `sqlite/003_v0180_memories.sql` — `memories`
    table with composite unique index, full-text search index (GIN/keyword),
    `memory_extraction_log` tracking table.
  - `005_v0180_memory_phase2.sql` / `sqlite/004_v0180_memory_phase2.sql` —
    Persona memory columns, extraction log unique constraint.
- **API endpoints.** Eight new authenticated endpoints:
  - `GET /memories` — list user's memories (filterable by status/query)
  - `GET /memories/count` — active + pending counts
  - `PUT /memories/:id` — edit a memory's key/value/confidence
  - `DELETE /memories/:id` — delete a memory
  - `POST /memories/:id/approve` — approve a pending memory
  - `POST /memories/:id/reject` — reject (archive) a pending memory
  - `GET /admin/memories/pending` — admin pending review queue
  - `POST /admin/memories/bulk-approve` — admin bulk approve

### Changed
- `CompletionHandler` now accepts an `*knowledge.Embedder` parameter for
  memory injection with semantic relevance filtering.
- Persona create/update forms include memory configuration fields
  (enabled toggle, extraction prompt textarea).
- Admin settings save handler includes `memory_extraction` and
  `memory_extraction_enabled` keys.
- `store.Stores` struct includes `Memories MemoryStore` field.
- Admin panel sections include Memory with pending review loader.

### Technical Notes
- Memory embeddings use `vector(3072)` matching the existing KB/notes
  schema. HNSW index is not used (pgvector limits HNSW to 2000 dims);
  filtered sequential scans are performant for per-user memory tables.
  IVFFlat or dimension reduction available as future optimizations.
- SQLite hybrid recall loads embeddings into Go and computes cosine
  similarity at the application level, reusing the existing
  `cosineSimilarity()` function from the knowledge base store.
- Memory tools use late registration (like KB search and note tools)
  because they require stores and embedder dependencies initialized
  in main.go.

## [0.17.3] — 2026-02-28

### Added
- **Wikilink bi-directional linking.** Notes support `[[Title]]` and
  `[[Title|display text]]` syntax. Links are extracted on save via regex,
  resolved to target note IDs by case-insensitive title match, and stored
  in a `note_links` junction table. Dangling links (references to notes
  that don't exist yet) are preserved and automatically resolved when a
  matching note is created later.
- **Transclusion embeds.** `![[Title]]` syntax renders embedded note content
  inline in read mode. Content is fetched asynchronously with a recursion
  guard (max depth 1 — nested transclusions render as plain text references).
  Transclusion links are visually distinct in both the editor (border-left,
  italic) and the graph (dashed edges).
- **Backlinks panel.** Read mode shows a collapsible "Linked mentions" panel
  below the note content listing all notes that link to the current note.
  Each backlink is clickable to navigate directly. Count badge updates on
  every note open.
- **Knowledge graph visualization.** Canvas-based force-directed graph with
  no external dependencies (~480 lines). Physics: O(n²) Coulomb repulsion,
  Hooke spring attraction on edges, center gravity. Interaction: pan, zoom
  (0.15–4.0x toward cursor), drag nodes, hover highlights node + neighbors.
  Click opens note editor. Nodes sized by √(link_count), colored by folder
  path (10-color palette). Ghost nodes for unresolved `[[links]]` with
  toggle button. Energy-based pause (stops rAF when total kinetic energy
  falls below threshold). ResizeObserver for responsive canvas sizing.
- **CM6 note editor.** New `CM.noteEditor()` factory function with live
  markdown preview (heading sizes for h1–h3, blockquote styling, fenced code
  block decorations), `[[wikilink]]` autocomplete triggered by `[[` with
  async title search, and clickable wikilink chip rendering. Falls back to
  plain `<textarea>` if CM6 bundle is unavailable.
- **Wikilink autocomplete.** Typing `[[` in the note editor triggers an
  async completion popup backed by `GET /notes/search-titles?q=`. Results
  are fetched via `ILIKE` title match, limited to 8 suggestions. Selecting
  a result inserts the full `[[Title]]` syntax.
- **Daily notes.** "Today" button in the notes toolbar creates or opens a
  daily note titled `Daily — YYYY-MM-DD` in the `/daily/` folder with a
  starter template (Tasks + Notes sections). Idempotent — repeated clicks
  navigate to the existing daily note.
- **Save-to-note from chat.** "Note" button in message action bar captures
  the full message content (or the current text selection within that message)
  into a new note with pre-filled title extracted from the first line.
  Provenance is tracked via `source_message_id` column on the notes table,
  enabling future jump-to-source navigation.
- **API endpoints.** Three new authenticated endpoints:
  - `GET /notes/search-titles?q=&limit=` — lightweight title search for
    autocomplete (returns `[{id, title}]`).
  - `GET /notes/:id/backlinks` — notes linking to the specified note, with
    display text and metadata.
  - `GET /notes/graph` — full graph topology: nodes (with inbound/outbound
    link counts), resolved edges, and unresolved (dangling) link titles.
- **`note_links` table.** New junction table with `source_note_id`,
  `target_note_id` (nullable for dangling links), `target_title`,
  `display_text`, `is_transclusion`, and `created_at`. Primary key on
  `(source_note_id, target_title)`. Index on `target_note_id` for backlink
  queries. Migrations for both Postgres and SQLite.
- **`source_message_id` column.** New nullable UUID column on `notes` table
  for chat-to-note provenance tracking.
- **Wikilink extraction package.** Standalone `notelinks/` package with
  regex-based parser handling `[[Title]]`, `[[Title|Display]]`,
  `![[Title]]`, and `![[Title|Display]]`. Deduplicates by lowercase title,
  preserves first occurrence. 10 unit tests covering edge cases.

### Changed
- Notes editor replaces `<textarea>` with CM6 `noteEditor` instance. The
  container `#noteEditorContentContainer` is lazily initialized on first
  edit; destroyed on panel close to avoid stale state.
- Note create and update handlers now extract wikilinks from content and
  call `ReplaceLinks()` (transactional DELETE + batch INSERT). Create also
  calls `ResolveByTitle()` to fix dangling links from other notes.
- `showNotesList()` destroys the CM6 editor instance and hides graph view.
- `saveNote()` and `deleteNote()` call `invalidateNoteGraph()` to clear
  the cached graph data.
- Read mode renders `[[wikilinks]]` as styled clickable chips via post-
  processing of the formatted HTML. Clicking navigates to the linked note
  or offers to create it if not found.
- `ARCHITECTURE.md` updated with `notelinks/` package, `note-graph.js`,
  `CM.noteEditor()` factory, and expanded notes description covering the
  linking model.
- `ROADMAP.md` updated with full v0.17.3 section including all checklist
  items.
- CM6 bundle entrypoint (`index.mjs`) exports `noteEditor` alongside
  existing `chatInput` and `codeEditor`.
- `theme.mjs` adds `noteEditorTheme` with full-height layout (min 200px,
  max 60vh), heading size decorations, and blockquote styling.

## [0.17.2] — 2026-02-28

### Added
- **CodeMirror 6 integration.** Rich editor infrastructure compiled at Docker
  build time via esbuild (IIFE bundle, ~295KB min / ~90KB gzip). Two factory
  functions exposed on `window.CM`:
  - `CM.chatInput()` — Markdown-mode editor for the chat input with
    auto-growing height, Enter=send / Shift+Enter=newline, spell check, and
    WYSIWYG fenced code block decorations (visual container with monospace
    font and accent border, matching claude.ai UX).
  - `CM.codeEditor()` — Full-featured code editor for admin extension panel
    with line numbers, bracket matching, search/replace, fold gutter, and 10
    bundled language modes (Markdown, JavaScript, JSON, SQL, HTML, CSS, YAML,
    Go, Python, Rust).
- **Graceful degradation.** All CM6 integration points check `window.CM`
  availability. If the bundle fails to load, the app falls back to native
  `<textarea>` with zero breakage.
- **Dark/Light/System theme toggle.** New appearance setting with three
  modes. Light theme overrides all CSS variables via `[data-theme="light"]`
  selector. System mode tracks `prefers-color-scheme` media query in real
  time. Theme changes emit `theme.changed` on the EventBus; CM6 editors
  toggle `oneDark` syntax theme via compartment reconfiguration.
- **Vim/Emacs keybinding preference.** Editor keybinding mode (Standard /
  Vim / Emacs) configurable in appearance settings. Applies to code editors
  and extension editors only — chat input always uses standard keybindings.
  Live-switchable on already-open editors via `keymap.changed` event.
  Bundled statically (~40KB for both modes).
- **Inline code shortcut.** Ctrl/Cmd+E wraps selection in backticks or
  inserts an empty inline code pair with cursor between them.
- **Code block shortcut.** Typing ` ``` ` at the start of a line expands
  to a fenced code block with cursor positioned inside. The decoration plugin
  renders code blocks with a styled visual container in the chat input.
- **CI path-based change detection.** New `detect-changes` job classifies
  changed files into frontend/backend/infra/docs buckets. Test jobs skip
  when irrelevant (FE-only changes skip Go tests, docs-only changes skip
  all tests and deploy). Tags always run the full pipeline.

### Changed
- Docker build pipeline: both `Dockerfile.frontend` and unified `Dockerfile`
  now include a `cm6-build` stage (Node 20 Alpine → esbuild → IIFE bundle).
- `ChatInput` abstraction in `chat.js` replaces direct textarea access
  across 7 callsites (`chat.js`, `tokens.js`, `attachments.js`).
- Extension editor in `admin-handlers.js` uses `CM.codeEditor()` with JSON
  and JavaScript modes, replacing bare `<textarea>` with manual Tab handler.
- Service worker excludes `/vendor/codemirror/` from cache (version-busted).
- Debug state snapshot includes CM6 version and language list.
- `ARCHITECTURE.md` updated to v0.17 reflecting CM6 integration, SQLite
  dual-driver, modular frontend file structure, and theme system.
- CI pipeline header updated to v0.17.2 with path gating documentation.
- `build-editor.sh` falls back to `npm install` if `package-lock.json`
  is missing (belt-and-suspenders for local dev).

### Fixed
- **App initialization crash.** `Events.publish` (nonexistent) → `Events.emit`
  (correct API). The unhandled exception during `initAppearance()` killed
  `initListeners()`, leaving the entire app half-initialized — settings modal
  unclosable, keyboard shortcuts unwired, paste handlers missing.
- **Cursor invisible in dark and light mode.** CM6 cursor used `--accent`
  color (low contrast on both themes). Switched to `--text` for consistent
  visibility. Added explicit `borderLeftWidth: 2px`.
- **Placeholder text offset.** Double padding between `.cm-editor` wrapper
  (12px) and `.cm-content` (8px) pushed placeholder 20px below expected
  position. Zeroed `.cm-content` padding — wrapper is the single source of
  truth.

## [0.17.1] — 2026-02-27

### Added
- **SQLite backend.** Full dual-driver database layer — set `DB_DRIVER=sqlite`
  to run with an embedded SQLite database. Pure Go (no CGO), zero external
  dependencies. 19 store files covering all domain stores: channels, messages,
  users, teams, personas, knowledge bases, notes, usage, audit, extensions,
  and more. Feature parity with Postgres including knowledge base vector
  search via app-level cosine similarity computed in Go.
- **Dialect-aware test infrastructure.** `database.SetupTestDB()` detects
  `DB_DRIVER` and provisions either a Postgres test database or a SQLite
  temp file. Exported `database.PH(n)` returns `$N` or `?` per dialect.
  `database.TruncateAll()` uses `TRUNCATE CASCADE` on Postgres and
  `DELETE FROM` with `PRAGMA foreign_keys` toggling on SQLite.
  `dialectSQL()` helper in handler tests converts `$N` placeholders and
  strips `::jsonb` casts at runtime.
- **SQLite CI pipeline.** New `test-sqlite` job runs the full handler
  integration test suite and store tests against an embedded SQLite
  database. Parallel with the existing Postgres test job. Build gate
  verifies `CGO_ENABLED=0` compilation.
- **Generic provider test config.** Live provider integration tests now
  read `PROVIDER`, `PROVIDER_KEY`, and `PROVIDER_URL` environment
  variables instead of hardcoded `VENICE_API_KEY`. Legacy fallback
  preserved. Model selection prefers non-reasoning models to avoid
  thinking budget requirements with low `max_tokens`.

### Changed
- `kb_chunks` table in SQLite schema includes `embedding TEXT` column
  for JSON-encoded float64 vectors (previously omitted as feature-gated).
- `SimilaritySearch` on SQLite loads candidate chunks, decodes JSON
  embeddings, and computes cosine distance in Go — replacing the
  previous "not available" error.
- `InsertChunks` on SQLite now stores embedding vectors as JSON text.
- Live test names genericized: `TestLive_Venice*` → `TestLive_*`.
- CI `build-and-deploy` depends on `[test, test-frontend, test-sqlite]`.

### Fixed
- **SQLite `RETURNING` + `time.Time` scan failure.** The modernc/sqlite
  driver cannot scan `datetime('now')` TEXT columns into `time.Time` via
  `RETURNING`. All 13 SQLite store `Create` methods rewritten to set
  timestamps in Go (`time.Now().UTC()`) and use `ExecContext` instead of
  `QueryRowContext(...).Scan()`. Format: `2006-01-02 15:04:05` (`timeFmt`
  constant in `helpers.go`).
- **SQLite missing `id` in INSERTs.** Unlike Postgres (`DEFAULT
  gen_random_uuid()`), SQLite `TEXT PRIMARY KEY` columns have no
  auto-generation. Added `store.NewID()` / `uuid.New()` to: `usage_log`,
  `model_pricing` (both upsert paths), `team_members` (AddMember),
  `group_members` (AddMember), `refresh_tokens` (CreateRefreshToken).
- **Test seed helpers SQLite-aware.** `SeedTestUser`, `SeedTestChannel`,
  `SeedTestTeam`, `SeedTestTeamMember`, `SeedTestGroup`, `SeedGroupMember`
  now branch on `IsSQLite()` to provide application-generated UUIDs
  instead of relying on `RETURNING id`.
- **Postgres-isms in SQLite stores.** `extension.go` Update used `now()`
  → `datetime('now')`; `ListForUser` COALESCE used `true` → `1`.
- SQLite store bool/int type mismatches: `persona.go` auto-fetch flag,
  `usage.go` exclude-BYOK filter, `user_settings.go` visibility map
  values — all corrected from `0`/`1` to `false`/`true`.

## [0.17.0] — 2026-02-27

### Added
- **Persona-KB binding.** Personas can now have knowledge bases directly
  bound to them. When a user selects a persona with bound KBs, the
  `kb_search` tool is automatically scoped to those KBs and the persona's
  system prompt includes a KB listing hint. Admin and team admin preset
  forms include a KB picker with per-KB auto-search toggles.
  Migration adds `persona_knowledge_bases` join table.
- **Enterprise KB mode.** New `discoverable` flag on knowledge bases
  controls whether users can see and attach KBs directly. When
  `kb_direct_access` platform policy is set to `true`, users cannot
  attach KBs to channels directly — they access KBs exclusively through
  persona bindings curated by admins. New `ListDiscoverableKBs` and
  `SetDiscoverable` endpoints.
- **Role fallback alerts (issue #69).** When a role's primary provider
  fails and the fallback activates, the resolver emits a `role.fallback`
  event on the EventBus with audit log entry. Admin users see a persistent
  dismissable banner. 5-minute per-role cooldown prevents flooding.
- **Chat rename.** Double-click any chat title in the sidebar to edit
  inline. Enter saves, Escape cancels.
- **Utility model auto-naming.** After the first assistant response,
  a background request to the utility role generates a concise title.
  Falls back to truncation when no utility model is configured.
  New endpoint: `POST /channels/:id/generate-title`.
- **Chat token count.** Conversation token estimate shown in the model
  bar, color-coded against context budget.
- **State restore on refresh.** Active chat ID persisted to
  `sessionStorage`, auto-restored on page reload.

### Fixed
- **ResolvePreset group access bypass.** `ResolvePreset()` used raw SQL
  that skipped `resource_grants` checks from v0.16.0. Now uses
  `PersonaStore.UserCanAccess()`.
- **KB create scope authorization.** Now enforces admin role for global
  KBs and team admin role for team KBs.
- **Completion handler persona ID scoping.** `personaID` variable scoped
  inside an `if` block but referenced downstream. Fixed by threading
  through function signatures.
- **Nil slice JSON marshaling.** `ListDiscoverableKBs` returns `[]`
  instead of `null` when no KBs match.

### Changed
- `UpdateDocumentStorageKey` moved from raw `ExecContext` to store method.
- EventBus route table: `role.fallback` → `DirToClient`.
- Resolver gains `.WithBus(bus)` builder (nil-safe, backwards compatible).
- Service worker: `/extensions/` excluded from fetch handler.
- Mermaid renderer v2.0 (pan/zoom, export) promoted to builtin.
- Removed DIAG diagnostics from `TestGroupBasedPersonaAccess`.
- Paste-to-file threshold synced from backend `PublicSettings`
  (`storage.paste_to_file_chars`, admin-configurable, default 2000).
  Resolves hardcoded constant in `attachments.js`.
- Embedding dropdown already had tolerant type filter, manual model ID
  fallback, and auto-switch on empty — confirmed complete, checkbox updated.

## [0.16.0] — 2026-02-27

### Added
- **User groups.** Global and team-scoped groups decoupled from team
  membership. `groups` and `group_members` tables. Admin and team admin
  CRUD for group management. Groups serve as ACL targets for resources.
- **Resource grants.** Three-way grant model (`team_only`, `global`,
  `groups`) for Personas and Knowledge Bases. `resource_grants` table
  with `grant_scope` and `granted_groups UUID[]` columns. Grant picker
  UI on Persona and KB forms with group multi-select.
- **Schema consolidation.** 9 incremental migrations collapsed into
  single `001_v016_schema.sql`. Fresh installs use the consolidated
  file; upgrade path preserved via migration version tracking.

### Changed
- Persona and KB list queries now filter through `resource_grants` for
  non-admin users. Team-only remains the default scope.
- Admin panel shows group membership counts and grant summaries.

## [0.15.1] — 2026-02-26

### Added
- **`attachment_recall` tool.** Two operations: `list` returns filenames
  and metadata for the current channel's attachments; `read` extracts
  and returns content by attachment ID. Channel-scoped access control.
- **`conversation_search` tool.** Full-text search across the current
  channel's message history using PostgreSQL `plainto_tsquery`. Returns
  matching messages with timestamps and role context.
- **Token estimator attachment awareness.** `Tokens.estimateAttachments()`
  accounts for staged file sizes in context budget calculations. Warning
  thresholds include attachment estimates.

## [0.15.0] — 2026-02-26

### Added
- **Background compaction scanner.** Periodic scan identifies channels
  exceeding configurable context thresholds. Automatic summarization via
  utility role compresses old messages into summary nodes. Per-channel
  opt-in/out via `auto_compact` channel setting.
- **Compaction service.** `compaction.Service` orchestrates summary
  generation: estimates token usage, selects messages for compression,
  calls utility role, inserts summary as tree boundary node with metadata.
- **Context budget guard rail.** 80% ceiling prevents compaction from
  triggering mid-generation. Cooldown timer prevents repeated compaction
  of the same channel.
- **Summarize & Continue button.** User-triggered compaction from the
  context warning bar. Reuses compaction service with immediate execution.

### Changed
- Scanner configurable via `global_settings`: threshold percentage,
  cooldown duration, enabled/disabled toggle. Channel-level overrides.

## [0.14.0] — 2026-02-26

### Added
- **Knowledge bases.** RAG pipeline: upload documents → chunk (recursive
  text splitter) → embed via pgvector → `kb_search` tool for semantic
  retrieval. Team and personal KB scopes. Channel KB toggle enables
  per-conversation knowledge access.
- **Document ingestion.** `knowledge.Ingest()` pipeline: file upload →
  text extraction (reuses v0.12.0 pipeline) → chunking with configurable
  overlap → embedding via the embedding role → storage as `kb_chunks`
  with vector index.
- **KB admin panel.** Knowledge Bases section under AI category. Create,
  delete, upload documents, view chunk counts and storage usage. Team
  admin scoped to team KBs.
- **Notes semantic search.** Note search upgraded from exact text match
  to pgvector cosine similarity when embeddings are available.
- **`kb_search` tool.** Registered when embedding role is configured.
  Accepts query text, returns top-K chunks with source document
  attribution. Channel KB bindings control which KBs are searched.

### Changed
- pgvector extension enabled in schema (`CREATE EXTENSION IF NOT EXISTS
  vector`). `kb_chunks` table includes `embedding vector(1536)` column
  with IVFFlat index.

## [0.13.1] — 2026-02-26

### Added
- **`web_search` tool.** Search provider abstraction with two backends:
  DuckDuckGo (HTML scraping, zero config) and SearXNG (self-hosted,
  JSON API). Returns title, URL, and snippet for each result. Configured
  via `SEARCH_PROVIDER` and `SEARXNG_URL` env vars.
- **`url_fetch` tool.** Fetches and extracts text content from URLs.
  Respects robots.txt. Content-type detection with HTML-to-text
  conversion. Configurable timeout and size limits.
- **Tool categories.** Tools now have a `category` field (builtin,
  search, knowledge, browser). Tools toggle UI in chat bar groups
  by category with per-tool enable/disable.
- **Tools toggle UI.** Popup menu on chat input toolbar showing all
  available tools. Per-tool checkbox state sent as `disabled_tools[]`
  in completion requests. Browser extension tools included.

## [0.13.0] — 2026-02-25

### Changed
- **Admin panel refactor.** Replaced 12-tab modal with fullscreen admin
  panel. Four categories (People, AI, System, Monitoring) with section
  sidebar navigation. URL-based routing (`#admin/people/users`).
  Responsive layout, classification banner-aware positioning.
- **CSS design token cleanup.** Consolidated duplicate color/spacing
  variables. Admin panel uses shared token system with main UI.

## [0.12.0] — 2026-02-25

### Added
- **File handling and vision support.** Upload images, PDFs, and documents
  into chat via 📎 button, drag-and-drop, or paste. Multimodal message
  assembly injects base64 images for vision-capable models and extracted
  text for documents. Staged attachment strip shows upload progress and
  extraction status. Auth-aware blob rendering with Bearer tokens. Image
  lightbox viewer. Vision capability hints on image attachments.
- **Storage backend abstraction.** `ObjectStore` interface with two
  implementations: PVC (local filesystem) for single-node/dev and S3
  (minio-go) for multi-node production. S3 backend works with any
  S3-compatible API: MinIO, Ceph RGW, AWS S3. Auto-detection when
  `STORAGE_BACKEND` is not set (tries PVC, disables if not writable).
  Both backends share identical semantics — all handlers, attachment
  CRUD, multimodal assembly, and orphan cleanup work regardless of
  backend.
- **S3 configuration.** `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`,
  `S3_SECRET_KEY`, `S3_REGION`, `S3_PREFIX`, `S3_FORCE_PATH_STYLE` env
  vars. Endpoint auto-detects SSL from scheme. Path-style URLs default
  on (required for MinIO/Ceph). Optional key prefix for shared buckets.
  CI pipeline syncs S3 secrets to K8s when `STORAGE_BACKEND=s3`.
- **Text extraction pipeline.** PDF, DOCX, XLSX, PPTX, ODT, RTF text
  extraction via filesystem-based queue. Inline and sidecar modes.
  Crash recovery for items stuck in processing state.
- **Admin storage panel.** Backend health, file count, total size,
  orphan detection and cleanup. Shows PVC path or S3 endpoint/bucket
  depending on active backend. Extraction queue status.
- **Vault CLI commands.** `switchboard vault rekey` re-encrypts all
  provider API keys when rotating the encryption key.
  `switchboard vault status` shows encryption health. Admin UI
  encryption status indicator in Settings tab.
- **Per-chat model persistence.** Server-side `channels.settings`
  JSONB field stores `last_selector_id`. Roams across devices with
  localStorage write-through cache as fallback.

## [0.11.0] — 2026-02-25

### Added
- **Browser extension system.** Full lifecycle: manifest registration, script
  injection, scoped context (`ctx.renderers`, `ctx.tools`, `ctx.events`,
  `ctx.storage`, `ctx.ui`), permission-aware API proxying. Extensions self-
  register via `Extensions.register()` and receive isolated contexts during
  `initAll()`. Admin CRUD endpoints, asset serving (public, no auth needed
  for `<script>` tag loading), auto-seeder for builtin extensions.
- **Custom renderer pipeline.** Block renderers intercept code fences by
  language tag, post renderers process the DOM after insertion. Case-
  insensitive language matching handles LLM capitalization (`Mermaid`,
  `Diff`, `CSV`). Nested ` ```markdown ``` ` fence unwrapping prevents the
  common LLM pattern of wrapping responses in markdown fences from breaking
  inner code blocks.
- **Browser tool bridge.** Extensions register tool handlers via
  `ctx.tools.handle()`. Server collects browser tool schemas alongside
  server tools, includes them in LLM completions. Tool calls route through
  WebSocket EventBus (`tool.call.*` → browser → `tool.result.*` → server).
  30-second timeout with error recovery.
- **Server tools: calculator and datetime.** Auto-register via `init()`,
  zero wiring changes. Calculator: recursive-descent evaluator with
  arithmetic, 17 math functions, constants. Datetime: current date/time/
  timezone/unix/ISO-week in any IANA timezone.
- **6 built-in browser extensions** (self-contained, own CSS via
  `_injectStyles()`/`destroy()` lifecycle):
  - **Mermaid**: block + post renderer, SVG diagrams, dark mode detection,
    local vendor with CDN fallback, max-height constraint with scroll
  - **KaTeX**: block renderer (` ```latex/math/tex ```) + post renderer
    (inline `$...$` and `$$...$$` in text nodes)
  - **CSV Table**: block renderer, RFC 4180 parser with quoted fields,
    sortable columns (click headers), numeric-aware sorting
  - **Diff Viewer**: block renderer, red/green syntax highlighting for
    unified diffs, stats badge (+N/-N), hunk/file headers
  - **JS Sandbox**: browser tool (`js_eval`), sandboxed iframe
    (`allow-scripts` only), console capture, 10s timeout
  - **Regex Tester**: browser tool (`regex_test`), multi-input matching,
    full match details with named groups and indices
- **Admin extension editor.** Edit button on each extension in Admin →
  Extensions. Inline editor shows name, description, manifest JSON, and
  script source with tab-key support. System extensions show overwrite
  warning.
- **Enhanced diagnostics.** Test 5: browser extensions (loaded, active,
  renderers, tool handlers). Test 6: Service Worker cache (registration,
  scope, state, cache names, entry counts). Purge Cache button in Debug
  Log modal footer.
- **Extension-owned styles.** All 4 rendering extensions (mermaid, katex,
  csv, diff) inject their own `<style>` tags during `init()` and remove
  them during `destroy()`. Global stylesheet only keeps `.ext-rendered`
  wrapper. User-created extensions can bring their own CSS without
  touching the global stylesheet.
- **Notes extension rendering.** `runExtensionPostRender()` called in both
  note read mode and preview mode. Mermaid diagrams and KaTeX math now
  render correctly in notes.
- **Database migration 006_extensions.sql.** `extensions` and
  `extension_user_settings` tables.

### Fixed
- **WebSocket token field mismatch.** API saved `{ accessToken: '...' }`
  but EventBus read `tokens.access`. Tool bridge was dead on arrival.
- **Extension asset 401.** Asset route was behind auth middleware, but
  `<script>` tags don't send Authorization headers. Moved to public group.
- **`runExtensionPostRender is not defined`.** Stale Service Worker cache
  served old `ui-format.js` without the function. Added `typeof` guard.
- **Extension init order.** Extensions loaded after `loadChats()` — block
  renderers not registered when messages first rendered. Moved extension
  loading before chat loading.
- **K8s Ingress WebSocket routing.** Traefik `pathType: Exact` doesn't
  reliably win over `Prefix` rules in the same Ingress resource. Changed
  `/ws` and `/health` to `Prefix` for longest-prefix-wins routing.
- **Mermaid SVG oversized.** Added `max-height: 600px` on diagram
  container with overflow scroll, removed mermaid.js hardcoded `height`
  attribute from SVGs so CSS constraints apply.

### Changed
- Extension CSS removed from global `styles.css`. Each extension owns its
  styles via `_injectStyles()` in `init()` with `destroy()` cleanup.
  Idempotent injection guards prevent duplicates.
- Markdown fence language extraction lowercased at the point of extraction
  in `ui-format.js`, making all block renderer pattern matches
  case-insensitive without per-extension workarounds.
- `_unwrapMarkdownFence()` pre-processor strips outer ` ```markdown ```  `
  wrappers when they contain nested fences. Handles think-block
  placeholders and trailing explanation text. Only triggers when nested
  fences are present — plain markdown code blocks still render normally.

## [0.10.5] — 2026-02-24

### Added
- **`ui-primitives.js` — shared rendering primitives and registries.**
  Single source of truth for provider types, role definitions, and reusable
  UI components. Extension-ready via registry pattern (`Providers.add()`,
  `Roles.add()`). Primitives follow the `renderPresetForm()` pattern:
  `(container, options) → control object` with `getValues/setValues/clear`.
  - `Providers` registry — types, labels, default endpoints (was 5 places → 1)
  - `Roles` registry — names, type filters, hints (was hardcoded in 2 places → 1)
  - `renderCapBadges(caps, opts)` — consolidates 3 badge builders (compact + detailed)
  - `renderProviderForm(container, opts)` — replaces 3 form implementations
  - `renderProviderList(container, opts)` — replaces 3 list renderers
  - `renderRoleConfig(container, opts)` — replaces 2 role UIs + 4 handlers
  - `renderUsageDashboard(container, opts)` — replaces 3 usage renderers

### Fixed
- **Admin provider form now auto-fills endpoint on type change.** Was missing
  from admin (worked in user BYOK and team forms). Now all three scopes use
  the same primitive with identical behavior.
- **Team provider form consolidated.** Was two separate HTML forms (create +
  edit) with separate listeners. Now a single dual-mode form matching the
  pattern used by admin and user BYOK scopes.

### Changed
- Provider type definitions removed from `index.html` (2 static `<select>`s)
  and `settings-handlers.js` (1 dynamic build + 2 endpoint maps). All now
  sourced from `Providers` registry in `ui-primitives.js`.
- Provider list rendering uses event delegation instead of inline `onclick`
  handlers. Each list returns `{ refresh, getCache }` control handles.
- Role configuration uses `data-role-*` attributes for event delegation
  instead of `id`-based selectors and global `onchange` handlers.
- Usage dashboards accept options for compact/full mode, custom API calls,
  and extension-provided extra columns.
- All 16 `confirm()` calls replaced with `showConfirm()` — styled modal
  dialog matching the app design instead of browser-native dialog. Supports
  `danger` styling, Escape/Enter keys, click-outside dismiss.
- Sidebar collapse icon now always visible (dimmed) next to the logo,
  brightens on hover. Previously the icon replaced the logo on hover only.
  When sidebar is collapsed, only the collapse icon shows (logo hidden).
- New `.popup-menu` + `.popup-menu-item` shared CSS base for all dropdown
  and flyout menus. `createPopupMenu(anchor, opts)` primitive available for
  future menu creation with consistent behavior.
- Removed ~360 lines of duplicated code across `admin-handlers.js`,
  `settings-handlers.js`, `ui-admin.js`, and `ui-settings.js`.
- Removed ~40 lines of static HTML form markup from `index.html`.

## [0.10.4] — 2026-02-24

### Added
- **`model_type` field across the full pipeline.** Models now carry a type
  classification (`chat`, `embedding`, `image`) sourced from provider APIs
  at sync time — no hardcoded lists. Venice's `/v1/models` returns `type`
  per model; OpenAI-compatible APIs pass through the field when present.
  - New DB column: `model_catalog.model_type VARCHAR(20) DEFAULT 'chat'`
  - Migration: `005_model_type.sql`
  - Propagation: `providers.Model.Type` → `CatalogSyncEntry.ModelType` →
    `CatalogEntry.ModelType` → `UserModel.ModelType` → frontend `model_type`

### Fixed
- **Admin role save didn't refresh UI.** `adminSaveRole()` showed "✓ Saved"
  but never called `UI.loadAdminRoles()`, so dropdowns appeared stale after
  save. Now reloads the roles panel after a successful save.
- **Role model dropdowns showed all models regardless of type.** Embedding
  role showed chat models, utility role showed embedding models. Both admin
  and user role UIs now filter the model dropdown by `model_type`:
  - "embedding" role → only `model_type === 'embedding'` models
  - "utility" role → only `model_type === 'chat'` models

### Changed
- Venice provider now reads the `type` field from each model in the API
  response and normalizes it (`text` → `chat`, `embedding` → `embedding`,
  `image` → `image`).
- OpenAI provider wire type extended with optional `type` field for
  OpenAI-compatible APIs that include it.

## [0.10.3] — 2026-02-24

### Changed
- **Frontend refactor: 2 monolith files → 13 domain-scoped files.** Split
  `ui.js` (2,582 lines) and `app.js` (2,940 lines) into 13 focused files
  averaging ~544 lines each. No features added, no functions renamed, no
  architectural changes. Vanilla JS, no modules, no build step.

  **New file structure:**

  | File | Lines | Domain |
  |------|------:|--------|
  | ui-format.js | 353 | Markdown rendering, `esc()`, code blocks, side panel |
  | ui-core.js | 974 | UI object: sidebar, chat list, messages, streaming, model selector |
  | ui-settings.js | 640 | Settings tabs, teams, providers, user preferences |
  | ui-admin.js | 645 | Admin tabs, users, roles, usage, teams |
  | tokens.js | 123 | Context tracking, token estimation |
  | notes.js | 364 | Notes panel, editor, multi-select |
  | chat.js | 584 | Chat ops, send, regen, edit, branch, summarize |
  | settings-handlers.js | 692 | Settings save, provider CRUD, command palette |
  | admin-handlers.js | 652 | Admin actions, presets, team management |
  | app.js | 567 | State, init, boot, auth, listener dispatch |

  Unchanged: `api.js` (575), `debug.js` (580), `events.js` (327).

- **`initListeners()` decomposed** into domain-specific init functions:
  `_initChatListeners()`, `_initSettingsListeners()`, `_initAdminListeners()`,
  `_initNotesListeners()`, `_initGlobalKeyboard()`. The orchestrator in `app.js`
  dispatches to each.
- **Side panel resize** changed from self-invoking IIFE to `_initSidePanelResize()`
  called during listener init, avoiding DOM timing issues.
- **Service worker** updated with new file list for pre-caching.
- **Policy-gating tests** updated to read from the correct source files after
  the split. All 159 tests pass.

## [0.10.2] — 2026-02-24

### Added
- **Summarize & Continue** — User-triggered conversation compaction using the
  utility model role. Button appears in context warning bar at ≥75% context
  usage. `POST /channels/:id/summarize` calls the utility role to generate a
  summary, inserts it as a tree node with `metadata.type = "summary"`, and
  updates the cursor. Subsequent completions use the summary as a context
  boundary — messages before it are replaced by the summary as a system message.
  Multiple summaries stack. Fork-aware (summaries are tree nodes with their own
  branch position). "Show full history" toggle reveals collapsed earlier messages.
- **BYOK Role Overrides** — Users with personal providers can override the org's
  utility and embedding model roles. Resolution chain: personal → team → global.
  New "Model Roles" tab in Settings (visible when BYOK is enabled). Stored in
  `user.settings` JSONB under `model_roles` key, same shape as team overrides.
- **Utility Rate Limiting** — `utility_rate_limit` global setting (default: 20
  calls/hour/user, 0 = unlimited). Org-funded utility calls check against
  `usage_log` before executing. BYOK calls exempt (user pays their own way).
  Returns 429 with clear message when exceeded.
- **Message metadata in path** — `PathMessage` now includes `metadata` JSONB
  field, enabling summary detection and future message-type extensibility.
- **Per-chat model/preset restore** — Switching between chats now restores the
  last-used model or preset in the selector. Stored in localStorage keyed by
  channel ID. Falls back to the channel's base model, then the global default,
  if the original selection is no longer available. Cleaned up on chat deletion.

### Changed
- **Generation role removed** — `RoleGeneration` ("generation") removed from
  `ValidRoles` and admin Roles tab. Image/media generation will be
  extension-managed (v0.11.0) with its own provider config, not a role slot.
  The current completion/embedding abstraction doesn't fit image gen's
  fundamentally different API surface.
- **Resolver.Complete/Embed signatures** — Now accept `userID` parameter for
  personal role override resolution. Empty string skips personal lookup.
- **Proactive token refresh** — Access token is now refreshed at 80% of its
  lifetime (~12min for 15min tokens). On page reload, a conservative 60s refresh
  is scheduled since token age is unknown. Eliminates the race condition where
  profile succeeds on a near-expired token but subsequent calls fail.
- **Auth guard in `startApp()`** — If token is invalidated during startup
  (e.g., 401 on loadChats after profile succeeded), app returns to login
  instead of rendering a broken UI with cascading 401 errors.
- **Per-chat model restore fallback** — When the stored model/preset is removed,
  falls back to admin default → first visible model (was keeping stale selection).
  Also cleans up the stale localStorage entry.
- **BYOK Role Overrides** — Fixed response parsing (`configs.configs` not
  `configs.data`) and model field mapping (`configId`/`baseModelId` not
  `provider_config_id`/`model_id`).
- **`GetRole` endpoint integration test** — `GET /admin/roles/:role` now
  exercised by CI via `TestIntegration_Roles_GetSingleRole`, catching the
  `GetConfig` signature mismatch that caused the build failure.
- **Auth resilience frontend tests** — 10 tests covering startup auth guard,
  token refresh lifecycle, and the profile-success-then-401 edge case.
- **JS syntax lint in CI** — `node --check` runs on all `src/js/*.js` files
  before frontend tests, catching parse errors that tests alone can't detect.

## [0.10.1] — 2026-02-24

### Added
- **Admin System Prompt** — Global system prompt configured in Admin › Settings.
  Injected as the first system message in every conversation, before user/preset
  system prompts. Users cannot override or disable it. Stored in
  `global_settings['system_prompt']`.
- **Personas tab** — User presets moved from the Models tab to their own
  "Personas" tab in Settings. Tab is hidden when admin disables user presets
  (`allow_user_personas` policy).
- **Team Management modal** — Team admin functionality extracted from Settings
  into its own tabbed modal (Members, Providers, Presets, Usage, Activity).
  Accessed via "Team Management" flyout menu item, or "Manage →" on team cards
  in Settings. Multi-team admins see a picker first; single-team admins go
  straight to the tabbed view. Back arrow in header returns to picker.
- **Preview pane: clear button** — Trash icon in the preview header resets the
  iframe and shows the empty hint. Also auto-clears when deleting a chat that
  had active preview content.
- **Preview/Notes pane: fullscreen** — Expand button in the header toggles
  fullscreen mode (panel fills entire viewport width).
- **Preview/Notes pane: resizable** — Drag the left edge of the side panel
  to resize (280px–70vw). Width resets on close.
- **Code block: download** — "Download" button on every code block. Infers
  file extension from language tag (e.g. `python` → `.py`, `go` → `.go`).

### Changed
- **Personal Usage scoped to BYOK** — `GET /usage` and the user Usage tab now
  only show consumption against personal (BYOK) providers. Global provider
  usage is the org's cost, not the user's. New `QueryByUserPersonal` store
  method filters on `scope = 'personal' AND owner_id = user_id`. Admin usage
  views remain unaffected. Usage tab hidden when admin disables user providers
  (`allow_user_byok` policy).
- **OpenAI streaming usage race** — Deferred the Done event until the usage
  chunk arrives. Previously, finish_reason fired Done before the usage chunk
  (which has `choices: []`), so streaming token counts were always 0 for all
  OpenAI-compatible providers.

### Fixed
- **Usage logging zero-token guard** — Removed early exit in `logUsage` that
  suppressed rows when tokens were 0. Combined with the streaming race above,
  this meant no streaming usage was ever recorded.
- **Live chat completion test** — `TestLive_VeniceChatCompletion` sent wrong
  field names (`messages`/`config_id` instead of `content`/`provider_config_id`).

## [0.10.0] — 2026-02-24

### Added
- **Model Roles** — Named model slots (`utility`, `embedding`, `generation`)
  with primary + fallback bindings. Stored in `global_settings['model_roles']`.
  Team-level overrides via `teams.settings` JSONB. New `server/roles/` package
  with `Resolver.Complete()` and `Resolver.Embed()` for automatic failover.
  Admin API: `GET/PUT /admin/roles/:role`, `POST /admin/roles/:role/test`.
  Team API: `GET/PUT/DELETE /teams/:id/roles/:role`.
- **Provider Embed() interface** — All providers implement `Embed()`. OpenAI,
  Venice, OpenRouter support `/v1/embeddings`; Anthropic returns `ErrNotSupported`.
  New types: `EmbeddingRequest`, `EmbeddingResponse`.
- **Usage Tracking** — Every completion (streaming and non-streaming) logs
  token counts and cost to `usage_log` table. Cost calculated at insert time
  from `model_pricing` (no retroactive recalculation). Provider scope
  denormalized for efficient admin filtering. Admin views exclude BYOK.
  New stores: `UsageStore`, `PricingStore`. Admin API:
  `GET /admin/usage`, `GET /admin/usage/users/:id`, `GET /admin/usage/teams/:id`.
  User API: `GET /usage` (personal summary).
- **Model Pricing** — `model_pricing` table with catalog sync and manual admin
  overrides. Sync from provider APIs (Venice, OpenRouter) auto-populates
  pricing with `source='catalog'`; manual entries never overwritten. Admin API:
  `GET/PUT/DELETE /admin/pricing`.
- **Streaming token capture** — OpenAI: `stream_options.include_usage` +
  parse usage/cache from final chunk. Anthropic: parse `message_start` for
  input/cache tokens, `message_delta` for output tokens. Cache token fields
  (`CacheCreationTokens`, `CacheReadTokens`) on `CompletionResponse`,
  `StreamEvent`, and `streamResult`.
- **Admin Roles tab** — Configure primary/fallback provider+model per role,
  test-fire from UI.
- **Admin Usage dashboard** — Period selector (7d/30d/90d), group-by
  (model/user/day/provider), totals cards, breakdown table, pricing table.
- **Team Usage dashboard** — Team admins can view usage against their
  team-owned providers via `GET /teams/:teamId/usage`. Filters to
  `provider_configs WHERE scope='team' AND owner_id=teamId`. Integrated
  into team management panel with period/group-by selectors.
- **Admin Reset Password** — Button in user list with vault destruction
  warning dialog (two-step confirmation).
- **User Usage tab** — Settings modal "Usage" tab shows personal token
  consumption and estimated costs across all conversations, including BYOK.
  Period selector (7d/30d/90d) and group-by (model/day).
- **Live Venice integration tests** — Gated behind `VENICE_API_KEY` env var.
  Tests: non-streaming completion, streaming completion, usage logging for
  both modes, pricing from catalog sync, and direct embeddings via BGE-M3.
  Uses `qwen3-4b` (Venice Small) — cheapest at $0.05/$0.15 per 1M tokens.
- **Migration 004** — `usage_log` table, `model_pricing` table, `model_roles`
  seed in `global_settings`.

### Fixed
- **UEK re-wrap on password change** — `ChangePassword` now decrypts UEK
  with old password and re-encrypts with new password + fresh salt. Previously,
  password changes silently broke all personal BYOK keys.
- **UEK destruction on admin password reset** — `ResetPassword` now nullifies
  vault columns, evicts UEK from cache, deletes personal provider configs,
  and logs an audit event. Previously, admin resets left orphaned encrypted
  UEK that silently broke personal keys.
- **Streaming completions logged zero tokens** — `StreamEvent` lacked token
  fields and providers discarded usage data from final chunks. Both OpenAI
  and Anthropic streaming parsers now capture and propagate token counts.
- **OpenAI streaming usage race condition** — The OpenAI protocol sends
  chunks in order: content → finish_reason → usage → [DONE]. The parser
  sent `Done=true` on the finish_reason chunk (step 2) before the usage
  chunk arrived (step 3, with `choices:[]`). The receiver returned
  immediately on Done, so usage was captured but never delivered.
  Fix: defer the Done event as `pendingFinish` until the usage chunk
  arrives, then flush with tokens attached. Tool-call finishes flush
  immediately (tool loop needs the event). Affects all OpenAI-compatible
  providers (OpenAI, Venice, OpenRouter).
- **Token accumulation across tool iterations** — `streamResult` in
  `stream_loop.go` now accumulates input/output/cache tokens across
  multi-tool-call iterations instead of only capturing the final iteration.
- **Admin pricing leaked BYOK entries** — `PricingStore.List()` returned
  all model_pricing rows including those from personal BYOK providers.
  Admin panel showed pricing entries for user-private providers they
  shouldn't see, with O(users × models) scale explosion. Now joins on
  `provider_configs` and filters `scope != 'personal'`. Admin `UpsertPricing`
  also validates provider scope, rejecting manual pricing on personal
  providers.
- **Team role handlers used wrong param name** — `ListTeamRoles`,
  `UpdateTeamRole`, `DeleteTeamRole` read `c.Param("id")` but routes use
  `:teamId`. Every team role operation silently got an empty team ID.
- **Usage logging suppressed for zero-token streams** — `logUsage` had an
  early exit when `inputTokens == 0 && outputTokens == 0`. Combined with the
  OpenAI streaming parser bug (below), this silently dropped all streaming
  usage rows. Removed the guard — requests are always recorded.
- **Live chat completion test used wrong field names** — `TestLive_VeniceChatCompletion`
  sent `messages` and `config_id` but the handler expects `content` and
  `provider_config_id`. Test silently skipped on the binding error.

## [0.9.4] — 2026-02-24

### Added
- **API key encryption (vault)** — Two-tier AES-256-GCM encryption for stored
  API keys. Global/team keys encrypted with env-var-derived key (HKDF-SHA256);
  personal BYOK keys encrypted with per-user encryption key (UEK) derived from
  password via Argon2id. Admin cannot recover personal keys without user's
  passphrase. New `server/crypto/` package: `vault.go`, `cache.go`,
  `resolver.go`, `backfill.go` with 11 round-trip tests.
- **UEK lifecycle** — UEK generated on registration, unwrapped on login
  (Argon2id → AES-GCM), cached in `sync.Map` for session duration, evicted
  with memory zeroing on logout. Pre-migration users auto-initialize vault
  on first login.
- **Migration 003_vault.sql** — Adds `encrypted_uek`, `uek_salt`, `uek_nonce`,
  `vault_set` to users table. Adds `api_key_enc` (BYTEA), `key_nonce`,
  `key_scope` to provider_configs. Backfills `key_scope` from existing scope.
- **Startup backfill** — `BackfillEncryptedKeys()` encrypts plaintext API keys
  on first startup with `ENCRYPTION_KEY` set. `EnforceEncryptionKey()` refuses
  startup if encrypted keys exist but env var is missing.
- **CI/CD: encryption secret** — `ENCRYPTION_KEY` Gitea secret synced to k8s
  `switchboard-encryption` secret. Backend manifest references it as optional
  `secretKeyRef`.

### Fixed
- **HTML code blocks render live in chat (XSS)** — When a model's `</think>`
  tag directly abutted a code fence (no newline), the fence wasn't recognized
  by marked.js, causing raw HTML to render as live DOM elements (canvas games,
  styles, etc.). Three-layer fix: (1) DOMPurify switched from permissive
  `ADD_TAGS` (default allows canvas, style, form, etc.) to strict
  `ALLOWED_TAGS` allowlist of only markdown-produced elements; (2) think-block
  placeholders padded with `\n\n` to ensure adjacent fences start on fresh
  lines; (3) unclosed code fences auto-closed before `marked.parse` for
  streaming protection.

## [0.9.3] — 2026-02-23

### Changed
- **Code blocks: button-driven collapse** — Replaced `<details>` wrapper with
  inline collapse/expand toggle button in the code toolbar. Auto-collapses at
  >15 lines with a fade mask; toggle available on all blocks >5 lines. User
  can always expand/collapse regardless of threshold. Smooth CSS transition
  instead of native `<details>` jump.
- **Thinking blocks: always visible** — Thinking blocks are always rendered in
  the DOM regardless of the `showThinking` setting. The setting now controls
  whether blocks start expanded (`<details open>`) or collapsed. User can
  always click to toggle. Setting label updated to "Auto-expand thinking blocks".

### Added
- **Admin default model** — New `default_model` policy in Admin → Settings.
  Dropdown populated from enabled models. When a user has no saved selection
  (fresh login, cleared browser) or their saved model is no longer available,
  the admin default is used before falling back to first visible. Resolution
  chain: `localStorage → admin default → first visible`.
- **Reasoning/thinking support for OpenAI-compatible providers** — Models that
  send `reasoning_content` in stream deltas (Grok, DeepSeek, etc.) now have
  thinking blocks streamed live and persisted as `<think>` tags. Renders as
  collapsible thinking blocks in both streaming and history views.
- **Favicon animation during generation** — Browser tab favicon pulses with
  cascading dot opacity animation while a completion is in progress, restoring
  to the static favicon when done.
- **Tool calls in message history** — Tool call metadata (name, arguments,
  result, error status) is now persisted alongside assistant messages in the
  database. History view renders tool calls as collapsed `<details>` blocks
  showing "🔧 tool_name → done" with expandable input/output JSON.
- **Notes tool: "View note" link** — When a notes tool (`note_create`,
  `note_update`, etc.) completes, a "📝 View note" button appears in both
  the live streaming tool indicator and the history tool call block. Clicking
  opens the Notes panel and navigates directly to the note.
- **Notes panel: Copy button** — "Copy" button added to note read mode,
  copies title + content as markdown to clipboard.
- **Brand hover: jitter-free crossfade** — Sidebar brand logo→collapse icon
  swap uses opacity crossfade instead of `display: none` toggle, eliminating
  layout reflow jitter on hover.

### Fixed
- **Model selector shows unavailable model** — Selector restoration searched
  all models including user-hidden ones. Saved selection (localStorage) for a
  hidden model like Claude Opus would re-select it on every page load even when
  only Grok was visible. Resolution now filters to visible models only.
- **Refresh toast shows wrong count** — "Loaded 33 models" counted all models
  including hidden; now shows only visible count ("Loaded 1 model").
- **Tool calls vanish after completion** — Live streaming tool indicators
  disappeared when `reloadActivePath()` rebuilt messages from DB. Fixed: the
  `getActivePath` query now includes `tool_calls` column, and `PathMessage`
  carries the data through to the frontend renderer.
- **Tool result "undefined results" text** — Operator precedence bug in tool
  result summary parser caused `undefined results` to display for note_create.
  Fixed summary logic to properly branch on title vs count.
- **Regenerate streams at wrong position** — Regen'd response appeared below
  the full conversation (appended at bottom) then snapped to correct position
  after completion. Fixed: display is now truncated to the parent message
  before streaming starts, so the new response streams in-place as a clean
  branch. Backend context was already correct (excludes old response).
- **Regenerate loses tools, reasoning, and tool execution** — Regen handler
  was a stripped-down copy of the completion handler missing tool definitions,
  tool execution loop, reasoning_content forwarding, and tool_calls persistence.
  Model lost access to note tools on regen and fell back to generic capabilities.
  Refactored: extracted `streamWithToolLoop()` into `stream_loop.go` as the
  single canonical streaming implementation. Both `streamCompletion` (normal
  chat) and `Regenerate` now call the shared function — only persistence
  differs. Future streaming features (new event types, tool capabilities)
  automatically apply to all code paths.
- **OpenRouter free models crash** — Free models with nil pricing caused
  `pq: invalid input syntax for type json` on catalog sync. Nil pricing now
  routes through `ToJSON()` producing `"{}"` instead of nil `[]byte`.
- **API key appears unsaved** — `ListGlobalConfigs` returned raw
  `ProviderConfig` structs where `APIKeyEnc` is `json:"-"` (never serialized),
  so `has_key` was always `undefined`. Now returns computed `has_key` field.
- **Case-insensitive usernames** — Login, registration, and all user lookups
  use `LOWER()` in SQL. All user creation paths normalize to lowercase.
  New migration `002_ci_username.sql` adds `LOWER()` unique indexes and
  normalizes existing rows.

## [0.9.2] — 2026-02-23

### Added
- **Collapsible code blocks**: Code blocks over 15 lines auto-collapse into
  `<details>` with language and line count summary. Language label shown in
  top-left corner of all code blocks.
- **HTML preview**: "Preview" button on HTML code blocks opens a sandboxed
  iframe (`allow-scripts`, no `allow-same-origin`). Auto-detected for
  untagged blocks via heuristic.
- **Token count estimate**: Live token counter below input area showing
  approximate tokens and context usage percentage when model has `max_context`.
- **Context length warning**: Dismissable banner above input at 75% (yellow)
  and 90% (red) context usage with guidance to start a new chat.
- **Proxy interception detection**: `_parseJSON()` checks Content-Type before
  `.json()` on all API paths including streaming. Typed `proxyBlocked` errors
  with proxy page title extraction and actionable splash messages.
- **Environment injection**: `window.__ENV__` wired through entrypoint, k8s,
  and index.html for dev/test/production gating.
- **Enhanced diagnostics**: `NET:PROXY` log type, Content-Type capture in
  fetch interceptor, environment info in export header and state snapshot.
- **Team admin audit scoping**: New `GET /api/v1/teams/:teamId/audit` and
  `/audit/actions` endpoints scoped to team members. Activity Log section
  in team manage panel with filter dropdown and pagination.
- **New favicon**: Switchboard panel design with provider-colored jacks.
  Animated SVG (rotating plugs), 32px PNG, 256px PNG, and ICO.
- **Seed users** (dev/test only): `SEED_USERS=user:pass:role,...` env var
  pre-creates active users on startup. Ignored in production. K8s secret
  `switchboard-seed-users` wired in backend manifest.

## [0.9.1] — 2026-02-23

### Removed
- **Static known model table**: Deleted `knownModels` map from backend and
  `KNOWN_MODELS` from frontend. The same model ID can have different capabilities
  depending on the provider (e.g. DeepSeek has tool_calling on OpenRouter but
  not on Venice). A hardcoded table can't represent this.
- **Frontend `lookupKnownCaps()`**: Removed client-side capability guessing.
  Backend is the sole source of truth via catalog → heuristic chain.

### Changed
- **Resolution chain simplified**: catalog (provider API sync) → heuristic inference.
  No intermediate known table. Providers that report capabilities via API are
  authoritative; heuristics are best-effort for unsynced models.
- **All providers updated**: OpenAI, OpenRouter, Anthropic, Venice now call
  `InferCapabilities()` directly instead of the dead known table lookup.
- **Frontend `resolveCapabilities()`**: Now passes through backend caps as-is.
  No client-side merging with a static table.
- [x] EXTENSIONS.md recovered into repo, updated with Appendix A (Custom
  Renderers) and Appendix B (Model Roles with utility/embedding/generation slots)
- [x] ROADMAP.md restructured: extension foundation pulled to v0.11.0,
  model roles to v0.10.0, dependency graph, TBD replaces post-1.0,
  removed v0.8→v0.9 migration (OBE — no public release, no test path)

### Added
- **Heuristic patterns**: Updated to detect qwen3, gpt-5, grok, kimi, minimax,
  glm-5, gemma-3 model families. Vision expanded to claude-opus/sonnet (not
  just claude-3). Reasoning expanded for thinking, grok, glm patterns.

### Fixed
- **Preset capability pills**: Presets with auto-resolve (no `provider_config_id`)
  now inherit base model capabilities via `GetByModelIDAny` catalog fallback.
- **Venice `optimizedForCode`**: Added mapping to `CodeOptimized` capability.
- **CI test stability**: BYOK journey tests use unreachable endpoints so auto-fetch
  doesn't race with simulated data injection.

## [0.9.0] — 2026-02-22

### Added
- **Schema consolidation**: 21 migrations collapsed to single `001_initial.sql`
- **Store layer**: All database access through typed interfaces (no raw SQL in handlers)
- **Persona model**: Trust-boundary model replacing old presets; scoped global/team/personal
- **Capabilities resolver**: Three-tier chain — catalog → known table → heuristic inference
- **Three-state model visibility**: enabled / disabled / team-only
- **BYOK auto-fetch**: Creating a personal provider triggers model discovery from provider API
- **User model refresh**: `POST /api-configs/:id/models/fetch` endpoint + UI button
- **Composite model IDs**: `configId:modelId` format prevents cross-provider collisions
- **Audit log foundation**: All admin operations logged with actor, action, resource
- **Journey integration tests**: API-driven test suite replacing fake-data tests
- **Frontend test suite**: 107 tests, 27 suites validating model processing pipeline
- **Live Venice API test**: Proves real BYOK → auto-fetch → models visible flow

### Fixed
- **API key storage**: `json:"-"` tag on `ProviderConfig.APIKeyEnc` silently dropped
  keys during admin create/update. Fixed with wrapper structs that bypass the tag.
- **NULL model_default scan**: `scanProviders()` crashed on NULL `model_default` column,
  silently hiding all team and BYOK models. Fixed with `sql.NullString`.
- **Nil slice serialization**: Go nil slices serialized as JSON `null` instead of `[]`,
  breaking frontend fallback chains. Fixed with `make([]T, 0)`.
- **Frontend error swallowing**: API responses with `errors` field were silently ignored.

### Changed
- Backward-compatible API routes with v0.8 field name aliases
- User model preferences table (`user_model_settings`)