Files
mesh-delivery/modules/mesh-grants/internal/grants/memberships.go
T
jschoubben ec0b7e2dcc
mesh/delivery merged: source.main moved to novox/mesh-delivery@da514816204b
mesh/check being carried to ec0b7e2dcc06 on da514816204b | from 24cb379434f5
Keep holding a membership that would drop a capability its holder still asks for (issue 1945)
A dropped verified-sender comes from a root-free word that failed for one
pass, not from a release, and losing it cut the operator's approvals on
2026-10-27. Holdings.CapabilitiesLost holds such a membership with its own
reason while the holder is assigned and its manifest still asks for it; a
dropped tool or field and a moved seat stay published.
2026-10-27 20:56:09 +01:00

351 lines
13 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. held is
// every holding the memberships composed now carry (Holdings.ByMemberships).
func publish(p Publisher, m Membership, h Holdings, held map[string]bool) (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
}
// A membership is held back only when it would take the last holder from a seat some placed module still claims (the
// 09:21 kind of issue 1945; the lead's decision in review round 1 of its fix, replacing the hold on any loss of review
// 1ff5049), or drop a capability a kind's holder still asks for (verified-sender, dropped by a root-free word that
// failed): a dropped tool or verb, or a seat handed to another node, is published.
if found {
kept, retired := WithoutRetired(was)
m.Retired = retired
var last membership.Membership
if json.Unmarshal(kept, &last) == nil {
last.Node = m.Node
if lost := h.LastHolders(h.ByMemberships(last), held); len(lost) > 0 {
m.Published = ShadowDifference + "the last holder of " + strings.Join(lost, ", ")
return m, false
}
if lost := h.CapabilitiesLost(last, m.Membership); len(lost) > 0 {
m.Published = CapabilityDifference + 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 or the user list's pass says when it was not published because it would
// leave a seat with no holder (LastHolders).
const ShadowDifference = "not published, a shadow difference: publishing would lose "
// Holdings is how the part tells whether a list or a membership would leave a seat with no holder (issue 1945): every
// holding some assigned module claims now, and each seat's scope. A holding is named by its seat for a seat held once
// for the mesh or a site, and `<seat>@<node>` for a seat of every node, so a seat handed to another node keeps its
// holding and one dropped on a node does not.
type Holdings struct {
Claimed map[string]bool
Scopes map[string]string
// Asked is every capability an assigned module's claim of a kind asks for, `<node>/<module>/<seat>/<kind>/<capability>`.
Asked map[string]bool
}
// HoldingsOf is the holdings the assigned modules claim now, by their running manifest or the trunk's, each scope as the
// seat's declaration says (the seat catalogue, or the manifest defining it), else the claim's. Pure.
func HoldingsOf(in Inputs) Holdings {
h := Holdings{Claimed: map[string]bool{}, Scopes: map[string]string{}, Asked: map[string]bool{}}
for _, m := range in.Manifests {
for _, d := range m.DefinesSeats {
if d.Scope != "" {
h.Scopes[canonicalSeat(d.Name)] = d.Scope
}
}
}
for node, modules := range in.Assigned {
for _, module := range modules {
m, ok := in.Running[node+"."+module]
if !ok {
m, ok = in.Manifests[module]
}
if !ok {
continue
}
for _, c := range m.Claims {
seat := canonicalSeat(c.Name)
if _, known := h.scope(seat); !known {
h.Scopes[seat] = c.At()
}
h.Claimed[h.key(seat, node)] = true
for _, capability := range c.Capabilities {
h.Asked[askedKey(node, module, seat, c.Kind, capability)] = true
}
}
}
}
return h
}
func canonicalSeat(name string) string {
if s, ok := catalogue.SeatNamed(name); ok {
return s.Name
}
return name
}
func (h Holdings) scope(seat string) (string, bool) {
if s, ok := catalogue.SeatNamed(seat); ok && s.Scope != "" {
return s.Scope, true
}
sc, ok := h.Scopes[seat]
return sc, ok
}
func (h Holdings) key(seat, node string) string {
seat = canonicalSeat(seat)
if sc, _ := h.scope(seat); sc == catalogue.ScopeNode {
return seat + "@" + node
}
return seat
}
// ByUsers is every holding a user list carries: a user subscribed to a seat's verb (seatSubject) holds it on its
// machine; a verb the seat catalogue retired holds nothing. Pure.
func (h Holdings) ByUsers(users []User) map[string]bool {
out := map[string]bool{}
for _, u := range users {
node, _, _ := strings.Cut(u.User, ".")
if node == "node" || node == "provisioner" {
_, node, _ = strings.Cut(u.User, ".")
}
for _, s := range u.Subscribe {
if m := seatSubject.FindStringSubmatch(s); m != nil && !retiredVerb(m[1], m[2]) {
out[h.key(m[1], node)] = true
}
}
}
return out
}
// ByMemberships is every holding memberships carry: a seat verb served holds the seat on the membership's node. Pure.
func (h Holdings) ByMemberships(ms ...membership.Membership) map[string]bool {
out := map[string]bool{}
for _, m := range ms {
for _, s := range m.Seats {
if !retiredVerb(s.Seat, s.Verb) {
out[h.key(s.Seat, m.Node)] = true
}
}
}
return out
}
// LastHolders is every holding before carries that now does not while an assigned module still claims it: the seats a
// list or a membership would leave with no holder, sorted. Empty means none would; a seat no longer claimed, a verb
// dropped, or a seat held elsewhere now, is no loss here. Pure.
func (h Holdings) LastHolders(before, now map[string]bool) []string {
var out []string
for k := range before {
if !now[k] && h.Claimed[k] {
out = append(out, k)
}
}
sort.Strings(out)
return out
}
func askedKey(node, module, seat, kind, capability string) string {
return node + "/" + module + "/" + canonicalSeat(seat) + "/" + kind + "/" + capability
}
// CapabilitiesLost is every capability a kind's holder carries in the membership in force (its seat traffic's kinds) that
// the composed one lacks while that holder is still assigned and its manifest's claim still asks for it, `<seat> <kind>
// <capability> of <node>/<module>`, sorted. Such a drop comes from a word that failed for one pass (the controller's
// root-free verb drops verified-sender), not from a release, and losing verified-sender stops the operator's approvals by
// a channel (issue 1945; review 1ff5049's protection, kept by the lead in review round 1 of its fix). Pure.
func (h Holdings) CapabilitiesLost(last, now membership.Membership) []string {
if last.SeatTraffic == nil {
return nil
}
has := map[string]bool{}
if now.SeatTraffic != nil {
for _, k := range now.SeatTraffic.Kinds {
for _, c := range k.Capabilities {
has[askedKey(k.Node, k.Module, k.Seat, k.Kind, c)] = true
}
}
}
var out []string
for _, k := range last.SeatTraffic.Kinds {
for _, c := range k.Capabilities {
if key := askedKey(k.Node, k.Module, k.Seat, k.Kind, c); !has[key] && h.Asked[key] {
out = append(out, k.Seat+" "+k.Kind+" "+c+" of "+k.Node+"/"+k.Module)
}
}
}
sort.Strings(out)
return slices.Compact(out)
}
// CapabilityDifference opens what a membership's pass says when it was not published because it would drop a capability
// its holder still asks for.
const CapabilityDifference = ShadowDifference + "a capability its holder still asks for: "