P0.2.4 — Health Check Tool — Audit

1. Scope of this audit

P0.2.4 lands a single MCP tool — server_health — that returns a snapshot of the server’s runtime state:

{ status: "ok", version, uptime_ms, db_tables, phase, mode }

The six fields come from three surfaces already present in the repo:

  1. ColibriServerContext (P0.2.1, src/server.ts) — already supplies version, mode, nowMs, bootStartMs, and the registerColibriTool helper that wraps the 5-stage α middleware chain.
  2. initDb / getDb (P0.2.2, src/db/index.ts) — open the SQLite singleton. The sqlite_master table is the source of truth for db_tables.
  3. startup() (P0.2.3, src/startup.ts) — runs Phase 1 (transport) and Phase 2 (DB open). The new tool must be able to answer during BOTH phases — during Phase 1 there is no DB; during Phase 2 the DB is live.

This audit answers: what is already in the repo that server_health must reuse, and what seams must P0.2.4 carve into the existing surface to reach the DB + phase state without breaking P0.2.1 / P0.2.2 / P0.2.3 contracts?

2. Existing surface — src/server.ts (from P0.2.1, 569 LOC)

2.1. ColibriServerContext shape (L147-164)

Readonly fields that P0.2.4 consumes as-is:

Field Type Source Use in health tool
server McpServer createServer Registers the tool via registerTool (wrapped by registerColibriTool).
version string package.json Returned verbatim as version.
mode RuntimeMode detectMode(process.env) Returned verbatim as mode.
nowMs () => number performance.now() Used for uptime_ms = Math.floor(nowMs() - bootStartMs).
bootStartMs number captured at createServer time Baseline for uptime.
logger (...args) => void console.error Not used by the handler (no error path is logged).

No existing field gives P0.2.4 the DB handle or the current phase. Two new fields must be added (deviation #4 and #5 in the task brief):

New field Type Writer Reader
db Database.Database \| undefined startup.ts Phase 2 after initDb() server_health handler
phase "phase1" \| "phase2" \| undefined bootstrap() sets "phase1"; startup.ts Phase 2 bumps to "phase2" server_health handler

Both are mutable (writable after construction), which is a departure from the otherwise-readonly context. Per the task spec we add them without the readonly modifier; existing readonly fields stay untouched. Default is undefined so an incompletely-initialised ctx (rare path) returns phase: undefined + db_tables: 0 safely.

2.2. registerColibriTool (L268-399)

Signature:

registerColibriTool<I extends z.ZodRawShape>(
  ctx: ColibriServerContext,
  name: string,
  toolConfig: ColibriToolConfig<I>,
  handler: (args: z.infer<z.ZodObject<I>>) => Promise<unknown> | unknown,
): void

Wraps the handler in the 5-stage chain: tool-lock → schema-validate → audit-enter → dispatch → audit-exit.

Key invariants P0.2.4 inherits for free:

  • TOOL_NAME_RE (L261) — ^[a-z_][a-z0-9_]*$. server_health matches. The task spec’s server/health form would be rejected (deviation #2).
  • Duplicate detection — a second registerColibriTool(ctx, 'server_health', …) throws tool already registered. Tests that call bootstrap() twice against the same ctx must reset _registeredToolNames.
  • Envelope wrapping — the handler’s return value x becomes { ok: true, data: x } under response.structuredContent. Tests assert against the envelope shape, not the raw handler return.
  • Audit events — every call emits one enter + one exit through ctx.auditSink. Tests can use the makeRecordingSink pattern from server.test.ts L61-78 to assert event emission.

2.3. bootstrap() (L517-546)

Current body:

export async function bootstrap(
  options: BootstrapOptions = {},
): Promise<ColibriServerContext> {
  const exit = options.exit ?? process.exit.bind(process);
  const ctx = createServer(options.createOptions ?? {});
  try {
    registerColibriTool(ctx, 'server_ping', { ... }, (): { ... } => ({ ... }));
    await start(ctx);
    return ctx;
  } catch (err) {
    ctx.logger('[colibri] fatal:', err);
    exit(1);
    return ctx;
  }
}

P0.2.4 will add, after the server_ping registration (deviation #3):

ctx.phase = 'phase1';              // before start()
registerHealthTool(ctx);           // after server_ping

The ordering is important:

  1. ctx.phase = 'phase1' must be set before start(ctx) so a client calling server_health during the Phase 1 handshake window sees phase: "phase1" (the “phase tracking” property the task demands).
  2. registerHealthTool(ctx) must run before start(ctx) because MCP SDK forbids registerTool after server.connect(transport) (verified in server.test.ts L127-176 linked-pair harness, which always registers before connect).

2.4. Script-invocation IIFE (L557-568)

Unchanged. P0.2.4 does not touch the if (isInvokedAsScript()) block. The dynamic import of ./startup.js handles ctx.phase = 'phase2' and ctx.db = db via the Phase 2 hook.

3. Existing surface — src/startup.ts (from P0.2.3, 425 LOC)

3.1. Phase 2 heavy-init (L246-268)

logger('[Startup] Phase 2: heavy-init...');
try {
  const db = initDbFn(dbPath);
  const elapsedMs = Math.floor(nowMs() - phase1StartMs);
  logger(`[Startup] Complete in ${elapsedMs}ms`);
  return { ctx, db, elapsedMs };
} catch (err) {
  ...
}

P0.2.4 adds two mutations between initDbFn and return:

const db = initDbFn(dbPath);
ctx.db = db;
ctx.phase = 'phase2';
const elapsedMs = Math.floor(nowMs() - phase1StartMs);
return { ctx, db, elapsedMs };

Both writes MUST run before the return. A thrown initDbFn aborts before the mutations — ctx.phase stays "phase1" and ctx.db stays undefined, which is the correct Phase-2-failure payload (phase: "phase1", db_tables: 0).

3.2. shutdown() interaction

shutdown() calls closeDbImpl() (closeDb from src/db/index.ts). After closeDb, getDb() throws and ctx.db becomes a reference to a closed handle. The server_health handler MUST NOT assume the handle is live — it must catch SqliteError from .prepare(...) / .pluck().get() and return db_tables: 0 safely.

In practice, Phase 0 shutdown closes the transport before the DB, so a server_health call would hit a closed transport first and never reach the handler. But the handler still needs the defensive check for the rare re-ordering case (e.g. future signal-handler refactor) and to preserve the “never throws from handler” invariant.

4. Existing surface — src/db/index.ts (from P0.2.2, 320 LOC)

4.1. sqlite_master discovery

better-sqlite3 exposes the DB schema via the standard sqlite_master table. The Phase 0 query for db_tables is:

SELECT COUNT(*) AS c FROM sqlite_master
  WHERE type = 'table' AND name NOT LIKE 'sqlite_%'

Alternatives considered and rejected:

  • PRAGMA table_list — returns a richer result set (including virtual tables and attached-DB tables) but is sqlite 3.37+; better-sqlite3@^11.5 bundles sqlite 3.45+ so availability is fine. Rejected because the COUNT(*) aggregate is cheaper and the filter is explicit.
  • PRAGMA database_list — lists attached databases, not tables. Wrong granularity.

The sqlite_% filter excludes sqlite_sequence, sqlite_stat1, and the internal sqlite_schema alias. Migration-created tables never start with sqlite_ (lint rule in 001_init.sql comments).

4.2. Current table count

After 001_init.sql (empty body, user_version = 1): 0 tables.

The migration deliberately ships no schema — Phase 0 α owns the file, not the schema. Per-concept tables are earned by later migrations:

Migration Owner Tables added
002_beta.sql P0.3.2 β CRUD tasks, task_transitions
003_epsilon.sql P0.6.2 ε CRUD skills
004_zeta.sql P0.7.2 ζ record store thoughts
005_eta.sql P0.8 η Merkle merkle_frames, merkle_leaves
006_nu.sql P0.9 ν integrations integrations, integration_events

This means server_health will return db_tables: 0 for the entire Phase 0 P0.2.4 → P0.3.2 window. Tests must pin this expectation AND demonstrate that the count grows correctly when a test migration adds a table (use an in-memory DB with CREATE TABLE to simulate later migrations without depending on Wave E code).

4.3. getDb() vs ctx.db

getDb() throws when the singleton is null. P0.2.4’s handler must NOT call getDb() — it has no access to the module singleton through the ctx-first pattern. It reads ctx.db instead, which is undefined during Phase 1 and defined during Phase 2. This keeps the handler pure with respect to module state.

5. Existing surface — src/modes.ts (from P0.4.1, 186 LOC)

RUNTIME_MODES tuple (L31):

export const RUNTIME_MODES = ['FULL', 'READONLY', 'TEST', 'MINIMAL'] as const;

RuntimeMode type (L54) = 'FULL' | 'READONLY' | 'TEST' | 'MINIMAL'.

server_health returns the mode string verbatim. The Zod output schema must accept any of the four values. Tests instantiate the ctx with each mode and assert round-trip.

6. Existing test conventions (from Wave A/B/C test files)

6.1. Test file location

Per the Sigma-locked deviation #1, tests live at src/__tests__/tools/health.test.ts, not tests/tools/health.test.ts. This matches:

  • src/__tests__/server.test.ts (P0.2.1)
  • src/__tests__/startup.test.ts (P0.2.3)
  • src/__tests__/db-init.test.ts (P0.2.2)
  • src/__tests__/task-state-machine.test.ts (P0.3.1)
  • src/__tests__/skill-schema.test.ts (P0.6.1)
  • src/__tests__/trail-schema.test.ts (P0.7.1)

The src/__tests__/tools/ subdirectory is new to this repo but mirrors the expected future layout of src/__tests__/domains/tasks/, src/__tests__/domains/skills/, etc.

6.2. Harness patterns

From server.test.ts:

  • makeLinkedPair(extra) (L115-177) — builds a ctx with InMemoryTransport.createLinkedPair(), connects a Client, and returns { ctx, client, cleanup }. Use this to call server_health end-to-end.
  • makeRecordingSink() (L61-78) — builds an AuditSink that pushes every enter / exit into a shared array. Use this to assert the 5-stage chain runs around server_health.
  • makeRecordingTransport() (L84-107) — no-op transport for tests that never connect. Not needed here; health tests use linked pairs or direct invocation.

From db-init.test.ts:

  • makeTempDbPath() (L37-41) — allocates a unique os.tmpdir() subdirectory for a real SQLite file. Use this for tests that need a real initDb + initDbFn seam.
  • afterEach cleanup (L55-66) — swallows Windows WAL file lock errors. P0.2.4 tests inherit the same pattern.

6.3. jest.isolateModulesAsync is broken

config.test.ts documents that jest.isolateModulesAsync fails under ts-jest ESM (zod locale cache bug). P0.2.4 does NOT use it. For per-test state resets (e.g. phase mutations), we construct a fresh ctx per test.

6.4. Coverage target

100% branch coverage on src/tools/health.ts. Branches to cover:

  • ctx.db === undefineddb_tables: 0
  • ctx.db !== undefined, query succeeds → db_tables: N
  • ctx.db !== undefined, query throws → db_tables: 0 (defensive)
  • ctx.phase === undefined → default to “phase1” (sentinel — in practice bootstrap always sets it, but the narrowing branch exists)
  • ctx.phase === "phase1" → emit “phase1”
  • ctx.phase === "phase2" → emit “phase2”

7. Risks and mitigations

# Risk Mitigation
R-1 Parallel Wave D collision on src/server.ts bootstrap() edit is a single 2-line append after L536 (registration) + 1-line append before start() (phase init). Trivial to rebase; conflict resolution is line-level.
R-2 Parallel Wave D collision on src/startup.ts P0.6.2, P0.7.2 don’t touch startup.ts. Only P0.2.4 mutates Phase 2. Low collision probability.
R-3 ColibriServerContext extension breaks P0.2.1 / P0.2.3 type assumptions The new fields are optional (db?, phase?). All existing readonly fields stay. No test in server.test.ts or startup.test.ts asserts on the shape as a closed set, so TS structural typing accepts the extension.
R-4 Query on closed DB handle throws Handler wraps query in try/catch → db_tables: 0 on any DB error. Preserves the “never throws from handler” invariant.
R-5 Response time > 100ms SELECT COUNT(*) FROM sqlite_master on an empty DB is O(1) and executes in < 1ms on any reasonable hardware. The test asserts < 100 ms against performance.now() around the client.callTool call.
R-6 Cross-worktree leak (Wave C saw one) Pre-clean already ran git status — clean. Step 1 of audit mandates it.

8. References

  • Task spec: docs/guides/implementation/task-breakdown.md L140-151
  • P0.2.1 audit/contract/packet (src/server.ts shape)
  • P0.2.2 audit/contract/packet (SQLite + sqlite_master)
  • P0.2.3 audit/contract/packet (Phase 1 / Phase 2 split)
  • P0.4.1 contract (RuntimeMode tuple + capability matrix)
  • src/server.ts L147-164 — ColibriServerContext
  • src/server.ts L268-399 — registerColibriTool
  • src/server.ts L517-546 — bootstrap
  • src/startup.ts L246-268 — Phase 2 body
  • src/db/index.ts L230-284 — initDb + pragmas
  • src/modes.ts L31 — RUNTIME_MODES tuple

9. Audit conclusion

All five required inputs exist and are stable:

  1. ColibriServerContext with version / mode / nowMs / bootStartMs
  2. registerColibriTool helper (5-stage chain wrapper)
  3. initDb + better-sqlite3sqlite_master for db_tables
  4. startup() Phase 1 / Phase 2 split for phase tracking
  5. RuntimeMode string for mode

P0.2.4 can proceed to Step 2 (contract) with the five Sigma-approved deviations locked:

  1. Test path src/__tests__/tools/health.test.ts (not tests/tools/...)
  2. Tool name server_health (not server/health — regex rejects slash)
  3. Registration in bootstrap() after server_ping
  4. ColibriServerContext.db? field for the DB handle
  5. ColibriServerContext.phase? field for phase tracking

No cross-worktree leaks. Pre-clean clean.


Back to top

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

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