ADR 0206: a node reports the grant it holds; the manager adopts a licence by refreshing it
The operator's flow: clients publish what their credentials file holds, the manager takes in a licence it does not own and rotates it from then on. The token itself cannot be published (design 32 §10, ADR 0201), so a node reports fingerprints and identity as state and hands the grant over only when the manager asks; adopting is refreshing, newest login first; bindings with a generation replace the rotated/switched events. Designs 36 and 39 and to-be 40 amended; a pointer note on ADR 0183.
This commit is contained in:
+10
@@ -166,6 +166,16 @@ the node is bound to, and refuses with a notification otherwise.
|
||||
> "offers the grant to the manager" in the decision above now mean in practice. The agent module could
|
||||
> ask through its runtime; it does not need to.
|
||||
|
||||
> **The mechanism changed — 2026-10-04, by [ADR 0206](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md).**
|
||||
> What stands: the manager holding the seat, one rotation source, the grants encrypted in its store, a
|
||||
> token sealed to the receiving module's key on request/reply and never an event, the agent module alone
|
||||
> writing what the agent reads, the identity guard, bindings as a person's act. What moved: the dated note
|
||||
> above — the manager no longer starts every exchange. Each node reports what it holds as state, without
|
||||
> the secret; the manager asks a node for its grant only when a report shows one it does not hold, adopts
|
||||
> a licence by refreshing it rather than into a licence configured beforehand, and keeps what each
|
||||
> consumer should hold as state, from which the node fetches its token by request. The rotation and switch
|
||||
> events are gone.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0024](0024-model-access-is-a-provision.md), [ADR 0050](0050-model-access-is-vendor-agnostic.md) — the licence as a named thing, the carve-out this moves with the manager
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-10-04
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
|
||||
---
|
||||
|
||||
# 206. A node reports the Anthropic grant it holds; the licence manager adopts a licence by refreshing it, and what each node should hold is the manager's state
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)
|
||||
made the licence manager a module holding the `anthropic-licence-manager` seat: one rotation source, the
|
||||
long-lived grants in its own store, a short-lived token handed to a node sealed on request/reply, the
|
||||
agent module alone writing what the agent reads. How the manager *learns* a licence, and who starts each
|
||||
exchange, it left to a later shape, and three texts have since disagreed: ADR 0183 has a node register
|
||||
its key and the manager adopt a login only into a licence the node is already bound to; its dated note
|
||||
of 2026-10-03 has the manager start every exchange and visit every node on a schedule; the agent module
|
||||
as built asks the seat for its token when an event says to, and pushes a login to the seat.
|
||||
|
||||
**The operator settled it on 2026-10-04, in the operator's own words:** the manager must hold the active refresh token;
|
||||
whichever node a login happened on holds the latest one; every client publishes what its credentials
|
||||
file holds, the manager sees a licence it does not own yet and takes it into its store, and from then on
|
||||
rotates it and distributes the access token. A manager launched for the first time holds no licence and
|
||||
accepts what the clients report. Several nodes report the same account — today the nodes are all logged in
|
||||
to one personal account — and before the manager adopts a grant it must know the refresh token still
|
||||
works.
|
||||
|
||||
Two facts bound how that is built:
|
||||
|
||||
- **A refresh token cannot be published.** Anything published on the bus is kept, and a secret never
|
||||
enters a stream, sealed or not ([design 32](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §10).
|
||||
A module's state is a stream too, and the runtime refuses a value carrying a field named like a
|
||||
credential ([ADR 0201](0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md);
|
||||
refused live on 2026-10-04 for an `Authorization` header).
|
||||
- **A refresh token can only be checked by using it.** No endpoint answers "is this refresh token
|
||||
valid" without exchanging it, and an exchange is presumed to rotate it (ADR 0183: the predecessor
|
||||
lost a licence to a reused one). Checking and adopting are therefore one act, and whoever checks
|
||||
becomes the token's only live holder.
|
||||
|
||||
Since ADR 0201 the bus has the shape this needs: **state** every node sees, including one that joins
|
||||
later or a manager that starts later, read whole on start and then watched.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Each node publishes its credentials file, the token included.** What the operator described,
|
||||
literally. Rejected for the token only: it would sit in a stream every principal that reads the
|
||||
bucket can read, for as long as the bucket keeps it, and the runtime refuses it anyway.
|
||||
2. **The manager visits every node on a schedule and collects a waiting login** (ADR 0183's dated
|
||||
note). Rejected: the manager must know every node in advance and poll it, a node that joins later
|
||||
waits for the next visit, and "what does each node hold" lives nowhere anyone can read.
|
||||
3. **Each node reports what it holds as state, without the secret; the manager asks for the secret
|
||||
only when the report shows a grant it does not hold, and adopts by refreshing.** Chosen: the
|
||||
operator's flow, with the one part that cannot be on the bus moved onto request/reply.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. Every agent module reports what its node holds, as its own state.** One key per node in the
|
||||
module's `holdings` state: the account's identity as the agent's own state file names it (account id,
|
||||
address, organisation), the kind, the refresh token's **fingerprint** and whether one is present at
|
||||
all, the access token's fingerprint and expiry, the licence it was last handed, and when the credentials
|
||||
file last changed. Written when the module starts — a node already logged in when the module is first
|
||||
assigned reports at once — and again whenever the credentials file changes. **No token, ever**: a
|
||||
fingerprint names a token without being one.
|
||||
|
||||
**2. A licence is an account, and the manager learns it from the reports.** The manager reads every
|
||||
node's `holdings` at start and watches them. A report carrying a refresh token whose fingerprint the
|
||||
manager does not hold is a **candidate**: for an account it has no licence for yet, a new licence; for
|
||||
one it has, a login made since. A manager launched for the first time holds no licence and treats
|
||||
every report as a candidate. An API key still enters only through the seat's `adopt` verb, from a file
|
||||
on the manager's node.
|
||||
|
||||
**3. The secret travels only when asked for.** For a candidate, the manager calls that node's agent
|
||||
module on request/reply, giving its own public key, and is answered with the grant sealed to that key
|
||||
(ADR 0183's channel, unchanged).
|
||||
|
||||
**4. Adopting is refreshing.** The manager exchanges the candidate's refresh token at the vendor's
|
||||
endpoint under its lease for that account. If the exchange succeeds, the grant it got back is the
|
||||
licence's, stored encrypted, and the manager is from then on its only rotation source. If it fails, the
|
||||
candidate is recorded dead, nothing is adopted, and the report says so. **Several nodes, one account:**
|
||||
candidates for one account are tried newest login first; the first that refreshes is adopted, and the
|
||||
manager does not exchange the others.
|
||||
|
||||
**5. A node holds an access token only, so the latest login wins.** A node bound to an adopted licence
|
||||
is handed the access token and nothing else, and the agent module writes the credentials file without a
|
||||
refresh token — so the agent on the node can never refresh it, and two refreshers never hold one grant.
|
||||
A refresh token appearing in a node's file afterwards can therefore only be a person's login there; its
|
||||
report makes it a candidate, and if it refreshes it replaces the licence's grant. That is the operator's
|
||||
"whichever node a login happened on holds the latest one", made mechanical.
|
||||
|
||||
**6. What each consumer should hold is the manager's state.** One key per consumer in the manager's
|
||||
`bindings` state: the licence, its kind, and a **generation** that increases with every rotation and
|
||||
every switch. The agent module watches its own key; when the generation is newer than the one it
|
||||
applied, it asks the seat's `current` verb for the token, sending its public key, and is answered sealed
|
||||
(request/reply). A node that was away reads its key when it is back and asks once. The `licence.rotated`
|
||||
and `licence.switched` events go: what they announced is now the state itself, and a node needs the
|
||||
latest, not the history.
|
||||
|
||||
**7. A first binding follows the login.** When the manager adopts a licence from a node's report, a
|
||||
node with no binding yet whose report names that account is bound to it. Every later change is a
|
||||
person's act through `bind`, `switch` and `release`, as ADR 0183 says.
|
||||
|
||||
**8. The identity guard stands, on two sources.** The account a grant is filed under is the identity
|
||||
the node read from the agent's own state. Where the vendor's answer to the refresh names the account,
|
||||
the manager compares the two and refuses a mismatch with a notification; whether it names it is
|
||||
measured when the manager is built, and the record of which source decided is kept in the audit.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The manager needs no configuration to start: launched on a mesh whose nodes are logged in, it adopts
|
||||
every account they hold, one licence each, from the newest login that still refreshes.
|
||||
- Every node's holding is readable by anyone allowed to read the state — the console, an agent, the
|
||||
operator — without a token in sight, which is what `licence_status` on each node answered one at a
|
||||
time.
|
||||
- **What got harder:** adoption consumes the refresh token the node held. On a node whose grant was
|
||||
adopted, the agent's own copy is dead from that moment; until the manager hands it an access token
|
||||
(decision 6), the agent keeps the access token it already had, which lives hours. And a node whose
|
||||
file still holds a refresh token after adoption — it was not handed one yet — is a second holder of a
|
||||
dead grant, not a live one, so the rotation-source rule holds.
|
||||
- A candidate whose refresh fails is not retried by the manager: a dead refresh token does not come
|
||||
back. A person logs in again, and the new report is a new candidate.
|
||||
- Nothing in the reports is secret, but they do say which account each node uses; readers of the state
|
||||
are declared in manifests like any other.
|
||||
|
||||
## How it is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| No report carries a token | the runtime refuses a credential-named field (ADR 0201's test); the agent module's test: a report built from a full credentials file holds fingerprints and identity only |
|
||||
| A node already logged in reports at start | the agent module's test: with a credentials file present and unchanged, starting writes its `holdings` key |
|
||||
| A candidate is adopted only by a successful refresh, newest login first, once per account | the manager's tests against a stub vendor: two reports for one account, the newer refreshes and is adopted, the older is never exchanged; a failing refresh adopts nothing and records the candidate dead |
|
||||
| A node is handed an access token only | the agent module's test: the file it writes after a hand-over holds no refresh token |
|
||||
| A newer generation is fetched once, by request | the agent module's test: a `bindings` change with a newer generation asks `current` once; an equal one asks nothing |
|
||||
| No event carries a token, and none announces a rotation any more | the manager's test of everything it publishes |
|
||||
| Live | the manager launched with no licence on a mesh whose four nodes are logged in to one account adopts one licence, binds the four nodes, and each node's file then holds an access token and no refresh token |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md) — the manager, its seat and its channel, which this extends
|
||||
- [ADR 0201](0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md) — module state, and the refusal of a secret in it
|
||||
- [design 32](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §10 — no secret in a stream
|
||||
- [to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md), [to-be 39](../03-DESIGN/01-to-be/39-the-anthropic-licence-manager.md) — the two modules, amended by this record
|
||||
@@ -304,6 +304,7 @@ python3 00-META/checks/index.py fail if stale
|
||||
- **0203** — [The account's environment is one module's, and every module contributes to it](0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)
|
||||
- **0204** — [A module contributes shell code to the login shell in named slots, and the login shell is the mesh's seat](0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)
|
||||
- **0205** — [Software the distribution does not package ships as a pinned archive of the module's own](0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md)
|
||||
- **0206** — [A node reports the Anthropic grant it holds; the licence manager adopts a licence by refreshing it, and what each node should hold is the manager's state](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md)
|
||||
|
||||
### How it is built
|
||||
|
||||
|
||||
Reference in New Issue
Block a user