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-keyat6e2389644, based on v5.3.1, 337 commits behind main ate3b7a7e3c
Context
Four readers answer "which credential does this call spend?", each its own way (sites on main; † only on the bench):
- Dispatch — claw
Registry.ResolveWithContextand the crossing (forwardableProviderEnv), re-walked in the container; claude_codeselectedAnthropicCredEnvForCLI; pipiResolveEnv,piCodexSeed; codexcodexCredEnvForCLI— overresolveProviderChain's chain (overrides, vars, the allowlist'sdropForbidden). - 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 filtersPoolSurvivingSpecsandAmbientSurvivingSpecs(shared with the spawn); the ledger'srouteSlot,credentialSlotForRoute,planProviderFor. - Plan —
model.CredentialPlan(#2288): the publisher's frozen(node, provider) → fingerprintmap, joined on the executor'sproviderword; 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
- One resolver.
pkg/backend/credroute, a leaf:Resolve(ctx, Call) (Spend, error), the arm registry,Call,Leg,Spend,Refusal, the posture andSandboxHomeState(moved from delegate, which aliases it). It importspkg/secretsandenvtrust, 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 everybackendcaps.Names()entry has an arm (a witness enumerates them); a backend without one, a custom registration, is refusedno-armunder the stamp and keeps its own path otherwise — never an engineif. - Inputs.
Call: backend, spec, ONE leg's hint, role (node, supervisor, auxiliary), sandbox (none— the host, a degradedautoincluded — or the driver's host-state mount). Legs:model.Legs(RouteView) []credroute.Leg,resolveProviderChainexported beside the view — override collapse, expansion,dropForbidden; dispatch, the pre-flight (RouteViewForDispatch) and admission read it, and the other chain parsers go. Run:secrets.Credentialsand the frozen stamps — the allowlist (a pod-detected pick included, asallowlistedDetectorPick), the pool stamp, the content boundary. Posture: the whole pod environment, no name list to drift, each name with itsenvtrustprovenance, captured once per attempt by the leaf'sCapture(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). - 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, whatCredentialPlanpredicts); 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. - 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 degradedauto) and any pull, mount or start — or adopted: a subbot child under a sandboxed parent is judged instartSandbox's adopt branch, besiderefuseUnservableSecretGrants, on its own nodes. Not the parent's walk: the child compiles at its dispatch, its legs reading vars itswith: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 refusedfallbacks: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 staysladderEscapes' question;forSource("")keeps an unreadable route). Supervisors: the spawn and the pre-flight keep the specsResolvefunds, warning on the rest;PoolSurvivingSpecsbecomes 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). - (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'sundecidablewarning 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; afallbacks:rung is warned and never serves (feature-dev'skimi_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. - (b) pi's pod money is all the run did not seal (#109, (i)):
piAnthropicEnvChannels†, the namespiCredentialEnvNamesforwards, 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 (piCodexSeedseedsOAuthDir("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. - (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'sErrProviderOutsideAllowlist/ErrRouteUnreadableUnderAllowlist), so everyerrors.Askeeps matching and the leaf names none of them. A reason with no error today unwraps to a leaf sentinel (the stamp's:ErrAmbientRefused);ErrPoolContentRefusedmoves 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.GuardSubscriptionOAuthbecomes an arm refusal, closing t50 r2's open MEDIUM. Rejected: per-backend types — every reader learns all; no unwrap — everyerrors.Assite edited at once. - (d) The ledger and the fine table learn the credential keys (#110, (iii); amends #2288). Every usage event (delegate events, their
folded_spendslices,llm_step_finished) gains the Spend'scredential_channel,credential_slotandcredential_fingerprint, written by the process that built the call; the ledger books on the slot, the fine table on the fingerprint. Thefingerprintkey keeps its meaning — the session LABEL (claude_code's source, claw'sclaw:<provider>) resume andNodesServedkey 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,providerword to plan row — plan ≠ dispatch stays. - Migration — from main, dispatch first, claw first.
- Interim: a reader asks
Resolvefirst from its unit on (admission U1, meter U2, every backend's dispatch by U2). A backend whose unit has not landed answersErrNoArm, not a refusal: unstamped, the reader keeps today's path for that leg (dispatch's walk, admission's probe orundecidablewarning,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.Legsreplaces the chain parse;ClawSandboxCredentialbecomesResolve; admission moves to its one site, adopt branch included; the bench's witnesses keep their assertions. - U2, the meter: the pre-flight on
model.Legsand the Spend (AnthropicWireRoutes' chain read goes), the supervisor filters onResolve, 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,planProviderForandrouteSlot'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, codeCREDENTIAL_AMBIENT_REFUSEDwith its own remedy, unwrapping toErrAmbientRefusedlike 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.
- Interim: a reader asks
Consequences
- A new posture, stamp, credential kind or backend is one arm plus its parity rows; a new chain form, one
model.Legsedit. - 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.
