diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 661ef137..e9260a73 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -2414,9 +2414,15 @@ func TestGrants(t *testing.T) { b.loaded = append(b.loaded, grants.User{User: "c.old"}) b.put(grants.ReadHave, "b.mod-y", runs) hash("b.mod-y") - if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "would drop c.old of the list in force") { + if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "would take from loaded users: c.old (left out)") || + !slices.Contains(res.KeptLoaded, "c.old") { t.Errorf("a list dropping a loaded user was not held: %+v", res.Sealed) } + // With its hash, the loaded user the haves do not compose is kept as the loaded list gives it, and the list sealed. + hash("c.old") + if res := p.Compose(); res.Sealed == nil || res.Sealed.Why != "" || !strings.Contains(string(b.own[grants.StateUserList][grants.KeyWrittenUsers]), `"c.old"`) { + t.Errorf("the loaded user was not kept in the list sealed: %+v", res.Sealed) + } // The view answers: the assignments alone places again. delete(b.refused, grants.ReadAssignments) if in := grants.ReadInputs(b, &grants.Manifests{}); in.ByHaves != "" || slices.Contains(in.Assigned["a"], "mesh-delivery-assigner") { @@ -2424,6 +2430,184 @@ func TestGrants(t *testing.T) { } }) + // Issue 1966, the list sealed 2026-11-07 23:09: composed from the bootstrapped seats alone, it took the node seats from the + // tool runners, the vault's reads from the node-engines, the seat queues from the build modules, and left out the ledger + // account of a held module. A pass that composes part of the mesh (the seat list unread, or placed by the haves) seals + // each user of the loaded list and of the list in force with the union of what it composes and what those give it, so it + // only adds (the indexer's serving of mesh-catalog included); one that would still take a permission is held, naming it. + t.Run("a pass composing part of the mesh keeps what the bus loaded, adding only (issue 1966)", func(t *testing.T) { + indexer, err := os.ReadFile("../mesh-delivery-indexer/module.json") + if err != nil { + t.Fatal(err) + } + const ( + nodeSeat = "mesh.seat.node-files.call.*.a.*" + vaultRead = "$JS.API.DIRECT.GET.KV_mesh-vault_signers.>" + buildPull = "$JS.API.CONSUMER.MSG.NEXT.SEAT_NODE_BUILD_GO.workers" + ) + ledgerX := strings.Replace(xManifest, `"state"`, `"requires":[{"name":"records","storage":"ledger","template":"ledger",`+ + `"types":[{"name":"decision","schema":{},"operations":[{"name":"propose","effect":"set-fields"}]}]}],"state"`, 1) + catalogSeat := func(s string) bool { return strings.HasPrefix(s, "mesh.seat.mesh-catalog.") } + // Review round 1: the list in force gives a wide publish and a deny, the pass composes the user narrower with the + // composer's empty deny: the user kept keeps the deny, and dropping it counts as a loss. + for _, d := range []struct { + name string + composed, kept grants.User + loss string + }{ + {"a deny of the list in force is kept", + grants.User{User: "a.mod-v", Publish: []string{"mesh.a"}, Subscribe: []string{}, PublishDeny: []string{}}, + grants.User{User: "a.mod-v", Publish: []string{"mesh.>", "mesh.a"}, Subscribe: []string{}, PublishDeny: []string{"mesh.seat.mesh-vault.desk.>"}}, + "deny dropped mesh.seat.mesh-vault.desk.>"}, + {"a deny the pass composes is kept beside it", + grants.User{User: "a.mod-v", Publish: []string{"mesh.a"}, Subscribe: []string{}, PublishDeny: []string{"mesh.b"}}, + grants.User{User: "a.mod-v", Publish: []string{"mesh.>", "mesh.a"}, Subscribe: []string{}, PublishDeny: []string{"mesh.b", "mesh.seat.mesh-vault.desk.>"}}, + "deny mesh.b"}, + } { + inForce := grants.User{User: "a.mod-v", Publish: []string{"mesh.>"}, Subscribe: []string{}, PublishDeny: []string{"mesh.seat.mesh-vault.desk.>"}} + got := grants.KeepLoaded([]grants.User{d.composed}, nil, map[string]grants.User{"a.mod-v": inForce})["a.mod-v"] + if !slices.Equal(got.Publish, d.kept.Publish) || !slices.Equal(got.PublishDeny, d.kept.PublishDeny) { + t.Errorf("%s: kept %+v, want %+v", d.name, got, d.kept) + } + if !slices.Contains(grants.Losses(d.composed, inForce), d.loss) { + t.Errorf("%s: losses %v name no %q", d.name, grants.Losses(d.composed, inForce), d.loss) + } + } + for _, row := range []struct { + name string + bend func(*fakeBus, *grants.Part) + // held is what the pass's sealed says it holds, "" when it seals. + held string + }{ + {"the seat list unread", func(b *fakeBus, p *grants.Part) { + p.SeatList = &seatGate{bootstrapped: []string{meshbroker.Seat, meshcatalog.Seat}} + }, ""}, + {"placed by the haves", func(b *fakeBus, p *grants.Part) { + b.refused[grants.ReadAssignments] = true + runs := nodeengine.HaveByNodeModuleValue{V: keys.Version, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} + b.put(grants.ReadHave, "a.mod-x", runs) + b.put(grants.ReadHave, "b.mod-y", runs) + b.put(grants.ReadHave, "a.mesh-delivery-indexer", runs) + }, ""}, + // The ledger of a held module stays with its module's users, whatever the seat list (held.go). + {"the ledger of a module whose manifest is refused", func(b *fakeBus, p *grants.Part) { + b.refuse("mod-x", ledgerX, "it is held") + }, ""}, + // The union cannot give a login a loaded user has no hash for: held, naming it. + {"a loaded user with no hash, the seat list unread", func(b *fakeBus, p *grants.Part) { + p.SeatList = &seatGate{bootstrapped: []string{meshbroker.Seat, meshcatalog.Seat}} + delete(b.states[grants.ReadAccounts], "b.mod-y") + }, "would take from loaded users: b.mod-y (left out)"}, + } { + t.Run(row.name, func(t *testing.T) { + b := fixture() + b.manifestOf("mod-x", ledgerX) + b.manifestOf("mesh-delivery-indexer", string(indexer)) + b.assign("a", "mesh-delivery-indexer") + busKey, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + b.put(grants.ReadHave, "a.nats", nodeengine.HaveByNodeModuleValue{V: keys.Version, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK, + UserList: &nodeengine.UserListWritten{Source: grantsstate.GrantsUserListSource, Revision: 8, Wanted: "sha256:aa", Digest: "sha256:aa", Users: 5, + Key: busKey.PublicKey, Follows: grantsstate.FollowGrants}}) + whole := shadowOf(t, b) + for _, u := range whole { + b.put(grants.ReadAccounts, u.User, map[string]any{"user": u.User, "hash": "$2a$10$x", "generation": 1, "fingerprint": "sha256:1"}) + } + p := partOn(b) + p.Objects = &fakeObjects{} + // The list in force, composed whole. + p.Compose() + if g, _, _ := p.Sealed(); g.Digest == "" { + t.Fatal("no list in force sealed") + } + // 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 m map[string]any + if err := json.Unmarshal(b.own[grants.StateMemberships]["a.mesh-delivery-indexer"], &m); err != nil { + t.Fatal(err) + } + var e any + _ = json.Unmarshal([]byte(extra), &e) + m["serves"] = append(m["serves"].([]any), e) + b.own[grants.StateMemberships]["a.mesh-delivery-indexer"], _ = json.Marshal(m) + } + servesExtra := func() bool { + return strings.Contains(string(b.own[grants.StateMemberships]["a.mesh-delivery-indexer"]), "mesh-delivery-indexer.extra") + } + // The bus loaded it with what a whole seat list gives besides, and without the indexer's serving yet. + var loaded []grants.User + for _, u := range whole { + switch u.User { + case "a.mod-x": + u.Subscribe = append(slices.Clone(u.Subscribe), nodeSeat) + case "node.a": + u.Subscribe = append(slices.Clone(u.Subscribe), vaultRead) + case "b.mod-y": + u.Publish = append(slices.Clone(u.Publish), buildPull) + case "a.mesh-delivery-indexer": + u.Subscribe = slices.DeleteFunc(slices.Clone(u.Subscribe), catalogSeat) + } + loaded = append(loaded, u) + } + if !slices.ContainsFunc(loaded, func(u grants.User) bool { return u.User == "ledger.a.mod-x" }) { + t.Fatalf("the fixture composes no ledger user: %v", names(loaded)) + } + b.loaded = loaded + row.bend(b, p) + res := p.Compose() + if g, _, _ := p.Sealed(); true { + t.Logf("sealed %d users (%s), kept with the loaded list's grants %v, said %q", g.Users, short(g.Digest), res.KeptLoaded, res.Sealed.Why) + } + 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") + }) { + 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 row.held != "" { + if res.Sealed == nil || !strings.Contains(res.Sealed.Why, row.held) { + t.Fatalf("not held for %q: %+v", row.held, res.Sealed) + } + return + } + g, _, _ := p.Sealed() + if res.Sealed == nil || res.Sealed.Why != "" || g.Digest == "" { + t.Fatalf("not sealed: %+v (kept %v, refused %v)", res.Sealed, res.KeptLoaded, res.Refused) + } + sealedAs := func(user string) (grants.User, bool) { + raw, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(g.Digest, user)) + var u grants.User + return u, ok && json.Unmarshal(raw, &u) == nil + } + if row.name == "the ledger of a module whose manifest is refused" { + // The seat list read, the pass composes the whole mesh and keeps no loaded permission beyond it; the held + // module's users, its ledger's among them, stay as the list in force holds them. + if u, ok := sealedAs("ledger.a.mod-x"); !ok || len(grants.Losses(u, whole[slices.IndexFunc(whole, func(u grants.User) bool { return u.User == "ledger.a.mod-x" })])) > 0 { + t.Errorf("the held module's ledger account is not kept: %+v (%v)", u, ok) + } + return + } + for _, l := range loaded { + u, ok := sealedAs(l.User) + if !ok { + t.Errorf("%s of the loaded list is not in the list sealed", l.User) + continue + } + if lost := grants.Losses(u, l); len(lost) > 0 { + t.Errorf("%s loses %v in the list sealed", l.User, lost) + } + } + if ix, _ := sealedAs("a.mesh-delivery-indexer"); !slices.ContainsFunc(ix.Subscribe, catalogSeat) { + t.Errorf("the indexer does not gain its serving of mesh-catalog: %v", ix.Subscribe) + } + }) + } + }) + // ADR 0590 clause 5's test for this repository (issue 1126): no module.json here names mesh-controller, the lease // read and the builder's consume of artifacts-gone included. Read as text, as mesh-catalog's controllerdeps check reads // its own: mesh-sdk names no module, so it holds no such check (issue 774, the operator 2026-11-03). @@ -3657,3 +3841,7 @@ func (g *seatGate) MayAct(seats ...string) bool { } return true } + +func short(d string) string { + return strings.TrimPrefix(d, "sha256:")[:min(12, len(strings.TrimPrefix(d, "sha256:")))] +} diff --git a/modules/mesh-grants/internal/grants/held.go b/modules/mesh-grants/internal/grants/held.go index 89283481..23dc5c33 100644 --- a/modules/mesh-grants/internal/grants/held.go +++ b/modules/mesh-grants/internal/grants/held.go @@ -26,7 +26,8 @@ const HeldModuleCondition = "mesh-grants.module-held" // heldModuleUsers are, by user, why it is kept: the users of candidates (the ones the list in force and the bus carry) that // belong to a module placed on a node whose manifest the indexer holds (in.Refused) and whose want names no build. A user of -// a module on a node is `.` or `..` (buscompose.Principal.Username). Pure. +// a module on a node is `.`, `..` or its ledger's `ledger..` +// (buscompose.Principal.Username). Pure. func heldModuleUsers(in Inputs, candidates []string) map[string]string { held := map[string]string{} for _, g := range in.Refused { @@ -55,7 +56,8 @@ func heldModuleUsers(in Inputs, candidates []string) map[string]string { state = "unreadable" } for _, user := range candidates { - if user == prefix || strings.HasPrefix(user, prefix+".") { + // And its ledger account, `ledger..` (issue 1966: dropped from the list sealed 2026-11-07 23:09). + if user == prefix || strings.HasPrefix(user, prefix+".") || user == "ledger."+prefix { out[user] = fmt.Sprintf("the manifest of %s is %s: %s", module, state, why) } } diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 663c9e43..84dc881d 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -167,6 +167,9 @@ type Inputs struct { // Refused is every manifest the indexer holds that the part cannot read or mesh-sdk's reader refuses, assigned or // not, each with why: its module counts as placed nowhere, so it is said loudly, never passed over (issue 1945). Refused []Gap + // SeatListUnread says the seat list has not been read (the gate says so, seatlist.Reader.MayAct with no seat): only the + // bootstrapped seats compose, so the pass keeps what the bus loaded (keeploaded.go). + SeatListUnread bool // ByHaves says why this pass placed the modules where the node-engines' haves say they run, the assignments view not // answering (bootstrap only, issue 1966); empty when the assignments placed them. Such a pass retires nothing // (ReadFailures) and seals no list dropping a user of the list in force (holdsBack). @@ -321,6 +324,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { in := Inputs{Assigned: map[string][]string{}, Manifests: map[string]catalogue.Manifest{}, Digests: map[string]string{}, Fingerprints: map[string]string{}, NoFingerprint: map[string]string{}, Running: map[string]catalogue.Manifest{}, Values: map[string]any{}, Gaps: append([]Gap(nil), standingGaps...)} + in.SeatListUnread = gate != nil && !gate.MayAct() nodes := map[string]bool{} // **The seat's assignments alone places modules** (novox/hq ADR 0549 §5, ADR 0567 §9; issue 774): the controller's // assignments are read no more, and the seats held once for the mesh are held where it says. Not read is a failed diff --git a/modules/mesh-grants/internal/grants/keeploaded.go b/modules/mesh-grants/internal/grants/keeploaded.go new file mode 100644 index 00000000..80ef40a5 --- /dev/null +++ b/modules/mesh-grants/internal/grants/keeploaded.go @@ -0,0 +1,173 @@ +package grants + +import ( + "encoding/json" + "fmt" + "slices" + "sort" +) + +// **A pass that cannot compose the whole mesh keeps what the bus loaded** (issue 1966, the registry delivery's sealed list of +// 2026-11-07 23:09). While the seat list is unread (only the bootstrapped seats compose, ADR 0636 §1) or a pass is placed by +// the node-engines' haves (Inputs.ByHaves), what the composer derives is a part of the mesh: that list sealed took the node +// seats from every tool runner, the vault's reads from the node-engines and the seat queues from the build modules. Such a +// pass composes each user of the loaded list and of the list in force as the union of what it composes and what those lists +// give it (publish, subscribe, the allowance of responses, and every deny any of them says), so it +// only adds, the indexer's serving of the seat that reads the list included; and the list is held where it would still take +// a permission from a loaded user (holdsBack, LoadedLosses). + +// keepsLoaded says why this pass keeps what the bus loaded, or "" when it composes the whole mesh. +func (in Inputs) keepsLoaded() string { + switch { + case in.ByHaves != "": + return in.ByHaves + case in.SeatListUnread: + return "the seat list is unread, so only the bootstrapped seats compose" + } + return "" +} + +// KeepLoaded is, by user, the union of each user of loaded and of inForce with what composed gives it, for every such user +// composed gives less than either list (or not at all). Pure. +func KeepLoaded(composed, loaded []User, inForce map[string]User) map[string]User { + byUser := map[string]User{} + for _, u := range composed { + byUser[u.User] = u + } + others := map[string][]User{} + for _, u := range loaded { + others[u.User] = append(others[u.User], u) + } + for name, u := range inForce { + others[name] = append(others[name], u) + } + out := map[string]User{} + for name, from := range others { + c, composedHere := byUser[name] + short := !composedHere + for _, o := range from { + if len(Losses(c, o)) > 0 { + short = true + } + } + if !short { + continue + } + // Every deny any list says is kept: a deny the composer drops while it composes part of the mesh would widen the + // user (the composer gives every composed user a deny, empty or not, so an intersection would drop the list in + // force's). + u := User{User: name, Publish: c.Publish, Subscribe: c.Subscribe, AllowResponses: c.AllowResponses, PublishDeny: c.PublishDeny} + for _, o := range from { + u.Publish = set(append(slices.Clone(u.Publish), o.Publish...)) + u.Subscribe = set(append(slices.Clone(u.Subscribe), o.Subscribe...)) + u.AllowResponses = u.AllowResponses || o.AllowResponses + if o.PublishDeny != nil || u.PublishDeny != nil { + u.PublishDeny = set(append(slices.Clone(u.PublishDeny), o.PublishDeny...)) + } + } + if u.Publish == nil { + u.Publish = []string{} + } + if u.Subscribe == nil { + u.Subscribe = []string{} + } + out[name] = u + } + return out +} + +// Losses are how now differs from had where had's word is lost: each publish and subscribe had gives that now does not, +// the allowance of responses, and, where had's denies are known, each deny now adds and each deny of had now drops (a deny +// dropped widens the user past what had held it to). Pure. +func Losses(now, had User) []string { + var out []string + for _, s := range minus(had.Publish, now.Publish) { + out = append(out, "publish "+s) + } + for _, s := range minus(had.Subscribe, now.Subscribe) { + out = append(out, "subscribe "+s) + } + if had.AllowResponses && !now.AllowResponses { + out = append(out, "responses") + } + if had.PublishDeny != nil { + for _, s := range minus(now.PublishDeny, had.PublishDeny) { + out = append(out, "deny "+s) + } + for _, s := range minus(had.PublishDeny, now.PublishDeny) { + out = append(out, "deny dropped "+s) + } + } + return out +} + +// LoadedLosses names, sorted, each loaded user the list composed now (listed: its users; composed: their grants) would +// take a permission from or leave out, with how many it loses. Pure. +func LoadedLosses(listed []string, composed, loaded []User) []string { + byUser := map[string]User{} + for _, u := range composed { + byUser[u.User] = u + } + var out []string + for _, l := range loaded { + c, ok := byUser[l.User] + switch { + case !slices.Contains(listed, l.User) || !ok: + out = append(out, l.User+" (left out)") + default: + if n := len(Losses(c, l)); n > 0 { + out = append(out, fmt.Sprintf("%s (%d permissions)", l.User, n)) + } + } + } + sort.Strings(out) + return slices.Compact(out) +} + +// MembershipNarrows names what the membership last published (was) gives that the one composed now does not, field by field +// (` `), the private network, the contributions received and the bindings aside, which the pass carries over +// or the bus module writes; none when now covers was. Both are memberships as JSON. Pure. +func MembershipNarrows(was, now []byte) []string { + var a, b map[string]any + if json.Unmarshal(was, &a) != nil || json.Unmarshal(now, &b) != nil { + return nil + } + var out []string + for _, field := range sortedKeys(a) { + switch field { + case "mesh", "receives", "bindings": + continue + } + out = append(out, uncovered(field, a[field], b[field])...) + } + return out +} + +// uncovered names each part of a that b does not hold: a key of an object by key, an element of a list by its value. +func uncovered(path string, a, b any) []string { + switch av := a.(type) { + case map[string]any: + bv, _ := b.(map[string]any) + var out []string + for _, k := range sortedKeys(av) { + out = append(out, uncovered(path+"."+k, av[k], bv[k])...) + } + return out + case []any: + bv, _ := b.([]any) + var out []string + for _, e := range av { + ej, _ := json.Marshal(e) + if !slices.ContainsFunc(bv, func(x any) bool { xj, _ := json.Marshal(x); return string(xj) == string(ej) }) { + out = append(out, path+" "+string(ej)) + } + } + return out + } + aj, _ := json.Marshal(a) + bj, _ := json.Marshal(b) + if string(aj) != string(bj) { + return []string{path + " " + string(aj)} + } + return nil +} diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 3a120965..02dca360 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" + "slices" "sort" "strings" "sync" @@ -105,7 +106,10 @@ type Result struct { Memberships int `json:"memberships"` MembershipsWritten int `json:"memberships_written"` Published []string `json:"published,omitempty"` - NotPublished []string `json:"not_published,omitempty"` + // KeptLoaded are the users a pass composing part of the mesh gave the union of what it composes and what the loaded list + // and the list in force give them (keeploaded.go, issue 1966), read with grants_user_list's last pass. + KeptLoaded []string `json:"kept_loaded,omitempty"` + NotPublished []string `json:"not_published,omitempty"` // Retired is, by `.`, the seat verbs its last published membership served that mesh-sdk's seat // catalogue no longer has (Membership.Retired): the pass keeps it, since the state memberships holds the bare // membership alone (issue 774). @@ -331,7 +335,27 @@ func (p *Part) Compose() Result { } p.sayPrincipalsRefused(refused, kept) p.sayHeldModules(heldUsers, kept) - users = WithHashes(withKept(users, kept), in.Hashes) + users = withKept(users, kept) + // A pass that composes part of the mesh keeps what the bus loaded and the list in force give each user (keeploaded.go). + if why := in.keepsLoaded(); why != "" { + if union := KeepLoaded(users, loaded, inForce); len(union) > 0 { + merged := make(map[string]string, len(allRefused)+len(union)) + keptAll := make(map[string]User, len(kept)+len(union)) + for u, w := range allRefused { + merged[u] = w + } + for u, k := range kept { + keptAll[u] = k + } + for u, k := range union { + merged[u], keptAll[u] = "keeps what the loaded list gives it: "+why, k + } + users = withKept(slices.DeleteFunc(users, func(u User) bool { _, ok := union[u.User]; return ok }), union) + allRefused, kept = merged, keptAll + res.KeptLoaded = sortedKeys(union) + } + } + users = WithHashes(users, in.Hashes) st.Digest, st.Users = Digest(users), len(users) st.Compare = Compare(users, loaded, Placed(in)).Excusing(ExcusedBy(MultiHeld(listRecs))) if loadErr != nil { @@ -381,7 +405,7 @@ func (p *Part) Compose() Result { bus = names(loaded) lost = holdings.LastHolders(holdings.ByUsers(loaded), holdings.ByUsers(users)) } - p.writeSealed(&res, list, n, leftOut, dropped, in, bus, lost, users) + p.writeSealed(&res, list, n, leftOut, dropped, in, bus, lost, users, loaded) } ms := Memberships(recs) res.Memberships = len(ms) @@ -410,6 +434,17 @@ func (p *Part) Compose() Result { 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 + } + } + } + } // 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 { diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index 139642f3..591c5c94 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -83,7 +83,7 @@ func (p *Part) Sealed() (grantsstate.GrantsUserList, uint64, bool) { // writeSealed puts the list sealed to the bus node and writes the key, each only when what it says changed. It says in // res what it wrote, or why it wrote nothing. -func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut, dropped []string, in Inputs, bus []string, lost []string, composed []User) { +func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut, dropped []string, in Inputs, bus []string, lost []string, composed, loaded []User) { cur, _, had := p.Sealed() next := cur // The bus node follows this list alone (ADR 0571 clause 8): always since the controller ended by its unassignment @@ -104,10 +104,10 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut, dropped why = "no node runs the bus's module, so the list is sealed to nobody" case key == "": why = "the bus node's have of the bus module names no key yet (its node-engine says it from this release)" - case cur.Digest != digest && p.holdsBack(in, leftOut, dropped, bus, lost, listUsers(list), digest) != "": + case cur.Digest != digest && p.holdsBack(in, leftOut, dropped, bus, lost, listUsers(list), composed, loaded, digest) != "": // The list in force is never replaced by one composed past a failed read, or one that drops a user of it for want // of a hash: that user's module would be locked out of the bus (review of the switch). - why = p.holdsBack(in, leftOut, dropped, bus, lost, listUsers(list), digest) + why = p.holdsBack(in, leftOut, dropped, bus, lost, listUsers(list), composed, loaded, digest) held = why p.keepHashHold(res, digest) case cur.Digest != digest || cur.SealedTo != grantsstate.SealingKeyID(key) || cur.Node != in.BusNode: @@ -299,10 +299,10 @@ func listUsers(list []byte) []string { // it kept of it; where it kept none (a list written before this release), the users the bus loaded (bus, nil when // they could not be read). Empty when it may. // -// In a pass placed by the node-engines' haves (in.ByHaves, issue 1966) the list is held, too, while it would leave out any user -// of the list in force or of the list the bus loaded (listed are the users of the list composed now): a bootstrap placement -// adds logins and never takes one away. -func (p *Part) holdsBack(in Inputs, leftOut, dropped, bus, lost, listed []string, digest string) string { +// In a pass that composes part of the mesh (placed by the node-engines' haves, or the seat list unread; keeploaded.go, issue +// 1966) the list is held, too, while it would leave out any user of the list in force or take any permission from a user of +// the list the bus loaded (listed are the users of the list composed now, composed their grants): such a pass only adds. +func (p *Part) holdsBack(in Inputs, leftOut, dropped, bus, lost, listed []string, composed, loaded []User, digest string) string { p.hashHold = nil if f := in.ReadFailed(); len(f) > 0 { return "held: the list in force is kept while a read of this pass failed (" + strings.Join(f, "; ") + ")" @@ -324,7 +324,10 @@ func (p *Part) holdsBack(in Inputs, leftOut, dropped, bus, lost, listed []string default: had = bus } - if in.ByHaves != "" { + if why := in.keepsLoaded(); why != "" { + if losses := LoadedLosses(listed, composed, loaded); len(losses) > 0 { + return "held: " + why + ", and the list composed so would take from loaded users: " + strings.Join(losses, ", ") + } var gone []string for _, u := range append(slices.Clone(had), bus...) { if !slices.Contains(listed, u) && !slices.Contains(gone, u) { @@ -333,7 +336,7 @@ func (p *Part) holdsBack(in Inputs, leftOut, dropped, bus, lost, listed []string } if len(gone) > 0 { slices.Sort(gone) - return "held: " + in.ByHaves + ", and the list composed so would drop " + strings.Join(gone, ", ") + " of the list in force" + return "held: " + why + ", and the list composed so would drop " + strings.Join(gone, ", ") + " of the list in force" } } var unhashed []string