diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 3d44661..2ea008b 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -21,6 +21,7 @@ import ( "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" "git.novox.be/novox/mesh-sdk/go/seal" "mesh-grants/internal/grants" @@ -745,28 +746,151 @@ func TestGrants(t *testing.T) { changed := slices.Clone(shadow) x := slices.IndexFunc(changed, func(u grants.User) bool { return u.User == "a.mod-x" }) changed[x].Publish = append(slices.Clone(changed[x].Publish[1:]), "extra.subject") + // Only added rights (ADR 0562): the loaded list lacks a publish of a.mod-x the shadow grants. + fewer := slices.Clone(shadow) + fewer[x].Publish = slices.Clone(fewer[x].Publish[1:]) + // A deny the shadow adds where the loaded list says its own (empty): a publish right lost. And the same deny where + // the loaded list says none, as nats_users answers today, on a user whose grants differ: it cannot be compared. + denied := slices.Clone(shadow) + denied[x].PublishDeny = []string{"mesh.seat.node-launcher.tool.secret"} + readable := slices.Clone(fewer) + for i := range readable { + readable[i].PublishDeny = []string{} + } + // The live comparison the operator's yes was refused on, 2026-10-27 21:19 UTC (issue 1945): 41 users differ, every + // difference an added right. Read whole; a row's mutate makes one of the cases that must still refuse. + var live struct { + Compare grants.Comparison `json:"compare"` + Credentials grants.Credentials `json:"credentials"` + } + if raw, err := os.ReadFile(filepath.Join("testdata", "takeover-compare-2026-10-27.json")); err != nil || json.Unmarshal(raw, &live) != nil { + t.Fatalf("the captured comparison cannot be read: %v", err) + } + if len(live.Compare.Differ) != 41 || len(live.Credentials.LosesLogin)+len(live.Credentials.LeftOut) != 0 { + t.Fatalf("the captured comparison is not the one of 21:19: %d differ", len(live.Compare.Differ)) + } + captured := func(mutate func(*grants.Comparison)) *grants.Comparison { + raw, _ := json.Marshal(live.Compare) + var c grants.Comparison + _ = json.Unmarshal(raw, &c) + if mutate != nil { + mutate(&c) + } + return &c + } + nodeAce := func(c *grants.Comparison) *grants.UserDiff { + return &c.Differ[slices.IndexFunc(c.Differ, func(d grants.UserDiff) bool { return d.User == "node.ace" })] + } for _, row := range []struct { name string loaded []grants.User added, dropped, gone []string differ []string equal bool + // live is a captured comparison used in place of Compare; removing the users take-over is refused for + // (ADR 0562 §2), and adds the users whose added rights the ask names. + live *grants.Comparison + removing []string + adds int }{ - {"equal", shadow, nil, nil, nil, nil, true}, - {"one user the bus lacks", shadow[1:], []string{shadow[0].User}, nil, nil, nil, false}, - {"one loaded user still placed", append(slices.Clone(shadow), grants.User{User: "a.mod-x.worker"}), nil, []string{"a.mod-x.worker"}, nil, nil, false}, - {"one loaded user placement no longer names", append(slices.Clone(shadow), grants.User{User: "b.gone"}), nil, []string{"b.gone"}, []string{"b.gone"}, nil, true}, - {"grants differ", changed, nil, nil, nil, []string{"a.mod-x"}, false}, + {"equal", shadow, nil, nil, nil, nil, true, nil, nil, 0}, + {"one user the bus lacks", shadow[1:], []string{shadow[0].User}, nil, nil, nil, false, nil, []string{shadow[0].User}, 0}, + {"one loaded user still placed", append(slices.Clone(shadow), grants.User{User: "a.mod-x.worker"}), nil, []string{"a.mod-x.worker"}, nil, nil, false, nil, []string{"a.mod-x.worker"}, 0}, + {"one loaded user placement no longer names", append(slices.Clone(shadow), grants.User{User: "b.gone"}), nil, []string{"b.gone"}, []string{"b.gone"}, nil, true, nil, nil, 0}, + {"grants differ", changed, nil, nil, nil, []string{"a.mod-x"}, false, nil, []string{"a.mod-x"}, 1}, + {"only a right added", fewer, nil, nil, nil, []string{"a.mod-x"}, false, nil, nil, 1}, + {"a deny added refuses", readable, nil, nil, nil, []string{"a.mod-x"}, false, nil, []string{"a.mod-x"}, 1}, + {"a deny the loaded list cannot say refuses a user whose grants differ", fewer, nil, nil, nil, []string{"a.mod-x"}, false, nil, []string{"a.mod-x"}, 1}, + {"captured 21:19: only added rights", nil, nil, nil, nil, nil, false, captured(nil), nil, 41}, + {"captured, a right removed", nil, nil, nil, nil, nil, false, captured(func(c *grants.Comparison) { + nodeAce(c).SubscribeDropped = []string{"mesh.mod.mesh-vault.event.secret-changed"} + }), []string{"node.ace"}, 41}, + {"captured, responses lost", nil, nil, nil, nil, nil, false, captured(func(c *grants.Comparison) { + nodeAce(c).Responses = "shadow false, loaded true" + }), []string{"node.ace"}, 41}, + {"captured, a user dropped", nil, nil, nil, nil, nil, false, captured(func(c *grants.Comparison) { + c.Dropped = []string{"novox.mesh-vault.worker"} + }), []string{"novox.mesh-vault.worker"}, 41}, + {"captured, a user added", nil, nil, nil, nil, nil, false, captured(func(c *grants.Comparison) { + c.Added = []string{"shanks.new-module"} + }), []string{"shanks.new-module"}, 41}, + {"captured, a login lost", nil, nil, nil, nil, nil, false, captured(nil), []string{"novox.telegram"}, 41}, } { t.Run(row.name, func(t *testing.T) { - c := grants.Compare(shadow, row.loaded, grants.Placed(in)) - var differ []string - for _, d := range c.Differ { - differ = append(differ, d.User) + var c grants.Comparison + if row.live != nil { + c = *row.live + } else { + from := shadow + if strings.Contains(row.name, "deny") { + from = denied + } + c = grants.Compare(from, row.loaded, grants.Placed(in)) + var differ []string + for _, d := range c.Differ { + differ = append(differ, d.User) + } + if !slices.Equal(c.Added, nonNil(row.added)) || !slices.Equal(c.Dropped, nonNil(row.dropped)) || + !slices.Equal(c.Gone, row.gone) || !slices.Equal(differ, row.differ) || c.Equal() != row.equal { + t.Errorf("compare = %+v", c) + } } - if !slices.Equal(c.Added, nonNil(row.added)) || !slices.Equal(c.Dropped, nonNil(row.dropped)) || - !slices.Equal(c.Gone, row.gone) || !slices.Equal(differ, row.differ) || c.Equal() != row.equal { - t.Errorf("compare = %+v", c) + var cr grants.Credentials + if row.live != nil { + cr = live.Credentials + } + if strings.Contains(row.name, "login lost") { + cr.LosesLogin = []string{"novox.telegram"} + } + // Removing, and the users with no hash in the vault, are what the yes is refused for. + var removing []string + for _, r := range c.Removing() { + removing = append(removing, strings.Fields(r)[0]) + } + removing = append(removing, cr.LosesLogin...) + if !slices.Equal(removing, row.removing) || len(c.AddedRights()) != row.adds { + t.Errorf("take-over refused for %v, want %v; added rights of %d users, want %d", c.Removing(), row.removing, len(c.AddedRights()), row.adds) + } + // The ask's words pass the operator channel's content rule, read at a glance and fit one message; where only + // rights are added they name every user whose rights are added, and where a loss stands they never say none is + // taken away and name the users the yes would be refused for. + summary, points, list := grants.TakeOverWords(c, cr) + if row.removing != nil { + if strings.Contains(summary, "takes none away") { + t.Errorf("the ask says none is taken away with a loss: %q", summary) + } + for _, u := range row.removing { + if !strings.Contains(list, u) { + t.Errorf("the ask does not name %s, refused for: %s", u, list) + } + } + } + if row.live != nil && row.removing == nil && c.Equal() { + t.Error("the captured comparison is equal: the old guard would not have refused it") + } + if row.adds > 0 { + if r, ok := outward.Check(summary + "\n" + strings.Join(points, "\n") + "\n" + list); !ok { + t.Errorf("the ask's words break the content rule: %s", r) + } + } + if row.adds > 0 && row.removing == nil { + expl := summary + "\n" + strings.Join(points, "\n") + for _, text := range []string{expl, list} { + if r, ok := outward.Check(text); !ok { + t.Errorf("the ask's words break the content rule: %s", r) + } + } + if err := asks.Glance(expl); err != nil || len([]rune(list)) > grants.AddedWordsBudget { + t.Errorf("the ask's words do not fit: %v, a list of %d characters", err, len([]rune(list))) + } + for _, d := range c.AddedRights() { + if !strings.Contains(list, d.User) && !strings.Contains(list, "more users") { + t.Errorf("the ask does not name %s", d.User) + } + } + } + if row.live != nil && row.removing == nil && (!strings.Contains(summary, "adds 391 rights to 41 users") || !strings.Contains(list, "node.ace, node.novox: 36 reads, 1 call, 1 event each")) { + t.Errorf("the captured ask says %q and lists %q", summary, list) } if row.name == "grants differ" && (len(c.Differ[0].PublishAdded) != 1 || c.Differ[0].PublishDropped[0] != "extra.subject") { t.Errorf("the grant difference is not named: %+v", c.Differ[0]) @@ -1059,6 +1183,23 @@ func TestGrants(t *testing.T) { return res.Asked } yes, no := asks.Option{ID: "yes"}, asks.Option{ID: "no"} + // settled is the placement of the lists agreeing, every hash held, and the loaded list as edit leaves it. + settled := func(edit func(modX *grants.User)) func() { + return func() { + b.states[grants.ReadAssignments] = nil + b.assign("a", "nats") + b.assign("a", "mod-x") + b.assign("b", "mod-y") + b.loaded = append(shadowOf(t, b), grants.User{User: "b.gone"}) + x := slices.IndexFunc(b.loaded, func(u grants.User) bool { return u.User == "a.mod-x" }) + b.loaded[x].Publish = slices.Clone(b.loaded[x].Publish) + edit(&b.loaded[x]) + for _, u := range shadowOf(t, b) { + b.put(grants.ReadAccounts, u.User, map[string]any{"user": u.User, "hash": "$2a$10$x", "generation": 1}) + } + } + } + lacks := func(modX *grants.User) { modX.Publish = modX.Publish[1:] } // the shadow adds a right (ADR 0562) for _, row := range []struct { name, act string option asks.Option @@ -1066,20 +1207,22 @@ func TestGrants(t *testing.T) { said, never []string // the refusal's words and the users it must not name state string outcome string + // between changes the lists after the ask and before the yes; asked are words the ask must carry. + between func() + asked []string }{ - {"a yes while a user differs is refused and said, naming it", "take-over", yes, func() {}, []string{"users differ", "b.mod-x"}, []string{"b.gone"}, grants.Shadow, "refused"}, - {"a no leaves the switch", "take-over", no, func() {}, nil, nil, grants.Shadow, "declined"}, - {"a yes with the lists agreeing and every hash held records the switch", "take-over", yes, func() { - b.states[grants.ReadAssignments] = nil - b.assign("a", "nats") - b.assign("a", "mod-x") - b.assign("b", "mod-y") - b.loaded = append(shadowOf(t, b), grants.User{User: "b.gone"}) - for _, u := range shadowOf(t, b) { - b.put(grants.ReadAccounts, u.User, map[string]any{"user": u.User, "hash": "$2a$10$x", "generation": 1}) - } - }, nil, nil, grants.TookOver, "recorded"}, - {"a yes to hand back records the switch back", "hand-back", yes, func() {}, nil, nil, grants.Shadow, "recorded"}, + {"a yes while a user differs is refused and said, naming it", "take-over", yes, func() {}, []string{"users differ", "b.mod-x"}, []string{"b.gone"}, grants.Shadow, "refused", nil, nil}, + {"a no leaves the switch", "take-over", no, func() {}, nil, nil, grants.Shadow, "declined", nil, nil}, + {"a yes while a loaded user would lose a right is refused, naming it", "take-over", yes, settled(func(modX *grants.User) { + modX.Publish = append(modX.Publish, "extra.subject") + }), []string{"other than by an added right", "a.mod-x (would lose 1 publish)"}, []string{"b.gone"}, grants.Shadow, "refused", nil, nil}, + {"a yes after the added rights changed since the ask is refused", "take-over", yes, settled(lacks), []string{"changed since you were asked"}, nil, grants.Shadow, "refused", + func() { + lacks(&b.loaded[slices.IndexFunc(b.loaded, func(u grants.User) bool { return u.User == "a.mod-x" })]) + }, []string{"a.mod-x: 1 read"}}, + {"a yes with only added rights and every hash held records the switch, the ask naming them", "take-over", yes, settled(lacks), nil, nil, grants.TookOver, "recorded", + nil, []string{"adds 1 right to 1 user and takes none away", "a.mod-x: 1 read"}}, + {"a yes to hand back records the switch back", "hand-back", yes, func() {}, nil, nil, grants.Shadow, "recorded", nil, nil}, } { t.Run(row.name, func(t *testing.T) { row.setup() @@ -1100,6 +1243,22 @@ func TestGrants(t *testing.T) { if yesOpt, ok := ask.Option("yes"); !ok || yesOpt.Level != asks.Approve || !strings.HasPrefix(yesOpt.Binds, "sha256:") { t.Errorf("the yes does not approve and bind its act: %+v", yesOpt) } + for _, text := range []string{ask.Explanation, ask.Details} { + if r, ok := outward.Check(text); !ok { + t.Errorf("the ask breaks the content rule: %s", r) + } + } + if err := asks.Glance(ask.Explanation); err != nil { + t.Error(err) + } + for _, w := range row.asked { + if !strings.Contains(ask.Explanation+"\n"+ask.Details, w) { + t.Errorf("the ask does not say %q: %s\n%s", w, ask.Explanation, ask.Details) + } + } + if row.between != nil { + row.between() + } said = nil if err := p.Warranted(row.option, warrant(id)); err != nil { t.Fatal(err) diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 7756720..16d177f 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -299,7 +299,7 @@ func (p *Part) Compose() Result { } } if !p.deciding.Load() { - if err := p.RaiseRequested(); err != nil { + if err := p.RaiseRequested(res.UserList); err != nil { res.Errors = append(res.Errors, err.Error()) } if err := p.Reconcile(); err != nil { diff --git a/modules/mesh-grants/internal/grants/takeover.go b/modules/mesh-grants/internal/grants/takeover.go index 6ee843d..bd5e678 100644 --- a/modules/mesh-grants/internal/grants/takeover.go +++ b/modules/mesh-grants/internal/grants/takeover.go @@ -5,7 +5,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "sort" "strings" "time" @@ -67,6 +66,9 @@ type Request struct { // RaisedAt is when it was asked, and Failed why asking it failed (the ask is then closed as refused). RaisedAt time.Time `json:"raised_at,omitempty"` Failed string `json:"failed,omitempty"` + // Adds is the digest of the rights the grants part's list added when the take-over was asked (Comparison.AddedDigest): + // the rights the ask named, which its yes answers (ADR 0562 §3). + Adds string `json:"adds,omitempty"` } // Asker is the client of the router the part asks through (mesh-sdk asker.Client). @@ -174,9 +176,9 @@ func (p *Part) request(act, caller, why string) (any, error) { return map[string]any{"asked": a.Ask, "says": "the part's process asks the operator on their channel; only the operator's yes records the switch, and it takes effect when the bus node loads the grants part's list. grants_take_over_state shows where it stands"}, nil } -// RaiseRequested is the process's: it asks the operator the request a tool left, once. Where the part has no asker (the -// tools) it does nothing. -func (p *Part) RaiseRequested() error { +// RaiseRequested is the process's: it asks the operator the request a tool left, once, naming the rights the pass's +// comparison c adds. Where the part has no asker (the tools) it does nothing. +func (p *Part) RaiseRequested(st UserListState) error { if p.Asker == nil { return nil } @@ -194,10 +196,13 @@ func (p *Part) RaiseRequested() error { if s, err := p.readSwitch(); err != nil || s.Open != r.ID { return nil // an ask no longer open is not asked } - ask, err := p.AskOf(r) + ask, err := p.AskOf(r, st) if err != nil { return err } + if r.Act == ActTakeOver { + r.Adds = st.Compare.AddedDigest() + } if err := p.Asker.Ask(ask); err != nil { // Closed as refused, and the half-made record the asker kept before publishing is taken away, so nothing stays open. r.Failed = err.Error() @@ -295,16 +300,31 @@ func (p *Part) Following() string { return "not yet: the bus node loaded another list" } -// AskOf is the ask of a request, in the words the operator reads: what changes, why, what it risks. Its yes binds the act. -func (p *Part) AskOf(r Request) (asks.Ask, error) { +// AskOf is the ask of a request, in the words the operator reads: what changes, why, what it risks; for a take-over every +// right the grants part's list adds by the user list st compares now, by user, or what a yes would be refused for +// (ADR 0562 §3, TakeOverWords). Its yes binds the act. +func (p *Part) AskOf(r Request, st UserListState) (asks.Ask, error) { + c := st.Compare verb, headline, does, stays, expl := "grants_take_over", "Switch the bus's user list to the grants part?", "The switch is recorded; it takes effect when the bus node loads the grants part's list.", "The controller's list stays in force.", "The grants part has composed the user list the bus holds today. Your yes records the switch; it takes effect when the bus node loads the part's list.\n"+ - "If the lists differ in any user, or a user has no hash in the vault, the switch is refused and you are told which." + "If the lists differ other than by an added right, or a user has no hash in the vault, the switch is refused and you are told which." if r.Act == ActHandBack { verb, headline, does, stays, expl = "grants_hand_back", "Give the bus's user list back to the controller?", "The switch back is recorded; it takes effect when the bus node loads the controller's list.", "The grants part's list stays in force.", "The bus follows the grants part's list now.\nThe controller still writes its list until it loses its lease, so the bus can follow it again." } - bind, err := asks.ActDigest(asks.Act{"verb": verb, "module": "mesh-grants", "ask": r.ID}) + details := "Asked by " + r.By + ": " + r.Why + act := asks.Act{"verb": verb, "module": "mesh-grants", "ask": r.ID} + if summary, points, list := TakeOverWords(c, st.Credentials); r.Act == ActTakeOver && summary != "" { + if len(Refusals(c, st.Credentials)) > 0 { + expl = summary + "\n" + strings.Join(points, "\n") + details = "Refused for:\n" + list + "\n" + details + } else { + expl = summary + " Your yes records the switch; it takes effect when the bus node loads the part's list.\n" + strings.Join(points, "\n") + details = "Added, by user:\n" + list + "\nA user added or dropped, a right taken away or a missing hash refuses the switch, naming them.\n" + details + } + act["adds"] = c.AddedDigest() + } + bind, err := asks.ActDigest(act) if err != nil { return asks.Ask{}, err } @@ -312,7 +332,7 @@ func (p *Part) AskOf(r Request) (asks.Ask, error) { Options: []asks.Option{{ID: "yes", Label: "Yes, switch", Does: does, Level: asks.Approve, Binds: bind}, {ID: "no", Label: "No, leave it", Does: stays, Level: asks.Acknowledge}}, Expires: p.now().Add(asks.ApproveLasts - time.Minute), OnExpiry: stays + " Nothing changes.", About: "mesh-grants-" + r.Act, - Details: "Asked by " + r.By + ": " + r.Why}, nil + Details: details}, nil } // Warranted is the process's act on the operator's answer (asker.Client.Listen hands it once, after checking the ask, the @@ -336,7 +356,7 @@ func (p *Part) Warranted(o asks.Option, w asks.Warrant) error { } var reasons []string if act == ActTakeOver { - reasons = p.Decide() + reasons = p.decide(w.Ask) if s, err = p.readSwitch(); err != nil { // Decide clears a refusal and writes the switch: read it again return err } @@ -390,35 +410,25 @@ func (p *Part) finish(s Switch, a Act) error { } // Decide is what stands between a take-over and the grants part's list, read from a composition made now: the loaded list -// not read, any user the two lists keep apart (one the loaded list has that the shadow cannot compose among them, such as -// an enrolment user or a person whose records are in the controller's store alone), and any user with no hash in the vault. +// not read, any user the two lists keep apart other than by a right the shadow list adds (a user added, a loaded user +// dropped that placement still names, a right a loaded user would lose: ADR 0562, which changes ADR 0547 clause 8's +// equality), and any user with no hash in the vault. // Nothing here but what is true now: a take-over is refused for these and for no other reason. Empty means none. func (p *Part) Decide() []string { + return p.decide("") +} + +// decide is Decide for the yes to the ask (none: empty). A right the shadow list adds is no reason (ADR 0562 §1): the ask +// names it, and the yes covers the rights the ask named and no other (ADR 0562 §3). +func (p *Part) decide(ask string) []string { p.deciding.Store(true) res := p.Compose() p.deciding.Store(false) - var reasons []string - if c := res.UserList.Compare; !c.Equal() { - if c.Unread != "" { - reasons = append(reasons, "the loaded list could not be read: "+c.Unread) - } - if d := c.Differing(); len(d) > 0 { - reasons = append(reasons, fmt.Sprintf("%d users differ between the shadow list and the loaded list: %s", len(d), strings.Join(d, ", "))) - } + reasons := Refusals(res.UserList.Compare, res.UserList.Credentials) + if ask != "" && p.askedAdds(ask) != res.UserList.Compare.AddedDigest() { + reasons = append(reasons, "the rights the grants part's list adds changed since you were asked, so the yes does not cover them: ask the take-over again") } reasons = append(reasons, p.followReasons(res, ReadInputs(p.Read, &p.cache))...) - cr, gone := res.UserList.Credentials, res.UserList.Compare.Gone - var unhashed []string - for _, u := range append(append([]string(nil), cr.LeftOut...), cr.LosesLogin...) { - if !contains(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, ", "))) - } if len(reasons) == 0 { if s, err := p.readSwitch(); err == nil && len(s.Refusing) > 0 { p.clearRefused(&s) @@ -428,6 +438,19 @@ func (p *Part) Decide() []string { return reasons } +// askedAdds is the digest of the added rights the ask named: empty when its request is not the one kept. +func (p *Part) askedAdds(ask string) string { + raw, _, found, err := p.Store.Get(StateTakeover, KeyRequest) + if err != nil || !found { + return "" + } + var r Request + if json.Unmarshal(raw, &r) != nil || r.ID != ask { + return "" + } + return r.Adds +} + func contains(s []string, v string) bool { for _, x := range s { if x == v { diff --git a/modules/mesh-grants/internal/grants/userlist.go b/modules/mesh-grants/internal/grants/userlist.go index bffade3..601236c 100644 --- a/modules/mesh-grants/internal/grants/userlist.go +++ b/modules/mesh-grants/internal/grants/userlist.go @@ -12,6 +12,7 @@ import ( "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. @@ -20,6 +21,9 @@ type User struct { 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:"-"` @@ -125,7 +129,7 @@ func ComposeUsers(r buscompose.Records) ([]User, error) { 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}) + 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 @@ -223,6 +227,16 @@ type UserDiff struct { 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. @@ -264,6 +278,282 @@ func (c Comparison) Differing() []string { 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 { @@ -285,10 +575,17 @@ func Compare(shadow, loaded []User, placed func(user string) bool) Comparison { if s.AllowResponses != l.AllowResponses { d.Responses = fmt.Sprintf("shadow %v, loaded %v", s.AllowResponses, l.AllowResponses) } - if len(d.PublishAdded)+len(d.PublishDropped)+len(d.SubscribeAdded)+len(d.SubscribeDropped) == 0 && d.Responses == "" { + 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 { @@ -355,7 +652,7 @@ func (c Comparison) Excusing(f func(user, subject string, added, subscribe bool) } 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 len(d.PublishAdded)+len(d.PublishDropped)+len(d.SubscribeAdded)+len(d.SubscribeDropped) == 0 && d.Responses == "" { + if d.same() { c.Same++ continue } diff --git a/modules/mesh-grants/testdata/takeover-compare-2026-10-27.json b/modules/mesh-grants/testdata/takeover-compare-2026-10-27.json new file mode 100644 index 0000000..7bb7387 --- /dev/null +++ b/modules/mesh-grants/testdata/takeover-compare-2026-10-27.json @@ -0,0 +1,1166 @@ +{ + "captured": "novox/mesh-grants.grants_user_list at 2026-10-27T21:19:53Z, revision 94593 (issue 1945): its compare and credentials, no hash", + "compare": { + "added": [], + "differ": [ + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_keys", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_pauses", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_toolchains", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-checker_kept", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_manifests" + ], + "user": "ace.mesh-build-go" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-image_keys", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-image_pauses", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-image_steps" + ], + "user": "ace.mesh-build-image" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_keys", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_pauses", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_toolchains" + ], + "user": "ace.mesh-build-node" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_assignments", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_builds", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_composed", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_grants", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_asks", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_compositions", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_differences", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_puts", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_wants", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_builds", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_candidates", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_manifests", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_modules", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_placement", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_placement-candidates", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-teller_answers", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-watchman_presence", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_node-engine_have" + ], + "user": "ace.mesh-delivery-assigner" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_asks", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_installed", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_records", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_rounds", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_runs", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_updates" + ], + "user": "ace.pacman.keeper" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_assignments", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_route-proxy_bound" + ], + "user": "ace.route-proxy" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_keys", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_pauses", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_toolchains", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-checker_kept", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_manifests" + ], + "user": "g14.mesh-build-go" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_keys", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_pauses", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_toolchains" + ], + "user": "g14.mesh-build-node" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-mail-notifier_shown" + ], + "user": "g14.mesh-mail-notifier" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_asks", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_installed", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_records", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_rounds", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_runs", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_updates" + ], + "user": "g14.pacman.keeper" + }, + { + "publish_added": [ + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.ace.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.novox.keycloak.issuer", + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.novox.mailu.domain", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_placement.>", + "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node", + "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_placement.>", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.ace.>", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.novox.keycloak.issuer", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.novox.mailu.domain", + "$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_placement.>", + "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node", + "$JS.API.STREAM.INFO.KV_mesh-config_resolved", + "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_placement", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_placement-candidates", + "$JS.FC.KV_mesh-config_resolved.>", + "$JS.FC.KV_mesh-delivery-builder_builds.>", + "$JS.FC.KV_mesh-delivery-indexer_manifests.>", + "$JS.FC.KV_mesh-delivery-indexer_modules.>", + "$JS.FC.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.FC.KV_mesh-delivery-indexer_placement.>", + "mesh.seat.mesh-vault.call.own.node~ace" + ], + "subscribe_added": [ + "mesh.mod.mesh-vault.event.secret-changed" + ], + "user": "node.ace" + }, + { + "publish_added": [ + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.ace.influxdb.bucket", + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.ace.influxdb.org", + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.g14.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.novox.keycloak.issuer", + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.novox.mailu.domain", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_placement.>", + "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node", + "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_placement.>", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.ace.influxdb.bucket", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.ace.influxdb.org", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.g14.>", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.novox.keycloak.issuer", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.novox.mailu.domain", + "$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_placement.>", + "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node", + "$JS.API.STREAM.INFO.KV_mesh-config_resolved", + "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_placement", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_placement-candidates", + "$JS.FC.KV_mesh-config_resolved.>", + "$JS.FC.KV_mesh-delivery-builder_builds.>", + "$JS.FC.KV_mesh-delivery-indexer_manifests.>", + "$JS.FC.KV_mesh-delivery-indexer_modules.>", + "$JS.FC.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.FC.KV_mesh-delivery-indexer_placement.>", + "mesh.seat.mesh-vault.call.own.node~g14" + ], + "subscribe_added": [ + "mesh.mod.mesh-vault.event.secret-changed" + ], + "user": "node.g14" + }, + { + "publish_added": [ + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.ace.influxdb.bucket", + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.ace.influxdb.org", + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.novox.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_placement.>", + "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node", + "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_placement.>", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.ace.influxdb.bucket", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.ace.influxdb.org", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.novox.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_placement.>", + "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node", + "$JS.API.STREAM.INFO.KV_mesh-config_resolved", + "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_placement", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_placement-candidates", + "$JS.FC.KV_mesh-config_resolved.>", + "$JS.FC.KV_mesh-delivery-builder_builds.>", + "$JS.FC.KV_mesh-delivery-indexer_manifests.>", + "$JS.FC.KV_mesh-delivery-indexer_modules.>", + "$JS.FC.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.FC.KV_mesh-delivery-indexer_placement.>", + "mesh.seat.mesh-vault.call.own.node~novox" + ], + "subscribe_added": [ + "mesh.mod.mesh-vault.event.secret-changed" + ], + "user": "node.novox" + }, + { + "publish_added": [ + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.ace.influxdb.bucket", + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.ace.influxdb.org", + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.novox.keycloak.issuer", + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.novox.mailu.domain", + "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.shanks.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_placement.>", + "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node", + "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_placement.>", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.ace.influxdb.bucket", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.ace.influxdb.org", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.novox.keycloak.issuer", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.novox.mailu.domain", + "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.shanks.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_placement.>", + "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node", + "$JS.API.STREAM.INFO.KV_mesh-config_resolved", + "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_placement", + "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_placement-candidates", + "$JS.FC.KV_mesh-config_resolved.>", + "$JS.FC.KV_mesh-delivery-builder_builds.>", + "$JS.FC.KV_mesh-delivery-indexer_manifests.>", + "$JS.FC.KV_mesh-delivery-indexer_modules.>", + "$JS.FC.KV_mesh-delivery-indexer_placement-candidates.>", + "$JS.FC.KV_mesh-delivery-indexer_placement.>", + "mesh.seat.mesh-vault.call.own.node~shanks" + ], + "subscribe_added": [ + "mesh.mod.mesh-vault.event.secret-changed" + ], + "user": "node.shanks" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_desk-channel_shown" + ], + "user": "novox.desk-channel" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_keys", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_pauses", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_toolchains", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-checker_kept", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_manifests" + ], + "user": "novox.mesh-build-go" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_keys", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_pauses", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_toolchains" + ], + "user": "novox.mesh-build-node" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-config_kept", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-config_predefined", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-config_proposals", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-config_pulls", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-config_resolved", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-config_values", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_assignments", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_declared-config", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_predefined", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_placement" + ], + "user": "novox.mesh-config" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_toolchains", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_toolchains", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_assignments", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_compositions", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_builds", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-checker_hearing", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-checker_kept", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-checker_owed", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-checker_previews", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-checker_tries", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-checker_verdicts", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-gate_source", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-gate_turns", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_candidates", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_manifests", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_modules", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-watchman_presence", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_node-engine_have" + ], + "user": "novox.mesh-delivery-checker" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-forge-bridge_heads", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-forge-bridge_performed", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-gate_merges", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-gate_owed" + ], + "user": "novox.mesh-delivery-forge-bridge" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_toolchains", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_toolchains", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_assignments", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_builds", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-checker_previews", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-gate_source", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_candidates", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_manifests", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_modules", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_placement", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_placement-candidates", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_takeins", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_taken" + ], + "user": "novox.mesh-delivery-indexer" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_asks", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-gate_asks", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-teller_answers", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-teller_asked", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-view_answers" + ], + "user": "novox.mesh-delivery-teller" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-image_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_wants", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-watchman_presence", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-watchman_pulse", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-watchman_stuck", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_node-engine_have" + ], + "user": "novox.mesh-delivery-watchman" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-config_resolved", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_assignments", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_grants", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_wants", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_builds", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_manifests", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_modules", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_placement", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_assertions", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_memberships", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_takeover", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_user-list", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-vault_bus-accounts", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_node-engine_have" + ], + "user": "novox.mesh-grants" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-mail_accounts" + ], + "user": "novox.mesh-mail-web" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-mail_sends" + ], + "user": "novox.mesh-mail.send" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-provisioner_applied", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-provisioner_collect", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-provisioner_needs", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-provisioner_registry" + ], + "user": "novox.mesh-provisioner" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_assignments", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-vault_bus-accounts", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_node-engine_have" + ], + "user": "novox.mesh-vault" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_messenger_asks", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_messenger_identities", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_messenger_open", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_messenger_sent", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_messenger_walks" + ], + "user": "novox.messenger" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_only-office_convert-answers" + ], + "user": "novox.only-office.converter" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_asks", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_installed", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_records", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_rounds", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_runs", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_updates" + ], + "user": "novox.pacman.keeper" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_assignments", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_route-proxy_bound" + ], + "user": "novox.route-proxy" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_telegram_messages", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_telegram_offset" + ], + "user": "novox.telegram" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_brother_device-answers" + ], + "user": "shanks.brother.device" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_keys", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_pauses", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_toolchains", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-checker_kept", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_manifests" + ], + "user": "shanks.mesh-build-go" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-image_keys", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-image_pauses", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-image_steps" + ], + "user": "shanks.mesh-build-image" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_keys", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_pauses", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_toolchains" + ], + "user": "shanks.mesh-build-node" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_toolchains", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-image_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_toolchains", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_assignments", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_builds", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_hearing", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_instances", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_queues", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_reasks", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-gate_source", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_candidates", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_manifests", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_modules" + ], + "user": "shanks.mesh-delivery-builder" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_pauses", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-go_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-image_pauses", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-image_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_pauses", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-build-node_steps", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-config_resolved", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_assignments", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_builds", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-controller_composed", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_asks", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_differences", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_wants", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_builds", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-checker_tries", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-gate_asks", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-gate_source", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_candidates", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_modules", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_placement", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_placement-candidates", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-indexer_taken", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-teller_answers", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-view_answers", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-view_reach", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-watchman_presence", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-watchman_stuck", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_node-engine_have" + ], + "user": "shanks.mesh-delivery-view" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-mail-notifier_shown" + ], + "user": "shanks.mesh-mail-notifier" + }, + { + "subscribe_added": [ + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_asks", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_installed", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_records", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_rounds", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_runs", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_pacman_updates" + ], + "user": "shanks.pacman.keeper" + } + ], + "dropped": [], + "excused": [ + { + "subject": "$SRV.INFO.mesh-store", + "user": "ace.node-tools", + "why": "seat mesh-store is claimed on several machines: composed as held on each until holdings follow the catalogue" + }, + { + "subject": "$SRV.INFO.mesh-store.>", + "user": "ace.node-tools", + "why": "seat mesh-store is claimed on several machines: composed as held on each until holdings follow the catalogue" + }, + { + "subject": "$SRV.PING.mesh-store", + "user": "ace.node-tools", + "why": "seat mesh-store is claimed on several machines: composed as held on each until holdings follow the catalogue" + }, + { + "subject": "$SRV.PING.mesh-store.>", + "user": "ace.node-tools", + "why": "seat mesh-store is claimed on several machines: composed as held on each until holdings follow the catalogue" + }, + { + "subject": "$SRV.STATS.mesh-store", + "user": "ace.node-tools", + "why": "seat mesh-store is claimed on several machines: composed as held on each until holdings follow the catalogue" + }, + { + "subject": "$SRV.STATS.mesh-store.>", + "user": "ace.node-tools", + "why": "seat mesh-store is claimed on several machines: composed as held on each until holdings follow the catalogue" + }, + { + "subject": "mesh.seat.mesh-store.call.databases.*", + "user": "ace.node-tools", + "why": "seat mesh-store is claimed on several machines: composed as held on each until holdings follow the catalogue" + }, + { + "subject": "mesh.seat.mesh-store.call.query.*", + "user": "ace.node-tools", + "why": "seat mesh-store is claimed on several machines: composed as held on each until holdings follow the catalogue" + }, + { + "subject": "mesh.seat.mesh-store.tool.databases", + "user": "ace.node-tools", + "why": "seat mesh-store is claimed on several machines: composed as held on each until holdings follow the catalogue" + }, + { + "subject": "mesh.seat.mesh-store.tool.query", + "user": "ace.node-tools", + "why": "seat mesh-store is claimed on several machines: composed as held on each until holdings follow the catalogue" + }, + { + "subject": "mesh.seat.mesh-delivery.call.mark-ready.*", + "user": "novox.node-tools", + "why": "a verb the seat catalogue retired" + }, + { + "subject": "mesh.seat.mesh-delivery.tool.mark-ready", + "user": "novox.node-tools", + "why": "a verb the seat catalogue retired" + } + ], + "loaded": 60, + "same": 19, + "shadow": 60 + }, + "credentials": { + "adds": [], + "hashes_held": 60, + "left_out": [], + "loses_login": [], + "users": [ + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "ace.dnsmasq", + "would": "same" + }, + { + "generation": 6, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "ace.mesh-build-go", + "would": "same" + }, + { + "generation": 2, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "ace.mesh-build-image", + "would": "same" + }, + { + "generation": 4, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "ace.mesh-build-node", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "ace.mesh-delivery-assigner", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "ace.mesh-reads", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "ace.node-tools", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "ace.pacman.keeper", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "ace.route-proxy", + "would": "same" + }, + { + "adopted": true, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "controller", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "g14.mesh-build-go", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "g14.mesh-build-node", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "g14.mesh-mail-notifier", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "g14.mesh-reads", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "g14.node-tools", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "g14.pacman.keeper", + "would": "same" + }, + { + "adopted": true, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "node.ace", + "would": "same" + }, + { + "adopted": true, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "node.g14", + "would": "same" + }, + { + "adopted": true, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "node.novox", + "would": "same" + }, + { + "adopted": true, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "node.shanks", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.desk-channel", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.distribution", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.dnsmasq", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-build-go", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-build-node", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-config", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-controller", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-delivery-checker", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-delivery-forge-bridge", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-delivery-indexer", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-delivery-teller", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-delivery-watchman", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-grants", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-mail-reader", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-mail-web", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-mail.send", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-provisioner", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-reads", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.mesh-vault", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.messenger", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.nats", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.node-tools", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.only-office.converter", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.pacman.keeper", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.route-proxy", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "novox.telegram", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "provisioner.ace", + "would": "same" + }, + { + "generation": 3, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "provisioner.g14", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "provisioner.novox", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "provisioner.shanks", + "would": "same" + }, + { + "adopted": true, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "shanks.brother.device", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "shanks.mesh-build-go", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "shanks.mesh-build-image", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "shanks.mesh-build-node", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "shanks.mesh-delivery-builder", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "shanks.mesh-delivery-view", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "shanks.mesh-mail-notifier", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "shanks.mesh-reads", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "shanks.node-tools", + "would": "same" + }, + { + "generation": 1, + "hash_held": true, + "in_shadow": true, + "loaded": true, + "user": "shanks.pacman.keeper", + "would": "same" + } + ] + } +}