From a59e40c02f4e73aa01405373854ae93e46da172e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 11:56:08 +0100 Subject: [PATCH] A-280: the grants part lists a module's finding-events stream in its membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a module that requires finding-events and is bound, the membership lists the stream its binding names, the subject mesh.bus..finding-events.{event}. and the finding events, so the tool runner carrying a stdio module (the enrolment) says an emitted finding event there; the test holds the enrolment's membership. Builds: design 47 G A-280, A-274, §529 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../internal/buscompose/buscompose_test.go | 17 ++++++++++++ .../internal/buscompose/membership.go | 27 ++++++++++++++++++- .../mesh-grants/internal/buscompose/users.go | 3 +++ .../mesh-grants/internal/grants/records.go | 13 +++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index a708010c..66b265d2 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -22,6 +22,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/announce" sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/membership" "git.novox.be/novox/mesh-sdk/go/seatdef" "github.com/nats-io/nats.go/jetstream" ) @@ -916,6 +917,22 @@ func healthChecks(t *testing.T) { MayPublish(perms, c.subject), c.may) } } + // Lead answer A-280 (A-274, §529): the enrolment's membership lists its finding-events stream under its binding's subject + // with the machine last, and the finding events, so the tool runner carrying it says them there and is granted them. + enrolment := Declared{Module: "mesh-enrolment", FindingEvents: []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, + Bindings: []membership.BusBinding{{Local: FindingEventsLocal, Storage: catalogue.StorageStream, Template: catalogue.TemplateEvents, + Stream: "mesh-enrolment_finding-events", Subject: "mesh.bus.mesh-enrolment.finding-events"}}} + if got, want := MembershipFor("a", enrolment, Assignments{}).Streams, []StreamIssued{{Stream: "mesh-enrolment_finding-events", + Events: []string{"finding-written", "finding-cleared", "findings-stated"}, Subject: "mesh.bus.mesh-enrolment.finding-events.{event}.a"}}; !reflect.DeepEqual(got, want) { + t.Errorf("the enrolment's membership streams are %+v, want %+v", got, want) + } + if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Carries: []Declared{enrolment}}); err != nil || + !MayPublish(perms, "mesh.bus.mesh-enrolment.finding-events.finding-written.a") { + t.Errorf("the tool runner carrying the enrolment may not say its finding-written under its binding (%v)", err) + } + if got := MembershipFor("a", Declared{Module: "mesh-enrolment", FindingEvents: enrolment.FindingEvents}, Assignments{}).Streams; len(got) != 0 { + t.Errorf("a finding-events entry not yet bound is listed: %+v", got) + } // ADR 0618 §4: the node-engine says each of its seat's events with its own machine as the last token, and a watcher hears // that event from every machine, its consumer filtered the same way; a flat event of a node seat a module holds is // heard flat. One rule (machineEvent) places both sides. diff --git a/modules/mesh-grants/internal/buscompose/membership.go b/modules/mesh-grants/internal/buscompose/membership.go index cc6a3ef0..4d4ec27c 100644 --- a/modules/mesh-grants/internal/buscompose/membership.go +++ b/modules/mesh-grants/internal/buscompose/membership.go @@ -4,6 +4,8 @@ import ( "slices" "sort" "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // What the mesh issues an assignment to serve and to reach (novox/hq ADR 0160). @@ -62,7 +64,7 @@ func MembershipFor(node string, d Declared, where Assignments) Membership { // both, so the composed side and the pass's agree. m.Bindings = d.Bindings m.Logs = append(logsIssuedFor(d), readLogsIssuedFor(withReadLogs(d, where.Logs))...) - m.Streams = streamsIssuedFor(d) + m.Streams = append(streamsIssuedFor(d), findingEventsIssued(d, node)...) m.Hears = HeardIssuedFor(node, d) t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches) for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) { @@ -178,3 +180,26 @@ func kindListed(list []KindHeld, k KindHeld) bool { } return false } + +// findingEventsIssued is the module's `finding-events` stream as its membership lists it (design 47 G §529, A-274, lead +// answer A-280): the stream its binding names, its subject `mesh.bus..finding-events.{event}.` and the +// finding events, so the tool runner says an emitted finding event there as on the first stream that carries it. None +// before the bus module bound it. +func findingEventsIssued(d Declared, node string) []StreamIssued { + if len(d.FindingEvents) == 0 || !safeSubject.MatchString(node) { + return nil + } + for _, b := range d.Bindings { + if b.Local != FindingEventsLocal || b.Storage != catalogue.StorageStream || b.Stream == "" || + b.Subject != "mesh.bus."+d.Module+"."+FindingEventsLocal { + continue + } + events := make([]string, 0, len(d.FindingEvents)) + for _, e := range d.FindingEvents { + // The machine is the subject's last token, so an event is named without it. + events = append(events, strings.TrimSuffix(e, ".*")) + } + return []StreamIssued{{Stream: b.Stream, Events: events, Subject: b.Subject + ".{event}." + node}} + } + return nil +} diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index e7b9dc93..43994e79 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -122,6 +122,9 @@ type Declared struct { // part from the bus module's `created`: the module's own account and the tool runner carrying it are granted each // entry's subjects (boundOf, issue 1966), in the same pass that writes the binding into its membership. Bindings []membership.BusBinding + // FindingEvents are the events its `finding-events` entry carries (an `events` template it requires, design 47 G §529), + // as its manifest names them: the membership lists that stream under its binding's subject (lead answer A-280). + FindingEvents []string } // ProcessPrincipal is one process of a module that declares bus (novox/hq ADR 0436 §1, §2), as the broker diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 4a769e44..7b3a184c 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -174,6 +174,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio Objects: objectsOf(m), // The ledgers it asks for in `requires` (issue 1966 D5): each assignment gets a ledger account. Ledgers: ledgersOf(m), + // The events of its finding stream asked for in `requires` (lead answer A-280). + FindingEvents: findingEventsOf(m), } d.Loads = loadsABundle(m) // Its marked tools (design 46 approval mark §1, §6): a name its `needs-approval` gives that it serves as a tool. @@ -712,3 +714,14 @@ func catchUpsOf(m catalogue.Manifest) []string { } return tools } + +// findingEventsOf are the events of m's `finding-events` entry, an `events` template it requires (design 47 G §529; lead +// answer A-280), none where it asks for none. +func findingEventsOf(m catalogue.Manifest) []string { + for _, e := range m.BusState { + if e.Name == buscompose.FindingEventsLocal && e.Storage == catalogue.StorageStream && e.Template == catalogue.TemplateEvents { + return append([]string(nil), e.Events...) + } + } + return nil +}