From 3a9b47918d1d3c3dcb25c2c09e30ff2a7f82ac31 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 5 Sep 2026 13:43:01 +0200 Subject: [PATCH 1/2] =?UTF-8?q?ADR=200050=20(proposed)=20=E2=80=94=20model?= =?UTF-8?q?=20access=20is=20vendor-agnostic;=20amend=2014-model-access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the completed vendor-agnostic analysis into HQ design. The model-access provision stays one vendor-blind interface (extends 0024/0027); the vendor-specific lifecycle moves into a per-vendor adapter keyed by the licence's `vendor` field, mirroring registrar-scoped public-dns providers (0044), named at the consumer's real coupling per 0040. The crux is the sealing-vs-central-rotation carve-out: for refreshable-grant vendors only, the manager node holds the refresh token encrypted at rest (a bounded, declared exception), access tokens sealed per holder, refresh stripped on delivery. Static-key vendors keep full sealing. Amend 03-DESIGN/01-to-be/14-model-access.md with the adapter generalisation as a proposed section (prose + diagram, no code); regenerate the decision index. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- .../0050-model-access-is-vendor-agnostic.md | 206 ++++++++++++++++++ 02-DECISIONS/README.md | 1 + 03-DESIGN/01-to-be/14-model-access.md | 70 +++++- 3 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 02-DECISIONS/0050-model-access-is-vendor-agnostic.md diff --git a/02-DECISIONS/0050-model-access-is-vendor-agnostic.md b/02-DECISIONS/0050-model-access-is-vendor-agnostic.md new file mode 100644 index 0000000..6a25a69 --- /dev/null +++ b/02-DECISIONS/0050-model-access-is-vendor-agnostic.md @@ -0,0 +1,206 @@ +--- +topic: what runs on it +status: proposed +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 ` 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. diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index 586deeb..94bd9c3 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -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) *(proposed)* ### How it is built diff --git a/03-DESIGN/01-to-be/14-model-access.md b/03-DESIGN/01-to-be/14-model-access.md index dad14f7..7d74c33 100644 --- a/03-DESIGN/01-to-be/14-model-access.md +++ b/03-DESIGN/01-to-be/14-model-access.md @@ -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 (proposed) + +*[ADR 0050](../../02-DECISIONS/0050-model-access-is-vendor-agnostic.md) is proposed and not yet +ratified; this section describes what it decides. The section above stands as what is built.* + +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`. -- 2.54.0 From 860d512e915803b4472fcc8f641aa95d7e75e5d7 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 5 Sep 2026 21:42:20 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Accept=20ADR=200050=20=E2=80=94=20model=20a?= =?UTF-8?q?ccess=20is=20vendor-agnostic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- 02-DECISIONS/0050-model-access-is-vendor-agnostic.md | 2 +- 02-DECISIONS/README.md | 2 +- 03-DESIGN/01-to-be/14-model-access.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/02-DECISIONS/0050-model-access-is-vendor-agnostic.md b/02-DECISIONS/0050-model-access-is-vendor-agnostic.md index 6a25a69..7952015 100644 --- a/02-DECISIONS/0050-model-access-is-vendor-agnostic.md +++ b/02-DECISIONS/0050-model-access-is-vendor-agnostic.md @@ -1,6 +1,6 @@ --- topic: what runs on it -status: proposed +status: accepted date: 2026-09-05 deciders: jochen reconstructed: false diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index 94bd9c3..eff1d6f 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -118,7 +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) *(proposed)* +- **0050** — [Model access is vendor-agnostic, and a vendor is an adapter](0050-model-access-is-vendor-agnostic.md) ### How it is built diff --git a/03-DESIGN/01-to-be/14-model-access.md b/03-DESIGN/01-to-be/14-model-access.md index 7d74c33..4009227 100644 --- a/03-DESIGN/01-to-be/14-model-access.md +++ b/03-DESIGN/01-to-be/14-model-access.md @@ -107,10 +107,10 @@ 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 (proposed) +## The vendor-agnostic generalisation -*[ADR 0050](../../02-DECISIONS/0050-model-access-is-vendor-agnostic.md) is proposed and not yet -ratified; this section describes what it decides. The section above stands as what is built.* +*[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 -- 2.54.0