P0.4.1 — Step 2 Contract

Behavioral contract for src/modes.ts. Every invariant is verifiable. Step 3 (packet) may not expand scope without amending this document first. Step 5 (verify) MUST cite each section as pass/fail.

Upstream authority: docs/guides/implementation/task-breakdown.md §P0.4.1 (plain checklist), Sigma dispatch brief (richer capability names, mirrored donor-namespace guard). Where the dispatch brief tightens task-breakdown it is because the wider capability set feeds P0.2.3 + P0.2.4 directly.


§1. Module surface (exports)

src/modes.ts MUST export exactly these symbols, no more, no fewer:

  1. RuntimeMode — a TypeScript type alias that is the literal union 'FULL' | 'READONLY' | 'TEST' | 'MINIMAL'. Implemented via a const tuple/object + typeof to keep the string list and the type in lockstep, mirroring src/config.ts’s Readonly<z.infer<typeof schema>> posture (single source of truth for the value set).
  2. RUNTIME_MODES — a frozen readonly array or tuple of the four string values, iteration-friendly for callers that need to enumerate modes (e.g. server_health responses, CLI help text). Order MUST be ['FULL', 'READONLY', 'TEST', 'MINIMAL'].
  3. detectMode(env?: NodeJS.ProcessEnv): RuntimeMode — pure function. Reads env.COLIBRI_MODE. See §2 for semantics.
  4. ModeCapabilities — a TypeScript interface or type with exactly the four boolean fields listed in §3. Readonly at the type level.
  5. capabilitiesFor(mode: RuntimeMode): ModeCapabilities — pure lookup function. See §3 for the matrix.

Any additional symbol is out of scope. default export forbidden (matches repo convention — src/config.ts uses named exports only).


§2. detectMode semantics

  1. Signature: export function detectMode(env: NodeJS.ProcessEnv = process.env): RuntimeMode.
  2. Donor-namespace guard (FIRST). Before reading COLIBRI_MODE, scan env for any key AMS_MODE. If present, throw Error with message:

    "Colibri does not support the legacy AMS_MODE variable. Rename to COLIBRI_MODE or unset."

    The message shape mirrors src/config.ts’s assertNoDonorNamespace text (same prefix “Colibri does not support”, same remediation clause). Tests assert against /legacy AMS_MODE/ and /Rename to COLIBRI_MODE/.

  3. Default when unset or empty string. If env.COLIBRI_MODE is undefined OR '' (empty string), return 'FULL'. Empty-string is treated as unset because .env tooling commonly round-trips through '' for cleared keys.

  4. Case-sensitive match, uppercase only. Validate the value against the RUNTIME_MODES tuple. Any other value — including lowercase ('full'), mixed case ('Full'), whitespace-padded (' FULL '), or semantically-adjacent strings ('WATCH', 'DEBUG', 'PROD') — MUST throw Error with message:

    Invalid COLIBRI_MODE: `<offending-value>`. Expected one of: FULL, READONLY, TEST, MINIMAL.

    Tests assert against /Invalid COLIBRI_MODE/ and against the offending-value literal via regex-escape.

  5. No side effects. The function never writes to env, never calls Object.freeze, never reads files, never logs. It is referentially transparent given its input.

  6. Evaluation order. The AMS_MODE guard runs BEFORE the COLIBRI_MODE validation. Rationale: a user who sets both AMS_MODE=full (lowercase, donor) and COLIBRI_MODE=FULL (uppercase, canonical) should see the donor-namespace error first, because that is the higher-signal misconfiguration. Tests cover this ordering.

§3. Capability matrix

ModeCapabilities has exactly these four fields:

  • canWriteDatabase: boolean — gates INSERT/UPDATE/DELETE on data/colibri.db. READONLY refuses writes; MINIMAL has no domain handlers registered so it is also false.
  • canAcceptMCPConnections: boolean — gates the StdioServerTransport.connect() call. All four Phase-0 modes do accept connections; the server without connections is an unreachable process.
  • canDispatchExternalIO: boolean — gates git operations, HTTP calls, filesystem writes outside data/. FULL and TEST only. READONLY refuses external I/O (anything that mutates the outside world); MINIMAL likewise.
  • canRunIntegrationTests: boolean — gates registration of test-only endpoints or long-running integration fixtures (Jest --testPathPattern integration, sqlite seeding helpers). Only TEST is true.

3a. Matrix (authoritative)

Mode canWriteDatabase canAcceptMCPConnections canDispatchExternalIO canRunIntegrationTests
FULL true true true false
READONLY false true false false
TEST true true true true
MINIMAL false true false false

Observations that MUST hold:

  • READONLY and MINIMAL have identical bit-vectors. Callers that need to branch on them MUST compare on the mode string, not only on capabilities. A TSDoc comment on both ModeCapabilities and the lookup function MUST state this explicitly to prevent future refactors from collapsing the two.
  • FULL differs from TEST in exactly one bit (canRunIntegrationTests). This is deliberate: production bootstrap must not register test-only endpoints.
  • canAcceptMCPConnections is true for every mode. It exists as a field (rather than a constant) because future non-Phase-0 modes may set it to false (e.g. a pure CLI mode, or a migration-only boot); hard-coding true everywhere now preserves the field’s expressive role.

3b. capabilitiesFor semantics

  1. Signature: export function capabilitiesFor(mode: RuntimeMode): ModeCapabilities.
  2. Total over RuntimeMode. The function handles all four cases exhaustively. TypeScript’s exhaustiveness check MUST catch a missing case (via never or an unreachable branch). Since mode is typed as RuntimeMode (a closed union), passing an arbitrary string is a type error — we do not add a runtime default branch that throws, because that dead branch would be uncoverable by the coverage tool and would push statement coverage below 100%.
  3. Frozen return value. Each returned ModeCapabilities is Object.freezed. Callers cannot mutate the result; a shared frozen object per mode is acceptable (and encouraged — pre-computed module-scoped constants).
  4. No side effects. Same purity constraints as detectMode.

§4. Test-coverage invariants

src/__tests__/modes.test.ts MUST achieve:

  • 100% statement coverage on src/modes.ts.
  • 100% function coverage on src/modes.ts.
  • 100% line coverage on src/modes.ts.
  • Branch coverage ≥ 90% (nice-to-have; not a hard gate because a helper utility such as Object.freeze may produce an uncoverable branch under ts-jest transform).

Test cases that MUST exist (each a separate it(...)):

  1. detectMode({ COLIBRI_MODE: 'FULL' }) returns 'FULL'.
  2. detectMode({ COLIBRI_MODE: 'READONLY' }) returns 'READONLY'.
  3. detectMode({ COLIBRI_MODE: 'TEST' }) returns 'TEST'.
  4. detectMode({ COLIBRI_MODE: 'MINIMAL' }) returns 'MINIMAL'.
  5. detectMode({}) returns 'FULL' (default when unset).
  6. detectMode({ COLIBRI_MODE: '' }) returns 'FULL' (default when empty string).
  7. detectMode({ COLIBRI_MODE: 'full' }) throws /Invalid COLIBRI_MODE/ (lowercase rejected).
  8. detectMode({ COLIBRI_MODE: 'FOO' }) throws /Invalid COLIBRI_MODE/ (unknown value rejected).
  9. detectMode({ AMS_MODE: 'full' }) throws /legacy AMS_MODE/ (donor alone).
  10. detectMode({ COLIBRI_MODE: 'FULL', AMS_MODE: 'full' }) throws /legacy AMS_MODE/ (donor wins ordering).
  11. capabilitiesFor('FULL') matches the row in §3a exactly.
  12. capabilitiesFor('READONLY') matches the row in §3a exactly.
  13. capabilitiesFor('TEST') matches the row in §3a exactly (note canRunIntegrationTests: true).
  14. capabilitiesFor('MINIMAL') matches the row in §3a exactly.
  15. capabilitiesFor('READONLY') and capabilitiesFor('MINIMAL') have identical capability fields but are documented as semantically distinct (asserts field equality + documents the distinction).
  16. capabilitiesFor('FULL') returns a frozen object (mutation throws in strict mode).
  17. RUNTIME_MODES is exactly ['FULL', 'READONLY', 'TEST', 'MINIMAL'] and is frozen.

Test isolation uses the src/__tests__/config.test.ts pattern: pass explicit env objects to the pure factory; no jest.isolateModulesAsync. No subprocess harness — src/modes.ts has no eager side-effects, so module-load is already pure and a direct import per test file suffices.


§5. Style + tooling invariants

  • src/modes.ts is authored in TypeScript 5.3 with strict: true + noUncheckedIndexedAccess: true + exactOptionalPropertyTypes: true (from tsconfig.json). The module MUST pass npm run build with zero errors and zero warnings.
  • The module passes npm run lint with zero errors and zero warnings (@typescript-eslint/no-explicit-any, consistent-type-imports, eqeqeq, curly, no-console all observed).
  • The module uses named exports only (no export default).
  • TSDoc comment on every exported symbol (RuntimeMode, RUNTIME_MODES, detectMode, ModeCapabilities, capabilitiesFor), authored to the same voice as src/config.ts’s leading block + per-export comments.
  • No imports beyond Node built-ins or repo-local files. In practice this module needs zero runtime dependencies (it is pure TypeScript).

§6. Non-regressions

This round MUST NOT modify:

  • package.json, tsconfig.json, jest.config.ts, .eslintrc.json, .prettierrc, .env.example — configuration surface is frozen for this task.
  • src/config.ts, src/index.ts, src/__tests__/smoke.test.ts, src/__tests__/config.test.ts — prior Phase 0 code is frozen.
  • Any file under docs/spec/, docs/architecture/decisions/, docs/agents/, docs/reference/, docs/guides/ — the mode semantics are ALREADY documented in docs/guides/implementation/task-breakdown.md §P0.4.1; no doc rewrite is part of this task.
  • Any workflow file under .github/workflows/ — CI is owned by P0.1.3 (already landed); the new module simply flows through the existing build-test-lint matrix.

Explicitly out of scope:

  • Writing docs/2-plugin/modes.md (the γ concept doc). That is a documentation round, not code — typically folded into a later docs-polish task.
  • Updating .env.example to declare COLIBRI_MODE. The default 'FULL' is sufficient for Phase 0; a later P0.4.x task may add the comment block.
  • Teaching any consumer (P0.2.3, P0.2.4) to read from src/modes.ts. Those tasks import from it when they land.

§7. Verifiable acceptance criteria (Step 5 input)

Step 5 verification passes if and only if every one of the following holds. Each MUST be cited in docs/verification/p0-4-1-modes-verification.md.

  1. src/modes.ts exists with non-zero bytes and exports exactly the five symbols in §1.
  2. src/__tests__/modes.test.ts exists with non-zero bytes, contains at least 17 it(...) blocks (one per case in §4), and all of them pass.
  3. npm run lint exits with code 0 and zero error / zero warning lines from src/modes.ts or src/__tests__/modes.test.ts.
  4. npm run build exits with code 0.
  5. npm test exits with code 0. Total test count equals 15 (prior) + N where N ≥ 17 (the new suite).
  6. Coverage report (coverage/lcov.info / text summary) shows src/modes.ts at 100% statements + 100% functions + 100% lines. Branch coverage is reported but not gated.
  7. grep -rn "AMS_MODE" src/modes.ts src/__tests__/modes.test.ts matches ONLY the explicit guard check + the two tests that exercise it (no ambient usage).
  8. No file outside the allow-list in §9 is touched by this PR.

§8. Risks (for the packet to mitigate)

  • Risk C-1: The capability matrix in task-breakdown ({ canWrite, canRunTests, heavyInit }) differs from the Sigma dispatch brief ({ canWriteDatabase, canAcceptMCPConnections, canDispatchExternalIO, canRunIntegrationTests }). Chosen resolution: Sigma wins — the wider matrix is strictly informative, and downstream consumers P0.2.3 + P0.2.4 will want the named bits. The audit §6 records this decision; the contract §3 pins the Sigma matrix.
  • Risk C-2: Empty-string handling for COLIBRI_MODE. .env tooling often sets cleared keys to '' rather than unsetting them. Chosen resolution: treat '' identically to undefined and return 'FULL'. Tests cover both cases.
  • Risk C-3: Putting a default: throw branch in the capabilitiesFor switch (to defend against runtime abuse) creates an uncoverable statement and breaks 100% statement coverage. Chosen resolution: rely on TypeScript’s RuntimeMode union to close the input space; add an exhaustiveness check via a never assertion in a branch that is provably unreachable at runtime but typechecks. If the coverage tool flags this, fall back to covering it by casting a dummy value at test time.

§9. File allow-list (what the PR may touch)

Exactly four files:

  • src/modes.ts (new)
  • src/__tests__/modes.test.ts (new)
  • docs/audits/p0-4-1-modes-audit.md (new; created in Step 1)
  • docs/contracts/p0-4-1-modes-contract.md (this file)
  • docs/packets/p0-4-1-modes-packet.md (new; Step 3)
  • docs/verification/p0-4-1-modes-verification.md (new; Step 5)

Anything else in the diff is a scope leak.


P0.4.1 Step 2 Contract — 2026-04-16 — branch feature/p0-4-1-runtime-modes. Audit baseline: 5f6334ed. Gates docs/packets/p0-4-1-modes-packet.md. Authority: docs/guides/implementation/task-breakdown.md §P0.4.1 + Sigma dispatch brief.


Back to top

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

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