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:
jochen
2026-10-04 11:58:46 +02:00
parent f6668d76d6
commit 82fa5f79ea
6 changed files with 243 additions and 64 deletions
@@ -2,8 +2,9 @@
layer: to-be
status: designed
code: []
updated: 2026-10-03
updated: 2026-10-04
decisions:
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
- 02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
@@ -139,31 +140,32 @@ it is the person's to remove, and until then the agent sees the mesh's tools twi
## 5. The licence: the consumer side
[ADR 0183](../../02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md)
decides it; to-be 39 is the manager's half. This module:
decides it and [ADR 0206](../../02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md) says how it moves; to-be 39 is the manager's half. This module:
- **makes a keypair** in its state the first time it runs, and answers `claude_code_public_key` with the
public half when the manager asks;
- **serves `apply`**: the manager's hand-over, a token sealed to the module's key, with the licence's
name and kind. A rotation of the same licence is applied only if newer within one lineage; a switch is
applied regardless, because across licences the expiries are unrelated. The answer says applied or
refused and why, and never echoes a token;
- **is reconciled, never pulls**: the manager asks every bound node on a schedule and after every
rotation, so a node that was away receives its token when it is back; between visits it keeps the last
token, and `licence_status` says how long it has left;
- **writes** for a subscription licence the credentials file as the operator, access-token-only; for the
API-key licence sets the key-helper in the managed settings to a small program that prints the key
from the module's state, so no file under the home is touched;
- **holds a login for the manager to collect**: when the credentials file holds a full grant it did not
write — a person logged in — it answers `claude_code_pending_login`, when the manager asks, with the
grant sealed to the key the manager gives in its request and the account's identity read from the
agent's state file; the manager decides, and the next hand-over strips the refresh token;
- **serves `licence_status`**: which licence and kind this node holds, when the token expires, whether
- **makes a keypair** in its state the first time it runs, and sends the public half with every request
that is answered sealed;
- **reports what the node holds**, as its own `holdings` state, one key for this node: the account's
identity read from the agent's state file, the kind, the refresh token's fingerprint and whether one is
present, the access token's fingerprint and expiry, the licence and generation it last applied, when the
credentials file last changed. Written at start — a node already logged in reports at once — and on every
change of the file. Never a token: the runtime refuses one anyway;
- **hands over a grant only when asked**: `claude_code_grant` answers the manager, which gives its public
key, with the full grant in the credentials file sealed to that key — the one time a refresh token
leaves the node, for the manager to adopt by refreshing it;
- **watches the manager's `bindings` state** for this node, and when the generation is newer than the one
it applied, asks the seat's `current` verb for the token, sealed to its own key. A rotation of the same
licence is applied only if newer within one lineage; a switch is applied regardless;
- **writes** for a subscription licence the credentials file as the operator, **access-token-only** — so
the agent here never refreshes, and a refresh token appearing later is a person's login, reported like
any change; for the API-key licence sets the key-helper in the managed settings to a small program that
prints the key from the module's state, so no file under the home is touched;
- **serves `claude_code_status`**: which licence and kind this node holds, when the token expires, whether
the file matches what was handed over — by fingerprint, never by value.
Switching is the seat's `switch` verb, asked through the console; this module only applies what it is
handed. *2026-10-03:* every exchange is started by the manager, by the operator's direction (ADR 0183's dated
note); this module is a bundle the node's runtime launches over stdio and answers what it is asked
([ADR 0193](../../02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md)). The tool names follow the catalogue's `<module>_<verb>` form.
Switching is the seat's `switch` verb, asked through the console; this module only applies what the
state says it should hold. *2026-10-04:* this replaces the manager's visits of 2026-10-03 (ADR 0183's dated
note): the node reports, the manager asks for a secret only when a report shows one it does not hold, and
a token is fetched by request when the state says it changed.
## 6. Scope, settings and the order of assignment
@@ -2,8 +2,9 @@
layer: to-be
status: designed
code: []
updated: 2026-10-03
updated: 2026-10-04
decisions:
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
- 02-DECISIONS/0024-model-access-is-a-provision.md
- 02-DECISIONS/0050-model-access-is-vendor-agnostic.md
@@ -74,24 +75,22 @@ Carried from the predecessor, where each rule was earned by an incident:
## 4. Handing a token to a node
**The manager starts every exchange** (ADR 0183's dated note of 2026-10-03), by `mesh/ask` through the
runtime that launched it ([ADR 0198](../../02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)). The manager asks each bound node's module for its public
key the first time and keeps it. From then on:
*Amended 2026-10-04 by [ADR 0206](../../02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md)*, replacing the manager's visits: **what each consumer
should hold is the manager's state, and the token is fetched when it changes.**
- **On rotation**, the manager calls `claude-code.apply@<node>` on every node bound to the rotated
licence, with the new token sealed to that node's module key. The module answers *applied*, or
*refused* and why, and the manager records it.
- **On a switch**, the same call with the other licence's token, and the binding is the authority: the
module applies a bind without comparing expiries, because across two licences the numbers are
unrelated.
- **On a schedule**, every few minutes, the manager visits each bound node: a node whose token is near
expiry, or that did not answer last time, is handed its current token. A node that was away is
served when it is back, with nothing for it to ask.
- **Never as an event.** What the manager emits names the licence and the outcome and carries no token.
- **The manager keeps a `bindings` state**, one key per consumer: the licence, its kind, and a
**generation** that increases with every rotation and every switch. Nothing in it is secret.
- **The agent module on each node watches its own key.** When the generation is newer than the one it
applied, it asks the seat's `current` verb, sending its public key, and is answered with the token
sealed to it — request/reply, never an event. A node that was away reads its key when it is back and
asks once; a manager that is down leaves every node on its last token, which lives hours.
- **On a switch** the agent applies the new licence's token without comparing expiries, because across
two licences the numbers are unrelated; within one licence it applies only a newer grant.
- **No event announces a rotation or a switch.** What they announced is the state itself, and a node
needs the latest, not the history. What the manager still emits names an outcome and carries no token.
A node whose module does not answer for its key cannot be handed a token, and the manager says so by name
rather than falling silent. A node whose module refuses — a wrong identity, a stale grant within one
lineage — is recorded as drift and reported.
A consumer that never asks is visible: its own report (§6) names the licence and generation it holds,
and a node behind its binding is drift the manager reports.
## 5. Who gets which licence
@@ -118,27 +117,46 @@ already keeps.
## 6. Adopting a grant
A licence enters the mesh one of two ways, and the token never passes through a prompt, a terminal or an
argument:
*Amended 2026-10-04 by [ADR 0206](../../02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md)*: a licence is an account, learned from what the nodes
report, and adopted by refreshing it.
- **From a node's login.** A person logs in on a node, as they always have. On its next visit the manager
asks that node's module for a waiting login, giving its own public key; the module answers with the
full grant sealed to it and the account's identity read from the agent's own state file. The manager adopts it into the licence the node is bound to **only if the
identity matches** that licence's recorded account; a licence not yet identified is identified by its
first adoption; a mismatch is refused and notified, because the predecessor once filed one account's
grant into another's row this way.
- **Every node reports what it holds**, as the agent module's `holdings` state, one key per node: the
account's identity read from the agent's own state file, the kind, the refresh token's fingerprint and
whether one is present, the access token's fingerprint and expiry, the licence and generation it was
last handed, when the credentials file last changed. Written when the module starts — a node already
logged in reports at once — and on every change. Never a token.
- **The manager reads every report at start and watches them.** A report with a refresh token whose
fingerprint the manager does not hold is a candidate: a new licence for an account it has none for, a
login made since for one it has. A manager launched for the first time holds no licence and takes every
report as a candidate.
- **The secret is asked for, never published.** For a candidate the manager calls that node's agent
module, giving its own public key, and is answered with the grant sealed to it.
- **Adopting is refreshing.** The manager exchanges the candidate's refresh token under its lease for
that account; success makes the returned grant the licence's and the manager its only rotation source;
failure records the candidate dead and adopts nothing. Candidates for one account are tried newest login
first, and the first that refreshes ends the search — the others are never exchanged.
- **The latest login wins.** A bound node is handed an access token only and its file holds no refresh
token, so a refresh token appearing there later is a person's login; its report makes it a candidate,
and if it refreshes it replaces the licence's grant.
- **A first binding follows the login**: a node with no binding whose report names the adopted account
is bound to it. Every later change is `bind`, `switch` or `release`.
- **The identity guard** files a grant under the identity the node read; where the vendor's refresh
answer names the account too, a mismatch is refused and notified. Which source decided is audited.
- **An API key** is delivered to the manager by the operator through the seat's `adopt` verb from a file
on the manager's node, never as an argument.
## 7. What it emits and serves
**Events**, no secret in any: `licence.rotated`, `licence.switched`, `licence.adopted`,
`licence.failing`, `licence.refused`, `usage.read` — the audit logger records them all.
**Events**, no secret in any: `licence.adopted`, `licence.failing`, `licence.refused`, `usage.read` —
the audit logger records them all. *2026-10-04 (ADR 0206):* `licence.rotated` and `licence.switched` are
gone; a rotation or a switch is a new generation in the `bindings` state.
**State**: `bindings`, which it keeps; the agent module's `holdings`, which it reads.
**The seat's verbs**, the contract every future holder must serve: `licences` (each with kind,
identity, expiry, failures, who is bound), `bindings`, `bind`, `switch`, `release`, `refresh` (now, one
or all), `usage` (current and history), `adopt`, and `visit` (reconcile one node now). A node's key and
a consumer's token are not seat verbs: the manager asks the node, by the agent module's own tools.
or all), `usage` (current and history), `adopt`, and `current` (a consumer's token, sealed to the key the
consumer sends — ADR 0206). The manager asks a node for a candidate grant by the agent module's own tool.
## 8. Settings
@@ -2,8 +2,9 @@
layer: to-be
status: designed
code: []
updated: 2026-10-03
updated: 2026-10-04
decisions:
- 02-DECISIONS/0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md
- 02-DECISIONS/0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md
- 02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
@@ -116,12 +117,13 @@ runtime's port; the controller's tests and the catalogue's checks pass.
managed directory through the account's escalation, only when their content changed.
3. **The keypair**, made once in the state directory; X25519 and an authenticated cipher from the
language's own library, so the bundle carries no dependency.
4. **The tools**: `claude_code_status` (what is rendered, what licence is held, when its token expires,
fingerprints only); `claude_code_render` (render now); `claude_code_public_key`;
`claude_code_apply` (a sealed token, applied only if newer within one lineage unless it is a switch;
the credentials write as the operator, access-token-only, atomic; the key-helper program for an API
key); `claude_code_pending_login` (a full grant found in the credentials file, sealed to the key the
caller gives, with the account's identity).
4. **The tools and the state** (*2026-10-04, ADR 0206*): `claude_code_status` (what is rendered, what
licence is held, when its token expires, fingerprints only); `claude_code_render` (render now);
`claude_code_grant` (the full grant in the credentials file, sealed to the key the manager gives).
The `holdings` state, written at start and on every change of the credentials file; a watch of the
manager's `bindings` key for this node, which asks the seat's `current` on a newer generation and
applies the sealed answer — only if newer within one lineage unless it is a switch; the credentials
write as the operator, access-token-only, atomic; the key-helper program for an API key.
5. **The documentation**: the six predecessor files and the hand-made console entry a person removes.
**Proof, before anything runs live.** Unit tests: the renderer writes the mesh's keys and nothing else;
@@ -143,8 +145,9 @@ touched: the module writes the credentials file only when it is handed a token.
for the key the grants are encrypted with, a tools bundle and a long-running bundle for the daemon, both launched by the runtime, settings
with defaults); the store's migrations; the refresh with its plan, lease, floor and cadence as pure
functions; the vendor client from `anthropic-manager`; adoption from a file and from a node's waiting
login with the identity guard; usage and its threshold; the visit — key, hand-over, waiting login — per
bound node; the seat's verbs.
login with the identity guard; usage and its threshold; the seat's verbs. *2026-10-04 (ADR 0206):* in place
of the visit, the watch of every node's `holdings`, adoption of a candidate by refreshing it (newest login
first, once per account), the `bindings` state with a generation per consumer, and `current`.
**Proof, before anything runs live.** Unit tests: two refresh runs started together rotate one grant
once; a mismatching identity is refused; a worker bound to a dead licence is refused and never lent
@@ -158,9 +161,10 @@ node's key.
yet live on the control node when this package starts, this package waits for it: no tool container, no
credential copied by hand.
**Order.** Assign the manager on the control node; push. Adopt the API key from a file there. Adopt the
two subscription grants: a login on a workstation carrying the agent module, collected by the manager's
visit. Bind each node's agent to a licence.
**Order.** Assign the manager on the control node; push. It reads every node's `holdings` and adopts each
account the nodes are logged in to, by refreshing the newest login's grant (ADR 0206); each node with no
binding is bound to the account it reported. Adopt the API key from a file there. A second subscription
account enters by a login on a workstation carrying the agent module.
**Proof.** Through the console: `anthropic-licence-manager.licences` lists three licences with identity
and expiry; within the cadence the audit shows a rotation and a later expiry; a forced `refresh` is