V-2: the vault keeps the licence bindings from binding.changed and a first view (issue 1409)

The vault consumes the licence manager's binding.changed and, at its start,
asks the seat's bindings until it answers; it keeps each consumer's licence,
the licence's key and the generation in its own state licence-bindings,
never an older generation, deleting a released consumer's key. The state is
in the vault's old state section, and holds no secret.

Builds: design 39 bus-and-switch §17, B2, B9, B10, B22, B25

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
This commit is contained in:
2026-11-11 23:39:15 +01:00
parent b63e8ae58a
commit 256a412590
7 changed files with 368 additions and 5 deletions
@@ -0,0 +1,231 @@
package main
// The licence bindings the vault keeps (design 39 bus-and-switch §17, B2, B10, B25): which licence each consumer is
// bound to, so the vault hands a licence's access token only to a machine a binding names. Learned from the licence
// manager's `binding.changed`, which the vault consumes, and at its start from the seat's verb `bindings`, its first
// view, asked until it answers; kept in the vault's own state `licence-bindings`, one key per consumer with the
// licence, its key and the generation, deleted on a release. The vault never reads the manager's state (ADR 0596 §2).
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"sync"
"time"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/views"
)
const (
// LicenceBindingsState is the vault's own state of the licence bindings (§17; B2: in the old section `state`).
LicenceBindingsState = "licence-bindings"
// BindingChangedEvent is the licence manager's event of a binding written or deleted (§21), which the vault consumes.
BindingChangedEvent = "claude-licence-manager.binding.changed"
// LicenceBindingsVerb is the seat's verb the vault asks for its first view (B10), declared in its invokes (B9).
LicenceBindingsVerb = "seat:anthropic-licence-manager.bindings"
)
// LicenceBinding is a consumer's key of `licence-bindings` (B25): the licence, its key and the binding's generation.
type LicenceBinding struct {
Licence string `json:"licence"`
Key string `json:"key"`
Generation int64 `json:"generation"`
}
// LicenceBindings is the state `licence-bindings`.
type LicenceBindings struct {
Get func(consumer string) (*LicenceBinding, error)
Put func(consumer string, b LicenceBinding) error
Delete func(consumer string) error
Keys func() ([]string, error)
Log func(string, ...any)
}
// busLicenceBindings is the state on the bus.
func busLicenceBindings() LicenceBindings {
st := stdio.State(LicenceBindingsState)
return LicenceBindings{
Get: func(consumer string) (*LicenceBinding, error) {
e, err := st.Get(consumer)
if err != nil || e == nil || len(e.Value) == 0 {
return nil, err
}
var b LicenceBinding
return &b, json.Unmarshal(e.Value, &b)
},
Put: func(consumer string, b LicenceBinding) error { _, err := st.Put(consumer, b); return err },
Delete: func(consumer string) error { return st.Delete(consumer) },
Keys: st.Keys,
Log: say,
}
}
// bindingChanged is the body of `binding.changed` as the manager says it (§21, B7; B22: decoded from a copy of the
// writer's form, testdata/binding-changed.json).
type bindingChanged struct {
Consumer string `json:"consumer"`
Licence string `json:"licence"`
Key string `json:"key"`
Generation int64 `json:"generation"`
}
// keep writes a consumer's binding unless the state holds a newer generation; an empty licence is a release.
func (lb LicenceBindings) keep(consumer string, b LicenceBinding) error {
if consumer == "" {
return errors.New("a binding names no consumer")
}
held, err := lb.Get(consumer)
if err != nil {
return err
}
if held != nil && held.Generation > b.Generation {
return nil // the state holds a newer move already
}
if b.Licence == "" {
if held == nil {
return nil
}
return lb.Delete(consumer)
}
if held != nil && *held == b {
return nil
}
return lb.Put(consumer, b)
}
// Heard takes one `binding.changed`.
func (lb LicenceBindings) Heard(body []byte) error {
var e bindingChanged
if err := json.Unmarshal(body, &e); err != nil {
return fmt.Errorf("%s is not a binding's change: %w", BindingChangedEvent, err)
}
if e.Licence != "" && e.Key == "" {
return fmt.Errorf("%s for %s names no licence key", BindingChangedEvent, e.Consumer)
}
return lb.keep(e.Consumer, LicenceBinding{Licence: e.Licence, Key: e.Key, Generation: e.Generation})
}
// FirstView takes the manager's answer to `bindings`, by consumer: every binding it names is kept, and every key the
// state holds for a consumer it does not name is deleted, since that consumer was released.
func (lb LicenceBindings) FirstView(values map[string]json.RawMessage) error {
var errs []error
for consumer, raw := range values {
var b LicenceBinding
if err := json.Unmarshal(raw, &b); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", consumer, err))
continue
}
errs = append(errs, lb.keep(consumer, b))
}
held, err := lb.Keys()
if err != nil {
return errors.Join(append(errs, err)...)
}
for _, consumer := range held {
if _, named := values[consumer]; !named {
errs = append(errs, lb.Delete(consumer))
}
}
return errors.Join(errs...)
}
// licenceBindingsAnswer is the seat's answer to `bindings` (B10, B25) as a first view, by consumer.
func licenceBindingsAnswer(raw []byte) (views.Answer, error) {
var listed []struct {
Consumer string `json:"consumer"`
LicenceBinding
}
if err := json.Unmarshal(unwrapToolAnswer(raw), &listed); err != nil {
return views.Answer{}, fmt.Errorf("%s answered what is not a list of bindings: %w", LicenceBindingsVerb, err)
}
values := map[string]json.RawMessage{}
for _, b := range listed {
if b.Consumer == "" || b.Licence == "" || b.Key == "" {
return views.Answer{}, fmt.Errorf("%s answered a binding without its consumer, licence or key", LicenceBindingsVerb)
}
raw, _ := json.Marshal(b.LicenceBinding)
values[b.Consumer] = raw
}
return views.Answer{Owner: "claude-licence-manager", State: "bindings", Values: values, At: time.Now()}, nil
}
// licenceBindingsView is the first view's follower; nil until main begins it.
var licenceBindingsView *views.Follower
// newLicenceBindingsView makes the follower of the first view: asked until it answers, and each answer, at the start or
// asked again by hand, written into the state.
func newLicenceBindingsView(ask func(subject string, body any) (json.RawMessage, error), lb LicenceBindings,
findings views.FindingWriter) *views.Follower {
var mu sync.Mutex
var taken time.Time
var f *views.Follower
f = &views.Follower{Owner: "claude-licence-manager", Tool: LicenceBindingsVerb, State: "bindings", Logf: say,
Ask: func(context.Context, string) (views.Answer, error) {
raw, err := ask(LicenceBindingsVerb, map[string]any{})
if err != nil {
return views.Answer{}, err
}
return licenceBindingsAnswer(raw)
},
Raise: func(s views.Status) {
fd := views.UnansweredFinding(Seat, s, time.Now())
if err := findings.Put(fd); err != nil {
say("the finding %s is not written: %v", fd.ID, err)
}
},
Clear: func(s views.Status) { _ = findings.Delete(views.UnansweredFinding(Seat, s, time.Now())) },
// Each answer, once: its picture is the manager's bindings when it answered.
Said: func(s views.Status) {
mu.Lock()
fresh := !s.Answered.IsZero() && s.Answered.After(taken)
if fresh {
taken = s.Answered
}
mu.Unlock()
if !fresh {
return
}
values := f.Values()
if err := lb.FirstView(values); err != nil {
say("the licence bindings of the first view are not all kept: %v", err)
return
}
consumers := make([]string, 0, len(values))
for c := range values {
consumers = append(consumers, c)
}
sort.Strings(consumers)
say("the licence bindings of %d consumer(s) are kept from the first view: %v", len(consumers), consumers)
}}
return f
}
// watchLicenceBindings begins the first view and hears `binding.changed`. It answers whether it began; one that did not
// is begun again at the next minute pass.
func watchLicenceBindings(lb LicenceBindings) bool {
err := stdio.Subscribe(BindingChangedEvent, func(e stdio.Envelope) error {
if err := lb.Heard(e.Body); err != nil {
say("%v", err)
}
return nil
})
if err != nil {
say("does not hear %s yet (%v); asked again at the next pass", BindingChangedEvent, err)
return false
}
if licenceBindingsView == nil {
licenceBindingsView = newLicenceBindingsView(stdio.Ask, lb,
meshconditionsclient.FindingWriter{State: stdio.State(FindingsState)})
go func() {
if err := licenceBindingsView.Start(context.Background()); err != nil {
say("the first view of the licence bindings ended unanswered: %v", err)
}
}()
}
return true
}
@@ -0,0 +1,115 @@
package main
import (
"encoding/json"
"errors"
"os"
"slices"
"sort"
"testing"
)
// memLicenceBindings is the state licence-bindings in memory.
func memLicenceBindings() (LicenceBindings, map[string]LicenceBinding) {
held := map[string]LicenceBinding{}
return LicenceBindings{
Get: func(c string) (*LicenceBinding, error) {
if b, ok := held[c]; ok {
return &b, nil
}
return nil, nil
},
Put: func(c string, b LicenceBinding) error { held[c] = b; return nil },
Delete: func(c string) error { delete(held, c); return nil },
Keys: func() ([]string, error) {
var out []string
for c := range held {
out = append(out, c)
}
sort.Strings(out)
return out, nil
},
Log: func(string, ...any) {},
}, held
}
// design 39 bus-and-switch §17, B22, B25: the vault keeps a consumer's binding from binding.changed, decoded from a copy
// of the manager's form, with the licence's key; an older generation is passed over and a release deletes the key.
func TestALicenceBindingIsKeptFromBindingChanged(t *testing.T) {
lb, held := memLicenceBindings()
said, err := os.ReadFile("testdata/binding-changed.json")
if err != nil {
t.Fatal(err)
}
if err := lb.Heard(said); err != nil {
t.Fatal(err)
}
if b := held["laptop"]; b != (LicenceBinding{Licence: "api", Key: "api", Generation: 3}) {
t.Fatalf("laptop is kept as %+v", b)
}
for _, c := range []struct {
body string
want *LicenceBinding
}{
{`{"consumer":"laptop","licence":"other","key":"other","generation":2}`, &LicenceBinding{Licence: "api", Key: "api", Generation: 3}},
{`{"consumer":"laptop","licence":"other","key":"other","generation":4}`, &LicenceBinding{Licence: "other", Key: "other", Generation: 4}},
{`{"consumer":"laptop","licence":"","key":"","generation":4,"from":"other"}`, nil},
} {
if err := lb.Heard([]byte(c.body)); err != nil {
t.Fatal(err)
}
got, ok := held["laptop"]
if (c.want == nil) != !ok || (c.want != nil && got != *c.want) {
t.Fatalf("after %s laptop is kept as %+v (held %v), want %+v", c.body, got, ok, c.want)
}
}
if err := lb.Heard([]byte(`{"consumer":"server","licence":"api","generation":5}`)); err == nil {
t.Fatal("a binding without its licence's key was kept")
}
}
// design 39 bus-and-switch §17, B10, B25: the first view, the seat's answer to bindings, keeps every consumer's binding
// with its key and deletes a consumer it does not name; the vault invokes the verb and consumes the event its manifest
// names.
func TestTheFirstViewOfTheLicenceBindingsIsKept(t *testing.T) {
lb, held := memLicenceBindings()
held["gone"] = LicenceBinding{Licence: "api", Key: "api", Generation: 9}
raw, err := os.ReadFile("testdata/bindings-answer.json")
if err != nil {
t.Fatal(err)
}
a, err := licenceBindingsAnswer(raw)
if err != nil {
t.Fatal(err)
}
if err := lb.FirstView(a.Values); err != nil {
t.Fatal(err)
}
want := map[string]LicenceBinding{"laptop": {Licence: "acct-1@example.org", Key: "acct-1_at_example.org", Generation: 1013},
"server": {Licence: "api", Key: "api", Generation: 1016}}
if len(held) != 2 || held["laptop"] != want["laptop"] || held["server"] != want["server"] {
t.Fatalf("the first view kept %v", held)
}
if _, err := licenceBindingsAnswer([]byte(`[{"consumer":"laptop","licence":"api","generation":1}]`)); err == nil {
t.Fatal("an answer without a licence's key was taken")
}
lb.Put = func(string, LicenceBinding) error { return errors.New("the bucket is full") }
held["laptop"] = LicenceBinding{Licence: "api", Key: "api", Generation: 1}
if err := lb.FirstView(a.Values); err == nil {
t.Fatal("a first view not kept was not said")
}
manifest, _ := os.ReadFile("../../module.json")
var m struct {
Invokes, Consumes []string
State []json.RawMessage
}
_ = json.Unmarshal(manifest, &m)
kept := false
for _, st := range m.State {
kept = kept || string(st) == `"`+LicenceBindingsState+`"`
}
if !slices.Contains(m.Invokes, LicenceBindingsVerb) || !slices.Contains(m.Consumes, BindingChangedEvent) || !kept {
t.Fatalf("the manifest invokes %v, consumes %v, keeps %s", m.Invokes, m.Consumes, m.State)
}
}
@@ -323,6 +323,8 @@ func main() {
watched, watching := watchHandings(env), watchAssignments()
wants := watchWants(env)
leavers := watchLeavers(env)
bindings := busLicenceBindings()
licences := watchLicenceBindings(bindings)
kick := make(chan struct{}, 1)
go func() {
for range kick {
@@ -337,6 +339,9 @@ func main() {
if !leavers {
leavers = watchLeavers(env)
}
if !licences {
licences = watchLicenceBindings(bindings)
}
if !watched {
watched = watchHandings(env)
}
@@ -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"}
@@ -0,0 +1 @@
[{"consumer":"laptop","licence":"acct-1@example.org","key":"acct-1_at_example.org","kind":"subscription","generation":1013,"since":"2026-10-04T12:00:00Z","why":"carried over","by":"claude-licence-manager"},{"consumer":"server","licence":"api","key":"api","kind":"api-key","generation":1016,"since":"2026-10-04T12:00:00Z","why":"carried over","by":"claude-licence-manager"}]
@@ -148,10 +148,11 @@ func TestNoBucketHoldsASecret(t *testing.T) {
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatal(err)
}
if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"},"signers","bound","node-logins","findings"]` {
if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"},"signers","bound","node-logins","findings","licence-bindings"]` {
t.Errorf("the manifest declares buckets %s: a secret is in no bucket, sealed or not (ADR 0201 §13), and the "+
"buckets are the accounts' hashes (ADR 0547 clause 9), the signing keys' public halves (ADR 0550 §2) and the "+
"credentials' outcomes, names and generations alone (ADR 0570 clauses 3, 6) and each node's login in use (ADR 0583)", s)
"credentials' outcomes, names and generations alone (ADR 0570 clauses 3, 6), each node's login in use (ADR 0583) "+
"and the licence bindings, a licence's name, key and generation per consumer (design 39 bus-and-switch §17)", s)
}
if r, _ := json.Marshal(manifest["reads"]); string(r) != `["node-engine.have","mesh-grants.accounts","mesh-provisioner.plans","mesh-provisioner.provisions"]` {
t.Errorf("the manifest reads %s: the vault reads the seat mesh-provisioner's plans and provisions, the haves, the "+
@@ -182,6 +183,12 @@ func TestNoBucketHoldsASecret(t *testing.T) {
code = []byte(strings.Replace(string(code), "stdio.State(NodeLoginsState)."+call, "", -1))
}
}
// The licence bindings, in licencebindings.go alone: the state opened once, a licence's name, key and generation per
// consumer, never a secret (design 39 bus-and-switch §17, B25).
if f == "licencebindings.go" {
code = []byte(strings.Replace(string(code), "st := stdio.State(LicenceBindingsState)", "", 1))
code = []byte(strings.Replace(string(code), "meshconditionsclient.FindingWriter{State: stdio.State(FindingsState)}", "", 1))
}
// The signing keys' public halves, in signing.go alone: one read and one write of its own bucket (ADR 0550 §2).
if f == "signing.go" {
code = []byte(strings.Replace(string(code), "stdio.State(meshvault.StateSigners).Get(", "", 1))
+6 -3
View File
@@ -32,7 +32,8 @@
],
"invokes": [
"seat:mesh-hand-acts.hand-acts",
"mesh-delivery-assigner.assigner_read_assignments"
"mesh-delivery-assigner.assigner_read_assignments",
"seat:anthropic-licence-manager.bindings"
],
"reads": [
"node-engine.have",
@@ -48,7 +49,8 @@
"signers",
"bound",
"node-logins",
"findings"
"findings",
"licence-bindings"
],
"receives": {
"secret": "${dir:grants}/mesh.json"
@@ -254,6 +256,7 @@
}
},
"consumes": [
"mesh-delivery-assigner.views.assignments"
"mesh-delivery-assigner.views.assignments",
"claude-licence-manager.binding.changed"
]
}