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)
}
}