M-9: every change of a binding is said as binding.changed (issue 1409)

Bind, switch, release, a login's move and a rotation's advance each say one
binding.changed per consumer moved, with the licence's key, after the key of
bindings is written or deleted; a switch through the verbs says one
licence.switched with the from, to, consumers, by and why the audit's
switched entry also holds. licence.bound, released and rotated are said no
more. An adoption advances the consumers already bound before it moves the
login's nodes, so each is said once.

Builds: design 39 bus-and-switch §21, §27 (the verb's switch), §36, B7, B16

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
This commit is contained in:
2026-11-11 23:30:39 +01:00
parent 9cc62818f4
commit 3991211993
2 changed files with 112 additions and 37 deletions
@@ -79,27 +79,48 @@ func CauseOf(ctx context.Context) Cause {
return Cause{Why: "a rule of this module's own", By: Itself}
}
// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line
// in the journal, a row in the audit table, and the event `licence.<what>` on the bus. Never a token.
// record says one change of what a consumer holds, the same fields in two places (novox/hq issue 625): a line in
// the journal and an entry of the audit. The bus hears it as binding.changed (§21); `licence.bound`, `released` and
// `rotated` are said no more (B16). Never a token.
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) error {
if err := m.Store.Audit(ctx, what, detail); err != nil {
m.Log("%s, but %v", line, err)
return err
}
m.Log("%s", line)
_ = m.Emit("licence."+what, detail)
return nil
}
// moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom.
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) error {
what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence)
if from != "" {
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence)
// changed says that one consumer's key of `bindings` was written or deleted (§21, B7): its licence (empty on a
// release) and that licence's key, kind, generation, the licence before, why, by and when. Never a token.
func (m *Manager) changed(b Binding, from string) {
key := ""
if b.Licence != "" {
key = KeyFor(b.Licence)
}
return m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation,
"why": c.Why, "by": c.By})
_ = m.Emit("binding.changed", map[string]any{"consumer": b.Consumer, "licence": b.Licence, "key": key,
"kind": b.Kind, "generation": b.Generation, "from": from, "why": b.Why, "by": b.By,
"at": m.Now().UTC().Format(time.RFC3339Nano)})
}
// switchedFields are a switch's fields, the same in the event licence.switched and the audit entry switched (§27).
func switchedFields(from, to string, consumers []string, c Cause) map[string]any {
return map[string]any{"from": from, "to": to, "consumers": consumers, "by": c.By, "why": c.Why}
}
// moved records a consumer bound or switched, after its key is written: in the journal and the audit (a switch with
// §27's fields), and as binding.changed.
func (m *Manager) moved(ctx context.Context, b Binding, from string, c Cause) error {
what, line := "bound", fmt.Sprintf("bound %s to %s", b.Consumer, b.Licence)
detail := map[string]any{"consumer": b.Consumer, "licence": b.Licence, "from": from, "generation": b.Generation,
"why": c.Why, "by": c.By}
if from != "" && from != b.Licence {
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", b.Consumer, from, b.Licence)
detail = switchedFields(from, b.Licence, []string{b.Consumer}, c)
}
err := m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, b.Generation, c.Why, c.By), detail)
m.changed(b, from)
return err
}
// Settings are the manager's own, declared with defaults (design 39 §8).
@@ -305,7 +326,7 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor f
if err != nil {
return err
}
if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, c); err != nil {
if err := m.moved(ctx, b, "", c); err != nil {
return err
}
}
@@ -418,6 +439,11 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
// Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625).
cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself}
ctx = WithCause(ctx, cause)
// The consumers already bound to it take the new grant first, so each consumer this adoption moves is said once
// (§21).
if err := m.publishAdvance(ctx, l, floor); err != nil {
return "", err
}
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
return "", err
} else if b == nil || b.Licence != l.Name {
@@ -429,7 +455,7 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
if b != nil {
from = b.Licence
}
if err := m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause); err != nil {
if err := m.moved(ctx, moved, from, cause); err != nil {
return "", err
}
}
@@ -442,14 +468,11 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
}
bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself}
if b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: bc.Why, By: bc.By, Floor: floor}); err == nil {
if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, bc); err != nil {
if err := m.moved(ctx, b, "", bc); err != nil {
return "", err
}
}
}
if err := m.publishAdvance(ctx, l, floor); err != nil {
return "", err
}
_ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil})
m.Log("adopted %s from %s (%s)", l.Name, c.Node, why)
return l.Name, nil
@@ -506,7 +529,10 @@ func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) {
// it fetched again (novox/hq issue 625).
func (m *Manager) publishAdvance(ctx context.Context, l Licence, floor func(string) int64) error {
c := CauseOf(ctx)
_, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
advanced, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
for _, b := range advanced {
m.changed(b, b.Licence)
}
return err
}
@@ -761,9 +787,12 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
if before != nil {
from = before.Licence
}
if err := m.moved(ctx, consumer, from, licence, b.Generation, c); err != nil {
if err := m.moved(ctx, b, from, c); err != nil {
return nil, err
}
if from != "" && from != licence {
_ = m.Emit("licence.switched", switchedFields(from, licence, []string{consumer}, c)) // one per act (§27, §36)
}
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
}
@@ -781,6 +810,7 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any,
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}); err != nil {
return nil, err
}
m.changed(Binding{Consumer: consumer, Generation: was.Generation, Why: c.Why, By: c.By}, was.Licence)
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
}
@@ -186,12 +186,11 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges
if len(bs) != 2 || mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
t.Fatalf("bindings %v, state %v", bs, mm.bindings.values)
}
// The adoption is said, and so is each binding it made (novox/hq issue 625).
// The adoption is said, and so is each binding it made, once (novox/hq issue 625; design 39 bus-and-switch §21).
events := strings.Join(mm.events, "|")
for _, want := range []string{"licence.adopted ", `licence.bound {"by":"claude-licence-manager","consumer":"laptop"`,
`licence.bound {"by":"claude-licence-manager","consumer":"server"`} {
if !strings.Contains(events, want) {
t.Fatalf("events lack %s: %v", want, mm.events)
for _, want := range []string{"licence.adopted ", `"consumer":"laptop"`, `"consumer":"server"`} {
if strings.Count(events, want) != 1 {
t.Fatalf("events say %s other than once: %v", want, mm.events)
}
}
}
@@ -522,27 +521,62 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) {
}
}
// changedSince is the binding.changed events said from mark on, decoded.
func (mm *miniMesh) changedSince(mark int) []map[string]any {
var out []map[string]any
for _, e := range mm.events[mark:] {
if body, ok := strings.CutPrefix(e, "binding.changed "); ok {
var v map[string]any
_ = json.Unmarshal([]byte(body), &v)
out = append(out, v)
}
}
return out
}
// A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release.
func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release. design 39
// bus-and-switch §21/AC1, §36/AC1, B7, B16: each says one binding.changed per consumer moved, with its licence's key,
// after its key is written or deleted; a switch says one licence.switched with §27's fields.
func TestEveryMoveSaysOneBindingChangedPerConsumer(t *testing.T) {
mm := newMesh(t)
var lines []string
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
ctx := context.Background()
once := func(mark int, consumer, licence, by string) map[string]any {
t.Helper()
got := mm.changedSince(mark)
if len(got) != 1 || got[0]["consumer"] != consumer || got[0]["licence"] != licence || got[0]["by"] != by {
t.Fatalf("binding.changed said %v, want one for %s to %q by %s", got, consumer, licence, by)
}
if licence != "" && got[0]["key"] != KeyFor(licence) {
t.Fatalf("binding.changed names the key %v", got[0]["key"])
}
if mm.bound(consumer).Licence != licence {
t.Fatalf("said before the key was written: %+v", mm.bound(consumer))
}
return got[0]
}
mark := len(mm.events)
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
if s := mm.bound("laptop"); !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
t.Fatalf("a login's binding said %+v", s)
}
once(mark, "laptop", licence1, Itself)
file := filepath.Join(t.TempDir(), "key")
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
t.Fatal(err)
}
mark = len(mm.events)
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
t.Fatal(err)
}
if e := once(mark, "laptop", "api", "g14.node-tools"); e["from"] != licence1 || e["kind"] != "api-key" || e["at"] == nil {
t.Fatalf("the switch said %v", e)
}
if s := mm.bound("laptop"); s.Why != "the switch verb" || s.By != "g14.node-tools" {
t.Fatalf("the node is told %+v", s)
}
@@ -551,35 +585,46 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
t.Fatalf("the journal said:\n%s", said)
}
var switched string
var switched []string
for _, e := range mm.events {
if strings.HasPrefix(e, "licence.switched ") {
switched = e
switched = append(switched, e)
}
}
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
if !strings.Contains(switched, want) {
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
if len(switched) != 1 {
t.Fatalf("licence.switched said %d times: %v", len(switched), mm.events)
}
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"to":"api"`, `"consumers":["laptop"]`, `"why":"the switch verb"`} {
if !strings.Contains(switched[0], want) {
t.Fatalf("the event %q lacks %s", switched[0], want)
}
}
all := mm.audit.under("")
last := all[len(all)-1]
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
all := mm.audit.under("switched")
if raw, _ := json.Marshal(all[len(all)-1]); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), `"to":"api"`) {
t.Fatalf("the audit kept %s", raw)
}
_, _ = mm.m.Bind(ctx, "server", licence1)
mark = len(mm.events)
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) {
t.Fatalf("a rotation asked for said no caller: %v", mm.events)
}
once(mark, "server", licence1, "novox.node-tools")
mark = len(mm.events)
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil {
t.Fatal(err)
}
if e := once(mark, "laptop", "", "person.operator"); e["from"] != "api" {
t.Fatalf("the release said %v", e)
}
if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") {
t.Fatalf("the journal said:\n%s", said)
}
for _, e := range mm.events {
for _, gone := range []string{"licence.bound ", "licence.released ", "licence.rotated "} {
if strings.HasPrefix(e, gone) {
t.Fatalf("an event no longer said was said: %s", e)
}
}
if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") {
t.Fatalf("an event carries a secret: %s", e)
}