M-2: the manager's manifest asks for its kept data by template

Eight bus-state entries in the typed long form; the old state and the reads of
claude-code.holdings go, and so does the run-once prepare, since nothing writes the
tables again. The database and grant-key stay as the carry-over's read-only source.
The manifest declares its new events, its consumption of holdings-changed, the
operator channel it uses, the verbs it invokes, six settings and its eight tools.

Builds: design 39 bus-and-switch §1, §9, §21, §23, §27, §33, §37-§44, A3, B9, B16

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
This commit is contained in:
2026-11-11 23:01:43 +01:00
parent e995e40519
commit 1c03299721
4 changed files with 207 additions and 51 deletions
@@ -3,9 +3,6 @@
// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports
// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage.
//
// `claude-licence-manager prepare` is the preparation step (ADR 0135): the host runs it once before the
// version that needs it, with the module's words and no bus, and it brings the store's schema to shape.
//
// stdout is the MCP channel; everything this module says, it says on stderr.
package main
@@ -32,14 +29,6 @@ func say(format string, args ...any) {
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "prepare" {
if err := prepare(); err != nil {
say("preparing the store failed: %v", err)
os.Exit(1)
}
say("the store's schema is what this version needs")
return
}
go daemon()
if err := stdio.Serve("", tools()); err != nil {
say("%v", err)
@@ -47,17 +36,6 @@ func main() {
}
}
func prepare() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
store, err := PgStoreFromEnv(ctx)
if err != nil {
return err
}
defer store.Close()
return store.Migrate(ctx)
}
// ---- what the binary is handed -----------------------------------------------------------------------
var (
@@ -0,0 +1,50 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// design 39 bus-and-switch §1: the module moves to the new form whole — no `reads`, no old `state`, no `prepare`
// step — and asks for each piece of its kept data by template.
func TestTheManifestKeepsNoOldForm(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, old := range []string{"reads", "state"} {
if _, ok := m[old]; ok {
t.Errorf("the manifest keeps the old section %q", old)
}
}
var resources []struct {
ID string `json:"id"`
}
_ = json.Unmarshal(m["resources"], &resources)
for _, r := range resources {
if r.ID == "prepare" {
t.Error("the manifest keeps the run-once step prepare")
}
}
var requires []json.RawMessage
_ = json.Unmarshal(m["requires"], &requires)
kept := map[string]string{}
for _, r := range requires {
var e struct{ Name, Template string }
if json.Unmarshal(r, &e) == nil {
kept[e.Name] = e.Template
}
}
for name, template := range map[string]string{"licences": "state", "bindings": "state", "leases": "state",
"offered": "state", "usage": "log", "audit": "log", "carried": "state", "conditions": "state"} {
if kept[name] != template {
t.Errorf("bus-state entry %s is %q, want template %s", name, kept[name], template)
}
}
}
@@ -3,5 +3,11 @@
"floor_minutes": 60,
"failures_to_notify": 3,
"cooldown_hours": 24,
"refresh_warn_days": 3
"refresh_warn_days": 3,
"switch_auto": true,
"switch_at_pct": 99,
"room_below_pct": 90,
"warn_at_pct": 90,
"usage_minutes": 5,
"usage_stale_minutes": 15
}
+150 -28
View File
@@ -4,7 +4,74 @@
"slug": "licmgr",
"requires": [
"postgres-database",
"secret"
"secret",
{
"name": "licences",
"storage": "key-value",
"template": "state",
"options": {
"history": 1
}
},
{
"name": "bindings",
"storage": "key-value",
"template": "state",
"options": {
"history": 8
}
},
{
"name": "leases",
"storage": "key-value",
"template": "state",
"options": {
"history": 1,
"key-ttl": true
}
},
{
"name": "offered",
"storage": "key-value",
"template": "state",
"options": {
"history": 1,
"key-ttl": true
}
},
{
"name": "usage",
"storage": "stream",
"template": "log",
"options": {
"max-age-days": 35,
"max-mib": 256
}
},
{
"name": "audit",
"storage": "stream",
"template": "log",
"options": {
"max-mib": 64
}
},
{
"name": "carried",
"storage": "key-value",
"template": "state",
"options": {
"history": 1
}
},
{
"name": "conditions",
"storage": "key-value",
"template": "state",
"options": {
"history": 1
}
}
],
"contributes": {
"postgres-database": {
@@ -24,6 +91,14 @@
"grant-key": "${dir:state}/grant.key"
}
},
"uses": [
"operator-channel"
],
"invokes": [
"seat:operator-channel.tell",
"seat:mesh-vault.licence-put",
"seat:mesh-vault.get"
],
"seats": [
{
"name": "anthropic-licence-manager",
@@ -63,18 +138,14 @@
"emits": [
"licence.adopted",
"licence.refused",
"licence.bound",
"licence.switched",
"licence.released",
"licence.rotated",
"licence.failing",
"usage.read"
"usage.read",
"binding.changed",
"holdings.asked"
],
"state": [
"bindings"
],
"reads": [
"claude-code.holdings"
"consumes": [
"claude-code.holdings-changed"
],
"config": {
"cadence_minutes": {
@@ -111,6 +182,52 @@
"minimum": 0,
"trusted": true,
"default": 3
},
"switch_auto": {
"type": "boolean",
"description": "Whether the manager moves every consumer off a licence whose session or weekly usage reaches switch_at_pct, to the licence with the most room; off, it still raises its no-room and near-limit conditions.",
"trusted": true,
"default": true
},
"switch_at_pct": {
"type": "number",
"description": "The session or weekly usage of a licence, in percent, at which its consumers are moved to the licence with the most room.",
"minimum": 1,
"maximum": 100,
"trusted": true,
"default": 99
},
"room_below_pct": {
"type": "number",
"description": "What both the session and the weekly usage of a licence must be below, in percent, for it to be a target of a switch.",
"minimum": 1,
"maximum": 100,
"trusted": true,
"default": 90
},
"warn_at_pct": {
"type": "number",
"description": "The session or weekly usage of a licence with consumers, in percent, at which it is warned about once.",
"minimum": 1,
"maximum": 100,
"trusted": true,
"default": 90
},
"usage_minutes": {
"type": "number",
"description": "How often each subscription's usage is read, in minutes.",
"minimum": 1,
"maximum": 60,
"trusted": true,
"default": 5
},
"usage_stale_minutes": {
"type": "number",
"description": "How old a subscription's last successful usage reading may grow before its room is not known and it is said, in minutes.",
"minimum": 5,
"maximum": 180,
"trusted": true,
"default": 15
}
},
"resources": [
@@ -133,28 +250,33 @@
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"takes-config": ["cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours", "refresh_warn_days"],
"takes-config": [
"cadence_minutes",
"floor_minutes",
"failures_to_notify",
"cooldown_hours",
"refresh_warn_days",
"switch_auto",
"switch_at_pct",
"room_below_pct",
"warn_at_pct",
"usage_minutes",
"usage_stale_minutes"
],
"merge": "json",
"template": "files/settings.json"
},
{
"id": "prepare",
"type": "process",
"name": "claude-licence-manager-prepare",
"artifact": "code",
"run": [
"./claude-licence-manager",
"prepare"
],
"run-once": true,
"env": {
"DATABASE_URL_FILE": "${dir:state}/database.url"
},
"restart-on": [
"database-url"
]
}
],
"tools": [
"licences_switches",
"licences_read_usage",
"licences_switch_now",
"licences_settings",
"licences_kept",
"licences_resync",
"licences_conditions",
"licences_carried"
],
"build": {
"artifacts": [
{