From af63f121290093f65b8a8e0ac548d198ed81ccb2 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 12:34:39 +0200 Subject: [PATCH] The licence manager binds a node reporting an account it already holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found going live: the other nodes report the adopted account with older logins, which are never candidates, and the first binding was only made at adoption — so a node reporting afterwards was never bound (ADR 0206 §7). --- .../cmd/claude-licence-manager/manager.go | 38 +++++++++++++++++++ .../claude-licence-manager/manager_test.go | 21 ++++++++++ 2 files changed, 59 insertions(+) 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 b6ffb76..f057b7c 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -198,6 +198,12 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e accounts = append(accounts, a) } sort.Strings(accounts) + // A node reporting an account the manager already holds, and bound to nothing, is bound to it + // (ADR 0206 §7) — whenever its report arrives, not only when the licence is adopted: a node whose own + // login is older than the one adopted is never a candidate, and would otherwise never be bound. + if err := m.bindReporters(ctx, reports); err != nil { + return nil, err + } var adopted []string for _, account := range accounts { list := byAccount[account] @@ -225,6 +231,38 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e return adopted, nil } +// bindReporters binds each reporting node that is bound to nothing to the licence its account already has. +func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error { + for _, rep := range reports { + if rep.Identity == nil || rep.Identity.AccountUUID == "" { + continue + } + if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil { + if err != nil { + return err + } + continue + } + l, err := m.Store.LicenceForAccount(ctx, rep.Identity.AccountUUID) + if err != nil { + return err + } + if l == nil { + continue + } + b, err := m.Store.Bind(ctx, rep.Node, l.Name) + if err != nil { + return err + } + _ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its account's report"}) + if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation}); err != nil { + return err + } + m.Log("bound %s to %s, the licence its account already has", rep.Node, l.Name) + } + return nil +} + func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) (string, error) { answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey) if 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 f269c33..026a583 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 @@ -336,3 +336,24 @@ func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) { t.Fatalf("the report's time does not parse: %v", err) } } + +// A node whose report arrives after its account was adopted — with an older login, so never a candidate — +// is still bound to that account's licence, once. +func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)}) + late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour)) + _, _ = mm.m.Consider(ctx, []Holdings{late}) + if mm.state["laptop"].Licence != licence1 { + t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.state) + } + if strings.Contains(strings.Join(mm.vendor.exchanged, ","), "rt-older") { + t.Fatal("the older login was exchanged") + } + g := mm.state["laptop"].Generation + _, _ = mm.m.Consider(ctx, []Holdings{late}) + if mm.state["laptop"].Generation != g { + t.Fatal("a node already bound was bound again") + } +}