From b5f8604d037a4645af44ec06524e6d5738af10c9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 25 Oct 2026 21:54:58 +0100 Subject: [PATCH] =?UTF-8?q?Copy=20the=20controller's=20bus=20composer=20in?= =?UTF-8?q?to=20mesh-sdk=20as=20buscompose,=20held=20byte=20for=20byte=20t?= =?UTF-8?q?o=20the=20controller's=20lists=20(issue=201377,=20ADR=200547=20?= =?UTF-8?q?=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grants part composes the bus's user list with the composer the controller uses (ADR 0547 §5), copied rather than moved: the controller is not changed and keeps its own until it loses the lease. Left out: the bus client, the broker's address from the environment and the client's two methods; the lease's two subjects are copied in. One table test holds the copy to four meshes' lists and the server golden, written by the controller's own internal/broker from the same fixtures (testdata/controller-user-lists.json). --- go/buscompose/agreement.go | 125 ++ go/buscompose/announced.go | 82 + go/buscompose/buildbacklog.go | 47 + go/buscompose/buscompose_test.go | 84 + go/buscompose/callers.go | 117 ++ go/buscompose/cancelled.go | 11 + go/buscompose/composed.go | 196 ++ go/buscompose/controller_buckets.go | 247 +++ go/buscompose/declaration_files.go | 111 ++ go/buscompose/derived.go | 357 ++++ go/buscompose/doc.go | 12 + go/buscompose/fixtures_test.go | 103 + go/buscompose/log.go | 184 ++ go/buscompose/membership.go | 178 ++ go/buscompose/modstreams.go | 343 ++++ go/buscompose/nats.go | 1740 +++++++++++++++++ go/buscompose/onnats.go | 5 + go/buscompose/process_grants.go | 52 + go/buscompose/provisioner.go | 217 ++ go/buscompose/raise.go | 73 + go/buscompose/readiness.go | 139 ++ go/buscompose/sdk_membership.go | 38 + go/buscompose/seattraffic.go | 290 +++ go/buscompose/state.go | 265 +++ go/buscompose/streams.go | 612 ++++++ go/buscompose/testdata/composed.conf | 78 + .../testdata/controller-user-lists.json | 28 + go/buscompose/users.go | 420 ++++ go/buscompose/waits.go | 94 + go/buscompose/writers.go | 308 +++ 30 files changed, 6556 insertions(+) create mode 100644 go/buscompose/agreement.go create mode 100644 go/buscompose/announced.go create mode 100644 go/buscompose/buildbacklog.go create mode 100644 go/buscompose/buscompose_test.go create mode 100644 go/buscompose/callers.go create mode 100644 go/buscompose/cancelled.go create mode 100644 go/buscompose/composed.go create mode 100644 go/buscompose/controller_buckets.go create mode 100644 go/buscompose/declaration_files.go create mode 100644 go/buscompose/derived.go create mode 100644 go/buscompose/doc.go create mode 100644 go/buscompose/fixtures_test.go create mode 100644 go/buscompose/log.go create mode 100644 go/buscompose/membership.go create mode 100644 go/buscompose/modstreams.go create mode 100644 go/buscompose/nats.go create mode 100644 go/buscompose/onnats.go create mode 100644 go/buscompose/process_grants.go create mode 100644 go/buscompose/provisioner.go create mode 100644 go/buscompose/raise.go create mode 100644 go/buscompose/readiness.go create mode 100644 go/buscompose/sdk_membership.go create mode 100644 go/buscompose/seattraffic.go create mode 100644 go/buscompose/state.go create mode 100644 go/buscompose/streams.go create mode 100644 go/buscompose/testdata/composed.conf create mode 100644 go/buscompose/testdata/controller-user-lists.json create mode 100644 go/buscompose/users.go create mode 100644 go/buscompose/waits.go create mode 100644 go/buscompose/writers.go diff --git a/go/buscompose/agreement.go b/go/buscompose/agreement.go new file mode 100644 index 0000000..d4083ca --- /dev/null +++ b/go/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/go/buscompose/announced.go b/go/buscompose/announced.go new file mode 100644 index 0000000..9145249 --- /dev/null +++ b/go/buscompose/announced.go @@ -0,0 +1,82 @@ +package buscompose + +import ( + "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 controller +// keeps the last word of each (announce.KeptBucket) 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. + +// StatesAnnouncedBucket is the controller's bucket of the last announcement of each module (announce.KeptBucket). +const StatesAnnouncedBucket = announce.KeptBucket + +// controllerReadable are the controller's states others may read: the record mirrors (the packages each node declares +// among them, which the pacman module reads, ADR 0507 §5), the chain's buckets, and the grants issued and the +// compositions' digests, which the assigner reads (ADR 0528 clause 2, ADR 0539 clause 6). +var controllerReadable = []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts, + MirrorDeclaredPackages, ControllerGrants, ControllerComposed} + +// ControllerAnnouncement is what the controller announces it keeps (ADR 0495 clause 2): every bucket of its own by its local +// name, the ones others may read marked readable. It announces itself, as any module does from its code. +func ControllerAnnouncement(node string) announce.States { + s := announce.States{Module: ControllerSeat, Node: node} + readable := map[string]bool{} + for _, n := range controllerReadable { + readable[n] = true + } + for _, bucket := range ControllerBuckets() { + local, mine := strings.CutPrefix(bucket, ControllerSeat+"_") + if !mine { + continue + } + s.States = append(s.States, announce.State{Name: local, Readable: readable[local]}) + } + sort.Slice(s.States, func(i, j int) bool { return s.States[i].Name < s.States[j].Name }) + return s +} + +// ReadsAnnounced splits a reader's `reads` (`.`) by what each owner announced: granted when the owner +// announced the state readable, or announced nothing yet (the transition); refused when the owner announced and did +// not announce that state readable. +func ReadsAnnounced(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 !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 +} diff --git a/go/buscompose/buildbacklog.go b/go/buscompose/buildbacklog.go new file mode 100644 index 0000000..f62cdae --- /dev/null +++ b/go/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/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go new file mode 100644 index 0000000..7b2c6b6 --- /dev/null +++ b/go/buscompose/buscompose_test.go @@ -0,0 +1,84 @@ +package buscompose + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// Defends issue 1377 and novox/hq ADR 0547 §5: this is the controller's bus composer copied, and it composes byte for +// byte what the controller's composes. testdata/controller-user-lists.json was written by the controller's own +// internal/broker (mesh-controller at 7cd4d18b) from fixtures_test.go compiled there unchanged; composed.conf is the +// controller's golden of the whole server configuration. A row that differs is the copy drifting from the controller, +// which is a fault while both compose (until the controller loses its lease, ADR 0547 §13). +func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "controller-user-lists.json")) + if err != nil { + t.Fatal(err) + } + var want []struct { + Name, List string + Missing []string + Err string + } + if err := json.Unmarshal(raw, &want); err != nil { + t.Fatal(err) + } + fixtures := UserListFixtures() + if len(want) != len(fixtures) { + t.Fatalf("%d fixtures, %d lists the controller composed", len(fixtures), len(want)) + } + for i, f := range fixtures { + t.Run(f.Name, func(t *testing.T) { + if want[i].Name != f.Name { + t.Fatalf("fixture %q against the controller's %q", f.Name, want[i].Name) + } + list, missing, err := ComposedFor(f) + got := "" + if err != nil { + got = err.Error() + } + if got != want[i].Err { + t.Fatalf("error %q, the controller's %q", got, want[i].Err) + } + if strings.Join(missing, ",") != strings.Join(want[i].Missing, ",") { + t.Errorf("left out %v, the controller %v", missing, want[i].Missing) + } + if list != want[i].List { + t.Errorf("the list differs from the controller's:\n%s", list) + } + }) + } + t.Run("the controller's 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: KindController, PasswordHash: "$2a$11$cccccccccccccccccccccc"}, + {Kind: KindEnrolment, Node: "one", 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) + } + want, err := os.ReadFile(filepath.Join("testdata", "composed.conf")) + if err != nil { + t.Fatal(err) + } + if got != string(want) { + t.Errorf("the configuration differs from the controller's golden:\n%s", got) + } + }) +} diff --git a/go/buscompose/callers.go b/go/buscompose/callers.go new file mode 100644 index 0000000..5af25dd --- /dev/null +++ b/go/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/go/buscompose/cancelled.go b/go/buscompose/cancelled.go new file mode 100644 index 0000000..f7fa097 --- /dev/null +++ b/go/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/go/buscompose/composed.go b/go/buscompose/composed.go new file mode 100644 index 0000000..be03e18 --- /dev/null +++ b/go/buscompose/composed.go @@ -0,0 +1,196 @@ +package buscompose + +import ( + "context" + "fmt" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats.go/jetstream" +) + +// The compositions' buckets (novox/hq ADR 0539 clauses 2 and 4): one per node, `composed-`, holding that node's +// compositions, which only that node's node-engine reads, since a composition can carry a sealed value (ADR 0488 §2's +// reason); and `mesh-controller_composed`, holding each composition's digest and the judgement of its move, which the +// assigner reads. The controller writes both, by compare-and-set while it holds the lease; each keeps five revisions +// of a key. The names and layouts are mesh-sdk's (delivery.ComposedBucketFor, ControllerComposedBucket, ComposedKey). + +// ControllerComposed is the local name of the compositions' digests, as a reader names it in `reads` after +// `mesh-controller.`: delivery.ControllerComposedBucket. +const ControllerComposed = "composed" + +// ComposedValueBytes is the most a composition may hold: a file over 64 KiB travels by digest, so a module's +// composition is far smaller; just under the server's largest message, which also carries the subject and headers. +const ComposedValueBytes = 1<<20 - 8<<10 + +// ComposedBucketBytes is a node's composition bucket's size: five revisions of every module's compositions of the node. +const ComposedBucketBytes = 256 << 20 + +// ComposedDigestsBytes is the size of the bucket of digests: a value of a few hundred bytes per node, module and +// fingerprint kept. +const ComposedDigestsBytes = 64 << 20 + +// ComposedConfig is a node's composition bucket as the controller asserts it. +func ComposedConfig(node string) (jetstream.KeyValueConfig, error) { + bucket, err := delivery.ComposedBucketFor(node) + if err != nil { + return jetstream.KeyValueConfig{}, err + } + return jetstream.KeyValueConfig{ + Bucket: bucket, + Description: "the compositions of " + node + " (novox/hq ADR 0539): each module's resources for this node at one " + + "fingerprint, and the node's own, signed; written by the controller alone, read by this node's node-engine " + + "alone; deleted when outside the module's five newest fingerprints and named by no want", + History: delivery.ComposedHistory, + MaxValueSize: ComposedValueBytes, + MaxBytes: ComposedBucketBytes, + Storage: jetstream.FileStorage, + }, nil +} + +// ComposedDigestsConfig is the bucket of the compositions' digests as the controller asserts it. +func ComposedDigestsConfig() jetstream.KeyValueConfig { + return jetstream.KeyValueConfig{ + Bucket: delivery.ControllerComposedBucket, + Description: "each composition's digest and the judgement of its move from what the node runs (novox/hq ADR 0539 " + + "clauses 2 and 14): written by the controller alone, read by the assigner", + History: delivery.ControllerComposedHistory, + MaxValueSize: 4 << 10, + MaxBytes: ComposedDigestsBytes, + Storage: jetstream.FileStorage, + } +} + +// EnsureComposed creates the bucket of digests and each named node's composition bucket if absent and brings their +// options to match. An update, never a delete. +func EnsureComposed(ctx context.Context, js jetstream.JetStream, nodes ...string) error { + if _, err := js.CreateOrUpdateKeyValue(ctx, ComposedDigestsConfig()); err != nil { + return fmt.Errorf("asserting bucket %s: %w", delivery.ControllerComposedBucket, err) + } + for _, node := range nodes { + cfg, err := ComposedConfig(node) + if err != nil { + return err + } + if _, err := js.CreateOrUpdateKeyValue(ctx, cfg); err != nil { + return fmt.Errorf("asserting bucket %s: %w", cfg.Bucket, err) + } + } + return nil +} + +// ComposedWrites is what the controller publishes to write a node's compositions: the bucket's own subjects. +func ComposedWrites(node string) (string, error) { + bucket, err := delivery.ComposedBucketFor(node) + if err != nil { + return "", err + } + return "$KV." + bucket + ".>", nil +} + +// 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" + +// administers says a person may invoke every tool (`*`): the operator. +func administers(invokes []string) bool { + for _, i := range invokes { + if i == "*" { + return true + } + } + return false +} + +// HandPathWrites is what the operator's account publishes for release 6's hand path (ADR 0539 clauses 2 and 10): a want +// of any node, written and read back, in the assigner's bucket; each named node's compositions; the digests; and each +// node's object bucket for a file over 64 KiB, whose put reads an object's metadata and purges a replaced one's chunks. +func HandPathWrites(nodes []string) ([]string, error) { + out := append([]string{"$KV." + delivery.AssignerWantsBucket + ".want.>"}, readGrants(delivery.AssignerWantsBucket, "want.>")...) + out = append(out, "$KV."+delivery.ControllerComposedBucket+".>") + out = append(out, readGrants(delivery.ControllerComposedBucket, "")...) + for _, node := range nodes { + w, err := ComposedWrites(node) + if err != nil { + return nil, err + } + bucket, _ := delivery.ComposedBucketFor(node) + out = append(out, w) + out = append(out, readGrants(bucket, "")...) + files, err := DeclarationFilesWrites(node) + if err != nil { + return nil, err + } + stream := "OBJ_" + DeclarationFilesBucket + node + out = append(out, files, "$JS.API.STREAM.INFO."+stream, "$JS.API.DIRECT.GET."+stream+".>", + "$JS.API.STREAM.MSG.GET."+stream, "$JS.API.STREAM.PURGE."+stream) + } + return out, nil +} + +// 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" + +// RaiseAfterDeclarations is Raise once the declarations' stream is retired: the mesh's streams and consumers but the +// declarations' stream, and no node's consumer of a declaration. +func RaiseAfterDeclarations(r Raiser) error { + for _, s := range MeshStreams() { + if s.Name == DeclarationsStream { + continue + } + if err := r.EnsureStream(s); err != nil { + return fmt.Errorf("asserting stream %s: %w", s.Name, err) + } + } + return AssertMeshConsumers(r) +} + +// TakeInConsumer is the durable consumer the controller takes done keys in from, on the stream of each of TakeInBuckets +// (novox/hq ADR 0544 §1). +const TakeInConsumer = "mesh-controller-take-in" + +// TakeInBuckets are the buckets the take-in consumes: the indexer's modules (each module's trunk value) and the builder's +// builds (each build's key). +var TakeInBuckets = []string{delivery.ModulesBucket, delivery.BuilderBuildsBucket} + +// ConfigFollowConsumer is the durable consumer the controller follows mesh-config's resolved values with, on that +// bucket's stream (novox/hq ADR 0545). +const ConfigFollowConsumer = "mesh-controller-config-follow" diff --git a/go/buscompose/controller_buckets.go b/go/buscompose/controller_buckets.go new file mode 100644 index 0000000..775f1c9 --- /dev/null +++ b/go/buscompose/controller_buckets.go @@ -0,0 +1,247 @@ +package buscompose + +import ( + "context" + "fmt" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats.go/jetstream" +) + +// The controller's own key-value buckets (novox/hq to-be 45 §1, §6, §7). +// +// **What the controller must remember across its own restart, it keeps on the bus.** A call's +// outcome lived in the memory of the process that served it (novox/hq issue 265), so a controller +// replaced while a push ran answered "no such call" for the one thing its caller had been told to +// ask about. The bus already outlives the controller and is the shape ADR 0201 gives a module's +// current state: one value per key, written by one owner, read by anybody granted it. These are the +// controller's, written by it alone — the writers table of to-be 45 §1 — and asserted on every start +// like the streams, so a bus raised from nothing has them before the first call is served. + +// CallsBucket keeps every call of the mesh's own verbs and what came of it; HandActsBucket every act +// a person did by hand, with why; ConditionsBucket every condition open now (to-be 45 §2), one key +// each, and ConditionHistoryBucket every transition of one — raised, changed, silenced, cleared — +// for ninety days. +// +// **The history is a bucket of its own** because its keys expire and an open condition's must not: +// a bucket has one age for every key, and a condition open longer than the history is kept would +// otherwise vanish from the store while still true. +var ( + CallsBucket = BucketName(ControllerSeat, "calls") + HandActsBucket = BucketName(ControllerSeat, "hand-acts") + ConditionsBucket = BucketName(ControllerSeat, "conditions") + ConditionHistoryBucket = BucketName(ControllerSeat, "condition-history") + // LeaseBucket holds the controller's lease (to-be 45 §6): one key, `holder`, which the instance + // allowed to act writes by compare-and-set and renews; its revision when taken is the epoch. + LeaseBucket = BucketName(ControllerSeat, "lease") + // AskedBucket keeps what the controller asked the operator about its conditions (novox/hq ADR 0259): + // each ask by its id, its options and the actions they stand for, how it ended and whether the + // controller acted on its warrant — so a restart neither asks twice nor acts twice. + AskedBucket = BucketName(ControllerSeat, "asked") + // DeliveryMergesBucket holds the merges mesh-delivery announced it made, each with the ask whose Approve it merged + // on (novox/hq ADR 0420 §7, issue 1025): per merge a key `///` holding the + // announcement, kept DeliveryMergesKeptFor. What lets the build of a moved module register it from its new + // repository: the controller reads the ask's warrant from the router and accepts only the operator's Approve of + // those heads. Written by the controller alone, from the announcement, when it holds the lease. + DeliveryMergesBucket = BucketName(ControllerSeat, "delivery-merges") +) + +// DeliveryMergesKeptFor is how long a merge mesh-delivery announced is kept: a week, as the events are. +const DeliveryMergesKeptFor = 7 * 24 * time.Hour + +// The controller's record mirrors (novox/hq ADR 0289, design 54, design 57 package F3): a bucket per kind of +// record, one key per record, written from the store by the lease holder only when the record changed, with +// one event on the controller's seat per write. Read by whatever names them in `reads` as +// `mesh-controller.` (ADR 0201 §1), written by nobody else. **No secret in either** (ADR 0201): an +// assignment's value names its module and node; a module's declared config is what its manifest, which is +// public, declares. +// +// AssignmentsBucket holds which module is assigned to which node, keyed `.`; and +// DeclaredConfigBucket each registered module's config declarations as its manifest holds them, keyed by +// the module: what mesh-config resolves a config value from (ADR 0348 clauses 2 and 4); and PredefinedBucket the +// list of predefined keys the mesh published (the newest trunk build's of the catalogue, ADR 0348 §7), one key +// `list`: what mesh-config publishes as mesh-config_predefined (design 57 K3, issue 662). +var ( + AssignmentsBucket = BucketName(ControllerSeat, MirrorAssignments) + DeclaredConfigBucket = BucketName(ControllerSeat, MirrorDeclaredConfig) + PredefinedBucket = BucketName(ControllerSeat, MirrorPredefined) + // DeclaredPackagesBucket holds, keyed by the node, each package the modules assigned to it declare, present or absent, + // with the declaring modules: what the pacman module's verbs read to say a package is declared (ADR 0507 §5). + DeclaredPackagesBucket = BucketName(ControllerSeat, MirrorDeclaredPackages) +) + +// The mirrors' local names, as a reader names them in `reads` after `mesh-controller.`. +const ( + MirrorAssignments = "assignments" + MirrorDeclaredConfig = "declared-config" + MirrorPredefined = "predefined" + // MirrorDeclaredPackages is the local name of the packages each node's modules declare (ADR 0507 §5, issue 1840/S3). + MirrorDeclaredPackages = "declared-packages" +) + +// The chain's buckets (novox/hq ADR 0408, issue 1089): builds, rollouts and assignments are one chain kept on the bus +// alone, one bucket each, one key per record, written by the lease holder by compare-and-set. The assignments bucket is +// AssignmentsBucket above, which keeps its name and its readers (ADR 0408 §3). Nothing is removed from them (§4), they keep +// ChainHistory earlier revisions of every key (§5), and a value is never cut (§6). +var ( + BuildsBucket = BucketName(ControllerSeat, ChainBuilds) + RolloutsBucket = BucketName(ControllerSeat, ChainRollouts) +) + +// **The assignments bucket keeps the marker age (MirrorMarkerTTL) it was made with as a mirror.** A stream that allows +// messages an age cannot be made to disallow them (the server refuses the update: "message TTL status can not be +// disabled", nats-server stream.go), so asserting the bucket without the age would fail the controller's start on a +// mesh that has the bucket. The chain never purges or removes a key (ADR 0408 §4), so no marker is ever written and the +// age ends nothing; it is kept only so the stream's configuration stays one the server accepts. +// +// The chain buckets' local names, as a reader names them in `reads` after `mesh-controller.`. +const ( + ChainBuilds = "builds" + ChainRollouts = "rollouts" +) + +// The chain buckets' bounds. ChainHistory is the bus's largest history (ADR 0408 §5). A value is at most ChainValueBytes, +// just under the server's largest message of a mebibyte, which also carries the subject and the headers (ADR 0408 §6): +// the controller writes none larger and the move refuses to. The buckets' size is the arithmetic of the survey of +// issue 981: 8585 builds of 1 to 40 KB, 1049 rollouts of about 2 KB a revision and 64 revisions, so 512 MiB for the two +// that grow, and the assignments, 285 of under a kilobyte, 64 MiB. +const ( + ChainHistory = 64 + ChainValueBytes = 1<<20 - 8<<10 + ChainBuildsBytes = 512 << 20 + ChainRolloutsBytes = 512 << 20 + ChainAssignmentBytes = 64 << 20 +) + +// GrantsBucket holds, keyed `.`, each fingerprint of a module whose bus credential the controller +// has issued (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, issue 1394): written by the controller alone once the bus's +// user list composed with the module's manifest at that fingerprint was sent to the bus's machine; read by the assigner, +// whose sixth rule moves a node's want to a fingerprint only once its grant exists. The name and layout are mesh-sdk's +// (delivery.ControllerGrantsBucket, ControllerGrantKey). +var GrantsBucket = BucketName(ControllerSeat, ControllerGrants) + +// ControllerGrants is the grants bucket's local name, as a reader names it in `reads` after `mesh-controller.`. +const ControllerGrants = "grants" + +// ChainBuckets are the three buckets of the chain: builds, rollouts and assignments. +func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, AssignmentsBucket} } + +// MirrorsWrittenByAnother are the controller's mirrors whose writer is not the controller, each with its reason: the rule +// that a mirror is written by the controller alone (TestTheMirrorsListAndTheWritersTableAgree) has these exceptions and no +// other. +var MirrorsWrittenByAnother = map[string]string{} + +// The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket. +const ( + MirrorValueBytes = 64 << 10 + MirrorBytes = 64 << 20 + // MirrorMarkerTTL is the longest a purge's marker is kept (design 54 §2): the bucket allows a + // per-message age for it, and the writer gives each marker a day. + MirrorMarkerTTL = 24 * time.Hour +) + +// PlanStateBuckets are the buckets of the plans' state that release 6 deletes (novox/hq ADR 0539 clause 13): the merge +// window's clock, the silent sends' waits and the building plans' hold. The controller deletes each at its first start +// and says it once (composed_start.go); none is asserted, granted or written any more. +var PlanStateBuckets = []string{BucketName(ControllerSeat, "merge-window"), BucketName(ControllerSeat, "rollout-silent"), + BucketName(ControllerSeat, "plans-building")} + +// AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own. +const AskedKeptFor = 30 * 24 * time.Hour + +// LeaseTTL is how long the lease's key lives unrenewed (to-be 45 §6): fifteen seconds, renewed +// every five. The bucket's age, so the bus forgets a holder that stopped renewing. +const LeaseTTL = 15 * time.Second + +// The bounds to-be 45 §6 sets for calls: the last thousand, or fourteen days, whichever is fewer. +// A call is two keys — its record, and its answer apart so a listing does not read every answer — +// so the stream holds twice as many messages as it keeps calls. +const ( + KeptCallsDurably = 1000 + CallsKeptFor = 14 * 24 * time.Hour + // CallAnswerBytes is the most of one answer kept: a whole declaration is far smaller, and an + // answer larger is cut and says so. + CallAnswerBytes = 64 << 10 + // HandActsKeptFor is as long as a condition's history (to-be 45 §2): an act by hand is read + // back beside what it addressed. + HandActsKeptFor = 90 * 24 * time.Hour + // ConditionHistoryKeptFor is how long a condition's transitions are kept (to-be 45 §2). + ConditionHistoryKeptFor = 90 * 24 * time.Hour +) + +// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares. +func IsControllerBucket(bucket string) bool { + return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket || + bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket || + bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket || + bucket == DeclaredPackagesBucket || + bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket || + bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket || + bucket == GrantsBucket || bucket == delivery.ControllerComposedBucket +} + +// ControllerBuckets are the controller's own buckets, in the order they are asserted. +func ControllerBuckets() []string { + return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket, + AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, DeclaredPackagesBucket, + DeliveryMergesBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket, GrantsBucket, + delivery.ControllerComposedBucket} +} + +// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection. +type ControllerBucketsAsserter interface { + EnsureControllerBuckets() error +} + +// chainBucket is the options of a bucket of the chain: sixty-four revisions of each key, no age, a value up to +// ChainValueBytes, on disk. +func chainBucket(bucket, why string, bytes int64, markers time.Duration) jetstream.KeyValueConfig { + return jetstream.KeyValueConfig{ + Bucket: bucket, + Description: why, + History: ChainHistory, + MaxValueSize: ChainValueBytes, + MaxBytes: bytes, + Storage: jetstream.FileStorage, + LimitMarkerTTL: markers, + } +} + +// EnsureLeaseBucket creates the lease's bucket if absent and brings its options to match (to-be 45 +// §6). **Before the lease is taken, by any candidate**: it is the lease's own precondition, and asserting +// a bucket that exists changes nothing. File storage, so its revisions — the epochs — outlive a restart of +// the bus; one raised again from nothing is moved past the highest epoch issued when the lease is taken. +func EnsureLeaseBucket(ctx context.Context, js jetstream.JetStream) error { + if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ + Bucket: LeaseBucket, + Description: "the controller's lease (novox/hq to-be 45 §6): the key `holder`, written by compare-and-set " + + "by the one controller instance that may act and renewed every five seconds; its revision when taken " + + "is the epoch every declaration and plan write carries", + History: 1, + TTL: LeaseTTL, + MaxValueSize: 4 << 10, + MaxBytes: 1 << 20, + Storage: jetstream.FileStorage, + }); err != nil { + return fmt.Errorf("asserting bucket %s: %w", LeaseBucket, err) + } + return nil +} + +// HaveBucketConfig is the options of the have bucket (novox/hq ADR 0429 §1): delivery.HaveHistory revisions of each +// key, no age, 64 MiB on disk like the assignments. A module's have is JSON of about 500 bytes, but a node's own key +// carries its whole facts (its profile among them), several kilobytes on a desktop: a value is as large as the chain's +// buckets take, since a value is never cut (novox/hq ADR 0408 §6, issue 1162). +func HaveBucketConfig() jetstream.KeyValueConfig { + return jetstream.KeyValueConfig{ + Bucket: delivery.HaveBucket, + Description: "what each machine has of each module, one key `.` (and `.node` for the machine's " + + "own facts) (novox/hq ADR 0429, design 47): written by each machine's node-engine under its own node's keys " + + "alone, never by the controller; read by the delivery view and the watchman", + History: delivery.HaveHistory, + MaxValueSize: ChainValueBytes, + MaxBytes: 64 << 20, + Storage: jetstream.FileStorage, + } +} diff --git a/go/buscompose/declaration_files.go b/go/buscompose/declaration_files.go new file mode 100644 index 0000000..af20264 --- /dev/null +++ b/go/buscompose/declaration_files.go @@ -0,0 +1,111 @@ +package buscompose + +import ( + "context" + "fmt" + "time" + + "git.novox.be/novox/mesh-sdk/go/declaration" + "github.com/nats-io/nats.go/jetstream" +) + +// 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 +} + +// EnsureDeclarationFiles is the same on a JetStream the caller holds. +func EnsureDeclarationFiles(ctx context.Context, js jetstream.JetStream, nodes ...string) error { + for _, node := range nodes { + cfg, err := DeclarationFilesConfig(node) + if err != nil { + return err + } + if _, err := js.CreateOrUpdateObjectStore(ctx, cfg); err != nil { + return fmt.Errorf("asserting the object bucket %s: %w", cfg.Bucket, err) + } + } + return nil +} + +// DeclarationFilesConfig is a node's bucket as the controller asserts it. +func DeclarationFilesConfig(node string) (jetstream.ObjectStoreConfig, error) { + bucket, err := DeclarationFilesBucketOf(node) + if err != nil { + return jetstream.ObjectStoreConfig{}, err + } + return jetstream.ObjectStoreConfig{ + Bucket: bucket, + Description: "the contents of the files over 64 KiB of the declaration of " + node + ", each under the hex of its sha256 " + + "digest (novox/hq ADR 0486): written by the controller alone, read by the node-engine of that node by name, removed " + + "a day after the node's latest declaration no longer names it", + MaxBytes: DeclarationFilesMaxBytes, + Storage: jetstream.FileStorage, + }, nil +} diff --git a/go/buscompose/derived.go b/go/buscompose/derived.go new file mode 100644 index 0000000..04e0e87 --- /dev/null +++ b/go/buscompose/derived.go @@ -0,0 +1,357 @@ +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 +} + +// 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", + } +} + +// AssertNodeConsumers brings every known node's declaration consumer into being. +// +// Asserted on start as well as created at enrolment, for the reason the streams are: a mesh raised +// from a restored backup, or one whose bus was recreated, has node records and no consumers, and a +// node whose consumer is missing hears nothing while everything else about it looks correct. +func AssertNodeConsumers(e Ensurer, nodes []string) error { + for _, n := range nodes { + if err := e.EnsureConsumer(NodeConsumer(n)); err != nil { + return fmt.Errorf("asserting how %s hears its declaration: %w", n, err) + } + } + return nil +} + +// 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/go/buscompose/doc.go b/go/buscompose/doc.go new file mode 100644 index 0000000..7b25d27 --- /dev/null +++ b/go/buscompose/doc.go @@ -0,0 +1,12 @@ +// 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, Compose), with the grants, +// streams, state and logs it composes them from. +// +// **A copy of the controller's internal/broker, composer half** (issue 1377, novox/hq ADR 0547 §5), taken at +// mesh-controller 7cd4d18b: the bus client (jetstream.go), the broker's address from the environment (broker.go, onnats.go +// but its one constant) and the two methods on that client are left out; the lease's two subjects are copied in +// (nats.go). The controller keeps its own copy, unchanged, until it loses its lease at ADR 0547's third milestone, so +// for that time two composers state who may say what and must say the same: TestTheCopiedComposerComposesAsTheControllers +// holds this copy to lists the controller's own code wrote from the same fixtures. A change to what is granted is made +// here and, while the controller composes, in the controller's copy too, never in one alone. +package buscompose diff --git a/go/buscompose/fixtures_test.go b/go/buscompose/fixtures_test.go new file mode 100644 index 0000000..6753cf6 --- /dev/null +++ b/go/buscompose/fixtures_test.go @@ -0,0 +1,103 @@ +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"}, + }, + "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"}}}, + }, + "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 +} diff --git a/go/buscompose/log.go b/go/buscompose/log.go new file mode 100644 index 0000000..5c22001 --- /dev/null +++ b/go/buscompose/log.go @@ -0,0 +1,184 @@ +package buscompose + +import ( + "fmt" + "sort" + "strings" + + "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. Unlike a bucket, a log is its owner's alone: no other module +// reads it, so there is no read of a log to grant or issue. +// +// 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 +} + +// 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 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: 0, + 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 +} + +// 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 +} + +// A LogAsserter is the part of a JetStream connection log assertion needs. It has no way to remove a +// log, by design: nothing the mesh runs asks for one. +type LogAsserter interface { + // EnsureLog creates the log's stream if absent and brings its configuration to match if present, + // never deleting or recreating it. + EnsureLog(l Log) error + // LogStreams is every log stream on the server: every stream named with LogStreamPrefix. + LogStreams() ([]string, error) +} + +// RaiseLogs asserts every declared log and answers the log streams on the server that nothing +// declares any more. +// +// **Those are reported, never removed** (novox/hq ADR 0297 §2, ADR 0201 §15–16): a log is a module's +// record, and a manifest edited, a module renamed or unassigned is an ordinary day's work that must +// not take a record with it. Removing one is a person's act, outside the mesh. +func RaiseLogs(a LogAsserter, logs []Log) (undeclared []string, err error) { + sorted := append([]Log(nil), logs...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Stream() < sorted[j].Stream() }) + declared := map[string]bool{} + for _, l := range sorted { + if err := a.EnsureLog(l); err != nil { + return nil, fmt.Errorf("asserting %s's log %q: %w", l.Module, l.Name, err) + } + declared[l.Stream()] = true + } + names, err := a.LogStreams() + if err != nil { + return nil, fmt.Errorf("listing the bus's logs: %w", err) + } + for _, n := range names { + if strings.HasPrefix(n, LogStreamPrefix) && !declared[n] { + undeclared = append(undeclared, n) + } + } + sort.Strings(undeclared) + return undeclared, nil +} diff --git a/go/buscompose/membership.go b/go/buscompose/membership.go new file mode 100644 index 0000000..d28ff68 --- /dev/null +++ b/go/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 +} + +// 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 { + if servedOnlyByTheController(s) { + continue // answered by the serving controller alone, never through a membership + } + 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 = logsIssuedFor(d) + 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} + 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/go/buscompose/modstreams.go b/go/buscompose/modstreams.go new file mode 100644 index 0000000..d270738 --- /dev/null +++ b/go/buscompose/modstreams.go @@ -0,0 +1,343 @@ +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 (catalogue.WorkQueueStreamProblems). +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 +} + +// Pruner is the part of a JetStream connection that removes a module's consumer on a module's stream it +// may no longer hear (ADR 0363 §6). Optional: what only records what it is asked to assert has none. +type Pruner interface { + ConsumerNames(stream string) ([]string, error) + RemoveConsumer(stream, name string) error +} + +// PruneHeard removes, from each module stream, every durable consumer the composition no longer asks for, +// and answers what it removed as `/`. A consumer that loses its grant loses what it reads +// through, whatever its runtime still holds; a stream that does not exist yet has nothing to remove. +func PruneHeard(p Pruner, streams []ModuleStream, wanted []Consumer) ([]string, error) { + keep := map[string]bool{} + for _, c := range wanted { + keep[c.Stream+"/"+c.Name] = true + } + var removed []string + for _, s := range streams { + names, err := p.ConsumerNames(s.Name) + if err != nil { + return removed, fmt.Errorf("listing the consumers on %s: %w", s.Name, err) + } + for _, n := range names { + if keep[s.Name+"/"+n] { + continue + } + if err := p.RemoveConsumer(s.Name, n); err != nil { + return removed, fmt.Errorf("removing %s's consumer on %s, which is no longer granted: %w", n, s.Name, err) + } + removed = append(removed, s.Name+"/"+n) + } + } + return removed, nil +} + +// EnsureHeard asserts a consumer on a module's stream (ADR 0363 §5, §6). **On a restricted stream, a consumer +// the bus will not bring to match is removed**: a pull consumer delivers to whoever pulls it, so one left with +// its wider filters after a narrowing was refused would keep handing over what the grant no longer names, +// whatever the user list now says. The next assertion makes it again with the granted filters. The refusal is +// returned either way, saying what was done, so the send says it and raises it. +func EnsureHeard(r Raiser, c Consumer, restricted bool) error { + err := r.EnsureConsumer(c) + if err == nil || !restricted { + return err + } + p, prunes := r.(Pruner) + if !prunes { + return fmt.Errorf("%w; it could not be removed here, and may still deliver more than it is granted", err) + } + if rerr := p.RemoveConsumer(c.Stream, c.Name); rerr != nil { + return fmt.Errorf("%w; REMOVING IT FAILED TOO, so it may still deliver more than it is granted: %v", err, rerr) + } + return fmt.Errorf("%w; REMOVED, so it delivers nothing it is no longer granted: the next assertion makes it "+ + "again with its granted filters (novox/hq ADR 0363 §6)", err) +} diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go new file mode 100644 index 0000000..f96a3f0 --- /dev/null +++ b/go/buscompose/nats.go @@ -0,0 +1,1740 @@ +// 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" + "git.novox.be/novox/mesh-sdk/go/announce" + "regexp" + "slices" + "sort" + "strings" + "time" + + "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" + KindController Kind = "controller" + 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 this principal writes, for the controller alone (novox/hq ADR 0486 §2): + // 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 the tools the controller asks in its own name for a credential a provider's software + // issued, each `.` (novox/hq ADR 0305): on the subject that names its caller, so the tool + // knows the controller asks. The vault's holder is granted the same tools in its own name (issue 723, + // vaultCalls); the controller's grant goes with its path in R1 (issue 682). Composed from the records, so the installer's first user list — a controller + // with no records — carries none, and moves with issue 464 like the rest of the controller's caller-named + // grants. + AsksIssued []string + + // AsksTheVault are the vault's verbs the controller asks in its own name (VerbsTheControllerAsksTheVault, + // issue 675), those the vault's holder serves: on the subject that names its caller, so the vault knows the + // controller asks. Composed from the records like AsksIssued, so the installer's first user list carries none. + AsksTheVault []string + + // AsksTheConfig are the mesh-config verbs the controller asks in its own name (VerbsTheControllerAsksTheConfig, + // issue 925), those mesh-config's holder serves: on the subject that names its caller, so mesh-config takes a + // value as the controller carries it (ADR 0348 §8). Composed from the records like AsksTheVault, so the + // installer's first user list carries none. + AsksTheConfig []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). The controller + // keeps its own grant to both until R1 (issue 682), for the modules still filled the old way (clause 17). + 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 + // 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 + + // WitnessesController is a machine principal whose node-engine witnesses the controller's upgrades: + // the machine runs the controller (novox/hq ADR 0236, lease/witness.go). It may read the lease's one + // key, and nothing else of the bucket, so it can judge a new controller build and put the previous + // one back. + WitnessesController bool + // 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 + // 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 + + // 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"}} + +// VerbsTheControllerAsksForASecret are the seat verbs `give` calls (novox/hq ADR 0259 §10): the operator's +// desk opens a prompt that does not show what is typed, and answers it sealed to the controller's call. +var VerbsTheControllerAsksForASecret = []SeatVerb{{Seat: "node-launcher", Verb: "secret"}} + +// ControllerOnly are the subjects the controller alone may publish, 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 controller asks: only the bus makes that true. +func ControllerOnly() []string { + var out []string + for _, v := range VerbsTheControllerAsksForASecret { + 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 +} + +// 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 +} + +// VerbsTheControllerAsksTheDeliveryOwner are the mesh-delivery seat's verbs the controller calls (novox/hq +// ADR 0239): its self-check reads `stalled`, and healer H2 takes the one transition the table allows +// through `close`. A mesh seat's verb is flat: no machine in the subject. +// +// And, since novox/hq ADR 0259, `release` and `stop`: the controller asks the operator for them about a +// delivery held past its bound, and calls them on the operator's warrant, with its why. +// +// And, since novox/hq ADR 0282, `times`: the self-check reads the delivery times to say a delivery over its budget. +// +// And, since novox/hq ADR 0306, `groups`: a merge heard from a head waits for the window while its delivery group +// has a member still to merge. +var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery", Verb: "stalled"}, + {Seat: "mesh-delivery", Verb: "close"}, {Seat: "mesh-delivery", Verb: "release"}, {Seat: "mesh-delivery", Verb: "stop"}, + {Seat: "mesh-delivery", Verb: "times"}, {Seat: "mesh-delivery", Verb: "groups"}} + +// VerbsTheControllerAsksTheVault are the mesh-vault seat's verbs the controller calls in its own name (novox/hq +// ADR 0349 clauses 8 to 10, design 57's V2, issue 675): it carries a value given at its terminal, typed at a desk +// or issued by a provider's software into the vault, sealed to a key the vault made for the call, and asks a +// rotation. The vault takes these from the controller alone, which it knows from the subject that names its +// caller; granted as Principal.AsksTheVault says. +// +// And `move-asks` (design 57's V3, issue 676): the controller copies its record of asks at a desk, which holds no +// value, into the vault's. +// +// And `get` (novox/hq ADR 0349 §16, issue 755): the controller asks for a core module's own credential sealed to its +// machine's key, which it carries in that machine's declaration and cannot open (mesh-vault's core.go). +var VerbsTheControllerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "accept"}, + {Seat: "mesh-vault", Verb: "secret-ask"}, {Seat: "mesh-vault", Verb: "issued"}, {Seat: "mesh-vault", Verb: "rotate"}, + {Seat: "mesh-vault", Verb: "move-asks"}, {Seat: "mesh-vault", Verb: "get"}} + +// VerbsTheControllerAsksTheConfig are the mesh-config seat's verbs the controller calls in its own name (issue 925): +// `config`, the one verb mesh-config serves, which move-config uses to set each value and the settings verb to set, +// clear and propose through mesh-config. mesh-config takes a value as the controller carries it only from the +// subject that names the controller as caller (ADR 0348 §8); granted as Principal.AsksTheConfig says. +var VerbsTheControllerAsksTheConfig = []SeatVerb{{Seat: "mesh-config", Verb: "config"}} + +// 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"} + +// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant +// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the +// controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release +// sent by its own `upgrade release-backlog` (novox/hq issue 1732). Named one by one; a node seat's on any machine, a +// mesh seat's flat. +var VerbsTheControllerActsOnAWarrant = []SeatVerb{{Seat: "node-service-manager", Verb: "restart"}, + {Seat: ControllerSeat, Verb: "plans"}, {Seat: ControllerSeat, Verb: "push"}, {Seat: ControllerSeat, Verb: "upgrade"}} + +// VerbsTheControllerAsksTheOperatorChannel are the operator channel's verbs the controller calls (novox/hq +// ADR 0380): `tell`, one plain message with no condition behind it — a module's own secret given, named, +// where and when and by whom, asking nothing. A mesh seat's verb is flat: no machine in the subject. +var VerbsTheControllerAsksTheOperatorChannel = []SeatVerb{{Seat: "operator-channel", Verb: "tell"}} + +// DegradableGrant is a publish grant whose absence from the installer's carried first-user list is not a +// bug in the template: a fallback covers the refusal. +type DegradableGrant struct { + Subject string + Reason string +} + +// DegradableGrants are the only publish grants TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose +// may find missing from the installer's carried list without failing (novox/hq issue 835, the forge check +// of mesh-controller#345): a change that only edits mesh-host's examples can never satisfy a check that +// reads the mesh-host commit the machines actually run, so the carried list lags behind the controller's +// derived one until mesh-host's own module next builds. operator-channel.tell is the one grant the +// controller already has a fallback for: refused (no holder, or the bus refuses it), the controller raises +// the secret-given condition exactly as before (ADR 0380 §9), so an older carried list still comes up and +// works. Every other grant stays required — missing one of those is a controller that comes up and is +// refused on the first thing it tries, with no fallback that covers it. +var DegradableGrants = []DegradableGrant{ + { + Subject: announce.Subject(ControllerSeat), + Reason: "refused: the controller's own announcement waits for its first push, and until it is kept every reader of " + + "its states is granted from its `reads` as before (novox/hq ADR 0495, issue 1790)", + }, + { + Subject: "$KV." + StatesAnnouncedBucket + ".>", + Reason: "refused: no announcement is kept until the controller's first push, and until then every reader is " + + "granted from its `reads` as before (novox/hq ADR 0495, issue 1790)", + }, + { + Subject: "mesh.seat." + ControllerSeat + ".tool.push", + Reason: "refused: the ask of a held move (Release) is answered, and the refusal told, while the operator's own " + + "push at the terminal still sends the machine (issue 1732; ADR 0259)", + }, + { + Subject: "mesh.seat." + ControllerSeat + ".tool.upgrade", + Reason: "refused: the ask of the builds held after a failed release is answered, and the refusal told, while the " + + "operator's own `upgrade release-backlog` at the terminal still releases them (issue 1732; ADR 0259)", + }, + { + Subject: "mesh.seat." + VerbsTheControllerAsksTheOperatorChannel[0].Seat + ".tool." + VerbsTheControllerAsksTheOperatorChannel[0].Verb, + Reason: "refused: the controller raises the secret-given condition as before (ADR 0380 §9)", + }, + { + Subject: "$JS.ACK.EVENTS." + MergesConsumerName + ".>", + Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at " + + "its first push, which is when it acknowledges a merge (ADR 0407 decision 3, issue 973)", + }, + { + Subject: "$KV." + DeclaredPackagesBucket + ".>", + Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + + "first push, and keeps the packages each node declares only after that, holding the lease (ADR 0507 §5, issue 1840)", + }, + { + Subject: "mesh.seat." + ControllerSeat + ".event.artifacts-gone", + Reason: "refused: the take-in is refused all the same and says why; only the builder's rebuild waits for the " + + "controller's first push, and the builder also asks held when a module's value or its own start comes (novox/hq " + + "issue 1937)", + }, + { + Subject: "mesh.seat." + ControllerSeat + ".event.sent", + Reason: "refused: the controller says a tier sent to a machine only for outsiders, and a send that could not " + + "be said is printed and goes on (ADR 0406 §5, issue 974)", + }, + { + Subject: "$KV." + DeliveryMergesBucket + ".>", + Reason: "refused: the controller then keeps no merge mesh-delivery announced, so the build of a moved module is " + + "refused as it is without the operator's Approve — failing closed (ADR 0420 §7, issue 1025)", + }, + { + Subject: "$KV." + BuildsBucket + ".>", + Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + + "first push, and writes the chain only after that, holding the lease (ADR 0408 §2 and §8, issue 1089)", + }, + { + Subject: "mesh.seat." + ControllerSeat + ".event.bucket-deleted", + Reason: "refused: nothing is deleted at genesis; the controller composes its own permissions into the bus module's " + + "composition before a later start's deletions (novox/hq ADR 0539 clause 13)", + }, + { + Subject: "$KV." + delivery.ControllerComposedBucket + ".>", + Reason: "carried only at genesis: the controller composes its own permissions into the bus module's composition, " + + "and writes a composition's digest only after the bus applied it (novox/hq ADR 0539 clauses 2 and 7)", + }, + { + Subject: "$JS.ACK.KV_" + delivery.ModulesBucket + "." + TakeInConsumer + ".>", + Reason: "refused: an acknowledgement refused leaves the key delivered again after its wait, and the take-in's first " + + "batch with something new composes the bus's node, which grants it (novox/hq ADR 0544 §1)", + }, + { + Subject: "$JS.ACK.KV_" + delivery.BuilderBuildsBucket + "." + TakeInConsumer + ".>", + Reason: "refused: an acknowledgement refused leaves the key delivered again after its wait, and the take-in's first " + + "batch with something new composes the bus's node, which grants it (novox/hq ADR 0544 §1)", + }, + { + Subject: "$KV." + GrantsBucket + ".>", + Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + + "first push, and writes a grant only after a send of the bus's user list, which comes after that (ADR 0528 " + + "clause 2, issue 1394)", + }, + { + Subject: "$KV." + RolloutsBucket + ".>", + Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + + "first push, and writes the chain only after that, holding the lease (ADR 0408 §2 and §8, issue 1089)", + }, +} + +// 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: + return "node." + p.Node + case KindController: + return "controller" + 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. +func (p Principal) inbox() string { 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 controller's alone (ControllerOnly), + // 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 + } + + var pub, sub []string + switch p.Kind { + case KindController: + // The controller owns the mesh's own traffic and the streams. It is the only writer of + // stream definitions (design 25 §3), so it alone reaches the JetStream API — and it alone + // issues memberships (novox/hq ADR 0160), which it publishes into the assignments stream + // after each push; refused by the server on 2026-10-01 until this line named them. + // + // **Not what the machines say** (novox/hq to-be 45 §1): `mesh.control.>` is subscribed, never + // published — a machine's report has one writer, its node-engine, and the controller granted + // that subject would be a second (the writers table refuses it). It was granted from the first + // composition and nothing ever published under it. + pub = []string{"mesh.node.>", "mesh.assignment.>", "$JS.API.>"} + // **And where its consumers deliver.** A push consumer delivers on `_DELIVER.`, + // and a client bound to it subscribes exactly that; the server refused it for every + // principal the first time one bound a consumer (2026-09-28). Each kind below is granted + // its own consumers' delivery subjects and no other's. + sub = []string{"mesh.control.>", "$JS.API.>", "_DELIVER." + ControllerName, "_DELIVER." + ControllerName + ".>"} + + // Work the mesh's own flows submit to a role, and the outcomes they wait on (ADR 0121). A + // build is the one today: the controller asks, and reads the answer from the seat's event + // like the catalogue does — which is why no holder needs to publish into anybody's inbox. + // A node-scoped seat's work subject carries no node (novox/hq ADR 0190): the ask goes to + // the role, and whichever machine holding it is idle takes it. + // **And what the mesh says it did** (novox/hq ADR 0134). The control plane states its own + // facts under the seat it holds, because a role's events belong to the role and keep their + // address while the holder is replaced. Named one by one rather than as a whole namespace: + // least authority, and a fact nothing states is authority nobody uses. + for _, event := range ControllerStates { + pub = append(pub, seatEventSubject(ControllerSeat, event)) + } + + // Every module's tools: **the control plane is the way in** (novox/hq ADR 0095). A person + // or an agent asks through it and every question passes one process where an audit + // belongs — so it, alone among principals, may call any tool by name. The first `ask` on + // the new bus was refused the publish (2026-09-28). + pub = append(pub, "mesh.mod.*.tool.>") + + // **And the mesh's own verbs, as the seat it holds** (novox/hq ADR 0132, ADR 0154): + // `status`, `push`, `assign` are the mesh-controller seat's tools, served by its holder. The + // whole verb namespace of its own seat rather than a list: the list is the seat's protocol, + // which this package mirrors rather than reads, and a verb the seat does not declare is a + // subject nothing publishes. + sub = append(sub, "mesh.seat."+ControllerSeat+".tool.>") + // And says so (novox/hq ADR 0197): it answers discovery for the seat it serves. + sub = append(sub, announcing(ControllerSeat)...) + // And the verbs the self-check reads with, of any machine's holder (novox/hq to-be 45 §4). + for _, v := range VerbsTheSelfCheckAsks { + pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*") + } + // And the bus's planned step: a snapshot taken now, before the bus is replaced (ADR 0236). + for _, v := range VerbsTheBusStepAsks { + pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*") + } + // And the operator's desk, for a secret given there (ADR 0259 §10). + for _, v := range VerbsTheControllerAsksForASecret { + pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*") + } + // And the operator channel, a mesh seat, asked flat: one message when a secret is given (ADR 0380). + for _, v := range VerbsTheControllerAsksTheOperatorChannel { + pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb) + } + // And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239). + for _, v := range VerbsTheControllerAsksTheDeliveryOwner { + pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb) + } + // And the verbs a warrant chooses (novox/hq ADR 0259): a node seat's on any machine, its own flat. + for _, v := range VerbsTheControllerActsOnAWarrant { + if v.Seat == ControllerSeat { + pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb) + continue + } + pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*") + } + // And asking the operator (novox/hq ADR 0259): an ask and its cancel under its own name, its warrants + // heard under its own name, the record of its asks read under its own name — as any user of the seat, + // derived the same way, from the seat its holder declares. + tp, ts := seatTrafficGrants(SeatTrafficOf(ControllerSeat, nil, p.Uses, nil)) + pub = append(pub, tp...) + sub = append(sub, ts...) + // And asks who answers (novox/hq to-be 45 §4, D3): the self-check finds every seat's holder by + // the same discovery the console reads. The question only; the answers come to its own inbox. + pub = append(pub, "$SRV.INFO") + + // The events it reacts to, and its ack subject on the stream they arrive from + // (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the + // controller a subscriber to every event in the mesh, and its permission list would stop + // saying what it is for. The one wildcard is the emitter of a provider's standing (ADR + // 0224) — still two named events, from whichever module provides. The ack grant below is scoped per stream because the controller's + // consumer name is the same on both and `$JS.ACK.CONTROL.controller.>` does not cover a + // delivery from EVENTS — a consumer that cannot ack has every message redelivered for + // ever, refused by the list it already has. + sub = append(sub, ControllerFollows...) + pub = append(pub, "$JS.ACK.EVENTS."+ControllerName+".>", "$JS.ACK.EVENTS."+MergesConsumerName+".>") + // And the take-in's two durable consumers on the indexer's and the builder's buckets (novox/hq ADR 0544 §1): + // acknowledged after each key is taken in, and no other consumer of those streams. + for _, bucket := range TakeInBuckets { + pub = append(pub, "$JS.ACK.KV_"+bucket+"."+TakeInConsumer+".>") + } + + // **Where an enrolment's answer goes**, and `allow_responses` does not cover it. That + // permits one reply to the reply subject of a message the user received — and a message a + // JetStream consumer delivers has had that field claimed for the consumer's own ack address + // (design 25 §2), so the address the controller actually answers is the one the request + // carried in its payload, which is not a reply subject as the server understands it. + // + // Verified against a real server before this line existed: the answer was refused with + // "Permissions Violation for Publish to _INBOX.enrol.anchor…", and every enrolment on the + // mesh would have timed out while the controller logged success. + // + // **The enrolment inbox space, not a blanket `_INBOX.>`.** Design 25 §4 refuses that, and + // this is not it: nothing but an enrolling node ever subscribes under this prefix, each + // scoped to its own token's, so the controller publishing here is the mesh answering + // enrolments and can reach nothing else. + pub = append(pub, "_INBOX."+enrolmentPrefix+".>") + + // **And its own buckets** (novox/hq to-be 45 §1): the calls it served and the acts done by + // hand, which it alone writes. A put is a publish to the bucket's subject, which `$JS.API.>` + // does not cover; each bucket named, not `$KV.>`, which would let it write any module's state. + // And says what it keeps, as every module does (novox/hq ADR 0495 clause 2, issue 1790). + pub = append(pub, announce.Subject(ControllerSeat)) + for _, bucket := range ControllerBuckets() { + if bucket == delivery.HaveBucket { + continue // each node's node-engine writes its own keys of it, the controller none (ADR 0429 §1) + } + pub = append(pub, "$KV."+bucket+".>") + } + // **And the object bucket a declaration's large files travel in** (novox/hq ADR 0486 §2, issue 1697): the + // controller alone writes it, as a publish to the bucket's own subjects, which `$JS.API.>` does not cover. + for _, node := range p.FilesFor { + writes, err := DeclarationFilesWrites(node) + if err != nil { + return Permissions{}, err + } + pub = append(pub, writes) + // And each node's compositions, which it alone writes (novox/hq ADR 0539 clause 2). + composed, err := ComposedWrites(node) + if err != nil { + return Permissions{}, err + } + pub = append(pub, composed) + } + // **And what the bus says about itself, read-only** (novox/hq to-be 45 §3, S9): a durable + // consumer that gave up on a message, or one that was deleted. The server already publishes + // both in the mesh's own account; the controller says each as a condition in the mesh's words. + // Named, not `$JS.EVENT.>`: the other advisories are every API call the mesh makes. + sub = append(sub, BusAdvisories...) + // **And what a consumer gave up on, kept and delivered again** (novox/hq issue 330): the notice + // acknowledged once the message is copied, the copy kept, and an event delivered again to the one + // consumer that gave it up. An ask to a seat is not delivered again, so no seat's queue is granted. + pub = append(pub, "$JS.ACK."+DeadLetterNoticesStream+"."+ControllerName+".>", deadLetterPrefix+">", + againPrefix+">") + + 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()...) + // And, for an administrator (`*`, the operator), release 6's hand path (ADR 0539 clauses 2 and 10): a want written + // at the terminal, and a composition made by hand with mesh-hand-compose. + if administers(p.Invokes) { + hand, err := HandPathWrites(p.FilesFor) + if err != nil { + return Permissions{}, err + } + pub = append(pub, hand...) + } + + 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") + } + pub = []string{"mesh.control.enrol"} + 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 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, 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())) + } + + 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) + } + // 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): the one subject a runtime derives for itself, read + // directly from the stream and followed live. Nothing else's. + sub = append(sub, MembershipSubject(p.Node, p.Module)) + pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+"."+MembershipSubject(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 — but the controller's own seat, whose verbs only the serving + // controller answers, on its own connection (servedOnlyByTheController). + for _, s := range p.Holds { + if servedOnlyByTheController(s) { + continue + } + 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. + pub = append(pub, stateGrants(stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State, + PerMachine: p.PerMachine, Reads: p.Reads, KeyedReads: p.KeyedReads})...) + // And its logs (novox/hq ADR 0297): appended to and read directly, its own alone. + pub = append(pub, logGrants(p.Module, p.Logs)...) + + // 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 { + if servedOnlyByTheController(s) { + continue + } + 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 + // directly from the stream and followed live. This node's and no other's — the one token + // that varies is the module, so the pattern is the machine's own assignments. + sub = append(sub, "mesh.assignment."+p.Node+".*") + pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+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))...) + } + // **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 || p.Kind == KindController { + // 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 for one release beside these, + // so callers and runtimes move without a gap; their retirement is hq issue 464. + // + // **Not the controller's, this release.** Its grants are also the installer's first user list + // (TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose), judged against the node-engine the mesh + // runs, so a change to them waits on a node-engine delivery. It calls and serves on the subjects that name + // no caller meanwhile — a caller-named call to its seat reaches nobody and is asked again on those at once — + // and moves with 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 + if p.Kind != KindController { + pub, sub = callerNamed(p.Username(), pub, sub) + if p.Kind == KindModule { + vaultsOwn = vaultCalls(p) + pub = append(pub, vaultsOwn...) + } + } else { + // **But the tools that answer a software-issued credential** (novox/hq ADR 0305 §2): those answer the + // controller alone, and know it only from the subject's last token, so it asks them in its own name — + // those tools, on any machine, and nothing else under `call`. + for _, key := range p.AsksIssued { + module, tool, ok := strings.Cut(key, ".") + if !ok || module == "" || tool == "" || strings.ContainsAny(key, "*> ") { + continue + } + pub = append(pub, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+CallerToken(p.Username())) + } + // **And the vault's intake verbs** (issue 675): the vault takes a value from the controller alone, known + // from the subject's last token — the verbs its holder serves, flat, and nothing else of its seat. + for _, v := range VerbsTheControllerAsksTheVault { + if slices.Contains(p.AsksTheVault, v.Verb) { + pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username())) + } + } + // **And mesh-config's verb** (issue 925): it takes a trusted value from the controller alone, known from the + // subject's last token. + for _, v := range VerbsTheControllerAsksTheConfig { + if slices.Contains(p.AsksTheConfig, v.Verb) { + pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username())) + } + } + // And its consumer of mesh-config's resolved values, acknowledged once a change is composed (novox/hq ADR 0545): + // where mesh-config serves, as its verb, so genesis grants nothing of mesh-config. + if len(p.AsksTheConfig) > 0 { + pub = append(pub, "$JS.ACK.KV_"+ConfigResolvedBucket+"."+ConfigFollowConsumer+".>") + } + } + + 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 controller alone may publish is denied to everybody else whose grant reaches it. + var deny []string + if p.Kind != KindController { + for _, only := range ControllerOnly() { + for _, a := range pub { + if slices.Contains(vaultsOwn, a) { + continue + } + if SubjectsOverlap(a, only) { + deny = append(deny, only) + break + } + } + } + } + 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 — and so does the controller. 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 — the controller reads the engine's words and records nothing. 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 == KindController || 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 VerbsTheControllerAsksForASecret { + 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. +// servedOnlyByTheController says a seat's verbs are answered by the serving controller alone, on its own +// connection (the KindController grant), never by a module claiming the seat or a runtime carrying it: the +// controller's own seat. Its verbs decide what the mesh is — and `root-free` decides whether the router believes +// a verified sender (novox/hq ADR 0259 §8) — so a machine's runtime, whose credential an agent on that machine +// may hold, answering one would be an agent answering it (the confirmation review of 2026-10-09). +func servedOnlyByTheController(s Seat) bool { return s.Name == ControllerSeat } + +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" + case KindController: + return "CONTROL" + } + return "" +} + +func consumerDurable(p Principal) string { + switch p.Kind { + case KindModule: + return p.Node + "_" + p.Module + case KindNode: + return p.Node + case KindController: + return "controller" + } + 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 controller. 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 controller. 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()) + } + fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", p.Username(), p.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") + } + b.WriteString(" ]\n }\n") + b.WriteString(systemAccount) + b.WriteString("}\n") + return b.String(), nil +} + +// 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" + +// 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, lease/witness.go): its own node tools' PING, and the lease's key where it +// runs the controller. Reads only: a write to the bucket is `$KV..>`, the controller's alone +// (the writers table). The answers come to the machine's own inbox. +func WitnessSubjects(p Principal) []string { + out := []string{pingSubject(p.Node)} + if p.WitnessesController { + out = append(out, leaseReadSubject(LeaseBucket)) + } + return append(out, 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 and leaseReadSubject are the controller's lease witness's subjects (mesh-controller internal/lease), copied +// with the composer so the grants it composes name them without the lease's code. +func pingSubject(node string) string { return "$SRV.PING." + nodeToolsService + "." + node } + +func leaseReadSubject(bucket string) string { + return "$JS.API.DIRECT.GET.KV_" + bucket + ".$KV." + bucket + "." + leaseKey +} + +const ( + nodeToolsService = "node-tools" + leaseKey = "holder" +) diff --git a/go/buscompose/onnats.go b/go/buscompose/onnats.go new file mode 100644 index 0000000..ee39eda --- /dev/null +++ b/go/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/go/buscompose/process_grants.go b/go/buscompose/process_grants.go new file mode 100644 index 0000000..44d913b --- /dev/null +++ b/go/buscompose/process_grants.go @@ -0,0 +1,52 @@ +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. + pub = append(pub, stateGrants(stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State})...) + + // 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/go/buscompose/provisioner.go b/go/buscompose/provisioner.go new file mode 100644 index 0000000..5def5fd --- /dev/null +++ b/go/buscompose/provisioner.go @@ -0,0 +1,217 @@ +package buscompose + +import ( + "errors" + "fmt" + "git.novox.be/novox/mesh-sdk/go/delivery" + "strings" + + "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. **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 controller's assignments mirror, +// where a provider's consumers are bound to it in their own machines' plans, which the provider machine's client +// reads to fill ${consumers:} with each one's credential (novox/hq ADR 0373, issue 679). It holds placement +// and plans, never a secret's value; the client writes none of it. +var ProvisionerReadsWhole = []string{AssignmentsBucket} + +// 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. +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/go/buscompose/raise.go b/go/buscompose/raise.go new file mode 100644 index 0000000..f802655 --- /dev/null +++ b/go/buscompose/raise.go @@ -0,0 +1,73 @@ +package buscompose + +import "fmt" + +// Bringing the bus's own objects into being, in the one order that works. +// +// **Asserted on every start rather than created once at genesis.** A stream somebody deleted, a mesh +// raised from a restored backup, or a bus whose data directory was replaced all have records and no +// objects — and a node whose consumer is missing hears nothing while everything else about it looks +// correct. Idempotence is the whole requirement, and the parts are already idempotent; this is the +// order they have to be asked in. + +// Raiser is everything asserting the bus's objects needs of a connection to it. +type Raiser interface { + Asserter + Ensurer +} + +// Raise asserts the mesh's streams, the controller's own consumers, and one consumer per node. +// +// **The order is not a preference.** A consumer on a stream that does not exist is refused, and the +// refusal names the stream rather than the order — so somebody reading it goes looking for a deleted +// stream instead of a reversed pair of lines. Nodes last, because the one a node reads lives on a +// stream the mesh's own set defines. +func Raise(r Raiser, nodes []string) error { + if err := AssertMeshStreams(r); err != nil { + return err + } + if err := AssertMeshConsumers(r); err != nil { + return err + } + if err := AssertNodeConsumers(r, nodes); err != nil { + return err + } + return nil +} + +// RaiseSeats asserts one work queue per declared seat, and the worker of whoever holds it. +// +// Separate from Raise because it is answered by a different question: the mesh's own objects exist +// because the mesh does, and a seat's exist because a module declaring one was registered. Kept +// beside it so the order is visible — a holder's worker needs the seat's stream, and a seat's stream +// needs nothing. +func RaiseSeats(r Raiser, seats []DeclaredSeat, holders map[string]Holder) error { + for _, s := range SeatStreams(seats) { + if err := r.EnsureStream(s); err != nil { + return fmt.Errorf("asserting the work queue for %s: %w", s.Name, err) + } + } + for _, s := range seats { + h, held := holders[s.Name] + if !held { + // **The stream exists and the consumer does not, on purpose.** Work queues until a + // holder appears, so installing the module a week after something started sending to it + // flushes the backlog instead of having lost it. + continue + } + c, needed := HolderConsumerFor(h.Node, h.Module, s) + if !needed { + continue + } + if err := r.EnsureConsumer(c); err != nil { + return fmt.Errorf("asserting how %s on %s works %s: %w", h.Module, h.Node, s.Name, err) + } + } + return nil +} + +// Holder is which module on which machine holds a seat. +type Holder struct { + Node string + Module string +} diff --git a/go/buscompose/readiness.go b/go/buscompose/readiness.go new file mode 100644 index 0000000..5fb24ec --- /dev/null +++ b/go/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/go/buscompose/sdk_membership.go b/go/buscompose/sdk_membership.go new file mode 100644 index 0000000..d9ad34c --- /dev/null +++ b/go/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/go/buscompose/seattraffic.go b/go/buscompose/seattraffic.go new file mode 100644 index 0000000..1b7095c --- /dev/null +++ b/go/buscompose/seattraffic.go @@ -0,0 +1,290 @@ +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 one read: **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. + +// 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() { + 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+".>") + } + } + 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 +} + +// 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/go/buscompose/state.go b/go/buscompose/state.go new file mode 100644 index 0000000..9d8bdac --- /dev/null +++ b/go/buscompose/state.go @@ -0,0 +1,265 @@ +package buscompose + +import ( + "fmt" + "git.novox.be/novox/mesh-sdk/go/announce" + "sort" + "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 { + read(bucket, "") + } + } + for _, k := range a.KeyedReads { + if bucket, ok := bucketOfRead(k.Read); ok && safeSubject.MatchString(k.Key) { + read(bucket, k.Key) + } + } + 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 +} + +// A BucketAsserter is the part of a JetStream connection bucket assertion needs. +type BucketAsserter interface { + // EnsureBucket creates the bucket if absent and brings its options to match if present, never + // discarding what it holds. + EnsureBucket(b Bucket) error + // BucketNames is every key-value bucket on the server. + BucketNames() ([]string, error) +} + +// RaiseBuckets asserts every declared bucket and answers the buckets on the server that nothing +// declares any more. +// +// **Those are reported, never removed** (novox/hq ADR 0201, ADR 0030): what a module stored is +// data, and a manifest edited, a module renamed or a catalogue entry dropped is an ordinary day's +// work that must not take data with it. Removing one is a person's act. +func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err error) { + sorted := append([]Bucket(nil), buckets...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Bucket() < sorted[j].Bucket() }) + declared := map[string]bool{} + for _, b := range sorted { + if err := a.EnsureBucket(b); err != nil { + return nil, fmt.Errorf("asserting %s's state %q: %w", b.Module, b.Name, err) + } + declared[b.Bucket()] = true + } + names, err := a.BucketNames() + if err != nil { + return nil, fmt.Errorf("listing the bus's state: %w", err) + } + for _, n := range names { + // A seat's cancelled set is the mesh's own (novox/hq ADR 0219), not a module's state; so are + // the controller's own buckets (novox/hq to-be 45 §1). + if !declared[n] && !IsCancelledSet(n) && !IsControllerBucket(n) { + undeclared = append(undeclared, n) + } + } + sort.Strings(undeclared) + return undeclared, nil +} diff --git a/go/buscompose/streams.go b/go/buscompose/streams.go new file mode 100644 index 0000000..fd2dd42 --- /dev/null +++ b/go/buscompose/streams.go @@ -0,0 +1,612 @@ +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. +// +// **CONTROL names its subjects rather than taking `mesh.control.>`**, because heartbeats live +// under that prefix and must not be persisted: a lost heartbeat is the next heartbeat, and a +// stream of them is a stream of the least valuable messages the mesh sends, competing for the +// same retention as the ones that matter. +// +// **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{ + { + Name: "CONTROL", + // A build's outcome is no longer here: it is the build-machine seat's own event, so one + // publish reaches whoever asked, the controller and the catalogue (novox/hq ADR 0121). + Subjects: []string{"mesh.control.*.report", "mesh.control.enrol"}, + Retention: RetentionWorkQueue, + Why: "the store-window guarantee (ADR 0083): the controller naks with a delay while its " + + "store is away and the message is redelivered; nothing is dropped", + }, + { + 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)", + }, + { + Name: AssignmentsStream, + Subjects: []string{"mesh.assignment.*.*"}, + Retention: RetentionLastPerSubject, + Direct: true, + Why: "one membership per assignment, always the newest: what the mesh issued this module " + + "on this machine to serve and to reach (ADR 0160); read directly by the runtime it is for", + }, + { + 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.>" + +// eventsTheControllerFollows are the events the controller's events consumer takes: all it follows but the forge's +// merges, which the merges consumer takes (MergesConsumerName). +func eventsTheControllerFollows() []string { + var out []string + for _, subject := range ControllerFollows { + if subject != ControllerFollows[3] { + out = append(out, subject) + } + } + return append(out, ControllerHears...) +} + +// 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 +} + +// An Asserter is the part of a JetStream connection stream assertion needs. Narrow on purpose: it +// keeps this testable without a server, and keeps the client library out of everything that only +// wants to know what the streams are. +type Asserter interface { + // EnsureStream creates the stream if absent and updates it to match if present. It must be + // idempotent: the controller asserts on every start, not only at genesis. + EnsureStream(s Stream) error +} + +// AssertMeshStreams brings the foundation set into being, in order, and says which one failed +// rather than that something did. +// +// Asserted on every start rather than created once at genesis, because a stream that was deleted, +// or a mesh raised from a restored backup, must converge rather than run without the guarantee +// its messages assume. Idempotence is the whole requirement. +func AssertMeshStreams(a Asserter) error { + for _, s := range MeshStreams() { + if err := a.EnsureStream(s); err != nil { + return fmt.Errorf("asserting stream %s: %w", s.Name, err) + } + } + return nil +} + +// 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 mesh's own consumers. +// +// A seat's streams and a module's consumers are derived from declarations (derived.go). These two +// are not: **the controller is not a module and files no manifest**, so its authority and its +// subscriptions cannot come from a declaration that does not exist. They are named here, where the +// mesh's own streams are named, and narrowly — a controller subscribing `mesh.mod.*.event.>` would +// hear every event in the mesh, which it has no business doing and which would make its permission +// list stop explaining anything. + +// ControllerName is the controller's durable consumer on each stream it reads, and the name its +// ack subject is derived from (nats.go: `$JS.ACK..controller.>`). +const ControllerName = "controller" + +// MergesConsumerName is the controller's durable consumer of the forge's merged event on EVENTS, apart from the +// events consumer (novox/hq ADR 0407 decision 3, issue 973). A merge is never dropped, so this consumer has no +// max-deliver, which would send a merge held five times to DEAD_LETTERS to wait for a person, and no healer +// resets it, which would drop the merges it holds. A consumer of its own because both of those are properties of +// a consumer, not of a subject. +const MergesConsumerName = "controller-merges" + +// ControllerSeat is the role the control plane holds, and ControllerStates are the facts it states +// under it (novox/hq ADR 0134). +// +// **Written here as well as in `link`, and a test keeps them agreeing.** `link` imports `broker`, so +// `broker` cannot import `link`; a grant naming a subject the controller never publishes is authority +// nobody uses, and a controller publishing one the grant omits is refused at the moment it has +// something to say. +const ControllerSeat = "mesh-controller" + +var ControllerStates = []string{"applied", "refused", "built-before", + // What is wrong, said as it changes (novox/hq to-be 45 §2): a condition raised, changed in + // severity, resolver or silence, and cleared. The operator-channel's holder and any other surface + // consume them; the controller tells nobody itself. + "condition-raised", "condition-changed", "condition-cleared", + // And the self-check's heartbeat, at the end of every run (to-be 45 §4, S10): watched from a + // machine that is not the control node, so the controller going quiet is itself said. + "doctor-heartbeat", + // And a value given by hand, replaced after its module's first good start (novox/hq ADR 0228). + "secret-replaced", + // And every act a healer takes on a condition (novox/hq to-be 45 §7, Phase 3): a repair the mesh + // made by itself is said like one a person made, never quietly. + "healer-acted", + // And a build put back after its gate failed on its first machine (novox/hq ADR 0236, to-be 45 §8). + "rolled-back", + // And a pull request's merge check, judged (novox/hq to-be 45 §9): what the forge's holder sets as + // the pull request's status, and anybody else may read. + "checked", + // (A walk kept in a new state and a tier sent to a machine are gone with the plans, novox/hq ADR 0539.) + // And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3): the assignments and + // each registered module's declared config (design 57 F3). + "assignment-changed", "declared-config-changed", "predefined-changed", + // And a builder's key not taken in because the artifact store does not hold what it names (novox/hq issue 1937). + "artifacts-gone", + // And a bucket of the plans' state deleted at the controller's first start, and the declarations' stream once no node + // reads one (novox/hq ADR 0539 clauses 11 and 13). + "bucket-deleted"} + +// BusAdvisories are what the bus server says about the mesh's own account that the controller +// reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it +// may and gave up on it, and one that was deleted. Read-only: an advisory is the server's to +// publish, and the controller's subscription changes nothing on the bus. +var BusAdvisories = []string{ + "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", + "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", +} + +// ControllerFollows are the events the controller reacts to: the catalogue saying a module's +// current version moved, and a catalogue that has just started saying it may have missed builds. +// +// **Derived the same way a module's subscription is**, from the emitter and the bare local event +// name, rather than written out. They were written out while the catalogue still spelled its events +// as the old bus's routing keys, and the moment those were converted (novox/hq 04-ISSUES/127) a +// hard-coded pair became a controller listening to a subject nothing publishes — the same fault, from +// the other side. Deriving them means the conversion could not leave these behind. +var ControllerFollows = []string{ + moduleEventSubject("mesh-catalog", "upgraded"), + moduleEventSubject("mesh-catalog", "catching-up"), + // A build's outcome, which is the build-machine role's own event now (ADR 0121) rather than a + // message on the control branch. Same three audiences, one publish: whoever asked, this, and the + // catalogue. + seatEventSubject("node-build-agent", "built"), + // The forge's merges: what moved a source, so the mesh builds what that source produces + // without anybody telling it (novox/hq 04-ISSUES/131). Appended, because the index is a name. + moduleEventSubject("gitea", "pull.merged"), + // The retired build role's outcome too, while the handover runs (novox/hq ADR 0190): the one + // build machine keeps answering on its seat until build-agent replaces it, and the outcome that + // registers build-agent itself comes from there. Appended, for the same reason as above; goes + // with the retired seat row. + seatEventSubject("mesh-build-machine", "built"), + // **Every provider's standing** (novox/hq ADR 0224): a consumer it has failed for minutes, and + // that consumer recovered. The one pattern on this list, and a narrow one — two named events, + // from whichever module provides — because the rule is about every provider, and a list of + // providers here would be a list somebody forgets to extend. On 2026-10-05 the identity provider + // failed every consumer for a day and only its journal said so (issue 179). Appended, because + // the index is a name. + moduleEventSubject("*", ProvisionerFailing), + moduleEventSubject("*", ProvisionerRecovered), + // **What becomes of a consumer the mesh stopped asking for** (novox/hq ADR 0230): retired, waiting + // for a person, re-enabled, deleted — a provider's third word, from whichever module provides. + // Appended, because the index is a name. + moduleEventSubject("*", ProvisionerRetirement), + // **A pull request's head, announced** (novox/hq to-be 45 §9): what the controller asks the build + // seat to check before it merges — every machine of the facts snapshot composed with the change. + // Appended, because the index is a name. + moduleEventSubject("gitea", "pull.updated"), + // **The operator's answers to what the controller asked** (novox/hq ADR 0259): the router's warrant, or + // the end of an ask without one, said to the controller alone under its own name. On the stream, so a + // controller that was away hears what was decided meanwhile. Appended, because the index is a name. + DecidedSubject, +} + +// ControllerHears are events the controller's events consumer takes beside the ones it follows, and that need no +// subscribe grant of their own. **A push consumer delivers onto its deliver subject** (`_DELIVER.controller.>`, already +// granted), so the subject a consumer filters on is never one the controller subscribes: listing this one in +// ControllerFollows would add a subscribe grant the installer's carried user list, which lives in mesh-host, would +// have to carry too (TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose), for nothing the consumer uses. +// +// A merge mesh-delivery made, announced (novox/hq ADR 0420 §7, issue 1025): with the ask whose Approve it merged on and +// the heads that Approve covered, kept a week to read the warrant a moved module's registration needs. +var ControllerHears = []string{DeliveryMergedSubject} + +// DeliveryMergedEvent is the event a module says when it merged a pull request. +const DeliveryMergedEvent = "merged" + +// DeliveryMergedSubject is where the delivery's owner says it merged a pull request: one wildcard subject, the +// module that claims the delivery seat being whichever the catalogue says (novox/hq ADR 0437 §3). Who said it is +// read from the subject, and whether it claims the seat is the controller's to check. +var DeliveryMergedSubject = moduleEventSubject("*", DeliveryMergedEvent) + +// DeliveryMergedSubjectOf is where one module says it merged a pull request. +func DeliveryMergedSubjectOf(module string) string { + return moduleEventSubject(module, DeliveryMergedEvent) +} + +// 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" + +// DecidedSubject is where the router says the controller's warrants: the seat's event named by the +// controller as its caller. +var DecidedSubject = seatEventSubject(AsksSeat, "decided."+ControllerSeat) + +// 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 is what the controller consumes, in the order a person reads it. +// +// **Unlimited redelivery on CONTROL, deliberately.** The store window's bound is the controller's, +// not the server's (window.go): a message is held with a nak-and-delay until the controller either +// takes it or gives up and says so. A max-deliver here would give up on a push that was being +// held through a store restart — the exact message the stream exists to protect — some minutes +// before the controller had finished deciding about it. +func MeshConsumers() []Consumer { + return ControllerConsumers() +} + +// ControllerConsumers are the consumers the controller's own event loop takes from: MeshConsumers without the build +// seat's check workers, which the builders take from and the loop never does (issue 1063). A consumer added to the +// controller is counted here without a change elsewhere. +func ControllerConsumers() []Consumer { + return []Consumer{ + { + Name: ControllerName, + Stream: "CONTROL", + Push: true, + AckWaitSeconds: 30, + Why: "the controller is the single consumer of what nodes say; explicit ack and no " + + "max-deliver, because the store window's bound is the controller's own", + }, + // The forge's merges (novox/hq ADR 0407 decision 3, issue 973): never dropped, so no max-deliver and no + // reset; a merge the controller cannot record yet is held with a growing delay until it can. **Asserted before + // the events consumer**, which stops filtering merges: the order of this list is the order of assertion. + { + Name: MergesConsumerName, + Stream: "EVENTS", + // Within what the controller may subscribe already (`_DELIVER.controller.>`). + DeliverSubject: "_DELIVER." + ControllerName + ".merges", + Filters: []string{ControllerFollows[3]}, + Push: true, + AckWaitSeconds: 30, + MaxAckPending: 1, + FromNow: true, + TakesOverFrom: ControllerName, + Why: "the forge's merges, one at a time; no max-deliver and never reset, because a merge is never " + + "dropped and no person releases one", + }, + { + Name: ControllerName, + Stream: "EVENTS", + Filters: append(eventsTheControllerFollows(), AgainFilter(ControllerName)), + Push: true, + AckWaitSeconds: 30, + MaxDeliver: 5, + // One at a time (novox/hq issue 175): acting on a merge builds for minutes, and an + // announcement handed over behind it must wait on the server, not time out on the + // client and come back to be acted on again. + MaxAckPending: 1, + FromNow: true, + // **The one consumer the mesh resets by itself** (healer H4, issue 248): it fell a week + // behind once and held every merge after it. What a reset drops is caught up elsewhere — a + // build's outcome from the build records a plan settles from (issue 214), a provider's failing + // word by the provider saying it again every quarter of an hour (ADR 0224). **Not the merges**: + // they are the merges consumer's below, which a reset leaves alone (ADR 0407 decision 3). + Resettable: "what it drops is caught up: build outcomes from the build records (issue 214), a " + + "provider's failing word said again (ADR 0224); the forge's merges are not its, they have a " + + "consumer of their own that no reset touches", + Why: "the events the mesh's own controller reacts to, one at a time; after " + + "max-deliver it gives the event up, and the controller keeps it in DEAD_LETTERS until " + + "a person delivers it again or drops it", + }, + // What the server said a consumer gave up on (novox/hq issue 330): copied into DEAD_LETTERS and + // acknowledged. No max-deliver: a notice the controller could not copy is offered again, and said. + { + Name: ControllerName, + Stream: DeadLetterNoticesStream, + Push: true, + AckWaitSeconds: 30, + Why: "the controller copies each message a consumer gave up on into DEAD_LETTERS; no max-deliver, " + + "because a notice it gave up on would lose the message it is about", + }, + } +} + +// NoticesConsumer is the controller's consumer on DEAD_LETTER_NOTICES (novox/hq issue 330). +func NoticesConsumer() Consumer { + for _, c := range MeshConsumers() { + if c.Stream == DeadLetterNoticesStream { + return c + } + } + panic("the mesh's consumers carry none on " + DeadLetterNoticesStream) +} + +// AssertServingConsumers are the controller's own consumers its serving cannot go without: all but the +// one on DEAD_LETTER_NOTICES, which the keeper of dead letters asserts and retries by itself, so a fault +// there never stops the controller serving (novox/hq issue 330). +func AssertServingConsumers(e Ensurer) error { + for _, c := range MeshConsumers() { + if c.Stream == DeadLetterNoticesStream { + continue + } + if err := e.EnsureConsumer(c); err != nil { + return fmt.Errorf("asserting consumer %s on %s: %w", c.Name, c.Stream, err) + } + } + return nil +} + +// Ensurer is the part of a JetStream connection consumer assertion needs, narrow for the reason +// Asserter is. +type Ensurer interface { + EnsureConsumer(c Consumer) error +} + +// AssertMeshConsumers brings the controller's own consumers into being, and says which one failed. +// +// After the streams, necessarily: a consumer on a stream that does not exist is refused, and the +// refusal names the stream rather than the order. +func AssertMeshConsumers(e Ensurer) error { + for _, c := range MeshConsumers() { + if err := e.EnsureConsumer(c); err != nil { + return fmt.Errorf("asserting consumer %s on %s: %w", c.Name, c.Stream, err) + } + } + return nil +} diff --git a/go/buscompose/testdata/composed.conf b/go/buscompose/testdata/composed.conf new file mode 100644 index 0000000..7560fd8 --- /dev/null +++ b/go/buscompose/testdata/composed.conf @@ -0,0 +1,78 @@ +# Composed by the mesh controller. 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 controller. 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: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { + publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.ACK.KV_mesh-delivery-builder_builds.mesh-controller-take-in.>", "$JS.ACK.KV_mesh-delivery-indexer_modules.mesh-controller-take-in.>", "$JS.API.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_composed.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_grants.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.artifacts-gone", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.bucket-deleted", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } + subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] } + allow_responses: { max: 1, ttl: "1m" } + } } + { user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: { + publish: { allow: ["mesh.control.enrol"] } + 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-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.>", "$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-delivery-assigner_wants.>", "$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-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.>", "$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-delivery-assigner_wants", "$JS.API.STREAM.INFO.KV_node-engine_have", "$JS.FC.KV_composed-one.>", "$JS.FC.KV_mesh-delivery-assigner_wants.>", "$JS.FC.KV_node-engine_have.>", "$JS.FC.OBJ_declaration-files-one.>", "$KV.node-engine_have.one.>", "$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"] } + subscribe: { allow: ["_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"] } + allow_responses: { max: 1, ttl: "1m" } + } } + { user: "one.mesh-delivery-watchman", password: "$2a$11$wwwwwwwwwwwwwwwwwwwwww", permissions: { + publish: { allow: ["$JS.ACK.EVENTS.one_mesh-delivery-watchman.>", "$JS.API.CONSUMER.INFO.EVENTS.one_mesh-delivery-watchman", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-delivery-watchman", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.mesh-delivery-watchman", "mesh.bus.connz"] } + subscribe: { allow: ["$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.assignment.one.mesh-delivery-watchman", "mesh.bus.connect", "mesh.bus.disconnect", "mesh.mod.mesh-delivery-watchman.call.>", "mesh.mod.mesh-delivery-watchman.tool.>"] } + 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.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.ASSIGNMENTS.mesh.assignment.one.telegram", "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.assignment.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.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.audit"] } + 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.assignment.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.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop", "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.assignment.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/go/buscompose/testdata/controller-user-lists.json b/go/buscompose/testdata/controller-user-lists.json new file mode 100644 index 0000000..14aa755 --- /dev/null +++ b/go/buscompose/testdata/controller-user-lists.json @@ -0,0 +1,28 @@ +[ + { + "Name": "empty", + "List": "# The mesh's users, composed by the controller. 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: \"controller\", password: \"$2a$11$controllercontrollercofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.\u003e\", \"$JS.ACK.DEAD_LETTER_NOTICES.controller.\u003e\", \"$JS.ACK.EVENTS.controller-merges.\u003e\", \"$JS.ACK.EVENTS.controller.\u003e\", \"$JS.ACK.KV_mesh-delivery-builder_builds.mesh-controller-take-in.\u003e\", \"$JS.ACK.KV_mesh-delivery-indexer_modules.mesh-controller-take-in.\u003e\", \"$JS.API.\u003e\", \"$KV.mesh-controller_asked.\u003e\", \"$KV.mesh-controller_assignments.\u003e\", \"$KV.mesh-controller_builds.\u003e\", \"$KV.mesh-controller_calls.\u003e\", \"$KV.mesh-controller_composed.\u003e\", \"$KV.mesh-controller_condition-history.\u003e\", \"$KV.mesh-controller_conditions.\u003e\", \"$KV.mesh-controller_declared-config.\u003e\", \"$KV.mesh-controller_declared-packages.\u003e\", \"$KV.mesh-controller_delivery-merges.\u003e\", \"$KV.mesh-controller_grants.\u003e\", \"$KV.mesh-controller_hand-acts.\u003e\", \"$KV.mesh-controller_lease.\u003e\", \"$KV.mesh-controller_predefined.\u003e\", \"$KV.mesh-controller_rollouts.\u003e\", \"$KV.mesh-controller_states-announced.\u003e\", \"$SRV.INFO\", \"_INBOX.enrol.\u003e\", \"mesh.again.\u003e\", \"mesh.assignment.\u003e\", \"mesh.events.dead.\u003e\", \"mesh.mod.*.tool.\u003e\", \"mesh.mod.mesh-controller.event.states\", \"mesh.node.\u003e\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.artifacts-gone\", \"mesh.seat.mesh-controller.event.assignment-changed\", \"mesh.seat.mesh-controller.event.bucket-deleted\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.checked\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.declared-config-changed\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"mesh.seat.mesh-controller.event.healer-acted\", \"mesh.seat.mesh-controller.event.predefined-changed\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.rolled-back\", \"mesh.seat.mesh-controller.event.secret-replaced\", \"mesh.seat.mesh-controller.tool.plans\", \"mesh.seat.mesh-controller.tool.push\", \"mesh.seat.mesh-controller.tool.upgrade\", \"mesh.seat.mesh-delivery.tool.close\", \"mesh.seat.mesh-delivery.tool.groups\", \"mesh.seat.mesh-delivery.tool.release\", \"mesh.seat.mesh-delivery.tool.stalled\", \"mesh.seat.mesh-delivery.tool.stop\", \"mesh.seat.mesh-delivery.tool.times\", \"mesh.seat.node-backup.tool.backed-up.*\", \"mesh.seat.node-backup.tool.now.*\", \"mesh.seat.node-intrusion-prevention.tool.banned.*\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.seat.node-service-manager.tool.restart.*\", \"mesh.seat.operator-channel.tool.tell\"] }\n subscribe: { allow: [\"$JS.API.\u003e\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.\u003e\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.\u003e\", \"_DELIVER.controller\", \"_DELIVER.controller.\u003e\", \"_INBOX.controller.\u003e\", \"mesh.control.\u003e\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.*.event.provisioner.retirement\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.gitea.event.pull.updated\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.mesh-controller.tool.\u003e\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.operator-channel.event.decided.mesh-controller\"] }\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": "" + }, + { + "Name": "the controller's someRecords", + "List": "# The mesh's users, composed by the controller. 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: \"controller\", password: \"$2a$11$controllercontrollercofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.\u003e\", \"$JS.ACK.DEAD_LETTER_NOTICES.controller.\u003e\", \"$JS.ACK.EVENTS.controller-merges.\u003e\", \"$JS.ACK.EVENTS.controller.\u003e\", \"$JS.ACK.KV_mesh-delivery-builder_builds.mesh-controller-take-in.\u003e\", \"$JS.ACK.KV_mesh-delivery-indexer_modules.mesh-controller-take-in.\u003e\", \"$JS.API.\u003e\", \"$KV.composed-one.\u003e\", \"$KV.composed-two.\u003e\", \"$KV.mesh-controller_asked.\u003e\", \"$KV.mesh-controller_assignments.\u003e\", \"$KV.mesh-controller_builds.\u003e\", \"$KV.mesh-controller_calls.\u003e\", \"$KV.mesh-controller_composed.\u003e\", \"$KV.mesh-controller_condition-history.\u003e\", \"$KV.mesh-controller_conditions.\u003e\", \"$KV.mesh-controller_declared-config.\u003e\", \"$KV.mesh-controller_declared-packages.\u003e\", \"$KV.mesh-controller_delivery-merges.\u003e\", \"$KV.mesh-controller_grants.\u003e\", \"$KV.mesh-controller_hand-acts.\u003e\", \"$KV.mesh-controller_lease.\u003e\", \"$KV.mesh-controller_predefined.\u003e\", \"$KV.mesh-controller_rollouts.\u003e\", \"$KV.mesh-controller_states-announced.\u003e\", \"$O.declaration-files-one.\u003e\", \"$O.declaration-files-two.\u003e\", \"$SRV.INFO\", \"_INBOX.enrol.\u003e\", \"mesh.again.\u003e\", \"mesh.assignment.\u003e\", \"mesh.events.dead.\u003e\", \"mesh.mod.*.tool.\u003e\", \"mesh.mod.mesh-controller.event.states\", \"mesh.node.\u003e\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.artifacts-gone\", \"mesh.seat.mesh-controller.event.assignment-changed\", \"mesh.seat.mesh-controller.event.bucket-deleted\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.checked\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.declared-config-changed\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"mesh.seat.mesh-controller.event.healer-acted\", \"mesh.seat.mesh-controller.event.predefined-changed\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.rolled-back\", \"mesh.seat.mesh-controller.event.secret-replaced\", \"mesh.seat.mesh-controller.tool.plans\", \"mesh.seat.mesh-controller.tool.push\", \"mesh.seat.mesh-controller.tool.upgrade\", \"mesh.seat.mesh-delivery.tool.close\", \"mesh.seat.mesh-delivery.tool.groups\", \"mesh.seat.mesh-delivery.tool.release\", \"mesh.seat.mesh-delivery.tool.stalled\", \"mesh.seat.mesh-delivery.tool.stop\", \"mesh.seat.mesh-delivery.tool.times\", \"mesh.seat.node-backup.tool.backed-up.*\", \"mesh.seat.node-backup.tool.now.*\", \"mesh.seat.node-intrusion-prevention.tool.banned.*\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.seat.node-service-manager.tool.restart.*\", \"mesh.seat.operator-channel.tool.tell\"] }\n subscribe: { allow: [\"$JS.API.\u003e\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.\u003e\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.\u003e\", \"_DELIVER.controller\", \"_DELIVER.controller.\u003e\", \"_INBOX.controller.\u003e\", \"mesh.control.\u003e\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.*.event.provisioner.retirement\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.gitea.event.pull.updated\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.mesh-controller.tool.\u003e\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.operator-channel.event.decided.mesh-controller\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"enrol.three\", password: \"$2a$11$enrol.threeenrol.threefixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"mesh.control.enrol\"] }\n subscribe: { allow: [\"_INBOX.enrol.three.\u003e\"] }\n } }\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-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.\u003e\", \"$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-delivery-assigner_wants.\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-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.\u003e\", \"$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-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-one.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-one.\u003e\", \"$KV.node-engine_have.one.\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\"] }\n subscribe: { allow: [\"_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\"] }\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-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.two.\u003e\", \"$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-delivery-assigner_wants.\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-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.two.\u003e\", \"$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-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-two.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-two.\u003e\", \"$KV.node-engine_have.two.\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\"] }\n subscribe: { allow: [\"_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\"] }\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.INFO.EVENTS.one_telegram\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram\"] }\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.assignment.one.telegram\", \"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.INFO.EVENTS.two_shop\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop\", \"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.assignment.two.shop\", \"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": "" + }, + { + "Name": "seats, consumers, people and a hashless user", + "List": "# The mesh's users, composed by the controller. 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: \"controller\", password: \"$2a$11$controllercontrollercofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.\u003e\", \"$JS.ACK.DEAD_LETTER_NOTICES.controller.\u003e\", \"$JS.ACK.EVENTS.controller-merges.\u003e\", \"$JS.ACK.EVENTS.controller.\u003e\", \"$JS.ACK.KV_mesh-delivery-builder_builds.mesh-controller-take-in.\u003e\", \"$JS.ACK.KV_mesh-delivery-indexer_modules.mesh-controller-take-in.\u003e\", \"$JS.API.\u003e\", \"$KV.composed-one.\u003e\", \"$KV.composed-two.\u003e\", \"$KV.mesh-controller_asked.\u003e\", \"$KV.mesh-controller_assignments.\u003e\", \"$KV.mesh-controller_builds.\u003e\", \"$KV.mesh-controller_calls.\u003e\", \"$KV.mesh-controller_composed.\u003e\", \"$KV.mesh-controller_condition-history.\u003e\", \"$KV.mesh-controller_conditions.\u003e\", \"$KV.mesh-controller_declared-config.\u003e\", \"$KV.mesh-controller_declared-packages.\u003e\", \"$KV.mesh-controller_delivery-merges.\u003e\", \"$KV.mesh-controller_grants.\u003e\", \"$KV.mesh-controller_hand-acts.\u003e\", \"$KV.mesh-controller_lease.\u003e\", \"$KV.mesh-controller_predefined.\u003e\", \"$KV.mesh-controller_rollouts.\u003e\", \"$KV.mesh-controller_states-announced.\u003e\", \"$O.declaration-files-one.\u003e\", \"$O.declaration-files-two.\u003e\", \"$SRV.INFO\", \"_INBOX.enrol.\u003e\", \"mesh.again.\u003e\", \"mesh.assignment.\u003e\", \"mesh.events.dead.\u003e\", \"mesh.mod.*.tool.\u003e\", \"mesh.mod.mesh-controller.event.states\", \"mesh.node.\u003e\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.artifacts-gone\", \"mesh.seat.mesh-controller.event.assignment-changed\", \"mesh.seat.mesh-controller.event.bucket-deleted\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.checked\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.declared-config-changed\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"mesh.seat.mesh-controller.event.healer-acted\", \"mesh.seat.mesh-controller.event.predefined-changed\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.rolled-back\", \"mesh.seat.mesh-controller.event.secret-replaced\", \"mesh.seat.mesh-controller.tool.plans\", \"mesh.seat.mesh-controller.tool.push\", \"mesh.seat.mesh-controller.tool.upgrade\", \"mesh.seat.mesh-delivery.tool.close\", \"mesh.seat.mesh-delivery.tool.groups\", \"mesh.seat.mesh-delivery.tool.release\", \"mesh.seat.mesh-delivery.tool.stalled\", \"mesh.seat.mesh-delivery.tool.stop\", \"mesh.seat.mesh-delivery.tool.times\", \"mesh.seat.node-backup.tool.backed-up.*\", \"mesh.seat.node-backup.tool.now.*\", \"mesh.seat.node-intrusion-prevention.tool.banned.*\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.seat.node-service-manager.tool.restart.*\", \"mesh.seat.operator-channel.tool.tell\"] }\n subscribe: { allow: [\"$JS.API.\u003e\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.\u003e\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.\u003e\", \"_DELIVER.controller\", \"_DELIVER.controller.\u003e\", \"_INBOX.controller.\u003e\", \"mesh.control.\u003e\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.*.event.provisioner.retirement\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.gitea.event.pull.updated\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.mesh-controller.tool.\u003e\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.operator-channel.event.decided.mesh-controller\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\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-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.\u003e\", \"$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-delivery-assigner_wants.\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-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.\u003e\", \"$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-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-one.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-one.\u003e\", \"$KV.node-engine_have.one.\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\"] }\n subscribe: { allow: [\"_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\"] }\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-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.two.\u003e\", \"$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-delivery-assigner_wants.\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-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.two.\u003e\", \"$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-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-two.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-two.\u003e\", \"$KV.node-engine_have.two.\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\"] }\n subscribe: { allow: [\"_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\"] }\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.INFO.EVENTS.one_mesh-watchman\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-watchman\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.mesh-watchman\"] }\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.assignment.one.mesh-watchman\", \"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.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.ASSIGNMENTS.mesh.assignment.one.telegram\", \"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.assignment.one.telegram\", \"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: [\"$JS.API.CONSUMER.CREATE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-controller_composed.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-controller_composed.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.DIRECT.GET.KV_composed-one.\u003e\", \"$JS.API.DIRECT.GET.KV_composed-two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-controller_composed.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-one.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-two.\u003e\", \"$JS.API.STREAM.INFO.KV_composed-one\", \"$JS.API.STREAM.INFO.KV_composed-two\", \"$JS.API.STREAM.INFO.KV_mesh-controller_composed\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.OBJ_declaration-files-one\", \"$JS.API.STREAM.INFO.OBJ_declaration-files-two\", \"$JS.API.STREAM.MSG.GET.OBJ_declaration-files-one\", \"$JS.API.STREAM.MSG.GET.OBJ_declaration-files-two\", \"$JS.API.STREAM.PURGE.OBJ_declaration-files-one\", \"$JS.API.STREAM.PURGE.OBJ_declaration-files-two\", \"$JS.FC.KV_composed-one.\u003e\", \"$JS.FC.KV_composed-two.\u003e\", \"$JS.FC.KV_mesh-controller_composed.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$KV.composed-one.\u003e\", \"$KV.composed-two.\u003e\", \"$KV.mesh-controller_composed.\u003e\", \"$KV.mesh-delivery-assigner_wants.want.\u003e\", \"$O.declaration-files-one.\u003e\", \"$O.declaration-files-two.\u003e\", \"$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.INFO.EVENTS.two_shop\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop\", \"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.assignment.two.shop\", \"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": "" + }, + { + "Name": "state, logs, streams, queues and processes", + "List": "# The mesh's users, composed by the controller. 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.INFO.EVENTS.ace_plex\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.ace_plex\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.ace.plex\"] }\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.assignment.ace.plex\", \"mesh.mod.plex.call.\u003e\", \"mesh.mod.plex.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"controller\", password: \"$2a$11$controllercontrollercofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.\u003e\", \"$JS.ACK.DEAD_LETTER_NOTICES.controller.\u003e\", \"$JS.ACK.EVENTS.controller-merges.\u003e\", \"$JS.ACK.EVENTS.controller.\u003e\", \"$JS.ACK.KV_mesh-delivery-builder_builds.mesh-controller-take-in.\u003e\", \"$JS.ACK.KV_mesh-delivery-indexer_modules.mesh-controller-take-in.\u003e\", \"$JS.API.\u003e\", \"$KV.composed-ace.\u003e\", \"$KV.composed-novox.\u003e\", \"$KV.mesh-controller_asked.\u003e\", \"$KV.mesh-controller_assignments.\u003e\", \"$KV.mesh-controller_builds.\u003e\", \"$KV.mesh-controller_calls.\u003e\", \"$KV.mesh-controller_composed.\u003e\", \"$KV.mesh-controller_condition-history.\u003e\", \"$KV.mesh-controller_conditions.\u003e\", \"$KV.mesh-controller_declared-config.\u003e\", \"$KV.mesh-controller_declared-packages.\u003e\", \"$KV.mesh-controller_delivery-merges.\u003e\", \"$KV.mesh-controller_grants.\u003e\", \"$KV.mesh-controller_hand-acts.\u003e\", \"$KV.mesh-controller_lease.\u003e\", \"$KV.mesh-controller_predefined.\u003e\", \"$KV.mesh-controller_rollouts.\u003e\", \"$KV.mesh-controller_states-announced.\u003e\", \"$O.declaration-files-ace.\u003e\", \"$O.declaration-files-novox.\u003e\", \"$SRV.INFO\", \"_INBOX.enrol.\u003e\", \"mesh.again.\u003e\", \"mesh.assignment.\u003e\", \"mesh.events.dead.\u003e\", \"mesh.mod.*.tool.\u003e\", \"mesh.mod.mesh-controller.event.states\", \"mesh.node.\u003e\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.artifacts-gone\", \"mesh.seat.mesh-controller.event.assignment-changed\", \"mesh.seat.mesh-controller.event.bucket-deleted\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.checked\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.declared-config-changed\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"mesh.seat.mesh-controller.event.healer-acted\", \"mesh.seat.mesh-controller.event.predefined-changed\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.rolled-back\", \"mesh.seat.mesh-controller.event.secret-replaced\", \"mesh.seat.mesh-controller.tool.plans\", \"mesh.seat.mesh-controller.tool.push\", \"mesh.seat.mesh-controller.tool.upgrade\", \"mesh.seat.mesh-delivery.tool.close\", \"mesh.seat.mesh-delivery.tool.groups\", \"mesh.seat.mesh-delivery.tool.release\", \"mesh.seat.mesh-delivery.tool.stalled\", \"mesh.seat.mesh-delivery.tool.stop\", \"mesh.seat.mesh-delivery.tool.times\", \"mesh.seat.node-backup.tool.backed-up.*\", \"mesh.seat.node-backup.tool.now.*\", \"mesh.seat.node-intrusion-prevention.tool.banned.*\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.seat.node-service-manager.tool.restart.*\", \"mesh.seat.operator-channel.tool.tell\"] }\n subscribe: { allow: [\"$JS.API.\u003e\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.\u003e\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.\u003e\", \"_DELIVER.controller\", \"_DELIVER.controller.\u003e\", \"_INBOX.controller.\u003e\", \"mesh.control.\u003e\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.*.event.provisioner.retirement\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.gitea.event.pull.updated\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.mesh-controller.tool.\u003e\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.operator-channel.event.decided.mesh-controller\"] }\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-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.ace.\u003e\", \"$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-delivery-assigner_wants.\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-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.ace.\u003e\", \"$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-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-ace.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-ace.\u003e\", \"$KV.node-engine_have.ace.\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\"] }\n subscribe: { allow: [\"_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\"] }\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-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.novox.\u003e\", \"$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-delivery-assigner_wants.\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-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.novox.\u003e\", \"$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-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-novox.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-novox.\u003e\", \"$KV.node-engine_have.novox.\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\"] }\n subscribe: { allow: [\"_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\"] }\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.DELETE.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-delivery-builder\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-delivery-builder\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.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.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.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\", \"$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: [\"$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.assignment.novox.mesh-delivery-builder\", \"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: [\"_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.DELETE.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\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.ASSIGNMENTS.mesh.assignment.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.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$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\"] }\n subscribe: { allow: [\"$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.assignment.novox.mesh-delivery-view\", \"mesh.mod.mesh-delivery-view.call.\u003e\", \"mesh.mod.mesh-delivery-view.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": "" + } +] \ No newline at end of file diff --git a/go/buscompose/users.go b/go/buscompose/users.go new file mode 100644 index 0000000..c366072 --- /dev/null +++ b/go/buscompose/users.go @@ -0,0 +1,420 @@ +package buscompose + +import ( + "fmt" + "git.novox.be/novox/mesh-sdk/go/announce" + "slices" + "sort" +) + +// 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 + // 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 + // 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 + // 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). + 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 + // 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. +// +// The controller is always first and always present: a mesh whose own controller is not in the file +// is a mesh that cannot be told anything, and there is no state of the records in which that is +// correct. +func Users(r Records) ([]Principal, error) { + out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r), + AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}} + + for _, node := range sortedCopy(r.Nodes) { + witness := false + var checks []string + for _, d := range r.Assigned[node] { + if d.Module == controllerModule { + witness = true + } + checks = append(checks, d.Checks...) + } + out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks, + MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey)}) + // **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] { + // **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, + }) + } + 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, SnapshotsTheBus: d.SnapshotsTheBus, + PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads, + Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues, + }) + // **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 d.RunsAs == "" && runnerServes(d) { + // 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}) + } + } + for _, node := range sortedCopy(r.Enrolling) { + out = append(out, Principal{Kind: KindEnrolment, Node: node}) + } + for _, person := range sortedNames(r.People) { + p := Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]} + if administers(p.Invokes) { + // The operator's hand path writes each node's compositions (ADR 0539 clause 10). + p.FilesFor = sortedCopy(r.Nodes) + } + out = append(out, p) + } + + // 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 +} + +// controllerModule is the controller's module: the machine assigned it witnesses its upgrades. +const controllerModule = "mesh-controller" + +// 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 +} + +// 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 +} + +// vaultVerbsOf are the vault's intake verbs its holder serves, as the records say (issue 675): what the controller +// is granted to ask it in its own name. None while no holder serves them. +func vaultVerbsOf(r Records) []string { return servedOf(r, VerbsTheControllerAsksTheVault) } + +// 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 +} + +// 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 +} diff --git a/go/buscompose/waits.go b/go/buscompose/waits.go new file mode 100644 index 0000000..c92300e --- /dev/null +++ b/go/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/go/buscompose/writers.go b/go/buscompose/writers.go new file mode 100644 index 0000000..1796082 --- /dev/null +++ b/go/buscompose/writers.go @@ -0,0 +1,308 @@ +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 (the controller's store, a module's own) 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 +} + +// isController, and the other writers' tests, are who a row's writer is as a principal. +func isController(p Principal, _ string) bool { return p.Kind == KindController } + +// 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 +} + +// ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps, or the +// controller saying what it keeps itself (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 + } + switch p.Kind { + case KindController: + return tokens[2] == ControllerSeat + case 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 declaration", Writer: "controller (lease holder)", KeptIn: "the bus, last per subject", + Others: "read", Subjects: []string{"mesh.node.*.declare"}, Writes: isController}, + // The operator's hand-over of a directory used as found, asked of the machine's engine at the controller's + // terminal (novox/hq issue 356). One publisher; and because a responder can still reach the subject through a + // reply, the ask is signed with the mesh's key and the engine verifies it — the row bounds who is granted the + // publish, the signature who is believed. + {State: "a hand-over asked of a machine", Writer: "controller, at its terminal", KeptIn: "the machine, beside its state", + Others: "the engine verifies the mesh's signature and records it, or refuses", + Subjects: []string{"mesh.node.*.ask.hand-over"}, Writes: isController}, + // The operator asking a machine's engine for a fresh search for setuid programs, at the controller's + // terminal (novox/hq issue 361): signed as a hand-over is, under a signing context of its own. + {State: "a fresh setuid search asked of a machine", Writer: "controller, at its terminal", + KeptIn: "the machine, which throws its last search away", Others: "the engine verifies the mesh's signature and starts it, or refuses", + Subjects: []string{"mesh.node.*.ask.setuid-search"}, Writes: isController}, + // A read-only judgement of a machine's declared directories asked of its engine (novox/hq issue 358): signed as a + // hand-over is, under a signing context of its own. It changes nothing on the machine. + {State: "a judgement of a machine's directories asked of it", Writer: "controller", + KeptIn: "nowhere: the engine answers it and keeps nothing", Others: "the engine verifies the mesh's signature and answers, or refuses", + Subjects: []string{"mesh.node.*.ask.judge-directories"}, Writes: isController}, + {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): the node is what the controller judges the terminal by, so that + // subject is this machine's engine's alone. + Subjects: []string{"mesh.control.*.report", "mesh.control.*.health", "mesh.control.*.cli"}, Writes: ownMachine}, + {State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket, + Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: isController}, + // The merges mesh-delivery announced, for a moved module's registration (ADR 0420 §7), written by the lease holder. + {State: "the merges mesh-delivery announced", Writer: "controller (lease holder)", + KeptIn: "key-value " + DeliveryMergesBucket, Others: "read at a moved module's registration", + Subjects: kvOf(DeliveryMergesBucket), Writes: isController}, + // And the compositions (novox/hq ADR 0539 clauses 2 and 4): each node's in its own bucket, which only that node's + // node-engine reads, and each digest with the judgement of its move, which the assigner reads. + {State: "the compositions and their digests", Writer: "controller (lease holder), compare-and-set", + KeptIn: "key-value composed- for each node, and " + delivery.ControllerComposedBucket, + Others: "a node-engine reads its own node's; the assigner reads the digests", + Subjects: kvOf(delivery.ControllerComposedBucket), Writes: isControllerOrTheHandPath}, + {State: "conditions", Writer: "controller", KeptIn: "key-value " + ConditionsBucket + " (and its history, " + + ConditionHistoryBucket + "); how often each was observed, when last and its newest evidence in the " + + "controller's store, so a look that changes nothing writes no bucket", Others: "raise or clear only through observations the controller reads", + Subjects: append(kvOf(ConditionsBucket), kvOf(ConditionHistoryBucket)...), Writes: isController}, + {State: "calls and their outcomes", Writer: "controller", KeptIn: "key-value " + CallsBucket, + Others: "read by id", Subjects: kvOf(CallsBucket), Writes: isController}, + {State: "the hand-act log", Writer: "controller, through the verbs that act", KeptIn: "key-value " + HandActsBucket, + Others: "—", Subjects: kvOf(HandActsBucket), Writes: isController}, + // What the healers did (novox/hq to-be 45 §7, Phase 3): the controller's alone, in its store — the + // budgets and the mesh-wide brake are counted from it, so a controller restarting cannot reset them. + {State: "the healers' acts and their brake", Writer: "controller (lease holder), each act begun before it is made", + KeptIn: "the controller's store", Others: "read through healers; each act said as healer-acted and in its condition's tried"}, + {State: "stream definitions and bus permissions", Writer: "controller", 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: isController}, + {State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state", + Others: "the controller 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, kept by + // the controller. + {State: "a module's states announced", Writer: "the module itself, from its code, under its own name", + KeptIn: "the bus (EVENTS)", Others: "the controller keeps the last of each and grants readers from it", + Subjects: []string{"mesh.mod.*.event.states"}, Writes: ownAnnouncer}, + {State: "the states announced", Writer: "controller, from each module's announcement", + KeptIn: "key-value " + StatesAnnouncedBucket, Others: "the composition of the bus's users reads it", + Subjects: kvOf(StatesAnnouncedBucket), Writes: isController}, + {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 controller asks the build seat to check it", Subjects: []string{"mesh.mod.*.event.pull.updated"}, + Writes: ownModule}, + {State: "a pull request's merge check", Writer: "the build seat's holder that ran it, said as the controller's `checked`", + 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 controller 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: "—"}, + {State: "the facts snapshot", Writer: "controller", KeptIn: "the artifact store, facts/latest", + Others: "the build seat reads"}, + // What a consumer gave up on (novox/hq issue 330): copied by the controller from the server's notice, + // and delivered again or dropped only through its verb, with why. + {State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream, + Others: "read, delivered again or dropped through the controller's dead-letters verb", + Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: isController}, + // The record mirrors (novox/hq ADR 0289 §9, design 54 §2): rows of this table, so a grant that lets + // anything else write them is refused at composition. Built so far: the assignments and each registered + // module's declared config (design 57 F3), and the list of predefined keys kept (design 57 K3). + {State: "the assignments mirror", Writer: "controller (lease holder): the verbs that assign, and the sends that record what runs; " + + "a mirror of its store only until the assignments are moved to the bus (novox/hq ADR 0408)", + KeptIn: "key-value " + AssignmentsBucket + ", the record once moved", Others: "read through `reads` as mesh-controller." + MirrorAssignments, + Subjects: kvOf(AssignmentsBucket), Writes: isController}, + // The chain (novox/hq ADR 0408): builds and rollouts beside the assignments above, which keep their bucket. Not + // mirrors of the store but the record, written by the lease holder by compare-and-set and removed by nobody. + {State: "the builds of the chain", Writer: "controller (lease holder), compare-and-set on the key's revision", + KeptIn: "key-value " + BuildsBucket + ", sixty-four revisions of each key, none removed", + Others: "read through `reads` as mesh-controller." + ChainBuilds, Subjects: kvOf(BuildsBucket), Writes: isController}, + {State: "the rollouts of the chain", Writer: "controller (lease holder), compare-and-set on the key's revision", + KeptIn: "key-value " + RolloutsBucket + ", sixty-four revisions of each key, none removed", + Others: "read through `reads` as mesh-controller." + ChainRollouts, Subjects: kvOf(RolloutsBucket), Writes: isController}, + // The grants issued (novox/hq ADR 0528 clause 2, issue 1394): the controller's, read by the assigner for its sixth rule. + {State: "the grants issued", Writer: "controller (lease holder), once the bus's user list carrying a module's manifest at a fingerprint was sent", + KeptIn: "key-value " + GrantsBucket + ", one key a module's fingerprint, none removed", + Others: "read through `reads` as mesh-controller." + ControllerGrants, Subjects: kvOf(GrantsBucket), Writes: isController}, + // 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, never by the controller; 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 contents of a declaration's files over 64 KiB (novox/hq ADR 0486 §2): the controller alone writes the bucket, each + // object under the digest of its content; a node-engine reads one by its name, with no list and no write. + {State: "a declaration's large files", Writer: "controller, each object under the sha256 digest of its content; the " + + "operator's account on release 6's hand path alone (ADR 0539 clause 10)", + 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: isControllerOrTheHandPath}, + {State: "the declared-config mirror", Writer: "controller (lease holder), from its store, only on a change", + KeptIn: "key-value " + DeclaredConfigBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredConfig, + Subjects: kvOf(DeclaredConfigBucket), Writes: isController}, + {State: "the predefined-keys mirror", Writer: "controller (lease holder), from its store, only on a change", + KeptIn: "key-value " + PredefinedBucket, Others: "read through `reads` as mesh-controller." + MirrorPredefined, + Subjects: kvOf(PredefinedBucket), Writes: isController}, + {State: "the declared-packages mirror", Writer: "controller (lease holder), from the composition of each node, only on a change", + KeptIn: "key-value " + DeclaredPackagesBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredPackages, + Subjects: kvOf(DeclaredPackagesBucket), Writes: isController}, + // 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 controller reads it at a walk's end and a gate; 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 + } + } +} + +// isControllerOrTheHandPath is the controller, or the operator's account (a person who may invoke every tool) writing +// release 6's hand path: the one second writer ADR 0539 clauses 2 and 10 name, for a composition made by hand. +func isControllerOrTheHandPath(p Principal, pattern string) bool { + return isController(p, pattern) || (p.Kind == KindPerson && administers(p.Invokes)) +}