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" ],