ADR 0050 (proposed) — model access is vendor-agnostic [awaiting ratification] #25

Merged
jschoubben merged 2 commits from feat/adr-0050-vendor-agnostic-model-access into main 2026-09-05 19:42:28 +00:00
3 changed files with 276 additions and 1 deletions
@@ -0,0 +1,206 @@
---
topic: what runs on it
status: accepted
date: 2026-09-05
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0024-model-access-is-a-provision.md
---
# 50. Model access is vendor-agnostic, and a vendor is an adapter
## Context
[ADR 0024](0024-model-access-is-a-provision.md) 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](0024-model-access-is-a-provision.md), and
[`14-model-access`](../03-DESIGN/01-to-be/14-model-access.md)) 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](0024-model-access-is-a-provision.md) 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](0024-model-access-is-a-provision.md) 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](0040-what-a-module-is.md)'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](0024-model-access-is-a-provision.md) and
[ADR 0027](0027-a-provision-names-what-the-consumer-is-coupled-to.md) 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](0044-a-public-name-is-provisioned-like-any-capability.md)).
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](0024-model-access-is-a-provision.md) 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](0024-model-access-is-a-provision.md)), and a provider seals nothing
because the credential travels the mesh's own asymmetric channel
([ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md)). 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](0024-model-access-is-a-provision.md) 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](0024-model-access-is-a-provision.md), and
[`14-model-access`](../03-DESIGN/01-to-be/14-model-access.md)).
- **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`](../03-DESIGN/01-to-be/14-model-access.md)
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`](../03-DESIGN/01-to-be/14-model-access.md):
a consumer with more than one candidate is refused with the candidates and the command named.
## References
- [ADR 0024](0024-model-access-is-a-provision.md) — 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](0027-a-provision-names-what-the-consumer-is-coupled-to.md) — a provision names the
coupling; `model-access` is drawn at the consumer's.
- [ADR 0040](0040-what-a-module-is.md) — the naming rule and the neutral-interface / scoped-provider
shape a vendor adapter follows.
- [ADR 0044](0044-a-public-name-is-provisioned-like-any-capability.md) — registrar-scoped `public-dns`
providers, the precedent a `vendor`-scoped adapter mirrors.
- [ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md) — 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`](../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.
+1
View File
@@ -118,6 +118,7 @@ python3 00-META/checks/index.py fail if stale
- **0047** — [A module runs its code as its own process, with its own account](0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md)
- **0048** — [A provider creates the credential the mesh minted, and seals nothing](0048-a-provider-creates-the-credential-the-mesh-minted.md)
- **0049** — [A consumer's identity is bounded by the tightest backend that must accept it](0049-a-consumers-identity-fits-the-tightest-backend.md)
- **0050** — [Model access is vendor-agnostic, and a vendor is an adapter](0050-model-access-is-vendor-agnostic.md)
### How it is built
+69 -1
View File
@@ -4,7 +4,7 @@ status: in-progress
code:
- mesh-control internal/licences
- mesh-control cmd/mesh-control/licence.go
updated: 2026-08-31
updated: 2026-09-05
decisions:
- 02-DECISIONS/0024-model-access-is-a-provision.md
- 02-DECISIONS/0009-modules-and-the-graph.md
@@ -107,6 +107,66 @@ observability, changing a binding — and the binding is then declared as usual.
plainly is what stops the declaration language growing a conditional**, and nothing built here
grew one.
## The vendor-agnostic generalisation
*[ADR 0050](../../02-DECISIONS/0050-model-access-is-vendor-agnostic.md) is accepted; this section
describes what it decides. The section above stands as what is built today.*
What runs is one vendor — the mesh's Anthropic feature. A read-only trace asked whether the
`model-access` provision is Anthropic-shaped or genuinely general, and found that the general layer
already exists: a licence is a record with a `vendor` and a non-secret `serves`, a holder's
credential is sealed per holder, `accept` takes an operator-supplied value and discards the
plaintext, and a locally-run model answers at node scope with no licence. None of that names
Anthropic. What is Anthropic's is a thin band: the credential is a subscription OAuth grant — an
hourly access token and a refresh token — and that shape alone drags central rotation, a
refresh-token-stripping delivery, an identity guard and a `utilization%` usage reading behind it.
**A vendor is an adapter.** The vendor-specific lifecycle moves into a per-vendor adapter selected by
the licence's `vendor` field — the same shape as a registrar-scoped `public-dns` provider behind the
neutral `public-dns` interface ([ADR 0044](../../02-DECISIONS/0044-a-public-name-is-provisioned-like-any-capability.md)).
A consumer still requires `model-access` and never names a vendor; the interface is drawn at the
consumer's real coupling — *reach a model* — per [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md).
The field is `vendor` rather than `provider`, because "provider" already means *which node answers a
brokered provision* and the two facts must not share a word.
The adapter declares a `shape` — `static-key` or `refreshable-grant` — and, optionally, the verbs a
vendor happens to need: `accept` a supplied key, `refresh` a grant, read an `identity` off the
credential to catch a mis-binding, report `usage`, and `deliver` the value. **A static-key vendor
implements almost nothing** — a supplied key, sealed to its holders, delivered. The abstraction is
built so the common vendor is small and the rare one carries its own weight.
```
requires: model-access (the consumer, vendor-blind)
│
┌──────┴───────┐
│ a licence │ vendor: … serves: base URL, model
└──────┬───────┘
selected by │ vendor
┌─────────────────┼──────────────────────────┐
▼ ▼ ▼
anthropic-api-key anthropic (another vendor)
shape: static-key shape: refreshable-grant
accept, deliver accept, refresh, identity,
usage, deliver
```
**The crux is one relaxation, stated plainly.** A refreshable credential cannot be both sealed so the
mesh cannot read it *and* rotated centrally — rotation needs a readable refresh token, and the working
central rotation is the half [ADR 0024](../../02-DECISIONS/0024-model-access-is-a-provision.md) keeps
on purpose. So for `refreshable-grant` vendors only, the **manager node holds the refresh token
encrypted at rest** — a bounded, declared exception. Access tokens stay sealed per holder, and the
refresh token is stripped on delivery, so *a node never holds a refresh token* remains true for every
node but the one manager. **Static-key vendors keep the full guarantee**: there is nothing to rotate,
so `accept` discards the plaintext and the carve-out never fires — and static-key is the majority. The
exception is written down because a relaxed guarantee that is not stated is indistinguishable from a
broken one, and it is narrow on three axes at once: refreshable-grant only, the refresh token only,
the manager node only.
Usage is normalised to `(licence, consumer, period, metric, value)` with the raw response kept beside
it; the metric is vendor-defined, so no false common unit is forced. Anthropic becomes the first
`refreshable-grant` adapter, and `anthropic-api-key` — the same vendor's plain keys — is the
`static-key` case that proves the abstraction is more than one vendor in disguise.
## How it is checked
In the lab, on real machines, in the order a person would meet it: a consumer is refused with both
@@ -114,3 +174,11 @@ candidates named; put on one and still refused because no key exists; the key is
input and not echoed; the public half arrives saying it came from a record rather than a machine;
the key arrives readable only by that machine — and it is **nowhere in the control plane's own
database**, nor in anything that crossed the broker.
For the generalisation ([ADR 0050](../../02-DECISIONS/0050-model-access-is-vendor-agnostic.md)): a
second vendor — `anthropic-api-key`, static-key — is bound to a consumer and exercises the whole path
with the carve-out switched off, its key sealed per node and absent from the control plane's database.
For a `refreshable-grant` licence, the refresh token is asserted to exist (encrypted) **only on the
manager node**, to be **absent from every holder's delivery**, and the delivered credential to be
access-token-only; a `static-key` licence stores no refresh token anywhere. A scenario with two
vendors confirms each licence's lifecycle runs its own adapter, selected by `vendor`.