From a8131809b203bf1be070239fc8dd7026b32924fe Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 00:39:21 +0100 Subject: [PATCH] M-17: the manager's README says what it keeps on the bus and how it switches (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 39 bus-and-switch §52, §1 to §13, §21, §23 to §46 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- modules/claude-licence-manager/README.md | 99 +++++++++++++++++------- 1 file changed, 72 insertions(+), 27 deletions(-) diff --git a/modules/claude-licence-manager/README.md b/modules/claude-licence-manager/README.md index 58a984b35..de0b4c789 100644 --- a/modules/claude-licence-manager/README.md +++ b/modules/claude-licence-manager/README.md @@ -1,58 +1,103 @@ # claude-licence-manager Holds the `anthropic-licence-manager` seat: every Anthropic licence the mesh has, kept alive by one -rotation source, and handed to each consumer sealed (novox/hq ADR 0183, ADR 0206, design 39). +rotation source, its consumers moved off a licence near its limit by the manager itself (novox/hq ADR 0183, +ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access token lives in the vault; this module +keeps none. ## How a licence comes to exist -Nothing is configured. Every node running `claude-code` reports what it holds as that module's -`holdings` state — the account, fingerprints and expiries, never a token. This module reads every report -when it starts and watches them: +Nothing is configured. Every node running `claude-code` says what it holds as the event +`claude-code.holdings-changed` (the account, fingerprints and expiries, never a token), at its start, on every change, +and again when this module says `holdings.asked` (at every start, after the carry-over, and on `licences_resync`): 1. A report with a refresh token it does not hold is a **candidate**. 2. It asks that node's `claude_code_grant`, giving its public key, and receives the grant sealed to it. -3. **It refreshes it.** If the vendor exchanges the token, the grant is this module's — encrypted at rest - with the key the vault made for it — and from then on it is the only refresher. If not, the candidate - is recorded dead and nothing is adopted. +3. **It refreshes it**, under the account's lease. If the vendor exchanges the token, the licence is new, named by its + account's address and kept under that name's key (`@` as `_at_`, any other character outside letters, digits, `-` + and `.` as `_`); its grant and access token go to the vault (`licence-put`), and from then on this module is the + only refresher. If not, the candidate is recorded dead and nothing is adopted. An adoption whose key another licence + has is refused. 4. Several nodes logged in to one account: newest login first; the rest are never exchanged. -5. A node reporting that account and bound to nothing is bound to it. +5. The node it came from is bound to it; a node reporting that account and bound to nothing is bound to it. -Each node is then handed an access token only, so the agent there never refreshes, and a refresh token -appearing on a node later can only be a person's login — which wins if it refreshes. +From its next reading a new subscription has a room and is a target of the switch. An API key enters through `adopt`, +from a file on this module's node or sealed by a node's `claude_code_add_api_key`; it is never a target. -An API key enters through `adopt`, from a file on this module's node. +## What it keeps on the bus -## What each consumer holds +Each piece asked for in `requires` by its template (ADR 0595), read by `licences_kept`: -This module's `bindings` state: one key per consumer (a node's name) with the licence, its kind and a -generation that grows with every rotation and switch. `claude-code` watches its own key and, on a newer -generation, asks `current` with its public key. +| name | template | what | +|---|---|---| +| `licences` | state, history 1 | one key per licence: account, address, organisation, expiries, failures, adoption, the vault's generation, the latest reading; never a token | +| `bindings` | state, history 8 | one key per consumer (a node's name): licence, kind, generation, since, why and by | +| `leases` | state, a lifetime per key | `licence.`, `account.`: one refresh or adoption at a time, two minutes | +| `offered` | state, a lifetime per key | each grant a node offered and its outcome, 90 days | +| `usage` | log, 35 days, 256 MiB | every reading, one subject per licence key, with the vendor's answer whole | +| `audit` | log, 64 MiB | every act, one subject per kind (`switched`, `carried`, `settings` among them) | +| `carried` | state | `postgres`: when the carry-over ran, and per table the rows read and entries written | +| `conditions` | state | each condition said raised, and since when | + +Its database (`postgres-database`, read with the module's own credential) and `grant.key` are kept only as the +carry-over's source: at its first start, before it hears an event, refreshes or reads usage, the manager reads every +row of today's six tables and writes them here and to the vault, then `carried.postgres`. Until then it serves its +reads alone and answers every act with "the carry-over has not ended"; a failed read or write raises +`carry-over-failed`. With `carried.postgres` present it never reads the database again. The database is never written. + +## Switching on usage + +Every `usage_minutes` the manager reads each subscription's usage with its access token from the vault: the session, +weekly and sonnet windows, each with its reset time. A licence's room is 100 less the higher of its session and weekly +percentages, known only when it was read within `usage_stale_minutes` and its refresh is healthy. When a reading puts +a licence with consumers at or above `switch_at_pct`, every consumer bound to it moves, in one act, to the licence +with the most room whose windows are both below `room_below_pct`: one `binding.changed` per consumer, one +`licence.switched`, and one `operator-channel.tell`. A consumer is never moved back by the rule. + +Its conditions are said on the operator channel's condition subject, the router deciding where they go: +`licence..no-room` (urgent; no licence has room), `licence..near-limit` (at `warn_at_pct`, once per +crossing), `licence..usage-unread`, `carry-over-failed` and `settings..invalid`. ## The seat's verbs -`licences`, `bindings`, `bind`, `switch`, `release`, `refresh`, `usage`, `adopt`, `current` — through -the console as `anthropic-licence-manager.`. No answer carries a token. +`licences`, `bindings` (with `consumer` for one), `bind`, `switch`, `release`, `refresh`, `usage` (`licence`, +`since`, `limit` up to 2000, `raw`), `adopt`, `public-key`, `current` (the licence, its key, kind, generation and +health, never the token) — through the console as `anthropic-licence-manager.`. No answer carries a token. + +## Its tools + +`licences_switches` (every switch and why), `licences_read_usage` (read now, `dry`), `licences_switch_now` (apply the +rule now, even with `switch_auto` off), `licences_settings` (each value, its default and where it was set), +`licences_kept` (every entry it keeps, how full against its cap), `licences_resync` (say every binding, holdings.asked +and each condition again, and hand each access token to the vault again), `licences_conditions`, `licences_carried` +(the carry-over's record; `run` while it has not ended). ## Settings -`settings.json` in the state directory: `cadence_minutes` (240), `floor_minutes` (60), -`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3). +Through mesh-config, each `trusted`, read again at every reading: `cadence_minutes` (240), `floor_minutes` (60), +`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3), `switch_auto` (on), `switch_at_pct` (99, +1 to 100), `room_below_pct` (90, 1 to 100), `warn_at_pct` (90, 1 to 100), `usage_minutes` (5, 1 to 60), +`usage_stale_minutes` (15, 5 to 180). A value outside its bound keeps its default and raises its condition. ## Events -`licence.adopted`, `licence.refused`, `licence.failing`, `usage.read` — none carries a secret. +`licence.adopted`, `licence.refused`, `licence.switched`, `licence.failing`, `usage.read`, `binding.changed`, +`holdings.asked` — none carries a secret. It consumes `claude-code.holdings-changed`. ## Code and tests -Go, one binary (`cmd/claude-licence-manager`): the seat's verbs and the daemon in one launched bundle, -`prepare` as the run-once preparation step. The sealed box is `claude-code`'s own format, byte for byte — -the two modules carry the same `seal.go` — and a test opens one sealed by the TypeScript agent module the -Go one replaced, so the format is the one already on the machines. +Go, one binary (`cmd/claude-licence-manager`): the seat's verbs, the tools and the daemon in one launched bundle. The +sealed box is `claude-code`'s own format, byte for byte — the two modules carry the same `seal.go` — and a test opens +one sealed by the TypeScript agent module the Go one replaced, so the format is the one already on the machines. go test ./... - # the store against a real postgres: - docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine - MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./... + # the whole module on a real bus, and today's rows on a real postgres: + go test -tags integration ./... + +The whole tests (`whole_integration_test.go`) start the built binary as its runtime does, its kept data in a real +nats-server, and today's database on `MESH_TEST_POSTGRES` or a private postgres started from the machine's `initdb` +on a socket of its own; with neither, the tests that need one skip and say so. The vault, the agent modules, the +operator channel and the vendor are played beside it; a condition is read through the operator channel's client. ## Files it does not know