M-4: leases and offered grants kept in the bus's own storage

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
This commit is contained in:
2026-11-11 23:17:16 +01:00
parent 15094d6e72
commit 9770dab09e
6 changed files with 125 additions and 52 deletions
@@ -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
}
@@ -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)
}
}
@@ -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},
@@ -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
@@ -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()
@@ -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 {