W3.9 (format): a seat's event may carry dotted words and a subject with parts

The seat git's events (pull.updated, forge.status) and design 47's events with parts
(owed.<repository>.<number>, running.<node>.<module>, milestone.<milestone>.asked) could not be
written in a definition: an event's name was one word and its subject the name. An event now takes
dotted words and an optional subject whose words are its name with parts among them; the seat list
says it by its subject with * for each part, so a consumer naming it is heard as the seat's.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-09 20:39:37 +01:00
parent 0177432196
commit 3034867fb6
4 changed files with 146 additions and 2 deletions
+17
View File
@@ -4,6 +4,8 @@ import (
"reflect"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// The delivery seat answers "what did the mesh's checks say of this pull request?" as a verb of its own,
@@ -58,3 +60,18 @@ func TestTheDeliverySeatPromisesChecks(t *testing.T) {
t.Fatalf("a holder without %s: %v", before[0], err)
}
}
// An event whose subject carries parts is said by its subject with `*` for each part, so a consumer naming one
// instance or every instance of it is heard as the seat's (design 47 *What each step says*).
func TestAnEventWithPartsIsTheSeatsByItsSubject(t *testing.T) {
s := SeatFromDefinition(seatdef.Definition{Seat: "mesh-delivery", Scope: "mesh", Events: []seatdef.Message{
{Name: "owed", Subject: "owed.<repository>.<number>"}, {Name: "pull.updated"}}})
for _, c := range []struct {
event string
says bool
}{{"owed.*.*", true}, {"owed.novox-mesh-sdk.12", true}, {"owed", false}, {"pull.updated", true}, {"pull.merged", false}} {
if got := SeatSays(s.Emits, c.event); got != c.says {
t.Errorf("%s: says %v, want %v (emits %v)", c.event, got, c.says, s.Emits)
}
}
}
+11 -1
View File
@@ -12,7 +12,7 @@ import (
// contribution kind given as data gets its checks (withShapeCode), which are code and never a definition's.
func SeatFromDefinition(d seatdef.Definition) Seat {
s := Seat{Name: d.Seat, Scope: d.Scope, Replicated: d.Replicated, Delivers: d.Delivers,
Accepts: seatdef.MessageNames(d.Accepts), Emits: seatdef.MessageNames(d.Events), PulledByKind: d.PulledByKind,
Accepts: seatdef.MessageNames(d.Accepts), Emits: localEvents(d.Events), PulledByKind: d.PulledByKind,
Yields: d.Yields, HeldByTheEngine: d.HeldByTheEngine, Decision: d.Decision}
if len(s.Accepts) == 0 {
s.Accepts = nil
@@ -62,6 +62,16 @@ func SeatsFromDefinitions(defs []seatdef.Definition) []Seat {
return out
}
// localEvents are the events as a manifest names them: an event whose subject carries parts by its subject with `*`
// for each part (owed.*.*), so SeatSays matches a consumer's name against it.
func localEvents(es []seatdef.Message) []string {
out := make([]string, 0, len(es))
for _, e := range es {
out = append(out, e.Local())
}
return out
}
func examples(in []map[string]any) []map[string]any {
if in == nil {
return nil
+81 -1
View File
@@ -110,10 +110,81 @@ type Verb struct {
// Message is one accepted message or one event of the seat, its body a JSON Schema.
type Message struct {
// Name is the message's name; an event's may be lower-case words joined by dots (pull.updated), as a module's
// event keys are.
Name string `json:"name"`
Body map[string]any `json:"body,omitempty"`
// ByCaller says the subject ends in the name of who sent it (or, for an event, of who it is for).
ByCaller bool `json:"by-caller,omitempty"`
// Subject is an event's subject after `mesh.seat.<seat>.event.` when it carries parts (design 47 *What each step
// says*: `owed.<repository>.<number>`, `running.<node>.<module>`, `milestone.<milestone>.asked`): its tokens are
// the name's words in order with parts between angle brackets among them, so a reader that knows only the name
// still knows the event. Empty, the subject is the name. A part `<node>` is the node that publishes it, which
// the grants part grants a node's account alone (ADR 0363 §2).
Subject string `json:"subject,omitempty"`
}
// SubjectPattern is the event's subject after the seat's event prefix with its parts between angle brackets: the
// name itself when the definition gives no subject.
func (m Message) SubjectPattern() string {
if m.Subject == "" {
return m.Name
}
return m.Subject
}
// Parts are the names of the subject's parts, in order; none for a subject that is the name.
func (m Message) Parts() []string {
var out []string
for _, tok := range strings.Split(m.SubjectPattern(), ".") {
if keyPartOK.MatchString(tok) {
out = append(out, tok[1:len(tok)-1])
}
}
return out
}
// Local is the subject after the seat's event prefix with `*` for each part: what a manifest's `emits` and
// `consumes` name of the event, and the filter that hears every instance of it.
func (m Message) Local() string {
toks := strings.Split(m.SubjectPattern(), ".")
for i, tok := range toks {
if keyPartOK.MatchString(tok) {
toks[i] = "*"
}
}
return strings.Join(toks, ".")
}
// checkSubject says what is wrong with an event's subject, or "".
func checkSubject(m Message) string {
if m.Subject == "" {
return ""
}
if m.ByCaller {
return "an event named by its caller ends in the caller's name; it carries no subject of its own"
}
var words, parts []string
for _, tok := range strings.Split(m.Subject, ".") {
switch {
case keyPartOK.MatchString(tok):
if slices.Contains(parts, tok) {
return fmt.Sprintf("part %s twice", tok)
}
parts = append(parts, tok)
case wordName.MatchString(tok):
words = append(words, tok)
default:
return fmt.Sprintf("%q is neither a lower-case word nor a part between < and >", tok)
}
}
if len(parts) == 0 {
return "no part: an event whose subject is its name gives no subject"
}
if strings.Join(words, ".") != m.Name {
return fmt.Sprintf("its words are %q, not the event's name %q", strings.Join(words, "."), m.Name)
}
return ""
}
// HolderEvent is one of the holder's own events listed beside the seat.
@@ -582,12 +653,21 @@ func checkMessages(field string, ms []Message, whole bool) Problems {
seen := map[string]bool{}
for i, m := range ms {
where := fmt.Sprintf("%s[%d]", field, i)
if !wordName.MatchString(m.Name) {
named := wordName
if field == "events" {
named = eventName
}
if !named.MatchString(m.Name) {
ps = append(ps, Problem{where + ".name", "missing, or not a lower-case name"})
} else if seen[m.Name] {
ps = append(ps, Problem{where + ".name", fmt.Sprintf("%q twice", m.Name)})
}
seen[m.Name] = true
if m.Subject != "" && field != "events" {
ps = append(ps, Problem{where + ".subject", "only an event carries a subject of its own"})
} else if why := checkSubject(m); why != "" {
ps = append(ps, Problem{where + ".subject", why})
}
if whole && m.Body == nil {
ps = append(ps, Problem{where + ".body", "missing: a whole definition gives every message's body"})
}
+37
View File
@@ -52,6 +52,19 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) {
`"subject":"mesh.mod.<holder>.event.verdict","family":"checker.asks"}],"family":`, 1), "a family names the holder's own stream"},
{"a holder's event on a subject of no module", strings.Replace(whole, `"family":`, `"holder-events":[{"name":"verdict",`+
`"subject":"mesh.event.verdict"}],"family":`, 1), "holder-events[0].subject: not mesh.mod.<holder>.event.verdict"},
{"an event with dotted words", strings.Replace(whole, `"name":"created"`, `"name":"pull.updated"`, 1), ""},
{"an event whose subject carries parts", strings.Replace(whole, `"name":"created"`,
`"name":"milestone.asked","subject":"milestone.<milestone>.asked"`, 1), ""},
{"a subject whose words are not the name", strings.Replace(whole, `"name":"created"`,
`"name":"owed","subject":"owing.<repository>"`, 1), `its words are "owing", not the event's name "owed"`},
{"a subject with no part", strings.Replace(whole, `"name":"created"`, `"name":"owed","subject":"owed"`, 1),
"events[0].subject: no part"},
{"a subject's part twice", strings.Replace(whole, `"name":"created"`,
`"name":"running","subject":"running.<node>.<node>"`, 1), "part <node> twice"},
{"a subject on an event named by its caller", strings.Replace(whole, `"name":"created"`,
`"name":"owed","by-caller":true,"subject":"owed.<number>"`, 1), "carries no subject of its own"},
{"an accept with dotted words", strings.Replace(whole, `"events":`,
`"accepts":[{"name":"run.now","body":{}}],"events":`, 1), "accepts[0].name"},
{"a verb twice", strings.Replace(listGrain, `]}`, `,{"name":"add"}]}`, 1), `"add" twice`},
{"states at the seat list's grain", strings.Replace(listGrain, `"domain":"Core","decision":"ADR 1",`,
`"domain":"Core","decision":"ADR 1","states":[{"name":"asks"}],`, 1), "grain: seat-list holds"},
@@ -150,3 +163,27 @@ func withState(def, state string) string {
}
return strings.Replace(def, `"domain":"Core","decision":"ADR 1",`, `"domain":"Core","decision":"ADR 1","states":[`+state+`],`, 1)
}
// TestAnEventsSubjectNamesItsParts holds the three readings of an event's subject: its pattern, its parts in order and
// the local name a manifest's emits and consumes give it.
func TestAnEventsSubjectNamesItsParts(t *testing.T) {
for _, c := range []struct {
m Message
pattern, parts, local string
}{
{Message{Name: "created"}, "created", "", "created"},
{Message{Name: "pull.updated"}, "pull.updated", "", "pull.updated"},
{Message{Name: "running", Subject: "running.<node>.<module>"}, "running.<node>.<module>", "node,module", "running.*.*"},
{Message{Name: "milestone.asked", Subject: "milestone.<milestone>.asked"}, "milestone.<milestone>.asked", "milestone", "milestone.*.asked"},
} {
if got := c.m.SubjectPattern(); got != c.pattern {
t.Errorf("%s: pattern %q, want %q", c.m.Name, got, c.pattern)
}
if got := strings.Join(c.m.Parts(), ","); got != c.parts {
t.Errorf("%s: parts %q, want %q", c.m.Name, got, c.parts)
}
if got := c.m.Local(); got != c.local {
t.Errorf("%s: local %q, want %q", c.m.Name, got, c.local)
}
}
}