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-185 — capabilitiesFor 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-410 — registerColibriTool 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 typedToolNotAdmittedErrorat 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-lockstage (stage 1 of the 5-stage chaintool-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:
registerColibriToolinspects the current mode viacapabilitiesFor(mode)and the per-toolrequiresset (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 isvoid 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
READONLYserver refuses mutating tools at the lock stage withToolNotAdmittedError. (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: booleantoColibriToolConfig. Stage 4 (dispatch) readscapabilitiesFor(ctx.mode). Ifmutates && !canWriteDatabase, returnERR_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.mdand 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.mdAGENTS.mddocs/colibri-system.mddocs/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
READONLYserver 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.tssuite — 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.