pleach
Architecture

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.

Subpath@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-opusclaude-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

LockWhat it freezes
TokenizerPer-family token-count semantics
Prompt-cache keyProvider-side prompt cache identity
Tool-call dialectSchema shape the provider accepts (OpenAI vs Anthropic vs Google)
Refusal patternThe 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:

SlotWhen it lands
familyLockAt the resolution boundary; carries resolverPath, requestedFamily, familyMismatch, byokActive
fallbackStepAfter each in-family rung attempt; carries originalModel, attemptModel, inFamily: true, attemptIndex
providerCascadeAt 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

On this page