From 078d3f53bfd39e6e87f0b61a9d300460e0d68fa1 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 10:35:22 +0100 Subject: [PATCH] RR-1, RR-2: the seat-definition reader reads callers as text or list, class, keep as an object, step pipeline and on kind:image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text callers stay read in the release (G's own reader refuses them); both old and new keep forms are read; check stays beside pipeline. Builds: design 47 G §340, §409, §336, §338, §391, §396, §397, §203, §266, §324 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- go/seatdef/callers.go | 126 +++++++++++++++++++++++++++++++++++++ go/seatdef/seatdef.go | 60 ++++++++++++++---- go/seatdef/seatdef_test.go | 24 ++++++- 3 files changed, 197 insertions(+), 13 deletions(-) create mode 100644 go/seatdef/callers.go diff --git a/go/seatdef/callers.go b/go/seatdef/callers.go new file mode 100644 index 0000000..2ef7edd --- /dev/null +++ b/go/seatdef/callers.go @@ -0,0 +1,126 @@ +package seatdef + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" +) + +// Callers says who may call a verb or send an accepted kind. It is read as text (the form every definition on main +// has) or as a list (the form design 47 G §336 writes); the reader release carries both so that a definition in +// either form is read (design 47 G §340), and G's own reader refuses the text. +type Callers struct { + // Text is the callers in words, when the definition gave them so. + Text string + // List is the callers as a list of entries, when the definition gave them so. + List []string +} + +// IsText says whether the definition gave its callers in words. +func (c Callers) IsText() bool { return c.List == nil } + +// UnmarshalJSON reads a string or a list of strings. +func (c *Callers) UnmarshalJSON(raw []byte) error { + raw = bytes.TrimSpace(raw) + switch { + case len(raw) > 0 && raw[0] == '"': + return json.Unmarshal(raw, &c.Text) + case len(raw) > 0 && raw[0] == '[': + c.List = []string{} + return json.Unmarshal(raw, &c.List) + } + return errors.New("callers is a text or a list of entries") +} + +// MarshalJSON writes the form the definition was read in. +func (c Callers) MarshalJSON() ([]byte, error) { + if c.IsText() { + return json.Marshal(c.Text) + } + return json.Marshal(c.List) +} + +func checkCallers(where string, c *Callers) Problems { + if c == nil { + return nil + } + if c.IsText() { + if c.Text == "" { + return Problems{{where, "is empty"}} + } + return nil + } + var ps Problems + for i, e := range c.List { + if e == "" { + ps = append(ps, Problem{fmt.Sprintf("%s[%d]", where, i), "is empty"}) + } + } + return ps +} + +// The classes of a verb (design 47 G §391). +const ( + ClassRead = "read" + ClassAct = "act" + ClassWarrant = "warrant" +) + +// Classes are the classes a verb's `class` may name. +var Classes = []string{ClassRead, ClassAct, ClassWarrant} + +// Keep is an event's `keep`: the string of today's definitions (KeepLastPerSubject or KeepStream, with the message's +// max-age-days and max-mib beside it) or the object of design 47 G §396 (`max-age-days`, `max-mib`, +// `last-per-subject`). The reader release reads both (design 47 G §340, §409). +type Keep struct { + // Mode is the string form; empty when the object form was given. + Mode string + // MaxAgeDays, MaxMiB and LastPerSubject are the object form's fields; MaxAgeDays and MaxMiB are nil when unsaid. + MaxAgeDays *int + MaxMiB *int + LastPerSubject bool + // Object says the object form was given. + Object bool +} + +type keepObject struct { + MaxAgeDays *int `json:"max-age-days"` + MaxMiB *int `json:"max-mib"` + LastPerSubject bool `json:"last-per-subject"` +} + +// UnmarshalJSON reads the string or the object, the object strictly. +func (k *Keep) UnmarshalJSON(raw []byte) error { + raw = bytes.TrimSpace(raw) + if len(raw) > 0 && raw[0] == '"' { + return json.Unmarshal(raw, &k.Mode) + } + var o keepObject + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&o); err != nil { + return fmt.Errorf("keep is a string or an object of max-age-days, max-mib and last-per-subject: %w", err) + } + *k = Keep{MaxAgeDays: o.MaxAgeDays, MaxMiB: o.MaxMiB, LastPerSubject: o.LastPerSubject, Object: true} + return nil +} + +// MarshalJSON writes the form the definition was read in. +func (k Keep) MarshalJSON() ([]byte, error) { + if !k.Object { + return json.Marshal(k.Mode) + } + return json.Marshal(keepObject{k.MaxAgeDays, k.MaxMiB, k.LastPerSubject}) +} + +func checkKeepObject(where string, k Keep) Problems { + var ps Problems + if k.MaxAgeDays != nil && *k.MaxAgeDays < 0 { + ps = append(ps, Problem{where + ".max-age-days", "is negative (0 is no age limit)"}) + } + if k.MaxMiB != nil && *k.MaxMiB <= 0 { + ps = append(ps, Problem{where + ".max-mib", "is not above zero"}) + } + return ps +} diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index e77fbbb..b6ce53d 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -118,14 +118,19 @@ const ( StepCompose = "compose" StepGrant = "grant" StepPlace = "place" + // StepPipeline is the runner's step, in the place of StepCheck from G's delivery (design 47 G §203). + StepPipeline = "pipeline" ) // Steps are the steps an acting rule names, in a delivery's order. -var Steps = []string{StepTakeIn, StepValidate, StepCheck, StepBuild, StepCompose, StepGrant, StepPlace} +var Steps = []string{StepTakeIn, StepValidate, StepCheck, StepBuild, StepCompose, StepGrant, StepPlace, StepPipeline} // OnEvery is an acting rule's `on` for every module; an absent `on` reads as it. const OnEvery = "every" +// OnKindImage is an acting rule's `on` for the modules whose artifact entry's kind is image (design 47 G §324, §397). +const OnKindImage = "kind:image" + // 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. @@ -139,6 +144,8 @@ func DefaultBound(step string) time.Duration { return 20 * time.Minute case StepGrant, StepPlace: return 10 * time.Minute + case StepPipeline: + return 30 * time.Minute } return 0 } @@ -177,8 +184,8 @@ func checkActs(rs []ActingRule) Problems { 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.On != "" && r.On != OnEvery && r.On != OnKindImage && !wordName.MatchString(r.On) { + ps = append(ps, Problem{where + ".on", fmt.Sprintf("%q is neither every, kind:image nor a language's lower-case name", r.On)}) } if r.Bound != "" { if d, err := time.ParseDuration(r.Bound); err != nil || d <= 0 { @@ -204,8 +211,10 @@ type Verb struct { Replaces []string `json:"replaces,omitempty"` Input map[string]any `json:"input,omitempty"` Answer map[string]any `json:"answer,omitempty"` - // Callers says who may call it, in words: the list itself is the holder's trusted config value (design 51). - Callers string `json:"callers,omitempty"` + // Callers says who may call it, as a list (design 47 G §336) or, in the definitions before G, in words. + Callers *Callers `json:"callers,omitempty"` + // Class is the verb's class, ClassRead, ClassAct or ClassWarrant (design 47 G §391). + Class string `json:"class,omitempty"` } // Message is one accepted message or one event of the seat, its body a JSON Schema. @@ -227,7 +236,9 @@ type Message struct { // 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"` + Keep *Keep `json:"keep,omitempty"` + // Callers says who may send an accepted kind, as a verb's (design 47 G §338). + Callers *Callers `json:"callers,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. @@ -246,6 +257,14 @@ const ( // 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 } +// legacyMode is the string form of Keep, empty for none or for the object form. +func (m Message) legacyMode() string { + if m.Keep == nil { + return "" + } + return m.Keep.Mode +} + // 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 { @@ -761,6 +780,10 @@ func checkVerbs(vs []Verb, whole bool) Problems { ps = append(ps, Problem{where + ".name", fmt.Sprintf("%q twice", v.Name)}) } seen[v.Name] = true + ps = append(ps, checkCallers(where+".callers", v.Callers)...) + if v.Class != "" && !slices.Contains(Classes, v.Class) { + ps = append(ps, Problem{where + ".class", fmt.Sprintf("%q is not a class (%s)", v.Class, strings.Join(Classes, ", "))}) + } if whole && v.Input == nil { ps = append(ps, Problem{where + ".input", "missing: a whole definition gives every verb's input"}) } @@ -786,18 +809,31 @@ 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 + mode := m.legacyMode() switch { - case m.Keep != "" && field != "events": + case m.Keep != nil && field != "events": ps = append(ps, Problem{where + ".keep", "only an event is kept"}) - 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: + case m.Keep != nil && m.Keep.Object: + ps = append(ps, checkKeepObject(where+".keep", *m.Keep)...) + if m.MaxAgeDays != 0 || m.MaxMiB != 0 { + ps = append(ps, Problem{where + ".max-age-days", "an event whose keep is an object gives its max-age-days and max-mib there"}) + } + if m.Keep.LastPerSubject && 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 mode != "" && mode != KeepLastPerSubject && mode != KeepStream: + ps = append(ps, Problem{where + ".keep", fmt.Sprintf("%q is not %s or %s", mode, KeepLastPerSubject, KeepStream)}) + case mode == 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): + case mode == 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): + case mode != 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.Callers != nil && field != "accepts" { + ps = append(ps, Problem{where + ".callers", "only an accepted kind carries callers"}) + } + ps = append(ps, checkCallers(where+".callers", m.Callers)...) 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 548207e..74cc1fb 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -105,7 +105,7 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { {"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)`}, + `acts[0].step: "deploy" is not a step (take-in, validate, check, build, compose, grant, place, pipeline)`}, {"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), @@ -114,6 +114,27 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { "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"}, + // The reader release (design 47 G §340, §409): the forms G writes beside the ones on main. + {"callers as text", strings.Replace(whole, `"name":"list",`, `"name":"list","callers":"a person",`, 1), ""}, + {"callers as a list", strings.Replace(whole, `"name":"list",`, `"name":"list","callers":["operator","seat:mesh-grants","invokers"],"class":"read",`, 1), ""}, + {"callers as neither", strings.Replace(whole, `"name":"list",`, `"name":"list","callers":7,`, 1), "callers is a text or a list"}, + {"a list with an empty entry", strings.Replace(whole, `"name":"list",`, `"name":"list","callers":["operator",""],`, 1), "verbs[0].callers[1]: is empty"}, + {"a class that is none", strings.Replace(whole, `"name":"list",`, `"name":"list","class":"free",`, 1), `verbs[0].class: "free" is not a class (read, act, warrant)`}, + {"an accepted kind's callers", strings.Replace(whole, `"events":`, `"accepts":[{"name":"step","body":{},"callers":["seat:mesh-pipeline-runner"]}],"events":`, 1), ""}, + {"an event's callers", strings.Replace(whole, `"name":"created"`, `"name":"created","callers":["operator"]`, 1), "only an accepted kind carries callers"}, + {"keep as an object", strings.Replace(whole, `"name":"created"`, + `"name":"owed","keep":{"max-age-days":7,"max-mib":64}`, 1), ""}, + {"keep as an object with no age limit", strings.Replace(whole, `"name":"created"`, + `"name":"owed","keep":{"max-age-days":0,"last-per-subject":true},"subject":"owed."`, 1), ""}, + {"keep as an object, last per subject, one subject", strings.Replace(whole, `"name":"created"`, + `"name":"owed","keep":{"last-per-subject":true}`, 1), "give it a subject with parts"}, + {"keep as an object with a field it does not know", strings.Replace(whole, `"name":"created"`, + `"name":"owed","keep":{"days":7}`, 1), "keep is a string or an object"}, + {"keep as an object with a negative age", strings.Replace(whole, `"name":"created"`, + `"name":"owed","keep":{"max-age-days":-1}`, 1), "events[0].keep.max-age-days: is negative"}, + {"the step pipeline and an image rule", strings.Replace(listGrain, `"verbs":`, + `"acts":[{"step":"pipeline"},{"step":"build","on":"kind:image"},{"step":"check"}],"verbs":`, 1), ""}, + {"an acting rule on another kind", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"build","on":"kind:Image"}],"verbs":`, 1), "acts[0].on"}, {"over 256 KiB", listGrain[:len(listGrain)-1] + `,"description":"` + strings.Repeat("x", MaxSize) + `"}`, ErrTooLarge.Error()}, } @@ -230,6 +251,7 @@ func TestAnActingRuleReadsItsStepsDefaults(t *testing.T) { {ActingRule{Step: StepGrant, Bound: "5m"}, "go", true, "5m0s"}, {ActingRule{Step: StepPlace}, "go", true, "10m0s"}, {ActingRule{Step: StepTakeIn}, "go", true, "0s"}, + {ActingRule{Step: StepPipeline}, "go", true, "30m0s"}, } { 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)