P0.5.2 — δ Fallback Chain — Verification
Step 5 of the 5-step chain. Test evidence for the Phase 0 library-only single-member fallback stub mandated by ADR-005 §Decision. Closes Phase 0 at 28/28.
1. Gate results
cd .worktrees/claude/p0-5-2-fallback
npm run build # clean (no TS errors)
npm run lint # clean (no ESLint warnings/errors)
npm test # 26 suites / 1084 tests passing
Test count comparison
| Metric | Before P0.5.2 | After P0.5.2 | Δ |
|---|---|---|---|
| Test suites | 25 | 26 | +1 (src/__tests__/domains/router/fallback.test.ts) |
| Tests | 1051 | 1084 | +33 |
The +33 (vs. packet target of 10–15) exceeds the lower bound because two additional describes were added during impl: routeRequest — upstream forwarding subtests (seam-injection passes) and default dispatcher tests (coverage 100% on fallback.ts). All 33 tests pass first try after the initial ESLint curly fix.
Coverage (fallback.ts, focused)
-------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
fallback.ts | 100 | 85.71 | 100 | 100 | 217-219, 310
-------------|---------|----------|---------|---------|-------------------
- Statements: 100%
- Branches: 85.71% — uncovered are optional-chain / nullish-coalescing defenses in the
FallbackChainExhaustedErrorctor’sattempts[N-1]access (N is always ≥ 1 in Phase 0 so the branch.error.message ?? 'unknown'is defensively untestable). - Functions: 100%
- Lines: 100%
2. Acceptance criteria verification
Every AC from the contract §6 is verified by at least one passing test.
| AC | Contract ref | Test case in fallback.test.ts |
Status |
|---|---|---|---|
| AC1 | happy path returns RouteResult | returns RouteResult with model='claude' and content on success + projects upstream CompletionResult fields into RouteResult + RouteResult.model is abstract 'claude', not upstream version string + RouteResult is frozen |
PASS |
| AC2 | delegates to scoring | consults scoreIntent before calling upstream + uses default scoreIntent when scoringFn not injected |
PASS |
| AC3 | delegates to upstream (1×) | calls completionFn exactly once on success |
PASS |
| AC4 | forwards prompt | forwards prompt unchanged to upstream completionFn |
PASS |
| AC5–AC6 | forwards maxTokens/systemPrompt/model/apiKey | forwards maxTokens / systemPrompt / model / apiKey to upstream + omits optional keys from upstream options when not provided + forwards injected fetchFn / logger / delayFn to upstream |
PASS |
| AC7 | FallbackChainExhaustedError on failure | throws FallbackChainExhaustedError when upstream throws AnthropicApiError |
PASS |
| AC8 | attempts.length === 1 | FallbackChainExhaustedError has exactly 1 attempt |
PASS |
| AC9 | attempts[0].model === ‘claude’ | attempts[0].model === 'claude' (Phase 0 invariant) |
PASS |
| AC10 | preserves AnthropicApiError | preserves AnthropicApiError in attempts[0].error (same instance) |
PASS |
| AC11 | preserves AnthropicConfigError | preserves AnthropicConfigError in attempts[0].error (same instance) + exposes cause pointing to the last attempt error |
PASS |
| AC12 | ZERO cascade | does not retry after upstream failure — completionFn called exactly 1× + does not retry after AnthropicConfigError |
PASS |
| AC13 | determinism | two identical calls route to the same model + different prompts route to the same model (Phase 0 single-member) |
PASS |
| AC14 | ROUTER_PHASE_0_SHAPE marker | asserts single-member chain (members === 1) + asserts no circuit breaker (hasCircuitBreaker === false) + asserts modelsSupported contains only 'claude' + is deeply frozen |
PASS |
| AC15 | tools passthrough | passes tools array through to completionFn via default dispatcher + accepts empty tools array without error + dispatches to createCompletionWithTools when tools non-empty |
PASS |
| AC16 | error message format | includes attempt count and model name + error name is 'FallbackChainExhaustedError' + error code is 'FALLBACK_CHAIN_EXHAUSTED' |
PASS |
| AC17 | non-Error thrown values | wraps non-Error thrown string into Error instance |
PASS |
3. Invariant verification (ADR-005 §Decision)
| # | Invariant | Evidence |
|---|---|---|
| I1 | Exactly one upstream call per routeRequest, success or failure |
calls completionFn exactly once on success + does not retry after upstream failure |
| I2 | Winner always ‘claude’ in Phase 0 | consults scoreIntent + uses default scoreIntent — both return ‘claude’ |
| I3 | RouteResult.model === ‘claude’ (abstract ID, not version) | RouteResult.model is abstract 'claude', not upstream version string |
| I4 | Throws FallbackChainExhaustedError on failure | throws FallbackChainExhaustedError when upstream throws AnthropicApiError |
| I5 | err.attempts.length === 1 | FallbackChainExhaustedError has exactly 1 attempt |
| I6 | err.attempts[0].model === ‘claude’ | attempts[0].model === 'claude' (Phase 0 invariant) |
| I7 | Preserves original error instance | preserves AnthropicApiError ... (same instance) + preserves AnthropicConfigError ... (same instance) |
| I8 | No timers / circuit breaker state | Grepped fallback.ts — no setTimeout, setInterval, Date.now(), Date(). |
| I9 | No MCP tool registered | Grepped src/server.ts on worktree — no new imports from src/domains/router/*; tool list unchanged at 14. |
| I10 | No env var reads | Grepped fallback.ts — no process.env. |
| I11 | ROUTER_PHASE_0_SHAPE asserts invariants | All 4 shape tests pass. |
| I12 | Deterministic routing | two identical calls route to the same model + different prompts route to the same model |
| I13 | Tools passthrough dispatches to createCompletionWithTools | dispatches to createCompletionWithTools when tools non-empty |
4. Forbidden-checks (§forbiddens from the task prompt)
| Forbidden | Check | Status |
|---|---|---|
| No MCP tool registered | src/server.ts diff against main — no change |
OK |
| No env vars | src/config.ts diff against main — no change |
OK |
| No circuit breaker / timers / retries in Phase 0 | grep -nE 'setTimeout\|setInterval\|Date\.now\|new Date' src/domains/router/fallback.ts → 0 hits |
OK |
| No multi-model fallback | attempts.length === 1 test + ROUTER_PHASE_0_SHAPE.members === 1 test |
OK |
| No real Anthropic API in tests | All tests inject completionFn OR a stubbed fetchFn |
OK |
No modification of scoring.ts |
git diff origin/main -- src/domains/router/scoring.ts → empty |
OK |
No modification of src/domains/integrations/claude.ts |
git diff origin/main -- src/domains/integrations/claude.ts → empty |
OK |
No modification of src/server.ts / src/config.ts |
git diff origin/main -- src/server.ts src/config.ts → empty |
OK |
5. Non-regression
- All 25 pre-existing test suites remain green. Pre-existing intermittent
startup — subprocess smokeflake (documented in memory as “flagged by Waves F and G sub-agents; predates”) intermittently surfaced once on first run; second + third runs clean. Not caused by P0.5.2. src/__tests__/domains/router/scoring.test.ts— 23 tests, all green — unaffected.src/__tests__/domains/integrations/claude.test.ts— claude wrapper tests unchanged.- ESLint config + tsconfig unchanged.
6. Deviation from packet
The packet sketched 14 test cases; the actual file landed 33 because:
- Multiple ACs collapsed into shared describes got split out for readability during impl (e.g.
forwards X to upstreambecame three separate assertions + a negative test for omission + a separate seams test). default dispatchertests were added to liftfallback.tscoverage from 86.36% to 100% on stmts/funcs/lines — needed to exercise thecreateCompletion/createCompletionWithToolsdispatch branch whencompletionFnis NOT injected.
Neither deviation changes scope; both strengthen evidence.
7. Phase 0 completion
This task is the last of 28 Phase 0 sub-tasks.
| Phase 0 status | Before | After |
|---|---|---|
| Non-deferred sub-tasks complete | 26/28 | 28/28 |
δ concept colibri_code |
none (deferred) |
partial (Wave I close commit will graduate) |
| Phase 0 shipped code axes | 7/15 (α β γ ε ζ η ν) | 8/15 (+ δ) |
| MCP tool surface | 14 | 14 (zero added) |
Per ADR-006 graduation rules, shipping a Phase 0 stub lifts the concept’s colibri_code field from none to partial. The frontmatter update belongs to the Wave I close commit, not this PR.
8. Conclusion
All acceptance criteria pass. All ADR-005 §Decision invariants are enforced by tests. Zero tool-surface, env-var, DB, or timer additions. Phase 0 closes at 28/28 on the boundary mandated by ADR-005. Ready to open PR.