From 9770dab09ea09e7bf27beccd4abd170622b9f33a Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:17:16 +0100 Subject: [PATCH] M-4: leases and offered grants kept in the bus's own storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lease is a key created with a two-minute lifetime, which fails while another holds it: the bus's own compare-and-set keeps one rotation source (ADR 0183 §6). An offered grant's outcome is kept under its fingerprint in the key form for 90 days, every outcome kept. Builds: design 39 bus-and-switch §4, §5, B13 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/busstore.go | 71 +++++++++++++++++++ .../claude-licence-manager/busstore_test.go | 41 ++++++++++- .../cmd/claude-licence-manager/main.go | 4 +- .../cmd/claude-licence-manager/manager.go | 4 +- .../claude-licence-manager/manager_test.go | 6 +- .../cmd/claude-licence-manager/store.go | 51 ++----------- 6 files changed, 125 insertions(+), 52 deletions(-) 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 079592c88..6a8fe1db3 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go @@ -41,6 +41,8 @@ type BindingState struct { type BusStore struct { LicencesState keptState BindingsState keptState + LeasesState keptState + OfferedState keptState Now func() time.Time rest } @@ -178,3 +180,72 @@ func (s *BusStore) Advance(ctx context.Context, licence string, m Move) ([]Bindi } return out, nil } + +// ---- leases and offers (§4, §5) --------------------------------------------------------------------------------- + +// leaseValue is a lease's key in `leases`: its holder and until when. +type leaseValue struct { + Holder string `json:"holder"` + Until string `json:"until"` +} + +// Lease takes a lease by creating its key with a lifetime of d (§4): a create fails while another holds it, the bus's +// own compare-and-set. A key its own holder holds is taken again. +func (s *BusStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) { + v := leaseValue{Holder: holder, Until: s.Now().Add(d).UTC().Format(time.RFC3339)} + _, err := s.LeasesState.CreateTTL(key, v, d) + if err == nil { + return true, nil + } + if !errors.Is(err, stdio.ErrChanged) { + return false, err + } + var held leaseValue + if ok, gerr := s.get(s.LeasesState, key, &held); gerr != nil || !ok || held.Holder != holder { + return false, gerr + } + _, err = s.LeasesState.PutTTL(key, v, d) + return err == nil, err +} + +// Unlease deletes a lease its holder holds; another's is left to it or its lifetime. +func (s *BusStore) Unlease(_ context.Context, key, holder string) error { + var held leaseValue + if ok, err := s.get(s.LeasesState, key, &held); err != nil || !ok || held.Holder != holder { + return err + } + return s.LeasesState.Delete(key) +} + +// offeredFor is how long the outcome of an offered grant is kept (§5): longer than a refresh token lives. +const offeredFor = 90 * 24 * time.Hour + +// offeredValue is a grant's key in `offered`: what became of it, where and when (§5, B13: every outcome). +type offeredValue struct { + Node string `json:"node"` + Account string `json:"account"` + Outcome Outcome `json:"outcome"` + Why string `json:"why"` + At string `json:"at"` +} + +// OfferedKey is a fingerprint's key in `offered`: through the licence key form (B13). +func OfferedKey(fingerprint string) string { return KeyFor(fingerprint) } + +func (s *BusStore) Outcome(_ context.Context, fingerprint string) (Outcome, error) { + var v offeredValue + if ok, err := s.get(s.OfferedState, OfferedKey(fingerprint), &v); err != nil || !ok { + return "", err + } + return v.Outcome, nil +} + +func (s *BusStore) RecordOutcome(_ context.Context, fingerprint, node, account string, o Outcome, why string) error { + return s.putOffered(fingerprint, offeredValue{Node: node, Account: account, Outcome: o, Why: why, + At: s.Now().UTC().Format(time.RFC3339)}, offeredFor) +} + +func (s *BusStore) putOffered(fingerprint string, v offeredValue, ttl time.Duration) error { + _, err := s.OfferedState.PutTTL(OfferedKey(fingerprint), v, ttl) + return err +} 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 b6875fb61..51ba4c4f4 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,7 +3,6 @@ package main import ( "context" "encoding/json" - "errors" "sort" "strings" "sync" @@ -51,7 +50,7 @@ func (s *memState) write(key string, value any, ttl time.Duration, create bool) s.mu.Lock() defer s.mu.Unlock() if create && s.live(key) { - return 0, errors.New("the key has a value") + return 0, stdio.ErrChanged } raw, err := json.Marshal(value) if err != nil { @@ -176,3 +175,41 @@ func TestEachMoveWritesTheBindingWithWhyAndBy(t *testing.T) { t.Fatalf("server's history %v", h) } } + +// design 39 bus-and-switch §4, §5, B13: a lease is a key created with a two-minute lifetime that another holder cannot +// create; an offered grant's outcome is kept under its fingerprint in the key form for 90 days. +func TestALeaseAndAnOfferAreKeptByTheirKeysAndLifetimes(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + key := "licence." + KeyFor(licence1) + if ok, err := mm.store.Lease(ctx, key, "one", leaseFor); !ok || err != nil { + t.Fatalf("the first lease: %v %v", ok, err) + } + if ok, _ := mm.store.Lease(ctx, key, "two", leaseFor); ok { + t.Fatal("a second holder took a live lease") + } + if u := mm.leases.until[key]; !u.Equal(mm.now.Add(2 * time.Minute)) { + t.Fatalf("the lease lives until %v", u) + } + mm.now = mm.now.Add(2 * time.Minute) + if ok, _ := mm.store.Lease(ctx, key, "two", leaseFor); !ok { + t.Fatal("a lease past its lifetime was not taken") + } + _ = mm.store.Unlease(ctx, key, "one") + if _, held := mm.leases.values[key]; !held { + t.Fatal("another holder's lease was ended") + } + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-dead", false, t0)}) + fp := OfferedKey(Fingerprint("rt-dead")) + if strings.ContainsAny(fp, ":") || !strings.HasPrefix(fp, "sha256_") { + t.Fatalf("the offered key %q is not in the key form", fp) + } + var v offeredValue + _ = json.Unmarshal(mm.offered.values[fp], &v) + if v.Outcome != Dead || v.Node != "laptop" || v.Account != "acct-1" || v.Why == "" || v.At == "" { + t.Fatalf("offered %+v", v) + } + if u := mm.offered.until[fp]; !u.Equal(mm.now.Add(90 * 24 * time.Hour)) { + t.Fatalf("the outcome lives until %v", u) + } +} 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 49402da9b..b1f255b1b 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -72,8 +72,8 @@ func manager() (*Manager, error) { node = n } built = &Manager{ - Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"), Now: time.Now, - rest: store}, + Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"), + LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), Now: time.Now, rest: store}, 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 8bf20922d..ed8460209 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -251,7 +251,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e var adopted []string for _, account := range accounts { list := byAccount[account] - key := "account:" + account + key := "account." + account ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor) if err != nil || !ok { continue @@ -521,7 +521,7 @@ func Due(l Licence, now time.Time, s Settings) bool { // lease live and does nothing; one started just after finds a fresh grant and is not due. force refreshes // whatever the age — the seat's `refresh` verb. func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[string]any, error) { - key := "licence:" + name + key := "licence." + KeyFor(name) ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor) if err != nil { return nil, err 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 a9be95903..37389aa9d 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 @@ -88,6 +88,8 @@ type miniMesh struct { mem *MemoryStore licences *memState bindings *memState + leases *memState + offered *memState vendor *stubVendor logins map[string]FullGrant // node → what its credentials file holds events []string @@ -103,7 +105,9 @@ func newMesh(t *testing.T) *miniMesh { now := func() time.Time { return mm.now } mm.mem = NewMemoryStore(now) mm.licences, mm.bindings = newMemState(now), newMemState(now) - mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, Now: now, rest: mm.mem} + 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} mm.vendor = &stubVendor{live: map[string]bool{}, now: now} crypt, _ := NewCrypt("a key the vault made") mm.keys, _ = GenerateKeyPair() 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 be18e0f78..744fa0452 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go @@ -91,16 +91,16 @@ type Store interface { Unbind(ctx context.Context, consumer string) (bool, error) // Advance gives every consumer of a licence its next generation: what a rotation is to them. Advance(ctx context.Context, licence string, m Move) ([]Binding, error) - rest -} - -// rest is what the store keeps beside its licences and bindings. -type rest interface { // Lease takes a lease for d, or answers false while another holder's is live. Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) 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(ctx context.Context, what string, detail map[string]any) error @@ -122,46 +122,7 @@ type MemoryStore struct { // NewMemoryStore is an empty store whose leases age by now. func NewMemoryStore(now func() time.Time) *MemoryStore { - return &MemoryStore{now: now, - leases: map[string]struct { - holder string - until time.Time - }{}, outcomes: map[string]Outcome{}} -} - -func (m *MemoryStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) { - m.mu.Lock() - defer m.mu.Unlock() - if held, ok := m.leases[key]; ok && held.until.After(m.now()) && held.holder != holder { - return false, nil - } - m.leases[key] = struct { - holder string - until time.Time - }{holder, m.now().Add(d)} - return true, nil -} - -func (m *MemoryStore) Unlease(_ context.Context, key, holder string) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.leases[key].holder == holder { - delete(m.leases, key) - } - return nil -} - -func (m *MemoryStore) Outcome(_ context.Context, fp string) (Outcome, error) { - m.mu.Lock() - defer m.mu.Unlock() - return m.outcomes[fp], nil -} - -func (m *MemoryStore) RecordOutcome(_ context.Context, fp, _, _ string, o Outcome, _ string) error { - m.mu.Lock() - defer m.mu.Unlock() - m.outcomes[fp] = o - return nil + return &MemoryStore{now: now} } func (m *MemoryStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, _ map[string]any) error {