diff --git a/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go b/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go new file mode 100644 index 0000000..6340de3 --- /dev/null +++ b/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go @@ -0,0 +1,215 @@ +//go:build integration + +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/seal" + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "mesh-grants/internal/buscompose" +) + +// theBusNodeFollowsTheSealedListUnderTheComposedGrants, run by TestTheGrantsPartWhole, defends novox/hq ADR 0547 clause 7 on a real server (issue 1945): with the list this composer writes, the grants part +// creates its object bucket and its state, puts the sealed list and names it in `user-list.sealed`; the node-engine of +// the node holding mesh-broker watches that one key, reads the object by its name and opens it with its key, and may +// neither list the bucket, nor read another key of the state, nor write; another node reads nothing of either. +func theBusNodeFollowsTheSealedListUnderTheComposedGrants(t *testing.T) { + records := buscompose.Records{ + Nodes: []string{"novox", "ace"}, + Assigned: map[string][]buscompose.Declared{ + "novox": {{Module: "nats", SnapshotsTheBus: true}, + {Module: buscompose.GrantsModule, State: []buscompose.Bucket{{Module: buscompose.GrantsModule, Name: "user-list"}}}}, + "ace": {{Module: "plex"}}, + }, + } + users, err := buscompose.Users(records) + if err != nil { + t.Fatal(err) + } + for i := range users { + users[i].PasswordHash = "pw-" + users[i].Username() + } + accounts, err := buscompose.ComposeAccounts(users) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + conf := filepath.Join(dir, "server.conf") + if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: \""+dir+"\" }\n"+accounts), 0o600); err != nil { + t.Fatal(err) + } + opts, err := server.ProcessConfigFile(conf) + if err != nil { + t.Fatalf("the composed list is not the server's: %v", err) + } + opts.NoLog, opts.NoSigs = true, true + s, err := server.NewServer(opts) + if err != nil { + t.Fatal(err) + } + go s.Start() + if !s.ReadyForConnections(30 * time.Second) { + t.Fatal("the bus did not come up") + } + t.Cleanup(s.Shutdown) + dial := func(user string) *nats.Conn { + nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw-"+user), nats.CustomInboxPrefix("_INBOX."+user)) + if err != nil { + t.Fatalf("%s: %v", user, err) + } + t.Cleanup(nc.Close) + return nc + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // The grants part: its state and its object bucket, the sealed list put and named. + grants, err := jetstream.New(dial("novox." + buscompose.GrantsModule)) + if err != nil { + t.Fatal(err) + } + // Its state is made as the grants part makes a declared state, by its stream's name (CreateKeyValue would ask the + // account's info, which no module is granted). + if _, err := grants.CreateStream(ctx, jetstream.StreamConfig{Name: "KV_" + buscompose.GrantsUserListBucket, + Subjects: []string{"$KV." + buscompose.GrantsUserListBucket + ".>"}, MaxMsgsPerSubject: 1, AllowDirect: true, + AllowRollup: true, DenyDelete: true, Discard: jetstream.DiscardNew}); err != nil { + t.Fatalf("the grants part's state: %v", err) + } + kv, err := grants.KeyValue(ctx, buscompose.GrantsUserListBucket) + if err != nil { + t.Fatalf("the grants part binds its state: %v", err) + } + objects, err := grants.CreateObjectStore(ctx, jetstream.ObjectStoreConfig{Bucket: buscompose.GrantsUserListObjects}) + if err != nil { + t.Fatalf("the grants part's object bucket: %v", err) + } + node, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + list := []byte(accounts) + digest := delivery.UserListDigest(list) + object, err := delivery.SealUserList(list, node.PublicKey) + if err != nil { + t.Fatal(err) + } + name := delivery.UserListObjectName(digest) + for range 2 { // put twice: a second put of a name purges its old chunks + if _, err := objects.PutBytes(ctx, name, object); err != nil { + t.Fatalf("the grants part put its object: %v", err) + } + } + value, err := delivery.EncodeGrantsUserList(delivery.GrantsUserList{Follow: delivery.FollowGrants, Digest: digest, + Object: name, SealedTo: delivery.SealingKeyID(node.PublicKey), Node: "novox", Revision: 1, Users: len(users)}) + if err != nil { + t.Fatal(err) + } + if _, err := kv.Put(ctx, buscompose.GrantsUserListKey, value); err != nil { + t.Fatal(err) + } + if _, err := kv.Put(ctx, "list", []byte(`{"v":1}`)); err != nil { + t.Fatal(err) + } + + // The bus node's engine: the key watched, the object by name (as mesh-host's link reads a declaration's file). + busNode := dial("node.novox") + js, err := busNode.JetStream(nats.MaxWait(5 * time.Second)) + if err != nil { + t.Fatal(err) + } + keys, err := js.KeyValue(buscompose.GrantsUserListBucket) + if err != nil { + t.Fatalf("the bus node binds the grants part's state: %v", err) + } + w, err := keys.Watch(buscompose.GrantsUserListKey) + if err != nil { + t.Fatalf("the bus node watches the key: %v", err) + } + defer w.Stop() + var got delivery.GrantsUserList + select { + case e := <-w.Updates(): + if e == nil { + t.Fatal("the watch found no value") + } + if got, err = delivery.DecodeGrantsUserList(e.Value()); err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("the watch said nothing") + } + read := func(js nats.JetStreamContext, name string) ([]byte, error) { + meta, err := js.GetLastMsg(buscompose.GrantsUserListObjectStream, "$O."+buscompose.GrantsUserListObjects+".M."+ + base64.URLEncoding.EncodeToString([]byte(name)), nats.DirectGet()) + if err != nil { + return nil, err + } + var info struct { + NUID string `json:"nuid"` + Size int `json:"size"` + } + if err := json.Unmarshal(meta.Data, &info); err != nil { + return nil, err + } + sub, err := js.SubscribeSync("$O."+buscompose.GrantsUserListObjects+".C."+info.NUID, nats.OrderedConsumer(), + nats.BindStream(buscompose.GrantsUserListObjectStream)) + if err != nil { + return nil, err + } + defer sub.Unsubscribe() + var body []byte + for len(body) < info.Size { + m, err := sub.NextMsg(5 * time.Second) + if err != nil { + return nil, err + } + body = append(body, m.Data...) + } + return body, nil + } + body, err := read(js, got.Object) + if err != nil { + t.Fatalf("the bus node reads the object by its name: %v", err) + } + opened, err := delivery.OpenUserList(body, got.Digest, func(b seal.Box) ([]byte, error) { + s, err := seal.Open(b, node.PrivateKey) + return []byte(s), err + }) + if err != nil || !bytes.Equal(opened, list) { + t.Fatalf("the bus node opened %d bytes, %v", len(opened), err) + } + + // And nothing beyond: no other key, no list of the bucket, no write. + if _, err := keys.Get("list"); err == nil { + t.Error("the bus node read another key of the grants part's state") + } + if _, err := js.StreamInfo(buscompose.GrantsUserListObjectStream); err == nil { + t.Error("the bus node read the object stream's info, which lists every name") + } + if _, err := js.Publish("$O."+buscompose.GrantsUserListObjects+".M.x", []byte("x")); err == nil { + t.Error("the bus node wrote the grants part's bucket") + } + // Another node reads nothing of it. + other, err := dial("node.ace").JetStream(nats.MaxWait(2 * time.Second)) + if err != nil { + t.Fatal(err) + } + if _, err := read(other, got.Object); err == nil { + t.Error("another node read the object") + } + if _, err := other.GetLastMsg("KV_"+buscompose.GrantsUserListBucket, "$KV."+buscompose.GrantsUserListBucket+"."+buscompose.GrantsUserListKey, nats.DirectGet()); err == nil { + t.Errorf("another node read the key: %v", err) + } +} diff --git a/modules/mesh-grants/cmd/mesh-grants/integration_test.go b/modules/mesh-grants/cmd/mesh-grants/integration_test.go index fb0836c..c546269 100644 --- a/modules/mesh-grants/cmd/mesh-grants/integration_test.go +++ b/modules/mesh-grants/cmd/mesh-grants/integration_test.go @@ -192,4 +192,6 @@ func TestTheGrantsPartWhole(t *testing.T) { t.Errorf("the bucket %s: %v, want none", bucket, err) } } + // And what the part composes holds on a server that loads it (one integration test per module, the test budget). + t.Run("the composed grants hold on a server that loads them", theBusNodeFollowsTheSealedListUnderTheComposedGrants) } diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 449da6e..4a1e624 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -20,11 +20,11 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-sdk/go/asks" - "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/membership" "git.novox.be/novox/mesh-sdk/go/seal" + "mesh-grants/internal/buscompose" "mesh-grants/internal/grants" ) @@ -483,6 +483,16 @@ func TestGrants(t *testing.T) { } files++ src, err := os.ReadFile(path) + // The bus's composer, the part's own since novox/hq ADR 0593 §1, is pure: it names subjects (a create's or a + // publish's grant among them) and holds no connection to the bus, so it can neither publish nor create. + if filepath.Dir(path) == filepath.Join("internal", "buscompose") { + for _, client := range []string{"jetstream.JetStream", "jetstream.KeyValue", "jetstream.ObjectStore ", "nats.Conn", "nats.Connect"} { + if strings.Contains(string(src), client) { + t.Errorf("%s holds %s: the composer is pure and reaches no bus", path, client) + } + } + return err + } if path != asksOnly && (strings.Contains(string(src), ".Publish(") || strings.Contains(string(src), "mesh.assignment")) { t.Errorf("%s publishes: only asking.go publishes, a condition", path) } diff --git a/modules/mesh-grants/internal/buscompose/accounts.go b/modules/mesh-grants/internal/buscompose/accounts.go new file mode 100644 index 0000000..1402804 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/accounts.go @@ -0,0 +1,188 @@ +package buscompose + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + "time" +) + +// **The accounts the grants part wants, and the one shape a held account takes** (novox/hq ADR 0561 §1, §2; issue 774). +// +// The bus composer derives every user from placement, the manifests and the wants. For each account the mesh mints (a +// module's, a module's process's, a machine's provisioner client's), the grants part writes one key of its state +// `accounts`, keyed by the bus user, holding the account's fields that are not secret: what the controller's credential +// carries today beside the password. The vault watches that state, mints the user it holds no value for, and keeps the +// value as the module's own secret on that node, built by HeldCredential, which gives byte for byte the controller's +// shape, so a runtime reads it unchanged. The state is read by the vault alone (NamedReaders), as `bus-accounts` is read +// by the grants part alone. +// +// The node accounts, the controller's own, an enrolment user and a person are never wanted here: the vault mints none +// of them (ADR 0561 §4, §5). + +// AccountsState is the grants part's state of wanted accounts, by bus user. +const AccountsState = "accounts" + +// GrantsAccounts is that state as a reader names it (`.`). +const GrantsAccounts = GrantsModule + "." + AccountsState + +// AccountsBucket is the bucket the state lives in. +var AccountsBucket = BucketName(GrantsModule, AccountsState) + +// AccountsSubject is the subject of one wanted account's key: `$KV.mesh-grants_accounts.`. +func AccountsSubject(user string) string { return "$KV." + AccountsBucket + "." + user } + +// AccountIssuedEvent is what the vault says when it minted an account: AccountIssued, never a value or a hash. +const AccountIssuedEvent = "account-issued" + +// AccountIssuedSubject is where it says it, as the runtime addresses mesh-vault's events. +const AccountIssuedSubject = "mesh.mod." + VaultSeat + ".event." + AccountIssuedEvent + +// ClaimedSeat is one seat a module claims, as its runtime needs it (the controller's seatClaimed): the name, the scope (a +// node-scoped seat's verb carries the machine) and the verbs the runtime serves for it. +type ClaimedSeat struct { + Seat string `json:"seat"` + Scope string `json:"scope"` + Serves []string `json:"serves,omitempty"` +} + +// WantedAccount is one key of the grants part's state `accounts`: an account the mesh needs, without its password. +type WantedAccount struct { + User string `json:"user"` + // Kind is KindModule (KindNodeTools for the runtime, named as a module is), KindProcess or KindProvisioner. + Kind Kind `json:"kind"` + Node string `json:"node"` + Module string `json:"module"` + // Process is a KindProcess account's process id. + Process string `json:"process,omitempty"` + // URL is the bus's address as that node reaches it, `nats://:`; Fingerprint the bus certificate's. + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + // Claims are a module's claims with the verbs each seat promises: none for a process or a provisioner client. + Claims []ClaimedSeat `json:"claims,omitempty"` + // At is when the grants part wrote the key: not compared (Same). + At time.Time `json:"at"` +} + +// mintedKinds are the kinds of account the vault mints (ADR 0561 §1). +var mintedKinds = []Kind{KindModule, KindNodeTools, KindProcess, KindProvisioner} + +// principal is the account as the composer names it. +func (w WantedAccount) principal() Principal { + return Principal{Kind: w.Kind, Node: w.Node, Module: w.Module, Process: w.Process} +} + +// Valid says why w is not an account the vault may mint, or nil: a kind it mints, every name a subject token, the user +// the one the composer derives from them, and an address to reach the bus at. +func (w WantedAccount) Valid() error { + if !slices.Contains(mintedKinds, w.Kind) { + return fmt.Errorf("%s is a %s account, which the vault never mints (ADR 0561 §4)", w.User, w.Kind) + } + for what, v := range map[string]string{"node": w.Node, "module": w.Module} { + if !safeSubject.MatchString(v) { + return fmt.Errorf("%s: the %s %q is not a name the mesh writes", w.User, what, v) + } + } + if w.Kind == KindProcess && !safeSubject.MatchString(w.Process) { + return fmt.Errorf("%s: the process %q is not a name the mesh writes", w.User, w.Process) + } + if w.Kind != KindProcess && w.Process != "" { + return fmt.Errorf("%s: only a process's account names a process", w.User) + } + if want := w.principal().Username(); w.User != want { + return fmt.Errorf("%s is not the user of a %s account of %s on %s (that is %s)", w.User, w.Kind, w.Module, w.Node, want) + } + if !strings.HasPrefix(w.URL, "nats://") || len(w.URL) == len("nats://") { + return fmt.Errorf("%s names no bus address (url, nats://:)", w.User) + } + if len(w.Claims) > 0 && w.Kind != KindModule && w.Kind != KindNodeTools { + return fmt.Errorf("%s: a %s account holds no seat, so it carries no claims", w.User, w.Kind) + } + return nil +} + +// Same says whether two wanted accounts want the same account: every field but when it was written. +func (w WantedAccount) Same(o WantedAccount) bool { + a, b := w, o + a.At, b.At = time.Time{}, time.Time{} + ra, _ := json.Marshal(a) + rb, _ := json.Marshal(b) + return string(ra) == string(rb) +} + +// OwnSecret is the module's own secret the account is held in: `broker`, `broker-` or `provisioner-broker` +// (the controller's names, ADR 0436 §2, ADR 0349 §11). +func (w WantedAccount) OwnSecret() string { + switch w.Kind { + case KindProcess: + return "broker-" + w.Process + case KindProvisioner: + return "provisioner-broker" + } + return "broker" +} + +// ReadWantedAccount reads the key user of the state: refused when it is not a valid wanted account of that user. +func ReadWantedAccount(user string, raw []byte) (WantedAccount, error) { + var w WantedAccount + if err := json.Unmarshal(raw, &w); err != nil { + return WantedAccount{}, fmt.Errorf("%s %s is not a wanted account: %w", GrantsAccounts, user, err) + } + if w.User != user { + return WantedAccount{}, fmt.Errorf("%s %s names the user %q", GrantsAccounts, user, w.User) + } + return w, w.Valid() +} + +// AccountIssued is the vault's account-issued: which account it minted, and its generation. Never a value or a hash. +type AccountIssued struct { + User string `json:"user"` + Kind Kind `json:"kind"` + Node string `json:"node"` + Module string `json:"module"` + Process string `json:"process,omitempty"` + Generation int `json:"generation"` + At time.Time `json:"at"` +} + +// HeldCredential is the value the account's holder reads, exactly as the controller builds it today (issueWith, +// sealProcess and sealProvisionerClient): a module's `{url, fingerprint, node, module, user, password, claims}`, a +// process's without claims, a provisioner client's without module or claims; an empty fingerprint or claims left out. +func HeldCredential(w WantedAccount, password string) ([]byte, error) { + if err := w.Valid(); err != nil { + return nil, err + } + if password == "" { + return nil, fmt.Errorf("%s: an account is held with its password", w.User) + } + switch w.Kind { + case KindProcess: + return json.Marshal(struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + Node string `json:"node"` + Module string `json:"module"` + User string `json:"user"` + Password string `json:"password"` + }{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password}) + case KindProvisioner: + return json.Marshal(struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + Node string `json:"node"` + User string `json:"user"` + Password string `json:"password"` + }{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, User: w.User, Password: password}) + } + return json.Marshal(struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + Node string `json:"node"` + Module string `json:"module"` + User string `json:"user"` + Password string `json:"password"` + Claims []ClaimedSeat `json:"claims,omitempty"` + }{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password, + Claims: w.Claims}) +} diff --git a/modules/mesh-grants/internal/buscompose/agreement.go b/modules/mesh-grants/internal/buscompose/agreement.go new file mode 100644 index 0000000..d4083ca --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/agreement.go @@ -0,0 +1,125 @@ +package buscompose + +import ( + "fmt" + "sort" + "strings" +) + +// Do the emitters and the consumers of a catalogue agree? +// +// **The check that was missing** (novox/hq 04-ISSUES/127). Every manifest was individually +// well-formed and every derivation individually correct, and no cross-module subscription in the +// mesh matched anything: a consumer's declaration derived into a namespace nobody publishes to. +// Nothing failed, because a subscription that matches nothing is not an error — it is silence. +// +// The comparison has to be over the whole catalogue, because the two halves live in different +// manifests, and it cannot simply demand that every consumed event have a live emitter: a module +// may be installed long before the one whose events it wants. So the rule is narrower and still +// catches this: **where the emitter is present, it must emit what the consumer asked for.** + +// AConsumer is one module's interest in another's events, as this check needs it. +type AConsumer struct { + Module string + Consumes []string +} + +// AnEmitter is one module's events. +type AnEmitter struct { + Module string + Emits []string +} + +// Disagreements are the consumed events whose emitter is in the catalogue and does not emit them. +// +// Returned as sentences rather than as structs: every one of them is read by a person deciding +// whether a manifest or a catalogue is wrong, and a pair of names without the reason is a puzzle. +func Disagreements(emitters []AnEmitter, consumers []AConsumer, seats []DeclaredSeat) []string { + emits := map[string]map[string]bool{} + for _, e := range emitters { + if emits[e.Module] == nil { + emits[e.Module] = map[string]bool{} + } + for _, name := range e.Emits { + emits[e.Module][name] = true + } + } + // A seat's events are published by its holder under the seat's name, so a consumer naming the + // seat is naming something real even though no module declares it as its own. + for _, s := range seats { + if len(s.Emits) == 0 { + continue + } + if emits[s.Name] == nil { + emits[s.Name] = map[string]bool{} + } + for _, name := range s.Emits { + emits[s.Name][name] = true + } + } + + var out []string + for _, c := range consumers { + for _, pattern := range c.Consumes { + emitter, event, named := strings.Cut(pattern, ".") + // Every event from everyone, or every event from one module: both are deliberate and + // neither names a particular event to check. + if !named || emitter == "*" || emitter == catalogueTheRest || event == catalogueTheRest { + continue + } + known, present := emits[emitter] + if !present { + // Not installed here, which is ordinary: a module lives in its own repository and + // may be registered later. Nothing to compare, so nothing to say. + continue + } + if matchesAny(event, known) { + continue + } + out = append(out, fmt.Sprintf( + "%s consumes %q and %s emits %s — so that subscription would match nothing, and "+ + "nothing would report it", + c.Module, pattern, emitter, listOf(known))) + } + } + sort.Strings(out) + return out +} + +// matchesAny says whether one of an emitter's event names satisfies a consumer's pattern. +func matchesAny(pattern string, emitted map[string]bool) bool { + want := strings.Split(pattern, ".") + for name := range emitted { + if matches(want, strings.Split(name, ".")) { + return true + } + } + return false +} + +func matches(pattern, name []string) bool { + for i, part := range pattern { + if part == catalogueTheRest { + return i < len(name) + } + if i >= len(name) { + return false + } + if part != "*" && part != name[i] { + return false + } + } + return len(pattern) == len(name) +} + +func listOf(names map[string]bool) string { + if len(names) == 0 { + return "nothing" + } + out := make([]string, 0, len(names)) + for n := range names { + out = append(out, n) + } + sort.Strings(out) + return strings.Join(out, ", ") +} diff --git a/modules/mesh-grants/internal/buscompose/announced.go b/modules/mesh-grants/internal/buscompose/announced.go new file mode 100644 index 0000000..bdd8c49 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/announced.go @@ -0,0 +1,74 @@ +package buscompose + +import ( + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/announce" +) + +// Who may read a module's state is what the module announced (novox/hq ADR 0495 clauses 2 and 3, ADR 0513 §3; issue +// 1790). A module says on the bus which states it keeps and which others may read (announce.Subject); the composer is +// given the last word of each (Records.Announced) and grants a reader's `reads: .` only when the owner +// announced that state readable. An owner that has announced nothing yet is read as before, from the reader's `reads` +// (the transition, until every module announces), and is named as not yet announced. + +// ReadsAnnouncedBy splits one reader module's `reads` (`.`) by what each owner announced: granted when the +// owner announced the state readable (by this reader, where it named its readers: announce.State.Readers), when the +// composer names the reader among the state's readers (NamedReaders, ADR 0547 clause 9), or when the owner announced +// nothing yet (the transition); refused otherwise, and a read named for other modules is refused. There is no judgement +// without the reader (issue 774): one made without it refused every named reader, and Users judges again for the +// reader whatever split it is given (withAnnouncedReads). An empty reader is judged by the readable mark alone. +func ReadsAnnouncedBy(reader string, reads []string, announced map[string]announce.States) (granted, refused []string) { + for _, r := range reads { + owner, name, ok := strings.Cut(r, ".") + s, said := announced[owner] + switch { + case reader != "" && !ReaderMay(reader, r): + refused = append(refused, r) + case reader != "" && namedReader(reader, r): + // A state the composer names its readers for is theirs whatever its owner announced. + granted = append(granted, r) + case reader != "" && ok && said && s.Keeps(name): + if s.ReadableBy(name, reader) { + granted = append(granted, r) + } else { + refused = append(refused, r) + } + case !ok || !said || s.Readable(name): + granted = append(granted, r) + default: + refused = append(refused, r) + } + } + return granted, refused +} + +// NotAnnounced are the owners some module reads the state of and that have announced nothing yet, sorted: read from the +// readers' `reads` as before, until they announce. +func NotAnnounced(r Records) []string { + seen := map[string]bool{} + var out []string + for _, ds := range r.Assigned { + for _, d := range ds { + for _, read := range append(append([]string(nil), d.Reads...), d.ReadsRefused...) { + owner, _, ok := strings.Cut(read, ".") + if !ok || seen[owner] { + continue + } + if _, said := r.Announced[owner]; !said { + seen[owner] = true + out = append(out, owner) + } + } + } + } + sort.Strings(out) + return out +} + +// namedReader says the composer names module among the readers of read (NamedReaders). +func namedReader(module, read string) bool { + return slices.Contains(NamedReaders[strings.TrimSpace(read)], module) +} diff --git a/modules/mesh-grants/internal/buscompose/buildbacklog.go b/modules/mesh-grants/internal/buscompose/buildbacklog.go new file mode 100644 index 0000000..f62cdae --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/buildbacklog.go @@ -0,0 +1,47 @@ +package buscompose + +import "git.novox.be/novox/mesh-sdk/go/delivery" + +// The view alone reads the build seats' queues (novox/hq ADR 0525 §2, issue 1887 A8a): the shared workers' counts and the +// oldest waiting ask, to show the backlog. It may not publish an ask: only the builder and the checker ask, by their seat use. +// The watchman is granted nothing here: a waiting ask has no pulse, a taken one is read from the build module's steps (issue +// 1908). Nothing is a wildcard over a stream or a worker. +const ( + // ViewModule is the module of the delivery view (mesh-delivery-view). + ViewModule = "mesh-delivery-view" + // BuilderModule is the module of the builder (mesh-delivery-builder). + BuilderModule = "mesh-delivery-builder" +) + +// BuildAskReads are the API subjects the builder requests when it takes over, at its start, a key naming an ask (issue +// 1910): each build seat's stream's information and its messages by sequence, to tell whether the ask is on its stream. +// Reads only: no consumer, no pull, no acknowledgement; the builder puts its asks by its seat use, as before. +func BuildAskReads() []string { + var out []string + for _, lang := range delivery.AskLanguages { + if stream, err := delivery.AskStream(lang); err == nil { + out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.STREAM.MSG.GET."+stream) + } + } + return out +} + +// BuildBacklogReads are the API subjects the view requests: the information of each build seat's stream and of each of its +// shared workers (delivery.AskStream, delivery.AskWorker), and the message of the stream by sequence. Reads only: no +// message is pulled, none acknowledged, none published. +func BuildBacklogReads() []string { + var out []string + for _, lang := range delivery.AskLanguages { + stream, err := delivery.AskStream(lang) + if err != nil { + continue + } + out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.STREAM.MSG.GET."+stream) + for _, kind := range delivery.AskKinds { + if w, err := delivery.AskWorker(lang, kind); err == nil { + out = append(out, "$JS.API.CONSUMER.INFO."+stream+"."+w) + } + } + } + return out +} diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go new file mode 100644 index 0000000..0a4e583 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -0,0 +1,883 @@ +package buscompose + +import ( + "encoding/json" + "maps" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats.go/jetstream" +) + +// Issue 1126 B3a (novox/hq ADR 0590): the composed user lists and the server configuration are golden files this +// composer wrote (testdata/user-lists.json, testdata/composed.conf), first from the lists the ended controller composed +// with its user and the lease's grants taken out. A change to what any user is granted shows here as a diff to read; +// MESH_SDK_UPDATE_GOLDEN=1 writes them anew. +var updateGolden = os.Getenv("MESH_SDK_UPDATE_GOLDEN") == "1" + +// golden compares got with the golden file name, or writes it. +func golden(t *testing.T, name string, got []byte) { + t.Helper() + path := filepath.Join("testdata", name) + if updateGolden { + if err := os.WriteFile(path, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Errorf("%s differs from what is composed now; read the diff and, if it is meant, write it with MESH_SDK_UPDATE_GOLDEN=1", path) + } +} + +const SignersBucketName = "mesh-vault_signers" + +func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { + // The creates the grants part makes from it (issue 1790), as subtests of the one test (the test budget). + t.Run("only declared streams are created", theGrantsPartCreatesOnlyDeclaredStreams) + t.Run("a declared stream is created as the controller asserted it", declaredStreamsAreCreatedAsTheControllerAssertsThem) + // ADR 0567 §3, §9: a node reads every key of the seat's placement and its candidates (it composes with the whole + // proposal laid over), and none of its open proposals; it writes neither. + reads := strings.Join(NodeComposeReads("laptop"), " ") + for bucket, granted := range map[string]bool{delivery.PlacementBucket: true, delivery.PlacementCandidatesBucket: true, + delivery.PlacementProposalsBucket: false} { + if strings.Contains(reads, "$JS.API.DIRECT.GET.KV_"+bucket+".>") != granted || strings.Contains(reads, "$KV."+bucket+".laptop") { + t.Errorf("a node is granted %s: %v, want %v", bucket, !granted, granted) + } + } + // It hears the creation of those two buckets, and of no other. + if got := NodeComposeAdvisories(); len(got) != 2 || got[0] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement" || + got[1] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates" { + t.Errorf("a node hears the creation of %v", got) + } + type list struct { + Name, List string + Missing []string + Err string + Memberships json.RawMessage + } + var lists []list + for _, f := range UserListFixtures() { + l, missing, err := ComposedFor(f) + row := list{Name: f.Name, List: l, Missing: missing} + if err != nil { + row.Err = err.Error() + } + if row.Memberships, err = json.Marshal(MembershipsFor(f)); err != nil { + t.Fatal(err) + } + // B3a/AC1: no list holds a user for the ended controller. + if strings.Contains(l, `user: "controller"`) { + t.Errorf("%s: the list holds the controller's user", f.Name) + } + lists = append(lists, row) + } + raw, err := json.MarshalIndent(lists, "", " ") + if err != nil { + t.Fatal(err) + } + golden(t, "user-lists.json", append(raw, '\n')) + // An asker on a seat pulled by kind hears what that seat's workers gave up on, and no other stream's dead letters + // (novox/hq issue 734: a check ask given up during a bus reload left its try running for ever). + t.Run("an asker reads its seat's dead letters", func(t *testing.T) { + build := Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check", "recheck"}, PulledByKind: true} + perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-checker", Uses: []Seat{build}}) + if err != nil { + t.Fatal(err) + } + for subject, want := range map[string]bool{ + "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.>": true, + "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.w.next": true, + "$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1": true, + "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTERS.x1": true, + "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.EVENTS.>": false, + "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1": false, + "$JS.API.STREAM.PURGE.DEAD_LETTERS": false, + "$JS.FC.DEAD_LETTERS.x1": false, + } { + if got := MayPublish(perms, subject); got != want { + t.Errorf("may publish %s: %v, want %v", subject, got, want) + } + } + plain, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop", + Uses: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}}}) + if err != nil { + t.Fatal(err) + } + if MayPublish(plain, "$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1") { + t.Error("a user of a seat not pulled by kind reads DEAD_LETTERS") + } + }) + // ADR 0563 §1, §3, §6: each node's engine alone answers its own node's node-engine verbs and says its events, any + // runtime may call them, and it asks the operator under node-engine- alone. + t.Run("a node's engine holds its node seat and asks in its own name", func(t *testing.T) { + router := Seat{Name: AsksSeat, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, + ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}} + users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{ + "one": {{Module: "messenger", Holds: []Seat{router}, Invokes: []string{"seat:" + EngineSeat + ".root-free"}}}, + "two": {{Module: RuntimeModule}, {Module: "shop", Invokes: []string{"*"}}}}}) + if err != nil { + t.Fatal(err) + } + for _, u := range users { + perms, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + answers := MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") || + MaySubscribe(perms, "mesh.seat."+EngineSeat+".call.root-free.one.messenger~one") + if engineOne := u.Kind == KindNode && u.Node == "one"; answers != engineOne { + t.Errorf("%s answers one's root-free: %v", u.Username(), answers) + } + switch { + case u.Kind == KindNode && u.Node == "one": + for subject, want := range map[string]bool{ + "mesh.seat.operator-channel.accept.ask.node-engine-one": true, + "mesh.seat.operator-channel.accept.cancel.node-engine-one": true, + "mesh.seat.operator-channel.accept.ask.node-engine-two": false, + "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-one.hand-over-1": true, + "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-two.hand-over-1": false, + "mesh.seat." + EngineSeat + ".event.done.one": true, + "mesh.seat." + EngineSeat + ".event.done.two": false, + "$SRV.INFO": true, + } { + if got := MayPublish(perms, subject); got != want { + t.Errorf("node one may publish %s: %v, want %v", subject, got, want) + } + } + if !MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-one") || + MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-two") || + !MaySubscribe(perms, "$SRV.INFO."+EngineSeat) || MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.hand-over.two") { + t.Errorf("node one hears %v", perms.Subscribe) + } + // A verb or an event the seat's contract gains later is its engine's on its own node without a release of + // the grants part (2026-10-31: mesh-host #184's optional verb composition was refused on every node, the + // grants part's mesh-sdk pin predating it): the seat's subjects on its node, any verb, any event. + for _, verb := range []string{"composition", "compose", "a-verb-added-later"} { + if !MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool."+verb+".one") || + !MaySubscribe(perms, "mesh.seat."+EngineSeat+".call."+verb+".one.messenger~one") || + MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool."+verb+".two") || + MaySubscribe(perms, "mesh.seat."+EngineSeat+".call."+verb+".two.messenger~one") { + t.Errorf("node one does not answer %s on its own node alone: %v", verb, perms.Subscribe) + } + } + if !MayPublish(perms, "mesh.seat."+EngineSeat+".event.an-event-added-later.one") || + MayPublish(perms, "mesh.seat."+EngineSeat+".event.an-event-added-later.two") { + t.Errorf("node one does not say a later event on its own node alone: %v", perms.Publish) + } + case u.Kind == KindNodeTools || u.Module == "messenger": + if !MayPublish(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") { + t.Errorf("%s cannot call one's root-free", u.Username()) + } + } + } + }) + // Issue 1126 (ADR 0550 §5): a node's engine asks the vault's `own` in its own name while the vault serves it, and no + // other node's. + t.Run("a node asks the vault its own secrets", func(t *testing.T) { + one := Principal{Kind: KindNode, Node: "one", AsksItsOwnSecrets: true} + perms, err := PermissionsFor(one) + if err != nil { + t.Fatal(err) + } + if !MayPublish(perms, VaultOwnCall(one)) || MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"})) { + t.Errorf("node one asks own as %v, and as two %v", MayPublish(perms, VaultOwnCall(one)), + MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"}))) + } + if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one"}); MayPublish(plain, VaultOwnCall(one)) { + t.Error("a node asks own while the vault does not serve it") + } + if !slices.Contains(perms.Subscribe, VaultSecretChanged) { + t.Errorf("node one does not hear the vault's secret-changed: %v", perms.Subscribe) + } + // L3 s6 (ADR 0550 §2): while the vault serves `sign`, a node asks it in its own name alone, and reads the vault's + // `signers`, never writing it; the ended controller's lease it reads no more (ADR 0590). + signs, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", AsksTheVaultToSign: true}) + signersGet := "$JS.API.DIRECT.GET.KV_" + SignersBucketName + ".$KV." + SignersBucketName + ".key" + for subject, want := range map[string]bool{VaultSignCall(one): true, signersGet: true, + "$JS.API.DIRECT.GET.KV_mesh-controller_lease.$KV.mesh-controller_lease.holder": false, + VaultSignCall(Principal{Kind: KindNode, Node: "two"}): false, "$KV." + SignersBucketName + ".key": false} { + if MayPublish(signs, subject) != want { + t.Errorf("a node asking sign may publish %s: %v, want %v", subject, !want, want) + } + } + if MayPublish(perms, VaultSignCall(one)) || MayPublish(perms, signersGet) { + t.Error("a node asks sign or reads signers while the vault does not serve sign") + } + // And what a module on another node serves from its settings, that key alone (mailu's smtp domain, issue 1126). + users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{ + "two": {{Module: "mailer", Served: []string{"domain", "not.a.token"}}}}}) + if err != nil { + t.Fatal(err) + } + get := func(key string) string { + return "$JS.API.DIRECT.GET.KV_" + ResolvedBucket + ".$KV." + ResolvedBucket + "." + key + } + for _, u := range users { + if u.Kind != KindNode { + continue + } + perms, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + if u.Node == "one" && (!reflect.DeepEqual(u.ServedReads, []string{"two.mailer.domain"}) || !MayPublish(perms, get("two.mailer.domain")) || + MayPublish(perms, get("two.mailer.sitename"))) { + t.Errorf("node one reads %v of two's resolved values", u.ServedReads) + } + if u.Node == "two" && len(u.ServedReads) != 0 { + t.Errorf("node two is granted its own served keys apart: %v", u.ServedReads) + } + } + }) + // 774/S39 (ADR 0583 §1): while a node rotates, both its logins are users with the node's permissions and its one + // inbox, each calling in its own name; every node reads its own node-logins key alone; with no rotation the list + // is the one above (AC2: the controller's golden lists, unchanged). + t.Run("a node rotating has two logins of one node", func(t *testing.T) { + vault := Seat{Name: VaultSeat, Serves: []string{"rotate-node", "node-account-confirm", "sign", "own"}} + since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC) + users, err := Users(Records{Nodes: []string{"ace", "g14"}, + Assigned: map[string][]Declared{"ace": {{Module: "mesh-vault", Holds: []Seat{vault}}}}, + NodeLogins: map[string]delivery.NodeLogin{ + "g14": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: delivery.RotationBegun, Since: since}, + // A node that rotated before and rotates no more logs in as its second login alone. + "ace": {Node: "ace", Login: "node.ace--b", Since: since}}}) + if err != nil { + t.Fatal(err) + } + perms := map[string]Permissions{} + for _, u := range users { + if u.Kind == KindNode { + p, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + perms[u.Username()] = p + } + } + if len(perms) != 3 || perms["node.g14"].Publish == nil || perms["node.g14--b"].Publish == nil || perms["node.ace--b"].Publish == nil { + t.Fatalf("the node users are %v", slices.Sorted(maps.Keys(perms))) + } + // Equal, but for the caller each names in its calls (the bus's proof of which login called, ADR 0583 §3). + own := func(p Permissions, login string) Permissions { + swap := func(in []string) []string { + out := make([]string, len(in)) + for i, s := range in { + out[i] = strings.ReplaceAll(s, CallerToken(login), "") + } + return out + } + return Permissions{Publish: swap(p.Publish), PublishDeny: swap(p.PublishDeny), Subscribe: swap(p.Subscribe), + AllowResponses: p.AllowResponses} + } + if a, b := own(perms["node.g14"], "node.g14"), own(perms["node.g14--b"], "node.g14--b"); !reflect.DeepEqual(a, b) { + t.Errorf("the two logins' permissions differ:\n%v\n%v", a, b) + } + for login, p := range perms { + node, _ := delivery.LoginNode(login) + if !slices.Contains(p.Subscribe, "_INBOX.node."+node+".>") || slices.Contains(p.Subscribe, "_INBOX."+login+".>") && login != "node."+node { + t.Errorf("%s's inbox: %v", login, p.Subscribe) + } + } + // AC3: g14 reads node-logins key g14 and not ace, and writes neither; it confirms in its own login's name. + get := func(key string) string { + return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key + } + confirm := func(login string) string { + return "mesh.seat.mesh-vault.call.node-account-confirm." + CallerToken(login) + } + for subject, want := range map[string]bool{get("g14"): true, get("ace"): false, "$KV." + delivery.NodeLoginsBucket + ".g14": false, + confirm("node.g14--b"): true, confirm("node.g14"): false} { + if MayPublish(perms["node.g14--b"], subject) != want { + t.Errorf("node.g14--b may publish %s: %v, want %v", subject, !want, want) + } + } + if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "g14"}); MayPublish(plain, get("g14")) { + t.Error("a node reads node-logins while the vault does not rotate") + } + // And nothing but the grants part names node-logins among its reads. + if !ReaderMay(GrantsModule, delivery.NodeLoginsRead) || ReaderMay("shop", delivery.NodeLoginsRead) { + t.Error("node-logins' named readers") + } + }) + // 774/S40/AC1 (ADR 0587 §4, §6, ADR 0590): an enrolling node's user may publish the redeem call in its own name and + // nothing else, and subscribe its own inbox alone. + t.Run("an enrolling node redeems at the seat alone", func(t *testing.T) { + holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}} + { + users, err := Users(Records{Nodes: []string{"ace"}, Enrolling: []string{"lab"}, + Assigned: map[string][]Declared{"ace": {{Module: "mesh-enrolment", Holds: []Seat{holder}}}}}) + if err != nil { + t.Fatal(err) + } + var lab *Principal + for i := range users { + if users[i].Username() == "enrol.lab" { + lab = &users[i] + } + } + if lab == nil { + t.Fatal("no user enrol.lab") + } + perms, err := PermissionsFor(*lab) + if err != nil { + t.Fatal(err) + } + want := []string{"mesh.seat.mesh-enrolment.call.redeem.enrol~lab"} + if !reflect.DeepEqual(perms.Publish, want) || !reflect.DeepEqual(perms.Subscribe, []string{"_INBOX.enrol.lab.>"}) || + perms.AllowResponses { + t.Errorf("enrol.lab publishes %v, subscribes %v, answers %v", perms.Publish, perms.Subscribe, perms.AllowResponses) + } + } + // No holder serving redeem: the user may enrol nowhere, refused by name. + if _, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "lab"}); err == nil { + t.Error("an enrolment user that may enrol nowhere was composed") + } + }) + // 774/S41/AC1 (ADR 0587 §1-§3, ADR 0561 §5): mesh-enrolment writes its bucket (with per-key expiry) and calls the + // vault's enrolment-token, node-account and sign in its own name; no other module or runtime may call those two, + // however wide its grant; and its bucket is read by the grants part alone besides it. + t.Run("mesh-enrolment alone asks the vault to enrol", func(t *testing.T) { + vault := Seat{Name: VaultSeat, Serves: []string{"sign", "enrolment-token", "node-account"}} + holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}} + calls := []string{"seat:mesh-vault.enrolment-token", "seat:mesh-vault.node-account", "seat:mesh-vault.sign"} + users, err := Users(Records{Nodes: []string{"ace"}, Assigned: map[string][]Declared{"ace": { + {Module: "mesh-vault", Holds: []Seat{vault}}, + // One process with a bus account of its own (ADR 0587 §1): never carried by the tool runner. + {Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls, + State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}}, + {Module: "shop", Invokes: calls[:2]}, + {Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}}) + if err != nil { + t.Fatal(err) + } + call := func(verb, user string) string { return "mesh.seat.mesh-vault.call." + verb + "." + CallerToken(user) } + for _, u := range users { + perms, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + name := u.Username() + for _, verb := range []string{"enrolment-token", "node-account"} { + enrols := name == "ace.mesh-enrolment" + if MayPublish(perms, call(verb, name)) != enrols || (!enrols && MayPublish(perms, "mesh.seat.mesh-vault.tool."+verb)) { + t.Errorf("%s may call %s: %v", name, verb, !enrols) + } + } + if name == "ace.mesh-enrolment" && (!MayPublish(perms, call("sign", name)) || + !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") || !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".address.10.0.0.7")) { + t.Errorf("mesh-enrolment does not sign or write its bucket: %v", perms.Publish) + } + if name != "ace.mesh-enrolment" && MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") { + t.Errorf("%s writes the enrolments", name) + } + } + if !ReaderMay(GrantsModule, delivery.EnrolmentsRead) || ReaderMay("shop", delivery.EnrolmentsRead) { + t.Error("the enrolments' named readers") + } + }) + t.Run("the golden server configuration", func(t *testing.T) { + seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}} + got, err := Compose( + Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data", + TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"}, + []Principal{ + {Kind: KindEnrolment, Node: "one", Redeems: true, PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"}, + {Kind: KindNode, Node: "one", PasswordHash: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn"}, + {Kind: KindModule, Node: "one", Module: "telegram", Holds: []Seat{seat}, + Serves: []string{"status"}, PasswordHash: "$2a$11$tttttttttttttttttttttt"}, + {Kind: KindModule, Node: "two", Module: "shop", Uses: []Seat{seat}, + Emits: []string{"order.placed"}, PasswordHash: "$2a$11$ssssssssssssssssssssss"}, + {Kind: KindModule, Node: "two", Module: "audit", + Consumes: []string{"shop.order.placed"}, PasswordHash: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa"}, + {Kind: KindModule, Node: "one", Module: WatchmanModule, PasswordHash: "$2a$11$wwwwwwwwwwwwwwwwwwwwww"}, + {Kind: KindModule, Node: "one", Module: "nats", SnapshotsTheBus: true, + Serves: []string{"nats_streams"}, PasswordHash: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb"}, + }) + if err != nil { + t.Fatal(err) + } + golden(t, "composed.conf", []byte(got)) + }) + // 774/AC2 (novox/hq ADR 0561 §2): a wanted account round-trips through the grants part's state `accounts`, keyed by + // its user, at the subject `$KV.mesh-grants_accounts.`, read by the vault alone; and the vault's account-issued + // names the account, never a value. + t.Run("a wanted account is kept by its user and read by the vault alone", func(t *testing.T) { + for _, w := range []WantedAccount{ + {User: "ace.mesh-build-go", Kind: KindModule, Node: "ace", Module: "mesh-build-go", URL: "nats://novox.mesh:4222", + Fingerprint: "ab", Claims: []ClaimedSeat{{Seat: "node-build-go", Scope: "node", Serves: []string{"build"}}}}, + {User: "novox.mesh-mail.send", Kind: KindProcess, Node: "novox", Module: "mesh-mail", Process: "send", + URL: "nats://novox.mesh:4222"}, + {User: "provisioner.g14", Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner-client", + URL: "nats://novox.mesh:4222", Fingerprint: "ab"}, + } { + if err := w.Valid(); err != nil { + t.Fatalf("%s: %v", w.User, err) + } + raw, err := json.Marshal(w) + if err != nil { + t.Fatal(err) + } + back, err := ReadWantedAccount(w.User, raw) + if err != nil || !back.Same(w) { + t.Errorf("%s does not round-trip: %+v, %v", w.User, back, err) + } + if got := AccountsSubject(w.User); got != "$KV.mesh-grants_accounts."+w.User { + t.Errorf("%s is kept at %s", w.User, got) + } + if _, err := ReadWantedAccount("someone.else", raw); err == nil { + t.Errorf("%s was read under another user's key", w.User) + } + } + for _, bad := range []WantedAccount{ + {User: "ace.x", Kind: KindModule, Node: "ace", Module: "y", URL: "nats://a:1"}, + {User: "ace.x.p", Kind: KindProcess, Node: "ace", Module: "x", URL: "nats://a:1"}, + {User: "node.ace", Kind: KindNode, Node: "ace", URL: "nats://a:1"}, + {User: "controller", Kind: "controller", URL: "nats://a:1"}, + {User: "ace.x", Kind: KindModule, Node: "ace", Module: "x"}, + } { + if bad.Valid() == nil { + t.Errorf("%+v is taken as a wanted account", bad) + } + } + if ReaderMay("mesh-delivery-assigner", GrantsAccounts) || !ReaderMay(VaultSeat, GrantsAccounts) { + t.Errorf("%s is read by %v alone, want the vault's", GrantsAccounts, NamedReaders[GrantsAccounts]) + } + e := AccountIssued{User: "ace.x", Kind: KindModule, Node: "ace", Module: "x", Generation: 1} + raw, _ := json.Marshal(e) + if strings.Contains(string(raw), "password") || strings.Contains(string(raw), "hash") || + AccountIssuedSubject != "mesh.mod.mesh-vault.event.account-issued" { + t.Errorf("account-issued says %s at %s", raw, AccountIssuedSubject) + } + }) + // 774/AC3 (novox/hq ADR 0561 §2): the account the vault seals is, byte for byte, what the controller hands for each of + // its three kinds: testdata/controller-credentials.json was written by the controller's own code (its "how"). + t.Run("a held account is the controller's shape byte for byte", func(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "controller-credentials.json")) + if err != nil { + t.Fatal(err) + } + var captured struct { + Rows []struct{ Name, Held string } + } + if err := json.Unmarshal(raw, &captured); err != nil { + t.Fatal(err) + } + vault := ClaimedSeat{Seat: "mesh-vault", Scope: "mesh", Serves: []string{"get", "accept", "secret-ask", "issued", + "rotate", "move-in", "node-key", "own", "move-asks"}} + controller := ClaimedSeat{Seat: "mesh-controller", Scope: "mesh", Serves: strings.Split("tools,calls,status,nodes,"+ + "node,placement-export,modules,seats,builds,plans,delivery-plan,delivery-order,delivery-check,deliver,"+ + "delivery-stop,delivery-backups-again,delivery-walks,plan,assign,unassign,pin,unpin,push,rotate,give,issued,"+ + "secret-move,secret-ask,issue,token,settings,command,queue,cancel,clear,replay,try,kill,pause,resume,hand-act,"+ + "drill,warranted,hand-acts,durations,conditions,healers,doctor,upgrade,bus,retire,cleanup,judge-directories,"+ + "root-free,dead-letters,data,build,artifacts,held,collect,move-placement,move-config,mirrors,images", ",")} + at := "nats://10.0.0.1:4222" + wanted := map[string]struct { + w WantedAccount + password string + }{ + "module": {WantedAccount{User: "laptop.cap-bot", Kind: KindModule, Node: "laptop", Module: "cap-bot", URL: at, + Fingerprint: "ab:cd", Claims: []ClaimedSeat{vault, {Seat: "node-service-manager", Scope: "node", + Serves: []string{"status"}}, controller, {Seat: "no-such-seat", Scope: "node"}}}, "pw-module"}, + "module-no-claims-no-fingerprint": {WantedAccount{User: "laptop.cap-plain", Kind: KindModule, Node: "laptop", + Module: "cap-plain", URL: at}, "pw-plain"}, + "provisioner": {WantedAccount{User: "provisioner.laptop", Kind: KindProvisioner, Node: "laptop", Module: "nats", + URL: at, Fingerprint: "ab:cd"}, "pw-prov"}, + "process": {WantedAccount{User: "laptop.mesh-mail.send", Kind: KindProcess, Node: "laptop", Module: "mesh-mail", + Process: "send", URL: at, Fingerprint: "ab:cd"}, "pw-proc"}, + } + if len(captured.Rows) != len(wanted) { + t.Fatalf("%d rows captured, %d wanted", len(captured.Rows), len(wanted)) + } + for _, row := range captured.Rows { + c, ok := wanted[row.Name] + if !ok { + t.Fatalf("no wanted account for the captured %s", row.Name) + } + got, err := HeldCredential(c.w, c.password) + if err != nil || string(got) != row.Held { + t.Errorf("%s:\n got %s (%v)\nwant %s", row.Name, got, err, row.Held) + } + } + if _, err := HeldCredential(wanted["module"].w, ""); err == nil { + t.Error("an account was held with no password") + } + }) + // 774 subtask 8 (novox/hq ADR 0561 §6): a module's consumer is created as the grants part creates it, and every read is + // judged for its reader. + t.Run("every read is judged for its reader; a consumer is created as the grants part creates it", func(t *testing.T) { + announced := map[string]announce.States{} + // Issue 774 (sweep item 2): Users judges every read for its reader again, whatever split the caller made: a + // reader-less split refused the vault the grants part's accounts it is named for. + announced["mesh-grants"] = announce.States{Module: "mesh-grants", States: []announce.State{{Name: "accounts", Readers: []string{VaultSeat}}}} + announced["disk-load"] = announce.States{Module: "disk-load", States: []announce.State{{Name: "readings"}}} + judged, err := Users(Records{Nodes: []string{"a"}, Announced: announced, Assigned: map[string][]Declared{"a": { + {Module: VaultSeat, ReadsRefused: []string{GrantsAccounts}}, + {Module: "restic", ReadsRefused: []string{"disk-load.readings"}}, + {Module: "messenger", Reads: []string{GrantsAccounts}}}}}) + if err != nil { + t.Fatal(err) + } + reads := map[string]string{} + for _, u := range judged { + if u.Kind == KindModule { + reads[u.Module] = strings.Join(u.Reads, ",") + } + } + if reads[VaultSeat] != GrantsAccounts || reads["restic"] != "" || reads["messenger"] != "" { + t.Errorf("the reads granted for their readers: %v", reads) + } + // AccountsEntry is the very lines ComposeAccounts writes for a user, so a writer of kept users repeats none. + filled, _ := WithPasswords(judged, map[string]string{judged[0].Username(): "$2a$11$h"}) + list, err := ComposeAccounts(filled[:1]) + perms, perr := PermissionsFor(filled[0]) + if err != nil || perr != nil || !strings.Contains(list, " users = [\n"+AccountsEntry(filled[0].Username(), "$2a$11$h", perms)+" ]\n") { + t.Errorf("the accounts entry is not ComposeAccounts' own: %v %v\n%s", err, perr, list) + } + c, _ := ConsumerFor(Principal{Kind: KindModule, Node: "a", Module: "m", Consumes: []string{"x.happened"}}) + cfg := ConsumerConfig(c) + if cfg.Durable != "a_m" || cfg.AckPolicy != jetstream.AckExplicitPolicy || cfg.AckWait != 30*time.Second || + cfg.MaxDeliver != 5 || cfg.DeliverPolicy != jetstream.DeliverNewPolicy || len(cfg.FilterSubjects) != 2 || + cfg.Description == "" { + t.Errorf("a module's consumer is created as %+v", cfg) + } + // The grants part is granted the create of each account's consumer by its stream and name, and no other. + users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": { + {Module: GrantsModule}, {Module: "m", Consumes: []string{"x.happened"}}, {Module: "quiet"}}}}) + if err != nil { + t.Fatal(err) + } + for _, u := range users { + if u.Module != GrantsModule || u.Kind != KindModule { + continue + } + perms, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + if !MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_quiet") || + MayPublish(perms, "$JS.API.CONSUMER.DELETE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m.mesh.mod.x.event.happened") { + t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers) + } + } + // 1952 S5 (novox/hq ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8): the health holders' credentials. + healthChecks(t) + q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"}, + QueueOwner{Process: "p", Stream: "M_WORK", MaxDeliver: 3})) + if q.Durable != "a_m_p" || q.FilterSubject != "" || q.MaxDeliver != 3 || q.DeliverPolicy != jetstream.DeliverAllPolicy { + t.Errorf("a process's queue consumer is created as %+v", q) + } + }) +} + +// healthChecks are the health holders' credential cases (ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8; issue 1952 S5). +func healthChecks(t *testing.T) { + t.Helper() + announced := map[string]announce.States{ + "mesh-healers": {Module: "mesh-healers", States: []announce.State{{Name: "attempts", Readable: true}, {Name: "would", Readable: true}}}} + for _, c := range []struct { + reader, read string + may bool + }{ + {"mesh-conditions", "mesh-healers.attempts", true}, {"mesh-conditions", HealersWould, false}, {"mesh-delivery-view", HealersWould, false}, + // ADR 0577 clause 1: the node-engine's data items are disk-load's alone to read. + {"disk-load", delivery.EngineDataRead, true}, {"restic", delivery.EngineDataRead, false}, + } { + granted, _ := ReadsAnnouncedBy(c.reader, []string{c.read}, announced) + if (len(granted) == 1) != c.may { + t.Errorf("%s reads %s: %v, want %v", c.reader, c.read, granted, c.may) + } + } + bucket := func(m, n string) Bucket { return Bucket{Module: m, Name: n} } + users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": { + {Module: "mesh-conditions", State: []Bucket{bucket("mesh-conditions", "conditions"), bucket("mesh-conditions", "takeover")}, + Reads: []string{"mesh-self-check.findings", "mesh-healers.attempts"}}, + {Module: "mesh-self-check", State: []Bucket{bucket("mesh-self-check", "findings")}, Reads: []string{"restic.good"}}, + // Issue 1955 L7: the grants part keeps the hearings it withholds; the self-check reads them. + {Module: GrantsModule, State: []Bucket{bucket(GrantsModule, GrantsWithheldState)}}, + {Module: "mesh-healers", State: []Bucket{bucket("mesh-healers", "findings"), bucket("mesh-healers", "attempts"), bucket("mesh-healers", "would")}}, + {Module: WatchmanModule, State: []Bucket{bucket(WatchmanModule, "findings")}, Reads: []string{delivery.NodeHealthRead}}, + {Module: "mesh-hand-acts", State: []Bucket{bucket("mesh-hand-acts", "warrants")}, Logs: []Log{{Module: "mesh-hand-acts", Name: "acts"}}}, + // ADR 0570 clauses 1, 8 (issue 1954 L6-P5): mesh-provisioner keeps provisions and plans; the vault and a provider + // read them. + {Module: "mesh-provisioner", State: []Bucket{bucket("mesh-provisioner", "provisions"), bucket("mesh-provisioner", "plans"), + bucket("mesh-provisioner", "registry")}}, + {Module: "mesh-vault", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}}, + {Module: "postgres", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}}, + // The node's provisioner client reads its machine's plans and records no needs (ADR 0570 clause 2; its V3). + {Module: "mesh-provisioner-client", ProvisionsNode: true}, + // ADR 0571 clauses 2, 4 (issue 1955): the seat mesh-artifact-sweep says artifacts-gone; the assigner keeps seat-holders. + {Module: "artifact-store-tools", Holds: []Seat{{Name: "mesh-artifact-sweep", Emits: []string{"artifacts-gone"}}}, + Reads: []string{delivery.SeatHoldersRead}}, + {Module: delivery.AssignerModule, State: []Bucket{bucket(delivery.AssignerModule, delivery.SeatHoldersState)}}, + // ADR 0571 clause 3: disk-load keeps the readings; the backup holder reads them. + // ADR 0577 clause 1: and it reads its own node's keys of the node-engine's data items, which restic may not. + {Module: "disk-load", State: []Bucket{bucket("disk-load", "readings")}, Reads: []string{delivery.EngineDataRead}}, + {Module: "restic", Reads: []string{"disk-load.readings", delivery.EngineDataRead}, State: []Bucket{bucket("restic", "good")}}, + // ADR 0571 clauses 1, 9: the indexer reads the builder's log through its own consumer; the catalogue module asks + // the builder's tool. + {Module: delivery.BuilderModule, Logs: []Log{{Module: delivery.BuilderModule, Name: delivery.BuilderLog, MaxAgeDays: 30}}}, + {Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead}}, + {Module: "mesh-catalog", Invokes: []string{delivery.BuilderModule + "." + delivery.BuildsSinceTool}}, + {Module: "telegram", Holds: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}}, + Processes: []ProcessPrincipal{{Process: "w", Queues: []QueueOwner{{Process: "w", Stream: "M_WORK"}, {Process: "w", Stream: "KV_x"}}}}}, + {Module: "nats", SnapshotsTheBus: true, Processes: []ProcessPrincipal{{Process: "nats-tools", + Removes: []string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}}, {Process: "other"}}}, + {Module: "shop", Processes: []ProcessPrincipal{{Process: "nats-tools", Removes: []string{catalogue.RemovesEngineActs}}}}, + }}}) + if err != nil { + t.Fatal(err) + } + may := map[string][]string{ + "a.mesh-conditions": {"$KV.mesh-conditions_conditions.machine.g14.silent"}, + "a.mesh-provisioner": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"}, + "a.mesh-vault": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", + "$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak"}, + "a.mesh-hand-acts": {"$KV.mesh-hand-acts_warrants.mesh-config.ask-1", "mesh.log.mesh-hand-acts.acts.act-1"}, + "a.disk-load": {"$KV.disk-load_readings.a.pg.data", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.a.pg", + "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.c1.$KV.mesh-host_data.a.*"}, + "provisioner.a": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak", + "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.c1.$KV.mesh-provisioner_plans.a.keycloak", + // Every machine's plans, as the client V3 watches them (issue 774, 2026-10-30). + "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.c1.$KV.mesh-provisioner_plans.>", + "$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.b.keycloak", + // The needs, for the client before V3, until the lease write (review round 3). + "$KV." + ProvisionerNeedsBucket + ".a.keycloak.db.x"}, + // ADR 0571 clause 3: restic's bundle writes each item's last good night as its own state; the self-check reads it. + "a.mesh-self-check": {"$JS.API.DIRECT.GET.KV_restic_good.$KV.restic_good.a.pg.data"}, + "a.mesh-grants": {"$KV.mesh-grants_withheld.reader.mail.a"}, + "a.restic": {"$JS.API.DIRECT.GET.KV_disk-load_readings.$KV.disk-load_readings.a.pg.data", "$KV.restic_good.a.pg.data", + // ADR 0571 clause 7: its own membership from the grants part's state. + "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic"}, + "a.mesh-delivery-indexer": {"$JS.API.CONSUMER.CREATE.LOG_mesh-delivery-builder_build-log.r1.mesh.log.mesh-delivery-builder.build-log.letta", + "$JS.API.CONSUMER.MSG.NEXT.LOG_mesh-delivery-builder_build-log.r1", "$JS.API.CONSUMER.DELETE.LOG_mesh-delivery-builder_build-log.r1", + "$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_build-log.mesh.log.mesh-delivery-builder.build-log.letta"}, + "a.mesh-delivery-builder": {"mesh.log.mesh-delivery-builder.build-log.letta"}, + "a.mesh-catalog": {"mesh.mod.mesh-delivery-builder.tool.builder_builds_since"}, + WatchmanModule: {"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES." + WatchmanModule, "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES." + WatchmanModule, + "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS"}, + WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.EVENTS", "$JS.API.STREAM.INFO.SEAT_TELEGRAM_SENDER", + "$JS.API.STREAM.MSG.GET.M_WORK", "$JS.API.STREAM.INFO.EVENTS", "mesh.events.dead.EVENTS.a_m"}, + "a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", + "$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts", + // Research 072 row 33: the ASSIGNMENTS stream is read and removed at the lease write. + "$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS", + // Issue 1126 B5 (ADR 0590 clause 6): every leftover of the ended controller, each by its name, read and deleted. + "$JS.API.STREAM.INFO.KV_mesh-controller_lease", "$JS.API.STREAM.DELETE.KV_mesh-controller_lease", + "$JS.API.STREAM.DELETE.KV_mesh-controller_rollouts", "$JS.API.STREAM.DELETE.KV_mesh-controller_states-announced", + "$JS.API.STREAM.DELETE.CONTROL", "$JS.API.STREAM.DELETE.OBJ_declaration-files-a", + "$JS.API.CONSUMER.DELETE.EVENTS.controller", "$JS.API.CONSUMER.DELETE.EVENTS.controller-merges", + "$JS.API.CONSUMER.DELETE.EVENTS.novox_mesh-delivery", "$JS.API.CONSUMER.DELETE.CONTROL.controller", + // And the consumers no part composes that the controller made before it ended: mesh-catalog's of the + // controller's built-before, and each node's engine's of restic's backed-up (issue 1126). + "$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-catalog", "$JS.API.CONSUMER.DELETE.EVENTS.novox_mesh-catalog", + "$JS.API.CONSUMER.INFO.EVENTS.a_mesh-host", "$JS.API.CONSUMER.DELETE.EVENTS.a_mesh-host", + "$JS.API.CONSUMER.INFO.CONTROL.controller"}, + } + mayNot := map[string][]string{ + // The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2). + "a.mesh-conditions": {"$KV.mesh-self-check_findings.x", "$KV.mesh-healers_attempts.x", "$KV.mesh-controller_conditions.x"}, + "a.mesh-self-check": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch", "$KV.restic_good.a.pg.data", + "$KV.mesh-grants_withheld.reader.mail.a", "$SRV.INFO", "$SRV.PING.node-tools", + "$JS.API.DIRECT.GET.KV_mesh-grants_withheld.$KV.mesh-grants_withheld.reader.mail.a"}, + // Only mesh-provisioner writes provisions and plans, and it writes none of the grants part's states (ADR 0570 + // clauses 1, 8). + "a.mesh-provisioner": {"$KV.mesh-grants_accounts.a.m", "$KV.mesh-grants_user-list.sealed"}, + "a.mesh-vault": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak", + "mesh.seat.mesh-artifact-sweep.event.artifacts-gone"}, + // Each writes its own state alone, and these users are composed. + "a.disk-load": {"$KV.restic_good.a.pg.data", "$KV.mesh-host_data.a.pg", + "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.b.pg", "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.c1.$KV.mesh-host_data.b.*"}, + "a.mesh-delivery-builder": {"$KV.disk-load_readings.a.pg.data"}, + // It reads every machine's plans (a provider's consumers are bound there, ADR 0373) but writes none, nor another + // machine's needs, which it keeps for the client before V3 until the lease write. + "provisioner.a": {"$KV.mesh-provisioner_plans.a.keycloak", "$KV." + ProvisionerNeedsBucket + ".b.keycloak.db.x"}, + // Only the assigner writes seat-holders. + "a.artifact-store-tools": {"$KV.mesh-delivery-assigner_seat-holders.mesh-broker"}, + // Only disk-load writes its readings. + "a.restic": {"$KV.disk-load_readings.a.pg.data", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.a.pg"}, + "a.postgres": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"}, + // Nor mesh-hand-acts' log and warrants, which it alone writes (ADR 0565 clause 10). + "a.mesh-healers": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch", + "$KV.mesh-hand-acts_warrants.x", "mesh.log.mesh-hand-acts.acts.x"}, + // The watchman never deletes or changes the controller's consumer (clause 5). + // Its reads and deletes of a message are on the streams a dead letter comes from, named, never a bucket's + // (the lead, 2026-10-29). + WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.KV_mesh-vault_bus-accounts", "$JS.API.STREAM.MSG.GET.KV_x", + "$JS.API.STREAM.MSG.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", "$JS.API.STREAM.MSG.GET.OTHER", + // In L4 it neither delivers again nor drops (clause 5, the lead 2026-10-29). + "$JS.API.STREAM.MSG.DELETE.EVENTS", "$JS.API.STREAM.MSG.DELETE.DEAD_LETTERS", "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x", + "mesh.again.a_m.x"}, + WatchmanModule: {"$KV.mesh-conditions_conditions.x", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", + "$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES." + WatchmanModule}, + // The bus module's tools remove the one consumer and the acts' keys, nothing else (Limits 4); its other accounts + // remove nothing; an account is granted the removals its bus declares and no other (the catalogue refuses them + // outside the bus module). + "a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.other", "$JS.API.CONSUMER.DELETE.EVENTS.a_m", + "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.PURGE.KV_mesh-host_acts", + "$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X", + // It updates nothing, purges nothing, deletes no stream but the leftovers' and writes no key of them (ADR 0590). + "$JS.API.STREAM.UPDATE.KV_mesh-host_acts", "$JS.API.STREAM.UPDATE.KV_mesh-controller_calls", "$JS.API.STREAM.UPDATE.EVENTS", + "$JS.API.STREAM.UPDATE.KV_mesh-controller_lease", "$JS.API.STREAM.PURGE.KV_mesh-controller_lease", + "$JS.API.STREAM.DELETE.KV_mesh-controller_other", "$JS.API.STREAM.DELETE.OBJ_declaration-files-zz", + "$JS.API.CONSUMER.DELETE.EVENTS.a_mesh-delivery", "$KV.mesh-controller_lease.holder"}, + "a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1", + "$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"}, + "a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"}, + "a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.ASSIGNMENTS"}, + } + // ADR 0571 clause 1: a read naming another module's log is issued to the reader as a log it reads, never writes. + if m := MembershipFor("a", Declared{Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead, "x.y"}}, + Placements{Logs: map[string]bool{delivery.BuildLogRead: true}}); len(m.Logs) != 1 || m.Logs[0].Writes || + m.Logs[0].Stream != "LOG_mesh-delivery-builder_build-log" || m.Logs[0].Name != delivery.BuildLogRead { + t.Errorf("the indexer's membership issues the logs %+v", m.Logs) + } + // ADR 0565 clauses 6, 7 (issue 1953 L5-S4): a node's engine writes its own packages key and no other node's; only the + // assigner writes its policies. + for _, u := range users { + perms, err := PermissionsFor(u) + if err != nil { + continue + } + // ADR 0570 clause 6: a node's engine reads its own node's provision keys, and no other node's, and writes none. + if u.Kind == KindNode && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.db") || + MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.b.keycloak.db") || + MayPublish(perms, "$KV.mesh-provisioner_provisions.a.keycloak.db")) { + t.Errorf("node %s reads the provisions %v", u.Node, perms.Publish) + } + // Issue 774: a node's engine writes its own health report's key and no other node's. + if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_health.a") || MayPublish(perms, "$KV.mesh-host_health.b")) { + t.Errorf("node %s writes the health reports %v", u.Node, perms.Publish) + } + if u.Kind == KindModule && u.Module == WatchmanModule && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b") || + MayPublish(perms, "$KV.mesh-host_health.b")) { + t.Errorf("the watchman reads the health reports %v, %v", MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b"), perms.Publish) + } + // ADR 0565 clause 6, ADR 0590: and the ended controller's mirror it reads no more. + if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b") || + MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.a")) { + t.Errorf("node %s writes the packages keys %v", u.Node, perms.Publish) + } + if u.Kind == KindModule && MayPublish(perms, "$KV.mesh-delivery-assigner_policies.x") { + t.Errorf("%s writes the assigner's policies", u.Username()) + } + // ADR 0571 clause 10 (issue 774, 774/S5/AC2): nobody hears memberships on mesh.assignment; they are read from + // the grants part's state. + if MaySubscribe(perms, "mesh.assignment.a.restic") { + t.Errorf("%s hears mesh.assignment", u.Username()) + } + } + // Nor the runtime carrying the node's modules, which reads each one's membership from the state. + if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: RuntimeModule, + Carries: []Declared{{Module: "restic"}}}); err != nil || MaySubscribe(perms, "mesh.assignment.a.restic") || + !MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic") { + t.Errorf("the runtime hears mesh.assignment or reads no membership: %v", err) + } + // 1953 S-age (ADR 0565 §10): a log that says an age keeps its entries that long and no longer; one that says none, as + // long as the log (ADR 0297). + if aged, kept := (Log{Module: "mesh-hand-acts", Name: "acts", MaxAgeDays: 400}).Config(), (Log{Module: "m", Name: "l"}).Config(); aged.MaxAge != 400*24*time.Hour || kept.MaxAge != 0 || !aged.DenyDelete { + t.Errorf("an aged log's stream keeps %s, an unaged one %s", aged.MaxAge, kept.MaxAge) + } + // The tools account's secret is the module's own broker-, which its manifest's own-secrets names and the + // vault keeps by that name (AccountSecret). + if w := (WantedAccount{User: "a.nats.nats-tools", Kind: KindProcess, Node: "a", Module: "nats", Process: "nats-tools", + URL: "nats://a:4222"}); w.Valid() != nil || w.OwnSecret() != "broker-nats-tools" { + t.Errorf("the tools account is held in %q (%v)", w.OwnSecret(), w.Valid()) + } + // Issue 1952: a module that holds a seat says its own seat's events, also when the tool runner carries it, so the + // self-check's `checked`, the keeper's condition events and the healers' `healer-acted` reach their hearers. Its + // membership lists them, the runtime is granted them; a user of the seat is not, nor a machine's event on a node seat + // (granted per machine, to the holder's own account only). + selfCheck := Declared{Module: "mesh-self-check", Holds: []Seat{{Name: "mesh-self-check", Scope: "mesh", Emits: []string{"checked"}}}} + for _, c := range []struct { + name string + d Declared + subject string + may bool + }{ + {"the self-check says checked", selfCheck, "mesh.seat.mesh-self-check.event.checked", true}, + {"the keeper says a condition raised", Declared{Module: "mesh-conditions", Holds: []Seat{{Name: "mesh-conditions", Scope: "mesh", + Emits: []string{"condition-raised", "condition-cleared"}}}}, "mesh.seat.mesh-conditions.event.condition-cleared", true}, + {"a user does not say the seat's events", Declared{Module: "mesh-delivery-view", Uses: []Seat{{Name: "mesh-self-check", + Scope: "mesh", Emits: []string{"checked"}}}}, "mesh.seat.mesh-self-check.event.checked", false}, + {"the holder says no other seat's", selfCheck, "mesh.seat.mesh-conditions.event.condition-raised", false}, + {"a machine's event on a node seat is not composed here", Declared{Module: "pause", Holds: []Seat{{Name: "node-power", + Scope: "node", Emits: []string{"paused.*"}}}}, "mesh.seat.node-power.event.paused.b", false}, + } { + m := MembershipFor("a", c.d, Placements{}) + listed := m.SeatTraffic != nil && slices.ContainsFunc(m.SeatTraffic.Publish, func(p string) bool { return p == c.subject }) + perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Carries: []Declared{c.d}}) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + if listed != c.may || MayPublish(perms, c.subject) != c.may { + t.Errorf("%s: the membership lists %s %v and the runtime may publish it %v, want %v", c.name, c.subject, listed, + MayPublish(perms, c.subject), c.may) + } + } + seen := map[string]bool{} + for _, u := range users { + name := u.Username() + if u.Module == WatchmanModule && u.Kind == KindModule { + name = WatchmanModule + } + perms, err := PermissionsFor(u) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if name == WatchmanModule { + // It hears its watchdogs' words on core subjects (Limits 3, 11). + for _, subject := range []string{"mesh.mod.power.event.sleeping", "mesh.control.g14.tools-alive", + "mesh.seat.mesh-self-check.event.checked", "mesh.mod.keycloak.event.provisioner.failing", + "mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak"} { + if !MaySubscribe(perms, subject) { + t.Errorf("the watchman may not hear %s", subject) + } + } + // Never the bus's API audit, which carries every API answer in the account (the lead, 2026-10-29). + if MaySubscribe(perms, "$JS.EVENT.ADVISORY.API") || MaySubscribe(perms, "$JS.EVENT.ADVISORY.API.x") { + t.Error("the watchman hears the bus's API audit") + } + if !MayPublish(perms, "$JS.API.DIRECT.GET.PROV_APPLIED.prov.applied.g14.keycloak") { + t.Error("the watchman may not read the newest provisioner report") + } + } + subjects := append(append([]string(nil), may[name]...), may[name+" sources"]...) + denied := append(append([]string(nil), mayNot[name]...), mayNot[name+" sources"]...) + if len(may[name+" sources"]) > 0 { + seen[name+" sources"] = true + } + for _, subject := range subjects { + seen[name] = true + if !MayPublish(perms, subject) { + t.Errorf("%s may not publish %s", name, subject) + } + } + for _, subject := range denied { + seen[name] = true + if MayPublish(perms, subject) { + t.Errorf("%s may publish %s", name, subject) + } + } + } + // Every row names a user that was composed, in either table: a row for a user absent would pass for nothing. + for _, table := range []map[string][]string{may, mayNot} { + for name := range table { + if !seen[name] { + t.Errorf("no user %s was composed", name) + } + } + } +} diff --git a/modules/mesh-grants/internal/buscompose/callers.go b/modules/mesh-grants/internal/buscompose/callers.go new file mode 100644 index 0000000..5af25dd --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/callers.go @@ -0,0 +1,117 @@ +package buscompose + +import ( + "regexp" + "strings" +) + +// A tool call names its caller (novox/hq issue 365, by ADR 0259 §3's precedent for asks). +// +// mesh.mod..call.[.]. +// mesh.seat..call.[.]. +// +// The last token is the bus user that published the call, and each user is granted these subjects with its own +// name there and no other — the caller is a fact the server enforces, and the tool runtime hands it to the +// module from the subject a call arrived on (mesh-tools node-tools internal/bus caller.go holds the same shape). +// +// **A kind of its own, `call`, not the `tool` subject with a token appended.** Every grant to call a tool is a +// wildcard over the `tool` kind — `.tool..*` for the instance on any machine, `mesh.mod.*.tool.>` for every +// tool — and either would match a `tool` subject with any caller appended, so a caller could name another. +// Under `call` nothing is granted but the caller-named subjects. No stream's filter covers it: a tool call is +// never persisted (design 25 §3). +// +// **Derived from the `tool` grants, in one place** (callerNamed): wherever a principal may call or answer a +// tool, it may call it in its own name, or answer it naming any caller — so no kind of principal is left +// unable to call in its own name, and a new grant to call is one in both shapes by construction. +// +// The `tool` grants stay beside these for one release, so every caller and every runtime moves without a gap: +// their retirement is hq issue 464. +const CallKind = "call" + +var userName = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`) + +// CallerToken is a bus user's name as the last token of a call it publishes: each dot written `~`, which no part +// of a user's name may hold (safeSubject), so the token names that user and no other. "" for a name that is not +// a user's. +func CallerToken(user string) string { + if !userName.MatchString(user) { + return "" + } + return strings.ReplaceAll(user, ".", "~") +} + +// toolGrant splits a grant on the `tool` kind — `mesh.mod..tool.` or `mesh.seat..tool.`, any +// part possibly a wildcard — at the kind; ok is false for any other subject. +func toolGrant(subject string) (head, rest string, ok bool) { + parts := strings.SplitN(subject, ".", 5) + if len(parts) != 5 || parts[0] != "mesh" || (parts[1] != "mod" && parts[1] != "seat") || parts[3] != "tool" || + parts[2] == "" || parts[4] == "" { + return "", "", false + } + return parts[0] + "." + parts[1] + "." + parts[2], parts[4], true +} + +// CalledSubject is where a call to a tool subject goes naming its caller; "" when the subject is not a tool's +// or the user is not a bus user. +func CalledSubject(subject, user string) string { + head, rest, ok := toolGrant(subject) + token := CallerToken(user) + if !ok || token == "" || strings.ContainsAny(rest, "*>") { + return "" + } + return head + "." + CallKind + "." + rest + "." + token +} + +// CalledPattern is what a holder answering a tool subject also subscribes, to hear the calls that name their +// caller: the same address under the `call` kind, any caller last. "" for a subject that is not a tool's. +func CalledPattern(subject string) string { + head, rest, ok := toolGrant(subject) + if !ok || strings.ContainsAny(rest, "*>") { + return "" + } + return head + "." + CallKind + "." + rest + ".*" +} + +// calledPublish is a grant to call on the `tool` kind, as the same grant in the caller's own name: its last +// token the caller's, and nothing that reaches past it. A `>` is every tail a call carries — the tool alone or +// the tool and the machine — so it becomes both, each ending in the caller. +func calledPublish(grant, token string) []string { + head, rest, ok := toolGrant(grant) + if !ok || token == "" { + return nil + } + base := head + "." + CallKind + "." + switch { + case rest == ">": + return []string{base + "*." + token, base + "*.*." + token} + case strings.HasSuffix(rest, ".>"): + return []string{base + strings.TrimSuffix(rest, ">") + "*." + token} + } + return []string{base + rest + "." + token} +} + +// calledSubscribe is a grant to answer on the `tool` kind, as the same grant for the calls naming any caller. +func calledSubscribe(grant string) []string { + head, rest, ok := toolGrant(grant) + if !ok { + return nil + } + base := head + "." + CallKind + "." + if strings.HasSuffix(rest, ">") { + return []string{base + rest} + } + return []string{base + rest + ".*"} +} + +// callerNamed adds, beside a principal's grants on the `tool` kind, the same grants under `call`: to publish in +// its own name, to subscribe naming anybody. +func callerNamed(user string, pub, sub []string) ([]string, []string) { + token := CallerToken(user) + for _, g := range pub { + pub = append(pub, calledPublish(g, token)...) + } + for _, g := range sub { + sub = append(sub, calledSubscribe(g)...) + } + return unique(pub), unique(sub) +} diff --git a/modules/mesh-grants/internal/buscompose/cancelled.go b/modules/mesh-grants/internal/buscompose/cancelled.go new file mode 100644 index 0000000..f7fa097 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/cancelled.go @@ -0,0 +1,11 @@ +package buscompose + +import "strings" + +// A seat's cancelled set (novox/hq ADR 0219) is a bucket the mesh no longer raises or grants (R4g, ADR 0525 clause 6): the +// controller asks no work queue, so nothing is cancelled. A bus that still holds one is not reported as state nothing declares. + +// IsCancelledSet says a bucket is a seat's cancelled set rather than a module's state. +func IsCancelledSet(bucket string) bool { + return strings.HasPrefix(bucket, "SEAT_") && strings.HasSuffix(bucket, "_cancelled") +} diff --git a/modules/mesh-grants/internal/buscompose/composed.go b/modules/mesh-grants/internal/buscompose/composed.go new file mode 100644 index 0000000..6aeb6bb --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/composed.go @@ -0,0 +1,57 @@ +package buscompose + +import ( + "fmt" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The compositions' buckets (novox/hq ADR 0539 clauses 2 and 4): one per node, `composed-`, holding the +// compositions the ended controller made for that node, which only that node's node-engine reads. Nothing writes them +// since the controller ended (ADR 0590); a node composes its own modules (ADR 0547 §2). + +// ComposedReads is what a node's node-engine publishes to follow its wants and compositions (ADR 0539 clauses 1, 2 and +// 9): every key of its own composition bucket, and its own wants (`want..` and `want..node`) in the +// assigner's bucket; nothing of another node's, nor the bucket of digests. +func ComposedReads(node string) ([]string, error) { + bucket, err := delivery.ComposedBucketFor(node) + if err != nil { + return nil, err + } + if !safeSubject.MatchString(node) { + return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node) + } + return append(readGrants(bucket, ""), readGrants(delivery.AssignerWantsBucket, "want."+node+".>")...), nil +} + +// VerbsTheNodeAgentAsks are the seat verbs a node's node-engine calls of its own machine while it applies a want (ADR +// 0539 clause 16, ADR 0429 §14): its backup holder backs a data module up before it moves, and says when it was. Asked +// of its own machine only, `mesh.seat..tool..`. +var VerbsTheNodeAgentAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}, {Seat: "node-backup", Verb: "backed-up"}} + +// NodeAgentAsks are the subjects of VerbsTheNodeAgentAsks for one node. +func NodeAgentAsks(node string) ([]string, error) { + if !safeSubject.MatchString(node) { + return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node) + } + var out []string + for _, v := range VerbsTheNodeAgentAsks { + out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb+"."+node) + } + return out, nil +} + +// NodeAgentHears are the events a node's node-engine subscribes while it applies a want: the backup holder's word that a +// backup is taken (`backed-up`), from whichever module holds node-backup on its machine (restic today). +var NodeAgentHears = []string{"mesh.mod." + BackupHolderModule + ".event.backed-up"} + +// BackupHolderModule is the module that holds node-backup on every node, restic: the one whose word a node-engine hears. +// A second holder of the seat is a change of this grant. +const BackupHolderModule = "restic" + +// DeclarationsStream is the stream of each node's last declaration, `mesh.node..declare` (issue 107). Release 6 +// sends none (ADR 0539 clauses 5 and 11): the stream is kept, with each node's consumer, only so a node away during the +// switch finds its last declaration and with it the new node-engine; the controller deletes it once every node's have +// names the new node-engine, and from then on neither asserts it nor any node's consumer on it; nor makes it again once +// it is gone from the bus for any reason, since a stream made anew would hold no node's last declaration (issue 1945). +const DeclarationsStream = "NODES" diff --git a/modules/mesh-grants/internal/buscompose/create.go b/modules/mesh-grants/internal/buscompose/create.go new file mode 100644 index 0000000..0147716 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/create.go @@ -0,0 +1,115 @@ +package buscompose + +import ( + "time" + + "github.com/nats-io/nats.go/jetstream" +) + +// The streams a manifest declares, as the server is asked to create them (novox/hq ADR 0547 §5, issue 1790): the +// grants part creates each one the bus does not hold yet, with what the controller asserts at its start, so the two +// make the same stream. Pure. Only a create is ever asked with these: a stream the bus holds is never updated by the +// grants part (CreateStreamGrants grants no update). + +// BucketStreamConfig is a module's bucket as the stream the controller's CreateOrUpdateKeyValue makes of its options +// (mesh-controller internal/broker BucketConfig, with no marker age held): the client library's key-value shape +// (rollup, no delete, direct reads, discard new, a duplicate window of two minutes or the age when shorter) with the +// mesh's caps, file storage and one replica. +func BucketStreamConfig(b Bucket) jetstream.StreamConfig { + history := b.History + if history < 1 { + history = 1 + } + age := time.Duration(b.TTLSeconds) * time.Second + dupes := 2 * time.Minute + if age > 0 && age < dupes { + dupes = age + } + cfg := jetstream.StreamConfig{ + Name: "KV_" + b.Bucket(), + Description: b.Why(), + Subjects: []string{"$KV." + b.Bucket() + ".>"}, + MaxMsgsPerSubject: int64(history), + MaxBytes: StateMaxBytes, + MaxAge: age, + MaxMsgSize: StateMaxValueBytes, + Storage: jetstream.FileStorage, + Replicas: 1, + AllowRollup: true, + DenyDelete: true, + Duplicates: dupes, + MaxMsgs: -1, + MaxConsumers: -1, + AllowDirect: true, + Discard: jetstream.DiscardNew, + } + if b.KeyTTL { + cfg.AllowMsgTTL, cfg.SubjectDeleteMarkerTTL = true, KeyTTLMarkerTTL + } + return cfg +} + +// ModuleStreamConfig is a module's own stream as the controller asserts it (mesh-controller internal/broker +// StreamConfigFor of ModuleStream.Stream). +func ModuleStreamConfig(m ModuleStream) jetstream.StreamConfig { + s := m.Stream() + cfg := jetstream.StreamConfig{ + Name: s.Name, + Subjects: s.Subjects, + Retention: jetstream.LimitsPolicy, + MaxAge: time.Duration(s.MaxAge) * time.Second, + MaxMsgsPerSubject: int64(s.MaxMsgsPerSubject), + Description: s.Why, + AllowDirect: s.Direct, + AllowMsgTTL: s.AllowMsgTTL, + } + if s.Retention == RetentionWorkQueue { + cfg.Retention = jetstream.WorkQueuePolicy + } + if s.Restricted || s.WorkQueue { + cfg.Metadata = map[string]string{} + if s.Restricted { + cfg.Metadata[RestrictedMetadata] = "true" + } + if s.WorkQueue { + cfg.Metadata[WorkQueueMetadata] = "true" + } + } + if s.MaxBytes > 0 { + cfg.MaxBytes = s.MaxBytes + } + if s.DiscardNew { + cfg.Discard = jetstream.DiscardNew + } + if s.DuplicatesSeconds > 0 { + cfg.Duplicates = time.Duration(s.DuplicatesSeconds) * time.Second + } + return cfg +} + +// ConsumerConfig is a pull consumer as the controller's EnsureConsumer creates it (mesh-controller internal/broker +// JetStream.EnsureConsumer, a consumer not there yet): its durable name, explicit acks, its ack wait, max deliver and max +// ack pending, its description, one filter or several, and from the stream's end when it is made FromNow. The grants part +// creates a module's and a process's consumers with it from the lease write (novox/hq ADR 0561 §6): a create alone, +// never an update of one the bus holds. A push consumer or a queue group is the controller's own and never made here. +func ConsumerConfig(c Consumer) jetstream.ConsumerConfig { + cfg := jetstream.ConsumerConfig{ + Durable: c.Name, + AckPolicy: jetstream.AckExplicitPolicy, + AckWait: time.Duration(c.AckWaitSeconds) * time.Second, + MaxDeliver: c.MaxDeliver, + MaxAckPending: c.MaxAckPending, + Description: c.Why, + } + switch len(c.Filters) { + case 0: + case 1: + cfg.FilterSubject = c.Filters[0] + default: + cfg.FilterSubjects = c.Filters + } + if c.FromNow { + cfg.DeliverPolicy = jetstream.DeliverNewPolicy + } + return cfg +} diff --git a/modules/mesh-grants/internal/buscompose/create_test.go b/modules/mesh-grants/internal/buscompose/create_test.go new file mode 100644 index 0000000..0df9580 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/create_test.go @@ -0,0 +1,46 @@ +package buscompose + +import ( + "testing" + "time" + + "github.com/nats-io/nats.go/jetstream" +) + +// Defends issue 1790 (ADR 0547 §5): the grants part creates a declared bucket and stream as the controller asserts them. +func declaredStreamsAreCreatedAsTheControllerAssertsThem(t *testing.T) { + plain := BucketStreamConfig(Bucket{Module: "mesh-vault", Name: "bus-accounts"}) + keyed := BucketStreamConfig(Bucket{Module: "m", Name: "presence", History: 5, TTLSeconds: 60, KeyTTL: true}) + queue := ModuleStreamConfig(ModuleStream{Module: "b", Name: "ASKS", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32, + WorkQueue: true, Duplicates: 1}) + for _, row := range []struct { + what string + got, want any + }{ + {"bucket name", plain.Name, "KV_mesh-vault_bus-accounts"}, + {"bucket subjects", plain.Subjects[0], "$KV.mesh-vault_bus-accounts.>"}, + {"bucket history defaults to one", plain.MaxMsgsPerSubject, int64(1)}, + {"bucket cap", plain.MaxBytes, int64(StateMaxBytes)}, + {"bucket value cap", plain.MaxMsgSize, int32(StateMaxValueBytes)}, + {"bucket duplicate window", plain.Duplicates, 2 * time.Minute}, + {"bucket discards new", plain.Discard, jetstream.DiscardNew}, + {"bucket direct and rollup, no delete", plain.AllowDirect && plain.AllowRollup && plain.DenyDelete, true}, + {"bucket without key ttl", plain.AllowMsgTTL, false}, + {"history", keyed.MaxMsgsPerSubject, int64(5)}, + {"age", keyed.MaxAge, time.Minute}, + {"duplicate window under the age", keyed.Duplicates, time.Minute}, + {"key ttl markers", keyed.SubjectDeleteMarkerTTL, KeyTTLMarkerTTL}, + {"key ttl", keyed.AllowMsgTTL, true}, + {"queue retention", queue.Retention, jetstream.WorkQueuePolicy}, + {"queue subjects", queue.Subjects[0], "mesh.stream.b.event.ask"}, + {"queue age", queue.MaxAge, 7 * 24 * time.Hour}, + {"queue cap", queue.MaxBytes, int64(32 << 20)}, + {"queue discards new", queue.Discard, jetstream.DiscardNew}, + {"queue duplicates", queue.Duplicates, time.Hour}, + {"queue metadata", queue.Metadata[WorkQueueMetadata] + queue.Metadata[RestrictedMetadata], "truetrue"}, + } { + if row.got != row.want { + t.Errorf("%s: %v, want %v", row.what, row.got, row.want) + } + } +} diff --git a/modules/mesh-grants/internal/buscompose/creates_streams_test.go b/modules/mesh-grants/internal/buscompose/creates_streams_test.go new file mode 100644 index 0000000..3593797 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/creates_streams_test.go @@ -0,0 +1,155 @@ +package buscompose + +import "testing" + +// Defends issue 1790 and novox/hq ADR 0547 §5 (the operator, 2026-10-26 08:54): the grants part may create the stream +// of each bucket, log and stream a placed module's manifest declares, by its own name, and read it back; nothing +// wider, nothing updated or deleted, and no other principal so. Held byte-equal in the controller's copy. And ADR 0547 +// clause 7 (issue 1945, the operator's second exception): the grants part writes its own object bucket of sealed user +// lists, and the node-engine of the node holding mesh-broker alone reads its one key and an object of it by name. +func theGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) { + records := Records{ + Nodes: []string{"novox", "ace"}, + Assigned: map[string][]Declared{"ace": {{Module: "plex"}}, "novox": { + {Module: "nats", SnapshotsTheBus: true}, + // mesh-sdk's copy alone (ADR 0547 clause 9, named readers): the vault's hashes are read by the grants part + // alone, whatever another module's reads say; the controller's copy grants any module that declares it. + {Module: "mesh-vault", State: []Bucket{{Module: "mesh-vault", Name: "bus-accounts"}}}, + {Module: "spy", Serves: []string{"spy_x"}, Reads: []string{VaultBusAccounts}}, + {Module: "mesh-delivery-builder", + State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5}}, + Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds"}}, + Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "BUILDASKS", Events: []string{"ask"}}}}, + {Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}, Reads: []string{VaultBusAccounts}}, + {Module: RuntimeModule}, + // The node-engine's manifest as a placement names it (issue 774, met twice on 2026-10-30): no account of its + // own, so the runner would carry it, and its state is the node's to write alone. + {Module: EngineModule, NoAccount: true, Consumes: []string{"restic.backed-up"}, + State: []Bucket{{Module: EngineModule, Name: "health"}, {Module: EngineModule, Name: EngineActsState}}}, + }}, + // The node-engine's own manifest, placed on every node though no node is assigned it (novox/hq ADR 0563 §2, §3). + Engine: &Declared{Module: EngineModule, State: []Bucket{{Module: EngineModule, Name: EngineActsState}}}, + } + users, err := Users(records) + if err != nil { + t.Fatal(err) + } + perms := map[string]Permissions{} + for _, u := range users { + if u.Module == EngineModule { + t.Errorf("the node-engine's manifest composed a user of its own: %s", u.Username()) + } + if u.Kind == KindNode || u.Kind == KindNodeTools { + p, err := PermissionsFor(u) + if err != nil { + t.Fatalf("%s: %v", u.Username(), err) + } + perms[u.Username()] = p + continue + } + if u.Kind != KindModule { + continue + } + p, err := PermissionsFor(u) + if err != nil { + t.Fatalf("%s: %v", u.Username(), err) + } + perms[u.Module] = p + } + for _, row := range []struct { + who, subject string + may bool + }{ + {GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", true}, + {GrantsModule, "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps", true}, + {GrantsModule, "$JS.API.STREAM.CREATE.LOG_mesh-delivery-builder_builds", true}, + {GrantsModule, "$JS.API.STREAM.CREATE.BUILDASKS", true}, + {GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-grants_assertions", true}, + {GrantsModule, "$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_steps", false}, + {GrantsModule, "$JS.API.STREAM.DELETE.KV_mesh-delivery-builder_steps", false}, + {GrantsModule, "$JS.API.STREAM.PURGE.KV_mesh-delivery-builder_steps", false}, + {GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_undeclared", false}, + {GrantsModule, "$JS.API.STREAM.CREATE.EVENTS", false}, + {GrantsModule, "$JS.API.STREAM.CREATE.ASSIGNMENTS", false}, + // Research 072 row 33: a runtime reads its membership from the grants part's state, never the assignments stream. + {"mesh-delivery-builder", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.novox.mesh-delivery-builder", false}, + {GrantsModule, "$JS.API.STREAM.INFO.EVENTS", false}, + {"mesh-delivery-builder", "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", false}, + // The node-engine's acts: created by the grants part, written by each node's engine under its own keys alone. + {GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-host_acts", true}, + {"node.ace", "$JS.API.STREAM.CREATE.KV_mesh-host_acts", false}, + {"node.ace", "$KV.mesh-host_acts.ace.act.hand-over-1", true}, + {"node.ace", "$KV.mesh-host_acts.ace.open.hand-over.abc", true}, + {"node.ace", "$KV.mesh-host_acts.novox.act.hand-over-1", false}, + {"node.ace", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.ace.ask.hand-over-1", true}, + {"node.ace", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.novox.ask.hand-over-1", false}, + {"novox.node-tools", "$KV.mesh-host_acts.novox.act.hand-over-1", false}, + // The runner never carries the engine (issue 774): its health report is the node's own key alone. + {"novox.node-tools", "$KV.mesh-host_health.novox", false}, + {"novox.node-tools", "$KV.mesh-host_acts.novox.ask.x", false}, + {"node.novox", "$KV.mesh-host_health.novox", true}, + {"node.novox", "$KV.mesh-host_health.ace", false}, + // The sealed user list (ADR 0547 clause 7): the grants part writes its own bucket and no other. + {GrantsModule, "$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list", true}, + {GrantsModule, "$O.mesh-grants-user-list.C.abc", true}, + {GrantsModule, "$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list", true}, + {GrantsModule, "$O.declaration-files-novox.C.abc", false}, + {GrantsModule, "$JS.API.STREAM.DELETE.OBJ_mesh-grants-user-list", false}, + {"mesh-delivery-builder", "$O.mesh-grants-user-list.C.abc", false}, + // The bus node's engine reads the one key and an object by name; nothing of the bucket's list, no write. + {"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", true}, + {"node.novox", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_user-list.abc.$KV.mesh-grants_user-list.sealed", true}, + {"node.novox", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", true}, + {"node.novox", "$JS.API.CONSUMER.CREATE.OBJ_mesh-grants-user-list.abc.$O.mesh-grants-user-list.C.abc", true}, + {"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.list", false}, + {"node.novox", "$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list", false}, + {"node.novox", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", false}, + {"node.novox", "$O.mesh-grants-user-list.C.abc", false}, + {"node.ace", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", false}, + {"node.ace", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", false}, + {GrantsModule, "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", true}, + {"spy", "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", false}, + {"novox.node-tools", "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", false}, + } { + if got := MayPublish(perms[row.who], row.subject); got != row.may { + t.Errorf("%s may publish %s: %v, want %v", row.who, row.subject, got, row.may) + } + } + // Issue 1790 (2026-10-26): a module hears the bus create each bucket it may bind, its own and those it reads, so a + // state missing at its start is bound when it appears; no other bucket's, and no other advisory. + for _, row := range []struct { + who, subject string + may bool + }{ + {"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps", true}, + {GrantsModule, "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-vault_bus-accounts", true}, + {"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_assertions", false}, + {"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.LOG_mesh-delivery-builder_builds", false}, + {"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.DELETED.KV_mesh-delivery-builder_steps", false}, + } { + if got := MaySubscribe(perms[row.who], row.subject); got != row.may { + t.Errorf("%s may hear %s: %v, want %v", row.who, row.subject, got, row.may) + } + } + // A name that is no plain token is never granted, and a module other than the grants part is never granted one. + if got := CreateStreamGrants([]string{"*", ">", "KV_a.b", "KV_a_b"}); len(got) != 2 { + t.Errorf("grants for wildcard names: %v", got) + } + other, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "shop", CreatesStreams: []string{"KV_shop_x"}}) + if err != nil { + t.Fatal(err) + } + if MayPublish(other, "$JS.API.STREAM.CREATE.KV_shop_x") { + t.Error("a module other than the grants part was granted a create") + } + // The writers table refuses the grants part an update, and a create of a stream it was not given. + g := Principal{Kind: KindModule, Node: "novox", Module: GrantsModule, CreatesStreams: []string{"KV_a_b"}} + if err := CheckWriters(g, []string{"$JS.API.STREAM.CREATE.KV_a_b"}); err != nil { + t.Errorf("the declared create refused: %v", err) + } + for _, refused := range []string{"$JS.API.STREAM.UPDATE.KV_a_b", "$JS.API.STREAM.CREATE.KV_a_c", "$JS.API.STREAM.CREATE.*"} { + if CheckWriters(g, []string{refused}) == nil { + t.Errorf("%s was not refused", refused) + } + } +} diff --git a/modules/mesh-grants/internal/buscompose/declaration_files.go b/modules/mesh-grants/internal/buscompose/declaration_files.go new file mode 100644 index 0000000..8fd959a --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/declaration_files.go @@ -0,0 +1,78 @@ +package buscompose + +import ( + "time" + + "git.novox.be/novox/mesh-sdk/go/declaration" +) + +// The object buckets a declaration's large files travel in (novox/hq ADR 0486, issues 1696 and 1697): **one bucket for each +// node**, `declaration-files-`. +// +// **The controller alone writes them, and a node-engine reads an object by its name from its own bucket** (ADR 0486 §2): its +// account may neither list the bucket nor write it, no account may touch another node's, and no other account has any access. +// Why a bucket each: a subject pattern cannot tell a name from a wildcard. A grant of `...M.*` matches a publish to the literal +// subject `...M.*` too (probed on nats-server 2.11.17 by the review of this issue: a node created a consumer filtered on +// `$O..C.*` and read every object of the bucket), so in one shared bucket a node could read every other node's files. +// With a bucket each, the same trick reads only the node's own files, which are the files of its own declaration. +// +// A bucket of the bus's object store is a stream, OBJ_, whose messages are the objects' metadata +// (`$O..M.`) and chunks (`$O..C.`). + +// DeclarationFilesBucket is the prefix of the buckets' names, which mesh-sdk fixes for the node-engine and the controller alike. +const DeclarationFilesBucket = declaration.FileBucketPrefix + +// DeclarationFilesBucketOf is the bucket a node's files are in: mesh-sdk's FileBucketFor, the one place the name is composed. A +// node name it refuses (one that could carry a subject wildcard or separator into a grant) is an error, never a bucket. +func DeclarationFilesBucketOf(node string) (string, error) { return declaration.FileBucketFor(node) } + +// DeclarationFilesStreamOf is the stream under a node's bucket: mesh-sdk's FileStreamFor. +func DeclarationFilesStreamOf(node string) (string, error) { return declaration.FileStreamFor(node) } + +// DeclarationFilesMaxBytes is a bucket's size: the objects are immutable and regenerable, and one node's hold well under a hundred +// megabytes. +const DeclarationFilesMaxBytes = 1 << 30 + +// DeclarationFilesKeptFor is how long an object stays after no node's latest declaration names it (ADR 0486 §3). +const DeclarationFilesKeptFor = 24 * time.Hour + +// DeclarationFilesWrites is the one pattern the controller may publish to write a node's bucket: every object's metadata and +// chunks. The server's API for the stream is the controller's `$JS.API.>`. +func DeclarationFilesWrites(node string) (string, error) { + bucket, err := DeclarationFilesBucketOf(node) + if err != nil { + return "", err + } + return "$O." + bucket + ".>", nil +} + +// DeclarationFilesReads is what a node-engine's account may publish to read an object of its own bucket by its name, and nothing +// more. The set is the one node-engine's own call (mesh-host internal/link FileObject) makes, for B the node's bucket and S its +// stream (issue 1697, notes 4 and 5): +// +// - the object's metadata, by a direct get of the last message of the one subject that names it. **Only the suffixed +// form**: the bare `$JS.API.DIRECT.GET.S` takes a sequence number and would read any message of the stream. +// - the ordered consumer on that object's chunks: `$JS.API.CONSUMER.CREATE.S..$O.B.C.`. **Not `S.>`**: +// a consumer filtered on `$O.B.M.>` would read every object's name. +// - the flow-control replies of that consumer, without which a large object stalls at the flow-control window. +// - the consumer's deletion, best effort: the consumer's inactivity threshold removes it if this is refused. +// +// **Never granted:** STREAM.INFO (a `subjects_filter` of `>` lists every name), STREAM.MSG.GET (any message by its sequence), a +// publish to `$O.B.>` (a write), and any subject of another node's stream. A literal `*` in a subject matches the grant's `*`: it +// reaches the objects of this node's own bucket and no other (above). +func DeclarationFilesReads(node string) ([]string, error) { + bucket, err := DeclarationFilesBucketOf(node) + if err != nil { + return nil, err + } + stream, err := DeclarationFilesStreamOf(node) + if err != nil { + return nil, err + } + return []string{ + "$JS.API.DIRECT.GET." + stream + ".$O." + bucket + ".M.*", + "$JS.API.CONSUMER.CREATE." + stream + ".*.$O." + bucket + ".C.*", + "$JS.API.CONSUMER.DELETE." + stream + ".*", + "$JS.FC." + stream + ".>", + }, nil +} diff --git a/modules/mesh-grants/internal/buscompose/derived.go b/modules/mesh-grants/internal/buscompose/derived.go new file mode 100644 index 0000000..c1d23fb --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/derived.go @@ -0,0 +1,367 @@ +package buscompose + +import ( + "fmt" + "sort" + "strings" +) + +// Streams and consumers derived from what modules declare. +// +// The mesh's own four exist before any module does (streams.go). Everything here is the other +// half: a seat's stream comes into being when the module declaring it is **registered**, and a +// consumer when a module is **assigned** — which is why ADR 0116's task 1.4 had to be narrowed to +// the foundation set. Neither has happened at genesis. +// +// All of it is a pure function of declarations. The controller is still the only writer; this is +// only what it writes. + +// A Consumer is a durable subscription the controller creates on a module's behalf. A module +// declares what it reacts to, never how delivery works, so it does not name these and cannot +// misconfigure them. +type Consumer struct { + Name string + Stream string + // Filters are the subjects this consumer receives. One consumer per module with several + // filters, rather than one per consumed event: its ack subject is derived from its name, and + // a module with five consumers would need five ack permissions to ack its own deliveries. + Filters []string + // Queue is the queue group, set for a seat's worker so that "exactly one holder" survives a + // seat later being relaxed to several. Authority and delivery are kept separate on purpose. + Queue string + // Push asks the server to deliver to a subject rather than wait to be pulled. + // + // For the mesh's own consumer, where the controller wants every message to arrive in the one + // loop it already runs: pulling would mean a second goroutine fetching batches and handing + // them over, and a loop that acts on one message at a time is the property the store window + // depends on. A queue group implies this, because a group has nothing to pull from. + Push bool + // TakesOverFrom names a consumer of the same stream whose filter this one takes over (issue 973): made while + // that consumer still filters this one's subject, it starts after what that consumer has acknowledged, so a + // message the old one held unacknowledged is not lost to the change. Otherwise it starts as FromNow says. + TakesOverFrom string + // DeliverSubject, when set, is where a push consumer delivers instead of DeliverSubjectFor's. + DeliverSubject string + // AckWaitSeconds before an unacknowledged delivery is redelivered. + AckWaitSeconds int + // MaxDeliver is how often a message is handed over before the consumer gives it up; zero for no + // bound. What a consumer gives up is kept in DEAD_LETTERS by the controller (novox/hq issue 330). + MaxDeliver int + // MaxAckPending is how many deliveries the server lets stand unacknowledged at once; zero for + // the server's default, which is many. **One, for a consumer handled one at a time** + // (novox/hq issue 175): a handler that builds for minutes keeps its own message alive with a + // heartbeat, but everything handed over behind it times out unacknowledged and comes back — + // and a merge that came back rebuilt what it had just built, five times over on 2026-09-30. + // With one outstanding, the server holds the rest, and the heartbeat is keeping the message. + MaxAckPending int + // FromNow makes a consumer that does not exist yet start at the stream's end rather than its + // beginning (novox/hq issue 248). For a consumer that reacts to announcements — a merge, a build's + // outcome — on a stream that keeps a week of them: the server's default, everything the stream + // holds, replays every merge and every build of that week as if it had just happened. A consumer + // that exists keeps where it is, whatever this says; only its making is decided here. + FromNow bool + Why string + // Resettable says why this consumer may be re-made to deliver from now by the mesh itself, with + // nobody asked (novox/hq to-be 45 §7, healer H4): what a reset drops, something else catches up. + // Empty for every consumer where nothing would — a module's, whose events would be lost to it. + // Not a property of the consumer on the bus: nothing here is sent to the server. + Resettable string +} + +// seatStreamName is the stream holding a seat's inbound work. Named after the seat rather than +// the module holding it, because the holder can change and the queued work must not care — which +// is the whole reason a caller addresses a seat instead of a module. +func seatStreamName(seat string) string { return "SEAT_" + upperSnake(seat) } + +// SeatStreams is one work queue per declared seat, created when the declaring module is +// registered rather than when it is assigned. +// +// **The stream exists before anyone holds the seat, and that is the point.** Work queues until a +// holder appears, so installing the telegram module a week after something started sending to it +// flushes the backlog instead of having lost it. A stream created at assignment would make "the +// holder is not here yet" mean "your messages are gone". +func SeatStreams(seats []DeclaredSeat) []Stream { + sorted := append([]DeclaredSeat(nil), seats...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) + + var out []Stream + for _, s := range sorted { + if len(s.Accepts) == 0 { + // A seat that only emits and serves needs no stream: its events ride EVENTS and its + // tools are core request/reply, which is never persisted. + continue + } + retain := s.RetainSeconds + if retain == 0 { + retain = 7 * 24 * 60 * 60 + } + out = append(out, Stream{ + Name: seatStreamName(s.Name), + Subjects: []string{"mesh.seat." + s.Name + ".accept.>"}, + Retention: RetentionWorkQueue, + MaxAge: retain, + Why: fmt.Sprintf("work submitted to the %s seat; one holder consumes it, and it "+ + "queues while nobody does", s.Name), + }) + } + return out +} + +// A DeclaredSeat is a seat as the catalogue knows it. Mirrored here rather than imported so this +// package stays free of the catalogue's own types — the same reason the host mirrors the +// contracts instead of importing the sdk. +type DeclaredSeat struct { + Name string + Accepts []string + // Emits are the verbs the seat's holder publishes under the seat's own name. An event about a + // role belongs here rather than in the holder's namespace, because the name then outlives + // whoever fills it (novox/hq ADR 0121, 04-ISSUES/127). + Emits []string + // Serves are the verbs the holder answers, request and reply. + Serves []string + RetainSeconds int + // PulledByKind is Seat's: a worker per accepted kind, derived from the holders' seat traffic, not here. + PulledByKind bool +} + +// ConsumerFor is the durable consumer a module's declarations imply, or false when it subscribes +// to nothing and needs none. +// +// One per module, with every consumed subject as a filter, because its ack permission is derived +// from its name: a module with a consumer per event would need an ack permission per consumer, +// and the permission list would stop being derivable from the declaration. +func ConsumerFor(p Principal) (Consumer, bool) { + // A module that reacts to anything — a module's events or a role's (novox/hq ADR 0121). Watching + // a role was missing here, so the one module that does it got no consumer at all: it started, + // connected, and its graph stayed empty with nothing anywhere reporting why. + // And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants. + hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0 + hearsAStream := false + for _, h := range p.Hears { + if len(h.Filters) > 0 { + hearsAStream = true + } + } + if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn && !hearsAStream) { + return Consumer{}, false + } + perms, err := PermissionsFor(p) + if err != nil { + return Consumer{}, false + } + // Events, wherever they live: a module's own namespace, and the namespace of any role it watches + // (novox/hq ADR 0121). Tool subjects and inboxes are subscribed directly and are not a consumer's + // business, which is why this is a filter and not the whole list. + var filters []string + for _, s := range perms.Subscribe { + // Not a module stream's, which its own consumer on that stream hears (novox/hq ADR 0363). + if strings.Contains(s, ".event.") && !strings.HasPrefix(s, moduleStreamPrefix) { + filters = append(filters, s) + } + } + // **A module that hears only a module's stream still has one here** (novox/hq ADR 0363 §7): what it + // gives up on there is delivered again under `mesh.again..` on EVENTS, and only this filters it. + if len(filters) == 0 && !hearsAStream { + return Consumer{}, false + } + sort.Strings(filters) + // And the events given up on and delivered again to this consumer alone (novox/hq issue 330). + filters = append(filters, AgainFilter(consumerDurable(p))) + return Consumer{ + Name: consumerDurable(p), + Stream: consumerStream(p), + Filters: filters, + AckWaitSeconds: 30, + MaxDeliver: 5, + // From now, as the controller's own consumers have been since issue 248: a module made on a stream that + // keeps a week replays none of it. What a part needs of the present it reads from its owner + // (novox/hq ADR 0443 §1, §3). A consumer that exists keeps its place whatever this says. + FromNow: true, + Why: "what " + p.Module + " declared it consumes; after max-deliver it gives an event up, and the " + + "controller keeps it in DEAD_LETTERS until a person delivers it again or drops it", + }, true +} + +// ModuleConsumer is one module's durable consumer, with the module and node it is for. +type ModuleConsumer struct { + Node, Module string + Consumer Consumer +} + +// ConsumersOf is every module's durable consumer the composed users imply: each module user's, and +// each module a runtime carries — a carried module with no account of its own is no user (novox/hq +// issue 195), and its consumer is still the controller's to make, since the runtime reads it on the +// module's behalf (ADR 0198). One per module and node, whichever of the two named it first. +func ConsumersOf(users []Principal) []ModuleConsumer { + var out []ModuleConsumer + seen := map[string]bool{} + add := func(p Principal) { + key := p.Node + "/" + p.Module + if seen[key] { + return + } + c, needed := ConsumerFor(p) + // **A module with a work-queue consumer of its own needs no other reason to be visited here** + // (novox/hq ADR 0436 §1, §2, §6): mesh-mail's sender consumes and watches nothing of its own, so + // ConsumerFor alone would have found nothing needed and skipped it before its one queue consumer + // was ever composed. + if !needed && len(p.Queues) == 0 { + return + } + seen[key] = true + if needed { + out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: c}) + // And one on each module stream it hears (novox/hq ADR 0363 §5). + for _, h := range p.Hears { + if len(h.Filters) == 0 { + continue + } + out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: heardConsumer(p, h)}) + } + } + // And the one durable consumer of each of its own work-queue streams, for the one process its + // bus.queues names (novox/hq ADR 0436 §1, §2, §6). + for _, q := range p.Queues { + out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: QueueConsumer(p, q)}) + } + } + for _, p := range users { + if p.Kind == KindModule { + add(p) + } + } + for _, p := range users { + if p.Kind != KindNodeTools { + continue + } + for _, d := range p.Carries { + // **A carried module's work-queue consumer is still the controller's to make** (novox/hq ADR + // 0436 §1, §2, §6), the same reason its ordinary consumer is (ADR 0198): mesh-mail has no + // account of its own (NoAccount) and is carried here, but its process does, and the consumer + // it reads through must exist and survive PruneHeard whether or not the module itself speaks. + add(Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits, + Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, + Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues}) + } + } + return out +} + +// AccountConsumers are the durable consumers an account needs (novox/hq ADR 0561 §6): of ConsumersOf, each module's on +// EVENTS and each process's on its own work queue, never one on a module stream it hears (those the controller makes with +// the stream's grant). The grants part creates them from the lease write, with these configurations. +func AccountConsumers(users []Principal) []ModuleConsumer { + queues := map[string]bool{} + for _, p := range users { + for _, q := range p.Queues { + queues[q.Stream] = true + } + for _, d := range p.Carries { + for _, q := range d.Queues { + queues[q.Stream] = true + } + } + } + var out []ModuleConsumer + for _, mc := range ConsumersOf(users) { + if mc.Consumer.Stream == consumerStream(Principal{Kind: KindModule}) || queues[mc.Consumer.Stream] { + out = append(out, mc) + } + } + return out +} + +// HolderConsumerFor is the worker a seat's holders share on that seat's work queue. +// +// **One worker for every holder, and each holder pulls one ask when it is idle** (novox/hq ADR +// 0190). The seat is *authority* — who may be the telegram sender — and the worker is *delivery*, +// kept separate so that relaxing one changes nothing about the other: a node-scoped seat has a +// holder per machine, and all of them take from this one consumer, so the work is shared without +// any holder knowing about the others. Pulled rather than pushed because a push consumer hands the +// next ask to whichever subscriber the server picks, busy or not, and a pulled one is asked for by +// a holder that has just become free. Which is also what ends the race issue 186 describes — asks +// delivered behind the one being worked, expiring unacknowledged and dropped after the fifth +// redelivery: nothing is delivered that nobody asked for. A long build keeps its own ask alive +// (stillWorking); the ack wait is for a holder that died. +func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool) { + if len(seat.Accepts) == 0 || seat.PulledByKind { + // A seat pulled by kind has a worker per kind, which its holders' seat traffic derives (SeatTrafficObjects): one + // worker filtered on every accept would overlap them on the work queue. + return Consumer{}, false + } + return Consumer{ + Name: "SEAT_" + upperSnake(seat.Name) + "_worker", + Stream: seatStreamName(seat.Name), + Filters: []string{"mesh.seat." + seat.Name + ".accept.>"}, + AckWaitSeconds: 60, + MaxDeliver: 5, + // As many in flight as there are holders working, which pulling bounds by itself: a holder + // fetches one and fetches again only after it acknowledged. The server's default stands. + Why: fmt.Sprintf("%s on %s holds %s; every holder pulls one ask at a time from this worker "+ + "and acknowledges after the work is done, so a crash mid-work redelivers rather than "+ + "loses and an idle holder is the one that takes the next ask", module, node, seat.Name), + }, true +} + +// NodeConsumer is the durable consumer a node reads its own declaration through. +// +// **Derived from a node existing, and created by the controller, because a host cannot create it.** +// A host's account may subscribe its own declaration subject and publish its own ack subject, and +// reaches no part of the JetStream API — which is correct (the controller is the only writer of +// consumer definitions, design 25 §3) and means the consumer must be waiting before the host binds +// to it. Named after the node, because the node's ack grant is `$JS.ACK.NODES..>` and a +// consumer named anything else is one the host cannot acknowledge a delivery from. +// +// **No max-deliver, and a long ack wait.** A declaration is settled only after the node has applied +// it and reported, which is minutes on a machine pulling images; and a declaration the mesh cannot +// get a node to accept is not one to give up on, because the stream keeps only the newest per node +// anyway — so there is exactly one message per node to redeliver, for as long as that node is away. +func NodeConsumer(node string) Consumer { + return Consumer{ + Name: node, + Stream: "NODES", + Filters: []string{"mesh.node." + node + ".declare"}, + Push: true, + AckWaitSeconds: 300, + Why: "how " + node + " hears what it should be; last-per-subject, so a node that was away " + + "gets exactly the current declaration and nothing older", + } +} + +// AllOverlaps reports subject filters claimed by more than one stream, across the mesh's own and +// every derived one. +// +// NATS refuses an overlapping stream rather than merging it (verified against nats-server 2.10: +// "subjects overlap with an existing stream"), so this is not a subtle divergence — it is a +// registration that fails. Catching it here names both streams, before a half-applied mesh does. +func AllOverlaps(seats []DeclaredSeat) []string { + all := append(MeshStreams(), SeatStreams(seats)...) + seen := map[string]string{} + var clashes []string + for _, s := range all { + for _, subject := range s.Subjects { + if first, ok := seen[subject]; ok { + clashes = append(clashes, fmt.Sprintf("%s and %s both claim %s", first, s.Name, subject)) + continue + } + seen[subject] = s.Name + } + } + sort.Strings(clashes) + return clashes +} + +// upperSnake makes a stream name from a seat name. NATS stream names may not contain a dot, +// a space or a wildcard, and a hyphen is legal but reads badly beside the mesh's own. +func upperSnake(s string) string { + out := []rune(s) + for i, r := range out { + switch { + case r >= 'a' && r <= 'z': + out[i] = r - 32 + case r == '-' || r == '.': + out[i] = '_' + } + } + return string(out) +} diff --git a/modules/mesh-grants/internal/buscompose/doc.go b/modules/mesh-grants/internal/buscompose/doc.go new file mode 100644 index 0000000..6c891e7 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/doc.go @@ -0,0 +1,10 @@ +// Package buscompose is the bus's composer: who may say what on the bus, as the user list and the server configuration +// the bus loads, derived from the mesh's records (Users, WithPasswords, ComposeAccounts), with the grants, streams, state +// and logs it composes them from. +// +// **The grants part's own** (novox/hq ADR 0593 §1): moved here from mesh-sdk's buscompose at abf5eac, where only the +// names and shapes other modules read stay. Only the grants part composes the user list, so no other module links this +// code. It is pure: it names subjects and holds no connection to the bus (the part's scan in grants_test.go holds it so); +// the controller's raising and asserting of streams, buckets and consumers is not carried over, since the controller +// ended (ADR 0590). The golden lists in testdata are the ones mesh-sdk's copy composed, byte for byte. +package buscompose diff --git a/modules/mesh-grants/internal/buscompose/engine.go b/modules/mesh-grants/internal/buscompose/engine.go new file mode 100644 index 0000000..6426db7 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/engine.go @@ -0,0 +1,82 @@ +package buscompose + +import ( + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/provisions" +) + +// The node seat node-engine (novox/hq ADR 0563 §1): every node's engine holds it on its own bus account, and no module +// claims it. Its verbs and events are the catalogue's (catalogue.EngineVerbs), read here rather than copied. + +// EngineSeat is the seat. +const EngineSeat = catalogue.EngineSeat + +// EngineModule is the node-engine's manifest's module name, and EngineActsState its state the acts asked through the +// node seat and its own asks are kept in (ADR 0563 §2, §3: "the engine's own state"): the bucket mesh-host_acts, keys +// `.act.`, `.open..` and `.ask.`, each written by that node's engine alone. +const ( + EngineModule = "mesh-host" + EngineActsState = "acts" +) + +// EngineActsBucket is that state's bucket. +var EngineActsBucket = BucketName(EngineModule, EngineActsState) + +// EnginePackagesBucket is the engine's state of its node's declared packages (novox/hq ADR 0565 clause 6): one key, the +// node's name, written by that node's engine alone. +var EnginePackagesBucket = BucketName(EngineModule, delivery.EnginePackagesState) + +// EngineDataBucket is the engine's state of its node's placed modules' data items (novox/hq ADR 0577 clause 1): one +// key `.` per module, written by that node's engine alone. +var EngineDataBucket = delivery.EngineDataBucket + +// EnginePackagesGrants is what a node's engine publishes to keep its node's key of the packages bucket: the key alone, +// and its read. +func EnginePackagesGrants(node string) []string { + return append([]string{"$KV." + EnginePackagesBucket + "." + node}, readGrants(EnginePackagesBucket, node)...) +} + +// engineGrants is what a node's engine is granted to hold its node seat and to ask the operator (ADR 0563 §1, §3): it +// answers its own node's verbs (the caller-named subjects are added beside them with every principal's), says the +// seat's events about its node, answers discovery for the seat, asks who answers what (the login shell's execute, ADR +// 0563 §6), and asks the operator under asks.EngineAsker(node) on the router's seat as its holder declares it, as any +// user of that seat is granted. +// +// Its verbs and events are granted as any verb and any event of the seat on its own node, not as the contract's list: +// the list is compiled into whichever mesh-sdk the grants part pins, so a verb the engine gained before the grants part +// was released was refused on every node and the engine reopened its link every few minutes (2026-10-31, the optional +// verb composition of mesh-host #184). As a module answers `mesh.mod..tool.>`, nothing is given away: no other +// principal may subscribe this seat's subjects of this node, and a caller is still granted each verb by name. +func engineGrants(p Principal) (pub, sub []string) { + sub = append(sub, "mesh.seat."+EngineSeat+".tool.*."+p.Node) + pub = append(pub, "mesh.seat."+EngineSeat+".event.*."+p.Node) + sub = append(sub, announcing(EngineSeat)...) + pub = append(pub, discovering()...) + tp, ts := seatTrafficGrants(SeatTrafficOf(asks.EngineAsker(p.Node), nil, p.Uses, nil)) + // And its acts and asks, under its own node's keys alone; never the bucket's creation, which is the grants part's. + if own, err := NodeKeysGrants(EngineActsBucket, p.Node); err == nil { + pub = append(pub, own...) + } + // And the provisions its node's modules consume, its own node's keys alone, read (novox/hq ADR 0570 clause 6: the + // consuming engine checks the vault's name and generation of what it is handed). + if safeSubject.MatchString(p.Node) { + pub = append(pub, readGrants(provisions.ProvisionsBucket, p.Node+".>")...) + } + // And its node's own health report, its key `` alone (issue 774, the operator's alert table): what the watchman + // reads for the machine's alerts. + if safeSubject.MatchString(p.Node) { + pub = append(pub, append([]string{"$KV." + delivery.NodeHealthBucket + "." + p.Node}, + readGrants(delivery.NodeHealthBucket, p.Node)...)...) + } + // And its node's placed modules' data items, its own node's keys alone (ADR 0577 clause 1). + if own, err := NodeKeysGrants(EngineDataBucket, p.Node); err == nil { + pub = append(pub, own...) + } + // And its node's declared packages, its own key alone (ADR 0565 clause 6). + if safeSubject.MatchString(p.Node) { + pub = append(pub, EnginePackagesGrants(p.Node)...) + } + return append(pub, tp...), append(sub, ts...) +} diff --git a/modules/mesh-grants/internal/buscompose/fixtures_test.go b/modules/mesh-grants/internal/buscompose/fixtures_test.go new file mode 100644 index 0000000..bfeda2f --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/fixtures_test.go @@ -0,0 +1,119 @@ +package buscompose + +// The fixtures the copied bus composer is held to (issue 1377, ADR 0547 §5): the same source is compiled into the +// controller's internal/broker to write the expected lists, and into mesh-sdk's buscompose to compare against them. + +// UserListFixture is one mesh's records, and the hashes its accounts carry. +type UserListFixture struct { + Name string + Records Records + // Hashless are users given no hash, so they are left out and named. + Hashless []string +} + +func fixtureTelegramSeat() Seat { + return Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}} +} + +// UserListFixtures are the meshes, from the controller's own tests' records outward. +func UserListFixtures() []UserListFixture { + return []UserListFixture{ + {Name: "empty"}, + {Name: "the controller's someRecords", Records: Records{ + Nodes: []string{"two", "one"}, + Assigned: map[string][]Declared{ + "one": {{Module: "telegram", Serves: []string{"status"}}}, + "two": {{Module: "shop", Emits: []string{"order.placed"}}}, + }, + Enrolling: []string{"three"}, + People: map[string][]string{"ada": {"mesh-catalog.catalog_tools"}}, + }}, + {Name: "seats, consumers, people and a hashless user", Hashless: []string{"two.audit"}, Records: Records{ + Nodes: []string{"one", "two"}, + Assigned: map[string][]Declared{ + "one": { + {Module: "telegram", Holds: []Seat{fixtureTelegramSeat()}, Serves: []string{"status"}}, + {Module: "nats", SnapshotsTheBus: true, Serves: []string{"nats_streams"}}, + {Module: "mesh-watchman"}, + {Module: "mesh-grants", Emits: []string{"membership-changed"}}, + }, + "two": { + {Module: "shop", Uses: []Seat{fixtureTelegramSeat()}, Emits: []string{"order.placed"}}, + {Module: "audit", Consumes: []string{"shop.order.placed"}}, + }, + }, + People: map[string][]string{"ada": {"*"}, "bob": {"shop.status"}}, + Interchangeable: map[string]bool{"shop": true}, + }}, + {Name: "state, logs, streams, queues and processes", Records: Records{ + Nodes: []string{"ace", "novox"}, + Assigned: map[string][]Declared{ + "novox": { + {Module: "mesh-delivery-builder", Emits: []string{"built"}, + State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5}, + {Module: "mesh-delivery-builder", Name: "presence", PerMachine: true, KeyTTL: true, TTLSeconds: 60}}, + Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds", MaxMiB: 64}}, + Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "asks", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32, WorkQueue: true}}, + Queues: []QueueOwner{{Process: "worker", Stream: "asks", MaxDeliver: 5}}, + Processes: []ProcessPrincipal{{Process: "worker", Emits: []string{"built"}, + Queues: []QueueOwner{{Process: "worker", Stream: "asks", MaxDeliver: 5}}, + State: []string{"steps"}, Invokes: []string{"gitea.gitea_commit"}}}, + Invokes: []string{"gitea.gitea_commit"}, + }, + {Module: "mesh-delivery-view", Reads: []string{"mesh-delivery-builder.steps"}, + KeyedReads: []KeyedRead{{Read: "mesh-delivery-builder.presence", Key: "novox"}}}, + // The grants part, which may create every stream declared here (ADR 0547 §5, issue 1790). + {Module: "mesh-grants", Emits: []string{"membership-changed"}, + State: []Bucket{{Module: "mesh-grants", Name: "assertions", History: 5}}}, + }, + "ace": { + {Module: "plex", Serves: []string{"plex_status"}, Checks: []string{"plex.plex_status"}}, + {Module: "silent", NoAccount: true}, + }, + }, + RootFree: map[string]bool{"ace": true}, + }}, + } +} + +// HashOf is the stand-in hash a fixture's user carries. +func HashOf(user string) string { + h := "$2a$11$" + for len(h) < 29 { + h += user + } + return h[:29] + "fixturefixturefixturefixture00" +} + +// ComposedFor is a fixture's user list as the composer writes it, and the users left out. +func ComposedFor(f UserListFixture) (string, []string, error) { + users, err := Users(f.Records) + if err != nil { + return "", nil, err + } + hashes := map[string]string{} + skip := map[string]bool{} + for _, u := range f.Hashless { + skip[u] = true + } + for _, u := range users { + if !skip[u.Username()] { + hashes[u.Username()] = HashOf(u.Username()) + } + } + filled, missing := WithPasswords(users, hashes) + list, err := ComposeAccounts(filled) + return list, missing, err +} + +// MembershipsFor is every membership a fixture's records issue, by `.`, as the composer makes them. +func MembershipsFor(f UserListFixture) map[string]Membership { + where := PlacementsOf(f.Records, f.Records.Interchangeable) + out := map[string]Membership{} + for node, ds := range f.Records.Assigned { + for _, d := range ds { + out[node+"."+d.Module] = MembershipFor(node, d, where) + } + } + return out +} diff --git a/modules/mesh-grants/internal/buscompose/health_grants.go b/modules/mesh-grants/internal/buscompose/health_grants.go new file mode 100644 index 0000000..29036e9 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/health_grants.go @@ -0,0 +1,212 @@ +package buscompose + +import ( + "fmt" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/health" +) + +// **The health holders' grants** (novox/hq ADR 0564 clauses 2, 5 and 8, Limits 2, 4 and 8; issue 1952 S5; ADR 0590). +// +// - Nobody reads mesh-healers' `would` (clause 8): what the healers would have done reaches no condition. Every +// observer's `findings`, the healers' `attempts`, the wants and the haves are read through `reads` as any announced +// state is; and no module writes another's state, so an observer can never write the keeper's buckets (clause 2). +// - The watchman makes its own consumer on DEAD_LETTER_NOTICES, by its own name, pulled, and reads whether the ended +// controller's consumer `controller` still stands; it may neither delete nor change that consumer (clause 5). +// - The bus module's tools run on an account of their own, declared by their bundle's `bus` with `removes` +// (catalogue.BusAccounts), which may remove only keys of the node-engine's acts bucket (Limits 4) and the ended +// controller's leftovers (ADR 0590 clause 6), never the backup's account `broker`, which only reads (ADR 0235). + +// HealersWould is what mesh-healers would have done while a controller was placed: read by nobody (ADR 0564 clause 8). +var HealersWould = health.HealersModule + "." + health.WouldState + +// ControllerLeftoverConsumers are the ended controller's durable consumers left on the bus, one leftover of the module +// mesh-delivery placed nowhere (issue 1126, milestone section 3), and mesh-catalog's of the controller's built-before, +// which its manifest no longer consumes and no part composes; each `.`. +var ControllerLeftoverConsumers = []string{ + "EVENTS." + ControllerName, "EVENTS." + MergesConsumerName, "EVENTS.novox_mesh-delivery", "EVENTS.novox_mesh-catalog", + "CONTROL." + ControllerName, DeadLetterNoticesStream + "." + ControllerName, +} + +// ControllerLeftoverConsumersOn are ControllerLeftoverConsumers and, for each node given, the consumer the controller +// made for the node's engine (`EVENTS._mesh-host`, of restic's backed-up): the engine hears that event without a +// durable consumer, and the grants part composes none for it, so nothing reads it (issue 1126). +func ControllerLeftoverConsumersOn(nodes []string) []string { + out := slices.Clone(ControllerLeftoverConsumers) + for _, n := range nodes { + if safeSubject.MatchString(n) { + out = append(out, "EVENTS."+n+"_"+EngineModule) + } + } + return out +} + +// ControllerLeftoverBuckets are the ended controller's buckets, each by its local name (`KV_mesh-controller_`). +var ControllerLeftoverBuckets = []string{"asked", "assignments", "builds", "calls", "checks-running", "composed", + "condition-history", "conditions", "declared-config", "declared-packages", "delivery-merges", "grants", "hand-acts", + "lease", "predefined", "rollouts", "states-announced"} + +// ControllerLeftoverStreams are the ended controller's streams, by their names: CONTROL, ASSIGNMENTS, each of its buckets' +// and, for each node given, the declaration files' object bucket the controller wrote (ADR 0486 §2). +func ControllerLeftoverStreams(nodes []string) []string { + out := []string{"CONTROL", AssignmentsStream} + for _, b := range ControllerLeftoverBuckets { + out = append(out, "KV_"+BucketName(catalogue.EndedControllerName, b)) + } + for _, n := range nodes { + if safeSubject.MatchString(n) { + out = append(out, "OBJ_"+DeclarationFilesBucket+n) + } + } + return out +} + +// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, ADR 0590 clause +// 6), each removal its grant: the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal +// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills; and +// the ended controller's leftovers, each consumer and each stream read and deleted by its name, for the nodes given (the +// declaration files' object buckets). A removal outside the set is refused, as the indexer's judge refuses it in a +// manifest (novox/hq ADR 0578 §1). +func BusRemovalGrants(removes, nodes []string) ([]string, error) { + var out []string + for _, r := range removes { + switch r { + case catalogue.RemovesEngineActs: + out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>") + case catalogue.RemovesControllerLeftovers: + for _, c := range ControllerLeftoverConsumersOn(nodes) { + out = append(out, "$JS.API.CONSUMER.INFO."+c, "$JS.API.CONSUMER.DELETE."+c) + } + for _, s := range ControllerLeftoverStreams(nodes) { + out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.DELETE."+s) + } + default: + return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4, ADR 0590)", r, + strings.Join([]string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}, ", ")) + } + } + return out, nil +} + +// removesLeftover is an account declared to remove the ended controller's leftovers deleting one of its streams: the +// writers table's one exemption from stream definitions being the grants part's (ADR 0590 clause 6). +func removesLeftover(p Principal, pattern string) bool { + name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.DELETE.") + return ok && p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesControllerLeftovers) && + slices.Contains(ControllerLeftoverStreams(p.FilesFor), name) +} + +// removesEngineActs is an account declared to remove keys of the node-engine's acts bucket: the writers table's one +// exemption from a node's engine being the only writer of its keys, for removal (ADR 0564 Limits 4). +func removesEngineActs(p Principal, pattern string) bool { + return p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesEngineActs) && pattern == "$KV."+EngineActsBucket+".>" +} + +// WatchmanDeadLetterConsumer is the watchman's own consumer on DEAD_LETTER_NOTICES, made only once the lease names no +// controller and `controller` is gone (ADR 0564 clause 5). +const WatchmanDeadLetterConsumer = WatchmanModule + +// WatchmanDeadLetterGrants are what the watchman may publish for the dead letters in L4 (ADR 0564 clause 5), at least +// privilege: its own pulled consumer on DEAD_LETTER_NOTICES (made, read, pulled, acknowledged); whether `controller` +// still stands (read only); a read of DEAD_LETTERS (its information and one kept message); a copy kept +// (`mesh.events.dead.>`); and on each stream a notice can name (sources: EVENTS, the seats' work queues and the +// modules' own), its information, for a source's age (ADR 0376), and the message given up on, each stream by its name. +// Never a wildcard over the streams and never a bucket's or an object store's stream: a read of any message would read +// every bucket (the lead, 2026-10-29, on issue 1952 S5). Delivering again and dropping stay refused in L4, so it may +// neither delete a message nor publish `mesh.again.>`. +func WatchmanDeadLetterGrants(sources []string) []string { + own := DeadLetterNoticesStream + "." + WatchmanDeadLetterConsumer + out := []string{ + "$JS.API.STREAM.INFO." + DeadLetterNoticesStream, + "$JS.API.CONSUMER.INFO." + DeadLetterNoticesStream + "." + ControllerName, + "$JS.API.CONSUMER.CREATE." + own, + "$JS.API.CONSUMER.INFO." + own, + "$JS.API.CONSUMER.MSG.NEXT." + own, + "$JS.ACK." + own + ".>", + "$JS.API.STREAM.INFO." + DeadLettersStream, + "$JS.API.STREAM.MSG.GET." + DeadLettersStream, + deadLetterPrefix + ">", + // And the provisioner clients' reports, the newest per subject read at its start (S21, ADR 0362 §3). + "$JS.API.STREAM.INFO." + ProvAppliedStream, + "$JS.API.STREAM.MSG.GET." + ProvAppliedStream, + "$JS.API.DIRECT.GET." + ProvAppliedStream + ".>", + } + for _, s := range DeadLetterSources(sources) { + out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.MSG.GET."+s) + } + return out +} + +// WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and +// Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool +// runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed +// connections (S9), the provisioner clients' reports (S21), the dead letters kept, and the lease write's events. Never the bus's API audit +// (`$JS.EVENT.ADVISORY.API`): it carries every API call's request and answer in the account, the vault's hashes among +// them (the lead, 2026-10-29). +func WatchmanHears() []string { + return []string{ + "mesh.mod.power.event.*", + "mesh.mod.*.event.provisioner.failing", + "mesh.mod.*.event.provisioner.recovered", + "mesh.control.*.tools-alive", + "mesh.seat.mesh-self-check.event." + health.EventChecked, + "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", + "mesh.mod.nats.event.bus.slow-consumer", + "mesh.mod.nats.event.bus.connection-closed", + ProvAppliedSubjects, + deadLetterPrefix + ">", + } +} + +// DeadLetterSources are the streams of names a dead letter may come from, sorted, none twice: each a plain name, never a +// bucket's (KV_) or an object store's (OBJ_) stream, the dead letters' own two streams, or a pattern. +func DeadLetterSources(names []string) []string { + seen := map[string]bool{} + var out []string + for _, n := range names { + switch { + case seen[n], !safeSubject.MatchString(n), strings.HasPrefix(n, "KV_"), strings.HasPrefix(n, "OBJ_"), + n == DeadLettersStream, n == DeadLetterNoticesStream: + continue + } + seen[n] = true + out = append(out, n) + } + sort.Strings(out) + return out +} + +// deadLetterSourcesOf are the streams the records' consumers give up on: EVENTS, the work queue of every seat that +// accepts work, and every module's and process's own work queue. +func deadLetterSourcesOf(r Records) []string { + out := []string{EventsStream} + for _, mods := range r.Assigned { + for _, d := range mods { + for _, s := range append(append([]Seat(nil), d.Holds...), d.Uses...) { + if len(s.Accepts) > 0 { + out = append(out, seatStreamName(s.Name)) + } + } + for _, q := range d.Queues { + out = append(out, q.Stream) + } + for _, pr := range d.Processes { + for _, q := range pr.Queues { + out = append(out, q.Stream) + } + } + } + } + return DeadLetterSources(out) +} + +// keepsDeadLetters is the watchman keeping the dead letters: a second writer of the copies beside the controller, which +// keeps them until its lease is handed away and the watchman after (ADR 0564 clause 5). Delivering again stays the +// controller's in L4. +func keepsDeadLetters(p Principal, pattern string) bool { + return p.Kind == KindModule && p.Module == WatchmanModule && pattern == deadLetterPrefix+">" +} diff --git a/modules/mesh-grants/internal/buscompose/log.go b/modules/mesh-grants/internal/buscompose/log.go new file mode 100644 index 0000000..53bb845 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/log.go @@ -0,0 +1,202 @@ +package buscompose + +import ( + "fmt" + "strings" + "time" + + "github.com/nats-io/nats.go/jetstream" +) + +// A module's log on the bus (novox/hq ADR 0297): its record of operations, each entry appended under +// a key and kept as long as the log is. +// +// A log follows a bucket in every respect (ADR 0201): the module names it locally, the mesh derives +// its stream and subjects, the controller creates it from the catalogue on every raise and from +// registration, never a module, and **no path of the mesh removes one** — a log whose declaration +// is gone is reported, as a bucket is. Only its owner appends to it. Another module reads it when its manifest's +// `reads` names it, `.` (novox/hq ADR 0571 clause 1; the lead, 2026-10-29): by direct gets, and through an +// ordered consumer of its own it creates and lets go of to read from a time or the newest per key and then follow new +// entries; never a durable consumer, never a write. +// +// Pure, but for the stream's configuration, which is the server's own type so that what is tested +// is exactly what is sent; jetstream.go is the part that asks a server. + +// The mesh's caps on a log: what an entry's value may weigh, what a message on the log's stream may +// weigh — the value and its headers, which the server counts in a message's size, so a value of the +// full size still fits with the expected-last-sequence header an append carries — and what a log holds +// when its module says nothing and at most. +const ( + LogMaxEntryBytes = 256 * 1024 + LogMaxMessageBytes = LogMaxEntryBytes + 4*1024 + LogDefaultMiB = 1024 + LogMostMiB = 8192 +) + +// A Log is one module's declared log as the bus holds it. +type Log struct { + Module string + Name string + // MaxMiB is its cap in MiB; zero is LogDefaultMiB. + MaxMiB int + // MaxAgeDays is how long an entry is kept; zero is as long as the log (catalogue.LogDeclaration). + MaxAgeDays int +} + +// LogStreamPrefix starts every log's stream name, which no other stream of the mesh's starts with. +const LogStreamPrefix = "LOG_" + +// LogStreamName is the stream a module's log lives in: `LOG__`. The module and the +// local name are each one token with no underscore, so two modules can never derive one stream. +func LogStreamName(module, name string) string { return LogStreamPrefix + module + "_" + name } + +// LogSubject is the subject a log's entries are published under, without the key: an entry for key K +// is on `.`. +func LogSubject(module, name string) string { return "mesh.log." + module + "." + name } + +// Stream is this log's stream name. +func (l Log) Stream() string { return LogStreamName(l.Module, l.Name) } + +// Subject is this log's subject, without the key. +func (l Log) Subject() string { return LogSubject(l.Module, l.Name) } + +// MaxBytes is this log's cap in bytes. +func (l Log) MaxBytes() int64 { + mib := l.MaxMiB + if mib <= 0 { + mib = LogDefaultMiB + } + return int64(mib) * 1024 * 1024 +} + +// Why is carried into the server's description of the stream, so somebody reading the server's own +// state finds whose it is and why it is kept. +func (l Log) Why() string { + return fmt.Sprintf("%s's log %q (novox/hq ADR 0297): its record of operations, one entry per operation "+ + "under its key, appended by %s alone and kept as long as the log; never removed by the mesh, because "+ + "it is data", l.Module, l.Name, l.Module) +} + +// Config is the stream a log is, field by field as novox/hq ADR 0297 fixes it: a file stream kept by +// limits, with no maximum age unless the log says one (ADR 0565 §10) and no cap per key, whose message is an entry's value and 4 KiB of +// headers at most, that refuses a new entry when full rather than +// drop an old one, and that refuses deleting an entry or purging it. Direct gets are allowed, which +// is how the runtime reads it. +func (l Log) Config() jetstream.StreamConfig { + return jetstream.StreamConfig{ + Name: l.Stream(), + Description: l.Why(), + Subjects: []string{l.Subject() + ".>"}, + Storage: jetstream.FileStorage, + Retention: jetstream.LimitsPolicy, + MaxAge: time.Duration(l.MaxAgeDays) * 24 * time.Hour, + MaxMsgs: -1, + MaxMsgsPerSubject: -1, + MaxBytes: l.MaxBytes(), + MaxMsgSize: LogMaxMessageBytes, + Discard: jetstream.DiscardNew, + AllowDirect: true, + DenyDelete: true, + DenyPurge: true, + Replicas: 1, + } +} + +// logsIssuedFor is every log a module's code may reach, as its membership lists them: its own. +func logsIssuedFor(d Declared) []LogIssued { + var out []LogIssued + for _, l := range d.Logs { + if !safeSubject.MatchString(d.Module) || !safeSubject.MatchString(l.Name) { + continue + } + out = append(out, LogIssued{Name: l.Name, Stream: LogStreamName(d.Module, l.Name), + Subject: LogSubject(d.Module, l.Name), Writes: true}) + } + return out +} + +// logGrants is what a principal publishes to reach the logs its module keeps: for each, appending +// under the log's subjects, binding to its stream, and reading it directly. Nothing more — the +// runtime reads a log by direct gets alone and makes no consumer on it — and nothing of any other +// module's log, because a log is its owner's alone. Replies come on the principal's inbox, as for a +// bucket. +func logGrants(module string, names []string) []string { + if !safeSubject.MatchString(module) { + return nil + } + var out []string + for _, name := range names { + if !safeSubject.MatchString(name) { + continue + } + stream := LogStreamName(module, name) + out = append(out, + LogSubject(module, name)+".>", + "$JS.API.STREAM.INFO."+stream, + "$JS.API.DIRECT.GET."+stream, + "$JS.API.DIRECT.GET."+stream+".>") + } + return out +} + +// DeclaredLogs is every log a module placed anywhere declares, as a read names it: `.`. +func DeclaredLogs(r Records) map[string]bool { + out := map[string]bool{} + for _, ds := range r.Assigned { + for _, d := range ds { + for _, l := range d.Logs { + out[d.Module+"."+l.Name] = true + } + } + } + return out +} + +// withReadLogs is d with ReadLogs set: of its reads, those naming a log of logs. +func withReadLogs(d Declared, logs map[string]bool) Declared { + d.ReadLogs = nil + for _, r := range d.Reads { + if logs[strings.TrimSpace(r)] { + d.ReadLogs = append(d.ReadLogs, strings.TrimSpace(r)) + } + } + return d +} + +// readLogGrants is what a reader of other modules' logs publishes, for each `.` read: the stream's info, +// direct gets (an entry by sequence, the last of a key), and an ordered consumer of its own (created with any name and +// filter, pulled, read and deleted). No append, no purge, no delete of an entry: the log's stream refuses those anyway. +func readLogGrants(reads []string) []string { + var out []string + for _, r := range reads { + module, name, ok := strings.Cut(r, ".") + if !ok || !safeSubject.MatchString(module) || !safeSubject.MatchString(name) { + continue + } + stream := LogStreamName(module, name) + out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.DIRECT.GET."+stream, "$JS.API.DIRECT.GET."+stream+".>", + "$JS.API.CONSUMER.CREATE."+stream+".>", "$JS.API.CONSUMER.MSG.NEXT."+stream+".*", + "$JS.API.CONSUMER.INFO."+stream+".*", "$JS.API.CONSUMER.DELETE."+stream+".*") + } + return out +} + +// readLogsIssuedFor is every other module's log a module's code reads, as its membership lists them: by the read's +// name, never written. +func readLogsIssuedFor(d Declared) []LogIssued { + var out []LogIssued + for _, r := range d.ReadLogs { + module, name, _ := strings.Cut(r, ".") + out = append(out, LogIssued{Name: r, Stream: LogStreamName(module, name), Subject: LogSubject(module, name)}) + } + return out +} + +// logNames is the local names of a module's logs. +func logNames(logs []Log) []string { + out := make([]string, 0, len(logs)) + for _, l := range logs { + out = append(out, l.Name) + } + return out +} diff --git a/modules/mesh-grants/internal/buscompose/membership.go b/modules/mesh-grants/internal/buscompose/membership.go new file mode 100644 index 0000000..8601788 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/membership.go @@ -0,0 +1,178 @@ +package buscompose + +import ( + "slices" + "sort" + "strings" +) + +// What the mesh issues an assignment to serve and to reach (novox/hq ADR 0160). +// +// A module's code names its tools and its events; **where they land is the mesh's to decide**, and +// it decided it twice — once in the runtime, once here, by one rule compiled into both. Now the +// controller composes a membership for every module on every machine and publishes it to a subject +// only that assignment reads; the runtime serves exactly what the membership says, and the account's +// grant is the same composition read the other way. The shape issued today is the shape the mesh +// already had, so nothing moves when a membership first arrives; only who decides it moves. + +// Placements is where every module runs, for deciding which instance answers for the module. +type Placements struct { + // Nodes is each module's machines. + Nodes map[string][]string + // Interchangeable is each module whose definition says its instances are the same anywhere, + // so the module's plain subject is issued to all of them in one queue. + Interchangeable map[string]bool + // Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5). + Kinds []KindHeld + // Logs is every log a module placed anywhere declares, `.` (DeclaredLogs): a read naming one is issued + // as a log read (novox/hq ADR 0571 clause 1). + Logs map[string]bool +} + +// AnswersForTheModule says whether an instance of a module on one machine is issued the module's +// plain subject: when it is the only instance, or when the definition says instances are +// interchangeable. A stateful module on two machines gets only its machines' subjects, so a call +// that names none reaches nothing rather than the wrong store. +func (p Placements) AnswersForTheModule(module string) bool { + return len(p.Nodes[module]) <= 1 || p.Interchangeable[module] +} + +// MembershipFor composes one assignment's membership from what it declared and where everything +// runs. The subjects are the ones PermissionsFor grants, derived here once more only until the +// grant itself is read from the membership — which is the next step, not this one. +func MembershipFor(node string, d Declared, where Placements) Membership { + own := "mesh.mod." + d.Module + m := Membership{ + Node: node, Module: d.Module, + Emits: own + ".event.{event}", + Tools: own + ".tool.tools", + } + // This machine's address always; the module's when this instance answers for the module. + m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}." + node}) + if where.AnswersForTheModule(d.Module) { + m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module}) + } + for _, s := range d.Holds { + for _, verb := range s.Serves { + m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)}) + } + } + m.State = stateIssuedFor(d, node) + m.Logs = append(logsIssuedFor(d), readLogsIssuedFor(withReadLogs(d, where.Logs))...) + m.Streams = streamsIssuedFor(d) + m.Hears = HeardIssuedFor(node, d) + m.Waits = waitsIssuedFor(d) + t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches) + for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) { + if !s.Kinded { + continue + } + for _, k := range where.Kinds { + if k.Seat == s.Name && !kindListed(t.Kinds, k) { + t.Kinds = append(t.Kinds, k) + } + } + } + if len(t.Publish)+len(t.Subscribe)+len(t.Answers)+len(t.Workers)+len(t.Records)+len(t.Kinds) > 0 { + m.SeatTraffic = &t + } + if len(d.Invokes) > 0 { + m.Reaches = map[string][]string{} + for _, t := range d.Invokes { + if t == "*" || strings.HasPrefix(t, "seat:") { + continue // every tool, or a role's: addressed by name, not resolved per instance + } + module, tool, ok := strings.Cut(t, ".") + if !ok { + continue + } + var reach []string + if where.AnswersForTheModule(module) { + reach = append(reach, "mesh.mod."+module+".tool."+tool) + } + nodes := append([]string{}, where.Nodes[module]...) + sort.Strings(nodes) + for _, n := range nodes { + reach = append(reach, "mesh.mod."+module+".tool."+tool+"."+n) + } + m.Reaches[t] = reach + } + } + return m +} + +// PlacementsOf reads where everything runs from the records the bus's accounts are composed from. +func PlacementsOf(r Records, interchangeable map[string]bool) Placements { + p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable, Logs: DeclaredLogs(r)} + for node, declared := range r.Assigned { + for _, d := range declared { + p.Nodes[d.Module] = append(p.Nodes[d.Module], node) + } + } + for _, nodes := range p.Nodes { + sort.Strings(nodes) + } + // Where the router runs: a verified sender is believed only while its machine is root-free too. A router + // placed nowhere, or on more than one machine, frees nothing. + var routerNodes []string + for node, declared := range r.Assigned { + for _, d := range declared { + for _, s := range d.Holds { + if s.Name == routerSeat && !slices.Contains(routerNodes, node) { + routerNodes = append(routerNodes, node) + } + } + } + } + routerFree := len(routerNodes) == 1 && r.RootFree[routerNodes[0]] + for node, declared := range r.Assigned { + for _, d := range declared { + for _, s := range d.Holds { + if s.Kinded && s.Kind != "" { + p.Kinds = append(p.Kinds, KindHeld{Seat: s.Name, Kind: s.Kind, Module: d.Module, Node: node, + Capabilities: placedCapabilities(s.Capabilities, d.RunsAs, routerFree && r.RootFree[node])}) + } + } + } + } + sort.Slice(p.Kinds, func(i, j int) bool { + a, b := p.Kinds[i], p.Kinds[j] + if a.Seat != b.Seat { + return a.Seat < b.Seat + } + if a.Kind != b.Kind { + return a.Kind < b.Kind + } + return a.Node < b.Node + }) + return p +} + +// routerSeat is the seat the router of asks holds (novox/hq ADR 0259 §3). +const routerSeat = "operator-channel" + +// placedCapabilities is what a kind's claim promises, as far as its placement lets the router believe it +// (novox/hq ADR 0259 §8): `verified-sender` only from a holder that runs as an account of its own, on a bus +// account of its own — never one the machine's runtime carries as the operator's account — and only while +// its machine and the router's were root-free when composed (rootFree; the review of 2026-10-09, H3). The +// membership carrying it is composed at a push, so it can outlive a pass that later fails: the router asks +// the controller's root-free verb again before it honours an approval, and that is the check that holds. +func placedCapabilities(declared []string, runsAs string, rootFree bool) []string { + var out []string + for _, c := range declared { + if c == "verified-sender" && (runsAs == "" || !rootFree) { + continue + } + out = append(out, c) + } + return out +} + +func kindListed(list []KindHeld, k KindHeld) bool { + for _, x := range list { + if x.Seat == k.Seat && x.Kind == k.Kind && x.Module == k.Module && x.Node == k.Node { + return true + } + } + return false +} diff --git a/modules/mesh-grants/internal/buscompose/modstreams.go b/modules/mesh-grants/internal/buscompose/modstreams.go new file mode 100644 index 0000000..61ba5e8 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/modstreams.go @@ -0,0 +1,288 @@ +package buscompose + +import ( + "fmt" + "sort" + "strings" +) + +// A module's own stream on the bus (novox/hq ADR 0363). +// +// A module declares a stream as it declares a log: named locally, created by the controller from the +// catalogue on every raise and every send, never removed by the mesh. Its events are published on +// `mesh.stream..event.`, a subject space the shared EVENTS stream (`mesh.mod.*.event.>`) +// does not take — the bus refuses two streams claiming one subject, so a module's stream cannot share +// EVENTS' — and a consumer hears it through a durable consumer of its own on that stream, named as on +// EVENTS. Pure; the controller's raise asserts what is composed here. + +// moduleStreamPrefix starts every subject a module's own stream takes. +const moduleStreamPrefix = "mesh.stream." + +// ModuleStreamSubject is where a module's event on its own stream lands: `mesh.stream..event.`. +// The token before `.event.` is the emitter, as on EVENTS, so a runtime reads the same key from it. +func ModuleStreamSubject(module, event string) string { + return moduleStreamPrefix + module + ".event." + event +} + +// OnAModuleStream says a subject is one a module's own stream takes. +func OnAModuleStream(subject string) bool { return strings.HasPrefix(subject, moduleStreamPrefix) } + +// WirePattern is a local event pattern as the bus spells it: `**` is `>`. +func WirePattern(local string) string { + parts := strings.Split(local, ".") + for i, p := range parts { + if p == "**" { + parts[i] = ">" + } + } + return strings.Join(parts, ".") +} + +// A ModuleStream is one module's declared stream as the bus holds it. +type ModuleStream struct { + Module string + // Name is its name on the bus, `MAIL`. + Name string + // Events are the events it carries, as the bus spells them (WirePattern). + Events []string + MaxAgeDays int + MaxMiB int + // Restricted says a trusted config value of the module grants who hears it (ADR 0363 §4). + Restricted bool + // WorkQueue says this stream holds ADR 0436 §6's work-queue retention: a message is removed once a + // consumer acknowledges it, and the bus refuses a new one when full rather than dropping its oldest. + WorkQueue bool + // Duplicates is the work-queue's duplicate window in hours (novox/hq ADR 0436 §6); meaningless unless + // WorkQueue. + Duplicates int +} + +// Subjects are the subjects the stream takes. +func (s ModuleStream) Subjects() []string { + var out []string + for _, e := range s.Events { + out = append(out, ModuleStreamSubject(s.Module, e)) + } + sort.Strings(out) + return out +} + +// Stream is the stream the controller asserts: kept by limits, its age and size the module's, dropping +// its oldest when full — a consumer that was away catches up within the age, and the module's own record +// answers for anything older. +func (s ModuleStream) Stream() Stream { + why := fmt.Sprintf("%s's own stream (novox/hq ADR 0363): its events, kept %d days and at most %d MiB so a "+ + "consumer that was away catches up", s.Module, s.MaxAgeDays, s.MaxMiB) + if s.Restricted { + why += "; heard only by the consumers its trusted config value grants" + } + if s.WorkQueue { + why += "; a work-queue (novox/hq ADR 0436 §6): a message is removed once its one consumer " + + "acknowledges it, and a full stream refuses a new one rather than dropping what it holds" + } + out := Stream{ + Name: s.Name, + Subjects: s.Subjects(), + Retention: RetentionLimits, + MaxAge: s.MaxAgeDays * 24 * 60 * 60, + MaxBytes: int64(s.MaxMiB) * 1024 * 1024, + Why: why, + // Said on the bus, so a dead letter kept from it ages out with it (novox/hq ADR 0376 §3). + Restricted: s.Restricted, + } + if s.WorkQueue { + out.Retention = RetentionWorkQueue + out.DiscardNew = true + out.DuplicatesSeconds = s.Duplicates * 60 * 60 + // Said on the bus too, reusing the one flag a restricted stream's dead letters already age and hide + // by (novox/hq ADR 0436 §6, ADR 0376 §3-4): a work-queue stream's grant is always empty (ADR 0436 + // §6 refuses one that carries a grant), so this never collides with a trusted-config restriction. + out.Restricted = true + // And its own metadata, apart from the bus's work-queue retention SEAT_* and other mesh-internal + // streams share without being one of this clause's: only this flag says a dead letter kept from the + // stream is drop-only (novox/hq ADR 0436 §6), never the stream's Retention alone. + out.WorkQueue = true + } + return out +} + +// QueueOwner is the one process of a module that is the durable consumer of one of its own work-queue +// streams (novox/hq ADR 0436 §1, §2, §6): named in that process's bus.queues, and checked at registration +// to be the only one (the indexer's judge, novox/hq ADR 0578 §1). +type QueueOwner struct { + // Process is the process resource's id — its bus account's name, with the node and module (novox/hq ADR + // 0436 §2). + Process string + // Stream is the work-queue stream's name on the bus. + Stream string + // MaxDeliver bounds how many times a message is handed over before it is given up as a dead letter + // (novox/hq ADR 0436 §6). + MaxDeliver int +} + +// QueueConsumer is the durable consumer the one process a module's bus.queues names is granted on its own +// work-queue stream (novox/hq ADR 0436 §1, §2, §6): no filter, since it is the stream's only consumer — +// unlike heardConsumer, which is one of possibly several hearing a module's ordinary stream. +func QueueConsumer(p Principal, q QueueOwner) Consumer { + return Consumer{ + Name: p.Node + "_" + p.Module + "_" + q.Process, + Stream: q.Stream, + AckWaitSeconds: 30, + MaxDeliver: q.MaxDeliver, + Why: fmt.Sprintf("%s's process %s is the one consumer %s's bus.queues names for its work-queue "+ + "stream %s (novox/hq ADR 0436 §6); after max-deliver %d it gives a message up, and the "+ + "controller keeps it in %s, offered only drop", p.Module, q.Process, p.Module, q.Stream, + q.MaxDeliver, DeadLettersStream), + } +} + +// ModuleStreams is every declared module stream as the controller asserts it, in name order. +func ModuleStreams(declared []ModuleStream) []Stream { + sorted := append([]ModuleStream(nil), declared...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) + out := make([]Stream, 0, len(sorted)) + for _, s := range sorted { + out = append(out, s.Stream()) + } + return out +} + +// Heard is a module's stream another module hears, and the subjects it hears of it: its `consumes` +// within the stream and, for a restricted stream, within what the provider's trusted config value grants it +// (ADR 0363 §5). Composed only with at least one subject. +type Heard struct { + Stream string + Emitter string + Filters []string + // Restricted says the stream is restricted by its provider's trusted config value (ADR 0363 §4): then it + // is heard only by a module on an account of its own (ADR 0376 §1, ComposedHears). + Restricted bool +} + +// ComposedHears is what of a module's hearing the controller composes (novox/hq ADR 0376 §1): every stream it +// hears, but a restricted one only for a module that runs in a runtime of its own on a bus account of its own +// (`runs-as`, ADR 0259). **The tool runner never hears a restricted stream**: a module it carries reaches the bus +// through the tool runner's account, which every agent session on that machine reaches the bus as too, so a +// consumer, its grants or its bind word composed there would hand the stream to any session. A module that +// declares no `runs-as` and keeps its own container credential gets none either: only `runs-as` is an account +// the mesh keeps from agents. +func ComposedHears(d Declared) []Heard { + if d.RunsAs != "" { + return d.Hears + } + var out []Heard + for _, h := range d.Hears { + if !h.Restricted { + out = append(out, h) + } + } + return out +} + +// Withheld is a restricted stream a grant gives a module that the controller composes nothing for, because the +// module has no account of its own (novox/hq ADR 0376 §2): said in `doctor` and in the controller's log, never +// silent. +type Withheld struct { + Node, Module, Stream, Emitter string +} + +// Said is a withheld hearing in the operator's words: the module, the stream and the remedy. +func (w Withheld) Said() string { + return fmt.Sprintf("%s on %s is granted part of %s's restricted stream %s, and hears none of it: it is carried "+ + "by the machine's tool runner, whose bus account every agent session there shares, so nothing is composed "+ + "for it. Declare runs-as in %s's manifest, so it runs as an account of its own (novox/hq ADR 0376)", + w.Module, w.Node, w.Emitter, w.Stream, w.Module) +} + +// WithheldOf is every granted hearing of a restricted stream composed for nothing, by machine and module. +func WithheldOf(r Records) []Withheld { + var out []Withheld + for _, node := range sortedCopy(r.Nodes) { + for _, d := range r.Assigned[node] { + if d.RunsAs != "" { + continue + } + for _, h := range d.Hears { + if h.Restricted && len(h.Filters) > 0 { + out = append(out, Withheld{Node: node, Module: d.Module, Stream: h.Stream, Emitter: h.Emitter}) + } + } + } + } + return out +} + +// heardConsumer is the durable consumer a module hears a module's stream through: named as its consumer +// on EVENTS, on that stream, with the subjects it was granted as its filters. +func heardConsumer(p Principal, h Heard) Consumer { + filters := append([]string(nil), h.Filters...) + sort.Strings(filters) + return Consumer{ + Name: consumerDurable(p), + Stream: h.Stream, + Filters: filters, + AckWaitSeconds: 30, + MaxDeliver: 5, + // From now, like a module's consumer on EVENTS (novox/hq ADR 0443 §1). + FromNow: true, + Why: "what " + p.Module + " consumes of " + h.Emitter + "'s stream " + h.Stream + " and is granted " + + "(novox/hq ADR 0363); after max-deliver it gives an event up, and the controller keeps it in " + + "DEAD_LETTERS until a person delivers it again or drops it", + } +} + +// heardGrants are what a principal publishes to read and acknowledge the consumers it hears module streams +// through: asking about each, pulling from it, acknowledging it — its own name on each stream, no other's. +func heardGrants(p Principal, hears []Heard) []string { + var out []string + durable := consumerDurable(p) + for _, h := range hears { + if !safeSubject.MatchString(h.Stream) || len(h.Filters) == 0 { + continue + } + out = append(out, + "$JS.API.CONSUMER.INFO."+h.Stream+"."+durable, + "$JS.API.CONSUMER.MSG.NEXT."+h.Stream+"."+durable, + "$JS.ACK."+h.Stream+"."+durable+".>") + } + return out +} + +// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's. +func ownStreamGrants(module string, streams []ModuleStream) []string { + var out []string + for _, s := range streams { + if s.Module != module { + continue + } + out = append(out, s.Subjects()...) + } + return out +} + +// streamsIssuedFor is every stream of its own a module's membership names. +func streamsIssuedFor(d Declared) []StreamIssued { + var out []StreamIssued + for _, s := range d.Streams { + if s.Module != d.Module || !safeSubject.MatchString(d.Module) { + continue + } + out = append(out, StreamIssued{Stream: s.Name, Events: append([]string(nil), s.Events...), + Subject: ModuleStreamSubject(d.Module, "{event}")}) + } + return out +} + +// HeardIssuedFor is every module stream an assignment hears, as its membership names it. +func HeardIssuedFor(node string, d Declared) []HeardIssued { + var out []HeardIssued + // A restricted stream's bind word only for a module of its own account (novox/hq ADR 0376 §1). + for _, h := range ComposedHears(d) { + if len(h.Filters) == 0 { + continue + } + out = append(out, HeardIssued{Stream: h.Stream, Consumer: node + "_" + d.Module}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Stream < out[j].Stream }) + return out +} diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go new file mode 100644 index 0000000..77c52e4 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -0,0 +1,1616 @@ +// Composing the bus's own configuration. +// +// An account is *composed*, never called for: the controller writes accounts, users and +// per-subject permissions into one file the host keeps current, and the server reloads it in +// place (novox/hq ADR 0106 — never through a management API; design 25 §4). +// +// Everything here is pure. Given the principals, it returns the file's text — so the whole of the +// mesh's authority model is testable as strings, with no server. +// +// **Permissions are per subject, so a module's own name is the server's to enforce.** ADR 0042 +// reserves a module's origin — it publishes only under its own name — and here that is a refusal +// rather than something a library promises. +package buscompose + +import ( + "errors" + "fmt" + "regexp" + "slices" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// A Kind is what a principal is, which decides the shape of its authority rather than its +// contents: a module's comes from its declaration, a host's from its node, and the controller's +// and the enrolment user's are fixed. +type Kind string + +const ( + KindModule Kind = "module" + KindNode Kind = "node" + KindEnrolment Kind = "enrolment" + // KindPerson is somebody reaching the mesh's tools from a workstation (design 25 §7). Its + // authority is a list of tools and nothing else — not control, not declarations, not builds, + // and no ability to answer anything, because a person asks. + KindPerson Kind = "person" + // KindNodeTools is a machine's tool runtime (novox/hq ADR 0175, to-be 38): one process per + // node, on the host side, serving every assigned module's tools and every held seat's verbs. + // Its authority is the union of what the modules it carries would each have had for their + // tools — and nothing of what they consume, because tools are what it runs, not reactions. + KindNodeTools Kind = "node-tools" + // KindProvisioner is a machine's provisioner client (novox/hq ADR 0349 clauses 6 and 11, design 57 P3): a + // service of the module holding NodeProvisionerSeat, outside the tool runner, under an account of its own so + // the vault can tell it from an agent. Its authority is provisionerGrants and nothing else. + KindProvisioner Kind = "provisioner" + // KindProcess is one process of a module that declares bus (novox/hq ADR 0436 §1, §2): a service of that + // module, outside the tool runner and outside the module's own account, under a bus account scoped to + // exactly what its bus says — never root, never the operator account, and never able to answer a tool + // call or a seat's verb (§3). One per process and machine. + KindProcess Kind = "process" +) + +// RuntimeModule is the module that IS the node's tool runtime (novox/hq ADR 0175). Where it is +// assigned, the mesh composes one runtime principal for the machine in place of that module's own, +// and the per-module containers that served tools until then stop being the way tools reach a node. +// Mirrored in the catalogue package, which the agreement test holds to the same string; one +// constant, so a rename is one edit and the two packages cannot drift. +const RuntimeModule = "node-tools" + +// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it +// emits (novox/hq ADR 0118, design 29 §5). +type Seat struct { + Name string + // Scope is where the seat has one holder. A node-scoped seat's tool carries the node in its + // subject, because one subject reaching six machines' holders is not an address + // (novox/hq ADR 0132, design 33 §4). Empty reads as mesh. + Scope string + Accepts []string + Emits []string + Serves []string + Versions []string // protocol versions served beside the current one; empty for v1 only + + // Kinded says the seat is a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): each holder claims one + // kind, and its verbs' subjects carry it. Kind is the kind this principal's claim names, for a seat it + // holds. + Kinded bool + Kind string + // ByCaller are the accepts and emits whose last token names the calling module (ADR 0259 §3). + ByCaller []string + // Proofs are the seat's proof verbs: core request and reply, never on a stream (ADR 0259 §3). + Proofs []string + // Records are the holder's buckets, by their full name, each user reads under its own name. + Records []string + // Capabilities are what this principal's claim of a kinded bench promises (ADR 0234 §2). + Capabilities []string + // DeclaredBy is the module that declares the seat. On a kinded bench it alone submits work to a kind + // and answers its proofs (novox/hq ADR 0259 §8): the router, not any user or watcher of the bench. + DeclaredBy string + // PulledByKind says each accepted kind is a worker of its own on the seat's queue, filtered to that kind, which + // every holder pulls in the order Accepts lists them: the build seats' (novox/hq ADR 0525 §2, ADR 0536 §4). + PulledByKind bool +} + +// A Principal is one user of the bus. Its permissions are derived from what it declares and +// nothing else (novox/hq ADR 0043), over the three namespaces of design 29 §2: its own, the seats +// it holds, and the seats it uses. +type Principal struct { + Kind Kind + Node string + Module string + // Process is the process resource's id, for a KindProcess principal alone (novox/hq ADR 0436 §2): + // Username and inbox are ... + Process string + + // FilesFor are the nodes whose declaration-file buckets (novox/hq ADR 0486 §2) this principal may remove, for the bus + // module's tools removing the ended controller's leftovers alone (ADR 0590 clause 6): every node of the mesh, one + // bucket each. + FilesFor []string + + Emits []string + Consumes []string + Serves []string + + Holds []Seat + Uses []Seat + // Watches are seats whose events this principal consumes. Separate from Consumes because a + // role's event lives under the seat's namespace and not a module's, and this package cannot tell + // a seat's name from a module's by looking at it — whoever resolved the declaration can, and + // does (novox/hq ADR 0121). + // + // **Found by a consumer reading nothing.** The catalogue consumes the build machine's outcome; + // with that name read as a module's, its subscription pointed at `mesh.mod.mesh-build-machine.…`, + // a namespace no such module owns. Every service started and the graph stayed empty. + Watches []Seat + + // Carries are the modules whose tools this principal serves, for a KindNodeTools principal + // (novox/hq ADR 0175): every module assigned to its node, as each declares itself. Its + // serving authority is the union of theirs — each module's own tool namespace and each held + // seat's verbs on this node — derived from the same declarations the modules' own principals + // are, so the runtime can serve nothing a module could not have served for itself. + Carries []Declared + + // Invokes are the tools this principal may call, as `.`; a single `*` is every + // tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so + // (novox/hq ADR 0152) — the console's does, and nothing else's. + // + // **A list, not a role.** A person is not a module and holds no seat: nothing is addressed + // to them, nothing is delivered to them, and they have no durable consumer to acknowledge. + // What they have is permission to ask. A module that invokes gains exactly the same + // permission and nothing beside it. + Invokes []string + + // AsksIssued are, for the vault's holder, the tools it asks in its own name for a credential a provider's software + // issued, each `.` (novox/hq ADR 0305, issue 723, vaultCalls): on the subject that names its caller, + // so the tool knows the vault asks. + AsksIssued []string + + // AsksTheDesk says this principal is the vault's holder, which asks the desks' secret prompt in its own name + // (novox/hq ADR 0349 clause 9, issue 723): `node-launcher.secret` on any machine, on the subject that names + // its caller, and nothing else of that seat. With AsksIssued on the same principal, the vault's whole reach + // outside its own seat: it asks each issuing provider's tool in its own name (clause 10). + AsksTheDesk bool + + // State is the local names of the state this principal's module keeps, and Reads the state of + // others it reads as `.` (novox/hq ADR 0201): a bucket each, kept by the owner's + // instances and read by whoever declares it. + State []string + Reads []string + // PerMachine is the local names of its state keyed by machine, reaching the key named for Node and + // no other; KeyedReads the keys of others' state it reads one key at a time (novox/hq ADR 0260). + PerMachine []string + KeyedReads []KeyedRead + // Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297). + Logs []string + // ReadLogs are, of Reads, those naming another module's log (novox/hq ADR 0571 clause 1). + ReadLogs []string + // Streams are the streams this principal's module owns, and Hears the module streams it hears with the + // subjects it was granted (novox/hq ADR 0363). + Streams []ModuleStream + Hears []Heard + // Queues are its own work-queue streams and the one process each is the consumer of (novox/hq ADR 0436 + // §1, §2, §6); for a KindProcess principal, its own alone. + Queues []QueueOwner + // Asks says a KindProcess principal reads the records of its module's own asks (novox/hq ADR 0436 §1), + // on whichever seat of Uses keeps them (novox/hq ADR 0259 §3) — never set for any other kind. + Asks bool + + // SnapshotsTheBus is the bus's own module, the one holding mesh-broker (novox/hq ADR 0235). Its + // whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing + // else — not its declarations, which the node's tool runtime serves for it. + SnapshotsTheBus bool + // Removes are, for a process or tools bundle account whose bus declares them, what it may remove from the bus + // (novox/hq ADR 0564 Limits 4): its whole authority is then BusRemovalGrants of them. + Removes []string + // DeadLetterSources are, for the watchman, the streams a dead letter it keeps may come from, by name + // (deadLetterSourcesOf, novox/hq ADR 0564 clause 5): never a bucket's. + DeadLetterSources []string + + // Checks are the tools a machine principal's node-engine asks as a declared health check, each + // `.` (novox/hq ADR 0240, to-be 48 §3): asked of the instance on its own machine and + // nowhere else. + Checks []string + // MovesIntoTheVault is a machine principal whose node-engine may ask the vault's `move-in` in its own name (design + // 57's V3, issue 676): set while the vault's holder serves it, so the engine can hand in the values sealed to its + // machine, which nothing else can open. + MovesIntoTheVault bool + // StatesItsKeyToTheVault is a machine principal whose node-engine may ask the vault's `node-key` in its own name + // (issue 786, novox/hq ADR 0372): set while the vault's holder serves it. That one verb, and nothing else new. + StatesItsKeyToTheVault bool + // AsksItsOwnSecrets is a machine principal whose node-engine may ask the vault's `own` in its own name (novox/hq + // ADR 0547 §2, ADR 0550 §5, issue 1126): its modules' own secrets sealed to its key, for the compositions it makes + // itself. Set while the vault's holder serves it. + AsksItsOwnSecrets bool + // AsksTheVaultToSign is a machine principal whose node-engine may ask the vault's `sign` in its own name, read the + // vault's public signing halves (`signers`) and the controller's lease key (novox/hq ADR 0550 §2): its own node's + // certificate, the keys it trusts, and the lease write that drops the controller's key. Set while the vault's holder + // serves `sign`. + AsksTheVaultToSign bool + // ServedReads are, for a machine principal, the keys of mesh-config's resolved values that other nodes' modules serve + // from (`..`, catalogue.ServedSettings): what every consumer is told, and so what a node composing + // its own modules reads of a provider elsewhere (issue 1126), each key alone. Sorted, none twice. + ServedReads []string + // FollowsGrantsUserList is the machine principal of the node holding mesh-broker: its node-engine reads the grants + // part's sealed user list, one key and an object by name (novox/hq ADR 0547 clause 7, GrantsUserListFollows). + FollowsGrantsUserList bool + // BoundBuckets are the providers' buckets of entries, `_bound`, a provisioner client reads its own + // machine's keys of (mesh-sdk go/provider, issue 679): every bucket named bound of a module the mesh places. + BoundBuckets []string + // CreatesStreams are the streams the grants part's principal alone may create (novox/hq ADR 0547 §5; the operator, + // 2026-10-26 08:54): the stream of every bucket, log and stream a manifest of a module placed anywhere declares, + // by name. Created only, never updated or deleted: a new module state is on the bus when the grants part hears its + // manifest, not at the controller's next start (issue 1790). + CreatesStreams []string + // CreatesConsumers are the durable consumers the grants part's principal alone may create beside the controller + // (novox/hq ADR 0561 §6): each placed module's on EVENTS and each process's on its work queue (AccountConsumers), by + // stream and name. Created only, never updated or deleted. + CreatesConsumers []ModuleConsumer + + // Login is a node principal's login when it is not `node.`: its second login, `node.--b`, composed beside or + // in place of the first as the vault's node-logins says (novox/hq ADR 0583 §1, issue 774). Each login is granted + // the node's permissions and the node's inbox `_INBOX.node.`; what it calls, it calls in its own name, so the + // vault tells the login that confirms a rotation from the one it replaces (§3). + Login string + // RotatesItsLogin is a machine principal whose node-engine reads its own node's key of the vault's node-logins, + // and no other key, and confirms a switch with node-account-confirm in its own name (novox/hq ADR 0583 §1 insight, + // §3). Set while the vault's holder serves rotate-node. + RotatesItsLogin bool + + // Redeems is an enrolment principal that may call the seat mesh-enrolment's redeem in its own name, and nothing else + // of it (novox/hq ADR 0587 §4): set while the seat's holder serves redeem. LeaseHandedAway drops the controller's + // enrolment subject from it, which is served no more from the lease write (§6). + Redeems bool + LeaseHandedAway bool + + // VaultEnrols says the vault's holder serves an enrolment verb (VerbsTheEnrolmentAsksTheVault): every principal but + // the module mesh-enrolment and its processes is then denied them, however wide its grant (ADR 0587 §1). + VaultEnrols bool + + // PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal + // and never appears here: this file is written to a node's disk and read by a server, and a + // secret that can be read from a configuration file is a secret with a wider blast radius + // than the one it protects (novox/hq design 29 §10). + PasswordHash string +} + +// SeatVerb is one verb of one seat, on every machine holding it. +type SeatVerb struct{ Seat, Verb string } + +// VerbsTheSelfCheckAsks are the seat verbs the controller's self-check and watchdogs call (novox/hq +// to-be 45 §4): D8 reads every machine's ban list. **Named one by one, and the test that holds them +// to the probe registry is the reason they cannot drift** — a probe that calls a verb its grant does +// not name is refused by the bus on every run (found live on 2026-10-06: D8 timed out on each +// machine, refused). Asked of any machine (`.*`), read-only verbs, nothing else of the seat. +// +// D13 reads every machine's backup holder: what it measured of the data declared there (novox/hq ADR +// 0233). +var VerbsTheSelfCheckAsks = []SeatVerb{{Seat: "node-intrusion-prevention", Verb: "banned"}, + {Seat: "node-backup", Verb: "backed-up"}} + +// VerbsTheBusStepAsks are the seat verbs the bus's planned step calls (novox/hq to-be 45 §8, ADR 0236): +// the bus machine's backup holder takes the bus's snapshot now, before the bus is replaced (ADR 0235's +// dump), and says when it is taken (`backed-up`, granted with the self-check's). The one verb of +// the controller's grant that acts: through the step a person starts, and through the backups a delivery plan +// takes before it asks its first tier (novox/hq ADR 0404). +var VerbsTheBusStepAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}} + +// VerbsTheVaultAsksForASecret are the seat verbs the vault's holder calls for a secret typed at a desk (novox/hq ADR +// 0259 §10, ADR 0349 clause 9): the operator's desk opens a prompt that does not show what is typed, and answers it +// sealed to the vault's call. +var VerbsTheVaultAsksForASecret = []SeatVerb{{Seat: "node-launcher", Verb: "secret"}} + +// DeskOnly are the subjects the vault's holder alone may publish in its own name, however wide another's grant (the +// review of 2026-10-09, M4): the desk's hidden prompt, on its seat's subjects and on any holder's own module subjects. A +// grant of every tool — the runtime's, which carries every agent's calls, or a person's `*` — would otherwise reach it, +// and the prompt says the vault asks: only the bus makes that true. +func DeskOnly() []string { + var out []string + for _, v := range VerbsTheVaultAsksForASecret { + for _, base := range []string{"mesh.seat." + v.Seat + ".tool." + v.Verb, "mesh.mod.*.tool." + v.Seat + "." + v.Verb} { + out = append(out, base, base+".*") + } + // And where a call names its caller (novox/hq issue 365): any machine, any caller. + for _, base := range []string{"mesh.seat." + v.Seat + "." + CallKind + "." + v.Verb, "mesh.mod.*." + CallKind + "." + v.Seat + "." + v.Verb} { + out = append(out, base+".>") + } + } + return out +} + +// VerbsTheEnrolmentAsksTheVault are the vault's verbs the seat mesh-enrolment's holder alone calls (novox/hq ADR 0587 +// §1, §3; ADR 0561 §5): a token and its hash made and dropped, a joining node's account minted and retired. +var VerbsTheEnrolmentAsksTheVault = []SeatVerb{{Seat: VaultSeat, Verb: delivery.EnrolmentTokenVerb}, + {Seat: VaultSeat, Verb: delivery.NodeAccountVerb}} + +// EnrolmentOnly are the subjects the holder of mesh-enrolment alone may publish, however wide another's grant: the +// vault's two enrolment verbs, by the tool's and the caller-named shape (Principal.VaultEnrols denies them). +func EnrolmentOnly() []string { + var out []string + for _, v := range VerbsTheEnrolmentAsksTheVault { + out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb, "mesh.seat."+v.Seat+".tool."+v.Verb+".>", + "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".>") + } + return out +} + +// MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does. +func MayPublish(perms Permissions, subject string) bool { + for _, d := range perms.PublishDeny { + if SubjectsOverlap(d, subject) { + return false + } + } + for _, a := range perms.Publish { + if SubjectsOverlap(a, subject) { + return true + } + } + return false +} + +// MaySubscribe says whether a principal with these permissions may subscribe to (and so answer) a subject. +func MaySubscribe(perms Permissions, subject string) bool { + for _, a := range perms.Subscribe { + if SubjectsOverlap(a, subject) { + return true + } + } + return false +} + +// VaultMoveIn is the vault's verb a machine's node-engine asks in its own name to hand in a value sealed to its +// machine (design 57's V3, issue 676); granted as Principal.MovesIntoTheVault says. +var VaultMoveIn = SeatVerb{Seat: "mesh-vault", Verb: "move-in"} + +// VaultNodeKey is the vault's verb a machine's node-engine asks in its own name to state its sealing key (issue 786, +// novox/hq ADR 0372); granted as Principal.StatesItsKeyToTheVault says. +var VaultNodeKey = SeatVerb{Seat: "mesh-vault", Verb: "node-key"} + +// VaultOwn is the vault's verb a machine's node-engine asks in its own name for its modules' own secrets sealed to its +// key (issue 1126, ADR 0550 §5); granted as Principal.AsksItsOwnSecrets says. +var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"} + +// VaultSign is the vault's verb a machine's node-engine asks in its own name for its own node's certificate (novox/hq +// ADR 0550 §2); granted as Principal.AsksTheVaultToSign says. +var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb} + +// VaultRotateNode is the vault's verb that begins a node's account rotation; while its holder serves it, every node +// principal reads its own node's key of node-logins (novox/hq ADR 0583 §1, §2). VaultNodeAccountConfirm is the verb a +// node-engine confirms a switch with, in its new login's name (§3). +var ( + VaultRotateNode = SeatVerb{Seat: "mesh-vault", Verb: delivery.RotateNodeVerb} + VaultNodeAccountConfirm = SeatVerb{Seat: "mesh-vault", Verb: delivery.NodeAccountConfirmVerb} +) + +// EnrolmentRedeem is the seat mesh-enrolment's verb a joining node-engine calls as enrol. (novox/hq ADR 0587 §4, +// §6). +var EnrolmentRedeem = SeatVerb{Seat: catalogue.EnrolmentSeat, Verb: catalogue.EnrolmentVerbRedeem} + +// EnrolmentRedeemCall is the one subject an enrolment user may publish once it enrols through the seat: redeem, in its +// own name. +func EnrolmentRedeemCall(p Principal) string { + return "mesh.seat." + EnrolmentRedeem.Seat + "." + CallKind + "." + EnrolmentRedeem.Verb + "." + CallerToken(p.Username()) +} + +// NodeLoginsReads is what a node's engine is granted of the vault's node-logins: its own node's key, read and watched, +// and no other (novox/hq ADR 0583 §1 insight). +func NodeLoginsReads(node string) []string { + if !safeSubject.MatchString(node) { + return nil + } + return readGrants(delivery.NodeLoginsBucket, node) +} + +// perMachineEvents are a node-scoped seat's events about the holder itself, whose last token is the +// holder's machine (novox/hq ADR 0219): `paused.`, the build agent saying whether it takes work. +var perMachineEvents = map[string]bool{"paused.*": true} + +// enrolmentPrefix is the space every enrolling node's user and inbox live under, so the one place the +// controller may answer an enrolment is derived from the same constant the user is named from. +const enrolmentPrefix = "enrol" + +// safeSubject refuses anything that would change the meaning of a subject rather than sit inside +// one. A name carrying a dot would silently widen a permission by adding a token; a name carrying +// `>` or `*` would widen it to a wildcard, which is the whole authority model gone. +var safeSubject = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// Username is how a principal is named to the server. The node is part of it, so the same module +// on two machines holds two users, each sealed to its own — the rule management.go already +// applies, kept. +func (p Principal) Username() string { + switch p.Kind { + case KindPerson: + return "person." + p.Module + case KindModule, KindNodeTools: + // The runtime is named exactly as the module it stands for would have been: the mesh + // issues its credential through the same path a module's takes (`module issue`), and + // that path knows the node and the module, not the kind. + return p.Node + "." + p.Module + case KindProcess: + // One account per process and machine (novox/hq ADR 0436 §2): `..`, + // never the module's own name alone, which is a different account with a different secret. + return p.Node + "." + p.Module + "." + p.Process + case KindNode: + if p.Login != "" { + return p.Login + } + return "node." + p.Node + case KindProvisioner: + // Named for the machine, as a node is: one client per machine, whichever module runs it, and never + // `.`, which is the module's own account or the tool runner's. + return "provisioner." + p.Node + case KindEnrolment: + // Per token, not one shared user. **The inbox is the reason**: with a single `enrolment` + // user every machine enrolling at once could read every other's answer, and an answer + // carries that node's credentials sealed to it. Design 25 §6 says the inbox a token + // derives, and a permission belongs to a user, so the user is per token. + // + // Named after the node, which **is** the token's id: a token is issued for a node record, + // the mesh holds one live claim per record, and the node's name is the one identifier both + // sides already have before anything else is agreed. It is also exactly what the other + // transport does, where the account is named after the node and the secret is its password. + return enrolmentPrefix + "." + p.Node + } + return "" +} + +// AskReportSubject is where the mesh asks one machine's node-engine to say again what it last applied +// (novox/hq to-be 45 §6): its answer is an ordinary report, on its own report subject. +func AskReportSubject(node string) string { return "mesh.node." + node + ".ask.report" } + +// AskHandOverSubject is where the controller's terminal asks one machine's node-engine to hand a directory it +// uses as found to the mesh (novox/hq issue 356, issue 339): a request on core NATS, answered once on the reply +// it carries. Only the controller is granted a publish here (the writers table holds it), but **that is not who +// the engine hears**: the bus lets any principal allowed to answer reply to a message it received, on the reply +// subject that message named, so a message can arrive here from any responder. The ask is therefore signed with +// the mesh's key (link.SignedHandOver), and the engine verifies it before reading anything out of it. +func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask.hand-over" } + +// AskSetuidSearchSubject is where the controller's terminal asks one machine's node-engine to throw its last +// search for setuid programs away and start a full one (novox/hq issue 361): a request answered once, signed as +// a hand-over is (link.SetuidSearchContext), for the same reason. +func AskSetuidSearchSubject(node string) string { return "mesh.node." + node + ".ask.setuid-search" } + +// AskJudgeDirectoriesSubject is where the controller asks one machine's node-engine to judge, read-only, which of +// its declared directories the walk that follows no link another account could make would refuse (novox/hq issue +// 358): a request answered once, signed as a hand-over is (link.JudgeDirectoriesContext), for the same reason. +func AskJudgeDirectoriesSubject(node string) string { + return "mesh.node." + node + ".ask.judge-directories" +} + +// AskMoveSecretSubject is where the controller asks one machine's node-engine to move one value sealed to it into +// the vault (design 57's V3, issue 676): a request answered once, signed as a hand-over is (link.MoveSecretContext), +// for the same reason. +func AskMoveSecretSubject(node string) string { return "mesh.node." + node + ".ask.move-secret" } + +// AskAccessActSubject is where the controller asks one machine's node-engine to run an act on one access's folder (novox/hq +// ADR 0328 §7): a request answered once, signed as a hand-over is (the node-engine's link.AccessActContext). The +// node-engine has subscribed to it since ADR 0325; the grant was missing, so each connect logged a permissions violation. +func AskAccessActSubject(node string) string { return "mesh.node." + node + ".ask.access-act" } + +// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25 +// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's +// inbox is derived from its own identity and its permissions name that prefix and no other. +// +// A node's two logins share the node's inbox (novox/hq ADR 0583 §1): its answers reach it whichever login it dials as. +func (p Principal) inbox() string { + if p.Kind == KindNode { + return "_INBOX.node." + p.Node + ".>" + } + return "_INBOX." + p.Username() + ".>" +} + +// Permissions is what a principal may publish and subscribe, and whether it may answer. +type Permissions struct { + Publish []string + // PublishDeny are subjects refused although an allow covers them: the vault's holder's alone (DeskOnly), + // denied to everybody whose grant is wide enough to reach them. The server's deny outranks its allow. + PublishDeny []string + Subscribe []string + // AllowResponses lets a principal reply to a request it received, on the reply subject that + // request carried, once. + // + // **This is what makes scoped inboxes possible at all**, and design 25 §4 did not say it. If + // every user's inbox is private to it, a module serving a tool cannot publish the answer — + // the answer goes to the *caller's* inbox, which the responder has no permission for. The two + // ways out are granting responders `_INBOX.>`, which is precisely the blanket grant §4 + // refuses, or this: the server itself permits one reply to the subject of a message the user + // actually received, and nothing else. The authority is bounded by having been asked. + AllowResponses bool +} + +// ResponseTTL is how long the bus lets a principal answer a request it received. Its one answer has +// to come inside this, and a seat's holder answers within link.AnswerWithin — inside it by design. +// **A broker reloading its user list forgets every answer it was about to permit**, whatever this +// says (novox/hq issue 265): a call that is still running when the list reloads has its answer +// refused, which is why a holder answers before it does what can reload it. +const ResponseTTL = time.Minute + +// BusSnapshotGrants is the whole authority of the bus's own module (novox/hq ADR 0235): the +// snapshot API and what it needs, and nothing that changes a stream. +// +// **Read-only, and the writers table proves it**: none of these overlaps a subject a write of the +// mesh's state is a publish to — not a stream's definition, not a bucket, not a message. A snapshot is +// the server reading its own blocks out to the asker, while it goes on taking writes; it neither +// pauses nor reconfigures the stream. Named one by one rather than `$JS.API.>`, which would be the +// controller's authority over every stream: +// +// - the stream names, and one stream's information (whether it is on disk at all — a memory stream +// cannot be snapshotted and is said as skipped); +// - the snapshot request itself, for any stream: the archive comes to the asker's own inbox; +// - the acknowledgement the server waits for past its window: a publish to the subject the server +// put on each chunk, `$JS.SNAPSHOT.ACK....`, which only the server's +// own internal subscription hears. +// +// Restoring is not here: a restore creates a stream, which is the controller's to define, and the +// mesh restores into a new store beside the live one, swapped in by a person (to-be 43). +var BusSnapshotGrants = struct{ Publish []string }{Publish: []string{ + "$JS.API.STREAM.NAMES", + "$JS.API.STREAM.INFO.*", + "$JS.API.STREAM.SNAPSHOT.*", + "$JS.SNAPSHOT.ACK.>", +}} + +// PermissionsFor derives a principal's authority. Pure, and the only place authority is decided: +// a permission that cannot be derived from a declaration is a permission nobody can explain. +func PermissionsFor(p Principal) (Permissions, error) { + for _, part := range []struct{ what, value string }{ + {"node", p.Node}, {"module", p.Module}, {"process", p.Process}, + } { + if part.value == "" { + continue + } + if !safeSubject.MatchString(part.value) { + return Permissions{}, fmt.Errorf( + "%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", part.value) + } + } + + if p.Kind == KindModule && p.SnapshotsTheBus { + pub := append([]string(nil), BusSnapshotGrants.Publish...) + sort.Strings(pub) + if err := CheckWriters(p, pub); err != nil { + return Permissions{}, err + } + // Its own inbox for the answers and the archive's chunks, and nothing else: nothing is asked + // of it, so it answers nothing. + return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil + } + + if p.Kind == KindProcess && len(p.Removes) > 0 { + pub, err := BusRemovalGrants(p.Removes, p.FilesFor) + if err != nil { + return Permissions{}, err + } + sort.Strings(pub) + if err := CheckWriters(p, pub); err != nil { + return Permissions{}, err + } + // Its own inbox for the answers, and nothing else (Limits 4). + return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil + } + + var pub, sub []string + switch p.Kind { + case KindProvisioner: + var err error + if pub, sub, err = provisionerGrants(p); err != nil { + return Permissions{}, err + } + + case KindProcess: + var err error + if pub, sub, err = processGrants(p); err != nil { + return Permissions{}, err + } + + case KindPerson: + // Tools, and nothing else. Every subject a person may publish is a tool call; a person + // who could publish an event would be able to claim a module said something. + invoked, err := invokedSubjects(p.Invokes) + if err != nil { + return Permissions{}, err + } + pub = append(pub, invoked...) + // And may ask what answers (novox/hq ADR 0197): a question every service answers about + // itself, its replies to the asker's own inbox. + pub = append(pub, discovering()...) + + case KindEnrolment: + // A leaked token is useless for anything but enrolling: it cannot read a declaration, hear + // an event, or subscribe any inbox but the one its own token derives (design 25 §6). + // + // **The inbox was missing and the handshake could not have completed without it.** An + // enrolling node publishes its request and waits on an address it states in the payload; + // with nothing to subscribe it waits out its timeout against a mesh that answered. Its own + // and no wider: `_INBOX.enrol..>`, so what is sealed to one machine cannot be read by + // another enrolling beside it. + if p.Node == "" { + // Refused rather than composed into `_INBOX.enrol..>`, which is a subject with an empty + // token in it — and worse, one every nameless enrolment user would share. A shared + // enrolment inbox is one machine able to read the credentials sealed to another. + return Permissions{}, errors.New( + "an enrolment user names no node, so its inbox would be shared with every other " + + "enrolment: a token is issued for a node record, and that record's name is " + + "the token's id") + } + // **A node enrols through the seat mesh-enrolment alone** (novox/hq ADR 0587 §4, §6, ADR 0590): its user calls + // redeem in its own name, while the seat's holder serves it. + if !p.Redeems { + return Permissions{}, fmt.Errorf("the enrolment user of %s may enrol nowhere: no holder of %s serves %s", + p.Node, EnrolmentRedeem.Seat, EnrolmentRedeem.Verb) + } + pub = []string{EnrolmentRedeemCall(p)} + sub = []string{p.inbox()} + + case KindNode: + // A host publishes its own node's control traffic and subscribes its own declaration — + // and nothing of any other node's. + // And binding to its consumer, which asks the server about it (CONSUMER.INFO) — the one + // thing the host does that nothing granted. Found the first time a machine dialled a + // permissioned server: "this node cannot read its declarations" (2026-09-28). The ack and + // the inbox are granted below with every principal's. + pub = []string{ + "mesh.control." + p.Node + ".>", + "$JS.API.CONSUMER.INFO.NODES." + p.Node, + } + // The deliver subject carries the stream as well as the consumer's name, so what a + // subscriber is permitted has to carry it too (novox/hq 04-ISSUES/146). The bare name + // stays: an existing consumer keeps delivering where it always did until the controller's + // next assertion moves it, and a permission that only allowed the new shape would refuse + // every node in the mesh for exactly as long as that took. + sub = []string{"mesh.node." + p.Node + ".declare", + "_DELIVER." + p.Node, "_DELIVER." + p.Node + ".>", + // And the mesh asking it to say again what it last applied (novox/hq to-be 45 §6, the + // `report` verb healer H1 asks): its own machine's, on core NATS and off any stream. It + // answers through its report, the one thing it already says — no reply to anybody's inbox. + AskReportSubject(p.Node), + // And the controller's terminal asking it to hand a directory used as found to the mesh (novox/hq + // issue 356), which it answers on the request's reply: the one request a node is asked. + AskHandOverSubject(p.Node), + // And asking it for a fresh search for setuid programs (novox/hq issue 361), answered the same way. + AskSetuidSearchSubject(p.Node), + // And asking it to judge its declared directories, read-only (novox/hq issue 358), answered the same way. + AskJudgeDirectoriesSubject(p.Node), + // And asking it to move a value sealed to it into the vault (design 57's V3, issue 676), answered the + // same way. + AskMoveSecretSubject(p.Node), + // And asking it to run an act on an access's folder (novox/hq ADR 0328 §7), answered the same way. + AskAccessActSubject(p.Node)} + // The node-engine witnesses the core builds it places (novox/hq to-be 45 §8, ADR 0236; the + // contract is lease/witness.go): it asks its own machine's node tools PING, and, where the + // machine runs the controller, reads the lease's one key — read, never written. + pub = append(pub, WitnessSubjects(p)...) + // And the keys of its own node in the have bucket, which it alone writes (novox/hq ADR 0429 §1, issue 1136): + // what the node has of each module, read by the view and the watchman. Never the controller's. + haveKeys, err := NodeKeysGrants(delivery.HaveBucket, p.Node) + if err != nil { + return Permissions{}, err + } + pub = append(pub, haveKeys...) + // And every node's own facts, `.node`, its record among them (issue 1126, ADR 0555 §3): a node agent composing + // its own modules reads the other nodes' public records, which its firewall and resolver need (ADR 0547 §2). Read + // only: each node still writes its own keys alone. + pub = append(pub, readGrants(delivery.HaveBucket, "*."+delivery.HaveNodeWord)...) + // And what it composes its own modules from (ADR 0547 §2, ADR 0567 §9): the seat mesh-placement's placement and + // candidates, the indexer's modules and manifests, the builder's builds (the manifest of a build its want names), and mesh-config's resolved values of + // its own node alone. Read only. + pub = append(pub, NodeComposeReads(p.Node)...) + // And, of the other nodes' resolved values, the keys their modules serve from (issue 1126), each one key. + served, err := ServedReadGrants(p.ServedReads) + if err != nil { + return Permissions{}, err + } + pub = append(pub, served...) + // And an object of the declaration bucket by its name, and nothing of the bucket's list or writing + // (novox/hq ADR 0486 §2, issue 1697). + fileReads, err := DeclarationFilesReads(p.Node) + if err != nil { + return Permissions{}, err + } + pub = append(pub, fileReads...) + // And its own compositions and wants, which it follows (novox/hq ADR 0539 clauses 1, 2 and 9), and nothing of another + // node's. + composedReads, err := ComposedReads(p.Node) + if err != nil { + return Permissions{}, err + } + pub = append(pub, composedReads...) + // And its own machine's backup holder, before a data module moves (ADR 0539 clause 16). + backups, err := NodeAgentAsks(p.Node) + if err != nil { + return Permissions{}, err + } + pub = append(pub, backups...) + // And the backup holder's word that it backed the module up (ADR 0539 clause 16), whichever module holds + // node-backup: one event, by its name. + sub = append(sub, NodeAgentHears...) + // And the creation of the seat mesh-placement's two states it composes from (ADR 0567 §9), so a node agent started + // before they exist binds each when the bus says it was made, with no timer; no other bucket's. + sub = append(sub, NodeComposeAdvisories()...) + // And, while the vault serves it, the vault's move-in in its own name and nobody else's (issue 676): the + // values sealed to this machine, which only it opens, handed in sealed to the vault's key for the call. + if p.MovesIntoTheVault { + pub = append(pub, "mesh.seat."+VaultMoveIn.Seat+"."+CallKind+"."+VaultMoveIn.Verb+"."+CallerToken(p.Username())) + } + // And, while the vault serves it, its node-key in its own name (issue 786): the machine's sealing key, stated + // by the engine that holds it, which the vault takes from no other caller. + if p.StatesItsKeyToTheVault { + pub = append(pub, "mesh.seat."+VaultNodeKey.Seat+"."+CallKind+"."+VaultNodeKey.Verb+"."+CallerToken(p.Username())) + } + // And, while the vault serves it, its modules' own secrets in its own name (issue 1126): sealed to its key, for the + // compositions it makes itself. + if p.AsksItsOwnSecrets { + pub = append(pub, VaultOwnCall(p)) + // And the vault's word that a secret changed (names and generations, never a value), so the node composes again + // on it rather than at the next unrelated change (ADR 0547 §2). + sub = append(sub, VaultSecretChanged) + } + // And, while the vault serves `sign`, its own node's certificate in its own name, and the vault's public signing + // halves, read and watched (ADR 0550 §2). Read only. + if p.AsksTheVaultToSign { + pub = append(pub, VaultSignCall(p)) + pub = append(pub, readGrants(delivery.SignersBucket, "")...) + } + // And, while the vault rotates node accounts, its own node's key of node-logins, read and watched, and no other + // key (novox/hq ADR 0583 §1 insight): the login it dials as; and the confirmation of a switch in its own name (§3). + if p.RotatesItsLogin { + pub = append(pub, NodeLoginsReads(p.Node)...) + pub = append(pub, "mesh.seat."+VaultNodeAccountConfirm.Seat+"."+CallKind+"."+VaultNodeAccountConfirm.Verb+"."+ + CallerToken(p.Username())) + } + // And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3). + ep, es := engineGrants(p) + pub = append(pub, ep...) + sub = append(sub, es...) + // And, on the node holding mesh-broker, the grants part's sealed user list it follows from take-over (novox/hq ADR + // 0547 clause 7): one key, and an object by its name. + if p.FollowsGrantsUserList { + pub = append(pub, GrantsUserListFollows()...) + } + + case KindModule: + // 0. The watchman alone hears who connects and disconnects, and asks who is connected (ADR 0431 §2). + if p.Module == WatchmanModule { + sub = append(sub, BusConnectSubject, BusDisconnectSubject) + pub = append(pub, BusConnzSubject) + // And the dead letters, from its own consumer, never the controller's (novox/hq ADR 0564 clause 5). + pub = append(pub, WatchmanDeadLetterGrants(p.DeadLetterSources)...) + // And the words its watchdogs hear (ADR 0564 clause 4, Limits 3, 11). + sub = append(sub, WatchmanHears()...) + } + // The grants part alone publishes the modules' memberships into the assignments stream (novox/hq ADR 0547 §5; + // the operator, 2026-10-25 22:31): one per subject, `mesh.assignment..`, reading the last one of each + // first so it publishes only what changed, and no stream it defines. + if p.Module == GrantsModule { + pub = append(pub, "mesh.assignment.*.*", "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment.*.*") + // And creating each stream a manifest declares, by its own name (the operator, 2026-10-26 08:54; issue + // 1790): the create and the read of that one stream, never an update, a delete or a stream not declared. + pub = append(pub, CreateStreamGrants(p.CreatesStreams)...) + // And creating each durable consumer an account needs, by its stream and name (ADR 0561 §6). + pub = append(pub, CreateConsumerGrants(p.CreatesConsumers)...) + // And its own object bucket, where it writes the user list sealed to the bus node (ADR 0547 clause 7). + pub = append(pub, GrantsUserListWrites()...) + } + // The view alone reads the build seats' workers for the backlog of asks (issue 1887 A8a). + if p.Module == ViewModule { + pub = append(pub, BuildBacklogReads()...) + } + // The builder alone reads the build seats' streams by sequence, to put again at its start an ask a key names that no + // stream holds (issue 1910). + if p.Module == BuilderModule { + pub = append(pub, BuildAskReads()...) + } + // 1. Its own namespace: it publishes its events there and serves its tools there. Nothing + // else may publish into it, so an event's source is a fact the server enforces rather + // than a claim in the body (design 29 §2). + own := "mesh.mod." + p.Module + for _, e := range p.Emits { + pub = append(pub, own+".event."+e) + } + // And its own streams' events, on their own subjects (novox/hq ADR 0363 §2). + pub = append(pub, ownStreamGrants(p.Module, p.Streams)...) + // Every tool under its own name, not a list: the tools a module serves are what its code + // answers, and a second copy of that list in the manifest would be a second source of + // truth for the mesh to keep in step (2026-09-28: every module that served a tool was + // refused the subscription, because none had written the list twice). Nothing is given + // away — no other principal may subscribe this namespace, and a caller's authority is + // still granted per tool, by name, on the publish side. + sub = append(sub, own+".tool.>") + // It says what it serves (novox/hq ADR 0197): discovery for its own name and every seat it + // holds a verb of, answered by the runtime that serves them. + announced := []string{p.Module} + for _, s := range p.Holds { + if len(s.Serves) > 0 { + announced = append(announced, s.Name) + } + } + sub = append(sub, announcing(announced...)...) + // Its own membership (ADR 0160), from the grants part's state memberships, its own key (novox/hq ADR 0571 clause + // 7): its one read of it, since the assignments stream's direct get went with the stream (research 072 row 33), + // and no longer heard on its subject, where the grants part stops publishing at the lease write (ADR 0571 clause + // 10, issue 774). + pub = append(pub, readGrants(GrantsMembershipsBucket, MembershipKey(p.Node, p.Module))...) + + // 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same + // grant a person gets and derived the same way, so "what may this module ask" is + // answered by the one list that answers it for everybody. Publish only: an answer + // arrives on its own inbox, which every principal has below. + invoked, err := invokedSubjects(p.Invokes) + if err != nil { + return Permissions{}, err + } + pub = append(pub, invoked...) + + // 2. What it consumes, by the emitter's own subject — an event is addressed to its + // emitter, because the emitter's identity is the meaning (ADR 0118). + for _, c := range p.Consumes { + subject, err := consumedSubject(c) + if err != nil { + return Permissions{}, err + } + sub = append(sub, subject) + } + + // 2a. What it hears of a module's own stream, as it was granted (novox/hq ADR 0363 §5): the subjects, + // and its own consumer on that stream, asked about, pulled from and acknowledged. + for _, h := range p.Hears { + if len(h.Filters) == 0 { + continue + } + sub = append(sub, h.Filters...) + } + pub = append(pub, heardGrants(p, p.Hears)...) + + // 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a + // role is hearing what it announced, not taking part in it. + for _, w := range p.Watches { + if w.Kinded { + continue // composed by SeatTrafficOf below + } + for _, e := range w.Emits { + sub = append(sub, seatSubject(w, "event", e)) + } + } + + // 2c. Its own consumer, which it **pulls**: the runtime asks for the next message and is + // answered on its own inbox, so what it needs is to ask about the consumer and to ask it + // for messages — its own consumer's name, and no other's. Pulled rather than pushed + // because that is the one shape a runtime's client binds without creating anything; the + // controller and the hosts are pushed to. Named here rather than through ConsumerFor, + // which asks for these permissions to build the consumer and would ask forever. A + // subject for a consumer that turns out not to exist grants nothing anybody can use. + pub = append(pub, + "$JS.API.CONSUMER.INFO."+consumerStream(p)+"."+consumerDurable(p), + "$JS.API.CONSUMER.MSG.NEXT."+consumerStream(p)+"."+consumerDurable(p)) + + // 3. Seats it holds: full participation. + for _, s := range p.Holds { + if s.isNewTraffic() { + // Composed by SeatTrafficOf below, worker and all; only its tools are served here. + for _, t := range s.Serves { + sub = append(sub, seatToolSubject(s, t, p.Node)) + } + continue + } + // Taking work from the role's queue: the worker consumer every holder shares (asked + // about, pulled from, acknowledged), on the seat's own stream (novox/hq ADR 0190). A + // holder pulls — asks the consumer for its next message, answered on its own inbox — + // so what it needs is MSG.NEXT on that worker and nothing delivered to it. The first + // machine to take work over the new bus was refused the asking (2026-09-28). + worker := "SEAT_" + upperSnake(s.Name) + "_worker" + stream := seatStreamName(s.Name) + pub = append(pub, + "$JS.API.CONSUMER.INFO."+stream+"."+worker, + "$JS.API.CONSUMER.MSG.NEXT."+stream+"."+worker, + "$JS.ACK."+stream+"."+worker+".>") + for _, a := range s.Accepts { + sub = append(sub, seatSubject(s, "accept", a)) + } + for _, e := range s.Emits { + // **A machine says its own state and no other's** (novox/hq ADR 0219): on a node-scoped + // seat, an event about the holder itself carries the machine as its last token, and + // each holder is granted its own machine's alone. + if s.Scope == "node" && p.Node != "" && perMachineEvents[e] { + pub = append(pub, seatSubject(s, "event", strings.TrimSuffix(e, "*")+p.Node)) + continue + } + pub = append(pub, seatSubject(s, "event", e)) + } + for _, t := range s.Serves { + sub = append(sub, seatToolSubject(s, t, p.Node)) + } + } + + // 4. Seats it uses: publish only, and only the accepts half. A caller cannot subscribe a + // seat's inbound subject and watch other modules' traffic, nor publish its outbound + // events and lie about outcomes (design 29 §2). + for _, s := range p.Uses { + for _, a := range plainVerbs(s, s.Accepts) { + pub = append(pub, seatSubject(s, "accept", a)) + } + for _, t := range s.Serves { + pub = append(pub, seatToolSubject(s, t, "*")) + } + } + + // 5. Its state, and the state of others it reads (novox/hq ADR 0201): every one read and + // watched, its own written too. + kept := stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State, PerMachine: p.PerMachine, Reads: p.Reads, + KeyedReads: p.KeyedReads} + pub = append(pub, stateGrants(kept)...) + // And it hears each of those buckets created, so one not on the bus at its start is bound when it appears + // (novox/hq issue 1790; mesh-sdk process.AwaitBucket). + sub = append(sub, stateCreatedHeard(kept)...) + // And its logs (novox/hq ADR 0297): appended to and read directly, its own alone. + pub = append(pub, logGrants(p.Module, p.Logs)...) + pub = append(pub, readLogGrants(p.ReadLogs)...) + + // 6. Its traffic on seats that name their caller or their kind, ask proofs or keep records + // (novox/hq ADR 0259 §3). + tp, ts := seatTrafficGrants(SeatTrafficOf(p.Module, p.Holds, p.Uses, p.Watches)) + pub = append(pub, tp...) + sub = append(sub, ts...) + + case KindNodeTools: + // **One process serves what every module on the machine would have served for itself** + // (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that + // module's own principal has, for the same reason: the tools a module serves are what its + // code answers, and a list here would be a second copy of it. Each held seat's verbs on + // this node, as the holder's own principal would be granted them. + var serves []string + for _, d := range p.Carries { + if !safeSubject.MatchString(d.Module) { + return Permissions{}, fmt.Errorf( + "%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", d.Module) + } + serves = append(serves, d.Module) + for _, s := range d.Holds { + serves = append(serves, s.Name) + } + own := "mesh.mod." + d.Module + sub = append(sub, own+".tool.>") + // A tool that emits an event is the module's code and emits under the module's name + // (ADR 0042); the runtime carrying that code may publish what the module declared it + // emits, and nothing it did not. + for _, e := range d.Emits { + pub = append(pub, own+".event."+e) + } + // And its own streams' events (novox/hq ADR 0363 §2). + pub = append(pub, ownStreamGrants(d.Module, d.Streams)...) + for _, s := range d.Holds { + for _, t := range s.Serves { + sub = append(sub, seatToolSubject(s, t, p.Node)) + } + } + } + // Every assigned module's membership on this node (ADR 0160): one per module, read and watched from the + // grants part's state memberships (ADR 0571 clause 7), and no longer heard on `mesh.assignment`, where the grants + // part stops publishing at the lease write (ADR 0571 clause 10, issue 774). This node's and no other's — the + // one token that varies is the module, so the pattern is the machine's own assignments. + pub = append(pub, readGrants(GrantsMembershipsBucket, p.Node+".*")...) + // And that it is there (novox/hq to-be 45 §3, S11): its own heartbeat, under its machine's + // name and no other's, on core NATS like the host's. + pub = append(pub, "mesh.control."+p.Node+".tools-alive") + // And every tool on the mesh (ADR 0175, decision 5): any node may call any tool on any + // node, as the console already could — the runtime is the console's serving mode. + invoked, err := invokedSubjects([]string{"*"}) + if err != nil { + return Permissions{}, err + } + pub = append(pub, invoked...) + // And its own machine's provisioner client, for a carried module's config and non-secret bound values, + // naming the module from the bundle that asks (novox/hq ADR 0370 §3, §4): the ask on its own machine's + // subject alone, and what the client says moved, so a module's code's watches are asked again. + pub = append(pub, ProvisionerAskSubject(p.Node)) + sub = append(sub, "prov.changed."+p.Node+".>") + // It says what it serves and may ask what answers (novox/hq ADR 0197): the runtime answers + // discovery for each module and seat it carries, and the console it is asks the bus. + // One service per runtime process, named for the runtime: the bus lets a principal answer each + // request once, so the runtime announces everything it carries under its own name. + sub = append(sub, announcing(append([]string{RuntimeModule}, serves...)...)...) + pub = append(pub, discovering()...) + // **And it consumes for the modules it carries** (novox/hq ADR 0198, which changes ADR 0175's + // "it consumes nothing"): a module's long-running code is a bundle this runtime launches, and + // the runtime is its bus — it reads the module's own durable consumer and acknowledges what + // the module's code took. Exactly the grants the module's own principal has for that consumer, + // on its name and no other's: asking about it, pulling from it, acknowledging it. The + // consumer is still the controller's to make, from the module's own principal. + for _, d := range p.Carries { + own := Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits, + Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, + Streams: d.Streams, Hears: ComposedHears(d)} + // And the module streams it hears, each through its own consumer there (novox/hq ADR 0363 §8) — never + // a restricted one, which only a module on an account of its own hears (ADR 0376 §1). + pub = append(pub, heardGrants(own, own.Hears)...) + if _, consumes := ConsumerFor(own); !consumes { + continue + } + stream, durable := consumerStream(own), consumerDurable(own) + pub = append(pub, + "$JS.API.CONSUMER.INFO."+stream+"."+durable, + "$JS.API.CONSUMER.MSG.NEXT."+stream+"."+durable, + "$JS.ACK."+stream+"."+durable+".>") + } + // **And it keeps and reads state for the modules it carries** (novox/hq ADR 0201): the union + // of what each may do with a bucket — an owner's write, a reader's read. That one module's code + // does not write another's bucket through it is the runtime's to keep, from the membership + // each assignment is issued, as it keeps each module's events under that module's own name. + for _, d := range p.Carries { + pub = append(pub, stateGrants(stateAccess{Module: d.Module, Node: p.Node, Keeps: stateNames(d.State), + PerMachine: perMachineNames(d.State), Reads: d.Reads, KeyedReads: d.KeyedReads})...) + } + // **And it keeps the logs of the modules it carries** (novox/hq ADR 0297): each module's own, the + // union over them. That one module's code does not append to another's log through it is the + // runtime's to keep, from the logs each membership lists. + for _, d := range p.Carries { + pub = append(pub, logGrants(d.Module, logNames(d.Logs))...) + pub = append(pub, readLogGrants(d.ReadLogs)...) + } + // **And the event families a session may wait on** (novox/hq ADR 0367 §2): the tool runner's module's own, + // named one by one in its manifest, as a plain subscription — no consumer is made for them, and no + // call or setting adds one. + waits, err := waitGrants(p.Carries) + if err != nil { + return Permissions{}, err + } + sub = append(sub, waits...) + // **Never the traffic of a trusted holder** (novox/hq ADR 0259 §8): the machine's runtime runs as the + // operator's account, which every agent runs as, so a module saying warrants or speaking for a kind + // that proves its sender is never composed into it — refused here, naming it, whatever registration + // let through. + for _, d := range p.Carries { + if why := trustedTraffic(d); why != "" { + return Permissions{}, fmt.Errorf("%s on %s is carried by the machine's runtime, and %s: it runs "+ + "as an account of its own, never the runtime's (novox/hq ADR 0259)", d.Module, p.Node, why) + } + } + // **And the seat traffic of the modules it carries** (novox/hq ADR 0259 §3): a bundle reaches the + // bus only through its runtime, so the runtime is granted the union. That one module's code does + // not publish under another's name or kind through it is the runtime's to keep, from the seat + // traffic each module's membership lists. + for _, d := range p.Carries { + tp, ts := seatTrafficGrants(SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)) + pub = append(pub, tp...) + sub = append(sub, ts...) + } + sub = unique(sub) + pub = unique(pub) + } + + if p.Kind == KindPerson { + // An inbox to hear answers in, and nothing else. No ack subject: a person has no durable + // consumer, because nothing is delivered to a person — they ask and are answered. + sub = append(sub, p.inbox()) + } + if p.Kind == KindNodeTools { + // Its reply space, so the answers to what its tools call come back to it. No ack subject + // for the same reason a person has none: nothing is delivered to it. + sub = append(sub, p.inbox()) + } + if p.Kind == KindProcess { + // Its own reply space, and nothing wider (novox/hq ADR 0436 §1, §3): a process's queue consumer is + // pulled and acknowledged by publishing, granted above with its own stream and consumer name, never + // a generic EVENTS consumer's — consumerStream/consumerDurable below are a module's own, not a + // process's. + sub = append(sub, p.inbox()) + } + + if p.Kind == KindModule || p.Kind == KindNode { + // Its own reply space, and nothing wider. + sub = append(sub, p.inbox()) + + // Acking a JetStream delivery is a publish to that consumer's own ack address — a + // different subject from anything the consumer subscribes. Without it every message a + // module received would be redelivered forever, refused by the permission list it already + // has (design 25 §4). Scoped to this principal's own consumer name, so it can ack its own + // deliveries and no other's. + pub = append(pub, "$JS.ACK."+consumerStream(p)+"."+consumerDurable(p)+".>") + } + + // **And every tool grant again, naming its caller** (novox/hq issue 365): a call in this principal's own + // name, and an answer to a call naming anybody. The `tool` grants above stay beside these, so callers and runtimes + // move without a gap; their retirement is hq issue 464. + // The vault's calls in its own name (issue 723), kept apart so the desk's grant is not denied below. + var vaultsOwn []string + pub, sub = callerNamed(p.Username(), pub, sub) + if p.Kind == KindModule { + vaultsOwn = vaultCalls(p) + pub = append(pub, vaultsOwn...) + } + + sort.Strings(pub) + sort.Strings(sub) + // One writer per piece of state (novox/hq to-be 45 §1): a grant that would make a second is + // refused here, at composition, naming the state and its writer. + if err := CheckWriters(p, pub); err != nil { + return Permissions{}, err + } + // What the vault's holder alone may publish is denied to everybody else whose grant reaches it. + var deny []string + for _, only := range DeskOnly() { + for _, a := range pub { + if slices.Contains(vaultsOwn, a) { + continue + } + if SubjectsOverlap(a, only) { + deny = append(deny, only) + break + } + } + } + // And the vault's enrolment verbs to every principal but mesh-enrolment's (novox/hq ADR 0587 §1). + if p.VaultEnrols && !((p.Kind == KindModule || p.Kind == KindProcess) && p.Module == delivery.EnrolmentModule) { + for _, only := range EnrolmentOnly() { + if slices.ContainsFunc(pub, func(a string) bool { return SubjectsOverlap(a, only) }) { + deny = append(deny, only) + } + } + } + return Permissions{ + Publish: pub, + PublishDeny: deny, + Subscribe: sub, + // A module answers what it was asked — a tool call reaches it on its own namespace, so the + // authority is bounded by having been asked. A node is asked one + // thing, a hand-over on its own subject (novox/hq issue 356), and answers that: the node is its + // machine's engine, root there already, and it is delivered only its own subjects. What it answers is + // never trusted for being an answer. A + // provisioner client is asked by its own machine's tool runner alone, on its own machine's subject (novox/hq + // ADR 0370 §3), and answers that. A person is never asked anything, and is granted nothing here. + AllowResponses: p.Kind == KindModule || p.Kind == KindNodeTools || p.Kind == KindNode || + p.Kind == KindProvisioner, + }, nil +} + +// vaultCalls are the calls the vault's holder makes in its own name (novox/hq ADR 0349 clauses 9 and 10, issue +// 723): the desks' secret prompt on any machine, and each tool that answers a credential its provider's software +// issued, on any machine — each on the subject whose last token is the vault's own name, which the bus lets it +// alone publish, so the prompt and the tool know the vault asks. Nothing else outside its seat. +func vaultCalls(p Principal) []string { + token := CallerToken(p.Username()) + if token == "" { + return nil + } + var out []string + if p.AsksTheDesk { + for _, v := range VerbsTheVaultAsksForASecret { + out = append(out, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".*."+token) + } + } + for _, key := range p.AsksIssued { + module, tool, ok := strings.Cut(key, ".") + if !ok || module == "" || tool == "" || strings.ContainsAny(key, "*> ") { + continue + } + out = append(out, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+token) + } + return out +} + +// seatSubject places a seat's verb under the kind of traffic it is. +// +// **The kind token is load-bearing, not decoration.** A stream is defined by a subject filter, so +// without it a stream over a seat or a module's namespace would capture that namespace's *tool* +// traffic too — and a tool call must never be persisted (design 25 §3: tools stay on core NATS). +// Found while defining the streams: the first draft of design 29 had one namespace per module +// with no kind, which reads well and cannot be filtered. +// +// A seat serving more than its current protocol version carries the version as a token +// (design 29 §8): the seat stays one role, and v1 and v2 run beside each other until nothing is +// bound to the old one. +func seatSubject(s Seat, kind, verb string) string { + return "mesh.seat." + s.Name + "." + kind + "." + verb +} + +// seatToolSubject is where a role's tool is asked. Mesh-wide for a mesh-scoped seat; a node-scoped +// seat carries the node it is asked of, because a flat subject would reach every machine's holder +// and the queue group would silently pick a winner (novox/hq ADR 0132, design 33 §4). A holder +// subscribes its own node's; a user publishes any node's (`*`) and names the machine in the subject. +func seatToolSubject(s Seat, verb, node string) string { + base := seatSubject(s, "tool", verb) + if s.Scope == "node" && node != "" { + return base + "." + node + } + return base +} + +// consumerStream and consumerDurable are the two halves of a consumer's identity, and they are +// two functions because conflating them was a real bug. +// +// **A durable name may not contain a dot; an ack subject is built from two names that do.** The +// server acknowledges on `$JS.ACK...…`, so a single string "EVENTS.one_audit" +// reads correctly inside the permission and is rejected as a consumer name — *nats: invalid +// consumer name*. Caught against a running server, and worth the comment because the shape of +// the failure if it had not been is the one design 25 §4 warns about: a consumer that cannot ack +// has every message redelivered forever, and its permission list looks right while it happens. +// +// They are derived here, beside the permission that must match them, because two places deriving +// the same name is how a module ends up unable to ack its own deliveries. +// consumedSubject is where a consumed event lands, from the local pattern a module declared. +// +// **The mesh's wildcards become this transport's** (design 29 §1): `*` is one name on both, and `**` +// — the rest — is `>` here. A module writes neither transport's spelling, so a manifest stays correct +// when the wire changes, which is the whole reason names are local. +// +// `**` on its own is every event from every module: the emitter is any, the event is anything. An +// audit logger wants exactly that and says so in one token. +func consumedSubject(pattern string) (string, error) { + if pattern == catalogueTheRest { + return "mesh.mod.*.event.>", nil + } + emitter, event, named := strings.Cut(pattern, ".") + if !named || emitter == "" || event == "" { + return "", fmt.Errorf( + "%q does not name an emitter and an event: a consumed event is ., or "+ + "%q for every event", pattern, catalogueTheRest) + } + if emitter == catalogueTheRest { + return "", fmt.Errorf("%q stands for the rest of a name, so it cannot name the emitter", catalogueTheRest) + } + // Each name is checked before it becomes a subject: a name carrying a dot would add a token and + // silently widen the permission, which is the whole reason safeSubject exists. + var out []string + for _, part := range strings.Split(event, ".") { + switch part { + case catalogueTheRest: + out = append(out, ">") + case "*": + out = append(out, "*") + default: + if !safeSubject.MatchString(part) { + return "", fmt.Errorf("%q cannot be part of a subject: it would widen the permission", part) + } + out = append(out, part) + } + } + if emitter != "*" && !safeSubject.MatchString(emitter) { + return "", fmt.Errorf("%q cannot name an emitter: it would widen the permission", emitter) + } + return "mesh.mod." + emitter + ".event." + strings.Join(out, "."), nil +} + +// catalogueTheRest is the mesh's wildcard for "the rest of a name", duplicated from the catalogue +// package for the one direction of dependency the build queue's name is duplicated for. +const catalogueTheRest = "**" + +func consumerStream(p Principal) string { + switch p.Kind { + case KindModule: + return "EVENTS" + case KindNode: + return "NODES" + } + return "" +} + +func consumerDurable(p Principal) string { + switch p.Kind { + case KindModule: + return p.Node + "_" + p.Module + case KindNode: + return p.Node + } + return "" +} + +// Server is everything the composed file needs that is not a principal. +type Server struct { + // ClientPort carries TLS itself. There is no plaintext port beside it: a bus reachable + // without TLS is one a module can reach without TLS by mistake. + ClientPort int + MonitoringPort int + TLSCert string + TLSKey string + TLSCA string + // StoreDir is a host directory bind, not a named volume — issue 115 is resolved and converted + // four modules away from named volumes; the bus's own data is not the place to bring one back. + StoreDir string +} + +// Compose renders the server's whole configuration. The order is stable and the output is +// deterministic, because the file's digest is what the module's entrypoint watches to decide +// whether to reload: a composer that reordered a map on each run would signal a reload every time +// the controller restarted, for a file that had not changed. +func Compose(s Server, principals []Principal) (string, error) { + sorted := append([]Principal(nil), principals...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Username() < sorted[j].Username() }) + + var b strings.Builder + b.WriteString("# Composed by the mesh. Do not edit: the next composition overwrites it.\n") + b.WriteString("# Accounts and permissions are derived from what each module declares and nothing\n") + b.WriteString("# else (novox/hq ADR 0043, design 29 §2).\n\n") + + fmt.Fprintf(&b, "port: %d\n", s.ClientPort) + fmt.Fprintf(&b, "http: 127.0.0.1:%d\n\n", s.MonitoringPort) + + // **No `verify`, and it said `verify: true` until this configuration was run.** That setting + // makes the server demand a *client* certificate, and nothing in the mesh presents one: a host + // pins this server's exact certificate and authenticates with the password the mesh minted + // (ADR 0004, design 25 §4), and so does a module's runtime. With it on, every connection in the + // mesh is refused at the TLS handshake before any password is looked at, and the error — + // "client didn't provide a certificate" — reads as a fault in the client. + // + // TLS is still required: the block is what requires it, and verify only decides whether client + // certificates are checked. + b.WriteString("tls {\n") + fmt.Fprintf(&b, " cert_file: %q\n", s.TLSCert) + fmt.Fprintf(&b, " key_file: %q\n", s.TLSKey) + fmt.Fprintf(&b, " ca_file: %q\n", s.TLSCA) + b.WriteString("}\n\n") + + b.WriteString("jetstream {\n") + fmt.Fprintf(&b, " store_dir: %q\n", s.StoreDir) + b.WriteString("}\n\n") + + accounts, err := ComposeAccounts(sorted) + if err != nil { + return "", err + } + b.WriteString(accounts) + return b.String(), nil +} + +// ComposeAccounts is the accounts block alone — every user, and nothing about the server. +// +// **This is the only part of the configuration the mesh writes, and the split is deliberate.** A +// server's ports, its TLS paths and its store directory are properties of the container the module +// raises: they live in its image and its mounts, and they change when it does. The controller has no +// business knowing them, and a controller that did would have to be kept in step with a Dockerfile +// it never sees. What only the mesh knows is *who may connect*, so that is what it writes, and the +// module's own configuration includes it. +// +// Four things checked against a running server before this shape was committed to: a user in an +// included file authenticates; an unknown user is refused, so the include is the whole authority +// rather than an addition to something; a publish outside a user's grant is refused; and rewriting +// this file alone and signalling a reload makes a new user appear **without dropping the connection +// the mesh already has** — which is what makes every later account, permission or person change cost +// nothing (task 1.2's payoff). +func ComposeAccounts(principals []Principal) (string, error) { + sorted := append([]Principal(nil), principals...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Username() < sorted[j].Username() }) + + var b strings.Builder + b.WriteString("# The mesh's users, composed by the grants part. Do not edit: the next\n") + b.WriteString("# composition overwrites it. Permissions are derived from what each module\n") + b.WriteString("# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\n") + + // One account for the mesh: accounts in NATS isolate subject spaces entirely, and the mesh is + // one space (design 25 §4). The cost of that — that permissions are the only isolation — is + // paid in the scoping of every inbox and every ack subject. + // JetStream is enabled per account once accounts exist at all: with only the global block set, + // a user in MESH is told "JetStream not enabled for account" the first time it binds a + // consumer, which is the first thing every host does (2026-09-28). + b.WriteString("accounts {\n MESH {\n jetstream: enabled\n") + b.WriteString(meshSystemImports) + b.WriteString(" users = [\n") + for _, p := range sorted { + perms, err := PermissionsFor(p) + if err != nil { + return "", err + } + if p.PasswordHash == "" { + return "", fmt.Errorf("%s has no password hash: a user without one is a user anybody is", p.Username()) + } + b.WriteString(AccountsEntry(p.Username(), p.PasswordHash, perms)) + } + b.WriteString(" ]\n }\n") + b.WriteString(systemAccount) + b.WriteString("}\n") + return b.String(), nil +} + +// AccountsEntry is one user of the accounts block, as ComposeAccounts writes each: its name, its password hash and its +// permissions, in the lines the bus reads. Exported so a writer that keeps a user from grants it holds rather than +// composes (mesh-grants' refused users) writes the same lines, never a copy of them (issue 774). +func AccountsEntry(user, passwordHash string, perms Permissions) string { + var b strings.Builder + fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", user, passwordHash) + if len(perms.PublishDeny) > 0 { + fmt.Fprintf(&b, " publish: { allow: [%s], deny: [%s] }\n", quoted(perms.Publish), quoted(perms.PublishDeny)) + } else { + fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish)) + } + fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe)) + if perms.AllowResponses { + fmt.Fprintf(&b, " allow_responses: { max: 1, ttl: \"%dm\" }\n", int(ResponseTTL/time.Minute)) + } + b.WriteString(" } }\n") + return b.String() +} + +// WatchmanModule is the one module whose principal hears the bus's connect and disconnect events and asks its list of +// connections (novox/hq ADR 0431 §2, issue 1147): the watchman of the repository mesh-delivery, which writes presence +// from them. No other principal is granted the subjects below, and nothing is a wildcard over them. +const WatchmanModule = "mesh-delivery-watchman" + +// CreateStreamGrants is what creating each named stream needs, and nothing wider (novox/hq ADR 0547 §5; the operator, +// 2026-10-26 08:54): its create, and its information to read back what it holds. Each by its own name: a name that +// is not one plain subject token is left out, since a wildcard there would be every stream. +func CreateStreamGrants(streams []string) []string { + var out []string + for _, s := range streams { + if !safeSubject.MatchString(s) { + continue + } + out = append(out, "$JS.API.STREAM.CREATE."+s, "$JS.API.STREAM.INFO."+s) + } + return out +} + +// CreateConsumerGrants are the subjects that create each consumer by its stream and name, and read its info back: the +// create of the client library's CreateConsumer for a consumer with no filter or several, never one filter's form (a +// wildcard in a filter would widen the grant), an update or a delete. +func CreateConsumerGrants(consumers []ModuleConsumer) []string { + var out []string + for _, mc := range consumers { + c := mc.Consumer + if !safeSubject.MatchString(c.Stream) || !safeSubject.MatchString(c.Name) || len(c.Filters) == 1 { + continue + } + out = append(out, "$JS.API.CONSUMER.CREATE."+c.Stream+"."+c.Name, "$JS.API.CONSUMER.INFO."+c.Stream+"."+c.Name) + } + return out +} + +// GrantsModule is the one module whose principal publishes the modules' memberships (novox/hq ADR 0547 §5): the grants +// part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here. +const GrantsModule = "mesh-grants" + +// The grants part's state memberships (novox/hq ADR 0571 clause 7): one key per assignment, `.`, its +// membership (Membership) bare, as the assignments stream carries it, written only when it differs. A module's runtime +// reads its own key, the tool runner its node's; the grants part alone writes it. Both sides go through +// membership.EncodeGrantsMembership and membership.DecodeGrantsMembership (or BareGrantsMembership for a reader with a +// type of its own), never json of a type of their own: the grants part once wrote its wrapper here and every reader +// served nothing (issue 774). The decoder still reads that wrapper, for the transition. +const ( + GrantsMembershipsState = "memberships" + GrantsMembershipsBucket = GrantsModule + "_" + GrantsMembershipsState + GrantsMembershipsRead = GrantsModule + "." + GrantsMembershipsState +) + +// MembershipKey is an assignment's key in the grants part's memberships: `.`. +func MembershipKey(node, module string) string { return node + "." + module } + +// The bus's own events, imported into MESH from the default system account `$SYS` under subjects of MESH's own +// (novox/hq ADR 0431 §1, issue 1147): the server says an account's connections opening and closing only to the system +// account, and the list of an account's connections only to a request made there. `$SYS` holds no user, so nobody +// gains its other requests and events; it exports exactly these three, to MESH alone. +const ( + BusConnectSubject = "mesh.bus.connect" + BusDisconnectSubject = "mesh.bus.disconnect" + BusConnzSubject = "mesh.bus.connz" +) + +// meshSystemImports is what MESH imports from $SYS: the two event streams and the one service. +const meshSystemImports = " imports = [\n" + + " { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.CONNECT\" }, to: \"" + BusConnectSubject + "\" }\n" + + " { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.DISCONNECT\" }, to: \"" + BusDisconnectSubject + "\" }\n" + + " { service: { account: \"$SYS\", subject: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\" }, to: \"" + BusConnzSubject + "\" }\n" + + " ]\n" + +// systemAccount is the account `$SYS`, the server's default system account by its own name (a config that declares an +// account of that name makes it the system account), with no user and the three exports to MESH. Quoted, because an +// unquoted `$` begins a variable in the server's config grammar. +const systemAccount = " \"$SYS\" {\n" + + " exports = [\n" + + " { stream: \"$SYS.ACCOUNT.MESH.CONNECT\", accounts: [\"MESH\"] }\n" + + " { stream: \"$SYS.ACCOUNT.MESH.DISCONNECT\", accounts: [\"MESH\"] }\n" + + " { service: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\", accounts: [\"MESH\"] }\n" + + " ]\n" + + " }\n" + +// unique is a sorted list with each subject once. Two carried modules holding seats with the same +// verb, or the runtime module itself carried beside the others, would otherwise write a grant twice +// — harmless to the server, and noise in a file that is read as the mesh's authority model. +func unique(values []string) []string { + sort.Strings(values) + out := values[:0] + for i, v := range values { + if i == 0 || v != values[i-1] { + out = append(out, v) + } + } + return out +} + +func quoted(values []string) string { + if len(values) == 0 { + return "" + } + out := make([]string, len(values)) + for i, v := range values { + out[i] = fmt.Sprintf("%q", v) + } + return strings.Join(out, ", ") +} + +// invokedSubjects is the publish side of a grant to call tools: one subject per `.`, +// or the whole tool namespace for `*`. A person's authority and a module's `invokes` are both this +// (novox/hq ADR 0152), so a malformed entry is refused in one place, before it could be widened into +// something that happens to parse. +func invokedSubjects(invokes []string) ([]string, error) { + var out []string + for _, t := range invokes { + if t == "*" { + // Every module's tools and every role's (novox/hq ADR 0132): a role's verb is a tool + // like any other, addressed to the seat instead of a module. + out = append(out, "mesh.mod.*.tool.>", "mesh.seat.*.tool.>") + continue + } + if rest, isSeat := strings.CutPrefix(t, "seat:"); isSeat { + // A role's tool, `seat:.`. Both address shapes, because the grant is + // written without knowing the seat's scope: a mesh seat's verb is flat and a node + // seat's carries the machine (design 33 §4). + seat, verb, ok := strings.Cut(rest, ".") + if !ok || seat == "" || verb == "" { + return nil, fmt.Errorf( + "%q does not name a role's tool: one invokes seat:.", t) + } + out = append(out, "mesh.seat."+seat+".tool."+verb, "mesh.seat."+seat+".tool."+verb+".*") + continue + } + module, tool, ok := strings.Cut(t, ".") + if !ok || module == "" || tool == "" { + return nil, fmt.Errorf( + "%q does not name a tool: one invokes ., seat:., or * for every one", t) + } + // Both ways a module's tool is addressed (novox/hq ADR 0159): to whichever instance + // answers, and to the instance on one machine, which is the same subject with the machine + // as its last token. + out = append(out, "mesh.mod."+module+".tool."+tool, "mesh.mod."+module+".tool."+tool+".*") + } + return out, nil +} + +// announcing is what a principal that serves tools subscribes to answer the NATS services +// protocol's discovery (novox/hq ADR 0197): the questions asked of every service, and those asked of +// each name it serves — its own and no other's, so it cannot answer for a service it is not. +func announcing(names ...string) []string { + out := []string{"$SRV.PING", "$SRV.INFO", "$SRV.STATS"} + for _, n := range names { + if !safeSubject.MatchString(n) { + continue + } + for _, verb := range []string{"PING", "INFO", "STATS"} { + out = append(out, "$SRV."+verb+"."+n, "$SRV."+verb+"."+n+".>") + } + } + return out +} + +// discovering is what a principal publishes to ask what answers (novox/hq ADR 0197): the services +// protocol's discovery requests, whose replies come to its own inbox. +func discovering() []string { + return []string{"$SRV.PING", "$SRV.PING.>", "$SRV.INFO", "$SRV.INFO.>"} +} + +// WitnessSubjects are the subjects a machine's node-engine publishes to witness the core builds it +// places (novox/hq ADR 0236): its own node tools' PING. The answers come to the machine's own inbox. +func WitnessSubjects(p Principal) []string { + return append([]string{pingSubject(p.Node)}, CheckSubjects(p)...) +} + +// CheckSubjects are the tools a machine's node-engine asks as declared health checks (novox/hq ADR 0240, +// to-be 48 §3): each `.` on this machine's instance — `mesh.mod..tool..` +// — and never the plain subject, which any machine's instance may answer. Sorted and once each. +func CheckSubjects(p Principal) []string { + seen := map[string]bool{} + var out []string + for _, c := range p.Checks { + module, tool, ok := strings.Cut(c, ".") + if !ok || !safeSubject.MatchString(module) || !safeSubject.MatchString(tool) { + continue + } + subject := "mesh.mod." + module + ".tool." + tool + "." + p.Node + if !seen[subject] { + seen[subject] = true + out = append(out, subject) + } + } + sort.Strings(out) + return out +} + +// pingSubject is a node-engine's witness's ping of its own node's tools (novox/hq ADR 0236). +func pingSubject(node string) string { return "$SRV.PING." + nodeToolsService + "." + node } + +const nodeToolsService = "node-tools" + +// VaultOwnCall is the subject a machine principal's node-engine asks the vault's `own` on, in its own name. +func VaultOwnCall(p Principal) string { + return "mesh.seat." + VaultOwn.Seat + "." + CallKind + "." + VaultOwn.Verb + "." + CallerToken(p.Username()) +} + +// VaultSignCall is the subject a machine principal's node-engine asks the vault's `sign` on, in its own name. +func VaultSignCall(p Principal) string { + return "mesh.seat." + VaultSign.Seat + "." + CallKind + "." + VaultSign.Verb + "." + CallerToken(p.Username()) +} + +// VaultSecretChanged is the vault's event that a secret changed (a name and its generation, never a value), which a +// node composing its own hears to compose again (issue 1126, ADR 0547 §2). +var VaultSecretChanged = "mesh.mod." + VaultOwn.Seat + ".event.secret-changed" diff --git a/modules/mesh-grants/internal/buscompose/onnats.go b/modules/mesh-grants/internal/buscompose/onnats.go new file mode 100644 index 0000000..ee39eda --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/onnats.go @@ -0,0 +1,5 @@ +package buscompose + +// NATSVar names the environment variable that says the controller is on the bus (mesh-controller internal/broker +// onnats.go); kept with the composer for the readiness it composes. +const NATSVar = "MESH_BUS_NATS" diff --git a/modules/mesh-grants/internal/buscompose/process_grants.go b/modules/mesh-grants/internal/buscompose/process_grants.go new file mode 100644 index 0000000..99c7b1d --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/process_grants.go @@ -0,0 +1,56 @@ +package buscompose + +// The bus account of one process of a module that declares bus (novox/hq ADR 0436 §1, §2, §3, §5). +// +// **Never the module's own account's grants, never the tool runner's.** A process's authority is read +// straight from its bus declaration — its emits, its queue's consumer, its state, whether it reads the +// records of its module's own asks, and the tools and seat verbs it calls — and nothing else: it holds no +// seat, serves no tool, hears no module stream, and answers nothing a caller asked (§3). Modelled on +// provisionerGrants, the other kind of process account the mesh mints (ADR 0349 clauses 6 and 11). + +// processGrants is one process's whole authority: to publish, and to subscribe. +func processGrants(p Principal) (pub, sub []string, err error) { + // 1. Its own events, under its module's own namespace — the same subjects the module's own principal + // would publish them on, had it declared them there instead (novox/hq ADR 0436 §1). + own := "mesh.mod." + p.Module + for _, e := range p.Emits { + pub = append(pub, own+".event."+e) + } + + // 2. The one durable consumer of each work-queue stream its bus names (novox/hq ADR 0436 §1, §2, §6): + // asked about, pulled from and acknowledged, by the same name ConsumersOf composes the consumer under + // (QueueConsumer) — and no other stream's. + for _, q := range p.Queues { + consumer := p.Node + "_" + p.Module + "_" + q.Process + pub = append(pub, + "$JS.API.CONSUMER.INFO."+q.Stream+"."+consumer, + "$JS.API.CONSUMER.MSG.NEXT."+q.Stream+"."+consumer, + "$JS.ACK."+q.Stream+"."+consumer+".>") + } + + // 3. Its module's own state, by the local names its bus names alone — never the whole of what the + // module keeps (novox/hq ADR 0436 §1), read and written as stateGrants gives any module's own bucket. + kept := stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State} + pub = append(pub, stateGrants(kept)...) + // And it hears each of them created, so one not on the bus at its start is bound when it appears (novox/hq issue + // 1790; process.AwaitBucket). + sub = append(sub, stateCreatedHeard(kept)...) + + // 4. The router's records of its module's own asks (novox/hq ADR 0436 §1, ADR 0259 §3): the same + // direct-get subjects a module's own principal reads for the seats its Uses names that keep them — + // read here from the one list, so the two cannot disagree. + if p.Asks { + pub = append(pub, SeatTrafficOf(p.Module, nil, p.Uses, nil).Records...) + } + + // 5. The tools and seat verbs it calls (novox/hq ADR 0152, ADR 0436 §1): the same derivation a + // module's own principal's invokes takes, so `callerNamed` (applied to every non-controller principal) + // adds the caller-named `call` variants for it exactly as it would for a module. + invoked, err := invokedSubjects(p.Invokes) + if err != nil { + return nil, nil, err + } + pub = append(pub, invoked...) + + return pub, sub, nil +} diff --git a/modules/mesh-grants/internal/buscompose/provisioner.go b/modules/mesh-grants/internal/buscompose/provisioner.go new file mode 100644 index 0000000..e279463 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/provisioner.go @@ -0,0 +1,229 @@ +package buscompose + +import ( + "errors" + "fmt" + "strings" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/provisions" + + "github.com/nats-io/nats.go" +) + +// The provisioner client's own bus account (novox/hq ADR 0349 clauses 6 and 11, design 57 package P3). +// +// **Not the tool runner's account.** The client fills a machine's files and asks the vault for the secrets that +// machine's declaration names; the vault hands a secret only to an account of its own, because a call under the +// tool runner's account cannot be told from an agent's on that machine (ADR 0305 §3, §12). So each machine that +// runs the client has a principal of its own, KindProvisioner, named for the machine, and granted what the client +// does and nothing else: read its own machine's keys in each domain's bucket, write its own machine's keys of the +// provisioner server's needs, applied and collections (design 57 P4), read the server's registry, say what it +// applied on its own machine, and ask the vault in its own name. + +// NodeProvisionerSeat is the seat the provisioner client holds on every machine (ADR 0349 clause 3). +const NodeProvisionerSeat = "node-provisioner" + +// ProvisionerSecret is the own secret of the module running the client that its bus account is delivered in, as +// `broker` is a module's own (novox/hq issue 195): a module that claims NodeProvisionerSeat and declares no own +// secret of this name has nowhere to read an account, and is given none. +const ProvisionerSecret = "provisioner-broker" + +// ConfigResolvedBucket is the config domain's resolved values, keyed `..` (ADR 0348, research +// 051): mesh-config's state `resolved`, written by mesh-config alone. +var ConfigResolvedBucket = BucketName("mesh-config", "resolved") + +// ProvisionerReads are the domains' buckets a provisioner client reads its own machine's keys of, each keyed +// `.…` (research 051): placement, the controller's assignments mirror (`.`), whose value carries +// the provisioner's plan of a module handed to it (inventory.Provisioning, design 57 P5); and config, +// mesh-config's resolved values; and the seat mesh-provisioner's plans of its machine, `.` (novox/hq ADR +// 0570 clause 2). **Named one by one**, as the controller's asks are: a domain that moves to the +// provisioner adds its bucket here, and a bucket not named is one the client cannot read. +var ProvisionerReads = []string{ConfigResolvedBucket} + +// ProvisionerReadsWhole are the buckets a provisioner client reads every key of: the seat mesh-placement's placement, +// where a provider's consumers are placed on their own machines, which the provider machine's client reads to fill +// ${consumers:} with each one's credential (novox/hq ADR 0373, issue 679; ADR 0567, issue 1126: the +// controller's assignments mirror it read before is gone). It holds placement, never a secret's value; the client +// writes none of it. **The seat's plans are read whole for the same +// reason** (2026-10-30 recovery, issue 774): the client V3 watches every machine's plans to bind a provider's +// consumers, and granted only its own machine's keys it read none. ADR 0570 clause 3 has providers act on the seat's +// provision keys instead; once the client does, plans go back to its own machine's keys. +var ProvisionerReadsWhole = []string{delivery.PlacementBucket, provisions.PlansBucket} + +// BoundState is a providing module's state of entries, one per consumer (mesh-sdk go/provider, issue 678): a +// consumer's machine's client reads its own keys of it, `..[.]`, to fill +// ${bound::} (issue 679). +const BoundState = "bound" + +// ProvisionerServerModule is the module holding the seat mesh-provisioner once for the mesh (ADR 0349 clause 3, +// design 57 P4), whose buckets the clients write their own machine's keys of. +const ProvisionerServerModule = "mesh-provisioner" + +// The provisioner server's buckets (ADR 0349 clause 3): its state, created by the controller from its manifest +// like any module's, and written by its own account whole. +var ( + // ProvisionerRegistryBucket is which domain answers which kind of placeholder, keyed by the kind; the + // server's alone, read whole by every client. + ProvisionerRegistryBucket = BucketName(ProvisionerServerModule, "registry") + // ProvisionerNeedsBucket is what each module on each machine needs, `...`. + ProvisionerNeedsBucket = BucketName(ProvisionerServerModule, "needs") + // ProvisionerAppliedBucket is what is applied where, `.`. + ProvisionerAppliedBucket = BucketName(ProvisionerServerModule, "applied") + // ProvisionerCollectBucket is the items of every collection, `..`. + ProvisionerCollectBucket = BucketName(ProvisionerServerModule, "collect") +) + +// ProvisionerWrites are the server's buckets a client writes, and reads, its own machine's keys of (`.>`) +// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A client +// cannot write another machine's needs, or say another machine applied a file. +// +// **The needs stay granted for one release, and are removed after the lease write** (review round 3 of design 47's +// release): the client's V3 reads its machine's plans from the seat mesh-provisioner's `plans` (novox/hq ADR 0570 clause +// 2) and records no needs, but the client before it still runs on a node until its delivery reaches it, and it writes +// them. +var ProvisionerWrites = []string{ProvisionerNeedsBucket, ProvisionerAppliedBucket, ProvisionerCollectBucket} + +// VerbsTheProvisionerAsksTheVault are the vault's verbs a provisioner client asks in its own name (ADR 0349 +// clause 11): `get`, a secret its machine's declaration names, answered sealed to the asker's key. On the subject +// that names its caller, so the vault knows which machine's client asks from the bus's word. +var VerbsTheProvisionerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "get"}} + +// VaultSecretChangedSubject is where the vault says a secret it holds changed (`secret-changed`: which, by whom, +// when, its generation; never a value, ADR 0349 clause 11), as link.EventSubject addresses mesh-vault's event. A +// provisioner client listens to it so a rotated secret reaches its file without a new declaration (design 57 V4): +// it asks the vault again, in its own name, for a secret its machine's declaration names. +const VaultSecretChangedSubject = "mesh.mod.mesh-vault.event.secret-changed" + +// ProvisionerAppliedSubject is where a machine's client says what it applied for one module, applied or failed +// with the reason (ADR 0349 clauses 4 and 15): `prov.applied..`. +func ProvisionerAppliedSubject(node, module string) string { + return "prov.applied." + node + "." + module +} + +// ProvisionerAskSubject is where a machine's tool runner asks that machine's provisioner client for a config value +// or a non-secret bound value of a module it carries, naming the module from the bundle that asks (novox/hq ADR +// 0370 §1, §3, §4; issue 685): `prov.ask.`, which the client subscribes and the tool runner's account of the +// same machine alone may publish. The bus proves only that the tool runner asks; the module is the runtime's word, +// so the client answers no secret here (ADR 0370 §2). +func ProvisionerAskSubject(node string) string { return "prov.ask." + node } + +// ProvisionerChangedSubject is where a machine's client says that a value of one module it answers moved, so the +// tool runner asks again for the module's code's watches (ADR 0349 §14, ADR 0370 §1): `prov.changed..`, +// carrying which value and never the value itself, heard by that machine's tool runner alone. +func ProvisionerChangedSubject(node, module string) string { + return "prov.changed." + node + "." + module +} + +// provisionerGrants is a provisioner client's whole authority: to publish, and to subscribe. +func provisionerGrants(p Principal) (pub, sub []string, err error) { + if p.Node == "" { + // Refused rather than composed into `$KV...>`, a pattern no machine's keys are under, and a + // name every nameless client would share. + return nil, nil, errors.New("a provisioner client names no machine, so it would have no keys of its own " + + "to read and an account it shared with every other") + } + for _, bucket := range ProvisionerReads { + pub = append(pub, machineKeysRead(bucket, p.Node)...) + } + for _, bucket := range ProvisionerReadsWhole { + pub = append(pub, wholeBucketRead(bucket)...) + } + for _, bucket := range p.BoundBuckets { + module, name, ok := strings.Cut(bucket, "_") + if !ok || name != BoundState || !safeSubject.MatchString(module) || strings.Contains(module, "_") { + return nil, nil, fmt.Errorf("%q is not a provider's bucket of entries, _%s", bucket, BoundState) + } + pub = append(pub, machineKeysRead(bucket, p.Node)...) + } + // The server's buckets (design 57 P4): its own machine's keys of the needs, the applied and the + // collections, read and written, `$KV...>` and nothing wider; and the registry, read whole, + // which says which domain answers which kind and is the same for every machine. + for _, bucket := range ProvisionerWrites { + pub = append(pub, machineKeysRead(bucket, p.Node)...) + pub = append(pub, "$KV."+bucket+"."+p.Node+".>") + } + pub = append(pub, wholeBucketRead(ProvisionerRegistryBucket)...) + // Its own node's own have, `.node`, read and never written, and no other key of the bucket: it fills every + // module again when the node-engine moves to a new composition of the node's own (issue 1945), since an apply can + // leave a filled file as the composition had it. + pub = append(pub, readGrants(delivery.HaveBucket, p.Node+".node")...) + pub = append(pub, "prov.applied."+p.Node+".>") + token := CallerToken(p.Username()) + if token == "" { + return nil, nil, fmt.Errorf("%q cannot name a caller", p.Username()) + } + for _, v := range VerbsTheProvisionerAsksTheVault { + pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+token) + } + // What moved of a value it answers its own machine's tool runner (ADR 0370 §1), under its own machine's name. + pub = append(pub, "prov.changed."+p.Node+".>") + // Its own reply space, for the vault's answers and its reads' and watches' deliveries; what the vault says + // changed; and its own machine's tool runner asking it for a module's config and non-secret bound values (ADR + // 0370 §3), which it answers on the ask's reply and nowhere else. + return pub, []string{p.inbox(), VaultSecretChangedSubject, ProvisionerAskSubject(p.Node)}, nil +} + +// machineKeysRead is reading every key of a bucket under one machine's name, `.>`, and no other key: a +// direct get of a key's own subject, and a consumer whose filter is under it — the server's create-with-filter +// form, which it holds to the filter the subject names (as stateGrants does for one key, novox/hq ADR 0260). +// Binding, deleting a consumer and flow control cannot be narrowed by key and read no value. +func machineKeysRead(bucket, node string) []string { + stream := "KV_" + bucket + keys := "$KV." + bucket + "." + node + ".>" + return []string{ + "$JS.API.STREAM.INFO." + stream, + "$JS.API.DIRECT.GET." + stream + "." + keys, + "$JS.API.CONSUMER.CREATE." + stream + ".*." + keys, + "$JS.API.CONSUMER.DELETE." + stream + ".>", + "$JS.FC." + stream + ".>", + } +} + +// wholeBucketRead is reading every key of a bucket and writing none: what stateGrants grants a module for a +// bucket it `reads`. +func wholeBucketRead(bucket string) []string { + stream := "KV_" + bucket + return []string{ + "$JS.API.STREAM.INFO." + stream, + "$JS.API.DIRECT.GET." + stream + ".>", + "$JS.API.CONSUMER.CREATE." + stream + ".>", + "$JS.API.CONSUMER.DELETE." + stream + ".>", + "$JS.FC." + stream + ".>", + } +} + +// LastProvApplied is what a machine's provisioner client last said of one module's files, as it published it on +// ProvisionerAppliedSubject and ProvAppliedStream keeps it (design 57 P6): false when it has said nothing of it in +// the stream's week. +func LastProvApplied(js nats.JetStreamContext, node, module string) ([]byte, bool, error) { + msg, err := js.GetLastMsg(ProvAppliedStream, ProvisionerAppliedSubject(node, module)) + if errors.Is(err, nats.ErrMsgNotFound) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("what %s's provisioner client said of %s cannot be read: %w", node, module, err) + } + return msg.Data, true, nil +} + +// ProvAppliedReports is the newest word of every machine's provisioner client on every module it said one of, kept by +// ProvAppliedStream, by subject (`prov.applied..`). +func ProvAppliedReports(js nats.JetStreamContext) (map[string][]byte, error) { + info, err := js.StreamInfo(ProvAppliedStream, &nats.StreamInfoRequest{SubjectsFilter: ProvAppliedSubjects}) + if err != nil { + return nil, fmt.Errorf("the provisioner clients' reports cannot be listed: %w", err) + } + out := map[string][]byte{} + for subject := range info.State.Subjects { + msg, err := js.GetLastMsg(ProvAppliedStream, subject) + if errors.Is(err, nats.ErrMsgNotFound) { + continue // aged out between the two reads + } + if err != nil { + return nil, fmt.Errorf("the provisioner client's report on %s cannot be read: %w", subject, err) + } + out[subject] = msg.Data + } + return out, nil +} diff --git a/modules/mesh-grants/internal/buscompose/readiness.go b/modules/mesh-grants/internal/buscompose/readiness.go new file mode 100644 index 0000000..5fb24ec --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/readiness.go @@ -0,0 +1,139 @@ +package buscompose + +import ( + "fmt" + "sort" + "strings" +) + +// Whether a mesh could move its bus, and what is missing if not. +// +// **Asked before anything moves, and answerable from records alone.** The rollout moves every node at +// once (novox/hq ADR 0116 step 5), so there is no partial state to inspect afterwards and no half to +// roll back: either the mesh was ready or it was not. That makes a readiness question the most +// valuable thing here — it costs nothing, it can be asked of a running mesh any number of times, and +// every answer is a thing somebody can go and fix. +// +// Deliberately pure. It is handed what the mesh knows and returns sentences; nothing here connects to +// anything, so it can be asked on a workstation about a mesh it has never reached. + +// Readiness is what the mesh knows about its own ability to move. +type Readiness struct { + // TheBus is the address the mesh's own traffic would move to, empty when nothing names one. + TheBus string + // ServerStanding is whether a bus is reachable at that address, as somebody checked. + ServerStanding bool + // Holder is the node running the module that holds the bus seat, empty when nothing does. + Holder string + // AccountsComposed is whether that node has been sent the composed user list. + AccountsComposed bool + // Nodes is every machine the mesh knows. + Nodes []string + // Credentialled is which of them has a credential for the new bus. + Credentialled map[string]bool + // Modules is every assigned module, as `/`. + Modules []string + // ModuleCredentialled is which of those has one. + ModuleCredentialled map[string]bool +} + +// NotReady is every reason this mesh cannot move its bus yet, in the order somebody would fix them. +// +// Empty means ready. **Each entry names one thing and what to do about it**, because a readiness check +// that says "not ready" is a check nobody can act on — and this is read at the point where the next +// step is irreversible. +func NotReady(r Readiness) []string { + var why []string + + if strings.TrimSpace(r.TheBus) == "" { + why = append(why, "nothing names the bus to move to: set "+NATSVar+" on the control node "+ + "to the address the new server answers on") + } + if !r.ServerStanding { + why = append(why, "no bus is answering at that address. Step 2 of the change raises it beside "+ + "the one the mesh is on, carrying nothing — assign the module that holds "+ + "mesh-broker and push the machine that runs it") + } + if r.Holder == "" { + why = append(why, "no machine holds mesh-broker, so nothing would compose the bus's user "+ + "list. Assign the module that claims it") + } else if !r.AccountsComposed { + why = append(why, fmt.Sprintf( + "%s holds mesh-broker and has not been sent the composed user list, so the bus would "+ + "refuse every connection. `push %s`", r.Holder, r.Holder)) + } + + // A node with no credential cannot come back after the move, and a node that cannot come back is + // a machine the mesh has lost until somebody goes to it. + var missing []string + for _, n := range r.Nodes { + if !r.Credentialled[n] { + missing = append(missing, n) + } + } + sort.Strings(missing) + if len(missing) > 0 { + why = append(why, fmt.Sprintf( + "%d machine(s) have no credential for the new bus and would not come back: %s. Each needs "+ + "one minted before the move, not after — after, there is no bus to ask over", + len(missing), strings.Join(missing, ", "))) + } + + // A module without one keeps running and stops being reachable, which is a smaller fault and still + // one somebody should choose rather than discover. + var quiet []string + for _, m := range r.Modules { + if !r.ModuleCredentialled[m] { + quiet = append(quiet, m) + } + } + sort.Strings(quiet) + if len(quiet) > 0 { + why = append(why, fmt.Sprintf( + "%d module(s) have no credential for the new bus: %s. Each keeps serving and stops "+ + "answering tools and hearing events until it is issued one", + len(quiet), strings.Join(quiet, ", "))) + } + + return why +} + +// WhatMoves is what the rollout would do, in order, for somebody reading before they commit. +// +// **Written out rather than summarised.** This is the one step with nothing to inspect afterwards, so +// the last useful moment to disagree with it is while reading this. +func WhatMoves(r Readiness) []string { + out := []string{ + fmt.Sprintf("compose the bus's user list and send it to %s", holderOr(r.Holder)), + fmt.Sprintf("move this control plane to %s, and confirm it is heard", busOr(r.TheBus)), + } + nodes := append([]string(nil), r.Nodes...) + sort.Strings(nodes) + for _, n := range nodes { + out = append(out, fmt.Sprintf("move %s, and confirm it reports", n)) + } + if len(r.Modules) > 0 { + out = append(out, fmt.Sprintf("move %d module runtime(s), and confirm each answers", + len(r.Modules))) + } + // **The old broker goes, and it goes last** (novox/hq ADR 0131). AMQP is not a provision, so once + // every machine reports on the new bus nothing of the mesh is left speaking to it, and its module + // is unassigned. Said as a step so nobody reads the move as leaving a second bus behind. + out = append(out, "then unassign the old broker's module: AMQP is not a provision (ADR 0131), and "+ + "once every machine reports on the new bus nothing of the mesh speaks to it") + return out +} + +func holderOr(node string) string { + if node == "" { + return "whichever machine holds mesh-broker" + } + return node +} + +func busOr(address string) string { + if address == "" { + return "the new bus" + } + return address +} diff --git a/modules/mesh-grants/internal/buscompose/sdk_membership.go b/modules/mesh-grants/internal/buscompose/sdk_membership.go new file mode 100644 index 0000000..d9ad34c --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/sdk_membership.go @@ -0,0 +1,38 @@ +package buscompose + +import ( + "git.novox.be/novox/mesh-sdk/go/membership" + "git.novox.be/novox/mesh-sdk/go/process" +) + +// The membership the mesh issues an assignment (novox/hq ADR 0160), its subject, its stream, the bare address and the +// pin are mesh-sdk's, public so that a module's program outside this Go module, the route proxy, reads them too +// (novox/hq ADR 0480 clause 4, issue 1633). This package keeps no second copy: what follows only names them where the +// controller already did. +type ( + Membership = membership.Membership + Served = membership.Served + SeatServed = membership.SeatServed + StateIssued = membership.StateIssued + LogIssued = membership.LogIssued + Worker = membership.Worker + SeatTraffic = membership.SeatTraffic + KindHeld = membership.KindHeld + StreamIssued = membership.StreamIssued + HeardIssued = membership.HeardIssued + WaitIssued = membership.WaitIssued +) + +// AssignmentsStream holds every assignment's membership, the newest per subject. +const AssignmentsStream = membership.AssignmentsStream + +var ( + // MembershipSubject is where an assignment's own membership is published. + MembershipSubject = membership.Subject + // CredentialIn reads the user and password out of a bus address, and the address without them. + CredentialIn = membership.CredentialIn + // BareAddress is a bus address with any credential stripped. + BareAddress = membership.BareAddress + // PinnedToFingerprint accepts exactly the certificate with this SHA-256 and no other. + PinnedToFingerprint = process.PinnedToFingerprint +) diff --git a/modules/mesh-grants/internal/buscompose/seattraffic.go b/modules/mesh-grants/internal/buscompose/seattraffic.go new file mode 100644 index 0000000..243f7ac --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/seattraffic.go @@ -0,0 +1,319 @@ +package buscompose + +import ( + "fmt" + "sort" +) + +// What a module may say and take on the seats it holds, uses and watches, beyond tools (novox/hq ADR +// 0259 §3, to-be 46 §10). +// +// Three rules are added to the ones a seat always had, each a subject whose last token names who may +// publish it, granted to that publisher alone — the way a node seat's event about a machine carries the +// machine (ADR 0219): +// +// - **A verb named by its caller** (`by-caller`). A user of the seat submits that accept, and hears that +// event, under its own module's name and no other: `accept.ask.`, `event.decided.`. The +// holder takes every caller's accept and says the event to any caller. So an ask's asker is a fact the +// server enforces, and a warrant reaches only the asker it is for. +// - **A kinded bench** (ADR 0234 §2). A holder claims one kind and takes its own kind's accepts, says its +// own kind's events and asks its own kind's proofs, and nothing of another kind; a user submits to any +// kind. Each kind has its own worker on the seat's queue, so a holder that is away keeps its work and +// holds up no other kind. +// - **A proof** (`proofs`): core request and reply on `mesh.seat..proof..`. No stream's +// subjects cover it, so what travels there — a code typed by the operator — is never persisted. A kinded +// holder asks with its own kind; the modules that watch the seat answer. +// +// And two reads: **a holder's records**, a bucket the seat names, read by each user under its own name only +// (`$KV...>`), so an asker reads the state of its own asks and no other asker's; and on a seat pulled by +// kind, **what its workers gave up on**, the copies DEAD_LETTERS keeps of the seat's stream (DeadLetterReads, issue 734). + +// BuildAskMaxDeliver bounds the deliveries of a build seat's ask (issue 1907): mesh-sdk delivery.AskMaxDeliver, written +// here as the broker mirrors the catalogue rather than importing it. +const BuildAskMaxDeliver = 5 + +// KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded. +var KindedBenches = map[string]bool{"channel": true, "intake": true} + +// WorkerName is the worker a seat's holders pull from: one for the seat, or one per kind on a kinded bench. +func WorkerName(seat, kind string) string { + if kind == "" { + return "SEAT_" + upperSnake(seat) + "_worker" + } + return "SEAT_" + upperSnake(seat) + "_" + upperSnake(kind) + "_worker" +} + +func namesVerb(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +// SeatTrafficOf derives one module's seat traffic from the seats it holds, uses and watches. Only seats +// carrying one of the rules above are read: every other seat is composed as it always was. +func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic { + var t SeatTraffic + for _, s := range holds { + if !s.isNewTraffic() { + // **A holder says its own seat's events** (novox/hq ADR 0564 clause 6, issue 1952): its own account is + // granted them by the held seats' rule (PermissionsFor, step 3), and a module the tool runner carries had + // no way to, so the self-check's `checked` was never said. Listed here, the membership names them for the + // runtime and the runtime is granted them. A machine's event on a node seat carries the machine, which + // only the holder's own account is composed with, so it is left to that rule. + for _, e := range s.Emits { + if s.Scope == "node" && perMachineEvents[e] { + continue + } + t.Publish = append(t.Publish, seatSubject(s, "event", e)) + } + continue + } + kind := "" + if s.Kinded { + kind = s.Kind + if kind == "" || !safeSubject.MatchString(kind) { + // A kinded claim without a usable kind is refused at registration; here it is granted + // nothing, which is the same answer at the last place it could be asked. + continue + } + } + if len(s.Accepts) > 0 && s.PulledByKind { + // One worker per accepted kind, which every holder pulls in the order of Accepts (ADR 0525 §4, ADR 0536 §4): + // `SEAT___worker`, filtered to `accept.` (mesh-sdk delivery.AskWorker). + for _, a := range s.Accepts { + t.Workers = append(t.Workers, Worker{Stream: seatStreamName(s.Name), Consumer: WorkerName(s.Name, a), + Filter: "mesh.seat." + s.Name + ".accept." + a}) + } + } else if len(s.Accepts) > 0 { + stream := seatStreamName(s.Name) + filter := "mesh.seat." + s.Name + ".accept.>" + if kind != "" { + filter = "mesh.seat." + s.Name + ".accept.*." + kind + } + t.Workers = append(t.Workers, Worker{Stream: stream, Consumer: WorkerName(s.Name, kind), Filter: filter}) + } + for _, a := range s.Accepts { + switch { + case kind != "": + t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+"."+kind)) + case namesVerb(s.ByCaller, a): + t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+".*")) + } + } + for _, e := range s.Emits { + switch { + case kind != "": + t.Publish = append(t.Publish, seatSubject(s, "event", e+"."+kind)) + case namesVerb(s.ByCaller, e): + t.Publish = append(t.Publish, seatSubject(s, "event", e+".*")) + } + } + if kind != "" { + for _, v := range s.Proofs { + t.Publish = append(t.Publish, seatSubject(s, "proof", v+"."+kind)) + } + } + } + for _, s := range uses { + if !s.isNewTraffic() { + continue + } + for _, a := range s.Accepts { + switch { + case namesVerb(s.ByCaller, a): + t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module)) + case s.Kinded && module == s.DeclaredBy: + // Work for a kind is put on its queue by the bench's own router, and by no other user. + t.Publish = append(t.Publish, seatSubject(s, "accept", a+".*")) + } + } + for _, e := range s.Emits { + if namesVerb(s.ByCaller, e) { + t.Subscribe = append(t.Subscribe, seatSubject(s, "event", e+"."+module)) + } + } + for _, b := range s.Records { + if !safeSubject.MatchString(b) { + continue + } + t.Records = append(t.Records, "$JS.API.DIRECT.GET.KV_"+b+".$KV."+b+"."+module+".>") + } + if s.PulledByKind && len(s.Accepts) > 0 && safeSubject.MatchString(s.Name) { + t.Records = append(t.Records, DeadLetterReads(seatStreamName(s.Name))...) + } + } + for _, w := range watches { + if w.Kinded { + for _, e := range w.Emits { + t.Subscribe = append(t.Subscribe, seatSubject(w, "event", e+".*")) + } + if module == w.DeclaredBy { + // A code is answered by the bench's own router, and by no other watcher. + for _, v := range w.Proofs { + t.Answers = append(t.Answers, seatSubject(w, "proof", v+".*")) + } + } + } + } + t.Publish = unique(t.Publish) + t.Subscribe = unique(t.Subscribe) + t.Answers = unique(t.Answers) + t.Records = unique(t.Records) + sort.Slice(t.Workers, func(i, j int) bool { return t.Workers[i].Consumer < t.Workers[j].Consumer }) + return t +} + +// DeadLetterReads is what a user of a seat pulled by kind is granted to hear which of the asks it put the seat's workers +// gave up on (novox/hq issue 734): an ordered consumer on DEAD_LETTERS filtered to the copies the controller keeps of that +// seat's stream (DeadLetterSubject(stream, *)), pulled, and its deletion. A copy carries the ask's own message id +// (Mesh-Dead-Msg-Id), so the asker ends the step it waits for instead of waiting for ever; it reads the seat's dead +// letters and no other stream's, and changes none. +func DeadLetterReads(stream string) []string { + return []string{ + "$JS.API.STREAM.INFO." + DeadLettersStream, + "$JS.API.CONSUMER.CREATE." + DeadLettersStream + ".*." + DeadLetterSubject(stream, ">"), + "$JS.API.CONSUMER.MSG.NEXT." + DeadLettersStream + ".*", + "$JS.API.CONSUMER.DELETE." + DeadLettersStream + ".*", + } +} + +// seatTrafficGrants is the bus permissions seat traffic needs: the subjects themselves, and the JetStream API a +// worker is pulled and acknowledged through and a record is read through. +func seatTrafficGrants(t SeatTraffic) (pub, sub []string) { + pub = append(pub, t.Publish...) + sub = append(sub, t.Subscribe...) + sub = append(sub, t.Answers...) + for _, w := range t.Workers { + pub = append(pub, + "$JS.API.CONSUMER.INFO."+w.Stream+"."+w.Consumer, + "$JS.API.CONSUMER.MSG.NEXT."+w.Stream+"."+w.Consumer, + "$JS.ACK."+w.Stream+"."+w.Consumer+".>") + } + pub = append(pub, t.Records...) + return pub, sub +} + +// isNewTraffic says whether a seat carries any of the rules above, so a seat that carries none is +// composed exactly as before them. +func (s Seat) isNewTraffic() bool { + return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0 || s.PulledByKind +} + +// plainVerbs is a seat's accepts or emits with those the rules above compose taken out: a verb named by +// its caller and every verb of a kinded bench are composed by SeatTrafficOf and nowhere else. +func plainVerbs(s Seat, verbs []string) []string { + if s.Kinded { + return nil + } + var out []string + for _, v := range verbs { + if !namesVerb(s.ByCaller, v) { + out = append(out, v) + } + } + return out +} + +// SeatTrafficObjects is the work queues and workers the seat traffic of every composed user implies +// (novox/hq ADR 0259 §3): a queue for each seat a holder takes work from, and each holder's worker on it — +// one per kind on a kinded bench, filtered to that kind, so the kinds never take each other's work. Only +// seats carrying the rules above; the mesh's own seats' queues are RaiseSeats'. +func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) { + streams := map[string]Stream{} + consumers := map[string]Consumer{} + add := func(module string, holds []Seat) { + for _, w := range SeatTrafficOf(module, holds, nil, nil).Workers { + seat, byKind := "", false + for _, s := range holds { + if seatStreamName(s.Name) == w.Stream { + seat, byKind = s.Name, s.PulledByKind + } + } + streams[w.Stream] = Stream{ + Name: w.Stream, + Subjects: []string{"mesh.seat." + seat + ".accept.>"}, + Retention: RetentionWorkQueue, + MaxAge: 7 * 24 * 60 * 60, + Why: "work submitted to the " + seat + " seat; its holders take it, each kind its own, and it queues while nobody does", + } + c := Consumer{ + Name: w.Consumer, + Stream: w.Stream, + Filters: []string{w.Filter}, + AckWaitSeconds: 60, + // No bound on redelivery: a channel away for a day keeps its work, offered again later and + // later by its holder's runtime (novox/hq ADR 0259; the correctness review of 2026-10-08). + MaxDeliver: 0, + Why: module + " holds " + seat + "; it pulls one ask at a time and acknowledges once it has " + + "recorded it, so a crash redelivers rather than loses", + } + if byKind { + // A build seat's ask is offered a bounded number of times (issue 1907): the holder that takes it past + // mesh-sdk delivery.AskRuns ends it failed saying it is poison, and the bound stops a run that kills + // every node it reaches even when that holder dies too. + c.MaxDeliver = BuildAskMaxDeliver + c.Why = module + " holds " + seat + "; each holder pulls an ask when it has a free slot, and an ask that " + + "no run ends is offered at most " + fmt.Sprint(BuildAskMaxDeliver) + " times, its step failed as poison" + } + consumers[w.Consumer] = c + } + } + for _, p := range users { + switch p.Kind { + case KindModule: + add(p.Module, p.Holds) + case KindNodeTools: + for _, d := range p.Carries { + add(d.Module, d.Holds) + } + } + } + var ss []Stream + for _, s := range streams { + ss = append(ss, s) + } + sort.Slice(ss, func(i, j int) bool { return ss[i].Name < ss[j].Name }) + var cs []Consumer + for _, c := range consumers { + cs = append(cs, c) + } + sort.Slice(cs, func(i, j int) bool { return cs[i].Name < cs[j].Name }) + return ss, cs +} + +// trustedTraffic is why a module's seat traffic is the trusted holder's (novox/hq ADR 0259 §8), or "": it +// says a seat's event to one caller each (a warrant), or holds a kind of a kinded bench that proves its sender. +func trustedTraffic(d Declared) string { + for _, s := range d.Holds { + for _, e := range s.Emits { + if namesVerb(s.ByCaller, e) { + return "it says " + s.Name + "'s " + e + " to one caller each" + } + } + if s.Kinded && namesVerb(s.Capabilities, "verified-sender") { + return "it holds " + s.Name + " of kind " + s.Kind + ", which proves its sender" + } + } + return "" +} + +// TrafficQueues is the work queue of every seat naming its caller or its kind that accepts work, held or not +// (novox/hq ADR 0259 §3): what is submitted before a holder is assigned waits for it. +func TrafficQueues(seats []Seat) []Stream { + var out []Stream + seen := map[string]bool{} + for _, s := range seats { + if len(s.Accepts) == 0 || !s.isNewTraffic() || seen[s.Name] { + continue + } + seen[s.Name] = true + out = append(out, Stream{Name: seatStreamName(s.Name), Subjects: []string{"mesh.seat." + s.Name + ".accept.>"}, + Retention: RetentionWorkQueue, MaxAge: 7 * 24 * 60 * 60, + Why: "work submitted to the " + s.Name + " seat; its holders take it, each kind its own, and it queues while nobody does"}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} diff --git a/modules/mesh-grants/internal/buscompose/state.go b/modules/mesh-grants/internal/buscompose/state.go new file mode 100644 index 0000000..6f4eed6 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/state.go @@ -0,0 +1,329 @@ +package buscompose + +import ( + "fmt" + "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/delivery" + "strings" + "time" +) + +// A module's state on the bus (novox/hq ADR 0201, design 32 §4, design 25 §3). +// +// A module names the state it keeps (`state`) and the state of others it reads (`reads`), and each +// is a key-value bucket: the server's own last-per-subject stream with direct reads, delete markers +// and watches, which is the state relationship the mesh already uses for declarations, opened to +// modules. The controller creates every bucket from the catalogue — from registration, like a +// seat's stream, so a reader may watch before the owner runs anywhere — and no module can. +// +// Pure, like everything else in this package that decides what the bus holds; jetstream.go is the +// part that asks a server. + +// The mesh's caps on a bucket, the same for every module: a value is a piece of state, not a file, +// and a bucket that grew without bound would be one module filling the bus's disk for everyone. +const ( + StateMaxValueBytes = 256 * 1024 + StateMaxBytes = 64 * 1024 * 1024 +) + +// A Bucket is one module's declared state as the bus holds it. +type Bucket struct { + Module string + Name string + // History is how many values a key keeps; zero is one. + History int + // TTLSeconds is how long a value lives; zero is until replaced or deleted. + TTLSeconds int + // PerMachine says each machine's instance writes only the key named for its machine (novox/hq ADR + // 0260), and is granted that key and no other. + PerMachine bool + // KeyTTL says the bucket allows a time to live per key (novox/hq ADR 0433 §1): its owner writes each key with its + // own, and the server writes a delete marker, kept KeyTTLMarkerTTL, when it ends. + KeyTTL bool +} + +// KeyTTLMarkerTTL is how long the marker an expired key leaves is kept: long enough for a watcher to hear it. +const KeyTTLMarkerTTL = time.Minute + +// BucketName is the bucket a module's state lives in: the module and the local name joined by an +// underscore, which neither may contain, so two modules can never derive one bucket. +func BucketName(module, name string) string { return module + "_" + name } + +// Bucket is this bucket's name on the bus. +func (b Bucket) Bucket() string { return BucketName(b.Module, b.Name) } + +// Why is carried into the server's description of the bucket, so somebody reading the server's +// own state finds whose it is and why it is kept. +func (b Bucket) Why() string { + return fmt.Sprintf("%s's state %q (novox/hq ADR 0201): its current value per key, written by %s, "+ + "read by whatever declares it reads it; kept when %s is unassigned, because it is data", + b.Module, b.Name, b.Module, b.Module) +} + +// bucketOfRead is the bucket a read names, `.`, or false when it names none. +func bucketOfRead(read string) (string, bool) { + at := strings.LastIndex(read, ".") + if at <= 0 || at == len(read)-1 { + return "", false + } + module, name := read[:at], read[at+1:] + if !safeSubject.MatchString(module) || !safeSubject.MatchString(name) { + return "", false + } + return BucketName(module, name), true +} + +// readGrants is what reaching a bucket's keys needs: binding to it, a direct get, an ordered consumer for listing and +// watching and the deletion of that consumer and its flow control. key is one key's subject tail, a pattern such as +// `one.>` for a prefix of keys, or empty for every key. The bucket is made with AllowDirect, so a get is the direct get +// and never the stream's message get. +func readGrants(bucket, key string) []string { + stream := "KV_" + bucket + out := []string{"$JS.API.STREAM.INFO." + stream} + if key == "" { + out = append(out, + "$JS.API.DIRECT.GET."+stream+".>", + "$JS.API.CONSUMER.CREATE."+stream+".>") + } else { + out = append(out, + "$JS.API.DIRECT.GET."+stream+".$KV."+bucket+"."+key, + "$JS.API.CONSUMER.CREATE."+stream+".*.$KV."+bucket+"."+key) + } + return append(out, + "$JS.API.CONSUMER.DELETE."+stream+".>", + "$JS.FC."+stream+".>") +} + +// NodeKeysGrants is what a node's node-engine is granted of a bucket it writes by node, `.>` and no other key +// (novox/hq ADR 0429 §1: each key one writer, enforced by credentials): the writes of those keys and what a get, +// create, update and watch of them need. The node name is checked as every subject token is. +func NodeKeysGrants(bucket, node string) ([]string, error) { + if !safeSubject.MatchString(node) { + return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node) + } + return append([]string{"$KV." + bucket + "." + node + ".>"}, readGrants(bucket, node+".>")...), nil +} + +// stateGrants is what a principal publishes to reach the state its modules keep and read: for every +// bucket, binding to it, reading a key directly, and an ordered consumer for listing and watching, +// created and deleted on the bucket's own stream, with its flow control answered; for a bucket an +// owner keeps, writing under the bucket's own subjects too. +// +// **Measured against a running server, 2026-10-04** (novox/hq research 024), and each one is there +// because leaving it out failed: without STREAM.INFO nothing binds; without DIRECT.GET nothing is +// read; without CONSUMER.CREATE no key is listed and nothing is watched; without CONSUMER.DELETE a +// watch cannot be stopped and lingers on the server. A write outside these is refused by the server +// — and reaches the writer as a timeout, not a refusal, which is why the runtime refuses first. +// +// **One key, where the state is per machine** (novox/hq ADR 0260). The owner's instance on a machine +// writes and reads the key named for that machine, and a holder granted a read for its machine reads +// that key: a direct get of the key's own subject, and a consumer whose filter is that subject — the +// server's create-with-filter form, which a client uses for a watch of one key and which the server +// holds to the filter it names. What cannot be narrowed by key: binding (STREAM.INFO), deleting a +// consumer (named at random by the client) and flow control; none of them reads or writes a value. +func stateGrants(a stateAccess) []string { + var out []string + read := func(bucket, key string) { out = append(out, readGrants(bucket, key)...) } + machineKey := "" + if safeSubject.MatchString(a.Node) { + machineKey = a.Node + } + perMachine := map[string]bool{} + for _, n := range a.PerMachine { + perMachine[n] = true + } + for _, name := range a.Keeps { + if !safeSubject.MatchString(name) { + continue + } + bucket := BucketName(a.Module, name) + if perMachine[name] { + if machineKey == "" { + continue // a machine with no usable name reaches none of it + } + read(bucket, machineKey) + out = append(out, "$KV."+bucket+"."+machineKey) + continue + } + read(bucket, "") + out = append(out, "$KV."+bucket+".>") + } + // **And it says what it keeps** (novox/hq ADR 0495 clause 2, issue 1790): a module keeping state announces it on its + // own event subject, from its code; refused, its readers are read as before. + if len(a.Keeps) > 0 && safeSubject.MatchString(a.Module) { + out = append(out, announce.Subject(a.Module)) + } + for _, r := range a.Reads { + if bucket, ok := bucketOfRead(r); ok { + if NodeKeyedReads[r] { + if machineKey != "" { + read(bucket, machineKey+".>") + } + continue + } + read(bucket, "") + } + } + for _, k := range a.KeyedReads { + if bucket, ok := bucketOfRead(k.Read); ok && safeSubject.MatchString(k.Key) { + read(bucket, k.Key) + } + } + return out +} + +// NodeKeyedReads are the states keyed `.>` whose reader reads its own machine's keys alone, whatever its `reads` +// names: the node-engine's data items, each node's disk-load reading its own node's (novox/hq ADR 0577 clause 1). A +// machine with no usable name reaches none of them. +var NodeKeyedReads = map[string]bool{delivery.EngineDataRead: true} + +// StreamCreatedAdvisory is the subject the bus says a stream was created on (the server's own advisory), here for a +// bucket's stream KV_. +func StreamCreatedAdvisory(bucket string) string { + return "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_" + bucket +} + +// stateCreatedHeard is what a principal subscribes to so that a bucket it may bind and that is not on the bus yet is +// bound when it appears (novox/hq issue 1790, 2026-10-26: since the grants part creates a manifest's buckets, ADR 0547, +// a module's delivery can start it before its new state exists): the bus's advisory that the bucket's stream was +// created, for each bucket stateGrants lets it bind, and no other. Read-only: the server publishes it in the mesh's own +// account, and hearing it changes nothing. +func stateCreatedHeard(a stateAccess) []string { + var out []string + seen := map[string]bool{} + add := func(bucket string) { + if !seen[bucket] { + seen[bucket] = true + out = append(out, StreamCreatedAdvisory(bucket)) + } + } + perMachine := map[string]bool{} + for _, n := range a.PerMachine { + perMachine[n] = true + } + for _, name := range a.Keeps { + if !safeSubject.MatchString(name) || (perMachine[name] && !safeSubject.MatchString(a.Node)) { + continue + } + add(BucketName(a.Module, name)) + } + for _, r := range a.Reads { + if bucket, ok := bucketOfRead(r); ok { + add(bucket) + } + } + for _, k := range a.KeyedReads { + if bucket, ok := bucketOfRead(k.Read); ok && safeSubject.MatchString(k.Key) { + add(bucket) + } + } + return out +} + +// KeyedRead is one key of another module's state a principal reads, and no other key of it. +type KeyedRead struct { + Read string `json:"read"` + Key string `json:"key"` +} + +// stateAccess is what one principal may do with state: the module's own buckets (some per machine), +// what it reads whole, and what it reads for its machine's key alone. +type stateAccess struct { + Module, Node string + Keeps, PerMachine []string + Reads []string + KeyedReads []KeyedRead +} + +// perMachineNames is the local names of a module's buckets keyed by machine. +func perMachineNames(buckets []Bucket) []string { + var out []string + for _, b := range buckets { + if b.PerMachine { + out = append(out, b.Name) + } + } + return out +} + +// stateIssuedFor is every bucket a module's code may reach on a machine, as its membership lists them. +func stateIssuedFor(d Declared, node string) []StateIssued { + var out []StateIssued + for _, b := range d.State { + issued := StateIssued{Name: b.Name, Bucket: BucketName(d.Module, b.Name), Writes: true} + if b.PerMachine { + issued.Key = node + } + out = append(out, issued) + } + for _, r := range d.Reads { + if bucket, ok := bucketOfRead(r); ok { + out = append(out, StateIssued{Name: r, Bucket: bucket}) + } + } + for _, k := range d.KeyedReads { + if bucket, ok := bucketOfRead(k.Read); ok { + out = append(out, StateIssued{Name: k.Read, Bucket: bucket, Key: k.Key}) + } + } + return out +} + +// stateNames is the local names of a module's own buckets. +func stateNames(buckets []Bucket) []string { + out := make([]string, 0, len(buckets)) + for _, b := range buckets { + out = append(out, b.Name) + } + return out +} + +// ResolvedBucket is mesh-config's state of resolved values, keyed `..`. +const ResolvedBucket = "mesh-config_resolved" + +// NodeComposeReads is what a node's agent reads to compose its own modules (novox/hq ADR 0547 §2, ADR 0567 §3, §9, +// issue 1126): every key of the seat mesh-placement's placement and candidates (both the assigner's states), of the +// indexer's modules and manifests and of the builder's builds, and of mesh-config's resolved values only its own node's, +// `.>`. Every candidate key, not only `*.`: a node answering its candidate composes with the whole +// proposal laid over (the seed places every node at once, and a binding crosses nodes), and nothing in a placement is +// secret. Reads only; the node is checked as every subject token is by the caller's principal. +func NodeComposeReads(node string) []string { + var out []string + for _, bucket := range []string{delivery.PlacementBucket, delivery.PlacementCandidatesBucket, delivery.ModulesBucket, + delivery.ManifestsBucket, delivery.BuilderBuildsBucket} { + out = append(out, readGrants(bucket, "")...) + } + return append(out, readGrants(ResolvedBucket, node+".>")...) +} + +// NodeComposeAdvisories are the stream-created advisories of the seat mesh-placement's placement and candidates +// (ADR 0567 §9): what a node agent hears to bind each once it is made. Those two and no wider. +func NodeComposeAdvisories() []string { + return []string{StreamCreatedAdvisory(delivery.PlacementBucket), StreamCreatedAdvisory(delivery.PlacementCandidatesBucket)} +} + +// ServedReadGrants is the read of each key of mesh-config's resolved values named, `..` +// (Principal.ServedReads, issue 1126): a get and a watch of that key alone. Each part is checked as every subject token +// is, so a key cannot widen the grant. +func ServedReadGrants(keys []string) ([]string, error) { + var out []string + seen := map[string]bool{} + for _, k := range keys { + parts := strings.Split(k, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("%q is not ..", k) + } + for _, part := range parts { + if !safeSubject.MatchString(part) { + return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", part) + } + } + for _, g := range readGrants(ResolvedBucket, k) { + if !seen[g] { + seen[g] = true + out = append(out, g) + } + } + } + return out, nil +} diff --git a/modules/mesh-grants/internal/buscompose/streams.go b/modules/mesh-grants/internal/buscompose/streams.go new file mode 100644 index 0000000..2412c68 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/streams.go @@ -0,0 +1,311 @@ +package buscompose + +import ( + "fmt" + "sort" + "strings" +) + +// The mesh's own streams. +// +// **These and no more** (novox/hq ADR 0116 task 1.4, as revised by ADR 0118; the two that keep what a +// consumer gave up on added for issue 330). An earlier +// reading had the controller create *every* stream at genesis, from a fixed set. That is only the +// mesh's own half: a seat's streams are created when the module declaring it is registered, and a +// module's durable consumers when it is assigned — neither of which has happened at genesis. What +// is here is the foundation, which exists before any module does. +// +// The controller is the only writer of stream definitions (design 25 §3). A module declares +// nothing about them and cannot reach the JetStream API to make one. + +// Retention is how a stream decides what to keep, which is the whole of what distinguishes the +// mesh's four relationships on the wire (design 29 §4). +type Retention string + +const ( + // RetentionWorkQueue: a message is removed once a consumer acknowledges it. Exactly one + // worker does the work, and a worker that dies has its message redelivered. + RetentionWorkQueue Retention = "workqueue" + // RetentionLastPerSubject: only the newest message on each subject survives. This is the + // state shape — a node that was away gets exactly the current declaration and nothing older. + RetentionLastPerSubject Retention = "last_per_subject" + // RetentionLimits: kept until it ages or the stream fills. Events, where a subscriber that + // was down catches up and nobody is obliged to act. + RetentionLimits Retention = "limits" +) + +// A Stream is one of the mesh's own, as the controller asserts it. +type Stream struct { + Name string + Subjects []string + Retention Retention + // MaxAge in seconds, zero for unbounded. Per stream — JetStream has no per-subject age, + // which is why differing retention between modules would mean a stream each. + MaxAge int + // MaxMsgsPerSubject caps each subject independently, so one noisy emitter cannot push + // another's events out of a shared stream. Verified: with a cap of 3, ten messages on one + // subject and one on another leave four in the stream, not three. + MaxMsgsPerSubject int + // Why is carried into the assertion so an operator reading the server's own state finds the + // reason there, rather than only in a repository they may not have. + Why string + // Direct lets a client read a subject's last message without a consumer, which is how a + // runtime reads its own membership with no JetStream API beyond one request (ADR 0160). + Direct bool + // MaxBytes bounds the stream's size, zero for unbounded. With DiscardNew a full stream refuses + // what comes next rather than dropping what it holds: the publisher is told, and says so. + MaxBytes int64 + DiscardNew bool + // DuplicatesSeconds is the window in which a message id published twice is kept once; zero for the + // server's default (two minutes). + DuplicatesSeconds int + // AllowMsgTTL lets a message carry a time to live of its own (`Nats-TTL`), for what must not outlive the + // stream it came from (novox/hq ADR 0376 §3, §5). + AllowMsgTTL bool + // Restricted marks a module's stream restricted by a grant (novox/hq ADR 0363 §4), in the stream's own + // metadata, so whoever keeps one of its messages reads it from the bus (ADR 0376 §3). + Restricted bool + // WorkQueue marks a module stream declared retention: work-queue (novox/hq ADR 0436 §6), in the + // stream's own metadata, so a dead letter kept from it is read as drop-only from the bus, by the + // declaration and not by the bus's work-queue retention alone — which SEAT_* and other mesh-internal + // streams share without being one of ADR 0436's, and whose dead letters are still delivered again + // (ADR 0264 §4). + WorkQueue bool +} + +// RestrictedMetadata is the key a restricted stream's metadata says it under. +const RestrictedMetadata = "mesh-restricted" + +// WorkQueueMetadata is the key a module stream declared retention: work-queue (novox/hq ADR 0436 §6) says +// it under: never set by a mesh-internal work-queue stream (a seat's, a check's), only by Stream.WorkQueue. +const WorkQueueMetadata = "mesh-work-queue" + +// What a durable consumer gave up on is kept (novox/hq issue 330, design 25 §3). +// +// **The server says it and keeps it; the controller copies it.** A consumer that handed a message over +// as often as it may stops offering it and publishes `$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES` with +// the stream and the message's sequence. DeadLetterNoticesStream captures those advisories as the +// server publishes them, so one said while no controller listens is still there when one starts. The +// controller's consumer on it fetches the given-up message by its sequence, while the source stream +// still holds it, and keeps a copy in DeadLettersStream under DeadLetterSubject, with the consumer, +// the subject, how often it was handed over and when it was given up. It stays there until a person +// delivers it again or drops it, with why; a condition is open for as long as it does. +const ( + DeadLetterNoticesStream = "DEAD_LETTER_NOTICES" + DeadLettersStream = "DEAD_LETTERS" + // MaxDeliveriesAdvisories is the subject the server says a given-up message on. + MaxDeliveriesAdvisories = "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>" + deadLetterPrefix = "mesh.events.dead." + againPrefix = "mesh.again." + // DeadLettersBytes bounds the kept copies. Full, the stream refuses the next copy, which is said + // as the consumer's condition; it never drops one it holds. + DeadLettersBytes = 256 << 20 +) + +// DeadLetterSubject is where a message one consumer gave up on is kept: `mesh.events.dead..`. +func DeadLetterSubject(stream, consumer string) string { + return deadLetterPrefix + stream + "." + consumer +} + +// DeadLetterOf is the stream and consumer a kept message's subject names; false for any other subject. +func DeadLetterOf(subject string) (stream, consumer string, ok bool) { + rest, found := strings.CutPrefix(subject, deadLetterPrefix) + if !found { + return "", "", false + } + stream, consumer, ok = strings.Cut(rest, ".") + return stream, consumer, ok && stream != "" && consumer != "" && !strings.Contains(consumer, ".") +} + +// AgainSubject is where an event given up on is delivered again to the one consumer that gave it up, +// and to nobody else: `mesh.again..` and the original subject without its `mesh.`. Every +// consumer on EVENTS filters its own (AgainFilter), and a module's runtime reads the event's key from +// the tokens around `.event.`, so the handler sees the same key it saw the first time. +func AgainSubject(consumer, original string) string { + return againPrefix + consumer + "." + strings.TrimPrefix(original, "mesh.") +} + +// AgainFilter is the one consumer's share of the subjects events are delivered again on. +func AgainFilter(consumer string) string { return againPrefix + consumer + ".>" } + +// OriginalOfAgain is the subject an event delivered again was first published on; false for a subject +// that is not one delivered again. +func OriginalOfAgain(subject string) (string, bool) { + rest, found := strings.CutPrefix(subject, againPrefix) + if !found { + return "", false + } + _, original, ok := strings.Cut(rest, ".") + if !ok || original == "" { + return "", false + } + return "mesh." + original, true +} + +// MeshStreams is the foundation set, in the order a person reads it. +// +// **EVENTS filters on the `event` token**, which is the reason that token exists. A module's +// namespace carries both its events and its tool calls; a filter of `mesh.mod.*.>` would persist +// every tool invocation in the mesh, and a tool call must never be persisted (design 25 §3 keeps +// tools on core NATS, where a lost call is a timeout the caller already handles). +func MeshStreams() []Stream { + return []Stream{ + // CONTROL, the ended controller's work queue of what nodes reported, is no longer the mesh's (novox/hq ADR 0590 + // clause 6): not declared here, which would make it again; the bus module's tools remove it. + { + Name: "NODES", + Subjects: []string{"mesh.node.*.declare"}, + Retention: RetentionLastPerSubject, + Why: "one declaration per node, always the newest; a node that sees sequence n refuses " + + "n-1 by construction (issue 107)", + }, + // The assignments stream (ADR 0160) is no longer the mesh's: a runtime reads its membership from the grants + // part's state memberships (ADR 0571 clause 7), and the bus module's tools delete the stream at the lease write + // (research 072 row 33), so it is not declared here, which would make it again. + { + Name: EventsStream, + // A seat's own events ride here too: they are 1:many like any event, and the + // `event` token keeps them clear of both the seat's work queue (`accept`) and its + // tools (`tool`), which must not be persisted. + // And an event given up on, delivered again to the one consumer that gave it up + // (novox/hq issue 330): under `mesh.again..`, which only that consumer filters. + Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>", againPrefix + ">"}, + Retention: RetentionLimits, + MaxAge: 7 * 24 * 60 * 60, + MaxMsgsPerSubject: 10000, + // A restricted dead letter delivered again keeps what is left of its stream's age (ADR 0376 §5). + AllowMsgTTL: true, + Why: "a subscriber that was down catches up; tool traffic under the same prefix is " + + "excluded by the event token; per-subject caps keep a noisy emitter from " + + "evicting a quiet one without splitting the stream", + }, + { + Name: DeadLetterNoticesStream, + Subjects: []string{MaxDeliveriesAdvisories}, + Retention: RetentionWorkQueue, + MaxAge: 7 * 24 * 60 * 60, + Why: "the server's word that a consumer gave up on a message, kept until the controller has " + + "copied the message into DEAD_LETTERS (novox/hq issue 330); a week, the longest the source " + + "streams keep what they are about", + }, + { + Name: DeadLettersStream, + Subjects: []string{deadLetterPrefix + ">"}, + Retention: RetentionLimits, + MaxBytes: DeadLettersBytes, + DiscardNew: true, + DuplicatesSeconds: 24 * 60 * 60, + AllowMsgTTL: true, + Why: "every message a consumer gave up on, with its consumer, subject, deliveries and when, kept " + + "until a person delivers it again or drops it with why (novox/hq issue 330); no age but a restricted " + + "stream's message, which ages out with its stream (ADR 0376), and full it refuses the next copy " + + "rather than drop one it holds", + }, + { + Name: ProvAppliedStream, + Subjects: []string{ProvAppliedSubjects}, + Retention: RetentionLastPerSubject, + Direct: true, + MaxAge: 7 * 24 * 60 * 60, + Why: "each machine's provisioner client's newest word on each module's files, applied, waiting or " + + "failed with the reason (novox/hq ADR 0349 §4): what a walk's end and a gate wait for (ADR 0362, " + + "ADR 0349 §15), read by subject; a week, as the events are", + }, + } +} + +// ProvAppliedStream keeps what each machine's provisioner client last said of each module's files, newest per +// subject (novox/hq ADR 0349 §4, ADR 0362, design 57 P6): `prov.applied..` lies outside `mesh.*`, so +// no other stream captures it, and a report said while no controller listened is still there to be read. +const ProvAppliedStream = "PROV_APPLIED" + +// ProvAppliedSubjects is every machine's client's reports: `prov.applied..`, a module's name dots and +// all (ProvisionerAppliedSubject). +const ProvAppliedSubjects = "prov.applied.>" + +// DeliverSubjectFor is where a push consumer's messages land. +// +// **Per consumer, which means per stream as well as per name** (novox/hq 04-ISSUES/146). A push +// consumer delivers onto an ordinary subject, and everything subscribed to that subject gets a +// copy. The controller holds a consumer called `controller` on CONTROL and another called +// `controller` on EVENTS; while both were given `_DELIVER.controller`, the one process holding +// both subscriptions acted on every message twice — a joining machine was enrolled twice from one +// request, and the second enrolment minted a credential that replaced the one the machine had just +// been handed. Every report and every followed event doubled the same way, silently: nothing is +// redelivered, no count is wrong, the work simply happens twice. +// +// The stream belongs in it because the pair is what identifies a consumer — the server scopes a +// durable's name to its stream, and this subject was the one place that scoping was dropped. It +// stays inside what a controller may already subscribe (`_DELIVER.controller.>`). +func DeliverSubjectFor(c Consumer) string { + if c.DeliverSubject != "" { + return c.DeliverSubject + } + return "_DELIVER." + c.Name + "." + c.Stream +} + +// Overlaps reports subject filters claimed by more than one stream. +// +// **Corrected against the server**: an earlier version of this comment said NATS accepts +// overlapping streams and stores the message twice. It does not — it refuses the second stream +// with "subjects overlap with an existing stream" (verified against nats-server 2.10). The check +// still earns its place, for a different reason: the server's refusal arrives when the controller +// is applying, naming one stream, at a moment when the mesh is half-configured. This one arrives +// where the set is written, names both, and cannot reach a running mesh. +// +// It also decides a design question. Because overlap is refused rather than merged, a shared +// EVENTS stream and a per-module stream cannot coexist — the module's would be refused — so +// "one stream for most, its own for a module that wants different retention" is not an option +// the server allows. It is all of one or all of the other. +func Overlaps() []string { + seen := map[string]string{} + var clashes []string + for _, s := range MeshStreams() { + for _, subject := range s.Subjects { + if first, ok := seen[subject]; ok { + clashes = append(clashes, fmt.Sprintf("%s and %s both claim %s", first, s.Name, subject)) + continue + } + seen[subject] = s.Name + } + } + sort.Strings(clashes) + return clashes +} + +// The ended controller's consumers' names (novox/hq ADR 0590 clause 6): kept only so its leftovers are named for their +// removal (ControllerLeftoverConsumers) and the watchman can read whether `controller` on DEAD_LETTER_NOTICES still +// stands. +const ( + ControllerName = "controller" + MergesConsumerName = "controller-merges" +) + +// AsksSeat is the seat an ask is made on and its warrant heard from (novox/hq ADR 0259): the router's. +const AsksSeat = "operator-channel" + +// The provider standing events, by their local names. Written here as well as in the catalogue +// (catalogue.ProvisionerEvents), which this package cannot import; a test keeps them agreeing. +const ( + ProvisionerFailing = "provisioner.failing" + ProvisionerRecovered = "provisioner.recovered" + ProvisionerRetirement = "provisioner.retirement" +) + +// moduleEventSubject is where one module's event lands. The same derivation PermissionsFor uses, so +// what the controller subscribes and what the emitter is permitted to publish cannot drift apart. +func moduleEventSubject(module, event string) string { + return "mesh.mod." + module + ".event." + event +} + +// seatEventSubject is where a role's own event lands, derived the same way a holder's permission is. +func seatEventSubject(seat, verb string) string { + return "mesh.seat." + seat + ".event." + verb +} + +// EventsStream holds every module's and every role's events, a build's log among them. +const EventsStream = "EVENTS" + +// MeshConsumers are the mesh's own durable consumers, beside those derived from declarations: none since the controller +// ended (novox/hq ADR 0590), whose consumers they were. +func MeshConsumers() []Consumer { return nil } diff --git a/modules/mesh-grants/internal/buscompose/testdata/composed.conf b/modules/mesh-grants/internal/buscompose/testdata/composed.conf new file mode 100644 index 0000000..0fe0bb6 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/testdata/composed.conf @@ -0,0 +1,73 @@ +# Composed by the mesh. Do not edit: the next composition overwrites it. +# Accounts and permissions are derived from what each module declares and nothing +# else (novox/hq ADR 0043, design 29 §2). + +port: 4222 +http: 127.0.0.1:8222 + +tls { + cert_file: "/tls/tls.crt" + key_file: "/tls/tls.key" + ca_file: "/tls/ca.crt" +} + +jetstream { + store_dir: "/data" +} + +# The mesh's users, composed by the grants part. Do not edit: the next +# composition overwrites it. Permissions are derived from what each module +# declares and nothing else (novox/hq ADR 0043, design 29 §2). + +accounts { + MESH { + jetstream: enabled + imports = [ + { stream: { account: "$SYS", subject: "$SYS.ACCOUNT.MESH.CONNECT" }, to: "mesh.bus.connect" } + { stream: { account: "$SYS", subject: "$SYS.ACCOUNT.MESH.DISCONNECT" }, to: "mesh.bus.disconnect" } + { service: { account: "$SYS", subject: "$SYS.REQ.ACCOUNT.MESH.CONNZ" }, to: "mesh.bus.connz" } + ] + users = [ + { user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: { + publish: { allow: ["mesh.seat.mesh-enrolment.call.redeem.enrol~one"] } + subscribe: { allow: ["_INBOX.enrol.one.>"] } + } } + { user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: { + publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.CREATE.KV_composed-one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.>", "$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-host_acts.*.$KV.mesh-host_acts.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.one", "$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.one", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.one.>", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.>", "$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*", "$JS.API.CONSUMER.DELETE.KV_composed-one.>", "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.>", "$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-host_acts.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_data.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_health.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.>", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*", "$JS.API.CONSUMER.INFO.NODES.one", "$JS.API.DIRECT.GET.KV_composed-one.>", "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.>", "$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-host_acts.$KV.mesh-host_acts.one.>", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.one.>", "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.one", "$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.one", "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.one.>", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.>", "$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*", "$JS.API.STREAM.INFO.KV_composed-one", "$JS.API.STREAM.INFO.KV_mesh-config_resolved", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants", "$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-host_acts", "$JS.API.STREAM.INFO.KV_mesh-host_data", "$JS.API.STREAM.INFO.KV_mesh-host_health", "$JS.API.STREAM.INFO.KV_mesh-host_packages", "$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions", "$JS.API.STREAM.INFO.KV_node-engine_have", "$JS.FC.KV_composed-one.>", "$JS.FC.KV_mesh-config_resolved.>", "$JS.FC.KV_mesh-delivery-assigner_candidates.>", "$JS.FC.KV_mesh-delivery-assigner_placement.>", "$JS.FC.KV_mesh-delivery-assigner_wants.>", "$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-host_acts.>", "$JS.FC.KV_mesh-host_data.>", "$JS.FC.KV_mesh-host_health.>", "$JS.FC.KV_mesh-host_packages.>", "$JS.FC.KV_mesh-provisioner_provisions.>", "$JS.FC.KV_node-engine_have.>", "$JS.FC.OBJ_declaration-files-one.>", "$KV.mesh-host_acts.one.>", "$KV.mesh-host_data.one.>", "$KV.mesh-host_health.one", "$KV.mesh-host_packages.one", "$KV.node-engine_have.one.>", "$SRV.INFO", "$SRV.INFO.>", "$SRV.PING", "$SRV.PING.>", "$SRV.PING.node-tools.one", "mesh.control.one.>", "mesh.seat.node-backup.call.backed-up.one.node~one", "mesh.seat.node-backup.call.now.one.node~one", "mesh.seat.node-backup.tool.backed-up.one", "mesh.seat.node-backup.tool.now.one", "mesh.seat.node-engine.event.*.one"] } + subscribe: { allow: ["$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement", "$SRV.INFO", "$SRV.INFO.node-engine", "$SRV.INFO.node-engine.>", "$SRV.PING", "$SRV.PING.node-engine", "$SRV.PING.node-engine.>", "$SRV.STATS", "$SRV.STATS.node-engine", "$SRV.STATS.node-engine.>", "_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.mod.restic.event.backed-up", "mesh.node.one.ask.access-act", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.move-secret", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare", "mesh.seat.node-engine.call.*.one.*", "mesh.seat.node-engine.tool.*.one"] } + allow_responses: { max: 1, ttl: "1m" } + } } + { user: "one.mesh-delivery-watchman", password: "$2a$11$wwwwwwwwwwwwwwwwwwwwww", permissions: { + publish: { allow: ["$JS.ACK.DEAD_LETTER_NOTICES.mesh-delivery-watchman.>", "$JS.ACK.EVENTS.one_mesh-delivery-watchman.>", "$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.mesh-delivery-watchman", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.mesh-delivery-watchman", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.mesh-delivery-watchman", "$JS.API.CONSUMER.INFO.EVENTS.one_mesh-delivery-watchman", "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES.mesh-delivery-watchman", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-delivery-watchman", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.mesh-delivery-watchman", "$JS.API.DIRECT.GET.PROV_APPLIED.>", "$JS.API.STREAM.INFO.DEAD_LETTERS", "$JS.API.STREAM.INFO.DEAD_LETTER_NOTICES", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.API.STREAM.INFO.PROV_APPLIED", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS", "$JS.API.STREAM.MSG.GET.PROV_APPLIED", "$JS.FC.KV_mesh-grants_memberships.>", "mesh.bus.connz", "mesh.events.dead.>"] } + subscribe: { allow: ["$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$SRV.INFO", "$SRV.INFO.mesh-delivery-watchman", "$SRV.INFO.mesh-delivery-watchman.>", "$SRV.PING", "$SRV.PING.mesh-delivery-watchman", "$SRV.PING.mesh-delivery-watchman.>", "$SRV.STATS", "$SRV.STATS.mesh-delivery-watchman", "$SRV.STATS.mesh-delivery-watchman.>", "_INBOX.one.mesh-delivery-watchman.>", "mesh.bus.connect", "mesh.bus.disconnect", "mesh.control.*.tools-alive", "mesh.events.dead.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.mesh-delivery-watchman.call.>", "mesh.mod.mesh-delivery-watchman.tool.>", "mesh.mod.nats.event.bus.connection-closed", "mesh.mod.nats.event.bus.slow-consumer", "mesh.mod.power.event.*", "mesh.seat.mesh-self-check.event.checked", "prov.applied.>"] } + allow_responses: { max: 1, ttl: "1m" } + } } + { user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: { + publish: { allow: ["$JS.API.STREAM.INFO.*", "$JS.API.STREAM.NAMES", "$JS.API.STREAM.SNAPSHOT.*", "$JS.SNAPSHOT.ACK.>"] } + subscribe: { allow: ["_INBOX.one.nats.>"] } + } } + { user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: { + publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.telegram", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.telegram", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.FC.KV_mesh-grants_memberships.>", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] } + subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "$SRV.STATS", "$SRV.STATS.telegram", "$SRV.STATS.telegram.>", "_INBOX.one.telegram.>", "mesh.mod.telegram.call.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] } + allow_responses: { max: 1, ttl: "1m" } + } } + { user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: { + publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.audit", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.audit", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.FC.KV_mesh-grants_memberships.>"] } + subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "$SRV.STATS", "$SRV.STATS.audit", "$SRV.STATS.audit.>", "_INBOX.two.audit.>", "mesh.mod.audit.call.>", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] } + allow_responses: { max: 1, ttl: "1m" } + } } + { user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: { + publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.shop", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.shop", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.FC.KV_mesh-grants_memberships.>", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] } + subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "$SRV.STATS", "$SRV.STATS.shop", "$SRV.STATS.shop.>", "_INBOX.two.shop.>", "mesh.mod.shop.call.>", "mesh.mod.shop.tool.>"] } + allow_responses: { max: 1, ttl: "1m" } + } } + ] + } + "$SYS" { + exports = [ + { stream: "$SYS.ACCOUNT.MESH.CONNECT", accounts: ["MESH"] } + { stream: "$SYS.ACCOUNT.MESH.DISCONNECT", accounts: ["MESH"] } + { service: "$SYS.REQ.ACCOUNT.MESH.CONNZ", accounts: ["MESH"] } + ] + } +} diff --git a/modules/mesh-grants/internal/buscompose/testdata/controller-credentials.json b/modules/mesh-grants/internal/buscompose/testdata/controller-credentials.json new file mode 100644 index 0000000..ab11cf1 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/testdata/controller-credentials.json @@ -0,0 +1,21 @@ +{ + "how": "Written by mesh-controller 0fbb5ef's own issueWith, sealProvisionerClient and heldProcessAccount (cmd/mesh-controller), run unchanged in a scratch integration test against a local store with the default seats seeded, on 2026-10-27 (issue 774): each row is the exact value the controller handed the vault. Never edit a row by hand: capture it again.", + "rows": [ + { + "name": "module", + "held": "{\"url\":\"nats://10.0.0.1:4222\",\"fingerprint\":\"ab:cd\",\"node\":\"laptop\",\"module\":\"cap-bot\",\"user\":\"laptop.cap-bot\",\"password\":\"pw-module\",\"claims\":[{\"seat\":\"mesh-vault\",\"scope\":\"mesh\",\"serves\":[\"get\",\"accept\",\"secret-ask\",\"issued\",\"rotate\",\"move-in\",\"node-key\",\"own\",\"move-asks\"]},{\"seat\":\"node-service-manager\",\"scope\":\"node\",\"serves\":[\"status\"]},{\"seat\":\"mesh-controller\",\"scope\":\"mesh\",\"serves\":[\"tools\",\"calls\",\"status\",\"nodes\",\"node\",\"placement-export\",\"modules\",\"seats\",\"builds\",\"plans\",\"delivery-plan\",\"delivery-order\",\"delivery-check\",\"deliver\",\"delivery-stop\",\"delivery-backups-again\",\"delivery-walks\",\"plan\",\"assign\",\"unassign\",\"pin\",\"unpin\",\"push\",\"rotate\",\"give\",\"issued\",\"secret-move\",\"secret-ask\",\"issue\",\"token\",\"settings\",\"command\",\"queue\",\"cancel\",\"clear\",\"replay\",\"try\",\"kill\",\"pause\",\"resume\",\"hand-act\",\"drill\",\"warranted\",\"hand-acts\",\"durations\",\"conditions\",\"healers\",\"doctor\",\"upgrade\",\"bus\",\"retire\",\"cleanup\",\"judge-directories\",\"root-free\",\"dead-letters\",\"data\",\"build\",\"artifacts\",\"held\",\"collect\",\"move-placement\",\"move-config\",\"mirrors\",\"images\"]},{\"seat\":\"no-such-seat\",\"scope\":\"node\"}]}" + }, + { + "name": "module-no-claims-no-fingerprint", + "held": "{\"url\":\"nats://10.0.0.1:4222\",\"node\":\"laptop\",\"module\":\"cap-plain\",\"user\":\"laptop.cap-plain\",\"password\":\"pw-plain\"}" + }, + { + "name": "provisioner", + "held": "{\"url\":\"nats://10.0.0.1:4222\",\"fingerprint\":\"ab:cd\",\"node\":\"laptop\",\"user\":\"provisioner.laptop\",\"password\":\"pw-prov\"}" + }, + { + "name": "process", + "held": "{\"url\":\"nats://10.0.0.1:4222\",\"fingerprint\":\"ab:cd\",\"node\":\"laptop\",\"module\":\"mesh-mail\",\"user\":\"laptop.mesh-mail.send\",\"password\":\"pw-proc\"}" + } + ] +} diff --git a/modules/mesh-grants/internal/buscompose/testdata/user-lists.json b/modules/mesh-grants/internal/buscompose/testdata/user-lists.json new file mode 100644 index 0000000..4851ccc --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/testdata/user-lists.json @@ -0,0 +1,289 @@ +[ + { + "Name": "empty", + "List": "# The mesh's users, composed by the grants part. Do not edit: the next\n# composition overwrites it. Permissions are derived from what each module\n# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\naccounts {\n MESH {\n jetstream: enabled\n imports = [\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.CONNECT\" }, to: \"mesh.bus.connect\" }\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.DISCONNECT\" }, to: \"mesh.bus.disconnect\" }\n { service: { account: \"$SYS\", subject: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\" }, to: \"mesh.bus.connz\" }\n ]\n users = [\n ]\n }\n \"$SYS\" {\n exports = [\n { stream: \"$SYS.ACCOUNT.MESH.CONNECT\", accounts: [\"MESH\"] }\n { stream: \"$SYS.ACCOUNT.MESH.DISCONNECT\", accounts: [\"MESH\"] }\n { service: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\", accounts: [\"MESH\"] }\n ]\n }\n}\n", + "Missing": null, + "Err": "", + "Memberships": {} + }, + { + "Name": "the controller's someRecords", + "List": "# The mesh's users, composed by the grants part. Do not edit: the next\n# composition overwrites it. Permissions are derived from what each module\n# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\naccounts {\n MESH {\n jetstream: enabled\n imports = [\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.CONNECT\" }, to: \"mesh.bus.connect\" }\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.DISCONNECT\" }, to: \"mesh.bus.disconnect\" }\n { service: { account: \"$SYS\", subject: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\" }, to: \"mesh.bus.connz\" }\n ]\n users = [\n { user: \"node.one\", password: \"$2a$11$node.onenode.onenode.ofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*\", \"$JS.API.CONSUMER.INFO.NODES.one\", \"$JS.API.DIRECT.GET.KV_composed-one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.one\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.one\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.one.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*\", \"$JS.API.STREAM.INFO.KV_composed-one\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$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-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-one.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-one.\u003e\", \"$KV.mesh-host_acts.one.\u003e\", \"$KV.mesh-host_data.one.\u003e\", \"$KV.mesh-host_health.one\", \"$KV.mesh-host_packages.one\", \"$KV.node-engine_have.one.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.one\", \"mesh.control.one.\u003e\", \"mesh.seat.node-backup.call.backed-up.one.node~one\", \"mesh.seat.node-backup.call.now.one.node~one\", \"mesh.seat.node-backup.tool.backed-up.one\", \"mesh.seat.node-backup.tool.now.one\", \"mesh.seat.node-engine.event.*.one\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.one\", \"_DELIVER.one.\u003e\", \"_INBOX.node.one.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.one.ask.access-act\", \"mesh.node.one.ask.hand-over\", \"mesh.node.one.ask.judge-directories\", \"mesh.node.one.ask.move-secret\", \"mesh.node.one.ask.report\", \"mesh.node.one.ask.setuid-search\", \"mesh.node.one.declare\", \"mesh.seat.node-engine.call.*.one.*\", \"mesh.seat.node-engine.tool.*.one\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.two\", password: \"$2a$11$node.twonode.twonode.tfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.two.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-two.*.$O.declaration-files-two.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-two.*\", \"$JS.API.CONSUMER.INFO.NODES.two\", \"$JS.API.DIRECT.GET.KV_composed-two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.two\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.two\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.two.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.two.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-two.$O.declaration-files-two.M.*\", \"$JS.API.STREAM.INFO.KV_composed-two\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$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-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-two.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-two.\u003e\", \"$KV.mesh-host_acts.two.\u003e\", \"$KV.mesh-host_data.two.\u003e\", \"$KV.mesh-host_health.two\", \"$KV.mesh-host_packages.two\", \"$KV.node-engine_have.two.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.two\", \"mesh.control.two.\u003e\", \"mesh.seat.node-backup.call.backed-up.two.node~two\", \"mesh.seat.node-backup.call.now.two.node~two\", \"mesh.seat.node-backup.tool.backed-up.two\", \"mesh.seat.node-backup.tool.now.two\", \"mesh.seat.node-engine.event.*.two\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.two\", \"_DELIVER.two.\u003e\", \"_INBOX.node.two.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.two.ask.access-act\", \"mesh.node.two.ask.hand-over\", \"mesh.node.two.ask.judge-directories\", \"mesh.node.two.ask.move-secret\", \"mesh.node.two.ask.report\", \"mesh.node.two.ask.setuid-search\", \"mesh.node.two.declare\", \"mesh.seat.node-engine.call.*.two.*\", \"mesh.seat.node-engine.tool.*.two\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.telegram\", password: \"$2a$11$one.telegramone.telegrfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_telegram.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_telegram\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.telegram\", \"$SRV.INFO.telegram.\u003e\", \"$SRV.PING\", \"$SRV.PING.telegram\", \"$SRV.PING.telegram.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.telegram\", \"$SRV.STATS.telegram.\u003e\", \"_INBOX.one.telegram.\u003e\", \"mesh.mod.telegram.call.\u003e\", \"mesh.mod.telegram.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"person.ada\", password: \"$2a$11$person.adaperson.adapefixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"mesh.mod.mesh-catalog.call.catalog_tools.*.person~ada\", \"mesh.mod.mesh-catalog.call.catalog_tools.person~ada\", \"mesh.mod.mesh-catalog.tool.catalog_tools\", \"mesh.mod.mesh-catalog.tool.catalog_tools.*\"] }\n subscribe: { allow: [\"_INBOX.person.ada.\u003e\"] }\n } }\n { user: \"two.shop\", password: \"$2a$11$two.shoptwo.shoptwo.shfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.two_shop.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.two_shop\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"mesh.mod.shop.event.order.placed\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.shop\", \"$SRV.INFO.shop.\u003e\", \"$SRV.PING\", \"$SRV.PING.shop\", \"$SRV.PING.shop.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.shop\", \"$SRV.STATS.shop.\u003e\", \"_INBOX.two.shop.\u003e\", \"mesh.mod.shop.call.\u003e\", \"mesh.mod.shop.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n \"$SYS\" {\n exports = [\n { stream: \"$SYS.ACCOUNT.MESH.CONNECT\", accounts: [\"MESH\"] }\n { stream: \"$SYS.ACCOUNT.MESH.DISCONNECT\", accounts: [\"MESH\"] }\n { service: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\", accounts: [\"MESH\"] }\n ]\n }\n}\n", + "Missing": null, + "Err": "", + "Memberships": { + "one.telegram": { + "node": "one", + "module": "telegram", + "serves": [ + { + "subject": "mesh.mod.telegram.tool.{tool}.one" + }, + { + "subject": "mesh.mod.telegram.tool.{tool}", + "queue": "serve.telegram" + } + ], + "emits": "mesh.mod.telegram.event.{event}", + "tools": "mesh.mod.telegram.tool.tools" + }, + "two.shop": { + "node": "two", + "module": "shop", + "serves": [ + { + "subject": "mesh.mod.shop.tool.{tool}.two" + }, + { + "subject": "mesh.mod.shop.tool.{tool}", + "queue": "serve.shop" + } + ], + "emits": "mesh.mod.shop.event.{event}", + "tools": "mesh.mod.shop.tool.tools" + } + } + }, + { + "Name": "seats, consumers, people and a hashless user", + "List": "# The mesh's users, composed by the grants part. Do not edit: the next\n# composition overwrites it. Permissions are derived from what each module\n# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\naccounts {\n MESH {\n jetstream: enabled\n imports = [\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.CONNECT\" }, to: \"mesh.bus.connect\" }\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.DISCONNECT\" }, to: \"mesh.bus.disconnect\" }\n { service: { account: \"$SYS\", subject: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\" }, to: \"mesh.bus.connz\" }\n ]\n users = [\n { user: \"node.one\", password: \"$2a$11$node.onenode.onenode.ofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_user-list.*.$KV.mesh-grants_user-list.sealed\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*\", \"$JS.API.CONSUMER.CREATE.OBJ_mesh-grants-user-list.*.$O.mesh-grants-user-list.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_user-list.*\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*\", \"$JS.API.CONSUMER.DELETE.OBJ_mesh-grants-user-list.*\", \"$JS.API.CONSUMER.INFO.NODES.one\", \"$JS.API.DIRECT.GET.KV_composed-one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.one\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.one\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.one.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*\", \"$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.*\", \"$JS.API.STREAM.INFO.KV_composed-one\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$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-grants_user-list\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-one.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-grants_user-list.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-one.\u003e\", \"$JS.FC.OBJ_mesh-grants-user-list.\u003e\", \"$KV.mesh-host_acts.one.\u003e\", \"$KV.mesh-host_data.one.\u003e\", \"$KV.mesh-host_health.one\", \"$KV.mesh-host_packages.one\", \"$KV.node-engine_have.one.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.one\", \"mesh.control.one.\u003e\", \"mesh.seat.node-backup.call.backed-up.one.node~one\", \"mesh.seat.node-backup.call.now.one.node~one\", \"mesh.seat.node-backup.tool.backed-up.one\", \"mesh.seat.node-backup.tool.now.one\", \"mesh.seat.node-engine.event.*.one\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.one\", \"_DELIVER.one.\u003e\", \"_INBOX.node.one.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.one.ask.access-act\", \"mesh.node.one.ask.hand-over\", \"mesh.node.one.ask.judge-directories\", \"mesh.node.one.ask.move-secret\", \"mesh.node.one.ask.report\", \"mesh.node.one.ask.setuid-search\", \"mesh.node.one.declare\", \"mesh.seat.node-engine.call.*.one.*\", \"mesh.seat.node-engine.tool.*.one\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.two\", password: \"$2a$11$node.twonode.twonode.tfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.two.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-two.*.$O.declaration-files-two.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-two.*\", \"$JS.API.CONSUMER.INFO.NODES.two\", \"$JS.API.DIRECT.GET.KV_composed-two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.two\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.two\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.two.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.two.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-two.$O.declaration-files-two.M.*\", \"$JS.API.STREAM.INFO.KV_composed-two\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$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-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-two.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-two.\u003e\", \"$KV.mesh-host_acts.two.\u003e\", \"$KV.mesh-host_data.two.\u003e\", \"$KV.mesh-host_health.two\", \"$KV.mesh-host_packages.two\", \"$KV.node-engine_have.two.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.two\", \"mesh.control.two.\u003e\", \"mesh.seat.node-backup.call.backed-up.two.node~two\", \"mesh.seat.node-backup.call.now.two.node~two\", \"mesh.seat.node-backup.tool.backed-up.two\", \"mesh.seat.node-backup.tool.now.two\", \"mesh.seat.node-engine.event.*.two\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.two\", \"_DELIVER.two.\u003e\", \"_INBOX.node.two.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.two.ask.access-act\", \"mesh.node.two.ask.hand-over\", \"mesh.node.two.ask.judge-directories\", \"mesh.node.two.ask.move-secret\", \"mesh.node.two.ask.report\", \"mesh.node.two.ask.setuid-search\", \"mesh.node.two.declare\", \"mesh.seat.node-engine.call.*.two.*\", \"mesh.seat.node-engine.tool.*.two\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.mesh-grants\", password: \"$2a$11$one.mesh-grantsone.mesfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_mesh-grants.\u003e\", \"$JS.API.CONSUMER.CREATE.EVENTS.two_audit\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.mesh-grants\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_mesh-grants\", \"$JS.API.CONSUMER.INFO.EVENTS.two_audit\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-grants\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.*.*\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.mesh-grants\", \"$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.\u003e\", \"$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$O.mesh-grants-user-list.\u003e\", \"mesh.assignment.*.*\", \"mesh.mod.mesh-grants.event.membership-changed\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.mesh-grants\", \"$SRV.INFO.mesh-grants.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-grants\", \"$SRV.PING.mesh-grants.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-grants\", \"$SRV.STATS.mesh-grants.\u003e\", \"_INBOX.one.mesh-grants.\u003e\", \"mesh.mod.mesh-grants.call.\u003e\", \"mesh.mod.mesh-grants.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.mesh-watchman\", password: \"$2a$11$one.mesh-watchmanone.mfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_mesh-watchman.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.mesh-watchman\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_mesh-watchman\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-watchman\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.mesh-watchman\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.mesh-watchman\", \"$SRV.INFO.mesh-watchman.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-watchman\", \"$SRV.PING.mesh-watchman.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-watchman\", \"$SRV.STATS.mesh-watchman.\u003e\", \"_INBOX.one.mesh-watchman.\u003e\", \"mesh.mod.mesh-watchman.call.\u003e\", \"mesh.mod.mesh-watchman.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.nats\", password: \"$2a$11$one.natsone.natsone.nafixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.API.STREAM.INFO.*\", \"$JS.API.STREAM.NAMES\", \"$JS.API.STREAM.SNAPSHOT.*\", \"$JS.SNAPSHOT.ACK.\u003e\"] }\n subscribe: { allow: [\"_INBOX.one.nats.\u003e\"] }\n } }\n { user: \"one.telegram\", password: \"$2a$11$one.telegramone.telegrfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_telegram.\u003e\", \"$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_telegram\", \"$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram\", \"$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"mesh.seat.telegram-sender.event.delivered\", \"mesh.seat.telegram-sender.event.failed\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.telegram\", \"$SRV.INFO.telegram.\u003e\", \"$SRV.PING\", \"$SRV.PING.telegram\", \"$SRV.PING.telegram.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.telegram\", \"$SRV.STATS.telegram.\u003e\", \"_INBOX.one.telegram.\u003e\", \"mesh.mod.telegram.call.\u003e\", \"mesh.mod.telegram.tool.\u003e\", \"mesh.seat.telegram-sender.accept.send\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"person.ada\", password: \"$2a$11$person.adaperson.adapefixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"mesh.mod.*.call.*.*.person~ada\", \"mesh.mod.*.call.*.person~ada\", \"mesh.mod.*.tool.\u003e\", \"mesh.seat.*.call.*.*.person~ada\", \"mesh.seat.*.call.*.person~ada\", \"mesh.seat.*.tool.\u003e\"], deny: [\"mesh.seat.node-launcher.tool.secret\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.mod.*.tool.node-launcher.secret\", \"mesh.mod.*.tool.node-launcher.secret.*\", \"mesh.seat.node-launcher.call.secret.\u003e\", \"mesh.mod.*.call.node-launcher.secret.\u003e\"] }\n subscribe: { allow: [\"_INBOX.person.ada.\u003e\"] }\n } }\n { user: \"person.bob\", password: \"$2a$11$person.bobperson.bobpefixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"mesh.mod.shop.call.status.*.person~bob\", \"mesh.mod.shop.call.status.person~bob\", \"mesh.mod.shop.tool.status\", \"mesh.mod.shop.tool.status.*\"] }\n subscribe: { allow: [\"_INBOX.person.bob.\u003e\"] }\n } }\n { user: \"two.shop\", password: \"$2a$11$two.shoptwo.shoptwo.shfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.two_shop.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.two_shop\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"mesh.mod.shop.event.order.placed\", \"mesh.seat.telegram-sender.accept.send\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.shop\", \"$SRV.INFO.shop.\u003e\", \"$SRV.PING\", \"$SRV.PING.shop\", \"$SRV.PING.shop.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.shop\", \"$SRV.STATS.shop.\u003e\", \"_INBOX.two.shop.\u003e\", \"mesh.mod.shop.call.\u003e\", \"mesh.mod.shop.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n \"$SYS\" {\n exports = [\n { stream: \"$SYS.ACCOUNT.MESH.CONNECT\", accounts: [\"MESH\"] }\n { stream: \"$SYS.ACCOUNT.MESH.DISCONNECT\", accounts: [\"MESH\"] }\n { service: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\", accounts: [\"MESH\"] }\n ]\n }\n}\n", + "Missing": [ + "two.audit" + ], + "Err": "", + "Memberships": { + "one.mesh-grants": { + "node": "one", + "module": "mesh-grants", + "serves": [ + { + "subject": "mesh.mod.mesh-grants.tool.{tool}.one" + }, + { + "subject": "mesh.mod.mesh-grants.tool.{tool}", + "queue": "serve.mesh-grants" + } + ], + "emits": "mesh.mod.mesh-grants.event.{event}", + "tools": "mesh.mod.mesh-grants.tool.tools" + }, + "one.mesh-watchman": { + "node": "one", + "module": "mesh-watchman", + "serves": [ + { + "subject": "mesh.mod.mesh-watchman.tool.{tool}.one" + }, + { + "subject": "mesh.mod.mesh-watchman.tool.{tool}", + "queue": "serve.mesh-watchman" + } + ], + "emits": "mesh.mod.mesh-watchman.event.{event}", + "tools": "mesh.mod.mesh-watchman.tool.tools" + }, + "one.nats": { + "node": "one", + "module": "nats", + "serves": [ + { + "subject": "mesh.mod.nats.tool.{tool}.one" + }, + { + "subject": "mesh.mod.nats.tool.{tool}", + "queue": "serve.nats" + } + ], + "emits": "mesh.mod.nats.event.{event}", + "tools": "mesh.mod.nats.tool.tools" + }, + "one.telegram": { + "node": "one", + "module": "telegram", + "serves": [ + { + "subject": "mesh.mod.telegram.tool.{tool}.one" + }, + { + "subject": "mesh.mod.telegram.tool.{tool}", + "queue": "serve.telegram" + } + ], + "emits": "mesh.mod.telegram.event.{event}", + "tools": "mesh.mod.telegram.tool.tools", + "seat-traffic": { + "publish": [ + "mesh.seat.telegram-sender.event.delivered", + "mesh.seat.telegram-sender.event.failed" + ] + } + }, + "two.audit": { + "node": "two", + "module": "audit", + "serves": [ + { + "subject": "mesh.mod.audit.tool.{tool}.two" + }, + { + "subject": "mesh.mod.audit.tool.{tool}", + "queue": "serve.audit" + } + ], + "emits": "mesh.mod.audit.event.{event}", + "tools": "mesh.mod.audit.tool.tools" + }, + "two.shop": { + "node": "two", + "module": "shop", + "serves": [ + { + "subject": "mesh.mod.shop.tool.{tool}.two" + }, + { + "subject": "mesh.mod.shop.tool.{tool}", + "queue": "serve.shop" + } + ], + "emits": "mesh.mod.shop.event.{event}", + "tools": "mesh.mod.shop.tool.tools" + } + } + }, + { + "Name": "state, logs, streams, queues and processes", + "List": "# The mesh's users, composed by the grants part. Do not edit: the next\n# composition overwrites it. Permissions are derived from what each module\n# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\naccounts {\n MESH {\n jetstream: enabled\n imports = [\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.CONNECT\" }, to: \"mesh.bus.connect\" }\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.DISCONNECT\" }, to: \"mesh.bus.disconnect\" }\n { service: { account: \"$SYS\", subject: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\" }, to: \"mesh.bus.connz\" }\n ]\n users = [\n { user: \"ace.plex\", password: \"$2a$11$ace.plexace.plexace.plfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.ace_plex.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.ace.plex\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.ace_plex\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.ace_plex\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.ace.plex\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.plex\", \"$SRV.INFO.plex.\u003e\", \"$SRV.PING\", \"$SRV.PING.plex\", \"$SRV.PING.plex.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.plex\", \"$SRV.STATS.plex.\u003e\", \"_INBOX.ace.plex.\u003e\", \"mesh.mod.plex.call.\u003e\", \"mesh.mod.plex.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.ace\", password: \"$2a$11$node.acenode.acenode.afixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.ace\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.ace\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-ace.*.$O.declaration-files-ace.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-ace.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-ace.*\", \"$JS.API.CONSUMER.INFO.NODES.ace\", \"$JS.API.DIRECT.GET.KV_composed-ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.ace\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.ace\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.ace.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-ace.$O.declaration-files-ace.M.*\", \"$JS.API.STREAM.INFO.KV_composed-ace\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$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-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-ace.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-ace.\u003e\", \"$KV.mesh-host_acts.ace.\u003e\", \"$KV.mesh-host_data.ace.\u003e\", \"$KV.mesh-host_health.ace\", \"$KV.mesh-host_packages.ace\", \"$KV.node-engine_have.ace.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.ace\", \"mesh.control.ace.\u003e\", \"mesh.mod.plex.call.plex_status.ace.node~ace\", \"mesh.mod.plex.tool.plex_status.ace\", \"mesh.seat.node-backup.call.backed-up.ace.node~ace\", \"mesh.seat.node-backup.call.now.ace.node~ace\", \"mesh.seat.node-backup.tool.backed-up.ace\", \"mesh.seat.node-backup.tool.now.ace\", \"mesh.seat.node-engine.event.*.ace\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.ace\", \"_DELIVER.ace.\u003e\", \"_INBOX.node.ace.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.ace.ask.access-act\", \"mesh.node.ace.ask.hand-over\", \"mesh.node.ace.ask.judge-directories\", \"mesh.node.ace.ask.move-secret\", \"mesh.node.ace.ask.report\", \"mesh.node.ace.ask.setuid-search\", \"mesh.node.ace.declare\", \"mesh.seat.node-engine.call.*.ace.*\", \"mesh.seat.node-engine.tool.*.ace\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.novox\", password: \"$2a$11$node.novoxnode.novoxnofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-novox.*.$O.declaration-files-novox.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-novox.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-novox.*\", \"$JS.API.CONSUMER.INFO.NODES.novox\", \"$JS.API.DIRECT.GET.KV_composed-novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.novox\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.novox\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.novox.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-novox.$O.declaration-files-novox.M.*\", \"$JS.API.STREAM.INFO.KV_composed-novox\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$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-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-novox.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-novox.\u003e\", \"$KV.mesh-host_acts.novox.\u003e\", \"$KV.mesh-host_data.novox.\u003e\", \"$KV.mesh-host_health.novox\", \"$KV.mesh-host_packages.novox\", \"$KV.node-engine_have.novox.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.novox\", \"mesh.control.novox.\u003e\", \"mesh.seat.node-backup.call.backed-up.novox.node~novox\", \"mesh.seat.node-backup.call.now.novox.node~novox\", \"mesh.seat.node-backup.tool.backed-up.novox\", \"mesh.seat.node-backup.tool.now.novox\", \"mesh.seat.node-engine.event.*.novox\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.novox\", \"_DELIVER.novox.\u003e\", \"_INBOX.node.novox.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.novox.ask.access-act\", \"mesh.node.novox.ask.hand-over\", \"mesh.node.novox.ask.judge-directories\", \"mesh.node.novox.ask.move-secret\", \"mesh.node.novox.ask.report\", \"mesh.node.novox.ask.setuid-search\", \"mesh.node.novox.declare\", \"mesh.seat.node-engine.call.*.novox.*\", \"mesh.seat.node-engine.tool.*.novox\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-delivery-builder\", password: \"$2a$11$novox.mesh-delivery-bufixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-delivery-builder.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_presence.*.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-delivery-builder\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-delivery-builder\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-delivery-builder\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_presence.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-delivery-builder\", \"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_builds\", \"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_builds.\u003e\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_NODE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_NODE\", \"$JS.FC.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$KV.mesh-delivery-builder_presence.novox\", \"$KV.mesh-delivery-builder_steps.\u003e\", \"mesh.log.mesh-delivery-builder.builds.\u003e\", \"mesh.mod.gitea.call.gitea_commit.*.novox~mesh-delivery-builder\", \"mesh.mod.gitea.call.gitea_commit.novox~mesh-delivery-builder\", \"mesh.mod.gitea.tool.gitea_commit\", \"mesh.mod.gitea.tool.gitea_commit.*\", \"mesh.mod.mesh-delivery-builder.event.built\", \"mesh.mod.mesh-delivery-builder.event.states\", \"mesh.stream.mesh-delivery-builder.event.ask\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_presence\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"$SRV.INFO\", \"$SRV.INFO.mesh-delivery-builder\", \"$SRV.INFO.mesh-delivery-builder.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-delivery-builder\", \"$SRV.PING.mesh-delivery-builder.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-delivery-builder\", \"$SRV.STATS.mesh-delivery-builder.\u003e\", \"_INBOX.novox.mesh-delivery-builder.\u003e\", \"mesh.mod.mesh-delivery-builder.call.\u003e\", \"mesh.mod.mesh-delivery-builder.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-delivery-builder.worker\", password: \"$2a$11$novox.mesh-delivery-bufixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.asks.novox_mesh-delivery-builder_worker.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.INFO.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.MSG.NEXT.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$KV.mesh-delivery-builder_steps.\u003e\", \"mesh.mod.gitea.call.gitea_commit.*.novox~mesh-delivery-builder~worker\", \"mesh.mod.gitea.call.gitea_commit.novox~mesh-delivery-builder~worker\", \"mesh.mod.gitea.tool.gitea_commit\", \"mesh.mod.gitea.tool.gitea_commit.*\", \"mesh.mod.mesh-delivery-builder.event.built\", \"mesh.mod.mesh-delivery-builder.event.states\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"_INBOX.novox.mesh-delivery-builder.worker.\u003e\"] }\n } }\n { user: \"novox.mesh-delivery-view\", password: \"$2a$11$novox.mesh-delivery-vifixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-delivery-view.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_presence.*.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-delivery-view\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-delivery-view\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_RECHECK_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-delivery-view\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_presence.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-delivery-view\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_NODE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_NODE\", \"$JS.FC.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_presence\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"$SRV.INFO\", \"$SRV.INFO.mesh-delivery-view\", \"$SRV.INFO.mesh-delivery-view.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-delivery-view\", \"$SRV.PING.mesh-delivery-view.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-delivery-view\", \"$SRV.STATS.mesh-delivery-view.\u003e\", \"_INBOX.novox.mesh-delivery-view.\u003e\", \"mesh.mod.mesh-delivery-view.call.\u003e\", \"mesh.mod.mesh-delivery-view.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-grants\", password: \"$2a$11$novox.mesh-grantsnovoxfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-grants.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_assertions.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-grants\", \"$JS.API.CONSUMER.CREATE.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_assertions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-grants\", \"$JS.API.CONSUMER.INFO.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-grants\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.*.*\", \"$JS.API.DIRECT.GET.KV_mesh-grants_assertions.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-grants\", \"$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.\u003e\", \"$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.CREATE.KV_mesh-grants_assertions\", \"$JS.API.STREAM.CREATE.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.CREATE.asks\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_assertions\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.INFO.asks\", \"$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list\", \"$JS.FC.KV_mesh-grants_assertions.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$KV.mesh-grants_assertions.\u003e\", \"$O.mesh-grants-user-list.\u003e\", \"mesh.assignment.*.*\", \"mesh.mod.mesh-grants.event.membership-changed\", \"mesh.mod.mesh-grants.event.states\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_assertions\", \"$SRV.INFO\", \"$SRV.INFO.mesh-grants\", \"$SRV.INFO.mesh-grants.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-grants\", \"$SRV.PING.mesh-grants.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-grants\", \"$SRV.STATS.mesh-grants.\u003e\", \"_INBOX.novox.mesh-grants.\u003e\", \"mesh.mod.mesh-grants.call.\u003e\", \"mesh.mod.mesh-grants.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n \"$SYS\" {\n exports = [\n { stream: \"$SYS.ACCOUNT.MESH.CONNECT\", accounts: [\"MESH\"] }\n { stream: \"$SYS.ACCOUNT.MESH.DISCONNECT\", accounts: [\"MESH\"] }\n { service: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\", accounts: [\"MESH\"] }\n ]\n }\n}\n", + "Missing": null, + "Err": "", + "Memberships": { + "ace.plex": { + "node": "ace", + "module": "plex", + "serves": [ + { + "subject": "mesh.mod.plex.tool.{tool}.ace" + }, + { + "subject": "mesh.mod.plex.tool.{tool}", + "queue": "serve.plex" + } + ], + "emits": "mesh.mod.plex.event.{event}", + "tools": "mesh.mod.plex.tool.tools" + }, + "ace.silent": { + "node": "ace", + "module": "silent", + "serves": [ + { + "subject": "mesh.mod.silent.tool.{tool}.ace" + }, + { + "subject": "mesh.mod.silent.tool.{tool}", + "queue": "serve.silent" + } + ], + "emits": "mesh.mod.silent.event.{event}", + "tools": "mesh.mod.silent.tool.tools" + }, + "novox.mesh-delivery-builder": { + "node": "novox", + "module": "mesh-delivery-builder", + "serves": [ + { + "subject": "mesh.mod.mesh-delivery-builder.tool.{tool}.novox" + }, + { + "subject": "mesh.mod.mesh-delivery-builder.tool.{tool}", + "queue": "serve.mesh-delivery-builder" + } + ], + "emits": "mesh.mod.mesh-delivery-builder.event.{event}", + "reaches": { + "gitea.gitea_commit": [ + "mesh.mod.gitea.tool.gitea_commit" + ] + }, + "tools": "mesh.mod.mesh-delivery-builder.tool.tools", + "state": [ + { + "name": "steps", + "bucket": "mesh-delivery-builder_steps", + "writes": true + }, + { + "name": "presence", + "bucket": "mesh-delivery-builder_presence", + "writes": true, + "key": "novox" + } + ], + "logs": [ + { + "name": "builds", + "stream": "LOG_mesh-delivery-builder_builds", + "subject": "mesh.log.mesh-delivery-builder.builds", + "writes": true + } + ], + "streams": [ + { + "stream": "asks", + "events": [ + "ask" + ], + "subject": "mesh.stream.mesh-delivery-builder.event.{event}" + } + ] + }, + "novox.mesh-delivery-view": { + "node": "novox", + "module": "mesh-delivery-view", + "serves": [ + { + "subject": "mesh.mod.mesh-delivery-view.tool.{tool}.novox" + }, + { + "subject": "mesh.mod.mesh-delivery-view.tool.{tool}", + "queue": "serve.mesh-delivery-view" + } + ], + "emits": "mesh.mod.mesh-delivery-view.event.{event}", + "tools": "mesh.mod.mesh-delivery-view.tool.tools", + "state": [ + { + "name": "mesh-delivery-builder.steps", + "bucket": "mesh-delivery-builder_steps" + }, + { + "name": "mesh-delivery-builder.presence", + "bucket": "mesh-delivery-builder_presence", + "key": "novox" + } + ] + }, + "novox.mesh-grants": { + "node": "novox", + "module": "mesh-grants", + "serves": [ + { + "subject": "mesh.mod.mesh-grants.tool.{tool}.novox" + }, + { + "subject": "mesh.mod.mesh-grants.tool.{tool}", + "queue": "serve.mesh-grants" + } + ], + "emits": "mesh.mod.mesh-grants.event.{event}", + "tools": "mesh.mod.mesh-grants.tool.tools", + "state": [ + { + "name": "assertions", + "bucket": "mesh-grants_assertions", + "writes": true + } + ] + } + } + } +] diff --git a/modules/mesh-grants/internal/buscompose/userlist_switch.go b/modules/mesh-grants/internal/buscompose/userlist_switch.go new file mode 100644 index 0000000..11ea7b9 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/userlist_switch.go @@ -0,0 +1,174 @@ +package buscompose + +import ( + "slices" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/health" +) + +// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clause 7, issue 1945). The grants part writes +// the list it composed as one object of its own object bucket, sealed to the key of the node holding mesh-broker, and +// names it in one key of its own state `user-list`. The node-engine of that node follows the key when the switch says +// so (clause 8), opens the object and writes the bus's user-list file. +// +// Who may do what, and nothing more: +// +// - the grants part writes its object bucket (create, put, purge of an object's old chunks, delete) and its own state +// as before; +// - the node-engine of the node holding mesh-broker reads that one key of the state, watched, and an object of the +// bucket by its name: never the bucket's list of names, never a write, never another key of the grants part's state; +// - no other principal touches either. + +// GrantsUserListBucket, GrantsUserListKey, GrantsUserListObjects and GrantsUserListObjectStream are delivery's names, +// here where the grants are composed. +const ( + GrantsUserListBucket = delivery.GrantsUserListBucket + GrantsUserListKey = delivery.GrantsUserListKey + GrantsUserListObjects = delivery.GrantsUserListObjects + GrantsUserListObjectStream = delivery.GrantsUserListObjectStream +) + +// GrantsUserListFollows is what the node-engine of the node holding mesh-broker may publish to follow the grants part's +// list: the one key `user-list.sealed` read and watched (its own ordered consumer, filtered on that key alone), and an +// object of the bucket by its name, as a node reads its declaration's files (ADR 0486 §2: never STREAM.INFO of the object +// stream, which lists every name, and never STREAM.MSG.GET). +func GrantsUserListFollows() []string { + kv := "KV_" + GrantsUserListBucket + key := "$KV." + GrantsUserListBucket + "." + GrantsUserListKey + obj, s := GrantsUserListObjects, GrantsUserListObjectStream + return []string{ + "$JS.API.DIRECT.GET." + kv + "." + key, + "$JS.API.CONSUMER.CREATE." + kv + ".*." + key, + "$JS.API.CONSUMER.DELETE." + kv + ".*", + "$JS.API.STREAM.INFO." + kv, + "$JS.FC." + kv + ".>", + "$JS.API.DIRECT.GET." + s + ".$O." + obj + ".M.*", + "$JS.API.CONSUMER.CREATE." + s + ".*.$O." + obj + ".C.*", + "$JS.API.CONSUMER.DELETE." + s + ".*", + "$JS.FC." + s + ".>", + } +} + +// GrantsUserListWrites is what the grants part may publish to keep its object bucket: create it and read its info back, +// put an object (its chunks and its metadata, the old chunks of a name purged), read an object's metadata, and delete +// one. Its own state `user-list` it writes already, as every module writes its own. +func GrantsUserListWrites() []string { + obj, s := GrantsUserListObjects, GrantsUserListObjectStream + return []string{ + "$JS.API.STREAM.CREATE." + s, + "$JS.API.STREAM.INFO." + s, + "$JS.API.STREAM.PURGE." + s, + "$JS.API.DIRECT.GET." + s + ".>", + "$JS.API.STREAM.MSG.GET." + s, + "$O." + obj + ".>", + } +} + +// grantsOwnObjects is the grants part writing its own object bucket, and nothing of another. +func grantsOwnObjects(p Principal, pattern string) bool { + return p.Kind == KindModule && p.Module == GrantsModule && + (pattern == "$O."+GrantsUserListObjects+".>" || pattern == "$JS.API.STREAM.CREATE."+GrantsUserListObjectStream) +} + +// **Named readers** (novox/hq ADR 0547 clause 9, progressive insight of 2026-10-26; issue 1945). A state named here is +// read by the modules named and by no other, whatever another module's `reads` says: the vault's `bus-accounts` holds +// every bus account's bcrypt hash, and from take-over the grants part alone may read it. A module's announcement may name +// readers too (announce.State.Readers); both are honoured. +const VaultBusAccounts = "mesh-vault.bus-accounts" + +// ArtifactStoreTools is the module holding the seat mesh-artifact-sweep (novox/hq ADR 0571 clause 2). +const ArtifactStoreTools = "artifact-store-tools" + +// ConfigModule is the module that holds the seat mesh-config, a reader of the controller's lease (NamedReaders). +const ConfigModule = "mesh-config" + +// NamedReaders are the states only the modules named may read, by `.`: the grants part's wanted accounts +// are read by the vault alone (novox/hq ADR 0561 §2); nobody reads the healers' `would` (ADR 0564 clause 8, +// health_grants.go). +var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat}, HealersWould: {}, + // ADR 0577 clause 1: the node-engine's data items are read by disk-load alone, each node's its own node's keys + // (NodeKeyedReads); the engine announces nothing of its own, so its readers are named here. + delivery.EngineDataRead: {health.DiskLoadModule}, + // ADR 0583 §1 insight: every node's login is read by the grants part alone, each engine its own node's key through its + // machine principal (NodeLoginsReads), never through `reads`. + delivery.NodeLoginsRead: {GrantsModule}, + // ADR 0587 §2: the open enrolments are read by their holder and, as a named reader, by the grants part. + delivery.EnrolmentsRead: {GrantsModule}} + +// ReaderMay says whether module may read the state read (`.`) by the named readers: always, for a state +// that names none. +func ReaderMay(module, read string) bool { + readers, named := NamedReaders[read] + return !named || slices.Contains(readers, module) +} + +// readsNamed splits a module's reads by the named readers: those it may read, and those named for others. +func readsNamed(module string, reads []string) (granted, refused []string) { + for _, r := range reads { + if ReaderMay(module, strings.TrimSpace(r)) { + granted = append(granted, r) + } else { + refused = append(refused, r) + } + } + return granted, refused +} + +// withAnnouncedReads is d with its reads judged for d's module (ReadsAnnouncedBy) against what the owners announced: +// every read its manifest names, granted or refused by the caller, judged again here, so a caller that split them +// without the reader (the controller's own copy of a reader-less judgement, issue 774: the vault, the assigner, the +// grants part and the artifact store's tools refused the lease they are named for) cannot refuse a read the reader is +// named for, nor grant one named for another. With no announcements read it is withNamedReaders. +func withAnnouncedReads(d Declared, announced map[string]announce.States) Declared { + if announced == nil { + return withNamedReaders(d) + } + all := append(append([]string(nil), d.Reads...), d.ReadsRefused...) + if len(all) == 0 { + return d + } + d.Reads, d.ReadsRefused = ReadsAnnouncedBy(d.Module, all, announced) + return d +} + +// withNamedReaders is d with the reads named for other modules moved to its refused reads. +func withNamedReaders(d Declared) Declared { + granted, refused := readsNamed(d.Module, d.Reads) + if len(refused) == 0 { + return d + } + d.Reads = granted + d.ReadsRefused = append(append([]string(nil), d.ReadsRefused...), refused...) + return d +} + +// **The hearings the grants part withholds, on the bus** (issue 1955 L7, mesh-delivery e564eaf): a hearing of a restricted +// stream a module asks in the tool runner, withheld because only an account of its own may hear it (ADR 0376 §2), kept +// in the grants part's state `withheld`, one key `..` (the controller's condition id +// of it), written on change only and deleted once heard; the grants part's tools read it (D-heard was dropped on +// 2026-10-30, so no other module reads it). +const ( + GrantsWithheldState = "withheld" + GrantsWithheldBucket = GrantsModule + "_" + GrantsWithheldState + GrantsWithheldRead = GrantsModule + "." + GrantsWithheldState + WithheldVersion = 1 +) + +// WithheldHearing is one withheld hearing: the machine, the module, the restricted stream and its emitter, the plain +// words of it and the remedy (Withheld.Said), and when the grants part first wrote it as it stands. +type WithheldHearing struct { + V int `json:"v"` + Node string `json:"node"` + Module string `json:"module"` + Stream string `json:"stream"` + Emitter string `json:"emitter"` + Said string `json:"said"` + At time.Time `json:"at"` +} + +// WithheldKey is a withheld hearing's key: `..`. +func WithheldKey(w Withheld) string { return w.Module + "." + strings.ToLower(w.Stream) + "." + w.Node } diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go new file mode 100644 index 0000000..ea1668f --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -0,0 +1,568 @@ +package buscompose + +import ( + "fmt" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Every user the composed file should contain, derived from what the mesh knows. +// +// **The list is derived, never kept.** A stored user list would be a second account of who may +// reach the bus, able to disagree with the records it came from — and the disagreement would be +// invisible, because both would look internally consistent. So this is a pure function of the +// mesh's records, run again every time the file is written. +// +// Records are mirrored into this package's own types rather than imported from the catalogue, for +// the reason DeclaredSeat is: composing authority is a different job from parsing a manifest, and +// this package stays free of the other's types so a change to a manifest field cannot quietly widen +// a permission. + +// Declared is one module on one node, as composing its authority needs it. +type Declared struct { + Module string + Emits []string + Consumes []string + Serves []string + // Served are the config keys its served values and zone name fill from its settings (catalogue.ServedSettings): what + // every node may read of its resolved values where it runs (Principal.ServedReads, issue 1126). + Served []string + // Holds are the seats this module claims, with the protocol each seat declares. A seat the + // mesh defines for itself declares no protocol, so holding one grants nothing on the bus — + // which is right: those seats are about who does a job, not about who may say what. + Holds []Seat + // Uses are the seats this module sends to. + Uses []Seat + // Issued are the tools that answer a credential its software issued, one per offer that says so (novox/hq + // ADR 0305): the controller is granted to ask each in its own name, and no other tool so. + Issued []string + // Watches are the seats whose events it consumes. + Watches []Seat + // Invokes are the tools it calls, `.` or `*` (novox/hq ADR 0152). + Invokes []string + // Waits are the event families a session may wait on through the tool runner, the tool runner's module's + // alone (novox/hq ADR 0367 §2): its account subscribes them for a call, through no consumer. + Waits []Wait + // State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201). + State []Bucket + // Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297). + Logs []Log + // Streams are the streams it owns, each its events' one place (novox/hq ADR 0363). + Streams []ModuleStream + // Queues are its own work-queue streams and the one process each is the consumer of (novox/hq ADR 0436 + // §1, §2, §6): one durable consumer per entry, named `__`. + Queues []QueueOwner + // Hears are the module streams it hears on this machine, each with the subjects it was granted + // (novox/hq ADR 0363 §5); HearsAStream says its `consumes` names a module that owns a stream, granted + // or not, so its membership is issued again when a grant changes. + Hears []Heard + HearsAStream bool + // Reads are other modules' state it reads, each `.` (novox/hq ADR 0201). + Reads []string + // ReadsRefused are the reads its manifest names that the owner's announcement does not make readable (novox/hq ADR + // 0495 clause 3, issue 1790): granted nothing, kept to be said. + ReadsRefused []string + // ReadLogs are, of its reads, those naming another module's log (`.`, novox/hq ADR 0571 clause 1): filled + // by Users and PlacementsOf from the logs the modules placed anywhere declare, never by a caller. + ReadLogs []string + // KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is + // granted for the pieces the modules beside it offer (novox/hq ADR 0260). + KeyedReads []KeyedRead + // NoAccount says the module declares no own secret named broker, so no account could ever be + // delivered to it and nothing can connect as it (novox/hq issue 195). Said in the negative so a + // record that does not say is composed as it always was. + NoAccount bool + // SnapshotsTheBus says the module holds mesh-broker — it is the bus — and so is the one module + // granted the snapshot API, to copy the bus's streams for the night's backup (novox/hq ADR 0235). + SnapshotsTheBus bool + // Checks are the module's own tools its health asks, each `.` (novox/hq ADR 0240, to-be + // 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more. + Checks []string + // Loads says the module has a compiled bundle whose entrypoints the machine's tool runtime loads and runs + // (novox/hq ADR 0175; issue 1329): a helper that reaches the bus on the runner's account, so the runner + // must carry the module although it declares no tools. + Loads bool + // RunsAs is the account the module runs as in a runtime of its own (novox/hq ADR 0259 §8): it is never + // carried by the machine's runtime, and reaches the bus on its own account. + RunsAs string + // ProvisionsNode says the module runs the machine's provisioner client: it claims NodeProvisionerSeat and + // declares the own secret ProvisionerSecret its account is delivered in (novox/hq ADR 0349 clause 11, design 57 + // P3). The client is given a bus account of its own, never the module's or the tool runner's. + ProvisionsNode bool + // Processes are this module's own processes that declare bus (novox/hq ADR 0436 §1), each getting a + // principal of its own: granted exactly what its bus says, and nothing of this module's other grants — + // composed whether or not the module itself has an account (NoAccount), because a process account is + // delivered in its own secret, broker-, never the module's `broker`. + Processes []ProcessPrincipal +} + +// ProcessPrincipal is one process of a module that declares bus (novox/hq ADR 0436 §1, §2), as the broker +// composes its own account from: its own emits, its own work-queue consumers, its own state, whether it +// reads the router's records of its module's own asks, and the tools and seat verbs it calls. +type ProcessPrincipal struct { + // Process is the process resource's id, naming the account ... + Process string + // Emits are the events this process may publish, named as the module's own Emits are. + Emits []string + // Queues are the module's own work-queue streams this process is the one consumer of. + Queues []QueueOwner + // Removes are what its account may remove from the bus, as its bus declares them (the indexer's judge refuses any other; novox/hq + // ADR 0564 Limits 4): a tools bundle's account, whose whole authority is then BusRemovalGrants. + Removes []string + // State is the module's own state, by local name, this process reads and writes. + State []string + // Asks says whether this process reads the records of its module's own asks (novox/hq ADR 0259 §3), on + // whichever seats the module uses that keep them (the router's, operator-channel, among others). + Asks bool + // Invokes are the tools and seat verbs this process calls, each . or a role's + // seat:., or * for every tool on the mesh. + Invokes []string +} + +// Records is what composing a user list needs to know about the mesh, and nothing more. +type Records struct { + // Nodes is every machine the mesh knows. Each gets a host user. + Nodes []string + // Assigned is the modules on each node, as they declare themselves. + Assigned map[string][]Declared + // Enrolling is every node with a live token — one enrolment user each, because the inbox an + // answer goes to is scoped to the token and a shared one is one machine reading another's + // sealed credentials (design 25 §6). The grants part fills it from the seat mesh-enrolment's keys at live or + // redeeming (novox/hq ADR 0587 §4), the controller from its store. + Enrolling []string + // People is each person's name against the tools they may invoke, `*` for an administrator. + People map[string][]string + // Interchangeable is each module whose definition says its instances are the same anywhere + // (ADR 0160), which decides whether the module's plain subject is issued to every instance. + Interchangeable map[string]bool + // RootFree is each machine judged root-free when this was composed (novox/hq ADR 0259 §8): it names an + // account agents run as, judged unable to become root by its node-engine, and serves no login shell + // execute. A machine absent is not free: no record of a pass is no pass. + RootFree map[string]bool + // Engine is the node-engine's own declaration, from its manifest (EngineModule): placed on every node though no node is + // assigned it, since every node runs it (novox/hq ADR 0563 §2, §3). Its state, logs and streams are created by the + // grants part as a placed module's are; it adds no principal, since each node's engine speaks as the node's account. + // Nil where its manifest was not read. + Engine *Declared + // NodeLogins is each node's key of the vault's node-logins (novox/hq ADR 0583 §1): the login in use and the one + // rotating, each composed as a user of the node's permissions. A node absent logs in as `node.` alone. + NodeLogins map[string]delivery.NodeLogin + // Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the + // controller keeps it; a module absent has announced nothing yet. Nil where none was read. + Announced map[string]announce.States +} + +// Users is every user the composed file should contain, in the order it will be written. +func Users(r Records) ([]Principal, error) { + var out []Principal + + served := servedReadsOf(r) + logs := DeclaredLogs(r) + grants := -1 + for _, node := range sortedCopy(r.Nodes) { + var checks []string + bus := false + for _, d := range r.Assigned[node] { + if d.SnapshotsTheBus { + bus = true + } + checks = append(checks, d.Checks...) + } + // **One user per login the node has** (novox/hq ADR 0583 §1): the one in use, and while it rotates the other too, + // each with the node's permissions. + for _, login := range nodeLoginsOf(r, node) { + out = append(out, Principal{Kind: KindNode, Node: node, Login: login, Checks: checks, + MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), + AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r), + FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)}) + } + // **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one + // per machine that runs it, delivered to the module running it. + for _, d := range r.Assigned[node] { + if d.ProvisionsNode { + out = append(out, Principal{Kind: KindProvisioner, Node: node, Module: d.Module, + BoundBuckets: boundBucketsOf(r)}) + break + } + } + // **Where the runtime is assigned, the machine gets one runtime principal in place of the + // runtime module's own** (novox/hq ADR 0175, to-be 38). It carries every module on the + // node: its serving grants are the union of theirs. Every other module keeps its own + // principal — a module still serving tools from its own container holds its own + // credential until it moves, and the two serve side by side in the meantime. + runtimeHere := false + for _, d := range r.Assigned[node] { + if d.Module == RuntimeModule { + runtimeHere = true + } + } + for _, d := range r.Assigned[node] { + // **The node-engine's manifest is no placed module's** (novox/hq ADR 0563 §2, §3; issue 774): where a placement + // names it, it adds no user, no process and nothing the runner carries; its grants are the node's account's. + if !Composed(d) { + continue + } + // A state with named readers is read by them alone (ADR 0547 clause 9), and a read is granted what its owner + // announced (ADR 0495 clause 3): judged here, for this reader, whatever split the caller made (issue 774). + d = withReadLogs(withAnnouncedReads(d, r.Announced), logs) + // **A process account is its own, delivered in its own secret** (broker-, novox/hq + // ADR 0436 §1, §2), never the module's `broker` — so it is composed whether or not the module + // itself has an account, and is never carried by the machine's runtime, which runs as the + // operator's account (ADR 0436 §5): added here, before either of those is judged. + for _, pr := range d.Processes { + out = append(out, Principal{ + Kind: KindProcess, Node: node, Module: d.Module, Process: pr.Process, + Emits: pr.Emits, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks, + Invokes: pr.Invokes, Removes: pr.Removes, + }) + // The ended controller's leftovers include each node's declaration files' bucket (ADR 0590 clause 6). + if slices.Contains(pr.Removes, catalogue.RemovesControllerLeftovers) { + out[len(out)-1].FilesFor = sortedCopy(r.Nodes) + } + } + if runtimeHere && d.Module == RuntimeModule { + continue + } + // **A module with nowhere to read an account is no user** (novox/hq issue 195). `module + // issue` refuses it one (issue 078: an account nothing reads is an orphan), so its user + // could only ever be left out of the file for want of a password — and every such module + // was named, on every status and plan, as a credential the mesh had not minted. Where the + // runtime is, it speaks for the module; where it is not, the module cannot speak at all, + // and a user would not change that. + if d.NoAccount { + continue + } + out = append(out, Principal{ + Kind: KindModule, Node: node, Module: d.Module, + Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves, + Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes, + State: stateNames(d.State), Reads: d.Reads, ReadLogs: d.ReadLogs, SnapshotsTheBus: d.SnapshotsTheBus, + PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads, + Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues, + }) + // **The grants part creates what a manifest declares** (novox/hq ADR 0547 §5; the operator, 2026-10-26 + // 08:54): every placed module's buckets, logs and streams, each by its stream's name. + if d.Module == WatchmanModule { + out[len(out)-1].DeadLetterSources = deadLetterSourcesOf(r) + } + if d.Module == GrantsModule { + out[len(out)-1].CreatesStreams = declaredStreamsOf(r) + grants = len(out) - 1 + } + // **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the + // account its own runtime runs on (ADR 0259 §8's runs-as): the one module granted it. + if holdsSeat(d, VaultSeat) { + out[len(out)-1].AsksTheDesk = true + out[len(out)-1].AsksIssued = issuedOf(r) + } + } + if runtimeHere { + var carried []Declared + for _, d := range r.Assigned[node] { + if CarriedByRunner(d) { + d = withReadLogs(withAnnouncedReads(d, r.Announced), logs) + // Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1). + d.Hears = ComposedHears(d) + carried = append(carried, d) + } + } + out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: RuntimeModule, Carries: carried}) + } + } + // A node enrols through the seat mesh-enrolment alone (ADR 0587 §6, ADR 0590): while no holder serves redeem, an + // enrolling node has nowhere to enrol, and gets no user. + if servesVerb(r, EnrolmentRedeem) { + for _, node := range sortedCopy(r.Enrolling) { + out = append(out, Principal{Kind: KindEnrolment, Node: node, Redeems: true}) + } + } + for _, person := range sortedNames(r.People) { + out = append(out, Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]}) + } + + // The vault's enrolment verbs are mesh-enrolment's alone, however wide another's grant (novox/hq ADR 0587 §1). + if slices.ContainsFunc(VerbsTheEnrolmentAsksTheVault, func(v SeatVerb) bool { return servesVerb(r, v) }) { + for i := range out { + out[i].VaultEnrols = true + } + } + + // The grants part creates the durable consumers the accounts need (novox/hq ADR 0561 §6), by stream and name. + if grants >= 0 { + out[grants].CreatesConsumers = AccountConsumers(out) + } + + // Refused here rather than discovered by the server. Two users with one name is a file the + // server reads as one of them, and which one depends on the order — so a module assigned to a + // node twice, or a person named after nothing, is a composition that must not be written. + seen := map[string]string{} + for _, p := range out { + name := p.Username() + if name == "" || name == "." { + return nil, fmt.Errorf("a %s user has no name, so nothing could authenticate as it", p.Kind) + } + if first, already := seen[name]; already { + return nil, fmt.Errorf( + "two users would be called %q (a %s and a %s): the server would read the file as "+ + "one of them, and which one depends on the order", name, first, p.Kind) + } + seen[name] = string(p.Kind) + } + return out, nil +} + +// WithPasswords fills each user's hash from what the mesh minted, and says which users have none. +// +// **Separated from Users because they fail differently.** A user missing from the records is a bug +// in deriving them; a user with no password is a step that has not happened yet — a module assigned +// but never given a credential, a node enrolled before this existed. The second is ordinary and its +// remedy is to mint one, so it is named rather than returned as an error, and the caller decides +// whether a partial composition is worth writing. +func WithPasswords(principals []Principal, hashes map[string]string) (filled []Principal, missing []string) { + for _, p := range principals { + hash, ok := hashes[p.Username()] + if !ok || hash == "" { + missing = append(missing, p.Username()) + continue + } + p.PasswordHash = hash + filled = append(filled, p) + } + return filled, missing +} + +func sortedCopy(in []string) []string { + out := append([]string(nil), in...) + sort.Strings(out) + return out +} + +func sortedNames(in map[string][]string) []string { + out := make([]string, 0, len(in)) + for k := range in { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// asksSeatOf is the seat an ask is made on, as its holder declares it (novox/hq ADR 0259): the controller +// asks the operator through it like any other user, and is granted what its declaration names for a caller. +// None while nothing holds it. +// issuedOf is every tool of a module assigned anywhere that answers a credential its software issued, as +// `.` (novox/hq ADR 0305): what the controller asks in its own name, and nothing beside. +func issuedOf(r Records) []string { + seen := map[string]bool{} + var out []string + for _, node := range sortedCopy(r.Nodes) { + for _, d := range r.Assigned[node] { + for _, t := range d.Issued { + if key := d.Module + "." + t; !seen[key] { + seen[key] = true + out = append(out, key) + } + } + } + } + sort.Strings(out) + return out +} + +func asksSeatOf(r Records) []Seat { + for _, node := range sortedCopy(r.Nodes) { + for _, d := range r.Assigned[node] { + for _, s := range d.Holds { + if s.Name == AsksSeat && namesVerb(s.ByCaller, "ask") { + seat := s + seat.Kind, seat.Capabilities = "", nil + return []Seat{seat} + } + } + } + } + return nil +} + +// nodeLoginsOf are the logins composed for node, as Principal.Login names them ("" for `node.`): the one its +// node-logins key says is in use and the one rotating, in that order; `node.` alone when the key is absent or does +// not hold (a key naming another node's login is no login of this node's). +func nodeLoginsOf(r Records, node string) []string { + l, ok := r.NodeLogins[node] + if !ok || l.Node != node || (l.Login != delivery.FirstLogin(node) && l.Login != delivery.SecondLogin(node)) { + return []string{""} + } + login := func(name string) string { + if name == delivery.FirstLogin(node) { + return "" + } + return name + } + out := []string{login(l.Login)} + if l.Rotates() && l.Rotating == delivery.OtherLogin(node, l.Login) { + out = append(out, login(l.Rotating)) + } + return out +} + +// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name. +const VaultSeat = "mesh-vault" + +func holdsSeat(d Declared, seat string) bool { + for _, s := range d.Holds { + if s.Name == seat { + return true + } + } + return false +} + +// servedOf are the verbs of asked that a seat's holder serves, as the records say, by name. +func servedOf(r Records, asked []SeatVerb) []string { + seen := map[string]bool{} + var out []string + for _, node := range sortedCopy(r.Nodes) { + for _, d := range r.Assigned[node] { + for _, s := range d.Holds { + for _, v := range asked { + if s.Name == v.Seat && slices.Contains(s.Serves, v.Verb) && !seen[v.Verb] { + seen[v.Verb] = true + out = append(out, v.Verb) + } + } + } + } + } + sort.Strings(out) + return out +} + +// servesMoveIn says the vault's holder serves move-in, as the records say (issue 676): what lets each machine's +// node-engine ask it in its own name. +func servesMoveIn(r Records) bool { return servesVerb(r, VaultMoveIn) } + +// servesVerb says a holder of verb's seat serves it, as the records say. +func servesVerb(r Records, verb SeatVerb) bool { + for _, node := range r.Nodes { + for _, d := range r.Assigned[node] { + for _, s := range d.Holds { + if s.Name == verb.Seat && slices.Contains(s.Serves, verb.Verb) { + return true + } + } + } + } + return false +} + +// boundBucketsOf is every provider's bucket of entries, `_bound` (mesh-sdk go/provider, issue 678): the state +// named bound of every module placed anywhere, once each, in name order. A consumer on any machine may be bound to +// it, so each machine's provisioner client reads its own keys of each (issue 679). +func boundBucketsOf(r Records) []string { + seen := map[string]bool{} + for _, mods := range r.Assigned { + for _, d := range mods { + for _, b := range d.State { + if b.Name == BoundState { + seen[b.Bucket()] = true + } + } + } + } + out := make([]string, 0, len(seen)) + for b := range seen { + out = append(out, b) + } + sort.Strings(out) + return out +} + +// Composed says a placed module is composed as one: its own user, its processes and what the runner carries of it. +// The node-engine's manifest (EngineModule) is not, though a placement may name it: each node's engine speaks as the +// node's account, which alone writes the engine's state under its own key (engineGrants, novox/hq ADR 0563 §2, §3), and +// Records.Engine creates that state. Granted to another account it is a second writer, and the writers table refuses +// the whole list (issue 774: met for mesh-host_acts and then mesh-host_health, 2026-10-30). +func Composed(d Declared) bool { return d.Module != EngineModule } + +// CarriedByRunner says the machine's tool runner carries this placed module: a module composed at all (Composed) that +// runs as no account of its own and has something for the runner to serve (runnerServes). The one test of what the +// runner's grants are composed from. +func CarriedByRunner(d Declared) bool { return Composed(d) && d.RunsAs == "" && runnerServes(d) } + +// runnerServes says the machine's tool runner has something of this module to serve (novox/hq ADR 0436 §1, +// ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, event families a session waits on, +// a bundle the runtime loads (Loads), or a check its health asks. A module with none of these is a process of its own on its own credential, and +// carrying it would hand every session on the machine the runner's account with that module's state, seat +// traffic and streams. A module with no account of its own (NoAccount) is spoken for by the runner, so it +// stays carried, and so does the runtime module itself. Whether the module has its own broker secret is not +// the test: a module with tools and its own credential is still carried (ADR 0175). +func runnerServes(d Declared) bool { + return d.Module == RuntimeModule || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0 +} + +// declaredStreamsOf is the stream of every bucket, log and stream a module placed anywhere declares, once each, in name +// order (novox/hq ADR 0547 §5, issue 1790): `KV__`, `LOG__` and each module stream's own +// name. What the grants part may create, and nothing beside. +func declaredStreamsOf(r Records) []string { + seen := map[string]bool{} + placed := [][]Declared{} + for _, mods := range r.Assigned { + placed = append(placed, mods) + } + // The node-engine's manifest is placed on every node (ADR 0563): its declared state is created as a placed module's. + if r.Engine != nil { + placed = append(placed, []Declared{*r.Engine}) + } + for _, mods := range placed { + for _, d := range mods { + for _, b := range d.State { + seen["KV_"+b.Bucket()] = true + } + for _, l := range d.Logs { + seen[l.Stream()] = true + } + for _, s := range d.Streams { + seen[s.Name] = true + } + } + } + out := make([]string, 0, len(seen)) + for s := range seen { + out = append(out, s) + } + sort.Strings(out) + return out +} + +// servedReadsOf is every key of mesh-config's resolved values a module assigned on a node serves from, +// `..`, sorted: each part a subject token, so a key that cannot be one is granted nothing (and the +// node composing a consumer of it refuses, naming it), never a wider read. +func servedReadsOf(r Records) []string { + var out []string + for node, declared := range r.Assigned { + for _, d := range declared { + for _, k := range d.Served { + if safeSubject.MatchString(node) && safeSubject.MatchString(d.Module) && safeSubject.MatchString(k) { + out = append(out, node+"."+d.Module+"."+k) + } + } + } + } + sort.Strings(out) + return slices.Compact(out) +} + +// servedOn is, of the served keys, those of other nodes than node: its own slice it reads whole. +func servedOn(served []string, node string) []string { + var out []string + for _, k := range served { + if !strings.HasPrefix(k, node+".") { + out = append(out, k) + } + } + return out +} diff --git a/modules/mesh-grants/internal/buscompose/waits.go b/modules/mesh-grants/internal/buscompose/waits.go new file mode 100644 index 0000000..c92300e --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/waits.go @@ -0,0 +1,94 @@ +package buscompose + +import ( + "fmt" + "strings" +) + +// What a session may wait on through the mesh MCP server (novox/hq ADR 0367). +// +// **A grant apart from `consumes`, and the tool runner's alone.** The tool runner's module names in its +// manifest the event families a session may wait on, one by one; the runtime's account is granted a plain +// subscription on those subjects and nothing wider — no durable consumer, no queue of its own — and its +// membership names the same list, from which `mesh_wait` refuses any other. Both are read from this one +// composition, so the grant and what the tool runner offers cannot disagree, and nothing but the manifest +// adds a family (§6). + +// Wait is one event family the tool runner's module declares: its name as the manifest gives it, and the +// emitter — a seat or a module — and the event, as the inventory resolved them. +type Wait struct { + // Name is the family as the manifest names it, `.`: what a session asks for. + Name string + Emitter string + // Seat says the emitter is a seat, whose events live under the seat's namespace (novox/hq ADR 0121). + Seat bool + // Event is the event, with any further token the subject carries: `decided.claude-code`, the asker of a + // seat that names it last (ADR 0259 §3). + Event string + // Last names what the seat's event subject ends in — "kind" for a kinded bench, "asker" for a seat naming + // its caller (ADR 0259 §3) — and is empty for an event that ends at its name. A family needing it is waited + // on with it given, and refused without. + Last string +} + +// WaitSubject is where a family's events land. Each token is checked before it becomes a subject: a +// wildcard, a dot inside a name or an empty token would widen the grant. +func WaitSubject(w Wait) (string, error) { + if !safeSubject.MatchString(w.Emitter) { + return "", fmt.Errorf("%q cannot name an emitter of a waited event: it would widen the subject", w.Emitter) + } + for _, t := range strings.Split(w.Event, ".") { + if !safeSubject.MatchString(t) { + return "", fmt.Errorf("%q cannot name a waited event: each of its names is plain, never a wildcard "+ + "(novox/hq ADR 0367 §2)", w.Event) + } + } + if w.Last != "" && !strings.Contains(w.Event, ".") { + return "", fmt.Errorf("%s names no %s: %s's events are said for each %s, so a family is %s.%s.<%s>; "+ + "without it no event would ever be heard (novox/hq ADR 0367 §5)", + w.Name, w.Last, w.Emitter, w.Last, w.Emitter, w.Event, w.Last) + } + if w.Seat { + return "mesh.seat." + w.Emitter + ".event." + w.Event, nil + } + return "mesh.mod." + w.Emitter + ".event." + w.Event, nil +} + +// waitGrants is the subscription the tool runner's account is granted for its module's families, and an +// error for a carried module other than the tool runner's that declares any (ADR 0367 §2). +func waitGrants(carries []Declared) ([]string, error) { + var sub []string + for _, d := range carries { + if len(d.Waits) == 0 { + continue + } + if d.Module != RuntimeModule { + return nil, fmt.Errorf("%s declares what a session waits on; only %s, the tool runner's module, "+ + "does (novox/hq ADR 0367 §2)", d.Module, RuntimeModule) + } + for _, w := range d.Waits { + s, err := WaitSubject(w) + if err != nil { + return nil, err + } + sub = append(sub, s) + } + } + return sub, nil +} + +// waitsIssuedFor is the families a membership names: the tool runner's module's, and nobody else's. +func waitsIssuedFor(d Declared) []WaitIssued { + if d.Module != RuntimeModule { + return nil + } + var out []WaitIssued + for _, w := range d.Waits { + s, err := WaitSubject(w) + if err != nil { + continue // refused where the account is composed, which names it + } + out = append(out, WaitIssued{Event: w.Name, Subject: s}) + } + return out +} diff --git a/modules/mesh-grants/internal/buscompose/writers.go b/modules/mesh-grants/internal/buscompose/writers.go new file mode 100644 index 0000000..8ae64d6 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/writers.go @@ -0,0 +1,282 @@ +package buscompose + +import ( + "fmt" + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The writers table (novox/hq to-be 45 §1, ADR 0227 rule 1), compiled in. +// +// **Every kind of state the core keeps has one writer; anyone else asks it.** The table is the design's, +// row for row, with what each row writes on the bus where it writes there. Two things read it: the +// composition of every principal's grants (PermissionsFor), which **refuses a grant that lets a +// principal publish on a subject another writes** — so a second writer cannot be granted by accident, +// and the composition says which state and whose — and the test beside it, which walks the rows +// against the design's list and the composition of a whole mesh. Changing a writer is a change to +// this table, through a decision. + +// WriterRow is one row of the writers table. +type WriterRow struct { + State string + Writer string + KeptIn string + Others string + // Subjects are the bus subjects a write of this state is a publish to; none for state kept off the + // bus (a module's own store) or not built yet. + Subjects []string + // Writes says a principal granted a publish pattern overlapping Subjects is this state's writer — + // given the pattern, because a machine writes its own report and no other's. + Writes func(p Principal, pattern string) bool + // Shared says why more than one principal may publish here; empty for one writer. + Shared string +} + +// createsDeclared, and the other writers' tests, are who a row's writer is as a principal. +// +// createsDeclared is the grants part creating one stream a manifest declares, by its own name: the create alone, never an +// update or a delete (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); or the bus module's tools deleting one of +// the ended controller's leftover streams (ADR 0590 clause 6). +func createsDeclared(p Principal, pattern string) bool { + if removesLeftover(p, pattern) { + return true + } + name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.") + if ok && p.Kind == KindModule && p.Module == GrantsModule && name == GrantsUserListObjectStream { + return true + } + return ok && p.Kind == KindModule && p.Module == GrantsModule && slices.Contains(p.CreatesStreams, name) && + safeSubject.MatchString(name) +} + +// ownMachine is a node writing a subject whose third token is its own name, and no wildcard there. +func ownMachine(p Principal, pattern string) bool { + tokens := strings.Split(pattern, ".") + return p.Kind == KindNode && len(tokens) > 2 && tokens[2] == p.Node +} + +// holdsSeatOf is a principal holding the seat a `mesh.seat..…` pattern names: its module's own +// principal, or the node tools carrying a module that holds it (ADR 0175, the runtime is its modules). +func holdsSeatOf(p Principal, pattern string) bool { + tokens := strings.Split(pattern, ".") + if len(tokens) < 3 { + return false + } + seat := tokens[2] + holds := func(seats []Seat) bool { + return slices.ContainsFunc(seats, func(s Seat) bool { return s.Name == seat }) + } + switch p.Kind { + case KindModule: + return holds(p.Holds) + case KindNodeTools: + return slices.ContainsFunc(p.Carries, func(d Declared) bool { return holds(d.Holds) }) + } + return false +} + +// ownModule is a module publishing under its own name, or the node tools carrying it. +func ownModule(p Principal, pattern string) bool { + tokens := strings.Split(pattern, ".") + if len(tokens) < 3 { + return false + } + module := tokens[2] + switch p.Kind { + case KindModule: + return p.Module == module + case KindNodeTools: + return slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.Module == module }) + } + return false +} + +// moduleNamed is the writer that is module: its own principal, a process of it, or the node tools carrying it. +func moduleNamed(module string) func(Principal, string) bool { + return func(p Principal, _ string) bool { + switch p.Kind { + case KindModule, KindProcess: + return p.Module == module + case KindNodeTools: + return slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.Module == module }) + } + return false + } +} + +// ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps (novox/hq ADR +// 0495 clause 2): each under its own name alone. +func ownAnnouncer(p Principal, pattern string) bool { + tokens := strings.Split(pattern, ".") + if len(tokens) < 3 { + return false + } + if p.Kind == KindProcess { + return p.Module == tokens[2] + } + return ownModule(p, pattern) +} + +// ownProvisioner is a machine's provisioner client saying a report about its own machine: the third token of +// `prov.applied..…` is its machine (novox/hq ADR 0349 §4, design 57 P3). +func ownProvisioner(p Principal, pattern string) bool { + tokens := strings.Split(pattern, ".") + return p.Kind == KindProvisioner && len(tokens) > 3 && tokens[2] == p.Node +} + +// ownNodeKeys is a node writing the have keys of its own node: `$KV...…`, the node in the third token +// and no wildcard there (novox/hq ADR 0429 §1). +func ownNodeKeys(p Principal, pattern string) bool { + tokens := strings.Split(pattern, ".") + return p.Kind == KindNode && len(tokens) > 2 && tokens[2] == p.Node +} + +// kvOf is a bucket's write subjects. +func kvOf(bucket string) []string { return []string{"$KV." + bucket + ".>"} } + +// WritersTable is to-be 45 §1, in its order. +var WritersTable = []WriterRow{ + {State: "a machine's applied state and its report", Writer: "the node-engine's apply queue", + KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply", + // And its health statement between reports (novox/hq ADR 0240): the same writer stating the same + // machine, inside the grant it already had (`mesh.control..>`). And a line mesh-cli was given + // on the machine (novox/hq ADR 0272): that subject is this machine's engine's alone. + Subjects: []string{"mesh.control.*.report", "mesh.control.*.health", "mesh.control.*.cli"}, Writes: ownMachine}, + {State: "stream definitions and bus permissions", Writer: "the grants part", KeptIn: "the bus", Others: "—", + // A stream's definition, and a durable consumer's by the API that names it so. Not every + // consumer create: a module watching its own bucket makes and deletes an ordered consumer on the + // bucket's stream (ADR 0201), which defines nothing the mesh keeps. + Subjects: []string{"$JS.API.STREAM.CREATE.>", "$JS.API.STREAM.UPDATE.>", "$JS.API.STREAM.DELETE.>", + "$JS.API.CONSUMER.DURABLE.CREATE.>"}, + Writes: createsDeclared, + Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the ended controller's leftover streams, each by its name, and no other (novox/hq ADR 0590 clause 6)"}, + {State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state", + Others: "the builder asks", + Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"}, + Writes: holdsSeatOf, + Shared: "every machine holding the build seat answers the asks it took; each outcome names its ask"}, + // What a module keeps and who may read it (novox/hq ADR 0495 clauses 2 and 3, issue 1790): said by the module. + {State: "a module's states announced", Writer: "the module itself, from its code, under its own name", + KeptIn: "the bus (EVENTS)", Others: "the grants part grants readers from it", + Subjects: []string{"mesh.mod.*.event.states"}, Writes: ownAnnouncer}, + {State: "a merge announced", Writer: "one announcer per forge (the hook, or the poll when the hook is absent — never both)", + KeptIn: "the bus", Others: "—", Subjects: []string{"mesh.mod.*.event.pull.merged"}, Writes: ownModule}, + // A pull request's head, before it merges (novox/hq to-be 45 §9): the same one announcer. + {State: "a pull request's head announced", Writer: "the forge's announcer, the one that announces its merges", + KeptIn: "the bus", Others: "the checker checks it", Subjects: []string{"mesh.mod.*.event.pull.updated"}, + Writes: ownModule}, + {State: "a pull request's merge check", Writer: "the checker that ran it", + KeptIn: "the bus", Others: "the forge's holder sets it as the pull request's status"}, + {State: "a provider's standing", Writer: "the provider", KeptIn: "the provider's events", + Others: "the watchman keeps the newest word as a condition", + Subjects: []string{"mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", + "mesh.mod.*.event.provisioner.retirement"}, + Writes: ownModule}, + {State: "the operator-channel's open messages", Writer: "the seat's holder", KeptIn: "its own key-value state", + Others: "—"}, + // The seat mesh-enrolment's open enrolments (novox/hq ADR 0587 §2, issue 774): its holder alone writes them, each a + // compare-and-set at the revision read; the grants part reads them as a named reader. + {State: "the open enrolments", Writer: "the seat mesh-enrolment's holder, compare-and-set", + KeptIn: "key-value " + delivery.EnrolmentsBucket + ", with per-key expiry", + Others: "the grants part reads them (a named reader) and composes each enrol. user from them", + Subjects: kvOf(delivery.EnrolmentsBucket), Writes: moduleNamed(delivery.EnrolmentModule)}, + // Which login each node uses (novox/hq ADR 0583 §1 insight): the vault alone writes it. + {State: "a node's logins", Writer: "the vault", KeptIn: "key-value " + delivery.NodeLoginsBucket, + Others: "the grants part reads every key, each node's engine its own node's key alone", + Subjects: kvOf(delivery.NodeLoginsBucket), Writes: moduleNamed(delivery.VaultModule)}, + // What a consumer gave up on (novox/hq issue 330): copied by the watchman from the server's notice (ADR 0564 + // clause 5, ADR 0590 clause 2). + {State: "a message a consumer gave up on", Writer: "the watchman", KeptIn: "the bus, the stream " + DeadLettersStream, + Others: "read through the watchman's dead-letters; delivered again by nobody until the hand-act log's home is delivered", + Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: keepsDeadLetters}, + // What each machine has of each module (novox/hq ADR 0429 §1, design 47): written by that machine's node-engine under + // its own node's keys; the view and the watchman read it. + {State: "a machine's have", Writer: "the node-engine of the machine, under its own keys `.>`", + KeptIn: "key-value " + delivery.HaveBucket + ", twenty revisions of each key", + Others: "read through `reads` as node-engine.have", Subjects: kvOf(delivery.HaveBucket), Writes: ownNodeKeys}, + // The acts a node's engine was asked through its node seat, and its own asks (novox/hq ADR 0563 §2, §3): the + // node-engine's declared state, created by the grants part, written by each node's engine under its own keys. + // A node's own health report (issue 774): its engine's own key, read by the watchman. + {State: "a machine's health report", Writer: "the node-engine of the machine, its key `` alone", + KeptIn: "key-value " + delivery.NodeHealthBucket, Others: "read by the watchman through `reads`", + Subjects: kvOf(delivery.NodeHealthBucket), Writes: ownNodeKeys}, + // A node's declared packages (novox/hq ADR 0565 clause 6): its engine's own key, read by pacman's verbs. + {State: "a machine's declared packages", Writer: "the node-engine of the machine, its key `` alone", + KeptIn: "key-value " + EnginePackagesBucket, Others: "read by pacman through `reads`", + Subjects: kvOf(EnginePackagesBucket), Writes: ownNodeKeys}, + // A node's placed modules' data items (novox/hq ADR 0577 clause 1): its engine's own keys, read by that node's disk-load. + {State: "a machine's data items", Writer: "the node-engine of the machine, under its own keys `.>`", + KeptIn: "key-value " + EngineDataBucket, Others: "read by the node's disk-load through `reads`, its own node's keys alone", + Subjects: kvOf(EngineDataBucket), Writes: ownNodeKeys}, + {State: "a machine's hand acts", Writer: "the node-engine of the machine, under its own keys `.>`, compare-and-set", + KeptIn: "key-value " + EngineActsBucket + ", the node-engine's declared state acts", + Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket), + Writes: func(p Principal, pattern string) bool { + return ownNodeKeys(p, pattern) || removesEngineActs(p, pattern) + }, + Shared: "the bus module's tools account removes keys of a full bucket, for removal only (novox/hq ADR 0564 Limits 4)"}, + // The object buckets (novox/hq ADR 0486 §2, ADR 0547 clause 7): the declaration files' buckets the ended controller + // wrote are written by nobody (ADR 0590); the grants part writes its own. + {State: "an object bucket", Writer: "the grants part, its own bucket alone", + KeptIn: "the object bucket " + DeclarationFilesBucket + "- of each node, an object removed a day after the " + + "node's latest declaration no longer names it", + Others: "a node-engine reads an object of its own bucket by its name and may neither list the bucket nor write it", + Subjects: []string{"$O.*.>"}, Writes: func(p Principal, pattern string) bool { + return grantsOwnObjects(p, pattern) + }, + Shared: "nobody else: the grants part writes its own object bucket " + GrantsUserListObjects + ", the user list sealed to the bus node, and no other (novox/hq ADR 0547 clause 7)"}, + // What a machine's provisioner client placed of a module's files (novox/hq ADR 0349 §4, design 57 P6): its own + // machine's word alone, which a walk's end and a gate wait for (ADR 0362, ADR 0349 §15). Kept newest per module + // in the stream ProvAppliedStream, and by the client in the provisioner server's `applied` bucket. + {State: "a module's files placed on a machine", Writer: "that machine's provisioner client", + KeptIn: "the bus, the stream " + ProvAppliedStream + ", newest per module; and key-value " + + ProvisionerAppliedBucket, + Others: "the watchman reads it; a failure is that machine's failed apply", + Subjects: []string{"prov.applied.*.>"}, Writes: ownProvisioner}, +} + +// CheckWriters refuses a grant that lets a principal publish on a subject the writers table gives +// another writer (to-be 45 §1): which state, whose, and the pattern that would make a second writer. +func CheckWriters(p Principal, publish []string) error { + var problems []string + for _, pattern := range publish { + for _, row := range WritersTable { + if row.Writes == nil { + continue + } + for _, subject := range row.Subjects { + if !SubjectsOverlap(pattern, subject) || row.Writes(p, pattern) { + continue + } + problems = append(problems, fmt.Sprintf("%s may publish %s, which writes %s (%s), whose writer is %s", + p.Username(), pattern, row.State, subject, row.Writer)) + } + } + } + if len(problems) == 0 { + return nil + } + return fmt.Errorf("a second writer would be granted (novox/hq to-be 45 §1, one writer per piece of state):\n %s", + strings.Join(problems, "\n ")) +} + +// SubjectsOverlap says some subject matches both patterns: `*` is one token, `>` one or more to the end. +func SubjectsOverlap(a, b string) bool { + x, y := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; ; i++ { + switch { + case i == len(x) && i == len(y): + return true + case i == len(x) || i == len(y): + return false + case x[i] == ">" || y[i] == ">": + return true + case x[i] == "*" || y[i] == "*" || x[i] == y[i]: + continue + default: + return false + } + } +} diff --git a/modules/mesh-grants/internal/grants/accounts.go b/modules/mesh-grants/internal/grants/accounts.go index 248d147..c5847f2 100644 --- a/modules/mesh-grants/internal/grants/accounts.go +++ b/modules/mesh-grants/internal/grants/accounts.go @@ -7,8 +7,8 @@ import ( "sort" "git.novox.be/novox/mesh-sdk/go/announce" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" + "mesh-grants/internal/buscompose" ) // **The accounts the mesh wants, said to the vault** (novox/hq ADR 0561 §2, issue 774). The bus composer derives every diff --git a/modules/mesh-grants/internal/grants/assertions.go b/modules/mesh-grants/internal/grants/assertions.go index 099a7d5..7f717fb 100644 --- a/modules/mesh-grants/internal/grants/assertions.go +++ b/modules/mesh-grants/internal/grants/assertions.go @@ -11,8 +11,8 @@ import ( "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" + "mesh-grants/internal/buscompose" ) // Item is one bucket, log or stream a manifest declares, as composed, beside what the bus holds of it. diff --git a/modules/mesh-grants/internal/grants/consumers.go b/modules/mesh-grants/internal/grants/consumers.go index 19e1bcf..c64344e 100644 --- a/modules/mesh-grants/internal/grants/consumers.go +++ b/modules/mesh-grants/internal/grants/consumers.go @@ -11,7 +11,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/buscompose" + "mesh-grants/internal/buscompose" ) // **The durable consumers an account needs** (novox/hq ADR 0561 §6, issue 774): a module's consumer on EVENTS and each diff --git a/modules/mesh-grants/internal/grants/grants.go b/modules/mesh-grants/internal/grants/grants.go index 50eeeaa..8011cf6 100644 --- a/modules/mesh-grants/internal/grants/grants.go +++ b/modules/mesh-grants/internal/grants/grants.go @@ -8,8 +8,8 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/delivery" + "mesh-grants/internal/buscompose" ) // **The grants the part writes** (novox/hq ADR 0547 §4, §5, ADR 0561 §2, §6; issue 774). A module's grant is the record diff --git a/modules/mesh-grants/internal/grants/memberships.go b/modules/mesh-grants/internal/grants/memberships.go index 8a8361f..eb7cbbf 100644 --- a/modules/mesh-grants/internal/grants/memberships.go +++ b/modules/mesh-grants/internal/grants/memberships.go @@ -9,9 +9,9 @@ import ( "sort" "strings" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/membership" + "mesh-grants/internal/buscompose" ) // Memberships are published for real (the operator, 2026-10-25 22:31): since release 6 nobody else publishes them, so a diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 2eabf83..566ade3 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -12,9 +12,9 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/membership" + "mesh-grants/internal/buscompose" ) // The part's own states, as its manifest declares them, and the keys of the two that hold one value. diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 183f2da..64caaa8 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -6,9 +6,9 @@ import ( "slices" "strings" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "mesh-grants/internal/buscompose" ) // Records is what the bus composer composes from, rebuilt from the bus (mesh-controller internal/inventory busrecords.go, diff --git a/modules/mesh-grants/internal/grants/refused.go b/modules/mesh-grants/internal/grants/refused.go index d994a58..b05f494 100644 --- a/modules/mesh-grants/internal/grants/refused.go +++ b/modules/mesh-grants/internal/grants/refused.go @@ -7,7 +7,7 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/buscompose" + "mesh-grants/internal/buscompose" ) // **One refused principal never freezes the list** (issue 774, sweep 2026-10-30; met twice that day: the engine's acts, diff --git a/modules/mesh-grants/internal/grants/rootfree.go b/modules/mesh-grants/internal/grants/rootfree.go index ab714f8..3d4c22e 100644 --- a/modules/mesh-grants/internal/grants/rootfree.go +++ b/modules/mesh-grants/internal/grants/rootfree.go @@ -7,8 +7,8 @@ import ( "sort" "strings" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/health" + "mesh-grants/internal/buscompose" ) // CallRootFree is the node seat's verb that judges one machine root-free (novox/hq ADR 0259 §8, ADR 0563 §6), asked of diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index 17bbf7a..9b83190 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -10,8 +10,8 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/delivery" + "mesh-grants/internal/buscompose" ) // **The list the bus node loads** (novox/hq ADR 0547 clause 7, issue 1945). Each pass composes the bus's user list with diff --git a/modules/mesh-grants/internal/grants/userlist.go b/modules/mesh-grants/internal/grants/userlist.go index 53a8de4..c3ea7b3 100644 --- a/modules/mesh-grants/internal/grants/userlist.go +++ b/modules/mesh-grants/internal/grants/userlist.go @@ -10,9 +10,9 @@ import ( "sort" "strings" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "mesh-grants/internal/buscompose" ) // User is one bus user and its grants, as the bus module's nats_users answers them: never a password hash. diff --git a/modules/mesh-grants/internal/grants/withheld.go b/modules/mesh-grants/internal/grants/withheld.go index 1c130cb..c70e4a9 100644 --- a/modules/mesh-grants/internal/grants/withheld.go +++ b/modules/mesh-grants/internal/grants/withheld.go @@ -6,7 +6,7 @@ import ( "sort" "time" - "git.novox.be/novox/mesh-sdk/go/buscompose" + "mesh-grants/internal/buscompose" ) // The hearings the part withholds (novox/hq ADR 0376 §2; issue 1955 L7, the lead 2026-10-29): a module granted to hear a