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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user