Skip to content
Like what we’re building? Star on GitHub

ADR-161 — One resolver decides which credential a call spends; dispatch, admission, meter and plan read its answer verbatim ​

  • Status: Accepted — the operator decided it on 2026-11-04: the structure (debt-leap #107, option A), then #108 (decision 5), #109 (6), #110 (8), #111 (the ordering in 9), #112 (ADR-155's amendment). Design only: nothing below is built.
  • Date: 2026-11-04
  • Deciders: jo (#107–#112), Claude (analysis, design)
  • Extends: ADR-131 §7 (the stamp's typed refusal is decided once, by the resolver)
  • Relates to: ADR-085 · ADR-129 §5 (the allowlist is an input) · ADR-132 · ADR-155 (units 4 and 6 wait on U2, amended) · #2288 (CredentialPlan, amended by 8)
  • Serves: #2022; witness bench leap/usagecap-ambient-key at 6e2389644, based on v5.3.1, 337 commits behind main at e3b7a7e3c

Context ​

Four readers answer "which credential does this call spend?", each its own way (sites on main; † only on the bench):

  • Dispatch — claw Registry.ResolveWithContext and the crossing (forwardableProviderEnv), re-walked in the container; claude_code selectedAnthropicCredEnvForCLI; pi piResolveEnv, piCodexSeed; codex codexCredEnvForCLI — over resolveProviderChain's chain (overrides, vars, the allowlist's dropForbidden).
  • Admission — admitSandboxCredentials (#1768: fork lane, sandboxed starts only), three probes recomposing the dispatch's env from the raw fields.
  • Meter — the pre-flight's AnthropicWireRoutes (a third chain parser, first leg only), routeKey, supervisorKey, clawUnslottedKey†, AmbientEnvMeter†, the supervisor filters PoolSurvivingSpecs and AmbientSurvivingSpecs (shared with the spawn); the ledger's routeSlot, credentialSlotForRoute, planProviderFor.
  • Plan — model.CredentialPlan (#2288): the publisher's frozen (node, provider) → fingerprint map, joined on the executor's provider word; the pool's fine table books donors from it, blind to the pod.

Each #2022 review round (fleet t49, t50) found a new disagreement: meter ≠ dispatch (t49 r1; t50 r2, open: under ITERION_FORBID_SUBSCRIPTION_OAUTH the pre-flight parks on the pod's account a node GuardSubscriptionOAuth refuses), crossing ≠ in-process and meter ≠ meter (a z.ai key beside a forfait refused a node the forfait funds, t49 r2, t50 r1), admission ≠ dispatch (t50 r2, open: the probe splits the hint on its first comma), backend ≠ backend (pi and codex read no stamp, t49 r2), plan ≠ dispatch (on main: a pool-served claw anthropic/claude-* node beside a gateway ANTHROPIC_BASE_URL spends the pod's ANTHROPIC_API_KEY, the plan books the donor; #2288's fix inside its own derivations, 258bc2fbf, is a fifth answer).

Each fix missed a reader the next round found: the Nth-variant test failing (philosophy 2). Hint-blind auxiliaries ask too (anthropicFunding, ctxFundsProvider, forfait.Check, ADR-131 §7's list). The stamp (RunBundle.AmbientLLMRefused) has no producer before #2005 PR-C'; on main only claw and claude_code read it.

Decision ​

  1. One resolver. pkg/backend/credroute, a leaf: Resolve(ctx, Call) (Spend, error), the arm registry, Call, Leg, Spend, Refusal, the posture and SandboxHomeState (moved from delegate, which aliases it). It imports pkg/secrets and envtrust, nothing above; model and delegate import it to register their arms, runview, runner and runtime to call it. An arm is its backend's precedence moved out of the dispatch verbatim. From U4 every backendcaps.Names() entry has an arm (a witness enumerates them); a backend without one, a custom registration, is refused no-arm under the stamp and keeps its own path otherwise — never an engine if.
  2. Inputs. Call: backend, spec, ONE leg's hint, role (node, supervisor, auxiliary), sandbox (none — the host, a degraded auto included — or the driver's host-state mount). Legs: model.Legs(RouteView) []credroute.Leg, resolveProviderChain exported beside the view — override collapse, expansion, dropForbidden; dispatch, the pre-flight (RouteViewForDispatch) and admission read it, and the other chain parsers go. Run: secrets.Credentials and the frozen stamps — the allowlist (a pod-detected pick included, as allowlistedDetectorPick), the pool stamp, the content boundary. Posture: the whole pod environment, no name list to drift, each name with its envtrust provenance, captured once per attempt by the leaf's Capture(ctx) (the #2808 seam's carried environment, moved there). A planted value funds in process (the local lane's .env), never across the sandbox (D20, ADR-132).
  3. Output. Spend{Channel, Slot, Fingerprint, BaseURL, Source} — Channel: run key, pinned key, run forfait, gateway pair, pod env, host login, own config; Fingerprint: the slot's audit fingerprint (Credentials.Fingerprint, what CredentialPlan predicts); Source: claude_code's session label — or a refusal (7). The crossing hands the container the Spend and today's per-name classes (planted and stamp-withheld names as explicit empties); inside, the node's call lands on the handed Spend or refuses, and a subagent's spec (agent) resolves by the same arm. The meter key is the meter's scope (tenant, org, pinned) × the Spend's slot and fingerprint.
  4. Readers. Dispatch builds the client and spawn env from the Spend, never walking again, and stamps it on its usage events (8). Admission runs at ONE site every start passes, once its sandbox is known: resolved — after mode, driver and host-state mount, before the host exits (none, a degraded auto) and any pull, mount or start — or adopted: a subbot child under a sandboxed parent is judged in startSandbox's adopt branch, beside refuseUnservableSecretGrants, on its own nodes. Not the parent's walk: the child compiles at its dispatch, its legs reading vars its with: binds then; it is judged before any of its nodes spends. It judges fork-lane runs and every stamped run, every leg of every node: a node with none resolving is refused, all such nodes named, sorted; a refused fallbacks: entry, a supervisor or an undecided backend is warned. Meter: the pre-flight keys each node's first resolving leg, the one dispatch spends first (none: not metered; a capped leg's fall-through stays ladderEscapes' question; forSource("") keeps an unreadable route). Supervisors: the spawn and the pre-flight keep the specs Resolve funds, warning on the rest; PoolSurvivingSpecs becomes the content-boundary refusal; AmbientSurvivingSpecs' prefix rule goes (a stamped run's sealed vendor key serves its supervisor: ADR-131 §7 refuses the environment only).
  5. (a) The stamp binds every backend (#108, (i)). pi and codex refuse the pod's money (6). kimi, grok and opencode read their own config, never a run credential: no funding arm by design. Their arm, registered in U2, only refuses — ambient-stamp under the stamp — and otherwise answers own config, the pod's money, admitted with today's undecidable warning and keyed as today (kimi and grok unmetered, opencode on the pod's unslotted key). A stamped node made only of them refuses the launch; a fallbacks: rung is warned and never serves (feature-dev's kimi_quota, copilot's kimi and grok). Rejected: (ii) claw and claude_code only (ADR-131 §7's letter, the bench) — the rest spend the pod under a stamp whose field says "this bundle ONLY"; (iii) own-config legs admitted with a warning — the same leak, named.
  6. (b) pi's pod money is all the run did not seal (#109, (i)): piAnthropicEnvChannels†, the names piCredentialEnvNames forwards, pi's store (~/.pi/agent/auth.json, ITERION_PI_AGENT_DIR, PI_CODING_AGENT_DIR), the openai-codex bridge's fallback (piLoadCodexView → CODEX_HOME, ~/.codex/auth.json). The run's sealed money serves: its keys, its ChatGPT forfait (piCodexSeed seeds OAuthDir("codex") into a per-attempt agent dir). Other spawns get explicit empties and an empty agent dir; the bridge's own-login exits refuse. Rejected: (ii) an operator-pinned agent dir may serve — a pod login the run did not seal; (iii) the Anthropic channels only — thirty-odd provider keys stay spendable.
  7. (c) The refusal. Scope: one leg; chain semantics unchanged. One *credroute.Refusal{Backend, Provider, Spec, Leg, Reason} unwrapping to the cause its arm sets — each site's error today (claude_code's *ErrNoFacadeCredential, the allowlist's ErrProviderOutsideAllowlist / ErrRouteUnreadableUnderAllowlist), so every errors.As keeps matching and the leaf names none of them. A reason with no error today unwraps to a leaf sentinel (the stamp's: ErrAmbientRefused); ErrPoolContentRefused moves to the leaf (runview re-exports it), since the claw arm cannot import runview. Reasons: ambient-stamp, subscription-switch, hint-unfunded, no-credential, outside-allowlist, content-boundary, no-arm, and the guard's arm-pending (9). Admission codes: SANDBOX_CREDENTIAL_UNREACHABLE (no-credential), CREDENTIAL_AMBIENT_REFUSED, CREDENTIAL_SUBSCRIPTION_FORBIDDEN, each with its remedy. The meter reads a refusal as not metered. GuardSubscriptionOAuth becomes an arm refusal, closing t50 r2's open MEDIUM. Rejected: per-backend types — every reader learns all; no unwrap — every errors.As site edited at once.
  8. (d) The ledger and the fine table learn the credential keys (#110, (iii); amends #2288). Every usage event (delegate events, their folded_spend slices, llm_step_finished) gains the Spend's credential_channel, credential_slot and credential_fingerprint, written by the process that built the call; the ledger books on the slot, the fine table on the fingerprint. The fingerprint key keeps its meaning — the session LABEL (claude_code's source, claw's claw:<provider>) resume and NodesServed key on — and leaves the ledger. The plan stays a prediction: a disagreeing observation books on its own fingerprint and warns. From U4, an event without the keys is a miss: booked on nobody, the fine table voided to the coarse booking (#2288's rule). Rejected: (i) claw's events carry the hint, the ledger re-derives — one more derivation; (ii) main's join, provider word to plan row — plan ≠ dispatch stays.
  9. Migration — from main, dispatch first, claw first.
    • Interim: a reader asks Resolve first from its unit on (admission U1, meter U2, every backend's dispatch by U2). A backend whose unit has not landed answers ErrNoArm, not a refusal: unstamped, the reader keeps today's path for that leg (dispatch's walk, admission's probe or undecidable warning, routeKey's arm, the ledger's derivation), so nothing changes before its unit. Stamped, no leg takes a path blind to the stamp: claude_code's already refuses the environment; kimi, grok and opencode refuse by their arm (5); pi and codex meet the guard.
    • U0: rebase the bench onto main (each conflict with #2808's and #2288's rewrites becomes a parity row). Each row (posture × run credentials and stamps × backend × spec × leg × role × sandbox × plan) asserts that the built client or crossed env, the admission verdict, the meter key and the booked fingerprint agree. Seen red: the two open t50 r2 rows, the plan row, and this ADR's review rows (a fall-through leg unmetered, an allowlist-dropped leg admitted, an unsandboxed stamped start and an adopted child unjudged).
    • U1, claw: the registry walk, the crossing and the switch guard move into the arm; model.Legs replaces the chain parse; ClawSandboxCredential becomes Resolve; admission moves to its one site, adopt branch included; the bench's witnesses keep their assertions.
    • U2, the meter: the pre-flight on model.Legs and the Spend (AnthropicWireRoutes' chain read goes), the supervisor filters on Resolve, the credential keys on claw's events, the fine table on the fingerprint; the own-config arms; the guard. U0–U2 land as one PR (no red row lands); #2022 closes; ADR-155's units 4 and 6 wait on it.
    • U3 claude_code; U4 pi and codex under 5 and 6, whose rows alone change expected values; the guard, credentialSlotForRoute, planProviderFor and routeSlot's label read go. U5 the auxiliaries.
    • U6: no env read on the LLM path bypasses the posture: the #2808 scan widens to delegate, pkg/supervise, the meter, pkg/backend/detect's pod reads and the helpers they call — secrets.ForbidSubscriptionOAuth (its posture form reaches the claw arm in U1), LLMEndpointAllowPrivate (envtrust.Inherited), LoadCodexCredentialsFromDisk (CODEX_HOME, HOME) — each gaining a form over a lookup the posture supplies (its inherited value for an operator-only switch), the process form flagged there; the process-scoped sites (the registry factories, OpenAIForfaitServes) move behind the arms.
    • Ordering with #2005 (#111): PR-C', the stamp's producer (SchemaVersion 27), lands after U2. Until U4 the guard — pi and codex entries U2 registers from pkg/backend/delegate — refuses their legs on a stamped run: reason arm-pending, code CREDENTIAL_AMBIENT_REFUSED with its own remedy, unwrapping to ErrAmbientRefused like the own-config refusals. Stricter than the end state, never a leak. Witness: a stamped pi row and a stamped codex row refused at admission and dispatch, seen red by unregistering the guard (the leg spends the pod's env). U4 replaces it with the arms and drops the reason. Rejected: after U4 — one answer on every backend from the first stamped run, but #2005 waits on U3 and U4; before U2 — no wait, but stamped runs meet t50 r2's open divergences and pi and codex spend the pod.

Consequences ​

  • A new posture, stamp, credential kind or backend is one arm plus its parity rows; a new chain form, one model.Legs edit.
  • The meter and the fine table book what the call spent, on the leg dispatch spends; admission refuses what dispatch would on every run it judges, adopted children included; a trusted unstamped run's refusals still arrive at dispatch.
  • Behaviour changes only where a parity row says so: a stamped run's supervisor on its own sealed key; stamped pi and codex legs refused from PR-C' to U4.
  • Usage events gain three keys, additively; the re-derivations are deleted, not kept as a fallback; the bench lands only through U0, its witnesses the parity spec.
  • Out of scope: the publisher's walk and the plan's derivation (they share only the leg grammar, ir.SplitProviderStep), the pool's lending.