This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/docs/DESIGN-0.28.5.md
2026-03-14 22:51:50 +00:00

755 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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()` |