Files
hq/02-DECISIONS/0050-model-access-is-vendor-agnostic.md
jschoubben 860d512e91 Accept ADR 0050 — model access is vendor-agnostic
Verified and ratified: model-access stays one vendor-blind provision; per-vendor
adapter keyed by licence.vendor (mirrors public-dns registrar providers); the
sealing-vs-central-rotation carve-out bounded to refreshable-grant vendors /
refresh token / manager node only. Status proposed -> accepted; index regenerated
(records + index checks pass); design doc note updated.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-05 21:42:20 +02:00

14 KiB

topic, status, date, deciders, reconstructed, extends
topic status date deciders reconstructed extends
what runs on it accepted 2026-09-05 jochen false 02-DECISIONS/0024-model-access-is-a-provision.md

50. Model access is vendor-agnostic, and a vendor is an adapter

Context

ADR 0024 settled that model access is a provision and that a licence is a named thing an operator uses. What shipped, and runs, is a single vendor: the mesh's "claude" feature. A read-only trace of that feature (2026-09-05, in the code workspace) was made to answer whether the model-access provision is Anthropic-shaped or genuinely general. The finding is that the vendor-agnostic layer already largely exists, and the Anthropic specifics are a thin band around it that a per-vendor adapter can hold.

What is already general, with evidence. mesh-control internal/licences models licence(name, vendor, serves) and licence_holder(licence, node, module, sealed), and each holder's credential is sealed per-holder through internal/secrets. serves carries the non-secret facts (a base URL, a model) and is not vendor-specific. The accept verb (ADR 0024, and 14-model-access) already takes an operator-supplied value, seals it to each holder and discards the plaintext. A model the mesh runs itself answers model-access at node scope with no licence at all. None of that mentions Anthropic.

What is Anthropic-specific. The credential is not a static key: it is a subscription OAuth grant — an hourly access token plus a refresh token. That shape drags four things behind it that a static key does not need: central rotation (one manager node refreshes under a lease and publishes the new token), delivery that strips the refresh token so a consuming node holds only an access token, an identity guard that reads the credential to catch a mis-binding, and a usage reading with Anthropic's own utilization% semantics. Most vendors are a single static key, which the sealed-key model already handles and which needs none of these four.

The tension at the centre of this. A refreshable credential cannot be both sealed so the mesh cannot read it and rotated centrally. Central rotation means some node in the mesh holds the refresh token in readable form, because that is what refreshing requires. Per-holder sealing means no node but the holder can read the credential. For a static key the two never meet — there is nothing to rotate. For a refreshable grant they collide directly, and this record exists to say which gives way, and by how much.

Considered Options

  1. Keep Anthropic special-cased in the core. Leave the three binding columns and the claude_* schema, and add other vendors beside them the same way. Rejected. It is exactly what ADR 0024 ruled against: a module that names a vendor cannot be moved onto another model without editing it, and moving it is the point. It also grows the core by one band per vendor, when the bands are the same shape.

  2. One provision, and refuse to hold any refresh token — re-seal only. Make every credential purely sealed per-holder, including refreshable ones; let each holder refresh its own grant. Rejected. It throws away the hard half ADR 0024 says already works — the lease, the single-refresher, the switch-on-exhaustion — and replaces it with N nodes each holding a refresh token, which is the very thing today's delivery strips on the stated ground that a node never holds a refresh token. A refresh token is the long-lived secret; spraying it across every holder is strictly worse than keeping one copy on one node.

  3. One provision, and abandon central rotation entirely for refreshable vendors — treat the grant as opaque and let it expire. Rejected. For a subscription-seat vendor an expired access token is a dead licence; without rotation the feature that works today stops working. This is option 2's cost without option 2's autonomy.

  4. One vendor-blind provision, plus a per-vendor adapter, with a bounded carve-out for the refreshable case. Adopted, below.

Decision

model-access is one consumer-facing, vendor-blind provision. A consumer declares requires: model-access, and is coupled to reaching a model — a base URL, a model name, a key — and not to which vendor answers. That is the coupling the name is drawn at (ADR 0040's rule: name the interface at the widest boundary across which the consumer does not care which implementation serves it). Where a consumer were genuinely coupled to a specific wire API it could not swap across, the same rule would split the name — but the consumers that exist reach their model through a CLI or SDK that hides the vendor, so model-access is the true coupling and stays one name. This extends ADR 0024 and ADR 0027 without changing them.

A vendor is an adapter, keyed by the licence's vendor field. The lifecycle a licence needs is vendor-specific and lives in a per-vendor adapter selected by licence.vendor, exactly as public-dns is one neutral interface answered by registrar-scoped providers — cloudflare-dns, route53-dns (ADR 0044). A consumer names model-access and never a vendor, the same way a module names public-dns and never a registrar.

The field is named vendor, not provider. The inventory already uses "provider" for the provider-pin — which node answers a brokered provision. Reusing it for which company sells this licence would collide two unrelated facts on one word. vendor is the licence's, and is separate.

The adapter's capabilities, all but one optional

An adapter declares:

  • shape — static-key or refreshable-grant. This is the switch the carve-out below turns on.
  • accept(value) → sealed — take an operator-supplied credential and seal it to the holders, the accept verb ADR 0024 already defines.
  • refresh(licence) — refreshable-grant only: the lease / rotate / publish machinery.
  • identity(credential) → account-id — the mis-binding guard, for a vendor whose credential carries an identity worth checking.
  • usage(licence) → normalised rows — the vendor's usage reading, mapped to the common shape below.
  • deliver — the credential value only; the destination path is the consumer's, not the adapter's.

A static-key vendor implements almost nothing — shape: static-key, accept is the generic seal, deliver is the value, and refresh, identity and usage are absent or trivial. The abstraction earns its keep by making the common vendor small, not the rare one clever.

The carve-out — the one place the guarantee is relaxed, said plainly

The mesh's standing principle is that it cannot read what it stores: accept seals to the holders and discards the plaintext (ADR 0024), and a provider seals nothing because the credential travels the mesh's own asymmetric channel (ADR 0048). A refreshable-grant credential cannot honour that principle and be centrally rotated at the same time, and central rotation is the working half ADR 0024 is explicit about keeping.

So, for refreshable-grant vendors only:

  • the manager node holds the refresh token encrypted at rest — readable by that node, because rotation requires it. This is the bounded exception.
  • access tokens are still sealed per-holder, as every credential is; a holder reads its own and no other node reads it.
  • the refresh token is stripped on delivery — it never reaches a consuming node. A node never holds a refresh token stays true for every node but the one manager.

Static-key vendors keep the full guarantee. There is no token to rotate, so there is nothing to hold readably, so accept discards the plaintext and the carve-out never fires. The majority of vendors are static-key, and the majority therefore lose nothing.

The exception is stated rather than hidden because a relaxed guarantee that is not written down is indistinguishable from a broken one. It is bounded on three axes at once: refreshable-grant vendors only, the refresh token only, the manager node only.

The settled details this record also fixes

  • Usage is normalised to (licence, consumer, period, metric, value) plus the raw response as jsonb. The metric is vendor-defined — Anthropic's utilization% is one metric, a token count is another — and no common unit is forced across vendors. The raw response is kept so a reading can be re-derived if the normalisation is later found wrong.
  • Binding is explicit per consumer, and an unchosen consumer is refused — no implicit fallback. This is the direction the resolver already takes, and it is the safe one: a mesh with several ways to reach a model refuses a consumer that has not said which, naming the candidates and the command, rather than silently choosing one (ADR 0024, and 14-model-access).
  • Subscription-seat authentication lives entirely inside the adapter, never in the generic core. So does an interactive /login — an adapter-specific "adopt" origin for a credential a person must produce in a browser; the generic licence key <name> covers the static-key case.
  • Anthropic is the first refreshable-grant adapter, carrying the OAuth refresh, the usage reading, the identity guard and access-token-only delivery. anthropic-api-key is a static-key adapter for the same vendor's plain API keys, and is the early second case that proves the abstraction is not a single vendor wearing a coat: it exercises the whole path with the carve-out switched off.

Consequences

  • The carve-out is the mesh's one deliberate relaxation of "it cannot read what it stores." It is bounded to refreshable-grant vendors, to the refresh token, and to the manager node; the static-key majority keep the full guarantee unchanged. This is the open risk the analysis carried here, and it is recorded as an exception rather than pretended away.
  • Anthropic collapses from special case to adapter. The three binding columns (nodes.node_license, nodes.hal_claude_account, agents.claude_account) become three ordinary consumers of model-access; the claude_* schema becomes the generic licence tables plus one adapter. What was hardcoded becomes data keyed by vendor.
  • Adding a vendor is adding an adapter, and a static-key vendor is nearly free. The modules that want a model do not change when a vendor is added — they named model-access, not a vendor.
  • The refresh-token concentration is now a stated property to defend, not an accident. The manager node is a place a long-lived secret lives readably, and losing it or compromising it is a bounded, named blast radius rather than a surprise.

How each claim here is checked

  • Vendor-blind provision, static-key path. A lab scenario binds an anthropic-api-key licence to a consumer; the consumer resolves, receives a key sealed to its node, and reaches a model — and the key is nowhere in the control plane's database nor in anything that crossed the broker. This is the licence_holder sealed-per-node check that 14-model-access already runs, now asserted for a second vendor.
  • The carve-out is exactly as narrow as stated. For a refreshable-grant licence, a test asserts the refresh token exists (encrypted) only on the manager node, is absent from every holder's delivery, and that the delivered credential is access-token-only — and that for a static-key licence no refresh token is stored anywhere.
  • Adapter selection is keyed by vendor. A scenario with two vendors on two licences verifies each licence's lifecycle runs its own adapter, and that a consumer naming model-access never names a vendor to get one.
  • Refuse-if-unchosen. Already checked in 14-model-access: a consumer with more than one candidate is refused with the candidates and the command named.

References

  • ADR 0024 — model access is a provision, a licence is a named thing, and accept; this record generalises its single vendor and keeps its working central rotation.
  • ADR 0027 — a provision names the coupling; model-access is drawn at the consumer's.
  • ADR 0040 — the naming rule and the neutral-interface / scoped-provider shape a vendor adapter follows.
  • ADR 0044 — registrar-scoped public-dns providers, the precedent a vendor-scoped adapter mirrors.
  • ADR 0048 — the mesh seals credentials and holds no readable copy; the carve-out here is the bounded, named exception to that for a refreshable grant.
  • 03-DESIGN/01-to-be/14-model-access.md — the design this record extends, amended to describe the adapter generalisation.
  • The read-only vendor-agnostic analysis, 2026-09-05 (code workspace) — the inventory and the decisions taken on the open questions this record encodes.