P0.5.2 — δ Fallback Chain — Audit
Step 1 of the 5-step executor chain. Inventory of the surface P0.5.2 lands into, the same two-spec conflict that P0.5.1 already resolved, the seams it must consume, and the Phase 0 invariants ADR-005 §Decision mandates.
Spec conflict and resolution
The same conflict P0.5.1 resolved applies here:
| Source | Claim |
|---|---|
docs/architecture/decisions/ADR-005-multi-model-defer.md §Decision |
“Fallback chain has one member. If Claude fails, the call fails — no cascade. Adapter layer is single-target. router_call wraps the Anthropic SDK only.” |
docs/guides/implementation/task-breakdown.md §P0.5 header + §P0.5.2 |
“spec-only — deferred per ADR-005 … design only.” |
ADR-005 wins — same precedent as P0.5.1 (PR #149, merged 5a3eb10f). ADR-005 §Implementation §”Phase 0 router stub (P0.5)” names P0.5.2 — router_call wrapping Anthropic SDK as the second in-scope Phase 0 task. Moreover ADR-005 §”Phase 1.5 upgrade path” §3 names src/domains/router/fallback.ts as the exact file that Phase 1.5 will extend — confirming that the file exists at Phase 0 boundary with single-member semantics.
The doc-fix to reconcile task-breakdown.md §P0.5.2 against ADR-005 is out-of-scope for this PR; it belongs to the Wave I close commit (same policy as the P0.5.1 audit). This task ships what ADR-005 §Decision mandates.
Task scope
This task ships the Phase 0 library-only single-member fallback chain:
src/domains/router/fallback.ts—routeRequest(prompt, options)delegates tocreateCompletionfromsrc/domains/integrations/claude.ts- Consults
scoreIntent()from./scoring.js(always picks'claude'per P0.5.1) - On Anthropic success → wrap into
RouteResult - On Anthropic error → throw
FallbackChainExhaustedErrorwithattempts: [{ model, error }](single-entry array — Phase 0 invariant) - Library-only: NO new MCP tools, NO new env vars, NO DB writes, NO timers
- NO circuit breaker (Phase 1.5 scope — single-model chain has nothing to break past)
Phase 0 acceptance (this task)
From ADR-005 §Decision:
routeRequest(prompt, options)returnsPromise<RouteResult>.- Winner is always
'claude'(delegated toscoreIntent— P0.5.1 invariant). - Delegates to
createCompletionfromsrc/domains/integrations/claude.ts. - On failure → throws
FallbackChainExhaustedErrorwithattempts.length === 1. - ZERO cascade — only one upstream call is made even on failure.
- Zero new MCP tools (ADR-004 stays at 14).
- Zero new env vars (
ANTHROPIC_API_KEYre-used via the existing claude wrapper). - Zero timers (no circuit breaker state).
- Barrel exports both scoring + fallback from
src/domains/router/index.ts. - Public shape preserved for Phase 1.5 drop-in replacement (chain expands to N members;
FallbackChainExhaustedError.attemptsbecomes a real log).
What this task does NOT do (Phase 1.5 scope)
- Multiple model slots (
COLIBRI_MODEL_1..8/ per-model adapters) — Phase 1.5 - Circuit breaker (failure tracking, 60s unavailable window) — Phase 1.5
- Per-model error aggregation beyond 1 entry — Phase 1.5 (single-model means a 1-element
attemptsarray is enough) router_callMCP tool — Phase 1.5- Scoring-driven fallback ordering — Phase 1.5 (only one model, no ordering needed)
- Cost / latency tracking across fallback — Phase 1.5
- Cross-model retry policy — Phase 1.5 (retry inside
createCompletionalready covers 429 / 5xx for the single upstream)
Existing files this task depends on
| Path | Role here | Key lines / notes |
|---|---|---|
src/domains/router/scoring.ts |
Exports ModelId, ScoreContext, IntentScore, scoreIntent. Read-only from P0.5.2’s perspective. |
L60–83 (types), L125–130 (fn) |
src/domains/router/index.ts |
Barrel. Currently export * from './scoring.js';. P0.5.2 appends export * from './fallback.js';. |
whole file (11 lines) |
src/domains/integrations/claude.ts |
Exports createCompletion(prompt, options), AnthropicConfigError, AnthropicApiError, CompletionOptions, CompletionResult. This is the single upstream the fallback delegates to. |
L332–358 (createCompletion), L56–85 (error classes) |
docs/architecture/decisions/ADR-005-multi-model-defer.md |
Authoritative scope doc. §Decision = single-member invariant; §”Phase 1.5 upgrade path” §3 = fallback.ts extension path. |
L36–46 (Decision), L91–100 (Phase 1.5) |
docs/architecture/decisions/ADR-004-tool-surface.md |
Locks post-Wave-H surface at 14 tools. This task adds zero tools. | L52–69 |
docs/reference/extractions/delta-model-router-extraction.md |
Donor-era δ spec (heritage-only). Not imported; Phase 1.5 consults it when growing the real fallback. | heritage |
Files NOT touched by this task
src/server.ts— no tool registration; no changes.src/config.ts— no new env vars; no changes.src/domains/router/scoring.ts— owned by P0.5.1 (read-only here).src/domains/integrations/claude.ts— read-only (P0.5.2 is a consumer, not a modifier).src/db/schema.sql— δ does not touch the database in Phase 0.- Migration slots — no migration added.
- All other
src/domains/*— δ is decoupled.
New files / directories this task creates
src/domains/router/fallback.ts ← impl (Step 4)
src/__tests__/domains/router/fallback.test.ts ← tests (Step 4)
docs/audits/p0-5-2-fallback-audit.md ← this file (Step 1)
docs/contracts/p0-5-2-fallback-contract.md ← Step 2
docs/packets/p0-5-2-fallback-packet.md ← Step 3
docs/verification/p0-5-2-fallback-verification.md ← Step 5
Constraints and hazards (memorized)
- Tool surface is locked at 14. ADR-004 post-Wave-H — this task registers zero MCP tools.
router_callis Phase 1.5 scope. - No env vars. Adapter configuration (
COLIBRI_MODEL_1..8) is Phase 1.5 work. - Single-member chain. ADR-005 §Decision: “If Claude fails, the call fails — no cascade.” Tests must assert exactly one upstream call on failure.
- No timers. No
setTimeout/setInterval; no circuit breaker state; no time-windowed unavailability.createCompletion’s internal retry loop (for 429/5xx) is the only retry-like behaviour — and it’s fully owned by P0.9.2, not re-implemented here. - Injection seam for testability.
routeRequestmust accept an injectablecompletionFn(defaulting to the realcreateCompletion) so tests mock the upstream withoutjest.unstable_mockModuleceremony. Pattern mirrorssrc/domains/integrations/claude.tswherefetchFnis the seam. - Library-only (same pattern as P0.9.1 / P0.9.2 / P0.9.3 / P0.5.1). No
src/server.tsimport; no MCP handler registration; no migration. - Forward-compat types.
RouteOptionsextendsScoreContext+CompletionOptionssubset so Phase 1.5 expansion is purely additive.RouteResultshape is kept small (model + content + finishReason + rawCompletion) — Phase 1.5 can appendcostUsd,modelsAttempted, etc. - Deterministic Phase 0.
scoreIntentalways returns'claude'(P0.5.1 invariant) ⇒ the winner is deterministic. Two identical calls route to the same model. - Error propagation of
AnthropicConfigError. MissingANTHROPIC_API_KEYshould bubble up as aFallbackChainExhaustedErrorwhose single attempt’serroris the originalAnthropicConfigError— callers caninstanceof-check for distinguishing config failures vs. API failures. - No cross-worktree leak. Step 1 verified clean
git statuson the worktree. - Cross-module import direction.
fallback.tsimports from both./scoring.js(sibling) and../integrations/claude.js(sibling domain). This is the first time a router module reaches into integrations — but it’s read-only, and it’s the exact coupling ADR-005 names (§”Phase 1.5 upgrade path” §3). ROUTER_PHASE_0_SHAPEmarker. A small exported const documents the Phase 0 invariant ({ members: 1, hasCircuitBreaker: false, modelsSupported: ['claude'] }). Tests assert it; if Phase 1.5 flips a flag without intentional code change, the test fails loudly.
Risk ledger
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Tests accidentally hit the real Anthropic API | Medium | High | routeRequest accepts completionFn injection; tests always inject a mock. No NODE_ENV=test guard needed. |
FallbackChainExhaustedError name confuses readers (“chain of one is hardly a chain”) |
Low | Low | Comment explains the name is Phase 1.5 forward-compat; the single-entry attempts array is how Phase 1.5 will log multi-model failure. |
| Phase 1.5 breaks Phase 0 call sites | Low | High | RouteResult is readonly; options are an open interface. Widening ModelId in scoring.ts is additive. |
Retries inside createCompletion interact with the fallback logic |
Low | Low | createCompletion retries 429/5xx up to 3 times; fallback.ts sees only the final result or terminal error. The retry is transparent. |
AnthropicConfigError silently swallowed |
Medium | High | Test asserts: config errors wrap into FallbackChainExhaustedError with attempts[0].error instanceof AnthropicConfigError. |
| Regression: scoring import unwiring | Low | Medium | Explicit test asserts routeRequest consulted scoreIntent via an injectable scoringFn (or direct spy); failure means the router bypassed scoring. |
Conclusion
Surface is src/domains/router/scoring.ts (from P0.5.1) + src/domains/integrations/claude.ts (from P0.9.2). ADR-005 §Decision and §Implementation unambiguously describe a single-member fallback chain that wraps the Anthropic SDK only. Step 2 encodes the behavioral contract (types + invariants + AC→test map); Step 3 lays out the implementation; Step 4 writes code + tests; Step 5 verifies. Task closes Phase 0 at 28/28.