From b91b4c427daa1e0973b07a7ed603efde84a88ba4 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 5 Oct 2026 09:59:19 +0200 Subject: [PATCH] Apply a binding that differs, and never repeat a generation a node passed A licence store rebuilt after issue 241 counted generations from one again, and nodes that apply only a higher number discarded the login move and the rotations unseen. Nodes now apply any binding other than the one applied; the manager moves its sequence past every generation a node reports. hq issue 243. --- .../cmd/claude-code/claude_code_test.go | 28 +++++++++++++++++++ modules/claude-code/cmd/claude-code/node.go | 11 ++++++-- .../cmd/claude-licence-manager/manager.go | 13 +++++++++ .../claude-licence-manager/manager_test.go | 15 ++++++++++ .../cmd/claude-licence-manager/pgstore.go | 10 +++++++ .../cmd/claude-licence-manager/store.go | 9 ++++++ 6 files changed, 83 insertions(+), 3 deletions(-) diff --git a/modules/claude-code/cmd/claude-code/claude_code_test.go b/modules/claude-code/cmd/claude-code/claude_code_test.go index 54a58c1..d5cc035 100644 --- a/modules/claude-code/cmd/claude-code/claude_code_test.go +++ b/modules/claude-code/cmd/claude-code/claude_code_test.go @@ -433,3 +433,31 @@ func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) { t.Fatalf("the key crossed in the clear: %v", sent) } } + +// A manager whose store was rebuilt counts generations from one again (novox/hq issue 243): a binding with a +// lower generation than the one applied is still a binding to apply, and only the one applied is skipped. +func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) { + p, w := node(t, "laptop") + var asked []string + if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 16}, + seat(t, "personal", "at-old", 16, &asked), writer(w)); err != nil { + t.Fatal(err) + } + // The rotation that came with it: a later token, as a refresh hands one over. + later := func(address string, args any) (json.RawMessage, error) { + asked = append(asked, address) + g, _ := json.Marshal(Grant{AccessToken: "at-new", ExpiresAt: now + 7_200_000}) + box, err := Seal(string(g), args.(map[string]any)["public_key"].(string)) + if err != nil { + t.Fatal(err) + } + return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 3, Sealed: &box}) + } + if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, + later, writer(w)); err != nil { + t.Fatal(err) + } + if len(asked) != 2 || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 3 { + t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p)) + } +} diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index 98cfae2..88647b1 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -255,14 +255,19 @@ func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) { } // OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is -// fetched when the generation is newer than the one applied. A released binding keeps the last token, -// which lives hours, and says so. +// fetched when the binding differs from the one applied. A released binding keeps the last token, which +// lives hours, and says so. +// +// **Differs, not "is newer"** (novox/hq issue 243). The state keeps only the latest value per node, so +// nothing older can arrive. A manager whose store was rebuilt counts generations from one again, and +// a node that waited for a number above its own ignored every binding it was sent, its login and the +// licence's rotations included, until the count caught up. Only the binding already applied is skipped. func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) { if b == nil { return "this node's binding was released; it keeps its last token until it expires", nil } var applied Binding - if readJSON(p.binding(), &applied) && applied.Generation >= b.Generation { + if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence { return "", nil } out, err := Pull(p, ask, write) 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 62eeac8..f59a7c3 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -49,6 +49,8 @@ type Holdings struct { Fingerprint string `json:"fingerprint"` } `json:"refresh"` ChangedAt string `json:"changedAt"` + // Generation is the binding generation the node last applied (novox/hq issue 243). + Generation int64 `json:"generation"` } // BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which @@ -189,6 +191,17 @@ func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[str // newest first; the first that refreshes is adopted and the rest are settled as skipped without being // exchanged. Answers what was adopted. func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) { + // **Never a generation a node has already passed** (novox/hq issue 243). A store rebuilt after a loss + // counts from one again, while every node still holds the number it last applied. The nodes no longer + // wait for a higher number, but a binding numbered exactly as the one a node applied would still be + // skipped. So the count is moved past every number reported, before anything here binds. + var highest int64 + for _, r := range reports { + highest = max(highest, r.Generation) + } + if err := m.Store.GenerationsAbove(ctx, highest); err != nil { + return nil, err + } byAccount, err := m.CandidatesIn(ctx, reports) 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 d4bf42f..5157d7d 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 @@ -423,3 +423,18 @@ func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) { } } } + +// A store rebuilt after a loss counts generations from one again (novox/hq issue 243): the first look at the +// reports moves the count past every generation a node says it applied, so no binding repeats one. +func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + laptop := mm.login("laptop", "rt-a", true, t0) + laptop.Generation = 16 + if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil { + t.Fatal(err) + } + if g := mm.state["laptop"].Generation; g <= 16 { + t.Fatalf("the laptop applied generation 16 and was given %d", g) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go b/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go index dee0b97..06e1bd1 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go @@ -225,6 +225,16 @@ func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) { return err == nil && tag.RowsAffected() == 1, err } +func (s *PgStore) GenerationsAbove(ctx context.Context, generation int64) error { + if generation <= 0 { + return nil + } + // setval with is_called, so the next nextval is generation+1; only ever forward. + _, err := s.pool.Exec(ctx, `select setval('binding_generation', $1, true) from binding_generation + where not is_called or last_value < $1`, generation) + return err +} + func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) { return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1 returning consumer, licence, generation`, licence) 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 2ff09da..b21a3ea 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go @@ -70,6 +70,8 @@ type Store interface { Unbind(ctx context.Context, consumer string) (bool, error) // Advance gives every consumer of a licence a new generation: what a rotation is to them. Advance(ctx context.Context, licence string) ([]Binding, error) + // GenerationsAbove makes every generation given from now on greater than this one. + GenerationsAbove(ctx context.Context, generation int64) error Outcome(ctx context.Context, fingerprint string) (Outcome, error) RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error @@ -200,6 +202,13 @@ func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) { return ok, nil } +func (m *MemoryStore) GenerationsAbove(_ context.Context, generation int64) error { + m.mu.Lock() + defer m.mu.Unlock() + m.generation = max(m.generation, generation) + return nil +} + func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) { m.mu.Lock() defer m.mu.Unlock()