From e995e40519629af3696fc23cceab15d7007e22b4 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 22:58:52 +0100 Subject: [PATCH 01/32] M-1: a licence's key form, and an adoption whose key is taken is refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A key of the bus takes fewer characters than a licence's name; the key is the store's alone, and two names that write the same key would overwrite each other. Builds: design 39 bus-and-switch §10, B6 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/keyform.go | 41 +++++++++++ .../claude-licence-manager/keyform_test.go | 72 +++++++++++++++++++ .../cmd/claude-licence-manager/manager.go | 28 ++++++++ 3 files changed, 141 insertions(+) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/keyform.go create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform.go b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform.go new file mode 100644 index 000000000..46732bd3b --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform.go @@ -0,0 +1,41 @@ +package main + +// A licence's key form (design 39 bus-and-switch §10, B6): the name every verb and tool takes and answers, written +// in the characters a key of the bus takes. The key is the store's alone. + +import ( + "context" + "strings" +) + +// KeyFor is a licence's key: its name in lower case, each `@` written as `_at_`, and every other character +// outside letters, digits, `-` and `.` written as `_`. +func KeyFor(name string) string { + var b strings.Builder + for _, r := range strings.ToLower(name) { + switch { + case r == '@': + b.WriteString("_at_") + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '.': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + return b.String() +} + +// keyTaken names the licence other than name whose key is name's, or "" when none is. +func (m *Manager) keyTaken(ctx context.Context, name string) (string, error) { + all, err := m.Store.Licences(ctx) + if err != nil { + return "", err + } + key := KeyFor(name) + for _, l := range all { + if l.Name != name && KeyFor(l.Name) == key { + return l.Name, nil + } + } + return "", nil +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go new file mode 100644 index 000000000..29e0e36c3 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +// design 39 bus-and-switch §10, B6. +func TestALicencesKeyForm(t *testing.T) { + for name, key := range map[string]string{ + "jane@example.org": "jane_at_example.org", + "Jane.Doe@Example.ORG": "jane.doe_at_example.org", + "0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5": "0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5", + "team key_1": "team_key_1", + "a+b/c:d": "a_b_c_d", + "ops-key": "ops-key", + } { + if got := KeyFor(name); got != key { + t.Errorf("KeyFor(%q) = %q, want %q", name, got, key) + } + } +} + +// design 39 bus-and-switch §10: an adoption whose key another licence has is refused, in the audit and as an event. +func TestAnAdoptionWhoseKeyIsTakenIsRefused(t *testing.T) { + ctx := context.Background() + t.Run("an API key", func(t *testing.T) { + mm := newMesh(t) + _ = mm.store.SaveLicence(ctx, Licence{Name: "Ops-Key", Kind: "api-key"}) + _, err := mm.m.adoptKey(ctx, "ops-key", "sk-ant-"+strings.Repeat("a", 40), "a test") + if err == nil || !strings.Contains(err.Error(), "Ops-Key") { + t.Fatalf("adopted a key whose key form is taken: %v", err) + } + if l, _ := mm.store.Licence(ctx, "ops-key"); l != nil { + t.Fatal("the clashing licence was kept") + } + assertRefused(t, mm) + }) + t.Run("a login", func(t *testing.T) { + mm := newMesh(t) + _ = mm.store.SaveLicence(ctx, Licence{Name: "ACCT-1@example.org", Kind: "api-key"}) + r := mm.login("laptop", "rt-laptop", true, t0.Add(-time.Minute)) + adopted, err := mm.m.Consider(ctx, []Holdings{r}) + if err != nil || len(adopted) != 0 { + t.Fatalf("adopted %v (%v), want none", adopted, err) + } + if len(mm.vendor.exchanged) != 0 { + t.Fatalf("a refused login was exchanged: %v", mm.vendor.exchanged) + } + if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-laptop")); o != Refused { + t.Fatalf("outcome %q, want refused", o) + } + assertRefused(t, mm) + }) +} + +func assertRefused(t *testing.T, mm *miniMesh) { + t.Helper() + said := false + for _, e := range mm.events { + said = said || strings.HasPrefix(e, "licence.refused ") + } + audited := false + for _, a := range mm.store.Audits { + audited = audited || a["what"] == "refused" + } + if !said || !audited { + t.Fatalf("refusal said %v, audited %v: %v", said, audited, mm.events) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index fe8b208cd..1fa28f126 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -348,6 +348,24 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) return "", nil } + // A new licence whose key another licence already has is refused before its grant is exchanged + // (design 39 bus-and-switch §10). + if held, err := m.Store.LicenceForAccount(ctx, c.Identity.AccountUUID); err != nil { + return "", err + } else if held == nil { + taken, err := m.keyTaken(ctx, NameFor(c.Identity)) + if err != nil { + return "", err + } + if taken != "" { + why := "its key " + KeyFor(NameFor(c.Identity)) + " is the key of the licence " + taken + _ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, why) + _ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why}) + _ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": why}) + return "", nil + } + } + // Adopting is refreshing (ADR 0206 §4): the exchange is the check, and from here this module is the // only holder of a live refresh token for the account. r := m.Vendor.Refresh(ctx, offered) @@ -751,6 +769,16 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str if held != nil && held.Kind != "api-key" { return nil, fmt.Errorf("%s is a subscription; an API key needs a name of its own", name) } + taken, err := m.keyTaken(ctx, name) + if err != nil { + return nil, err + } + if taken != "" { + why := "its key " + KeyFor(name) + " is the key of the licence " + taken + _ = m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why}) + _ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why}) + return nil, fmt.Errorf("%s is refused: %s", name, why) + } l := Licence{Name: name, Kind: "api-key", Sealed: m.Crypt.Seal(key), AdoptedAt: m.Now().UnixMilli()} if err := m.Store.SaveLicence(ctx, l); err != nil { return nil, err -- 2.54.0 From 1c032997210ca51721bf48d66ba1e6be7db21bbf Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:01:43 +0100 Subject: [PATCH 02/32] M-2: the manager's manifest asks for its kept data by template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../cmd/claude-licence-manager/main.go | 22 --- .../claude-licence-manager/manifest_test.go | 50 +++++ .../files/settings.json | 8 +- modules/claude-licence-manager/module.json | 178 +++++++++++++++--- 4 files changed, 207 insertions(+), 51 deletions(-) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index 0d966153b..5ca869a5f 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -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 ( diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go new file mode 100644 index 000000000..e087078ab --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go @@ -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) + } + } +} diff --git a/modules/claude-licence-manager/files/settings.json b/modules/claude-licence-manager/files/settings.json index 5d4aed520..fc6df9b3a 100644 --- a/modules/claude-licence-manager/files/settings.json +++ b/modules/claude-licence-manager/files/settings.json @@ -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 } diff --git a/modules/claude-licence-manager/module.json b/modules/claude-licence-manager/module.json index a55da31e2..00869aaac 100644 --- a/modules/claude-licence-manager/module.json +++ b/modules/claude-licence-manager/module.json @@ -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": [ { -- 2.54.0 From e85cf002a4b8a5fd428fe22b46fb5537238029e8 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:07:44 +0100 Subject: [PATCH 03/32] V-1: the vault serves licence-put, keeping the caller's newest generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A licence's grant and access token move into the vault (ADR 0550 §3), handed in two steps with a key made for the call, as move-in does; the caller's generation is the record's, an older one is refused, and secret-changed names the secret and its generation. Builds: design 39 bus-and-switch §15, B3, B4, B5, B6 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- modules/mesh-vault/cmd/mesh-vault/intake.go | 10 ++- modules/mesh-vault/cmd/mesh-vault/licences.go | 80 +++++++++++++++++++ .../cmd/mesh-vault/licences_test.go | 58 ++++++++++++++ modules/mesh-vault/cmd/mesh-vault/tools.go | 22 +++++ modules/mesh-vault/cmd/mesh-vault/vault.go | 10 +++ modules/mesh-vault/module.json | 3 +- 6 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 modules/mesh-vault/cmd/mesh-vault/licences.go create mode 100644 modules/mesh-vault/cmd/mesh-vault/licences_test.go diff --git a/modules/mesh-vault/cmd/mesh-vault/intake.go b/modules/mesh-vault/cmd/mesh-vault/intake.go index 9b5648add..38d49d00a 100644 --- a/modules/mesh-vault/cmd/mesh-vault/intake.go +++ b/modules/mesh-vault/cmd/mesh-vault/intake.go @@ -91,10 +91,12 @@ type callKey struct { // move is what `move-in` takes: the row of the controller's store (move.go). move *MoveOf // why is what `move-in` with replace_running was asked for, in the caller's words (owntake.go). - why string - public [32]byte - private [32]byte - until time.Time + why string + // generation is the licence's generation `licence-put` keeps its value at (licences.go). + generation int + public [32]byte + private [32]byte + until time.Time } // Opened is a first step's answer: the call, and the key to seal its value to. Never a value. diff --git a/modules/mesh-vault/cmd/mesh-vault/licences.go b/modules/mesh-vault/cmd/mesh-vault/licences.go new file mode 100644 index 000000000..6eee48ee2 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/licences.go @@ -0,0 +1,80 @@ +package main + +// A licence's two secrets (design 39 bus-and-switch §15, B3 to B6): `licence..grant` and +// `licence..access`, handed to the vault by the licence manager through `licence-put` in two steps — name +// the licence, the part and the generation and get a call and a key made for it, then send the call and the value sealed +// to that key. The caller's generation is the record's; the vault keeps the newest and refuses an older one, and says +// `secret-changed` with the secret's name and generation, never the value. + +import ( + "errors" + "fmt" + "regexp" +) + +// LicencePutVerb is the verb that hands a licence's secret to the vault. +const LicencePutVerb = "licence-put" + +// The parts of a licence the vault holds (§15). +const ( + LicenceGrant = "grant" + LicenceAccess = "access" +) + +// licenceKey is a licence's key form (§10, B6): lower case, letters, digits, `-`, `.` and `_`. +var licenceKey = regexp.MustCompile(`^[a-z0-9_.-]{1,120}$`) + +// LicenceSecret is the name of one part of a licence in the vault. +func LicenceSecret(key, part string) (string, error) { + if !licenceKey.MatchString(key) { + return "", fmt.Errorf("%q is not a licence's key: lower-case letters, digits, -, . and _", key) + } + if part != LicenceGrant && part != LicenceAccess { + return "", fmt.Errorf("the part %q is not a part of a licence: grant or access", part) + } + return "licence." + key + "." + part, nil +} + +// LicencePutOpen opens a call to take one part of a licence at a generation. +func (v *Vault) LicencePutOpen(caller, licence, part string, generation int) (Opened, error) { + if err := fromAGrantedCaller(caller, LicencePutVerb); err != nil { + return Opened{}, err + } + secret, err := LicenceSecret(licence, part) + if err != nil { + return Opened{}, err + } + if generation < 1 { + return Opened{}, errors.New("licence-put takes the licence's generation, one or more; nothing is taken") + } + id, key, err := v.openCall(LicencePutVerb, secret, caller, nil) + if err != nil { + return Opened{}, err + } + v.callsMu.Lock() + if c := v.pending[id]; c != nil { + c.generation = generation + } + v.callsMu.Unlock() + return Opened{Call: id, SealTo: key, Secret: secret}, nil +} + +// LicencePutTake opens the value sealed to the call's key and keeps it at the call's generation. +func (v *Vault) LicencePutTake(caller, call, sealed string) (Changed, error) { + c, err := v.takeCall(LicencePutVerb, call) + if err != nil { + return Changed{}, err + } + if c.by != caller { + return Changed{}, fmt.Errorf("the licence-put call %q was opened by another caller; nothing was taken", call) + } + value, err := c.open(sealed) + if err != nil { + return Changed{}, err + } + defer zero(value) + if len(value) == 0 { + return Changed{}, errors.New("the value handed in is empty; nothing was taken") + } + return v.keepWith(c.secret, string(value), caller, "", OriginAccepted, keepOptions{generation: c.generation}) +} diff --git a/modules/mesh-vault/cmd/mesh-vault/licences_test.go b/modules/mesh-vault/cmd/mesh-vault/licences_test.go new file mode 100644 index 000000000..dafc5dc0c --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/licences_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "strings" + "testing" +) + +// putLicence hands one part of a licence to the vault in licence-put's two steps. +func putLicence(t *testing.T, v testVault, caller, key, part string, generation int, value string) (Changed, error) { + t.Helper() + o, err := v.LicencePutOpen(caller, key, part, generation) + if err != nil { + return Changed{}, err + } + return v.LicencePutTake(caller, o.Call, sealToCall(t, o.SealTo, value)) +} + +// design 39 bus-and-switch §15, B3, B5: the caller's generation is the record's; an older one is refused; the +// event names the secret and its generation, never the value. +func TestLicencePutKeepsTheNewestGenerationAndRefusesAnOlder(t *testing.T) { + v := newVault(t, nil) + const manager = "claude-licence-manager" + if _, err := putLicence(t, v, manager, "jane_at_example.org", LicenceGrant, 3, `{"refreshToken":"rt-3"}`); err != nil { + t.Fatal(err) + } + rec, held, err := v.Store.Read("licence.jane_at_example.org.grant") + if err != nil || !held || rec.Generation != 3 { + t.Fatalf("held %v at generation %d (%v), want generation 3", held, rec.Generation, err) + } + if _, err := putLicence(t, v, manager, "jane_at_example.org", LicenceGrant, 2, `{"refreshToken":"rt-2"}`); err == nil || + !strings.Contains(err.Error(), "not newer") { + t.Fatalf("an older generation was taken: %v", err) + } + if c, err := putLicence(t, v, manager, "jane_at_example.org", LicenceGrant, 4, `{"refreshToken":"rt-4"}`); err != nil || + c.Generation != 4 { + t.Fatalf("generation 4: %+v %v", c, err) + } + said := 0 + for _, raw := range v.bus.raw { + if strings.Contains(raw, "rt-") { + t.Fatalf("an event carries the value: %s", raw) + } + if strings.Contains(raw, `"secret-changed"`) && strings.Contains(raw, "licence.jane_at_example.org.grant") { + said++ + } + } + if said != 2 { + t.Fatalf("secret-changed said %d times, want 2: %v", said, v.bus.raw) + } + for _, bad := range []struct{ key, part string }{{"Jane@x", LicenceGrant}, {"jane", "refresh"}} { + if _, err := v.LicencePutOpen(manager, bad.key, bad.part, 1); err == nil { + t.Errorf("licence-put took %s/%s", bad.key, bad.part) + } + } + if _, err := v.LicencePutOpen("", "jane", LicenceAccess, 1); err == nil { + t.Error("licence-put took an unproven caller") + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index 7349c63d9..5e23494bf 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -208,6 +208,28 @@ func definitions(env *Env) []toolDef { } return v.OwnDrift() }}, + {Name: Seat + "." + LicencePutVerb, + Description: "Hand the vault one of a licence's two secrets (design 39 bus-and-switch §15), in two steps: name " + + "the licence by its key, the part (grant or access) and its generation, and get a call and a key made for " + + "it; then send the call and the value sealed to that key. Keeps the newest generation and refuses an older " + + "one; says secret-changed with the secret's name and generation, never the value.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "licence": str("the first step: the licence's key"), + "part": str("the first step: grant or access"), + "generation": map[string]any{"type": "number", "description": "the first step: the licence's generation"}, + "call": str("the second step: the call the first step answered"), + "sealed": str("the second step: the value sealed to the call's key (NaCl anonymous box, base64) — never the value")}}, + run: func(caller string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + if call := arg(a, "call"); call != "" { + return v.LicencePutTake(caller, call, arg(a, "sealed")) + } + g, _ := a["generation"].(float64) + return v.LicencePutOpen(caller, arg(a, "licence"), arg(a, "part"), int(g)) + }}, {Name: Seat + ".move-in", Description: "Take one value of the controller's store into the vault, as it is (design 57's V3): from the " + "node-engine of a machine it was sealed to, and no other caller, in two steps — name the row (node, " + diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index 85f694226..85c0c6ccf 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault.go @@ -150,6 +150,9 @@ type keepOptions struct { replaceOnly bool keepPrevious bool refuse func(before Record) string + // generation, when one or more, is the record's generation as the caller numbers it (licence-put, licences.go): a + // value at a generation no newer than the one held is refused. + generation int } // keepWith is keep with the move's options. A covered bus account kept anew has its hash written once it is kept @@ -234,6 +237,10 @@ func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions) return Changed{}, errors.New(why) } } + if opt.generation > 0 && opt.generation <= before.Generation { + return Changed{}, fmt.Errorf("%s is held at generation %d, and generation %d is not newer: nothing was taken", + name, before.Generation, opt.generation) + } if opt.neverReplace { return Changed{}, &HeldAnother{Name: name, Generation: before.Generation, ChangedBy: before.ChangedBy, ChangedAt: before.ChangedAt, Origin: before.Origin} @@ -266,6 +273,9 @@ func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions) } else if !opt.madeAt.IsZero() { rec.MadeAt = opt.madeAt.UTC() } + if opt.generation > 0 { + rec.Generation = opt.generation + } if opt.movedFrom != nil { moved := *opt.movedFrom moved.At = now diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index 79a24ea6e..385095d3c 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -238,7 +238,8 @@ "rotate-node", "node-account-confirm", "abandon", - "retire" + "retire", + "licence-put" ] } ], -- 2.54.0 From 4179be7a56658bd3aa07d6ee222b41b0de04c146 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:09:22 +0100 Subject: [PATCH 04/32] C-1: a node's holdings are said as holdings-changed, and again when asked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The licence manager follows the nodes' holdings by an event and asks for them with holdings.asked, instead of reading the agent module's state (ADR 0596 §2). The state holdings stays the module's own. Builds: design 39 bus-and-switch §23 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-code/config_test.go | 2 +- .../claude-code/cmd/claude-code/holdings.go | 37 +++++++++++++ .../cmd/claude-code/holdings_test.go | 52 +++++++++++++++++++ modules/claude-code/cmd/claude-code/main.go | 35 ++++++++++--- modules/claude-code/module.json | 6 ++- 5 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 modules/claude-code/cmd/claude-code/holdings.go create mode 100644 modules/claude-code/cmd/claude-code/holdings_test.go diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index 5cdbc2238..bb985c3b7 100644 --- a/modules/claude-code/cmd/claude-code/config_test.go +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -279,7 +279,7 @@ func TestTheManifestListsEveryToolServed(t *testing.T) { // the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool}) || !slices.Equal(m.Reads, []string{"claude-licence-manager.bindings"}) || - !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed"}) { + !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", HoldingsAsked}) { t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes) } if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) || diff --git a/modules/claude-code/cmd/claude-code/holdings.go b/modules/claude-code/cmd/claude-code/holdings.go new file mode 100644 index 000000000..38d036408 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/holdings.go @@ -0,0 +1,37 @@ +package main + +// What this node holds, said (design 39 bus-and-switch §23): kept in this module's own state `holdings` and said as +// the event `holdings-changed`, the holdings report whole and never a token, at start, whenever it changes, and again +// when the licence manager says `holdings.asked`. + +import "encoding/json" + +// HoldingsChanged is the event that says what a node holds. +const HoldingsChanged = "holdings-changed" + +// HoldingsAsked is the licence manager's event asking every node to say what it holds. +const HoldingsAsked = Manager + ".holdings.asked" + +// HoldingsSayer keeps and says what this node holds. +type HoldingsSayer struct { + Put func(Holdings) error + Emit func(Holdings) error + said string +} + +// Say keeps and says now when it differs from what was said last, or always when again: true when it said it. A +// failure says nothing was said, so the next call tries again. +func (s *HoldingsSayer) Say(now Holdings, again bool) (bool, error) { + raw, _ := json.Marshal(now) + if !again && string(raw) == s.said { + return false, nil + } + if err := s.Put(now); err != nil { + return false, err + } + if err := s.Emit(now); err != nil { + return false, err + } + s.said = string(raw) + return true, nil +} diff --git a/modules/claude-code/cmd/claude-code/holdings_test.go b/modules/claude-code/cmd/claude-code/holdings_test.go new file mode 100644 index 000000000..c74b0d682 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/holdings_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +// design 39 bus-and-switch §23: the holdings are said at start, on a change and when asked again, kept in the +// module's own state each time, the report whole and never a token. +func TestHoldingsAreSaidAtStartAndOnChange(t *testing.T) { + var kept, said []string + failing := false + s := &HoldingsSayer{ + Put: func(h Holdings) error { raw, _ := json.Marshal(h); kept = append(kept, string(raw)); return nil }, + Emit: func(h Holdings) error { + if failing { + return errors.New("no responders") + } + raw, _ := json.Marshal(h) + said = append(said, string(raw)) + return nil + }, + } + fp := "sha256:0123456789abcdef" + start := Holdings{Node: "laptop"} + changed := Holdings{Node: "laptop"} + changed.Refresh.Present, changed.Refresh.Fingerprint = true, &fp + for i, step := range []struct { + h Holdings + again, out bool + }{{start, false, true}, {start, false, false}, {changed, false, true}, {changed, true, true}} { + if got, err := s.Say(step.h, step.again); err != nil || got != step.out { + t.Fatalf("step %d: said %v (%v), want %v", i, got, err, step.out) + } + } + if len(said) != 3 || len(kept) != 3 { + t.Fatalf("said %d, kept %d, want 3 each", len(said), len(kept)) + } + if !strings.Contains(said[1], fp) || !strings.Contains(said[1], `"node":"laptop"`) { + t.Fatalf("the report is not said whole: %s", said[1]) + } + failing = true + if got, err := s.Say(start, false); err == nil || got { + t.Fatal("a failed event counts as said") + } + failing = false + if got, _ := s.Say(start, false); !got { + t.Fatal("what failed to be said is not said at the next call") + } +} diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 53a18a1a0..6c7580ebf 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -20,6 +20,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "time" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" @@ -567,15 +568,24 @@ func run(p Paths, view *ServerView, configView *ConfigView) { // every change of the credentials file, polled, because the file is replaced by rename and a watch on // the old inode would go quiet. Fingerprints and expiries only. holdings := stdio.State("holdings") - reported := "" - report := func() { + var sayMu sync.Mutex + sayer := &HoldingsSayer{ + Put: func(h Holdings) error { _, err := holdings.Put(p.Node, h); return err }, + Emit: func(h Holdings) error { return stdio.Emit(HoldingsChanged, h) }, + } + sayHoldings := func(again bool) { + sayMu.Lock() + defer sayMu.Unlock() now := HoldingsOf(p) - raw, _ := json.Marshal(now) - if string(raw) == reported { - return - } - persist("reporting what this node holds", func() error { _, err := holdings.Put(p.Node, now); return err }, func(int) { - reported = string(raw) + said := false + persist("reporting what this node holds", func() error { + var err error + said, err = sayer.Say(now, again) + return err + }, func(int) { + if !said { + return + } account := "no account" if now.Identity != nil && now.Identity.EmailAddress != "" { account = now.Identity.EmailAddress @@ -593,6 +603,15 @@ func run(p Paths, view *ServerView, configView *ConfigView) { say("%s", line) }) } + report := func() { sayHoldings(false) } + // The licence manager asks every node to say what it holds again (§23): after its carry-over, at its start and on + // its resync. + go persist("hearing the licence manager ask what this node holds", func() error { + return stdio.Subscribe(HoldingsAsked, func(stdio.Envelope) error { + go sayHoldings(true) + return nil + }) + }, func(n int) { say("hearing %s%s", HoldingsAsked, refusals(n)) }) // What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer // generation is fetched with the seat's `current`, sealed to this module's key. diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 7bfbd71fd..c3e46ff70 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -35,7 +35,8 @@ "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", - "mesh-catalog.seat-definition-removed" + "mesh-catalog.seat-definition-removed", + "claude-licence-manager.holdings.asked" ], "state": [ "servers", @@ -48,7 +49,8 @@ "findings" ], "emits": [ - "judged" + "judged", + "holdings-changed" ], "reads": [ "claude-licence-manager.bindings" -- 2.54.0 From f29a9ad5634653bd5f69a056b7f0bbb7c2816a5f Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:11:09 +0100 Subject: [PATCH 05/32] M-8: current answers the licence and its health, never the token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vault hands every token (ADR 0550 §3); current names the licence, its key, kind, generation, health and access expiry, and keeps the account's identity for the agent's account file. bindings takes an optional consumer, answered unpaged. Builds: design 39 bus-and-switch §18, B7, B10, B19 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/main.go | 16 +++-- .../cmd/claude-licence-manager/manager.go | 62 +++++++++++-------- .../claude-licence-manager/manager_test.go | 39 +++++++----- 3 files changed, 69 insertions(+), 48 deletions(-) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index 5ca869a5f..1bb5cfe0c 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -259,8 +259,12 @@ func tools() []stdio.Tool { return []stdio.Tool{ verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it. Never a token.", nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Licences(ctx) }), - verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given.", - nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Store.Bindings(ctx) }), + verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given; with consumer, that consumer's alone.", + map[string]any{"consumer": str("a consumer; absent for every one")}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + c, _ := a["consumer"].(string) + return m.BindingsOf(ctx, strings.TrimSpace(c)) + }), verb("bind", "Bind a consumer — a node's agent, by the node's name — to a licence. Its node fetches the licence's token at once.", map[string]any{"consumer": consumer, "licence": str("a licence, as `licences` names it")}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { @@ -334,14 +338,14 @@ func tools() []stdio.Tool { nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) { return map[string]any{"public_key": m.Keys.PublicKey}, nil }), - verb("current", "For a consumer's agent module (ADR 0206): its token, sealed to the public key it sends, with the licence, kind and generation. Null when it is bound to nothing.", - map[string]any{"consumer": consumer, "public_key": str("the consumer's public key, PEM")}, + verb("current", "For a consumer's agent module: which licence it is bound to, the licence's key, kind and generation, its health (ok, failing or expiring) and when its access token expires; never the token, which the vault hands. Null when it is bound to nothing.", + map[string]any{"consumer": consumer}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { - c, k, err := two(a, "consumer", "public_key") + c, err := text(a, "consumer") if err != nil { return nil, err } - return m.Current(ctx, c, k) + return m.Current(ctx, c) }), } } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 1fa28f126..98a960d8d 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -636,47 +636,57 @@ func (m *Manager) ReadUsage(ctx context.Context) error { // ---- the seat's verbs -------------------------------------------------------------------------------- -// Current is a consumer's token sealed to the key it sent (ADR 0206 §6): for a subscription the access -// token and its expiries only — never the refresh token, which no node holds. Nil when it is bound to +// Current is which licence a consumer is bound to and its health, never the token (design 39 bus-and-switch §18, B7, +// B19): the licence, its key (the name of its secrets in the vault), kind, generation, health and accessExpiresAt, and +// for a subscription the account's identity the agent module points its account file at. Nil when it is bound to // nothing. -func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[string]any, error) { - if !strings.Contains(publicKey, "PUBLIC KEY") { - return nil, errors.New("current seals to the consumer's public key, and none was given") - } +func (m *Manager) Current(ctx context.Context, consumer string) (map[string]any, error) { b, err := m.Store.Binding(ctx, consumer) if err != nil || b == nil { return nil, err } l, err := m.Store.Licence(ctx, b.Licence) - if err != nil || l == nil || l.Sealed == "" { + if err != nil || l == nil { return nil, err } - plain, err := m.Crypt.Open(l.Sealed) - if err != nil { - return nil, err + out := map[string]any{"licence": l.Name, "key": KeyFor(l.Name), "kind": l.Kind, "generation": b.Generation, + "health": Health(*l, m.Now(), m.Settings)} + if l.AccessExpiresAt != 0 { + out["accessExpiresAt"] = time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339) } - handed := plain - if l.Kind == "subscription" { - var g FullGrant - if err := json.Unmarshal([]byte(plain), &g); err != nil { - return nil, err - } - access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt, - "refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes, - "subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier}) - handed = string(access) - } - box, err := Seal(handed, publicKey) - if err != nil { - return nil, err - } - out := map[string]any{"licence": l.Name, "kind": l.Kind, "generation": b.Generation, "sealed": box} if l.AccountUUID != "" { out["identity"] = Identity{AccountUUID: l.AccountUUID, EmailAddress: l.Email, OrganizationUUID: l.OrganizationUUID} } return out, nil } +// Health is a licence's health (B19): `failing` once its refresh failed failures_to_notify times in a row, `expiring` +// when its refresh token expires within refresh_warn_days, else `ok`. +func Health(l Licence, now time.Time, s Settings) string { + switch { + case l.Failures >= s.FailuresToNotify: + return "failing" + case l.RefreshExpiresAt != 0 && l.RefreshExpiresAt-now.UnixMilli() < s.RefreshWarn.Milliseconds(): + return "expiring" + } + return "ok" +} + +// BindingsOf is every binding, or the one of consumer when one is named (B10): a list either way, unpaged. +func (m *Manager) BindingsOf(ctx context.Context, consumer string) ([]Binding, error) { + all, err := m.Store.Bindings(ctx) + if err != nil || consumer == "" { + return all, err + } + out := []Binding{} + for _, b := range all { + if b.Consumer == consumer { + out = append(out, b) + } + } + return out, nil +} + // Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and // recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625). func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index 4f8103fba..e8b808d7a 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -253,32 +253,39 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) { } } -func TestCurrentHandsAnAccessTokenOnlySealedToTheConsumersKey(t *testing.T) { +// design 39 bus-and-switch §18, B7, B19: no answer of current holds a token; it names the licence's key and health. +func TestCurrentHoldsNoToken(t *testing.T) { mm := newMesh(t) ctx := context.Background() _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) - node, _ := GenerateKeyPair() - answer, err := mm.m.Current(ctx, "laptop", node.PublicKey) - if err != nil || answer["kind"] != "subscription" { + answer, err := mm.m.Current(ctx, "laptop") + if err != nil || answer["kind"] != "subscription" || answer["key"] != KeyFor(licence1) || answer["health"] != "ok" || + answer["accessExpiresAt"] == nil || answer["identity"] == nil { t.Fatalf("%v %v", answer, err) } - box := answer["sealed"].(SealedBox) - plain, err := Open(box, node.PrivateKey) - if err != nil { - t.Fatal(err) + raw, _ := json.Marshal(answer) + for _, token := range []string{"rt-", "at-", "sealed"} { + if strings.Contains(string(raw), token) { + t.Fatalf("current answered %s: %s", token, raw) + } } - var handed map[string]any - _ = json.Unmarshal([]byte(plain), &handed) - if !strings.HasPrefix(handed["accessToken"].(string), "at-") || handed["refreshToken"] != nil { - t.Fatalf("handed %v", handed) + l, _ := mm.store.Licence(ctx, licence1) + for failures, health := range map[int]string{0: "ok", 3: "failing"} { + l.Failures = failures + if got := Health(*l, mm.now, mm.m.Settings); got != health { + t.Errorf("%d failures: %s, want %s", failures, got, health) + } } - other, _ := GenerateKeyPair() - if _, err := Open(box, other.PrivateKey); err == nil { - t.Fatal("the hand-over opened with another key") + l.Failures, l.RefreshExpiresAt = 0, mm.now.Add(48*time.Hour).UnixMilli() + if got := Health(*l, mm.now, mm.m.Settings); got != "expiring" { + t.Errorf("a refresh token expiring in two days: %s", got) } - if a, _ := mm.m.Current(ctx, "nobody", node.PublicKey); a != nil { + if a, _ := mm.m.Current(ctx, "nobody"); a != nil { t.Fatalf("a consumer bound to nothing was answered %v", a) } + if bs, _ := mm.m.BindingsOf(ctx, "laptop"); len(bs) != 1 || bs[0].Consumer != "laptop" { + t.Fatalf("bindings of laptop: %v", bs) + } } func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) { -- 2.54.0 From 5ca3876beacc4f900282423ba7d17e5274b7f23c Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:13:31 +0100 Subject: [PATCH 06/32] M-7: every grant and access token lives in the vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manager hands what the vendor answers to licence-put, the grant first and then the access, at the licence's next generation, and reads a grant with get for one act; it keeps no token at rest (ADR 0550 §3). The sealing at rest goes; opening stays for the carry-over. Builds: design 39 bus-and-switch §15, §16, §20, §45, B15 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/crypt.go | 15 +--- .../cmd/claude-licence-manager/main.go | 1 + .../cmd/claude-licence-manager/manager.go | 78 +++++++++++++----- .../claude-licence-manager/manager_test.go | 51 ++++++++++-- .../cmd/claude-licence-manager/pgstore.go | 11 +-- .../cmd/claude-licence-manager/store.go | 3 +- .../cmd/claude-licence-manager/store_test.go | 21 ++++- .../cmd/claude-licence-manager/vaultgrants.go | 79 +++++++++++++++++++ .../vaultgrants_test.go | 43 ++++++++++ modules/claude-licence-manager/go.mod | 2 +- 10 files changed, 255 insertions(+), 49 deletions(-) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants.go create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go b/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go index 78cc180a3..2857e5f97 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go @@ -5,7 +5,6 @@ package main // key derived from the vault's secret by SHA-256 so a secret of any length serves. import ( - "crypto/rand" "crypto/sha256" "encoding/base64" "errors" @@ -13,7 +12,7 @@ import ( "strings" ) -// Crypt seals and opens what the store keeps. +// Crypt opens what today's database keeps at rest. type Crypt struct{ key []byte } // NewCrypt is the store's cipher from the vault's secret. @@ -35,16 +34,8 @@ func CryptFromFile(path string) (*Crypt, error) { return NewCrypt(string(raw)) } -// Seal encrypts a plaintext as `v1..`. -func (c *Crypt) Seal(plaintext string) string { - gcm, _ := newGCM(c.key) - iv := make([]byte, 12) - _, _ = rand.Read(iv) - b64 := base64.StdEncoding.EncodeToString - return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil)) -} - -// Open decrypts what Seal made with the same key. +// Open decrypts a grant today's database keeps at rest, `v1..`: the carry-over's alone, since +// nothing seals a grant at rest any more (design 39 bus-and-switch §20, B15). func (c *Crypt) Open(sealed string) (string, error) { parts := strings.Split(sealed, ".") if len(parts) != 3 || parts[0] != "v1" { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index 1bb5cfe0c..4b0dd45dd 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -76,6 +76,7 @@ func manager() (*Manager, error) { Store: store, Vendor: LiveVendor(), Crypt: crypt, + Grants: VaultGrants{Keys: keys}, Keys: keys, AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) { var a GrantAnswer diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 98a960d8d..2d35d6220 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -160,7 +160,10 @@ type GrantAnswer struct { type Manager struct { Store Store Vendor Vendor - Crypt *Crypt + // Crypt opens the grants today's database keeps at rest, for the carry-over alone (design 39 bus-and-switch §12, A3). + Crypt *Crypt + // Grants is the vault, which holds every licence's grant and access token (§15, §16). + Grants Grants Keys KeyPair // AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error // is the node not answering; a nil Sealed is no login waiting. @@ -395,7 +398,7 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID, Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID} if held != nil { - l.Name, l.NotifiedAt = held.Name, held.NotifiedAt + l.Name, l.NotifiedAt, l.Generation = held.Name, held.NotifiedAt, held.Generation if l.Email == "" { l.Email = held.Email } @@ -403,7 +406,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) l.OrganizationUUID = held.OrganizationUUID } } - m.keep(&l, r.Grant) + if err := m.keep(ctx, &l, r.Grant); err != nil { + return "", err + } l.AdoptedAt = max(now, c.ChangedAt) if err := m.Store.SaveLicence(ctx, l); err != nil { return "", err @@ -454,10 +459,16 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) return l.Name, nil } -// keep stores a grant on its licence: encrypted, fingerprinted, its expiries, fresh. -func (m *Manager) keep(l *Licence, g FullGrant) { - raw, _ := json.Marshal(g) - l.Sealed = m.Crypt.Seal(string(raw)) +// keep hands a grant to the vault at the licence's next generation, the grant first and then its access (design 39 +// bus-and-switch §15), and keeps on the licence its fingerprint and expiries alone: no token once it returns. +func (m *Manager) keep(ctx context.Context, l *Licence, g FullGrant) error { + grant, _ := json.Marshal(g) + access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt, + "refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes, + "subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier}) + if err := m.handOver(ctx, l, string(grant), string(access)); err != nil { + return err + } l.RefreshFingerprint = Fingerprint(g.RefreshToken) l.AccessExpiresAt = g.ExpiresAt if g.RefreshTokenExpiresAt != nil { @@ -465,6 +476,29 @@ func (m *Manager) keep(l *Licence, g FullGrant) { } l.Failures = 0 l.RotatedAt = m.Now().UnixMilli() + return nil +} + +// handOver puts a licence's two secrets in the vault at its next generation, the grant first (§15). +func (m *Manager) handOver(ctx context.Context, l *Licence, grant, access string) error { + generation := l.Generation + 1 + for _, part := range []struct{ name, value string }{{PartGrant, grant}, {PartAccess, access}} { + if err := m.Grants.Put(ctx, KeyFor(l.Name), part.name, generation, part.value); err != nil { + return fmt.Errorf("the vault did not take the %s of %s: %w", part.name, l.Name, err) + } + } + l.Generation = generation + return nil +} + +// grantOf reads a subscription's grant from the vault for one act (§16); the caller drops it when the act ends. +func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) { + var g FullGrant + plain, err := m.Grants.Get(ctx, KeyFor(l.Name), PartGrant) + if err != nil { + return g, fmt.Errorf("the grant of %s cannot be read from the vault: %w", l.Name, err) + } + return g, json.Unmarshal([]byte(plain), &g) } // publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the @@ -488,7 +522,7 @@ func (m *Manager) publishAdvance(ctx context.Context, l Licence) error { // Due says whether a licence needs a refresh now: near expiry, or older than the cadence. func Due(l Licence, now time.Time, s Settings) bool { - if l.Kind != "subscription" || l.Sealed == "" { + if l.Kind != "subscription" || l.Generation == 0 { return false } ms := now.UnixMilli() @@ -518,22 +552,19 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri if l == nil { return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil } - if l.Kind != "subscription" || l.Sealed == "" { + if l.Kind != "subscription" || l.Generation == 0 { return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil } now := m.Now() if !force && !Due(*l, now, m.Settings) { return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil } - plain, err := m.Crypt.Open(l.Sealed) + g, err := m.grantOf(ctx, *l) if err != nil { return nil, err } - var g FullGrant - if err := json.Unmarshal([]byte(plain), &g); err != nil { - return nil, err - } r := m.Vendor.Refresh(ctx, g) + g = FullGrant{} if !r.OK { l.Failures++ m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason) @@ -544,7 +575,9 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri } return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil } - m.keep(l, r.Grant) + if err := m.keep(ctx, l, r.Grant); err != nil { + return nil, err + } if l.RefreshExpiresAt != 0 { left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24), @@ -611,16 +644,15 @@ func (m *Manager) ReadUsage(ctx context.Context) error { return err } for _, l := range all { - if l.Kind != "subscription" || l.Sealed == "" { + if l.Kind != "subscription" || l.Generation == 0 { continue } - plain, err := m.Crypt.Open(l.Sealed) + g, err := m.grantOf(ctx, l) if err != nil { return err } - var g FullGrant - _ = json.Unmarshal([]byte(plain), &g) raw, err := m.Vendor.Usage(ctx, g.AccessToken) + g = FullGrant{} if err != nil || raw == nil { continue } @@ -789,7 +821,13 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str _ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why}) return nil, fmt.Errorf("%s is refused: %s", name, why) } - l := Licence{Name: name, Kind: "api-key", Sealed: m.Crypt.Seal(key), AdoptedAt: m.Now().UnixMilli()} + l := Licence{Name: name, Kind: "api-key", AdoptedAt: m.Now().UnixMilli()} + if held != nil { + l.Generation = held.Generation + } + if err := m.handOver(ctx, &l, key, key); err != nil { + return nil, err + } if err := m.Store.SaveLicence(ctx, l); err != nil { return nil, err } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index e8b808d7a..ae6f11d9a 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -47,7 +47,42 @@ func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) { return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil } +// memGrants is the vault as the manager sees it: every licence's two secrets by name, the newest generation kept. +type memGrants struct { + mu sync.Mutex + held map[string]heldGrant + puts []string +} + +type heldGrant struct { + generation int64 + value string +} + +func (g *memGrants) Put(_ context.Context, key, part string, generation int64, value string) error { + g.mu.Lock() + defer g.mu.Unlock() + name := SecretName(key, part) + if h, ok := g.held[name]; ok && h.generation >= generation { + return fmt.Errorf("%s is held at generation %d, and generation %d is not newer", name, h.generation, generation) + } + g.held[name] = heldGrant{generation, value} + g.puts = append(g.puts, fmt.Sprintf("%s@%d", name, generation)) + return nil +} + +func (g *memGrants) Get(_ context.Context, key, part string) (string, error) { + g.mu.Lock() + defer g.mu.Unlock() + h, ok := g.held[SecretName(key, part)] + if !ok { + return "", fmt.Errorf("the vault holds no secret named %s", SecretName(key, part)) + } + return h.value, nil +} + type miniMesh struct { + grants *memGrants m *Manager store *MemoryStore vendor *stubVendor @@ -61,14 +96,15 @@ type miniMesh struct { func newMesh(t *testing.T) *miniMesh { t.Helper() - mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0} + mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0, + grants: &memGrants{held: map[string]heldGrant{}}} now := func() time.Time { return mm.now } mm.store = NewMemoryStore(now) mm.vendor = &stubVendor{live: map[string]bool{}, now: now} crypt, _ := NewCrypt("a key the vault made") mm.keys, _ = GenerateKeyPair() mm.m = &Manager{ - Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Keys: mm.keys, + Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Grants: mm.grants, Keys: mm.keys, AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) { if mm.askDown { return GrantAnswer{}, fmt.Errorf("503 no responders") @@ -304,8 +340,8 @@ func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) { } } row, _ := mm.store.Licence(ctx, licence1) - if strings.Contains(row.Sealed, "rt-") { - t.Fatal("the grant is stored in the clear") + if raw, _ := json.Marshal(row); strings.Contains(string(raw), "rt-") || strings.Contains(string(raw), "at-") { + t.Fatalf("the licence keeps a token: %s", raw) } } @@ -420,9 +456,10 @@ func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) { if err != nil || r["adopted"] != true { t.Fatalf("%v %v", r, err) } - l, _ := mm.store.Licence(ctx, "api") - if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test-0123456789abcdefghij" { - t.Fatalf("stored %q", plain) + for _, part := range []string{PartGrant, PartAccess} { + if plain, _ := mm.grants.Get(ctx, "api", part); plain != "sk-ant-api03-test-0123456789abcdefghij" { + t.Fatalf("the vault holds %q as the %s", plain, part) + } } other, _ := GenerateKeyPair() wrong, _ := Seal("sk", other.PublicKey) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go b/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go index 06e1bd1b3..7c544d6ab 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go @@ -102,7 +102,8 @@ const licenceColumns = `name, kind, coalesce(account_uuid,''), coalesce(email,'' func scanLicence(row pgx.Row) (*Licence, error) { var l Licence - err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint, + var sealed string // the grant at rest, read by the carry-over alone (design 39 bus-and-switch §12) + err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &sealed, &l.RefreshFingerprint, &l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, nil @@ -150,15 +151,15 @@ func nullableInt(v int64) any { } func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error { - _, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, sealed, refresh_fingerprint, + _, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, refresh_fingerprint, access_expires_at, refresh_expires_at, failures, notified_at, adopted_at, rotated_at) - values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) on conflict (name) do update set kind = excluded.kind, account_uuid = excluded.account_uuid, email = excluded.email, - organization_uuid = excluded.organization_uuid, sealed = excluded.sealed, refresh_fingerprint = excluded.refresh_fingerprint, + organization_uuid = excluded.organization_uuid, refresh_fingerprint = excluded.refresh_fingerprint, access_expires_at = excluded.access_expires_at, refresh_expires_at = excluded.refresh_expires_at, failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at, rotated_at = excluded.rotated_at`, - l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), nullable(l.Sealed), + l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures, nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt)) return err diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go index b21a3eaa3..5436c5d38 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go @@ -19,7 +19,6 @@ type Licence struct { AccountUUID string `json:"accountUuid,omitempty"` Email string `json:"email,omitempty"` OrganizationUUID string `json:"organizationUuid,omitempty"` - Sealed string `json:"-"` // the grant or the key, encrypted at rest RefreshFingerprint string `json:"-"` AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"` RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"` @@ -27,6 +26,8 @@ type Licence struct { NotifiedAt int64 `json:"-"` AdoptedAt int64 `json:"adoptedAt"` RotatedAt int64 `json:"rotatedAt,omitempty"` + // Generation is the generation of the licence's secrets in the vault (design 39 bus-and-switch §2, §15). + Generation int64 `json:"generation,omitempty"` } // Binding is one consumer's binding and the generation it was last given (ADR 0206). diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go index 30182eebe..4ff3b785b 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go @@ -1,6 +1,8 @@ package main import ( + "crypto/rand" + "encoding/base64" "context" "os" "strings" @@ -33,7 +35,7 @@ func TestTheStoreOnPostgres(t *testing.T) { t.Fatalf("migration %d: %v", i+1, err) } } - l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", Sealed: "v1.x.y", + l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3} if err := s.SaveLicence(ctx, l); err != nil { t.Fatal(err) @@ -85,7 +87,7 @@ func TestTheStoreOnPostgres(t *testing.T) { t.Fatal("unbind") } all, _ := s.Licences(ctx) - if len(all) != 1 || !strings.HasPrefix(all[0].Sealed, "v1.") { + if len(all) != 1 { t.Fatalf("%v", all) } } @@ -109,7 +111,7 @@ func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) { func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) { a, _ := NewCrypt("one key") b, _ := NewCrypt("another key") - sealed := a.Seal(`{"refreshToken":"rt"}`) + sealed := sealAtRest(t, a, `{"refreshToken":"rt"}`) // The plaintext's own words, not two letters a random ciphertext holds one time in about seventy. if strings.Contains(sealed, "refreshToken") { t.Fatal("stored in the clear") @@ -124,3 +126,16 @@ func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) { t.Fatal("an empty key was accepted") } } + +// sealAtRest seals a grant as today's database keeps it, `v1..`: the rows the carry-over reads. +func sealAtRest(t *testing.T, c *Crypt, plaintext string) string { + t.Helper() + gcm, err := newGCM(c.key) + if err != nil { + t.Fatal(err) + } + iv := make([]byte, 12) + _, _ = rand.Read(iv) + b64 := base64.StdEncoding.EncodeToString + return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil)) +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants.go b/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants.go new file mode 100644 index 000000000..655c06ab2 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants.go @@ -0,0 +1,79 @@ +package main + +// The vault holds every licence's grant and access token (design 39 bus-and-switch §15, §16, B4, B5): the manager hands +// what it gets to `licence-put` in two steps, with a key the vault made for the call, and reads a grant with `get`, +// sealed to its own public key, for one act. + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + + stdio "git.novox.be/novox/mesh-sdk/go" + "golang.org/x/crypto/nacl/box" +) + +// The parts of a licence the vault holds (§15). +const ( + PartGrant = "grant" + PartAccess = "access" +) + +// Grants is where a licence's two secrets are kept. +type Grants interface { + // Put hands one part of a licence, by its key, to the vault at a generation; an older one is refused. + Put(ctx context.Context, key, part string, generation int64, value string) error + // Get reads one part of a licence from the vault. + Get(ctx context.Context, key, part string) (string, error) +} + +// SecretName is the name of one part of a licence in the vault. +func SecretName(key, part string) string { return "licence." + key + "." + part } + +// VaultGrants is the vault on the bus, asked as the seat mesh-vault. +type VaultGrants struct{ Keys KeyPair } + +// Put asks licence-put's two steps. +func (v VaultGrants) Put(_ context.Context, key, part string, generation int64, value string) error { + raw, err := stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"licence": key, "part": part, "generation": generation}) + if err != nil { + return err + } + var opened struct { + Call string `json:"call"` + SealTo string `json:"seal_to"` + } + if err := json.Unmarshal(raw, &opened); err != nil { + return err + } + pub, err := base64.StdEncoding.DecodeString(opened.SealTo) + if err != nil || len(pub) != 32 || opened.Call == "" { + return fmt.Errorf("licence-put answered no call and key to seal to: %s", raw) + } + var to [32]byte + copy(to[:], pub) + sealed, err := box.SealAnonymous(nil, []byte(value), &to, rand.Reader) + if err != nil { + return err + } + _, err = stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"call": opened.Call, + "sealed": base64.StdEncoding.EncodeToString(sealed)}) + return err +} + +// Get asks get, sealed to the manager's public key, and opens the answer. +func (v VaultGrants) Get(_ context.Context, key, part string) (string, error) { + raw, err := stdio.Ask("seat:mesh-vault.get", map[string]any{"name": SecretName(key, part), "public_key": v.Keys.PublicKey}) + if err != nil { + return "", err + } + var a struct { + Sealed SealedBox `json:"sealed"` + } + if err := json.Unmarshal(raw, &a); err != nil { + return "", err + } + return Open(a.Sealed, v.Keys.PrivateKey) +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants_test.go new file mode 100644 index 000000000..2205ba5a2 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +// design 39 bus-and-switch §15, §16, §20: a rotation reads the grant from the vault and hands it back, the grant first +// and then the access, at the next generation; the licence keeps no token once the call returns. +func TestARotationPutsTwiceAndKeepsNoToken(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + if _, err := mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}); err != nil { + t.Fatal(err) + } + key := KeyFor(licence1) + mm.grants.puts = nil + if r, err := mm.m.Rotate(ctx, licence1, true); err != nil || r["refreshed"] != true { + t.Fatalf("%v %v", r, err) + } + want := []string{SecretName(key, PartGrant) + "@2", SecretName(key, PartAccess) + "@2"} + if strings.Join(mm.grants.puts, " ") != strings.Join(want, " ") { + t.Fatalf("the rotation put %v, want %v", mm.grants.puts, want) + } + if g, _ := mm.grants.Get(ctx, key, PartGrant); !strings.Contains(g, `"refreshToken":"rt-2"`) { + t.Fatalf("the vault holds the grant %s", g) + } + if a, _ := mm.grants.Get(ctx, key, PartAccess); !strings.Contains(a, `"accessToken":"at-2"`) || strings.Contains(a, "refreshToken\":\"rt") { + t.Fatalf("the vault holds the access %s", a) + } + l, _ := mm.store.Licence(ctx, licence1) + raw, _ := json.Marshal(l) + for _, token := range []string{"rt-", "at-"} { + if strings.Contains(string(raw), token) { + t.Fatalf("the licence keeps %s: %s", token, raw) + } + } + if l.Generation != 2 { + t.Fatalf("the licence's generation is %d, want 2", l.Generation) + } +} diff --git a/modules/claude-licence-manager/go.mod b/modules/claude-licence-manager/go.mod index 8d928ca73..4b74e0437 100644 --- a/modules/claude-licence-manager/go.mod +++ b/modules/claude-licence-manager/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/jackc/pgx/v5 v5.11.0 + golang.org/x/crypto v0.57.0 ) require ( @@ -15,7 +16,6 @@ require ( github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect - golang.org/x/crypto v0.57.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect -- 2.54.0 From 15094d6e724579cbd32a3827a654ddb3ab5ee4d1 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:16:25 +0100 Subject: [PATCH 07/32] M-3: licences and bindings kept in the bus's own storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state licences holds one key per licence by its key form, never a secret, with its latest reading beside it; the state bindings, the same local name the consumers read, holds each consumer's licence, kind, generation, since, why and by, written by the store itself. Each consumer counts its own generations, past what its node reported. Builds: design 39 bus-and-switch §2, §3, B11, B22 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/busstore.go | 180 ++++++++++++++++++ .../claude-licence-manager/busstore_test.go | 178 +++++++++++++++++ .../claude-licence-manager/keyform_test.go | 2 +- .../cmd/claude-licence-manager/main.go | 31 +-- .../cmd/claude-licence-manager/manager.go | 115 +++++------ .../claude-licence-manager/manager_test.go | 77 ++++---- .../cmd/claude-licence-manager/store.go | 174 +++++------------ .../cmd/claude-licence-manager/store_test.go | 2 +- 8 files changed, 520 insertions(+), 239 deletions(-) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go new file mode 100644 index 000000000..079592c88 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go @@ -0,0 +1,180 @@ +package main + +// The manager's kept data on the bus (design 39 bus-and-switch §2, §3, B11, B22): the state `licences`, one key per +// licence by its key form (§10), and the state `bindings`, one key per consumer, its value the form the consumers +// have always read. Written by the manager alone. + +import ( + "context" + "encoding/json" + "errors" + "sort" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// keptState is a state this module keeps, as mesh-sdk's KeptState answers it. +type keptState interface { + Get(key string) (*stdio.StateEntry, error) + Put(key string, value any) (uint64, error) + PutTTL(key string, value any, ttl time.Duration) (uint64, error) + Create(key string, value any) (uint64, error) + CreateTTL(key string, value any, ttl time.Duration) (uint64, error) + Delete(key string) error + Keys() ([]string, error) + History(key string) ([]stdio.StateRevision, error) +} + +// BindingState is the value of a consumer's key in `bindings` (§3, B22: the form 0 its readers decode): no secret, +// only what it should hold, which generation, since when, why and by whom. +type BindingState struct { + Licence string `json:"licence"` + Kind string `json:"kind"` + Generation int64 `json:"generation"` + Since string `json:"since,omitempty"` + Why string `json:"why,omitempty"` + By string `json:"by,omitempty"` +} + +// BusStore keeps the manager's data in the bus's own storage. +type BusStore struct { + LicencesState keptState + BindingsState keptState + Now func() time.Time + rest +} + +func (s *BusStore) get(st keptState, key string, into any) (bool, error) { + e, err := st.Get(key) + if err != nil || e == nil || len(e.Value) == 0 || string(e.Value) == "null" { + return false, err + } + return true, json.Unmarshal(e.Value, into) +} + +func (s *BusStore) Licences(context.Context) ([]Licence, error) { + keys, err := s.LicencesState.Keys() + if err != nil { + return nil, err + } + out := []Licence{} + for _, k := range keys { + var l Licence + if ok, err := s.get(s.LicencesState, k, &l); err != nil { + return nil, err + } else if ok { + out = append(out, l) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +func (s *BusStore) Licence(_ context.Context, name string) (*Licence, error) { + var l Licence + ok, err := s.get(s.LicencesState, KeyFor(name), &l) + if err != nil || !ok || l.Name != name { + return nil, err + } + return &l, nil +} + +func (s *BusStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) { + all, err := s.Licences(ctx) + if err != nil { + return nil, err + } + for _, l := range all { + if l.AccountUUID == account { + return &l, nil + } + } + return nil, nil +} + +func (s *BusStore) SaveLicence(_ context.Context, l Licence) error { + if l.Name == "" { + return errors.New("a licence has a name") + } + _, err := s.LicencesState.Put(KeyFor(l.Name), l) + return err +} + +func (s *BusStore) Bindings(context.Context) ([]Binding, error) { + keys, err := s.BindingsState.Keys() + if err != nil { + return nil, err + } + out := []Binding{} + for _, k := range keys { + if b, err := s.binding(k); err != nil { + return nil, err + } else if b != nil { + out = append(out, *b) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer }) + return out, nil +} + +func (s *BusStore) binding(consumer string) (*Binding, error) { + var v BindingState + ok, err := s.get(s.BindingsState, consumer, &v) + if err != nil || !ok { + return nil, err + } + return &Binding{Consumer: consumer, Licence: v.Licence, Kind: v.Kind, Generation: v.Generation, Since: v.Since, + Why: v.Why, By: v.By}, nil +} + +func (s *BusStore) Binding(_ context.Context, consumer string) (*Binding, error) { + return s.binding(consumer) +} + +// Bind writes a consumer's key at its next generation: one more than the higher of its own and the floor (B11). +func (s *BusStore) Bind(_ context.Context, consumer, licence string, m Move) (Binding, error) { + before, err := s.binding(consumer) + if err != nil { + return Binding{}, err + } + generation := m.floor(consumer) + if before != nil { + generation = max(generation, before.Generation) + } + b := Binding{Consumer: consumer, Licence: licence, Kind: m.Kind, Generation: generation + 1, + Since: s.Now().UTC().Format(time.RFC3339), Why: m.Why, By: m.By} + _, err = s.BindingsState.Put(consumer, BindingState{Licence: b.Licence, Kind: b.Kind, Generation: b.Generation, + Since: b.Since, Why: b.Why, By: b.By}) + return b, err +} + +func (s *BusStore) Unbind(_ context.Context, consumer string) (bool, error) { + before, err := s.binding(consumer) + if err != nil || before == nil { + return false, err + } + return true, s.BindingsState.Delete(consumer) +} + +// Advance gives every consumer bound to licence its next generation, keeping its licence and since. +func (s *BusStore) Advance(ctx context.Context, licence string, m Move) ([]Binding, error) { + all, err := s.Bindings(ctx) + if err != nil { + return nil, err + } + var out []Binding + for _, b := range all { + if b.Licence != licence { + continue + } + b.Generation = max(b.Generation, m.floor(b.Consumer)) + 1 + b.Kind, b.Why, b.By = m.Kind, m.Why, m.By + if _, err := s.BindingsState.Put(b.Consumer, BindingState{Licence: b.Licence, Kind: b.Kind, + Generation: b.Generation, Since: b.Since, Why: b.Why, By: b.By}); err != nil { + return out, err + } + out = append(out, b) + } + return out, nil +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go new file mode 100644 index 000000000..b6875fb61 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "sort" + "strings" + "sync" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// memState is a state of the bus as the runtime answers it, in memory: values, their past values, keys' lifetimes. +type memState struct { + mu sync.Mutex + now func() time.Time + values map[string]json.RawMessage + until map[string]time.Time + history map[string][]stdio.StateRevision + rev uint64 + writes int +} + +func newMemState(now func() time.Time) *memState { + return &memState{now: now, values: map[string]json.RawMessage{}, until: map[string]time.Time{}, + history: map[string][]stdio.StateRevision{}} +} + +func (s *memState) live(key string) bool { + if u, ok := s.until[key]; ok && !s.now().Before(u) { + delete(s.values, key) + delete(s.until, key) + } + _, ok := s.values[key] + return ok +} + +func (s *memState) Get(key string) (*stdio.StateEntry, error) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.live(key) { + return nil, nil + } + return &stdio.StateEntry{Key: key, Value: s.values[key]}, nil +} + +func (s *memState) write(key string, value any, ttl time.Duration, create bool) (uint64, error) { + s.mu.Lock() + defer s.mu.Unlock() + if create && s.live(key) { + return 0, errors.New("the key has a value") + } + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + s.rev++ + s.writes++ + s.values[key] = raw + delete(s.until, key) + if ttl > 0 { + s.until[key] = s.now().Add(ttl) + } + s.history[key] = append(s.history[key], stdio.StateRevision{Key: key, Value: raw, Revision: s.rev, Op: "put", At: s.now()}) + return s.rev, nil +} + +func (s *memState) Put(key string, value any) (uint64, error) { return s.write(key, value, 0, false) } +func (s *memState) PutTTL(key string, value any, ttl time.Duration) (uint64, error) { + return s.write(key, value, ttl, false) +} +func (s *memState) Create(key string, value any) (uint64, error) { return s.write(key, value, 0, true) } +func (s *memState) CreateTTL(key string, value any, ttl time.Duration) (uint64, error) { + return s.write(key, value, ttl, true) +} + +func (s *memState) Delete(key string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.rev++ + s.writes++ + delete(s.values, key) + delete(s.until, key) + s.history[key] = append(s.history[key], stdio.StateRevision{Key: key, Revision: s.rev, Op: "delete", At: s.now()}) + return nil +} + +func (s *memState) Keys() ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := []string{} + for k := range s.values { + if s.live(k) { + out = append(out, k) + } + } + sort.Strings(out) + return out, nil +} + +func (s *memState) History(key string) ([]stdio.StateRevision, error) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]stdio.StateRevision(nil), s.history[key]...), nil +} + +// design 39 bus-and-switch §2: a rotation and a reading each write the licence's key, by its key form, and no field +// holds a token. +func TestARotationAndAReadingWriteTheLicenceKey(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + key := KeyFor(licence1) + before := len(mm.licences.history[key]) + if _, err := mm.m.Rotate(ctx, licence1, true); err != nil { + t.Fatal(err) + } + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatal(err) + } + if got := len(mm.licences.history[key]); got < before+2 { + t.Fatalf("the key %s was written %d times by a rotation and a reading, want 2", key, got-before) + } + for _, r := range mm.licences.history[key] { + for _, token := range []string{"rt-", "at-"} { + if strings.Contains(string(r.Value), token) { + t.Fatalf("a value of %s holds %s: %s", key, token, r.Value) + } + } + } + var l Licence + _ = json.Unmarshal(mm.licences.values[key], &l) + if l.Name != licence1 || l.AccountUUID != "acct-1" || !strings.Contains(string(mm.licences.values[key]), `"account":"acct-1"`) { + t.Fatalf("the key holds %+v", l) + } +} + +// design 39 bus-and-switch §3, B11: bind, switch, release and a rotation each write or delete the consumer's key with +// its why and by; each consumer counts its own generations, past what its node reported. +func TestEachMoveWritesTheBindingWithWhyAndBy(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + _ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1}) + mm.m.Reported = func(node string) int64 { + if node == "server" { + return 40 + } + return 0 + } + asked := WithCause(ctx, Cause{Why: "the switch verb", By: "jane"}) + if _, err := mm.m.Bind(asked, "server", "api"); err != nil { + t.Fatal(err) + } + if b := mm.bound("server"); b.Licence != "api" || b.Generation != 41 || b.Why != "the switch verb" || b.By != "jane" || + b.Since == "" || b.Kind != "api-key" { + t.Fatalf("server's binding %+v", b) + } + laptop := mm.bound("laptop").Generation + if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil { + t.Fatal(err) + } + if b := mm.bound("laptop"); b.Generation != laptop+1 || b.Licence != "api" { + t.Fatalf("laptop's switch %+v, from generation %d", b, laptop) + } + if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "jane"}), "server"); err != nil { + t.Fatal(err) + } + if _, ok := mm.bindings.values["server"]; ok { + t.Fatal("release left the key") + } + if h, _ := mm.bindings.History("server"); len(h) != 2 || h[1].Op != "delete" { + t.Fatalf("server's history %v", h) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go index 29e0e36c3..573ff76d9 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go @@ -63,7 +63,7 @@ func assertRefused(t *testing.T, mm *miniMesh) { said = said || strings.HasPrefix(e, "licence.refused ") } audited := false - for _, a := range mm.store.Audits { + for _, a := range mm.mem.Audits { audited = audited || a["what"] == "refused" } if !said || !audited { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index 4b0dd45dd..49402da9b 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -71,9 +71,9 @@ func manager() (*Manager, error) { if n := os.Getenv("MESH_NODE"); n != "" { node = n } - bindings := stdio.State("bindings") built = &Manager{ - Store: store, + Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"), Now: time.Now, + rest: store}, Vendor: LiveVendor(), Crypt: crypt, Grants: VaultGrants{Keys: keys}, @@ -86,16 +86,16 @@ func manager() (*Manager, error) { } return a, json.Unmarshal(raw, &a) }, - PutBinding: func(_ context.Context, consumer string, b BindingState) error { - _, err := bindings.Put(consumer, b) - return err + Reported: func(node string) int64 { + heardMu.Lock() + defer heardMu.Unlock() + return heard[node].Generation }, - DeleteBinding: func(_ context.Context, consumer string) error { return bindings.Delete(consumer) }, - Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, - Now: time.Now, - Log: say, - Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), - Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")), + Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, + Now: time.Now, + Log: say, + Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), + Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")), } return built, nil } @@ -127,9 +127,14 @@ func keypair(dir string) (KeyPair, error) { // ---- the long-running half --------------------------------------------------------------------------- +// heard is what every node last reported holding (ADR 0206), by node. +var ( + heardMu sync.Mutex + heard = map[string]Holdings{} +) + func daemon() { - var mu sync.Mutex - reports := map[string]Holdings{} + mu, reports := &heardMu, heard var passing sync.Mutex pass := func() { passing.Lock() // one pass at a time: each account is leased, and a pass is cheap diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 2d35d6220..8bf20922d 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -53,17 +53,6 @@ type Holdings struct { Generation int64 `json:"generation"` } -// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which -// generation. -type BindingState struct { - Licence string `json:"licence"` - Kind string `json:"kind"` - Generation int64 `json:"generation"` - // Why and By are what moved it and who asked (novox/hq issue 625), so the node can say so when it applies it. - Why string `json:"why,omitempty"` - By string `json:"by,omitempty"` -} - // Cause is why a consumer's binding changed and who or what asked for it (novox/hq issue 625): Why is the verb // called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself. type Cause struct { @@ -168,12 +157,11 @@ type Manager struct { // AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error // is the node not answering; a nil Sealed is no login waiting. AskGrant func(ctx context.Context, node, publicKey string) (GrantAnswer, error) - // PutBinding and DeleteBinding are this module's `bindings` state. - PutBinding func(ctx context.Context, consumer string, b BindingState) error - DeleteBinding func(ctx context.Context, consumer string) error - Emit func(event string, body map[string]any) error - Now func() time.Time - Log func(format string, args ...any) + // Reported is the binding generation a node last reported applying (B11), 0 when none is known. + Reported func(node string) int64 + Emit func(event string, body map[string]any) error + Now func() time.Time + Log func(format string, args ...any) // Holder names this process in a lease, so a second run is told apart. Holder string Settings Settings @@ -242,17 +230,9 @@ func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[str // newest first; the first that refreshes is adopted and the rest are settled as skipped without being // exchanged. Answers what was adopted. func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) { - // **Never a generation a node has already passed** (novox/hq issue 243). A store rebuilt after a loss - // counts from one again, while every node still holds the number it last applied. The nodes no longer - // wait for a higher number, but a binding numbered exactly as the one a node applied would still be - // skipped. So the count is moved past every number reported, before anything here binds. - var highest int64 - for _, r := range reports { - highest = max(highest, r.Generation) - } - if err := m.Store.GenerationsAbove(ctx, highest); err != nil { - return nil, err - } + // **Never a generation a node has already passed** (novox/hq issue 243, B11): each consumer's count is its own, + // and moves past the number its node reported applying. + floor := m.floorIn(reports) byAccount, err := m.CandidatesIn(ctx, reports) if err != nil { return nil, err @@ -265,7 +245,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e // A node reporting an account the manager already holds, and bound to nothing, is bound to it // (ADR 0206 §7) — whenever its report arrives, not only when the licence is adopted: a node whose own // login is older than the one adopted is never a candidate, and would otherwise never be bound. - if err := m.bindReporters(ctx, reports); err != nil { + if err := m.bindReporters(ctx, reports, floor); err != nil { return nil, err } var adopted []string @@ -277,7 +257,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e continue } for i, c := range list { - name, err := m.adoptOne(ctx, c, reports) + name, err := m.adoptOne(ctx, c, reports, floor) if err != nil { m.Log("adopting %s's login failed: %v", c.Node, err) continue @@ -296,7 +276,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e } // bindReporters binds each reporting node that is bound to nothing to the licence its account already has. -func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error { +func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor func(string) int64) error { for _, rep := range reports { if rep.Identity == nil || rep.Identity.AccountUUID == "" { continue @@ -314,21 +294,17 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error { if l == nil { continue } - b, err := m.Store.Bind(ctx, rep.Node, l.Name) + c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself} + b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor}) if err != nil { return err } - c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself} m.moved(ctx, rep.Node, "", l.Name, b.Generation, c) - if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation, - Why: c.Why, By: c.By}); err != nil { - return err - } } return nil } -func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) (string, error) { +func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, floor func(string) int64) (string, error) { answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey) if err != nil { // Not answering is not an answer: asked again on the next pass. @@ -429,7 +405,7 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) if b, err := m.Store.Binding(ctx, c.Node); err != nil { return "", err } else if b == nil || b.Licence != l.Name { - moved, err := m.Store.Bind(ctx, c.Node, l.Name) + moved, err := m.Store.Bind(ctx, c.Node, l.Name, Move{Kind: l.Kind, Why: cause.Why, By: cause.By, Floor: floor}) if err != nil { return "", err } @@ -446,12 +422,12 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil { continue } - if b, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil { - m.moved(ctx, rep.Node, "", l.Name, b.Generation, - Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself}) + bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself} + if b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: bc.Why, By: bc.By, Floor: floor}); err == nil { + m.moved(ctx, rep.Node, "", l.Name, b.Generation, bc) } } - if err := m.publishAdvance(ctx, l); err != nil { + if err := m.publishAdvance(ctx, l, floor); err != nil { return "", err } _ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil}) @@ -501,21 +477,30 @@ func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) { return g, json.Unmarshal([]byte(plain), &g) } -// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the -// cause ctx carries, so the node says why it fetched again (novox/hq issue 625). -func (m *Manager) publishAdvance(ctx context.Context, l Licence) error { - bindings, err := m.Store.Advance(ctx, l.Name) - if err != nil { - return err - } +// publishAdvance gives every consumer of a licence a new generation, with the cause ctx carries, so the node says why +// it fetched again (novox/hq issue 625). +func (m *Manager) publishAdvance(ctx context.Context, l Licence, floor func(string) int64) error { c := CauseOf(ctx) - for _, b := range bindings { - if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation, - Why: c.Why, By: c.By}); err != nil { - return err + _, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor}) + return err +} + +// floorIn is the floor of each consumer's generation (B11): the higher of what its node reported in reports and what +// Reported says it last applied. A worker's node is the part of its name before the first dot (ADR 0183 §19). +func (m *Manager) floorIn(reports []Holdings) func(string) int64 { + return func(consumer string) int64 { + node, _, _ := strings.Cut(consumer, ".") + var f int64 + for _, r := range reports { + if r.Node == node { + f = max(f, r.Generation) + } } + if m.Reported != nil { + f = max(f, m.Reported(node)) + } + return f } - return nil } // ---- keeping grants alive ---------------------------------------------------------------------------- @@ -599,7 +584,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name, time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By), map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By}) - if err := m.publishAdvance(ctx, *l); err != nil { + if err := m.publishAdvance(ctx, *l, m.floorIn(nil)); err != nil { return nil, err } return map[string]any{"licence": name, "refreshed": true, "expiresAt": time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)}, nil @@ -660,6 +645,11 @@ func (m *Manager) ReadUsage(ctx context.Context) error { if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil { return err } + // The latest reading beside its licence, so licences answers from one key (§2). + l.Usage = &reading + if err := m.Store.SaveLicence(ctx, l); err != nil { + return err + } _ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct, "weeklyPct": reading.WeeklyPct, "sonnetPct": reading.SonnetPct}) } @@ -733,7 +723,8 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin if err != nil { return nil, err } - b, err := m.Store.Bind(ctx, consumer, licence) + c := CauseOf(ctx) + b, err := m.Store.Bind(ctx, consumer, licence, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: m.floorIn(nil)}) if err != nil { return nil, err } @@ -741,12 +732,7 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin if before != nil { from = before.Licence } - c := CauseOf(ctx) m.moved(ctx, consumer, from, licence, b.Generation, c) - if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation, - Why: c.Why, By: c.By}); err != nil { - return nil, err - } return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil } @@ -759,9 +745,6 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any, if ok, err := m.Store.Unbind(ctx, consumer); err != nil || !ok { return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err } - if err := m.DeleteBinding(ctx, consumer); err != nil { - return nil, err - } c := CauseOf(ctx) m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By), map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}) @@ -835,7 +818,7 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str _ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By}) m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By) ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By}) - if err := m.publishAdvance(ctx, l); err != nil { + if err := m.publishAdvance(ctx, l, m.floorIn(nil)); err != nil { return nil, err } _ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": from, "replaced": held != nil}) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index ae6f11d9a..a9be95903 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -82,12 +82,14 @@ func (g *memGrants) Get(_ context.Context, key, part string) (string, error) { } type miniMesh struct { - grants *memGrants - m *Manager - store *MemoryStore + grants *memGrants + m *Manager + store *BusStore + mem *MemoryStore + licences *memState + bindings *memState vendor *stubVendor logins map[string]FullGrant // node → what its credentials file holds - state map[string]BindingState events []string now time.Time keys KeyPair @@ -96,10 +98,12 @@ type miniMesh struct { func newMesh(t *testing.T) *miniMesh { t.Helper() - mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0, + mm := &miniMesh{logins: map[string]FullGrant{}, now: t0, grants: &memGrants{held: map[string]heldGrant{}}} now := func() time.Time { return mm.now } - mm.store = NewMemoryStore(now) + mm.mem = NewMemoryStore(now) + mm.licences, mm.bindings = newMemState(now), newMemState(now) + mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, Now: now, rest: mm.mem} mm.vendor = &stubVendor{live: map[string]bool{}, now: now} crypt, _ := NewCrypt("a key the vault made") mm.keys, _ = GenerateKeyPair() @@ -117,8 +121,6 @@ func newMesh(t *testing.T) *miniMesh { box, err := Seal(string(raw), publicKey) return GrantAnswer{Sealed: &box, Fingerprint: Fingerprint(g.RefreshToken)}, err }, - PutBinding: func(_ context.Context, c string, b BindingState) error { mm.state[c] = b; return nil }, - DeleteBinding: func(_ context.Context, c string) error { delete(mm.state, c); return nil }, Emit: func(event string, body map[string]any) error { raw, _ := json.Marshal(body) mm.events = append(mm.events, event+" "+string(raw)) @@ -129,6 +131,15 @@ func newMesh(t *testing.T) *miniMesh { return mm } +// bound is a consumer's value in the state bindings, as its readers decode it. +func (mm *miniMesh) bound(consumer string) BindingState { + var b BindingState + if e, _ := mm.bindings.Get(consumer); e != nil { + _ = json.Unmarshal(e.Value, &b) + } + return b +} + func (mm *miniMesh) report(node, rt string, at time.Time, account string) Holdings { h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: account + "@example.org"}, Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)} @@ -165,8 +176,8 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges } // A first binding follows the login: both nodes reported this account and were bound to nothing. bs, _ := mm.store.Bindings(ctx) - if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 { - t.Fatalf("bindings %v, state %v", bs, mm.state) + if len(bs) != 2 || mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 { + t.Fatalf("bindings %v, state %v", bs, mm.bindings.values) } // The adoption is said, and so is each binding it made (novox/hq issue 625). events := strings.Join(mm.events, "|") @@ -264,9 +275,9 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) { mm := newMesh(t) ctx := context.Background() _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) - g0 := mm.state["laptop"].Generation + g0 := mm.bound("laptop").Generation _, _ = mm.m.Rotate(ctx, licence1, true) - g1 := mm.state["laptop"].Generation + g1 := mm.bound("laptop").Generation if g1 <= g0 { t.Fatalf("a rotation went from %d to %d", g0, g1) } @@ -278,13 +289,13 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) { if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil { t.Fatal(err) } - if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 { - t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"]) + if mm.bound("laptop").Licence != "api" || mm.bound("laptop").Generation <= g1 { + t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.bound("laptop")) } if _, err := mm.m.Release(ctx, "laptop"); err != nil { t.Fatal(err) } - if _, ok := mm.state["laptop"]; ok { + if _, ok := mm.bindings.values["laptop"]; ok { t.Fatal("a released consumer still has a binding in the state") } } @@ -333,7 +344,7 @@ func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) { _ = mm.m.ReadUsage(ctx) ls, _ := mm.m.Licences(ctx) bs, _ := mm.store.Bindings(ctx) - everything, _ := json.Marshal([]any{mm.events, mm.state, ls, bs}) + everything, _ := json.Marshal([]any{mm.events, mm.bindings.values, mm.licences.values, ls, bs}) for _, token := range []string{"rt-secret", "rt-1", "rt-2", "at-1", "at-2", "local-"} { if strings.Contains(string(everything), token) { t.Fatalf("%s was published: %s", token, everything) @@ -393,15 +404,15 @@ func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) { _, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)}) late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour)) _, _ = mm.m.Consider(ctx, []Holdings{late}) - if mm.state["laptop"].Licence != licence1 { - t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.state) + if mm.bound("laptop").Licence != licence1 { + t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.bindings.values) } if strings.Contains(strings.Join(mm.vendor.exchanged, ","), "rt-older") { t.Fatal("the older login was exchanged") } - g := mm.state["laptop"].Generation + g := mm.bound("laptop").Generation _, _ = mm.m.Consider(ctx, []Holdings{late}) - if mm.state["laptop"].Generation != g { + if mm.bound("laptop").Generation != g { t.Fatal("a node already bound was bound again") } } @@ -411,8 +422,8 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) { mm := newMesh(t) ctx := context.Background() _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.report("server", "", t0, "acct-1")}) - if mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 { - t.Fatalf("%v", mm.state) + if mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 { + t.Fatalf("%v", mm.bindings.values) } mm.now = t0.Add(time.Hour) mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-b", ExpiresAt: 1} @@ -422,11 +433,11 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) { if len(adopted) != 1 || adopted[0] != "acct-2@example.org" { t.Fatalf("adopted %v", adopted) } - if mm.state["laptop"].Licence != "acct-2@example.org" { - t.Fatalf("the node a login was made on stayed bound to %v", mm.state["laptop"]) + if mm.bound("laptop").Licence != "acct-2@example.org" { + t.Fatalf("the node a login was made on stayed bound to %v", mm.bound("laptop")) } - if mm.state["server"].Licence != licence1 { - t.Fatalf("another node moved: %v", mm.state["server"]) + if mm.bound("server").Licence != licence1 { + t.Fatalf("another node moved: %v", mm.bound("server")) } ls, _ := mm.store.Licences(ctx) if len(ls) != 2 { @@ -439,11 +450,11 @@ func TestALoginThatDoesNotRefreshMovesNothing(t *testing.T) { mm := newMesh(t) ctx := context.Background() _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) - g := mm.state["laptop"] + g := mm.bound("laptop") mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-dead", ExpiresAt: 1} _, _ = mm.m.Consider(ctx, []Holdings{mm.report("laptop", "rt-dead", t0.Add(time.Hour), "acct-2")}) - if mm.state["laptop"] != g { - t.Fatalf("a dead login moved its node: %v", mm.state["laptop"]) + if mm.bound("laptop") != g { + t.Fatalf("a dead login moved its node: %v", mm.bound("laptop")) } } @@ -483,7 +494,7 @@ func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) { if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil { t.Fatal(err) } - if g := mm.state["laptop"].Generation; g <= 16 { + if g := mm.bound("laptop").Generation; g <= 16 { t.Fatalf("the laptop applied generation 16 and was given %d", g) } } @@ -512,7 +523,7 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) { mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) } ctx := context.Background() _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) - if s := mm.state["laptop"]; !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself { + if s := mm.bound("laptop"); !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself { t.Fatalf("a login's binding said %+v", s) } file := filepath.Join(t.TempDir(), "key") @@ -525,7 +536,7 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) { if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil { t.Fatal(err) } - if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" { + if s := mm.bound("laptop"); s.Why != "the switch verb" || s.By != "g14.node-tools" { t.Fatalf("the node is told %+v", s) } said := strings.Join(lines, "\n") @@ -544,7 +555,7 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) { t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events) } } - last := mm.store.Audits[len(mm.store.Audits)-1] + last := mm.mem.Audits[len(mm.mem.Audits)-1] if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") { t.Fatalf("the audit kept %s", raw) } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go index 5436c5d38..be18e0f78 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go @@ -7,34 +7,57 @@ package main import ( "context" - "sort" "sync" "time" ) -// Licence is one licence: an account, or an API key. +// Licence is one licence: an account, or an API key. Its form is the value of the state `licences` (design 39 +// bus-and-switch §2): never a secret, which the vault holds (§15). type Licence struct { Name string `json:"name"` Kind string `json:"kind"` // subscription | api-key - AccountUUID string `json:"accountUuid,omitempty"` - Email string `json:"email,omitempty"` - OrganizationUUID string `json:"organizationUuid,omitempty"` - RefreshFingerprint string `json:"-"` + AccountUUID string `json:"account,omitempty"` + Email string `json:"address,omitempty"` + OrganizationUUID string `json:"organisation,omitempty"` + RefreshFingerprint string `json:"refreshFingerprint,omitempty"` AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"` RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"` Failures int `json:"failures"` - NotifiedAt int64 `json:"-"` + NotifiedAt int64 `json:"notifiedAt,omitempty"` AdoptedAt int64 `json:"adoptedAt"` + AdoptedFrom string `json:"adoptedFrom,omitempty"` RotatedAt int64 `json:"rotatedAt,omitempty"` - // Generation is the generation of the licence's secrets in the vault (design 39 bus-and-switch §2, §15). + // Generation is the generation of the licence's secrets in the vault (§2, §15). Generation int64 `json:"generation,omitempty"` + // Usage is the latest reading of the licence's usage (§2, §24). + Usage *UsageReading `json:"usage,omitempty"` } -// Binding is one consumer's binding and the generation it was last given (ADR 0206). +// Binding is one consumer's binding (ADR 0206, §3): the licence, kind and generation it should hold, since when, and +// why and by whom it was moved last. type Binding struct { Consumer string `json:"consumer"` Licence string `json:"licence"` + Kind string `json:"kind,omitempty"` Generation int64 `json:"generation"` + Since string `json:"since,omitempty"` + Why string `json:"why,omitempty"` + By string `json:"by,omitempty"` +} + +// Move is what moves a binding: the licence's kind, why and by whom, and the floor a consumer's generation must pass +// (B11: the generation its node last reported); a nil Floor is none. +type Move struct { + Kind, Why, By string + Floor func(consumer string) int64 +} + +// floor is the floor m gives consumer. +func (m Move) floor(consumer string) int64 { + if m.Floor == nil { + return 0 + } + return m.Floor(consumer) } // Outcome is what became of a login the manager was offered, by its refresh token's fingerprint. @@ -61,18 +84,21 @@ type Store interface { Licence(ctx context.Context, name string) (*Licence, error) LicenceForAccount(ctx context.Context, account string) (*Licence, error) SaveLicence(ctx context.Context, l Licence) error + Bindings(ctx context.Context) ([]Binding, error) + Binding(ctx context.Context, consumer string) (*Binding, error) + // Bind binds (or switches) a consumer at its next generation. + Bind(ctx context.Context, consumer, licence string, m Move) (Binding, error) + Unbind(ctx context.Context, consumer string) (bool, error) + // Advance gives every consumer of a licence its next generation: what a rotation is to them. + Advance(ctx context.Context, licence string, m Move) ([]Binding, error) + rest +} + +// rest is what the store keeps beside its licences and bindings. +type rest interface { // Lease takes a lease for d, or answers false while another holder's is live. Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) Unlease(ctx context.Context, key, holder string) error - Bindings(ctx context.Context) ([]Binding, error) - Binding(ctx context.Context, consumer string) (*Binding, error) - // Bind binds (or switches) a consumer at the next generation. - Bind(ctx context.Context, consumer, licence string) (Binding, error) - Unbind(ctx context.Context, consumer string) (bool, error) - // Advance gives every consumer of a licence a new generation: what a rotation is to them. - Advance(ctx context.Context, licence string) ([]Binding, error) - // GenerationsAbove makes every generation given from now on greater than this one. - GenerationsAbove(ctx context.Context, generation int64) error Outcome(ctx context.Context, fingerprint string) (Outcome, error) RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error @@ -81,69 +107,28 @@ type Store interface { Close() } -// MemoryStore is the tests' store. +// MemoryStore is the tests' store of what the bus store does not keep yet. type MemoryStore struct { mu sync.Mutex now func() time.Time - rows map[string]Licence - binds map[string]Binding leases map[string]struct { holder string until time.Time } - outcomes map[string]Outcome - usage []UsageRow - Audits []map[string]any - generation int64 + outcomes map[string]Outcome + usage []UsageRow + Audits []map[string]any } // NewMemoryStore is an empty store whose leases age by now. func NewMemoryStore(now func() time.Time) *MemoryStore { - return &MemoryStore{now: now, rows: map[string]Licence{}, binds: map[string]Binding{}, + return &MemoryStore{now: now, leases: map[string]struct { holder string until time.Time }{}, outcomes: map[string]Outcome{}} } -func (m *MemoryStore) Licences(context.Context) ([]Licence, error) { - m.mu.Lock() - defer m.mu.Unlock() - out := make([]Licence, 0, len(m.rows)) - for _, l := range m.rows { - out = append(out, l) - } - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out, nil -} - -func (m *MemoryStore) Licence(_ context.Context, name string) (*Licence, error) { - m.mu.Lock() - defer m.mu.Unlock() - if l, ok := m.rows[name]; ok { - return &l, nil - } - return nil, nil -} - -func (m *MemoryStore) LicenceForAccount(_ context.Context, account string) (*Licence, error) { - m.mu.Lock() - defer m.mu.Unlock() - for _, l := range m.rows { - if l.AccountUUID == account { - return &l, nil - } - } - return nil, nil -} - -func (m *MemoryStore) SaveLicence(_ context.Context, l Licence) error { - m.mu.Lock() - defer m.mu.Unlock() - m.rows[l.Name] = l - return nil -} - func (m *MemoryStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) { m.mu.Lock() defer m.mu.Unlock() @@ -166,67 +151,6 @@ func (m *MemoryStore) Unlease(_ context.Context, key, holder string) error { return nil } -func (m *MemoryStore) Bindings(context.Context) ([]Binding, error) { - m.mu.Lock() - defer m.mu.Unlock() - out := make([]Binding, 0, len(m.binds)) - for _, b := range m.binds { - out = append(out, b) - } - sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer }) - return out, nil -} - -func (m *MemoryStore) Binding(_ context.Context, consumer string) (*Binding, error) { - m.mu.Lock() - defer m.mu.Unlock() - if b, ok := m.binds[consumer]; ok { - return &b, nil - } - return nil, nil -} - -func (m *MemoryStore) Bind(_ context.Context, consumer, licence string) (Binding, error) { - m.mu.Lock() - defer m.mu.Unlock() - m.generation++ - b := Binding{Consumer: consumer, Licence: licence, Generation: m.generation} - m.binds[consumer] = b - return b, nil -} - -func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) { - m.mu.Lock() - defer m.mu.Unlock() - _, ok := m.binds[consumer] - delete(m.binds, consumer) - return ok, nil -} - -func (m *MemoryStore) GenerationsAbove(_ context.Context, generation int64) error { - m.mu.Lock() - defer m.mu.Unlock() - m.generation = max(m.generation, generation) - return nil -} - -func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) { - m.mu.Lock() - defer m.mu.Unlock() - var out []Binding - for c, b := range m.binds { - if b.Licence != licence { - continue - } - m.generation++ - b.Generation = m.generation - m.binds[c] = b - out = append(out, b) - } - sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer }) - return out, nil -} - func (m *MemoryStore) Outcome(_ context.Context, fp string) (Outcome, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go index 4ff3b785b..498935b83 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go @@ -1,9 +1,9 @@ package main import ( + "context" "crypto/rand" "encoding/base64" - "context" "os" "strings" "testing" -- 2.54.0 From 9770dab09ea09e7bf27beccd4abd170622b9f33a Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:17:16 +0100 Subject: [PATCH 08/32] M-4: leases and offered grants kept in the bus's own storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lease is a key created with a two-minute lifetime, which fails while another holds it: the bus's own compare-and-set keeps one rotation source (ADR 0183 §6). An offered grant's outcome is kept under its fingerprint in the key form for 90 days, every outcome kept. Builds: design 39 bus-and-switch §4, §5, B13 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/busstore.go | 71 +++++++++++++++++++ .../claude-licence-manager/busstore_test.go | 41 ++++++++++- .../cmd/claude-licence-manager/main.go | 4 +- .../cmd/claude-licence-manager/manager.go | 4 +- .../claude-licence-manager/manager_test.go | 6 +- .../cmd/claude-licence-manager/store.go | 51 ++----------- 6 files changed, 125 insertions(+), 52 deletions(-) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go index 079592c88..6a8fe1db3 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go @@ -41,6 +41,8 @@ type BindingState struct { type BusStore struct { LicencesState keptState BindingsState keptState + LeasesState keptState + OfferedState keptState Now func() time.Time rest } @@ -178,3 +180,72 @@ func (s *BusStore) Advance(ctx context.Context, licence string, m Move) ([]Bindi } return out, nil } + +// ---- leases and offers (§4, §5) --------------------------------------------------------------------------------- + +// leaseValue is a lease's key in `leases`: its holder and until when. +type leaseValue struct { + Holder string `json:"holder"` + Until string `json:"until"` +} + +// Lease takes a lease by creating its key with a lifetime of d (§4): a create fails while another holds it, the bus's +// own compare-and-set. A key its own holder holds is taken again. +func (s *BusStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) { + v := leaseValue{Holder: holder, Until: s.Now().Add(d).UTC().Format(time.RFC3339)} + _, err := s.LeasesState.CreateTTL(key, v, d) + if err == nil { + return true, nil + } + if !errors.Is(err, stdio.ErrChanged) { + return false, err + } + var held leaseValue + if ok, gerr := s.get(s.LeasesState, key, &held); gerr != nil || !ok || held.Holder != holder { + return false, gerr + } + _, err = s.LeasesState.PutTTL(key, v, d) + return err == nil, err +} + +// Unlease deletes a lease its holder holds; another's is left to it or its lifetime. +func (s *BusStore) Unlease(_ context.Context, key, holder string) error { + var held leaseValue + if ok, err := s.get(s.LeasesState, key, &held); err != nil || !ok || held.Holder != holder { + return err + } + return s.LeasesState.Delete(key) +} + +// offeredFor is how long the outcome of an offered grant is kept (§5): longer than a refresh token lives. +const offeredFor = 90 * 24 * time.Hour + +// offeredValue is a grant's key in `offered`: what became of it, where and when (§5, B13: every outcome). +type offeredValue struct { + Node string `json:"node"` + Account string `json:"account"` + Outcome Outcome `json:"outcome"` + Why string `json:"why"` + At string `json:"at"` +} + +// OfferedKey is a fingerprint's key in `offered`: through the licence key form (B13). +func OfferedKey(fingerprint string) string { return KeyFor(fingerprint) } + +func (s *BusStore) Outcome(_ context.Context, fingerprint string) (Outcome, error) { + var v offeredValue + if ok, err := s.get(s.OfferedState, OfferedKey(fingerprint), &v); err != nil || !ok { + return "", err + } + return v.Outcome, nil +} + +func (s *BusStore) RecordOutcome(_ context.Context, fingerprint, node, account string, o Outcome, why string) error { + return s.putOffered(fingerprint, offeredValue{Node: node, Account: account, Outcome: o, Why: why, + At: s.Now().UTC().Format(time.RFC3339)}, offeredFor) +} + +func (s *BusStore) putOffered(fingerprint string, v offeredValue, ttl time.Duration) error { + _, err := s.OfferedState.PutTTL(OfferedKey(fingerprint), v, ttl) + return err +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go index b6875fb61..51ba4c4f4 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go @@ -3,7 +3,6 @@ package main import ( "context" "encoding/json" - "errors" "sort" "strings" "sync" @@ -51,7 +50,7 @@ func (s *memState) write(key string, value any, ttl time.Duration, create bool) s.mu.Lock() defer s.mu.Unlock() if create && s.live(key) { - return 0, errors.New("the key has a value") + return 0, stdio.ErrChanged } raw, err := json.Marshal(value) if err != nil { @@ -176,3 +175,41 @@ func TestEachMoveWritesTheBindingWithWhyAndBy(t *testing.T) { t.Fatalf("server's history %v", h) } } + +// design 39 bus-and-switch §4, §5, B13: a lease is a key created with a two-minute lifetime that another holder cannot +// create; an offered grant's outcome is kept under its fingerprint in the key form for 90 days. +func TestALeaseAndAnOfferAreKeptByTheirKeysAndLifetimes(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + key := "licence." + KeyFor(licence1) + if ok, err := mm.store.Lease(ctx, key, "one", leaseFor); !ok || err != nil { + t.Fatalf("the first lease: %v %v", ok, err) + } + if ok, _ := mm.store.Lease(ctx, key, "two", leaseFor); ok { + t.Fatal("a second holder took a live lease") + } + if u := mm.leases.until[key]; !u.Equal(mm.now.Add(2 * time.Minute)) { + t.Fatalf("the lease lives until %v", u) + } + mm.now = mm.now.Add(2 * time.Minute) + if ok, _ := mm.store.Lease(ctx, key, "two", leaseFor); !ok { + t.Fatal("a lease past its lifetime was not taken") + } + _ = mm.store.Unlease(ctx, key, "one") + if _, held := mm.leases.values[key]; !held { + t.Fatal("another holder's lease was ended") + } + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-dead", false, t0)}) + fp := OfferedKey(Fingerprint("rt-dead")) + if strings.ContainsAny(fp, ":") || !strings.HasPrefix(fp, "sha256_") { + t.Fatalf("the offered key %q is not in the key form", fp) + } + var v offeredValue + _ = json.Unmarshal(mm.offered.values[fp], &v) + if v.Outcome != Dead || v.Node != "laptop" || v.Account != "acct-1" || v.Why == "" || v.At == "" { + t.Fatalf("offered %+v", v) + } + if u := mm.offered.until[fp]; !u.Equal(mm.now.Add(90 * 24 * time.Hour)) { + t.Fatalf("the outcome lives until %v", u) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index 49402da9b..b1f255b1b 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -72,8 +72,8 @@ func manager() (*Manager, error) { node = n } built = &Manager{ - Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"), Now: time.Now, - rest: store}, + Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"), + LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), Now: time.Now, rest: store}, Vendor: LiveVendor(), Crypt: crypt, Grants: VaultGrants{Keys: keys}, diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 8bf20922d..ed8460209 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -251,7 +251,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e var adopted []string for _, account := range accounts { list := byAccount[account] - key := "account:" + account + key := "account." + account ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor) if err != nil || !ok { continue @@ -521,7 +521,7 @@ func Due(l Licence, now time.Time, s Settings) bool { // lease live and does nothing; one started just after finds a fresh grant and is not due. force refreshes // whatever the age — the seat's `refresh` verb. func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[string]any, error) { - key := "licence:" + name + key := "licence." + KeyFor(name) ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor) if err != nil { return nil, err diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index a9be95903..37389aa9d 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -88,6 +88,8 @@ type miniMesh struct { mem *MemoryStore licences *memState bindings *memState + leases *memState + offered *memState vendor *stubVendor logins map[string]FullGrant // node → what its credentials file holds events []string @@ -103,7 +105,9 @@ func newMesh(t *testing.T) *miniMesh { now := func() time.Time { return mm.now } mm.mem = NewMemoryStore(now) mm.licences, mm.bindings = newMemState(now), newMemState(now) - mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, Now: now, rest: mm.mem} + mm.leases, mm.offered = newMemState(now), newMemState(now) + mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, LeasesState: mm.leases, + OfferedState: mm.offered, Now: now, rest: mm.mem} mm.vendor = &stubVendor{live: map[string]bool{}, now: now} crypt, _ := NewCrypt("a key the vault made") mm.keys, _ = GenerateKeyPair() diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go index be18e0f78..744fa0452 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go @@ -91,16 +91,16 @@ type Store interface { Unbind(ctx context.Context, consumer string) (bool, error) // Advance gives every consumer of a licence its next generation: what a rotation is to them. Advance(ctx context.Context, licence string, m Move) ([]Binding, error) - rest -} - -// rest is what the store keeps beside its licences and bindings. -type rest interface { // Lease takes a lease for d, or answers false while another holder's is live. Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) Unlease(ctx context.Context, key, holder string) error Outcome(ctx context.Context, fingerprint string) (Outcome, error) RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error + rest +} + +// rest is what the store keeps beside its licences and bindings. +type rest interface { RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) Audit(ctx context.Context, what string, detail map[string]any) error @@ -122,46 +122,7 @@ type MemoryStore struct { // NewMemoryStore is an empty store whose leases age by now. func NewMemoryStore(now func() time.Time) *MemoryStore { - return &MemoryStore{now: now, - leases: map[string]struct { - holder string - until time.Time - }{}, outcomes: map[string]Outcome{}} -} - -func (m *MemoryStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) { - m.mu.Lock() - defer m.mu.Unlock() - if held, ok := m.leases[key]; ok && held.until.After(m.now()) && held.holder != holder { - return false, nil - } - m.leases[key] = struct { - holder string - until time.Time - }{holder, m.now().Add(d)} - return true, nil -} - -func (m *MemoryStore) Unlease(_ context.Context, key, holder string) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.leases[key].holder == holder { - delete(m.leases, key) - } - return nil -} - -func (m *MemoryStore) Outcome(_ context.Context, fp string) (Outcome, error) { - m.mu.Lock() - defer m.mu.Unlock() - return m.outcomes[fp], nil -} - -func (m *MemoryStore) RecordOutcome(_ context.Context, fp, _, _ string, o Outcome, _ string) error { - m.mu.Lock() - defer m.mu.Unlock() - m.outcomes[fp] = o - return nil + return &MemoryStore{now: now} } func (m *MemoryStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, _ map[string]any) error { -- 2.54.0 From 2b604e0dad3438047abf04cd97e4a2fda93e1021 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:19:03 +0100 Subject: [PATCH 09/32] M-5: the usage and audit logs kept in the bus's own storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every reading is appended under its licence's key with the vendor's answer whole (ADR 0054), and every act under its kind, never rewritten; an act whose audit entry the log does not take fails and says so. The memory store goes: the tests run the bus store over a bus kept in memory. Builds: design 39 bus-and-switch §6, §7, B20 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/busstore.go | 119 ++++++++++++++++- .../claude-licence-manager/busstore_test.go | 126 ++++++++++++++++++ .../claude-licence-manager/keyform_test.go | 2 +- .../cmd/claude-licence-manager/main.go | 7 +- .../cmd/claude-licence-manager/manager.go | 70 +++++++--- .../claude-licence-manager/manager_test.go | 22 +-- .../cmd/claude-licence-manager/store.go | 65 +-------- 7 files changed, 313 insertions(+), 98 deletions(-) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go index 6a8fe1db3..a38ff6fbc 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "sort" "time" @@ -43,8 +44,9 @@ type BusStore struct { BindingsState keptState LeasesState keptState OfferedState keptState + UsageLog keptLog + AuditLog keptLog Now func() time.Time - rest } func (s *BusStore) get(st keptState, key string, into any) (bool, error) { @@ -249,3 +251,118 @@ func (s *BusStore) putOffered(fingerprint string, v offeredValue, ttl time.Durat _, err := s.OfferedState.PutTTL(OfferedKey(fingerprint), v, ttl) return err } + +// ---- the logs (§6, §7) ------------------------------------------------------------------------------------------ + +// keptLog is a log this module keeps, as mesh-sdk's KeptLog answers it. +type keptLog interface { + Append(key string, value any) (uint64, error) + Read(from uint64, keys string, limit int) (*stdio.LogPage, error) + Last(key string) (*stdio.LogEntry, error) + Info() (*stdio.LogInfo, error) +} + +// UsageEntry is one entry of the log `usage` (§6): when, the flattened reading and the vendor's answer whole (ADR 0054). +type UsageEntry struct { + At string `json:"at"` + Reading UsageReading `json:"reading"` + Raw map[string]any `json:"raw,omitempty"` +} + +// AuditKinds are the subjects of the log `audit` (§7); an act of another kind is kept under `drift`. +var AuditKinds = []string{"adopted", "refused", "failed", "rotated", "bound", "switched", "released", "drift", "carried", + "settings"} + +func (s *BusStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, raw map[string]any) error { + _, err := s.UsageLog.Append(KeyFor(licence), UsageEntry{At: time.UnixMilli(at).UTC().Format(time.RFC3339Nano), + Reading: r, Raw: raw}) + return err +} + +// Usage is the latest readings, newest first, of one licence or every one. +func (s *BusStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) { + names := map[string]string{} + all, err := s.Licences(ctx) + if err != nil { + return nil, err + } + for _, l := range all { + names[KeyFor(l.Name)] = l.Name + } + keys := "" + if licence != "" { + keys = KeyFor(licence) + } + entries, err := s.newest(s.UsageLog, keys, limit) + if err != nil { + return nil, err + } + out := []UsageRow{} + for _, e := range entries { + var u UsageEntry + if json.Unmarshal(e.Value, &u) != nil { + continue + } + at, _ := time.Parse(time.RFC3339Nano, u.At) + name := names[e.Key] + if name == "" { + name = e.Key + } + out = append(out, UsageRow{Licence: name, At: at.UnixMilli(), Reading: u.Reading}) + } + return out, nil +} + +// newest is up to limit entries of keys, newest first: read back from the log's last sequence, a window that doubles +// until it holds limit entries or reaches the first. +func (s *BusStore) newest(l keptLog, keys string, limit int) ([]stdio.LogEntry, error) { + info, err := l.Info() + if err != nil || info.Last == 0 { + return nil, err + } + for span := uint64(max(limit, 1)); ; span *= 2 { + from := uint64(1) + if info.Last > span { + from = info.Last - span + 1 + } + var got []stdio.LogEntry + for next := from; next != 0; { + page, err := l.Read(next, keys, 0) + if err != nil { + return nil, err + } + got = append(got, page.Entries...) + next = page.Next + } + if len(got) >= limit || from <= max(info.First, 1) { + if len(got) > limit { + got = got[len(got)-limit:] + } + for i, j := 0, len(got)-1; i < j; i, j = i+1, j-1 { + got[i], got[j] = got[j], got[i] + } + return got, nil + } + } +} + +// Audit appends one entry under its kind, with when; a log that does not take it fails the act (B20). +func (s *BusStore) Audit(_ context.Context, what string, detail map[string]any) error { + kind := "drift" + for _, k := range AuditKinds { + if k == what { + kind = k + } + } + entry := map[string]any{"at": s.Now().UTC().Format(time.RFC3339Nano)} + if kind != what { + entry["what"] = what + } + for k, v := range detail { + entry[k] = v + } + if _, err := s.AuditLog.Append(kind, entry); err != nil { + return fmt.Errorf("the audit did not take the %s entry, so the act is not done: %w", what, err) + } + return nil +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go index 51ba4c4f4..54fbce2ed 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "sort" "strings" "sync" @@ -213,3 +214,128 @@ func TestALeaseAndAnOfferAreKeptByTheirKeysAndLifetimes(t *testing.T) { t.Fatalf("the outcome lives until %v", u) } } + +// memLog is a log of the bus as the runtime answers it, in memory; full, it refuses an entry. +type memLog struct { + mu sync.Mutex + now func() time.Time + entries []stdio.LogEntry + full bool +} + +func newMemLog(now func() time.Time) *memLog { return &memLog{now: now} } + +func (l *memLog) Append(key string, value any) (uint64, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.full { + return 0, errors.New("the log is full: maximum bytes exceeded") + } + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + seq := uint64(len(l.entries) + 1) + l.entries = append(l.entries, stdio.LogEntry{Sequence: seq, Key: key, Value: raw, At: l.now()}) + return seq, nil +} + +func (l *memLog) Read(from uint64, keys string, limit int) (*stdio.LogPage, error) { + l.mu.Lock() + defer l.mu.Unlock() + p := &stdio.LogPage{Entries: []stdio.LogEntry{}, Last: uint64(len(l.entries))} + for _, e := range l.entries { + if e.Sequence >= from && (keys == "" || e.Key == keys) { + if limit > 0 && len(p.Entries) == limit { + p.Next = e.Sequence + break + } + p.Entries = append(p.Entries, e) + } + } + return p, nil +} + +func (l *memLog) Last(key string) (*stdio.LogEntry, error) { + l.mu.Lock() + defer l.mu.Unlock() + for i := len(l.entries) - 1; i >= 0; i-- { + if l.entries[i].Key == key { + e := l.entries[i] + return &e, nil + } + } + return nil, nil +} + +func (l *memLog) Info() (*stdio.LogInfo, error) { + l.mu.Lock() + defer l.mu.Unlock() + i := &stdio.LogInfo{Entries: uint64(len(l.entries)), Last: uint64(len(l.entries))} + if len(l.entries) > 0 { + i.First = 1 + } + return i, nil +} + +// under is every entry of a log's subject, decoded. +func (l *memLog) under(key string) []map[string]any { + l.mu.Lock() + defer l.mu.Unlock() + var out []map[string]any + for _, e := range l.entries { + if key == "" || e.Key == key { + var v map[string]any + _ = json.Unmarshal(e.Value, &v) + v["what"] = e.Key + out = append(out, v) + } + } + return out +} + +// design 39 bus-and-switch §6: a reading appends one entry under its licence's key, the reading beside the raw answer. +func TestAReadingAppendsOneUsageEntry(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatal(err) + } + got := mm.usage.under(KeyFor(licence1)) + if len(got) != 1 || len(mm.usage.entries) != 1 { + t.Fatalf("a reading appended %v", mm.usage.entries) + } + if got[0]["raw"] == nil || got[0]["reading"] == nil || got[0]["at"] == nil { + t.Fatalf("the entry %v", got[0]) + } + rows, _ := mm.store.Usage(ctx, licence1, 20) + if len(rows) != 1 || rows[0].Licence != licence1 || rows[0].Reading.SessionPct == nil { + t.Fatalf("usage answers %+v", rows) + } +} + +// design 39 bus-and-switch §7, B20: each act appends one entry under its kind; a full log fails the act loudly. +func TestEachActAppendsOneAuditEntry(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + _, _ = mm.m.Rotate(ctx, licence1, true) + _ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1}) + _, _ = mm.m.Bind(ctx, "laptop", "api") + _, _ = mm.m.Release(ctx, "laptop") + for kind, n := range map[string]int{"adopted": 1, "bound": 1, "rotated": 1, "switched": 1, "released": 1} { + if got := len(mm.audit.under(kind)); got != n { + t.Errorf("%s: %d entries, want %d", kind, got, n) + } + } + for _, e := range mm.audit.under("") { + if e["at"] == nil || e["by"] == nil && e["what"] != "adopted" { + t.Errorf("the entry %v has no at or by", e) + } + } + mm.audit.full = true + if _, err := mm.m.Bind(ctx, "laptop", "api"); err == nil || !strings.Contains(err.Error(), "the audit did not take") { + t.Fatalf("a bind with a full audit log: %v", err) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go index 573ff76d9..205a8f35f 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go @@ -63,7 +63,7 @@ func assertRefused(t *testing.T, mm *miniMesh) { said = said || strings.HasPrefix(e, "licence.refused ") } audited := false - for _, a := range mm.mem.Audits { + for _, a := range mm.audit.under("") { audited = audited || a["what"] == "refused" } if !said || !audited { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index b1f255b1b..839899692 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -63,17 +63,14 @@ func manager() (*Manager, error) { if err != nil { return nil, err } - store, err := PgStoreFromEnv(context.Background()) - if err != nil { - return nil, err - } node, _ := os.Hostname() if n := os.Getenv("MESH_NODE"); n != "" { node = n } built = &Manager{ Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"), - LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), Now: time.Now, rest: store}, + LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), UsageLog: stdio.Log("usage"), + AuditLog: stdio.Log("audit"), Now: time.Now}, Vendor: LiveVendor(), Crypt: crypt, Grants: VaultGrants{Keys: keys}, diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index ed8460209..eea1ef527 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -81,19 +81,23 @@ func CauseOf(ctx context.Context) Cause { // record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line // in the journal, a row in the audit table, and the event `licence.` on the bus. Never a token. -func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) { - _ = m.Store.Audit(ctx, what, detail) +func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) error { + if err := m.Store.Audit(ctx, what, detail); err != nil { + m.Log("%s, but %v", line, err) + return err + } m.Log("%s", line) _ = m.Emit("licence."+what, detail) + return nil } // moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom. -func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) { +func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) error { what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence) if from != "" { what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence) } - m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By), + return m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By), map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation, "why": c.Why, "by": c.By}) } @@ -299,7 +303,9 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor f if err != nil { return err } - m.moved(ctx, rep.Node, "", l.Name, b.Generation, c) + if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, c); err != nil { + return err + } } return nil } @@ -339,7 +345,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, if taken != "" { why := "its key " + KeyFor(NameFor(c.Identity)) + " is the key of the licence " + taken _ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, why) - _ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why}) + if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why}); err != nil { + return "", err + } _ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": why}) return "", nil } @@ -350,7 +358,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, r := m.Vendor.Refresh(ctx, offered) if !r.OK { _ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Dead, fmt.Sprintf("%d %s", r.Status, r.Reason)) - _ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason}) + if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason}); err != nil { + return "", err + } _ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": fmt.Sprintf("the login's refresh token did not refresh (%d)", r.Status)}) m.Log("%s's login for %s did not refresh: %d %s", c.Node, NameFor(c.Identity), r.Status, r.Reason) @@ -360,7 +370,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, if r.Account != "" && r.Account != c.Identity.AccountUUID { _ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, "the node says "+c.Identity.AccountUUID+", the vendor says "+r.Account) - _ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account}) + if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account}); err != nil { + return "", err + } _ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": "the account the node reported is not the one the vendor answered for"}) return "", nil @@ -394,8 +406,10 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, why = "a newer login" } _ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Adopted, why) - _ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID, - "vendorNamedAccount": r.Account != ""}) + if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID, + "vendorNamedAccount": r.Account != ""}); err != nil { + return "", err + } // A login moves its node (ADR 0209): the node this login came from is bound to its licence — // switched, if it was bound to another. Then every node reporting this account and bound to nothing. @@ -413,7 +427,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, if b != nil { from = b.Licence } - m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause) + if err := m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause); err != nil { + return "", err + } } for _, rep := range reports { if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID { @@ -424,7 +440,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, } bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself} if b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: bc.Why, By: bc.By, Floor: floor}); err == nil { - m.moved(ctx, rep.Node, "", l.Name, b.Generation, bc) + if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, bc); err != nil { + return "", err + } } } if err := m.publishAdvance(ctx, l, floor); err != nil { @@ -553,7 +571,9 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri if !r.OK { l.Failures++ m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason) - _ = m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}) + if err := m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}); err != nil { + return nil, err + } m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify) if err := m.Store.SaveLicence(ctx, *l); err != nil { return nil, err @@ -581,9 +601,11 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri } } ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By}) - m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name, + if err := m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name, time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By), - map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By}) + map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By}); err != nil { + return nil, err + } if err := m.publishAdvance(ctx, *l, m.floorIn(nil)); err != nil { return nil, err } @@ -732,7 +754,9 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin if before != nil { from = before.Licence } - m.moved(ctx, consumer, from, licence, b.Generation, c) + if err := m.moved(ctx, consumer, from, licence, b.Generation, c); err != nil { + return nil, err + } return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil } @@ -746,8 +770,10 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any, return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err } c := CauseOf(ctx) - m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By), - map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}) + if err := m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By), + map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}); err != nil { + return nil, err + } return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil } @@ -800,7 +826,9 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str } if taken != "" { why := "its key " + KeyFor(name) + " is the key of the licence " + taken - _ = m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why}) + if err := m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why}); err != nil { + return nil, err + } _ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why}) return nil, fmt.Errorf("%s is refused: %s", name, why) } @@ -815,7 +843,9 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str return nil, err } c := CauseOf(ctx) - _ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By}) + if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By}); err != nil { + return nil, err + } m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By) ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By}) if err := m.publishAdvance(ctx, l, m.floorIn(nil)); err != nil { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index 37389aa9d..87a501ed5 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -85,17 +85,18 @@ type miniMesh struct { grants *memGrants m *Manager store *BusStore - mem *MemoryStore + usage *memLog + audit *memLog licences *memState bindings *memState leases *memState offered *memState - vendor *stubVendor - logins map[string]FullGrant // node → what its credentials file holds - events []string - now time.Time - keys KeyPair - askDown bool + vendor *stubVendor + logins map[string]FullGrant // node → what its credentials file holds + events []string + now time.Time + keys KeyPair + askDown bool } func newMesh(t *testing.T) *miniMesh { @@ -103,11 +104,11 @@ func newMesh(t *testing.T) *miniMesh { mm := &miniMesh{logins: map[string]FullGrant{}, now: t0, grants: &memGrants{held: map[string]heldGrant{}}} now := func() time.Time { return mm.now } - mm.mem = NewMemoryStore(now) + mm.usage, mm.audit = newMemLog(now), newMemLog(now) mm.licences, mm.bindings = newMemState(now), newMemState(now) mm.leases, mm.offered = newMemState(now), newMemState(now) mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, LeasesState: mm.leases, - OfferedState: mm.offered, Now: now, rest: mm.mem} + OfferedState: mm.offered, UsageLog: mm.usage, AuditLog: mm.audit, Now: now} mm.vendor = &stubVendor{live: map[string]bool{}, now: now} crypt, _ := NewCrypt("a key the vault made") mm.keys, _ = GenerateKeyPair() @@ -559,7 +560,8 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) { t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events) } } - last := mm.mem.Audits[len(mm.mem.Audits)-1] + all := mm.audit.under("") + last := all[len(all)-1] if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") { t.Fatalf("the audit kept %s", raw) } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go index 744fa0452..774a8c44c 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go @@ -1,13 +1,11 @@ package main -// The manager's store (novox/hq ADR 0183, design 39 §1): licences with their grants encrypted, bindings -// with a generation, what became of each login it was offered, usage readings, and the audit. One -// interface, two implementations — postgres for the mesh (pgstore.go), memory for the tests — so every -// rule is tested without a database, and the database is asked only to keep rows. +// The manager's store (novox/hq ADR 0183, design 39 bus-and-switch §2 to §7): licences, bindings with a generation, +// leases, what became of each login it was offered, usage readings and the audit, all in the bus's own storage +// (busstore.go). Every rule is tested on the same store over a bus kept in memory. import ( "context" - "sync" "time" ) @@ -96,63 +94,8 @@ type Store interface { Unlease(ctx context.Context, key, holder string) error Outcome(ctx context.Context, fingerprint string) (Outcome, error) RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error - rest -} - -// rest is what the store keeps beside its licences and bindings. -type rest interface { RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) + // Audit appends one entry for an act; an error is the act failing (B20). Audit(ctx context.Context, what string, detail map[string]any) error - Close() } - -// MemoryStore is the tests' store of what the bus store does not keep yet. -type MemoryStore struct { - mu sync.Mutex - now func() time.Time - leases map[string]struct { - holder string - until time.Time - } - outcomes map[string]Outcome - usage []UsageRow - Audits []map[string]any -} - -// NewMemoryStore is an empty store whose leases age by now. -func NewMemoryStore(now func() time.Time) *MemoryStore { - return &MemoryStore{now: now} -} - -func (m *MemoryStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, _ map[string]any) error { - m.mu.Lock() - defer m.mu.Unlock() - m.usage = append(m.usage, UsageRow{Licence: licence, At: at, Reading: r}) - return nil -} - -func (m *MemoryStore) Usage(_ context.Context, licence string, limit int) ([]UsageRow, error) { - m.mu.Lock() - defer m.mu.Unlock() - var out []UsageRow - for i := len(m.usage) - 1; i >= 0 && len(out) < limit; i-- { - if licence == "" || m.usage[i].Licence == licence { - out = append(out, m.usage[i]) - } - } - return out, nil -} - -func (m *MemoryStore) Audit(_ context.Context, what string, detail map[string]any) error { - m.mu.Lock() - defer m.mu.Unlock() - d := map[string]any{"what": what} - for k, v := range detail { - d[k] = v - } - m.Audits = append(m.Audits, d) - return nil -} - -func (m *MemoryStore) Close() {} -- 2.54.0 From 9cc62818f48156fdad957d2da100828eb374121b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:28:14 +0100 Subject: [PATCH 10/32] M-6: today's rows carried over once, before the manager does anything else (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At its first start the manager reads the six tables of its database, read only, and writes them into its kept data and its grants into the vault at generation 1, then carried.postgres; until then only its read verbs answer. A second start never opens the database, and a stopped run is done again whole without doubling a log entry. The store's write code and its postgres test go: nothing writes the database again. Builds: design 39 bus-and-switch §1, §8, §11, §12, §13, A3, B14 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/carry.go | 437 ++++++++++++++++++ .../cmd/claude-licence-manager/carry_test.go | 276 +++++++++++ .../cmd/claude-licence-manager/main.go | 33 +- .../cmd/claude-licence-manager/manager.go | 15 +- .../claude-licence-manager/manager_test.go | 4 +- .../cmd/claude-licence-manager/pgstore.go | 368 +++++---------- .../cmd/claude-licence-manager/store_test.go | 85 ---- 7 files changed, 877 insertions(+), 341 deletions(-) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/carry.go create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go b/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go new file mode 100644 index 000000000..9f0358033 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go @@ -0,0 +1,437 @@ +package main + +// The carry-over (design 39 bus-and-switch §8, §11 to §13, A3, B14): at its first start the manager reads every row of +// today's database, the module's own and read only, and writes it into its kept data on the bus and its grants into +// the vault, then the key `carried.postgres`. Until that key exists it serves its read verbs alone and does nothing +// else. With the key present it never reads the database again. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" +) + +// CarriedKey is the key of the state `carried` the carry-over writes at its end (§8). +const CarriedKey = "postgres" + +// NotCarried is the answer of every acting verb and tool until the carry-over has ended (§11). +const NotCarried = "the carry-over has not ended" + +// usageKeptFor is how far back the carry-over reads usage: the log `usage` keeps no older reading (§6, §12). +const usageKeptFor = 35 * 24 * time.Hour + +// The tables of today's database, in the order the carry-over reads them (§11, §12). +var CarriedTables = []string{"licence", "binding", "lease", "offered", "usage", "audit"} + +// LicenceRow is a row of the table `licence`: the licence and its grant at rest, sealed with `grant.key`. +type LicenceRow struct { + Licence + Sealed string +} + +// OfferedRow is a row of the table `offered`. +type OfferedRow struct { + Fingerprint, Node, Account string + Outcome Outcome + Why string + At time.Time +} + +// UsageTableRow is a row of the table `usage`: the reading and the vendor's answer whole. +type UsageTableRow struct { + Licence string + At int64 // milliseconds + Reading UsageReading + Raw map[string]any +} + +// AuditRow is a row of the table `audit`. +type AuditRow struct { + At time.Time + What string + Detail map[string]any +} + +// CarrySource reads the six tables of today's database, each oldest first; a table that does not exist is no rows. +type CarrySource interface { + Licences(ctx context.Context) ([]LicenceRow, error) + Bindings(ctx context.Context) ([]Binding, error) + Leases(ctx context.Context) (int, error) + Offered(ctx context.Context) ([]OfferedRow, error) + Usage(ctx context.Context, since time.Time) ([]UsageTableRow, error) + Audit(ctx context.Context) ([]AuditRow, error) +} + +// CarriedTable is one table in `carried.postgres`: the rows read and the entries written for them. +type CarriedTable struct { + Read int `json:"read"` + Written int `json:"written"` +} + +// CarriedRecord is the value of `carried.postgres` (§8): when, and for each table the rows read and entries written. +type CarriedRecord struct { + At string `json:"at"` + Tables map[string]CarriedTable `json:"tables"` +} + +// Carry is the carry-over's gate: closed until `carried.postgres` exists, and the failure of its last run. +type Carry struct { + State keptState // the state `carried` + // Source opens today's database; called only while the key is absent. + Source func(ctx context.Context) (CarrySource, error) + + mu sync.Mutex + running bool + failure string + done chan struct{} + once sync.Once +} + +func (c *Carry) init() { + c.mu.Lock() + if c.done == nil { + c.done = make(chan struct{}) + } + c.mu.Unlock() +} + +// Ended is whether the carry-over has ended: the key `carried.postgres` exists. +func (c *Carry) Ended() bool { + if c == nil { + return true // a manager built with no carry-over to wait on: the tests' own + } + c.init() + select { + case <-c.done: + return true + default: + } + if e, err := c.State.Get(CarriedKey); err == nil && e != nil && len(e.Value) > 0 { + c.end() + return true + } + return false +} + +func (c *Carry) end() { c.once.Do(func() { close(c.done) }) } + +// Wait is closed once the carry-over has ended. +func (c *Carry) Wait() <-chan struct{} { + c.init() + return c.done +} + +// Acts answers an acting verb or tool: nothing until the carry-over has ended (§11). +func (c *Carry) Acts() error { + if c.Ended() { + return nil + } + return errors.New(NotCarried) +} + +// Failure is what the last run that failed names, empty when none failed. +func (c *Carry) Failure() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.failure +} + +// Run carries over every row when `carried.postgres` is absent, and says nothing else happened when it is present +// (§13). A run that fails names its table and error, and leaves the key absent, so the next is done again whole. +func (c *Carry) Run(ctx context.Context, m *Manager, s *BusStore) (*CarriedRecord, error) { + if c.Ended() { + return nil, nil + } + c.mu.Lock() + if c.running { + c.mu.Unlock() + return nil, errors.New("a carry-over is running") + } + c.running = true + c.mu.Unlock() + rec, err := c.run(ctx, m, s) + c.mu.Lock() + c.running = false + if err != nil { + c.failure = err.Error() + } else { + c.failure = "" + } + c.mu.Unlock() + if err != nil { + m.Log("the carry-over failed: %v", err) + return nil, err + } + c.end() + m.Log("carried over today's database: %v", rec.Tables) + return rec, nil +} + +func (c *Carry) run(ctx context.Context, m *Manager, s *BusStore) (*CarriedRecord, error) { + src, err := c.Source(ctx) + if err != nil { + return nil, fmt.Errorf("the database cannot be opened: %w", err) + } + if closer, ok := src.(interface{ Close() }); ok { + defer closer.Close() + } + co := carryOver{m: m, s: s, src: src, now: m.Now(), tables: map[string]CarriedTable{}} + if err := co.all(ctx); err != nil { + return nil, err + } + rec := CarriedRecord{At: co.now.UTC().Format(time.RFC3339), Tables: co.tables} + detail := map[string]any{"why": "the first start of the manager on the bus", "by": Itself, "tables": rec.Tables} + if err := s.Audit(ctx, "carried", detail); err != nil { + return nil, fmt.Errorf("carried: %w", err) + } + if _, err := c.State.Put(CarriedKey, rec); err != nil { + return nil, fmt.Errorf("carried: the key %s was not written: %w", CarriedKey, err) + } + return &rec, nil +} + +// carryOver is one run of the carry-over. +type carryOver struct { + m *Manager + s *BusStore + src CarrySource + now time.Time + tables map[string]CarriedTable + audit []AuditRow +} + +func (co *carryOver) all(ctx context.Context) error { + // The audit is read first: a licence's adoptedFrom comes from it (B14). + audit, err := co.src.Audit(ctx) + if err != nil { + return fmt.Errorf("audit: %w", err) + } + co.audit = audit + for _, table := range CarriedTables { + var t CarriedTable + var err error + switch table { + case "licence": + t, err = co.licences(ctx) + case "binding": + t, err = co.bindings(ctx) + case "lease": + t.Read, err = co.src.Leases(ctx) // a lease lives two minutes: none taken before the start still holds + case "offered": + t, err = co.offered(ctx) + case "usage": + t, err = co.usage(ctx) + case "audit": + t, err = co.audits() + } + if err != nil { + return fmt.Errorf("%s: %w", table, err) + } + co.tables[table] = t + } + return nil +} + +// adoptedFrom is the node the newest adoption of a licence names in the audit (B14). +func (co *carryOver) adoptedFrom(name string) string { + from := "" + for _, r := range co.audit { + if r.What != "adopted" || r.Detail["licence"] != name { + continue + } + if n, _ := r.Detail["node"].(string); n != "" { + from = n + } else if f, _ := r.Detail["from"].(string); f != "" { + from = f + } + } + return from +} + +// licences writes a key of `licences` per row, its grant opened with `grant.key` and handed to the vault at +// generation 1, the grant first (§12, §15). +func (co *carryOver) licences(ctx context.Context) (CarriedTable, error) { + rows, err := co.src.Licences(ctx) + if err != nil { + return CarriedTable{}, err + } + t := CarriedTable{Read: len(rows)} + for _, r := range rows { + l := r.Licence + l.Generation, l.Usage = 0, nil + l.AdoptedFrom = co.adoptedFrom(l.Name) + if r.Sealed != "" { + if co.m.Crypt == nil { + return t, fmt.Errorf("the grant of %s cannot be opened: no grant.key", l.Name) + } + plain, err := co.m.Crypt.Open(r.Sealed) + if err != nil { + return t, fmt.Errorf("the grant of %s: %w", l.Name, err) + } + grant, access := plain, plain + if l.Kind == "subscription" { + var g FullGrant + if err := json.Unmarshal([]byte(plain), &g); err != nil { + return t, fmt.Errorf("the grant of %s is not a grant", l.Name) + } + access = accessOf(g) + } + if err := co.m.handOver(ctx, &l, grant, access); err != nil { + return t, err + } + } + if err := co.s.SaveLicence(ctx, l); err != nil { + return t, err + } + t.Written++ + } + return t, nil +} + +// bindings writes a key of `bindings` per row, its generation kept, why "carried over", by the manager, since the +// carry-over's time (§12, B14); a key a run before this one wrote for the same row keeps its since, so it is put again +// with the same value (§13). +func (co *carryOver) bindings(ctx context.Context) (CarriedTable, error) { + rows, err := co.src.Bindings(ctx) + if err != nil { + return CarriedTable{}, err + } + kinds := map[string]string{} + if all, err := co.s.Licences(ctx); err == nil { + for _, l := range all { + kinds[l.Name] = l.Kind + } + } else { + return CarriedTable{}, err + } + t := CarriedTable{Read: len(rows)} + for _, r := range rows { + v := BindingState{Licence: r.Licence, Kind: kinds[r.Licence], Generation: r.Generation, + Since: co.now.UTC().Format(time.RFC3339), Why: "carried over", By: Itself} + var held BindingState + if ok, err := co.s.get(co.s.BindingsState, r.Consumer, &held); err != nil { + return t, err + } else if ok && held.Licence == v.Licence && held.Generation == v.Generation && held.Why == v.Why { + v.Since = held.Since + } + if _, err := co.s.BindingsState.Put(r.Consumer, v); err != nil { + return t, err + } + t.Written++ + } + return t, nil +} + +// offered writes a key of `offered` per row, its lifetime counted from the row's `at`; a row past its lifetime is +// gone already and written nowhere (§5, §12). +func (co *carryOver) offered(ctx context.Context) (CarriedTable, error) { + rows, err := co.src.Offered(ctx) + if err != nil { + return CarriedTable{}, err + } + t := CarriedTable{Read: len(rows)} + for _, r := range rows { + left := offeredFor - co.now.Sub(r.At) + if left <= 0 { + continue + } + v := offeredValue{Node: r.Node, Account: r.Account, Outcome: r.Outcome, Why: r.Why, + At: r.At.UTC().Format(time.RFC3339)} + if err := co.s.putOffered(r.Fingerprint, v, left); err != nil { + return t, err + } + t.Written++ + } + return t, nil +} + +// lastAt is the `at` of a log's last entry for a subject, zero when it has none: rows at or before it are in the log +// already (§13). +func lastAt(l keptLog, key string) (time.Time, error) { + e, err := l.Last(key) + if err != nil || e == nil { + return time.Time{}, err + } + var v struct { + At string `json:"at"` + } + if json.Unmarshal(e.Value, &v) != nil { + return time.Time{}, nil + } + at, _ := time.Parse(time.RFC3339Nano, v.At) + return at, nil +} + +// usage appends one entry of the log `usage` per row of the last 35 days, oldest first, its `at` kept (§12). +func (co *carryOver) usage(ctx context.Context) (CarriedTable, error) { + rows, err := co.src.Usage(ctx, co.now.Add(-usageKeptFor)) + if err != nil { + return CarriedTable{}, err + } + t := CarriedTable{Read: len(rows)} + last := map[string]time.Time{} + for _, r := range rows { + key := KeyFor(r.Licence) + before, seen := last[key] + if !seen { + if before, err = lastAt(co.s.UsageLog, key); err != nil { + return t, err + } + last[key] = before + } + at := time.UnixMilli(r.At).UTC() + if !at.After(before) { + t.Written++ // in the log already, from a run before this one + continue + } + if _, err := co.s.UsageLog.Append(key, UsageEntry{At: at.Format(time.RFC3339Nano), Reading: r.Reading, + Raw: r.Raw}); err != nil { + return t, err + } + t.Written++ + } + return t, nil +} + +// audits appends one entry of the log `audit` per row, oldest first, its `at` kept, under the subject its `what` +// names, `drift` for any other (§7, §12). +func (co *carryOver) audits() (CarriedTable, error) { + t := CarriedTable{Read: len(co.audit)} + last := map[string]time.Time{} + for _, r := range co.audit { + kind := "drift" + for _, k := range AuditKinds { + if k == r.What { + kind = k + } + } + before, seen := last[kind] + if !seen { + var err error + if before, err = lastAt(co.s.AuditLog, kind); err != nil { + return t, err + } + last[kind] = before + } + if !r.At.After(before) { + t.Written++ // in the log already, from a run before this one + continue + } + entry := map[string]any{} + for k, v := range r.Detail { + entry[k] = v + } + entry["at"] = r.At.UTC().Format(time.RFC3339Nano) + if kind != r.What { + entry["what"] = r.What + } + if _, err := co.s.AuditLog.Append(kind, entry); err != nil { + return t, fmt.Errorf("the audit did not take the carried %s entry: %w", r.What, err) + } + t.Written++ + } + return t, nil +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go new file mode 100644 index 000000000..a4be4be5e --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go @@ -0,0 +1,276 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgconn" +) + +// memSource is today's database as the carry-over reads it, from rows given; failAt makes one table's read fail. +type memSource struct { + licences []LicenceRow + bindings []Binding + leases int + offered []OfferedRow + usage []UsageTableRow + audit []AuditRow + failAt string +} + +func (s *memSource) fail(table string) error { + if s.failAt == table { + return errors.New("connection reset by peer") + } + return nil +} +func (s *memSource) Licences(context.Context) ([]LicenceRow, error) { + return s.licences, s.fail("licence") +} +func (s *memSource) Bindings(context.Context) ([]Binding, error) { + return s.bindings, s.fail("binding") +} +func (s *memSource) Leases(context.Context) (int, error) { return s.leases, s.fail("lease") } +func (s *memSource) Offered(context.Context) ([]OfferedRow, error) { + return s.offered, s.fail("offered") +} +func (s *memSource) Audit(context.Context) ([]AuditRow, error) { return s.audit, s.fail("audit") } +func (s *memSource) Usage(_ context.Context, since time.Time) ([]UsageTableRow, error) { + var out []UsageTableRow + for _, u := range s.usage { + if u.At >= since.UnixMilli() { + out = append(out, u) + } + } + return out, s.fail("usage") +} + +// todaysRows is a copy of the shape of today's rows: a subscription and an API key, two bindings, a lease, two offers, +// three readings (one older than 35 days) and four audit rows (one of a kind the log does not name). +func todaysRows(t *testing.T, mm *miniMesh) *memSource { + t.Helper() + grant, _ := json.Marshal(FullGrant{AccessToken: "at-old", RefreshToken: "rt-old", ExpiresAt: 7}) + pct := 42.0 + return &memSource{ + licences: []LicenceRow{ + {Licence: Licence{Name: licence1, Kind: "subscription", AccountUUID: "acct-1", Email: licence1, + RefreshFingerprint: Fingerprint("rt-old"), AccessExpiresAt: 7, AdoptedAt: 1}, Sealed: sealAtRest(t, mm.m.Crypt, string(grant))}, + {Licence: Licence{Name: "api", Kind: "api-key", AdoptedAt: 2}, Sealed: sealAtRest(t, mm.m.Crypt, "sk-ant-api03-x")}, + }, + bindings: []Binding{{Consumer: "laptop", Licence: licence1, Generation: 1013}, {Consumer: "server", Licence: "api", Generation: 1016}}, + leases: 1, + offered: []OfferedRow{{Fingerprint: "sha256:aa", Node: "laptop", Account: "acct-1", Outcome: Adopted, Why: "refreshed", At: t0.Add(-24 * time.Hour)}, + {Fingerprint: "sha256:bb", Node: "server", Outcome: Dead, Why: "400", At: t0.Add(-48 * time.Hour)}}, + usage: []UsageTableRow{ + {Licence: licence1, At: t0.Add(-40 * 24 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}}, + {Licence: licence1, At: t0.Add(-2 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}, Raw: map[string]any{"five_hour": 1.0}}, + {Licence: licence1, At: t0.Add(-1 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}}, + }, + audit: []AuditRow{ + {At: t0.Add(-72 * time.Hour), What: "adopted", Detail: map[string]any{"licence": licence1, "node": "laptop"}}, + {At: t0.Add(-71 * time.Hour), What: "bound", Detail: map[string]any{"consumer": "laptop"}}, + {At: t0.Add(-70 * time.Hour), What: "switched", Detail: map[string]any{"consumer": "server"}}, + {At: t0.Add(-69 * time.Hour), What: "grant-moved", Detail: map[string]any{"licence": "api"}}, + }, + } +} + +func carryFor(mm *miniMesh, carried *memState, src CarrySource) *Carry { + return &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { return src, nil }} +} + +func readCarried(t *testing.T, carried *memState) CarriedRecord { + t.Helper() + var rec CarriedRecord + e, _ := carried.Get(CarriedKey) + if e == nil { + t.Fatal("carried.postgres is not written") + } + _ = json.Unmarshal(e.Value, &rec) + return rec +} + +// design 39 bus-and-switch §11/AC1, §12/AC1: every row is carried over and the key written; the counts in +// carried.postgres equal the rows read for every table but lease, and the vault answers each licence's grant. +func TestEveryRowIsCarriedOver(t *testing.T) { + mm := newMesh(t) + carried := newMemState(func() time.Time { return mm.now }) + src := todaysRows(t, mm) + c := carryFor(mm, carried, src) + if _, err := c.Run(context.Background(), mm.m, mm.store); err != nil { + t.Fatal(err) + } + rec := readCarried(t, carried) + want := map[string]int{"licence": 2, "binding": 2, "lease": 1, "offered": 2, "usage": 2, "audit": 4} + for table, n := range want { + got := rec.Tables[table] + if got.Read != n || (table != "lease" && got.Written != got.Read) { + t.Errorf("%s: read %d written %d, want %d read and as many written", table, got.Read, got.Written, n) + } + } + if g, err := mm.grants.Get(context.Background(), KeyFor(licence1), PartGrant); err != nil || !strings.Contains(g, "rt-old") { + t.Fatalf("the vault answers the grant %q, %v", g, err) + } + if a, _ := mm.grants.Get(context.Background(), KeyFor(licence1), PartAccess); strings.Contains(a, "rt-old") || !strings.Contains(a, "at-old") { + t.Fatalf("the access secret is %q", a) + } + if a, _ := mm.grants.Get(context.Background(), KeyFor("api"), PartAccess); a != "sk-ant-api03-x" { + t.Fatalf("the API key's access is %q", a) + } + l, _ := mm.store.Licence(context.Background(), licence1) + if l == nil || l.Generation != 1 || l.AdoptedFrom != "laptop" || l.AccountUUID != "acct-1" || l.AccessExpiresAt != 7 { + t.Fatalf("the licence is %+v", l) + } + if b := mm.bound("laptop"); b.Generation != 1013 || b.Why != "carried over" || b.By != Itself || b.Kind != "subscription" || b.Since == "" { + t.Fatalf("laptop's binding %+v", b) + } + if u := mm.offered.until[OfferedKey("sha256:aa")]; !u.Equal(t0.Add(-24 * time.Hour).Add(offeredFor)) { + t.Fatalf("the offer lives until %v", u) + } + if got := mm.audit.under("drift"); len(got) != 1 || got[0]["licence"] != "api" { + t.Fatalf("drift holds %v", got) + } + if got := mm.audit.under("adopted"); len(got) != 1 || got[0]["at"] != t0.Add(-72*time.Hour).Format(time.RFC3339Nano) { + t.Fatalf("adopted holds %v", got) + } + if got := mm.usage.under(KeyFor(licence1)); len(got) != 2 || got[0]["raw"] == nil { + t.Fatalf("usage holds %v", got) + } + if len(mm.audit.under("carried")) != 1 || !c.Ended() { + t.Fatal("the carry-over did not say it carried, or did not end") + } +} + +// design 39 bus-and-switch §13/AC1: a second start, with carried.postgres present, never opens the database and +// writes nothing. +func TestASecondStartCarriesNothing(t *testing.T) { + mm := newMesh(t) + carried := newMemState(func() time.Time { return mm.now }) + if _, err := carryFor(mm, carried, todaysRows(t, mm)).Run(context.Background(), mm.m, mm.store); err != nil { + t.Fatal(err) + } + writes := carried.writes + mm.licences.writes + mm.bindings.writes + mm.offered.writes + entries := len(mm.audit.entries) + len(mm.usage.entries) + puts := len(mm.grants.puts) + opened := false + again := &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { opened = true; return nil, errors.New("no") }} + if rec, err := again.Run(context.Background(), mm.m, mm.store); rec != nil || err != nil || opened { + t.Fatalf("a second start answered %v %v, opened the database: %v", rec, err, opened) + } + if carried.writes+mm.licences.writes+mm.bindings.writes+mm.offered.writes != writes || + len(mm.audit.entries)+len(mm.usage.entries) != entries || len(mm.grants.puts) != puts { + t.Fatal("a second start wrote") + } +} + +// design 39 bus-and-switch §13/AC2: a carry-over stopped after bindings leaves no key, names its table, and a start +// again carries over whole: every count equals the rows read, and no log entry is appended twice. +func TestAStoppedCarryOverIsDoneAgainWhole(t *testing.T) { + mm := newMesh(t) + carried := newMemState(func() time.Time { return mm.now }) + src := todaysRows(t, mm) + src.failAt = "offered" + c := carryFor(mm, carried, src) + if _, err := c.Run(context.Background(), mm.m, mm.store); err == nil || !strings.Contains(c.Failure(), "offered") { + t.Fatalf("a stopped carry-over answered %v, its failure %q", err, c.Failure()) + } + if e, _ := carried.Get(CarriedKey); e != nil || c.Ended() { + t.Fatal("a stopped carry-over wrote its key") + } + src.failAt = "" + since := mm.bound("laptop").Since + mm.now = mm.now.Add(time.Minute) + if _, err := c.Run(context.Background(), mm.m, mm.store); err != nil { + t.Fatal(err) + } + for table, got := range readCarried(t, carried).Tables { + if table != "lease" && got.Written != got.Read { + t.Errorf("%s: read %d written %d", table, got.Read, got.Written) + } + } + if b := mm.bound("laptop"); b.Since != since { + t.Fatalf("the binding put again changed its since from %s to %s", since, b.Since) + } + if n := len(mm.audit.under("")); n != 5 { + t.Fatalf("the audit holds %d entries, want the four rows and one carried", n) + } + // the run stopped before the logs; a second pass over a log already written appends nothing. + co := carryOver{m: mm.m, s: mm.store, src: src, now: mm.now, tables: map[string]CarriedTable{}, audit: src.audit} + if _, err := co.usage(context.Background()); err != nil { + t.Fatal(err) + } + if got, err := co.audits(); err != nil || got.Written != 4 || len(mm.audit.under("")) != 5 || len(mm.usage.under("")) != 2 { + t.Fatalf("a log written again appended: %+v %v, %d usage entries", got, err, len(mm.usage.under(""))) + } +} + +// design 39 bus-and-switch §11/AC2: with the database unreachable the carry-over fails naming it, every acting verb +// answers "the carry-over has not ended", a read verb is served, and no vendor call is made. +func TestAnUnreachableDatabaseActsNotAndCallsNoVendor(t *testing.T) { + mm := newMesh(t) + carried := newMemState(func() time.Time { return mm.now }) + mm.m.Carry = &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { + return nil, errors.New("dial tcp 192.0.2.1:5432: connect: connection refused") + }} + if _, err := mm.m.Carry.Run(context.Background(), mm.m, mm.store); err == nil || !strings.Contains(mm.m.Carry.Failure(), "connection refused") { + t.Fatalf("the run answered %v, its failure %q", err, mm.m.Carry.Failure()) + } + built = mm.m + t.Cleanup(func() { built = nil }) + served := map[string]stdTool{} + for _, tool := range tools() { + served[strings.TrimPrefix(tool.Name, Seat+".")] = stdTool(tool.Run) + } + for _, name := range []string{"bind", "switch", "release", "refresh", "adopt"} { + if _, err := served[name](map[string]any{"consumer": "laptop", "licence": licence1, "name": "api", "file": "/none"}); err == nil || err.Error() != NotCarried { + t.Errorf("%s answered %v", name, err) + } + } + for _, name := range []string{"licences", "bindings", "usage", "public-key"} { + if _, err := served[name](map[string]any{}); err != nil { + t.Errorf("the read verb %s answered %v", name, err) + } + } + if len(mm.vendor.exchanged) != 0 { + t.Fatalf("the vendor was called: %v", mm.vendor.exchanged) + } +} + +type stdTool func(map[string]any) (any, error) + +// design 39 bus-and-switch §11/AC3: a table that does not exist (a fresh install) is read as no rows; any other +// failure of the database is one. +func TestAMissingTableIsNoRows(t *testing.T) { + if !noTable(&pgconn.PgError{Code: "42P01", Message: `relation "offered" does not exist`}) { + t.Fatal("a missing table is a failure") + } + for _, err := range []error{&pgconn.PgError{Code: "28P01"}, errors.New("connection refused"), nil} { + if noTable(err) { + t.Fatalf("%v is read as a missing table", err) + } + } +} + +// design 39 bus-and-switch §1/AC3: no code of the manager writes to its database. +func TestNoWriteToTheDatabase(t *testing.T) { + write := regexp.MustCompile(`(?i)\b(insert\s+into|update\s+\w+\s+set|delete\s+from|create\s+(table|sequence|index)|drop\s+(table|sequence|index)|alter\s+table|truncate\s|setval\s*\(|nextval\s*\()`) + files, _ := os.ReadDir(".") + for _, f := range files { + if !strings.HasSuffix(f.Name(), ".go") || strings.HasSuffix(f.Name(), "_test.go") { + continue + } + raw, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatal(err) + } + if w := write.Find(raw); w != nil { + t.Errorf("%s writes to the database: %q", f.Name(), w) + } + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index 839899692..dc3a4292e 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -73,6 +73,10 @@ func manager() (*Manager, error) { AuditLog: stdio.Log("audit"), Now: time.Now}, Vendor: LiveVendor(), Crypt: crypt, + // Today's database is the carry-over's source alone, read once and never written (§11, A3). + Carry: &Carry{State: stdio.State("carried"), Source: func(ctx context.Context) (CarrySource, error) { + return PgSourceFromEnv(ctx) + }}, Grants: VaultGrants{Keys: keys}, Keys: keys, AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) { @@ -130,7 +134,25 @@ var ( heard = map[string]Holdings{} ) +// carryOverFirst runs the carry-over at the start, before the manager hears an event, refreshes or reads usage (§11), and +// answers once it has ended: at once when `carried.postgres` exists, else when a run writes it, this one or one asked +// by hand later. +func carryOverFirst() { + <-stdio.Served() + m, err := manager() + if err != nil { + say("the carry-over cannot start: %v", err) + return + } + if !m.Carry.Ended() { + bus, _ := m.Store.(*BusStore) + _, _ = m.Carry.Run(context.Background(), m, bus) // a failure is said, and the run is asked again by hand (§44) + } + <-m.Carry.Wait() +} + func daemon() { + carryOverFirst() mu, reports := &heardMu, heard var passing sync.Mutex pass := func() { @@ -234,7 +256,8 @@ func text(a map[string]any, k string) (string, error) { } // verb is one of the seat's verbs: listed as `.`, so the runtime serves it on the seat's subject. -// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625). +// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625). A verb +// that acts answers nothing but "the carry-over has not ended" until it has (§11). func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool { return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: func(a map[string]any) (any, error) { @@ -242,11 +265,19 @@ func verb(name, description string, input map[string]any, run func(ctx context.C if err != nil { return nil, err } + if !reads[name] { + if err := m.Carry.Acts(); err != nil { + return nil, err + } + } ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)}) return run(ctx, m, a) }} } +// reads are the seat's verbs that only read, served while the carry-over has not ended (§11). +var reads = map[string]bool{"licences": true, "bindings": true, "usage": true, "public-key": true, "current": true} + func str(description string) map[string]any { return map[string]any{"type": "string", "description": description} } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index eea1ef527..28a541204 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -155,6 +155,8 @@ type Manager struct { Vendor Vendor // Crypt opens the grants today's database keeps at rest, for the carry-over alone (design 39 bus-and-switch §12, A3). Crypt *Crypt + // Carry is the carry-over's gate (§11): no act until it has ended. Nil is a manager with nothing to carry over. + Carry *Carry // Grants is the vault, which holds every licence's grant and access token (§15, §16). Grants Grants Keys KeyPair @@ -457,10 +459,7 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, // bus-and-switch §15), and keeps on the licence its fingerprint and expiries alone: no token once it returns. func (m *Manager) keep(ctx context.Context, l *Licence, g FullGrant) error { grant, _ := json.Marshal(g) - access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt, - "refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes, - "subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier}) - if err := m.handOver(ctx, l, string(grant), string(access)); err != nil { + if err := m.handOver(ctx, l, string(grant), accessOf(g)); err != nil { return err } l.RefreshFingerprint = Fingerprint(g.RefreshToken) @@ -473,6 +472,14 @@ func (m *Manager) keep(ctx context.Context, l *Licence, g FullGrant) error { return nil } +// accessOf is a subscription's `access` secret: its access token and expiry, never its refresh token (§15). +func accessOf(g FullGrant) string { + access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt, + "refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes, + "subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier}) + return string(access) +} + // handOver puts a licence's two secrets in the vault at its next generation, the grant first (§15). func (m *Manager) handOver(ctx context.Context, l *Licence, grant, access string) error { generation := l.Generation + 1 diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index 87a501ed5..d31471ac6 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -63,7 +63,9 @@ func (g *memGrants) Put(_ context.Context, key, part string, generation int64, v g.mu.Lock() defer g.mu.Unlock() name := SecretName(key, part) - if h, ok := g.held[name]; ok && h.generation >= generation { + if h, ok := g.held[name]; ok && h.generation == generation && h.value == value { + return nil // the vault keeps what it holds, unchanged + } else if ok && h.generation >= generation { return fmt.Errorf("%s is held at generation %d, and generation %d is not newer", name, h.generation, generation) } g.held[name] = heldGrant{generation, value} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go b/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go index 7c544d6ab..f6144ddf4 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go @@ -1,71 +1,27 @@ package main -// The store on the mesh's postgres (novox/hq design 39 §1), the database the mesh provisioned for this -// module. The schema is brought to this version's shape by the preparation step (ADR 0135), each -// statement idempotent. +// Today's database, read once by the carry-over and never written (design 39 bus-and-switch §1, §11, A3): the +// module's own, opened with the credential the mesh still provisions for it alone. Every query is a select; a table +// that does not exist (a fresh install) is read as no rows. import ( "context" "encoding/json" "errors" - "fmt" "os" "strings" "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) -// Schema is what this version needs. -var Schema = []string{ - `create table if not exists licence ( - name text primary key, - kind text not null check (kind in ('subscription', 'api-key')), - account_uuid text unique, - email text, - organization_uuid text, - sealed text, - refresh_fingerprint text, - access_expires_at bigint, - refresh_expires_at bigint, - failures integer not null default 0, - notified_at bigint, - adopted_at bigint not null, - rotated_at bigint)`, - `create sequence if not exists binding_generation`, - `create table if not exists binding ( - consumer text primary key, - licence text not null references licence(name), - generation bigint not null)`, - `create table if not exists lease ( - key text primary key, - holder text not null, - until timestamptz not null)`, - `create table if not exists offered ( - fingerprint text primary key, - node text not null, - account_uuid text, - outcome text not null, - why text not null, - at timestamptz not null default now())`, - `create table if not exists usage ( - licence text not null, - at bigint not null, - reading jsonb not null, - raw jsonb not null)`, - `create index if not exists usage_by_licence on usage (licence, at desc)`, - `create table if not exists audit ( - at timestamptz not null default now(), - what text not null, - detail jsonb not null)`, -} +// PgSource reads today's database. +type PgSource struct{ pool *pgxpool.Pool } -// PgStore is the store on postgres. -type PgStore struct{ pool *pgxpool.Pool } - -// PgStoreFromEnv opens the store the mesh provisioned, its URL in the file DATABASE_URL_FILE names. -func PgStoreFromEnv(ctx context.Context) (*PgStore, error) { +// PgSourceFromEnv opens the database the mesh provisioned, its URL in the file DATABASE_URL_FILE names. +func PgSourceFromEnv(ctx context.Context) (*PgSource, error) { file := os.Getenv("DATABASE_URL_FILE") if file == "" { return nil, errors.New("DATABASE_URL_FILE is not set: the manager's database is a requirement the mesh resolves") @@ -74,219 +30,131 @@ func PgStoreFromEnv(ctx context.Context) (*PgStore, error) { if err != nil { return nil, err } - return OpenPgStore(ctx, strings.TrimSpace(string(raw))) + return OpenPgSource(ctx, strings.TrimSpace(string(raw))) } -// OpenPgStore opens a store at a URL. -func OpenPgStore(ctx context.Context, url string) (*PgStore, error) { +// OpenPgSource opens a database at a URL, and reaches it once, so an unreachable one fails here. +func OpenPgSource(ctx context.Context, url string) (*PgSource, error) { pool, err := pgxpool.New(ctx, url) if err != nil { return nil, err } - return &PgStore{pool: pool}, nil + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + return &PgSource{pool: pool}, nil } -// Migrate brings the schema to this version's shape. -func (s *PgStore) Migrate(ctx context.Context) error { - for _, q := range Schema { - if _, err := s.pool.Exec(ctx, q); err != nil { - return fmt.Errorf("%s: %w", strings.Fields(q)[0:6], err) +// Close lets the database go. +func (s *PgSource) Close() { s.pool.Close() } + +// noTable is whether err is postgres saying a table does not exist (SQLSTATE 42P01). +func noTable(err error) bool { + var pg *pgconn.PgError + return errors.As(err, &pg) && pg.Code == "42P01" +} + +// each runs a select and scans each row; a table that does not exist is no rows. +func (s *PgSource) each(ctx context.Context, q string, scan func(pgx.Rows) error, args ...any) error { + rows, err := s.pool.Query(ctx, q, args...) + if err != nil { + if noTable(err) { + return nil } + return err + } + defer rows.Close() + for rows.Next() { + if err := scan(rows); err != nil { + return err + } + } + if err := rows.Err(); err != nil && !noTable(err) { + return err } return nil } -const licenceColumns = `name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''), - coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0), coalesce(refresh_expires_at,0), - failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0)` - -func scanLicence(row pgx.Row) (*Licence, error) { - var l Licence - var sealed string // the grant at rest, read by the carry-over alone (design 39 bus-and-switch §12) - err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &sealed, &l.RefreshFingerprint, - &l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt) - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - return &l, err +func (s *PgSource) Licences(ctx context.Context) ([]LicenceRow, error) { + out := []LicenceRow{} + err := s.each(ctx, `select name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''), + coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0), + coalesce(refresh_expires_at,0), failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0) + from licence order by adopted_at, name`, func(r pgx.Rows) error { + var l LicenceRow + err := r.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint, + &l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt) + out = append(out, l) + return err + }) + return out, err } -func (s *PgStore) Licences(ctx context.Context) ([]Licence, error) { - rows, err := s.pool.Query(ctx, `select `+licenceColumns+` from licence order by name`) - if err != nil { - return nil, err - } - defer rows.Close() - var out []Licence - for rows.Next() { - l, err := scanLicence(rows) - if err != nil { - return nil, err +func (s *PgSource) Bindings(ctx context.Context) ([]Binding, error) { + out := []Binding{} + err := s.each(ctx, `select consumer, licence, generation from binding order by generation, consumer`, + func(r pgx.Rows) error { + var b Binding + err := r.Scan(&b.Consumer, &b.Licence, &b.Generation) + out = append(out, b) + return err + }) + return out, err +} + +func (s *PgSource) Leases(ctx context.Context) (int, error) { + n := 0 + err := s.each(ctx, `select key from lease`, func(r pgx.Rows) error { + n++ + var k string + return r.Scan(&k) + }) + return n, err +} + +func (s *PgSource) Offered(ctx context.Context) ([]OfferedRow, error) { + out := []OfferedRow{} + err := s.each(ctx, `select fingerprint, node, coalesce(account_uuid,''), outcome, why, at from offered + order by at, fingerprint`, func(r pgx.Rows) error { + var o OfferedRow + var outcome string + err := r.Scan(&o.Fingerprint, &o.Node, &o.Account, &outcome, &o.Why, &o.At) + o.Outcome = Outcome(outcome) + out = append(out, o) + return err + }) + return out, err +} + +func (s *PgSource) Usage(ctx context.Context, since time.Time) ([]UsageTableRow, error) { + out := []UsageTableRow{} + err := s.each(ctx, `select licence, at, reading, raw from usage where at >= $1 order by at, licence`, + func(r pgx.Rows) error { + var u UsageTableRow + var reading, raw []byte + if err := r.Scan(&u.Licence, &u.At, &reading, &raw); err != nil { + return err + } + _ = json.Unmarshal(reading, &u.Reading) + _ = json.Unmarshal(raw, &u.Raw) + out = append(out, u) + return nil + }, since.UnixMilli()) + return out, err +} + +func (s *PgSource) Audit(ctx context.Context) ([]AuditRow, error) { + out := []AuditRow{} + err := s.each(ctx, `select at, what, detail from audit order by at`, func(r pgx.Rows) error { + var a AuditRow + var detail []byte + if err := r.Scan(&a.At, &a.What, &detail); err != nil { + return err } - out = append(out, *l) - } - return out, rows.Err() -} - -func (s *PgStore) Licence(ctx context.Context, name string) (*Licence, error) { - return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where name = $1`, name)) -} - -func (s *PgStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) { - return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where account_uuid = $1`, account)) -} - -func nullable(s string) any { - if s == "" { + _ = json.Unmarshal(detail, &a.Detail) + out = append(out, a) return nil - } - return s + }) + return out, err } - -func nullableInt(v int64) any { - if v == 0 { - return nil - } - return v -} - -func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error { - _, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, refresh_fingerprint, - access_expires_at, refresh_expires_at, failures, notified_at, adopted_at, rotated_at) - values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) - on conflict (name) do update set kind = excluded.kind, account_uuid = excluded.account_uuid, email = excluded.email, - organization_uuid = excluded.organization_uuid, refresh_fingerprint = excluded.refresh_fingerprint, - access_expires_at = excluded.access_expires_at, refresh_expires_at = excluded.refresh_expires_at, - failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at, - rotated_at = excluded.rotated_at`, - l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), - nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures, - nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt)) - return err -} - -// Lease is taken in the store before a row is read (design 39 §3): a second run started together finds -// it live and does nothing. -func (s *PgStore) Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) { - tag, err := s.pool.Exec(ctx, `insert into lease (key, holder, until) values ($1, $2, now() + make_interval(secs => $3)) - on conflict (key) do update set holder = excluded.holder, until = excluded.until - where lease.until < now() or lease.holder = excluded.holder`, key, holder, d.Seconds()) - if err != nil { - return false, err - } - return tag.RowsAffected() == 1, nil -} - -func (s *PgStore) Unlease(ctx context.Context, key, holder string) error { - _, err := s.pool.Exec(ctx, `delete from lease where key = $1 and holder = $2`, key, holder) - return err -} - -func (s *PgStore) bindingsWhere(ctx context.Context, q string, args ...any) ([]Binding, error) { - rows, err := s.pool.Query(ctx, q, args...) - if err != nil { - return nil, err - } - defer rows.Close() - var out []Binding - for rows.Next() { - var b Binding - if err := rows.Scan(&b.Consumer, &b.Licence, &b.Generation); err != nil { - return nil, err - } - out = append(out, b) - } - return out, rows.Err() -} - -func (s *PgStore) Bindings(ctx context.Context) ([]Binding, error) { - return s.bindingsWhere(ctx, `select consumer, licence, generation from binding order by consumer`) -} - -func (s *PgStore) Binding(ctx context.Context, consumer string) (*Binding, error) { - out, err := s.bindingsWhere(ctx, `select consumer, licence, generation from binding where consumer = $1`, consumer) - if err != nil || len(out) == 0 { - return nil, err - } - return &out[0], nil -} - -func (s *PgStore) Bind(ctx context.Context, consumer, licence string) (Binding, error) { - out, err := s.bindingsWhere(ctx, `insert into binding (consumer, licence, generation) values ($1, $2, nextval('binding_generation')) - on conflict (consumer) do update set licence = excluded.licence, generation = excluded.generation - returning consumer, licence, generation`, consumer, licence) - if err != nil { - return Binding{}, err - } - return out[0], nil -} - -func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) { - tag, err := s.pool.Exec(ctx, `delete from binding where consumer = $1`, consumer) - return err == nil && tag.RowsAffected() == 1, err -} - -func (s *PgStore) GenerationsAbove(ctx context.Context, generation int64) error { - if generation <= 0 { - return nil - } - // setval with is_called, so the next nextval is generation+1; only ever forward. - _, err := s.pool.Exec(ctx, `select setval('binding_generation', $1, true) from binding_generation - where not is_called or last_value < $1`, generation) - return err -} - -func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) { - return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1 - returning consumer, licence, generation`, licence) -} - -func (s *PgStore) Outcome(ctx context.Context, fp string) (Outcome, error) { - var o string - err := s.pool.QueryRow(ctx, `select outcome from offered where fingerprint = $1`, fp).Scan(&o) - if errors.Is(err, pgx.ErrNoRows) { - return "", nil - } - return Outcome(o), err -} - -func (s *PgStore) RecordOutcome(ctx context.Context, fp, node, account string, o Outcome, why string) error { - _, err := s.pool.Exec(ctx, `insert into offered (fingerprint, node, account_uuid, outcome, why) values ($1,$2,$3,$4,$5) - on conflict (fingerprint) do update set outcome = excluded.outcome, why = excluded.why, at = now()`, - fp, node, nullable(account), string(o), why) - return err -} - -func (s *PgStore) RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error { - reading, _ := json.Marshal(r) - rawJSON, _ := json.Marshal(raw) - _, err := s.pool.Exec(ctx, `insert into usage (licence, at, reading, raw) values ($1,$2,$3,$4)`, licence, at, reading, rawJSON) - return err -} - -func (s *PgStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) { - rows, err := s.pool.Query(ctx, `select licence, at, reading from usage where ($1 = '' or licence = $1) order by at desc limit $2`, licence, limit) - if err != nil { - return nil, err - } - defer rows.Close() - var out []UsageRow - for rows.Next() { - var u UsageRow - var reading []byte - if err := rows.Scan(&u.Licence, &u.At, &reading); err != nil { - return nil, err - } - _ = json.Unmarshal(reading, &u.Reading) - out = append(out, u) - } - return out, rows.Err() -} - -func (s *PgStore) Audit(ctx context.Context, what string, detail map[string]any) error { - raw, _ := json.Marshal(detail) - _, err := s.pool.Exec(ctx, `insert into audit (what, detail) values ($1, $2)`, what, raw) - return err -} - -func (s *PgStore) Close() { s.pool.Close() } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go index 498935b83..35f99deab 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go @@ -1,97 +1,12 @@ package main import ( - "context" "crypto/rand" "encoding/base64" - "os" "strings" "testing" - "time" ) -// Against a real postgres, because the questions are the database's: does the schema apply twice, does a -// lease refuse a second holder, does a generation only grow. Skipped unless one is named: -// -// 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 ./... -func TestTheStoreOnPostgres(t *testing.T) { - url := os.Getenv("MESH_TEST_POSTGRES") - if url == "" { - t.Skip("MESH_TEST_POSTGRES unset") - } - ctx := context.Background() - s, err := OpenPgStore(ctx, url) - if err != nil { - t.Fatal(err) - } - defer s.Close() - for _, table := range []string{"binding", "licence", "lease", "offered", "usage", "audit"} { - _, _ = s.pool.Exec(ctx, "drop table if exists "+table+" cascade") - } - _, _ = s.pool.Exec(ctx, "drop sequence if exists binding_generation") - for i := 0; i < 2; i++ { - if err := s.Migrate(ctx); err != nil { - t.Fatalf("migration %d: %v", i+1, err) - } - } - l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", - RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3} - if err := s.SaveLicence(ctx, l); err != nil { - t.Fatal(err) - } - l.Failures = 2 - _ = s.SaveLicence(ctx, l) - if got, _ := s.LicenceForAccount(ctx, "u-1"); got == nil || got.Failures != 2 || got.OrganizationUUID != "" { - t.Fatalf("%+v", got) - } - if none, err := s.Licence(ctx, "nobody"); none != nil || err != nil { - t.Fatalf("%v %v", none, err) - } - lease := func(holder string) bool { - ok, err := s.Lease(ctx, "licence:a", holder, time.Minute) - if err != nil { - t.Fatal(err) - } - return ok - } - if !lease("one") || lease("two") || !lease("one") { - t.Fatal("a lease did not refuse a second holder, or its own holder could not renew it") - } - _ = s.Unlease(ctx, "licence:a", "one") - if !lease("two") { - t.Fatal("a released lease was not taken") - } - b1, _ := s.Bind(ctx, "laptop", l.Name) - adv, _ := s.Advance(ctx, l.Name) - b3, _ := s.Bind(ctx, "laptop", l.Name) - if len(adv) != 1 || !(b1.Generation < adv[0].Generation && adv[0].Generation < b3.Generation) { - t.Fatalf("generations %d %v %d", b1.Generation, adv, b3.Generation) - } - _ = s.RecordOutcome(ctx, "sha256:x", "laptop", "u-1", Dead, "400") - if o, _ := s.Outcome(ctx, "sha256:x"); o != Dead { - t.Fatalf("outcome %q", o) - } - if o, _ := s.Outcome(ctx, "sha256:none"); o != "" { - t.Fatalf("an unknown login is %q", o) - } - pct := 1.0 - _ = s.RecordUsage(ctx, l.Name, 5, UsageReading{SessionPct: &pct}, map[string]any{}) - if u, _ := s.Usage(ctx, "", 5); len(u) != 1 || *u[0].Reading.SessionPct != 1 { - t.Fatalf("usage %v", u) - } - if err := s.Audit(ctx, "bound", map[string]any{"consumer": "laptop"}); err != nil { - t.Fatal(err) - } - if ok, _ := s.Unbind(ctx, "laptop"); !ok { - t.Fatal("unbind") - } - all, _ := s.Licences(ctx) - if len(all) != 1 { - t.Fatalf("%v", all) - } -} - func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) { prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1} in := int64(60) -- 2.54.0 From 3991211993621d011bcddd6de16952a72bfa3910 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:30:39 +0100 Subject: [PATCH 11/32] M-9: every change of a binding is said as binding.changed (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind, switch, release, a login's move and a rotation's advance each say one binding.changed per consumer moved, with the licence's key, after the key of bindings is written or deleted; a switch through the verbs says one licence.switched with the from, to, consumers, by and why the audit's switched entry also holds. licence.bound, released and rotated are said no more. An adoption advances the consumers already bound before it moves the login's nodes, so each is said once. Builds: design 39 bus-and-switch §21, §27 (the verb's switch), §36, B7, B16 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/manager.go | 68 +++++++++++----- .../claude-licence-manager/manager_test.go | 81 ++++++++++++++----- 2 files changed, 112 insertions(+), 37 deletions(-) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 28a541204..6262c4bf5 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -79,27 +79,48 @@ func CauseOf(ctx context.Context) Cause { return Cause{Why: "a rule of this module's own", By: Itself} } -// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line -// in the journal, a row in the audit table, and the event `licence.` on the bus. Never a token. +// record says one change of what a consumer holds, the same fields in two places (novox/hq issue 625): a line in +// the journal and an entry of the audit. The bus hears it as binding.changed (§21); `licence.bound`, `released` and +// `rotated` are said no more (B16). Never a token. func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) error { if err := m.Store.Audit(ctx, what, detail); err != nil { m.Log("%s, but %v", line, err) return err } m.Log("%s", line) - _ = m.Emit("licence."+what, detail) return nil } -// moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom. -func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) error { - what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence) - if from != "" { - what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence) +// changed says that one consumer's key of `bindings` was written or deleted (§21, B7): its licence (empty on a +// release) and that licence's key, kind, generation, the licence before, why, by and when. Never a token. +func (m *Manager) changed(b Binding, from string) { + key := "" + if b.Licence != "" { + key = KeyFor(b.Licence) } - return m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By), - map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation, - "why": c.Why, "by": c.By}) + _ = m.Emit("binding.changed", map[string]any{"consumer": b.Consumer, "licence": b.Licence, "key": key, + "kind": b.Kind, "generation": b.Generation, "from": from, "why": b.Why, "by": b.By, + "at": m.Now().UTC().Format(time.RFC3339Nano)}) +} + +// switchedFields are a switch's fields, the same in the event licence.switched and the audit entry switched (§27). +func switchedFields(from, to string, consumers []string, c Cause) map[string]any { + return map[string]any{"from": from, "to": to, "consumers": consumers, "by": c.By, "why": c.Why} +} + +// moved records a consumer bound or switched, after its key is written: in the journal and the audit (a switch with +// §27's fields), and as binding.changed. +func (m *Manager) moved(ctx context.Context, b Binding, from string, c Cause) error { + what, line := "bound", fmt.Sprintf("bound %s to %s", b.Consumer, b.Licence) + detail := map[string]any{"consumer": b.Consumer, "licence": b.Licence, "from": from, "generation": b.Generation, + "why": c.Why, "by": c.By} + if from != "" && from != b.Licence { + what, line = "switched", fmt.Sprintf("switched %s from %s to %s", b.Consumer, from, b.Licence) + detail = switchedFields(from, b.Licence, []string{b.Consumer}, c) + } + err := m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, b.Generation, c.Why, c.By), detail) + m.changed(b, from) + return err } // Settings are the manager's own, declared with defaults (design 39 §8). @@ -305,7 +326,7 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor f if err != nil { return err } - if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, c); err != nil { + if err := m.moved(ctx, b, "", c); err != nil { return err } } @@ -418,6 +439,11 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, // Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625). cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself} ctx = WithCause(ctx, cause) + // The consumers already bound to it take the new grant first, so each consumer this adoption moves is said once + // (§21). + if err := m.publishAdvance(ctx, l, floor); err != nil { + return "", err + } if b, err := m.Store.Binding(ctx, c.Node); err != nil { return "", err } else if b == nil || b.Licence != l.Name { @@ -429,7 +455,7 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, if b != nil { from = b.Licence } - if err := m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause); err != nil { + if err := m.moved(ctx, moved, from, cause); err != nil { return "", err } } @@ -442,14 +468,11 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, } bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself} if b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: bc.Why, By: bc.By, Floor: floor}); err == nil { - if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, bc); err != nil { + if err := m.moved(ctx, b, "", bc); err != nil { return "", err } } } - if err := m.publishAdvance(ctx, l, floor); err != nil { - return "", err - } _ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil}) m.Log("adopted %s from %s (%s)", l.Name, c.Node, why) return l.Name, nil @@ -506,7 +529,10 @@ func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) { // it fetched again (novox/hq issue 625). func (m *Manager) publishAdvance(ctx context.Context, l Licence, floor func(string) int64) error { c := CauseOf(ctx) - _, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor}) + advanced, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor}) + for _, b := range advanced { + m.changed(b, b.Licence) + } return err } @@ -761,9 +787,12 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin if before != nil { from = before.Licence } - if err := m.moved(ctx, consumer, from, licence, b.Generation, c); err != nil { + if err := m.moved(ctx, b, from, c); err != nil { return nil, err } + if from != "" && from != licence { + _ = m.Emit("licence.switched", switchedFields(from, licence, []string{consumer}, c)) // one per act (§27, §36) + } return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil } @@ -781,6 +810,7 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any, map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}); err != nil { return nil, err } + m.changed(Binding{Consumer: consumer, Generation: was.Generation, Why: c.Why, By: c.By}, was.Licence) return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index d31471ac6..31156bd76 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -186,12 +186,11 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges if len(bs) != 2 || mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 { t.Fatalf("bindings %v, state %v", bs, mm.bindings.values) } - // The adoption is said, and so is each binding it made (novox/hq issue 625). + // The adoption is said, and so is each binding it made, once (novox/hq issue 625; design 39 bus-and-switch §21). events := strings.Join(mm.events, "|") - for _, want := range []string{"licence.adopted ", `licence.bound {"by":"claude-licence-manager","consumer":"laptop"`, - `licence.bound {"by":"claude-licence-manager","consumer":"server"`} { - if !strings.Contains(events, want) { - t.Fatalf("events lack %s: %v", want, mm.events) + for _, want := range []string{"licence.adopted ", `"consumer":"laptop"`, `"consumer":"server"`} { + if strings.Count(events, want) != 1 { + t.Fatalf("events say %s other than once: %v", want, mm.events) } } } @@ -522,27 +521,62 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) { } } +// changedSince is the binding.changed events said from mark on, decoded. +func (mm *miniMesh) changedSince(mark int) []map[string]any { + var out []map[string]any + for _, e := range mm.events[mark:] { + if body, ok := strings.CutPrefix(e, "binding.changed "); ok { + var v map[string]any + _ = json.Unmarshal([]byte(body), &v) + out = append(out, v) + } + } + return out +} + // A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq -// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release. -func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) { +// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release. design 39 +// bus-and-switch §21/AC1, §36/AC1, B7, B16: each says one binding.changed per consumer moved, with its licence's key, +// after its key is written or deleted; a switch says one licence.switched with §27's fields. +func TestEveryMoveSaysOneBindingChangedPerConsumer(t *testing.T) { mm := newMesh(t) var lines []string mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) } ctx := context.Background() + once := func(mark int, consumer, licence, by string) map[string]any { + t.Helper() + got := mm.changedSince(mark) + if len(got) != 1 || got[0]["consumer"] != consumer || got[0]["licence"] != licence || got[0]["by"] != by { + t.Fatalf("binding.changed said %v, want one for %s to %q by %s", got, consumer, licence, by) + } + if licence != "" && got[0]["key"] != KeyFor(licence) { + t.Fatalf("binding.changed names the key %v", got[0]["key"]) + } + if mm.bound(consumer).Licence != licence { + t.Fatalf("said before the key was written: %+v", mm.bound(consumer)) + } + return got[0] + } + mark := len(mm.events) _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) if s := mm.bound("laptop"); !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself { t.Fatalf("a login's binding said %+v", s) } + once(mark, "laptop", licence1, Itself) file := filepath.Join(t.TempDir(), "key") _ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600) if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil { t.Fatal(err) } + mark = len(mm.events) asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"}) if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil { t.Fatal(err) } + if e := once(mark, "laptop", "api", "g14.node-tools"); e["from"] != licence1 || e["kind"] != "api-key" || e["at"] == nil { + t.Fatalf("the switch said %v", e) + } if s := mm.bound("laptop"); s.Why != "the switch verb" || s.By != "g14.node-tools" { t.Fatalf("the node is told %+v", s) } @@ -551,35 +585,46 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) { !strings.Contains(said, "the switch verb; asked by g14.node-tools") { t.Fatalf("the journal said:\n%s", said) } - var switched string + var switched []string for _, e := range mm.events { if strings.HasPrefix(e, "licence.switched ") { - switched = e + switched = append(switched, e) } } - for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} { - if !strings.Contains(switched, want) { - t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events) + if len(switched) != 1 { + t.Fatalf("licence.switched said %d times: %v", len(switched), mm.events) + } + for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"to":"api"`, `"consumers":["laptop"]`, `"why":"the switch verb"`} { + if !strings.Contains(switched[0], want) { + t.Fatalf("the event %q lacks %s", switched[0], want) } } - all := mm.audit.under("") - last := all[len(all)-1] - if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") { + all := mm.audit.under("switched") + if raw, _ := json.Marshal(all[len(all)-1]); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), `"to":"api"`) { t.Fatalf("the audit kept %s", raw) } + _, _ = mm.m.Bind(ctx, "server", licence1) + mark = len(mm.events) _, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true) - if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) { - t.Fatalf("a rotation asked for said no caller: %v", mm.events) - } + once(mark, "server", licence1, "novox.node-tools") + mark = len(mm.events) if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil { t.Fatal(err) } + if e := once(mark, "laptop", "", "person.operator"); e["from"] != "api" { + t.Fatalf("the release said %v", e) + } if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") { t.Fatalf("the journal said:\n%s", said) } for _, e := range mm.events { + for _, gone := range []string{"licence.bound ", "licence.released ", "licence.rotated "} { + if strings.HasPrefix(e, gone) { + t.Fatalf("an event no longer said was said: %s", e) + } + } if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") { t.Fatalf("an event carries a secret: %s", e) } -- 2.54.0 From b63e8ae58ac3bf23027de29358e8598283ce8f44 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:32:31 +0100 Subject: [PATCH 12/32] M-8: the bindings verb answers each licence's key (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each binding the verb bindings answers carries its licence's key beside the §3 fields, as binding.changed does, so the vault's first view matches a token's secret name to the binding without the key form of its own. Builds: design 39 bus-and-switch §17, B10, B25 (hq 128236e7 and after, PR #561) Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/manager.go | 10 ++++++---- .../cmd/claude-licence-manager/manager_test.go | 4 ++++ .../cmd/claude-licence-manager/store.go | 6 ++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 6262c4bf5..ead306c05 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -749,15 +749,17 @@ func Health(l Licence, now time.Time, s Settings) string { return "ok" } -// BindingsOf is every binding, or the one of consumer when one is named (B10): a list either way, unpaged. +// BindingsOf is every binding, or the one of consumer when one is named (B10): a list either way, unpaged, each with +// its licence's key beside the §3 fields (B25). func (m *Manager) BindingsOf(ctx context.Context, consumer string) ([]Binding, error) { all, err := m.Store.Bindings(ctx) - if err != nil || consumer == "" { - return all, err + if err != nil { + return nil, err } out := []Binding{} for _, b := range all { - if b.Consumer == consumer { + if consumer == "" || b.Consumer == consumer { + b.Key = KeyFor(b.Licence) out = append(out, b) } } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index 31156bd76..b2ec649cd 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -339,6 +339,10 @@ func TestCurrentHoldsNoToken(t *testing.T) { if bs, _ := mm.m.BindingsOf(ctx, "laptop"); len(bs) != 1 || bs[0].Consumer != "laptop" { t.Fatalf("bindings of laptop: %v", bs) } + // design 39 bus-and-switch B25: the verb bindings answers each binding's licence key beside the §3 fields. + if bs, _ := mm.m.BindingsOf(ctx, ""); len(bs) == 0 || bs[0].Key != KeyFor(bs[0].Licence) { + t.Fatalf("bindings answered %+v", bs) + } } func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go index 774a8c44c..bdfdf67ed 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go @@ -34,8 +34,10 @@ type Licence struct { // Binding is one consumer's binding (ADR 0206, §3): the licence, kind and generation it should hold, since when, and // why and by whom it was moved last. type Binding struct { - Consumer string `json:"consumer"` - Licence string `json:"licence"` + Consumer string `json:"consumer"` + Licence string `json:"licence"` + // Key is the licence's key (§10), answered by the verb bindings (B25); the store keeps none. + Key string `json:"key,omitempty"` Kind string `json:"kind,omitempty"` Generation int64 `json:"generation"` Since string `json:"since,omitempty"` -- 2.54.0 From 256a4125901dbcce51b46b12af6f5140c1147723 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:39:15 +0100 Subject: [PATCH 13/32] V-2: the vault keeps the licence bindings from binding.changed and a first view (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vault consumes the licence manager's binding.changed and, at its start, asks the seat's bindings until it answers; it keeps each consumer's licence, the licence's key and the generation in its own state licence-bindings, never an older generation, deleting a released consumer's key. The state is in the vault's old state section, and holds no secret. Builds: design 39 bus-and-switch §17, B2, B9, B10, B22, B25 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/mesh-vault/licencebindings.go | 231 ++++++++++++++++++ .../cmd/mesh-vault/licencebindings_test.go | 115 +++++++++ modules/mesh-vault/cmd/mesh-vault/main.go | 5 + .../mesh-vault/testdata/binding-changed.json | 1 + .../mesh-vault/testdata/bindings-answer.json | 1 + .../mesh-vault/cmd/mesh-vault/vault_test.go | 11 +- modules/mesh-vault/module.json | 9 +- 7 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 modules/mesh-vault/cmd/mesh-vault/licencebindings.go create mode 100644 modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go create mode 100644 modules/mesh-vault/cmd/mesh-vault/testdata/binding-changed.json create mode 100644 modules/mesh-vault/cmd/mesh-vault/testdata/bindings-answer.json diff --git a/modules/mesh-vault/cmd/mesh-vault/licencebindings.go b/modules/mesh-vault/cmd/mesh-vault/licencebindings.go new file mode 100644 index 000000000..d1d09422a --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/licencebindings.go @@ -0,0 +1,231 @@ +package main + +// The licence bindings the vault keeps (design 39 bus-and-switch §17, B2, B10, B25): which licence each consumer is +// bound to, so the vault hands a licence's access token only to a machine a binding names. Learned from the licence +// manager's `binding.changed`, which the vault consumes, and at its start from the seat's verb `bindings`, its first +// view, asked until it answers; kept in the vault's own state `licence-bindings`, one key per consumer with the +// licence, its key and the generation, deleted on a release. The vault never reads the manager's state (ADR 0596 §2). + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "sync" + "time" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + stdio "git.novox.be/novox/mesh-sdk/go" + + "git.novox.be/novox/mesh-sdk/go/views" +) + +const ( + // LicenceBindingsState is the vault's own state of the licence bindings (§17; B2: in the old section `state`). + LicenceBindingsState = "licence-bindings" + // BindingChangedEvent is the licence manager's event of a binding written or deleted (§21), which the vault consumes. + BindingChangedEvent = "claude-licence-manager.binding.changed" + // LicenceBindingsVerb is the seat's verb the vault asks for its first view (B10), declared in its invokes (B9). + LicenceBindingsVerb = "seat:anthropic-licence-manager.bindings" +) + +// LicenceBinding is a consumer's key of `licence-bindings` (B25): the licence, its key and the binding's generation. +type LicenceBinding struct { + Licence string `json:"licence"` + Key string `json:"key"` + Generation int64 `json:"generation"` +} + +// LicenceBindings is the state `licence-bindings`. +type LicenceBindings struct { + Get func(consumer string) (*LicenceBinding, error) + Put func(consumer string, b LicenceBinding) error + Delete func(consumer string) error + Keys func() ([]string, error) + Log func(string, ...any) +} + +// busLicenceBindings is the state on the bus. +func busLicenceBindings() LicenceBindings { + st := stdio.State(LicenceBindingsState) + return LicenceBindings{ + Get: func(consumer string) (*LicenceBinding, error) { + e, err := st.Get(consumer) + if err != nil || e == nil || len(e.Value) == 0 { + return nil, err + } + var b LicenceBinding + return &b, json.Unmarshal(e.Value, &b) + }, + Put: func(consumer string, b LicenceBinding) error { _, err := st.Put(consumer, b); return err }, + Delete: func(consumer string) error { return st.Delete(consumer) }, + Keys: st.Keys, + Log: say, + } +} + +// bindingChanged is the body of `binding.changed` as the manager says it (§21, B7; B22: decoded from a copy of the +// writer's form, testdata/binding-changed.json). +type bindingChanged struct { + Consumer string `json:"consumer"` + Licence string `json:"licence"` + Key string `json:"key"` + Generation int64 `json:"generation"` +} + +// keep writes a consumer's binding unless the state holds a newer generation; an empty licence is a release. +func (lb LicenceBindings) keep(consumer string, b LicenceBinding) error { + if consumer == "" { + return errors.New("a binding names no consumer") + } + held, err := lb.Get(consumer) + if err != nil { + return err + } + if held != nil && held.Generation > b.Generation { + return nil // the state holds a newer move already + } + if b.Licence == "" { + if held == nil { + return nil + } + return lb.Delete(consumer) + } + if held != nil && *held == b { + return nil + } + return lb.Put(consumer, b) +} + +// Heard takes one `binding.changed`. +func (lb LicenceBindings) Heard(body []byte) error { + var e bindingChanged + if err := json.Unmarshal(body, &e); err != nil { + return fmt.Errorf("%s is not a binding's change: %w", BindingChangedEvent, err) + } + if e.Licence != "" && e.Key == "" { + return fmt.Errorf("%s for %s names no licence key", BindingChangedEvent, e.Consumer) + } + return lb.keep(e.Consumer, LicenceBinding{Licence: e.Licence, Key: e.Key, Generation: e.Generation}) +} + +// FirstView takes the manager's answer to `bindings`, by consumer: every binding it names is kept, and every key the +// state holds for a consumer it does not name is deleted, since that consumer was released. +func (lb LicenceBindings) FirstView(values map[string]json.RawMessage) error { + var errs []error + for consumer, raw := range values { + var b LicenceBinding + if err := json.Unmarshal(raw, &b); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", consumer, err)) + continue + } + errs = append(errs, lb.keep(consumer, b)) + } + held, err := lb.Keys() + if err != nil { + return errors.Join(append(errs, err)...) + } + for _, consumer := range held { + if _, named := values[consumer]; !named { + errs = append(errs, lb.Delete(consumer)) + } + } + return errors.Join(errs...) +} + +// licenceBindingsAnswer is the seat's answer to `bindings` (B10, B25) as a first view, by consumer. +func licenceBindingsAnswer(raw []byte) (views.Answer, error) { + var listed []struct { + Consumer string `json:"consumer"` + LicenceBinding + } + if err := json.Unmarshal(unwrapToolAnswer(raw), &listed); err != nil { + return views.Answer{}, fmt.Errorf("%s answered what is not a list of bindings: %w", LicenceBindingsVerb, err) + } + values := map[string]json.RawMessage{} + for _, b := range listed { + if b.Consumer == "" || b.Licence == "" || b.Key == "" { + return views.Answer{}, fmt.Errorf("%s answered a binding without its consumer, licence or key", LicenceBindingsVerb) + } + raw, _ := json.Marshal(b.LicenceBinding) + values[b.Consumer] = raw + } + return views.Answer{Owner: "claude-licence-manager", State: "bindings", Values: values, At: time.Now()}, nil +} + +// licenceBindingsView is the first view's follower; nil until main begins it. +var licenceBindingsView *views.Follower + +// newLicenceBindingsView makes the follower of the first view: asked until it answers, and each answer, at the start or +// asked again by hand, written into the state. +func newLicenceBindingsView(ask func(subject string, body any) (json.RawMessage, error), lb LicenceBindings, + findings views.FindingWriter) *views.Follower { + var mu sync.Mutex + var taken time.Time + var f *views.Follower + f = &views.Follower{Owner: "claude-licence-manager", Tool: LicenceBindingsVerb, State: "bindings", Logf: say, + Ask: func(context.Context, string) (views.Answer, error) { + raw, err := ask(LicenceBindingsVerb, map[string]any{}) + if err != nil { + return views.Answer{}, err + } + return licenceBindingsAnswer(raw) + }, + Raise: func(s views.Status) { + fd := views.UnansweredFinding(Seat, s, time.Now()) + if err := findings.Put(fd); err != nil { + say("the finding %s is not written: %v", fd.ID, err) + } + }, + Clear: func(s views.Status) { _ = findings.Delete(views.UnansweredFinding(Seat, s, time.Now())) }, + // Each answer, once: its picture is the manager's bindings when it answered. + Said: func(s views.Status) { + mu.Lock() + fresh := !s.Answered.IsZero() && s.Answered.After(taken) + if fresh { + taken = s.Answered + } + mu.Unlock() + if !fresh { + return + } + values := f.Values() + if err := lb.FirstView(values); err != nil { + say("the licence bindings of the first view are not all kept: %v", err) + return + } + consumers := make([]string, 0, len(values)) + for c := range values { + consumers = append(consumers, c) + } + sort.Strings(consumers) + say("the licence bindings of %d consumer(s) are kept from the first view: %v", len(consumers), consumers) + }} + return f +} + +// watchLicenceBindings begins the first view and hears `binding.changed`. It answers whether it began; one that did not +// is begun again at the next minute pass. +func watchLicenceBindings(lb LicenceBindings) bool { + err := stdio.Subscribe(BindingChangedEvent, func(e stdio.Envelope) error { + if err := lb.Heard(e.Body); err != nil { + say("%v", err) + } + return nil + }) + if err != nil { + say("does not hear %s yet (%v); asked again at the next pass", BindingChangedEvent, err) + return false + } + if licenceBindingsView == nil { + licenceBindingsView = newLicenceBindingsView(stdio.Ask, lb, + meshconditionsclient.FindingWriter{State: stdio.State(FindingsState)}) + go func() { + if err := licenceBindingsView.Start(context.Background()); err != nil { + say("the first view of the licence bindings ended unanswered: %v", err) + } + }() + } + return true +} diff --git a/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go b/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go new file mode 100644 index 000000000..3f7d23a9f --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "slices" + "sort" + "testing" +) + +// memLicenceBindings is the state licence-bindings in memory. +func memLicenceBindings() (LicenceBindings, map[string]LicenceBinding) { + held := map[string]LicenceBinding{} + return LicenceBindings{ + Get: func(c string) (*LicenceBinding, error) { + if b, ok := held[c]; ok { + return &b, nil + } + return nil, nil + }, + Put: func(c string, b LicenceBinding) error { held[c] = b; return nil }, + Delete: func(c string) error { delete(held, c); return nil }, + Keys: func() ([]string, error) { + var out []string + for c := range held { + out = append(out, c) + } + sort.Strings(out) + return out, nil + }, + Log: func(string, ...any) {}, + }, held +} + +// design 39 bus-and-switch §17, B22, B25: the vault keeps a consumer's binding from binding.changed, decoded from a copy +// of the manager's form, with the licence's key; an older generation is passed over and a release deletes the key. +func TestALicenceBindingIsKeptFromBindingChanged(t *testing.T) { + lb, held := memLicenceBindings() + said, err := os.ReadFile("testdata/binding-changed.json") + if err != nil { + t.Fatal(err) + } + if err := lb.Heard(said); err != nil { + t.Fatal(err) + } + if b := held["laptop"]; b != (LicenceBinding{Licence: "api", Key: "api", Generation: 3}) { + t.Fatalf("laptop is kept as %+v", b) + } + for _, c := range []struct { + body string + want *LicenceBinding + }{ + {`{"consumer":"laptop","licence":"other","key":"other","generation":2}`, &LicenceBinding{Licence: "api", Key: "api", Generation: 3}}, + {`{"consumer":"laptop","licence":"other","key":"other","generation":4}`, &LicenceBinding{Licence: "other", Key: "other", Generation: 4}}, + {`{"consumer":"laptop","licence":"","key":"","generation":4,"from":"other"}`, nil}, + } { + if err := lb.Heard([]byte(c.body)); err != nil { + t.Fatal(err) + } + got, ok := held["laptop"] + if (c.want == nil) != !ok || (c.want != nil && got != *c.want) { + t.Fatalf("after %s laptop is kept as %+v (held %v), want %+v", c.body, got, ok, c.want) + } + } + if err := lb.Heard([]byte(`{"consumer":"server","licence":"api","generation":5}`)); err == nil { + t.Fatal("a binding without its licence's key was kept") + } +} + +// design 39 bus-and-switch §17, B10, B25: the first view, the seat's answer to bindings, keeps every consumer's binding +// with its key and deletes a consumer it does not name; the vault invokes the verb and consumes the event its manifest +// names. +func TestTheFirstViewOfTheLicenceBindingsIsKept(t *testing.T) { + lb, held := memLicenceBindings() + held["gone"] = LicenceBinding{Licence: "api", Key: "api", Generation: 9} + raw, err := os.ReadFile("testdata/bindings-answer.json") + if err != nil { + t.Fatal(err) + } + a, err := licenceBindingsAnswer(raw) + if err != nil { + t.Fatal(err) + } + if err := lb.FirstView(a.Values); err != nil { + t.Fatal(err) + } + want := map[string]LicenceBinding{"laptop": {Licence: "acct-1@example.org", Key: "acct-1_at_example.org", Generation: 1013}, + "server": {Licence: "api", Key: "api", Generation: 1016}} + if len(held) != 2 || held["laptop"] != want["laptop"] || held["server"] != want["server"] { + t.Fatalf("the first view kept %v", held) + } + if _, err := licenceBindingsAnswer([]byte(`[{"consumer":"laptop","licence":"api","generation":1}]`)); err == nil { + t.Fatal("an answer without a licence's key was taken") + } + lb.Put = func(string, LicenceBinding) error { return errors.New("the bucket is full") } + held["laptop"] = LicenceBinding{Licence: "api", Key: "api", Generation: 1} + if err := lb.FirstView(a.Values); err == nil { + t.Fatal("a first view not kept was not said") + } + + manifest, _ := os.ReadFile("../../module.json") + var m struct { + Invokes, Consumes []string + State []json.RawMessage + } + _ = json.Unmarshal(manifest, &m) + kept := false + for _, st := range m.State { + kept = kept || string(st) == `"`+LicenceBindingsState+`"` + } + if !slices.Contains(m.Invokes, LicenceBindingsVerb) || !slices.Contains(m.Consumes, BindingChangedEvent) || !kept { + t.Fatalf("the manifest invokes %v, consumes %v, keeps %s", m.Invokes, m.Consumes, m.State) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index c6dc697f9..2d2a1126b 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -323,6 +323,8 @@ func main() { watched, watching := watchHandings(env), watchAssignments() wants := watchWants(env) leavers := watchLeavers(env) + bindings := busLicenceBindings() + licences := watchLicenceBindings(bindings) kick := make(chan struct{}, 1) go func() { for range kick { @@ -337,6 +339,9 @@ func main() { if !leavers { leavers = watchLeavers(env) } + if !licences { + licences = watchLicenceBindings(bindings) + } if !watched { watched = watchHandings(env) } diff --git a/modules/mesh-vault/cmd/mesh-vault/testdata/binding-changed.json b/modules/mesh-vault/cmd/mesh-vault/testdata/binding-changed.json new file mode 100644 index 000000000..47485b980 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/testdata/binding-changed.json @@ -0,0 +1 @@ +{"at":"2026-10-04T12:00:00Z","by":"g14.node-tools","consumer":"laptop","from":"acct-1@example.org","generation":3,"key":"api","kind":"api-key","licence":"api","why":"the switch verb"} diff --git a/modules/mesh-vault/cmd/mesh-vault/testdata/bindings-answer.json b/modules/mesh-vault/cmd/mesh-vault/testdata/bindings-answer.json new file mode 100644 index 000000000..8e3466591 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/testdata/bindings-answer.json @@ -0,0 +1 @@ +[{"consumer":"laptop","licence":"acct-1@example.org","key":"acct-1_at_example.org","kind":"subscription","generation":1013,"since":"2026-10-04T12:00:00Z","why":"carried over","by":"claude-licence-manager"},{"consumer":"server","licence":"api","key":"api","kind":"api-key","generation":1016,"since":"2026-10-04T12:00:00Z","why":"carried over","by":"claude-licence-manager"}] diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index 68def3bd5..3252e1b99 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -148,10 +148,11 @@ func TestNoBucketHoldsASecret(t *testing.T) { if err := json.Unmarshal(raw, &manifest); err != nil { t.Fatal(err) } - if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"},"signers","bound","node-logins","findings"]` { + if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"},"signers","bound","node-logins","findings","licence-bindings"]` { t.Errorf("the manifest declares buckets %s: a secret is in no bucket, sealed or not (ADR 0201 §13), and the "+ "buckets are the accounts' hashes (ADR 0547 clause 9), the signing keys' public halves (ADR 0550 §2) and the "+ - "credentials' outcomes, names and generations alone (ADR 0570 clauses 3, 6) and each node's login in use (ADR 0583)", s) + "credentials' outcomes, names and generations alone (ADR 0570 clauses 3, 6), each node's login in use (ADR 0583) "+ + "and the licence bindings, a licence's name, key and generation per consumer (design 39 bus-and-switch §17)", s) } if r, _ := json.Marshal(manifest["reads"]); string(r) != `["node-engine.have","mesh-grants.accounts","mesh-provisioner.plans","mesh-provisioner.provisions"]` { t.Errorf("the manifest reads %s: the vault reads the seat mesh-provisioner's plans and provisions, the haves, the "+ @@ -182,6 +183,12 @@ func TestNoBucketHoldsASecret(t *testing.T) { code = []byte(strings.Replace(string(code), "stdio.State(NodeLoginsState)."+call, "", -1)) } } + // The licence bindings, in licencebindings.go alone: the state opened once, a licence's name, key and generation per + // consumer, never a secret (design 39 bus-and-switch §17, B25). + if f == "licencebindings.go" { + code = []byte(strings.Replace(string(code), "st := stdio.State(LicenceBindingsState)", "", 1)) + code = []byte(strings.Replace(string(code), "meshconditionsclient.FindingWriter{State: stdio.State(FindingsState)}", "", 1)) + } // The signing keys' public halves, in signing.go alone: one read and one write of its own bucket (ADR 0550 §2). if f == "signing.go" { code = []byte(strings.Replace(string(code), "stdio.State(meshvault.StateSigners).Get(", "", 1)) diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index 385095d3c..696131aab 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -32,7 +32,8 @@ ], "invokes": [ "seat:mesh-hand-acts.hand-acts", - "mesh-delivery-assigner.assigner_read_assignments" + "mesh-delivery-assigner.assigner_read_assignments", + "seat:anthropic-licence-manager.bindings" ], "reads": [ "node-engine.have", @@ -48,7 +49,8 @@ "signers", "bound", "node-logins", - "findings" + "findings", + "licence-bindings" ], "receives": { "secret": "${dir:grants}/mesh.json" @@ -254,6 +256,7 @@ } }, "consumes": [ - "mesh-delivery-assigner.views.assignments" + "mesh-delivery-assigner.views.assignments", + "claude-licence-manager.binding.changed" ] } -- 2.54.0 From 110c631b5f04c5411b5fa7739629ab8aab721454 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:42:11 +0100 Subject: [PATCH 14/32] V-3: a licence's token goes only to a machine a binding names (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vault's get judges a licence's secret by a rule of its own, before the asker rule: a grant goes to any proven caller, and an access token to a machine on which a consumer's binding in licence-bindings names that licence's key; a refusal names the rule and is recorded. Builds: design 39 bus-and-switch §16, §17, A1, B3, B8, B25 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/mesh-vault/licencebindings.go | 41 ++++++++++++++++++ .../cmd/mesh-vault/licences_test.go | 42 +++++++++++++++++++ modules/mesh-vault/cmd/mesh-vault/main.go | 3 +- modules/mesh-vault/cmd/mesh-vault/vault.go | 11 +++++ 4 files changed, 96 insertions(+), 1 deletion(-) diff --git a/modules/mesh-vault/cmd/mesh-vault/licencebindings.go b/modules/mesh-vault/cmd/mesh-vault/licencebindings.go index d1d09422a..bb85f20a5 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licencebindings.go +++ b/modules/mesh-vault/cmd/mesh-vault/licencebindings.go @@ -12,6 +12,7 @@ import ( "errors" "fmt" "sort" + "strings" "sync" "time" @@ -229,3 +230,43 @@ func watchLicenceBindings(lb LicenceBindings) bool { } return true } + +// licenceRefusal answers whether name is a licence's secret and, if it is, why the vault does not hand it to caller, +// empty when it does (§16, §17, A1, B3, B8, B25): a grant to any caller (B3), an access token to a machine on which a +// consumer's binding names that licence's key. The rule that a value goes only to a module under a bus account of +// its own does not apply to a licence's secrets (§17). +func (v *Vault) licenceRefusal(caller, name string) (bool, string) { + rest, ok := strings.CutPrefix(name, "licence.") + if !ok { + return false, "" + } + key, part := rest, "" + for _, p := range []string{LicenceGrant, LicenceAccess} { + if k, cut := strings.CutSuffix(rest, "."+p); cut { + key, part = k, p + } + } + if part == "" || !licenceKey.MatchString(key) { + return false, "" + } + if part == LicenceGrant { + return true, "" + } + machine, _, _ := strings.Cut(caller, ".") + if v.LicenceBindings == nil { + return true, "the vault keeps no licence bindings, so no binding names the licence " + key + } + consumers, err := v.LicenceBindings.Keys() + if err != nil { + return true, "the licence bindings cannot be read: " + err.Error() + } + for _, consumer := range consumers { + if c, _, _ := strings.Cut(consumer, "."); c != machine { + continue + } + if b, err := v.LicenceBindings.Get(consumer); err == nil && b != nil && b.Key == key { + return true, "" + } + } + return true, fmt.Sprintf("no consumer on %s is bound to the licence %s, and its token goes only to a machine a binding names", machine, key) +} diff --git a/modules/mesh-vault/cmd/mesh-vault/licences_test.go b/modules/mesh-vault/cmd/mesh-vault/licences_test.go index dafc5dc0c..373ad630a 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licences_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/licences_test.go @@ -56,3 +56,45 @@ func TestLicencePutKeepsTheNewestGenerationAndRefusesAnOlder(t *testing.T) { t.Error("licence-put took an unproven caller") } } + +// design 39 bus-and-switch §17/AC1, §16, B3, B8, B25: a licence's access token is refused to a machine no binding names +// and for a licence its binding does not name, and given after a binding.changed naming it, to any consumer of that +// machine; the grant goes to the manager's tool runner, with no machine guard. +func TestATokenIsGivenOnlyToAMachineABindingNames(t *testing.T) { + v := newVault(t, nil) + lb, _ := memLicenceBindings() + v.LicenceBindings = &lb + const manager = "novox.node-tools" + for _, key := range []string{"jane_at_example.org", "api"} { + for _, part := range []string{LicenceGrant, LicenceAccess} { + if _, err := putLicence(t, v, manager, key, part, 1, key+"-"+part); err != nil { + t.Fatal(err) + } + } + } + access := "licence.jane_at_example.org.access" + if _, _, err := asks(v, "g14.node-tools", access); err == nil || !strings.Contains(err.Error(), "no consumer on g14") { + t.Fatalf("a machine no binding names was handed the token: %v", err) + } + if err := lb.Heard([]byte(`{"consumer":"g14","licence":"api","key":"api","generation":4}`)); err != nil { + t.Fatal(err) + } + if _, _, err := asks(v, "g14.node-tools", access); err == nil { + t.Fatal("a licence its binding does not name was handed to g14") + } + if got, _, err := asks(v, "g14.node-tools", "licence.api.access"); err != nil || got != "api-access" { + t.Fatalf("g14's own licence: %q %v", got, err) + } + if err := lb.Heard([]byte(`{"consumer":"g14.worker-1","licence":"jane@example.org","key":"jane_at_example.org","generation":2}`)); err != nil { + t.Fatal(err) + } + if got, _, err := asks(v, "g14.node-tools", access); err != nil || got != "jane_at_example.org-access" { + t.Fatalf("after a binding.changed naming it: %q %v", got, err) + } + if _, _, err := asks(v, "ace.node-tools", access); err == nil { + t.Fatal("another machine was handed g14's token") + } + if got, _, err := asks(v, manager, "licence.jane_at_example.org.grant"); err != nil || got != "jane_at_example.org-grant" { + t.Fatalf("the manager's grant: %q %v", got, err) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index 2d2a1126b..129a44acc 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -58,7 +58,8 @@ func build() *Env { // The assignments as the vault's watch keeps them (watchAssignments), read over the bus until it has caught up. assigned = &WatchedAssignments{Fallback: readPlanRaw} watchedPlan := func(key string) ([]byte, error) { raw, err := assigned.Read(key); return asAssignment(key, raw, err) } - env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: watchedPlan, Confirm: readAssignment, Store: store}, + licenceBindings := busLicenceBindings() + env.Vault = &Vault{Store: store, Own: own, LicenceBindings: &licenceBindings, Askers: Declared{Read: watchedPlan, Confirm: readAssignment, Store: store}, Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask, Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave, WantRead: readWant, HaveKeys: func() ([]string, error) { return stdio.State(HaveRead).Keys() }, Assigner: seatAssignments, Signers: busSigners{}, diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index 85c0c6ccf..aac01cdf1 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault.go @@ -28,6 +28,9 @@ const ChangedEvent = "secret-changed" // Vault holds the secrets. type Vault struct { Store *Store + // LicenceBindings is the vault's own state of the licence bindings (licencebindings.go): which machine a licence's + // access token goes to. Nil hands it to no machine. + LicenceBindings *LicenceBindings // Own is the vault's own key; the private half never leaves its state directory. Own seal.KeyPair // Askers decides who may ask `get` for what. Nil allows nobody. @@ -386,8 +389,16 @@ func (v *Vault) Get(caller, name, publicKey string) (Answer, error) { _ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why}) return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why) } + isLicence, licenceWhy := v.licenceRefusal(caller, name) if isLogin { // Judged above: the node's own engine, sealed to its own key. + } else if isLicence { + // A licence's secret, by the licence rule (design 39 bus-and-switch §16, §17). + if licenceWhy != "" { + v.log("refused %s to %s: %s", name, caller, licenceWhy) + _ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: licenceWhy}) + return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, licenceWhy) + } } else if ok, why := askers.May(caller, name); !ok { if reason, notYet := strings.CutPrefix(why, notYetPrefix); notYet { // Not a refusal (issue 928): the client waits and asks again, and nothing is recorded against it. -- 2.54.0 From 6eccfc286aade9889db6e92d7a309a71f21a7fec Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:44:12 +0100 Subject: [PATCH 15/32] C-2: a node follows its licence binding by binding.changed and a first view (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent module consumes the manager's binding.changed, acting only on the one naming its node's consumer, and each time it hears those events anew asks the seat's bindings for its own consumer as its first view; its read of the manager's bindings state goes. Builds: design 39 bus-and-switch §22, B9, B10, B22 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-code/claude_code_test.go | 56 +++++++++++++++++++ .../cmd/claude-code/config_test.go | 9 +-- modules/claude-code/cmd/claude-code/main.go | 46 ++++++++------- modules/claude-code/cmd/claude-code/node.go | 49 ++++++++++++++++ .../claude-code/testdata/binding-changed.json | 1 + modules/claude-code/module.json | 9 ++- 6 files changed, 140 insertions(+), 30 deletions(-) create mode 100644 modules/claude-code/cmd/claude-code/testdata/binding-changed.json diff --git a/modules/claude-code/cmd/claude-code/claude_code_test.go b/modules/claude-code/cmd/claude-code/claude_code_test.go index e9d0db000..e5432bfc5 100644 --- a/modules/claude-code/cmd/claude-code/claude_code_test.go +++ b/modules/claude-code/cmd/claude-code/claude_code_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "reflect" + "slices" "strings" "testing" "time" @@ -524,3 +525,58 @@ func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) { t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p)) } } + +// design 39 bus-and-switch §22/AC1, B22: binding.changed, decoded from a copy of the manager's form, is acted on only +// when it names this node's consumer; an event for another consumer is ignored. +func TestAnEventForAnotherConsumerIsIgnored(t *testing.T) { + p, w := node(t, "server") + var asked []string + ask := seat(t, "api", "at-3", 3, &asked) + said, err := os.ReadFile("testdata/binding-changed.json") + if err != nil { + t.Fatal(err) + } + if done, err := OnBindingChanged(p, said, ask, writer(w)); err != nil || done != "" || len(asked) != 0 { + t.Fatalf("an event for laptop was acted on by server: %q %v, asked %v", done, err, asked) + } + p, w = node(t, "laptop") + if done, err := OnBindingChanged(p, said, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":3`) || len(asked) != 1 { + t.Fatalf("laptop's own event: %q %v, asked %v", done, err, asked) + } + if done, _ := OnBindingChanged(p, []byte(`{"consumer":"laptop","licence":"","generation":3,"from":"api"}`), ask, writer(w)); !strings.Contains(done, "released") { + t.Fatalf("a release said %q", done) + } +} + +// design 39 bus-and-switch §22, B10: at its start the module asks the seat's bindings for its own consumer and applies +// that first view; its manifest consumes binding.changed and no longer reads the manager's state. +func TestTheFirstViewOfTheBindingIsApplied(t *testing.T) { + p, w := node(t, "laptop") + var asked []string + current := seat(t, "api", "at-9", 9, &asked) + ask := func(address string, args any) (json.RawMessage, error) { + if address == SeatVerb("bindings") { + asked = append(asked, address) + if args.(map[string]any)["consumer"] != "laptop" { + t.Fatalf("bindings asked with %v", args) + } + return json.RawMessage(`[{"consumer":"laptop","licence":"api","key":"api","kind":"api-key","generation":9,"why":"carried over","by":"claude-licence-manager"}]`), nil + } + return current(address, args) + } + if done, err := FirstBinding(p, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":9`) { + t.Fatalf("the first view: %q %v, asked %v", done, err, asked) + } + if len(asked) != 2 || asked[0] != SeatVerb("bindings") || creds(t, p)["accessToken"] != "at-9" { + t.Fatalf("asked %v, credentials %v", asked, creds(t, p)) + } + raw, _ := os.ReadFile("../../module.json") + var m struct { + Consumes, Invokes []string + Reads []string + } + _ = json.Unmarshal(raw, &m) + if !slices.Contains(m.Consumes, BindingChanged) || !slices.Contains(m.Invokes, SeatVerb("bindings")) || len(m.Reads) != 0 { + t.Fatalf("the manifest consumes %v, invokes %v, reads %v", m.Consumes, m.Invokes, m.Reads) + } +} diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index bb985c3b7..d5e0da1d6 100644 --- a/modules/claude-code/cmd/claude-code/config_test.go +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -276,10 +276,11 @@ func TestTheManifestListsEveryToolServed(t *testing.T) { } _ = json.Unmarshal(raw, &m) // The hand-act seat's warranted and hand-act, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of - // the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. - if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool}) || - !slices.Equal(m.Reads, []string{"claude-licence-manager.bindings"}) || - !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", HoldingsAsked}) { + // the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. The + // licence binding is the seat's bindings and the manager's binding.changed (design 39 bus-and-switch §22, B9). + if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool, SeatVerb("bindings")}) || + len(m.Reads) != 0 || + !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", HoldingsAsked, BindingChanged}) { t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes) } if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) || diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 6c7580ebf..ebb8dc91f 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -613,29 +613,33 @@ func run(p Paths, view *ServerView, configView *ConfigView) { }) }, func(n int) { say("hearing %s%s", HoldingsAsked, refusals(n)) }) - // What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer - // generation is fetched with the seat's `current`, sealed to this module's key. - go persist("watching this node's licence binding", func() error { - return stdio.State(Manager+".bindings").Watch(p.Node, func(c stdio.StateChange) error { - if c.Key != p.Node { - return nil - } - var b *BindingState - if c.Op == "put" { - b = &BindingState{} - if err := json.Unmarshal(c.Value, b); err != nil { - return nil - } - } - if done, err := OnBinding(p, b, ask, writeManaged); err != nil { - say("fetching this node's token failed: %v", err) - } else if done != "" { - say("%s", done) - } - go report() + // What this node should hold (design 39 bus-and-switch §22): the manager's binding.changed naming this node's + // consumer, and its first view, the seat's bindings for this consumer, asked once each time the events are heard + // anew (the start, or a reconnect); a newer generation is fetched. + applied := func(done string, err error) { + if err != nil { + say("fetching this node's token failed: %v", err) + } else if done != "" { + say("%s", done) + } + go report() + } + go persist("hearing this node's licence binding", func() error { + err := stdio.Subscribe(BindingChanged, func(e stdio.Envelope) error { + applied(OnBindingChanged(p, e.Body, ask, writeManaged)) return nil }) - }, func(n int) { say("watching this node's licence binding%s", refusals(n)) }) + if err == nil { + go persist("asking this node's licence binding", func() error { + done, err := FirstBinding(p, ask, writeManaged) + if err == nil { + applied(done, nil) + } + return err + }, func(int) {}) + } + return err + }, func(n int) { say("hearing this node's licence binding%s", refusals(n)) }) report() var last string diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index 8b04d109c..f8d40a7c0 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -455,6 +455,55 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e return string(raw), nil } +// BindingChanged is the manager's event of a binding written or deleted (design 39 bus-and-switch §21), which this +// module consumes (§22). +const BindingChanged = Manager + ".binding.changed" + +// bindingOf is a consumer's binding as the manager says it, in binding.changed and in the verb bindings' answer: an +// empty licence is a release (B22: decoded from a copy of the writer's form, testdata/binding-changed.json). +type bindingOf struct { + Consumer string `json:"consumer"` + BindingState +} + +func (b bindingOf) state() *BindingState { + if b.Licence == "" { + return nil + } + return &b.BindingState +} + +// OnBindingChanged takes one binding.changed: acted on only when it names this node's consumer (§22). +func OnBindingChanged(p Paths, body []byte, ask Ask, write WriteManaged) (string, error) { + var b bindingOf + if err := json.Unmarshal(body, &b); err != nil { + return "", fmt.Errorf("%s is not a binding's change: %w", BindingChanged, err) + } + if b.Consumer != p.Node { + return "", nil + } + return OnBinding(p, b.state(), ask, write) +} + +// FirstBinding asks the seat's verb bindings for this node's consumer, its first view (§22, B10), and applies it: at +// the module's start, and again whenever it hears the manager's events anew. +func FirstBinding(p Paths, ask Ask, write WriteManaged) (string, error) { + raw, err := ask(SeatVerb("bindings"), map[string]any{"consumer": p.Node}) + if err != nil { + return "", err + } + var listed []bindingOf + if err := json.Unmarshal(raw, &listed); err != nil { + return "", fmt.Errorf("the seat's bindings answered what is not a list of bindings: %w", err) + } + for _, b := range listed { + if b.Consumer == p.Node { + return OnBinding(p, b.state(), ask, write) + } + } + return OnBinding(p, nil, ask, write) +} + // Apply applies what the seat handed over. A switch replaces the grant whole and cleans up after the old // licence; whatever it is, the file is written without a refresh token, so the agent here never refreshes. func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) { diff --git a/modules/claude-code/cmd/claude-code/testdata/binding-changed.json b/modules/claude-code/cmd/claude-code/testdata/binding-changed.json new file mode 100644 index 000000000..47485b980 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/testdata/binding-changed.json @@ -0,0 +1 @@ +{"at":"2026-10-04T12:00:00Z","by":"g14.node-tools","consumer":"laptop","from":"acct-1@example.org","generation":3,"key":"api","kind":"api-key","licence":"api","why":"the switch verb"} diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index c3e46ff70..b896660b0 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -28,7 +28,8 @@ "seat:mesh-catalog.seat-definitions", "mesh-delivery-indexer.indexer_read_modules", "mesh-delivery-indexer.indexer_read_manifests", - "mesh-delivery-assigner.assigner_read_assignments" + "mesh-delivery-assigner.assigner_read_assignments", + "seat:anthropic-licence-manager.bindings" ], "consumes": [ "mesh-delivery-indexer.views.modules", @@ -36,7 +37,8 @@ "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", - "claude-licence-manager.holdings.asked" + "claude-licence-manager.holdings.asked", + "claude-licence-manager.binding.changed" ], "state": [ "servers", @@ -52,9 +54,6 @@ "judged", "holdings-changed" ], - "reads": [ - "claude-licence-manager.bindings" - ], "tools": [ "claude_code_status", "claude_code_render", -- 2.54.0 From 31ce47d1cc7a0cf7c1454071934d202a42a6ea4a Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:51:00 +0100 Subject: [PATCH 16/32] C-3: a node takes its licence token from the vault (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent module asks the seat's current which licence it holds and its key, then the vault's get for that licence's access secret sealed to its own key: at its start, on a binding.changed with a newer generation than it applied, and on the vault's secret-changed for the licence it holds. The applied binding keeps the licence's key, so the vault's event is matched by the carried key, never one formed here. Builds: design 39 bus-and-switch §19, §18, B7, B9, B11 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-code/agent_account_test.go | 10 +- .../cmd/claude-code/claude_code_test.go | 86 +++++++++----- .../cmd/claude-code/config_test.go | 6 +- modules/claude-code/cmd/claude-code/main.go | 13 ++- modules/claude-code/cmd/claude-code/node.go | 107 +++++++++++++----- modules/claude-code/cmd/claude-code/render.go | 4 +- modules/claude-code/module.json | 7 +- 7 files changed, 157 insertions(+), 76 deletions(-) diff --git a/modules/claude-code/cmd/claude-code/agent_account_test.go b/modules/claude-code/cmd/claude-code/agent_account_test.go index ec597b7b1..66b213e67 100644 --- a/modules/claude-code/cmd/claude-code/agent_account_test.go +++ b/modules/claude-code/cmd/claude-code/agent_account_test.go @@ -457,13 +457,9 @@ func TestTheGuardIsAskedAboutEveryToolAndRefusesEachForAnotherAccount(t *testing func TestAMoveWritesTheAccountFileBesideTheToken(t *testing.T) { p, w := node(t, "anchor") writeFile(t, p.binding(), `{"licence":"personal","kind":"subscription","generation":4}`) - ask := func(address string, args any) (json.RawMessage, error) { - key := args.(map[string]any)["public_key"].(string) - g, _ := json.Marshal(Grant{AccessToken: "at", ExpiresAt: now + 3_600_000}) - box, _ := Seal(string(g), key) - return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 4, Sealed: &box, - Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}}) - } + var asked []string + ask := vaulted(t, Current{Licence: "personal", Key: "personal", Kind: "subscription", Generation: 4, + Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}}, "at", &asked) if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(w)); err != nil { t.Fatal(err) } diff --git a/modules/claude-code/cmd/claude-code/claude_code_test.go b/modules/claude-code/cmd/claude-code/claude_code_test.go index e5432bfc5..2f75f5e0b 100644 --- a/modules/claude-code/cmd/claude-code/claude_code_test.go +++ b/modules/claude-code/cmd/claude-code/claude_code_test.go @@ -283,31 +283,52 @@ func TestTheGrantAnswersOnlyAWaitingLoginSealedToTheManagersKey(t *testing.T) { } } -// seat answers `current` as the manager does: the grant sealed to the key the node sent. +// seat answers `current` as the manager does, the licence and its key and never a token, and `get` as the vault does: +// the licence's access secret sealed to the key the node sent (design 39 bus-and-switch §18, §19). func seat(t *testing.T, licence, token string, gen int64, asked *[]string) Ask { + return vaulted(t, Current{Licence: licence, Key: licence, Kind: "subscription", Generation: gen}, token, asked) +} + +// vaulted answers `current` with c and the vault's `get` of c's access secret with token. +func vaulted(t *testing.T, c Current, token string, asked *[]string) Ask { return func(address string, args any) (json.RawMessage, error) { *asked = append(*asked, address) - key := args.(map[string]any)["public_key"].(string) - g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000}) - box, err := Seal(string(g), key) - if err != nil { - t.Fatal(err) + a := args.(map[string]any) + switch address { + case SeatVerb("current"): + if _, sent := a["public_key"]; sent || a["consumer"] == nil { + t.Fatalf("current asked with %v", a) + } + return json.Marshal(c) + case VaultGet: + if a["name"] != AccessSecret(c.Key) { + t.Fatalf("the vault asked for %v", a["name"]) + } + // Each answer a little later, as each rotation hands a later token. + g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000 + int64(len(*asked))*1000}) + box, err := Seal(string(g), a["public_key"].(string)) + if err != nil { + t.Fatal(err) + } + return json.Marshal(map[string]any{"name": a["name"], "generation": 1, "sealed": box}) } - return json.Marshal(Current{Licence: licence, Kind: "subscription", Generation: gen, Sealed: &box}) + t.Fatalf("asked %s", address) + return nil, nil } } -func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) { +// design 39 bus-and-switch §19: a newer generation asks the seat which licence, and the vault for its token. +func TestANewerGenerationFetchesTheTokenOnceFromTheVault(t *testing.T) { p, w := node(t, "laptop") var asked []string ask := seat(t, "personal", "at-1", 3, &asked) if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); err != nil { t.Fatal(err) } - if len(asked) != 1 || asked[0] != "seat:anthropic-licence-manager.current" || creds(t, p)["accessToken"] != "at-1" { + if len(asked) != 2 || asked[0] != SeatVerb("current") || asked[1] != VaultGet || creds(t, p)["accessToken"] != "at-1" { t.Fatalf("asked %v, credentials %v", asked, creds(t, p)) } - if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 1 { + if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 2 { t.Fatal("an equal generation asked again") } if HoldingsOf(p).Generation != 3 || w["managed-mcp.json"] == "" { @@ -498,31 +519,31 @@ func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) { } } -// A manager whose store was rebuilt counts generations from one again (novox/hq issue 243): a binding with a -// lower generation than the one applied is still a binding to apply, and only the one applied is skipped. -func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) { +// design 39 bus-and-switch §19, B11: only a newer generation than the one applied is fetched; a lower one is not, and +// the vault's secret-changed for the licence held asks its token again, for another secret nothing. +func TestTheTokenIsAskedOnANewerGenerationAndOnTheVaultsChange(t *testing.T) { p, w := node(t, "laptop") var asked []string if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 16}, seat(t, "personal", "at-old", 16, &asked), writer(w)); err != nil { t.Fatal(err) } - // The rotation that came with it: a later token, as a refresh hands one over. - later := func(address string, args any) (json.RawMessage, error) { - asked = append(asked, address) - g, _ := json.Marshal(Grant{AccessToken: "at-new", ExpiresAt: now + 7_200_000}) - box, err := Seal(string(g), args.(map[string]any)["public_key"].(string)) - if err != nil { - t.Fatal(err) - } - return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 3, Sealed: &box}) + if done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, + seat(t, "personal", "at-low", 3, &asked), writer(w)); err != nil || done != "" || len(asked) != 2 { + t.Fatalf("a lower generation was fetched: %q %v, asked %v", done, err, asked) } - if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, - later, writer(w)); err != nil { + rotated := seat(t, "personal", "at-new", 16, &asked) + for _, other := range []string{`{"name":"licence.personal.grant","generation":5}`, `{"name":"licence.other.access","generation":5}`} { + if done, err := OnSecretChanged(p, []byte(other), rotated, writer(w)); err != nil || done != "" || len(asked) != 2 { + t.Fatalf("%s asked again: %q %v, asked %v", other, done, err, asked) + } + } + if _, err := OnSecretChanged(p, []byte(`{"name":"licence.personal.access","generation":5,"by":"x","at":"2026-11-11T00:00:00Z"}`), + rotated, writer(w)); err != nil { t.Fatal(err) } - if len(asked) != 2 || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 3 { - t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p)) + if len(asked) != 4 || asked[3] != VaultGet || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 16 { + t.Fatalf("asked %v, credentials %v: the vault's change was not applied", asked, creds(t, p)) } } @@ -540,7 +561,7 @@ func TestAnEventForAnotherConsumerIsIgnored(t *testing.T) { t.Fatalf("an event for laptop was acted on by server: %q %v, asked %v", done, err, asked) } p, w = node(t, "laptop") - if done, err := OnBindingChanged(p, said, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":3`) || len(asked) != 1 { + if done, err := OnBindingChanged(p, said, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":3`) || len(asked) != 2 { t.Fatalf("laptop's own event: %q %v, asked %v", done, err, asked) } if done, _ := OnBindingChanged(p, []byte(`{"consumer":"laptop","licence":"","generation":3,"from":"api"}`), ask, writer(w)); !strings.Contains(done, "released") { @@ -553,7 +574,7 @@ func TestAnEventForAnotherConsumerIsIgnored(t *testing.T) { func TestTheFirstViewOfTheBindingIsApplied(t *testing.T) { p, w := node(t, "laptop") var asked []string - current := seat(t, "api", "at-9", 9, &asked) + current := vaulted(t, Current{Licence: "api", Key: "api", Kind: "subscription", Generation: 9}, "at-9", &asked) ask := func(address string, args any) (json.RawMessage, error) { if address == SeatVerb("bindings") { asked = append(asked, address) @@ -567,16 +588,21 @@ func TestTheFirstViewOfTheBindingIsApplied(t *testing.T) { if done, err := FirstBinding(p, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":9`) { t.Fatalf("the first view: %q %v, asked %v", done, err, asked) } - if len(asked) != 2 || asked[0] != SeatVerb("bindings") || creds(t, p)["accessToken"] != "at-9" { + if len(asked) != 3 || asked[0] != SeatVerb("bindings") || creds(t, p)["accessToken"] != "at-9" { t.Fatalf("asked %v, credentials %v", asked, creds(t, p)) } + // §19: at every start the vault is asked, though the binding is the one applied. + if _, err := FirstBinding(p, ask, writer(w)); err != nil || len(asked) != 6 || asked[5] != VaultGet { + t.Fatalf("a start asked %v", asked) + } raw, _ := os.ReadFile("../../module.json") var m struct { Consumes, Invokes []string Reads []string } _ = json.Unmarshal(raw, &m) - if !slices.Contains(m.Consumes, BindingChanged) || !slices.Contains(m.Invokes, SeatVerb("bindings")) || len(m.Reads) != 0 { + if !slices.Contains(m.Consumes, BindingChanged) || !slices.Contains(m.Consumes, SecretChanged) || + !slices.Contains(m.Invokes, SeatVerb("bindings")) || !slices.Contains(m.Invokes, VaultGet) || len(m.Reads) != 0 { t.Fatalf("the manifest consumes %v, invokes %v, reads %v", m.Consumes, m.Invokes, m.Reads) } } diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index d5e0da1d6..701beb5df 100644 --- a/modules/claude-code/cmd/claude-code/config_test.go +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -277,10 +277,10 @@ func TestTheManifestListsEveryToolServed(t *testing.T) { _ = json.Unmarshal(raw, &m) // The hand-act seat's warranted and hand-act, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of // the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. The - // licence binding is the seat's bindings and the manager's binding.changed (design 39 bus-and-switch §22, B9). - if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool, SeatVerb("bindings")}) || + // licence binding is the seat's bindings and the manager's binding.changed, its token the vault's get and secret-changed (design 39 bus-and-switch §19, §22, B9). + if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool, SeatVerb("bindings"), SeatVerb("current"), VaultGet}) || len(m.Reads) != 0 || - !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", HoldingsAsked, BindingChanged}) { + !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", HoldingsAsked, BindingChanged, SecretChanged}) { t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes) } if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) || diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index ebb8dc91f..39efa0332 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -346,7 +346,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c return GuardStatus(p, last), nil }}, {Name: "claude_code_pull", - Description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its binding to change.", + Description: "Ask the licence manager which licence this node holds and the vault for its access token now, and apply it, rather than waiting for its binding or the token to change.", Run: func(map[string]any) (any, error) { return Pull(p, ask, writeManaged) }}, {Name: "claude_code_grant", Description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.", @@ -613,9 +613,9 @@ func run(p Paths, view *ServerView, configView *ConfigView) { }) }, func(n int) { say("hearing %s%s", HoldingsAsked, refusals(n)) }) - // What this node should hold (design 39 bus-and-switch §22): the manager's binding.changed naming this node's + // What this node should hold (design 39 bus-and-switch §19, §22): the manager's binding.changed naming this node's // consumer, and its first view, the seat's bindings for this consumer, asked once each time the events are heard - // anew (the start, or a reconnect); a newer generation is fetched. + // anew (the start, or a reconnect); the token is asked from the vault at that first view and on a newer generation. applied := func(done string, err error) { if err != nil { say("fetching this node's token failed: %v", err) @@ -640,6 +640,13 @@ func run(p Paths, view *ServerView, configView *ConfigView) { } return err }, func(n int) { say("hearing this node's licence binding%s", refusals(n)) }) + // The token of the licence this node holds, asked again when the vault says its access secret changed (§19). + go persist("hearing the vault's changes of this node's licence token", func() error { + return stdio.Subscribe(SecretChanged, func(e stdio.Envelope) error { + applied(OnSecretChanged(p, e.Body, ask, writeManaged)) + return nil + }) + }, func(n int) { say("hearing %s%s", SecretChanged, refusals(n)) }) report() var last string diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index f8d40a7c0..6c2117ea5 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -10,8 +10,10 @@ package main // fingerprints and expiries — never a token. Written at start and on every change of the credentials // file, so the licence manager learns a login, or a node already logged in, from the state alone. // - The grant itself leaves only when the manager asks `claude_code_grant`, sealed to the key it gives. -// - What this node should hold is the manager's `bindings` state; a newer generation for this node is -// fetched with the seat's `current` verb, sealed to this module's key, and written access-token-only. +// - What this node should hold is the manager's binding, heard as `binding.changed` and asked at start; the +// seat's `current` says which licence, and its `access` secret is asked from the vault, sealed to this +// module's key, at start, on a newer generation and on the vault's `secret-changed` for it, and written +// access-token-only (design 39 bus-and-switch §19). // - An MCP server registered through this module is a key in its `servers` state — `all.` for // every node, `.` for one — which every node watches. @@ -30,12 +32,23 @@ import ( "time" ) -// Seat is the licence manager's role, and Manager the module whose `bindings` state this one reads. +// Seat is the licence manager's role, and Manager the module whose events this one consumes. const ( Seat = "anthropic-licence-manager" Manager = "claude-licence-manager" ) +// VaultGet is the vault's verb a licence's access secret is asked by, and SecretChanged the vault's event that it +// changed (design 39 bus-and-switch §19, B9). +const ( + VaultGet = "seat:mesh-vault.get" + SecretChanged = "mesh-vault.secret-changed" +) + +// AccessSecret is the name of a licence's access secret in the vault, by the licence's key as the manager says it +// (§15, B7: the key is carried, never formed here). +func AccessSecret(key string) string { return "licence." + key + ".access" } + // SeatVerb is a seat's verb as the runtime addresses it: a role, not a module. func SeatVerb(verb string) string { return "seat:" + Seat + "." + verb } @@ -309,13 +322,14 @@ type BindingState struct { By string `json:"by,omitempty"` } -// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed. +// Current is what the seat answers to `current`: the licence this node is bound to, its key and its account, never +// its token (§18, B7). type Current struct { - Licence string `json:"licence"` - Kind string `json:"kind"` - Generation int64 `json:"generation"` - Sealed *SealedBox `json:"sealed"` - Identity *Identity `json:"identity"` + Licence string `json:"licence"` + Key string `json:"key"` + Kind string `json:"kind"` + Generation int64 `json:"generation"` + Identity *Identity `json:"identity"` } // Holdings is what this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager @@ -404,40 +418,50 @@ func GrantFor(p Paths, managerPublicKey string) (GrantAnswer, error) { return GrantAnswer{Sealed: &box, Identity: ReadIdentity(p.account()), Fingerprint: Fingerprint(rt)}, nil } -// Pull asks the seat for this node's current token and applies it. +// Pull asks the seat which licence this node holds, then the vault for that licence's access secret, sealed to this +// module's key, and applies it (§19). func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) { keys, err := Keypair(p) if err != nil { return nil, err } - raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node, "public_key": keys.PublicKey}) + raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node}) if err != nil { return nil, err } var c Current - if err := json.Unmarshal(raw, &c); err != nil || c.Sealed == nil { + if err := json.Unmarshal(raw, &c); err != nil || c.Licence == "" || c.Key == "" { return map[string]any{"applied": false, "reason": "the seat holds no licence for this node"}, nil } - return Apply(p, c, write) + raw, err = ask(VaultGet, map[string]any{"name": AccessSecret(c.Key), "public_key": keys.PublicKey}) + if err != nil { + return nil, err + } + var got struct { + Sealed *SealedBox `json:"sealed"` + } + if err := json.Unmarshal(raw, &got); err != nil || got.Sealed == nil { + return nil, fmt.Errorf("the vault answered no sealed %s", AccessSecret(c.Key)) + } + return Apply(p, c, *got.Sealed, write) } -// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is -// fetched when the binding differs from the one applied. A released binding keeps the last token, which -// lives hours, and says so. -// -// **Differs, not "is newer"** (novox/hq issue 243). The state keeps only the latest value per node, so -// nothing older can arrive. A manager whose store was rebuilt counts generations from one again, and -// a node that waited for a number above its own ignored every binding it was sent, its login and the -// licence's rotations included, until the count caught up. Only the binding already applied is skipped. +// OnBinding takes this node's binding as the manager said it: the token is fetched when its generation is newer than +// the one applied (§19). A home that holds no token of the applied kind has nothing applied, so it is fetched too. A +// released binding keeps the last token, which lives hours, and says so. func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) { if b == nil { return "this node's binding was released; it keeps its last token until it expires", nil } var applied Binding - if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence && - holdsApplied(p, applied.Kind) { + if readJSON(p.binding(), &applied) && b.Generation <= applied.Generation && holdsApplied(p, applied.Kind) { return "", nil } + return fetched(p, b, ask, write) +} + +// fetched pulls this node's token for binding b and says the line naming it. +func fetched(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) { out, err := Pull(p, ask, write) if err != nil { return "", err @@ -497,21 +521,44 @@ func FirstBinding(p Paths, ask Ask, write WriteManaged) (string, error) { return "", fmt.Errorf("the seat's bindings answered what is not a list of bindings: %w", err) } for _, b := range listed { - if b.Consumer == p.Node { - return OnBinding(p, b.state(), ask, write) + if b.Consumer == p.Node && b.state() != nil { + // At its start the module asks the vault whatever it applied before (§19). + return fetched(p, b.state(), ask, write) } } return OnBinding(p, nil, ask, write) } -// Apply applies what the seat handed over. A switch replaces the grant whole and cleans up after the old -// licence; whatever it is, the file is written without a refresh token, so the agent here never refreshes. -func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) { +// secretChanged is the vault's `secret-changed` as it says it: the secret's name and generation, never its value. +type secretChanged struct { + Name string `json:"name"` + Generation int64 `json:"generation"` +} + +// OnSecretChanged takes one `secret-changed`: acted on only when it names the access secret of the licence this node +// holds, which is then asked again (§19). +func OnSecretChanged(p Paths, body []byte, ask Ask, write WriteManaged) (string, error) { + var e secretChanged + if err := json.Unmarshal(body, &e); err != nil { + return "", fmt.Errorf("%s is not a secret's change: %w", SecretChanged, err) + } + var applied Binding + if !readJSON(p.binding(), &applied) || applied.Key == "" || e.Name != AccessSecret(applied.Key) { + return "", nil + } + return fetched(p, &BindingState{Licence: applied.Licence, Kind: applied.Kind, Generation: applied.Generation, + Why: "the vault's " + e.Name + " changed"}, ask, write) +} + +// Apply applies the access secret the vault handed over for the licence the seat named. A switch replaces the grant +// whole and cleans up after the old licence; whatever it is, the file is written without a refresh token, so the +// agent here never refreshes. +func Apply(p Paths, c Current, sealed SealedBox, write WriteManaged) (map[string]any, error) { keys, err := Keypair(p) if err != nil { return nil, err } - plain, err := Open(*c.Sealed, keys.PrivateKey) + plain, err := Open(sealed, keys.PrivateKey) if err != nil { return nil, err } @@ -571,7 +618,7 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) { if gen == 0 { gen = previous.Generation } - raw, _ := json.Marshal(Binding{Licence: c.Licence, Kind: c.Kind, Generation: gen}) + raw, _ := json.Marshal(Binding{Licence: c.Licence, Key: c.Key, Kind: c.Kind, Generation: gen}) if err := fsys.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil { return nil, err } diff --git a/modules/claude-code/cmd/claude-code/render.go b/modules/claude-code/cmd/claude-code/render.go index 539208648..49cb8a3ab 100644 --- a/modules/claude-code/cmd/claude-code/render.go +++ b/modules/claude-code/cmd/claude-code/render.go @@ -50,7 +50,9 @@ type Settings struct { // Binding is the licence this node holds, as it was last applied. type Binding struct { - Licence string `json:"licence"` + Licence string `json:"licence"` + // Key is the licence's key as the manager says it, which names its access secret in the vault (§19, B7). + Key string `json:"key,omitempty"` Kind string `json:"kind"` // subscription | api-key Generation int64 `json:"generation,omitempty"` } diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index b896660b0..80ef182df 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -29,7 +29,9 @@ "mesh-delivery-indexer.indexer_read_modules", "mesh-delivery-indexer.indexer_read_manifests", "mesh-delivery-assigner.assigner_read_assignments", - "seat:anthropic-licence-manager.bindings" + "seat:anthropic-licence-manager.bindings", + "seat:anthropic-licence-manager.current", + "seat:mesh-vault.get" ], "consumes": [ "mesh-delivery-indexer.views.modules", @@ -38,7 +40,8 @@ "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "claude-licence-manager.holdings.asked", - "claude-licence-manager.binding.changed" + "claude-licence-manager.binding.changed", + "mesh-vault.secret-changed" ], "state": [ "servers", -- 2.54.0 From bd65b023bdcbe2b633634cf3fd9fe6d8e5b9d639 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:53:10 +0100 Subject: [PATCH 17/32] M-10: the manager hears what each node holds as holdings-changed (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manager consumes the agent module's holdings-changed, keeping each report as its node's latest and considering them at once, in place of reading the agent module's state; once it hears them, at every start after its carry-over, it says holdings.asked with its time and who asks, so every node says what it holds again. Builds: design 39 bus-and-switch §23, §46, B12, B22 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/holdings.go | 68 +++++++++++++++++++ .../cmd/claude-licence-manager/main.go | 62 ++++++----------- .../claude-licence-manager/manager_test.go | 34 +++++++--- .../testdata/holdings-changed.json | 1 + 4 files changed, 114 insertions(+), 51 deletions(-) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/holdings.go create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/testdata/holdings-changed.json diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/holdings.go b/modules/claude-licence-manager/cmd/claude-licence-manager/holdings.go new file mode 100644 index 000000000..1925b9613 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/holdings.go @@ -0,0 +1,68 @@ +package main + +// What every node holds, heard as events (design 39 bus-and-switch §23, §46, B12): the agent module says +// `holdings-changed` with its holdings report whole, at its start, on every change and when asked; the manager says +// `holdings.asked` at every start, after its carry-over, and on its resync, and takes each report it hears as that +// node's latest. + +import ( + "encoding/json" + "fmt" + "sort" + "sync" + "time" +) + +// HoldingsChanged is the agent module's event saying what one node holds, which the manager consumes. +const HoldingsChanged = "claude-code.holdings-changed" + +// HoldingsAsked is the manager's event asking every node to say what it holds again. +const HoldingsAsked = "holdings.asked" + +// Heard is what every node last said it holds, by node. +type Heard struct { + mu sync.Mutex + reports map[string]Holdings +} + +// Take keeps one `holdings-changed` as its node's latest report. +func (h *Heard) Take(body []byte) (Holdings, error) { + var r Holdings + if err := json.Unmarshal(body, &r); err != nil { + return r, fmt.Errorf("%s is not a holdings report: %w", HoldingsChanged, err) + } + if r.Node == "" { + return r, fmt.Errorf("%s names no node", HoldingsChanged) + } + h.mu.Lock() + defer h.mu.Unlock() + if h.reports == nil { + h.reports = map[string]Holdings{} + } + h.reports[r.Node] = r + return r, nil +} + +// All is every node's latest report, by node's name. +func (h *Heard) All() []Holdings { + h.mu.Lock() + all := make([]Holdings, 0, len(h.reports)) + for _, r := range h.reports { + all = append(all, r) + } + h.mu.Unlock() + sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node }) + return all +} + +// Of is the generation node last reported applying. +func (h *Heard) Of(node string) int64 { + h.mu.Lock() + defer h.mu.Unlock() + return h.reports[node].Generation +} + +// AskHoldings says `holdings.asked`, its time and who asks (§23). +func (m *Manager) AskHoldings(by string) error { + return m.Emit(HoldingsAsked, map[string]any{"at": m.Now().UTC().Format(time.RFC3339Nano), "by": by}) +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index dc3a4292e..ec5e2015e 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -13,7 +13,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "sync" "time" @@ -87,11 +86,7 @@ func manager() (*Manager, error) { } return a, json.Unmarshal(raw, &a) }, - Reported: func(node string) int64 { - heardMu.Lock() - defer heardMu.Unlock() - return heard[node].Generation - }, + Reported: heard.Of, Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, Now: time.Now, Log: say, @@ -128,11 +123,8 @@ func keypair(dir string) (KeyPair, error) { // ---- the long-running half --------------------------------------------------------------------------- -// heard is what every node last reported holding (ADR 0206), by node. -var ( - heardMu sync.Mutex - heard = map[string]Holdings{} -) +// heard is what every node last said it holds (ADR 0206, design 39 bus-and-switch §23), by node. +var heard = &Heard{} // carryOverFirst runs the carry-over at the start, before the manager hears an event, refreshes or reads usage (§11), and // answers once it has ended: at once when `carried.postgres` exists, else when a run writes it, this one or one asked @@ -153,7 +145,6 @@ func carryOverFirst() { func daemon() { carryOverFirst() - mu, reports := &heardMu, heard var passing sync.Mutex pass := func() { passing.Lock() // one pass at a time: each account is leased, and a pass is cheap @@ -163,14 +154,7 @@ func daemon() { say("not ready: %v", err) return } - mu.Lock() - all := make([]Holdings, 0, len(reports)) - for _, r := range reports { - all = append(all, r) - } - mu.Unlock() - sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node }) - adopted, err := m.Consider(context.Background(), all) + adopted, err := m.Consider(context.Background(), heard.All()) if err != nil { say("considering the reports failed: %v", err) } @@ -179,42 +163,34 @@ func daemon() { } } - // What every node holds (ADR 0206): the whole current set, then each change. Asked again until it - // answers — the channel to the runtime opens as the bundle starts, and the agent module's state may - // arrive after this one. + // What every node holds (ADR 0206, design 39 bus-and-switch §23): each `holdings-changed` heard is that node's + // latest report and starts a pass; once heard, every node is asked to say it again (B12: at every start, after the + // carry-over). Asked again until it answers — the channel to the runtime opens as the bundle starts. go func() { waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second} for attempt := 0; ; attempt++ { - err := stdio.State("claude-code.holdings").Watch("", func(c stdio.StateChange) error { - mu.Lock() - if c.Op == "put" { - var h Holdings - if json.Unmarshal(c.Value, &h) == nil { - reports[c.Key] = h - } - } else { - delete(reports, c.Key) - } - mu.Unlock() - // During the current values the pass waits for the whole set: newest login first needs all. - if !c.Current { - go pass() + err := stdio.Subscribe(HoldingsChanged, func(e stdio.Envelope) error { + if _, err := heard.Take(e.Body); err != nil { + say("%v", err) + return nil } + go pass() return nil }) if err == nil { - mu.Lock() - n := len(reports) - mu.Unlock() - say("watching what %d node(s) hold", n) - pass() + say("hearing what the nodes hold") + if m, err := manager(); err != nil { + say("not ready to ask what the nodes hold: %v", err) + } else if err := m.AskHoldings(Itself); err != nil { + say("asking what the nodes hold could not be said: %v", err) + } return } pause := time.Minute if attempt < len(waits) { pause = waits[attempt] } - say("what the nodes hold cannot be watched yet (%v); asking again in %s", err, pause) + say("what the nodes hold cannot be heard yet (%v); asking again in %s", err, pause) time.Sleep(pause) } }() diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index b2ec649cd..c0e5da2a6 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -388,22 +388,40 @@ func TestANodeThatDoesNotAnswerIsAskedAgainAndOneWithNoLoginIsSettled(t *testing } } -func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) { - // The agent module's own report shape (claude-code's holdingsOf), parsed here. - raw := `{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription", - "refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null}, - "access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":0, - "changedAt":"2026-10-04T11:00:00.000Z"}` - var h Holdings - if err := json.Unmarshal([]byte(raw), &h); err != nil { +// design 39 bus-and-switch §23, §46, B12, B22: a holdings-changed, decoded from a copy of the agent module's form, is +// kept as its node's latest report, and one carrying a fingerprint the manager does not hold is a candidate; the +// manager asks every node with holdings.asked, its time and who asks. +func TestAHoldingsChangedIsHeardAndMakesACandidate(t *testing.T) { + mm := newMesh(t) + raw, err := os.ReadFile("testdata/holdings-changed.json") + if err != nil { t.Fatal(err) } + heard := &Heard{} + if _, err := heard.Take(raw); err != nil { + t.Fatal(err) + } + all := heard.All() + if len(all) != 1 || heard.Of("laptop") != 4 { + t.Fatalf("heard %+v", all) + } + h := all[0] if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || !h.Refresh.Present || h.Refresh.Fingerprint != "sha256:0123456789abcdef" { t.Fatalf("%+v", h) } if _, err := time.Parse(time.RFC3339Nano, h.ChangedAt); err != nil { t.Fatalf("the report's time does not parse: %v", err) } + if c, err := mm.m.CandidatesIn(context.Background(), all); err != nil || len(c["u-1"]) != 1 { + t.Fatalf("candidates %v %v", c, err) + } + if _, err := heard.Take([]byte(`{"kind":"subscription"}`)); err == nil { + t.Fatal("a report naming no node was kept") + } + if err := mm.m.AskHoldings(Itself); err != nil || len(mm.events) != 1 || + mm.events[0] != HoldingsAsked+` {"at":"`+t0.UTC().Format(time.RFC3339Nano)+`","by":"claude-licence-manager"}` { + t.Fatalf("said %v %v", mm.events, err) + } } // A node whose report arrives after its account was adopted — with an older login, so never a candidate — diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/testdata/holdings-changed.json b/modules/claude-licence-manager/cmd/claude-licence-manager/testdata/holdings-changed.json new file mode 100644 index 000000000..2a45f1859 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/testdata/holdings-changed.json @@ -0,0 +1 @@ +{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription","refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null},"access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":4,"changedAt":"2026-10-04T11:00:00.000Z"} -- 2.54.0 From 6cf98386e91263c9163386e9c6339b5fd68e7da7 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:55:22 +0100 Subject: [PATCH 18/32] M-11: each reading keeps its three reset times, and a licence's room (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The usage reading flattens the session, weekly and sonnet windows each with its reset time, keeps it beside the licence with when it was read, and says it as usage.read; a failed answer keeps the last percentages and writes the vendor's status and reason. A licence's room is 100 less the higher of its session and weekly windows, known only for a subscription read within usage_stale_minutes whose refresh is healthy; an API key's is never known. Builds: design 39 bus-and-switch §24, §25, §48, §2 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/manager.go | 29 ++++- .../claude-licence-manager/manager_test.go | 9 ++ .../cmd/claude-licence-manager/room.go | 26 +++++ .../cmd/claude-licence-manager/room_test.go | 105 ++++++++++++++++++ .../cmd/claude-licence-manager/vendor.go | 34 ++++-- 5 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/room.go create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index ead306c05..420afb5b3 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -130,10 +130,13 @@ type Settings struct { FailuresToNotify int Cooldown time.Duration // at most one notification per licence in this window RefreshWarn time.Duration // warn this long before a refresh token itself expires + // UsageStale is how old a licence's latest successful reading may be for its room to be known (§25, §33). + UsageStale time.Duration } // Defaults are the settings a fresh mesh runs with. -var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, RefreshWarn: 72 * time.Hour} +var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, + RefreshWarn: 72 * time.Hour, UsageStale: 15 * time.Minute} // SettingsFrom reads the settings file, keeping a default for anything absent or not positive. func SettingsFrom(path string) Settings { @@ -693,7 +696,21 @@ func (m *Manager) ReadUsage(ctx context.Context) error { } raw, err := m.Vendor.Usage(ctx, g.AccessToken) g = FullGrant{} - if err != nil || raw == nil { + if err == nil && raw == nil { + err = errors.New("the usage endpoint answered nothing") + } + if err != nil { + // A failed reading keeps the last percentages and writes the vendor's status and reason (§24). + kept := UsageReading{} + if l.Usage != nil { + kept = *l.Usage + } + kept.ReadError = err.Error() + l.Usage = &kept + if err := m.Store.SaveLicence(ctx, l); err != nil { + return err + } + m.Log("reading the usage of %s failed: %v", l.Name, err) continue } reading := FlattenUsage(raw) @@ -701,12 +718,16 @@ func (m *Manager) ReadUsage(ctx context.Context) error { return err } // The latest reading beside its licence, so licences answers from one key (§2). - l.Usage = &reading + latest := reading + latest.ReadAt = m.Now().UTC().Format(time.RFC3339Nano) + l.Usage = &latest if err := m.Store.SaveLicence(ctx, l); err != nil { return err } _ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct, - "weeklyPct": reading.WeeklyPct, "sonnetPct": reading.SonnetPct}) + "sessionResetsAt": reading.SessionResetsAt, "weeklyPct": reading.WeeklyPct, + "weeklyResetsAt": reading.WeeklyResetsAt, "sonnetPct": reading.SonnetPct, + "sonnetResetsAt": reading.SonnetResetsAt}) } return nil } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index c0e5da2a6..648f740cc 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -22,6 +22,9 @@ type stubVendor struct { issued int account string now func() time.Time + // usage is what the usage endpoint answers, and usageErr its failure; nil answers a session window alone. + usage map[string]any + usageErr error } func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed { @@ -44,6 +47,12 @@ func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed { } func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) { + if v.usageErr != nil { + return nil, v.usageErr + } + if v.usage != nil { + return v.usage, nil + } return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/room.go b/modules/claude-licence-manager/cmd/claude-licence-manager/room.go new file mode 100644 index 000000000..caa24142f --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/room.go @@ -0,0 +1,26 @@ +package main + +// A licence's room (design 39 bus-and-switch §25, §48): 100 less the higher of its session and weekly percentages in +// its latest reading, known only for a subscription read successfully within usage_stale_minutes, whose refresh has not +// failed failures_to_notify times in a row and whose refresh token has not expired. An API key's room is never known, +// so it is never a target. + +import ( + "math" + "time" +) + +// Room is a licence's room at now, and whether it is known. +func Room(l Licence, now time.Time, s Settings) (float64, bool) { + if l.Kind != "subscription" || l.Usage == nil || l.Usage.SessionPct == nil || l.Usage.WeeklyPct == nil { + return 0, false + } + read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt) + if err != nil || now.Sub(read) > s.UsageStale { + return 0, false + } + if l.Failures >= s.FailuresToNotify || (l.RefreshExpiresAt != 0 && l.RefreshExpiresAt <= now.UnixMilli()) { + return 0, false + } + return 100 - math.Max(*l.Usage.SessionPct, *l.Usage.WeeklyPct), true +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go new file mode 100644 index 000000000..9f8f5b5b8 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +// vendorUsage is the vendor's usage answer as it reads: three windows, each with its reset time. +var vendorUsage = map[string]any{ + "five_hour": map[string]any{"utilization": 41.0, "resets_at": "2026-11-11T23:00:00Z"}, + "seven_day": map[string]any{"utilization": 97.0, "resets_at": "2026-11-14T09:00:00Z"}, + "seven_day_sonnet": map[string]any{"utilization": 12.0, "resets_at": "2026-11-15T09:00:00Z"}, +} + +// design 39 bus-and-switch §24/AC1: a vendor answer is flattened with its three reset times, kept beside the licence +// with when it was read, and said as usage.read with those fields. +func TestAReadingIsFlattenedWithThreeResets(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + mm.vendor.usage = vendorUsage + mm.events = nil + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatal(err) + } + l, _ := mm.store.Licence(ctx, licence1) + u := l.Usage + if u == nil || *u.SessionPct != 41 || u.SessionResetsAt != "2026-11-11T23:00:00Z" || *u.WeeklyPct != 97 || + u.WeeklyResetsAt != "2026-11-14T09:00:00Z" || *u.SonnetPct != 12 || u.SonnetResetsAt != "2026-11-15T09:00:00Z" || + u.ReadAt != t0.UTC().Format(time.RFC3339Nano) || u.ReadError != "" { + t.Fatalf("kept %+v", u) + } + said := strings.Join(mm.events, "\n") + for _, want := range []string{`"weeklyResetsAt":"2026-11-14T09:00:00Z"`, `"sonnetResetsAt":"2026-11-15T09:00:00Z"`, + `"sessionResetsAt":"2026-11-11T23:00:00Z"`} { + if !strings.Contains(said, "usage.read ") || !strings.Contains(said, want) { + t.Fatalf("said %s, lacking %s", said, want) + } + } +} + +// design 39 bus-and-switch §24/AC2: a failed answer keeps the last percentages and their time, writes the vendor's +// status and reason, and appends no reading. +func TestAFailedReadingKeepsThePercentages(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + mm.vendor.usage = vendorUsage + _ = mm.m.ReadUsage(ctx) + mm.now = t0.Add(5 * time.Minute) + mm.vendor.usageErr = errors.New(`the usage endpoint answered 429: {"error":"rate_limited"}`) + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatal(err) + } + l, _ := mm.store.Licence(ctx, licence1) + u := l.Usage + if u == nil || *u.WeeklyPct != 97 || u.ReadAt != t0.UTC().Format(time.RFC3339Nano) || + u.ReadError != `the usage endpoint answered 429: {"error":"rate_limited"}` { + t.Fatalf("kept %+v", u) + } + if n := len(mm.usage.entries); n != 1 { + t.Fatalf("a failed reading appended: %d entries", n) + } +} + +// design 39 bus-and-switch §25/AC1, §48: a table of readings, ages and healths and the room each gives. +func TestTheRoomOfEachLicence(t *testing.T) { + now := t0 + pct := func(v float64) *float64 { return &v } + read := func(session, weekly float64, ago time.Duration) *UsageReading { + return &UsageReading{SessionPct: pct(session), WeeklyPct: pct(weekly), ReadAt: now.Add(-ago).Format(time.RFC3339Nano)} + } + for _, c := range []struct { + name string + l Licence + room float64 + known bool + }{ + {"the higher window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true}, + {"the session can be the higher", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 20, true}, + {"read at the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 15*time.Minute)}, 90, true}, + {"read before the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 16*time.Minute)}, 0, false}, + {"a failure since keeps the room", Licence{Kind: "subscription", Usage: func() *UsageReading { + u := read(10, 30, 5*time.Minute) + u.ReadError = "the usage endpoint answered 500: x" + return u + }()}, 70, true}, + {"never read", Licence{Kind: "subscription"}, 0, false}, + {"no weekly window", Licence{Kind: "subscription", Usage: &UsageReading{SessionPct: pct(10), + ReadAt: now.Format(time.RFC3339Nano)}}, 0, false}, + {"refresh failing", Licence{Kind: "subscription", Failures: 3, Usage: read(10, 10, time.Minute)}, 0, false}, + {"refresh failed twice", Licence{Kind: "subscription", Failures: 2, Usage: read(10, 10, time.Minute)}, 90, true}, + {"refresh token expired", Licence{Kind: "subscription", RefreshExpiresAt: now.UnixMilli(), + Usage: read(10, 10, time.Minute)}, 0, false}, + {"an API key", Licence{Kind: "api-key", Usage: read(0, 0, 0)}, 0, false}, + } { + room, known := Room(c.l, now, Defaults) + if room != c.room || known != c.known { + t.Errorf("%s: room %v known %v, want %v %v", c.name, room, known, c.room, c.known) + } + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/vendor.go b/modules/claude-licence-manager/cmd/claude-licence-manager/vendor.go index 522bd8fe2..1b07c695f 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/vendor.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/vendor.go @@ -140,6 +140,8 @@ func (v liveVendor) Refresh(ctx context.Context, g FullGrant) Refreshed { return NextGrant(g, r, time.Now().UnixMilli()) } +// Usage is the vendor's usage answer whole; a failed answer is an error naming its status and reason, the body, never +// only the status (design 39 bus-and-switch §24). func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil) if err != nil { @@ -148,25 +150,38 @@ func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]a req.Header.Set("authorization", "Bearer "+accessToken) resp, err := v.client.Do(req) if err != nil { - return nil, err + return nil, fmt.Errorf("the usage endpoint did not answer: %w", err) } defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode/100 != 2 { - return nil, fmt.Errorf("the usage endpoint answered %d", resp.StatusCode) + reason := string(body) + if len(reason) > 400 { + reason = reason[:400] + } + return nil, fmt.Errorf("the usage endpoint answered %d: %s", resp.StatusCode, reason) } var out map[string]any - return out, json.NewDecoder(resp.Body).Decode(&out) + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("the usage endpoint answered %d with what is not JSON", resp.StatusCode) + } + return out, nil } -// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows. +// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows (design 39 bus-and-switch +// §24), with, as the licence keeps it (§2), when it was last read successfully and why the latest reading failed. type UsageReading struct { SessionPct *float64 `json:"sessionPct"` SessionResetsAt string `json:"sessionResetsAt,omitempty"` WeeklyPct *float64 `json:"weeklyPct"` + WeeklyResetsAt string `json:"weeklyResetsAt,omitempty"` SonnetPct *float64 `json:"sonnetPct"` + SonnetResetsAt string `json:"sonnetResetsAt,omitempty"` + ReadAt string `json:"readAt,omitempty"` + ReadError string `json:"readError,omitempty"` } -// FlattenUsage reads the windows the predecessor read. +// FlattenUsage reads the session, weekly and sonnet windows, each with its reset time (§24). func FlattenUsage(u map[string]any) UsageReading { window := func(k string) (*float64, string) { w, ok := u[k].(map[string]any) @@ -180,8 +195,9 @@ func FlattenUsage(u map[string]any) UsageReading { } return &pct, resets } - s, resets := window("five_hour") - w, _ := window("seven_day") - so, _ := window("seven_day_sonnet") - return UsageReading{SessionPct: s, SessionResetsAt: resets, WeeklyPct: w, SonnetPct: so} + s, sResets := window("five_hour") + w, wResets := window("seven_day") + so, soResets := window("seven_day_sonnet") + return UsageReading{SessionPct: s, SessionResetsAt: sResets, WeeklyPct: w, WeeklyResetsAt: wResets, SonnetPct: so, + SonnetResetsAt: soResets} } -- 2.54.0 From 954e3afe56db01ceeec794caf223923396e2e753 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:57:38 +0100 Subject: [PATCH 19/32] M-12: the switch's settings, read again at every reading (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings gain switch_auto, switch_at_pct, room_below_pct, warn_at_pct, usage_minutes and usage_stale_minutes with the manifest's defaults and bounds, a value outside its bound keeping the default. The settings file is read again at every reading, each changed value written in the audit under settings, and the usage timer follows usage_minutes. A room or warning above switch_at_pct is found as an invalid pair naming both values. Builds: design 39 bus-and-switch §33, §24 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/main.go | 22 ++- .../cmd/claude-licence-manager/manager.go | 67 ++------ .../claude-licence-manager/manager_test.go | 7 +- .../cmd/claude-licence-manager/settings.go | 147 ++++++++++++++++++ .../claude-licence-manager/settings_test.go | 99 ++++++++++++ 5 files changed, 281 insertions(+), 61 deletions(-) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/settings.go create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/settings_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index ec5e2015e..f48ffa1bb 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -86,12 +86,13 @@ func manager() (*Manager, error) { } return a, json.Unmarshal(raw, &a) }, - Reported: heard.Of, - Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, - Now: time.Now, - Log: say, - Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), - Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")), + Reported: heard.Of, + Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, + Now: time.Now, + Log: say, + Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), + Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")), + SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"), } return built, nil } @@ -196,7 +197,12 @@ func daemon() { }() refresh := time.NewTicker(time.Minute) - usage := time.NewTicker(5 * time.Minute) + // Every usage_minutes, read again at every reading (§24, §33): the manager's one timer of its own readings. + every := Defaults.UsageEvery + if m, err := manager(); err == nil { + every = m.settings().UsageEvery + } + usage := time.NewTimer(every) for { select { case <-refresh.C: @@ -216,7 +222,9 @@ func daemon() { if err := m.ReadUsage(context.Background()); err != nil { say("reading usage failed: %v", err) } + every = m.settings().UsageEvery } + usage.Reset(every) } } } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 420afb5b3..040a9ecc9 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -23,6 +23,7 @@ import ( "regexp" "sort" "strings" + "sync" "time" ) @@ -123,49 +124,6 @@ func (m *Manager) moved(ctx context.Context, b Binding, from string, c Cause) er return err } -// Settings are the manager's own, declared with defaults (design 39 §8). -type Settings struct { - Cadence time.Duration // rotate a grant once older than this - Floor time.Duration // refresh in any case with less than this left - FailuresToNotify int - Cooldown time.Duration // at most one notification per licence in this window - RefreshWarn time.Duration // warn this long before a refresh token itself expires - // UsageStale is how old a licence's latest successful reading may be for its room to be known (§25, §33). - UsageStale time.Duration -} - -// Defaults are the settings a fresh mesh runs with. -var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, - RefreshWarn: 72 * time.Hour, UsageStale: 15 * time.Minute} - -// SettingsFrom reads the settings file, keeping a default for anything absent or not positive. -func SettingsFrom(path string) Settings { - s := Defaults - raw, err := os.ReadFile(path) - if err != nil { - return s - } - var f map[string]float64 - if json.Unmarshal(raw, &f) != nil { - return s - } - set := func(k string, unit time.Duration, into *time.Duration) { - if v := f[k]; v > 0 { - *into = time.Duration(v * float64(unit)) - } - } - set("cadence_minutes", time.Minute, &s.Cadence) - set("floor_minutes", time.Minute, &s.Floor) - set("cooldown_hours", time.Hour, &s.Cooldown) - if v := f["refresh_warn_days"]; v > 0 { - s.RefreshWarn = time.Duration(v * float64(24*time.Hour)) - } - if v := f["failures_to_notify"]; v > 0 { - s.FailuresToNotify = int(v) - } - return s -} - // GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing. type GrantAnswer struct { Sealed *SealedBox `json:"sealed"` @@ -193,8 +151,12 @@ type Manager struct { Now func() time.Time Log func(format string, args ...any) // Holder names this process in a lease, so a second run is told apart. - Holder string - Settings Settings + Holder string + // Settings are those in force, read again from SettingsFile at every reading (settings.go); an empty SettingsFile + // keeps them. + Settings Settings + SettingsFile string + settingsMu sync.RWMutex } const leaseFor = 2 * time.Minute @@ -595,7 +557,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil } now := m.Now() - if !force && !Due(*l, now, m.Settings) { + if !force && !Due(*l, now, m.settings()) { return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil } g, err := m.grantOf(ctx, *l) @@ -610,7 +572,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri if err := m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}); err != nil { return nil, err } - m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify) + m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.settings().FailuresToNotify) if err := m.Store.SaveLicence(ctx, *l); err != nil { return nil, err } @@ -622,7 +584,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri if l.RefreshExpiresAt != 0 { left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24), - left < m.Settings.RefreshWarn) + left < m.settings().RefreshWarn) } if err := m.Store.SaveLicence(ctx, *l); err != nil { return nil, err @@ -654,7 +616,7 @@ func (m *Manager) notify(l *Licence, why string, when bool) { return } now := m.Now().UnixMilli() - if l.NotifiedAt != 0 && now-l.NotifiedAt < m.Settings.Cooldown.Milliseconds() { + if l.NotifiedAt != 0 && now-l.NotifiedAt < m.settings().Cooldown.Milliseconds() { return } l.NotifiedAt = now @@ -669,7 +631,7 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) { } var out []map[string]any for _, l := range all { - if Due(l, m.Now(), m.Settings) { + if Due(l, m.Now(), m.settings()) { r, err := m.Rotate(ctx, l.Name, false) if err != nil { return out, err @@ -682,6 +644,9 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) { // ReadUsage reads and records each subscription's usage (ADR 0054); the event names the licence and numbers. func (m *Manager) ReadUsage(ctx context.Context) error { + if err := m.ReadSettings(ctx); err != nil { + return err + } all, err := m.Store.Licences(ctx) if err != nil { return err @@ -748,7 +713,7 @@ func (m *Manager) Current(ctx context.Context, consumer string) (map[string]any, return nil, err } out := map[string]any{"licence": l.Name, "key": KeyFor(l.Name), "kind": l.Kind, "generation": b.Generation, - "health": Health(*l, m.Now(), m.Settings)} + "health": Health(*l, m.Now(), m.settings())} if l.AccessExpiresAt != 0 { out["accessExpiresAt"] = time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339) } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index 648f740cc..c0d645616 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -263,11 +263,12 @@ func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) { ctx := context.Background() _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) mm.now = t0.Add(5 * time.Hour) - second := *mm.m - second.Holder = "another run" + second := &Manager{Store: mm.m.Store, Vendor: mm.m.Vendor, Crypt: mm.m.Crypt, Grants: mm.m.Grants, Keys: mm.m.Keys, + AskGrant: mm.m.AskGrant, Emit: mm.m.Emit, Now: mm.m.Now, Log: mm.m.Log, Holder: "another run", + Settings: mm.m.Settings} var wg sync.WaitGroup results := make([]map[string]any, 2) - for i, m := range []*Manager{mm.m, &second} { + for i, m := range []*Manager{mm.m, second} { wg.Add(1) go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }() } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go b/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go new file mode 100644 index 000000000..171d2b892 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go @@ -0,0 +1,147 @@ +package main + +// The manager's settings (design 39 §8, design 39 bus-and-switch §33): today's five and the six of the switch, each +// declared in the manifest's `config` with its default and bound, set through mesh-config and written into the +// settings file. They are read again at every reading, so a change takes effect at the next one, and each change of a +// value read is written in the audit under `settings`. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "reflect" + "time" +) + +// Settings are the manager's own, declared with defaults. +type Settings struct { + Cadence time.Duration // rotate a grant once older than this + Floor time.Duration // refresh in any case with less than this left + FailuresToNotify int + Cooldown time.Duration // at most one notification per licence in this window + RefreshWarn time.Duration // warn this long before a refresh token itself expires + // SwitchAuto is whether a reading at its threshold moves the licence's consumers (§26). + SwitchAuto bool + // SwitchAt, RoomBelow and WarnAt are percentages: where a licence's consumers move, what a target's windows must + // both be below, and where a licence with consumers is warned about. + SwitchAt, RoomBelow, WarnAt float64 + // UsageEvery is how often each subscription's usage is read. + UsageEvery time.Duration + // UsageStale is how old a licence's latest successful reading may be for its room to be known (§25). + UsageStale time.Duration +} + +// Defaults are the settings a fresh mesh runs with. +var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, + RefreshWarn: 72 * time.Hour, SwitchAuto: true, SwitchAt: 99, RoomBelow: 90, WarnAt: 90, UsageEvery: 5 * time.Minute, + UsageStale: 15 * time.Minute} + +// SettingsFrom reads the settings file. Today's five keep a default for anything absent or not positive; the six of +// the switch keep a default for anything absent or outside the bound the manifest declares (§33). +func SettingsFrom(path string) Settings { + s := Defaults + raw, err := os.ReadFile(path) + if err != nil { + return s + } + var f map[string]any + if json.Unmarshal(raw, &f) != nil { + return s + } + num := func(k string) (float64, bool) { v, ok := f[k].(float64); return v, ok } + set := func(k string, unit time.Duration, into *time.Duration) { + if v, ok := num(k); ok && v > 0 { + *into = time.Duration(v * float64(unit)) + } + } + set("cadence_minutes", time.Minute, &s.Cadence) + set("floor_minutes", time.Minute, &s.Floor) + set("cooldown_hours", time.Hour, &s.Cooldown) + set("refresh_warn_days", 24*time.Hour, &s.RefreshWarn) + if v, ok := num("failures_to_notify"); ok && v > 0 { + s.FailuresToNotify = int(v) + } + if v, ok := f["switch_auto"].(bool); ok { + s.SwitchAuto = v + } + bounded := func(k string, lo, hi float64) (float64, bool) { + v, ok := num(k) + return v, ok && v >= lo && v <= hi + } + for _, p := range []struct { + key string + into *float64 + }{{"switch_at_pct", &s.SwitchAt}, {"room_below_pct", &s.RoomBelow}, {"warn_at_pct", &s.WarnAt}} { + if v, ok := bounded(p.key, 1, 100); ok { + *p.into = v + } + } + if v, ok := bounded("usage_minutes", 1, 60); ok { + s.UsageEvery = time.Duration(v * float64(time.Minute)) + } + if v, ok := bounded("usage_stale_minutes", 5, 180); ok { + s.UsageStale = time.Duration(v * float64(time.Minute)) + } + return s +} + +// Values are the settings by their names in the manifest's `config`, in its units. +func (s Settings) Values() map[string]any { + return map[string]any{"cadence_minutes": s.Cadence.Minutes(), "floor_minutes": s.Floor.Minutes(), + "failures_to_notify": float64(s.FailuresToNotify), "cooldown_hours": s.Cooldown.Hours(), + "refresh_warn_days": s.RefreshWarn.Hours() / 24, "switch_auto": s.SwitchAuto, "switch_at_pct": s.SwitchAt, + "room_below_pct": s.RoomBelow, "warn_at_pct": s.WarnAt, "usage_minutes": s.UsageEvery.Minutes(), + "usage_stale_minutes": s.UsageStale.Minutes()} +} + +// Invalid is a setting above switch_at_pct, which moves no consumer until it is fixed (§33): the setting and both +// values. +type Invalid struct { + Setting string + Value float64 + SwitchAt float64 +} + +// Invalid is every setting of the pairs §33 names that is above switch_at_pct: room_below_pct, then warn_at_pct. +func (s Settings) Invalid() []Invalid { + var out []Invalid + if s.RoomBelow > s.SwitchAt { + out = append(out, Invalid{Setting: "room_below_pct", Value: s.RoomBelow, SwitchAt: s.SwitchAt}) + } + if s.WarnAt > s.SwitchAt { + out = append(out, Invalid{Setting: "warn_at_pct", Value: s.WarnAt, SwitchAt: s.SwitchAt}) + } + return out +} + +// settings are those in force. +func (m *Manager) settings() Settings { + m.settingsMu.RLock() + defer m.settingsMu.RUnlock() + return m.Settings +} + +// ReadSettings reads the settings file again, at every reading (§33): each value that changed is written in the audit +// under `settings`, its value before and now, and the settings read are those in force from then on. +func (m *Manager) ReadSettings(ctx context.Context) error { + if m.SettingsFile == "" { + return nil + } + next := SettingsFrom(m.SettingsFile) + m.settingsMu.Lock() + before := m.Settings + m.Settings = next + m.settingsMu.Unlock() + was, now := before.Values(), next.Values() + changed := map[string]any{} + for k, v := range now { + if !reflect.DeepEqual(was[k], v) { + changed[k] = map[string]any{"from": was[k], "to": v} + } + } + if len(changed) == 0 { + return nil + } + return m.record(ctx, "settings", fmt.Sprintf("%d setting(s) changed: %v", len(changed), changed), changed) +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/settings_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/settings_test.go new file mode 100644 index 000000000..d642e76eb --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/settings_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// design 39 bus-and-switch §33/AC1: each setting's default is the manifest's, a value at its bound is taken, and one +// outside its bound or absent keeps the default. +func TestEachSettingsDefaultAndBound(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Config map[string]struct { + Default any `json:"default"` + Minimum *float64 `json:"minimum"` + Maximum *float64 `json:"maximum"` + Trusted bool `json:"trusted"` + } `json:"config"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + defaults := Defaults.Values() + if len(m.Config) != len(defaults) { + t.Fatalf("the manifest declares %d settings, the code %d", len(m.Config), len(defaults)) + } + file := filepath.Join(t.TempDir(), "settings.json") + read := func(k string, v any) any { + b, _ := json.Marshal(map[string]any{k: v}) + _ = os.WriteFile(file, b, 0o600) + return SettingsFrom(file).Values()[k] + } + for k, c := range m.Config { + if !c.Trusted || c.Default != defaults[k] { + t.Errorf("%s: the manifest's default %v (trusted %v), the code's %v", k, c.Default, c.Trusted, defaults[k]) + } + if c.Maximum == nil { + continue + } + lo, hi := *c.Minimum, *c.Maximum + for v, want := range map[float64]any{lo: lo, hi: hi, lo - 1: defaults[k], hi + 1: defaults[k]} { + if got := read(k, v); got != want { + t.Errorf("%s set to %v reads %v, want %v", k, v, got, want) + } + } + } + if read("switch_auto", false) != false || read("switch_at_pct", "high") != 99.0 { + t.Error("switch_auto off, or a value of the wrong type, is not read as written") + } +} + +// design 39 bus-and-switch §33: room_below_pct or warn_at_pct above switch_at_pct is an invalid pair naming both +// values; equal is valid. +func TestAnInvalidPairIsFound(t *testing.T) { + s := Defaults + if len(s.Invalid()) != 0 { + t.Fatal("the defaults are invalid") + } + s.SwitchAt, s.RoomBelow, s.WarnAt = 80, 85, 80 + got := s.Invalid() + if len(got) != 1 || got[0] != (Invalid{Setting: "room_below_pct", Value: 85, SwitchAt: 80}) { + t.Fatalf("invalid %+v", got) + } + s.WarnAt = 95 + if got := s.Invalid(); len(got) != 2 || got[1].Setting != "warn_at_pct" { + t.Fatalf("invalid %+v", got) + } +} + +// design 39 bus-and-switch §33: the settings are read again at every reading, and each change of a value read is +// written once in the audit under settings. +func TestASettingChangedIsReadAtTheNextReadingAndAudited(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + mm.m.SettingsFile = filepath.Join(t.TempDir(), "settings.json") + _ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 99}`), 0o600) + if err := mm.m.ReadUsage(ctx); err != nil || len(mm.audit.under("settings")) != 0 { + t.Fatalf("an unchanged file audited %v (%v)", mm.audit.under("settings"), err) + } + _ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 95, "usage_minutes": 10}`), 0o600) + _ = mm.m.ReadUsage(ctx) + _ = mm.m.ReadUsage(ctx) + if s := mm.m.settings(); s.SwitchAt != 95 || s.UsageEvery.Minutes() != 10 { + t.Fatalf("in force %+v", s) + } + got := mm.audit.under("settings") + if len(got) != 1 { + t.Fatalf("audited %v", got) + } + if c, _ := got[0]["switch_at_pct"].(map[string]any); c["from"] != 99.0 || c["to"] != 95.0 { + t.Fatalf("audited %v", got[0]) + } +} -- 2.54.0 From 4e10ab7b2ea91fcb9b6dddd9f06961eb30767c5f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 00:00:19 +0100 Subject: [PATCH 20/32] M-13: a licence at its threshold moves every consumer, in one act (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After each reading of a licence with consumers whose session or weekly window is at or above switch_at_pct, the manager moves every consumer bound to it, when switch_auto is on and the settings are valid, to the other licence with known room whose windows are both below room_below_pct and the most room, the first by name on a tie. Each consumer moves as the switch verb does, by usage, and says binding.changed; the act says one licence.switched and one audit entry switched with the window, its percentage and reset and the target's room, and is told to the operator; the switch verb is not told. By hand the rule runs with switch_auto off, and dry answers the moves alone. Builds: design 39 bus-and-switch §26, §27, §28, §32, §33, B22 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/main.go | 9 +- .../cmd/claude-licence-manager/manager.go | 10 +- .../cmd/claude-licence-manager/switching.go | 163 ++++++++++++++++ .../claude-licence-manager/switching_test.go | 181 ++++++++++++++++++ 4 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/switching.go create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/switching_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index f48ffa1bb..a43e4a38d 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -86,8 +86,13 @@ func manager() (*Manager, error) { } return a, json.Unmarshal(raw, &a) }, - Reported: heard.Of, - Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, + Reported: heard.Of, + Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, + Tell: func(_ context.Context, headline string, lines []string) error { + _, err := stdio.Ask("seat:operator-channel.tell", map[string]any{"subject": Seat, "headline": headline, + "lines": lines}) + return err + }, Now: time.Now, Log: say, Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 040a9ecc9..4b0616b47 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -148,8 +148,10 @@ type Manager struct { // Reported is the binding generation a node last reported applying (B11), 0 when none is known. Reported func(node string) int64 Emit func(event string, body map[string]any) error - Now func() time.Time - Log func(format string, args ...any) + // Tell tells the operator one notice that needs no answer, through the seat operator-channel's tell (§28). + Tell func(ctx context.Context, headline string, lines []string) error + Now func() time.Time + Log func(format string, args ...any) // Holder names this process in a lease, so a second run is told apart. Holder string // Settings are those in force, read again from SettingsFile at every reading (settings.go); an empty SettingsFile @@ -693,6 +695,10 @@ func (m *Manager) ReadUsage(ctx context.Context) error { "sessionResetsAt": reading.SessionResetsAt, "weeklyPct": reading.WeeklyPct, "weeklyResetsAt": reading.WeeklyResetsAt, "sonnetPct": reading.SonnetPct, "sonnetResetsAt": reading.SonnetResetsAt}) + // On each reading, a licence at its threshold moves every consumer bound to it (§26). + if _, err := m.ApplyRule(ctx, l.Name, false, false); err != nil { + return err + } } return nil } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go b/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go new file mode 100644 index 000000000..e321ed7e0 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go @@ -0,0 +1,163 @@ +package main + +// The switch rule (design 39 bus-and-switch §26, §27, §28, §32): after a reading of licence L, when switch_auto is on and +// L, with one or more consumers bound to it, is at or above switch_at_pct in its session or weekly window, every +// consumer bound to L moves, in one act, to the other licence with known room whose windows are both below +// room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a +// binding.changed per consumer, one audit entry switched, and told to the operator. A consumer is only ever moved off +// a licence at its threshold, so none flaps between two. + +import ( + "context" + "fmt" + "sort" + "strings" +) + +// ByUsage is who moves a consumer by the rule (§26). +const ByUsage = "usage" + +// Switch is one act of the rule: from L to the target, the consumers it moves, the window of L that reached its +// threshold, its percentage and reset, and the target's room. +type Switch struct { + From string `json:"from"` + To string `json:"to"` + Consumers []string `json:"consumers"` + Window string `json:"window"` + Pct float64 `json:"pct"` + ResetsAt string `json:"resetsAt"` + Room float64 `json:"room"` +} + +// why is the cause a moved binding carries: L, the window, its percentage and its reset (§26). +func (s Switch) why() string { + return fmt.Sprintf("%s reached %g %% of its %s window, which resets at %s", s.From, s.Pct, s.Window, s.ResetsAt) +} + +// fields are the act's fields, the same in licence.switched and the audit entry switched (§27). +func (s Switch) fields() map[string]any { + f := switchedFields(s.From, s.To, s.Consumers, Cause{Why: s.why(), By: ByUsage}) + f["window"], f["pct"], f["resetsAt"], f["room"] = s.Window, s.Pct, s.ResetsAt, s.Room + return f +} + +// Rule is what the rule finds for one licence at its threshold: the act, or none with NoTarget when no other licence +// has room. +type Rule struct { + Switch *Switch + NoTarget bool +} + +// RuleFor is what §26 gives for licence l among every licence and binding, under the settings s. A licence below its +// threshold, or with no consumer, gives nothing. +func (m *Manager) RuleFor(l Licence, licences []Licence, bindings []Binding, s Settings) Rule { + var consumers []string + for _, b := range bindings { + if b.Licence == l.Name { + consumers = append(consumers, b.Consumer) + } + } + if len(consumers) == 0 || l.Usage == nil { + return Rule{} + } + sw := Switch{From: l.Name, Consumers: consumers} + session, weekly := l.Usage.SessionPct, l.Usage.WeeklyPct + switch { + case session != nil && *session >= s.SwitchAt && (weekly == nil || *session >= *weekly): + sw.Window, sw.Pct, sw.ResetsAt = "session", *session, l.Usage.SessionResetsAt + case weekly != nil && *weekly >= s.SwitchAt: + sw.Window, sw.Pct, sw.ResetsAt = "weekly", *weekly, l.Usage.WeeklyResetsAt + default: + return Rule{} + } + sort.Strings(sw.Consumers) + others := append([]Licence(nil), licences...) + sort.Slice(others, func(i, j int) bool { return others[i].Name < others[j].Name }) + best := -1.0 + for _, o := range others { + if o.Name == l.Name { + continue + } + room, known := Room(o, m.Now(), s) + if !known || *o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best { + continue + } + best, sw.To, sw.Room = room, o.Name, room + } + if sw.To == "" { + return Rule{NoTarget: true} + } + return Rule{Switch: &sw} +} + +// ApplyRule applies §26 to one licence, or to every one when licence is empty: after a reading, by switch_auto, or by +// hand, even with switch_auto off (§32, §39). With dry it answers the moves and moves nothing. An invalid pair of +// settings moves no consumer (§33). +func (m *Manager) ApplyRule(ctx context.Context, licence string, byHand, dry bool) ([]Switch, error) { + s := m.settings() + if (!s.SwitchAuto && !byHand) || len(s.Invalid()) > 0 { + return nil, nil + } + licences, err := m.Store.Licences(ctx) + if err != nil { + return nil, err + } + bindings, err := m.Store.Bindings(ctx) + if err != nil { + return nil, err + } + var acts []Switch + for _, l := range licences { + if licence != "" && l.Name != licence { + continue + } + r := m.RuleFor(l, licences, bindings, s) + if r.Switch == nil { + continue + } + acts = append(acts, *r.Switch) + if dry { + continue + } + if err := m.act(ctx, *r.Switch, licences); err != nil { + return acts, err + } + // What was moved is bound to the target now, for the next licence's rule. + if bindings, err = m.Store.Bindings(ctx); err != nil { + return acts, err + } + } + return acts, nil +} + +// act moves every consumer of one act as the switch verb does, then says the act once and tells the operator (§26 to +// §28). +func (m *Manager) act(ctx context.Context, sw Switch, licences []Licence) error { + kind := "" + for _, l := range licences { + if l.Name == sw.To { + kind = l.Kind + } + } + floor := m.floorIn(nil) + for _, consumer := range sw.Consumers { + b, err := m.Store.Bind(ctx, consumer, sw.To, Move{Kind: kind, Why: sw.why(), By: ByUsage, Floor: floor}) + if err != nil { + return fmt.Errorf("moving %s to %s: %w", consumer, sw.To, err) + } + m.changed(b, sw.From) + } + f := sw.fields() + if err := m.record(ctx, "switched", fmt.Sprintf("switched %s from %s to %s: %s; by %s", + strings.Join(sw.Consumers, ", "), sw.From, sw.To, sw.why(), ByUsage), f); err != nil { + return err + } + _ = m.Emit("licence.switched", f) + if m.Tell != nil { + line := fmt.Sprintf("%s; %s has %g %% room", sw.why(), sw.To, sw.Room) + if err := m.Tell(ctx, strings.Join(sw.Consumers, ", ")+" moved to "+sw.To, []string{line}); err != nil { + m.Log("the switch of %s to %s could not be told: %v", strings.Join(sw.Consumers, ", "), sw.To, err) + } + } + return nil +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/switching_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/switching_test.go new file mode 100644 index 000000000..3dfa8e8da --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/switching_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" +) + +// read is a licence's latest reading, read at t0. +func read(session, weekly float64) *UsageReading { + return &UsageReading{SessionPct: &session, SessionResetsAt: "2026-11-11T23:00:00Z", WeeklyPct: &weekly, + WeeklyResetsAt: "2026-11-14T09:00:00Z", ReadAt: t0.UTC().Format(time.RFC3339Nano)} +} + +func sub(name string, u *UsageReading) Licence { + return Licence{Name: name, Kind: "subscription", Generation: 1, Usage: u} +} + +// design 39 bus-and-switch §26/AC1, §25, §48: a table of licences, readings and bindings and the moves each gives. +func TestTheRuleMovesEachTable(t *testing.T) { + mm := newMesh(t) + on := func(consumers ...string) []Binding { + var out []Binding + for _, c := range consumers { + out = append(out, Binding{Consumer: c, Licence: "a"}) + } + return out + } + for _, c := range []struct { + name string + licences []Licence + bindings []Binding + to string + window string + noTarget bool + consumers int + }{ + {"below its threshold", []Licence{sub("a", read(50, 98)), sub("b", read(0, 0))}, on("x"), "", "", false, 0}, + {"no consumer", []Licence{sub("a", read(99, 99)), sub("b", read(0, 0))}, nil, "", "", false, 0}, + {"the weekly window at 99", []Licence{sub("a", read(40, 99)), sub("b", read(10, 30)), sub("c", read(5, 20))}, + on("x", "y", "z", "w"), "c", "weekly", false, 4}, + {"the session window at 100", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))}, on("x"), "b", "session", false, 1}, + {"a tie goes to the first by name", []Licence{sub("a", read(99, 0)), sub("c", read(10, 10)), sub("b", read(10, 10))}, + on("x"), "b", "session", false, 1}, + {"a target's windows both below room_below_pct", []Licence{sub("a", read(99, 0)), sub("b", read(0, 90)), + sub("c", read(89, 89))}, on("x"), "c", "session", false, 1}, + {"no other licence has room", []Licence{sub("a", read(99, 0)), sub("b", read(95, 0)), + {Name: "k", Kind: "api-key", Generation: 1}}, on("x"), "", "", true, 0}, + {"an unread licence is no target", []Licence{sub("a", read(0, 99)), sub("b", nil)}, on("x"), "", "", true, 0}, + } { + r := mm.m.RuleFor(c.licences[0], c.licences, c.bindings, Defaults) + to, window, n := "", "", 0 + if r.Switch != nil { + to, window, n = r.Switch.To, r.Switch.Window, len(r.Switch.Consumers) + } + if to != c.to || window != c.window || n != c.consumers || r.NoTarget != c.noTarget { + t.Errorf("%s: to %q window %q consumers %d no target %v", c.name, to, window, n, r.NoTarget) + } + } +} + +// fourOnA is four consumers bound to a, at 99 % of its weekly window, b with 70 % room and c with 60 %. +func fourOnA(t *testing.T) *miniMesh { + mm := newMesh(t) + ctx := context.Background() + for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 20)), sub("c", read(40, 10))} { + _ = mm.store.SaveLicence(ctx, l) + } + for _, c := range []string{"ace", "g14", "laptop", "server"} { + _, _ = mm.store.Bind(ctx, c, "a", Move{Kind: "subscription", Why: "set", By: "test"}) + } + mm.events = nil + return mm +} + +// design 39 bus-and-switch §27/AC1, §26, §21: one act says one licence.switched with the consumers moved, the window, +// its percentage, reset and the target's room; one binding.changed per consumer; one audit entry switched with the same +// fields; each binding moved by usage, its why naming the licence, the window, the percentage and the reset. +func TestOneSwitchedPerAct(t *testing.T) { + mm := fourOnA(t) + acts, err := mm.m.ApplyRule(context.Background(), "", false, false) + if err != nil || len(acts) != 1 || acts[0].To != "b" || acts[0].Room != 70 { + t.Fatalf("acts %+v %v", acts, err) + } + var switched []map[string]any + changed := 0 + for _, e := range mm.events { + name, body, _ := strings.Cut(e, " ") + var f map[string]any + _ = json.Unmarshal([]byte(body), &f) + switch name { + case "licence.switched": + switched = append(switched, f) + case "binding.changed": + changed++ + } + } + if len(switched) != 1 || changed != 4 { + t.Fatalf("said %v", mm.events) + } + s := switched[0] + if s["from"] != "a" || s["to"] != "b" || len(s["consumers"].([]any)) != 4 || s["by"] != ByUsage || + s["window"] != "weekly" || s["pct"] != 99.0 || s["resetsAt"] != "2026-11-14T09:00:00Z" || s["room"] != 70.0 { + t.Fatalf("licence.switched %v", s) + } + audit := mm.audit.under("switched") + if len(audit) != 1 || audit[0]["window"] != "weekly" || len(audit[0]["consumers"].([]any)) != 4 { + t.Fatalf("audit %v", audit) + } + b := mm.bound("laptop") + if b.Licence != "b" || b.By != ByUsage || b.Why != "a reached 99 % of its weekly window, which resets at 2026-11-14T09:00:00Z" { + t.Fatalf("laptop %+v", b) + } +} + +// design 39 bus-and-switch §28/AC1: the rule tells the operator once per act, and the switch verb never. +func TestTheRuleTellsOnceAndTheVerbNever(t *testing.T) { + mm := fourOnA(t) + var told []string + mm.m.Tell = func(_ context.Context, headline string, lines []string) error { + told = append(told, headline+" | "+strings.Join(lines, " ")) + return nil + } + _, _ = mm.m.ApplyRule(context.Background(), "", false, false) + if len(told) != 1 || told[0] != "ace, g14, laptop, server moved to b | a reached 99 % of its weekly window, "+ + "which resets at 2026-11-14T09:00:00Z; b has 70 % room" { + t.Fatalf("told %v", told) + } + _, _ = mm.m.Bind(WithCause(context.Background(), Cause{Why: "the switch verb", By: "a person"}), "ace", "c") + if len(told) != 1 { + t.Fatalf("the switch verb told %v", told) + } +} + +// design 39 bus-and-switch §32/AC1, §33/AC2: dry answers the same moves as the act and moves nothing; an invalid pair +// of settings, or switch_auto off, moves nothing after a reading, and the rule by hand runs with switch_auto off. +func TestDryAnswersTheSameMovesAndMovesNothing(t *testing.T) { + ctx := context.Background() + mm := fourOnA(t) + dry, err := mm.m.ApplyRule(ctx, "a", true, true) + if err != nil || len(dry) != 1 || mm.bound("ace").Licence != "a" || len(mm.events) != 0 { + t.Fatalf("dry %+v %v, said %v", dry, err, mm.events) + } + acts, _ := mm.m.ApplyRule(ctx, "a", true, false) + if len(acts) != 1 || acts[0].To != dry[0].To || strings.Join(acts[0].Consumers, ",") != strings.Join(dry[0].Consumers, ",") { + t.Fatalf("dry %+v, act %+v", dry, acts) + } + for name, change := range map[string]func(*Settings){ + "an invalid pair": func(s *Settings) { s.RoomBelow = 100 }, + "switch_auto off": func(s *Settings) { s.SwitchAuto = false }, + } { + mm := fourOnA(t) + change(&mm.m.Settings) + if acts, _ := mm.m.ApplyRule(ctx, "", false, false); len(acts) != 0 || mm.bound("ace").Licence != "a" { + t.Errorf("%s moved %+v", name, acts) + } + } + mm = fourOnA(t) + mm.m.Settings.SwitchAuto = false + if acts, _ := mm.m.ApplyRule(ctx, "", true, false); len(acts) != 1 || mm.bound("ace").Licence != "b" { + t.Fatalf("by hand with switch_auto off moved %+v", acts) + } +} + +// design 39 bus-and-switch §26: a reading at the threshold applies the rule at once. +func TestAReadingAtTheThresholdApplies(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + _ = mm.store.SaveLicence(ctx, Licence{Name: "b@example.org", Kind: "subscription", Usage: read(10, 10)}) + mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0}, + "seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}} + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatal(err) + } + if b := mm.bound("laptop"); b.Licence != "b@example.org" || b.By != ByUsage { + t.Fatalf("laptop %+v", b) + } +} -- 2.54.0 From 9e545658a530f9303318787b24b481be65636136 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 00:13:05 +0100 Subject: [PATCH 21/32] M-14: the manager's conditions, kept in its state and said on the operator channel (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each condition the manager holds is written in its state conditions with its body and since, then said on the operator channel's condition accept under claude-licence-manager, scope module; it is said again only when its words change, and said cleared, its key deleted, once it no longer holds. At its start, after the carry-over's run, it judges every kind anew: raised again each it holds, cleared each kept key it no longer holds. The kinds: carry-over-failed, settings..invalid for a pair above switch_at_pct and for a setting outside its bound, and per licence no-room when the rule finds no target, near-limit at warn_at_pct with consumers, and usage-unread once its latest success, or its adoption when none, is older than usage_stale_minutes. Headlines name counts and short names, cut at 60. Builds: design 39 bus-and-switch §9, §11, §29, §30, §31, §33, B17, B34, B36, B37, B38, B39 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/conditions.go | 328 ++++++++++++++++++ .../claude-licence-manager/conditions_test.go | 252 ++++++++++++++ .../cmd/claude-licence-manager/main.go | 7 + .../cmd/claude-licence-manager/manager.go | 10 +- .../cmd/claude-licence-manager/settings.go | 29 +- .../cmd/claude-licence-manager/switching.go | 65 ++-- modules/claude-licence-manager/go.mod | 2 + 7 files changed, 671 insertions(+), 22 deletions(-) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go new file mode 100644 index 000000000..59ddae278 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go @@ -0,0 +1,328 @@ +package main + +// The manager's conditions (design 39 bus-and-switch §9, §11, §29 to §31, §33, B17, B37 to B39): each said on the +// operator channel as any module says one (novox/hq ADR 0437 §9), the router alone deciding whether and where the +// operator is told, and each it said raised kept in its state `conditions`, written before it is said and deleted when +// it is said cleared. A raise or a clear is said again only when what it says changes (ADR 0289 §1); at its start the +// manager judges every kind anew, says raised again each it still holds and cleared each key it no longer holds. + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Publisher is the name the manager says its conditions under, and the id of their subject (B17). +const Publisher = "claude-licence-manager" + +// CarryOverFailed is the condition of a carry-over that failed (§11). +const CarryOverFailed = "carry-over-failed" + +// HeadlineLength is the operator channel's limit on a headline; a longer one is cut, ending in "…" (B39). +const HeadlineLength = 60 + +// KeptCondition is the value of a key of `conditions` (§9): the condition said raised, and since when. +type KeptCondition struct { + Body oc.Condition `json:"body"` + Since string `json:"since"` +} + +// SayCondition publishes a condition on the operator channel's condition accept, under the manager's name, in the form +// its client encodes (ADR 0437 §9). +func SayCondition(c oc.Condition) error { + subject, err := occ.ConditionSubject(Publisher) + if err != nil { + return err + } + raw, err := occ.EncodeCondition(c) + if err != nil { + return err + } + return stdio.SeatPublish(subject, json.RawMessage(raw), "") +} + +// condition is a raised condition of the manager: its key, severity, words and the line said when it is over. An +// empty needs is "Nothing for you to do."; the explanation is opened by its verdict here. +func condition(key, severity, headline, summary, needs, explanation, resolved string) oc.Condition { + verdict := occ.NothingToDo + if needs != "" { + verdict = occ.NeedsYou + needs + "." + } + if explanation != "" { + verdict += " " + explanation + } + return oc.Condition{Key: key, Severity: severity, State: occ.StateRaised, + Subject: oc.ConditionSubject{Scope: "module", ID: Publisher}, + Headline: cut(headline), Summary: summary, Needs: needs, Explanation: verdict, Resolved: resolved} +} + +// cut keeps a headline within the operator channel's limit, ending a longer one in "…" (B39). +func cut(headline string) string { + r := []rune(headline) + if len(r) <= HeadlineLength { + return headline + } + return string(r[:HeadlineLength-1]) + "…" +} + +// short is a licence's short name for a headline: its address before the "@", or its name (B39). +func short(name string) string { + if at := strings.Index(name, "@"); at > 0 { + return name[:at] + } + return name +} + +// machines is a count of consumers in a headline (B39). +func machines(n int) string { + if n == 1 { + return "1 machine" + } + return fmt.Sprintf("%d machines", n) +} + +// Judge is every condition the manager holds now, judged anew from its own state and readings, by key. +func (m *Manager) Judge(ctx context.Context) (map[string]oc.Condition, error) { + held := map[string]oc.Condition{} + if m.Carry != nil && !m.Carry.Ended() { + if why := m.Carry.Failure(); why != "" { + held[CarryOverFailed] = condition(CarryOverFailed, occ.SeverityUrgent, + "The licence manager could not carry over its licences", + "the carry-over of today's database failed: "+why, + "the licence manager could not carry over its licences; no consumer is moved and no token is refreshed until it does", + "It failed: "+why+".", "The licence manager carried over its licences") + } + } + s := m.settings() + for _, o := range s.OutOfBound { + bound := fmt.Sprintf("at least %g", o.Min) + if o.HasMax { + bound = fmt.Sprintf("between %g and %g", o.Min, o.Max) + } + key := "settings." + o.Setting + ".invalid" + held[key] = condition(key, occ.SeverityWarning, o.Setting+" is outside its bound", + fmt.Sprintf("%s is %g, outside its bound; its default %g is used", o.Setting, o.Value, o.Default), + fmt.Sprintf("set %s %s", o.Setting, bound), + fmt.Sprintf("%s is set to %g; the licence manager uses its default %g until it is fixed.", o.Setting, + o.Value, o.Default), o.Setting+" is within its bound again") + } + for _, p := range s.Invalid() { + key := "settings." + p.Setting + ".invalid" + if _, ok := held[key]; ok { + continue + } + held[key] = condition(key, occ.SeverityWarning, p.Setting+" is above switch_at_pct", + fmt.Sprintf("%s is %g, above switch_at_pct %g; no consumer is moved", p.Setting, p.Value, p.SwitchAt), + fmt.Sprintf("set %s at or below switch_at_pct (%g)", p.Setting, p.SwitchAt), + fmt.Sprintf("%s is %g, above switch_at_pct %g; the licence manager moves no consumer until it is fixed.", + p.Setting, p.Value, p.SwitchAt), p.Setting+" is at or below switch_at_pct again") + } + licences, err := m.Store.Licences(ctx) + if err != nil { + return nil, err + } + bindings, err := m.Store.Bindings(ctx) + if err != nil { + return nil, err + } + for _, l := range licences { + for key, c := range m.judgeLicence(l, licences, bindings, s) { + held[key] = c + } + } + return held, nil +} + +// judgeLicence is what licence l holds of no-room (§29), near-limit (§30) and usage-unread (§31). +func (m *Manager) judgeLicence(l Licence, licences []Licence, bindings []Binding, s Settings) map[string]oc.Condition { + held := map[string]oc.Condition{} + key := "licence." + KeyFor(l.Name) + "." + consumers := consumersOf(l.Name, bindings) + list := strings.Join(consumers, ", ") + if r := m.RuleFor(l, licences, bindings, s); r.NoTarget { + o := r.Over + var rooms []string + for _, other := range licences { + if other.Name != l.Name { + rooms = append(rooms, other.Name+" "+m.roomWords(other, s)) + } + } + sort.Strings(rooms) + others := "no other licence is held" + if len(rooms) > 0 { + others = strings.Join(rooms, "; ") + } + held[key+"no-room"] = condition(key+"no-room", occ.SeverityUrgent, + "No licence has room for "+machines(len(consumers)), + fmt.Sprintf("no licence has room for %s, bound to %s at %g %% of its %s window", list, l.Name, o.Pct, o.Window), + fmt.Sprintf("add a licence, or wait for %s's %s window to reset at %s", l.Name, o.Window, o.ResetsAt), + fmt.Sprintf("%s is at %g %% of its %s window, with %s bound to it; %s.", l.Name, o.Pct, o.Window, list, others), + list+" have a licence with room again") + } + if window, pct, resetsAt, ok := windowAt(l.Usage, s.WarnAt); ok && len(consumers) > 0 { + needs, plan := "", "" + if to, _ := m.targetFor(l, licences, s); to != "" { + plan = fmt.Sprintf("%s move to %s at %g %%. ", list, to, s.SwitchAt) + } else { + needs = "no other licence has room" + } + held[key+"near-limit"] = condition(key+"near-limit", occ.SeverityWarning, + fmt.Sprintf("%s at %g%% %s", short(l.Name), pct, window), + fmt.Sprintf("%s at %g %% of its %s window", l.Name, pct, window), needs, + fmt.Sprintf("%s%s is at %g %% of its %s window, which resets at %s; %s bound to it.", plan, l.Name, pct, + window, resetsAt, list), fmt.Sprintf("%s is below %g %% again", l.Name, s.WarnAt)) + } + if since, stale := m.unread(l, s); stale { + needs := "" + if len(consumers) > 0 { + needs = list + " are not moved off it while its usage is unknown" + } + why := l.Usage.readError() + held[key+"usage-unread"] = condition(key+"usage-unread", occ.SeverityWarning, + short(l.Name)+"'s usage cannot be read", + fmt.Sprintf("the usage of %s cannot be read: %s", l.Name, why), needs, + fmt.Sprintf("%s, older than %g minutes; the vendor answers: %s.", since, s.UsageStale.Minutes(), why), + l.Name+"'s usage is read again") + } + return held +} + +// readError is the vendor's reason a reading failed, or that none was given. +func (u *UsageReading) readError() string { + if u == nil || u.ReadError == "" { + return "no reason given" + } + return u.ReadError +} + +// unread is whether subscription l's latest successful reading is older than usage_stale_minutes (§31), or, never +// read successfully, its adoption is (B38); and the words naming since when. +func (m *Manager) unread(l Licence, s Settings) (string, bool) { + if l.Kind != "subscription" { + return "", false + } + if l.Usage != nil && l.Usage.ReadAt != "" { + read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt) + if err != nil { + return "its latest successful reading has no time", true + } + return "its latest successful reading was at " + l.Usage.ReadAt, m.Now().Sub(read) > s.UsageStale + } + if l.AdoptedAt == 0 { + return "", false + } + adopted := time.UnixMilli(l.AdoptedAt) + return "no reading has succeeded since its adoption at " + adopted.UTC().Format(time.RFC3339), + m.Now().Sub(adopted) > s.UsageStale +} + +// roomWords is another licence's room for the condition no-room, or why it is not known or not enough (§29). +func (m *Manager) roomWords(o Licence, s Settings) string { + room, known := Room(o, m.Now(), s) + switch { + case known && (*o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow): + return fmt.Sprintf("has %g %% room, a window not below %g %%", room, s.RoomBelow) + case known: + return fmt.Sprintf("has %g %% room", room) + case o.Kind != "subscription": + return "is an API key, whose room is never known" + case o.Usage == nil || o.Usage.ReadAt == "": + return "has no reading" + case o.Usage.SessionPct == nil || o.Usage.WeeklyPct == nil: + return "has no session or weekly window in its reading" + case o.Failures >= s.FailuresToNotify || (o.RefreshExpiresAt != 0 && o.RefreshExpiresAt <= m.Now().UnixMilli()): + return "does not refresh" + } + return fmt.Sprintf("was not read within %g minutes", s.UsageStale.Minutes()) +} + +// SayConditions judges every kind anew and says what changed: each condition held whose words differ from those kept +// is written in `conditions`, then said raised; each kept key no longer held is said cleared, then deleted. With +// again (at the start, and by licences_resync), each held is said raised again whether or not it changed. +func (m *Manager) SayConditions(ctx context.Context, again bool) error { + if m.Conditions == nil || m.Say == nil { + return nil + } + m.conditionsMu.Lock() + defer m.conditionsMu.Unlock() + held, err := m.Judge(ctx) + if err != nil { + return err + } + keys, err := m.Conditions.Keys() + if err != nil { + return err + } + var errs []error + for _, key := range keys { + if _, ok := held[key]; ok { + continue + } + var kept KeptCondition + e, err := m.Conditions.Get(key) + if err != nil { + errs = append(errs, err) + continue + } + if e != nil && len(e.Value) > 0 { + _ = json.Unmarshal(e.Value, &kept) + } + cleared := kept.Body + cleared.Key, cleared.State = key, occ.StateCleared + cleared.Subject = oc.ConditionSubject{Scope: "module", ID: Publisher} + if err := m.Say(cleared); err != nil { + errs = append(errs, fmt.Errorf("the clearing of %s could not be said: %w", key, err)) + continue + } + if err := m.Conditions.Delete(key); err != nil { + errs = append(errs, err) + } + } + var order []string + for key := range held { + order = append(order, key) + } + sort.Strings(order) + for _, key := range order { + c := held[key] + kept := KeptCondition{Body: c, Since: m.Now().UTC().Format(time.RFC3339)} + if e, err := m.Conditions.Get(key); err == nil && e != nil && len(e.Value) > 0 { + var was KeptCondition + if json.Unmarshal(e.Value, &was) == nil { + if !again && sameCondition(was.Body, c) { + continue + } + if was.Since != "" { + kept.Since = was.Since + } + } + } + if _, err := m.Conditions.Put(key, kept); err != nil { + errs = append(errs, err) + continue + } + if err := m.Say(c); err != nil { + // Not said: the key goes again, so the next judgement says it. + _ = m.Conditions.Delete(key) + errs = append(errs, fmt.Errorf("%s could not be said: %w", key, err)) + } + } + if len(errs) > 0 { + return fmt.Errorf("conditions: %v", errs) + } + return nil +} + +// sameCondition is whether two raised conditions say the same. +func sameCondition(a, b oc.Condition) bool { + ra, _ := json.Marshal(a) + rb, _ := json.Marshal(b) + return string(ra) == string(rb) +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go new file mode 100644 index 000000000..6fbe66679 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go @@ -0,0 +1,252 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" +) + +// saying gives mm's manager a state `conditions` and records each condition it says, in the form the operator +// channel's client encodes, refusing what it refuses. +func saying(mm *miniMesh) (*memState, *[]oc.Condition) { + kept := newMemState(func() time.Time { return mm.now }) + said := &[]oc.Condition{} + mm.m.Conditions = kept + mm.m.Say = func(c oc.Condition) error { + if _, err := occ.EncodeCondition(c); err != nil { + return err + } + if _, err := occ.ConditionSubject(Publisher); err != nil { + return err + } + *said = append(*said, c) + return nil + } + return kept, said +} + +// of is what was said of key, as "state" per saying. +func of(said []oc.Condition, key string) []string { + var out []string + for _, c := range said { + if c.Key == key { + out = append(out, c.State) + } + } + return out +} + +// design 39 bus-and-switch §9/AC1, §11, §33, B17, B37: each kind of condition the manager holds of itself is written in +// `conditions` with its body and since, said once with its key, severity, scope and id, its words opened by their +// verdict, and said cleared, its key deleted, once it no longer holds. +func TestAConditionIsKeptThenSaidOnce(t *testing.T) { + ctx := context.Background() + file := filepath.Join(t.TempDir(), "settings.json") + for _, c := range []struct { + name, key, severity string + raise, fix func(mm *miniMesh) + }{ + {"an invalid pair", "settings.room_below_pct.invalid", occ.SeverityWarning, + func(mm *miniMesh) { mm.m.Settings.RoomBelow = 100 }, func(mm *miniMesh) { mm.m.Settings.RoomBelow = 90 }}, + {"a setting outside its bound", "settings.switch_at_pct.invalid", occ.SeverityWarning, + func(mm *miniMesh) { + _ = os.WriteFile(file, []byte(`{"switch_at_pct": 150}`), 0o600) + mm.m.Settings = SettingsFrom(file) + }, + func(mm *miniMesh) { + _ = os.WriteFile(file, []byte(`{"switch_at_pct": 98}`), 0o600) + mm.m.Settings = SettingsFrom(file) + }}, + {"a carry-over that failed", CarryOverFailed, occ.SeverityUrgent, + func(mm *miniMesh) { + mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now }), + Source: func(context.Context) (CarrySource, error) { return nil, errors.New("connection refused") }} + _, _ = mm.m.Carry.Run(ctx, mm.m, mm.store) + }, + func(mm *miniMesh) { + mm.m.Carry.Source = func(context.Context) (CarrySource, error) { return &memSource{}, nil } + _, _ = mm.m.Carry.Run(ctx, mm.m, mm.store) + }}, + } { + mm := newMesh(t) + kept, said := saying(mm) + c.raise(mm) + for range 2 { + if err := mm.m.SayConditions(ctx, false); err != nil { + t.Fatalf("%s: %v", c.name, err) + } + } + if got := of(*said, c.key); strings.Join(got, ",") != "raised" { + t.Fatalf("%s: said %v of %s, all said %+v", c.name, got, c.key, *said) + } + s := (*said)[0] + if s.Severity != c.severity || s.Subject.Scope != "module" || s.Subject.ID != Publisher || s.Headline == "" || + s.Summary == "" || s.Resolved == "" || !strings.HasPrefix(s.Explanation, occ.NeedsYou+s.Needs) || s.Needs == "" { + t.Fatalf("%s: said %+v", c.name, s) + } + var k KeptCondition + if e, _ := kept.Get(c.key); e == nil || json.Unmarshal(e.Value, &k) != nil || k.Body.Key != c.key || k.Since == "" { + t.Fatalf("%s: kept %+v", c.name, k) + } + c.fix(mm) + _ = mm.m.SayConditions(ctx, false) + if got := of(*said, c.key); strings.Join(got, ",") != "raised,cleared" { + t.Fatalf("%s: said %v", c.name, got) + } + if e, _ := kept.Get(c.key); e != nil { + t.Fatalf("%s: the key is kept after its clearing", c.name) + } + } +} + +// design 39 bus-and-switch §9/AC2: at its start the manager says cleared each key of `conditions` it no longer holds, +// and says raised again each it still holds, unchanged or not. +func TestAStartClearsWhatItNoLongerHolds(t *testing.T) { + ctx := context.Background() + mm := newMesh(t) + kept, said := saying(mm) + gone := condition("licence.gone.no-room", occ.SeverityUrgent, "No licence has room for 1 machine", "s", "add one", + "", "x has a licence with room again") + mm.m.Settings.WarnAt = 100 + _ = mm.m.SayConditions(ctx, false) + _, _ = kept.Put(gone.Key, KeptCondition{Body: gone, Since: "2026-11-11T20:00:00Z"}) + *said = nil + if err := mm.m.SayConditions(ctx, true); err != nil { + t.Fatal(err) + } + if strings.Join(of(*said, gone.Key), ",") != "cleared" || strings.Join(of(*said, "settings.warn_at_pct.invalid"), ",") != "raised" { + t.Fatalf("a start said %+v", *said) + } + if (*said)[0].Resolved != gone.Resolved { + t.Fatalf("the clearing carries %+v", (*said)[0]) + } + if e, _ := kept.Get(gone.Key); e != nil { + t.Fatal("a key no longer held is kept") + } +} + +// design 39 bus-and-switch §29/AC1, B39: with every licence at its threshold, no-room is said once, its headline a +// count of machines, its words naming the licence, the window's reset and every other licence's room; a reading with +// room clears it. +func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) { + ctx := context.Background() + mm := fourOnA(t) + _, said := saying(mm) + _ = mm.store.SaveLicence(ctx, sub("b", read(95, 20))) + _ = mm.store.SaveLicence(ctx, sub("c", read(40, 99))) + for range 2 { + _ = mm.m.SayConditions(ctx, false) + } + key := "licence.a.no-room" + if strings.Join(of(*said, key), ",") != "raised" { + t.Fatalf("said %+v", *said) + } + var c oc.Condition + for _, s := range *said { + if s.Key == key { + c = s + } + } + if c.Severity != occ.SeverityUrgent || c.Headline != "No licence has room for 4 machines" || + c.Needs != "add a licence, or wait for a's weekly window to reset at 2026-11-14T09:00:00Z" || + !strings.Contains(c.Explanation, "a is at 99 % of its weekly window, with ace, g14, laptop, server bound to it") || + !strings.Contains(c.Explanation, "b has 5 % room, a window not below 90 %") || + c.Resolved != "ace, g14, laptop, server have a licence with room again" { + t.Fatalf("no-room %+v", c) + } + _ = mm.store.SaveLicence(ctx, sub("b", read(10, 20))) + _ = mm.m.SayConditions(ctx, false) + if strings.Join(of(*said, key), ",") != "raised,cleared" { + t.Fatalf("said %v after a reading with room", of(*said, key)) + } +} + +// design 39 bus-and-switch §30/AC1, B39: a rising series of readings says near-limit once at the crossing and again only +// when its words change; with no other licence having room it needs the operator; a reading after the reset clears it. +func TestNearLimitIsSaidOncePerCrossing(t *testing.T) { + ctx := context.Background() + mm := fourOnA(t) + _, said := saying(mm) + key := "licence.a.near-limit" + var last oc.Condition + for _, w := range []float64{80, 89, 91, 91, 95} { + _ = mm.store.SaveLicence(ctx, sub("a", read(10, w))) + _ = mm.m.SayConditions(ctx, false) + for _, s := range *said { + if s.Key == key { + last = s + } + } + } + if strings.Join(of(*said, key), ",") != "raised,raised" || last.Headline != "a at 95% weekly" || last.Needs != "" || + !strings.HasPrefix(last.Explanation, occ.NothingToDo+" ace, g14, laptop, server move to b at 99 %.") || + !strings.Contains(last.Explanation, "which resets at 2026-11-14T09:00:00Z") { + t.Fatalf("said %v, last %+v", of(*said, key), last) + } + _ = mm.store.SaveLicence(ctx, sub("b", read(95, 0))) + _ = mm.store.SaveLicence(ctx, sub("c", read(95, 0))) + _ = mm.m.SayConditions(ctx, false) + if c := (*said)[len(*said)-1]; c.Key != key || c.Needs != "no other licence has room" { + t.Fatalf("with no target said %+v", c) + } + _ = mm.store.SaveLicence(ctx, sub("a", read(3, 1))) + _ = mm.m.SayConditions(ctx, false) + if got := of(*said, key); got[len(got)-1] != "cleared" || len(got) != 4 { + t.Fatalf("after the reset said %v", got) + } +} + +// design 39 bus-and-switch §31/AC1, B34, B38: three failed readings in a row, the latest success then older than +// usage_stale_minutes, say usage-unread naming the vendor's reason, needing the operator for the consumers bound to it; +// one success clears it. A subscription never read successfully is said once its adoption is that old. +func TestUsageUnreadIsSaidAndCleared(t *testing.T) { + ctx := context.Background() + mm := newMesh(t) + _, said := saying(mm) + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatal(err) + } + key := "licence." + KeyFor(licence1) + ".usage-unread" + mm.vendor.usageErr = errors.New("429 Too Many Requests: rate limited") + for i := 1; i <= 3; i++ { + mm.now = t0.Add(time.Duration(i) * 6 * time.Minute) + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatal(err) + } + if got := len(of(*said, key)); (i < 3 && got != 0) || (i == 3 && got != 1) { + t.Fatalf("after %d failed readings said %v", i, of(*said, key)) + } + } + c := (*said)[len(*said)-1] + if c.Severity != occ.SeverityWarning || c.Needs != "laptop are not moved off it while its usage is unknown" || + !strings.Contains(c.Explanation, "the vendor answers: 429 Too Many Requests: rate limited") { + t.Fatalf("usage-unread %+v", c) + } + mm.vendor.usageErr = nil + _ = mm.m.ReadUsage(ctx) + if strings.Join(of(*said, key), ",") != "raised,cleared" { + t.Fatalf("after a success said %v", of(*said, key)) + } + // B38: never read successfully since its adoption. + never := Licence{Name: "new@example.org", Kind: "subscription", AdoptedAt: mm.now.UnixMilli()} + _ = mm.store.SaveLicence(ctx, never) + _ = mm.m.SayConditions(ctx, false) + newKey := "licence." + KeyFor(never.Name) + ".usage-unread" + if len(of(*said, newKey)) != 0 { + t.Fatal("said at its adoption") + } + mm.now = mm.now.Add(16 * time.Minute) + _ = mm.m.SayConditions(ctx, false) + if strings.Join(of(*said, newKey), ",") != "raised" { + t.Fatalf("said %v of a licence never read", of(*said, newKey)) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index a43e4a38d..798deddc8 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -98,6 +98,8 @@ func manager() (*Manager, error) { Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")), SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"), + Conditions: stdio.State("conditions"), + Say: SayCondition, } return built, nil } @@ -146,6 +148,11 @@ func carryOverFirst() { bus, _ := m.Store.(*BusStore) _, _ = m.Carry.Run(context.Background(), m, bus) // a failure is said, and the run is asked again by hand (§44) } + // At its start the manager judges every condition anew: raised again each it holds, cleared each it no longer + // holds, carry-over-failed among them (§9, §11). + if err := m.SayConditions(context.Background(), true); err != nil { + say("saying the conditions at the start: %v", err) + } <-m.Carry.Wait() } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 4b0616b47..2f998c2d4 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -25,6 +25,8 @@ import ( "strings" "sync" "time" + + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" ) // Fingerprint names a token without being one: the agent module's own fingerprint, so the two compare. @@ -159,6 +161,11 @@ type Manager struct { Settings Settings SettingsFile string settingsMu sync.RWMutex + // Conditions is the state `conditions` (design 39 bus-and-switch §9), and Say says one condition on the operator + // channel; either nil says none. + Conditions keptState + Say func(c oc.Condition) error + conditionsMu sync.Mutex } const leaseFor = 2 * time.Minute @@ -700,7 +707,8 @@ func (m *Manager) ReadUsage(ctx context.Context) error { return err } } - return nil + // What the readings and the settings now hold is said, each condition only when what it says changed (§9). + return m.SayConditions(ctx, false) } // ---- the seat's verbs -------------------------------------------------------------------------------- diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go b/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go index 171d2b892..dc2afdb8e 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go @@ -30,6 +30,18 @@ type Settings struct { UsageEvery time.Duration // UsageStale is how old a licence's latest successful reading may be for its room to be known (§25). UsageStale time.Duration + // OutOfBound is every setting the file sets outside the bound the manifest declares, kept at its default and said + // as settings..invalid until it is fixed (B37). + OutOfBound []OutOfBound +} + +// OutOfBound is one setting set outside its bound: the value set, the bound and the default kept in its place. +type OutOfBound struct { + Setting string + Value float64 + Min, Max float64 + Default float64 + HasMax bool } // Defaults are the settings a fresh mesh runs with. @@ -50,9 +62,18 @@ func SettingsFrom(path string) Settings { return s } num := func(k string) (float64, bool) { v, ok := f[k].(float64); return v, ok } + // below is a value under the manifest's minimum of 0 for today's five: kept at its default and said (B37). + below := func(k string, v float64, def float64) { + if v < 0 { + s.OutOfBound = append(s.OutOfBound, OutOfBound{Setting: k, Value: v, Default: def}) + } + } set := func(k string, unit time.Duration, into *time.Duration) { - if v, ok := num(k); ok && v > 0 { + v, ok := num(k) + if ok && v > 0 { *into = time.Duration(v * float64(unit)) + } else if ok { + below(k, v, float64(*into)/float64(unit)) } } set("cadence_minutes", time.Minute, &s.Cadence) @@ -61,12 +82,18 @@ func SettingsFrom(path string) Settings { set("refresh_warn_days", 24*time.Hour, &s.RefreshWarn) if v, ok := num("failures_to_notify"); ok && v > 0 { s.FailuresToNotify = int(v) + } else if ok { + below("failures_to_notify", v, float64(s.FailuresToNotify)) } if v, ok := f["switch_auto"].(bool); ok { s.SwitchAuto = v } bounded := func(k string, lo, hi float64) (float64, bool) { v, ok := num(k) + if ok && (v < lo || v > hi) { + def, _ := Defaults.Values()[k].(float64) + s.OutOfBound = append(s.OutOfBound, OutOfBound{Setting: k, Value: v, Min: lo, Max: hi, Default: def, HasMax: true}) + } return v, ok && v >= lo && v <= hi } for _, p := range []struct { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go b/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go index e321ed7e0..13027e4be 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go @@ -42,38 +42,47 @@ func (s Switch) fields() map[string]any { } // Rule is what the rule finds for one licence at its threshold: the act, or none with NoTarget when no other licence -// has room. +// has room; Over is then the licence's consumers and the window at its threshold, for the condition no-room (§29). type Rule struct { Switch *Switch NoTarget bool + Over *Switch } -// RuleFor is what §26 gives for licence l among every licence and binding, under the settings s. A licence below its -// threshold, or with no consumer, gives nothing. -func (m *Manager) RuleFor(l Licence, licences []Licence, bindings []Binding, s Settings) Rule { +// consumersOf are the consumers bound to licence, sorted. +func consumersOf(licence string, bindings []Binding) []string { var consumers []string for _, b := range bindings { - if b.Licence == l.Name { + if b.Licence == licence { consumers = append(consumers, b.Consumer) } } - if len(consumers) == 0 || l.Usage == nil { - return Rule{} + sort.Strings(consumers) + return consumers +} + +// windowAt is l's window at or above pct, the higher of the two, the session window on a tie (B36): its name, +// percentage and reset; ok false when neither is. +func windowAt(u *UsageReading, pct float64) (window string, at float64, resetsAt string, ok bool) { + if u == nil { + return "", 0, "", false } - sw := Switch{From: l.Name, Consumers: consumers} - session, weekly := l.Usage.SessionPct, l.Usage.WeeklyPct + session, weekly := u.SessionPct, u.WeeklyPct switch { - case session != nil && *session >= s.SwitchAt && (weekly == nil || *session >= *weekly): - sw.Window, sw.Pct, sw.ResetsAt = "session", *session, l.Usage.SessionResetsAt - case weekly != nil && *weekly >= s.SwitchAt: - sw.Window, sw.Pct, sw.ResetsAt = "weekly", *weekly, l.Usage.WeeklyResetsAt - default: - return Rule{} + case session != nil && *session >= pct && (weekly == nil || *session >= *weekly): + return "session", *session, u.SessionResetsAt, true + case weekly != nil && *weekly >= pct: + return "weekly", *weekly, u.WeeklyResetsAt, true } - sort.Strings(sw.Consumers) + return "", 0, "", false +} + +// targetFor is the licence §26 moves l's consumers to: among the others with known room whose windows are both below +// room_below_pct, the one with the most room, the first by name on a tie; empty when none has room. +func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string, float64) { others := append([]Licence(nil), licences...) sort.Slice(others, func(i, j int) bool { return others[i].Name < others[j].Name }) - best := -1.0 + to, best := "", -1.0 for _, o := range others { if o.Name == l.Name { continue @@ -82,10 +91,26 @@ func (m *Manager) RuleFor(l Licence, licences []Licence, bindings []Binding, s S if !known || *o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best { continue } - best, sw.To, sw.Room = room, o.Name, room + best, to = room, o.Name } - if sw.To == "" { - return Rule{NoTarget: true} + return to, best +} + +// RuleFor is what §26 gives for licence l among every licence and binding, under the settings s. A licence below its +// threshold, or with no consumer, gives nothing. +func (m *Manager) RuleFor(l Licence, licences []Licence, bindings []Binding, s Settings) Rule { + consumers := consumersOf(l.Name, bindings) + if len(consumers) == 0 { + return Rule{} + } + sw := Switch{From: l.Name, Consumers: consumers} + var ok bool + if sw.Window, sw.Pct, sw.ResetsAt, ok = windowAt(l.Usage, s.SwitchAt); !ok { + return Rule{} + } + if sw.To, sw.Room = m.targetFor(l, licences, s); sw.To == "" { + sw.Room = 0 + return Rule{NoTarget: true, Over: &sw} } return Rule{Switch: &sw} } diff --git a/modules/claude-licence-manager/go.mod b/modules/claude-licence-manager/go.mod index 4b74e0437..77296baf3 100644 --- a/modules/claude-licence-manager/go.mod +++ b/modules/claude-licence-manager/go.mod @@ -3,6 +3,8 @@ module claude-licence-manager go 1.26.0 require ( + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/jackc/pgx/v5 v5.11.0 golang.org/x/crypto v0.57.0 -- 2.54.0 From baaf2e56dda607731afd15d68178497790dcfa0b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 00:19:22 +0100 Subject: [PATCH 22/32] M-15: the manager's tools read all it keeps and start by hand all it does (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The licences verb answers each licence's organisation, latest usage with its reset times, its room or why it is not known, whether it is where a full licence's consumers move next, when and from where it was adopted and its open conditions. The usage verb takes since, a limit of at most 2000 and raw. The module's tools: licences_switches from the audit, licences_read_usage and licences_switch_now with dry, licences_settings with the level mesh-config answers (the manifest invokes its config verb), licences_kept over every bus-state entry with its fill, licences_resync, licences_conditions and licences_carried with its run, refused once carried. An acting tool waits for the carry-over. Builds: design 39 bus-and-switch §34, §35, §37, §38, §39, §40, §41, §42, §43, §44, §47, §20, §11, B9, B18, B40 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/busstore.go | 15 +- .../claude-licence-manager/busstore_test.go | 20 +- .../cmd/claude-licence-manager/main.go | 67 ++- .../cmd/claude-licence-manager/manager.go | 92 +++- .../cmd/claude-licence-manager/store.go | 6 +- .../cmd/claude-licence-manager/tools.go | 413 ++++++++++++++++++ .../cmd/claude-licence-manager/tools_test.go | 327 ++++++++++++++ modules/claude-licence-manager/module.json | 3 +- 8 files changed, 909 insertions(+), 34 deletions(-) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/tools.go create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go index a38ff6fbc..2ffaa3598 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go @@ -260,6 +260,7 @@ type keptLog interface { Read(from uint64, keys string, limit int) (*stdio.LogPage, error) Last(key string) (*stdio.LogEntry, error) Info() (*stdio.LogInfo, error) + Newest(keys string) ([]stdio.LogEntry, error) } // UsageEntry is one entry of the log `usage` (§6): when, the flattened reading and the vendor's answer whole (ADR 0054). @@ -279,8 +280,9 @@ func (s *BusStore) RecordUsage(_ context.Context, licence string, at int64, r Us return err } -// Usage is the latest readings, newest first, of one licence or every one. -func (s *BusStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) { +// Usage is the latest readings, newest first, of one licence or every one, none before since, the vendor's answer +// with raw (§35). +func (s *BusStore) Usage(ctx context.Context, licence string, since time.Time, limit int, raw bool) ([]UsageRow, error) { names := map[string]string{} all, err := s.Licences(ctx) if err != nil { @@ -304,11 +306,18 @@ func (s *BusStore) Usage(ctx context.Context, licence string, limit int) ([]Usag continue } at, _ := time.Parse(time.RFC3339Nano, u.At) + if at.Before(since) { + continue + } name := names[e.Key] if name == "" { name = e.Key } - out = append(out, UsageRow{Licence: name, At: at.UnixMilli(), Reading: u.Reading}) + row := UsageRow{Licence: name, At: at.UnixMilli(), Reading: u.Reading} + if raw { + row.Raw = u.Raw + } + out = append(out, row) } return out, nil } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go index 54fbce2ed..c6e92ded2 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go @@ -278,6 +278,24 @@ func (l *memLog) Info() (*stdio.LogInfo, error) { return i, nil } +func (l *memLog) Newest(keys string) ([]stdio.LogEntry, error) { + l.mu.Lock() + defer l.mu.Unlock() + last := map[string]int{} + for i, e := range l.entries { + if keys == "" || e.Key == keys { + last[e.Key] = i + } + } + out := []stdio.LogEntry{} + for i, e := range l.entries { + if j, ok := last[e.Key]; ok && j == i { + out = append(out, e) + } + } + return out, nil +} + // under is every entry of a log's subject, decoded. func (l *memLog) under(key string) []map[string]any { l.mu.Lock() @@ -309,7 +327,7 @@ func TestAReadingAppendsOneUsageEntry(t *testing.T) { if got[0]["raw"] == nil || got[0]["reading"] == nil || got[0]["at"] == nil { t.Fatalf("the entry %v", got[0]) } - rows, _ := mm.store.Usage(ctx, licence1, 20) + rows, _ := mm.store.Usage(ctx, licence1, time.Time{}, 20, false) if len(rows) != 1 || rows[0].Licence != licence1 || rows[0].Reading.SessionPct == nil { t.Fatalf("usage answers %+v", rows) } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index 798deddc8..d33c6d033 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -100,10 +100,34 @@ func manager() (*Manager, error) { SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"), Conditions: stdio.State("conditions"), Say: SayCondition, + ConfigLevels: func(context.Context) (map[string]string, error) { return configLevels(node) }, } return built, nil } +// configLevels asks mesh-config where each of the manager's own settings was set on its node (§40, B18): the source +// of each resolved value, by its key. +func configLevels(node string) (map[string]string, error) { + raw, err := stdio.Ask("seat:mesh-config.config", map[string]any{"action": "list", "module": Publisher, "node": node}) + if err != nil { + return nil, err + } + var answer struct { + Resolved []struct { + Key string `json:"key"` + Source string `json:"source"` + } `json:"resolved"` + } + if err := json.Unmarshal(raw, &answer); err != nil { + return nil, fmt.Errorf("mesh-config's answer: %w", err) + } + levels := map[string]string{} + for _, r := range answer.Resolved { + levels[r.Key] = r.Source + } + return levels, nil +} + // keypair is this module's own, made once in its state directory; the private half never leaves it. // Written whole, then linked into place, so a second process reads the first's key and never half of it. func keypair(dir string) (KeyPair, error) { @@ -274,6 +298,28 @@ func verb(name, description string, input map[string]any, run func(ctx context.C // reads are the seat's verbs that only read, served while the carry-over has not ended (§11). var reads = map[string]bool{"licences": true, "bindings": true, "usage": true, "public-key": true, "current": true} +// sinceOf is the argument since, RFC 3339; absent is the zero time. +func sinceOf(a map[string]any) (time.Time, error) { + v, _ := a["since"].(string) + if strings.TrimSpace(v) == "" { + return time.Time{}, nil + } + t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(v)) + if err != nil { + return time.Time{}, fmt.Errorf("since is not a time in RFC 3339: %w", err) + } + return t, nil +} + +// limitOf is the argument limit: def when absent or not positive, at most most. +func limitOf(a map[string]any, def, most int) int { + limit := def + if v, ok := a["limit"].(float64); ok && v > 0 { + limit = int(v) + } + return min(limit, most) +} + func str(description string) map[string]any { return map[string]any{"type": "string", "description": description} } @@ -286,8 +332,8 @@ func two(a map[string]any, k1, k2 string) (string, string, error) { func tools() []stdio.Tool { consumer := str("the node's name") - return []stdio.Tool{ - verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it. Never a token.", + return append([]stdio.Tool{ + verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it, its latest usage with its reset times, its room or why it is not known, whether it is where a full licence's consumers move next, when and from where it was adopted, and its open conditions. Never a token.", nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Licences(ctx) }), verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given; with consumer, that consumer's alone.", map[string]any{"consumer": str("a consumer; absent for every one")}, @@ -330,15 +376,18 @@ func tools() []stdio.Tool { } return m.RotateDue(ctx) }), - verb("usage", "Usage readings, the latest first, for every licence or one.", - map[string]any{"licence": str("a licence; absent for all"), "limit": map[string]any{"type": "number", "description": "how many readings (default 20)"}}, + verb("usage", "Usage readings, the latest first, for every licence or one: the flattened reading with its three reset times, none before since, at most limit (default 20, at most 2000), and with raw the vendor's answer whole.", + map[string]any{"licence": str("a licence; absent for all"), "since": str("a time, RFC 3339: no reading before it"), + "limit": map[string]any{"type": "number", "description": "how many readings (default 20, at most 2000)"}, + "raw": map[string]any{"type": "boolean", "description": "also the vendor's answer whole"}}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { l, _ := a["licence"].(string) - limit := 20 - if v, ok := a["limit"].(float64); ok && v > 0 { - limit = int(v) + since, err := sinceOf(a) + if err != nil { + return nil, err } - return m.Store.Usage(ctx, l, limit) + raw, _ := a["raw"].(bool) + return m.Store.Usage(ctx, l, since, limitOf(a, 20, 2000), raw) }), verb("adopt", "Adopt an API key — never as an argument: from a file on the manager's node (`file`), or sealed to the manager's `public-key` by a node's claude-code (`sealed`; its `claude_code_add_api_key` does this). Subscriptions are adopted from the nodes' logins by themselves.", map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key"), @@ -377,5 +426,5 @@ func tools() []stdio.Tool { } return m.Current(ctx, c) }), - } + }, moduleTools()...) } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 2f998c2d4..5c0ca7f31 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -166,6 +166,9 @@ type Manager struct { Conditions keptState Say func(c oc.Condition) error conditionsMu sync.Mutex + // ConfigLevels is where mesh-config says each setting was set on the manager's node (mesh, node or default), by + // setting (§40, B18); nil answers every one as the default. + ConfigLevels func(ctx context.Context) (map[string]string, error) } const leaseFor = 2 * time.Minute @@ -651,22 +654,40 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) { return out, nil } -// ReadUsage reads and records each subscription's usage (ADR 0054); the event names the licence and numbers. +// ReadUsage reads every subscription's usage, on the manager's cadence (§24): each reading written, the rule applied +// after it (§26), and the conditions said (§9). func (m *Manager) ReadUsage(ctx context.Context) error { + _, err := m.ReadUsageOf(ctx, "", false) + return err +} + +// Read is one reading as licences_read_usage answers it (§38): the licence, the reading or why it failed, and what §26 +// did or, with dry, would do. +type Read struct { + Licence string `json:"licence"` + Reading *UsageReading `json:"reading,omitempty"` + Error string `json:"readError,omitempty"` + Switches []Switch `json:"switches"` +} + +// ReadUsageOf reads the usage of one subscription, or every one when licence is empty, as §24 does, and applies §26 +// after each reading unless dry; dry answers what §26 would do (§32, §38). +func (m *Manager) ReadUsageOf(ctx context.Context, licence string, dry bool) ([]Read, error) { if err := m.ReadSettings(ctx); err != nil { - return err + return nil, err } all, err := m.Store.Licences(ctx) if err != nil { - return err + return nil, err } + out := []Read{} for _, l := range all { - if l.Kind != "subscription" || l.Generation == 0 { + if l.Kind != "subscription" || l.Generation == 0 || (licence != "" && l.Name != licence) { continue } g, err := m.grantOf(ctx, l) if err != nil { - return err + return out, err } raw, err := m.Vendor.Usage(ctx, g.AccessToken) g = FullGrant{} @@ -682,33 +703,39 @@ func (m *Manager) ReadUsage(ctx context.Context) error { kept.ReadError = err.Error() l.Usage = &kept if err := m.Store.SaveLicence(ctx, l); err != nil { - return err + return out, err } m.Log("reading the usage of %s failed: %v", l.Name, err) + out = append(out, Read{Licence: l.Name, Error: kept.ReadError, Switches: []Switch{}}) continue } reading := FlattenUsage(raw) if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil { - return err + return out, err } // The latest reading beside its licence, so licences answers from one key (§2). latest := reading latest.ReadAt = m.Now().UTC().Format(time.RFC3339Nano) l.Usage = &latest if err := m.Store.SaveLicence(ctx, l); err != nil { - return err + return out, err } _ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct, "sessionResetsAt": reading.SessionResetsAt, "weeklyPct": reading.WeeklyPct, "weeklyResetsAt": reading.WeeklyResetsAt, "sonnetPct": reading.SonnetPct, "sonnetResetsAt": reading.SonnetResetsAt}) // On each reading, a licence at its threshold moves every consumer bound to it (§26). - if _, err := m.ApplyRule(ctx, l.Name, false, false); err != nil { - return err + acts, err := m.ApplyRule(ctx, l.Name, false, dry) + if acts == nil { + acts = []Switch{} + } + out = append(out, Read{Licence: l.Name, Reading: &latest, Switches: acts}) + if err != nil { + return out, err } } // What the readings and the settings now hold is said, each condition only when what it says changed (§9). - return m.SayConditions(ctx, false) + return out, m.SayConditions(ctx, false) } // ---- the seat's verbs -------------------------------------------------------------------------------- @@ -894,7 +921,9 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil } -// Licences is each licence as a person reads it: health and who is bound, never a token. +// Licences is each licence as a person reads it (design 39 bus-and-switch §34, §47, B19, B40): today's fields, its +// latest usage with its reset times, its room or why it is not known, whether it is the target a full licence's +// consumers move to next, when and from where it was adopted, and the conditions open about it. Never a token. func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) { all, err := m.Store.Licences(ctx) if err != nil { @@ -904,27 +933,52 @@ func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) { if err != nil { return nil, err } + open, err := m.KeptConditions() + if err != nil { + return nil, err + } + s := m.settings() stamp := func(ms int64) any { if ms == 0 { return nil } return time.UnixMilli(ms).UTC().Format(time.RFC3339) } + targets := map[string]bool{} + for _, l := range all { + if len(consumersOf(l.Name, bindings)) > 0 { + if to, _ := m.targetFor(l, all, s); to != "" { + targets[to] = true + } + } + } out := []map[string]any{} for _, l := range all { - bound := []string{} - for _, b := range bindings { - if b.Licence == l.Name { - bound = append(bound, b.Consumer) - } + bound := consumersOf(l.Name, bindings) + if bound == nil { + bound = []string{} } account := l.Email if account == "" { account = l.AccountUUID } + var room any = m.roomWords(l, s) + if r, known := Room(l, m.Now(), s); known { + room = r + } + conditions := []map[string]any{} + prefix := "licence." + KeyFor(l.Name) + "." + for _, c := range open { + if strings.HasPrefix(c.Body.Key, prefix) { + conditions = append(conditions, map[string]any{"key": c.Body.Key, "severity": c.Body.Severity, + "headline": c.Body.Headline, "since": c.Since}) + } + } out = append(out, map[string]any{"name": l.Name, "kind": l.Kind, "account": account, - "accessExpiresAt": stamp(l.AccessExpiresAt), "refreshExpiresAt": stamp(l.RefreshExpiresAt), - "rotatedAt": stamp(l.RotatedAt), "failures": l.Failures, "bound": bound}) + "organisation": l.OrganizationUUID, "accessExpiresAt": stamp(l.AccessExpiresAt), + "refreshExpiresAt": stamp(l.RefreshExpiresAt), "rotatedAt": stamp(l.RotatedAt), "failures": l.Failures, + "bound": bound, "usage": l.Usage, "room": room, "target": targets[l.Name], "adoptedAt": stamp(l.AdoptedAt), + "adoptedFrom": l.AdoptedFrom, "conditions": conditions}) } return out, nil } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go index bdfdf67ed..878b4d877 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go @@ -76,6 +76,8 @@ type UsageRow struct { Licence string `json:"licence"` At int64 `json:"at"` Reading UsageReading `json:"reading"` + // Raw is the vendor's answer whole, answered only when asked (§35). + Raw map[string]any `json:"raw,omitempty"` } // Store is what the manager keeps. @@ -97,7 +99,9 @@ type Store interface { Outcome(ctx context.Context, fingerprint string) (Outcome, error) RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error - Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) + // Usage is the latest readings, newest first, of one licence or every one: at most limit, none before since (zero + // is none), the vendor's answer with raw (§35). + Usage(ctx context.Context, licence string, since time.Time, limit int, raw bool) ([]UsageRow, error) // Audit appends one entry for an act; an error is the act failing (B20). Audit(ctx context.Context, what string, detail map[string]any) error } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go b/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go new file mode 100644 index 000000000..706400efc --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go @@ -0,0 +1,413 @@ +package main + +// The module's own tools (design 39 bus-and-switch §37 to §44, B40): beside the seat's verbs, they read everything the +// manager keeps and start by hand everything it does by itself. A tool that acts answers "the carry-over has not ended" +// until it has (§11); licences_carried's run is the carry-over itself. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// tool is one of the module's own tools; acts says whether it acts, so the carry-over's gate holds it (§11). +func tool(name, description string, acts bool, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool { + return stdio.Tool{Name: name, Description: description, Input: input, + Run: func(a map[string]any) (any, error) { + m, err := manager() + if err != nil { + return nil, err + } + if acts { + if err := m.Carry.Acts(); err != nil { + return nil, err + } + } + ctx := WithCause(context.Background(), Cause{Why: "the " + name + " tool", By: stdio.CallerOf(a)}) + return run(ctx, m, a) + }} +} + +func boolean(description string) map[string]any { + return map[string]any{"type": "boolean", "description": description} +} + +func number(description string) map[string]any { + return map[string]any{"type": "number", "description": description} +} + +func moduleTools() []stdio.Tool { + licence := str("a licence, as `licences` names it; absent for all") + dry := boolean("answer what the rule would do, and do nothing") + return []stdio.Tool{ + tool("licences_switches", "Every switch of a consumer and why, newest first, from the audit: when, from and to which licence, the consumers, by whom and why, and for a switch by usage the reading that moved them.", + false, map[string]any{"licence": licence, "consumer": str("a consumer; absent for all"), + "since": str("a time, RFC 3339: no switch before it"), "limit": number("how many (default 20)")}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + l, _ := a["licence"].(string) + c, _ := a["consumer"].(string) + since, err := sinceOf(a) + if err != nil { + return nil, err + } + return m.Switches(strings.TrimSpace(l), strings.TrimSpace(c), since, limitOf(a, 20, 2000)) + }), + tool("licences_read_usage", "Read usage now, for one subscription or every one, as the manager does on its cadence; then apply the switch rule unless dry. Answers each reading and what the rule did or would do.", + true, map[string]any{"licence": licence, "dry": dry}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + l, _ := a["licence"].(string) + d, _ := a["dry"].(bool) + return m.ReadUsageOf(ctx, strings.TrimSpace(l), d) + }), + tool("licences_switch_now", "Apply the switch rule now from the latest readings, for one licence or all, even with switch_auto off since a person asked; with dry, answer the moves and make none.", + true, map[string]any{"licence": licence, "dry": dry}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + l, _ := a["licence"].(string) + d, _ := a["dry"].(bool) + acts, err := m.ApplyRule(ctx, strings.TrimSpace(l), true, d) + if acts == nil { + acts = []Switch{} + } + return acts, err + }), + tool("licences_settings", "The settings in force: each one's value, its default, where it was set (mesh, node or the default, as mesh-config answers it) and why it is invalid when it is.", + false, nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { + return m.SettingsInForce(ctx) + }), + tool("licences_kept", "Every entry the manager keeps on the bus: a state's keys, or a log's subjects with their last entry; with key, that key's value or that subject's last entry; and how full each is against its cap.", + false, map[string]any{"entry": str("one of licences, bindings, leases, offered, usage, audit, carried, conditions; absent for all"), + "key": str("a key of a state, or a subject of a log")}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + e, _ := a["entry"].(string) + k, _ := a["key"].(string) + return m.Kept(strings.TrimSpace(e), strings.TrimSpace(k)) + }), + tool("licences_resync", "Recover: say binding.changed again for every binding, ask every node what it holds, say again each condition held, and hand each licence's access token to the vault again. Answers what it said.", + true, nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { + return m.Resync(ctx) + }), + tool("licences_conditions", "The conditions the manager said and holds: each key of its state conditions with its body and since.", + false, nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) { + return m.KeptConditions() + }), + tool("licences_carried", "The carry-over's record (when, and per table the rows read and entries written), or, while it has not ended, the failure it names; with run and no record, carry over again. Refused once carried.", + false, map[string]any{"run": boolean("carry over again: only while it has not ended")}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + run, _ := a["run"].(bool) + return m.Carried(ctx, run) + }), + } +} + +// Switches are the audit's switches, newest first: of a licence (from or to it) and a consumer when named, none before +// since, at most limit (§37). +func (m *Manager) Switches(licence, consumer string, since time.Time, limit int) ([]map[string]any, error) { + bus, ok := m.Store.(*BusStore) + if !ok { + return nil, errors.New("the audit is not kept on the bus") + } + info, err := bus.AuditLog.Info() + if err != nil || info.Last == 0 { + return []map[string]any{}, err + } + var all []stdio.LogEntry + for next := uint64(1); next != 0; { + page, err := bus.AuditLog.Read(next, "switched", 0) + if err != nil { + return nil, err + } + all = append(all, page.Entries...) + next = page.Next + } + out := []map[string]any{} + for i := len(all) - 1; i >= 0 && len(out) < limit; i-- { + var e map[string]any + if json.Unmarshal(all[i].Value, &e) != nil { + continue + } + at, _ := time.Parse(time.RFC3339Nano, fmt.Sprint(e["at"])) + if at.Before(since) { + continue + } + if licence != "" && e["from"] != licence && e["to"] != licence { + continue + } + consumers, _ := e["consumers"].([]any) + if consumer != "" && !slices.Contains(consumers, any(consumer)) { + continue + } + s := map[string]any{"at": e["at"], "from": e["from"], "to": e["to"], "consumers": e["consumers"], + "by": e["by"], "why": e["why"]} + if _, ok := e["window"]; ok { + s["reading"] = map[string]any{"window": e["window"], "pct": e["pct"], "resetsAt": e["resetsAt"], + "room": e["room"]} + } + out = append(out, s) + } + return out, nil +} + +// SettingAnswer is one setting as licences_settings answers it (§40). +type SettingAnswer struct { + Setting string `json:"setting"` + Value any `json:"value"` + Default any `json:"default"` + Level string `json:"level"` + Invalid string `json:"invalid,omitempty"` +} + +// SettingsInForce is each setting in force, its default, where mesh-config says it was set, and why it is invalid +// when it is (§40, B18). +func (m *Manager) SettingsInForce(ctx context.Context) ([]SettingAnswer, error) { + levels := map[string]string{} + if m.ConfigLevels != nil { + got, err := m.ConfigLevels(ctx) + if err != nil { + return nil, fmt.Errorf("mesh-config did not say where each setting was set: %w", err) + } + levels = got + } + s := m.settings() + held, err := m.Judge(ctx) + if err != nil { + return nil, err + } + values, defaults := s.Values(), Defaults.Values() + var out []SettingAnswer + for k, v := range values { + a := SettingAnswer{Setting: k, Value: v, Default: defaults[k], Level: levels[k]} + if a.Level == "" { + a.Level = "default" + } + if c, ok := held["settings."+k+".invalid"]; ok { + a.Invalid = c.Summary + } + out = append(out, a) + } + sort.Slice(out, func(i, j int) bool { return out[i].Setting < out[j].Setting }) + return out, nil +} + +// kept are the entries the manager keeps on the bus, by their local names in the manifest (§2 to §9). +func (m *Manager) kept() (map[string]keptState, map[string]keptLog) { + states, logs := map[string]keptState{}, map[string]keptLog{} + if bus, ok := m.Store.(*BusStore); ok { + states["licences"], states["bindings"] = bus.LicencesState, bus.BindingsState + states["leases"], states["offered"] = bus.LeasesState, bus.OfferedState + logs["usage"], logs["audit"] = bus.UsageLog, bus.AuditLog + } + if m.Carry != nil { + states["carried"] = m.Carry.State + } + if m.Conditions != nil { + states["conditions"] = m.Conditions + } + return states, logs +} + +// Kept answers one entry the manager keeps, or every one (§41): a state's keys and their count, a log's subjects with +// their last entry and its fill against its cap; with key, one key's value or one subject's last entry. +func (m *Manager) Kept(entry, key string) (map[string]any, error) { + states, logs := m.kept() + out := map[string]any{} + names := []string{} + for n := range states { + names = append(names, n) + } + for n := range logs { + names = append(names, n) + } + sort.Strings(names) + if entry != "" && !slices.Contains(names, entry) { + return nil, fmt.Errorf("the manager keeps no entry %q: it keeps %s", entry, strings.Join(names, ", ")) + } + for _, n := range names { + if entry != "" && n != entry { + continue + } + if st, ok := states[n]; ok { + a, err := keptStateAnswer(st, key) + if err != nil { + return nil, fmt.Errorf("%s: %w", n, err) + } + out[n] = a + continue + } + a, err := keptLogAnswer(logs[n], key) + if err != nil { + return nil, fmt.Errorf("%s: %w", n, err) + } + out[n] = a + } + return out, nil +} + +func keptStateAnswer(st keptState, key string) (map[string]any, error) { + keys, err := st.Keys() + if err != nil { + return nil, err + } + sort.Strings(keys) + a := map[string]any{"kind": "state", "keys": keys, "fill": map[string]any{"keys": len(keys), "cap": "none declared"}} + if key != "" { + e, err := st.Get(key) + if err != nil { + return nil, err + } + if e == nil { + a["value"] = nil + } else { + a["value"] = e.Value + } + } + return a, nil +} + +func keptLogAnswer(l keptLog, key string) (map[string]any, error) { + info, err := l.Info() + if err != nil { + return nil, err + } + a := map[string]any{"kind": "log", "fill": map[string]any{"entries": info.Entries, "bytes": info.Bytes, + "maxBytes": info.MaxBytes}} + if key != "" { + e, err := l.Last(key) + if err != nil { + return nil, err + } + a["last"] = e + return a, nil + } + newest, err := l.Newest("") + if err != nil { + return nil, err + } + subjects := map[string]any{} + for _, e := range newest { + subjects[e.Key] = e + } + a["subjects"] = subjects + return a, nil +} + +// Resync says again what a consumer, the vault or a node may have missed (§42): binding.changed for every binding, +// holdings.asked, each condition held, and licence-put of each licence's access token. Answers what it said. +func (m *Manager) Resync(ctx context.Context) (map[string]any, error) { + bindings, err := m.Store.Bindings(ctx) + if err != nil { + return nil, err + } + said := map[string]any{} + consumers := []string{} + for _, b := range bindings { + m.changed(b, b.Licence) + consumers = append(consumers, b.Consumer) + } + said["binding.changed"] = consumers + if err := m.AskHoldings(CauseOf(ctx).By); err != nil { + return said, fmt.Errorf("holdings.asked could not be said: %w", err) + } + said["holdings.asked"] = true + if err := m.SayConditions(ctx, true); err != nil { + return said, err + } + conditions, err := m.KeptConditions() + if err != nil { + return said, err + } + keys := []string{} + for _, c := range conditions { + keys = append(keys, c.Body.Key) + } + said["conditions"] = keys + licences, err := m.Store.Licences(ctx) + if err != nil { + return said, err + } + put := []string{} + var errs []error + for _, l := range licences { + if l.Generation == 0 { + continue + } + key := KeyFor(l.Name) + access, err := m.Grants.Get(ctx, key, PartAccess) + if err == nil { + err = m.Grants.Put(ctx, key, PartAccess, l.Generation, access) + } + access = "" + if err != nil { + errs = append(errs, fmt.Errorf("the access token of %s: %w", l.Name, err)) + continue + } + put = append(put, l.Name) + } + said["licence-put"] = put + return said, errors.Join(errs...) +} + +// KeptConditions is each key of the state conditions with its body and since (§43), by key. +func (m *Manager) KeptConditions() ([]KeptCondition, error) { + out := []KeptCondition{} + if m.Conditions == nil { + return out, nil + } + keys, err := m.Conditions.Keys() + if err != nil { + return nil, err + } + sort.Strings(keys) + for _, k := range keys { + e, err := m.Conditions.Get(k) + if err != nil { + return nil, err + } + var c KeptCondition + if e == nil || json.Unmarshal(e.Value, &c) != nil { + continue + } + out = append(out, c) + } + return out, nil +} + +// Carried is the carry-over's record, or the failure its last run names while it has not ended; with run and no +// record it carries over again, and is refused once carried (§44). +func (m *Manager) Carried(ctx context.Context, run bool) (map[string]any, error) { + if m.Carry == nil { + return nil, errors.New("this manager has nothing to carry over") + } + if m.Carry.Ended() { + if run { + return nil, errors.New("the carry-over has ended: carried.postgres is written, and it is never done again") + } + e, err := m.Carry.State.Get(CarriedKey) + if err != nil || e == nil { + return nil, err + } + var rec CarriedRecord + if err := json.Unmarshal(e.Value, &rec); err != nil { + return nil, err + } + return map[string]any{"carried": rec}, nil + } + if !run { + return map[string]any{"carried": nil, "failure": m.Carry.Failure()}, nil + } + bus, _ := m.Store.(*BusStore) + rec, err := m.Carry.Run(ctx, m, bus) + if serr := m.SayConditions(ctx, false); serr != nil { + m.Log("saying the conditions after the carry-over: %v", serr) + } + if err != nil { + return map[string]any{"carried": nil, "failure": m.Carry.Failure()}, err + } + return map[string]any{"carried": rec}, nil +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go new file mode 100644 index 000000000..01c41af28 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go @@ -0,0 +1,327 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + "testing" + "time" +) + +// served is every verb and tool the binary serves, by name, run on mm's manager. +func served(t *testing.T, mm *miniMesh) map[string]stdTool { + t.Helper() + built = mm.m + t.Cleanup(func() { built = nil }) + out := map[string]stdTool{} + for _, tool := range tools() { + out[strings.TrimPrefix(tool.Name, Seat+".")] = stdTool(tool.Run) + } + return out +} + +// answer is a tool's answer as JSON, decoded into a generic form. +func answer(t *testing.T, run stdTool, args map[string]any) any { + t.Helper() + got, err := run(args) + if err != nil { + t.Fatalf("%v answered %v", args, err) + } + raw, _ := json.Marshal(got) + var v any + _ = json.Unmarshal(raw, &v) + return v +} + +// design 39 bus-and-switch §34/AC1, §47, B40: an answer per licence carries today's fields, its organisation, usage, +// room or why it is not known, whether it is the next target, when and from where it was adopted and its open +// conditions, and no token. +func TestLicencesAnswersEachFieldAndNoToken(t *testing.T) { + mm := fourOnA(t) + ctx := context.Background() + _ = mm.store.SaveLicence(ctx, Licence{Name: "k", Kind: "api-key", Generation: 1, AdoptedAt: t0.UnixMilli(), + AdoptedFrom: "a file"}) + _, _ = saying(mm) + _ = mm.store.SaveLicence(ctx, sub("a", read(40, 95))) + _ = mm.m.SayConditions(ctx, false) + got := answer(t, served(t, mm)["licences"], nil).([]any) + by := map[string]map[string]any{} + for _, l := range got { + f := l.(map[string]any) + by[f["name"].(string)] = f + for _, k := range []string{"name", "kind", "account", "organisation", "accessExpiresAt", "refreshExpiresAt", + "failures", "bound", "usage", "room", "target", "adoptedAt", "adoptedFrom", "conditions"} { + if _, ok := f[k]; !ok { + t.Errorf("%s has no %s: %v", f["name"], k, f) + } + } + } + if by["b"]["target"] != true || by["c"]["target"] != false || by["a"]["room"] != 5.0 || + by["k"]["room"] != "is an API key, whose room is never known" || by["k"]["adoptedFrom"] != "a file" || + len(by["a"]["bound"].([]any)) != 4 || by["a"]["usage"].(map[string]any)["weeklyResetsAt"] != "2026-11-14T09:00:00Z" { + t.Fatalf("licences %v", by) + } + if cs := by["a"]["conditions"].([]any); len(cs) != 1 || cs[0].(map[string]any)["key"] != "licence.a.near-limit" { + t.Fatalf("a's conditions %v", cs) + } + raw, _ := json.Marshal(got) + for _, token := range []string{"rt-", "at-", "local-"} { + if strings.Contains(string(raw), token) { + t.Fatalf("licences holds %s: %s", token, raw) + } + } +} + +// design 39 bus-and-switch §35/AC1: since and limit bound the answer, newest first; raw adds the vendor's answer. +func TestUsageIsBoundedBySinceAndLimit(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _ = mm.store.SaveLicence(ctx, sub("a", nil)) + for i := range 30 { + _ = mm.store.RecordUsage(ctx, "a", t0.Add(time.Duration(i)*time.Minute).UnixMilli(), UsageReading{}, + map[string]any{"i": float64(i)}) + } + run := served(t, mm)["usage"] + rows := answer(t, run, map[string]any{"licence": "a"}).([]any) + first := rows[0].(map[string]any) + if len(rows) != 20 || first["at"] != float64(t0.Add(29*time.Minute).UnixMilli()) || first["raw"] != nil { + t.Fatalf("by default %d rows, the first %v", len(rows), first) + } + since := t0.Add(25 * time.Minute).UTC().Format(time.RFC3339) + rows = answer(t, run, map[string]any{"since": since, "limit": 3.0, "raw": true}).([]any) + if len(rows) != 3 || rows[0].(map[string]any)["raw"].(map[string]any)["i"] != 29.0 { + t.Fatalf("since and limit 3: %v", rows) + } + if rows = answer(t, run, map[string]any{"since": since, "limit": 5000.0}).([]any); len(rows) != 5 { + t.Fatalf("since: %d rows", len(rows)) + } + if _, err := run(map[string]any{"since": "yesterday"}); err == nil { + t.Fatal("a since that is not a time is answered") + } +} + +// design 39 bus-and-switch §37/AC1: a switch by usage and one by a person are answered, newest first, with their +// causes, a switch by usage with the reading that moved them; licence and consumer narrow the answer. +func TestSwitchesAnswersBothCauses(t *testing.T) { + mm := fourOnA(t) + ctx := context.Background() + _, _ = mm.m.ApplyRule(ctx, "", false, false) + mm.now = t0.Add(time.Minute) + _, _ = mm.m.Bind(WithCause(ctx, Cause{Why: "the switch verb", By: "a person"}), "ace", "c") + run := served(t, mm)["licences_switches"] + got := answer(t, run, nil).([]any) + if len(got) != 2 { + t.Fatalf("switches %v", got) + } + person, usage := got[0].(map[string]any), got[1].(map[string]any) + if person["by"] != "a person" || person["why"] != "the switch verb" || person["to"] != "c" || person["reading"] != nil || + usage["by"] != ByUsage || usage["reading"].(map[string]any)["window"] != "weekly" || usage["from"] != "a" { + t.Fatalf("switches %v", got) + } + if got := answer(t, run, map[string]any{"licence": "c"}).([]any); len(got) != 1 { + t.Fatalf("of c %v", got) + } + if got := answer(t, run, map[string]any{"consumer": "g14"}).([]any); len(got) != 1 || got[0].(map[string]any)["by"] != ByUsage { + t.Fatalf("of g14 %v", got) + } +} + +// design 39 bus-and-switch §38/AC1, §24, §32: reading usage now writes the reading and applies the rule, and with dry +// reads, answers the moves and moves nothing. +func TestReadUsageNowAppliesTheRuleUnlessDry(t *testing.T) { + ctx := context.Background() + for _, dry := range []bool{true, false} { + mm := newMesh(t) + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + _ = mm.store.SaveLicence(ctx, Licence{Name: "b@example.org", Kind: "subscription", Usage: read(10, 10)}) + mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0}, + "seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}} + got := answer(t, served(t, mm)["licences_read_usage"], map[string]any{"dry": dry}).([]any) + r := got[0].(map[string]any) + moved := mm.bound("laptop").Licence == "b@example.org" + if len(got) != 1 || r["reading"].(map[string]any)["weeklyPct"] != 99.0 || + len(r["switches"].([]any)) != 1 || moved == dry || len(mm.usage.under("")) != 1 { + t.Fatalf("dry %v: answered %v, moved %v", dry, got, moved) + } + } +} + +// design 39 bus-and-switch §39/AC1, §32: switching now runs the rule with switch_auto off, and dry moves nothing; an +// acting tool waits for the carry-over (§11). +func TestSwitchNowRunsWithSwitchAutoOff(t *testing.T) { + mm := fourOnA(t) + mm.m.Settings.SwitchAuto = false + run := served(t, mm)["licences_switch_now"] + if got := answer(t, run, map[string]any{"dry": true}).([]any); len(got) != 1 || mm.bound("ace").Licence != "a" { + t.Fatalf("dry %v", got) + } + if got := answer(t, run, map[string]any{"licence": "a"}).([]any); len(got) != 1 || mm.bound("ace").Licence != "b" { + t.Fatalf("switched %v, ace on %s", got, mm.bound("ace").Licence) + } + mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now })} + if _, err := run(nil); err == nil || err.Error() != NotCarried { + t.Fatalf("before the carry-over answered %v", err) + } +} + +// design 39 bus-and-switch §40/AC1, B18: a value set at node level is answered with its level, the rest with the +// default, each with its value and default, and an invalid one saying why. +func TestSettingsAnswersTheLevel(t *testing.T) { + mm := newMesh(t) + mm.m.Settings.SwitchAt, mm.m.Settings.WarnAt = 80, 85 + mm.m.ConfigLevels = func(context.Context) (map[string]string, error) { + return map[string]string{"switch_at_pct": "node", "warn_at_pct": "mesh", "cadence_minutes": "default"}, nil + } + got := answer(t, served(t, mm)["licences_settings"], nil).([]any) + by := map[string]map[string]any{} + for _, s := range got { + f := s.(map[string]any) + by[f["setting"].(string)] = f + } + if len(by) != len(Defaults.Values()) || by["switch_at_pct"]["level"] != "node" || by["switch_at_pct"]["value"] != 80.0 || + by["switch_at_pct"]["default"] != 99.0 || by["usage_minutes"]["level"] != "default" || + !strings.Contains(fmtAny(by["room_below_pct"]["invalid"]), "above switch_at_pct 80") || + !strings.Contains(fmtAny(by["warn_at_pct"]["invalid"]), "85") || by["switch_auto"]["invalid"] != nil { + t.Fatalf("settings %v", by) + } + mm.m.ConfigLevels = func(context.Context) (map[string]string, error) { return nil, errors.New("no responders") } + if _, err := served(t, mm)["licences_settings"](nil); err == nil { + t.Fatal("mesh-config not answering is answered as the default") + } +} + +func fmtAny(v any) string { s, _ := v.(string); return s } + +// design 39 bus-and-switch §41/AC1, §20: every bus-state entry the manifest asks for is read by licences_kept, with its +// keys or subjects, one key's value and its fill; and no entry holds a token. +func TestKeptReadsEveryEntryOfTheManifest(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Requires []json.RawMessage `json:"requires"` + } + _ = json.Unmarshal(raw, &m) + mm := newMesh(t) + ctx := context.Background() + _, _ = saying(mm) + mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now })} + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-secret-1", true, t0)}) + _ = mm.m.ReadUsage(ctx) + run := served(t, mm)["licences_kept"] + all := answer(t, run, nil).(map[string]any) + entries := 0 + for _, r := range m.Requires { + var e struct { + Name string `json:"name"` + Template string `json:"template"` + } + if json.Unmarshal(r, &e) != nil || e.Template == "" { + continue + } + entries++ + got, ok := all[e.Name].(map[string]any) + if !ok || got["fill"] == nil || got["kind"] != map[string]string{"state": "state", "log": "log"}[e.Template] { + t.Errorf("licences_kept does not read %s as a %s: %v", e.Name, e.Template, all[e.Name]) + } + } + if entries != 8 || len(all) != entries { + t.Fatalf("the manifest asks for %d entries, licences_kept reads %d", entries, len(all)) + } + one := answer(t, run, map[string]any{"entry": "licences", "key": KeyFor(licence1)}).(map[string]any) + if one["licences"].(map[string]any)["value"] == nil { + t.Fatalf("one key %v", one) + } + if subjects := all["usage"].(map[string]any)["subjects"].(map[string]any); subjects[KeyFor(licence1)] == nil { + t.Fatalf("usage subjects %v", subjects) + } + if _, err := run(map[string]any{"entry": "tables"}); err == nil { + t.Fatal("an entry not kept is answered") + } + everything, _ := json.Marshal(all) + for _, token := range []string{"rt-secret", "rt-1", "at-1", "local-"} { + if strings.Contains(string(everything), token) { + t.Fatalf("a kept entry holds %s", token) + } + } +} + +// design 39 bus-and-switch §42/AC1: resync says binding.changed once per binding, holdings.asked once, each condition +// once, and licence-put of each licence's access once. +func TestResyncSaysEachOnce(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, said := saying(mm) + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.login("server", "rt-b", true, t0)}) + mm.m.Settings.WarnAt = 100 + _ = mm.m.SayConditions(ctx, false) + mm.events, *said = nil, nil + got := answer(t, served(t, mm)["licences_resync"], nil).(map[string]any) + count := map[string]int{} + for _, e := range mm.events { + name, _, _ := strings.Cut(e, " ") + count[name]++ + } + puts, _ := got["licence-put"].([]any) + if count["binding.changed"] != 2 || count[HoldingsAsked] != 1 || len(*said) != 1 || len(puts) != 1 || puts[0] != licence1 { + t.Fatalf("said %v, conditions %d, answered %v", mm.events, len(*said), got) + } +} + +// design 39 bus-and-switch §43/AC1: each key of conditions is answered with its body and since. +func TestConditionsAnswersEachKey(t *testing.T) { + mm := fourOnA(t) + ctx := context.Background() + _, _ = saying(mm) + _ = mm.store.SaveLicence(ctx, sub("a", read(40, 95))) + mm.m.Settings.RoomBelow = 100 + _ = mm.m.SayConditions(ctx, false) + got := answer(t, served(t, mm)["licences_conditions"], nil).([]any) + var keys []string + for _, c := range got { + f := c.(map[string]any) + if f["since"] == "" || f["body"].(map[string]any)["headline"] == "" { + t.Fatalf("condition %v", f) + } + keys = append(keys, f["body"].(map[string]any)["key"].(string)) + } + if strings.Join(keys, ",") != "licence.a.near-limit,settings.room_below_pct.invalid" { + t.Fatalf("keys %v", keys) + } +} + +// design 39 bus-and-switch §44/AC1: licences_carried answers the failure while not carried, carries over with run, then +// answers the record and refuses run. +func TestCarriedRunIsRefusedOnceCarried(t *testing.T) { + mm := newMesh(t) + _, said := saying(mm) + down := true + mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now }), + Source: func(context.Context) (CarrySource, error) { + if down { + return nil, errors.New("connection refused") + } + return &memSource{}, nil + }} + run := served(t, mm)["licences_carried"] + if _, err := run(map[string]any{"run": true}); err == nil || len(of(*said, CarryOverFailed)) != 1 { + t.Fatalf("a failed run answered %v, said %v", err, *said) + } + if got := answer(t, run, nil).(map[string]any); !strings.Contains(fmtAny(got["failure"]), "connection refused") { + t.Fatalf("not carried %v", got) + } + down = false + if got := answer(t, run, map[string]any{"run": true}).(map[string]any); got["carried"] == nil || + strings.Join(of(*said, CarryOverFailed), ",") != "raised,cleared" { + t.Fatalf("carried %v, said %v", got, *said) + } + if got := answer(t, run, nil).(map[string]any); got["carried"].(map[string]any)["tables"] == nil { + t.Fatalf("the record %v", got) + } + if _, err := run(map[string]any{"run": true}); err == nil { + t.Fatal("run is answered once carried") + } +} diff --git a/modules/claude-licence-manager/module.json b/modules/claude-licence-manager/module.json index 00869aaac..3c169a4df 100644 --- a/modules/claude-licence-manager/module.json +++ b/modules/claude-licence-manager/module.json @@ -97,7 +97,8 @@ "invokes": [ "seat:operator-channel.tell", "seat:mesh-vault.licence-put", - "seat:mesh-vault.get" + "seat:mesh-vault.get", + "seat:mesh-config.config" ], "seats": [ { -- 2.54.0 From b8ce072c9f8a78a19b84ca141da4f37ec71b7068 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 00:37:54 +0100 Subject: [PATCH 23/32] M-16: the manager whole on a real bus and today's database on a real postgres (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The built binary played as its runtime plays it, its kept data in a real nats-server's buckets and streams, today's rows in a real postgres: a start stopped at a table says carry-over-failed and acts on nothing, the next carries every row and a further one writes nothing; an unreachable database calls no vendor; a reading at the threshold moves four consumers in one act and no-room is raised and cleared on the operator channel's subject, read through its client; a new login is adopted, listed and a target. Builds: design 39 bus-and-switch §9, §11, §12, §13, §23, §26, §28, §29, §46, B41 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../whole_integration_test.go | 1143 +++++++++++++++++ modules/claude-licence-manager/go.mod | 8 +- modules/claude-licence-manager/go.sum | 46 +- 3 files changed, 1179 insertions(+), 18 deletions(-) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go new file mode 100644 index 000000000..103034053 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go @@ -0,0 +1,1143 @@ +//go:build integration + +package main + +// The manager whole against a real bus (design 39 bus-and-switch §9, §11 to §13, §23, §26, §28, §29, §46, B41): the +// built binary, played over its real stdin and stdout as the node's tool runtime plays it (tools/call with the caller in +// _meta; mesh/state.* from a real nats-server's key-value buckets, mesh/log.* from the logs' real streams, mesh/publish +// and mesh/seat.publish onto their subjects), and today's database on a real postgres (MESH_TEST_POSTGRES, or a private +// server started here from the machine's initdb on a socket of its own, listening on no port). What the manager asks of +// other parts is played beside it: the vault's licence-put and get, a node's claude_code_grant and its holdings-changed, +// the operator channel's tell, and the vendor's token and usage endpoints. A condition is taken off the operator +// channel's subject and decoded by its client, the contract the router reads (B41); the router itself is not run. + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + "golang.org/x/crypto/nacl/box" + + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + "git.novox.be/novox/mesh-sdk/go/testbus" +) + +func TestMain(m *testing.M) { + code := m.Run() + stopPostgres() + os.Exit(code) +} + +// ---- today's database on a real postgres --------------------------------------------------------------------- + +var ( + pgOnce sync.Once + pgBase string + pgDir string + pgWhy string + pgCount int + pgMu sync.Mutex +) + +func startPostgres() { + if u := os.Getenv("MESH_TEST_POSTGRES"); u != "" { + pgBase = u + return + } + initdb, err := exec.LookPath("initdb") + if err != nil { + pgWhy = "MESH_TEST_POSTGRES is unset and the machine has no initdb" + return + } + dir, err := os.MkdirTemp("", "licpg") + if err != nil { + pgWhy = err.Error() + return + } + pgDir = dir + if out, err := exec.Command(initdb, "-D", dir+"/data", "-U", "postgres", "-A", "trust", "-E", "UTF8", "--no-locale").CombinedOutput(); err != nil { + pgWhy = fmt.Sprintf("initdb: %v: %s", err, out) + return + } + // A socket in its own directory and no port: nothing on this machine reaches it but these tests. + if out, err := exec.Command(filepath.Join(filepath.Dir(initdb), "pg_ctl"), "-D", dir+"/data", "-l", dir+"/log", "-w", + "-o", "-k "+dir+" -c listen_addresses='' -F", "start").CombinedOutput(); err != nil { + pgWhy = fmt.Sprintf("pg_ctl start: %v: %s", err, out) + return + } + pgBase = "postgres://postgres@/postgres?host=" + dir +} + +func stopPostgres() { + if pgDir == "" { + return + } + if initdb, err := exec.LookPath("initdb"); err == nil { + _ = exec.Command(filepath.Join(filepath.Dir(initdb), "pg_ctl"), "-D", pgDir+"/data", "-m", "immediate", "stop").Run() + } + _ = os.RemoveAll(pgDir) +} + +// freshDatabase is an empty database of its own for one test, or a skip saying why there is none. +func freshDatabase(t *testing.T) (string, *pgx.Conn) { + t.Helper() + pgOnce.Do(startPostgres) + if pgBase == "" { + t.Skipf("NOT TESTED ON POSTGRES: %s", pgWhy) + } + pgMu.Lock() + pgCount++ + name := fmt.Sprintf("licmgr_%d_%d", os.Getpid(), pgCount) + pgMu.Unlock() + ctx := context.Background() + admin, err := pgx.Connect(ctx, pgBase) + if err != nil { + t.Fatal(err) + } + defer admin.Close(ctx) + if _, err := admin.Exec(ctx, "create database "+name); err != nil { + t.Fatal(err) + } + u, err := url.Parse(pgBase) + if err != nil { + t.Fatal(err) + } + u.Path = "/" + name + conn, err := pgx.Connect(ctx, u.String()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close(ctx) }) + return u.String(), conn +} + +// todaysSchema is the schema trunk's manager made (its PgStore's Schema): what the carry-over reads. +var todaysSchema = []string{ + `create table licence (name text primary key, kind text not null check (kind in ('subscription', 'api-key')), + account_uuid text unique, email text, organization_uuid text, sealed text, refresh_fingerprint text, + access_expires_at bigint, refresh_expires_at bigint, failures integer not null default 0, notified_at bigint, + adopted_at bigint not null, rotated_at bigint)`, + `create sequence binding_generation`, + `create table binding (consumer text primary key, licence text not null references licence(name), generation bigint not null)`, + `create table lease (key text primary key, holder text not null, until timestamptz not null)`, + `create table offered (fingerprint text primary key, node text not null, account_uuid text, outcome text not null, + why text not null, at timestamptz not null default now())`, + `create table usage (licence text not null, at bigint not null, reading jsonb not null, raw jsonb not null)`, + `create table audit (at timestamptz not null default now(), what text not null, detail jsonb not null)`, +} + +const ( + licenceA = "first@example.org" + licenceB = "second@example.org" + grantKey = "a key the vault made" +) + +// consumers are the four nodes bound to licenceA in today's rows. +var consumers = []string{"anchor", "home-server", "laptop", "workstation"} + +// fillTodaysRows writes a copy of the shape of today's rows: two subscriptions, four consumers bound to the first, a +// lease, two offers (one older than the offered state's 90 days), three readings (one older than 35 days) and four +// audit rows (one of a kind the log does not name). +func fillTodaysRows(t *testing.T, conn *pgx.Conn) { + t.Helper() + ctx := context.Background() + for _, s := range todaysSchema { + if _, err := conn.Exec(ctx, s); err != nil { + t.Fatal(err) + } + } + crypt, _ := NewCrypt(grantKey) + now := time.Now() + far := now.Add(30 * 24 * time.Hour).UnixMilli() + for i, l := range []struct{ name, account, refresh, access string }{ + {licenceA, "acct-a", "rt-a", "at-a"}, {licenceB, "acct-b", "rt-b", "at-b"}} { + grant, _ := json.Marshal(FullGrant{AccessToken: l.access, RefreshToken: l.refresh, ExpiresAt: far}) + if _, err := conn.Exec(ctx, `insert into licence (name, kind, account_uuid, email, sealed, refresh_fingerprint, + access_expires_at, refresh_expires_at, adopted_at, rotated_at) values ($1,'subscription',$2,$1,$3,$4,$5,$5,$6,$7)`, + l.name, l.account, sealAtRest(t, crypt, string(grant)), Fingerprint(l.refresh), far, int64(i+1), now.UnixMilli()); err != nil { + t.Fatal(err) + } + } + for i, c := range consumers { + if _, err := conn.Exec(ctx, `insert into binding values ($1, $2, $3)`, c, licenceA, 1013+i); err != nil { + t.Fatal(err) + } + } + reading := `{"sessionPct": 42, "weeklyPct": 10}` + for _, q := range []struct { + sql string + args []any + }{ + {`insert into lease values ('licence:x', 'novox:1', now())`, nil}, + {`insert into offered values ('sha256:aa', 'laptop', 'acct-a', 'adopted', 'refreshed', $1)`, []any{now.Add(-24 * time.Hour)}}, + {`insert into offered values ('sha256:bb', 'anchor', null, 'dead', '400', $1)`, []any{now.Add(-100 * 24 * time.Hour)}}, + {`insert into usage values ($1, $2, $3, '{}')`, []any{licenceA, now.Add(-40 * 24 * time.Hour).UnixMilli(), reading}}, + {`insert into usage values ($1, $2, $3, '{"five_hour": {"utilization": 42}}')`, []any{licenceA, now.Add(-2 * time.Hour).UnixMilli(), reading}}, + {`insert into usage values ($1, $2, $3, '{}')`, []any{licenceB, now.Add(-time.Hour).UnixMilli(), reading}}, + {`insert into audit values ($1, 'adopted', '{"licence": "first@example.org", "node": "laptop"}')`, []any{now.Add(-72 * time.Hour)}}, + {`insert into audit values ($1, 'bound', '{"consumer": "laptop"}')`, []any{now.Add(-71 * time.Hour)}}, + {`insert into audit values ($1, 'switched', '{"consumer": "anchor"}')`, []any{now.Add(-70 * time.Hour)}}, + {`insert into audit values ($1, 'grant-moved', '{"licence": "second@example.org"}')`, []any{now.Add(-69 * time.Hour)}}, + } { + if _, err := conn.Exec(ctx, q.sql, q.args...); err != nil { + t.Fatal(err) + } + } +} + +// ---- the vendor ---------------------------------------------------------------------------------------------- + +// vendor answers the token endpoint by refresh token and the usage endpoint by access token, and counts its calls. +type vendor struct { + mu sync.Mutex + calls int + logins map[string][2]string // refresh token: the next access token and the account + usage map[string][2]float64 // access token: session and weekly percentages + server *httptest.Server +} + +func newVendor(t *testing.T) *vendor { + v := &vendor{logins: map[string][2]string{}, usage: map[string][2]float64{}} + v.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + v.mu.Lock() + defer v.mu.Unlock() + v.calls++ + if r.URL.Path == "/token" { + _ = r.ParseForm() + l, ok := v.logins[r.PostForm.Get("refresh_token")] + if !ok { + http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest) + return + } + in := int64(8 * 3600) + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": l[0], "expires_in": in, + "account": map[string]string{"uuid": l[1]}}) + return + } + u, ok := v.usage[strings.TrimPrefix(r.Header.Get("authorization"), "Bearer ")] + if !ok { + http.Error(w, `{"error":"unknown token"}`, http.StatusUnauthorized) + return + } + resets := time.Now().Add(3 * time.Hour).UTC().Format(time.RFC3339) + _ = json.NewEncoder(w).Encode(map[string]any{"five_hour": map[string]any{"utilization": u[0], "resets_at": resets}, + "seven_day": map[string]any{"utilization": u[1], "resets_at": resets}}) + })) + t.Cleanup(v.server.Close) + return v +} + +func (v *vendor) set(access string, session, weekly float64) { + v.mu.Lock() + v.usage[access] = [2]float64{session, weekly} + v.mu.Unlock() +} + +func (v *vendor) called() int { + v.mu.Lock() + defer v.mu.Unlock() + return v.calls +} + +// ---- the runtime ----------------------------------------------------------------------------------------------- + +type wire struct { + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// said is one event the manager said, or one call it asked of another part. +type said struct { + key string + body json.RawMessage +} + +// mesh is the bus, the state directory, the database and the parts beside the manager, kept across its starts. +type mesh struct { + t *testing.T + ctx context.Context + nc *nats.Conn + js jetstream.JetStream + bin string + dir string + dbURL string + vendor *vendor + + mu sync.Mutex + said []said + news chan struct{} + secrets map[string]string // the vault: a secret's name and its value + calls map[string][32]byte // the vault: licence-put's open calls and the private key made for each + callArgs map[string]map[string]any + holdings []Holdings // the agent modules: what each node says it holds when asked + logins map[string]FullGrant // the agent modules: the login each node hands over, by node + conditions []oc.Condition +} + +const busStates = "licences bindings leases offered carried conditions" +const busLogs = "usage audit" + +func newWholeMesh(t *testing.T, dbURL string) *mesh { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + dir := t.TempDir() + bin := filepath.Join(dir, "claude-licence-manager") + build := exec.Command("go", "build", "-o", bin, ".") + build.Stderr = os.Stderr + if err := build.Run(); err != nil { + t.Fatalf("the manager does not build: %v", err) + } + nc, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + // What the bus module creates from the manifest's templates (§1 to §9) before the manager's want moves (§50). + for _, s := range strings.Fields(busStates) { + if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: Publisher + "_" + s, History: 8}); err != nil { + t.Fatal(err) + } + } + for _, l := range strings.Fields(busLogs) { + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "LOG_" + Publisher + "_" + l, + Subjects: []string{"mesh.log." + Publisher + "." + l + ".>"}}); err != nil { + t.Fatal(err) + } + } + _ = os.MkdirAll(filepath.Join(dir, "state"), 0o700) + for f, v := range map[string]string{"grant.key": grantKey, "database.url": dbURL, "settings.json": "{}"} { + if err := os.WriteFile(filepath.Join(dir, f), []byte(v), 0o600); err != nil { + t.Fatal(err) + } + } + m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, dbURL: dbURL, vendor: newVendor(t), + news: make(chan struct{}, 1), secrets: map[string]string{}, calls: map[string][32]byte{}, + callArgs: map[string]map[string]any{}, logins: map[string]FullGrant{}} + sub, err := nc.Subscribe("mesh.seat.operator-channel.accept.condition.>", func(msg *nats.Msg) { + c, err := occ.DecodeCondition(msg.Data) + if err != nil { + t.Errorf("a condition the operator channel's client does not decode: %v: %s", err, msg.Data) + return + } + if want, _ := occ.ConditionSubject(Publisher); msg.Subject != want { + t.Errorf("a condition said on %s, not %s", msg.Subject, want) + } + m.mu.Lock() + m.conditions = append(m.conditions, c) + m.mu.Unlock() + m.notify() + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sub.Unsubscribe() }) + return m +} + +func (m *mesh) notify() { + select { + case m.news <- struct{}{}: + default: + } +} + +// until waits for what to hold, woken by each event, call and condition the manager says, never by a timer of its own. +func (m *mesh) until(what string, ok func() bool) { + m.t.Helper() + deadline := time.After(60 * time.Second) + for { + m.mu.Lock() + done := ok() + m.mu.Unlock() + if done { + return + } + select { + case <-m.news: + case <-deadline: + m.t.Fatalf("never: %s", what) + } + } +} + +// count is how many of what was said have key; under mu. +func (m *mesh) count(key string) int { + n := 0 + for _, s := range m.said { + if s.key == key { + n++ + } + } + return n +} + +// of is the bodies said under key; under mu. +func (m *mesh) of(key string) []map[string]any { + var out []map[string]any + for _, s := range m.said { + if s.key == key { + var b map[string]any + _ = json.Unmarshal(s.body, &b) + out = append(out, b) + } + } + return out +} + +// conditionsOf is each condition said with key, in order; under mu. +func (m *mesh) conditionsOf(key string) []oc.Condition { + var out []oc.Condition + for _, c := range m.conditions { + if c.Key == key { + out = append(out, c) + } + } + return out +} + +// started is one start of the binary. +type started struct { + *mesh + in io.WriteCloser + cmd *exec.Cmd + next int + pending map[string]chan wire + pmu sync.Mutex + subs []string +} + +// start starts the manager as its runtime launches it, with the module's words (ADR 0192). +func (m *mesh) start() *started { + m.t.Helper() + cmd := exec.CommandContext(m.ctx, m.bin) + cmd.Env = append(os.Environ(), "MESH_LICENCE_STATE="+filepath.Join(m.dir, "state"), + "MESH_LICENCE_KEY_FILE="+filepath.Join(m.dir, "grant.key"), "DATABASE_URL_FILE="+filepath.Join(m.dir, "database.url"), + "MESH_LICENCE_SETTINGS="+filepath.Join(m.dir, "settings.json"), "MESH_NODE=novox", "MESH_MODULE="+Publisher, + "MESH_ANTHROPIC_TOKEN_ENDPOINT="+m.vendor.server.URL+"/token", "MESH_ANTHROPIC_USAGE_ENDPOINT="+m.vendor.server.URL+"/usage") + in, _ := cmd.StdinPipe() + out, _ := cmd.StdoutPipe() + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + m.t.Fatal(err) + } + p := &started{mesh: m, in: in, cmd: cmd, pending: map[string]chan wire{}} + m.t.Cleanup(p.stop) + go p.serve(out) + return p +} + +// stop ends this start, as the runtime does when the module moves. +func (p *started) stop() { + _ = p.in.Close() + _ = p.cmd.Process.Kill() + _ = p.cmd.Wait() +} + +func (p *started) send(v map[string]any) { + raw, _ := json.Marshal(v) + p.pmu.Lock() + defer p.pmu.Unlock() + _, _ = p.in.Write(append(raw, '\n')) +} + +func (p *started) answer(id json.RawMessage, result any, err error) { + if err != nil { + code := -32000 + if errors.Is(err, jetstream.ErrKeyExists) { + code = -32010 // a lost compare-and-set, as the runtime says it + } + p.send(map[string]any{"id": id, "error": map[string]any{"code": code, "message": err.Error()}}) + return + } + p.send(map[string]any{"id": id, "result": result}) +} + +func (p *started) serve(out io.Reader) { + scanner := bufio.NewScanner(out) + scanner.Buffer(make([]byte, 1<<20), 1<<24) + for scanner.Scan() { + var w wire + if json.Unmarshal(scanner.Bytes(), &w) != nil { + continue + } + if w.Method == "" { + var key string + _ = json.Unmarshal(w.ID, &key) + p.pmu.Lock() + ch := p.pending[key] + delete(p.pending, key) + p.pmu.Unlock() + if ch != nil { + ch <- w + } + continue + } + go p.handle(w) + } +} + +func (p *started) kv(state string) (jetstream.KeyValue, error) { + return p.js.KeyValue(p.ctx, Publisher+"_"+state) +} + +func (p *started) handle(w wire) { + var a struct { + State string `json:"state"` + Key string `json:"key"` + Value json.RawMessage `json:"value"` + Revision uint64 `json:"revision"` + Subject string `json:"subject"` + Body json.RawMessage `json:"body"` + Pattern string `json:"pattern"` + } + _ = json.Unmarshal(w.Params, &a) + if strings.HasPrefix(w.Method, "mesh/log.") { + p.log(w) + return + } + switch w.Method { + case "mesh/state.get": + kv, err := p.kv(a.State) + if err != nil { + p.answer(w.ID, nil, err) + return + } + e, err := kv.Get(p.ctx, a.Key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + p.answer(w.ID, nil, nil) + return + } + if err != nil { + p.answer(w.ID, nil, err) + return + } + p.answer(w.ID, map[string]any{"key": e.Key(), "value": json.RawMessage(e.Value()), "revision": e.Revision(), "at": e.Created()}, nil) + case "mesh/state.put", "mesh/state.create", "mesh/state.update": + kv, err := p.kv(a.State) + var rev uint64 + if err == nil { + // A key's own time to live is the bus's; no test here lives as long as one (a lease's two minutes). + switch w.Method { + case "mesh/state.put": + rev, err = kv.Put(p.ctx, a.Key, a.Value) + case "mesh/state.create": + rev, err = kv.Create(p.ctx, a.Key, a.Value) + default: + rev, err = kv.Update(p.ctx, a.Key, a.Value, a.Revision) + if err != nil { + err = fmt.Errorf("%w: %v", jetstream.ErrKeyExists, err) + } + } + } + p.answer(w.ID, map[string]any{"revision": rev}, err) + case "mesh/state.keys": + kv, err := p.kv(a.State) + if err != nil { + p.answer(w.ID, nil, err) + return + } + keys, err := kv.Keys(p.ctx) + if errors.Is(err, jetstream.ErrNoKeysFound) { + keys, err = []string{}, nil + } + sort.Strings(keys) + p.answer(w.ID, keys, err) + case "mesh/state.delete": + kv, err := p.kv(a.State) + if err == nil { + err = kv.Delete(p.ctx, a.Key) + } + p.answer(w.ID, map[string]any{}, err) + case "mesh/state.history": + kv, err := p.kv(a.State) + if err != nil { + p.answer(w.ID, nil, err) + return + } + out := []map[string]any{} + h, err := kv.History(p.ctx, a.Key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + p.answer(w.ID, out, nil) + return + } + for _, e := range h { + op, v := "put", json.RawMessage(e.Value()) + if e.Operation() != jetstream.KeyValuePut { + op, v = "delete", nil + } + out = append(out, map[string]any{"key": e.Key(), "value": v, "revision": e.Revision(), "op": op, "at": e.Created()}) + } + p.answer(w.ID, out, err) + case "mesh/publish": + p.answer(w.ID, map[string]any{}, p.nc.Publish("mesh.mod."+Publisher+".event."+a.Key, a.Body)) + p.say(a.Key, a.Body) + if a.Key == HoldingsAsked { + p.answerHoldings() + } + case "mesh/seat.publish": + p.answer(w.ID, map[string]any{}, p.nc.Publish(a.Subject, a.Body)) + case "mesh/subscribe": + p.pmu.Lock() + p.subs = append(p.subs, a.Pattern) + p.pmu.Unlock() + p.answer(w.ID, map[string]any{}, nil) + case "mesh/ask": + result, err := p.asked(a.Key, a.Body) + p.say("ask "+a.Key, a.Body) + p.answer(w.ID, result, err) + default: + p.answer(w.ID, nil, errors.New("nats: no responders available for request")) + } +} + +func (p *started) say(key string, body json.RawMessage) { + p.mu.Lock() + p.said = append(p.said, said{key: key, body: append(json.RawMessage(nil), body...)}) + p.mu.Unlock() + p.notify() +} + +// answerHoldings is each node's agent module hearing holdings.asked and saying what it holds (§23). +func (p *started) answerHoldings() { + p.mu.Lock() + reports := append([]Holdings(nil), p.holdings...) + p.mu.Unlock() + for _, r := range reports { + body, _ := json.Marshal(r) + p.mu.Lock() + p.next++ + id := fmt.Sprintf("e-%d", p.next) + p.mu.Unlock() + p.send(map[string]any{"id": id, "method": "mesh/event", "params": map[string]any{"envelope": map[string]any{ + "key": HoldingsChanged, "node": r.Node, "body": json.RawMessage(body)}}}) + } +} + +// asked answers what the manager asks of another part: the vault, a node's agent module, the operator channel. +func (p *started) asked(key string, body json.RawMessage) (any, error) { + var args map[string]any + _ = json.Unmarshal(body, &args) + p.mu.Lock() + defer p.mu.Unlock() + switch { + case key == "seat:mesh-vault.licence-put": + if call, _ := args["call"].(string); call != "" { + priv, ok := p.calls[call] + if !ok { + return nil, errors.New("no such call") + } + sealed, _ := base64.StdEncoding.DecodeString(fmt.Sprint(args["sealed"])) + var pub [32]byte + copy(pub[:], p.callArgs[call]["pub"].([]byte)) + value, ok := box.OpenAnonymous(nil, sealed, &pub, &priv) + if !ok { + return nil, errors.New("the sealed value does not open") + } + a := p.callArgs[call] + p.secrets[SecretName(fmt.Sprint(a["licence"]), fmt.Sprint(a["part"]))] = string(value) + delete(p.calls, call) + return map[string]any{"put": true}, nil + } + pub, priv, _ := box.GenerateKey(rand.Reader) + call := fmt.Sprintf("call-%d", len(p.callArgs)+1) + p.calls[call] = *priv + args["pub"] = pub[:] + p.callArgs[call] = args + return map[string]any{"call": call, "seal_to": base64.StdEncoding.EncodeToString(pub[:])}, nil + case key == "seat:mesh-vault.get": + name, _ := args["name"].(string) + v, ok := p.secrets[name] + if !ok { + return nil, fmt.Errorf("the vault holds no %s", name) + } + sealed, err := Seal(v, fmt.Sprint(args["public_key"])) + return map[string]any{"sealed": sealed}, err + case strings.HasPrefix(key, "claude-code.claude_code_grant@"): + node := strings.TrimPrefix(key, "claude-code.claude_code_grant@") + g, ok := p.logins[node] + if !ok { + return GrantAnswer{}, nil + } + raw, _ := json.Marshal(g) + sealed, err := Seal(string(raw), fmt.Sprint(args["public_key"])) + return GrantAnswer{Sealed: &sealed, Fingerprint: Fingerprint(g.RefreshToken)}, err + case key == "seat:operator-channel.tell": + return map[string]any{"told": true}, nil + } + return nil, errors.New("nats: no responders available for request") +} + +// keyMatches is a log's key against a pattern: `*` one name, `>` or `**` the rest, empty every key. +func keyMatches(pattern, key string) bool { + if pattern == "" { + return true + } + return namesMatch(strings.Split(pattern, "."), strings.Split(key, ".")) +} + +func namesMatch(p, k []string) bool { + if len(p) == 0 { + return len(k) == 0 + } + if p[0] == ">" || p[0] == "**" { + return len(k) > 0 + } + if len(k) == 0 || (p[0] != "*" && p[0] != k[0]) { + return false + } + return namesMatch(p[1:], k[1:]) +} + +// log answers the runtime's log verbs from the log's real stream, LOG__ on mesh.log.... +func (p *started) log(w wire) { + var a struct { + Log string `json:"log"` + Key string `json:"key"` + Keys string `json:"keys"` + Value json.RawMessage `json:"value"` + From uint64 `json:"from"` + Since string `json:"since"` + Limit int `json:"limit"` + } + _ = json.Unmarshal(w.Params, &a) + subject := "mesh.log." + Publisher + "." + a.Log + s, err := p.js.Stream(p.ctx, "LOG_"+Publisher+"_"+a.Log) + if err != nil { + p.answer(w.ID, nil, err) + return + } + info, err := s.Info(p.ctx) + if err != nil { + p.answer(w.ID, nil, err) + return + } + entry := func(r *jetstream.RawStreamMsg) map[string]any { + return map[string]any{"sequence": r.Sequence, "key": strings.TrimPrefix(r.Subject, subject+"."), + "value": json.RawMessage(r.Data), "at": r.Time} + } + all := func(keys string, since time.Time) []map[string]any { + out := []map[string]any{} + for seq := max(a.From, info.State.FirstSeq, 1); seq <= info.State.LastSeq; seq++ { + r, err := s.GetMsg(p.ctx, seq) + if err != nil || !keyMatches(keys, strings.TrimPrefix(r.Subject, subject+".")) || r.Time.Before(since) { + continue + } + out = append(out, entry(r)) + } + return out + } + switch w.Method { + case "mesh/log.append": + ack, err := p.js.Publish(p.ctx, subject+"."+a.Key, a.Value) + if err != nil { + p.answer(w.ID, nil, err) + return + } + p.answer(w.ID, map[string]any{"sequence": ack.Sequence}, nil) + case "mesh/log.last": + r, err := s.GetLastMsgForSubject(p.ctx, subject+"."+a.Key) + if errors.Is(err, jetstream.ErrMsgNotFound) { + p.answer(w.ID, nil, nil) + return + } + if err != nil { + p.answer(w.ID, nil, err) + return + } + p.answer(w.ID, entry(r), nil) + case "mesh/log.read": + var since time.Time + if a.Since != "" { + since, _ = time.Parse(time.RFC3339Nano, a.Since) + } + entries := all(a.Keys, since) + limit := a.Limit + if limit <= 0 { + limit = 100 + } + next := uint64(0) + if len(entries) > limit { + next = entries[limit]["sequence"].(uint64) + entries = entries[:limit] + } + p.answer(w.ID, map[string]any{"entries": entries, "next": next, "last": info.State.LastSeq}, nil) + case "mesh/log.newest": + newest := map[string]int{} + var out []map[string]any + for _, e := range all(a.Keys, time.Time{}) { + k := e["key"].(string) + if i, ok := newest[k]; ok { + out[i] = e + continue + } + newest[k] = len(out) + out = append(out, e) + } + if out == nil { + out = []map[string]any{} + } + p.answer(w.ID, out, nil) + case "mesh/log.info": + p.answer(w.ID, map[string]any{"entries": info.State.Msgs, "bytes": info.State.Bytes, + "max_bytes": info.Config.MaxBytes, "first": info.State.FirstSeq, "last": info.State.LastSeq}, nil) + default: + p.answer(w.ID, nil, fmt.Errorf("no log verb %s", w.Method)) + } +} + +// call is a tools/call of the manager by a person, answered as the tool's JSON. +func (p *started) call(name string, args map[string]any) (json.RawMessage, error) { + p.mu.Lock() + p.next++ + id := fmt.Sprintf("c-%d", p.next) + p.mu.Unlock() + ch := make(chan wire, 1) + p.pmu.Lock() + p.pending[id] = ch + p.pmu.Unlock() + p.send(map[string]any{"id": id, "method": "tools/call", "params": map[string]any{"name": name, "arguments": args, + "_meta": map[string]any{"mesh/caller": "person.operator"}}}) + select { + case w := <-ch: + var r struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } + if w.Error != nil { + return nil, errors.New(w.Error.Message) + } + if err := json.Unmarshal(w.Result, &r); err != nil || len(r.Content) != 1 { + return nil, fmt.Errorf("%s answered %s", name, w.Result) + } + if r.IsError { + return nil, errors.New(r.Content[0].Text) + } + return json.RawMessage(r.Content[0].Text), nil + case <-time.After(60 * time.Second): + return nil, fmt.Errorf("%s did not answer in 60s", name) + } +} + +func (p *started) must(name string, args map[string]any) json.RawMessage { + p.t.Helper() + raw, err := p.call(name, args) + if err != nil { + p.t.Fatalf("%s: %v", name, err) + } + return raw +} + +// carried is the manager's carry-over record, as licences_carried answers it. +func (p *started) carried() CarriedRecord { + p.t.Helper() + var a struct { + Carried *CarriedRecord `json:"carried"` + Failure string `json:"failure"` + } + if err := json.Unmarshal(p.must("licences_carried", nil), &a); err != nil || a.Carried == nil { + p.t.Fatalf("licences_carried answers no record (%v): %s", err, a.Failure) + } + return *a.Carried +} + +// bindings are the consumers' bindings, as the seat's verb answers them. +func (p *started) bindings() map[string]Binding { + p.t.Helper() + var list []Binding + if err := json.Unmarshal(p.must(Seat+".bindings", nil), &list); err != nil { + p.t.Fatal(err) + } + out := map[string]Binding{} + for _, b := range list { + out[b.Consumer] = b + } + return out +} + +// written is every revision of the states and every entry of the logs the carry-over writes: what a second start +// must leave as it is (§13). +func (m *mesh) written() map[string]uint64 { + m.t.Helper() + out := map[string]uint64{} + for _, s := range []string{"licences", "bindings", "offered", "carried"} { + info, err := m.js.Stream(m.ctx, "KV_"+Publisher+"_"+s) + if err != nil { + m.t.Fatal(err) + } + i, _ := info.Info(m.ctx) + out[s] = i.State.LastSeq + } + for _, l := range strings.Fields(busLogs) { + info, err := m.js.Stream(m.ctx, "LOG_"+Publisher+"_"+l) + if err != nil { + m.t.Fatal(err) + } + i, _ := info.Info(m.ctx) + out[l] = i.State.LastSeq + } + return out +} + +// ---- the tests ------------------------------------------------------------------------------------------------- + +// design 39 bus-and-switch §11/AC1, §12/AC1, §13/AC1, §13/AC2: a start stopped at a table after bindings says +// carry-over-failed naming it and acts on nothing; started again it carries every row whole, the counts in +// carried.postgres equal to the rows read, the vault answering each licence's grant, and clears the condition; a +// further start writes nothing. +func TestTodaysRowsAreCarriedOverOnceAndWhole(t *testing.T) { + dbURL, conn := freshDatabase(t) + fillTodaysRows(t, conn) + ctx := context.Background() + // The table after bindings cannot be read: the start stops there. + if _, err := conn.Exec(ctx, `alter table offered rename column why to reason`); err != nil { + t.Fatal(err) + } + m := newWholeMesh(t, dbURL) + first := m.start() + m.until("carry-over-failed is said", func() bool { return len(m.conditionsOf(CarryOverFailed)) > 0 }) + m.mu.Lock() + raised := m.conditionsOf(CarryOverFailed)[0] + m.mu.Unlock() + if raised.State != "raised" || raised.Severity != "urgent" || !strings.Contains(raised.Explanation+raised.Summary, "offered") { + t.Errorf("carry-over-failed said %+v", raised) + } + if _, err := first.call(Seat+".bind", map[string]any{"consumer": "laptop", "licence": licenceB}); err == nil || + !strings.Contains(err.Error(), "the carry-over has not ended") { + t.Errorf("bind before the carry-over ended answered %v", err) + } + if n := m.vendor.called(); n != 0 { + t.Errorf("the vendor was called %d times before the carry-over ended", n) + } + first.stop() + + if _, err := conn.Exec(ctx, `alter table offered rename column reason to why`); err != nil { + t.Fatal(err) + } + second := m.start() + m.until("holdings.asked after the carry-over", func() bool { return m.count(HoldingsAsked) > 0 }) + rec := second.carried() + want := map[string][2]int{"licence": {2, 2}, "binding": {4, 4}, "lease": {1, 0}, "offered": {2, 1}, "usage": {2, 2}, "audit": {4, 4}} + for table, n := range want { + if got := rec.Tables[table]; got.Read != n[0] || got.Written != n[1] { + t.Errorf("%s: read %d written %d, want read %d written %d", table, got.Read, got.Written, n[0], n[1]) + } + } + m.mu.Lock() + for _, l := range []struct{ name, refresh string }{{licenceA, "rt-a"}, {licenceB, "rt-b"}} { + if g := m.secrets[SecretName(KeyFor(l.name), PartGrant)]; !strings.Contains(g, l.refresh) { + t.Errorf("the vault holds %q as the grant of %s", g, l.name) + } + } + m.mu.Unlock() + if b := second.bindings(); len(b) != 4 || b["anchor"].Licence != licenceA || b["anchor"].Generation != 1013 || b["anchor"].Why != "carried over" { + t.Errorf("bindings answers %+v", b) + } + m.until("carry-over-failed is cleared", func() bool { + c := m.conditionsOf(CarryOverFailed) + return len(c) > 1 && c[len(c)-1].State == "cleared" + }) + second.stop() + + before := m.written() + asked := m.count(HoldingsAsked) + third := m.start() + m.until("holdings.asked at the third start", func() bool { return m.count(HoldingsAsked) > asked }) + if again := third.carried(); again.At != rec.At { + t.Errorf("the third start carried over again at %s (first at %s)", again.At, rec.At) + } + if after := m.written(); fmt.Sprint(after) != fmt.Sprint(before) { + t.Errorf("a further start wrote %v, before it %v", after, before) + } +} + +// design 39 bus-and-switch §11/AC2: with the database unreachable the condition is raised, no act is taken and the +// vendor is never called. +func TestAnUnreachableDatabaseRaisesAndCallsNoVendor(t *testing.T) { + m := newWholeMesh(t, "postgres://postgres@127.0.0.1:1/claude_licences?connect_timeout=2") + p := m.start() + m.until("carry-over-failed is said", func() bool { return len(m.conditionsOf(CarryOverFailed)) > 0 }) + m.mu.Lock() + c := m.conditionsOf(CarryOverFailed)[0] + m.mu.Unlock() + if c.State != "raised" || c.Severity != "urgent" || c.Subject.ID != Publisher { + t.Errorf("carry-over-failed said %+v", c) + } + if _, err := p.call("licences_read_usage", nil); err == nil || !strings.Contains(err.Error(), "the carry-over has not ended") { + t.Errorf("licences_read_usage before the carry-over answered %v", err) + } + if n := m.vendor.called(); n != 0 { + t.Errorf("the vendor was called %d times", n) + } + m.mu.Lock() + defer m.mu.Unlock() + if n := m.count(HoldingsAsked); n != 0 { + t.Errorf("holdings.asked was said %d times before the carry-over", n) + } +} + +// design 39 bus-and-switch §26/AC2, §28, §29/AC2, §9/AC3, B41: a reading of 99 % moves the four consumers bound to a +// licence to the one with the most room in one act, saying one binding.changed each, one licence.switched and one tell; +// with every licence at its threshold no-room is raised on the operator channel's subject, and a reading with room +// clears it. +func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) { + dbURL, conn := freshDatabase(t) + fillTodaysRows(t, conn) + m := newWholeMesh(t, dbURL) + p := m.start() + m.until("holdings.asked after the carry-over", func() bool { return m.count(HoldingsAsked) > 0 }) + m.vendor.set("at-b", 10, 20) + m.vendor.set("at-a", 99, 50) + p.must("licences_read_usage", map[string]any{"licence": licenceB}) + p.must("licences_read_usage", map[string]any{"licence": licenceA}) + m.until("the four consumers moved", func() bool { return m.count("binding.changed") >= 4 }) + m.mu.Lock() + switched, told := m.of("licence.switched"), m.of("ask seat:operator-channel.tell") + changed := m.of("binding.changed") + m.mu.Unlock() + if len(switched) != 1 || switched[0]["from"] != licenceA || switched[0]["to"] != licenceB || switched[0]["by"] != "usage" || + len(switched[0]["consumers"].([]any)) != 4 { + t.Errorf("licence.switched said %v", switched) + } + if len(told) != 1 { + t.Errorf("the operator was told %d times: %v", len(told), told) + } + moved := map[string]bool{} + for _, c := range changed { + if c["licence"] == licenceB && c["from"] == licenceA && c["by"] == "usage" { + moved[fmt.Sprint(c["consumer"])] = true + } + } + if len(moved) != 4 || len(changed) != 4 { + t.Errorf("binding.changed said %v", changed) + } + for c, b := range p.bindings() { + if b.Licence != licenceB || b.By != "usage" { + t.Errorf("%s is bound %+v", c, b) + } + } + + // Every licence at its threshold: the consumers have nowhere to go. + noRoom := "licence." + KeyFor(licenceB) + ".no-room" + m.vendor.set("at-a", 99, 50) + m.vendor.set("at-b", 99, 60) + p.must("licences_read_usage", map[string]any{"licence": licenceA}) + p.must("licences_read_usage", map[string]any{"licence": licenceB}) + m.until("no-room is raised", func() bool { return len(m.conditionsOf(noRoom)) > 0 }) + m.mu.Lock() + r := m.conditionsOf(noRoom)[0] + m.mu.Unlock() + if r.State != "raised" || r.Severity != "urgent" || r.Subject.ID != Publisher { + t.Errorf("no-room said %+v", r) + } + // A reading with room: the next reading of the full licence moves its consumers, and no-room is cleared. + m.vendor.set("at-a", 5, 30) + p.must("licences_read_usage", map[string]any{"licence": licenceA}) + p.must("licences_read_usage", map[string]any{"licence": licenceB}) + m.until("no-room is cleared", func() bool { + c := m.conditionsOf(noRoom) + return len(c) > 1 && c[len(c)-1].State == "cleared" + }) + m.mu.Lock() + defer m.mu.Unlock() + if c := m.conditionsOf(noRoom); len(c) != 2 { + t.Errorf("no-room was said %d times, not once raised and once cleared: %+v", len(c), c) + } + if n := m.count("licence.switched"); n != 2 { + t.Errorf("licence.switched was said %d times, not once per act", n) + } +} + +// design 39 bus-and-switch §23/AC1, §46/AC1, §11/AC3: on a fresh install (no table) the carry-over carries nothing and +// the manager says holdings.asked; each node's agent module answers with one holdings-changed; a report carrying an +// unknown refresh token's fingerprint makes a candidate, which is adopted, listed by licences with its organisation, +// and chosen as a target at its first reading with room. +func TestANewLoginIsAdoptedListedAndATarget(t *testing.T) { + dbURL, _ := freshDatabase(t) + m := newWholeMesh(t, dbURL) + changed := time.Now().UTC().Format(time.RFC3339Nano) + report := func(node, account, email, org, refresh string) Holdings { + h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: email, OrganizationUUID: org}, + Kind: "subscription", ChangedAt: changed} + h.Refresh.Present, h.Refresh.Fingerprint = true, Fingerprint(refresh) + return h + } + m.holdings = []Holdings{report("laptop", "acct-old", licenceA, "", "rt-old"), report("workstation", "acct-team", "team@example.org", "org-1", "rt-team")} + m.logins["laptop"] = FullGrant{AccessToken: "at-old-0", RefreshToken: "rt-old"} + m.logins["workstation"] = FullGrant{AccessToken: "at-team-0", RefreshToken: "rt-team"} + m.vendor.logins["rt-old"] = [2]string{"at-old", "acct-old"} + m.vendor.logins["rt-team"] = [2]string{"at-team", "acct-team"} + p := m.start() + m.until("both logins adopted", func() bool { return m.count("licence.adopted") >= 2 }) + m.mu.Lock() + asked := m.count("ask claude-code.claude_code_grant@laptop") + m.count("ask claude-code.claude_code_grant@workstation") + m.mu.Unlock() + if asked != 2 { + t.Errorf("the nodes were asked for their logins %d times, not once each", asked) + } + if rec := p.carried(); rec.Tables["licence"].Read != 0 || rec.Tables["audit"].Read != 0 { + t.Errorf("a fresh install carried %+v", rec.Tables) + } + + // The full licence is read first: nothing has room yet, so its consumer stays. + m.vendor.set("at-old", 99, 40) + m.vendor.set("at-team", 10, 5) + p.must("licences_read_usage", map[string]any{"licence": licenceA}) + p.must("licences_read_usage", map[string]any{"licence": "team@example.org"}) + var listed []map[string]any + if err := json.Unmarshal(p.must(Seat+".licences", nil), &listed); err != nil { + t.Fatal(err) + } + var team map[string]any + for _, l := range listed { + if l["name"] == "team@example.org" { + team = l + } + } + if team == nil || team["organisation"] != "org-1" || team["target"] != true || team["usage"] == nil { + t.Fatalf("licences lists the new login as %v (all: %v)", team, listed) + } + p.must("licences_read_usage", map[string]any{"licence": licenceA}) + m.until("the full licence's consumer moved to the new one", func() bool { + for _, s := range m.of("licence.switched") { + if s["to"] == "team@example.org" && s["from"] == licenceA { + return true + } + } + return false + }) + if b := p.bindings()["laptop"]; b.Licence != "team@example.org" { + t.Errorf("laptop is bound %+v", b) + } +} diff --git a/modules/claude-licence-manager/go.mod b/modules/claude-licence-manager/go.mod index 77296baf3..dc60fbeab 100644 --- a/modules/claude-licence-manager/go.mod +++ b/modules/claude-licence-manager/go.mod @@ -7,18 +7,24 @@ require ( git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/jackc/pgx/v5 v5.11.0 + github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 ) require ( + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.20.1 // indirect - github.com/nats-io/nats.go v1.54.0 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect + github.com/nats-io/nats-server/v2 v2.11.17 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect + golang.org/x/time v0.16.0 // indirect ) diff --git a/modules/claude-licence-manager/go.sum b/modules/claude-licence-manager/go.sum index 7475ceb70..96a5b2dcb 100644 --- a/modules/claude-licence-manager/go.sum +++ b/modules/claude-licence-manager/go.sum @@ -1,38 +1,50 @@ +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= -github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= -github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= -github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -- 2.54.0 From a8131809b203bf1be070239fc8dd7026b32924fe Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 00:39:21 +0100 Subject: [PATCH 24/32] M-17: the manager's README says what it keeps on the bus and how it switches (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 39 bus-and-switch §52, §1 to §13, §21, §23 to §46 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- modules/claude-licence-manager/README.md | 99 +++++++++++++++++------- 1 file changed, 72 insertions(+), 27 deletions(-) diff --git a/modules/claude-licence-manager/README.md b/modules/claude-licence-manager/README.md index 58a984b35..de0b4c789 100644 --- a/modules/claude-licence-manager/README.md +++ b/modules/claude-licence-manager/README.md @@ -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.`, `account.`: 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..no-room` (urgent; no licence has room), `licence..near-limit` (at `warn_at_pct`, once per +crossing), `licence..usage-unread`, `carry-over-failed` and `settings..invalid`. ## The seat's verbs -`licences`, `bindings`, `bind`, `switch`, `release`, `refresh`, `usage`, `adopt`, `current` — through -the console as `anthropic-licence-manager.`. 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.`. 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 -- 2.54.0 From 3b92e028621a687030c35fb1f6b9d8273ebf321b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 00:39:49 +0100 Subject: [PATCH 25/32] C-4: the agent module's README says it follows its binding by event and takes its token from the vault (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 39 bus-and-switch §52, §19, §22, §23, B33 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- modules/claude-code/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index 16dbccac0..6bac2b7aa 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -78,9 +78,9 @@ must see, a node that joins later included — kept, so it carries no secret eit | what | how | |---|---| -| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206) | +| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206), and said whole as the event `holdings-changed` at start, on every change and again when the licence manager says `holdings.asked` (design 39 bus-and-switch §23) | | a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it | -| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back | +| what this node should hold | the licence manager's `binding.changed` naming this node, and at start (or a reconnect) its first view, `anthropic-licence-manager.bindings` with `consumer` this node; an event for another node is ignored. On a newer generation, at start, and on the vault's `secret-changed` for the licence it holds, this module asks `anthropic-licence-manager.current` which licence, and the vault's `get` for that licence's access token (`licence..access`), sealed to its own key, and writes it access-token-only — so the agent here never refreshes, and never receives a refresh token. A home holding no token is fetched again at the same generation (design 39 bus-and-switch §19, §22) | | an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.` for every node, `.` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal | | a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus | | a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open | @@ -150,8 +150,8 @@ record is how a habit that found a way round is seen. ## Tools -`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull`, `claude_code_grant` (for the licence -manager), `claude_code_mcp_list`, `claude_code_mcp_unregister` (this node by default; `nodes: "all"` or a list for +`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull` (asks the vault for this node's +licence token now), `claude_code_grant` (for the licence manager), `claude_code_mcp_list`, `claude_code_mcp_unregister` (this node by default; `nodes: "all"` or a list for more). **What a session runs or obeys is the operator's, at the controller's terminal** (hq issue 340). A hook, a -- 2.54.0 From 9cf5c1ddbda2024c19370a2cfedcfafb0b144b07 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 00:42:53 +0100 Subject: [PATCH 26/32] V-4: the vault's first-view tools cover the licence bindings, and its README says the licence secrets (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vault_first_views lists the licence bindings' first view beside the assignments'; vault_first_view_again asks the one its new argument `of` names, and both when it is absent. Builds: design 39 bus-and-switch §52, §15 to §17, B21, B42 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- modules/mesh-vault/README.md | 15 +++++ .../cmd/mesh-vault/assignmentsview.go | 64 +++++++++++++++---- .../cmd/mesh-vault/licencebindings_test.go | 52 +++++++++++++++ 3 files changed, 118 insertions(+), 13 deletions(-) diff --git a/modules/mesh-vault/README.md b/modules/mesh-vault/README.md index dad246e7a..27af4c7a5 100644 --- a/modules/mesh-vault/README.md +++ b/modules/mesh-vault/README.md @@ -136,6 +136,21 @@ the declaration sealed by the vault to their machine, which the controller carri machine's sealing key as its own node-engine stated it in its own name, in `move-in`'s first step (`sealing_key`). A machine whose engine stated none, any other key, any other module and any other name are refused and recorded. +**A licence's two secrets, for the licence manager and the machines its bindings name** (design 39 bus-and-switch +§15 to §17, B2, B3, B8, B21, B25, B31, B32, B42; `licences.go`, `licencebindings.go`). The licence manager hands +each licence's grant and access token to the vault with `mesh-vault.licence-put`, in two steps: the licence's key, the +part (`grant` or `access`) and the generation, answered with a call and a key made for it; then the call and the value +sealed to that key. The caller's generation is the record's: the newest is kept, an older one refused, and +`secret-changed` says the name `licence..` and its generation, never the value. `get` hands a grant, sealed to +the key it is asked with, with no machine guard (B3); it hands an access token only to a machine on which a consumer's binding +names that licence, by the licence's key as the manager says it. Which licence each consumer is bound to the vault +keeps in its own state `licence-bindings` (one key per consumer: licence, key, generation; deleted on a release), +learned from the manager's `binding.changed`, which it consumes, and at its start from its first view, +`anthropic-licence-manager.bindings`, asked until it answers. A first view never replaces a binding of a newer +generation, and an access token asked before the first view answered is refused, saying why. The vault never reads +the manager's state. `vault_first_views` shows this first view beside the assignments', and `vault_first_view_again` +asks one again (`of`: `mesh-delivery-assigner.assignments` or `claude-licence-manager.bindings`; absent, both). + ## Taking values in: `secret-ask`, `issued`, `rotate` Design 57's V2 (issue 675; ADR 0349 clauses 8 to 10, ADR 0277 §22–§27 and ADR 0305 with the vault in the diff --git a/modules/mesh-vault/cmd/mesh-vault/assignmentsview.go b/modules/mesh-vault/cmd/mesh-vault/assignmentsview.go index 644029f0f..23cf68f31 100644 --- a/modules/mesh-vault/cmd/mesh-vault/assignmentsview.go +++ b/modules/mesh-vault/cmd/mesh-vault/assignmentsview.go @@ -168,25 +168,48 @@ func leaverKey(key string) (string, bool) { return node, ok && node != "" } -// viewTools are the read tool of the first view and its re-read, as every reader has them (design 25 § 12.2). +// firstViews are the vault's first views begun, by the owner's state each follows (`.`): the assigner's +// assignments and the licence manager's bindings (design 39 bus-and-switch B21, B42). +func firstViews() ([]string, map[string]*views.Follower) { + all := map[string]*views.Follower{} + if assignmentsView != nil { + all[assignerModule+".assignments"] = assignmentsView + } + if licenceBindingsView != nil { + all["claude-licence-manager.bindings"] = licenceBindingsView + } + names := make([]string, 0, len(all)) + for n := range all { + names = append(names, n) + } + sort.Strings(names) + return names, all +} + +// viewTools are the read tool of the first views and their re-read, as every reader has them (design 25 § 12.2; design +// 39 bus-and-switch B21, B42). func viewTools(env *Env) []toolDef { return []toolDef{ {Name: FirstViewsTool, - Description: "The vault's first view of the assigner's assignments: the tool it asks, the revision its picture holds, when it " + - "asked and was answered, how many asks (and by hand), and whether it is waiting.", + Description: "The vault's first views, of the assigner's assignments and of the licence manager's bindings: for each, the tool " + + "it asks, the revision its picture holds, when it asked and was answered, how many asks (and by hand), and whether it is waiting.", run: func(string, map[string]any) (any, error) { - if assignmentsView == nil { - return nil, fmt.Errorf("the vault reads the assigner's assignments only when it runs as the module") + names, all := firstViews() + if len(names) == 0 { + return nil, fmt.Errorf("the vault keeps its first views only when it runs as the module") } - return map[string]any{"first-views": []views.Status{assignmentsView.Status()}}, nil + out := []views.Status{} + for _, n := range names { + out = append(out, all[n].Status()) + } + return map[string]any{"first-views": out}, nil }}, {Name: FirstViewAgainTool, - Description: "Ask the first view of the assigner's assignments once more by hand, with why, recorded in the vault's log.", - Input: map[string]any{"why": str("why the first view is asked again")}, + Description: "Ask a first view once more by hand, with why, recorded in the vault's log: of names it by its owner's state " + + "(mesh-delivery-assigner.assignments or claude-licence-manager.bindings); absent, both are asked again.", + Input: map[string]any{"why": str("why the first view is asked again"), + "of": str("the first view, by its owner's state: mesh-delivery-assigner.assignments or claude-licence-manager.bindings; absent for both")}, run: func(caller string, a map[string]any) (any, error) { - if assignmentsView == nil { - return nil, fmt.Errorf("the vault reads the assigner's assignments only when it runs as the module") - } why := strings.TrimSpace(arg(a, "why")) if why == "" { return nil, fmt.Errorf("say why: a first view asked by hand keeps its why") @@ -194,8 +217,23 @@ func viewTools(env *Env) []toolDef { if err := fromAGrantedCaller(caller, FirstViewAgainTool); err != nil { return nil, err } - say("the first view of the assigner's assignments is asked again by %s: %s", caller, why) - return map[string]any{"asked-again": true}, assignmentsView.Again(context.Background(), why) + names, all := firstViews() + if of := strings.TrimSpace(arg(a, "of")); of != "" { + if all[of] == nil { + return nil, fmt.Errorf("the vault keeps no first view of %q here; it keeps %v", of, names) + } + names = []string{of} + } + if len(names) == 0 { + return nil, fmt.Errorf("the vault keeps its first views only when it runs as the module") + } + for _, n := range names { + say("the first view of %s is asked again by %s: %s", n, caller, why) + if err := all[n].Again(context.Background(), why); err != nil { + return nil, err + } + } + return map[string]any{"asked-again": true}, nil }}, } } diff --git a/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go b/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go index 3f7d23a9f..a9858b11b 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go @@ -1,12 +1,15 @@ package main import ( + "context" "encoding/json" "errors" "os" "slices" "sort" "testing" + + "git.novox.be/novox/mesh-sdk/go/views" ) // memLicenceBindings is the state licence-bindings in memory. @@ -113,3 +116,52 @@ func TestTheFirstViewOfTheLicenceBindingsIsKept(t *testing.T) { t.Fatalf("the manifest invokes %v, consumes %v, keeps %s", m.Invokes, m.Consumes, m.State) } } + +// design 39 bus-and-switch B21, B42: vault_first_views lists the licence bindings' first view beside the assignments'; +// vault_first_view_again asks the one its of names, and both when of is absent; an of it keeps no view of is refused. +func TestTheFirstViewToolsCoverTheLicenceBindings(t *testing.T) { + follower := func(owner, state string) *views.Follower { + return &views.Follower{Owner: owner, Tool: owner + ".read", State: state, + Ask: func(context.Context, string) (views.Answer, error) { + return views.Answer{Owner: owner, State: state, Values: map[string]json.RawMessage{}}, nil + }} + } + keptA, keptL := assignmentsView, licenceBindingsView + t.Cleanup(func() { assignmentsView, licenceBindingsView = keptA, keptL }) + assignmentsView, licenceBindingsView = follower(assignerModule, "assignments"), follower("claude-licence-manager", "bindings") + run := map[string]toolDef{} + for _, d := range viewTools(nil) { + run[d.Name] = d + } + listed, err := run[FirstViewsTool].run("person.operator", nil) + if err != nil { + t.Fatal(err) + } + var states []string + for _, s := range listed.(map[string]any)["first-views"].([]views.Status) { + states = append(states, s.Owner+"."+s.State) + } + if !slices.Equal(states, []string{"claude-licence-manager.bindings", "mesh-delivery-assigner.assignments"}) { + t.Errorf("vault_first_views lists %v", states) + } + byHand := func() (int, int) { return assignmentsView.Status().ByHand, licenceBindingsView.Status().ByHand } + again := run[FirstViewAgainTool].run + for _, c := range []struct { + of string + assignments, bindings int + refused bool + }{ + {of: "claude-licence-manager.bindings", bindings: 1}, + {of: "", assignments: 1, bindings: 2}, + {of: "mesh-delivery-assigner.assignments", assignments: 2, bindings: 2}, + {of: "claude-licence-manager.licences", assignments: 2, bindings: 2, refused: true}, + } { + _, err := again("person.operator", map[string]any{"why": "a test", "of": c.of}) + if (err != nil) != c.refused { + t.Errorf("of %q: %v", c.of, err) + } + if a, b := byHand(); a != c.assignments || b != c.bindings { + t.Errorf("of %q: asked by hand %d and %d times, want %d and %d", c.of, a, b, c.assignments, c.bindings) + } + } +} -- 2.54.0 From 36d9d739841390344d53df7c7dafa60ff8fc3066 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:00:42 +0100 Subject: [PATCH 27/32] V-3 fix: no access token before the first view is answered, and a first view keeps a newer event's key (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1, finding 1, and the lead's answer B43. Until the vault's first view of the licence bindings is answered since its start, licence-put's access token is refused, saying why. A first view removes a consumer's key it does not name only when that key was written before the view was asked. The newer generation's binding staying over a first view (B32) was built in V-2's keep and is named here. Builds: design 39 bus-and-switch §17, B31, B32, B43 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/mesh-vault/licencebindings.go | 41 ++++++++++++++++--- .../cmd/mesh-vault/licencebindings_test.go | 23 ++++++++++- .../cmd/mesh-vault/licences_test.go | 16 ++++++++ 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/modules/mesh-vault/cmd/mesh-vault/licencebindings.go b/modules/mesh-vault/cmd/mesh-vault/licencebindings.go index bb85f20a5..f852d9e63 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licencebindings.go +++ b/modules/mesh-vault/cmd/mesh-vault/licencebindings.go @@ -45,6 +45,12 @@ type LicenceBindings struct { Delete func(consumer string) error Keys func() ([]string, error) Log func(string, ...any) + // WrittenAt is when the bus wrote a consumer's key, so a first view keeps a key an event wrote after it was asked + // (B43); nil writes no time, and every key a first view does not name goes. + WrittenAt func(consumer string) (time.Time, error) + // Answered is whether the first view of the bindings has been answered since the vault started; until it has, an + // access token is refused, saying why (B31). Nil is a vault that keeps no first view (its tests). + Answered func() bool } // busLicenceBindings is the state on the bus. @@ -63,6 +69,14 @@ func busLicenceBindings() LicenceBindings { Delete: func(consumer string) error { return st.Delete(consumer) }, Keys: st.Keys, Log: say, + WrittenAt: func(consumer string) (time.Time, error) { + e, err := st.Get(consumer) + if err != nil || e == nil { + return time.Time{}, err + } + return e.At, nil + }, + Answered: func() bool { return licenceBindingsView != nil && !licenceBindingsView.Status().Answered.IsZero() }, } } @@ -111,9 +125,10 @@ func (lb LicenceBindings) Heard(body []byte) error { return lb.keep(e.Consumer, LicenceBinding{Licence: e.Licence, Key: e.Key, Generation: e.Generation}) } -// FirstView takes the manager's answer to `bindings`, by consumer: every binding it names is kept, and every key the -// state holds for a consumer it does not name is deleted, since that consumer was released. -func (lb LicenceBindings) FirstView(values map[string]json.RawMessage) error { +// FirstView takes the manager's answer to `bindings`, asked at asked, by consumer: every binding it names is kept, and +// every key the state holds for a consumer it does not name is deleted, since that consumer was released, unless an +// event wrote that key after the view was asked (B43): the view does not know that move yet. +func (lb LicenceBindings) FirstView(values map[string]json.RawMessage, asked time.Time) error { var errs []error for consumer, raw := range values { var b LicenceBinding @@ -128,9 +143,20 @@ func (lb LicenceBindings) FirstView(values map[string]json.RawMessage) error { return errors.Join(append(errs, err)...) } for _, consumer := range held { - if _, named := values[consumer]; !named { - errs = append(errs, lb.Delete(consumer)) + if _, named := values[consumer]; named { + continue } + if lb.WrittenAt != nil && !asked.IsZero() { + at, err := lb.WrittenAt(consumer) + if err != nil { + errs = append(errs, err) + continue + } + if at.After(asked) { + continue // written by an event after the view was asked (B43) + } + } + errs = append(errs, lb.Delete(consumer)) } return errors.Join(errs...) } @@ -192,7 +218,7 @@ func newLicenceBindingsView(ask func(subject string, body any) (json.RawMessage, return } values := f.Values() - if err := lb.FirstView(values); err != nil { + if err := lb.FirstView(values, s.Asked); err != nil { say("the licence bindings of the first view are not all kept: %v", err) return } @@ -256,6 +282,9 @@ func (v *Vault) licenceRefusal(caller, name string) (bool, string) { if v.LicenceBindings == nil { return true, "the vault keeps no licence bindings, so no binding names the licence " + key } + if v.LicenceBindings.Answered != nil && !v.LicenceBindings.Answered() { + return true, "the vault's first view of the licence bindings has not been answered since it started, so it does not yet know which machine a binding names; it asks the licence manager until it answers (B31)" + } consumers, err := v.LicenceBindings.Keys() if err != nil { return true, "the licence bindings cannot be read: " + err.Error() diff --git a/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go b/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go index a9858b11b..1297538ce 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go @@ -8,6 +8,7 @@ import ( "slices" "sort" "testing" + "time" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -85,7 +86,7 @@ func TestTheFirstViewOfTheLicenceBindingsIsKept(t *testing.T) { if err != nil { t.Fatal(err) } - if err := lb.FirstView(a.Values); err != nil { + if err := lb.FirstView(a.Values, time.Time{}); err != nil { t.Fatal(err) } want := map[string]LicenceBinding{"laptop": {Licence: "acct-1@example.org", Key: "acct-1_at_example.org", Generation: 1013}, @@ -98,10 +99,28 @@ func TestTheFirstViewOfTheLicenceBindingsIsKept(t *testing.T) { } lb.Put = func(string, LicenceBinding) error { return errors.New("the bucket is full") } held["laptop"] = LicenceBinding{Licence: "api", Key: "api", Generation: 1} - if err := lb.FirstView(a.Values); err == nil { + if err := lb.FirstView(a.Values, time.Time{}); err == nil { t.Fatal("a first view not kept was not said") } + // B43: a key an event wrote after the view was asked is kept though the view does not name it; one written before + // the ask and not named goes. + lb, held = memLicenceBindings() + asked := time.Date(2026, 11, 12, 1, 0, 0, 0, time.UTC) + written := map[string]time.Time{"late": asked.Add(time.Second), "gone": asked.Add(-time.Second)} + lb.WrittenAt = func(c string) (time.Time, error) { return written[c], nil } + held["late"] = LicenceBinding{Licence: "api", Key: "api", Generation: 2000} + held["gone"] = LicenceBinding{Licence: "api", Key: "api", Generation: 9} + if err := lb.FirstView(a.Values, asked); err != nil { + t.Fatal(err) + } + if _, ok := held["late"]; !ok { + t.Error("a key an event wrote after the first view was asked was removed by it") + } + if _, ok := held["gone"]; ok { + t.Error("a key written before the first view was asked, which it does not name, was kept") + } + manifest, _ := os.ReadFile("../../module.json") var m struct { Invokes, Consumes []string diff --git a/modules/mesh-vault/cmd/mesh-vault/licences_test.go b/modules/mesh-vault/cmd/mesh-vault/licences_test.go index 373ad630a..02f8c3357 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licences_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/licences_test.go @@ -73,6 +73,22 @@ func TestATokenIsGivenOnlyToAMachineABindingNames(t *testing.T) { } } access := "licence.jane_at_example.org.access" + // B31: until the first view of the bindings is answered, an access token is refused, saying why. + answered := false + lb.Answered = func() bool { return answered } + if err := lb.Heard([]byte(`{"consumer":"g14","licence":"jane","key":"jane_at_example.org","generation":1}`)); err != nil { + t.Fatal(err) + } + if _, _, err := asks(v, "g14.node-tools", access); err == nil || !strings.Contains(err.Error(), "first view of the licence bindings has not been answered") { + t.Fatalf("a token was handed before the first view was answered: %v", err) + } + answered = true + if _, _, err := asks(v, "g14.node-tools", access); err != nil { + t.Fatalf("once the first view is answered the bound machine is refused: %v", err) + } + if err := lb.Heard([]byte(`{"consumer":"g14","licence":"","key":"","generation":2}`)); err != nil { + t.Fatal(err) + } if _, _, err := asks(v, "g14.node-tools", access); err == nil || !strings.Contains(err.Error(), "no consumer on g14") { t.Fatalf("a machine no binding names was handed the token: %v", err) } -- 2.54.0 From 6a57bb7218d64ffeeeb672e6d953f7f024e35ddc Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:01:40 +0100 Subject: [PATCH 28/32] M-6 fix: each carried binding is said as binding.changed (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1, finding 2: a reader's first view taken while the carry-over runs is brought up to date by the event said after each key is written. Builds: design 39 bus-and-switch §12, §21 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/carry.go | 13 +++++++++-- .../cmd/claude-licence-manager/carry_test.go | 11 ++++++++++ .../whole_integration_test.go | 22 +++++++++++++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go b/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go index 9f0358033..445ca7e51 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go @@ -312,14 +312,23 @@ func (co *carryOver) bindings(ctx context.Context) (CarriedTable, error) { v := BindingState{Licence: r.Licence, Kind: kinds[r.Licence], Generation: r.Generation, Since: co.now.UTC().Format(time.RFC3339), Why: "carried over", By: Itself} var held BindingState - if ok, err := co.s.get(co.s.BindingsState, r.Consumer, &held); err != nil { + ok, err := co.s.get(co.s.BindingsState, r.Consumer, &held) + if err != nil { return t, err - } else if ok && held.Licence == v.Licence && held.Generation == v.Generation && held.Why == v.Why { + } + if ok && held.Licence == v.Licence && held.Generation == v.Generation && held.Why == v.Why { v.Since = held.Since } if _, err := co.s.BindingsState.Put(r.Consumer, v); err != nil { return t, err } + // Said after its key is written, as every binding's change is (§21), so a reader's first view taken during the + // carry-over is brought up to date by the event. + from := "" + if ok && held.Licence != v.Licence { + from = held.Licence + } + co.m.changed(Binding{Consumer: r.Consumer, Licence: v.Licence, Kind: v.Kind, Generation: v.Generation, Why: v.Why, By: v.By}, from) t.Written++ } return t, nil diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go index a4be4be5e..29e64f131 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "os" "regexp" "strings" @@ -145,6 +146,16 @@ func TestEveryRowIsCarriedOver(t *testing.T) { if len(mm.audit.under("carried")) != 1 || !c.Ended() { t.Fatal("the carry-over did not say it carried, or did not end") } + // §21: each carried binding is said as binding.changed after its key is written. + said := map[string]bool{} + for _, e := range mm.changedSince(0) { + if e["why"] == "carried over" && e["key"] == KeyFor(fmt.Sprint(e["licence"])) { + said[fmt.Sprint(e["consumer"])] = true + } + } + if !said["laptop"] || !said["server"] || len(mm.changedSince(0)) != 2 { + t.Fatalf("the carry-over said %v", mm.changedSince(0)) + } } // design 39 bus-and-switch §13/AC1: a second start, with carried.postgres present, never opens the database and diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go index 103034053..70a3ad73f 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go @@ -945,9 +945,24 @@ func TestTodaysRowsAreCarriedOverOnceAndWhole(t *testing.T) { if _, err := conn.Exec(ctx, `alter table offered rename column reason to why`); err != nil { t.Fatal(err) } + m.mu.Lock() + beforeSecond := len(m.of("binding.changed")) + m.mu.Unlock() second := m.start() m.until("holdings.asked after the carry-over", func() bool { return m.count(HoldingsAsked) > 0 }) rec := second.carried() + // §21: every carried binding is said by the start that carries it whole (the stopped one said those it wrote), so a reader's first view taken during the carry-over is brought up to date. + m.mu.Lock() + carriedSaid := 0 + for _, c := range m.of("binding.changed")[beforeSecond:] { + if c["why"] == "carried over" && c["licence"] == licenceA { + carriedSaid++ + } + } + m.mu.Unlock() + if carriedSaid != 4 { + t.Errorf("the carry-over said %d binding.changed, not one per binding", carriedSaid) + } want := map[string][2]int{"licence": {2, 2}, "binding": {4, 4}, "lease": {1, 0}, "offered": {2, 1}, "usage": {2, 2}, "audit": {4, 4}} for table, n := range want { if got := rec.Tables[table]; got.Read != n[0] || got.Written != n[1] { @@ -1017,14 +1032,17 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) { m := newWholeMesh(t, dbURL) p := m.start() m.until("holdings.asked after the carry-over", func() bool { return m.count(HoldingsAsked) > 0 }) + m.mu.Lock() + carried := m.count("binding.changed") + m.mu.Unlock() m.vendor.set("at-b", 10, 20) m.vendor.set("at-a", 99, 50) p.must("licences_read_usage", map[string]any{"licence": licenceB}) p.must("licences_read_usage", map[string]any{"licence": licenceA}) - m.until("the four consumers moved", func() bool { return m.count("binding.changed") >= 4 }) + m.until("the four consumers moved", func() bool { return m.count("binding.changed") >= carried+4 }) m.mu.Lock() switched, told := m.of("licence.switched"), m.of("ask seat:operator-channel.tell") - changed := m.of("binding.changed") + changed := m.of("binding.changed")[carried:] m.mu.Unlock() if len(switched) != 1 || switched[0]["from"] != licenceA || switched[0]["to"] != licenceB || switched[0]["by"] != "usage" || len(switched[0]["consumers"].([]any)) != 4 { -- 2.54.0 From cc8a354f754b72c5cc8a0fe97bec7f6d2a565e32 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:02:14 +0100 Subject: [PATCH 29/32] M-3 fix: an adopted licence says where it came from, and a newer login keeps its reading (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1, finding 3: adoptedFrom is the node a login came from, or where an API key came from; a newer login of the same account keeps the latest reading and generation the key holds. Builds: design 39 bus-and-switch §2, §34, §46 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/manager.go | 9 +++++---- .../cmd/claude-licence-manager/manager_test.go | 9 +++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 5c0ca7f31..12e7d2f2a 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -384,9 +384,10 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, } now := m.Now().UnixMilli() l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID, - Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID} + Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID, AdoptedFrom: c.Node} if held != nil { - l.Name, l.NotifiedAt, l.Generation = held.Name, held.NotifiedAt, held.Generation + // A newer login keeps what the key holds beside its grant: its latest reading (§2, §24). + l.Name, l.NotifiedAt, l.Generation, l.Usage = held.Name, held.NotifiedAt, held.Generation, held.Usage if l.Email == "" { l.Email = held.Email } @@ -898,9 +899,9 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str _ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why}) return nil, fmt.Errorf("%s is refused: %s", name, why) } - l := Licence{Name: name, Kind: "api-key", AdoptedAt: m.Now().UnixMilli()} + l := Licence{Name: name, Kind: "api-key", AdoptedAt: m.Now().UnixMilli(), AdoptedFrom: from} if held != nil { - l.Generation = held.Generation + l.Generation, l.Usage = held.Generation, held.Usage } if err := m.handOver(ctx, &l, key, key); err != nil { return nil, err diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index c0d645616..09c91fbe7 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -231,6 +231,12 @@ func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) { ctx := context.Background() _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0.Add(-time.Minute))}) before, _ := mm.store.Licence(ctx, licence1) + // §2: a licence says where it was adopted from, and a newer login keeps the latest reading the key holds. + pct := 42.0 + before.Usage = &UsageReading{SessionPct: &pct, ReadAt: t0.Format(time.RFC3339Nano)} + if before.AdoptedFrom != "laptop" || mm.store.SaveLicence(ctx, *before) != nil { + t.Fatalf("the adopted licence is %+v", before) + } _, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-old", true, t0.Add(-24*time.Hour))}) if strings.Join(mm.vendor.exchanged, ",") != "rt-a" { t.Fatalf("an older login was exchanged: %v", mm.vendor.exchanged) @@ -241,6 +247,9 @@ func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) { if after.RefreshFingerprint == before.RefreshFingerprint || mm.vendor.exchanged[len(mm.vendor.exchanged)-1] != "rt-new" { t.Fatalf("a newer login did not win: %v", mm.vendor.exchanged) } + if after.AdoptedFrom != "desktop" || after.Usage == nil || after.Usage.SessionPct == nil || *after.Usage.SessionPct != 42 { + t.Fatalf("after a newer login the licence is %+v", after) + } if ls, _ := mm.store.Licences(ctx); len(ls) != 1 { t.Fatalf("one account became %d licences", len(ls)) } -- 2.54.0 From b8e8be619da785903a549415822cf1677db1a38a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:02:54 +0100 Subject: [PATCH 30/32] M-15 fix: resync hands the vault each access token built from its grant (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1, finding 4: the vault hands a licence's access token only to a machine a binding names, so licences_resync builds it from the grant (the key itself for an API key) instead of reading it back. Builds: design 39 bus-and-switch §15, §17, §42, B23, B24 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/manager_test.go | 6 ++++++ .../cmd/claude-licence-manager/tools.go | 10 +++++++++- .../cmd/claude-licence-manager/tools_test.go | 1 + 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index 09c91fbe7..c94dbc3f1 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -61,6 +61,9 @@ type memGrants struct { mu sync.Mutex held map[string]heldGrant puts []string + // refuseAccess is the vault refusing a licence's access token to the manager's machine, as it does for a licence no + // binding there names (§17). + refuseAccess bool } type heldGrant struct { @@ -86,6 +89,9 @@ func (g *memGrants) Get(_ context.Context, key, part string) (string, error) { g.mu.Lock() defer g.mu.Unlock() h, ok := g.held[SecretName(key, part)] + if g.refuseAccess && part == PartAccess { + return "", fmt.Errorf("no consumer on this machine is bound to the licence %s", key) + } if !ok { return "", fmt.Errorf("the vault holds no secret named %s", SecretName(key, part)) } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go b/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go index 706400efc..f7bbd4dea 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go @@ -338,7 +338,15 @@ func (m *Manager) Resync(ctx context.Context) (map[string]any, error) { continue } key := KeyFor(l.Name) - access, err := m.Grants.Get(ctx, key, PartAccess) + // The access token is built from the grant, which the vault hands the manager; it hands a licence's access + // token only to a machine a binding names (§17), and the manager's machine may have none. + access, err := m.Grants.Get(ctx, key, PartGrant) + if err == nil && l.Kind == "subscription" { + var g FullGrant + if err = json.Unmarshal([]byte(access), &g); err == nil { + access = accessOf(g) + } + } if err == nil { err = m.Grants.Put(ctx, key, PartAccess, l.Generation, access) } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go index 01c41af28..b54e8e969 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go @@ -259,6 +259,7 @@ func TestResyncSaysEachOnce(t *testing.T) { mm.m.Settings.WarnAt = 100 _ = mm.m.SayConditions(ctx, false) mm.events, *said = nil, nil + mm.grants.refuseAccess = true // §42: the access token is built from the grant, never read back from the vault got := answer(t, served(t, mm)["licences_resync"], nil).(map[string]any) count := map[string]int{} for _, e := range mm.events { -- 2.54.0 From c8e321c8e1ba84b83bad6a4f3ecdee831bd9646b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:04:07 +0100 Subject: [PATCH 31/32] M-11 fix: a reading takes its licence's lease, and a grant the vault does not answer is a failed reading (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1, finding 5: the reading reads the grant under the licence's lease (another run holding it reads nothing that round), and a grant the vault refuses writes readError, keeps the percentages and goes on to the next licence, so the conditions are still said after the round. Review round 1, finding 6: these answers were built by earlier commits that did not name them: B26, B27, B28 and B29 by M-6 (9cc62818), B30 by M-9 (39912119), B35 by M-11 (6cf98386); B23 and B24 by V-1 (e85cf002) and M-7 (5ca3876b), named again in b8e8be61; B31 and B32 in 36d9d739. Builds: design 39 bus-and-switch §4, §16, §24, §31, B23, B24, B26, B27, B28, B29, B30, B34, B35 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/manager.go | 28 ++++++++++++++++--- .../cmd/claude-licence-manager/room_test.go | 23 +++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 12e7d2f2a..81828085f 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -686,11 +686,26 @@ func (m *Manager) ReadUsageOf(ctx context.Context, licence string, dry bool) ([] if l.Kind != "subscription" || l.Generation == 0 || (licence != "" && l.Name != licence) { continue } - g, err := m.grantOf(ctx, l) + // The reading takes the licence's grant under its lease, so it never reads a grant a refresh is replacing (§4, + // §16); a grant the vault does not answer is a failed reading like a vendor's refusal (§31). + key := "licence." + KeyFor(l.Name) + leased, err := m.Store.Lease(ctx, key, m.Holder, leaseFor) if err != nil { return out, err } - raw, err := m.Vendor.Usage(ctx, g.AccessToken) + if !leased { + out = append(out, Read{Licence: l.Name, Error: "another run holds its lease", Switches: []Switch{}}) + continue + } + unlease := func() { _ = m.Store.Unlease(ctx, key, m.Holder) } + if fresh, err := m.Store.Licence(ctx, l.Name); err == nil && fresh != nil { + l = *fresh + } + var raw map[string]any + g, err := m.grantOf(ctx, l) + if err == nil { + raw, err = m.Vendor.Usage(ctx, g.AccessToken) + } g = FullGrant{} if err == nil && raw == nil { err = errors.New("the usage endpoint answered nothing") @@ -703,7 +718,9 @@ func (m *Manager) ReadUsageOf(ctx context.Context, licence string, dry bool) ([] } kept.ReadError = err.Error() l.Usage = &kept - if err := m.Store.SaveLicence(ctx, l); err != nil { + err := m.Store.SaveLicence(ctx, l) + unlease() + if err != nil { return out, err } m.Log("reading the usage of %s failed: %v", l.Name, err) @@ -712,13 +729,16 @@ func (m *Manager) ReadUsageOf(ctx context.Context, licence string, dry bool) ([] } reading := FlattenUsage(raw) if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil { + unlease() return out, err } // The latest reading beside its licence, so licences answers from one key (§2). latest := reading latest.ReadAt = m.Now().UTC().Format(time.RFC3339Nano) l.Usage = &latest - if err := m.Store.SaveLicence(ctx, l); err != nil { + err = m.Store.SaveLicence(ctx, l) + unlease() + if err != nil { return out, err } _ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct, diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go index 9f8f5b5b8..aeafc0005 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go @@ -64,6 +64,29 @@ func TestAFailedReadingKeepsThePercentages(t *testing.T) { if n := len(mm.usage.entries); n != 1 { t.Fatalf("a failed reading appended: %d entries", n) } + // §16, §31: a grant the vault does not answer is a failed reading, written and gone past, never the end of the round. + mm.vendor.usageErr = nil + mm.grants.mu.Lock() + delete(mm.grants.held, SecretName(KeyFor(licence1), PartGrant)) + mm.grants.mu.Unlock() + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatalf("a grant the vault did not answer ended the round: %v", err) + } + l, _ = mm.store.Licence(ctx, licence1) + if !strings.Contains(l.Usage.ReadError, "cannot be read from the vault") || *l.Usage.WeeklyPct != 97 { + t.Fatalf("after the vault's refusal the reading is %+v", l.Usage) + } + // §4, §16: a reading is taken under the licence's lease; with another run holding it, nothing is read or written. + if ok, _ := mm.store.Lease(ctx, "licence."+KeyFor(licence1), "another run", time.Minute); !ok { + t.Fatal("the lease was not taken") + } + reads, err := mm.m.ReadUsageOf(ctx, licence1, true) + if err != nil || len(reads) != 1 || reads[0].Error != "another run holds its lease" { + t.Fatalf("a reading under another's lease answered %+v, %v", reads, err) + } + if again, _ := mm.store.Licence(ctx, licence1); again.Usage.ReadError != l.Usage.ReadError { + t.Fatalf("a reading under another's lease wrote %+v", again.Usage) + } } // design 39 bus-and-switch §25/AC1, §48: a table of readings, ages and healths and the room each gives. -- 2.54.0 From dbcba161d57cb9e485fb97df8c3db990c5c5641c Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:07:09 +0100 Subject: [PATCH 32/32] M-6 fix: the carry-over carries each live lease with its remaining lifetime (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lead's answer B44: a lease the bundle before held in today's database and still live at the run is written to `leases` under the bus's key form with what is left of its lifetime, so no refresh races it whatever the delivery's order; an expired one is counted read and not written. Builds: design 39 bus-and-switch §4, §12, B44 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/carry.go | 44 ++++++++++++++++++- .../cmd/claude-licence-manager/carry_test.go | 33 ++++++++++++-- .../cmd/claude-licence-manager/pgstore.go | 15 ++++--- 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go b/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go index 445ca7e51..cf3199641 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "sync" "time" ) @@ -59,12 +60,19 @@ type AuditRow struct { type CarrySource interface { Licences(ctx context.Context) ([]LicenceRow, error) Bindings(ctx context.Context) ([]Binding, error) - Leases(ctx context.Context) (int, error) + Leases(ctx context.Context) ([]LeaseRow, error) Offered(ctx context.Context) ([]OfferedRow, error) Usage(ctx context.Context, since time.Time) ([]UsageTableRow, error) Audit(ctx context.Context) ([]AuditRow, error) } +// LeaseRow is one row of today's table `lease`: its key (`licence:` or `account:`), holder and until when. +type LeaseRow struct { + Key string + Holder string + Until time.Time +} + // CarriedTable is one table in `carried.postgres`: the rows read and the entries written for them. type CarriedTable struct { Read int `json:"read"` @@ -219,7 +227,7 @@ func (co *carryOver) all(ctx context.Context) error { case "binding": t, err = co.bindings(ctx) case "lease": - t.Read, err = co.src.Leases(ctx) // a lease lives two minutes: none taken before the start still holds + t, err = co.leases(ctx) case "offered": t, err = co.offered(ctx) case "usage": @@ -334,6 +342,38 @@ func (co *carryOver) bindings(ctx context.Context) (CarriedTable, error) { return t, nil } +// leases writes a key of `leases` per lease still live at the run, with its remaining lifetime, so the manager never +// refreshes a licence the bundle before it holds a lease on, whatever the delivery's order; an expired one is counted +// read and not written (B44). Its key takes the bus's form: `licence.` or `account.` (§4). +func (co *carryOver) leases(ctx context.Context) (CarriedTable, error) { + rows, err := co.src.Leases(ctx) + if err != nil { + return CarriedTable{}, err + } + t := CarriedTable{Read: len(rows)} + for _, r := range rows { + left := r.Until.Sub(co.now) + if left <= 0 { + continue + } + key := "" + if name, ok := strings.CutPrefix(r.Key, "licence:"); ok { + key = "licence." + KeyFor(name) + } else if account, ok := strings.CutPrefix(r.Key, "account:"); ok { + key = "account." + account + } else { + co.m.Log("the lease %s names neither a licence nor an account; it is not carried", r.Key) + continue + } + v := leaseValue{Holder: r.Holder, Until: r.Until.UTC().Format(time.RFC3339)} + if _, err := co.s.LeasesState.PutTTL(key, v, left); err != nil { + return t, err + } + t.Written++ + } + return t, nil +} + // offered writes a key of `offered` per row, its lifetime counted from the row's `at`; a row past its lifetime is // gone already and written nowhere (§5, §12). func (co *carryOver) offered(ctx context.Context) (CarriedTable, error) { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go index 29e64f131..9c7782424 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go @@ -18,7 +18,7 @@ import ( type memSource struct { licences []LicenceRow bindings []Binding - leases int + leases []LeaseRow offered []OfferedRow usage []UsageTableRow audit []AuditRow @@ -37,7 +37,7 @@ func (s *memSource) Licences(context.Context) ([]LicenceRow, error) { func (s *memSource) Bindings(context.Context) ([]Binding, error) { return s.bindings, s.fail("binding") } -func (s *memSource) Leases(context.Context) (int, error) { return s.leases, s.fail("lease") } +func (s *memSource) Leases(context.Context) ([]LeaseRow, error) { return s.leases, s.fail("lease") } func (s *memSource) Offered(context.Context) ([]OfferedRow, error) { return s.offered, s.fail("offered") } @@ -65,7 +65,7 @@ func todaysRows(t *testing.T, mm *miniMesh) *memSource { {Licence: Licence{Name: "api", Kind: "api-key", AdoptedAt: 2}, Sealed: sealAtRest(t, mm.m.Crypt, "sk-ant-api03-x")}, }, bindings: []Binding{{Consumer: "laptop", Licence: licence1, Generation: 1013}, {Consumer: "server", Licence: "api", Generation: 1016}}, - leases: 1, + leases: []LeaseRow{{Key: "licence:x", Holder: "novox:1", Until: t0.Add(-time.Minute)}}, offered: []OfferedRow{{Fingerprint: "sha256:aa", Node: "laptop", Account: "acct-1", Outcome: Adopted, Why: "refreshed", At: t0.Add(-24 * time.Hour)}, {Fingerprint: "sha256:bb", Node: "server", Outcome: Dead, Why: "400", At: t0.Add(-48 * time.Hour)}}, usage: []UsageTableRow{ @@ -285,3 +285,30 @@ func TestNoWriteToTheDatabase(t *testing.T) { } } } + +// design 39 bus-and-switch §12, B44: a lease still live at the run is carried with its remaining lifetime, and a +// refresh waits for it to end; an expired one is counted read and not written. +func TestALiveLeaseIsCarriedAndBlocksARefreshUntilItEnds(t *testing.T) { + mm := newMesh(t) + carried := newMemState(func() time.Time { return mm.now }) + src := todaysRows(t, mm) + src.leases = []LeaseRow{{Key: "licence:" + licence1, Holder: "the old bundle", Until: mm.now.Add(90 * time.Second)}, + {Key: "account:acct-1", Holder: "the old bundle", Until: mm.now.Add(-time.Second)}} + if _, err := carryFor(mm, carried, src).Run(context.Background(), mm.m, mm.store); err != nil { + t.Fatal(err) + } + if got := readCarried(t, carried).Tables["lease"]; got.Read != 2 || got.Written != 1 { + t.Fatalf("lease: read %d written %d, want 2 read and 1 written", got.Read, got.Written) + } + if e, _ := mm.leases.Get("account.acct-1"); e != nil { + t.Fatalf("an expired lease was carried: %s", e.Value) + } + r, err := mm.m.Rotate(context.Background(), licence1, true) + if err != nil || r["refreshed"] != false || r["reason"] != "another run holds its lease" { + t.Fatalf("a refresh under the carried lease answered %v, %v", r, err) + } + mm.now = mm.now.Add(91 * time.Second) + if r, err := mm.m.Rotate(context.Background(), licence1, true); err != nil || r["reason"] == "another run holds its lease" { + t.Fatalf("once the carried lease ended the refresh answered %v, %v", r, err) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go b/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go index f6144ddf4..892986886 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go @@ -103,14 +103,15 @@ func (s *PgSource) Bindings(ctx context.Context) ([]Binding, error) { return out, err } -func (s *PgSource) Leases(ctx context.Context) (int, error) { - n := 0 - err := s.each(ctx, `select key from lease`, func(r pgx.Rows) error { - n++ - var k string - return r.Scan(&k) +func (s *PgSource) Leases(ctx context.Context) ([]LeaseRow, error) { + out := []LeaseRow{} + err := s.each(ctx, `select key, holder, until from lease order by key`, func(r pgx.Rows) error { + var l LeaseRow + err := r.Scan(&l.Key, &l.Holder, &l.Until) + out = append(out, l) + return err }) - return n, err + return out, err } func (s *PgSource) Offered(ctx context.Context) ([]OfferedRow, error) { -- 2.54.0