From 2c06c0d66358cc5f877840a4bb5ea415373a4695 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 3 Sep 2026 23:44:30 +0200 Subject: [PATCH 1/8] =?UTF-8?q?manifest:=20emits=20and=20consumes=20?= =?UTF-8?q?=E2=80=94=20the=20event=20relationship?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module declares the event types it emits and the patterns it consumes, parallel to provides/requires (novox/hq ADR 0046). Events span module.*, mesh.* and node.* sources; a consumes for an event nothing emits is a dangling edge. Fields only here; the dangling-edge check and the runtime wiring follow. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- internal/catalogue/manifest.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index aab61d2..94abd4e 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -126,6 +126,17 @@ type Manifest struct { // Requires are names that must be provided by something assigned to the same node. Requires []string `json:"requires,omitempty"` + // Emits are the event types this module publishes onto the broker — dotted topic keys, e.g. + // "module.umami.site.created". Declared so the mesh knows the event graph; events are + // provisioning's lighter sibling — 1:many and broadcast, no credential (novox/hq ADR 0046). + Emits []string `json:"emits,omitempty"` + + // Consumes are the event patterns this module subscribes to — topic patterns over module, + // mesh and node events alike, e.g. "node.*.joined" or "#" (the audit logger). The runtime + // wires the subscription; the module ships the handler. A Consumes for an event nothing on + // the mesh Emits is a dangling edge. + Consumes []string `json:"consumes,omitempty"` + // Claims are singular resources. Two modules claiming one thing within a scope cannot both // be assigned there — which is how exclusivity is expressed, rather than as a list of rivals // that every new module would force its predecessors to update. From 47bbb0cca6a90ee3105f559b6f184e4e7a7597d6 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 01:31:19 +0200 Subject: [PATCH 2/8] broker: a generic module account, scoped by emits and consumes (ADR 0048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateModuleAccount gives an assigned module its own broker account whose permissions ARE its manifest: declare and read its own ..events queue, read the events exchange to bind onto if it consumes, write the events exchange only if it emits. The account name carries the node (sealed per machine), the permissions carry the module (one cannot read another's queue). The builder becomes one instance of this rule rather than a separate kind. EnsureEventExchanges declares the bus the substrate owns — mesh.events, mesh.rpc, mesh.events.dead + a retention queue — idempotently, since a module account may not declare an exchange. Scope tested as patterns (no broker needed), and every management call verified against a real LavinMQ. Honest limit recorded in the code: LavinMQ has no topic permissions, so ADR 0047's emit-origin reservation (module..*) is stamped by the sdk, not enforced by the broker; a pure consumer like the audit logger is unaffected. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- internal/broker/management.go | 102 +++++++++++++++++++++++++ internal/broker/module_account_test.go | 96 +++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 internal/broker/module_account_test.go diff --git a/internal/broker/management.go b/internal/broker/management.go index 2873408..caf8739 100644 --- a/internal/broker/management.go +++ b/internal/broker/management.go @@ -67,6 +67,104 @@ const ExchangeName = "mesh" // constant that differed would be caught by the test that asserts they agree. const BuildQueueName = "builds" +// The events bus (novox/hq ADR 0047): one topic exchange every event rides, a second for tool +// RPC kept apart, and a dead-letter home for a poison event. The substrate owns these — a module's +// account cannot declare them, only bind its own queue to the events one. +const ( + EventsExchangeName = "mesh.events" + RPCExchangeName = "mesh.rpc" + DeadExchangeName = "mesh.events.dead" +) + +// ModuleQueueFor is the durable queue a module consumes its events from — one per module per node +// (novox/hq ADR 0047), named so the account that may read it is exactly this module's. +func ModuleQueueFor(node, module string) string { return node + "." + module + ".events" } + +// modulePermissions is a module's authority on the bus, derived from its manifest (novox/hq +// ADR 0048): what it consumes and what it emits, and nothing else. Pure, so the scope is tested as +// patterns without a broker — the way a builder's is. +// +// A note on the limit: the broker's write permission is per exchange, not per routing key (LavinMQ +// has no topic permissions), so an emitting module is granted the events exchange whole. ADR 0047's +// origin reservation — a module publishes only under `module..*` — is stamped by the sdk, not +// enforced here; that gap is the broker's, and is recorded rather than hidden. A pure consumer like +// the audit logger is unaffected: it is granted no write to the exchange at all. +func modulePermissions(node, module string, emits, consumes []string) (configure, write, read string) { + queue := regexp.QuoteMeta(ModuleQueueFor(node, module)) + events := regexp.QuoteMeta(EventsExchangeName) + + // Declare only its own queue. + configure = "^" + queue + "$" + + // Write to its own queue — binding a queue to an exchange is a write on the queue — and to the + // events exchange only if it emits. + writes := []string{queue} + if len(emits) > 0 { + writes = append(writes, events) + } + write = "^(" + strings.Join(writes, "|") + ")$" + + // Read its own queue to consume it, and the events exchange to bind onto, only if it consumes. + reads := []string{queue} + if len(consumes) > 0 { + reads = append(reads, events) + } + read = "^(" + strings.Join(reads, "|") + ")$" + return configure, write, read +} + +// CreateModuleAccount gives an assigned module its own broker account, scoped by what it emits and +// consumes (novox/hq ADR 0048). The account name carries the node so the same module on two machines +// holds two accounts, each sealed to its own; the permissions carry the module so one module cannot +// read another's queue. Generic — the builder is one instance of this rule, not a separate kind. +func (m *Management) CreateModuleAccount(ctx context.Context, node, module, password string, emits, consumes []string) (string, error) { + if !safeName.MatchString(node) { + return "", fmt.Errorf("%q cannot be part of a broker account: it is a permission pattern", node) + } + if !safeName.MatchString(module) { + return "", fmt.Errorf("%q cannot be part of a broker account: it is a permission pattern", module) + } + account := node + "-" + module + if !safeName.MatchString(account) { + return "", fmt.Errorf("%q is not a usable broker account name", account) + } + + if err := m.put(ctx, "/api/users/"+url.PathEscape(account), + map[string]string{"password": password, "tags": ""}); err != nil { + return "", fmt.Errorf("cannot create the broker account for %s on %s: %w", module, node, err) + } + + configure, write, read := modulePermissions(node, module, emits, consumes) + if err := m.put(ctx, "/api/permissions/%2f/"+url.PathEscape(account), map[string]string{ + "configure": configure, "write": write, "read": read, + }); err != nil { + return "", fmt.Errorf("cannot scope the broker account for %s on %s: %w", module, node, err) + } + return account, nil +} + +// EnsureEventExchanges declares the bus's exchanges and the dead-letter home, idempotently. The +// substrate owns them (a module's account may not declare an exchange), and a dead-letter exchange +// with no queue behind it drops what it receives — so a durable queue bound to `#` retains a poison +// event for inspection, which is the whole reason the trail exists. +func (m *Management) EnsureEventExchanges(ctx context.Context) error { + for _, exchange := range []string{EventsExchangeName, RPCExchangeName, DeadExchangeName} { + if err := m.put(ctx, "/api/exchanges/%2f/"+url.PathEscape(exchange), + map[string]any{"type": "topic", "durable": true}); err != nil { + return fmt.Errorf("cannot declare the %s exchange: %w", exchange, err) + } + } + if err := m.put(ctx, "/api/queues/%2f/"+url.PathEscape(DeadExchangeName), + map[string]any{"durable": true}); err != nil { + return fmt.Errorf("cannot declare the dead-letter queue: %w", err) + } + if err := m.post(ctx, "/api/bindings/%2f/e/"+url.PathEscape(DeadExchangeName)+ + "/q/"+url.PathEscape(DeadExchangeName), map[string]string{"routing_key": "#"}); err != nil { + return fmt.Errorf("cannot bind the dead-letter queue: %w", err) + } + return nil +} + // CreateNodeAccount gives a node its own broker account, with the token's secret as the password. // // Scoped so a node can reach its own queue and the one exchange, and nothing else. The patterns @@ -167,6 +265,10 @@ func (m *Management) put(ctx context.Context, path string, body any) error { return m.do(ctx, http.MethodPut, path, body) } +func (m *Management) post(ctx context.Context, path string, body any) error { + return m.do(ctx, http.MethodPost, path, body) +} + func (m *Management) get(ctx context.Context, path string) ([]byte, error) { request, err := m.request(ctx, http.MethodGet, path, nil) if err != nil { diff --git a/internal/broker/module_account_test.go b/internal/broker/module_account_test.go new file mode 100644 index 0000000..d14bce8 --- /dev/null +++ b/internal/broker/module_account_test.go @@ -0,0 +1,96 @@ +package broker_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "github.com/novox/mesh-control/internal/broker" +) + +// The audit logger consumes everything and emits nothing. Its account must let it declare and read +// its own queue and read the events exchange to bind onto — and must not reach another module's +// queue, nor grant any write to the events exchange (novox/hq ADR 0048). +func TestAConsumerReadsItsOwnQueueAndTheEventsExchangeAndNoOthers(t *testing.T) { + // Rebuilt through CreateModuleAccount's own path by asking for the same scope it would apply. + // The queue this module reads: + mine := broker.ModuleQueueFor("anchor", "audit-logger") + other := broker.ModuleQueueFor("anchor", "plex") + + read := scope(t, "read", "anchor", "audit-logger", nil, []string{"#"}) + if !read.MatchString(mine) { + t.Error("the audit logger may not read its own queue, so it consumes nothing") + } + if !read.MatchString(broker.EventsExchangeName) { + t.Error("the audit logger may not read the events exchange, so it cannot bind onto it") + } + if read.MatchString(other) { + t.Error("the audit logger may read another module's queue") + } + + // It emits nothing, so it is granted no write to the events exchange — only its own queue, to bind. + write := scope(t, "write", "anchor", "audit-logger", nil, []string{"#"}) + if write.MatchString(broker.EventsExchangeName) { + t.Error("a pure consumer was granted write to the events exchange") + } + if !write.MatchString(mine) { + t.Error("the audit logger may not write to its own queue, so it cannot bind it") + } +} + +// An emitter is granted the events exchange to write; a consumer is not. +func TestAnEmitterMayWriteTheEventsExchangeAndAConsumerMayNot(t *testing.T) { + emitter := scope(t, "write", "anchor", "umami", []string{"module.umami.site.created"}, nil) + if !emitter.MatchString(broker.EventsExchangeName) { + t.Error("an emitting module may not write the events exchange, so it cannot emit") + } +} + +// scope reconstructs one of the three permission patterns CreateModuleAccount would apply, by +// reading it back from a captured request against a stub management API. +func scope(t *testing.T, which, node, module string, emits, consumes []string) *regexp.Regexp { + t.Helper() + pat := capturePermission(t, which, node, module, emits, consumes) + re, err := regexp.Compile(pat) + if err != nil { + t.Fatalf("the %s pattern does not compile: %v", which, err) + } + return re +} + +// capturePermission runs CreateModuleAccount against a stub management API and returns the pattern +// it set for `which` ("configure"/"write"/"read"). The scope is tested where it is applied, not +// reconstructed by the test — so a change to the mapping cannot pass a test that hard-codes the old +// one. +func capturePermission(t *testing.T, which, node, module string, emits, consumes []string) string { + t.Helper() + var captured map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/permissions/") { + _ = json.NewDecoder(r.Body).Decode(&captured) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + t.Setenv(broker.ManagementVar, server.URL) + m, err := broker.ManagementFromEnvironment() + if err != nil { + t.Fatalf("stub management not usable: %v", err) + } + if _, err := m.CreateModuleAccount(context.Background(), node, module, "pw", emits, consumes); err != nil { + t.Fatalf("CreateModuleAccount: %v", err) + } + if captured == nil { + t.Fatal("no permissions were set") + } + pattern, ok := captured[which] + if !ok { + t.Fatalf("no %s permission was set; got %v", which, captured) + } + return pattern +} From f41d280e66fcac49ee299b615393d5a134150be8 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 01:33:03 +0200 Subject: [PATCH 3/8] =?UTF-8?q?cli:=20module=20issue=20=E2=80=94=20deliver?= =?UTF-8?q?=20a=20module=20its=20scoped=20broker=20account=20(ADR=200048)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'module issue --node ' looks up the module's emits/consumes from the catalogue, ensures the bus exchanges exist, creates its scoped account (CreateModuleAccount), and seals an amqps {url,fingerprint} to the node as the module's broker own-secret — the same delivery as 'builder issue', now generic. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- cmd/mesh-control/main.go | 1 + cmd/mesh-control/modules.go | 77 ++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 43a58d6..613f709 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -135,6 +135,7 @@ func usage() { module list what modules this mesh knows about module moved the source has a newer commit than the mesh built module forget remove one, unless a node is running it + module issue --node a broker account for a module, scoped to its emits and consumes status [--json] what is wrong, what is quiet, and what is out of date board [--listen ADDR] the same three questions, as a page that holds nothing api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here diff --git a/cmd/mesh-control/modules.go b/cmd/mesh-control/modules.go index cfa81cd..837b26e 100644 --- a/cmd/mesh-control/modules.go +++ b/cmd/mesh-control/modules.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/rand" + "encoding/base64" "encoding/json" "errors" "flag" @@ -10,6 +12,7 @@ import ( "sort" "strings" + "github.com/novox/mesh-control/internal/broker" "github.com/novox/mesh-control/internal/catalogue" "github.com/novox/mesh-control/internal/inventory" "github.com/novox/mesh-control/internal/overlay" @@ -194,8 +197,80 @@ func moduleCommand(ctx context.Context, args []string) error { fmt.Printf("%s forgotten\n", args[1]) return nil + case "issue": + // A module's broker account, scoped by its emits and consumes (novox/hq ADR 0048) and + // sealed to the machine that will run it — the generic case the builder was the first of. + set := flag.NewFlagSet("module issue", flag.ContinueOnError) + forNode := set.String("node", "", + "the machine that will run it, so the credential is delivered instead of printed") + positionals, err := parseAround(set, args[1:]) + if err != nil { + return err + } + if len(positionals) != 1 { + return errors.New("module issue --node ") + } + module := positionals[0] + if *forNode == "" { + return errors.New("module issue needs --node: a module's account is sealed to the " + + "machine that runs it, and the mesh cannot read it back to print") + } + + shelf, err := inv.Catalogue(ctx) + if err != nil { + return err + } + m, ok := shelf[module] + if !ok { + return fmt.Errorf("this mesh knows no module %q; `module add` it first", module) + } + + management, err := broker.ManagementFromEnvironment() + if err != nil { + return err + } + // The substrate owns the bus; make sure it exists before a module binds onto it. + if err := management.EnsureEventExchanges(ctx); err != nil { + return err + } + + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return err + } + password := base64.RawURLEncoding.EncodeToString(secret) + account, err := management.CreateModuleAccount(ctx, *forNode, module, password, m.Emits, m.Consumes) + if err != nil { + return err + } + + known, err := broker.FromEnvironment() + if err != nil { + return fmt.Errorf("cannot deliver a credential without knowing where the broker is: %w", err) + } + // The URL and what verifies the broker, together — a mesh's broker presents its own + // certificate, in no public trust store, so a URL alone fails at TLS (as `builder issue`). + held, err := json.Marshal(struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + }{ + URL: fmt.Sprintf("amqps://%s:%s@%s/", account, password, known.Address), + Fingerprint: known.Fingerprint, + }) + if err != nil { + return err + } + if err := inv.AcceptSecretForModule(ctx, *forNode, module, "broker", string(held)); err != nil { + return err + } + fmt.Printf("broker account %s created for %s, scoped to what it emits and consumes\n", + account, module) + fmt.Printf(" sealed to %s. It arrives with the next push — `push %s` to send it\n", + *forNode, *forNode) + return nil + default: - return fmt.Errorf("module has no %q; it has add, list, moved and forget", args[0]) + return fmt.Errorf("module has no %q; it has add, list, moved, forget and issue", args[0]) } } From 64496f03353219655c98dbd4da78c41c756f48c8 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 01:51:17 +0200 Subject: [PATCH 4/8] broker: the substrate pre-declares a consumer's dead-lettered queue (ADR 0048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LavinMQ refuses a non-administrator declaring a queue with a dead-letter exchange, so a scoped module cannot make its own. EnsureModuleQueue declares ..events with its DLX as the mesh, and 'module issue' does so for a consuming module — the runtime then passively checks it rather than declaring. Verified against a real broker: the scoped account binds and consumes the pre-declared queue. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- cmd/mesh-control/modules.go | 7 +++++++ internal/broker/management.go | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/cmd/mesh-control/modules.go b/cmd/mesh-control/modules.go index 837b26e..743a192 100644 --- a/cmd/mesh-control/modules.go +++ b/cmd/mesh-control/modules.go @@ -243,6 +243,13 @@ func moduleCommand(ctx context.Context, args []string) error { if err != nil { return err } + // A consumer's queue, with its dead-letter, is the substrate's to declare — its own account + // may not (ADR 0048). Made now, so it exists before the module binds onto it. + if len(m.Consumes) > 0 { + if err := management.EnsureModuleQueue(ctx, *forNode, module); err != nil { + return err + } + } known, err := broker.FromEnvironment() if err != nil { diff --git a/internal/broker/management.go b/internal/broker/management.go index caf8739..3270826 100644 --- a/internal/broker/management.go +++ b/internal/broker/management.go @@ -143,6 +143,21 @@ func (m *Management) CreateModuleAccount(ctx context.Context, node, module, pass return account, nil } +// EnsureModuleQueue declares a consuming module's queue with its dead-letter exchange, idempotently. +// The substrate declares it because a scoped module account may not: the broker refuses a queue with +// a dead-letter exchange to a non-administrator (novox/hq ADR 0048), so a consumer passively checks +// the queue the mesh made rather than declaring its own. +func (m *Management) EnsureModuleQueue(ctx context.Context, node, module string) error { + queue := ModuleQueueFor(node, module) + if err := m.put(ctx, "/api/queues/%2f/"+url.PathEscape(queue), map[string]any{ + "durable": true, + "arguments": map[string]any{"x-dead-letter-exchange": DeadExchangeName}, + }); err != nil { + return fmt.Errorf("cannot declare the queue for %s on %s: %w", module, node, err) + } + return nil +} + // EnsureEventExchanges declares the bus's exchanges and the dead-letter home, idempotently. The // substrate owns them (a module's account may not declare an exchange), and a dead-letter exchange // with no queue behind it drops what it receives — so a durable queue bound to `#` retains a poison From 931ca6f01e699d08574ee7748d4b13291e96cfb3 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 01:53:23 +0200 Subject: [PATCH 5/8] cli: module issue seals the node and module into the credential So the runtime knows the identity the account was scoped to, without a manifest naming the node. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- cmd/mesh-control/modules.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/mesh-control/modules.go b/cmd/mesh-control/modules.go index 743a192..3d10bd7 100644 --- a/cmd/mesh-control/modules.go +++ b/cmd/mesh-control/modules.go @@ -260,9 +260,15 @@ func moduleCommand(ctx context.Context, args []string) error { held, err := json.Marshal(struct { URL string `json:"url"` Fingerprint string `json:"fingerprint,omitempty"` + Node string `json:"node"` + Module string `json:"module"` }{ URL: fmt.Sprintf("amqps://%s:%s@%s/", account, password, known.Address), Fingerprint: known.Fingerprint, + // The node and module the account is for, so the runtime names its queue as the mesh + // scoped it (..events) without a manifest having to interpolate a node. + Node: *forNode, + Module: module, }) if err != nil { return err From b306c744679a2065d21a60ec5dd77a2c68065af1 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 21:12:33 +0200 Subject: [PATCH 6/8] filtering: a per-node 'expose' setting overrides a listen's source (ADR 0051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listens.from was a manifest constant — one value for every node a module runs on. Now a per-node setting overrides it: {"expose": {"5432": "anywhere"}} makes postgres public on the machine it is set for while it stays from:mesh elsewhere, and the firewall (ADR 0050) is computed from the effective source. Exposure() validates it — a port the module does not listen on, or a source that is not mesh/anywhere/machine, is refused rather than reaching nothing; UnusedSettings knows 'expose' is a real destination. Tested: default mesh, setting opens it to anywhere, bad settings refused. --- internal/catalogue/declaration.go | 15 +++++- internal/catalogue/filtering.go | 70 ++++++++++++++++++++++++++-- internal/catalogue/filtering_test.go | 48 ++++++++++++++++++- internal/catalogue/settings.go | 5 ++ 4 files changed, 131 insertions(+), 7 deletions(-) diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index ea5d8ed..3ca4de7 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -138,8 +138,19 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { } // Once, from every module's listens -- not per module. A module receiving only its own ports - // would write a rule set that closed every other module on the machine. - rules, err := r.Filtering(with.Generators, with.Ports) + // would write a rule set that closed every other module on the machine. Each module's per-node + // exposure settings override its listens' source first (novox/hq ADR 0051). + exposure := map[string]map[int]string{} + for _, m := range r.Modules { + e, err := Exposure(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + if e != nil { + exposure[m.Module] = e + } + } + rules, err := r.Filtering(with.Generators, with.Ports, exposure) if err != nil { return nil, err } diff --git a/internal/catalogue/filtering.go b/internal/catalogue/filtering.go index 36a1aba..53e9def 100644 --- a/internal/catalogue/filtering.go +++ b/internal/catalogue/filtering.go @@ -3,6 +3,7 @@ package catalogue import ( "fmt" "sort" + "strconv" "strings" ) @@ -31,7 +32,7 @@ type Rule struct { // a consequence of what runs on it, not a second list kept in step by hand. Nothing else opens a // port: **what is not declared is closed**, which is the property that makes the derivation worth // having rather than merely tidy. -func (r Resolution) Filtering(computed map[string]Generator, ports map[string]map[int]int) ([]Rule, error) { +func (r Resolution) Filtering(computed map[string]Generator, ports map[string]map[int]int, exposure map[string]map[int]string) ([]Rule, error) { // Keyed by what actually distinguishes an opening. Two modules wanting :443 from the mesh is // one rule with two sources; one wanting it from the mesh and another from anywhere is two, // and they are collapsed below -- deliberately, and only in the widening direction. @@ -70,10 +71,17 @@ func (r Resolution) Filtering(computed map[string]Generator, ports map[string]ma if at, known := ports[m.Module][l.Port]; known { port = at } - at := opening{port: port, protocol: l.At(), from: l.From} + // The source, with any per-node exposure setting applied. The override names the port + // the module declares, so it travels with that port to wherever the machine publishes + // it (novox/hq ADR 0051): the same module is internal on one node and public on another. + from := l.From + if override, set := exposure[m.Module][l.Port]; set { + from = override + } + at := opening{port: port, protocol: l.At(), from: from} rule, seen := found[at] if !seen { - rule = &Rule{Port: port, Protocol: l.At(), From: l.From} + rule = &Rule{Port: port, Protocol: l.At(), From: from} found[at] = rule } rule.Because = append(rule.Because, m.Module) @@ -100,6 +108,62 @@ func (r Resolution) Filtering(computed map[string]Generator, ports map[string]ma return widest(out), nil } +// ExposeSetting is the settings key that overrides a listen's source per node (novox/hq ADR 0051): +// +// {"expose": {"5432": "anywhere"}} +// +// makes the module's port 5432 open to the public on the node this is set for, whatever the +// manifest's default. It keys on the port the module declares — the mesh maps that to where the +// machine publishes, and the override travels with it. +const ExposeSetting = "expose" + +// Exposure reads a module's per-node exposure overrides from its settings: declared-port → source. +// +// It refuses an override for a port the module does not listen on, or to a source that is not a +// real one — an exposure setting that reaches no port, or names a source nothing enforces, is the +// "reads as a restriction and is none" fault this whole mechanism exists to prevent (novox/hq +// ADR 0048/0050). A module with no `expose` setting yields nothing and keeps its manifest defaults. +func Exposure(m Manifest, layers []Layer) (map[int]string, error) { + listened := make(map[int]bool, len(m.Listens)) + for _, l := range m.Listens { + listened[l.Port] = true + } + + out := map[int]string{} + for _, layer := range layers { + raw, ok := layer.Values[ExposeSetting] + if !ok { + continue + } + entries, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s: %s is a { port: source } map, and %q set it to something else", + m.Module, ExposeSetting, layer.From) + } + for portText, value := range entries { + port, err := strconv.Atoi(portText) + if err != nil { + return nil, fmt.Errorf("%s exposes %q, which is not a port", m.Module, portText) + } + if !listened[port] { + return nil, fmt.Errorf( + "%s exposes port %d, which it does not listen on — the setting reaches nothing", + m.Module, port) + } + source, ok := value.(string) + if !ok || (source != FromMesh && source != FromEverywhere && source != FromMachine) { + return nil, fmt.Errorf("%s exposes port %d as %v; it is %q, %q or %q", + m.Module, port, value, FromMesh, FromEverywhere, FromMachine) + } + out[port] = source + } + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} + // widest drops a rule that another already covers. // // A port open to anywhere is not additionally restricted by a second rule opening it to the mesh: diff --git a/internal/catalogue/filtering_test.go b/internal/catalogue/filtering_test.go index 204e37b..e222189 100644 --- a/internal/catalogue/filtering_test.go +++ b/internal/catalogue/filtering_test.go @@ -369,7 +369,7 @@ func TestAMachineOffTheNetworkIsBoundToItselfByAnAddressThatWorks(t *testing.T) // mustFilter is the rule set, refusing to continue if it could not be computed. func mustFilter(t *testing.T, r Resolution, computed map[string]Generator) []Rule { t.Helper() - rules, err := r.Filtering(computed, nil) + rules, err := r.Filtering(computed, nil, nil) if err != nil { t.Fatalf("no rule set could be computed: %v", err) } @@ -440,7 +440,7 @@ func TestAComputedModulesOwnListensAreNotLost(t *testing.T) { func TestAGeneratorThatCannotSayRefusesTheRuleSet(t *testing.T) { _, err := Resolution{Node: "anchor", Modules: []Manifest{{Module: "networking", Computed: "mesh-network"}}, - }.Filtering(map[string]Generator{"mesh-network": cannotSay{}}, nil) + }.Filtering(map[string]Generator{"mesh-network": cannotSay{}}, nil, nil) if err == nil { t.Fatal("a machine whose open ports could not be computed was given a rule set anyway") } @@ -578,3 +578,47 @@ func named(r Resolution) []string { } return out } + +// A port's source is a per-node setting, not a manifest constant: the same module is internal on +// one machine and public on another (novox/hq ADR 0051). postgres listens from the mesh by default; +// a setting on one node exposes it to anywhere, and the rule set follows. +func TestExposureSettingOverridesAListensSource(t *testing.T) { + postgres := Manifest{Module: "postgres", Version: "1", + Listens: []Listening{{Port: 5432, From: FromMesh, Why: "the database"}}} + + base, err := Resolution{Node: "ace", Modules: []Manifest{postgres}}.Filtering(nil, nil, nil) + if err != nil { + t.Fatalf("filtering: %v", err) + } + if len(base) != 1 || base[0].From != FromMesh { + t.Fatalf("without a setting, the default source is the mesh: %+v", base) + } + + expose := map[string]map[int]string{"postgres": {5432: FromEverywhere}} + exposed, err := Resolution{Node: "novox", Modules: []Manifest{postgres}}.Filtering(nil, nil, expose) + if err != nil { + t.Fatalf("filtering: %v", err) + } + if len(exposed) != 1 || exposed[0].From != FromEverywhere { + t.Fatalf("the setting did not open the port to anywhere: %+v", exposed) + } +} + +// Exposure refuses a setting that names a port the module does not listen on, or a source that is +// not a real one — a setting reaching nothing is worse than none (novox/hq ADR 0048/0051). +func TestExposureRefusesAPortNotListenedOnAndABadSource(t *testing.T) { + postgres := Manifest{Module: "postgres", Listens: []Listening{{Port: 5432, From: FromMesh}}} + layer := func(port, source string) []Layer { + return []Layer{{From: "node", Values: map[string]any{ExposeSetting: map[string]any{port: source}}}} + } + + if _, err := Exposure(postgres, layer("5432", FromEverywhere)); err != nil { + t.Fatalf("a valid exposure was refused: %v", err) + } + if _, err := Exposure(postgres, layer("6379", FromEverywhere)); err == nil { + t.Error("a port the module does not listen on was accepted") + } + if _, err := Exposure(postgres, layer("5432", "everyone")); err == nil { + t.Error("a source that is not mesh/anywhere/machine was accepted") + } +} diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index ef1fc8d..608f8a6 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -171,6 +171,11 @@ func UnusedSettings(m Manifest, layers []Layer) []string { var unused []string for _, layer := range layers { for key := range layer.Values { + // `expose` is a real destination for a module that listens: it overrides a port's + // source (novox/hq ADR 0051), validated in Exposure, so it is not stray here. + if key == ExposeSetting && len(m.Listens) > 0 { + continue + } unused = append(unused, fmt.Sprintf( "%s sets %q, and %s has no file or contribution to merge it into", layer.From, key, m.Module)) From b1bf1659d97286a4cfb8723192ca8d4bde6f0b6e Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 21:56:05 +0200 Subject: [PATCH 7/8] broker: a module account scopes its tool serve queues and mesh.rpc (ADR 0052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateModuleAccount now also grants serve..* (declare, bind, consume its own tool queues) and mesh.rpc (bind them on, publish replies) — so a module can serve its tools and reply, scoped to exactly its own, and no other module's. The broker tests still hold a module out of another's queue. --- internal/broker/management.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/broker/management.go b/internal/broker/management.go index 3270826..3ec9277 100644 --- a/internal/broker/management.go +++ b/internal/broker/management.go @@ -92,20 +92,27 @@ func ModuleQueueFor(node, module string) string { return node + "." + module + " func modulePermissions(node, module string, emits, consumes []string) (configure, write, read string) { queue := regexp.QuoteMeta(ModuleQueueFor(node, module)) events := regexp.QuoteMeta(EventsExchangeName) + rpc := regexp.QuoteMeta(RPCExchangeName) + // A module serves each of its tools on its own queue, namespaced by the module (novox/hq + // ADR 0052) — serve.. — so the account may declare, bind and read exactly its own, + // and no other module's. + serve := "serve\\." + regexp.QuoteMeta(module) + "\\..*" - // Declare only its own queue. - configure = "^" + queue + "$" + // Declare its own events queue and its own tool serve queues. + configure = "^(" + queue + "|" + serve + ")$" - // Write to its own queue — binding a queue to an exchange is a write on the queue — and to the - // events exchange only if it emits. - writes := []string{queue} + // Write to bind its queue and serve queues (binding is a write on the queue), and to the RPC + // exchange to publish replies (ADR 0052: replies ride mesh.rpc, never the default exchange, which + // would let it publish into any queue). To the events exchange only if it emits. + writes := []string{queue, serve, rpc} if len(emits) > 0 { writes = append(writes, events) } write = "^(" + strings.Join(writes, "|") + ")$" - // Read its own queue to consume it, and the events exchange to bind onto, only if it consumes. - reads := []string{queue} + // Read its own queue and serve queues to consume them, and the RPC exchange to bind its serve + // queues onto. The events exchange to bind onto only if it consumes. + reads := []string{queue, serve, rpc} if len(consumes) > 0 { reads = append(reads, events) } From 9b7ba2e20c35f26af07ebea5583e594bb739a89a Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 5 Sep 2026 02:51:39 +0200 Subject: [PATCH 8/8] identity: a consumer's identity fits the tightest backend, via a slug (ADR 0054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module may declare a short `slug`; the mesh derives mesh__ and refuses at assignment (naming the slug as the remedy) when it would still overflow — identityLimit is now 20, an S3 access key's, the tightest of the backends a login reaches (04-ISSUES/010). The slug rides the grant so the provider derives the same login the consumer does, even across nodes. CheckIdentity is now wired, in grantsFor. Also, the minted secret shrinks to 40 chars (30 bytes) from 43: an S3 secret key is 8-40, the same fit-the-tightest-backend rule on the credential's other half. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- cmd/mesh-control/plan.go | 18 ++++++++- internal/catalogue/bound_into_files.go | 2 +- internal/catalogue/declaration.go | 8 +++- internal/catalogue/identity.go | 37 ++++++++++++------ internal/catalogue/identity_test.go | 53 ++++++++++++++++++++++++++ internal/catalogue/manifest.go | 14 +++++++ internal/secrets/seal.go | 5 ++- 7 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 internal/catalogue/identity_test.go diff --git a/cmd/mesh-control/plan.go b/cmd/mesh-control/plan.go index 56fe989..bc58ff3 100644 --- a/cmd/mesh-control/plan.go +++ b/cmd/mesh-control/plan.go @@ -520,9 +520,25 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran // working for ever after its consumer went away. from = "" } + // The consumer's identity slug, from its own manifest, carried on the grant so the provider + // derives the same login the consumer does (novox/hq ADR 0054). Refused here if it still would + // not fit the tightest backend — the mesh chose the name, so the mesh refuses it, with the + // remedy a short slug rather than a login a provider silently shortened. + slug := "" + for _, mm := range plan.Modules { + if mm.Module == s.ConsumerModule { + slug = mm.Slug + break + } + } + if from != "" { + if err := catalogue.CheckIdentity(s.Consumer, catalogue.IdentitySource(slug, s.ConsumerModule)); err != nil { + return nil, err + } + } out = append(out, catalogue.Grant{ Provision: s.Name, Consumer: s.Consumer, At: onNetwork[s.Consumer], - From: from, Values: values, Sealed: s.ForProvider}) + From: from, Values: values, Slug: slug, Sealed: s.ForProvider}) } return out, nil } diff --git a/internal/catalogue/bound_into_files.go b/internal/catalogue/bound_into_files.go index 7bcd674..614d1da 100644 --- a/internal/catalogue/bound_into_files.go +++ b/internal/catalogue/bound_into_files.go @@ -57,7 +57,7 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str values := map[string]string{ "at": n.At, "from": n.From, - "as": ConsumerIdentity(node, m.Module), + "as": ConsumerIdentity(node, IdentitySource(m.Slug, m.Module)), } for key, value := range n.Serves { // The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000, diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 3ca4de7..558fc50 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -59,6 +59,10 @@ type Grant struct { // Values are what that module contributed — the name it wants, and anything else the // provision's own vocabulary defines. Values map[string]any + // Slug is the consumer module's identity slug, if it declared one — carried on the grant so the + // provider side derives the same login the consumer does, even across nodes where the consumer's + // manifest is not in view (novox/hq ADR 0054). Empty means "use the module name". + Slug string // At is where the consuming machine is on the private network, empty if it is not on one. // // Passed in with the grant because it is a fact about another machine, and resolution answers @@ -293,7 +297,7 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { } found = here } - file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, m.Module)) + file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module))) if err != nil { return nil, err } @@ -490,7 +494,7 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant, } out[g.Provision] = append(out[g.Provision], Contribution{ From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, - As: ConsumerIdentity(g.Consumer, g.From), + As: ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), Secret: grantPath(directories[g.Provision], g.Consumer, g.From), }) } diff --git a/internal/catalogue/identity.go b/internal/catalogue/identity.go index dd93ac8..502ee6b 100644 --- a/internal/catalogue/identity.go +++ b/internal/catalogue/identity.go @@ -34,7 +34,18 @@ var identityUnusable = regexp.MustCompile(`[^a-z0-9_]+`) // everything else alone. Withdrawal depends on it entirely. const IdentityPrefix = "mesh_" -// ConsumerIdentity is what one module on one machine is called, wherever it authenticates. +// IdentitySource is the name the mesh derives a consumer's identity from: the module's slug when it +// has declared one, otherwise its name (novox/hq ADR 0054). A module with a name short enough to fit +// the tightest backend needs no slug; one whose name would overflow declares a short legible one. +func IdentitySource(slug, name string) string { + if slug != "" { + return slug + } + return name +} + +// ConsumerIdentity is what one module on one machine is called, wherever it authenticates. The +// `module` argument is the identity source — a slug or a name; see IdentitySource. // // A dot and a dash both become an underscore, so `home-server` and `home.server` would collide — // which cannot happen, because a machine has one name and it is either. @@ -45,24 +56,26 @@ func ConsumerIdentity(node, module string) string { return IdentityPrefix + clean(node) + "_" + clean(module) } -// identityLimit is the shortest identifier limit among the systems these names reach: -// PostgreSQL's NAMEDATALEN - 1. -const identityLimit = 63 +// identityLimit is the shortest identifier limit among the systems these names reach: an S3 access +// key's 20 (novox/hq 04-ISSUES/010). PostgreSQL keeps 63 and MinIO 20, so 20 is the one that binds — +// the comment used to name PostgreSQL and was wrong. A name over it is refused, with the remedy a +// short slug (ADR 0054), not silently cut to fit. +const identityLimit = 20 -// CheckIdentity refuses a name a provider would silently shorten. +// CheckIdentity refuses an identity that would not fit the tightest backend a consumer reaches. // -// **Truncation is not an error in PostgreSQL** — a name past the limit is cut to fit and the -// statement succeeds. Two consumers agreeing for the first 63 bytes would become one login, which -// is 022 again at a length nobody would think to test. Refused here rather than in each -// provisioner, because the mesh chose the name and is the only thing that can choose another. +// **Truncation is not an error in most of these systems** — a name past the limit is cut to fit and +// the statement succeeds, so two consumers agreeing for the first N bytes would become one login +// (04-ISSUES/022) — and S3 refuses outright. Refused here, at the mesh, because the mesh chose the +// name and is the only thing that can choose another. The remedy is a first-class one: give the +// module a short `slug` (ADR 0054), or shorten the machine's name. func CheckIdentity(node, module string) error { got := ConsumerIdentity(node, module) if len(got) <= identityLimit { return nil } return fmt.Errorf( - "%s on %s would be identified as %q, which is %d characters and some providers keep %d — "+ - "another consumer shortened to the same name would share its login. Shorten the "+ - "machine's name or the module's", + "%s on %s is identified as %q, %d characters where a backend (an S3 access key) keeps %d — "+ + "give the module a shorter `slug` or shorten the machine's name", module, node, got, len(got), identityLimit) } diff --git a/internal/catalogue/identity_test.go b/internal/catalogue/identity_test.go new file mode 100644 index 0000000..07e7cef --- /dev/null +++ b/internal/catalogue/identity_test.go @@ -0,0 +1,53 @@ +package catalogue + +import "testing" + +// Each test names the decision it defends (novox/hq ADR 0017). + +func TestASlugIsPreferredOverTheModuleName(t *testing.T) { + // A module that declared a slug is identified by it, so a long name can be made to fit the + // tightest backend without a hash (novox/hq ADR 0054). + if got := IdentitySource("kc", "keycloak"); got != "kc" { + t.Errorf("the slug was not preferred: %q", got) + } + if got := IdentitySource("", "redis"); got != "redis" { + t.Errorf("without a slug, the name should be used: %q", got) + } + if ConsumerIdentity("anchor", IdentitySource("bkt", "bucketuser")) != "mesh_anchor_bkt" { + t.Error("a slug did not shape the identity") + } +} + +func TestCheckIdentityFitsTheTightestBackend(t *testing.T) { + // 20 is an S3 access key's limit (04-ISSUES/010), and the one that binds. mesh_anchor_keycloak + // is exactly 20 and allowed; the un-slugged bucketuser is 22 and refused, with the remedy a slug. + if err := CheckIdentity("anchor", "keycloak"); err != nil { // mesh_anchor_keycloak = 20 + t.Errorf("a 20-character identity was refused: %v", err) + } + if err := CheckIdentity("anchor", "bucketuser"); err == nil { // mesh_anchor_bucketuser = 22 + t.Error("an over-long identity was accepted") + } + // But with a slug it fits, and is accepted. + if err := CheckIdentity("anchor", IdentitySource("bkt", "bucketuser")); err != nil { + t.Errorf("a slugged identity that fits was refused: %v", err) + } +} + +func TestTheRefusalNamesTheRemedy(t *testing.T) { + err := CheckIdentity("anchor", "bucketuser") + if err == nil { + t.Fatal("expected a refusal") + } + if !contains(err.Error(), "slug") { + t.Errorf("the refusal did not point at the slug as the remedy: %v", err) + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 94abd4e..4912d2a 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -119,6 +119,13 @@ type Manifest struct { Module string `json:"module"` Version string `json:"version,omitempty"` + // Slug is a short identifier the mesh uses in place of the module name when it derives a + // consumer's login (novox/hq ADR 0054). Optional: a module with a short name needs none. It + // exists because `mesh__` must fit the tightest backend a consumer reaches — an S3 + // access key is 20 characters — and a long module name would overflow it. A person choosing + // `kc` for keycloak keeps the identity legible where a hash would not. + Slug string `json:"slug,omitempty"` + // Provides are the names other modules may require. A module always provides its own name; // this is for the rest — `zsh` provides `shell`, `xorg` provides `display-server`. Provides []Offer `json:"provides,omitempty"` @@ -467,6 +474,13 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, fmt.Sprintf( "%q is not a usable module name: lower-case letters, digits, dashes and dots", m.Module)) } + // A slug is a short identifier the mesh derives a login from (novox/hq ADR 0054). The same + // charset as a name; its length is checked against a backend's limit at assignment, where the + // node it joins is known — a slug that is fine on one machine's short name can overflow another's. + if m.Slug != "" && !name.MatchString(m.Slug) { + problems = append(problems, fmt.Sprintf( + "%q is not a usable slug: lower-case letters, digits, dashes and dots", m.Slug)) + } for _, offer := range m.Provides { p := offer.Name if !name.MatchString(p) { diff --git a/internal/secrets/seal.go b/internal/secrets/seal.go index 422c94f..1759880 100644 --- a/internal/secrets/seal.go +++ b/internal/secrets/seal.go @@ -54,7 +54,10 @@ func Make(consumerKey, providerKey string) (Sealed, error) { return Sealed{}, fmt.Errorf("both ends need a sealing key before a secret can be made") } - value := make([]byte, 32) + // 30 bytes, not 32: base64url of 30 is exactly 40 characters, and 40 is the longest secret an + // S3 access key accepts (8–40), the tightest of the backends a minted password reaches — the same + // "fit the tightest backend" rule ADR 0054 sets for the login, on the secret. 240 bits is ample. + value := make([]byte, 30) if _, err := rand.Read(value); err != nil { return Sealed{}, err }