mesh-grants: publish a membership that drops only a retired seat verb, and say every one held back

The guard that holds back a membership losing anything held back five since 01:24: the frozen
controller's memberships still serve the delivery seat's mark-ready, which mesh-sdk d506f21
retired on purpose (ADR 0421), so every membership composed with today's seat catalogue looked
like a loss. A seat verb the catalogue defines no longer is now dropped from the comparison and
named in the pass (retired); any other loss, a module's own seat's verbs included, is still
refused. The log named only the first held membership: it now says each one, with what it would
lose, whenever the set changes, and grants_membership with neither node nor module lists them.

Issue: 1945
This commit is contained in:
2026-10-26 09:11:03 +01:00
parent 13380e3d0e
commit 4ec40eec9a
6 changed files with 176 additions and 10 deletions
+4 -3
View File
@@ -67,9 +67,10 @@ func toolsOf(p *grants.Part) []stdio.Tool {
Run: func(a map[string]any) (any, error) { return p.Grants(grants.Answer(a, "module")) }},
{Name: grants.ToolMembership, Description: "One module's membership on one node (ADR 0547 clause 6): as composed now from the " +
"bus's records with the bus composer, and what the part's process published of it at its last pass (published, " +
"unchanged, or why not: a shadow difference names what publishing would lose), with what the composition cannot " +
"read. Read-only.",
Input: schema(map[string]string{"node": "the machine", "module": "the module"}, "node", "module"),
"unchanged, or why not: a shadow difference names what publishing would lose; verbs the seat catalogue retired are " +
"dropped and named), with what the composition cannot read. With neither node nor module: every membership the last " +
"pass held back, each with what it would lose. Read-only.",
Input: schema(map[string]string{"node": "the machine (with module)", "module": "the module (with node)"}),
Run: func(a map[string]any) (any, error) {
return p.MembershipOf(grants.Answer(a, "node"), grants.Answer(a, "module"))
}},
+58 -1
View File
@@ -639,6 +639,17 @@ func TestGrants(t *testing.T) {
m["serves"] = append(m["serves"].([]any), map[string]any{"subject": "mesh.mod.mod-x.tool.gone"})
return m
}, false, "tool.gone"},
// A verb the seat catalogue retired on purpose (mark-ready, mesh-sdk d506f21, ADR 0421) is dropped and named; a
// verb of a seat the catalogue does not define is still a loss (issue 1945).
{"a change that drops only a retired seat verb is published, the verb named", func(m map[string]any) map[string]any {
m["seats"] = []any{map[string]any{"seat": "mesh-delivery", "verb": "mark-ready", "subject": "mesh.seat.mesh-delivery.tool.mark-ready"}}
return m
}, true, "mesh-delivery mark-ready"},
{"a retired verb and a module seat's verb dropped: not published, the module seat's named", func(m map[string]any) map[string]any {
m["seats"] = []any{map[string]any{"seat": "mesh-delivery", "verb": "mark-ready", "subject": "mesh.seat.mesh-delivery.tool.mark-ready"},
map[string]any{"seat": "telegram-sender", "verb": "send", "subject": "mesh.seat.telegram-sender.tool.send"}}
return m
}, false, "telegram-sender"},
} {
t.Run(row.name, func(t *testing.T) {
b := fixture()
@@ -653,9 +664,12 @@ func TestGrants(t *testing.T) {
t.Fatalf("published %v, not published %v", res.Published, res.NotPublished)
}
if row.published {
if row.last != nil && !strings.Contains(string(b.stream["mesh.assignment."+key]), "10.0.0.1") {
if row.names == "" && row.last != nil && !strings.Contains(string(b.stream["mesh.assignment."+key]), "10.0.0.1") {
t.Errorf("the private network of the last membership is not carried: %s", b.stream["mesh.assignment."+key])
}
if raw, _, _, _ := b.ownGet(grants.StateMemberships, key); !strings.Contains(string(raw), row.names) {
t.Errorf("the pass does not name %q: %s", row.names, raw)
}
return
}
said := strings.Join(res.NotPublished, "\n")
@@ -707,6 +721,49 @@ func TestGrants(t *testing.T) {
})
})
t.Run("memberships held back: every one is said and listed", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
recs, _ := grants.Records(grants.ReadInputs(b, &grants.Manifests{}))
b.stream, b.seqs = map[string][]byte{}, map[string]uint64{}
for i, key := range []string{"a.mod-x", "b.mod-y"} {
raw, _ := json.Marshal(grants.Memberships(recs)[key].Membership)
var m map[string]any
_ = json.Unmarshal(raw, &m)
m["serves"] = append(m["serves"].([]any), map[string]any{"subject": "mesh.mod.lost.tool.gone"})
raw, _ = json.Marshal(m)
subject := "mesh.assignment." + key
b.stream[subject], b.seqs[subject] = raw, uint64(i+1)
}
var logged []string
var lmu sync.Mutex
w := &fakeWatcher{opened: make(chan string, 16)}
f := &grants.Follower{Part: partOn(b), Watch: w, Logf: func(format string, args ...any) {
lmu.Lock()
logged = append(logged, fmt.Sprintf(format, args...))
lmu.Unlock()
}}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { f.Run(ctx); close(done) }()
waitFor(t, func() bool {
lmu.Lock()
defer lmu.Unlock()
all := strings.Join(logged, "\n")
return strings.Contains(all, "held a.mod-x: ") && strings.Contains(all, "held b.mod-y: ")
})
cancel()
<-done
held, err := partOn(b).MembershipOf("", "")
h, _ := held.(map[string]any)
if err != nil || h["count"] != 2 || !strings.Contains(fmt.Sprint(h["held"]), "tool.gone") {
t.Errorf("grants_membership with neither node nor module = %v, %v", held, err)
}
if _, err := partOn(b).MembershipOf("a", ""); err == nil {
t.Error("a node without a module was answered")
}
})
t.Run("a refused read or write never stops it", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
+21 -3
View File
@@ -3,6 +3,7 @@ package grants
import (
"context"
"encoding/json"
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -35,6 +36,8 @@ type Follower struct {
watched map[string]bool
refused map[string]string
have map[string]string
// held is what the last pass held back, said again only when it changes.
held string
}
func (f *Follower) init() {
@@ -142,6 +145,23 @@ func (f *Follower) open() {
}
}
// said logs every membership the pass held back, each with what it would lose (issue 1945), when the set changes from
// the last pass's.
func (f *Follower) said(res Result) {
if held := strings.Join(res.NotPublished, "\n"); held != f.held {
f.held = held
if len(res.NotPublished) == 0 {
f.logf("no membership held back")
} else {
f.logf("%d memberships not published, tried again at the next event (grants_membership with no node and module lists them):",
len(res.NotPublished))
for _, n := range res.NotPublished {
f.logf("held %s", n)
}
}
}
}
// Run composes at start and after every change until ctx ends. It returns only when ctx ends.
func (f *Follower) Run(ctx context.Context) {
f.init()
@@ -159,9 +179,7 @@ func (f *Follower) Run(ctx context.Context) {
if len(res.Published) > 0 {
f.logf("published %d memberships: %v", len(res.Published), res.Published)
}
if len(res.NotPublished) > 0 {
f.logf("%d memberships not published, tried again at the next event; the first: %s", len(res.NotPublished), res.NotPublished[0])
}
f.said(res)
if res.Written {
f.logf("the shadow list moved to revision %d: %d users, %d differing from the loaded list",
res.Revision, res.UserList.Users, len(res.UserList.Compare.Differing()))
@@ -11,6 +11,7 @@ import (
"strings"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
)
@@ -41,6 +42,9 @@ type Membership struct {
// Published says what became of it at the last pass: "published", "unchanged" (equal to the one last published),
// or why it was not.
Published string `json:"published"`
// Retired are the seat verbs the last membership served that mesh-sdk's seat catalogue no longer has, `<seat>
// <verb>`: dropped on purpose, so their loss does not hold the membership back (issue 1945).
Retired []string `json:"retired,omitempty"`
}
// Memberships composes every assignment's membership from the records (buscompose.MembershipFor, as the controller's
@@ -101,7 +105,9 @@ func publish(p Publisher, m Membership) (Membership, bool) {
// composed with no root-free judgement, a kind's verified-sender would be dropped, and the operator's approvals by
// a channel would no longer be honoured. Any loss stays a shadow difference, named.
if found {
if lost := Losses(was, body); len(lost) > 0 {
kept, retired := WithoutRetired(was)
m.Retired = retired
if lost := Losses(kept, body); len(lost) > 0 {
m.Published = ShadowDifference + strings.Join(lost, "; ")
return m, false
}
@@ -114,6 +120,61 @@ func publish(p Publisher, m Membership) (Membership, bool) {
return m, true
}
// WithoutRetired is a membership without the seat verbs it serves that mesh-sdk's seat catalogue no longer has (a verb
// retired on purpose, such as the delivery seat's mark-ready, ADR 0421), and those verbs, `<seat> <verb>`, once each. A
// seat the catalogue does not define (a module's own) keeps every verb, so its loss is still refused. Pure.
func WithoutRetired(last []byte) ([]byte, []string) {
var m map[string]any
if json.Unmarshal(last, &m) != nil {
return last, nil
}
seats, ok := m["seats"].([]any)
if !ok {
return last, nil
}
var kept []any
var retired []string
for _, e := range seats {
served, _ := e.(map[string]any)
seat, _ := served["seat"].(string)
verb, _ := served["verb"].(string)
if retiredVerb(seat, verb) {
if r := seat + " " + verb; !slices.Contains(retired, r) {
retired = append(retired, r)
}
continue
}
kept = append(kept, e)
}
if len(retired) == 0 {
return last, nil
}
if len(kept) == 0 {
delete(m, "seats") // as an empty list is written: not at all
} else {
m["seats"] = kept
}
out, err := json.Marshal(m)
if err != nil {
return last, nil
}
sort.Strings(retired)
return out, retired
}
// retiredVerb says the seat catalogue defines seat with its verbs and no longer has verb among them. A seat the catalogue
// defines without verbs (its holder declares them) retires none.
func retiredVerb(seat, verb string) bool {
if seat == "" || verb == "" {
return false
}
s, ok := catalogue.SeatNamed(seat)
if !ok || len(s.Serves) == 0 {
return false
}
return !slices.ContainsFunc(s.Serves, func(v catalogue.Verb) bool { return v.Name == verb })
}
// sameMembership says two memberships say the same, whatever the order of their fields. Pure.
func sameMembership(was, now []byte) bool {
var a, b any
+1 -1
View File
@@ -97,7 +97,7 @@ type Result struct {
}
// NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships.
const NothingTakesEffect = "memberships only (on the assignments stream, a first one or one that only adds to the last published; a loss stays a shadow difference); the user list, assertions and grants stay in shadow until take-over"
const NothingTakesEffect = "memberships only (on the assignments stream, a first one or one that only adds to the last published or drops only a verb the seat catalogue retired; any other loss stays a shadow difference); the user list, assertions and grants stay in shadow until take-over"
// Part is the grants part: it reads, calls and writes through these, and nothing else.
type Part struct {
+30 -1
View File
@@ -62,8 +62,11 @@ func (p *Part) Grants(module string) (any, error) {
// MembershipOf answers the tool membership: one module on one node as composed now, and as last published by the
// part's process (its state memberships says what became of each at the last pass).
func (p *Part) MembershipOf(node, module string) (any, error) {
if node == "" && module == "" {
return p.Held()
}
if node == "" || module == "" {
return nil, errors.New("membership names a node and a module")
return nil, errors.New("membership names a node and a module, or neither for every membership held back")
}
in := ReadInputs(p.Read, &p.cache)
recs, left := Records(in)
@@ -90,6 +93,32 @@ func (p *Part) MembershipOf(node, module string) (any, error) {
return out, nil
}
// Held answers every membership the part's process held back at its last pass, each `<node>.<module>` with why (what
// publishing would lose), from the pass it keeps (issue 1945).
func (p *Part) Held() (any, error) {
raw, rev, found, err := p.Store.Get(StateUserList, KeyPass)
if err != nil {
return nil, fmt.Errorf("the last pass cannot be read: %w", err)
}
if !found {
return map[string]any{"held": []any{}, "says": "no pass of the part's process has been kept yet"}, nil
}
var res Result
if err := json.Unmarshal(raw, &res); err != nil {
return nil, fmt.Errorf("the last pass cannot be read: %w", err)
}
type held struct {
Membership string `json:"membership"`
Why string `json:"why"`
}
out := []held{}
for _, n := range res.NotPublished {
key, why, _ := strings.Cut(n, ": ")
out = append(out, held{Membership: key, Why: why})
}
return map[string]any{"held": out, "count": len(out), "pass": rev, "published": res.Published}, nil
}
// Pass composes and keeps the result as the last pass, for the tools to read.
func (p *Part) Pass() Result {
res := p.Compose()