Files
mesh-delivery/modules/mesh-grants/internal/grants/userlist.go
T
jschoubben 1a1d3a1ecc Compose both of a rotating node's logins from the vault's node-logins, so its engine can test-dial the new one
ADR 0583 §1 (insight 2026-11-01) and §2: the grants part reads the vault's node-logins as its named reader and
composes a node user per login, both while the node rotates and the new one alone after rotation-done; each node's
engine reads its own node's key alone (composed by mesh-sdk while the vault serves rotate-node). The part follows the
state by watch, so a rotation begun or ended composes again at once. A second login is judged its node's when the
list is compared (Placed), and a hash changing by a rotation node-logins names is not counted as a writer changing
several hashes at once (sealed.go). The state not on the bus yet is a standing gap: every node logs in as node.<n>,
as today. Tested as a subtest of TestGrants, no new test function. Built against the group's mesh-sdk head 5000774
through a go.work outside the worktree; the pin is the lead's.

(issue 774, 774/S53)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:45:44 +01:00

720 lines
26 KiB
Go

package grants
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
"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/delivery"
"git.novox.be/novox/mesh-sdk/go/outward"
)
// User is one bus user and its grants, as the bus module's nats_users answers them: never a password hash.
type User struct {
User string `json:"user"`
Publish []string `json:"publish"`
Subscribe []string `json:"subscribe"`
AllowResponses bool `json:"allow_responses"`
// PublishDeny is what the user may not publish though an allow reaches it (buscompose's ControllerOnly). The bus
// module's nats_users answers none today, so a loaded user's is nil, read as unknown; empty and not nil once it says.
PublishDeny []string `json:"publish_deny,omitempty"`
// Hash is the bcrypt hash the vault holds for the user (WithHashes): in memory alone, never in a digest, a compare
// or the part's state, and empty for a loaded user.
Hash string `json:"-"`
}
// WithHashes gives each user of the shadow list the hash the vault holds for it, when it holds one.
func WithHashes(users []User, hashes map[string]HeldHash) []User {
for i := range users {
users[i].Hash = hashes[users[i].User].Hash
}
return users
}
// Credential is one user's hash as the list would carry it: whether the vault holds one, and how the user stands in the
// shadow list and the loaded list. Never the hash: a bcrypt hash of the same password differs by its salt, so two hashes
// say nothing compared.
type Credential struct {
User string `json:"user"`
InShadow bool `json:"in_shadow"`
Loaded bool `json:"loaded"`
HashHeld bool `json:"hash_held"`
Adopted bool `json:"adopted,omitempty"`
// Generation is the vault's generation of the account the hash is of; zero for an adopted one.
Generation int `json:"generation,omitempty"`
// Would is how the list with the vault's hashes would differ from the loaded one for this user: same (a hash held,
// loaded), adds (a hash held, not loaded), left-out (in the shadow list, no hash: left out for want of a
// credential), loses-login (loaded, no hash held: its login would be lost) or not-composed (a hash held, loaded,
// and not in the shadow list).
Would string `json:"would"`
}
// The ways a user's line would differ.
const (
WouldSame = "same"
WouldAdd = "adds"
WouldLeaveOut = "left-out"
WouldLoseLogin = "loses-login"
WouldNotComposed = "not-composed"
)
// Credentials is every user's Credential, and the users of each way that differs.
type Credentials struct {
Users []Credential `json:"users"`
HashesHeld int `json:"hashes_held"`
LeftOut []string `json:"left_out"`
LosesLogin []string `json:"loses_login"`
Adds []string `json:"adds"`
}
// CredentialsOf says, of every user of the shadow and the loaded list, whether the vault holds its hash and how its line
// would differ. Pure.
func CredentialsOf(shadow, loaded []User, hashes map[string]HeldHash) Credentials {
c := Credentials{Users: []Credential{}, LeftOut: []string{}, LosesLogin: []string{}, Adds: []string{}}
rows := map[string]*Credential{}
row := func(user string) *Credential {
if rows[user] == nil {
h, held := hashes[user]
rows[user] = &Credential{User: user, HashHeld: held, Adopted: h.Adopted, Generation: h.Generation}
}
return rows[user]
}
for _, u := range shadow {
row(u.User).InShadow = true
}
for _, u := range loaded {
row(u.User).Loaded = true
}
for _, user := range sortedKeys(rows) {
r := rows[user]
switch {
case r.HashHeld && r.InShadow && r.Loaded:
r.Would = WouldSame
case r.HashHeld && r.InShadow:
r.Would = WouldAdd
c.Adds = append(c.Adds, user)
case r.InShadow:
r.Would = WouldLeaveOut
c.LeftOut = append(c.LeftOut, user)
case !r.HashHeld:
r.Would = WouldLoseLogin
c.LosesLogin = append(c.LosesLogin, user)
default:
r.Would = WouldNotComposed
}
if r.HashHeld {
c.HashesHeld++
}
c.Users = append(c.Users, *r)
}
return c
}
// ComposeUsers is the shadow list: every user the composer derives from the records, with its grants; an error when any
// principal's grants are refused (ComposeEach composes past one). Pure.
func ComposeUsers(r buscompose.Records) ([]User, error) {
users, refused, err := ComposeEach(r)
if err != nil {
return nil, err
}
if len(refused) > 0 {
first := sortedKeys(refused)[0]
return nil, fmt.Errorf("%s: %s", first, refused[first])
}
return users, nil
}
func set(in []string) []string {
out := slices.Clone(in)
sort.Strings(out)
out = slices.Compact(out)
if out == nil {
out = []string{}
}
return out
}
// Digest is a list's digest, `sha256:<hex>`, over its users and grants in order.
func Digest(users []User) string {
raw, _ := json.Marshal(users)
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:])
}
// LoadedUsers is the list the bus loaded, as its module's nats_users reads it from the file: every user by name, then
// each one's grants in full.
func LoadedUsers(c Caller) ([]User, error) {
raw, err := c.Call(CallUsers, map[string]any{})
if err != nil {
return nil, fmt.Errorf("%s: %w", CallUsers, err)
}
var names struct {
Users []struct {
User string `json:"user"`
} `json:"users"`
}
if err := json.Unmarshal(unwrap(raw), &names); err != nil {
return nil, fmt.Errorf("%s answered no list of users: %w", CallUsers, err)
}
full := map[string]User{}
ask := func(part string) error {
raw, err := c.Call(CallUsers, map[string]any{"user": part})
if err != nil {
return fmt.Errorf("%s %s: %w", CallUsers, part, err)
}
var got struct {
Users []User `json:"users"`
}
if err := json.Unmarshal(unwrap(raw), &got); err != nil {
return fmt.Errorf("%s %s answered no grants: %w", CallUsers, part, err)
}
for _, u := range got.Users {
u.Publish, u.Subscribe = set(u.Publish), set(u.Subscribe)
full[u.User] = u
}
return nil
}
// Every name with a dot in one call; each name without one (the controller) alone.
if err := ask("."); err != nil {
return nil, err
}
out := make([]User, 0, len(names.Users))
for _, n := range names.Users {
if _, ok := full[n.User]; !ok {
if err := ask(n.User); err != nil {
return nil, err
}
}
u, ok := full[n.User]
if !ok {
return nil, fmt.Errorf("%s names %s and gives no grants for it", CallUsers, n.User)
}
out = append(out, u)
}
sort.Slice(out, func(i, j int) bool { return out[i].User < out[j].User })
return out, nil
}
// unwrap takes a tool's answer out of an MCP content wrapper, when a runtime hands one.
func unwrap(raw json.RawMessage) []byte {
var wrapped struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
}
if json.Unmarshal(raw, &wrapped) == nil && len(wrapped.Content) == 1 && wrapped.Content[0].Text != "" {
return []byte(wrapped.Content[0].Text)
}
return raw
}
// UserDiff is how one user's grants differ: what the shadow list grants that the loaded list does not, and back.
type UserDiff struct {
User string `json:"user"`
PublishAdded []string `json:"publish_added,omitempty"`
PublishDropped []string `json:"publish_dropped,omitempty"`
SubscribeAdded []string `json:"subscribe_added,omitempty"`
SubscribeDropped []string `json:"subscribe_dropped,omitempty"`
Responses string `json:"responses,omitempty"`
// DenyAdded is what the shadow list denies the user to publish and the loaded list does not: a right lost. DenyUnread
// is the shadow's deny of a user whose grants differ, where the loaded list's deny cannot be read (nats_users answers
// none): it cannot be compared, so it is no added right either (ADR 0562 §2).
DenyAdded []string `json:"deny_added,omitempty"`
DenyUnread []string `json:"deny_unread,omitempty"`
}
// same says the user's grants agree in both lists.
func (d UserDiff) same() bool {
return len(d.PublishAdded)+len(d.PublishDropped)+len(d.SubscribeAdded)+len(d.SubscribeDropped)+len(d.DenyAdded) == 0 && d.Responses == ""
}
// Comparison is the shadow list against the loaded list.
type Comparison struct {
Shadow int `json:"shadow"`
Loaded int `json:"loaded"`
// Added are users in the shadow list the loaded list lacks (one left out for want of a credential among them);
// Dropped are loaded users the shadow list lacks; Gone are those of Dropped whose machine or module placement no
// longer names, which both lists agree are gone.
Added []string `json:"added"`
Dropped []string `json:"dropped"`
Gone []string `json:"gone,omitempty"`
Differ []UserDiff `json:"differ"`
// Excused are the subjects the comparison does not count, each with why (Comparison.Excusing).
Excused []Excuse `json:"excused,omitempty"`
Same int `json:"same"`
// Unread says the loaded list could not be read, and why: nothing is compared then.
Unread string `json:"unread,omitempty"`
}
// Equal says the two lists agree in every user, but those both agree are gone.
func (c Comparison) Equal() bool {
return c.Unread == "" && len(c.Added) == 0 && len(c.Differ) == 0 && len(c.Dropped) == len(c.Gone)
}
// Differing names every user that keeps the lists apart: added, dropped and not gone, or granted otherwise.
func (c Comparison) Differing() []string {
var out []string
out = append(out, c.Added...)
for _, d := range c.Dropped {
if !slices.Contains(c.Gone, d) {
out = append(out, d)
}
}
for _, d := range c.Differ {
out = append(out, d.User)
}
sort.Strings(out)
return out
}
// responsesLost says the shadow list takes away a user's allowance of responses the loaded list gives.
func (d UserDiff) responsesLost() bool { return strings.HasPrefix(d.Responses, "shadow false") }
// Removing names every user that keeps the lists apart other than by a right the shadow list adds, each with how: a user
// the shadow list adds, a loaded user it drops that placement still names, a publish or subscribe right a loaded user
// would lose, or an allowance of responses lost (ADR 0562 §2). Empty when the lists agree or differ only by added rights.
func (c Comparison) Removing() []string {
var out []string
for _, u := range c.Added {
out = append(out, u+" (a user the loaded list does not hold)")
}
for _, u := range c.Dropped {
if !slices.Contains(c.Gone, u) {
out = append(out, u+" (dropped, and placement still names it)")
}
}
for _, d := range c.Differ {
var lost []string
if n := len(d.PublishDropped); n > 0 {
lost = append(lost, fmt.Sprintf("%d publish", n))
}
if n := len(d.SubscribeDropped); n > 0 {
lost = append(lost, fmt.Sprintf("%d subscribe", n))
}
if d.responsesLost() {
lost = append(lost, "its allowance of responses")
}
if n := len(d.DenyAdded); n > 0 {
lost = append(lost, fmt.Sprintf("%d publish to a deny added", n))
}
switch {
case len(lost) > 0:
out = append(out, d.User+" (would lose "+strings.Join(lost, " and ")+")")
case len(d.DenyUnread) > 0:
out = append(out, d.User+" (a deny the loaded list cannot be compared on)")
}
}
sort.Strings(out)
return out
}
// AddedRights are the rights the shadow list adds, by user: every user's added publish and subscribe rights and an
// allowance of responses gained, whatever else the comparison holds. They are the whole difference only where Removing is
// empty, and only then is a take-over allowed for them (ADR 0562 §1, §2).
func (c Comparison) AddedRights() []UserDiff {
var out []UserDiff
for _, d := range c.Differ {
a := UserDiff{User: d.User, PublishAdded: d.PublishAdded, SubscribeAdded: d.SubscribeAdded}
if !d.responsesLost() {
a.Responses = d.Responses
}
if len(a.PublishAdded)+len(a.SubscribeAdded) > 0 || a.Responses != "" {
out = append(out, a)
}
}
return out
}
// AddedDigest is the digest of every right the shadow list adds: what an ask names and its yes answers (ADR 0562 §3).
func (c Comparison) AddedDigest() string {
var lines []string
for _, d := range c.AddedRights() {
for _, s := range d.PublishAdded {
lines = append(lines, d.User+"\tpublish\t"+s)
}
for _, s := range d.SubscribeAdded {
lines = append(lines, d.User+"\tsubscribe\t"+s)
}
if d.Responses != "" {
lines = append(lines, d.User+"\tresponses")
}
}
sort.Strings(lines)
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
return "sha256:" + hex.EncodeToString(sum[:])
}
// The kinds an added right is told by in the operator's ask.
const (
kindAdvisory = "advisories"
kindRead = "reads"
kindCall = "calls"
kindEvent = "events"
kindOther = "other"
)
var rightKinds = []string{kindRead, kindAdvisory, kindCall, kindEvent, kindOther}
func kindOfRight(subject string) string {
switch {
case strings.HasPrefix(subject, "$JS.EVENT.ADVISORY."):
return kindAdvisory
case strings.HasPrefix(subject, "$JS.") || strings.HasPrefix(subject, "$KV.") || strings.HasPrefix(subject, "$O."):
return kindRead
case strings.HasPrefix(subject, "mesh.seat."):
return kindCall
case strings.HasPrefix(subject, "mesh.mod.") && strings.Contains(subject, ".event."):
return kindEvent
}
return kindOther
}
// kindWords says what each kind is, in the operator's words.
var kindWords = map[string]string{kindRead: "reads of buckets and streams", kindAdvisory: "stream-created advisories",
kindCall: "seat calls", kindEvent: "events", kindOther: "other subjects"}
// tally is one user's added rights counted by kind, as "36 reads, 1 call"; responses allowed are said too.
func tally(d UserDiff) string {
n := map[string]int{}
for _, s := range append(append([]string(nil), d.PublishAdded...), d.SubscribeAdded...) {
n[kindOfRight(s)]++
}
var parts []string
for _, k := range rightKinds {
if n[k] > 0 {
word := k
if n[k] == 1 {
word = map[string]string{kindAdvisory: "advisory", kindRead: "read", kindCall: "call", kindEvent: "event", kindOther: "other"}[k]
}
parts = append(parts, fmt.Sprintf("%d %s", n[k], word))
}
}
if d.Responses != "" {
parts = append(parts, "responses allowed")
}
return strings.Join(parts, ", ")
}
// AddedWordsBudget is how many characters the ask's list of added rights may take, so the ask fits one message on a
// channel such as Telegram (4096 characters) with its headline, explanation and options.
const AddedWordsBudget = 2200
// AddedWords are an ask's words for the rights the shadow list adds (ADR 0562 §3): a summary, a point per kind with its
// total, and the list by user, users with the same count grouped on one line; within the content rule (outward.Check,
// a name that would not pass written with spaces) and AddedWordsBudget, past which the last lines are counted, not named.
func (c Comparison) AddedWords() (summary string, points []string, list string) {
adds := c.AddedRights()
if len(adds) == 0 || c.Unread != "" || len(c.Removing()) > 0 {
return "", nil, ""
}
total, byKind, usersOf := 0, map[string]int{}, map[string]int{}
group := map[string][]string{}
for _, d := range adds {
seen := map[string]bool{}
for _, s := range append(append([]string(nil), d.PublishAdded...), d.SubscribeAdded...) {
k := kindOfRight(s)
total++
byKind[k]++
if !seen[k] {
seen[k] = true
usersOf[k]++
}
}
t := tally(d)
group[t] = append(group[t], plainName(d.User))
}
summary = fmt.Sprintf("The grants part's list adds %s to %s and takes none away; no user, hash or login changes.", count(total, "right"), count(len(adds), "user"))
var kinds []string
for _, k := range rightKinds {
if byKind[k] > 0 {
kinds = append(kinds, fmt.Sprintf("%d %s (%s)", byKind[k], kindWords[k], count(usersOf[k], "user")))
}
}
// One point of the kinds, wrapped at the explanation's point length.
line := "Added: "
for i, k := range kinds {
if i > 0 && len([]rune(line))+len([]rune(k))+2 > 160 {
points = append(points, strings.TrimSuffix(line, ", "))
line = "Added: "
}
line += k + ", "
}
points = append(points, strings.TrimSuffix(line, ", ")+".")
if n := len(c.Excused); n > 0 {
points = append(points, fmt.Sprintf("Not counted: %d rights excused as before (a retired verb, a seat held on several machines).", n))
}
tallies := sortedKeys(group)
sort.SliceStable(tallies, func(i, j int) bool { return len(group[tallies[i]]) > len(group[tallies[j]]) })
var lines []string
used := 0
for i, t := range tallies {
users := group[t]
sort.Strings(users)
l := strings.Join(users, ", ") + ": " + t
if len(users) > 1 {
l += " each"
}
if used+len([]rune(l))+1 > AddedWordsBudget-80 {
rest := 0
for _, more := range tallies[i:] {
rest += len(group[more])
}
lines = append(lines, fmt.Sprintf("and %d more users: grants_user_list names every right added.", rest))
break
}
lines = append(lines, l)
used += len([]rune(l)) + 1
}
return summary, points, strings.Join(lines, "\n")
}
// count is n things, with the plural where n is not one.
func count(n int, thing string) string {
if n == 1 {
return "1 " + thing
}
return fmt.Sprintf("%d %ss", n, thing)
}
// Refusals are why a take-over is refused for the lists themselves, each naming its users: the loaded list unread, any user
// the lists keep apart other than by an added right (Removing), and any user with no hash in the vault but those both
// lists agree are gone. Empty when the lists differ at most by added rights and every login is kept.
func Refusals(c Comparison, cr Credentials) []string {
var reasons []string
if c.Unread != "" {
reasons = append(reasons, "the loaded list could not be read: "+c.Unread)
}
if d := c.Removing(); len(d) > 0 {
reasons = append(reasons, fmt.Sprintf("%d users differ between the shadow list and the loaded list other than by an added right: %s", len(d), strings.Join(d, ", ")))
}
var unhashed []string
for _, u := range append(append([]string(nil), cr.LeftOut...), cr.LosesLogin...) {
if !slices.Contains(c.Gone, u) {
unhashed = append(unhashed, u)
}
}
if len(unhashed) > 0 {
sort.Strings(unhashed)
reasons = append(reasons, fmt.Sprintf("the vault holds no hash for %d users (ADR 0547 clause 9: handed or adopted before take-over): %s",
len(unhashed), strings.Join(unhashed, ", ")))
}
return reasons
}
// TakeOverWords are the take-over ask's words for the lists as compared now: the rights the grants part's list adds
// (AddedWords) when nothing else keeps them apart, or else what a yes would be refused for, the users named in its list
// (ADR 0562 §2, §3). Empty when the lists agree. Held to the content rule: the loaded list's read error is not said.
func TakeOverWords(c Comparison, cr Credentials) (summary string, points []string, list string) {
refusals := Refusals(c, cr)
if len(refusals) == 0 {
return c.AddedWords()
}
summary = "Today a yes would be refused: the lists differ other than by an added right, the loaded list is unread, or a user has no hash."
var lines []string
if c.Unread != "" {
lines = append(lines, "The loaded list could not be read.")
refusals = refusals[1:]
}
used := 0
for _, r := range refusals {
if used+len([]rune(r)) > AddedWordsBudget-80 {
r = string([]rune(r)[:max(0, AddedWordsBudget-80-used)]) + "… grants_take_over_state says the rest."
}
lines = append(lines, r)
used += len([]rune(r)) + 1
if used >= AddedWordsBudget-80 {
break
}
}
for i := range lines {
if _, ok := outward.Check(lines[i]); !ok {
lines[i] = strings.ReplaceAll(lines[i], ".", " ")
}
}
return summary, []string{"Your yes now is refused and said, naming them; ask again once they agree."}, strings.Join(lines, "\n")
}
// plainName is a user's name as the operator channel's content rule lets it pass: as it is, or with spaces for its dots
// where it reads as a host name.
func plainName(user string) string {
if _, ok := outward.Check(user); ok {
return user
}
return strings.ReplaceAll(user, ".", " ")
}
// Compare compares the shadow list with the loaded one. placed says whether placement still names a loaded user's
// machine and module; nil names every one. Pure.
func Compare(shadow, loaded []User, placed func(user string) bool) Comparison {
c := Comparison{Shadow: len(shadow), Loaded: len(loaded), Added: []string{}, Dropped: []string{}, Differ: []UserDiff{}}
have := map[string]User{}
for _, u := range loaded {
have[u.User] = u
}
want := map[string]bool{}
for _, s := range shadow {
want[s.User] = true
l, ok := have[s.User]
if !ok {
c.Added = append(c.Added, s.User)
continue
}
d := UserDiff{User: s.User, PublishAdded: minus(s.Publish, l.Publish), PublishDropped: minus(l.Publish, s.Publish),
SubscribeAdded: minus(s.Subscribe, l.Subscribe), SubscribeDropped: minus(l.Subscribe, s.Subscribe)}
if s.AllowResponses != l.AllowResponses {
d.Responses = fmt.Sprintf("shadow %v, loaded %v", s.AllowResponses, l.AllowResponses)
}
if l.PublishDeny != nil {
d.DenyAdded = minus(s.PublishDeny, l.PublishDeny)
}
if d.same() {
// The same allows compose the same deny: one composer derives it from them.
c.Same++
continue
}
if l.PublishDeny == nil {
d.DenyUnread = s.PublishDeny
}
c.Differ = append(c.Differ, d)
}
for _, l := range loaded {
if !want[l.User] {
c.Dropped = append(c.Dropped, l.User)
if placed != nil && !placed(l.User) {
c.Gone = append(c.Gone, l.User)
}
}
}
return c
}
func minus(a, b []string) []string {
var out []string
for _, x := range a {
if !slices.Contains(b, x) {
out = append(out, x)
}
}
return out
}
// Placed says whether placement still names a user's machine and module: a machine's users (`node.<n>`,
// `provisioner.<n>`) while the machine is seen, a module's or process's (`<n>.<module>[.<process>]`) while the module is
// assigned there. The controller, enrolment users and persons are never judged gone from here.
func Placed(in Inputs) func(user string) bool {
return func(user string) bool {
parts := strings.Split(user, ".")
switch {
case len(parts) < 2:
return true
case parts[0] == "node" || parts[0] == "provisioner":
// A node's second login, node.<n>--b, is its node's (ADR 0583 §1).
return slices.Contains(in.Nodes, strings.TrimSuffix(parts[1], delivery.SecondLoginSuffix))
case parts[0] == "person" || parts[0] == "enrol":
return true
}
return slices.Contains(in.Nodes, parts[0]) && slices.Contains(in.Assigned[parts[0]], parts[1])
}
}
// Excuse is one subject the comparison does not count as a difference between the lists, and why.
type Excuse struct {
User string `json:"user"`
Subject string `json:"subject"`
Why string `json:"why"`
}
// Excusing removes from the differences the subjects f excuses (added: the shadow grants it and the loaded list does not),
// naming each in Excused; a user left with no difference counts as the same. Pure.
func (c Comparison) Excusing(f func(user, subject string, added, subscribe bool) string) Comparison {
var differ []UserDiff
for _, d := range c.Differ {
keep := func(list []string, added, subscribe bool) []string {
var out []string
for _, s := range list {
if why := f(d.User, s, added, subscribe); why != "" {
c.Excused = append(c.Excused, Excuse{User: d.User, Subject: s, Why: why})
continue
}
out = append(out, s)
}
return out
}
d.PublishAdded, d.PublishDropped = keep(d.PublishAdded, true, false), keep(d.PublishDropped, false, false)
d.SubscribeAdded, d.SubscribeDropped = keep(d.SubscribeAdded, true, true), keep(d.SubscribeDropped, false, true)
if d.same() {
c.Same++
continue
}
differ = append(differ, d)
}
if differ == nil {
differ = []UserDiff{}
}
c.Differ = differ
return c
}
// seatSubject is a seat verb's subject, for any caller (`.*`) or one machine's (`.<node>`, a seat of every node).
var seatSubject = regexp.MustCompile(`^mesh\.seat\.([^.]+)\.(?:call|tool)\.([^.]+)(?:\.[^.]+)?$`)
var seatService = regexp.MustCompile(`^\$SRV\.(?:INFO|PING|STATS)\.([^.]+)(?:\.>)?$`)
// ExcusedBy is the excuse of a comparison until placement lives in the catalogue (the operator, 2026-10-26): a verb the
// seat catalogue retired on purpose, which the shadow no longer grants, is no difference; and what the shadow grants more
// for a mesh seat that is claimed on several machines (the standing gap seat holdings: it composes the seat as held on each)
// is named and not counted, for a user on a machine that claims the seat and only as a subscription (what it serves), since
// a subscription to a seat's verbs on a machine whose holder does not answer them harms nothing and is gone when holdings
// follow the catalogue. Nothing else is excused: an added publish, a grant of a user on another machine, or the loss of any
// other subject, keeps the lists apart.
func ExcusedBy(multiHeld map[string][]string) func(user, subject string, added, subscribe bool) string {
// holds says the user is on a machine where the seat is claimed: the machine's tool runner, or a module of it.
holds := func(user, seat string) bool {
node, _, _ := strings.Cut(user, ".")
return slices.Contains(multiHeld[seat], node)
}
return func(user, subject string, added, subscribe bool) string {
if m := seatSubject.FindStringSubmatch(subject); m != nil {
if !added && subscribe && retiredVerb(m[1], m[2]) {
return "a verb the seat catalogue retired"
}
if added && subscribe && holds(user, m[1]) {
return "seat " + m[1] + " is claimed on several machines: composed as held on each until holdings follow the catalogue"
}
}
if m := seatService.FindStringSubmatch(subject); m != nil && added && subscribe && holds(user, m[1]) {
return "seat " + m[1] + " is claimed on several machines: composed as held on each until holdings follow the catalogue"
}
return ""
}
}
// MultiHeld are the mesh seats (held once for the mesh) that more than one machine's module claims, each with the
// machines that claim it.
func MultiHeld(r buscompose.Records) map[string][]string {
nodes := map[string][]string{}
for node, declared := range r.Assigned {
for _, d := range declared {
for _, s := range d.Holds {
if (s.Scope == "" || s.Scope == catalogue.ScopeMesh) && !slices.Contains(nodes[s.Name], node) {
nodes[s.Name] = append(nodes[s.Name], node)
}
}
}
}
for seat, ns := range nodes {
if len(ns) < 2 {
delete(nodes, seat)
continue
}
sort.Strings(ns)
}
return nodes
}