From 3034867fb65c12699db40688f01d58772ba1ceac Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 9 Nov 2026 20:04:50 +0100 Subject: [PATCH 1/4] 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.., running.., 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 --- go/catalogue/delivery_seat_test.go | 17 ++++++ go/catalogue/seat_from_definition.go | 12 +++- go/seatdef/seatdef.go | 82 +++++++++++++++++++++++++++- go/seatdef/seatdef_test.go | 37 +++++++++++++ 4 files changed, 146 insertions(+), 2 deletions(-) diff --git a/go/catalogue/delivery_seat_test.go b/go/catalogue/delivery_seat_test.go index 046b93d..033ce24 100644 --- a/go/catalogue/delivery_seat_test.go +++ b/go/catalogue/delivery_seat_test.go @@ -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.."}, {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) + } + } +} diff --git a/go/catalogue/seat_from_definition.go b/go/catalogue/seat_from_definition.go index 317b35e..1acc52c 100644 --- a/go/catalogue/seat_from_definition.go +++ b/go/catalogue/seat_from_definition.go @@ -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 diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index cfa8e13..ddaa7c2 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -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..event.` when it carries parts (design 47 *What each step + // says*: `owed..`, `running..`, `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 `` 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"}) } diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index d679037..ded6e2b 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -52,6 +52,19 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { `"subject":"mesh.mod..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..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..asked"`, 1), ""}, + {"a subject whose words are not the name", strings.Replace(whole, `"name":"created"`, + `"name":"owed","subject":"owing."`, 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.."`, 1), "part twice"}, + {"a subject on an event named by its caller", strings.Replace(whole, `"name":"created"`, + `"name":"owed","by-caller":true,"subject":"owed."`, 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.."}, "running..", "node,module", "running.*.*"}, + {Message{Name: "milestone.asked", Subject: "milestone..asked"}, "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) + } + } +} -- 2.54.0 From 7d04e0df29437379b6d5810bf83ead40b641ccc9 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 9 Nov 2026 20:39:43 +0100 Subject: [PATCH 2/4] W2.7 (format): an event kept last per subject, in a stream of its own The node-engine's running.. is kept last per subject with no age limit, which the shared events stream cannot say; the reader takes keep last-per-subject. Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- go/seatdef/seatdef.go | 19 +++++++++++++++++++ go/seatdef/seatdef_test.go | 6 ++++++ 2 files changed, 25 insertions(+) diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index ddaa7c2..6a065c8 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -122,8 +122,19 @@ type Message struct { // still knows the event. Empty, the subject is the name. A part `` is the node that publishes it, which // the grants part grants a node's account alone (ADR 0363 §2). Subject string `json:"subject,omitempty"` + // Keep says how an event is kept when not as the family keeps the seat's others: KeepLastPerSubject, in a stream + // of its own named KeptStream, the last message of each subject and no age limit (design 47: the node-engine's + // running.., so the last build of every module on every node stays on the bus however long ago + // it was said, and a durable consumer starting from the last message per subject holds them all). + Keep string `json:"keep,omitempty"` } +// KeepLastPerSubject is an event kept last per subject with no age limit, in a stream of its own. +const KeepLastPerSubject = "last-per-subject" + +// KeptStream is the stream an event with Keep is kept in: `.`, as a family is `.events`. +func KeptStream(seat string, m Message) string { return seat + "." + m.Name } + // 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 { @@ -663,6 +674,14 @@ func checkMessages(field string, ms []Message, whole bool) Problems { ps = append(ps, Problem{where + ".name", fmt.Sprintf("%q twice", m.Name)}) } seen[m.Name] = true + switch { + case m.Keep != "" && field != "events": + ps = append(ps, Problem{where + ".keep", "only an event is kept"}) + case m.Keep != "" && m.Keep != KeepLastPerSubject: + ps = append(ps, Problem{where + ".keep", fmt.Sprintf("%q is not %s", m.Keep, KeepLastPerSubject)}) + case m.Keep != "" && len(m.Parts()) == 0 && !m.ByCaller: + ps = append(ps, Problem{where + ".keep", "an event of one subject kept last per subject is one message: give it a subject with parts"}) + } 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 != "" { diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index ded6e2b..c406521 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -63,6 +63,12 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { `"name":"running","subject":"running.."`, 1), "part twice"}, {"a subject on an event named by its caller", strings.Replace(whole, `"name":"created"`, `"name":"owed","by-caller":true,"subject":"owed."`, 1), "carries no subject of its own"}, + {"an event kept last per subject", strings.Replace(whole, `"name":"created"`, + `"name":"running","subject":"running..","keep":"last-per-subject"`, 1), ""}, + {"an event kept otherwise", strings.Replace(whole, `"name":"created"`, + `"name":"running","subject":"running.","keep":"forever"`, 1), `events[0].keep: "forever" is not last-per-subject`}, + {"an event of one subject kept last per subject", strings.Replace(whole, `"name":"created"`, + `"name":"running","keep":"last-per-subject"`, 1), "give it a subject with parts"}, {"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`}, -- 2.54.0 From eddb2d4bf6c22706ac5141b2984fb3a74560a12e Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 9 Nov 2026 20:22:47 +0100 Subject: [PATCH 3/4] W3.3 (format): an event may be kept in a stream of its own with an age and a cap The seat mesh-delivery's and mesh-delivery-checker's owed events are kept on each owner's stream owed, 7 days and at most 64 MiB (design 47, What the gate owes the forge); the format could only keep an event last per subject. Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- go/seatdef/seatdef.go | 33 ++++++++++++++++++++++++--------- go/seatdef/seatdef_test.go | 8 +++++++- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index 6a065c8..40de632 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -122,15 +122,26 @@ type Message struct { // still knows the event. Empty, the subject is the name. A part `` is the node that publishes it, which // the grants part grants a node's account alone (ADR 0363 §2). Subject string `json:"subject,omitempty"` - // Keep says how an event is kept when not as the family keeps the seat's others: KeepLastPerSubject, in a stream - // of its own named KeptStream, the last message of each subject and no age limit (design 47: the node-engine's - // running.., so the last build of every module on every node stays on the bus however long ago - // it was said, and a durable consumer starting from the last message per subject holds them all). + // Keep says how an event is kept when not as the family keeps the seat's others, in a stream of its own named + // KeptStream: KeepLastPerSubject, the last message of each subject and no age limit (design 47: the node-engine's + // running.., so the last build of every module on every node stays on the bus however long ago it + // was said, and a durable consumer starting from the last message per subject holds them all); or KeepStream, + // every message within MaxAgeDays and MaxMiB. Keep string `json:"keep,omitempty"` + // MaxAgeDays and MaxMiB bound an event kept KeepStream: how long a message is kept and how much the stream holds + // (design 47 *What the gate owes the forge*: the stream `owed`, 7 days and at most 64 MiB). Given with KeepStream + // alone. + MaxAgeDays int `json:"max-age-days,omitempty"` + MaxMiB int `json:"max-mib,omitempty"` } -// KeepLastPerSubject is an event kept last per subject with no age limit, in a stream of its own. -const KeepLastPerSubject = "last-per-subject" +// The ways an event is kept in a stream of its own, KeptStream, instead of its seat's family. +const ( + // KeepLastPerSubject keeps the last message of each subject, with no age limit. + KeepLastPerSubject = "last-per-subject" + // KeepStream keeps every message for MaxAgeDays, at most MaxMiB in all. + KeepStream = "stream" +) // KeptStream is the stream an event with Keep is kept in: `.`, as a family is `.events`. func KeptStream(seat string, m Message) string { return seat + "." + m.Name } @@ -677,10 +688,14 @@ func checkMessages(field string, ms []Message, whole bool) Problems { switch { case m.Keep != "" && field != "events": ps = append(ps, Problem{where + ".keep", "only an event is kept"}) - case m.Keep != "" && m.Keep != KeepLastPerSubject: - ps = append(ps, Problem{where + ".keep", fmt.Sprintf("%q is not %s", m.Keep, KeepLastPerSubject)}) - case m.Keep != "" && len(m.Parts()) == 0 && !m.ByCaller: + case m.Keep != "" && m.Keep != KeepLastPerSubject && m.Keep != KeepStream: + ps = append(ps, Problem{where + ".keep", fmt.Sprintf("%q is not %s or %s", m.Keep, KeepLastPerSubject, KeepStream)}) + case m.Keep == KeepLastPerSubject && len(m.Parts()) == 0 && !m.ByCaller: ps = append(ps, Problem{where + ".keep", "an event of one subject kept last per subject is one message: give it a subject with parts"}) + case m.Keep == KeepStream && (m.MaxAgeDays <= 0 || m.MaxMiB <= 0): + ps = append(ps, Problem{where + ".keep", "an event kept in a stream of its own gives its max-age-days and max-mib"}) + case m.Keep != KeepStream && (m.MaxAgeDays != 0 || m.MaxMiB != 0): + ps = append(ps, Problem{where + ".max-age-days", "only an event kept as a stream of its own gives max-age-days and max-mib"}) } if m.Subject != "" && field != "events" { ps = append(ps, Problem{where + ".subject", "only an event carries a subject of its own"}) diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index c406521..2cca1b5 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -66,7 +66,13 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { {"an event kept last per subject", strings.Replace(whole, `"name":"created"`, `"name":"running","subject":"running..","keep":"last-per-subject"`, 1), ""}, {"an event kept otherwise", strings.Replace(whole, `"name":"created"`, - `"name":"running","subject":"running.","keep":"forever"`, 1), `events[0].keep: "forever" is not last-per-subject`}, + `"name":"running","subject":"running.","keep":"forever"`, 1), `events[0].keep: "forever" is not last-per-subject or stream`}, + {"an event kept in a stream of its own, bounded", strings.Replace(whole, `"name":"created"`, + `"name":"owed","subject":"owed..","keep":"stream","max-age-days":7,"max-mib":64`, 1), ""}, + {"an event kept in a stream of its own, unbounded", strings.Replace(whole, `"name":"created"`, + `"name":"owed","subject":"owed..","keep":"stream","max-age-days":7`, 1), "gives its max-age-days and max-mib"}, + {"a bound on an event kept by its family", strings.Replace(whole, `"name":"created"`, + `"name":"owed","max-mib":64`, 1), "events[0].max-age-days: only an event kept as a stream"}, {"an event of one subject kept last per subject", strings.Replace(whole, `"name":"created"`, `"name":"running","keep":"last-per-subject"`, 1), "give it a subject with parts"}, {"an accept with dotted words", strings.Replace(whole, `"events":`, -- 2.54.0 From 9e61f75d3b46703998b255bc24c0ce9b9ad41d31 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 9 Nov 2026 20:36:25 +0100 Subject: [PATCH 4/4] W1.1: the seat-definition reader reads a seat's acting rules A definition carrying acts was refused as an unknown field by every reader (design 47, What ADR 0656 leaves to this design). seatdef reads acts at either grain: each rule's step one of the seven, on every or a language, a bound as a duration, one step on one language once; ActsOn and Wait give a rule's match and its bound, the step's default when it names none. W1.2: the node-engine copy follows mesh-contracts a6c5eaa6 (its compose rule). Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- go/seatdef/seatdef.go | 101 +++++++++++++++++++++++++++++++++++++ go/seatdef/seatdef_test.go | 40 +++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index 40de632..e77fbbb 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -20,6 +20,7 @@ import ( "slices" "sort" "strings" + "time" ) // MaxSize is the largest definition, or part, the mesh keeps (design 26: at most 256 KiB a definition). @@ -92,6 +93,105 @@ type Definition struct { Paths []Path `json:"paths,omitempty"` // Defs are named types a schema refers to as `{"$ref": "#/defs/"}`. Defs map[string]map[string]any `json:"defs,omitempty"` + // Acts are the seat's acting rules (novox/hq ADR 0656 §1; design 47 *What ADR 0656 leaves to this design*): each step + // of a delivery the seat's holder acts in, on which modules, and how long an entry waits there for the holder's new + // version. At either grain: a seat-list definition names its holder's step too. + Acts []ActingRule `json:"acts,omitempty"` +} + +// ActingRule is one acting rule of a seat: the step its holder acts in, the modules it acts on (every module, or those +// whose manifest says this language), and how long an entry waits in that step for the holder's new version, a +// duration such as "30m"; none is the step's default (DefaultBound). +type ActingRule struct { + Step string `json:"step"` + On string `json:"on,omitempty"` + Bound string `json:"bound,omitempty"` +} + +// The steps of a delivery a seat's holder acts in (design 47): what the indexer, the checker, the build modules, the +// node-engine, the grants part and the assigner each do for a change. +const ( + StepTakeIn = "take-in" + StepValidate = "validate" + StepCheck = "check" + StepBuild = "build" + StepCompose = "compose" + StepGrant = "grant" + StepPlace = "place" +) + +// Steps are the steps an acting rule names, in a delivery's order. +var Steps = []string{StepTakeIn, StepValidate, StepCheck, StepBuild, StepCompose, StepGrant, StepPlace} + +// OnEvery is an acting rule's `on` for every module; an absent `on` reads as it. +const OnEvery = "every" + +// DefaultBound is how long an entry waits in step for its acting part's new version when the rule names no bound +// (design 47 *An acting part's new version acts first*): 60 minutes to build, 30 to compose, 20 to check (the first +// tier's), 10 to grant and to place; the indexer's take-in and validate have no wait, so zero. +func DefaultBound(step string) time.Duration { + switch step { + case StepBuild: + return 60 * time.Minute + case StepCompose: + return 30 * time.Minute + case StepCheck: + return 20 * time.Minute + case StepGrant, StepPlace: + return 10 * time.Minute + } + return 0 +} + +// Acting is the modules the rule acts on: OnEvery, or a language. +func (r ActingRule) Acting() string { + if r.On == "" { + return OnEvery + } + return r.On +} + +// ActsOn says whether the rule acts on a module whose manifest says language. +func (r ActingRule) ActsOn(language string) bool { + on := r.Acting() + return on == OnEvery || strings.EqualFold(on, strings.TrimSpace(language)) +} + +// Wait is how long an entry waits in the rule's step for its acting part's new version: its bound, or the step's +// default. A bound the reader refused never reaches here; one that does not parse reads as the default. +func (r ActingRule) Wait() time.Duration { + if d, err := time.ParseDuration(r.Bound); err == nil && d > 0 { + return d + } + return DefaultBound(r.Step) +} + +func checkActs(rs []ActingRule) Problems { + var ps Problems + seen := map[string]bool{} + for i, r := range rs { + where := fmt.Sprintf("acts[%d]", i) + switch { + case r.Step == "": + ps = append(ps, Problem{where + ".step", "missing"}) + case !slices.Contains(Steps, r.Step): + ps = append(ps, Problem{where + ".step", fmt.Sprintf("%q is not a step (%s)", r.Step, strings.Join(Steps, ", "))}) + } + if r.On != "" && r.On != OnEvery && !wordName.MatchString(r.On) { + ps = append(ps, Problem{where + ".on", fmt.Sprintf("%q is neither every nor a language's lower-case name", r.On)}) + } + if r.Bound != "" { + if d, err := time.ParseDuration(r.Bound); err != nil || d <= 0 { + ps = append(ps, Problem{where + ".bound", fmt.Sprintf("%q is not a duration such as 30m", r.Bound)}) + } + } + if k := r.Step + " " + r.Acting(); r.Step != "" && seen[k] { + ps = append(ps, Problem{where, fmt.Sprintf("the step %s on %s twice", r.Step, r.Acting())}) + } else { + seen[k] = true + } + } + return ps } // Verb is one verb the seat serves: its input and, written whole, its answer, each a JSON Schema. @@ -554,6 +654,7 @@ func (d Definition) check() Problems { } } ps = append(ps, checkStates(d.States)...) + ps = append(ps, checkActs(d.Acts)...) for i, p := range d.Provisions { where := fmt.Sprintf("provisions[%d]", i) if p.Name == "" { diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index 2cca1b5..548207e 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -99,6 +99,21 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { "key-forms":[{"key":"","value":{}}]}`), "states[0].history"}, {"a state twice", withState(withState(whole, `{"name":"asks","template":"state","key-forms":[{"key":"","value":{}}]}`), `{"name":"asks","template":"log","key-forms":[{"key":"","value":{}}]}`), `"asks" twice`}, + // W1.1: a seat's acting rules (design 47 *What ADR 0656 leaves to this design*), at either grain. + {"acting rules", strings.Replace(whole, `"family":`, `"acts":[{"step":"take-in"},{"step":"validate","on":"every"},`+ + `{"step":"build","on":"go","bound":"60m"}],"family":`, 1), ""}, + {"an acting rule at the seat list's grain", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"compose","bound":"30m"}],"verbs":`, 1), ""}, + {"an acting rule with no step", strings.Replace(listGrain, `"verbs":`, `"acts":[{"on":"go"}],"verbs":`, 1), "acts[0].step: missing"}, + {"an acting rule of an unknown step", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"deploy"}],"verbs":`, 1), + `acts[0].step: "deploy" is not a step (take-in, validate, check, build, compose, grant, place)`}, + {"an acting rule on no language", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"build","on":"Go Lang"}],"verbs":`, 1), + "acts[0].on"}, + {"an acting rule's bound that is no duration", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"build","bound":"an hour"}],"verbs":`, 1), + `acts[0].bound: "an hour" is not a duration`}, + {"an acting rule's field the format does not know", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"build","when":"now"}],"verbs":`, 1), + "when: a field the format does not know"}, + {"one step on one language twice", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"grant"},{"step":"grant","on":"every"}],"verbs":`, 1), + "acts[1]: the step grant on every twice"}, {"over 256 KiB", listGrain[:len(listGrain)-1] + `,"description":"` + strings.Repeat("x", MaxSize) + `"}`, ErrTooLarge.Error()}, } @@ -199,3 +214,28 @@ func TestAnEventsSubjectNamesItsParts(t *testing.T) { } } } + +// W1.1: what an acting rule reads as where it says less (design 47 *An acting part's new version acts first*). +func TestAnActingRuleReadsItsStepsDefaults(t *testing.T) { + for _, c := range []struct { + rule ActingRule + language string + acts bool + wait string + }{ + {ActingRule{Step: StepBuild, On: "go"}, "go", true, "1h0m0s"}, + {ActingRule{Step: StepBuild, On: "go"}, "typescript", false, "1h0m0s"}, + {ActingRule{Step: StepCompose}, "anything", true, "30m0s"}, + {ActingRule{Step: StepCheck, On: OnEvery}, "", true, "20m0s"}, + {ActingRule{Step: StepGrant, Bound: "5m"}, "go", true, "5m0s"}, + {ActingRule{Step: StepPlace}, "go", true, "10m0s"}, + {ActingRule{Step: StepTakeIn}, "go", true, "0s"}, + } { + if got := c.rule.ActsOn(c.language); got != c.acts { + t.Errorf("%+v acts on %q: %v, want %v", c.rule, c.language, got, c.acts) + } + if got := c.rule.Wait().String(); got != c.wait { + t.Errorf("%+v waits %s, want %s", c.rule, got, c.wait) + } + } +} -- 2.54.0