Middleware — the Phase 0 α chain

Every MCP tool call entering the Colibri server flows through a 5-stage α middleware chain before reaching its domain handler. The chain is the mechanical backbone of the α System Core concept and the subject of Phase 0 task P0.2.4.

This document specifies the chain that Phase 0 ships. For the donor genealogy (the 6-layer / 11-file AMS design that src/middleware/ used to hold before R53), see the appendix at the bottom of this page.


The five stages, in order

   MCP request
        │
        ▼
 1. tool-lock          ← per-tool mutex
        │
        ▼
 2. schema-validate    ← Zod 4 parse
        │
        ▼
 3. audit-enter        ← ζ entry row + ALS stamp
        │
        ▼
 4. dispatch           ← domain handler
        │
        ▼
 5. audit-exit         ← ζ exit row + result hash
        │
        ▼
   MCP response

The chain is assembled as nested async wrappers. Each stage is a file under src/middleware/ (target path, not yet created). All five stages are unconditional in Phase 0 — there are no feature flags, no env-var toggles, no “skip if…” branches. Simplicity is a feature. Rate limiting, ACL, circuit breaker, and retry all live elsewhere (writeback layer, Phase 2+ auth, or not shipped) per ADR-004 and ADR-005.


Stage 1 — tool-lock

Purpose. Serialize concurrent invocations of the same tool name. Two simultaneous task_create calls must not race β FSM transitions or ζ audit step-index writes; the lock enforces per-tool linearisation inside the single server process.

Inputs. The tool name (string) being invoked. That’s it — the lock is keyed by tool name only.

Outputs. Passes the request through once the lock is acquired. No transformation of the payload.

Mechanism. In-process Map<toolName, Promise>. A new call awaits the predecessor’s resolution before proceeding. The lock releases in a finally after audit-exit writes, so the serialisation boundary is the whole chain, not just the handler.

Failure handling. The lock is acquisition-only — it cannot “fail”. If the handler throws, the lock still releases via finally. There is no timeout in Phase 0; stdio is single-client and each tool handler has a bounded runtime.

Implementation. src/middleware/tool-lock.ts (P0.2.4 target).

Governs. s04 — Concurrency.


Stage 2 — schema-validate

Purpose. Validate the inbound arguments object against the Zod 4 schema registered for this tool by the 19-tool surface. No handler ever sees unvalidated input.

Inputs. The raw arguments object from the JSON-RPC tools/call request and the tool name. The tool name is used to look up its schema in the per-tool registry built by src/domains/<domain>/tools.ts at server boot.

Outputs. On success, the parsed-and-typed args object (Zod strips unknown keys by default, as per s03). The typed shape is what the rest of the chain sees.

Failure handling. Zod parse failure raises an MCP InvalidParams error (JSON-RPC code -32602) with the structured Zod error tree flattened into the data field. The chain stops before audit-enter — a call that never validated never enters the decision trail. This is intentional: ζ must only record calls the server accepted.

Implementation. src/middleware/schema-validate.ts (P0.2.4 target). The per-tool schema is looked up from the boot-time registry populated by each domain’s tools.ts.

Governs. s03 — Tool Surface.


Stage 3 — audit-enter

Purpose. Open a ζ decision-trail row for this call and stamp the session + correlation IDs into AsyncLocalStorage (ALS) so every downstream insert (including any nested db.run from the handler) inherits the context without explicit threading.

Inputs. The tool name, the typed args, and the current session (resolved from the transport layer — stdio Phase 0 uses a single implicit session).

Outputs. An audit_events row (see database.md) with step_index atomically assigned, event_type = 'tool_enter', and args_hash = sha256(stableSerialize(args)). The row ID is stashed in ALS so audit-exit can reference it.

Failure handling. Audit insert failure is a hard stop — if ζ can’t record the entry, the call does not proceed. This is stricter than the AMS donor which tolerated audit failure; Phase 0 treats the trail as non-optional per the legitimacy-axis invariant.

Implementation. src/middleware/audit-enter.ts (P0.2.4 target). Uses AsyncLocalStorage for session propagation — see s06 — Correlation IDs.

Governs. s06 — Correlation IDs, ζ — Decision Trail.


Stage 4 — dispatch

Purpose. Route the validated call to its domain handler. This is the one stage where the five-stage chain “does work” rather than bookkeeping; everything upstream prepares the call, everything downstream records what happened.

Inputs. Tool name, typed args, ALS context.

Outputs. Whatever the domain handler returns — a JSON-serialisable result object. The result is forwarded to audit-exit unchanged.

Handlers. Five domain roots exist in Phase 0: src/domains/tasks/ (β), src/domains/skills/ (ε), src/domains/trail/ (ζ — directory name is trail, not thought), src/domains/proof/ (η — directory name is proof, not merkle), src/domains/integrations/ (ν, library-only; no MCP tools). α/γ system tools (server_ping, server_health) are registered inline in src/server.ts alongside the middleware wrapper. Each domain exports a tools registration function; dispatch is a direct lookup — no string-building, no reflection.

Model routing. Phase 0 is Claude-only. δ Model Router is deferred to Phase 1.5 per ADR-005; dispatch does not branch on model.

Failure handling. Handler exceptions propagate outward to audit-exit, which still runs (wrapped in try…finally at the chain level). audit-exit records the error hash; the MCP response maps the exception to the appropriate JSON-RPC error code (see s05 — Error Taxonomy).

Implementation. src/middleware/dispatch.ts (P0.2.4 target).

Governs. s03 — Tool Surface, s05 — Error Taxonomy.


Stage 5 — audit-exit

Purpose. Close the ζ decision-trail row opened by audit-enter, stamp the result hash, and make the entry+exit pair available for Merkle rollup later.

Inputs. The original audit_events entry-row ID (from ALS), the handler result (or the thrown error), and a monotonic timestamp for duration.

Outputs. A second audit_events row with event_type = 'tool_exit', result_hash = sha256(stableSerialize(result)) (or error_hash for a thrown path), duration_ms, and a back-reference to the entry row.

Failure handling. Same strict rule as audit-enter: exit-row insert failure is a hard stop. The handler’s own result is dropped in favour of the insert error — Phase 0 will not return a result it could not log. This is the second half of the legitimacy-axis invariant: a call the client sees a response for is a call ζ saw end-to-end.

Implementation. src/middleware/audit-exit.ts (P0.2.4 target).

Governs. s06 — Correlation IDs, η — Proof Store (audit rows feed Merkle leaves).


What Phase 0 deliberately does not ship

Capability Where it actually lives Why it is not middleware
Retry / exponential backoff Writeback layer + handler-local db.transaction retries Retry belongs to the specific operation that failed, not to every tool call
ACL / role checks Phase 2+ (no roles in Phase 0) Phase 0 is single-user, stdio, no multi-tenant identity
Rate limiting Not applicable — stdio is single-client A local agent calling itself does not need throttling
Circuit breaker Phase 4+ (μ Integrity Monitor) Failure cascades require multi-process coordination Phase 0 does not have
JWT / auth middleware Phase 2+ (see ../3-world/physics/hardening.md) Transport is stdio; there is no token to check
Process-level / cross-process locks Not applicable — Phase 0 runs one server process Multi-process locks were an AMS donor artifact of WATCH_MODE=full + WATCH_MODE=none co-existence

Each row above was a layer in the donor middleware chain (see appendix) and has been consciously removed from Phase 0 scope. The 5-stage α chain is the minimum necessary surface for execution + legitimacy to function; additions are feature flags against Phase 2+ tasks, not bolt-ons to this chain.


Appendix — donor genealogy

The pre-R53 AMS src/middleware/ directory held 11 physical files implementing a 6-logical-layer chain: serialized-execution → ACL → audit → rate-limit → circuit-breaker → retry, plus four infrastructure files (runtime-pid-lock.js, startup-lock.js, watcher-leader.js, auth-permissions.js) for process coordination and tool-permission maps. That code was deleted in R53; its algorithms are preserved in ../reference/extractions/ for historical reference.

The donor design drove env vars (AMS_MCP_SERIALIZE_TOOL_CALLS, AMS_ACL_ENABLED, AMS_DB_RETRY_*, AMS_AUTH_MODE, etc.) that Phase 0 does not read. The Phase 0 env namespace is COLIBRI_* per s17 — Environment. There is no COLIBRI_MCP_SERIALIZE_TOOL_CALLS — serialisation is unconditional at Stage 1.

Do not cite the donor layer count (6 or 11) as a Colibri target. The target is 5 stages as specified above.


Cross-references


Back to top

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

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