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
attemptsarray to N, flipROUTER_PHASE_0_SHAPEliterals)
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 extendingScoreContext)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)
returns RouteResult with model='claude' and content on success(AC1, AC3)projects upstream CompletionResult fields into RouteResult(AC1; prompt/completion tokens + latencyMs + finishReason)consults scoreIntent before calling upstream(AC2; scoring spy called 1×)forwards prompt unchanged to upstream completionFn(AC4)forwards maxTokens / systemPrompt / model / apiKey to upstream(AC5, AC6; one test with all four)throws FallbackChainExhaustedError when upstream AnthropicApiError(AC7, AC8, AC9, AC10)throws FallbackChainExhaustedError when upstream AnthropicConfigError(AC7, AC11)does not retry after upstream failure (ZERO cascade invariant)(AC12 — completionFn called exactly 1× even on throw)two identical calls route to the same model(AC13 — determinism)ROUTER_PHASE_0_SHAPE asserts ADR-005 invariants(AC14)passes tools array through to completionFn(AC15)FallbackChainExhaustedError message includes attempt count and model name(AC16)wraps non-Error thrown values into Error instances(AC17)RouteResult.model is abstract 'claude', not upstream version string(AC1 strictness — assertsupstream.model='claude-sonnet-4-5'butresult.model==='claude')
4.4 Out-of-scope for tests
- Real
fetchcalls (no API hits) - Retry logic inside
createCompletion— that’s P0.9.2 scope, covered byclaude.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 buildTS error → fix types in fallback.ts; re-run; no test changes until types compile.npm run linterror → fix; most likely an unused param — prefix with_.npm testfailure in a new test → inspect; patch impl or test; re-run.npm testfailure in an existing test → revert all edits to files not named in §1 and re-run. If failure persists insrc/__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; throwsFallbackChainExhaustedErroron 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_codefromnonetopartialin 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.