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:
@@ -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"`) ||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user