Family-locked routing
One ProviderFamily and one Transport lock at session start, freezing tokenizer, prompt-cache key, tool-call dialect, and refusal pattern. The cascade walks in-family rungs only.
At session start the runtime locks one ProviderFamily and one
Transport. The lock freezes four properties for the session's
lifetime — the tokenizer, the prompt-cache key, the tool-call
dialect, and the refusal pattern. When a provider call fails, the
cascade walks the next rung in the same family; it never silently
widens to another. The only allowed cross-family path is a narrow
carve-out for models that never resolved through the matrix.
@pleach/coreSourcegraph/recovery/ + graph/seams/The (ProviderFamily × CallClass) resolution matrix itself is
host-supplied — @pleach/core reaches it through the
AgentAdapter.resolveModel<C>() seam rather than shipping the table,
so a consumer wires their own model map without forking the package.
The in-family cascade (pickNextInFamily) and the family-exhausted
terminal live in the package; the lookup table they consult does not.
Why the lock matters: provenance
The model that answers a session can change for ordinary reasons. A transient provider failure triggers a failover; a migration moves you from one provider to another over a release cycle. When it changes, an audit-bound deployment has to answer two questions about every turn: which provider and model actually produced it, and when — and why — the session changed.
Family-lock makes both answerable by constraining what can change
without a record. Within a session the family is pinned; a transient
failure falls back in-family only (claude-opus → claude-sonnet),
recorded as a fallbackStep row carrying inFamily: true and the
originating and attempted models. A genuine provider change is never a
silent widen — it's an explicit sessions.updateProviderModel that
re-locks the session and emits a session-lock-resynced event. Every
AuditableCall row carries the provider and transport that ran, so
"which model produced this answer, and when did it change" is a query
against the ledger, not a reconstruction after the fact.
The contrast that matters isn't whether another tool can switch models mid-thread — many can, and re-render the conversation cleanly into the new provider's format. It's whether the switch leaves a durable, queryable record you can put in front of an auditor. A model label in UI state, or a per-message field in a local dev tool's storage, isn't the same thing as a typed row in your own database — hash-chained, tenant-scoped, and joinable to the rest of the session's audit trail. That's the provenance family-lock is built to keep honest.
The routing cluster
Family-lock is one of three concepts that decide how a call reaches a model — paired with CallClass (what kind of call this is) and Seam (the per-class entry point). The cluster sits between the execution graph and the LLM transport. The full triplet framing lives at Concept clusters → Routing; this page is the deep-dive on the family lock itself. The matrix at Model resolution matrix is the reference table the seam consults; the wiring to a real SDK is at Providers.
What locks at session start
| Lock | What it freezes |
|---|---|
| Tokenizer | Per-family token-count semantics |
| Prompt-cache key | Provider-side prompt cache identity |
| Tool-call dialect | Schema shape the provider accepts (OpenAI vs Anthropic vs Google) |
| Refusal pattern | The shape and language of provider-side refusals |
Why these four. The tokenizer decides how many tokens a prompt is,
which decides cost and context-window fit. The prompt-cache key
decides which calls share a provider-side cache hit. The tool-call
dialect decides whether a tool-loop turn parses at all — Anthropic's
input_schema shape and OpenAI's function.parameters shape are
not interchangeable mid-session. The refusal pattern decides whether
a retry loop recognizes "I can't help with that" and stops, or
treats the refusal as a transient error and retries forever.
The lock is per-session and holds for the session's lifetime
unless you explicitly re-lock it. A session locked to anthropic
stays on Anthropic until something re-locks it; switching family is
an explicit operation — sessions.updateProviderModel re-locks the
session and emits a session-lock-resynced event — never a silent
mid-turn widen. See Session lifecycle
for how that boundary is drawn and what carries across.
The family-strict cascade
The cascade rule is one function: pickNextInFamily(family, currentModel, triedSet). It walks the locked family's column in the
model resolution matrix — same
family, next rung. The matrix's per-family ladders are the source
of truth for ordering.
When every in-family rung is tried, the runtime emits a
family-exhausted state. The consumer surface — UI, orchestrator,
host — picks a different family explicitly. No silent cross-family
widening.
A concrete walk. A session locked to anthropic on synthesize
starts at the matrix's (anthropic, synthesize) cell. The first
call fails with a provider-side 529. The cascade calls
pickNextInFamily("anthropic", "claude-sonnet-4-6", new Set([...]))
and gets the next rung in the same column. A fallbackStep row
lands in the ledger with inFamily: true and the new
attemptModel. When the column runs out, the runtime emits
family-exhausted and stops — no row crosses into
(openai, synthesize).
Why structural. A tool-call dialect that worked on Anthropic would start failing mid-session if the cascade silently widened to OpenAI: the same agent code, the same session id, two incompatible tool dialects within one conversation. The lock saves the session from in-flight schema drift; the explicit pivot makes the family change visible to the host instead of invisible inside the runtime.
Provider families
type ProviderFamily =
| "anthropic"
| "openai"
| "google"
| "deepseek"
| "moonshot"
| "mistral"
| "xai"The union is closed in the substrate matrix. Adding a family means editing the matrix and shipping a new minor release. BYOK and non-matrix-resolvable models bypass the union via the carve-out below.
Transports
type Transport =
| "native"
| "openrouter"
| "byok-native"
| "byok-openrouter"Transport is per-session, locked alongside family. native calls
the provider SDK directly. openrouter routes through OpenRouter
— useful for evaluation and for families without a native
transport. byok-native and byok-openrouter use customer-supplied
credentials instead of the deployment's. See
Providers for the per-transport wiring.
Mid-session escalation across transports doesn't silently happen.
A session that starts on anthropic + byok-native and exhausts
the user's key surfaces the exhaustion through the ledger as a
providerCascade row with outcome.status: "exhausted", not a
silent flip to the deployment's own credentials.
The BYOK / non-matrix carve-out
Non-matrix-resolvable models — BYOK rigs, multimodal-only slugs, unrecognized model identifiers — preserve the legacy cross-family fallback. Those sessions never had a family lock to honor in the first place, so the lock can't constrain a cascade it never participated in.
The carve-out is opt-in via BYOK configuration. It never fires silently against matrix-resolved models. A session that resolves through the matrix gets the family-strict cascade; a session operating on a BYOK slug gets the legacy fallback. See Providers for the wiring and Env vars for the credential surface.
Host-internal planner calls
The family lock governs the conversation's seam calls — the
synthesize, converse, reasoning, and utility calls that reach
a model through the provider seam. A host can also make
internal model calls that never pass through the seam, and those calls
are outside the lock by construction. The clearest case is the
planner cold-start.
The anchor-plan stage generates the turn's plan. A
host may run that generation on its own configured planner model — a
fixed model chosen by host config, not the session's locked family.
That call is host-internal, not a seam call: it consults no
(family x callClass) matrix cell and walks no in-family cascade,
because the planner model is pinned, not resolved.
It still writes a ledger row. Identify it by its stage and class —
stage: "anchor-plan", callClass: "utility", payload.kind: "planGeneration" — and by what it lacks: no fallbackStep, no
family-exhausted state, no cross-family providerCascade. There is
no family decision to record because the planner model was never a
matrix candidate.
This is a documented exception, not a leak. The lock constrains the
cascade for matrix-resolved seam calls; a pinned host planner model
sits beside that system, the same way a BYOK slug does. Watch it the
way you watch the BYOK carve-out: on a matrix-resolved session, the
only row whose family differs from the session's locked family should
be this planner cold-start. Any other non-locked-family row is the
leak signal — wire an alert that allows the anchor-plan /
utility planner row and reds on everything else.
On the audit row
Every AuditableCall.call carries provider: ProviderFamily and
transport: Transport — what actually ran, after resolution. Three
typed payload slots track family decisions:
| Slot | When it lands |
|---|---|
familyLock | At the resolution boundary; carries resolverPath, requestedFamily, familyMismatch, byokActive |
fallbackStep | After each in-family rung attempt; carries originalModel, attemptModel, inFamily: true, attemptIndex |
providerCascade | At a cross-family pivot — BYOK carve-out only under the lock |
A providerCascade row whose source is not the carve-out is the
canonical "the lock leaked" signal. Wire a dashboard alert on it;
under the matrix, that row shouldn't exist. The query is direct:
WHERE payload.kind = 'providerCascade' AND payload.source != 'imperative-carve-out' returns zero rows for a healthy
matrix-resolved fleet. See
Auditable call row
for the per-payload field shape.
In the fingerprint
family is part of the fingerprint cache key. Two calls with the
same prompt and tools but different families never share a cache
entry — correct, because the tokenizer differs, the tool dialect
differs, and the provider-side cache key differs. A cache hit
across families would mean serving an Anthropic-tokenized response
to an OpenAI-tokenized call; the byte counts and tool-arg shapes
wouldn't match.
transport participates in the same way. A session on
byok-native and one on openrouter route through different wire
shapes; sharing a cache entry would serve a response shaped for
one transport to a call expecting the other.
See Fingerprint for the full cache-key shape and the metadata fields the key deliberately excludes.
Where to go next
Model resolution matrix
The (family x callClass) cells and the per-family rung ladders the cascade walks.
Seams
The per-call-class entry point that calls into family resolution and dispatches the observer ladder.
Call classes
The four-class taxonomy that pairs with family to key the matrix.
Providers
The transport layer beneath the lock — native, openrouter, byok-native, byok-openrouter.
Call classes
The four-class taxonomy every LLM call declares — utility, reasoning, converse, synthesize — with the lint that restricts the literal and the structural cap of one synthesize per turn.
Model resolution matrix
Which model fires for which call class, resolved per provider family at session start.