mesh/delivery merged: source.main moved to novox/mesh-delivery@da514816204b
mesh/check being carried to ec0b7e2dcc06 on da514816204b | from 24cb379434f5
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.
351 lines
13 KiB
Go
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: "
|