Refuse an old wrapper of a held-back membership, so a reader falls back to the assignments stream
mesh/delivery merged: source.main moved to novox/mesh-sdk@0c99e847a2ae
mesh/delivery-group group fix/memberships-shape merged: every member is merged
mesh/check checking 83cf97b690f5 on 0c99e847a2ae | novox/mesh-sdk#134 in group fix/memberships-shape

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.
This commit is contained in:
2026-10-30 15:36:23 +01:00
parent 46d4b4da5e
commit 83cf97b690
2 changed files with 26 additions and 12 deletions
+13 -2
View File
@@ -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
+13 -10
View File
@@ -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 {