Changeset 0.28.5 (#191)

This commit is contained in:
2026-03-14 22:51:50 +00:00
parent 85d5e3cc13
commit 6f0ad1355c
17 changed files with 2389 additions and 110 deletions

754
docs/DESIGN-0.28.5.md Normal file
View File

@@ -0,0 +1,754 @@
# DESIGN — v0.28.5: Frontend SDK + Pipe/Filter Pipeline
**Status:** Draft
**Scope:** `switchboard-sdk.js`, pipe/filter pipeline, extension compat shims, ICD `sdk` test tier
**Depends on:** v0.28.4 (security tier), v0.28.3 (frontend decomposition — `sb.js`, IIFE→module, `sb.call()`)
**Informs:** v0.28.7 (unified packaging — manifest `"pipes"` key), v0.29.0+ (Starlark pipeline stages)
**Current version:** 0.28.4
---
## Overview
The platform has 47 JS files, 15+ globals (`API`, `Events`, `UI`, `App`,
`Extensions`, `ChatPane`, `Theme`, etc.), and a partially-decomposed
frontend (Phase 4 ES modules, `sb.register()`/`sb.ns()` action registry).
Surface authors and extension authors must hunt through multiple files,
understand load order, and hold implicit coupling in their heads.
v0.28.5 delivers two things:
1. **`switchboard-sdk.js` (the SDK)** — a single-object composition layer
(`sw`) over existing globals. One import, one init call, coherent API.
Does not rewrite internals; wraps and delegates.
2. **Pipe/filter pipeline** — three composable, priority-ordered transform
stages that formalize the ad-hoc extension hooks into a real pipeline:
pre-send → post-receive (stream) → post-render. Extensions register
filters; the SDK wires them. Replaces `ctx.renderers.register()` and
the implicit hook points in `chat.js` / `ui-core.js` / `ui-format.js`.
---
## Design Principles
**Wrap, don't rewrite.** The SDK delegates to `API`, `Events`, `UI`, `App`,
`Theme`, `Extensions`, `ChatPane`. It does not duplicate their logic. If
`API.streamCompletion()` changes, the SDK's `sw.api.post()` still works
because it calls through. This means v0.28.5 doesn't risk destabilizing
the 531 passing CI tests.
**The pipeline is the extension model.** The existing six hooks in
DESIGN-SURFACES.md (Stream Processing, Post-Render, Tool Bridge, etc.)
are replaced by three pipe stages plus the existing tool bridge. The old
`ctx.renderers.register()` becomes a compat shim that registers into
`sw.pipe.render()`. New extensions use `sw.pipe.*` directly.
**Filters are pure transforms.** A filter receives a context object and
returns it (possibly modified). Side effects are allowed (logging, metrics)
but the contract is input→output. This makes filters testable in isolation.
**Priority ordering is explicit.** Every filter declares a numeric priority.
Lower runs first. Ties break by registration order. The SDK enforces this
and exposes introspection (`sw.pipe.list()`).
**Error isolation is mandatory.** One filter throwing must not break the
chain. The SDK catches, logs, records the failure in telemetry, and
continues with the next filter. A filter can explicitly halt the chain
by returning `null` (pre-send: message suppressed) or a `{ halt: true }`
signal.
---
## SDK API Reference
### Boot
```js
// Idempotent. Safe to call multiple times (returns same instance).
// Typically called by the surface boot script after DOMContentLoaded.
const sw = Switchboard.init({ mount: '#app' });
```
`init()` performs:
1. Token load (`API.loadTokens()`)
2. Profile resolution (`API.getProfile()`)
3. Theme init (`Theme.init()`)
4. EventBus connection (`Events.connect()`)
5. Pipeline engine init (empty filter chains)
6. UserMenu hydration (absorbs cs15 band-aid)
7. Emits `sw:ready` event on `document`
If already initialized, returns the existing `sw` instance. The `mount`
option is reserved for future component auto-mounting but is not used in
v0.28.5 (surfaces manage their own DOM).
### Identity
```js
sw.user // { id, username, display_name, email, role, avatar }
sw.isAdmin // boolean (shorthand for sw.user.role === 'admin')
```
Read-only. Resolved during `init()`. Updates on `profile.updated` event.
Delegates to: `API.user`, `API.isAdmin`.
### REST Client
```js
const channels = await sw.api.get('/api/v1/channels');
const created = await sw.api.post('/api/v1/channels', { title, model });
const updated = await sw.api.put('/api/v1/channels/123', { title });
const deleted = await sw.api.del('/api/v1/channels/123');
// Streaming (returns raw Response for SSE consumption)
const resp = await sw.api.stream('/api/v1/chat/completions', body, signal);
```
All methods auto-prefix `BASE_PATH`, inject auth headers, handle 401
refresh, and parse JSON. `sw.api.stream()` returns the raw `Response`
for SSE reading (same contract as `API.streamCompletion()` but generic).
Delegates to: `API._get()`, `API._post()`, `API._put()`, `API._delete()`,
`API._fetch()`.
### Events
```js
const unsub = sw.on('chat.message.*', (payload, meta) => { ... });
sw.once('workflow.completed', (payload) => { ... });
sw.emit('custom.event', { data: 42 });
sw.off('chat.message.*', handler);
```
Thin passthrough to `Events.on/once/off/emit`. No behavior change.
The SDK does not add any event namespacing — extensions already namespace
by convention (`ext.{id}.{event}`).
Delegates to: `Events`.
### Components
```js
// Drop-in ChatPane — creates the component in the container
const pane = sw.chat(document.getElementById('myChat'), {
channelId: 'abc123',
standalone: true,
});
pane.renderMessages(messages);
pane.destroy();
// Drop-in notes panel
const notes = sw.notes(document.getElementById('myNotes'), {
projectId: 'proj-1',
});
```
`sw.chat()` wraps `ChatPane.create()` with SDK-aware defaults (auto-wires
pipe filters into the pane's stream/render path). `sw.notes()` wraps the
notes panel initialization from `notes.js`.
Delegates to: `ChatPane.create()`, notes init functions.
### UI Primitives
```js
sw.toast('Saved', 'success'); // UI.toast()
const ok = await sw.confirm('Delete?'); // showConfirm()
sw.modal.open(contentEl); // openModal()
sw.modal.close(); // closeModal()
```
Delegates to: `UI.toast()`, `showConfirm()`, `openModal()`, `closeModal()`.
### Theme
```js
sw.theme.current // 'dark' | 'light' (resolved, never 'system')
sw.theme.mode // 'dark' | 'light' | 'system' (user preference)
sw.theme.set('dark'); // Theme.set()
sw.theme.on('change', (resolved) => { ... });
```
`sw.theme.on('change', fn)` subscribes to the `theme.changed` EventBus
event and fires with the resolved theme string.
Delegates to: `Theme.get()`, `Theme.resolved()`, `Theme.set()`, `Events.on('theme.changed', ...)`.
---
## Pipe/Filter Pipeline
### Architecture
```
User types message
┌─────────────────┐
│ PRE-SEND chain │ sw.pipe.pre(priority, fn)
│ filter₁ → ··· → filterₙ
└────────┬────────┘
│ context: { message, channel, attachments, model, persona, metadata }
API.streamCompletion()
▼ SSE chunks
┌─────────────────┐
│ STREAM chain │ sw.pipe.stream(priority, fn)
│ filter₁ → ··· → filterₙ (per chunk)
└────────┬────────┘
│ context: { chunk, accumulated, channel, model, tokens }
formatMessage() → DOMPurify
▼ rendered HTML
┌─────────────────┐
│ RENDER chain │ sw.pipe.render(priority, fn)
│ filter₁ → ··· → filterₙ
└────────┬────────┘
│ context: { element, message, channel }
DOM inserted
```
### Registration API
```js
// Pre-send: transform message before it hits the server
sw.pipe.pre(10, (ctx) => {
// Inject KB context
ctx.metadata.kb_chunks = getRelevantChunks(ctx.message);
return ctx;
});
// Pre-send with scope: only run on workflow channels
sw.pipe.pre(20, (ctx) => {
// Auto-tag workflow messages with stage metadata
ctx.metadata.workflow_stage = ctx.channel.stage;
return ctx;
}, { scope: { channelType: ['workflow'] } });
// Post-receive: transform each SSE chunk during streaming
sw.pipe.stream(50, (ctx) => {
// Token counting
tokenCounter.add(ctx.chunk);
ctx.tokens = tokenCounter.total;
return ctx;
});
// Post-render: transform rendered DOM
sw.pipe.render(50, (ctx) => {
// Replace mermaid code blocks with rendered diagrams
ctx.element.querySelectorAll('pre code.language-mermaid').forEach(el => {
renderMermaid(el);
});
return ctx;
});
// Post-render scoped to DMs only
sw.pipe.render(60, (ctx) => {
// Highlight @mentions differently in DMs
highlightDmMentions(ctx.element);
return ctx;
}, { scope: { channelType: ['dm'] } });
// Introspection
sw.pipe.list();
// → { pre: [{priority, source, scope, avgMs}], stream: [...], render: [...] }
```
### Filter Contract
```typescript
// Type definitions (for documentation — runtime is plain JS)
interface PreSendContext {
message: string; // User's text (mutable)
channel: { id: string; type: string; title: string };
attachments: string[]; // File IDs
model: string; // Selected model ID
persona: string | null; // Persona ID if active
metadata: Record<string, any>; // Extensible metadata bag
regenerate: boolean; // true if this is a regeneration
regenerateMessageId: string | null; // message ID being regenerated
}
interface StreamContext {
chunk: string; // Current SSE delta text
accumulated: string; // Full response so far
channel: { id: string; type: string };
model: string;
event: string; // SSE event type (content, tool_use, etc.)
tokens: { input: number; output: number }; // Running count
}
interface RenderContext {
element: HTMLElement; // The message container DOM node (mutable)
message: { role: string; content: string; id?: string };
channel: { id: string; type: string };
}
// Filter scope — restricts which channels a filter runs on.
// Omit or pass null for "all channels" (default).
interface FilterScope {
channelType?: string[]; // ['direct','dm','group','channel','workflow','service']
}
// Registration options (third argument to sw.pipe.pre/stream/render)
interface FilterOpts {
scope?: FilterScope; // Channel type restriction
source?: string; // Label for introspection (defaults to extension ID or 'platform')
}
// All filters are synchronous. Return context to continue, null to halt.
type PipeFilter<T> = (ctx: T) => T | null;
```
### Halt Semantics
**Pre-send:** Returning `null` suppresses the message entirely. The SDK
calls `UI.toast('Message blocked by extension', 'warning')` and does not
call `API.streamCompletion()`. Use case: content policy filter.
**Stream:** Returning `null` drops the current chunk (not displayed). The
accumulated buffer is unchanged. Use case: real-time content filter that
strips sensitive data from the stream.
**Render:** Returning `null` skips remaining render filters for this
message. The DOM stays as-is after the last successful filter. Unusual
but available for guard-rail extensions.
### Timing Telemetry
The pipeline engine records per-filter execution time (monotonic clock).
Available via `sw.pipe.list()` and emitted on `pipe.stats` EventBus event
every 60 seconds (if any filters ran). Format:
```json
{
"pre": [{ "source": "kb-inject", "priority": 10, "calls": 47, "avgMs": 2.3, "errors": 0 }],
"stream": [{ "source": "token-counter", "priority": 50, "calls": 12840, "avgMs": 0.1, "errors": 0 }],
"render": [{ "source": "mermaid", "priority": 50, "calls": 47, "avgMs": 15.7, "errors": 1 }]
}
```
### Filter Scoping
Filters declare an optional `scope` at registration time. The pipeline
engine checks scope before invoking a filter — scoped-out filters are
skipped with zero overhead (no function call, no timing entry).
```js
// Only run on workflow and service channels
sw.pipe.pre(10, myFilter, { scope: { channelType: ['workflow', 'service'] } });
// Only run on direct (1:1 AI) chats
sw.pipe.stream(50, tokenCounter, { scope: { channelType: ['direct'] } });
// No scope = runs on everything (default)
sw.pipe.render(50, mermaidRenderer);
```
**Channel types** (from the `channels` table `type` column):
`direct`, `dm`, `group`, `channel`, `workflow`, `service`.
**Scope evaluation:** Before each filter call, the engine reads
`ctx.channel.type` and checks against the filter's `scope.channelType`
array. If the type is not in the array, the filter is skipped. If the
filter has no scope (or `scope` is `null`), it always runs.
**Introspection:** `sw.pipe.list()` includes the `scope` object for
each filter so extensions can inspect which filters apply to their
channel type.
**Manifest mapping (v0.28.7):** The `"pipes"` manifest key will support
the same `scope` object:
```json
{
"pipes": [
{
"stage": "pre",
"priority": 10,
"handler": "filters.kbInject",
"scope": { "channelType": ["direct", "dm", "group"] }
}
]
}
```
---
## Integration Points
### Pre-send: `chat.js` → `sendMessage()` + `regenerateMessage()`
**`sendMessage()` — current flow (chat.js line ~659):**
```
text = ChatInput.getValue()
→ build body
→ API.streamCompletion(channelId, text, model, ...)
```
**`sendMessage()` — new flow:**
```
text = ChatInput.getValue()
→ build PreSendContext { message, channel, ..., regenerate: false }
→ sw.pipe._runPre(context) // returns modified context or null
→ if null: toast + return
→ API.streamCompletion(ctx.channel.id, ctx.message, ctx.model, ...)
```
**`regenerateMessage()` — current flow (chat.js line ~835):**
```
messageId from clicked button
→ API.streamRegenerate(channelId, messageId, model, ...)
```
**`regenerateMessage()` — new flow:**
```
messageId from clicked button
→ build PreSendContext { message: '', channel, ..., regenerate: true, regenerateMessageId: messageId }
→ sw.pipe._runPre(context)
→ if null: toast + return
→ API.streamRegenerate(ctx.channel.id, messageId, ctx.model, ...)
```
Both paths share the same filter chain. The `regenerate` flag and
`regenerateMessageId` let filters distinguish — a content policy filter
runs on both, a KB inject filter might skip regeneration (the original
context is already in the conversation), a logging filter records both.
The `sendMessage()` function gains ~10 lines. `regenerateMessage()` gains
~8 lines. Extracted helper: `_buildPreSendContext(opts)` constructs the
context object for both callers.
**Metadata passthrough:** The `metadata` bag on `PreSendContext` maps to
the completion request body. Pre-send filters can inject KB chunks,
persona instructions, or custom fields that the backend completion
handler reads from the request. KB auto-inject (v0.28.6) will be the
first platform-level filter using this mechanism.
### Post-receive (stream): `ui-core.js` → `UI.streamResponse()`
Current flow (ui-core.js line ~1060):
```
SSE reader loop:
parse chunk → extract delta
content += delta
activeContentEl.innerHTML = formatMessage(content)
```
New flow:
```
SSE reader loop:
parse chunk → extract delta
streamCtx = { chunk: delta, accumulated: content, ... }
streamCtx = sw.pipe._runStream(streamCtx)
if streamCtx is null: continue (chunk dropped)
content = streamCtx.accumulated // filter may have mutated
activeContentEl.innerHTML = formatMessage(content)
```
The pipe runs synchronously per chunk (filters that need async should
buffer internally). The `accumulated` field lets filters see the full
response so far (for context-dependent transforms like citation
injection that need to see paragraph boundaries).
**Performance:** Stream filters run on every SSE chunk (potentially
hundreds per response). The telemetry tracks per-filter avgMs. Filters
exceeding 5ms average get a console warning. The pipeline engine skips
disabled filters without allocation.
### Post-render: `ui-format.js` + `ui-core.js`
Current flow:
```
formatMessage(content) // marked.js + DOMPurify
→ Extensions.runBlockRenderers() // inside formatMessage, per code block
→ el.innerHTML = html
→ runExtensionPostRender(el) // Extensions.runPostRenderers()
```
New flow:
```
formatMessage(content) // unchanged
→ el.innerHTML = html
→ renderCtx = { element: el, message, channel }
→ sw.pipe._runRender(renderCtx) // subsumes runExtensionPostRender
```
The render pipe replaces both `Extensions.runBlockRenderers()` and
`Extensions.runPostRenderers()`. The distinction between "block" and
"post" renderer types collapses into a single render stage — filters
operate on the full message DOM and can target code blocks, inline
elements, or the entire container.
**Block renderer compat:** Extensions that register block renderers via
the old `ctx.renderers.register(name, { type: 'block', ... })` API get
automatically shimmed into `sw.pipe.render()` filters. The shim wraps
the old `(lang, code, container)` signature into the new
`(renderCtx) => ...` contract. Priority is preserved.
---
## Backward Compatibility
### Extension Context Shim
The existing `Extensions._buildContext()` returns a `ctx` object with
`ctx.renderers.register()`, `ctx.tools.handle()`, `ctx.events.*`,
`ctx.ui.*`, `ctx.storage.*`, and `ctx.api.*`.
All of these continue to work. The shim layer:
| Old API | Shim behavior |
|---------|--------------|
| `ctx.renderers.register(name, { type: 'block', ... })` | Wraps into `sw.pipe.render()` filter with matching priority |
| `ctx.renderers.register(name, { type: 'post', ... })` | Wraps into `sw.pipe.render()` filter |
| `ctx.renderers.register(name, { type: 'inline', ... })` | Wraps into `sw.pipe.render()` filter (inline → DOM manipulation) |
| `ctx.tools.handle(name, fn)` | Unchanged — tool bridge is orthogonal to pipes |
| `ctx.events.*` | Unchanged — passthrough to `Events` |
| `ctx.ui.*` | Unchanged — passthrough to `UI` / `showConfirm` / `Theme` |
| `ctx.storage.*` | Unchanged — scoped `localStorage` |
| `ctx.api.*` | Unchanged — passthrough to `API._fetch` |
| `ctx.surfaces.*` | Unchanged (already stub/warn for removed features) |
The shim is invisible to existing extensions. They register the same way;
the pipeline engine sees their filters alongside new `sw.pipe.*` filters
and orders everything by priority.
### Global Availability
`sb.js` continues to work. `window.API`, `window.Events`, `window.UI`,
etc. remain available. The SDK is additive — it does not remove globals
or break direct access. Phase 5 (ES module `import`/`export` migration)
is when globals start going away, and the SDK becomes the only entry point.
### Load Order
```
base.html:
sb.js ← action registry (unchanged)
app-state.js ← App, fetchModels (unchanged)
api.js ← API (unchanged)
events.js ← Events (unchanged)
ui-primitives.js ← Theme, showConfirm, etc. (unchanged)
ui-format.js ← formatMessage (patched: render pipe integration)
ui-core.js ← UI (patched: stream pipe integration)
switchboard-sdk.js ← NEW: Switchboard object, pipe engine
extensions.js ← Extensions (patched: renderer shim)
... surface-specific JS ...
chat.js ← sendMessage (patched: pre-send pipe)
app.js ← init (patched: calls Switchboard.init)
```
`switchboard-sdk.js` loads after all primitives/globals it wraps and
before `extensions.js` (so the shim layer is ready when extensions
register renderers). `app.js` `init()` calls `Switchboard.init()` as
part of its boot sequence; the SDK handles idempotency.
---
## File Inventory
### New Files
| File | Purpose |
|------|---------|
| `src/js/switchboard-sdk.js` | SDK object, pipe engine, compat shims |
| `surfaces/icd-test-runner/js/tier-sdk.js` | ICD runner `sdk` test tier |
| `docs/DESIGN-0.28.5.md` | This document (archived after merge) |
### Modified Files
| File | Change |
|------|--------|
| `src/js/chat.js` | `sendMessage()`: pre-send pipe insertion |
| `src/js/ui-core.js` | `streamResponse()`: stream pipe insertion |
| `src/js/ui-format.js` | `runExtensionPostRender()`: delegates to render pipe |
| `src/js/extensions.js` | `_registerRenderer()`: shim into `sw.pipe.render()` |
| `src/js/app.js` | `init()`: call `Switchboard.init()` during boot |
| `src/templates/base.html` | Add `<script>` for `switchboard-sdk.js` |
| `surfaces/icd-test-runner/js/main.js` | Register `sdk` tier |
| `surfaces/icd-test-runner/manifest.json` | Include `tier-sdk.js` |
### No Changes
All other 40+ JS files are untouched. The SDK wraps them; it does not
modify their internals (except the five patched files above).
---
## ICD Test Tier: `sdk`
New tier in the ICD runner. Tests the SDK contract from the browser,
running against the live platform.
### Test Categories
**Boot:**
- `Switchboard.init()` returns an `sw` object with expected shape
- Double-init returns same instance (idempotency)
- `sw.user` is populated after init
- `sw.isAdmin` reflects actual role
- `sw:ready` event fires on `document`
**REST client:**
- `sw.api.get('/api/v1/health')` returns health object
- `sw.api.get('/api/v1/channels')` returns `{ data: [...] }` envelope
- `sw.api.post('/api/v1/channels', {...})` creates and returns channel
- `sw.api.del('/api/v1/channels/:id')` deletes (204 or JSON)
- Auth header injection (no manual token management)
**Events:**
- `sw.on()` receives events emitted by `sw.emit()`
- `sw.once()` fires exactly once
- `sw.off()` stops delivery
- WebSocket events propagate through `sw.on()`
**Theme:**
- `sw.theme.current` returns 'dark' or 'light'
- `sw.theme.set('dark')` changes resolved theme
- `sw.theme.on('change', fn)` fires on toggle
**Pipe registration:**
- `sw.pipe.pre(10, fn)` registers without error
- `sw.pipe.stream(50, fn)` registers without error
- `sw.pipe.render(50, fn)` registers without error
- `sw.pipe.pre(10, fn, { scope: { channelType: ['direct'] } })` registers with scope
- `sw.pipe.list()` returns registered filters in priority order with scope
- Duplicate registration (same source+priority) warns but succeeds
**Pipe execution — pre-send:**
- Register pre-send filter that appends metadata
- Send message → filter runs → metadata present in request
(verify via round-trip: message persisted, reload, check)
- Register halt filter (returns null) → message not sent, toast shown
- Multiple filters → run in priority order
- Filter throws → error logged, next filter runs, message still sends
- Regenerate: filter runs with `ctx.regenerate === true`
**Pipe execution — render:**
- Register render filter that adds a class to message elements
- Send message → response rendered → class present in DOM
- Priority ordering: low-priority filter sees high-priority filter's changes
- Extension compat: `ctx.renderers.register('test', { type: 'post', ... })`
→ filter appears in `sw.pipe.list()` render chain
**Pipe execution — stream:**
- Register stream filter that counts chunks
- Send message → stream completes → filter's counter > 0
- Stream filter that modifies `accumulated` → rendered content reflects change
**Pipe scoping:**
- Register filter scoped to `channelType: ['workflow']`
- Send message on a `direct` channel → filter does NOT run (counter stays 0)
- Send message on a `workflow` channel → filter runs (counter increments)
- Unscoped filter runs on all channel types
- `sw.pipe.list()` shows scope for scoped filters, null for unscoped
**Introspection:**
- `sw.pipe.list()` returns timing stats after filters have run
- Stats include `calls`, `avgMs`, `errors`, `scope` per filter
### Test Infrastructure
The `sdk` tier runs after `crud` (needs a channel to send messages into)
and before `security` (SDK tests assume valid auth). It creates its own
test channels (at least one `direct`, one `workflow` for scope tests),
registers ephemeral filters, sends messages, and verifies DOM/API state.
Estimated test count: 3035 tests.
---
## Changeset Plan
### CS1 — SDK Core + Docs
**Files:** `switchboard-sdk.js`, `DESIGN-0.28.5.md`, `base.html`
**Scope:** SDK object with identity, REST client, events, theme, UI
primitives, component wrappers. No pipe engine yet. `app.js` calls
`Switchboard.init()`. All existing tests stay green — no behavior change.
**Validates:** SDK loads, wraps globals, `sw:ready` fires, `sw.api.*`
works, `sw.theme.*` works. Manual verification: existing surfaces
boot normally with the new script tag.
### CS2 — Pipe Engine + Integration
**Files:** `switchboard-sdk.js` (pipe engine added), `chat.js`,
`ui-core.js`, `ui-format.js`, `extensions.js`
**Scope:** Pipeline execution engine, three filter chains, integration
into sendMessage/streamResponse/runExtensionPostRender, extension
renderer compat shim.
**Validates:** Pipe registration works, filters run in order, existing
block renderers (mermaid, katex, csv, diff) still render via compat
shim. CI green on both PG and SQLite.
**Breaking change:** `runExtensionPostRender()` now delegates to the
pipe engine. If an extension relied on `Extensions.runPostRenderers()`
being called directly (not through `runExtensionPostRender`), it would
need updating. In practice, all callsites go through `runExtensionPostRender`.
### CS3 — ICD Runner SDK Tier
**Files:** `tier-sdk.js`, `main.js`, `manifest.json`, `fixtures.js`
(new fixtures for SDK tests)
**Scope:** Full `sdk` test tier. Delivered as complete `.surface` archive
(installable via `POST /admin/surfaces/install`).
**Validates:** All SDK contract tests pass against live platform.
### CS4 — VERSION + CHANGELOG + ROADMAP
**Files:** `VERSION`, `CHANGELOG.md`, `ROADMAP.md`
**Scope:** Version bump to `0.28.5`, changelog entry, roadmap checkboxes.
---
## Resolved Decisions
1. **Stream filters are sync-only in v0.28.5.** Filters that need async
(e.g., external API calls per chunk) must buffer internally and flush
on their own schedule. Async stream filter support revisited if real
usage demands it. Rationale: backpressure risk on fast SSE streams is
not worth the complexity for MVP.
2. **Pre-send filters run on regenerate.** `regenerateMessage()` runs the
same pre-send chain as `sendMessage()`. The `PreSendContext` includes
`regenerate: true` and `regenerateMessageId: string` so filters can
distinguish. Content policy, KB inject, and all other pre-send filters
apply consistently — a filter that only guards new messages but not
regenerations would be a security gap.
3. **Per-channel filter scoping ships in v0.28.5.** Filters declare an
optional `scope` object at registration time that restricts which
channel types they run on. The pipeline engine checks scope before
invoking the filter — scoped-out filters have zero overhead (no call,
no timing entry). See "Filter Scoping" section below.
4. **KB auto-inject ships as a pipe filter in v0.28.6.** The platform
registers a built-in pre-send filter (`_kb-auto-inject`, priority 5)
that queries bound knowledge bases, runs top-K chunk retrieval, and
prepends context to `ctx.metadata.kb_context`. This is the first
real validation that the pipeline handles non-trivial platform-level
transforms, not just extension toys.
---
## Appendix: Existing Extension Hooks → Pipe Mapping
| DESIGN-SURFACES Hook | Current Implementation | Pipe Equivalent |
|----------------------|----------------------|----------------|
| Hook 2: Stream Processing | Not implemented (spec only) | `sw.pipe.stream()` |
| Hook 3: Post-Render | `ctx.renderers.register(type:'post')``Extensions.runPostRenderers()` | `sw.pipe.render()` |
| Hook 3: Block Render | `ctx.renderers.register(type:'block')``Extensions.runBlockRenderers()` | `sw.pipe.render()` (filter targets code blocks in DOM) |
| Hook 3: Inline Render | `ctx.renderers.register(type:'inline')` → not wired | `sw.pipe.render()` (filter modifies HTML string) |
| Hook 5: Tool Bridge | `ctx.tools.handle()``Events.on('tool.call.*')` | Unchanged — not a pipe stage |
| Hook 6: Surface Injection | Not implemented (spec only) | Not a pipe stage — future SDK feature |
| (new) Pre-send | Did not exist | `sw.pipe.pre()` |

View File

@@ -27,14 +27,19 @@ v0.9.xv0.27.5 Foundation → Extensions → Surfaces → Auth ✅
├─ v0.28.2 ICD audit — all domains ✅
├─ v0.28.3 ICD close-out + FE decomp ✅
├─ v0.28.4 Security tier (red team) ✅
├─ v0.28.5 Infrastructure
│ (virtual scroll, KB auto-inject,
Helm chart, provider model prefs,
│ git credentials UI)
└─ v0.28.6 Frontend SDK
├─ v0.28.5 Frontend SDK + Pipes ✅
│ (switchboard-sdk.js, pipe/filter
pipeline, component mounting)
├─ v0.28.6 Infrastructure
│ (virtual scroll, Helm, task webhook
│ UI, system tasks, model prefs)
└─ v0.28.7 Unified Packaging + Task RBAC
(.pkg archive, manifest.type,
task permission gate pre-Starlark)
v0.29.0 Starlark Sandbox
(eval loop, permissions, admin UI)
(eval loop, permissions, admin UI
— adds starlark capability to .pkg)
v0.29.1 API Extensions
(Starlark route handlers,
@@ -44,13 +49,26 @@ v0.9.xv0.27.5 Foundation → Extensions → Surfaces → Auth ✅
(namespaced tables, scoped db module,
declarative schema in manifest)
v0.30.0 Surface Packages
(full-stack .surface archives:
UI + API + schema + settings)
v0.29.3 Workflow Forms
(form_template → real UI, LLM optional,
.star validation, structured data)
v0.31.0 Editor Surface Package
v0.30.0 Package Lifecycle
(schema versioning, migrations,
settings extension point,
export/import, marketplace)
v0.30.1 SDK Adoption
(sw.notes factory, core surface
migration, fix-once-fix-everywhere)
v0.30.2 Workflow Packages
(stage surfaces, custom UI per stage,
team admin workflow builder)
v0.31.0 Editor Package
(E2E proof: rebuild editor as
installable surface, zero
installable .pkg, zero
platform special-casing)
```
@@ -227,9 +245,61 @@ New `security` tier in ICD test runner. 58 tests, 529/531 pass.
- [x] `surfaces/` directory with `build.sh``dist/<n>.surface`
- [x] `hello-dashboard.surface` unpacked into `surfaces/hello-dashboard/`
### v0.28.5 — Infrastructure
### v0.28.5 — Frontend SDK + Pipes ✅
`switchboard-sdk.js` — composition layer over existing globals. Surface
authors consume a single coherent API instead of hunting through 15 JS files.
Pipe/filter pipeline formalizes the extension hook model into composable,
priority-ordered transform stages.
**SDK core:**
- [x] `Switchboard.init({ mount })` — idempotent boot: tokens, profile, theme, events, user menu
- [x] `sw.user`, `sw.isAdmin` — resolved identity
- [x] `sw.api.get()` / `sw.api.post()` — authenticated REST, no token/base-path management
- [x] `sw.on(event, fn)` — WebSocket subscription (no `Events` global knowledge)
- [x] `sw.chat(container, opts)` — drop-in ChatPane (wraps `ChatPane.create()`)
- [ ] `sw.notes(container, opts)` — stub shipped (warns); notes component
needs factory refactor before clean mounting (v0.30.1 SDK Adoption)
- [x] `sw.toast()`, `sw.confirm()` — UI primitives
- [x] `sw.theme.current`, `sw.theme.on('change', fn)` — theme queries
- [x] Absorbs cs15 UserMenu band-aid — universal hydration moves into `init()`
**Pipe/filter pipeline:**
Three stages, each a priority-ordered chain of transform functions.
Filters receive typed context, return (possibly modified) context.
Pipeline wires filters in priority order; any filter can halt the chain.
- [x] `sw.pipe.pre(priority, fn)`**pre-send**: transform user message
before LLM request. Context: `{ message, channel, attachments, metadata }`.
Runs on both `sendMessage()` and `regenerateMessage()` with `regenerate` flag.
- [x] `sw.pipe.stream(priority, fn)`**post-receive**: transform LLM
response stream chunks as they arrive (sync-only). Context: `{ chunk,
accumulated, channel, model }`.
- [x] `sw.pipe.render(priority, fn)`**post-render**: transform rendered
HTML/markdown after DOMPurify. Context: `{ element, message, channel }`.
Replaces `runExtensionPostRender()` with compat shim for existing
`ctx.renderers.register()` extensions.
- [x] Pipeline execution engine: ordered dispatch, error isolation (one
filter throws → skip it, continue chain), timing telemetry per filter
- [x] `sw.pipe.list()` — introspection: list registered filters by stage
with priority, source, scope, and timing stats
- [x] Filter scoping: `{ scope: { channelType: [...] } }` restricts
execution to specific channel types with zero-overhead skip
- [ ] Manifest `"pipes"` key: declarative filter registration — deferred
to v0.28.7 (unified packaging, where manifest keys are extended)
**Testing:**
- [x] ICD runner gains `sdk` test tier: 36 tests — boot, identity, REST,
events, theme, pipe registration, execution, scoping, halt, error
isolation, compat shim, introspection
- [x] Pipe filter tests: priority ordering, halt semantics, error isolation,
scoped/unscoped execution
### v0.28.6 — Infrastructure
- [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels)
- [ ] KB auto-injection: top-K chunk prepend, context budget aware, per-channel toggle
- [ ] KB auto-injection: platform-registered pre-send pipe filter (`_kb-auto-inject`,
priority 5), top-K chunk prepend to `ctx.metadata.kb_context`, context budget aware,
per-channel toggle. First real validation of the v0.28.5 pipeline architecture.
- [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`)
- [ ] Per-provider model preferences — finalize: make `provider_config_id` required on
`PUT /models/preferences` (fixes NULL-in-UNIQUE dedup bug), migrate `/models/enabled`
@@ -240,23 +310,107 @@ New `security` tier in ICD test runner. 58 tests, 529/531 pass.
- [ ] `system.announcement` notification type: admin broadcast endpoint
(`POST /admin/notifications/broadcast`), fan-out to all active users via
`NotifyMany`, admin UI for composing announcements
- [ ] Task webhook trigger UI: schedule selector gains `webhook` option,
trigger URL displayed + copy button, outbound `webhook_url` + `webhook_secret`
fields in create/edit form, task-to-task chaining documentation in admin UI.
Backend fully implemented — this is pure admin UI work.
- [ ] System task type: `task_type: "system"` — built-in Go function registry
(`retention_sweep`, `memory_compact`, `session_cleanup`, `staleness_check`).
Admin creates task, picks function from dropdown, sets cron schedule. Executor
calls registered Go function instead of LLM completion. Replaces the current
goroutine-based background jobs with visible, configurable, auditable tasks.
Existing goroutines kept as fallback until system tasks are validated.
**Permanent track** — Go registry is not replaced by Starlark (v0.29.0).
Core platform ops must not break from bad user code. Admin-only by design
(no RBAC needed — hardcoded to admin role).
### v0.28.6Frontend SDK
`switchboard-sdk.js` — composition layer over existing globals. Surface
authors consume a single coherent API instead of hunting through 15 JS files.
### v0.28.7Unified Packaging + Task RBAC
Single `.pkg` archive format for both surfaces and extensions. Manifest
`"type"` field (`surface`, `extension`, `full`) drives install behavior.
Format first, capabilities later — v0.29.x adds Starlark/DB/API capabilities
into packages that already install cleanly.
- [ ] `Switchboard.init({ mount })` — idempotent boot: tokens, profile, theme, events, user menu
- [ ] `sw.user`, `sw.isAdmin` — resolved identity
- [ ] `sw.api.get()` / `sw.api.post()` — authenticated REST, no token/base-path management
- [ ] `sw.on(event, fn)` — WebSocket subscription (no `Events` global knowledge)
- [ ] `sw.chat(container, opts)` — drop-in ChatPane (wraps `ChatPane.create()`)
- [ ] `sw.notes(container, opts)` — drop-in notes component
- [ ] `sw.toast()`, `sw.confirm()` — UI primitives
- [ ] `sw.theme.current`, `sw.theme.on('change', fn)` — theme queries
- [ ] ICD runner gains `sdk` test tier: validates init, component mounting, event hooks
- [ ] Absorbs cs15 UserMenu band-aid — universal hydration moves into `init()`
Task permission model gates task creation by type and scope. Retroactively
locks down `action` tasks (webhook relay = data exfiltration risk) and
pre-positions for `starlark` tasks in v0.29.0.
Depends on: v0.28.5 (SDK — pipe/filter registration is part of manifest contract).
**Archive format:**
- [ ] `.pkg` archive: zip containing `manifest.json` + assets (JS, CSS,
templates, icons). Same structure as `.surface` archives, extended
manifest schema
- [ ] `manifest.type` field: `surface` (routes + templates + data loader),
`extension` (hooks + tools + pipes, no own route), `full` (both).
Absent `type` defaults to `surface` for backward compat
- [ ] `manifest.pipes` key: declarative pipe filter registration (pre-send,
post-receive, post-render) with priority and entry function reference.
Wired by SDK on extension load
- [ ] `manifest.tools` key: LLM-callable tool declarations (existing schema,
now part of unified manifest)
- [ ] `manifest.hooks` key: EventBus subscriptions (existing schema from
EXTENSIONS.md, carried forward)
**Install infrastructure:**
- [ ] `POST /admin/packages/install` — unified install endpoint. Reads
`manifest.type`, validates type-specific requirements, wires subsystems.
Replaces `POST /admin/surfaces/install` (old route kept as alias)
- [ ] Validation branches by type: `surface` requires `routes`/`template`,
`extension` requires at least one of `hooks`/`tools`/`pipes`, `full`
requires both sets
- [ ] Admin UI: merge Surfaces + Extensions into single "Packages" section.
Type badge on each entry. Filter by type. Enable/disable granular
for `full` packages (disable hooks independently of routes)
- [ ] `packages` table (or rename `surfaces``packages` with migration):
adds `type` column, retains all existing surface columns
**Migration:**
- [ ] Existing `.surface` archives install unchanged (type defaults to
`surface`). No re-install required
- [ ] Existing `extensions` table rows (loose-JS browser extensions) get
synthetic package wrappers: migration generates `manifest.json` from
existing DB fields, archives JS entry file into `.pkg` format
- [ ] `build.sh` in `surfaces/` updated to produce `.pkg` files. Directory
optionally renamed to `packages/`
- [ ] ICD runner surface install tests updated to use new endpoint (or alias)
**Task RBAC:**
Permission gate on task creation by `task_type` and `scope`. Must ship
before Starlark (v0.29.0) — cannot allow server-side code execution
without a permission model. Also retroactively locks down `action` tasks.
- [ ] `task_permissions` table (or extend `extension_permissions` pattern):
maps `(user_id | team_id | role) → task_type → allowed`. Default
permissions seeded on migration:
- Admin: all types, all scopes
- Team admin: `prompt`, `workflow` (team scope). `action` denied by default
- User: `prompt`, `workflow` (personal scope). `action` denied by default
- `system` always admin-only (hardcoded, not in permissions table)
- [ ] Create/update task handlers check permission before accepting
`task_type`. Existing `action` tasks grandfathered (can run, cannot
be cloned or created without permission). Migration adds warning
notification to owners of existing `action` tasks
- [ ] Admin UI: task permission management — per-user and per-team overrides.
"Allow action tasks for Team X" toggle. Pre-positions `starlark`
permission type (hidden until v0.29.0 enables it)
- [ ] `POST /api/v1/tasks` and `PATCH /api/v1/tasks/:id` validate
`task_type` against caller's permissions. 403 on denied type
- [ ] ICD tests: permission-denied task creation, team admin boundaries,
admin override
**ICD + docs:**
- [ ] `packages.md` ICD: install, list, enable/disable, delete, type filtering
- [ ] `EXTENSIONS.md` updated: packaging section references `.pkg` format,
loose-JS model documented as legacy
- [ ] `tasks.md` ICD updated: permission model, per-type RBAC
- [ ] ICD runner gains `packaging` test tier: install surface-type, install
extension-type, install full-type, type validation, backward compat
**Tier 2 — Medium value (pull into any v0.28.x):**
- [ ] User-installable extension packages: RBAC-gated `POST /packages/install`
(non-admin), team-scoped or personal scope, permission review flow.
Depends on v0.28.7 packaging.
- [ ] Memory compaction: summarize old memories, confidence decay, prune low-confidence
- [ ] `capability_match` routing policy ("cheapest model with tool_calling")
- [ ] New provider types registrable via config file (OpenAI-compatible + custom schema)
@@ -268,7 +422,8 @@ authors consume a single coherent API instead of hunting through 15 JS files.
Server-side extension runtime. Prove the eval loop, permission pipeline,
and admin UI before adding capabilities.
Depends on: v0.28.0 (platform polish), extension infrastructure (v0.11.0).
Depends on: v0.28.0 (platform polish — specifically v0.28.7 unified packaging),
extension infrastructure (v0.11.0).
- [ ] `go.starlark.net` integration (eval loop, timeout, memory ceiling)
- [ ] Permission model: manifest declarations, admin grant/revoke, DB schema
@@ -282,6 +437,21 @@ Depends on: v0.28.0 (platform polish), extension infrastructure (v0.11.0).
(emit to users)
- [ ] Migration: `extension_permissions` table
- [ ] ICD: update `extensions.md` with Starlark-specific endpoints
- [ ] `task_type: "starlark"`: task executor gains Starlark code path.
Task references a `.star` script (inline or from a `.pkg`), executor
runs it in the sandbox with task context (trigger payload, schedule
info, previous run data). No LLM, no webhook relay — actual computation.
RBAC gate from v0.28.7 enforced: `task.starlark` permission enabled
in `task_permissions` (hidden until this version). Admin-grantable
to team admins on a per-team basis.
**Two-track execution model (permanent):**
- `system` (v0.28.6): Go function registry — core platform ops
(`retention_sweep`, `memory_compact`, etc.). Cannot break from
bad user code. Admin-only, not editable, not versionable.
- `starlark`: custom admin/team admin tasks — nightly reports,
data quality checks, integration sync, cleanup scripts. Editable,
versionable, sandbox-isolated, permission-gated.
---
@@ -325,33 +495,192 @@ Depends on: v0.29.1 (API extensions — handlers need somewhere to persist).
---
## v0.30.0 — Surface Packages
## v0.29.3 — Workflow Forms
The `.surface` archive becomes the full-stack packaging unit.
`form_template` renders as real UI. The LLM moves to the corner —
present if configured, but not required for data collection. Workflows
become structured processes, not guided conversations.
Depends on: v0.29.2 (DB extensions).
Depends on: v0.29.2 (DB extensions — structured form data persists in
`ext_workflow_*` tables, not just `stage_data` JSON blob), v0.29.0
(Starlark — `.star` validators run on form submission).
- [ ] Install creates schema + mounts routes + serves assets in one operation
- [ ] Schema versioning: `"schema_version": N` in manifest
- [ ] Schema migrations: `"migrations": [{"from": 1, "to": 2, "sql": "..."}]`
- [ ] Settings extension point: surfaces declare settings sections in manifest,
platform settings surface renders them alongside core sections
- [ ] Export/import format for sharing surfaces across instances
- [ ] Surface marketplace (discovery, not hosting — instances pull from URLs)
**Form rendering:**
- [ ] `form_template` schema: typed field definitions (`text`, `email`,
`select`, `number`, `date`, `textarea`, `checkbox`, `file`).
Validation rules per field (`required`, `pattern`, `min`/`max`).
Current freeform JSON → structured schema with backward compat
(old JSON treated as `textarea` field prompts for the LLM)
- [ ] Stage renders as form when `form_template` has typed fields.
HTML form generation from schema, client-side validation,
styled with platform CSS custom properties
- [ ] Form submission → `stage_data` merge (same `MergeWorkflowStageData`
path as chat-based data collection, but from structured fields
instead of LLM extraction)
- [ ] Stage advance on form submit: if `auto_transition: true` and all
required fields pass validation, advance without human operator
or LLM involvement
**LLM-optional stages:**
- [ ] Stage without `persona_id` renders form only — no chat pane, no
completion endpoint, pure data collection
- [ ] Stage with `persona_id` + `form_template` renders form + chat
side-by-side: user fills form, LLM assists (auto-fill suggestions,
field explanations, validation help)
- [ ] Stage with `persona_id` + no `form_template` — current behavior
(pure chat-based collection, no change)
**Starlark validation:**
- [ ] `validate` Starlark hook: manifest declares `.star` validator per
stage. Runs on form submit before advance. Returns field errors
or approval. Uses `db` module for cross-reference validation
(e.g., "is this email already in the system?")
- [ ] `on_submit` Starlark hook: post-validation side effects (create
records, send notifications, call APIs via `http` module)
**Visitor form entry:**
- [ ] Visitor entry point renders form for non-chat stages (branded
page with form, no chat widget). Session-scoped same as current
visitor auth model
- [ ] Progressive: visitor sees form → submits → next stage (may be
form or chat or operator review)
**Team admin UX:**
- [ ] Form builder: visual field editor in workflow admin (drag fields,
set types/validation, preview). Replaces raw JSON textarea for
`form_template`. Admin-only initially, team admin in v0.30.2.
---
## v0.31.0 — Editor Surface Package
## v0.30.0 — Package Lifecycle
Rebuild the editor as an installable surface package. Zero platform
special-casing — same `POST /admin/surfaces/install`, same manifest,
same `/s/:slug` route as any third-party surface. If `pages.go` or
Lifecycle sophistication for the `.pkg` format established in v0.28.7.
Schema management, settings integration, and ecosystem features.
Depends on: v0.29.2 (DB extensions — schema declarations must exist before
versioning and migrations make sense).
- [ ] Install creates schema + mounts routes + serves assets in one operation
(extends v0.28.7 install with v0.29.2 schema support)
- [ ] Schema versioning: `"schema_version": N` in manifest
- [ ] Schema migrations: `"migrations": [{"from": 1, "to": 2, "sql": "..."}]`
- [ ] Settings extension point: packages declare settings sections in manifest,
platform settings surface renders them alongside core sections
- [ ] Export/import format for sharing packages across instances
- [ ] Package marketplace (discovery, not hosting — instances pull from URLs)
---
## v0.30.1 — SDK Adoption
Migrate core surfaces to consume the SDK instead of raw globals.
Prerequisite for the Editor Package — if the platform's own surfaces
don't use `sw.*`, the SDK isn't validated for real.
The goal: a bug fix in `sw.notes()` or `sw.chat()` propagates to every
surface automatically. No more duplicated wiring, no more "fixed on
chat but broken on editor" class of bugs.
Depends on: v0.28.5 (SDK exists), v0.30.0 (package lifecycle — settings
extension point needed for surface-specific config).
**Component factories:**
- [ ] `sw.notes(container, opts)` — real factory: creates NoteEditor,
note list, graph panel. Extracted from `notes.js` monolith into
self-contained mountable component. `opts: { projectId, channelId,
onLink, standalone }`. Returns instance with `destroy()`.
- [ ] `sw.chat(container, opts)` — extend existing wrapper with pipe
integration (stream/render filters auto-wired), model selector
binding, file upload, typing indicator. Currently thin wrapper
around `ChatPane.create()` — needs to become the canonical path.
- [ ] `sw.panels(container, opts)` — PaneContainer composition via SDK.
Resize, tab management, drag handles. Fixes the "scaling bug lives
in two places" problem — one implementation, one fix.
**Core surface migration:**
- [ ] Chat surface (`app.js`, `chat.js`): boot via `sw = Switchboard.init()`,
replace direct `API.*` / `Events.*` / `UI.*` calls with `sw.*` in
new code paths. Existing code keeps working (globals still exist).
Progressive — not a rewrite, just new code uses the SDK.
- [ ] Editor surface (`editor-surface.js`): mount chat pane via `sw.chat()`,
mount notes via `sw.notes()`, consume `sw.theme.on('change')` for
CM6 theme sync. Removes duplicated panel resize/scale wiring.
- [ ] Notes surface: mount via `sw.notes()`, share implementation with
chat sidebar notes panel.
- [ ] Settings surface: consume `sw.api.*` and `sw.theme.*`.
**Validation:**
- [ ] Existing ICD runner tests still pass (surfaces behave identically)
- [ ] ICD runner `sdk` tier extended with component mount/destroy tests
- [ ] Manual: chat, editor, notes surfaces all function after migration
- [ ] Bug fix propagation test: fix applied to `sw.notes()` visible on
both chat sidebar and notes surface simultaneously
---
## v0.30.2 — Workflow Packages
Workflows become packageable. Each stage can serve its own surface —
form, dashboard, chat, or custom UI. Team admins build and manage
workflows through a visual builder, not JSON editing.
Depends on: v0.29.3 (workflow forms — form rendering exists),
v0.30.0 (package lifecycle — schema versioning, settings extension point),
v0.30.1 (SDK adoption — stage surfaces consume `sw.*`).
**Stage surfaces:**
- [ ] A workflow stage can reference a surface (built-in or from a `.pkg`).
Stage config gains `surface_id` field — platform mounts that surface
when the stage is active, with stage data injected as context
- [ ] Built-in stage surfaces: `form` (v0.29.3 form renderer), `chat`
(current behavior), `review` (operator sees collected data + approve/reject)
- [ ] Custom stage surfaces: a `.pkg` with `type: "workflow-stage"` provides
a surface that receives stage data and emits advance/reject signals
via `sw.workflow.advance(data)` / `sw.workflow.reject(reason)` SDK calls
- [ ] Stage transitions respect surface completion — surface signals "done"
with structured data, workflow engine merges and advances
**Workflow-as-package:**
- [ ] `.pkg` with `type: "workflow"`: bundles workflow definition, stage
surfaces, Starlark handlers, and assets in one installable archive.
`POST /admin/packages/install` creates the workflow + stages + surfaces
- [ ] Manifest `"workflow"` key: stages, transitions, form schemas, surface
refs, on_complete hooks. Replaces manual workflow creation via admin API
- [ ] Version pinning: installed workflow package tracks its package version.
Upgrade re-publishes with new version number
**Team admin workflow builder:**
- [ ] Visual stage editor: drag-to-reorder stages, per-stage config panel
(surface type, persona, form fields, transition rules, assignment)
- [ ] Form builder integrated per stage (extends v0.29.3 admin form builder)
- [ ] Preview: team admin can walk through the workflow as a visitor would,
seeing each stage surface in sequence
- [ ] Publish/version from the builder UI — no JSON, no API calls
- [ ] Team admin permission: `workflow.manage` — team admins can create,
edit, publish workflows for their team. Global admin can create
global workflows. Regular users can only enter workflows, not build them
**SDK extensions:**
- [ ] `sw.workflow` namespace: `advance(data)`, `reject(reason)`,
`getData()`, `getStage()`, `onTransition(fn)` — stage surfaces
interact with the workflow engine through the SDK, not raw API calls
---
## v0.31.0 — Editor Package
Rebuild the editor as an installable `.pkg` package. Zero platform
special-casing — same `POST /admin/packages/install`, same manifest,
same `/s/:slug` route as any third-party package. If `pages.go` or
`main.go` need changes to support it, the platform abstraction is
wrong, not the editor.
Validates the full v0.29.xv0.30.0 stack E2E.
Validates the full v0.28.7v0.30.2 stack E2E: unified packaging, Starlark
handlers, DB extensions, package lifecycle, SDK adoption, and workflow
packages all exercised by one real-world package.
Depends on: v0.30.0 (surface packages with settings extension point).
Depends on: v0.30.2 (workflow packages — editor validates the full stack
including workflow stage surfaces).
**Platform primitives consumed (not owned):**
- CM6 (core UI primitive — chat input, notes, code blocks all use it)
@@ -359,15 +688,15 @@ Depends on: v0.30.0 (surface packages with settings extension point).
- Git tools + credentials (v0.28.0 — core settings, vault-encrypted)
- Provider resolution via `requires_provider` (v0.29.1)
**Surface package delivers:**
- [ ] Editor `.surface` archive: manifest, JS, CSS, Starlark handlers
**Package delivers:**
- [ ] Editor `.pkg` archive (type: `full`): manifest, JS, CSS, Starlark handlers
- [ ] Built separately, installed via admin API — follows exact same
patterns as any other surface
patterns as any other package
- [ ] Editor settings section (via v0.30.0 settings extension point):
keybindings (vim/emacs/standard), font size, editor theme
- [ ] Editor state persistence via `ext_editor_*` tables: open tabs,
cursor positions, split layout, per-workspace config
- [ ] File tree, tab bar, multi-pane layout — all via surface JS,
- [ ] File tree, tab bar, multi-pane layout — all via package JS,
consuming platform CSS custom properties
- [ ] Markdown preview (improved renderer, shared with notes surface)
- [ ] Remove editor from core: delete `surface-editor` template,