From 85826c633de5f653d6e8ce104a38d827b48d877e Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:23:11 +0100 Subject: [PATCH 1/2] =?UTF-8?q?The=20bus=20node's=20follower=20never=20fal?= =?UTF-8?q?ls=20back=20to=20the=20controller's=20user=20list=20once=20the?= =?UTF-8?q?=20grants=20part's=20key=20says=20the=20lease=20is=20handed=20(?= =?UTF-8?q?ADR=200571=20=C2=A78,=20ADR=200547=20=C2=A711)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A key that later cannot be read or is deleted is said, the key last heard stands and the list loaded stays. A key without the flag still falls back, as before. Tests: three rows added to the existing table test of the follower; no new test function. Built against the group's mesh-sdk through a go.work outside the tree; go.mod is not re-pinned here. (issue 774, 774/S31) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- internal/userlist/userlist.go | 27 +++++++++++++++++++++++---- internal/userlist/userlist_test.go | 24 +++++++++++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/internal/userlist/userlist.go b/internal/userlist/userlist.go index 536da9b..bfeaf3d 100644 --- a/internal/userlist/userlist.go +++ b/internal/userlist/userlist.go @@ -27,6 +27,11 @@ // against the key's digest, and written to the same file (mode 0600) only when the file's digest differs; otherwise the // controller's compositions are the source, as before. Never both. The have says which it follows and this node's key, // which the grants part seals to. +// +// **From the lease write it never follows the controller's list again** (ADR 0571 clause 8, ADR 0547 clause 11). The +// grants part's key then says the lease is handed (delivery.GrantsUserList.LeaseHanded): a key that later cannot be read or +// is deleted is said, the key last heard stands, and the list loaded stays; no list is written from a composition of the +// controller. package userlist import ( @@ -250,23 +255,36 @@ func (f *Follower) busNode() bool { } // takeGrants keeps the grants part's key as heard; a value it cannot read is said and leaves the controller's list -// followed (no list is followed that the node cannot check). +// followed (no list is followed that the node cannot check), unless the key last heard says the lease is handed: then +// the controller's list is never followed again (novox/hq ADR 0571 clause 8, ADR 0547 clause 11), the key as last heard +// stands, and the list loaded stays. func (f *Follower) takeGrants(u agent.Update) { f.mu.Lock() defer f.mu.Unlock() - f.grantsRev = u.Revision + handed := f.grants != nil && f.grants.LeaseHanded if u.Deleted { + if handed { + f.sayOnceLocked("the grants part's user-list key is deleted, and the lease is handed, so the list loaded is kept") + return + } + f.grantsRev = u.Revision f.grants = nil f.keep(nil, 0) return } g, err := delivery.DecodeGrantsUserList(u.Value) if err != nil { + if handed { + f.sayOnceLocked("the grants part's user-list key cannot be read, and the lease is handed, so the list loaded is kept: " + err.Error()) + return + } + f.grantsRev = u.Revision f.grants = nil f.keep(nil, 0) f.sayOnceLocked("the grants part's user-list key cannot be read, so the controller's list is followed: " + err.Error()) return } + f.grantsRev = u.Revision f.grants = &g f.keep(u.Value, u.Revision) } @@ -278,7 +296,8 @@ func (f *Follower) keep(value []byte, rev uint64) { } } -// Following is which list the follower takes now: the grants part's while its key says so, the controller's otherwise. +// Following is which list the follower takes now: the grants part's while its key says so or says the lease is handed, the +// controller's otherwise. func (f *Follower) Following() string { f.mu.Lock() defer f.mu.Unlock() @@ -286,7 +305,7 @@ func (f *Follower) Following() string { } func (f *Follower) followingLocked() string { - if f.grants != nil && f.grants.Follow == delivery.FollowGrants { + if f.grants != nil && (f.grants.Follow == delivery.FollowGrants || f.grants.LeaseHanded) { return delivery.FollowGrants } return delivery.FollowController diff --git a/internal/userlist/userlist_test.go b/internal/userlist/userlist_test.go index 94e89b3..e43240c 100644 --- a/internal/userlist/userlist_test.go +++ b/internal/userlist/userlist_test.go @@ -132,6 +132,9 @@ type gput struct { otherKey, badDigest bool // after puts it once the grants part's list was written, as a hand-back comes after a take-over. after bool + // handed says the controller's lease is handed (ADR 0582 clause 3); unreadable and deleted make the key a value that + // does not read, or a deleted key, instead of a list (774/S31). + handed, unreadable, deleted bool } type put struct { @@ -226,6 +229,20 @@ func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) { {name: "in shadow the grants part's list is not taken", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowController, content: three}}, wantWrites: []string{two}, wantUsers: 3, wantSource: "novox.nats." + fpOf("a")[len(delivery.SourcePrefix2):], wantFollows: delivery.FollowController}, + // 774/S31 (ADR 0571 clause 8, ADR 0547 clause 11): once the key says the lease is handed, the controller's list is never + // followed again, whatever becomes of the key; without the flag the controller's list is still the fallback. + {name: "a handed lease: a grants key that turns unreadable keeps the loaded list and never writes the controller's", + before: []put{{module: "nats", fp: fpOf("a"), content: two}}, + grants: []gput{{follow: delivery.FollowGrants, content: three, handed: true}, {unreadable: true, after: true}}, + wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants}, + {name: "a handed lease: a grants key that is deleted keeps the loaded list and never writes the controller's", + before: []put{{module: "nats", fp: fpOf("a"), content: two}}, + grants: []gput{{follow: delivery.FollowGrants, content: three, handed: true}, {deleted: true, after: true}}, + wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants}, + {name: "no handed lease: a grants key that turns unreadable falls back to the controller's list", + before: []put{{module: "nats", fp: fpOf("a"), content: two}}, + grants: []gput{{follow: delivery.FollowGrants, content: three}, {unreadable: true, after: true}}, + wantWrites: []string{three, two}, wantUsers: 3, wantSource: "novox.nats." + fpOf("a")[len(delivery.SourcePrefix2):], wantFollows: delivery.FollowController}, {name: "a grants list that does not open to its digest is refused and the file kept", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowGrants, content: three, badDigest: true}}, onDisk: two, wantErr: "could not be had", wantRefused: true, wantSource: delivery.GrantsUserListSource}, @@ -256,6 +273,11 @@ func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) { gs = append(append([]gput(nil), gs...), *c.kept) } for i, g := range gs { + if g.unreadable || g.deleted { + u := agent.Update{Key: delivery.GrantsUserListKey, Value: []byte("not a user-list key"), Revision: uint64(i + 1), Deleted: g.deleted} + grantsAfter = append(grantsAfter, u) + continue + } to := nodeKey.PublicKey if g.otherKey { other, _ := seal.GenerateKeyPair() @@ -271,7 +293,7 @@ func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) { } sealed[delivery.UserListObjectName(digest)] = object raw, err := delivery.EncodeGrantsUserList(delivery.GrantsUserList{Follow: g.follow, Digest: digest, - Object: delivery.UserListObjectName(digest), SealedTo: delivery.SealingKeyID(to), Node: node, Revision: uint64(i + 1)}) + Object: delivery.UserListObjectName(digest), SealedTo: delivery.SealingKeyID(to), Node: node, Revision: uint64(i + 1), LeaseHanded: g.handed}) if err != nil { t.Fatal(err) } From c27fad5c6e36eba7b70e9f038a7b614c0a65bb98 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:53:42 +0100 Subject: [PATCH 2/2] Pin mesh-sdk at c21d8fd, the removal group's head, so every member builds the same contract (issue 774) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index cb8acb7..d657e1e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index 4903c2c..c4ff816 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4 h1:xeuMBwOHNKrVUdGVg7Bdv5tkdueBvngwJFyk6n10rp0= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=