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

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
This commit is contained in:
2026-11-11 10:35:22 +01:00
parent 9db65adb81
commit 078d3f53bf
3 changed files with 197 additions and 13 deletions
+126
View File
@@ -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
}
+48 -12
View File
@@ -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.<node>.<module>, 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: `<seat>.<event>`, as a family is `<seat>.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 != "" {
+23 -1
View File
@@ -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.<repository>"`, 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)