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/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. Pure. func ComposeUsers(r buscompose.Records) ([]User, error) { principals, err := buscompose.Users(r) if err != nil { return nil, err } out := make([]User, 0, len(principals)) for _, p := range principals { perms, err := buscompose.PermissionsFor(p) if err != nil { return nil, fmt.Errorf("%s: %w", p.Username(), err) } out = append(out, User{User: p.Username(), Publish: set(perms.Publish), Subscribe: set(perms.Subscribe), AllowResponses: perms.AllowResponses, PublishDeny: set(perms.PublishDeny)}) } sort.Slice(out, func(i, j int) bool { return out[i].User < out[j].User }) return out, 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:`, 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.`, // `provisioner.`) while the machine is seen, a module's or process's (`.[.]`) 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": return slices.Contains(in.Nodes, parts[1]) 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 (`.`, 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 }