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:
RuntimeMode— a TypeScript type alias that is the literal union'FULL' | 'READONLY' | 'TEST' | 'MINIMAL'. Implemented via aconsttuple/object +typeofto keep the string list and the type in lockstep, mirroringsrc/config.ts’sReadonly<z.infer<typeof schema>>posture (single source of truth for the value set).RUNTIME_MODES— a frozenreadonlyarray or tuple of the four string values, iteration-friendly for callers that need to enumerate modes (e.g.server_healthresponses, CLI help text). Order MUST be['FULL', 'READONLY', 'TEST', 'MINIMAL'].detectMode(env?: NodeJS.ProcessEnv): RuntimeMode— pure function. Readsenv.COLIBRI_MODE. See §2 for semantics.ModeCapabilities— a TypeScript interface or type with exactly the four boolean fields listed in §3.Readonlyat the type level.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
- Signature:
export function detectMode(env: NodeJS.ProcessEnv = process.env): RuntimeMode. -
Donor-namespace guard (FIRST). Before reading
COLIBRI_MODE, scanenvfor any keyAMS_MODE. If present, throwErrorwith message:"Colibri does not support the legacy AMS_MODE variable. Rename to COLIBRI_MODE or unset."The message shape mirrors
src/config.ts’sassertNoDonorNamespacetext (same prefix “Colibri does not support”, same remediation clause). Tests assert against/legacy AMS_MODE/and/Rename to COLIBRI_MODE/. -
Default when unset or empty string. If
env.COLIBRI_MODEisundefinedOR''(empty string), return'FULL'. Empty-string is treated as unset because.envtooling commonly round-trips through''for cleared keys. -
Case-sensitive match, uppercase only. Validate the value against the
RUNTIME_MODEStuple. Any other value — including lowercase ('full'), mixed case ('Full'), whitespace-padded (' FULL '), or semantically-adjacent strings ('WATCH','DEBUG','PROD') — MUST throwErrorwith 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. -
No side effects. The function never writes to
env, never callsObject.freeze, never reads files, never logs. It is referentially transparent given its input. - Evaluation order. The
AMS_MODEguard runs BEFORE theCOLIBRI_MODEvalidation. Rationale: a user who sets bothAMS_MODE=full(lowercase, donor) andCOLIBRI_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— gatesINSERT/UPDATE/DELETEondata/colibri.db.READONLYrefuses writes;MINIMALhas no domain handlers registered so it is alsofalse.canAcceptMCPConnections: boolean— gates theStdioServerTransport.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 outsidedata/.FULLandTESTonly.READONLYrefuses external I/O (anything that mutates the outside world);MINIMALlikewise.canRunIntegrationTests: boolean— gates registration of test-only endpoints or long-running integration fixtures (Jest--testPathPattern integration, sqlite seeding helpers). OnlyTESTis 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:
READONLYandMINIMALhave identical bit-vectors. Callers that need to branch on them MUST compare on the mode string, not only on capabilities. A TSDoc comment on bothModeCapabilitiesand the lookup function MUST state this explicitly to prevent future refactors from collapsing the two.FULLdiffers fromTESTin exactly one bit (canRunIntegrationTests). This is deliberate: production bootstrap must not register test-only endpoints.canAcceptMCPConnectionsistruefor every mode. It exists as a field (rather than a constant) because future non-Phase-0 modes may set it tofalse(e.g. a pure CLI mode, or a migration-only boot); hard-codingtrueeverywhere now preserves the field’s expressive role.
3b. capabilitiesFor semantics
- Signature:
export function capabilitiesFor(mode: RuntimeMode): ModeCapabilities. - Total over
RuntimeMode. The function handles all four cases exhaustively. TypeScript’s exhaustiveness check MUST catch a missing case (vianeveror an unreachable branch). Sincemodeis typed asRuntimeMode(a closed union), passing an arbitrary string is a type error — we do not add a runtimedefaultbranch that throws, because that dead branch would be uncoverable by the coverage tool and would push statement coverage below 100%. - Frozen return value. Each returned
ModeCapabilitiesisObject.freezed. Callers cannot mutate the result; a shared frozen object per mode is acceptable (and encouraged — pre-computed module-scoped constants). - 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.freezemay produce an uncoverable branch under ts-jest transform).
Test cases that MUST exist (each a separate it(...)):
detectMode({ COLIBRI_MODE: 'FULL' })returns'FULL'.detectMode({ COLIBRI_MODE: 'READONLY' })returns'READONLY'.detectMode({ COLIBRI_MODE: 'TEST' })returns'TEST'.detectMode({ COLIBRI_MODE: 'MINIMAL' })returns'MINIMAL'.detectMode({})returns'FULL'(default when unset).detectMode({ COLIBRI_MODE: '' })returns'FULL'(default when empty string).detectMode({ COLIBRI_MODE: 'full' })throws/Invalid COLIBRI_MODE/(lowercase rejected).detectMode({ COLIBRI_MODE: 'FOO' })throws/Invalid COLIBRI_MODE/(unknown value rejected).detectMode({ AMS_MODE: 'full' })throws/legacy AMS_MODE/(donor alone).detectMode({ COLIBRI_MODE: 'FULL', AMS_MODE: 'full' })throws/legacy AMS_MODE/(donor wins ordering).capabilitiesFor('FULL')matches the row in §3a exactly.capabilitiesFor('READONLY')matches the row in §3a exactly.capabilitiesFor('TEST')matches the row in §3a exactly (notecanRunIntegrationTests: true).capabilitiesFor('MINIMAL')matches the row in §3a exactly.capabilitiesFor('READONLY')andcapabilitiesFor('MINIMAL')have identical capability fields but are documented as semantically distinct (asserts field equality + documents the distinction).capabilitiesFor('FULL')returns a frozen object (mutation throws in strict mode).RUNTIME_MODESis 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.tsis authored in TypeScript 5.3 withstrict: true+noUncheckedIndexedAccess: true+exactOptionalPropertyTypes: true(fromtsconfig.json). The module MUST passnpm run buildwith zero errors and zero warnings.- The module passes
npm run lintwith zero errors and zero warnings (@typescript-eslint/no-explicit-any,consistent-type-imports,eqeqeq,curly,no-consoleall 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 assrc/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 indocs/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 existingbuild-test-lintmatrix.
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.exampleto declareCOLIBRI_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.
src/modes.tsexists with non-zero bytes and exports exactly the five symbols in §1.src/__tests__/modes.test.tsexists with non-zero bytes, contains at least 17it(...)blocks (one per case in §4), and all of them pass.npm run lintexits with code 0 and zeroerror/ zerowarninglines fromsrc/modes.tsorsrc/__tests__/modes.test.ts.npm run buildexits with code 0.npm testexits with code 0. Total test count equals15 (prior) + NwhereN ≥ 17(the new suite).- Coverage report (
coverage/lcov.info/ text summary) showssrc/modes.tsat 100% statements + 100% functions + 100% lines. Branch coverage is reported but not gated. grep -rn "AMS_MODE" src/modes.ts src/__tests__/modes.test.tsmatches ONLY the explicit guard check + the two tests that exercise it (no ambient usage).- 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..envtooling often sets cleared keys to''rather than unsetting them. Chosen resolution: treat''identically toundefinedand return'FULL'. Tests cover both cases. - Risk C-3: Putting a
default: throwbranch in thecapabilitiesForswitch (to defend against runtime abuse) creates an uncoverable statement and breaks 100% statement coverage. Chosen resolution: rely on TypeScript’sRuntimeModeunion to close the input space; add an exhaustiveness check via aneverassertion 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.