From a479a90fb24e8f695447e74a154a852f8d3d390d Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 30 Oct 2026 15:31:26 +0100 Subject: [PATCH 1/2] Read the grants part's memberships through mesh-sdk's codec, so the tool runner serves its node's modules The tool runner unmarshalled the state's value straight into its membership; the grants part had written its wrapper there, so every module decoded empty, was marked as given by the grants part, and the correct copy on the assignments stream was passed over: novox served no tool. followGrants now reads with membership.BareGrantsMembership, which takes the bare membership, still takes the old wrapper (what the live mesh holds until the part writes again), and refuses a value naming no node or module, leaving the stream's copy in force. The fixture IssueInGrants built its value by hand from this package's type and so agreed with the mistake; it now writes through mesh-sdk's encoder, and IssueInGrantsWrapped writes the old wrapper for one integration test that proves tools and state are served from it (it fails without this fix with the empty membership seen live). Issue 774 (the bus login list, L3). Quick fix: unwrap here alone. Thorough, chosen: one codec in mesh-sdk the writer and this reader share, so the two cannot drift again. Pins mesh-sdk at the head of its fix/memberships-shape. --- node-tools/go.mod | 2 +- node-tools/go.sum | 4 +-- node-tools/internal/bus/bus.go | 10 +++++- node-tools/internal/bus/state_watch_test.go | 34 +++++++++++++++++++++ node-tools/internal/meshtest/meshtest.go | 29 +++++++++++++++++- 5 files changed, 74 insertions(+), 5 deletions(-) diff --git a/node-tools/go.mod b/node-tools/go.mod index 5423126..69cda1a 100644 --- a/node-tools/go.mod +++ b/node-tools/go.mod @@ -8,7 +8,7 @@ require ( ) require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030142317-46d4b4da5ebb github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.0 // indirect diff --git a/node-tools/go.sum b/node-tools/go.sum index a2b8e01..80f4477 100644 --- a/node-tools/go.sum +++ b/node-tools/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030142317-46d4b4da5ebb h1:QvNKoCjW1cwKsaY/9iJO0srTFIBQQwEZMkjb664BAPg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030142317-46d4b4da5ebb/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= diff --git a/node-tools/internal/bus/bus.go b/node-tools/internal/bus/bus.go index 4051a80..fc4b508 100644 --- a/node-tools/internal/bus/bus.go +++ b/node-tools/internal/bus/bus.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/membership" "log" "regexp" "strings" @@ -468,8 +469,15 @@ func (c *Conn) followGrants(module string) { if e.Operation() != jetstream.KeyValuePut { return // a membership withdrawn is the stream's, or the module's unassignment's, to say } + // Read through mesh-sdk's one codec of this state, the grants part's writer's (issue 774): it takes the bare + // membership and the wrapper the part wrote before, and refuses a value naming no node or module, which then + // leaves the assignments stream's copy in force instead of an empty membership. var m Membership - if err := json.Unmarshal(e.Value(), &m); err != nil { + body, err := membership.BareGrantsMembership(e.Value()) + if err == nil { + err = json.Unmarshal(body, &m) + } + if err != nil { c.Logf("[mesh-tools] the membership at %s is not one: %v", key, err) return } diff --git a/node-tools/internal/bus/state_watch_test.go b/node-tools/internal/bus/state_watch_test.go index 7e0e842..61166b4 100644 --- a/node-tools/internal/bus/state_watch_test.go +++ b/node-tools/internal/bus/state_watch_test.go @@ -116,6 +116,40 @@ func TestAWatchIsRefusedWhenNoCurrentValueIsTakenWithinTheBound(t *testing.T) { } } +// **A membership the grants part wrote in its old wrapper is served whole** (issue 774). Until 2026-10-30 the part wrote +// its pass record, the membership inside, into its state memberships; the tool runner read the value as a bare +// membership, took it over the assignments stream's copy, and served no tool and no state. This is what the live mesh +// held: read through mesh-sdk's codec, its tools are answered where the membership says and its state is reached. +func TestAMembershipTheGrantsPartWroteWrappedIsServed(t *testing.T) { + mesh := mt.New(t) + mesh.Bucket(t, "gitea_repos") + gitea := mt.MembershipOf("gitea", "anchor", false, nil) + gitea.State = []bus.StateIssued{{Name: "repos", Bucket: "gitea_repos", Writes: true}} + mesh.IssueInGrantsWrapped(t, gitea) + conn, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "anchor"}) + if err != nil { + t.Fatal(err) + } + conn.Logf = func(string, ...any) {} + t.Cleanup(conn.Close) + conn.Follow("gitea") + mt.Until(t, func() error { + if m := conn.Membership("gitea"); m == nil || len(m.Serves) == 0 { + return fmt.Errorf("gitea's membership is %+v, want the one inside the wrapper", m) + } + return nil + }) + if got, want := fmt.Sprint(conn.ServedOn("gitea", "gitea_list")), "[{mesh.mod.gitea.tool.gitea_list.anchor }]"; got != want { + t.Errorf("gitea_list is served on %s, want %s", got, want) + } + if _, err := conn.StatePut("gitea", "repos", "novox.hq", []byte(`{"v":1}`)); err != nil { + t.Fatalf("the state the membership issues is refused: %v", err) + } + if e, err := conn.StateGet("gitea", "repos", "novox.hq"); err != nil || e == nil || string(e.Value) != `{"v":1}` { + t.Errorf("the state reads back %+v, %v", e, err) + } +} + // owedWatcher is the gitea module's view of the checker's owed: the bucket, and a runtime connection // that serves gitea with the owed issued for reading. func owedWatcher(t *testing.T) (*bus.Conn, interface { diff --git a/node-tools/internal/meshtest/meshtest.go b/node-tools/internal/meshtest/meshtest.go index 1673a0d..a22fd93 100644 --- a/node-tools/internal/meshtest/meshtest.go +++ b/node-tools/internal/meshtest/meshtest.go @@ -15,6 +15,7 @@ import ( "encoding/json" "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/membership" "os" "path/filepath" "runtime" @@ -110,7 +111,23 @@ func (m *Mesh) Issue(t *testing.T, mem bus.Membership) { // IssueInGrants writes a membership where the grants part keeps it, its state memberships, key `.` // (novox/hq ADR 0571 clause 7; mesh-sdk buscompose.GrantsMembershipsBucket), making the bucket when it is not there. +// It writes with mesh-sdk's encoder, the grants part's own, never json of this package's type: a value built here by +// hand agreed with the reader's mistake and hid that the part wrote another shape (issue 774). func (m *Mesh) IssueInGrants(t *testing.T, mem bus.Membership) { + t.Helper() + m.putGrants(t, mem, membership.EncodeGrantsMembership(sdkMembership(t, mem))) +} + +// IssueInGrantsWrapped writes a membership as the grants part wrote it before mesh-sdk's codec (until 2026-10-30, what +// the live mesh holds until the part writes again): its pass record, the membership inside. +func (m *Mesh) IssueInGrantsWrapped(t *testing.T, mem bus.Membership) { + t.Helper() + body, _ := json.Marshal(map[string]any{"node": mem.Node, "module": mem.Module, "digest": "sha256:0", + "membership": sdkMembership(t, mem), "published": "published"}) + m.putGrants(t, mem, body) +} + +func (m *Mesh) putGrants(t *testing.T, mem bus.Membership, body []byte) { t.Helper() kv, err := m.js.KeyValue(buscompose.GrantsMembershipsBucket) if err != nil { @@ -118,12 +135,22 @@ func (m *Mesh) IssueInGrants(t *testing.T, mem bus.Membership) { t.Fatal(err) } } - body, _ := json.Marshal(mem) if _, err := kv.Put(buscompose.MembershipKey(mem.Node, mem.Module), body); err != nil { t.Fatal(err) } } +// sdkMembership is a membership of this package's type as mesh-sdk's, which the grants part composes and writes. +func sdkMembership(t *testing.T, mem bus.Membership) membership.Membership { + t.Helper() + raw, _ := json.Marshal(mem) + var out membership.Membership + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + return out +} + // ConsoleReads are the states a console's runtime reads to say what the mesh holds (novox/hq ADR 0571 clauses 4, 5): // placement, the indexer's modules and manifests, and the seat holders. func ConsoleReads() []bus.StateIssued { From ac645e2caeddd1b1237689e0e98e00d730168058 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 30 Oct 2026 15:38:40 +0100 Subject: [PATCH 2/2] Refuse a held-back membership's old wrapper in the tool runner, pinning mesh-sdk's review-round-1 codec The old grants part wrote its wrapper for a held-back membership too; the new part leaves such a key alone, and taking it would serve what the hold kept back with the stream's copy ignored. The integration test now proves that copy is served instead. Review round 1 of issue 774's fix. --- node-tools/go.mod | 2 +- node-tools/go.sum | 4 ++-- node-tools/internal/bus/bus.go | 5 +++-- node-tools/internal/bus/state_watch_test.go | 18 +++++++++++++++++- node-tools/internal/meshtest/meshtest.go | 7 ++++--- 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/node-tools/go.mod b/node-tools/go.mod index 69cda1a..650042f 100644 --- a/node-tools/go.mod +++ b/node-tools/go.mod @@ -8,7 +8,7 @@ require ( ) require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030142317-46d4b4da5ebb + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030143623-83cf97b690f5 github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.0 // indirect diff --git a/node-tools/go.sum b/node-tools/go.sum index 80f4477..1b0193a 100644 --- a/node-tools/go.sum +++ b/node-tools/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030142317-46d4b4da5ebb h1:QvNKoCjW1cwKsaY/9iJO0srTFIBQQwEZMkjb664BAPg= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030142317-46d4b4da5ebb/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030143623-83cf97b690f5 h1:hyyaB1rU7uzlQZvWAxZ9P6pL/4LCFpOVXMRgn9F7fic= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030143623-83cf97b690f5/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= diff --git a/node-tools/internal/bus/bus.go b/node-tools/internal/bus/bus.go index fc4b508..ee01017 100644 --- a/node-tools/internal/bus/bus.go +++ b/node-tools/internal/bus/bus.go @@ -470,8 +470,9 @@ func (c *Conn) followGrants(module string) { return // a membership withdrawn is the stream's, or the module's unassignment's, to say } // Read through mesh-sdk's one codec of this state, the grants part's writer's (issue 774): it takes the bare - // membership and the wrapper the part wrote before, and refuses a value naming no node or module, which then - // leaves the assignments stream's copy in force instead of an empty membership. + // membership and a wrapper the part wrote before of one published or unchanged, and refuses a held-back one's + // wrapper or a value naming no node or module, which then leaves the assignments stream's copy in force instead. + // A refused value arriving after a good one keeps the last good membership: nothing here clears it. var m Membership body, err := membership.BareGrantsMembership(e.Value()) if err == nil { diff --git a/node-tools/internal/bus/state_watch_test.go b/node-tools/internal/bus/state_watch_test.go index 61166b4..5ca378e 100644 --- a/node-tools/internal/bus/state_watch_test.go +++ b/node-tools/internal/bus/state_watch_test.go @@ -125,7 +125,13 @@ func TestAMembershipTheGrantsPartWroteWrappedIsServed(t *testing.T) { mesh.Bucket(t, "gitea_repos") gitea := mt.MembershipOf("gitea", "anchor", false, nil) gitea.State = []bus.StateIssued{{Name: "repos", Bucket: "gitea_repos", Writes: true}} - mesh.IssueInGrantsWrapped(t, gitea) + mesh.IssueInGrantsWrapped(t, gitea, "published") + // The old wrapper of a membership the part held back carries its unpublished composition: refused, so the + // assignments stream's copy is served, never what the hold kept back. + pages := mt.MembershipOf("pages", "anchor", false, nil) + mesh.Issue(t, pages) + heldBack := mt.MembershipOf("pages", "anchor", true, nil) + mesh.IssueInGrantsWrapped(t, heldBack, "not published, a shadow difference: publishing would lose the last holder of x") conn, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "anchor"}) if err != nil { t.Fatal(err) @@ -148,6 +154,16 @@ func TestAMembershipTheGrantsPartWroteWrappedIsServed(t *testing.T) { if e, err := conn.StateGet("gitea", "repos", "novox.hq"); err != nil || e == nil || string(e.Value) != `{"v":1}` { t.Errorf("the state reads back %+v, %v", e, err) } + conn.Follow("pages") + mt.Until(t, func() error { + if m := conn.Membership("pages"); m == nil || len(m.Serves) == 0 { + return fmt.Errorf("pages' membership is %+v, want the assignments stream's", m) + } + return nil + }) + if got, want := fmt.Sprint(conn.ServedOn("pages", "pages_list")), "[{mesh.mod.pages.tool.pages_list.anchor }]"; got != want { + t.Errorf("a held-back membership's wrapper is served: pages_list on %s, want the stream's %s", got, want) + } } // owedWatcher is the gitea module's view of the checker's owed: the bucket, and a runtime connection diff --git a/node-tools/internal/meshtest/meshtest.go b/node-tools/internal/meshtest/meshtest.go index a22fd93..4d9f5d9 100644 --- a/node-tools/internal/meshtest/meshtest.go +++ b/node-tools/internal/meshtest/meshtest.go @@ -119,11 +119,12 @@ func (m *Mesh) IssueInGrants(t *testing.T, mem bus.Membership) { } // IssueInGrantsWrapped writes a membership as the grants part wrote it before mesh-sdk's codec (until 2026-10-30, what -// the live mesh holds until the part writes again): its pass record, the membership inside. -func (m *Mesh) IssueInGrantsWrapped(t *testing.T, mem bus.Membership) { +// the live mesh holds until the part writes again): its pass record, the membership inside, published saying what +// became of it ("published", "unchanged", or why it was held back). +func (m *Mesh) IssueInGrantsWrapped(t *testing.T, mem bus.Membership, published string) { t.Helper() body, _ := json.Marshal(map[string]any{"node": mem.Node, "module": mem.Module, "digest": "sha256:0", - "membership": sdkMembership(t, mem), "published": "published"}) + "membership": sdkMembership(t, mem), "published": published}) m.putGrants(t, mem, body) }