From f3f0a5e6950351b96ef61f000d841075ccedeb17 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 8 Nov 2026 00:33:24 +0100 Subject: [PATCH] mesh-grants: a pass composing part of the mesh publishes the union of the memberships, so it only grows (issue 1966) Fault: the new assigner composes a membership that drops the placement seats and adds its state view-marks. The partial pass kept the published one whole, so the assigner's tools never got view-marks, its assignments view never answered, and the pass never left its partial state (third bootstrap loop of the kind after #170 and #171). Kind: the bootstrap hold freezes whole entries, so a grant needed to end the hold is never given. Quick fix: special-case the assigner (or let additions through for it). Rejected: the next module that gains a state meets the same loop. Thorough fix: while a pass keeps what the bus loaded, publish the union of the membership last published and the one composed (every list, deduplicated, published entries first; the private network, contributions and bindings the pass's), published when it differs; the pass says what it kept beyond the composed one. The user list already unions (KeepLoaded), additions pass there. Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5 --- modules/mesh-grants/grants_test.go | 36 +++++++++- .../mesh-grants/internal/grants/keeploaded.go | 71 +++++++++++++++++++ modules/mesh-grants/internal/grants/part.go | 24 ++++--- 3 files changed, 121 insertions(+), 10 deletions(-) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index e9260a73..d211c947 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -12,6 +12,7 @@ import ( "maps" "os" "path/filepath" + "reflect" "regexp" "slices" "strings" @@ -2473,6 +2474,29 @@ func TestGrants(t *testing.T) { t.Errorf("%s: losses %v name no %q", d.name, grants.Losses(d.composed, inForce), d.loss) } } + // The membership a pass composing part of the mesh publishes is the union of the one published and the one composed. + for _, d := range []struct { + name, was, now, want string + kept []string + }{ + {"narrows and adds: the union", `{"node":"a","emits":"e","seats":[{"seat":"s","verb":"v"}],"state":[{"name":"x"}]}`, + `{"node":"a","emits":"e","state":[{"name":"x"},{"name":"view-marks"}]}`, + `{"node":"a","emits":"e","seats":[{"seat":"s","verb":"v"}],"state":[{"name":"x"},{"name":"view-marks"}]}`, []string{`seats {"seat":"s","verb":"v"}`}}, + {"adds only", `{"node":"a","state":[{"name":"x"}]}`, `{"node":"a","state":[{"name":"x"},{"name":"y"}]}`, + `{"node":"a","state":[{"name":"x"},{"name":"y"}]}`, nil}, + {"narrows only: as published", `{"node":"a","state":[{"name":"x"},{"name":"y"}],"reaches":{"m.t":["s1"]}}`, `{"node":"a","state":[{"name":"x"}],"reaches":{"m.t":["s2"]}}`, + `{"node":"a","state":[{"name":"x"},{"name":"y"}],"reaches":{"m.t":["s1","s2"]}}`, []string{`reaches.m.t "s1"`, `state {"name":"y"}`}}, + {"the private network and received are the pass's", `{"node":"a","mesh":["m1"],"receives":{"r":1}}`, `{"node":"a","mesh":["m2"]}`, + `{"node":"a","mesh":["m2"]}`, nil}, + } { + grown, kept, err := grants.UnionMembership([]byte(d.was), []byte(d.now)) + var g, w any + _ = json.Unmarshal(grown, &g) + _ = json.Unmarshal([]byte(d.want), &w) + if err != nil || !reflect.DeepEqual(g, w) || !slices.Equal(kept, d.kept) { + t.Errorf("%s: union %s kept %v (%v), want %s kept %v", d.name, grown, kept, err, d.want, d.kept) + } + } for _, row := range []struct { name string bend func(*fakeBus, *grants.Part) @@ -2525,6 +2549,7 @@ func TestGrants(t *testing.T) { // The indexer's membership as published serves a tool more than any pass composes now (a tool runner's // seat, say): a pass composing part of the mesh keeps it as published, a whole one publishes what it composes. const extra = `{"subject":"mesh.tools.a.mesh-delivery-indexer.extra"}` + var lacks []byte { var m map[string]any if err := json.Unmarshal(b.own[grants.StateMemberships]["a.mesh-delivery-indexer"], &m); err != nil { @@ -2532,7 +2557,10 @@ func TestGrants(t *testing.T) { } var e any _ = json.Unmarshal([]byte(extra), &e) - m["serves"] = append(m["serves"].([]any), e) + // The published one also lacks a tool the pass composes: the pass narrows it and adds to it at once (issue + // 1966, the assigner's view-marks), so it publishes the union and the addition is not held. + lacks, _ = json.Marshal(m["serves"].([]any)[0]) + m["serves"] = append(m["serves"].([]any)[1:], e) b.own[grants.StateMemberships]["a.mesh-delivery-indexer"], _ = json.Marshal(m) } servesExtra := func() bool { @@ -2564,10 +2592,14 @@ func TestGrants(t *testing.T) { } partial := row.name != "the ledger of a module whose manifest is refused" if servesExtra() != partial || partial != slices.ContainsFunc(res.NotPublished, func(n string) bool { - return strings.HasPrefix(n, "a.mesh-delivery-indexer: kept as published: ") && strings.Contains(n, "mesh-delivery-indexer.extra") + return strings.HasPrefix(n, "a.mesh-delivery-indexer: kept beyond this pass: ") && strings.Contains(n, "mesh-delivery-indexer.extra") }) { t.Errorf("the published membership serving more: kept %v in a pass composing part of the mesh %v; not published %v", servesExtra(), partial, res.NotPublished) } + if partial := row.name != "the ledger of a module whose manifest is refused"; partial && + !strings.Contains(string(b.own[grants.StateMemberships]["a.mesh-delivery-indexer"]), string(lacks)) { + t.Errorf("the pass narrowing and adding does not publish its addition %s", lacks) + } if row.held != "" { if res.Sealed == nil || !strings.Contains(res.Sealed.Why, row.held) { t.Fatalf("not held for %q: %+v", row.held, res.Sealed) diff --git a/modules/mesh-grants/internal/grants/keeploaded.go b/modules/mesh-grants/internal/grants/keeploaded.go index 80ef40a5..dee095b4 100644 --- a/modules/mesh-grants/internal/grants/keeploaded.go +++ b/modules/mesh-grants/internal/grants/keeploaded.go @@ -171,3 +171,74 @@ func uncovered(path string, a, b any) []string { } return nil } + +// UnionMembership is the membership last published (was) grown by the one composed now: every list of both, deduplicated, was's +// elements first and then what now adds; an object by key; a single value as now says it. The private network, the contributions +// received and the bindings are now's, which carry them over or the bus module writes. A pass composing part of the mesh publishes +// it, so it only grows (issue 1966): a membership kept whole could never receive what the pass that ends the hold needs. It also +// names, as MembershipNarrows does, what was has that now lacks. Pure. +func UnionMembership(was, now []byte) ([]byte, []string, error) { + var a, b map[string]any + if err := json.Unmarshal(was, &a); err != nil { + return nil, nil, err + } + if err := json.Unmarshal(now, &b); err != nil { + return nil, nil, err + } + out := map[string]any{} + for k, v := range a { + out[k] = v + } + for k, v := range b { + switch k { + case "mesh", "receives", "bindings": + out[k] = v + default: + out[k] = unionOf(a[k], v) + } + } + for _, k := range []string{"mesh", "receives", "bindings"} { + if _, ok := b[k]; !ok { + delete(out, k) + } + } + body, err := json.Marshal(out) + return body, MembershipNarrows(was, now), err +} + +func unionOf(a, b any) any { + switch bv := b.(type) { + case map[string]any: + av, ok := a.(map[string]any) + if !ok { + return b + } + out := map[string]any{} + for k, v := range av { + out[k] = v + } + for k, v := range bv { + out[k] = unionOf(av[k], v) + } + return out + case []any: + av, ok := a.([]any) + if !ok { + return b + } + out := slices.Clone(av) + seen := map[string]bool{} + for _, e := range out { + j, _ := json.Marshal(e) + seen[string(j)] = true + } + for _, e := range bv { + if j, _ := json.Marshal(e); !seen[string(j)] { + seen[string(j)] = true + out = append(out, e) + } + } + return out + } + return b +} diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 02dca360..923c30fa 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -433,18 +433,26 @@ func (p *Part) Compose() Result { res.NotPublished = append(res.NotPublished, key+": not published: the membership last published cannot be read from its own "+StateMemberships+": "+err.Error()) continue } - m, now := publish(was, found, ms[key], holdings, seated) - // A pass that composes part of the mesh never narrows a published membership: it keeps the one published - // (keeploaded.go, issue 1966), so a tool runner or a seat holder goes on serving what it serves. - if why := in.keepsLoaded(); now && found && why != "" { - if body, err := json.Marshal(m.Membership); err == nil { - if bare, err := membership.BareGrantsMembership(was); err == nil { - if narrower := MembershipNarrows(bare, body); len(narrower) > 0 { - m.Published, now = "kept as published: "+why+", and this pass composes it without "+strings.Join(narrower, ", "), false + // A pass that composes part of the mesh only grows a published membership: it publishes the union of the one last + // published and the one composed (keeploaded.go, issue 1966), so a tool runner or a seat holder goes on serving what it + // serves and still receives what the pass that ends the hold needs. + if why := in.keepsLoaded(); found && why != "" { + if bare, err := membership.BareGrantsMembership(was); err == nil { + if body, err := json.Marshal(ms[key].Membership); err == nil { + if grown, kept, err := UnionMembership(bare, body); err == nil && len(kept) > 0 { + var u membership.Membership + if json.Unmarshal(grown, &u) == nil { + withUnion := ms[key] + withUnion.Membership = u + withUnion.Digest = digestOf(u) + ms[key] = withUnion + res.NotPublished = append(res.NotPublished, key+": kept beyond this pass: "+why+", this pass composes it without "+strings.Join(kept, ", ")) + } } } } } + m, now := publish(was, found, ms[key], holdings, seated) // Published is written to the state, where every runtime reads it (process.FollowMembership); only one // published, so a membership held back never reaches a reader. if now { -- 2.54.0