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