# Family-locked routing (/docs/family-lock)



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.

<SourceMeta subpath="@pleach/core" source="{ label: &#x22;graph/recovery/ + graph/seams/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/graph/recovery&#x22; }" />

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 [#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 [#the-routing-cluster]

Family-lock is one of three concepts that decide how a call
reaches a model — paired with [CallClass](/docs/call-classes)
(what kind of call this is) and [Seam](/docs/seams) (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](/docs/concept-clusters#routing-cluster);
this page is the deep-dive on the family lock itself. The matrix
at [Model resolution matrix](/docs/model-resolution-matrix) is
the reference table the seam consults; the wiring to a real SDK
is at [Providers](/docs/providers).

## What locks at session start [#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](/docs/session-lifecycle)
for how that boundary is drawn and what carries across.

## The family-strict cascade [#the-family-strict-cascade]

<Mermaid
  chart="flowchart TD
    Start[pickNextInFamily] --> R1[locked-family rung 1]
    R1 -.->|fail| R2[locked-family rung 2]
    R2 -.->|fail| R3[locked-family rung 3]
    R3 -.->|fail| FE[family-exhausted]
    FE --> Stop[runtime surfaces state to consumer]
    Other[other family column]
    FE -.-x Other"
/>

The cascade rule is one function: `pickNextInFamily(family,
currentModel, triedSet)`. It walks the locked family's column in the
[model resolution matrix](/docs/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 [#provider-families]

```typescript
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 [#transports]

```typescript
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](/docs/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 [#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](/docs/providers) for the wiring and
[Env vars](/docs/env-vars) for the credential surface.

## Host-internal planner calls [#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](/docs/seams). 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`](/docs/plans) 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 [#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](/docs/auditable-call-row#typed-payload-slots)
for the per-payload field shape.

## In the fingerprint [#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](/docs/fingerprint#whats-in-the-key) for the full
cache-key shape and the metadata fields the key deliberately
excludes.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Model resolution matrix" href="/docs/model-resolution-matrix" description="The (family x callClass) cells and the per-family rung ladders the cascade walks." />

  <Card title="Seams" href="/docs/seams" description="The per-call-class entry point that calls into family resolution and dispatches the observer ladder." />

  <Card title="Call classes" href="/docs/call-classes" description="The four-class taxonomy that pairs with family to key the matrix." />

  <Card title="Providers" href="/docs/providers" description="The transport layer beneath the lock — native, openrouter, byok-native, byok-openrouter." />
</Cards>
