Skip to content

Fallback Continuity

Fallback Continuity — two relay runners at full stride completing a clean baton exchange, the baton itself a thread of conversation drawn in a single teal line

A fallback chain has exactly one job. When the primary seat goes dark — a saturated GPU, a cold model, a seat that simply times out — the turn is supposed to survive the handoff and land somewhere that can finish it. That is the entire promise. A deep audit of the live proxy found the chain doing the opposite: quietly shredding the very conversation it was built to carry, then handing the stump to whoever caught it next.

The number that started the sweep, measured on the real handler through real HTTP: a fallback seat received 6 of 80 messages. It shipped 2026-07-26 to sanctum-proxy (origin/main 6aec9a6, PR #35), twelve defects deep, and one of them mattered more than the other eleven combined.

data is parsed once in handle_messages and loop-carried through every attempt in the chain. fit_context_to_budget truncates it in place, and nothing ever put it back.

So a 16384-token cathedral seat would amputate a session down to its last few turns, fail for its own unrelated reason, and the chain would hand that stump to the next seat. Including claude-cli-offline, the 200k-context bridge that needs_cathedral_context_fit() explicitly exempts from truncation. The exemption was real. The guarantee was void, because the damage had already happened upstream of it.

The fix is two lines: snapshot the body after routing, restore it at the top of every attempt. It costs one clone per failed attempt and nothing at all on the happy path.

let pristine = data.clone();
for model_key in &models_to_try {
data = pristine.clone();

Every other context defect below was chain-wide before this landed and is seat-local after it. That is the whole reason it went in first.

Seat capacity was never consulted when choosing a seat — max_prompt_tokens was read at forward time, after the seat was already committed. A 190k session could be deliberately shredded on a 16k seat while a seat that fits it sat one rung down. (The reverse failure — accepting a load that then stalls the kernel — is the subject of the Capacity Doctrine.)

The chain is now stable-partitioned by whether a seat can hold the estimated prompt. Seats that fit keep their configured priority; the ones that would truncate stay in the chain as a genuine fallback, because a degraded answer still beats no answer. Live, on a 38k-token request:

continuity: preferring seats that can hold the prompt
prompt_tokens=38414
promoted=["claude-cli-offline"]
deferred=["council-mlx", "council-code"]
DefectWhat was actually happening
Shared bodyTruncation by one seat inherited by every later seat
Seat choiceCapacity never consulted before committing to a seat
Tool pairingOn an agentic transcript every user turn carries a tool_result, so the drop-alignment loop walked itself to the end and kept one message
Lost taskThe first user turn — the instruction and its constraints — was always deleted first
Token estimate40 MCP tool definitions counted as zero tokens; one screenshot charged 200035
SilenceThe DEGRADED warning was suppressed on exactly the mid-loop turns where it fired
Tool imagesImages nested in a tool_result dropped; an image-only result became a single space
UsageStreaming responses reported zero tokens, blinding the client’s own accounting

Pairing is now an invariant rather than a per-path promise: repair_tool_pairing() runs after every message-dropping path and removes any tool_use or tool_result whose counterpart did not survive. An orphan is a hard 400 upstream, and on a backend that tolerates it the model renders output for a call it never made.

The original task now survives too. head_marker() folds the caller’s first turn into the truncation marker instead of deleting it, so the model can still see what it was asked to do.

tool_choice was never translated. Not mishandled — absent. A search of the entire tree found zero occurrences. A caller sending {"type":"none"} to forbid tool use had that decision silently replaced by the OpenAI default of auto the moment the request landed on a fallback seat, and a forced tool degraded to prose.

Streaming parallel tool calls were demultiplexed by “whichever block opened last” rather than by the index field. Two concurrent calls could have their argument fragments spliced into each other, so the permission prompt showed one invocation’s name with another’s arguments. The user approves one thing; a different one runs.

The PII scrubber walked tool schemas key-blind, so JSON Schema structural keywords went to the NER sidecar and came back rewritten. A $ref looks like a URL; format: email looks like an email. The result was a schema the model could not satisfy. Scrubbing is now key-aware inside schema subtrees only — enum, const, default and description are still scrubbed, and tool-call arguments still get the full treatment. No privacy posture changed.

Two defects were genuine judgment calls rather than clear bugs, so they went to the Jedi Council. Both rulings were unanimous.

Stream termination. An upstream can end with neither a finish_reason chunk nor a [DONE] sentinel, leaving the Anthropic message unterminated. The tempting fix is to close it cleanly. The Council rejected that: a clean end_turn makes a truncated answer look complete, which is precisely the silent-truncation mode the mid-stream error branch was written to prevent. The stream now closes loudly — an error event, then stop_reason: "error".

Native tools. An OpenAI-shape seat cannot express Anthropic server-tools (bash_*, text_editor_*, computer_*). The old behaviour deleted them and served the request anyway, reducing the caller’s permission surface without saying so while the matching tool_use blocks stayed in history. The Council chose explicit failure: skip the seat, log why, let the chain reach one that can honour them.

The guard is byte-exact and lowercase on purpose. Claude Code’s own tools are capitalised — Bash, Read, Edit — and are untouched. A regression test pins that.

Refusing when the seat cannot serve the request at all

Section titled “Refusing when the seat cannot serve the request at all”

A third ruling arrived from a parallel session mid-sweep. Someone asked proxyd for google/gemini-3-pro-image-preview. No seat matched, so the ladder walked down to the local MLX text model, which answered confidently and invented an image URL that does not exist.

That is not degradation. A text model answering an image request is fabrication, and a wrong answer that looks right is worse than an outage — the outage self-reports, the fabrication does not.

Seats now declare a capability: text, image, embedding or vision. An untyped seat is text, which all 43 live seats are, so the guard shipped with no config change. The class required by a turn comes from the requested name: a configured seat declares it, an unconfigured name is inferred from markers like image-preview, dall-e, imagen or embedding. The ladder may only traverse seats of that class, and if none remain the answer is a hard 501 capability_unavailable whose body carries no assistant content that could be mistaken for a result.

Text requests skip the check entirely, so the ordinary path is untouched.

This composes with continuity rather than competing with it. Continuity makes a switch carry the request faithfully; capability typing makes the proxy refuse when the destination cannot serve it. Every response now also carries x-sanctum-route-chain listing the seats traversed, because degrading to a fallback is an event, not an implementation detail — the same instinct behind the session continuity cache.

Every fix was written test-first and watched fail against the old code. The suite went from 152 to 171. clippy -D warnings is clean.

Live, through the deployed binary on port 4040:

proxy-e2e --full 46 checks fail=0 (identical to the pre-deploy baseline)

Targeted probes against the running proxy:

ProbeResult
Native server-tool on an OpenAI-shape chainAll seats skipped, honest ledger, zero inference burned
38k-token request to a capped seatDiverted to the 200k bridge, answered correctly from turn one
38k-token request to an all-capped chainTruncated 38414 to 2040, DEGRADED banner shown, still answered correctly
Image model with no image seatHard 501 capability_unavailable, zero content keys in the body
Ordinary text request200, correct answer, x-sanctum-route-chain present

That third row is the one to read twice. hard_tail_keep fired — the most destructive pass there is, keeping only the final handful of messages — and the model still recovered a passphrase planted in the very first user turn. That is head-pinning working on live traffic. Before this change, the first turn was the first thing deleted.

None of this changes the promise we made, which was always the small one: hand your turn to the next runner without dropping the baton. A relay is only ever as good as its exchange — and if we have done this right, you will finish a long conversation without ever learning which runner carried which leg of it.