A-280: the grants part lists a module's finding-events stream in its membership

For a module that requires finding-events and is bound, the membership lists the stream its binding names, the subject
mesh.bus.<module>.finding-events.{event}.<node> 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
This commit is contained in:
2026-11-12 11:56:08 +01:00
parent af96eeba86
commit a59e40c02f
4 changed files with 59 additions and 1 deletions
@@ -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.
@@ -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.<module>.finding-events.{event}.<node>` 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
}
@@ -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
@@ -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
}