Four files decide everything the router does:
config/pool.yaml what can be reached, and on what terms
config/concepts.yaml the routable units, authored by people
config/policy.yaml concept -> ranked candidates + objective
config/providers.yaml base URLs, and which env var holds each key
They could be one file. They are four because of what has to change independently.
Adding a model
A row in pool.yaml:
- id: vendor-balanced
provider: anthropic
model: claude-sonnet-5
residency: vendor
tier: mid
context_window: 200000
capabilities: [tool_use, json_mode, long_context, streaming]
price_in: 0.30
price_out: 1.50
And its id in whichever policy rows should consider it. That is the entire change. No concept file moves. Nobody re-authors a taxonomy because the model catalogue grew, and nothing is retrained, because the classifier compares against exemplar sentences rather than against a learned mapping from prompts to models.
Everything the hard filters gate on lives in the pool row — residency, tier, context window, capabilities — because those are properties of the model. Put them in the policy and you would repeat them per concept and eventually disagree with yourself.
Adding a route
A concept with a description and three or more exemplars, plus a policy row. The model catalogue is untouched.
The two changes are orthogonal, which is the whole point of the split: the question "what kinds of work exist" and the question "what can we reach" have different answers, different owners, and different rates of change.
Validated against each other, at load
An id in a policy row that names no model in the pool is an error at startup, not a silent drop to the next candidate:
{"status":"ready","models":5,"concepts":5,
"default_concept":"general.assist",
"catalogue_fingerprint":"c16768ae114fc540"}
/readyz fails while the four artifacts have not loaded and agreed with each
other, so a bad config takes the instance out of rotation instead of serving
subtly wrong routing. A typo in a model id is a container that will not come up
— which is loud, early, and much better than a policy row that quietly has one
fewer candidate than its author believed.
The fingerprint is there so two instances can be compared: same catalogue or not, one string.
Config, not a database
The four artifacts are baked into the image. A routing change is a deploy with a diff somebody reviewed.
The alternative — a table, an admin UI, live edits — is genuinely attractive and we are not doing it. Three reasons, in increasing order of importance.
It would put a lookup on the request path, against a 5% routing budget that exists to keep network calls off it.
It would make "what was the routing policy last Tuesday" a question about
database history rather than about git log.
And it would mean a residency constraint could change without a review. That is
the one that settles it: residency: in_infra on money-path work is a
compliance decision, and a compliance decision that can be edited in a web form
by whoever is on call is not one.
The cost, honestly
Adding a model needs a deploy, and deploys are slower than a form submission. That is a real cost, paid on a change that happens rarely, in exchange for a routing policy that is reviewable, revertible and attributable.
It is the same trade as authoring concepts rather than learning them, and the same reasoning: for the parts of this system that encode a rule somebody is accountable for, being fast to change is worth less than being hard to change by accident.