P0.5.2 — δ Fallback Chain — Execution Packet

Step 3 of the 5-step chain. Execution plan. Gates Step 4 (implement).

1. Files to create / modify

Path Action LOC (approx)
src/domains/router/fallback.ts Create — impl ~180
src/domains/router/index.ts Modify — append export * from './fallback.js'; +1 line
src/__tests__/domains/router/fallback.test.ts Create — tests ~280

No other files touched. No src/server.ts, no src/config.ts, no scoring.ts, no claude.ts.

2. Implementation plan — src/domains/router/fallback.ts

2.1 Module header (JSDoc)

Cite:

  • docs/architecture/decisions/ADR-005-multi-model-defer.md §Decision (single-member invariant)
  • docs/architecture/decisions/ADR-004-tool-surface.md (14-tool surface, zero added)
  • docs/audits/p0-5-2-fallback-audit.md + docs/contracts/p0-5-2-fallback-contract.md + this packet
  • Phase 0 invariants I1–I13 (from contract §3)
  • Phase 1.5 upgrade path (extend attempts array to N, flip ROUTER_PHASE_0_SHAPE literals)

2.2 Imports

import { scoreIntent, type ModelId, type ScoreContext } from './scoring.js';
import {
  createCompletion,
  createCompletionWithTools,
  AnthropicConfigError,
  AnthropicApiError,
  type CompletionResult,
  type AnthropicTool,
} from '../integrations/claude.js';

No other imports. No fs, no process, no timers.

2.3 Type exports

All types / classes / consts named in the contract §1:

  • RouteOptions (interface extending ScoreContext)
  • RouteResult (interface)
  • FallbackAttempt (interface)
  • CompletionFn (type alias)
  • ScoringFn (type alias)
  • FallbackChainExhaustedError (class extends Error)
  • ROUTER_PHASE_0_SHAPE (frozen const)
  • routeRequest (async fn)

2.4 ROUTER_PHASE_0_SHAPE

export const ROUTER_PHASE_0_SHAPE: {
  readonly members: 1;
  readonly hasCircuitBreaker: false;
  readonly modelsSupported: readonly ['claude'];
} = Object.freeze({
  members: 1,
  hasCircuitBreaker: false,
  modelsSupported: Object.freeze(['claude'] as const),
} as const);

2.5 FallbackChainExhaustedError

export class FallbackChainExhaustedError extends Error {
  readonly code = 'FALLBACK_CHAIN_EXHAUSTED' as const;
  readonly attempts: ReadonlyArray<FallbackAttempt>;
  override readonly cause: Error | undefined;

  constructor(attempts: ReadonlyArray<FallbackAttempt>) {
    const n = attempts.length;
    const modelList = attempts.map((a) => a.model).join(', ');
    const lastMsg = attempts[attempts.length - 1]?.error.message ?? 'unknown';
    const message =
      `δ fallback chain exhausted after ${n} attempt${n === 1 ? '' : 's'}: ` +
      `[${modelList}] ${lastMsg}`;
    super(message);
    this.name = 'FallbackChainExhaustedError';
    this.attempts = Object.freeze(attempts.slice());
    this.cause = attempts[attempts.length - 1]?.error;
  }
}

2.6 Default dispatcher for completionFn

When options.completionFn is absent, the router must dispatch between createCompletion (no tools) and createCompletionWithTools (tools). Encapsulate this in a private function:

function defaultCompletionFn(tools?: ReadonlyArray<AnthropicTool>): CompletionFn {
  if (tools && tools.length > 0) {
    return (prompt, opts) =>
      createCompletionWithTools(prompt, tools as AnthropicTool[], opts);
  }
  return (prompt, opts) => createCompletion(prompt, opts);
}

2.7 routeRequest impl (core)

export async function routeRequest(
  prompt: string,
  options: RouteOptions = {},
): Promise<RouteResult> {
  // Step 1: pick winner via scoring (always 'claude' in Phase 0).
  const scoring = options.scoringFn ?? scoreIntent;
  const decision = scoring(prompt, options);
  const winner: ModelId = decision.winner;

  // Step 2: resolve completion function.
  const completionFn: CompletionFn =
    options.completionFn ?? defaultCompletionFn(options.tools);

  // Step 3: delegate — single call, no retry, no cascade.
  const upstreamOptions = {
    ...(options.model !== undefined && { model: options.model }),
    ...(options.maxTokens !== undefined && { maxTokens: options.maxTokens }),
    ...(options.systemPrompt !== undefined && { systemPrompt: options.systemPrompt }),
    ...(options.apiKey !== undefined && { apiKey: options.apiKey }),
    ...(options.fetchFn !== undefined && { fetchFn: options.fetchFn }),
    ...(options.logger !== undefined && { logger: options.logger }),
    ...(options.delayFn !== undefined && { delayFn: options.delayFn }),
  };

  let upstream: CompletionResult;
  try {
    upstream = await completionFn(prompt, upstreamOptions);
  } catch (err) {
    const errorInstance =
      err instanceof Error ? err : new Error(String(err));
    const attempt: FallbackAttempt = Object.freeze({
      model: winner,
      error: errorInstance,
    });
    throw new FallbackChainExhaustedError([attempt]);
  }

  // Step 4: project upstream result into RouteResult.
  return Object.freeze({
    model: winner,
    content: upstream.content,
    finishReason: upstream.stopReason,
    promptTokens: upstream.promptTokens,
    completionTokens: upstream.completionTokens,
    latencyMs: upstream.latencyMs,
  });
}

Note: RouteResult.model is the abstract router ID ('claude'), NOT the specific model version (upstream.model might be 'claude-sonnet-4-5'). This keeps RouteResult in lock-step with the ModelId type union.

3. Implementation plan — src/domains/router/index.ts

Append one line:

export * from './scoring.js';
export * from './fallback.js';  // <-- new

4. Implementation plan — src/__tests__/domains/router/fallback.test.ts

4.1 Test fixtures

const FAKE_MODEL_VERSION = 'claude-sonnet-4-5';
const FAKE_PROMPT = 'Say hello.';

const SUCCESS_COMPLETION: CompletionResult = {
  content: 'Hello!',
  model: FAKE_MODEL_VERSION,
  promptTokens: 10,
  completionTokens: 5,
  latencyMs: 42,
  stopReason: 'end_turn',
};

4.2 Mock helpers

function makeMockCompletion(
  result: CompletionResult | Error,
): { fn: CompletionFn; calls: Array<{ prompt: string; opts: any }> } {
  const calls: Array<{ prompt: string; opts: any }> = [];
  const fn: CompletionFn = async (prompt, opts) => {
    calls.push({ prompt, opts });
    if (result instanceof Error) throw result;
    return result;
  };
  return { fn, calls };
}

function makeSpyScoring(): {
  fn: ScoringFn;
  calls: Array<{ prompt: string; ctx: ScoreContext | undefined }>;
} {
  const calls: Array<{ prompt: string; ctx: ScoreContext | undefined }> = [];
  const fn: ScoringFn = (prompt, ctx) => {
    calls.push({ prompt, ctx });
    return Object.freeze({
      winner: 'claude' as const,
      scores: Object.freeze({ claude: 1.0 }),
    });
  };
  return { fn, calls };
}

4.3 Test cases (target 14; aligns with 10–15 range)

  1. returns RouteResult with model='claude' and content on success (AC1, AC3)
  2. projects upstream CompletionResult fields into RouteResult (AC1; prompt/completion tokens + latencyMs + finishReason)
  3. consults scoreIntent before calling upstream (AC2; scoring spy called 1×)
  4. forwards prompt unchanged to upstream completionFn (AC4)
  5. forwards maxTokens / systemPrompt / model / apiKey to upstream (AC5, AC6; one test with all four)
  6. throws FallbackChainExhaustedError when upstream AnthropicApiError (AC7, AC8, AC9, AC10)
  7. throws FallbackChainExhaustedError when upstream AnthropicConfigError (AC7, AC11)
  8. does not retry after upstream failure (ZERO cascade invariant) (AC12 — completionFn called exactly 1× even on throw)
  9. two identical calls route to the same model (AC13 — determinism)
  10. ROUTER_PHASE_0_SHAPE asserts ADR-005 invariants (AC14)
  11. passes tools array through to completionFn (AC15)
  12. FallbackChainExhaustedError message includes attempt count and model name (AC16)
  13. wraps non-Error thrown values into Error instances (AC17)
  14. RouteResult.model is abstract 'claude', not upstream version string (AC1 strictness — asserts upstream.model='claude-sonnet-4-5' but result.model==='claude')

4.4 Out-of-scope for tests

  • Real fetch calls (no API hits)
  • Retry logic inside createCompletion — that’s P0.9.2 scope, covered by claude.test.ts
  • Multi-member chain — Phase 1.5 scope

5. Commit plan

audit(p0-5-2-fallback): inventory surface                   ← Step 1 (done, d7aaad41)
contract(p0-5-2-fallback): behavioral contract              ← Step 2 (done, 8990622f)
packet(p0-5-2-fallback): execution plan                     ← Step 3 (this file)
feat(p0-5-2): δ fallback chain Phase 0 stub per ADR-005 — closes Phase 0 28/28   ← Step 4
verify(p0-5-2-fallback): test evidence                      ← Step 5

6. Gate sequence

cd .worktrees/claude/p0-5-2-fallback
npm run build       # must succeed
npm run lint        # zero warnings
npm test            # all suites pass; ~1051 + 14 = ~1065 tests

7. Deviation plan if the gate fails

  • npm run build TS error → fix types in fallback.ts; re-run; no test changes until types compile.
  • npm run lint error → fix; most likely an unused param — prefix with _.
  • npm test failure in a new test → inspect; patch impl or test; re-run.
  • npm test failure in an existing test → revert all edits to files not named in §1 and re-run. If failure persists in src/__tests__/domains/integrations/*, it’s a preexisting flake (documented in memory — startup-subprocess smoke).

8. PR body plan

Title: feat(p0-5-2): δ fallback chain — Phase 0 stub per ADR-005 — closes Phase 0 28/28

Body:

  • Summary: single-member chain; scoring delegates to P0.5.1; delegates to createCompletion; throws FallbackChainExhaustedError on failure.
  • Phase 0 completion: P0.5.1 in PR #149 + this PR = 28/28. All Phase 0 sub-tasks have shipping code.
  • δ concept graduates colibri_code from none to partial in the Wave I close commit (not in this PR).
  • Test plan (3 bullets): build clean, lint clean, test ~1065 passing.

9. Conclusion

Plan is minimal, decoupled, and bounded. No env vars, no MCP tools, no DB, no timers. Step 4 writes the code exactly as sketched above.


Back to top

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

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