From a72eb3dbb28d288bd7ba354f6a72f1da677dde6a Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Tue, 13 Oct 2026 11:45:52 +0000 Subject: [PATCH 1/2] licence manager: say what moved a binding, why, and who asked (issue 625) A switch of g14 back to another licence on 2026-10-13 left no trace: the manager logged no line and sent no event for bind, switch or release, and its audit kept only the verb's name. Every change of what a consumer holds now goes to the journal, the audit and a licence. event with the same fields: consumer, licence before and after, generation, why (the verb or the rule) and by (the caller the runtime proved, or the manager itself). The node is told why in the bindings state and says it when it applies. The audit and binding tables become readable through the store's reader. The SDK moves to the version that proves the caller (issue 365). --- .../cmd/claude-code/claude_code_test.go | 19 +++ modules/claude-code/cmd/claude-code/node.go | 12 ++ .../cmd/claude-licence-manager/main.go | 10 +- .../cmd/claude-licence-manager/manager.go | 119 ++++++++++++++---- .../claude-licence-manager/manager_test.go | 74 ++++++++++- modules/claude-licence-manager/go.mod | 2 +- modules/claude-licence-manager/go.sum | 4 +- modules/claude-licence-manager/module.json | 10 +- 8 files changed, 217 insertions(+), 33 deletions(-) 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 60f3bfd7..021e0d56 100644 --- a/modules/claude-code/cmd/claude-code/claude_code_test.go +++ b/modules/claude-code/cmd/claude-code/claude_code_test.go @@ -285,6 +285,25 @@ func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) { } } +// The node's line names the generation it applied and why the manager moved it, and who asked (novox/hq issue 625). +func TestAnAppliedBindingSaysWhyAndWho(t *testing.T) { + p, w := node(t, "laptop") + var asked []string + done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 7, + Why: "the switch verb", By: "g14.node-tools"}, seat(t, "personal", "at-7", 7, &asked), writer(w)) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"generation":7`, `"why":"the switch verb"`, `"by":"g14.node-tools"`} { + if !strings.Contains(done, want) { + t.Fatalf("the line %s lacks %s", done, want) + } + } + if strings.Contains(done, "at-7") { + t.Fatal("the line carries the token") + } +} + func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) { p, w := node(t, "laptop") writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+ diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index 9c854748..854e6061 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -298,6 +298,9 @@ type BindingState struct { Licence string `json:"licence"` Kind string `json:"kind"` Generation int64 `json:"generation"` + // Why and By are what moved the binding and who asked, as the manager recorded it (novox/hq issue 625). + Why string `json:"why,omitempty"` + By string `json:"by,omitempty"` } // Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed. @@ -433,6 +436,15 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e if err != nil { return "", err } + // The line says which binding this was and why the manager moved it, so a node's journal alone answers + // what changed here and who asked (novox/hq issue 625). + out["generation"] = b.Generation + if b.Why != "" { + out["why"] = b.Why + } + if b.By != "" { + out["by"] = b.By + } raw, _ := json.Marshal(out) return string(raw), nil } 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 28b7a1d3..0d966153 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -253,6 +253,7 @@ 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). 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) { @@ -260,7 +261,8 @@ func verb(name, description string, input map[string]any, run func(ctx context.C if err != nil { return nil, err } - return run(context.Background(), m, a) + ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)}) + return run(ctx, m, a) }} } @@ -288,7 +290,7 @@ func tools() []stdio.Tool { if err != nil { return nil, err } - return m.Bind(ctx, c, l, "bind") + return m.Bind(ctx, c, l) }), verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it. A login on a node does this by itself (ADR 0209); this is for moving one without a login, or back.", map[string]any{"consumer": consumer, "licence": str("the licence to move to")}, @@ -297,7 +299,7 @@ func tools() []stdio.Tool { if err != nil { return nil, err } - return m.Bind(ctx, c, l, "switch") + return m.Bind(ctx, c, l) }), verb("release", "Unbind a consumer. Its node keeps its last token, which expires within hours.", map[string]any{"consumer": consumer}, @@ -306,7 +308,7 @@ func tools() []stdio.Tool { if err != nil { return nil, err } - return m.Release(ctx, c, "release") + return m.Release(ctx, c) }), verb("refresh", "Refresh a licence now, or every due licence when none is named; under each licence's lease, so it never races the daemon. Answers the outcome, never a token.", map[string]any{"licence": str("a licence; absent for every due one")}, 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 82ee30cf..fe8b208c 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -59,6 +59,54 @@ 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 { + Why string + By string +} + +// Itself names this module where it moved a binding by a rule of its own, with no caller. +const Itself = "claude-licence-manager" + +type causeKey struct{} + +// WithCause is ctx carrying why what runs in it changes a binding, and who asked. +func WithCause(ctx context.Context, c Cause) context.Context { + return context.WithValue(ctx, causeKey{}, c) +} + +// CauseOf is the cause ctx carries, or "a rule of this module's own" when none: never empty, so a record always +// says something, and a change nobody named a cause for reads as this module's own. +func CauseOf(ctx context.Context) Cause { + if c, ok := ctx.Value(causeKey{}).(Cause); ok && c.By != "" { + return c + } + 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. +func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) { + _ = m.Store.Audit(ctx, what, detail) + m.Log("%s", line) + _ = m.Emit("licence."+what, detail) +} + +// 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) { + 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), + map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation, + "why": c.Why, "by": c.By}) } // Settings are the manager's own, declared with defaults (design 39 §8). @@ -267,11 +315,12 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error { if err != nil { return err } - _ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its account's report"}) - if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation}); err != nil { + 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 } - m.Log("bound %s to %s, the licence its account already has", rep.Node, l.Name) } return nil } @@ -351,21 +400,21 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) // 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. + // 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) if b, err := m.Store.Binding(ctx, c.Node); err != nil { return "", err } else if b == nil || b.Licence != l.Name { - if _, err := m.Store.Bind(ctx, c.Node, l.Name); err != nil { + moved, err := m.Store.Bind(ctx, c.Node, l.Name) + if err != nil { return "", err } from := "" if b != nil { from = b.Licence } - _ = m.Store.Audit(ctx, map[bool]string{true: "switched", false: "bound"}[b != nil], - map[string]any{"consumer": c.Node, "licence": l.Name, "from": from, "by": "a login there"}) - if b != nil { - m.Log("%s moved from %s to %s: a login there", c.Node, from, l.Name) - } + m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause) } for _, rep := range reports { if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID { @@ -374,8 +423,9 @@ 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 _, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil { - _ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its login"}) + 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}) } } if err := m.publishAdvance(ctx, l); err != nil { @@ -399,14 +449,17 @@ func (m *Manager) keep(l *Licence, g FullGrant) { l.RotatedAt = m.Now().UnixMilli() } -// publishAdvance gives every consumer of a licence a new generation, and tells each in the state. +// 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 } + c := CauseOf(ctx) for _, b := range bindings { - if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation}); err != nil { + 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 } } @@ -482,7 +535,19 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri if err := m.Store.SaveLicence(ctx, *l); err != nil { return nil, err } - _ = m.Store.Audit(ctx, "rotated", map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt}) + c := CauseOf(ctx) + if force { + c.Why = "a refresh asked for: " + c.Why + } else { + c = Cause{Why: "due: older than its cadence or near its expiry", By: Itself} + if by := CauseOf(ctx); by.By != Itself { + c.By = by.By + } + } + 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, + 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 { return nil, err } @@ -594,8 +659,9 @@ func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[ return out, nil } -// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation. -func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[string]any, error) { +// 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) { l, err := m.Store.Licence(ctx, licence) if err != nil { return nil, err @@ -611,19 +677,21 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[s if err != nil { return nil, err } - from, what := "", "bound" + from := "" if before != nil { - from, what = before.Licence, "switched" + from = before.Licence } - _ = m.Store.Audit(ctx, what, map[string]any{"consumer": consumer, "licence": licence, "from": from, "by": by}) - if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation}); err != nil { + 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 } // Release unbinds a consumer; its node keeps its last token, which expires within hours. -func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]any, error) { +func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any, error) { was, err := m.Store.Binding(ctx, consumer) if err != nil { return nil, err @@ -634,7 +702,9 @@ func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string] if err := m.DeleteBinding(ctx, consumer); err != nil { return nil, err } - _ = m.Store.Audit(ctx, "released", map[string]any{"consumer": consumer, "licence": was.Licence, "by": by}) + 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}) return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil } @@ -685,7 +755,10 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str if err := m.Store.SaveLicence(ctx, l); err != nil { return nil, err } - _ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from}) + c := CauseOf(ctx) + _ = 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 { 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 de0bbe0c..287c6456 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 @@ -234,13 +234,13 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) { if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil { t.Fatal(err) } - if _, err := mm.m.Bind(ctx, "laptop", "api", "test"); err != nil { + 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 _, err := mm.m.Release(ctx, "laptop", "test"); err != nil { + if _, err := mm.m.Release(ctx, "laptop"); err != nil { t.Fatal(err) } if _, ok := mm.state["laptop"]; ok { @@ -454,3 +454,73 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) { t.Fatal("nothing was kept") } } + +// 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) { + mm := newMesh(t) + var lines []string + 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 { + t.Fatalf("a login's binding said %+v", s) + } + 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) + } + + 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 s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" { + t.Fatalf("the node is told %+v", s) + } + said := strings.Join(lines, "\n") + if !strings.Contains(said, "switched laptop from "+licence1+" to api (generation ") || + !strings.Contains(said, "the switch verb; asked by g14.node-tools") { + t.Fatalf("the journal said:\n%s", said) + } + var switched string + for _, e := range mm.events { + if strings.HasPrefix(e, "licence.switched ") { + 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) + } + } + last := mm.store.Audits[len(mm.store.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) + } + + _, _ = 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) + } + + if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil { + t.Fatal(err) + } + 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 { + 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) + } + } +} + +// A call with no cause is said as this module's own, never as nobody. +func TestNoCauseReadsAsTheModulesOwn(t *testing.T) { + if c := CauseOf(context.Background()); c.By != Itself || c.Why == "" { + t.Fatalf("no cause read as %+v", c) + } +} diff --git a/modules/claude-licence-manager/go.mod b/modules/claude-licence-manager/go.mod index dc914d55..e1c64c84 100644 --- a/modules/claude-licence-manager/go.mod +++ b/modules/claude-licence-manager/go.mod @@ -3,7 +3,7 @@ module claude-licence-manager go 1.25.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.7 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac github.com/jackc/pgx/v5 v5.11.0 ) diff --git a/modules/claude-licence-manager/go.sum b/modules/claude-licence-manager/go.sum index 79d630df..4c265826 100644 --- a/modules/claude-licence-manager/go.sum +++ b/modules/claude-licence-manager/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= -git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac h1:jnZ/n+fFMo7VQPNNXK1gjStHIJPw3SPbzPxsf/PpZqw= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= 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= diff --git a/modules/claude-licence-manager/module.json b/modules/claude-licence-manager/module.json index 591a3c61..95614901 100644 --- a/modules/claude-licence-manager/module.json +++ b/modules/claude-licence-manager/module.json @@ -8,7 +8,11 @@ ], "contributes": { "postgres-database": { - "name": "claude_licences" + "name": "claude_licences", + "readable": [ + "audit", + "binding" + ] } }, "binds": { @@ -59,6 +63,10 @@ "emits": [ "licence.adopted", "licence.refused", + "licence.bound", + "licence.switched", + "licence.released", + "licence.rotated", "licence.failing", "usage.read" ], From 1725ffc799bdcc018a4f3abb0ee8eb0b46913ceb Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Tue, 13 Oct 2026 11:49:30 +0000 Subject: [PATCH 2/2] licence manager: a login's adoption is said beside the bindings it made, not first (issue 625) The bindings a login makes are now announced as licence.bound before the adoption's own event, so the test asks for each event rather than for adoption to come first. --- .../cmd/claude-licence-manager/manager_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 287c6456..4f8103fb 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 @@ -132,8 +132,13 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 { t.Fatalf("bindings %v, state %v", bs, mm.state) } - if !strings.HasPrefix(strings.Join(mm.events, "|"), "licence.adopted") { - t.Fatalf("events %v", mm.events) + // The adoption is said, and so is each binding it made (novox/hq issue 625). + 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) + } } }