Files
hq/03-DESIGN/01-to-be/14-model-access.md
jschoubben 90e4a368dc ADR 0081: a decision nothing cites is not yet in the chain
Decisions were the one link the cycle checks skipped, and measuring found 19 of 70 records
orphaned — the credential flow and the module-runtime cluster among them, which is how a
stale premise about a settled decision survived in working memory. cycle.py now refuses an
accepted record nothing cites; the 19 got true homes (design frontmatter, the playbook that
implements 0021, META for the process records). The overview names the practice: spec-driven
development with provenance.

https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-17 22:36:33 +02:00

187 lines
12 KiB
Markdown

---
layer: to-be
status: in-progress
code:
- mesh-controller internal/licences
- mesh-controller cmd/mesh-controller/licence.go
updated: 2026-09-05
decisions:
- 02-DECISIONS/0024-model-access-is-a-provision.md
- 02-DECISIONS/0009-modules-and-the-graph.md
- 02-DECISIONS/0054-model-usage-is-recorded-at-two-grains.md
- 02-DECISIONS/0055-model-access-is-answered-by-a-licence-or-a-node.md
---
# 14 — Model access
*[ADR 0024](../../02-DECISIONS/0024-model-access-is-a-provision.md) decided it and listed four
things the mesh did not have. Written 2026-08-31, when two of them were built. **The other two are
still gaps and are still written as gaps** — the record's own warning is that pretending otherwise
is how a plan becomes a surprise.*
## What was built
**A licence is a record, and the first provision no machine answers.** Everything else the mesh
brokers is answered by something running on a node. A hosted model is on nobody's machine and is
reached over the public internet, so the rule that refuses two ends sharing no private network —
correct everywhere else — must not apply to it. A machine on no private network at all can hold a
licence, and that is not a special case to remember: it falls out of the answer not being a
machine.
**The name is the operator's.** *The personal account*, *the organisation's account*. Those are
names a person uses, and the mesh uses them too, because the whole point is saying **which one** a
consumer uses — and an anonymous credential hanging off a provider cannot be said. Many to many,
so deliberately **not a claim**: two machines sharing an account is the ordinary case rather than
a collision.
**One provision name for all of them.** A module requires `model-access`, never `anthropic`. A
module that named a provider could not be moved onto a model the mesh runs itself without editing
it — and moving it is the point.
**A model in a machine's own set answers it locally**, and no record is consulted. That is what
makes *the mesh's own model* an ordinary answer rather than a parallel arrangement.
### Accept: taking a value the mesh did not make
Every other credential here the mesh generated, sealed to both ends and discarded. An API key
arrives from a person, and the missing verb was *accept*: **take a value, seal it to each holder,
discard the plaintext.** A mesh that kept operator-supplied keys readably is the arrangement this
project measured and rejected.
**It seals to the holders that exist at that moment**, and this has a consequence that must be
said out loud rather than discovered:
> A consumer put on a licence *after* the key was supplied has no key, and **the mesh cannot make
> one** — it discarded the only copy.
So that state is reported at every point a person could meet it: when the consumer is put on the
licence, in `licence list`, and — decisively — **the declaration is refused** rather than written
without the file. A machine that resolves cleanly and receives nothing fails later, somewhere that
names neither the licence nor the mesh.
**A key is read from a file or standard input, never an argument.** A key on a command line is a
key in shell history and in every process listing taken while it ran. It is never echoed back:
what is stored is unreadable by whoever holds it, the controller included, and printing it
would put the one copy that matters on a terminal.
## Refusing is felt, and that is the design working
ADR 0024 predicted it: *a mesh holding three ways to reach a model refuses every consumer that has
not said which — which is correct and is a great deal of saying-which the first time.*
It is correct, and correct is not the same as usable. So the refusal names **the candidates and
the exact command**. The difference between a mesh that refuses helpfully and one that merely
refuses is whether anybody can act on it without going and reading something else.
## Still gaps
Unchanged from [ADR 0024](../../02-DECISIONS/0024-model-access-is-a-provision.md), and deliberately
not half-built:
**A consumer that is not a machine.** *This worker uses that licence* is a binding to an agent, not
to a node. What is delivered still lands on a machine; what is **chosen** is chosen per agent, and
the provisions model has no consumer identity other than a node. What exists today is per module
per machine, which is a step toward it and is not it.
*2026-08-31: this gap now has named consumers rather than hypothetical ones.*
[ADR 0026](../../02-DECISIONS/0026-the-mesh-has-a-session-of-its-own.md) puts two sessions on the
controller node — the node's own and the mesh's — each bound in its own right. See
[`15-the-agent-session.md`](15-the-agent-session.md).
**And for sessions the gap is already closed, which was not obvious.** A binding is per module per
machine, and this document called that *a step toward it and not it* — reasoning that a machine
cannot name an agent. It cannot; but the two sessions are **two modules**, because they are the
same mechanism started in different context roots and a context root is what a module delivers.
So `(node, module)` tells them apart, and asking for a licence per session needed no new consumer
identity. Checked rather than argued: two sessions on one machine hold different licences, each is
given its own key, and releasing one leaves the other.
**What is still open is the rest of the gap, and it is the harder half.** A *worker* is not one
per machine — many can run on one, from one module — so `(node, module)` cannot name them apart
and this reasoning does not extend to them. That belongs with
[ADR 0003](../../02-DECISIONS/0003-agents-are-persistent-employees.md), which is unbuilt, and it
is the reason this section stays open rather than being struck out.
**Switching is a reaction, not a declaration.** A licence that hits its limit and must be swapped is
a response to something observed. Expressing it as a declaration would make the declaration mean
*whatever is working right now*, which is not a thing anybody declared. It belongs with
observability, changing a binding — and the binding is then declared as usual. **Saying this
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
candidates named; put on one and still refused because no key exists; the key is given on standard
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 controller'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 controller'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`.