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.
This commit is contained in:
2026-10-30 15:23:17 +01:00
parent 2b1e148bf5
commit 46d4b4da5e
3 changed files with 95 additions and 2 deletions
+5 -2
View File
@@ -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, `<node>.<module>`, 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
+58
View File
@@ -0,0 +1,58 @@
package membership
import (
"bytes"
"encoding/json"
"errors"
"fmt"
)
// The grants part's state memberships (buscompose.GrantsMembershipsState) holds, at `<node>.<module>`, 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
}
+32
View File
@@ -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)
}
}
}