P0.6.3 — Step 2 Behavioral Contract

Grounded in ../audits/p0-6-3-capability-index-audit.md. Defines the public API, invariants, and error contracts for the ε capability index — the final module that closes the ε Skill Registry axis.


C1. Public API

All exports live in src/domains/skills/capability-index.ts and (for the live-state getter) src/domains/skills/repository.ts. No barrel, no eager side effect on import.

C1.1 Types

// src/domains/skills/capability-index.ts

/**
 * Minimal shape the index quantifies over. `SkillRow` (from repository.ts) is
 * structurally assignable to this, as is any test fixture that produces the
 * same two fields.
 */
export interface IndexableSkill {
  readonly name: string;
  readonly capabilities: readonly string[];
}

SkillRow (P0.6.2, unchanged) is the production value that flows into the index. The narrowed IndexableSkill is what the two pure helpers accept — this keeps the module test-friendly.

C1.2 Pure helpers

/**
 * Build a reverse lookup: capability string → Set of skill names that declare
 * it. Skills with multiple capabilities appear in every bucket. Skills with
 * an empty `capabilities` array contribute to no bucket (the index size is
 * unaffected). Duplicate entries within a single skill's `capabilities`
 * collapse at the Set level.
 *
 * Pure, synchronous, side-effect free. Safe to call multiple times.
 *
 * @param skills  Read-only view of skills to index. Input order does not
 *                affect the index — the Set membership is content-addressed
 *                by (capability, name).
 * @returns       `Map<capability, Set<skillName>>`. Keys are the capability
 *                strings **as they appear in the input** (no normalization).
 *                Values are Sets; iteration order is insertion order per JS
 *                spec, but callers should not rely on it — use
 *                `findSkillsByCapability` to get a sorted view.
 */
export function buildCapabilityIndex(
  skills: readonly IndexableSkill[],
): Map<string, Set<string>>;

/**
 * Query the index. Returns a **sorted** `string[]` of skill names that
 * declare `capability`, or `[]` if the capability is unknown (not present in
 * any skill's frontmatter).
 *
 * Sorting is lexicographic ASCII-ascending via `Array.prototype.sort()` with
 * no comparator. This matches the SQL `ORDER BY name ASC` ordering used by
 * `listSkills`, giving the parity test a clean equality assertion.
 *
 * Pure, synchronous, side-effect free.
 *
 * @param index       A Map produced by `buildCapabilityIndex`.
 * @param capability  The capability string to look up. Empty string returns
 *                    whatever `index.get("")` holds (normally `[]` because
 *                    the empty string is not a valid frontmatter capability).
 */
export function findSkillsByCapability(
  index: Map<string, Set<string>>,
  capability: string,
): string[];

C1.3 Live-state getter (lives in repository.ts)

// src/domains/skills/repository.ts  (new export)

/**
 * Return the current in-process capability index. The index is populated by
 * `loadSkillsFromDisk` — callers that query before any load see an empty Map
 * (not `undefined`, not `null`). After the first load, the returned Map
 * reflects the most recently loaded skill set; subsequent loads atomically
 * replace the Map reference (no in-place mutation of a Map held by prior
 * callers).
 *
 * Pure read; does not log, does not touch the DB.
 */
export function getCapabilityIndex(): Map<string, Set<string>>;

A non-exported __resetCapabilityIndexForTests() function is available inside the module for Jest suites that need a clean slate within a single process (e.g. any future cross-module integration test). It is not part of the public API and is not imported outside the test tree.

C1.4 No new MCP tool

capability-index.ts contains zero calls to registerColibriTool. The Phase 0 ε surface stays at exactly one tool (skill_list), per S17 §1 and ADR-004.


C2. Invariants

INV1 — Multi-capability membership

A skill whose capabilities array contains ["read", "write"] appears in both the "read" Set and the "write" Set of the resulting index. It is not placed in a combined "read+write" key, and it does not appear in exactly one of the two.

INV2 — Set semantics

Within a single capability bucket, each skill name appears at most once. Duplicate capabilities in a single skill’s frontmatter (which Zod would already reject at parse time) are silently collapsed by the Set. This makes findSkillsByCapability safe to call even on a malformed input fixture.

INV3 — Sorted return

findSkillsByCapability(index, C) returns a string[] whose elements are in ASCII-ascending order. This is asserted in every unit test that checks the return value.

INV4 — Unknown-capability → empty array

findSkillsByCapability(index, "nonexistent") returns [] (a fresh array), not undefined, not null, not a shared sentinel. Callers may mutate the returned array without affecting the index.

INV5 — Purity of index functions

buildCapabilityIndex and findSkillsByCapability do not:

  • read files
  • call console.* or any logger
  • mutate their inputs
  • open DB connections
  • throw except on genuinely unrecoverable input (which in TypeScript, with the above signatures, means only environmental failures like Set allocation — not in-scope for Phase 0).

INV6 — Index lifecycle

The module-level _capabilityIndex in repository.ts starts as new Map() on import. It is replaced (not mutated) by loadSkillsFromDisk after each successful DB transaction. getCapabilityIndex() returns the current reference.

INV7 — Parity with listSkills({capability})

For any capability string C present in the current skill set, the following must hold:

const viaIndex = findSkillsByCapability(getCapabilityIndex(), C).sort();
const viaSql   = listSkills(db, { capability: C }).map(r => r.name).sort();
expect(viaIndex).toEqual(viaSql);

This is the load-bearing contract between the SQL filter (LIKE '%"C"%') and the in-process index. Both sides are case-sensitive and use exact token match. The parity test in the new test suite asserts this for every capability declared in the fixture, including unknown capabilities (both return []).

INV8 — Build-once invariant

buildCapabilityIndex is called exactly once per loadSkillsFromDisk call, and only after the upsert+prune transaction has committed. It is not called on every MCP request, not lazily on the first getCapabilityIndex call, and not from any watcher.

INV9 — No subprocess, no worktree, no agent runtime

The capability-index module does not:

  • spawn subprocesses (child_process, worker_threads)
  • invoke git or create worktrees
  • export any agent_* symbol
  • reference src/domains/agents/ (which does not exist)

This is enforced by static audit and by the diff scope — the entire module fits in ~130 LOC of pure TypeScript.


C3. Error contracts

C3.1 buildCapabilityIndex

  • Valid input (empty or non-empty readonly IndexableSkill[]): returns a Map. Empty input returns an empty Map.
  • null / undefined input: not covered by the type signature; not tested. TypeScript strict mode prevents this at the call site.
  • SkillRow with unexpected runtime shape (capabilities: undefined): the inner for (const cap of skill.capabilities) iteration throws TypeError. This is acceptable — the DB column has DEFAULT '[]' and decodeRow guarantees readonly string[]. The only path where a mutated row could slip in is through a test fixture, which is the test author’s responsibility.

C3.2 findSkillsByCapability

  • Valid input: returns a sorted string[], possibly empty.
  • capability = "" (empty string): returns whatever index.get("") holds, which for a well-formed index is always [] (the SKILL.md parser rejects empty capability strings at the Zod layer). No error.
  • capability present in Map but Set is empty (theoretical edge case, not observable in production because we only ever add to a Set): returns []. Sorted empty array.
  • No throws.

C3.3 getCapabilityIndex

  • Always returns a Map, never undefined. Empty Map if loadSkillsFromDisk has not yet been called in the current process.
  • No throws.

C4. Storage model

No storage. The capability index is in-process memory only. Rebuilt on every loadSkillsFromDisk call from the post-transaction row set. There is no persistence across process restart — the index is reconstructed from the DB at startup, which is cheap (O(N) over the skill count; N is in the 10s for Phase 0).


C5. Integration with loadSkillsFromDisk

The following sequence is appended to loadSkillsFromDisk, after the existing tx() invocation and the summary logger(...) call:

const postLoadRows = listSkills(db);   // full list, no filter
_capabilityIndex = buildCapabilityIndex(postLoadRows);

That’s the entire integration. Three lines inside loadSkillsFromDisk (the comment, the read, the assign). getCapabilityIndex() is a one-line getter.

Why after the summary logger: the summary log is part of the contract-visible output of the loader. The index build does not log (INV5). Placing the index build after the log keeps logger output stable and makes the diff obvious.

Why listSkills(db) and not the upserts in-memory array: using listSkills(db) after commit ensures the index sees post-prune state. If we used the upsert array, pruned rows would remain in the index until the next process restart. Reading from the DB is O(N) over the same rows — no observable cost.


C6. Test suite contract

New file: src/__tests__/domains/skills/capability-index.test.ts.

C6.1 Fixtures

Pure in-memory fixtures — no tmpdir, no SKILL.md files, no SQLite. The fixture shape is a plain array of { name, capabilities } objects satisfying IndexableSkill. The parity test uses a real in-memory DB with 004_skills.sql applied, same fixture loaded via listSkills.

C6.2 Test suites (describe blocks)

Suite Tests Property covered
buildCapabilityIndex — empty 1 Empty input → empty Map
buildCapabilityIndex — single-capability 2 One skill, one capability → one bucket with one name
buildCapabilityIndex — multi-capability 2 One skill with ["read","write"] → appears in both buckets (INV1)
buildCapabilityIndex — multiple skills 2 Three skills, mixed capabilities → each bucket holds the right names
buildCapabilityIndex — duplicate capabilities in one skill 1 Set semantics (INV2)
buildCapabilityIndex — skill with empty capabilities 1 No bucket contribution
findSkillsByCapability — unknown capability 1 Returns [] (INV4)
findSkillsByCapability — sorted return 2 Lexicographic ASCII sort (INV3)
findSkillsByCapability — known capability 1 Returns the sorted names for one bucket
findSkillsByCapability — empty string key 1 Returns []
findSkillsByCapability — returns fresh array 1 Mutating the result does not affect the index
getCapabilityIndex — before any load 1 Empty Map (INV6)
getCapabilityIndex — after loadSkillsFromDisk 2 Index reflects loaded skills
getCapabilityIndex — after second load (rebuild) 1 Post-prune state reflected
parity — listSkills({capability: C}) vs findSkillsByCapability(index, C) 1 parameterized Load-bearing INV7 check — iterates every capability present in the fixture + one unknown, asserts set equality with a sorted-.map(r=>r.name) projection

Total: ~20 tests.

C6.3 Test ordering + isolation

  • Each test uses a fresh in-memory DB (via makeTestDb() helper, reused from P0.6.2 test file style).
  • Each test constructs its own fixture — no shared mutable state between tests.
  • __resetCapabilityIndexForTests() is called in beforeEach of any suite that touches the module-level getter, to isolate test order.

C7. Non-contracts (explicit negative space)

  • No skill_get MCP tool.
  • No skill_reload MCP tool.
  • No agent_spawn, agent_status, agent_list MCP tools.
  • No file watcher (neither fs.watch nor chokidar).
  • No src/domains/agents/ directory.
  • No subprocess spawn.
  • No worktree creation from this task (the worktree the task runs in is not code-managed).
  • No normalization of capability strings (case / whitespace).
  • No new migration.
  • No persistence of the index.
  • No change to src/startup.ts.
  • No change to src/server.ts.
  • No change to the skill_list tool’s input or output shape.

C8. Acceptance criteria trace (second pass, post-contract)

AC Contract anchor
buildCapabilityIndex returns Map<string, Set<string>> C1.2 signature
Multi-capability skills in every relevant bucket INV1
findSkillsByCapability returns sorted string[], [] for unknown INV3, INV4
Index built once in loader, not lazily INV8 + §C5
Parity with listSkills({capability}) confirmed by test INV7 + C6.2 parity suite
Zero new MCP tools C1.4, §C7
No src/domains/agents/ created, no subprocess spawning INV9, §C7
No file watcher, no hot-reload path §C7 + §C5

Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.