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:
ColibriServerContext(P0.2.1,src/server.ts) — already suppliesversion,mode,nowMs,bootStartMs, and theregisterColibriToolhelper that wraps the 5-stage α middleware chain.initDb/getDb(P0.2.2,src/db/index.ts) — open the SQLite singleton. Thesqlite_mastertable is the source of truth fordb_tables.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_healthmatches. The task spec’sserver/healthform would be rejected (deviation #2). - Duplicate detection — a second
registerColibriTool(ctx, 'server_health', …)throwstool already registered. Tests that callbootstrap()twice against the same ctx must reset_registeredToolNames. - Envelope wrapping — the handler’s return value
xbecomes{ ok: true, data: x }underresponse.structuredContent. Tests assert against the envelope shape, not the raw handler return. - Audit events — every call emits one
enter+ oneexitthroughctx.auditSink. Tests can use themakeRecordingSinkpattern fromserver.test.tsL61-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:
ctx.phase = 'phase1'must be set beforestart(ctx)so a client callingserver_healthduring the Phase 1 handshake window seesphase: "phase1"(the “phase tracking” property the task demands).registerHealthTool(ctx)must run beforestart(ctx)because MCP SDK forbidsregisterToolafterserver.connect(transport)(verified inserver.test.tsL127-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.5bundles sqlite 3.45+ so availability is fine. Rejected because theCOUNT(*)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 withInMemoryTransport.createLinkedPair(), connects aClient, and returns{ ctx, client, cleanup }. Use this to callserver_healthend-to-end.makeRecordingSink()(L61-78) — builds anAuditSinkthat pushes everyenter/exitinto a shared array. Use this to assert the 5-stage chain runs aroundserver_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 uniqueos.tmpdir()subdirectory for a real SQLite file. Use this for tests that need a realinitDb+initDbFnseam.afterEachcleanup (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 === undefined→db_tables: 0ctx.db !== undefined, query succeeds →db_tables: Nctx.db !== undefined, query throws →db_tables: 0(defensive)ctx.phase === undefined→ default to “phase1” (sentinel — in practicebootstrapalways 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.mdL140-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.tsL147-164 — ColibriServerContextsrc/server.tsL268-399 — registerColibriToolsrc/server.tsL517-546 — bootstrapsrc/startup.tsL246-268 — Phase 2 bodysrc/db/index.tsL230-284 — initDb + pragmassrc/modes.tsL31 — RUNTIME_MODES tuple
9. Audit conclusion
All five required inputs exist and are stable:
- ✅
ColibriServerContextwithversion/mode/nowMs/bootStartMs - ✅
registerColibriToolhelper (5-stage chain wrapper) - ✅
initDb+better-sqlite3→sqlite_masterfordb_tables - ✅
startup()Phase 1 / Phase 2 split forphasetracking - ✅
RuntimeModestring formode
P0.2.4 can proceed to Step 2 (contract) with the five Sigma-approved deviations locked:
- Test path
src/__tests__/tools/health.test.ts(nottests/tools/...) - Tool name
server_health(notserver/health— regex rejects slash) - Registration in
bootstrap()afterserver_ping ColibriServerContext.db?field for the DB handleColibriServerContext.phase?field for phase tracking
No cross-worktree leaks. Pre-clean clean.