C-2: a node follows its licence binding by binding.changed and a first view (issue 1409)

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
This commit is contained in:
2026-11-11 23:44:12 +01:00
parent 110c631b5f
commit 6eccfc286a
6 changed files with 140 additions and 30 deletions
@@ -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)
}
}
@@ -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"`) ||
+25 -21
View File
@@ -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
@@ -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) {
@@ -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"}
+4 -5
View File
@@ -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",