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

ADR-131 — A tenant's OpenAI-compatible gateway is a credential: base URL + key, sealed per run, spent in and out of the sandbox, never the pod environment ​

  • Status: Proposed (2026-10-21 — the plan it records was arbitrated by the operator on six points; it lands with PR-A of #2005 and is reviewed there; Delivery below says which decisions PR-A makes true and which wait for PR-B'/PR-C'/PR-R)
  • Date: 2026-10-21
  • Deciders: jo (direction, six arbitrations of 2026-10-21), Claude (analysis, design); plan reviewed cross-family by codex gpt-6-astra (16 findings, all confirmed and folded in)
  • Extends: ADR-121 (the pair vocabulary gains claw+openai_compatible_key; §1 "env-funded selects nothing" narrowed to keyless runs), ADR-122 (§6 "the gateway is env-funded … never to a bundle credential" superseded for a run that holds a gateway key), ADR-129 (§4/§7: the acceptance run is team-funded and seals exactly its key; override cells reach supervisors)
  • Serves: #2005, epic #2000; the operator's decision of 2026-10-21 that the Revi bots move to the PLIAGE LiteLLM gateway (Scaleway models) one key per team, a key per repository riding the webhook pin

Context ​

A deployment serves openai_compatible/<gateway model id> from its own environment only: OPENAI_COMPATIBLE_BASE_URL / _API_KEY are read by the registry factory, the dispatch check, the sandbox forward and the netproxy pin, and ParseProvider refuses the provider as a BYOK kind. A team that must spend its own gateway therefore needs a dedicated runner pool carrying the key in its pod environment (#2029, honorabilite), one pool per team, a forked bot per team. The walk classifies every gateway route as "env-funded, books on nobody": no fingerprint, no tier, no spend per credential, and llm_fallback=none refuses nothing on such a route. The operator wants the opposite shape: one gateway key per team, registered like any key, metered like any key, one key per repository where PLIAGE meters per virtual key, and the sovereignty posture kept by llm_fallback=none.

Decision ​

  1. The record. secrets.ProviderOpenAICompatible; ApiKey.BaseURL required for the provider and refused elsewhere (blank included, on update); https only, no userinfo/query/fragment, canonical (host lower-cased and in punycode, literal addresses as net.IP prints them, trailing dot and default port dropped, trailing / and every trailing /v1 stripped — idempotent on its own output, a property a seeded fuzz asserts, so re-sending a stored URL is a no-op); the input and the STORED value each at most 2048 bytes (a name grows in punycode; the second cap reads the result); literal non-public addresses, single-label hosts (every /etc/hosts alias, _gateway, instance-data, a Service name — a public gateway has a dot) and the names a cluster, a cloud or a container reserves (localhost.localdomain, kubernetes.default, *.localhost, *.localdomain, *.localdomain4, *.localdomain6, *.local, *.internal, *.svc, *.cluster.local) refused at write — judged on the CONVERTED host (punycode first, so a full-width or invisible spelling of a refused host is the host it spells), a name that resolves to such an address refused at dial time; whitespace, non-printable characters and percent-escapes refused anywhere in the value (a pasted secret glued to a URL, an IPv6 zone), the path restricted to ASCII letters, digits and -._~:@/ (a blank-looking letter must not glue one either; a backslash or a sub-delimiter reads as something else to a browser's parser); a host written as a number in any base (0x7f.0.0.1, 2130706433, 127.1) and the names a container resolves to itself refused with the reserved names; labels and names bounded at the DNS wire limits; a refusal names a rule and never echoes the value; every tier may hold one (team, personal, org through the org audience, platform through the allow-list) — sovereignty stays the team's llm_fallback=none, which skips the shared tiers; key routes decode strictly (an unknown field or a second JSON value is a 400); a base_url change is a rotation: the secret is re-sent and sealed under the new URL in the same write, under a compare-and-swap on the sealed blob that every rotation of a gateway key carries — the stored secret is never opened to follow a URL, so a principal with mutate rights who never held the secret (an org admin of the team's org) cannot aim it at a host of theirs.
  2. Identity. The seal AAD of a gateway key binds its normalised base URL (a row repointed in the store fails closed; ids never carry the delimiter). Fingerprints are endpoint-aware for this provider only, through one helper secrets.APIKeyFingerprint(provider, secret, baseURL) used at create, rotate and the base_url change, and carried into the bundle beside a gateway key (RunBundle.APIKeyFingerprints) so the runner stamps what the server stamped; a vendor key's identity stays sha256(secret)[:8], which the runner recomputes as it always has, and the pool and the fallback door never lend a gateway key at all — a grant that names the provider is refused by name and its lease released (the walk's vocabulary is pinned by cloudpublisher's provider-coverage test, the pool's refusal by pkg/server's lendability test).
  3. Repository audience. ApiKey.Repos (any provider; an entry is the forge's full path of one repository, never a URL, a host or a glob) is checked in Resolve before the pin exemption, from the launch's repository identity stamped on the walk's context; a launch without a repository cannot spend a repo-restricted key; which audience refused travels with the withheld key (secrets.AudienceRefusal) into the log line and the pin diagnostic, and the preview recomputes the same predicates (repo_filtered, naming the launch's repository but never the key's own list — a shared-tier key's repositories are another tenant's); the facade policy's native probe reads the same audiences. The webhook lane, the HTTP repo-targeted launch, the trigger spine (plan.Repo, asserted by the emitter), resume and fork name a repository; the scheduled, board and pipeline lanes do not yet (PR-R stamps them from the integration) and so never spend a restricted key; a personal-source preview cannot name one. The audience is operator intent, not an authorisation boundary: the generic webhook's caller asserts project_path, the trigger emitter asserts repo, and a pledged key is served by the pledge's own rules, as bots is.
  4. Carriage. RunBundle.APIBaseURLs / PinnedAPIBaseURLs / APIKeyFingerprints, RunBundle.AmbientLLMRefused; every write into the key maps goes through one chokepoint (setAPIKey/clearAPIKey, pinned by a source-scanning test); a gateway key travels only with its canonical URL and the server's fingerprint, at the seal as at the run; the pool and the fallback door release a grant they do not seal; queue SchemaVersion 27 ships with the producer (PR-C'), the consumers having landed with PR-B'1 and PR-B'2 (26 was claimed by the debt-landing convention).
  5. The walk. Gateway routes record openai_compatible as a pinned provider; the walk vocabulary and the pool vocabulary split (the pool never lends a gateway key); "team holds a gateway key" is computed before the walk without the ceiling predicate; a run is env-funded only when every route is a gateway route AND the team holds no gateway key; under none a gateway route nothing of the team's own funds is refused (pool-stamped runs excepted), and the gateway slot never pays an unattributed route (#2485 closed for this shape); DefaultPairOrder gains claw+openai_compatible_key as a seal-whitelist entry, never a ladder rung; a frozen pre-upgrade list is replayed as stored with one named warning; a ContentBoundary stamp freezes the funding boundary on the run.
  6. Pins. A webhook pin on a gateway key is exclusive (withheld, deleted or undecryptable → named refusal); the scheduled and board lanes rebuild the pin from the integration's webhook; reserved __iterion_* card keys are server-owned.
  7. Runtime. The run's credential is served before the environment, in-process (compatgw.NewClient per registry instance) and in the sandbox (the pair crosses with no vendor key; the private-endpoint hatch crosses always-explicit, "" for a tenant URL; the tenant host is a strict per-host netproxy pin admitted by a per-run rule; a run holding a gateway key is always proxied). Under AmbientLLMRefused every env fallback of the registry and of the provider hint returns a typed refusal, auxiliaries included (classifier, human-node LLM, companion, recovery, verified-action rungs, supervisors); server-side helpers refuse a stamped run; supervisor spend is metered (PR-B'2).
  8. Parity. A non-claw route to the gateway is refused at launch with a typed error mirrored by the runview screen; override cells gain the supervisor:<name> selector; the launch screen warns when a cell or dial moves a tool-less node onto claw.
  9. Catalog and presence. The catalog stays operator-wide (OPENAI_COMPATIBLE_MODELS, _CATALOG_PROVIDER); the cloud presence row makes a gateway key unlock claw only.
  10. Rollout. PR-0 (patching writes, unknown-provider refusal) is the server rollback floor: a rollback to it keeps gateway rows closed (unknown provider, 409 on update) but no longer reads repos on vendor keys — such a key funds every repository on a PR-0 server, so the repository audience is relied upon only once PR-A is the floor; runners and pools roll first on schema 27, the server after; keys are created only after the server phase; resumable runs are drained before each data flip.

Delivery ​

  • PR-A (this ADR's landing): §1 except the dial-time check of resolved addresses (PR-C'); §2 at the key routes (create, rotate, the base_url change); §3 (the gate, the diagnostics, the preview, the native probe; the scheduled, board and pipeline stamps are PR-R's); the studio create form and list; the CLI flags. Two guards hold the inert state honest until the walk spends the key: a webhook pin on a gateway key is refused at write (PR-C' lifts it and makes the pin exclusive, §6) and the pool refuses to lend one (§5, permanent).
  • PR-B'1 (consumer first, inert at v25): §4's carriage on the consuming side — the bundle gains the URL maps paired with the key maps and the ambient-refusal flag, absent on a bundle sealed today (the fingerprint map is written only beside a gateway key, so a bundle of vendor keys serialises byte for byte as before); the seal refuses a gateway key travelling without its canonical URL or the server's fingerprint; the publisher writes its key maps through ONE chokepoint (setAPIKey/clearAPIKey, pinned by a source-scanning test) that carries the URL and the fingerprint with the key; the pool and the fallback door refuse a gateway grant by name and release a grant they do not seal (unreachable by construction today — the pool vocabulary excludes the provider — defence in depth); the runner reads the pair, the flag and the server's fingerprint. No queue schema change: the bump is the producer's (PR-C'), and pkg/queue is untouched.
  • PR-B'2 (still inert, lands BEFORE PR-C': the runner must consume what the producer will emit and fence what it must refuse): §7's consumer half — the registry's uncached gateway arm reading the run's pair first, the typed ambient refusal at every environment fallback and every auxiliary (classifier, interaction LLM, companion, recovery, verified-action rungs, supervisors with the stamp-aware filter), the sandbox crossing always-explicit with the netproxy pin and the always-proxied rule, supervisor spend metered — and §7's server-helper refusal with the ContentBoundary stamp's carriage. PR-C': §4's producer (the walk seals the pair, SchemaVersion 27), §5, §9, the pin exclusivity of §6. PR-R: §6's lane stamps and the §8 parity. Each amendment appended to ADR-121/122/129 names the PR that makes it true.

Alternatives considered ​

  • A sovereign runner pool per team (the honorabilite recipe): proven, but a Deployment, a Secret, a chart entry, a mapping and a forked bot per team — linear cost, rejected by the operator for the general case.
  • One shared gateway key on the generic runners plus per-integration routing: cheapest, but one LiteLLM identity for every team (no per-team metering or quota) and the key in every sandbox — rejected.
  • Catalog hints on the record: a per-run table and a run-aware pricer; deferred.
  • Secret-only fingerprints: a token reused across gateways or tenants would share a ceiling, alive_runs and the spend ledger; rejected after the astra review.

Consequences ​

  • A team's gateway key is a first-class credential: registered, listed, rotated, audited, metered, pinned per repository and restricted to repositories, with the ceiling as advisory as for every key.
  • The honorabilite pool, its forked bot and its chart entry become unnecessary; they are retired after the migration runbook.
  • Old runners never serve a team-funded route from their environment: schema 26 fences them; old servers cannot corrupt a gateway row: PR-0 patches and refuses.
  • Known limits, filed as tickets: hard per-key concurrency cap; trigger-spine pins; server-helper credential channel and the N-pools guard; local iterion models gateway row; gateway capabilities (#2103); studio editor for pins; review-pr claude-slot tools on claw beyond PR-R.