Control
Model routing and host identity
How Guild Stack decides which model a run uses — per-run host identity, evidence-graded catalogs, purpose floors, and a read-only inspection command.Page evidence and guidance
Use this page to answer three questions about a run: which host and execution target it is bound to, which models that target can actually be asked for, and why a given model was chosen.
Try it: /guild:models inspect prints the whole picture for a run and writes nothing.
What shipped, and what has not
Model routing lands in milestones. Read this table before anything else on the page — the Status column is the current shipped posture, not a roadmap.
Milestones were designed to be independently reversible by a flag in .guild/settings.json, but only some of that shipped: model_routing.discovery, model_routing.inspect, model_routing.shadow, and model_routing.enabled really do gate behaviour, while model_routing.identity_v2, model_routing.binding_enforce, and teams.proposal_v2 are declared but not implemented — turning them off changes nothing. See Rolling back below.
| Milestone | Status | What it does |
|---|---|---|
| M0 — inspect-only | Shipped / live, on by default — inspect-only; 2 of its 5 rollback legs are wired (discovery, inspect) | Per-run host identity, fail-closed run binding, discovery adapters + cache, the uncapped team proposal path, and the read-only /guild:models inspect surface. Changes no model selection anywhere. |
| M1 — shadow | Shadow — gated, off by default. Not part of the default posture | Runs the resolver alongside your real dispatch and records what it would have chosen. Still no effect on the dispatch. |
| M2 — opt-in routing | Opt-in, OFF by default — NOT the active selection posture, not released. Turning it on also requires evidenced M0 inspection + M1 shadow artifacts | The v2 policy actually selects the model, per project or per run. |
| M3 — default on | Planned / deferred — NOT shipped | Deferred. No released build turns it on. |
If you have never edited .guild/settings.json, you are on M0: routing is observable but not yet deciding.
A model listing is not an entitlement
The rule the whole design rests on: a model appearing on a provider’s public page, in a static registry, or in an unauthenticated list is not proof that your account can dispatch it today. Every catalog row therefore carries an evidence grade.
| Evidence | Means | Where it can come from |
|---|---|---|
available | This authenticated target can dispatch this model for this account. | An authenticated list whose published contract states account availability (today only the Claude API GET /v1/models), or real dispatch evidence from this exact target. |
advertised | The target listed the model. Entitlement is unproven. | codex debug models, the Codex app-server model/list, account-scoped pickers whose contract says nothing about availability. |
unknown | No usable evidence — the honest answer for a target with no discovery surface. | The Claude desktop app and web surface, and the Bedrock / Vertex / Foundry gateways before a first dispatch. |
unavailable | A dispatch failed with model-not-found or permission-denied here. | Dispatch evidence only. |
Eligibility is then closed and purpose-aware. Research, advisory, adversarial, and security work require available. Ordinary implementation work may attempt an advertised model only when you opt in with allow_advertised_attempt: true. unknown and unavailable are never eligible.
Static registry rows and built-in mappings can fill in missing metadata. They can never promote a row to available.
Targets, not hosts
A host family is not the unit of routing. The execution target is, exactly: host.family + host.surface + provider_kind + auth_mode + account_fingerprint + endpoint_fingerprint + org_fingerprint. Those seven fields are the whole tuple. The same CLI binary under two auth modes is two targets, and neither inherits the other’s entitlement; org_fingerprint is in the tuple so one org’s entitlement is never projected onto another’s.
The catalog cache key is a different, larger thing — don’t confuse the two. It hashes the seven tuple fields plus target_id, tool_version, adapter_id, adapter_version, and run_scope. Tool and adapter versions extend the cache key; they are not a substitute for the target’s org scope. When org_fingerprint is unknown, run_scope becomes the run id, so the entry is run-local and never reused across runs.
claude-cli-subscription— the picker is interactive-only, so entitlement cannot be proven before a dispatch; rows stayadvertisedorunknownuntil an ordinary dispatch upgrades them.claude-appandclaude-web— no programmatic discovery surface exists. The catalog reportsunknownrather than borrowing the API listing.claude-api— the one surface today whose contract states account availability.claude-gateway-bedrock/-vertex/-foundry— exact rows, gateway-native evidence only, honestunknownuntil dispatch evidence upgrades them.codex-cli-chatgpt—codex debug modelsbehind a versioned adapter;advertisedat best.codex-app-server— the richest Codex surface (provider-ordered reasoning efforts, visibility metadata) but with undefined entitlement semantics, so stilladvertised.codex-cli-api-keyandopenai-api— separate targets again.
Discovery is read-only, never issues a paid inference request, is bounded at three seconds, degrades to the newest valid cache or an honest unknown, and never puts credentials into a cache, receipt, or log.
Identity is per run, and unknown is safe
Each run gets one immutable identity record: host family, surface, adapter, target tuple, and a trust value of asserted or verified.
- Unset, malformed, or unrecognized identity resolves to
unknown. It never falls back to Claude. - Precedence is not trust.
GUILD_HOST_IDoutranks adapter injection for precedence, but it staysasserteduntil a handshake verifies it. - There is no workspace-wide “current host”. Two sessions in one project produce two distinct records, and a runtime write with a missing or mismatched run binding fails closed rather than landing in the wrong run.
Purpose floors
Complexity stays easy | medium | hard, but the purpose of the work can raise the floor and can never be talked below it.
purpose: researchalways resolveshardand thepowerfultier, even when a caller, lane, or heuristic labels the task easy. The receipt recordsforced_floor_reason: research_always_hard.- The floor is inherited by nested delegations, retries, and resumes. A child can raise a floor; it cannot lower one.
- Research-shaped work with no authoritative purpose metadata fails closed with
purpose_unboundinstead of being treated as general work. - Security review pins the exact model id rather than an alias, because alias drift is real — the
opusalias has already moved to a newer model, so a security route that resolved through an alias could quietly change model.
Strong versus weak review
Cross-family adversarial review claims the reviewer is independent of the producer. That claim is checked on two axes, stored separately and never compared across each other: host_family (the verified dispatch substrate) and model_family (the family of the model actually served).
A strong verdict needs verified identity on both sides, known and different values on both axes, and a bound actual served model on both finalized receipts. Everything else is weak — including a different host family with asserted identity, and including a provider that will not report which model answered. A weak review still runs and is still useful; it just cannot sign off as strong.
Today: the check is shipped and the degradation is correct, but no production path yet writes the finalized served-model evidence a strong verdict requires, so real runs report weak or unknown. That is the safe direction, and /guild:models inspect says so rather than implying a sign-off you do not have.
Inspect a run
/guild:models inspect # the candidate run, labelled as such
/guild:models inspect --run-id <id> # pin a run
/guild:models inspect --json # same view model, machine readable
It writes no file, starts no run, discovers nothing, and resolves no policy. It reports host and identity trust, the target, catalog age and per-model evidence state, the policy rule path, the selection, the frozen fallback chain, the outcome with the actual model, the review independence verdict, any degradation, and a list of every field it could not bind.
Three reading rules:
unknownis a finding, not a formatting artifact. Do not fill one in from context or from the requested model.assertedis notverified. An asserted identity can never back a strong-independence claim.- Exit code 3 means it refused. A field or the whole report tripped the redaction check. The refusal is the output; the value is not reconstructed.
Every row is rebuilt from artifacts that verify themselves. A persisted inspection file is treated as an untrusted pointer, and a claim it makes that the evidence does not support is printed as an explicit untrusted-claim warning naming the fields.
Teams are no longer capped
The same rollout retired the old six-specialist cap and the three-to-four default target. There is no size cap, no default target size, and no path that drops a role to make a team fit.
- The composer proposes every specialist it can justify, each with a stated reason and the obligations it owns, plus the roles it considered and rejected.
- The proposal is an approval artifact. Nothing dispatches until you approve it, and any edit produces a new proposal needing fresh approval. Omitting a participant is never the same as removing one.
- When the backend cannot run everyone at once, the work is split into dependency-ordered waves. Capacity changes the schedule, never the roster.
--team-size=Nanddefaults.team.sizeare migration-window concurrency hints clamped by real backend capacity.allow_largerdoes nothing and warns.
Rolling back
Seven closed keys in .guild/settings.json. No flag rewrites your existing settings bytes. An explicit off only wins on the four keys that are actually wired — the other three are declared but not implemented, so an off on them wins nothing.
Four of the seven keys actually do something; three do not yet. discovery, inspect, shadow, and enabled each have code that reads them, so turning one off really changes behaviour. identity_v2, binding_enforce, and teams.proposal_v2 are validated and printed by /guild:models inspect, but nothing in Guild branches on their value — turning them off today does nothing. Those rollback paths are not implemented yet; we would rather say so than let you plan a rollback around a switch that is not connected.
| Key | Default | Wired? | Turning it off, today |
|---|---|---|---|
model_routing.discovery | on | Yes | Adapters and cache writes stop; inspection reports “discovery disabled” rather than a stale claim. |
model_routing.inspect | on | Yes | The inspection surface disappears and no report is produced; nothing else changes. |
model_routing.shadow | off | Yes | Shadow comparison does not run. On, it writes run-local records only. |
model_routing.enabled | off | Yes | v2 selection never engages; your settings bytes are untouched and frozen run snapshots stay valid. |
model_routing.identity_v2 | on | No | Nothing — not implemented. Unknown-safe identity is unconditional; the old Claude-by-default behaviour was removed, not put behind this switch. |
model_routing.binding_enforce | on | No | Nothing — not implemented. Fail-closed run binding is unconditional; there is no observe-only mode to fall back to. |
teams.proposal_v2 | on | No | Nothing — not implemented. The v2 composer is the only composer, and the old cap no longer exists in the code, so there is nothing for an off to restore. |
These keys are read directly from .guild/settings.json and are not scaffolded by /guild:config init yet — set them by hand. A value other than "on" or "off" is rejected and the flag keeps its default.
Legacy tier maps keep working through a two-release compatibility window: they are read as lower-precedence preferences, never as availability truth, they never fill a research or review purpose, and nothing is written back to your settings file.
Known limits
- No production path publishes a catalog snapshot yet, so a run today reports an absent catalog and an opt-in resolve fails closed rather than choosing on no evidence.
- Strong review independence cannot yet be produced end to end, for the same reason.
- The published performance figures (cached inspection under 500 ms at p95, a three-second discovery budget) are measured on local fixtures with no network. Treat them as budgets, not as a network guarantee.
- Rejecting the legacy sizing keys after the compatibility window is designed but not yet implemented. The window has not started, so the gap is not reachable in a released build.