From 6eccfc286aade9889db6e92d7a309a71f21a7fec Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:44:12 +0100 Subject: [PATCH] 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",