M-17: the manager's README says what it keeps on the bus and how it switches (issue 1409)

Builds: design 39 bus-and-switch §52, §1 to §13, §21, §23 to §46

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
This commit is contained in:
2026-11-12 00:39:21 +01:00
parent b8ce072c9f
commit a8131809b2
+72 -27
View File
@@ -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.<key>`, `account.<id>`: 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.<key>.no-room` (urgent; no licence has room), `licence.<key>.near-limit` (at `warn_at_pct`, once per
crossing), `licence.<key>.usage-unread`, `carry-over-failed` and `settings.<setting>.invalid`.
## The seat's verbs
`licences`, `bindings`, `bind`, `switch`, `release`, `refresh`, `usage`, `adopt`, `current` — through
the console as `anthropic-licence-manager.<verb>`. 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.<verb>`. 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