ADR 0050 (proposed) — model access is vendor-agnostic [awaiting ratification] #25
@@ -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.
|
||||||
@@ -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)
|
- **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)
|
- **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)
|
- **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
|
### How it is built
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ status: in-progress
|
|||||||
code:
|
code:
|
||||||
- mesh-control internal/licences
|
- mesh-control internal/licences
|
||||||
- mesh-control cmd/mesh-control/licence.go
|
- mesh-control cmd/mesh-control/licence.go
|
||||||
updated: 2026-08-31
|
updated: 2026-09-05
|
||||||
decisions:
|
decisions:
|
||||||
- 02-DECISIONS/0024-model-access-is-a-provision.md
|
- 02-DECISIONS/0024-model-access-is-a-provision.md
|
||||||
- 02-DECISIONS/0009-modules-and-the-graph.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
|
plainly is what stops the declaration language growing a conditional**, and nothing built here
|
||||||
grew one.
|
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
|
## How it is checked
|
||||||
|
|
||||||
In the lab, on real machines, in the order a person would meet it: a consumer is refused with both
|
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;
|
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
|
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.
|
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`.
|
||||||
|
|||||||
Reference in New Issue
Block a user