Builds: design 39 bus-and-switch §52, §1 to §13, §21, §23 to §46 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
107 lines
7.0 KiB
Markdown
107 lines
7.0 KiB
Markdown
# claude-licence-manager
|
|
|
|
Holds the `anthropic-licence-manager` seat: every Anthropic licence the mesh has, kept alive by one
|
|
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` 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**, 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. The node it came from is bound to it; a node reporting that account and bound to nothing is bound to it.
|
|
|
|
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.
|
|
|
|
## What it keeps on the bus
|
|
|
|
Each piece asked for in `requires` by its template (ADR 0595), read by `licences_kept`:
|
|
|
|
| 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` (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
|
|
|
|
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.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, 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 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
|
|
|
|
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
|
|
|
|
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|