Files
mesh-catalog/modules/claude-licence-manager

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).