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.tsrouteRequest(prompt, options) delegates to createCompletion from src/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 FallbackChainExhaustedError with attempts: [{ 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:

  1. routeRequest(prompt, options) returns Promise<RouteResult>.
  2. Winner is always 'claude' (delegated to scoreIntent — P0.5.1 invariant).
  3. Delegates to createCompletion from src/domains/integrations/claude.ts.
  4. On failure → throws FallbackChainExhaustedError with attempts.length === 1.
  5. ZERO cascade — only one upstream call is made even on failure.
  6. Zero new MCP tools (ADR-004 stays at 14).
  7. Zero new env vars (ANTHROPIC_API_KEY re-used via the existing claude wrapper).
  8. Zero timers (no circuit breaker state).
  9. Barrel exports both scoring + fallback from src/domains/router/index.ts.
  10. Public shape preserved for Phase 1.5 drop-in replacement (chain expands to N members; FallbackChainExhaustedError.attempts becomes 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 attempts array is enough)
  • router_call MCP 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 createCompletion already 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)

  1. Tool surface is locked at 14. ADR-004 post-Wave-H — this task registers zero MCP tools. router_call is Phase 1.5 scope.
  2. No env vars. Adapter configuration (COLIBRI_MODEL_1..8) is Phase 1.5 work.
  3. Single-member chain. ADR-005 §Decision: “If Claude fails, the call fails — no cascade.” Tests must assert exactly one upstream call on failure.
  4. 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.
  5. Injection seam for testability. routeRequest must accept an injectable completionFn (defaulting to the real createCompletion) so tests mock the upstream without jest.unstable_mockModule ceremony. Pattern mirrors src/domains/integrations/claude.ts where fetchFn is the seam.
  6. Library-only (same pattern as P0.9.1 / P0.9.2 / P0.9.3 / P0.5.1). No src/server.ts import; no MCP handler registration; no migration.
  7. Forward-compat types. RouteOptions extends ScoreContext + CompletionOptions subset so Phase 1.5 expansion is purely additive. RouteResult shape is kept small (model + content + finishReason + rawCompletion) — Phase 1.5 can append costUsd, modelsAttempted, etc.
  8. Deterministic Phase 0. scoreIntent always returns 'claude' (P0.5.1 invariant) ⇒ the winner is deterministic. Two identical calls route to the same model.
  9. Error propagation of AnthropicConfigError. Missing ANTHROPIC_API_KEY should bubble up as a FallbackChainExhaustedError whose single attempt’s error is the original AnthropicConfigError — callers can instanceof-check for distinguishing config failures vs. API failures.
  10. No cross-worktree leak. Step 1 verified clean git status on the worktree.
  11. Cross-module import direction. fallback.ts imports 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).
  12. ROUTER_PHASE_0_SHAPE marker. 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.


Back to top

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

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