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