Files
mesh-delivery/modules/mesh-grants/internal/grants/memberships.go
T
jschoubben 2a94d16834 mesh-grants: ask the controller's root-free verb and wake on its condition events, so a verified sender is composed (issue 1945)
The part composed every membership with no root-free input, so telegram's verified-sender was dropped and the membership held as a shadow
difference. It now asks mesh-controller.root-free for the machines a composition needs, per pass, failing closed and said once, and wakes
on root-not-free condition-raised and condition-cleared.
2026-10-26 11:11:43 +01:00

257 lines
8.7 KiB
Go

package grants
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"reflect"
"slices"
"sort"
"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"
)
// Memberships are published for real from this milestone (the operator, 2026-10-25 22:31): since release 6 nobody
// publishes them, so a module placed since cannot serve its seat or reach a new state. The user list, the assertions and
// the grants stay in shadow until take-over.
// MembershipGaps are what a membership carries that the part cannot compose from the bus.
var MembershipGaps = []Gap{
{Input: "membership: private network and received contributions", Why: "kept in each node's composition, which only that node reads: carried over from the membership last published, none for a first one"},
{Input: "membership: root-free machines", Why: "asked of the controller's root-free verb at each pass and kept for that pass alone; a machine it does not call free (an error included) is composed as not free, and the pass says why"},
}
// Publisher publishes a membership on the assignments stream (membership.Subject), last per subject, and reads the one
// last published there. A refusal (the grant not yet on the bus) is an error the part says and tries again at the next
// event.
type Publisher interface {
LastMembership(node, module string) (body []byte, seq uint64, found bool, err error)
PublishMembership(node, module string, body []byte, after uint64) error
}
// Membership is one module's membership on one node, as composed now, and what became of its publishing.
type Membership struct {
Node string `json:"node"`
Module string `json:"module"`
Digest string `json:"digest"`
Membership membership.Membership `json:"membership"`
// 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
// parked composition pass, mesh-controller 003eee8a, issued them), by `<node>.<module>`. Pure.
func Memberships(r buscompose.Records) map[string]Membership {
where := buscompose.PlacementsOf(r, r.Interchangeable)
out := map[string]Membership{}
for node, declared := range r.Assigned {
for _, d := range declared {
m := buscompose.MembershipFor(node, d, where)
out[node+"."+d.Module] = Membership{Node: node, Module: d.Module, Digest: digestOf(m), Membership: m}
}
}
return out
}
func digestOf(m membership.Membership) string {
raw, _ := json.Marshal(m)
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:])
}
// Published words of a membership's last pass.
const (
MembershipPublished = "published"
MembershipUnchanged = "unchanged"
)
// publish publishes m when it differs from the membership last published for its assignment, carrying that one's private
// network and received contributions; it answers m as published (or why not) and whether it was published now.
func publish(p Publisher, m Membership) (Membership, bool) {
if p == nil {
m.Published = "not published: no publisher here (only the part's process publishes)"
return m, false
}
was, seq, found, err := p.LastMembership(m.Node, m.Module)
if err != nil {
m.Published = "not published: the membership last published cannot be read: " + err.Error()
return m, false
}
if found {
var last membership.Membership
if json.Unmarshal(was, &last) == nil {
m.Membership.Mesh, m.Membership.Receives = last.Mesh, last.Receives
m.Digest = digestOf(m.Membership)
}
}
body, err := json.Marshal(m.Membership)
if err != nil {
m.Published = "not published: " + err.Error()
return m, false
}
if found && sameMembership(was, body) {
m.Published = MembershipUnchanged
return m, false
}
// Only a first membership, or one that only adds to the last, is published (the lead's decision, review of 1ff5049):
// 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 {
kept, retired := WithoutRetired(was)
m.Retired = retired
if lost := Losses(kept, body); len(lost) > 0 {
m.Published = ShadowDifference + strings.Join(lost, "; ")
return m, false
}
}
if err := p.PublishMembership(m.Node, m.Module, body, seq); err != nil {
m.Published = fmt.Sprintf("not published (tried again at the next event): %v", err)
return m, false
}
m.Published = MembershipPublished
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
if json.Unmarshal(was, &a) != nil || json.Unmarshal(now, &b) != nil {
return false
}
return reflect.DeepEqual(a, b)
}
// ShadowDifference opens what a membership's pass says when it was not published because it would lose something.
const ShadowDifference = "not published, a shadow difference: publishing would lose "
// Losses names everything the last membership holds that now drops or weakens: a field gone, a value changed, a list
// element (a capability, a kind, a served address, a seat, a state, a stream) with no element in now that holds all of it.
// Empty means now holds all the last one did, and maybe more. Pure.
func Losses(last, now []byte) []string {
var a, b any
if json.Unmarshal(last, &a) != nil {
return nil
}
if json.Unmarshal(now, &b) != nil {
return []string{"the new membership is not JSON"}
}
return losses("", a, b)
}
func losses(path string, last, now any) []string {
switch l := last.(type) {
case map[string]any:
n, ok := now.(map[string]any)
if !ok {
return []string{fmt.Sprintf("%s: %s", name(path), brief(last))}
}
keys := make([]string, 0, len(l))
for k := range l {
keys = append(keys, k)
}
sort.Strings(keys)
var out []string
for _, k := range keys {
nv, ok := n[k]
if !ok {
out = append(out, fmt.Sprintf("%s: %s", name(path+"."+k), brief(l[k])))
continue
}
out = append(out, losses(path+"."+k, l[k], nv)...)
}
return out
case []any:
n, _ := now.([]any)
var out []string
for i, e := range l {
if !slices.ContainsFunc(n, func(c any) bool { return len(losses("", e, c)) == 0 }) {
out = append(out, fmt.Sprintf("%s[%d]: %s", name(path), i, brief(e)))
}
}
return out
}
if !reflect.DeepEqual(last, now) {
return []string{fmt.Sprintf("%s: %s, now %s", name(path), brief(last), brief(now))}
}
return nil
}
func name(path string) string {
if path == "" {
return "the membership"
}
return strings.TrimPrefix(path, ".")
}
// brief is a value in JSON, cut at 200 bytes.
func brief(v any) string {
raw, _ := json.Marshal(v)
if len(raw) > 200 {
return string(raw[:200]) + "…"
}
return string(raw)
}