From 46d4b4da5ebb3dd664391ccff4c917473a40c617 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 30 Oct 2026 15:23:17 +0100 Subject: [PATCH 1/2] One codec for the grants part's memberships, so its writer and the tool runner cannot drift The grants part wrote its own wrapper into mesh-grants_memberships while the tool runner read the value as a bare membership: novox's tool runner served no tool. EncodeGrantsMembership writes the bare membership; DecodeGrantsMembership and BareGrantsMembership read it, still read the old wrapper for the transition, and refuse a value naming no node or module instead of answering an empty membership. Issue 774 (the bus login list, L3). Quick fix: write the bare membership in the grants part alone. Thorough, chosen: one codec in mesh-sdk both sides use, because a hand-built test fixture agreed with the mistake and hid it. --- go/buscompose/nats.go | 7 ++-- go/membership/grants.go | 58 ++++++++++++++++++++++++++++++++ go/membership/membership_test.go | 32 ++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 go/membership/grants.go diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index 61bdfa9..7e536e2 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -1737,8 +1737,11 @@ func CreateConsumerGrants(consumers []ModuleConsumer) []string { const GrantsModule = "mesh-grants" // The grants part's state memberships (novox/hq ADR 0571 clause 7): one key per assignment, `.`, its -// membership (Membership) as the assignments stream carries it, written only when it differs. A module's runtime reads -// its own key, the tool runner its node's; the grants part alone writes it. +// membership (Membership) bare, as the assignments stream carries it, written only when it differs. A module's runtime +// reads its own key, the tool runner its node's; the grants part alone writes it. Both sides go through +// membership.EncodeGrantsMembership and membership.DecodeGrantsMembership (or BareGrantsMembership for a reader with a +// type of its own), never json of a type of their own: the grants part once wrote its wrapper here and every reader +// served nothing (issue 774). The decoder still reads that wrapper, for the transition. const ( GrantsMembershipsState = "memberships" GrantsMembershipsBucket = GrantsModule + "_" + GrantsMembershipsState diff --git a/go/membership/grants.go b/go/membership/grants.go new file mode 100644 index 0000000..0486ee5 --- /dev/null +++ b/go/membership/grants.go @@ -0,0 +1,58 @@ +package membership + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" +) + +// The grants part's state memberships (buscompose.GrantsMembershipsState) holds, at `.`, the bare +// membership as the assignments stream carries it. The writer and every reader use this one codec, so the two sides +// cannot drift: until 2026-10-30 the grants part wrote its own wrapper ({node, module, digest, membership, published, +// retired}) there while the tool runner read the value as a bare membership, and served nothing (issue 774). + +// EncodeGrantsMembership is the value of a membership's key in the grants part's memberships: the bare membership. +func EncodeGrantsMembership(m Membership) []byte { + raw, _ := json.Marshal(m) // a Membership holds nothing json cannot write + return raw +} + +// DecodeGrantsMembership reads a value of the grants part's memberships (BareGrantsMembership) as a Membership. +func DecodeGrantsMembership(raw []byte) (Membership, error) { + var m Membership + body, err := BareGrantsMembership(raw) + if err != nil { + return m, err + } + err = json.Unmarshal(body, &m) + return m, err +} + +// BareGrantsMembership is the bare membership a value of the grants part's memberships holds, for a reader that +// decodes it into a type of its own. It accepts the bare membership and, for the transition, the wrapper the grants +// part wrote before (a top-level object whose "membership" is an object), answering the membership inside it. A value +// that is not a JSON object, or whose membership names no node or no module, is an error, never an empty membership. +func BareGrantsMembership(raw []byte) (json.RawMessage, error) { + var probe struct { + Membership json.RawMessage `json:"membership"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return nil, fmt.Errorf("not a membership: %w", err) + } + body := raw + if inner := bytes.TrimSpace(probe.Membership); len(inner) > 0 { + if inner[0] != '{' { + return nil, errors.New("not a membership: its membership is no object") + } + body = inner + } + var m Membership + if err := json.Unmarshal(body, &m); err != nil { + return nil, fmt.Errorf("not a membership: %w", err) + } + if m.Node == "" || m.Module == "" { + return nil, errors.New("not a membership: it names no node or no module") + } + return json.RawMessage(body), nil +} diff --git a/go/membership/membership_test.go b/go/membership/membership_test.go index 88b742c..7bbe8e8 100644 --- a/go/membership/membership_test.go +++ b/go/membership/membership_test.go @@ -74,3 +74,35 @@ func TestAMembershipRoundTripsAsIssued(t *testing.T) { } } } + +// The grants part's memberships hold the bare membership; the wrapper written before 2026-10-30 still reads, and a +// value that is neither is an error, never an empty membership (issue 774). +func TestAGrantsMembershipIsReadBareOrWrappedAndNothingElse(t *testing.T) { + m := Membership{Node: "ace", Module: "forge", Tools: "mesh.mod.forge.tool", Serves: []Served{{Subject: "mesh.mod.forge.tool.{tool}"}}, + State: []StateIssued{{Name: "repos", Bucket: "forge_repos"}}} + bare := EncodeGrantsMembership(m) + inner, _ := json.Marshal(m) + wrapped := []byte(`{"node":"ace","module":"forge","digest":"sha256:x","membership":` + string(inner) + `,"published":"published","retired":["a b"]}`) + for _, c := range []struct { + name string + raw []byte + ok bool + }{ + {"bare, as written", bare, true}, + {"wrapped, as the grants part wrote it before", wrapped, true}, + {"not json", []byte("garbage"), false}, + {"an array", []byte(`[1,2]`), false}, + {"an object naming no node or module", []byte(`{"serves":[{"subject":"s"}]}`), false}, + {"a wrapper whose membership is no object", []byte(`{"node":"ace","module":"forge","membership":"x"}`), false}, + {"a wrapper whose membership names nothing", []byte(`{"node":"ace","module":"forge","membership":{}}`), false}, + } { + got, err := DecodeGrantsMembership(c.raw) + if (err == nil) != c.ok { + t.Errorf("%s: err %v", c.name, err) + continue + } + if c.ok && !reflect.DeepEqual(got, m) { + t.Errorf("%s: read %+v, want %+v", c.name, got, m) + } + } +} -- 2.54.0 From 83cf97b690f5d598ad96f7cae70b7c029f89099a Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 30 Oct 2026 15:36:23 +0100 Subject: [PATCH 2/2] Refuse an old wrapper of a held-back membership, so a reader falls back to the assignments stream The grants part's old build wrote its wrapper for every membership, a held-back one too, with the unpublished composition inside; the new part never rewrites such a key, so the decoder took the held-back membership as given. It now reads a wrapper only when its published says published or unchanged. The codec's rows join the round-trip test (mesh-sdk is over its unit budget). Review round 1 of issue 774's fix. --- go/membership/grants.go | 15 +++++++++++++-- go/membership/membership_test.go | 23 +++++++++++++---------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/go/membership/grants.go b/go/membership/grants.go index 0486ee5..b6d4a30 100644 --- a/go/membership/grants.go +++ b/go/membership/grants.go @@ -31,11 +31,14 @@ func DecodeGrantsMembership(raw []byte) (Membership, error) { // BareGrantsMembership is the bare membership a value of the grants part's memberships holds, for a reader that // decodes it into a type of its own. It accepts the bare membership and, for the transition, the wrapper the grants -// part wrote before (a top-level object whose "membership" is an object), answering the membership inside it. A value -// that is not a JSON object, or whose membership names no node or no module, is an error, never an empty membership. +// part wrote before (a top-level object whose "membership" is an object), answering the membership inside it only when +// its "published" says "published" or "unchanged". A value that is not a JSON object, a wrapper of a membership held +// back or saying nothing of its publishing, or a membership naming no node or no module, is an error, never an empty +// or unpublished membership. func BareGrantsMembership(raw []byte) (json.RawMessage, error) { var probe struct { Membership json.RawMessage `json:"membership"` + Published *string `json:"published"` } if err := json.Unmarshal(raw, &probe); err != nil { return nil, fmt.Errorf("not a membership: %w", err) @@ -45,6 +48,14 @@ func BareGrantsMembership(raw []byte) (json.RawMessage, error) { if inner[0] != '{' { return nil, errors.New("not a membership: its membership is no object") } + // The old wrapper was written for a membership held back too, its composed and unpublished membership inside: + // only one published (or unchanged) is the stream's, so any other is refused and the reader keeps the stream's. + switch { + case probe.Published == nil: + return nil, errors.New("not a published membership: the wrapper says nothing of its publishing") + case *probe.Published != "published" && *probe.Published != "unchanged": + return nil, fmt.Errorf("not a published membership: the wrapper says %q", *probe.Published) + } body = inner } var m Membership diff --git a/go/membership/membership_test.go b/go/membership/membership_test.go index 7bbe8e8..cfce963 100644 --- a/go/membership/membership_test.go +++ b/go/membership/membership_test.go @@ -73,28 +73,31 @@ func TestAMembershipRoundTripsAsIssued(t *testing.T) { t.Errorf("field %q is not on the wire under that name", k) } } -} -// The grants part's memberships hold the bare membership; the wrapper written before 2026-10-30 still reads, and a -// value that is neither is an error, never an empty membership (issue 774). -func TestAGrantsMembershipIsReadBareOrWrappedAndNothingElse(t *testing.T) { + // The grants part's memberships hold the bare membership through this package's codec; the wrapper written before + // 2026-10-30 still reads when it says the membership was published or unchanged, and anything else is an error, + // never an empty membership or one the part held back (issue 774). m := Membership{Node: "ace", Module: "forge", Tools: "mesh.mod.forge.tool", Serves: []Served{{Subject: "mesh.mod.forge.tool.{tool}"}}, State: []StateIssued{{Name: "repos", Bucket: "forge_repos"}}} - bare := EncodeGrantsMembership(m) inner, _ := json.Marshal(m) - wrapped := []byte(`{"node":"ace","module":"forge","digest":"sha256:x","membership":` + string(inner) + `,"published":"published","retired":["a b"]}`) + wrapped := func(published string) []byte { + return []byte(`{"node":"ace","module":"forge","digest":"sha256:x","membership":` + string(inner) + published + `,"retired":["a b"]}`) + } for _, c := range []struct { name string raw []byte ok bool }{ - {"bare, as written", bare, true}, - {"wrapped, as the grants part wrote it before", wrapped, true}, + {"bare, as written", EncodeGrantsMembership(m), true}, + {"wrapped and published, as the grants part wrote it before", wrapped(`,"published":"published"`), true}, + {"wrapped and unchanged", wrapped(`,"published":"unchanged"`), true}, + {"wrapped and held back", wrapped(`,"published":"not published, a shadow difference: publishing would lose x"`), false}, + {"wrapped, saying nothing of its publishing", wrapped(""), false}, {"not json", []byte("garbage"), false}, {"an array", []byte(`[1,2]`), false}, {"an object naming no node or module", []byte(`{"serves":[{"subject":"s"}]}`), false}, - {"a wrapper whose membership is no object", []byte(`{"node":"ace","module":"forge","membership":"x"}`), false}, - {"a wrapper whose membership names nothing", []byte(`{"node":"ace","module":"forge","membership":{}}`), false}, + {"a wrapper whose membership is no object", []byte(`{"node":"ace","module":"forge","membership":"x","published":"published"}`), false}, + {"a wrapper whose membership names nothing", []byte(`{"node":"ace","module":"forge","membership":{},"published":"published"}`), false}, } { got, err := DecodeGrantsMembership(c.raw) if (err == nil) != c.ok { -- 2.54.0