diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go index 6a8fe1db3..a38ff6fbc 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "sort" "time" @@ -43,8 +44,9 @@ type BusStore struct { BindingsState keptState LeasesState keptState OfferedState keptState + UsageLog keptLog + AuditLog keptLog Now func() time.Time - rest } func (s *BusStore) get(st keptState, key string, into any) (bool, error) { @@ -249,3 +251,118 @@ func (s *BusStore) putOffered(fingerprint string, v offeredValue, ttl time.Durat _, err := s.OfferedState.PutTTL(OfferedKey(fingerprint), v, ttl) return err } + +// ---- the logs (§6, §7) ------------------------------------------------------------------------------------------ + +// keptLog is a log this module keeps, as mesh-sdk's KeptLog answers it. +type keptLog interface { + Append(key string, value any) (uint64, error) + Read(from uint64, keys string, limit int) (*stdio.LogPage, error) + Last(key string) (*stdio.LogEntry, error) + Info() (*stdio.LogInfo, error) +} + +// UsageEntry is one entry of the log `usage` (§6): when, the flattened reading and the vendor's answer whole (ADR 0054). +type UsageEntry struct { + At string `json:"at"` + Reading UsageReading `json:"reading"` + Raw map[string]any `json:"raw,omitempty"` +} + +// AuditKinds are the subjects of the log `audit` (§7); an act of another kind is kept under `drift`. +var AuditKinds = []string{"adopted", "refused", "failed", "rotated", "bound", "switched", "released", "drift", "carried", + "settings"} + +func (s *BusStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, raw map[string]any) error { + _, err := s.UsageLog.Append(KeyFor(licence), UsageEntry{At: time.UnixMilli(at).UTC().Format(time.RFC3339Nano), + Reading: r, Raw: raw}) + return err +} + +// Usage is the latest readings, newest first, of one licence or every one. +func (s *BusStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) { + names := map[string]string{} + all, err := s.Licences(ctx) + if err != nil { + return nil, err + } + for _, l := range all { + names[KeyFor(l.Name)] = l.Name + } + keys := "" + if licence != "" { + keys = KeyFor(licence) + } + entries, err := s.newest(s.UsageLog, keys, limit) + if err != nil { + return nil, err + } + out := []UsageRow{} + for _, e := range entries { + var u UsageEntry + if json.Unmarshal(e.Value, &u) != nil { + continue + } + at, _ := time.Parse(time.RFC3339Nano, u.At) + name := names[e.Key] + if name == "" { + name = e.Key + } + out = append(out, UsageRow{Licence: name, At: at.UnixMilli(), Reading: u.Reading}) + } + return out, nil +} + +// newest is up to limit entries of keys, newest first: read back from the log's last sequence, a window that doubles +// until it holds limit entries or reaches the first. +func (s *BusStore) newest(l keptLog, keys string, limit int) ([]stdio.LogEntry, error) { + info, err := l.Info() + if err != nil || info.Last == 0 { + return nil, err + } + for span := uint64(max(limit, 1)); ; span *= 2 { + from := uint64(1) + if info.Last > span { + from = info.Last - span + 1 + } + var got []stdio.LogEntry + for next := from; next != 0; { + page, err := l.Read(next, keys, 0) + if err != nil { + return nil, err + } + got = append(got, page.Entries...) + next = page.Next + } + if len(got) >= limit || from <= max(info.First, 1) { + if len(got) > limit { + got = got[len(got)-limit:] + } + for i, j := 0, len(got)-1; i < j; i, j = i+1, j-1 { + got[i], got[j] = got[j], got[i] + } + return got, nil + } + } +} + +// Audit appends one entry under its kind, with when; a log that does not take it fails the act (B20). +func (s *BusStore) Audit(_ context.Context, what string, detail map[string]any) error { + kind := "drift" + for _, k := range AuditKinds { + if k == what { + kind = k + } + } + entry := map[string]any{"at": s.Now().UTC().Format(time.RFC3339Nano)} + if kind != what { + entry["what"] = what + } + for k, v := range detail { + entry[k] = v + } + if _, err := s.AuditLog.Append(kind, entry); err != nil { + return fmt.Errorf("the audit did not take the %s entry, so the act is not done: %w", what, err) + } + return nil +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go index 51ba4c4f4..54fbce2ed 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "sort" "strings" "sync" @@ -213,3 +214,128 @@ func TestALeaseAndAnOfferAreKeptByTheirKeysAndLifetimes(t *testing.T) { t.Fatalf("the outcome lives until %v", u) } } + +// memLog is a log of the bus as the runtime answers it, in memory; full, it refuses an entry. +type memLog struct { + mu sync.Mutex + now func() time.Time + entries []stdio.LogEntry + full bool +} + +func newMemLog(now func() time.Time) *memLog { return &memLog{now: now} } + +func (l *memLog) Append(key string, value any) (uint64, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.full { + return 0, errors.New("the log is full: maximum bytes exceeded") + } + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + seq := uint64(len(l.entries) + 1) + l.entries = append(l.entries, stdio.LogEntry{Sequence: seq, Key: key, Value: raw, At: l.now()}) + return seq, nil +} + +func (l *memLog) Read(from uint64, keys string, limit int) (*stdio.LogPage, error) { + l.mu.Lock() + defer l.mu.Unlock() + p := &stdio.LogPage{Entries: []stdio.LogEntry{}, Last: uint64(len(l.entries))} + for _, e := range l.entries { + if e.Sequence >= from && (keys == "" || e.Key == keys) { + if limit > 0 && len(p.Entries) == limit { + p.Next = e.Sequence + break + } + p.Entries = append(p.Entries, e) + } + } + return p, nil +} + +func (l *memLog) Last(key string) (*stdio.LogEntry, error) { + l.mu.Lock() + defer l.mu.Unlock() + for i := len(l.entries) - 1; i >= 0; i-- { + if l.entries[i].Key == key { + e := l.entries[i] + return &e, nil + } + } + return nil, nil +} + +func (l *memLog) Info() (*stdio.LogInfo, error) { + l.mu.Lock() + defer l.mu.Unlock() + i := &stdio.LogInfo{Entries: uint64(len(l.entries)), Last: uint64(len(l.entries))} + if len(l.entries) > 0 { + i.First = 1 + } + return i, nil +} + +// under is every entry of a log's subject, decoded. +func (l *memLog) under(key string) []map[string]any { + l.mu.Lock() + defer l.mu.Unlock() + var out []map[string]any + for _, e := range l.entries { + if key == "" || e.Key == key { + var v map[string]any + _ = json.Unmarshal(e.Value, &v) + v["what"] = e.Key + out = append(out, v) + } + } + return out +} + +// design 39 bus-and-switch §6: a reading appends one entry under its licence's key, the reading beside the raw answer. +func TestAReadingAppendsOneUsageEntry(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatal(err) + } + got := mm.usage.under(KeyFor(licence1)) + if len(got) != 1 || len(mm.usage.entries) != 1 { + t.Fatalf("a reading appended %v", mm.usage.entries) + } + if got[0]["raw"] == nil || got[0]["reading"] == nil || got[0]["at"] == nil { + t.Fatalf("the entry %v", got[0]) + } + rows, _ := mm.store.Usage(ctx, licence1, 20) + if len(rows) != 1 || rows[0].Licence != licence1 || rows[0].Reading.SessionPct == nil { + t.Fatalf("usage answers %+v", rows) + } +} + +// design 39 bus-and-switch §7, B20: each act appends one entry under its kind; a full log fails the act loudly. +func TestEachActAppendsOneAuditEntry(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + _, _ = mm.m.Rotate(ctx, licence1, true) + _ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1}) + _, _ = mm.m.Bind(ctx, "laptop", "api") + _, _ = mm.m.Release(ctx, "laptop") + for kind, n := range map[string]int{"adopted": 1, "bound": 1, "rotated": 1, "switched": 1, "released": 1} { + if got := len(mm.audit.under(kind)); got != n { + t.Errorf("%s: %d entries, want %d", kind, got, n) + } + } + for _, e := range mm.audit.under("") { + if e["at"] == nil || e["by"] == nil && e["what"] != "adopted" { + t.Errorf("the entry %v has no at or by", e) + } + } + mm.audit.full = true + if _, err := mm.m.Bind(ctx, "laptop", "api"); err == nil || !strings.Contains(err.Error(), "the audit did not take") { + t.Fatalf("a bind with a full audit log: %v", err) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go index 573ff76d9..205a8f35f 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go @@ -63,7 +63,7 @@ func assertRefused(t *testing.T, mm *miniMesh) { said = said || strings.HasPrefix(e, "licence.refused ") } audited := false - for _, a := range mm.mem.Audits { + for _, a := range mm.audit.under("") { audited = audited || a["what"] == "refused" } if !said || !audited { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index b1f255b1b..839899692 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -63,17 +63,14 @@ func manager() (*Manager, error) { if err != nil { return nil, err } - store, err := PgStoreFromEnv(context.Background()) - if err != nil { - return nil, err - } node, _ := os.Hostname() if n := os.Getenv("MESH_NODE"); n != "" { node = n } built = &Manager{ Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"), - LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), Now: time.Now, rest: store}, + LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), UsageLog: stdio.Log("usage"), + AuditLog: stdio.Log("audit"), Now: time.Now}, Vendor: LiveVendor(), Crypt: crypt, Grants: VaultGrants{Keys: keys}, diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index ed8460209..eea1ef527 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -81,19 +81,23 @@ func CauseOf(ctx context.Context) Cause { // 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.` on the bus. Never a token. -func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) { - _ = m.Store.Audit(ctx, what, detail) +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) { +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) } - m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By), + 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}) } @@ -299,7 +303,9 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor f if err != nil { return err } - m.moved(ctx, rep.Node, "", l.Name, b.Generation, c) + if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, c); err != nil { + return err + } } return nil } @@ -339,7 +345,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, if taken != "" { why := "its key " + KeyFor(NameFor(c.Identity)) + " is the key of the licence " + taken _ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, why) - _ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why}) + if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why}); err != nil { + return "", err + } _ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": why}) return "", nil } @@ -350,7 +358,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, r := m.Vendor.Refresh(ctx, offered) if !r.OK { _ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Dead, fmt.Sprintf("%d %s", r.Status, r.Reason)) - _ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason}) + if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason}); err != nil { + return "", err + } _ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": fmt.Sprintf("the login's refresh token did not refresh (%d)", r.Status)}) m.Log("%s's login for %s did not refresh: %d %s", c.Node, NameFor(c.Identity), r.Status, r.Reason) @@ -360,7 +370,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, if r.Account != "" && r.Account != c.Identity.AccountUUID { _ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, "the node says "+c.Identity.AccountUUID+", the vendor says "+r.Account) - _ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account}) + if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account}); err != nil { + return "", err + } _ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": "the account the node reported is not the one the vendor answered for"}) return "", nil @@ -394,8 +406,10 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, why = "a newer login" } _ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Adopted, why) - _ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID, - "vendorNamedAccount": r.Account != ""}) + if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID, + "vendorNamedAccount": r.Account != ""}); err != nil { + return "", err + } // A login moves its node (ADR 0209): the node this login came from is bound to its licence — // switched, if it was bound to another. Then every node reporting this account and bound to nothing. @@ -413,7 +427,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, if b != nil { from = b.Licence } - m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause) + if err := m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause); err != nil { + return "", err + } } for _, rep := range reports { if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID { @@ -424,7 +440,9 @@ 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 { - m.moved(ctx, rep.Node, "", l.Name, b.Generation, bc) + if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, bc); err != nil { + return "", err + } } } if err := m.publishAdvance(ctx, l, floor); err != nil { @@ -553,7 +571,9 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri if !r.OK { l.Failures++ m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason) - _ = m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}) + if err := m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}); err != nil { + return nil, err + } m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify) if err := m.Store.SaveLicence(ctx, *l); err != nil { return nil, err @@ -581,9 +601,11 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri } } ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By}) - m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name, + if err := m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name, time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By), - map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By}) + map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By}); err != nil { + return nil, err + } if err := m.publishAdvance(ctx, *l, m.floorIn(nil)); err != nil { return nil, err } @@ -732,7 +754,9 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin if before != nil { from = before.Licence } - m.moved(ctx, consumer, from, licence, b.Generation, c) + if err := m.moved(ctx, consumer, from, licence, b.Generation, c); err != nil { + return nil, err + } return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil } @@ -746,8 +770,10 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any, return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err } c := CauseOf(ctx) - m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By), - map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}) + if err := m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By), + map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}); err != nil { + return nil, err + } return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil } @@ -800,7 +826,9 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str } if taken != "" { why := "its key " + KeyFor(name) + " is the key of the licence " + taken - _ = m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why}) + if err := m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why}); err != nil { + return nil, err + } _ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why}) return nil, fmt.Errorf("%s is refused: %s", name, why) } @@ -815,7 +843,9 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str return nil, err } c := CauseOf(ctx) - _ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By}) + if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By}); err != nil { + return nil, err + } m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By) ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By}) if err := m.publishAdvance(ctx, l, m.floorIn(nil)); err != nil { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index 37389aa9d..87a501ed5 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -85,17 +85,18 @@ type miniMesh struct { grants *memGrants m *Manager store *BusStore - mem *MemoryStore + usage *memLog + audit *memLog licences *memState bindings *memState leases *memState offered *memState - vendor *stubVendor - logins map[string]FullGrant // node → what its credentials file holds - events []string - now time.Time - keys KeyPair - askDown bool + vendor *stubVendor + logins map[string]FullGrant // node → what its credentials file holds + events []string + now time.Time + keys KeyPair + askDown bool } func newMesh(t *testing.T) *miniMesh { @@ -103,11 +104,11 @@ func newMesh(t *testing.T) *miniMesh { mm := &miniMesh{logins: map[string]FullGrant{}, now: t0, grants: &memGrants{held: map[string]heldGrant{}}} now := func() time.Time { return mm.now } - mm.mem = NewMemoryStore(now) + mm.usage, mm.audit = newMemLog(now), newMemLog(now) mm.licences, mm.bindings = newMemState(now), newMemState(now) mm.leases, mm.offered = newMemState(now), newMemState(now) mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, LeasesState: mm.leases, - OfferedState: mm.offered, Now: now, rest: mm.mem} + OfferedState: mm.offered, UsageLog: mm.usage, AuditLog: mm.audit, Now: now} mm.vendor = &stubVendor{live: map[string]bool{}, now: now} crypt, _ := NewCrypt("a key the vault made") mm.keys, _ = GenerateKeyPair() @@ -559,7 +560,8 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) { t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events) } } - last := mm.mem.Audits[len(mm.mem.Audits)-1] + 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") { t.Fatalf("the audit kept %s", raw) } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go index 744fa0452..774a8c44c 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go @@ -1,13 +1,11 @@ package main -// The manager's store (novox/hq ADR 0183, design 39 §1): licences with their grants encrypted, bindings -// with a generation, what became of each login it was offered, usage readings, and the audit. One -// interface, two implementations — postgres for the mesh (pgstore.go), memory for the tests — so every -// rule is tested without a database, and the database is asked only to keep rows. +// The manager's store (novox/hq ADR 0183, design 39 bus-and-switch §2 to §7): licences, bindings with a generation, +// leases, what became of each login it was offered, usage readings and the audit, all in the bus's own storage +// (busstore.go). Every rule is tested on the same store over a bus kept in memory. import ( "context" - "sync" "time" ) @@ -96,63 +94,8 @@ type Store interface { Unlease(ctx context.Context, key, holder string) error Outcome(ctx context.Context, fingerprint string) (Outcome, error) RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error - rest -} - -// rest is what the store keeps beside its licences and bindings. -type rest interface { RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) + // Audit appends one entry for an act; an error is the act failing (B20). Audit(ctx context.Context, what string, detail map[string]any) error - Close() } - -// MemoryStore is the tests' store of what the bus store does not keep yet. -type MemoryStore struct { - mu sync.Mutex - now func() time.Time - leases map[string]struct { - holder string - until time.Time - } - outcomes map[string]Outcome - usage []UsageRow - Audits []map[string]any -} - -// NewMemoryStore is an empty store whose leases age by now. -func NewMemoryStore(now func() time.Time) *MemoryStore { - return &MemoryStore{now: now} -} - -func (m *MemoryStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, _ map[string]any) error { - m.mu.Lock() - defer m.mu.Unlock() - m.usage = append(m.usage, UsageRow{Licence: licence, At: at, Reading: r}) - return nil -} - -func (m *MemoryStore) Usage(_ context.Context, licence string, limit int) ([]UsageRow, error) { - m.mu.Lock() - defer m.mu.Unlock() - var out []UsageRow - for i := len(m.usage) - 1; i >= 0 && len(out) < limit; i-- { - if licence == "" || m.usage[i].Licence == licence { - out = append(out, m.usage[i]) - } - } - return out, nil -} - -func (m *MemoryStore) Audit(_ context.Context, what string, detail map[string]any) error { - m.mu.Lock() - defer m.mu.Unlock() - d := map[string]any{"what": what} - for k, v := range detail { - d[k] = v - } - m.Audits = append(m.Audits, d) - return nil -} - -func (m *MemoryStore) Close() {}