ADR-008 — Audit: documented-vs-implemented mode enforcement gap

Step: 1 of 5 (audit) Date: 2026-05-06 Branch: feature/adr-008-mode-enforcement Worktree: .worktrees/claude/adr-008-mode-enforcement Scope: ADR-only (no source code under audit). Inventory the runtime + docs surface around COLIBRI_MODE and capabilitiesFor to ground ADR-008.


1. Finding under inspection

A whole-system code review surfaced Finding #1: the runtime accepts COLIBRI_MODE and computes capability records, but no tool dispatch path consults them. A READONLY server happily executes task_update, thought_record, merkle_finalize. Callers cannot rely on the documented mode contract.

This audit pins the exact lines on both sides of the gap.


2. Runtime — code citations at HEAD (86e430fb)

2.1. src/modes.ts:174-185capabilitiesFor is exhaustive and frozen

export function capabilitiesFor(mode: RuntimeMode): ModeCapabilities {
  switch (mode) {
    case 'FULL':     return FULL_CAPS;
    case 'READONLY': return READONLY_CAPS;
    case 'TEST':     return TEST_CAPS;
    case 'MINIMAL':  return MINIMAL_CAPS;
  }
}

Each *_CAPS record is Object.freezed at module init (lines 126-155). The four fields are canWriteDatabase, canAcceptMCPConnections, canDispatchExternalIO, canRunIntegrationTests. READONLY and MINIMAL set canWriteDatabase: false; FULL and TEST set canWriteDatabase: true. The function itself is correct and ready to consult.

2.2. src/server.ts:226-229 — the only call site, return value discarded

// Capability read — currently advisory (pure-mutex tool-lock per contract §2
// + T0 Q-1 decision). Kept as an explicit reference so the mode → capability
// dependency is compiled-in for future P0.2.3 / P0.4.2 wiring.
void capabilitiesFor(mode);

This is createServer(). The call exists solely to keep the dependency edge alive for the type checker. The return value is never bound, never read, and never consulted by any other code path. This is the precise location of Finding #1.

2.3. src/server.ts:279-410registerColibriTool ignores the mode

The 5-stage middleware wrapper is composed inside registerColibriTool. Stage 1 (runWithToolLock, lines 412-437) is a per-tool mutex keyed on tool name only — there is no mode read. Stages 2-5 (schema-validate, audit-enter, dispatch, audit-exit) likewise never reference ctx.mode or the capability record.

2.4. Tool registration — no mutates declaration exists

ColibriToolConfig (lines 120-125) declares title, description, inputSchema, outputSchema. There is no mutates, no requires, no capability tag. Per-tool registration cannot opt in to enforcement because there is nothing to opt in with.

2.5. The 14 shipped tool registrations

Tool File Line Reads Writes
server_ping src/server.ts 538 n/a no
server_health src/tools/health.ts 158 DB count no (read-only SELECT)
task_create src/domains/tasks/repository.ts 673 yes (INSERT)
task_get src/domains/tasks/repository.ts 692 DB no
task_update src/domains/tasks/repository.ts 718 yes (UPDATE)
task_list src/domains/tasks/repository.ts 773 DB no
task_next_actions src/domains/tasks/repository.ts 792 DB no
skill_list src/domains/skills/repository.ts 453 DB no
thought_record src/domains/trail/repository.ts 368 yes (INSERT)
thought_record_list src/domains/trail/repository.ts 380 DB no
audit_verify_chain src/domains/trail/verifier.ts 175 DB no
audit_session_start src/tools/merkle.ts 418 yes (INSERT)
merkle_finalize src/tools/merkle.ts 447 yes (INSERT Merkle root + flag session)
merkle_root src/tools/merkle.ts 496 DB no

Mutating tools: 5 (task_create, task_update, thought_record, audit_session_start, merkle_finalize). Read-only tools: 9. None of the 14 declares its mutation posture in code today.

2.6. Mode plumbing into the context

CreateServerOptions.mode (line 137) and ColibriServerContext.mode (line 159) are present and propagated. bootstrap() calls detectMode(process.env) if mode is unset (line 217). server_health returns ctx.mode in its payload (buildHealthPayload at src/tools/health.ts:130-139). The context shape needed for enforcement is already in place; only the consult-and-refuse step is missing.


3. Docs — surfaces that imply enforcement

3.1. docs/2-plugin/modes.md — explicit “rejected” prose

Line 31 (verbatim):

The tool-lock stage of the α chain (stage 1 of tool-lock → schema-validate → audit-enter → dispatch → audit-exit) consults the active mode. A request whose tool is not in the admitted set is rejected with a typed ToolNotAdmittedError at the lock stage, before schema validation, dispatch, or auditing run. The rejection itself is audited as a denied event but is not counted as an executed call.

Lines 21-25 list the per-mode admitted tools:

Mode Tools admitted Writes
FULL all 14 shipped Phase 0 tools yes
READONLY read-side tools only no
TEST all 14 shipped tools yes (test DB)
MINIMAL server_ping, server_health only no

Lines 50-59 give a per-mode tool-count table. The whole document reads as a contract about admission.

3.2. docs/architecture/decisions/ADR-004-tool-surface.md — softer hint

Line 68 (the server_health row): “DB open, middleware registered, runtime mode” — implies mode is part of the operating contract a server_health consumer should care about. ADR-004 is otherwise silent on enforcement; it does not contradict modes.md but it leans on the same model.

3.3. docs/reference/mcp-tools-phase-0.md — declared mode enum

Line 930 describes server_health as returning “runtime mode (FULL | READONLY | TEST | MINIMAL)”. Line 941 declares the mode field in the output schema. Line 988 lists ERR_INVALID_MODE as a documented error code. The error code exists in prose but no tool currently emits it.

3.4. docs/colibri-system.md — top-line statement

Line 58: “Phase 0 runtime modes: 4: FULL, READONLY, TEST, MINIMAL. Selected via COLIBRI_MODE. No AMS_* env namespace is read by Phase 0 code.”

The canonical vision endorses the mode set but does not specify enforcement.

3.5. docs/3-world/physics/laws/rule-engine.md:164 — Phase-1 κ overlay

Admission — whether a given tool call is allowed at all — is a κ evaluation that runs at α’s tool-lock stage (stage 1 of the 5-stage chain tool-lock → schema-validate → audit-enter → dispatch → audit-exit). The inputs are: caller identity, tool name, current mode, reputation snapshot, and rule version. The output is admit-or-deny with a typed reason.

This is a Phase 1+ aspiration and currently spec-only. Mode is one of its inputs.

3.6. docs/audits/p0-2-1-mcp-server-audit.md:122 — audit-time contradiction (already flagged)

The P0.2.1 audit explicitly noted the contradiction between modes.md (consult mode at stage 1) and middleware.md + s17 (stage 1 is pure mutex). The packet at the time chose pure-mutex on the basis that capability gating could happen “either at tool registration time” or “as a sixth implicit stage in P0.4.2 or P0.2.3”. Neither path was ever taken; capability gating disappeared and was never ADR’d.

3.7. docs/contracts/p0-2-1-mcp-server-contract.md:74 — the original deferral

Stage 1 tool-lock in P0.2.1 is pure per-tool mutex, matching s17 + middleware.md. Capability-gating (which tools are admitted per mode) is a SEPARATE concern handled at TOOL REGISTRATION time: registerColibriTool inspects the current mode via capabilitiesFor(mode) and the per-tool requires set (added in a later task — P0.4.2 or P0.2.3), and refuses to register tools the current mode does not admit.

This is the design surface the runtime never delivered. ADR-008 must either close it (Option A or B) or retract it (Option C).

3.8. docs/audits/r82-f-deploy-boot-audit.md:44 — R82 confirmation

capabilitiesFor(mode) is read advisorily — the call is void capabilitiesFor(mode) with comment “currently advisory (pure-mutex tool-lock per contract §2 + T0 Q-1 decision)”. There is no mode-gated tool-registration in Phase 0. Every registered tool is exposed in every mode. Mode-based admission is deferred.

R82 stamped the gap as known and present in the canonical record. It is the most recent in-tree statement of the issue prior to ADR-008.


4. The contradiction in two sentences

  • Docs say: a READONLY server refuses mutating tools at the lock stage with ToolNotAdmittedError. (modes.md:31)
  • Code says: the only place that reads the capability record is void-discarded; the lock stage only serializes per-tool concurrency. (src/server.ts:226-229, src/server.ts:412-437)

A user who reads modes.md and sets COLIBRI_MODE=READONLY to safely inspect a frozen DB will be silently lied to. The first task_update they try will succeed and the DB will be mutated.


5. ADR scope (what ADR-008 must decide)

Three options per the dispatch packet, each closing the gap differently:

  • A — Enforced via tool config flag. Add mutates: boolean to ColibriToolConfig. Stage 4 (dispatch) reads capabilitiesFor(ctx.mode). If mutates && !canWriteDatabase, return ERR_READONLY_MODE. New stage in middleware semantics; per-tool declaration; small surface.
  • B — Per-tool opt-in via decorator/wrapper. A new requireCapability(name, fn) helper wraps tool handlers voluntarily. Tool authors must remember to opt in.
  • C — Amend ADR-004 to mark mode advisory. Document modes as informational. Update modes.md and friends to remove the implication of safety enforcement.

The recommended option per the dispatch packet is A.


6. ADR coexistence with ADR-007

ADR-007 (η session lifecycle) is being drafted in parallel in a sibling worktree. ADR-008 is independent: it touches src/server.ts and src/modes.ts in its implementation plan, neither of which ADR-007 modifies. The two ADRs land together as ADR-007 + ADR-008. Index entries in docs/architecture/decisions/index.md and docs/architecture/decisions/README.md will list both. No semantic conflict; no shared file.


7. Files that ADR-008 itself must NOT touch (per dispatch guardrails)

  • CLAUDE.md
  • AGENTS.md
  • docs/colibri-system.md
  • docs/reference/mcp-tools-phase-0.md

These are touched by the follow-up implementation task, not by ADR-008.

ADR-008 may cite them; it must not edit them.


8. Files this task creates or edits

Step Path Action
1 (audit) docs/audits/adr-008-mode-enforcement-audit.md this file
2 (contract) docs/contracts/adr-008-mode-enforcement-contract.md new
3 (packet) docs/packets/adr-008-mode-enforcement-packet.md new
4 (implement) docs/architecture/decisions/ADR-008-mode-enforcement.md new
4 (implement) docs/architecture/decisions/index.md edit (add ADR-008 entry)
4 (implement) docs/architecture/decisions/README.md edit (add ADR-008 to status table)
5 (verify) docs/verification/adr-008-mode-enforcement-verification.md new

npm run build && npm run lint && npm test must still pass — no source code changes in this task.


9. Test surface today (before ADR-008 is implemented)

  • 1357 tests pass at 86e430fb (R83 close projection per memory; verified via local re-run in §verify).
  • No test currently asserts mode-gated rejection. A READONLY server in tests is configured but never tested for refusal of mutations.
  • A follow-up implementation task (per ADR-008 §Implementation) adds a mode-enforcement.test.ts suite — outside the scope of this ADR-only round.

10. Cross-references

  • Code: src/modes.ts:174, src/server.ts:229, src/server.ts:412-437, src/tools/health.ts:130.
  • Docs: docs/2-plugin/modes.md:31, docs/architecture/decisions/ADR-004-tool-surface.md:68, docs/reference/mcp-tools-phase-0.md:930, docs/colibri-system.md:58.
  • Prior audits: docs/audits/p0-2-1-mcp-server-audit.md:122, docs/contracts/p0-2-1-mcp-server-contract.md:74, docs/audits/r82-f-deploy-boot-audit.md:44.
  • Coexistence: ADR-007 (η session lifecycle, sibling round) — independent ADR.

Back to top

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

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