P0.4.1 — Step 3 Packet
Execution plan from audit (5f6334ed) to contract (e9578bf8). This packet is the single authoritative script for Step 4 implementation. No code may land until every §1-§3 item is covered; no §8 acceptance check is skippable.
§0. Single-commit shape
Step 4 lands as ONE commit on feature/p0-4-1-runtime-modes:
feat(p0-4-1): runtime mode enum + capability matrix
The commit touches exactly two paths:
src/modes.ts(new, ~95 lines)src/__tests__/modes.test.ts(new, ~180 lines)
No other file is touched in Step 4. If Step 4 surfaces a need to edit package.json/tsconfig.json/jest.config.ts/.eslintrc.json, the implementer halts and escalates per contract §6 — do not guess-fix.
§1. src/modes.ts — authoring recipe
Authored in TypeScript 5.3 under strict: true + exactOptionalPropertyTypes: true + noUncheckedIndexedAccess: true.
1a. Top-of-file TSDoc block
Matches the voice of src/config.ts. Documents:
- Purpose (γ Server Lifecycle runtime modes for Phase 0).
- Canonical references:
docs/guides/implementation/task-breakdown.md §P0.4.1, audit doc, contract doc. - Consumed-by (future): P0.2.3 two-phase startup, P0.2.4 health tool.
1b. RUNTIME_MODES tuple
export const RUNTIME_MODES = ['FULL', 'READONLY', 'TEST', 'MINIMAL'] as const;
Frozen by as const. TypeScript infers the tuple’s literal type automatically.
1c. RuntimeMode type
export type RuntimeMode = (typeof RUNTIME_MODES)[number];
Single-source-of-truth pattern — the value tuple drives the type. Matches src/config.ts’s “Zod schema drives the Config type” idiom.
1d. Type guard
A private isRuntimeMode(v: string): v is RuntimeMode helper backs the detectMode validation. Implementation:
function isRuntimeMode(value: string): value is RuntimeMode {
return (RUNTIME_MODES as readonly string[]).includes(value);
}
1e. detectMode implementation
export function detectMode(env: NodeJS.ProcessEnv = process.env): RuntimeMode {
if ('AMS_MODE' in env) {
throw new Error(
'Colibri does not support the legacy AMS_MODE variable. ' +
'Rename to COLIBRI_MODE or unset.',
);
}
const raw = env.COLIBRI_MODE;
if (raw === undefined || raw === '') {
return 'FULL';
}
if (!isRuntimeMode(raw)) {
throw new Error(
`Invalid COLIBRI_MODE: \`${raw}\`. ` +
`Expected one of: ${RUNTIME_MODES.join(', ')}.`,
);
}
return raw;
}
Uses 'AMS_MODE' in env (not env.AMS_MODE !== undefined) so that even an explicit empty-string AMS_MODE='' triggers the guard. This matches src/config.ts’s posture that the donor namespace is rejected on presence, not value.
1f. ModeCapabilities type
export interface ModeCapabilities {
readonly canWriteDatabase: boolean;
readonly canAcceptMCPConnections: boolean;
readonly canDispatchExternalIO: boolean;
readonly canRunIntegrationTests: boolean;
}
1g. Frozen per-mode constants
Pre-computed, module-scoped, frozen. Each matches the row in contract §3a exactly:
const FULL_CAPS: ModeCapabilities = Object.freeze({
canWriteDatabase: true,
canAcceptMCPConnections: true,
canDispatchExternalIO: true,
canRunIntegrationTests: false,
});
const READONLY_CAPS: ModeCapabilities = Object.freeze({
canWriteDatabase: false,
canAcceptMCPConnections: true,
canDispatchExternalIO: false,
canRunIntegrationTests: false,
});
const TEST_CAPS: ModeCapabilities = Object.freeze({
canWriteDatabase: true,
canAcceptMCPConnections: true,
canDispatchExternalIO: true,
canRunIntegrationTests: true,
});
const MINIMAL_CAPS: ModeCapabilities = Object.freeze({
canWriteDatabase: false,
canAcceptMCPConnections: true,
canDispatchExternalIO: false,
canRunIntegrationTests: false,
});
1h. capabilitiesFor implementation
A switch over the closed RuntimeMode union, with an exhaustiveness assertion. The exhaustiveness branch must typecheck but MUST NOT be reachable at runtime (runtime reachability would bump uncovered lines). Strategy:
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;
}
}
No default. TypeScript’s exhaustiveness check (driven by the closed union) is sufficient — TS 5.3 reports an error at compile time if a new RuntimeMode value is added without a case. Under tsconfig.json’s noImplicitReturns, omitting a default requires every case to return, which is already the shape above.
Coverage note: because mode: RuntimeMode is typed as the closed union, the ts-jest compiler does NOT generate any implicit default branch. All four case arms get executed by the Step-4 test suite (cases 11-14 in contract §4), yielding 100% statement + 100% branch for this switch.
1i. Capability-distinction TSDoc comment
The TSDoc above capabilitiesFor includes a paragraph explicitly noting that READONLY and MINIMAL produce identical capability bit-vectors but are semantically distinct (the audit §3 sketch: READONLY = safe-mode wired runtime; MINIMAL = α-only boot floor). Callers needing to branch on that distinction MUST compare on mode, not only on the capability record. Preserves the design note from contract §3.
1j. No imports beyond Node built-in types
src/modes.ts depends on zero runtime imports. It uses only the NodeJS.ProcessEnv type (ambient from @types/node). This keeps the module’s coverage independent and leaves no import-graph surface for P0.2.3 to worry about.
§2. src/__tests__/modes.test.ts — test recipe
2a. Top-of-file TSDoc
Matches src/__tests__/config.test.ts:
- States that the pure
detectMode(env)entry point is the tested unit (same posture asloadConfig(env)). - Notes that no subprocess harness is needed because
src/modes.tshas no eager side-effects (unlikesrc/config.ts’sconfigexport). - Notes that
jest.isolateModulesAsyncis deliberately NOT used (broken under ts-jest ESM due to the zod v3 locale-cache bug — same rationale assrc/__tests__/config.test.tslines 10-14).
2b. Imports
import {
RUNTIME_MODES,
capabilitiesFor,
detectMode,
type ModeCapabilities,
type RuntimeMode,
} from '../modes.js';
The .js extension is the ESM convention the repo uses (matches src/__tests__/config.test.ts line 20). Type-only imports marked with type per @typescript-eslint/consistent-type-imports.
2c. Test structure
Three describe blocks:
describe('detectMode', ...)— covers contract §4 cases 1-10.describe('capabilitiesFor', ...)— covers contract §4 cases 11-16.describe('RUNTIME_MODES', ...)— covers contract §4 case 17.
Each it(...) is narrow. No shared test fixtures beyond the module’s own exports. No beforeEach needed — every test passes an explicit env object to detectMode (pure factory posture); the suite never mutates process.env.
2d. Expected coverage outcome
Running npm test -- --coverage with the new file MUST report:
src/modes.tsstatements: 100%src/modes.tsfunctions: 100%src/modes.tslines: 100%src/modes.tsbranches: ≥ 90% (target 100%; depending on how ts-jest compiles'AMS_MODE' in env, one implicit branch may register).src/config.tscoverage unchanged (100% statements/functions/lines — prior P0.1.4 baseline).src/index.tsandsrc/__tests__/smoke.test.tscoverage unchanged.
§3. Test strategy — per-test env isolation
The pattern is not beforeEach/afterEach on process.env. Every test passes an explicit env object to detectMode(env):
it('returns FULL when COLIBRI_MODE is unset', () => {
expect(detectMode({})).toBe('FULL');
});
This pattern:
- Avoids global state mutation entirely (no leak between tests).
- Needs no subprocess (unlike the P0.1.4 eager-import tests that needed
spawnSync+tsx). - Dodges the known ts-jest-ESM + zod locale-cache bug (P0.1.4 feedback).
src/modes.tshas no zod dependency, but using the same pure-factory pattern keeps the Phase 0 test idiom uniform.
One test (case 17 — RUNTIME_MODES is frozen) does NOT pass an env; it exercises the shape of the exported tuple directly.
§4. Acceptance criteria (mirrors contract §7)
Step 4 is complete when all of the following hold from inside the worktree at E:/AMS/.worktrees/claude/p0-4-1-runtime-modes/:
npm run lintexits 0.npm run buildexits 0 with no output on stdout/stderr beyond TypeScript’s silent completion.npm testexits 0 with test count15 + NwhereN ≥ 17.- The coverage summary table shows
src/modes.tsat 100%/100%/100% (statements/functions/lines). - No file outside the contract §9 allow-list appears in
git status. git diff --statshows exactly two files changed (src/modes.ts+src/__tests__/modes.test.ts), both new.
§5. Sub-phases within Step 4 (self-dispatch)
No sub-agent dispatch — this is a single-implementer task (Sigma already pre-approved the packet gate per dispatch brief). The implementer proceeds linearly:
- Author
src/modes.tsper §1. Save. - Author
src/__tests__/modes.test.tsper §2. Save. - Run
npm run lint— fix any issue before proceeding. - Run
npm test— observe the coverage table and the 15+N pass count. Fix any issue before proceeding. - Run
npm run build— must exit 0. git add src/modes.ts src/__tests__/modes.test.ts.git commit -m "feat(p0-4-1): runtime mode enum + capability matrix".- Report commit SHA + coverage % to the Step 5 verify pass.
Any Step 4 item that fails the gate halts the task: the implementer does NOT amend the commit, does NOT force-push, does NOT scope-creep. A follow-up commit on the same branch is the only allowed recovery path (per CLAUDE.md §5 + §3).
§6. Risks (mitigations)
- Risk P-1 (exhaustiveness + coverage): contract §8 C-3 already called out the
capabilitiesFordefault branch. Mitigation: nodefaultin the switch; rely on TS’s closed-union check. Iftsc --noImplicitReturnscomplains, the fourcasearms already return, so the check is satisfied implicitly. - Risk P-2 (lowercase-vs-uppercase surprise): operators may set
COLIBRI_MODE=full(lowercase) expecting it to work. Mitigation: the error message explicitly lists the four uppercase values — the offending input is echoed in backticks so users see the case mismatch immediately. - Risk P-3 (empty-string vs unset):
'' !== undefined. Mitigation: the implementation explicitly checks both; tests cover both. - Risk P-4 (AMS_MODE presence check):
env.AMS_MODE === undefineddoes not distinguish “unset” from “set to undefined”. Mitigation: use'AMS_MODE' in env, which tests object-key presence regardless of value. Matchessrc/config.ts’s.filter((k) => k.startsWith('AMS_'))posture (presence-based). - Risk P-5 (test isolation leak via
process.env): if any test mutatesprocess.envby accident, the other tests can see the mutation. Mitigation: the pattern in §3 — explicit env objects per call — forbids this. A cross-test assertionexpect(process.env.COLIBRI_MODE).toBeUndefined()at the top ofdetectModetests would be paranoid but unnecessary; the pattern is self-enforcing. - Risk P-6 (build breakage from unused helper): if
isRuntimeModeis only used insidedetectMode, ESLint’sno-unused-varsis satisfied. But if TypeScript’snoUnusedLocalswere enabled (it is NOT in the current tsconfig), the implementer would need to either export or inline the helper. Mitigation: keep the helper unexported; it IS used bydetectMode, so the check passes.
§7. Rollback strategy
Single-commit Step 4 means rollback = git reset --hard on the feature branch before push. After push, rollback = git revert with a new commit. No force-push, no branch deletion. The 5-step chain (audit + contract + packet + implement + verify) means each step is its own commit, so a partial rollback (e.g. drop Step 4 but keep Steps 1-3 for a later attempt) is possible without touching shared history.
§8. Gate (runs at end of Step 4, before Step 5 begins)
The Step 4 commit is not considered landed until:
git log -1 --name-onlyshows exactlysrc/modes.ts+src/__tests__/modes.test.ts+ no other paths.npm run lintexit 0 recaptured.npm testexit 0 recaptured, coverage table forsrc/modes.tsat 100%/100%/100% recaptured.npm run buildexit 0 recaptured.- Commit SHA recorded in Step 5 verification doc.
P0.4.1 Step 3 Packet — 2026-04-16 — branch feature/p0-4-1-runtime-modes. Audit: 5f6334ed. Contract: e9578bf8. Gates Step 4 implementation.