<!-- source: openspec/changes/add-specialist-tool-composition/design.md -->

# Design — add-specialist-tool-composition

> Companion to proposal.md. Records `D-STC-N` and the contracts the
> implementation must honour.

> **Schema-rename overlay — RAP-1170 (Wave 1, 2026-05-22).** Inline
> references below to `canvas_sessions` resolve to `ideas`,
> `canvas_messages` to `messages`, `canvas_files` to `files`,
> `canvas_invitees` to `spec_invitees` per
> `openspec/current/canvas.md § Naming clarification`. The original
> drafting names are preserved in code-block examples to keep the
> design diff legible; consumers should read the rename overlay
> alongside.

## D-STC-1 — Composition unit: per-archetype YAML

```yaml
# openspec/_methodology/specialist-tool-palettes/architect/tools.yaml
archetype: architect
tools:
  - id: read_openspec
  - id: propose_change
skills:
  - id: spec_authoring
research_agents:
  - id: scope_inquiry
vocabulary:
  ref: ./vocabulary.md
```

Schema (Zod):

```ts
export const PaletteSchema = z.object({
  archetype: z.string(),
  brand: z.string().optional(),  // omitted in platform defaults
  tools: z.array(z.object({
    id: z.string(),
    description: z.string().optional(),
    scope: z.enum(['read', 'write', 'execute']).optional(),
  })).default([]),
  skills: z.array(z.object({
    id: z.string(),
    bundled_from: z.string().optional(),
  })).default([]),
  research_agents: z.array(z.object({
    id: z.string(),
    description: z.string().optional(),
    bundled_from: z.string().optional(),
  })).default([]),
  vocabulary: z.object({
    ref: z.string(),  // relative path to a vocabulary doc
  }).optional(),
});
```

**Why per-archetype not per-specialist?** A brand typically has multiple
specialists in the same archetype (3 architects, 2 designers); they
share intent + palette. Per-specialist would force duplicate maintenance
without proven need. If the rule-of-three trigger fires later
(distinct palettes for individual specialists in the same archetype),
a future change introduces a per-specialist override layer.

**Why YAML not JSON?** Diff-readability — palette PRs are normal review
artefacts; YAML lists read like prose. The format is enforced via Zod
parsing; comments are allowed.

## D-STC-2 — Hosting: per-brand override, platform default fallback

```
openspec/
  _methodology/
    specialist-tool-palettes/
      <archetype>/tools.yaml   ← platform default
  brands/
    <brand>/
      archetypes/
        <archetype>/tools.yaml ← brand override (optional)
```

```ts
async function loadPalette({ archetype, brand }: { archetype: string; brand: string }): Promise<Palette> {
  const brandPath = path.join(
    REPO_ROOT,
    'openspec/brands',
    brand,
    'archetypes',
    archetype,
    'tools.yaml'
  );
  const platformPath = path.join(
    REPO_ROOT,
    'openspec/_methodology/specialist-tool-palettes',
    archetype,
    'tools.yaml'
  );
  const file = (await exists(brandPath)) ? brandPath : platformPath;
  return PaletteSchema.parse(yaml.parse(await fs.readFile(file, 'utf-8')));
}
```

**Why first-match-wins rather than merge?** A merge model creates
implicit semantics ("does brand-tool 'foo' replace platform-tool 'foo'
or extend it?"). First-match-wins is explicit: the brand's file is
the contract.

**Why platform under `_methodology/` not `_root/`?** `_methodology/`
hosts platform-level conventions and reusable artefacts; tool palettes
are a methodology surface. `_root/` is reserved for capability indices.

## D-STC-3 — Permission model: active-specialist gate at turn dispatch

```ts
// apps/studio/src/app/(auth-walled)/(canvas)/spec/[id]/turn/route.ts
export async function POST(req: Request) {
  // ...existing turn setup...
  const specialist = await loadActiveSpecialist(canvasId);
  const palette = await loadPalette({
    archetype: specialist.archetype,
    brand: canvas.brand_slug,
  });

  const allowedToolIds = new Set(palette.tools.map((t) => t.id));
  const filteredTools = ALL_TOOLS.filter((t) => allowedToolIds.has(t.id));

  const stream = await anthropic.messages.create({
    // ...existing params...
    tools: filteredTools,  // <- the gate
  });
  // ...existing streaming pipe...
}
```

The LLM only ever sees tools the active specialist's palette permits.
Out-of-palette tool calls cannot be invoked because the LLM doesn't
know they exist.

**Why filter at the dispatch boundary not in the tool handlers?**
Filtering inside handlers (return 403 on out-of-palette call) wastes
LLM tokens — the model still emits the call. Filtering at dispatch
saves cost + closes the surface.

**Why hard-block not audit-only?** Audit-only lets the LLM call out-
of-palette tools and just logs a warning. That defeats the manifesto
value 5 contract ("the chat is a runtime where curated specialists
arrive with their own tools"). Hard-block is the enforcement.

**Active specialist source:** `ideas.active_specialist_id`
(post-RAP-1170 rename of `canvas_sessions.active_specialist_id`). If
null, the idea is in default-architect mode — the palette resolves to
the architect archetype + idea's brand.

## D-STC-4 — Loader memoisation + invalidation

```ts
const paletteCache = new LRU<string, Palette>({ max: 100 });

function cacheKey({ archetype, brand }: { archetype: string; brand: string }): string {
  return `${brand}::${archetype}`;
}

export async function loadPalette(args: { archetype: string; brand: string }): Promise<Palette> {
  const key = cacheKey(args);
  let palette = paletteCache.get(key);
  if (!palette) {
    palette = await loadPaletteFromDisk(args);
    paletteCache.set(key, palette);
  }
  return palette;
}
```

* Cache size: 100 entries — covers ~10 brands × 10 archetypes; well
  beyond current scale.
* No TTL — palettes are author-controlled via repo PRs; cache lives
  for the process lifetime. A deploy invalidates by rebooting the
  worker.
* Dev mode: cache is bypassed via `NODE_ENV !== 'production'` so
  YAML edits hot-reload.

## D-STC-5 — Lint gates: drift + tool-id existence

```js
// tools/check-palette-drift.mjs
// For each brand override, compare to its platform default.
// Report brands whose override mtime is > 3 months older than the
// platform default's mtime (or has a strict subset of tools).

// tools/check-palette-tool-ids.mjs
// For each tool listed in any palette YAML, verify the id exists in
// the ALL_TOOLS registry. Fail CI on unknown ids.
```

Both gates run in CI as part of the existing `pnpm lint:openspec`
chain. The drift gate is informational by default; fails CI only when
the diff exceeds a documented threshold (>5 tools missing from the
override that exist on the platform).

## D-STC-6 — Failure mode: typed `palette-invalid` error

Tied to RAP-1094's error taxonomy:

```ts
// On Zod parse failure or yaml syntax error:
throw new ChatError({
  kind: 'palette-invalid',
  message: `Palette for archetype "${archetype}" brand "${brand}" failed validation: ${zodError.message}`,
  meta: { archetype, brand, path: filePath },
});
```

`palette-invalid` is added to the `ErrorKind` union from RAP-1094 if
that change ships first; otherwise this change introduces the kind
plus a fallback `unknown`-class banner.

**Why typed?** The user needs to know it's a config bug, not a
network/safety/rate-limit issue. A typed error surfaces the file path
and lets the team open the right PR.

## Phase 1 ratifications (palette loader + gate, turn-route deferred)

### D-STC-7 — Phase 1 ships loader + gate; turn-route wiring deferred

Phase 1 lands the palette schema (`packages/muse/src/runtime/palette-schema.ts`),
the loader (`packages/muse/src/runtime/specialist-tools.ts` exporting
`loadPalette`), the pure gate helper (`filterToolsForActiveSpecialist`),
example YAMLs (one platform default + one brand override), and the
typed `PaletteInvalidError`. The turn-route consumption
(`apps/studio/src/app/(auth-walled)/(canvas)/spec/[id]/turn/route.ts`)
is **deferred to a follow-up PR** because a sibling change
(`add-muse-translation-mode`, RAP-1131) also edits that file; we
serialize on the turn-route to keep diffs reviewable. The Phase 1
public API is shaped so the follow-up is a 5–10 line wiring change.

**Why ship loader + gate first?** It unblocks brand-authoring of
palette YAMLs in parallel with the turn-route work; it lets us catch
schema-design bugs before they couple to dispatch.

### D-STC-8 — Public API surface for the deferred turn-route follow-up

```ts
import {
  loadPalette,
  filterToolsForActiveSpecialist,
  PaletteInvalidError,
} from '@rapoport-studio/muse';

// Inside POST /spec/[id]/turn:
const { palette } = await loadPalette({
  archetype: specialist.archetype,
  brand: idea.brand_slug,
  root: process.cwd(),
});
const filteredTools = filterToolsForActiveSpecialist(ALL_TOOLS, palette);
```

* `loadPalette` returns `{ palette, source, path }` — `source` is
  `'brand' | 'platform' | 'missing'`. The dispatcher can branch on
  source for telemetry without re-reading the file system.
* `filterToolsForActiveSpecialist(tools, null)` returns the tools
  unchanged (additive — preserves current behaviour when no palette
  exists). The turn-route may opt into strict mode (`palette === null →
  return []`) in a future iteration; Phase 1 defaults to additive so
  the rollout is safe.
* `PaletteInvalidError` carries `kind: 'palette-invalid'` plus
  `archetype`, `brand`, `path`, and the underlying `cause` so the
  chat error taxonomy from RAP-1094 can render a typed banner.
* No hidden globals; the cache is module-local with a
  `clearPaletteCacheForTests` escape hatch. `__testing.setLoadFsForTests`
  mirrors the existing test seam in `packages/muse/src/templates/loader.ts`.

### D-STC-9 — Slug validation rejects path traversal

`loadPalette` validates `archetype` and `brand` against the same
lowercase-kebab-case rule as `packages/muse/src/templates/loader.ts`:
no `..`, no `/`, must match `/^[a-z][a-z0-9-]*$/`, ≤64 chars. Invalid
slugs throw before any fs access — defence against a malicious
specialist record steering the loader outside the openspec tree.

### D-STC-10 — Workers runtime resolves to `missing`, not throw

When `node:fs/promises` is unavailable (Cloudflare Workers without a
build-time palette bundle), `loadPalette` returns
`{ palette: null, source: 'missing', path: null }` instead of throwing.
The gate's null-palette branch (additive) then preserves current chat
behaviour. A follow-up may introduce a bundle shape mirroring
`MethodologyBundles` in `packages/muse/src/templates/loader.ts`; for
Phase 1 we keep the runtime additive-by-default in environments without
filesystem access.

## Open questions — all closed at proposal time

* **Q-STC-1 (closed):** Per-archetype or per-specialist? **Per-
  archetype.** D-STC-1 — specialists inherit; rule-of-three triggers
  per-specialist later.
* **Q-STC-2 (closed):** Brand override, platform-only, or both?
  **Both with platform-default fallback.** D-STC-2 — first-match-wins.
* **Q-STC-3 (closed):** Audit-only or hard-block? **Hard-block at
  dispatch.** D-STC-3 — manifesto value 5 contract requires it.
* **Q-STC-4 (closed):** YAML or JSON? **YAML.** D-STC-1 — diff
  readability.
* **Q-STC-5 (closed):** Merge brand + platform palettes? **No, first-
  match-wins.** D-STC-2 — explicit semantics.
* **Q-STC-6 (closed):** Cache TTL or rebuild-on-edit? **Process
  lifetime cache; dev bypass.** D-STC-4 — deploy is the invalidation.
