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