Merge pull request 'Reader release 3 before G: today's manifest validator accepts G's forms' (#212) from feat/47-G-reader-release-3 into main

This commit was merged in pull request #212.
This commit is contained in:
2026-11-13 11:04:19 +00:00
6 changed files with 112 additions and 10 deletions
@@ -49,6 +49,9 @@ var busTemplates = map[string]busTemplate{
catalogue.OptionTimeToLive: {kind: optionInt, least: 1, most: 315360000},
catalogue.OptionLifetimePerKey: {kind: optionFlag},
catalogue.OptionPerMachine: {kind: optionFlag},
// The cap of the state's own objects store `<state>-objects`, for a state needing more than the cap planned for it
// (design 47 G §513, §547).
catalogue.OptionObjectsCapMiB: {kind: optionInt, least: catalogue.ObjectsLeastMiB, most: catalogue.ObjectsMostMiB},
},
},
catalogue.TemplateEvents: {
@@ -52,6 +52,10 @@ func TestBusStateIsJudgedOnce(t *testing.T) {
{"template on another storage type", manifest(`{"name":"x","storage":"stream","template":"state"}`), []string{"\"x\"", "template state is on the storage type key-value"}},
{"past values 0", manifest(`{"name":"x","storage":"key-value","template":"state","options":{"history":0}}`), []string{"\"x\"", "options \"history\" is 0", "0 is refused"}},
{"past values 65", manifest(`{"name":"x","storage":"key-value","template":"state","options":{"history":65}}`), []string{"\"x\"", "\"history\" is 65", "1 to 64"}},
// Design 47 G §513, §547: a state needing more for its objects store names objects-max-mib, within the objects' bounds.
{"an objects cap on a state", manifest(`{"name":"x","storage":"key-value","template":"state","options":{"objects-max-mib":64}}`), nil},
{"an objects cap of 0", manifest(`{"name":"x","storage":"key-value","template":"state","options":{"objects-max-mib":0}}`), []string{"\"x\"", "\"objects-max-mib\" is 0"}},
{"an objects cap over 8192", manifest(`{"name":"x","storage":"key-value","template":"state","options":{"objects-max-mib":8193}}`), []string{"\"x\"", "\"objects-max-mib\" is 8193"}},
{"a lifetime that is no number", manifest(`{"name":"x","storage":"key-value","template":"state","options":{"ttl-seconds":"soon"}}`), []string{"\"x\"", "\"ttl-seconds\""}},
{"events age over 30 days", manifest(`{"name":"x","storage":"stream","template":"events","options":{"events":["seen"],"max-age-days":31}}`), []string{"\"x\"", "\"max-age-days\" is 31", "1 to 30"}},
{"events cap over 8192", manifest(`{"name":"x","storage":"stream","template":"events","options":{"events":["seen"],"max-mib":8193}}`), []string{"\"x\"", "\"max-mib\" is 8193"}},
@@ -116,7 +116,17 @@ func TestTheJudge(t *testing.T) {
"user": {Raw: json.RawMessage(`{"module":"user","version":"1","state":["x"]}`)},
"late": {Raw: json.RawMessage(`{"module":"late","version":"1","logs":[{"name":"l"}]}`)},
"newer": {Raw: json.RawMessage(`{"module":"newer","version":"1","state":["y"]}`)},
"mesh-grants": {Raw: json.RawMessage(`{"module":"mesh-grants","version":"1","logs":[{"name":"l"}]}`)}}},
"mesh-grants": {Raw: json.RawMessage(`{"module":"mesh-grants","version":"1","logs":[{"name":"l"}]}`)},
// Design 47 G §584, §627, A-345: the parts G puts on the foundation list keep their old sections before it, the
// old objects section, the stream `<module>-findings` and bus state in `requires` among them; another module's
// old objects section, which no trunk module uses, is refused as before.
"gitea": {Raw: json.RawMessage(`{"module":"gitea","version":"1","objects":[{"name":"incoming","max-mib":64}],` +
`"requires":[{"name":"idle","storage":"key-value","template":"state"}]}`)},
"mesh-host": {Raw: json.RawMessage(`{"module":"mesh-host","version":"1","streams":[` +
`{"name":"mesh-host-findings","events":["finding-written.*"],"max-age-days":7,"max-mib":16}]}`)},
"mesh-delivery-pipeline": {Raw: json.RawMessage(`{"module":"mesh-delivery-pipeline","version":"1",` +
`"objects":[{"name":"step-outputs","max-mib":64}]}`)}, // and the pipeline runner (§529, A-349)
"keeper": {Raw: json.RawMessage(`{"module":"keeper","version":"1","objects":[{"name":"incoming","max-mib":64}]}`)}}},
{Name: "trunk", Manifests: map[string]composer.Manifest{
"user": {Raw: json.RawMessage(`{"module":"user","version":"1","state":["x"]}`)},
"mesh-grants": {Raw: json.RawMessage(`{"module":"mesh-grants","version":"1","logs":[{"name":"l"}]}`)}}}}}
@@ -126,7 +136,8 @@ func TestTheJudge(t *testing.T) {
}
got := ov.Cases[0].Manifests
if got["late"].Accepted || !strings.Contains(got["late"].Refused, "late uses the old section logs") ||
!got["newer"].Accepted || !got["user"].Accepted || !got["mesh-grants"].Accepted {
!got["newer"].Accepted || !got["user"].Accepted || !got["mesh-grants"].Accepted || !got["gitea"].Accepted ||
!got["mesh-host"].Accepted || !got["mesh-delivery-pipeline"].Accepted || got["keeper"].Accepted || !strings.Contains(got["keeper"].Refused, "keeper uses the old section objects") {
t.Errorf("the change's old sections: %+v", got)
}
r.Cases[0].Manifests["c"] = composer.Manifest{Digest: strings.Repeat("a", 64)}
@@ -23,6 +23,12 @@ var OldSections = []string{"state", "logs", "streams", "objects"}
var Foundation = []string{"nats", "mesh-grants", "mesh-delivery-indexer", "mesh-delivery-assigner", "node-tools",
"mesh-delivery-builder", "mesh-delivery-checker", "mesh-config", "mesh-vault"}
// foundationAtG are the parts design 47 G puts on the foundation list in its own change: the forge module and the image
// registry module (G §584), the node-engine's module (§627) and the pipeline runner (§529, A-349). Until then their old
// sections are accepted as a foundation part's, its old `objects` section and its stream `<module>-findings` among them,
// and their bus state in `requires` as today (A-343, A-345); they are counted as today, so no other module's verdict moves.
var foundationAtG = []string{"gitea", "distribution", "mesh-host", "mesh-delivery-pipeline"}
// ViewOwners are the two owners whose state every reader reads through their first views and read tools, never by a
// `reads` entry (novox/hq ADR 0615 §1): CountOwnerReads counts what is left of those entries.
var ViewOwners = []string{"mesh-delivery-indexer", "mesh-delivery-assigner"}
@@ -90,7 +96,7 @@ func OldSectionProblems(m catalogue.Manifest, counts OldSectionCounts) []string
}
return nil
}
if counts == nil {
if counts == nil || slices.Contains(foundationAtG, m.Module) {
return nil
}
var problems []string
+53 -5
View File
@@ -4034,6 +4034,14 @@ func ProcessBusProblems(m catalogue.Manifest) []string {
m.Module, id, e, m.Module))
}
}
// Design 47 G A-340: a process hears only what its module consumes, as V5 lets it name only what its module asks for.
for _, e := range b.Consumes {
if !slices.Contains(m.Consumes, e) {
problems = append(problems, fmt.Sprintf(
"%s's process %s's bus consumes %q, which %s does not consume (design 47 G A-340)",
m.Module, id, e, m.Module))
}
}
for _, q := range b.Queues {
if !streams[q] {
problems = append(problems, fmt.Sprintf(
@@ -4067,12 +4075,24 @@ func filepathDir(p string) string {
}
// invokeNameValid says a module or process's invoked entry names a tool: <module>.<tool>, a role's
// seat:<seat>.<verb>, or * for every tool on the mesh (novox/hq ADR 0152, ADR 0436 §1).
// seat:<seat>.<verb> or a kind the seat accepts (seat:<seat>.<kind>, seat:<seat>.<stream>.<kind>, design 47 G A-102),
// or * for every tool on the mesh (novox/hq ADR 0152, ADR 0436 §1).
func invokeNameValid(t string) bool {
if t == "*" {
return true
}
t = strings.TrimPrefix(t, "seat:")
if rest, isSeat := strings.CutPrefix(t, "seat:"); isSeat {
// A seat's verb, or a kind the seat accepts: `<kind>`, or `<stream>.<kind>` for one that rides a stream (design 47 G
// A-102), an asker's queue `accept.<queue>` among them (§601, A-231); seatInvokeProblems judges it against the seat.
seat, after, named := strings.Cut(rest, ".")
if !named || !name.MatchString(seat) {
return false
}
if stream, kind, onStream := strings.Cut(after, "."); onStream {
return toolName.MatchString(stream) && toolName.MatchString(kind)
}
return toolName.MatchString(after)
}
first, second, named := strings.Cut(t, ".")
return named && name.MatchString(first) && toolName.MatchString(second)
}
@@ -4586,7 +4606,12 @@ func seatInvokeProblems(shelf catalogue.Shelf, declared map[string]catalogue.Sea
problems = append(problems, fmt.Sprintf("%s invokes %s, a verb of the seat %q, "+catalogue.UndeclaredSeat, module, invoke, seat))
continue
}
if !slices.ContainsFunc(verbs, func(v catalogue.Verb) bool { return v.Name == verb }) {
// An asker's queue of a build seat, `seat:<seat>.accept.<queue>` (design 47 G §544, §601, A-231), read by mesh-sdk's
// one reader: judged against the kinds the seat's definition accepts.
if _, queue, isQueue := catalogue.QueueInvoke(invoke); isQueue {
verb = queue
}
if !slices.ContainsFunc(verbs, func(v catalogue.Verb) bool { return v.Name == verb }) && !acceptsKind(seat, verb, declared) {
problems = append(problems, fmt.Sprintf("%s invokes %s, and the seat %s serves no verb %q", module, invoke, seat, verb))
continue
}
@@ -4598,6 +4623,15 @@ func seatInvokeProblems(shelf catalogue.Shelf, declared map[string]catalogue.Sea
return problems
}
// acceptsKind says the seat accepts the kind an invoke names after its seat (design 47 G A-102): `<kind>`, or
// `<stream>.<kind>` for a kind that rides a stream. A module's own seat names its accepts by kind alone.
func acceptsKind(seat, kind string, declared map[string]catalogue.SeatDeclaration) bool {
if s, isMesh := catalogue.SeatNamed(seat); isMesh {
return slices.Contains(s.Accepts, kind) || slices.Contains(s.AcceptedOn, kind)
}
return slices.Contains(declared[seat].Accepts, kind)
}
// SeatPathProblems is what is wrong with the paths a set of the mesh's own seats names (novox/hq ADR
// 0287): only a node seat names one, each absolute and literal; every path has exactly one seat naming it
// without stepping back; and a seat steps back only for another node seat naming the same path without
@@ -5618,6 +5652,19 @@ func StateProblems(m catalogue.Manifest) []string {
}
}
for _, r := range m.Reads {
if seat, state, isSeat, err := catalogue.SeatRead(r); isSeat {
// A read of a seat holder's state (design 47 G A-116, A-128): the seat declares the state; the holder is resolved
// where it is read.
switch s, known := catalogue.SeatNamed(seat); {
case err != nil:
problems = append(problems, fmt.Sprintf("%s reads %v", m.Module, err))
case !known:
problems = append(problems, fmt.Sprintf("%s reads %q, a state of the seat %q, "+catalogue.UndeclaredSeat, m.Module, r, seat))
case !slices.Contains(s.States, state):
problems = append(problems, fmt.Sprintf("%s reads %q, and the seat %s declares no state %q (design 47 G A-128)", m.Module, r, seat, state))
}
continue
}
module, _, err := catalogue.ReadState(r)
if err != nil {
problems = append(problems, fmt.Sprintf("%s reads %v", m.Module, err))
@@ -5654,8 +5701,9 @@ func PredefinedFormProblems(m catalogue.Manifest) []string {
for _, s := range m.Streams {
// A stream that keeps its history is one of the bus's predefined forms (novox/hq ADR 0592 §6).
// A foundation part keeps its old sections for good, its streams among them: its findings stream
// `<module>-findings` (design 47 G §529, A-30, A-146).
if !s.KeepsHistory() && !slices.Contains(Foundation, m.Module) && !slices.Contains(streamsKeptUntilMoved[m.Module], s.Name) {
// `<module>-findings` (design 47 G §529, A-30, A-146); so do the parts G puts on the list (A-345).
foundation := slices.Contains(Foundation, m.Module) || slices.Contains(foundationAtG, m.Module)
if !s.KeepsHistory() && !foundation && !slices.Contains(streamsKeptUntilMoved[m.Module], s.Name) {
out = append(out, fmt.Sprintf("%s keeps the stream %q, outside the bus's predefined forms: keep it as a state, "+
"a log or an object store, which the runtime reads for the module's tools and others read by `reads`", m.Module, s.Name))
}
@@ -2459,7 +2459,7 @@ func caseAProcessBusNamesOnlyTheModulesOwn(t *testing.T) {
// its events stream and a state of its key-value state, and a name it does not ask for there is refused as before.
asked := func(bus string) string {
raw := mailSenderProcessManifest(t, bus)
return strings.Replace(raw, `"resources"`, `"requires":[{"name":"mail-events","storage":"stream","template":"events",`+
return strings.Replace(raw, `"resources"`, `"consumes":["mesh-conditions.findings-asked"],"requires":[{"name":"mail-events","storage":"stream","template":"events",`+
`"options":{"events":["mail.send.queued"]}},{"name":"mail-kept","storage":"key-value","template":"state"}],"resources"`, 1)
}
for name, c := range map[string]struct{ bus, says string }{
@@ -2467,6 +2467,9 @@ func caseAProcessBusNamesOnlyTheModulesOwn(t *testing.T) {
"a state asked for by template": {`{"state":["mail-kept"]}`, ""},
"an event the events stream does not hold": {`{"emits":["mail.send.lost"]}`, "mail.send.lost"},
"a state the module does not ask for": {`{"state":["mail-other"]}`, "mail-other"},
// Design 47 G A-340: a process's bus may name, under consumes, an event its module consumes, judged as V5 judges.
"an event the module consumes": {`{"consumes":["mesh-conditions.findings-asked"]}`, ""},
"an event the module does not consume": {`{"consumes":["mesh-conditions.other"]}`, `consumes "mesh-conditions.other"`},
} {
_, err := ParseManifest([]byte(asked(c.bus)))
if (c.says == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.says)) {
@@ -2947,6 +2950,11 @@ func caseAStateNameIsLocalAndOneToken(t *testing.T) {
{`{"module":"a.b","version":"1","state":["s"]}`, `no dot`},
{`{"module":"a","version":"1","reads":["bindings"]}`, `a read is <module>.<name>`},
{`{"module":"a","version":"1","reads":["a.s"]}`, `which is its own state`},
// A read of a seat holder's state names the seat, which declares the state (design 47 G A-116, A-128).
{`{"module":"a","version":"1","reads":["seat:mesh-conditions.findings"]}`, ``},
{`{"module":"a","version":"1","reads":["seat:mesh-conditions.nothing"]}`, `the seat mesh-conditions declares no state "nothing"`},
{`{"module":"a","version":"1","reads":["seat:mesh-unknown.findings"]}`, `a state of the seat "mesh-unknown"`},
{`{"module":"a","version":"1","reads":["seat:mesh-conditions"]}`, `does not name a seat and its state`},
{`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds, per-machine, key-ttl, read-only, max-mib, form, catch-up}`},
} {
_, err := ParseManifest([]byte(c.manifest))
@@ -5218,6 +5226,11 @@ func caseTwoModulesCannotDeclareOneStream(t *testing.T) {
if got := PredefinedFormProblems(catalogue.Manifest{Module: "mesh-delivery-indexer", Streams: findings}); len(got) != 0 {
t.Fatalf("a foundation part's findings stream was refused: %v", got)
}
// So does a part G puts on the list, before it (design 47 G §627, A-345).
hostFindings := []catalogue.StreamDeclaration{{Name: "mesh-host-findings", Events: []string{"finding-written.*"}}}
if got := PredefinedFormProblems(catalogue.Manifest{Module: "mesh-host", Streams: hostFindings}); len(got) != 0 {
t.Fatalf("the node-engine's findings stream was refused: %v", got)
}
}
// A seat name meaning two protocols is the failure nobody could diagnose afterwards.
@@ -5240,6 +5253,11 @@ func caseTwoModulesCannotDeclareTheSameSeat(t *testing.T) {
func caseUsingASeatNobodyDeclaresIsRefused(t *testing.T) {
unclaimed := telegram()
unclaimed.Claims = nil
// A mesh seat that accepts a kind riding a stream (design 47 G A-102), beside the seat list's own.
was := catalogue.Seats()
t.Cleanup(func() { catalogue.UseSeats(was) })
catalogue.UseSeats(append(catalogue.Seats(), catalogue.Seat{Name: "mesh-steps", Scope: catalogue.ScopeMesh, Decision: "test",
Accepts: []string{"step"}, AcceptedOn: []string{"check.step"}}))
for _, row := range []struct {
name, want string
shop catalogue.Manifest
@@ -5268,8 +5286,20 @@ func caseUsingASeatNobodyDeclaresIsRefused(t *testing.T) {
{"what the engine reads of a manifest, and its event (ADR 0618 §3, §4)", "",
catalogue.Manifest{Module: "shop", Invokes: []string{"seat:node-engine.manifest-reads"},
Consumes: []string{"node-engine.manifest-reads-changed"}}, telegram(), false},
// An asker names the queue it asks as `seat:<seat>.accept.<queue>`, checked against the kinds the seat's definition
// accepts (design 47 G §601, A-231); a kind the seat accepts, alone or on its stream, as `seat:<seat>.<kind>` or
// `seat:<seat>.<stream>.<kind>` (A-102).
{"a queue a build seat accepts", "", catalogue.Manifest{Module: "shop", Invokes: []string{"seat:node-build-go.accept.check"}}, telegram(), false},
{"a queue a build seat does not accept", `the seat node-build-go serves no verb "other"`,
catalogue.Manifest{Module: "shop", Invokes: []string{"seat:node-build-go.accept.other"}}, telegram(), false},
{"a queue a declared seat accepts", "", catalogue.Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.accept.send"}}, telegram(), false},
{"a kind a seat accepts", "", catalogue.Manifest{Module: "shop", Invokes: []string{"seat:mesh-steps.step"}}, telegram(), false},
{"a kind a seat accepts on its stream", "", catalogue.Manifest{Module: "shop", Invokes: []string{"seat:mesh-steps.check.step"}}, telegram(), false},
{"a kind on a stream the seat does not take it on", `the seat mesh-steps serves no verb "delivery.step"`,
catalogue.Manifest{Module: "shop", Invokes: []string{"seat:mesh-steps.delivery.step"}}, telegram(), false},
} {
got := CatalogueProblems(catalogue.Shelf{"telegram": row.seat, "shop": row.shop})
// The manifest's own invokes rule (at parse) takes the dotted kind as well; the seat's rule then judges it.
got := append(invokeProblems(row.shop), CatalogueProblems(catalogue.Shelf{"telegram": row.seat, "shop": row.shop})...)
joined := strings.Join(got, "; ")
if row.want == "" && joined != "" || row.want != "" && (len(got) != 1 || !strings.Contains(joined, row.want) ||
catalogue.IsUndeclaredSeat(got[0]) != row.note) {