E-4: a misspelt event name is refused, not waited on for ever (issue 1966)

Before it subscribes, mesh_wait checks each name's emitter against the catalog
half the tool runner holds (its seats' definitions and its modules' entries),
refusing one neither knows and naming the half and its revisions; a known
emitter's undeclared event is waited on and its timed-out answer says so; while
the half is not yet read the answer says the names were unchecked. The first
views answer the halves by branch for it.

Builds: design 34 every-event §14, §19 (rows of the table of refusals)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 05:11:52 +01:00
parent 662ab2a3b1
commit 0b0f879e86
4 changed files with 166 additions and 2 deletions
+109 -1
View File
@@ -17,7 +17,9 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// WaitDefault is how long a wait lasts when no timeout is given, and WaitLongest the most it may last
@@ -75,12 +77,29 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin
return failure(fmt.Sprintf("mesh_wait names at most %d events in one call, not %d: each name is six "+
"subscriptions for the call (design 34 every-event §13)", waitMostNames, len(names)))
}
var subjects []string
known, unchecked := s.emitters()
var subjects, undeclared []string
for _, name := range names {
subs, err := buscompose.EventSubjects(name, from)
if err != nil {
return failure("mesh_wait may not wait on it: " + err.Error())
}
// A misspelt emitter is refused, never waited on for ever; a known emitter's undeclared event is waited on and
// its timed-out answer says so (design 34 every-event §14).
if unchecked == "" {
emitter, event, _ := strings.Cut(name, ".")
seatEvents, isSeat := known.seats[emitter]
moduleEvents, isModule := known.modules[emitter]
isSeat = isSeat && from != "module" && from != "stream"
isModule = isModule && from != "seat"
if !isSeat && !isModule {
return failure(fmt.Sprintf("mesh_wait may not wait on %q: %s is neither a seat nor a module of %s",
name, emitter, known.source))
}
if !(isSeat && declares(seatEvents, event)) && !(isModule && declares(moduleEvents, event)) {
undeclared = append(undeclared, name)
}
}
for _, subject := range subs {
if !slices.Contains(subjects, subject) {
subjects = append(subjects, subject)
@@ -96,6 +115,10 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin
if !ok {
answer = map[string]any{"timed-out": true, "waited": timeout.String(), "subjects": names,
"read": "nothing matching was heard; read the state once from the verb that owns it, then wait again"}
if len(undeclared) > 0 {
answer["undeclared"] = fmt.Sprintf("no declaration names %s: no seat definition and no module's entry in %s "+
"declares that event", strings.Join(undeclared, ", "), known.source)
}
} else {
name, from, _ := buscompose.EventName(heard.Subject)
h := map[string]any{"subject": name, "from": from, "body": heard.Body, "at": heard.At.Format(time.RFC3339Nano)}
@@ -109,6 +132,9 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin
}
answer = map[string]any{"heard": h, "read": read}
}
if unchecked != "" {
answer["unchecked"] = "the names were not checked: " + unchecked
}
// A subscription the bus refused while others stood is said (design 34 every-event §16).
if len(refused) > 0 {
answer["refused"] = refused
@@ -116,6 +142,88 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin
return answerText(answer)
}
// halfReader is a Source that also holds the catalog half of each followed branch (firstview.Views.Halves): mesh_wait
// checks an event name's emitter against it (design 34 every-event §14).
type halfReader interface {
Halves() (map[string]meshcatalogclient.Half, error)
}
// knownEmitters is what the catalog half says emits what: each seat's declared events and each module's, and the
// source a refusal names, with its revisions.
type knownEmitters struct {
seats, modules map[string][]string
source string
}
// emitters is the catalog half's emitters, or why they are not checked: the half not yet read (its first view,
// mesh_first_views), or no half held at all.
func (s *Surface) emitters() (known knownEmitters, unchecked string) {
hr, ok := s.src.(halfReader)
if !ok {
return known, "this tool runner holds no catalog half"
}
halves, err := hr.Halves()
if err != nil {
return known, "the catalog half is not yet read (" + err.Error() + "); its first view is in mesh_first_views"
}
seatEvents := map[string][]string{} // a seat's events, as its definition in the seat list declares them
for _, st := range catalogue.Seats() {
seatEvents[st.Name] = st.Emits
}
known.seats, known.modules = map[string][]string{}, map[string][]string{}
branches := make([]string, 0, len(halves))
for b := range halves {
branches = append(branches, b)
}
slices.Sort(branches)
var revisions []string
for _, b := range branches {
half := halves[b]
revisions = append(revisions, fmt.Sprintf("revision %d of %s", half.Revision.Revision, b))
for _, d := range half.Revision.Definitions {
known.seats[d.Seat] = seatEvents[d.Seat]
}
for m, entry := range half.Entries {
if _, taken := known.modules[m]; !taken {
known.modules[m] = eventsDeclared(entry.Manifest)
}
}
}
known.source = "the catalog half (mesh-catalog.revision, " + strings.Join(revisions, "; ") + ")"
return known, ""
}
// eventsDeclared is the events a module's manifest declares: its `emits` and its streams' `events`.
func eventsDeclared(manifest map[string]any) []string {
var out []string
add := func(list any) {
items, _ := list.([]any)
for _, x := range items {
if e, ok := x.(string); ok {
out = append(out, e)
}
}
}
add(manifest["emits"])
streams, _ := manifest["streams"].([]any)
for _, st := range streams {
if m, ok := st.(map[string]any); ok {
add(m["events"])
}
}
return out
}
// declares says whether one of the declared events is the event a name gives, or one its name is followed by.
func declares(declared []string, event string) bool {
for _, d := range declared {
if buscompose.Overlaps(d, event) || buscompose.Overlaps(d, event+".>") {
return true
}
}
return false
}
// waitNames is the event names a call gives: a list of names, or one name.
func waitNames(given any) ([]string, error) {
var names []string