Quality Escalation Is Not a Fallback

I used to think a retry was a retry. Something fell over, run it again, maybe somewhere else.

That is wrong, and the cost of being wrong is subtle.

Two failures arrive looking identical. One is a lane that is down. The other is a lane that answered, and answered badly.

Treat them the same and you get a system that shrugs. It hands you the same wrong answer from a different address.

So my router splits them. An availability fallback covers the first case. A quality escalation covers the second. This is how that works.

Availability fallback and quality escalation

An availability failure means the lane could not run. Credentials, a dead endpoint, a provider outage. The work never happened.

The right response is a substitute at the same capability. You do not need a smarter model. You need any model that is actually up.

A quality failure means the lane ran fine and produced something I rejected. The work happened and the output was not good enough.

The right response here is the opposite. A substitute at the same level will produce the same class of answer. You need to climb.

Two arrows from the same failed dispatch: one stepping sideways along the fallback chain at the same rung, one climbing one rung up the cost ladder
Availability fallback moves sideways. Quality escalation moves up. Same trigger shape, opposite intent.

Those two live in different places in my setup, and the registry says so out loud:

AVAILABILITY fallback: an unavailable lane is skipped and the next chain step takes over ON THE SAME TIER. It is a safety net, never an escalation.

QUALITY escalation: a REJECTED result fires a followup dispatch one tier UP escalation_order (never tier_map), linked by parent_dispatch. It climbs the escalation order; it never walks the fallback list.

A fallback chain that climbs is a cost leak. A ladder that walks sideways is a system that never gets a better answer. Naming them separately is what keeps each one honest.

The ladder

Seven rungs, cheapest first, ordered by list price for output tokens. That order says what a rung costs, not what it can do.

RungModelOutput cost per Mtok
tier-zerodeepseek-v4-flash$1.20
tier-lowdeepseek-v4-pro$3.96
tier-midsonnet-5$10.00
kimi-k3kimi-k3$15.00
tier-highopus-5$25.00
gpt-5.5gpt-5.5$30.00
fable-5fable-5$50.00

The two DeepSeek prices are peak rates I measured on 14 September; off-peak is half. My registry still carries $0.28 and $0.87 from spring, marked unverified. And deepseek-v4-flash is a retired name: DeepSeek serves it with V4.1-Flash.

A price column you do not re-measure drifts quietly, and the ladder only holds as long as the order does.

The ladder lives in wave7_models.yaml, not in Python. A new model slots in by editing the registry, and provider_registry.load_tier_ladder() reads it.

That direction was settled in ADR-036: model identity comes from the registry, never from string literals in code.

One detail matters more than it looks. Only four of these rungs are entry points.

tier-zero, tier-low, tier-mid and tier-high double as the classifier's scope buckets. A new dispatch enters the ladder at one of them.

The climb does not walk this list. It follows a separate, explicitly authored order: tier-zero, tier-low, tier-mid, tier-high, fable-5. kimi-k3 and gpt-5.5 are not on it. They stay on the price ladder and serve as availability fallbacks, but no climb starts or lands there.

That changed in August, and for a reason. When the climb did walk the price list, a rejection on tier-mid landed on kimi-k3: a different lane with its own quota, not a more capable model. Price order and capability order are not the same list.

Fable-5 is the only rung you reach by climbing alone. It is expensive enough that I want it reachable only by a system that has already spent a cheaper attempt and had it rejected.

Why starting cheap is not the contradiction it looks like

I wrote earlier that a router should hold a capability floor before it looks at price, because a cheap lane that produces rework is more expensive than a slightly pricier one that lands it in a single pass. I still think that is right.

The ladder starting on the cheapest rung looks like the opposite of that rule. It is not, and the difference is the trigger.

The ladder does not climb because something is expensive. It climbs because something was rejected. Rework is not the risk it is trying to avoid. Rework is the signal it runs on.

That flips the economics of a wrong guess. Under a pick-once router, a bad choice costs a full rejected pass and a manual re-run.

Under a ladder, a bad choice on rung one costs one attempt at the cheapest price on the ladder. It also produces the exact evidence needed to justify rung two.

What counts as a failure

Not a boolean. A class.

python
_ESCALATION_TABLE = {
    "model_error": "climb",
    "credit_exhausted": "climb",
    "auth_rejected": "no_climb",
    "timeout": "retry_same_tier",
    "empty_completion": "retry_same_tier",
    "completion_without_execution": "climb",
    "no_verdict": "retry_same_tier",
    "tool_missing": "no_climb",
    "unknown": "no_climb",
}

Read that table as a set of arguments rather than a config block.

auth_rejected does not climb, because a higher rung has the same auth problem. Climbing on a credential error just spends more money to fail again, one tier up.

timeout and empty_completion get one retry on the same rung first. Both are usually noise, not a verdict on the model. Climbing on the first timeout would escalate every hiccup into a bill.

credit_exhausted climbs and also notifies me, even at the top rung where climbing is impossible. A dead wallet is actionable no matter where on the ladder it happens.

The table started with six rows. The last three arrived in August, each after a failure I had actually seen. Every one got its own explicit answer instead of falling into a default.

Refusing to guess

The unknown row is the one I would defend hardest.

unknown maps to no_climb, and it is reported loudly. A failure class the table does not list at all gets no default: it raises, with the message refusing to guess a climb.

The tempting design is to treat unknown as a climb. It feels safe. Something broke, try harder, move up. That instinct is exactly how a routing layer starts spending money on a category of failure nobody has looked at yet.

An unknown failure class means my classifier met something I have not modeled. The correct response is to surface it, not to paper over it with a more expensive model.

Two invariants, enforced loudly

load_tier_ladder() refuses to load a ladder that violates either of these.

No two rungs may resolve to the same primary. A duplicate rung escalates nowhere. You climb, you land on the same model, you get the same answer, and the ladder has silently become a no-op with extra steps.

Cost must be strictly increasing. A higher rung has to actually be more expensive. Without that, "one tier up" stops meaning anything and the ladder is just a list.

The escalation order has its own check on top: every rung in it must exist on the price ladder.

All of these fail at load time, not at dispatch time. A misconfigured ladder never gets the chance to make a bad routing decision in front of real work.

It stages, it does not fire

When the table says climb, the escalation does not launch. It stages a followup bundle, and I promote it through the door.

The docstring is blunt about it: It STAGES ONLY, the operator promotes the bundle through the door; nothing is auto-fired.

This is the same human-on-the-loop line I hold everywhere else. An automated system that can escalate its own spending without a human in the path is a system that can surprise you with a bill.

The staged followup carries tier_from, tier_to and parent_dispatch on its spec. That last field is the one I care about.

It turns two dispatches into a chain. So the receipt can answer why the second attempt ran on a more expensive model, and the answer is a link to the attempt that got rejected.

Where the old router went

The cost-aware router I built on top of my field tests is still here.

It classifies a task into one of seven classes, holds a measured quality floor, and sorts the lanes that clear a capability bar of 7.0 by cost, with score as the tiebreaker.

It is no longer the selection path at the door. It became opt-in, behind --auto-route in provider_dispatch.py and subprocess_dispatch.py, where it still picks a provider and a model and writes its decision to the ledger.

Inside the door it now does two other jobs. It classifies the task, and it resolves the review gate weight. Both are judgment calls about the work. Neither is lane selection.

I did not plan that split. It fell out of the fact that per-task ranking and per-failure escalation answer different questions. One asks who should do this. The other asks what to do when the answer came back wrong.

The shape worth copying

You do not need seven rungs or my model list. You need the distinction.

Write down what a lane being down means in your system, and write down separately what a rejected answer means. Give them different code paths. Keep the sideways move on the same tier and make the upward move cost more.

Then make your failure taxonomy explicit, and make the unknown case loud instead of convenient. The failure classes you have not named yet are the ones that will quietly drain a budget.

The ladder, the registry and the constraint rules are open source in Vinix24/vnx-orchestration.

Read also: weighing a routing layer against a direct call is the neighbouring question. I wrote the decision rule for that in MCP versus CLI: when to replace a server with a command.

I write about building governed AI systems in the open. Follow along on LinkedIn if that is your kind of problem.

Vincent van Deth

AI Strategy & Architecture

I build production systems with AI — and I've spent the last six months figuring out what it actually takes to run them safely at scale.

My focus is AI Strategy & Architecture: designing multi-agent workflows, building governance infrastructure, and helping organisations move from AI experiments to auditable, production-grade systems. I'm the creator of VNX, an open-source governance layer for multi-agent AI that enforces human approval gates, append-only audit trails, and evidence-based task closure.

Based in the Netherlands. I write about what I build — including the failures.

Comments

Your email address will not be published. Comments are reviewed before publication.

Loading comments...