Judge object buckets and history streams as ADR 0592 decides

The indexer's judge takes a module's own object bucket (a plain local name,
once, no name its states take, none of the mesh's derived prefixes, 1 to 8192
MiB with no default, a grant only by a trusted value) and a stream that keeps
its history (no max age, deliveries or duplicates), counts such a stream among
the bus's predefined forms, gives a bucket's name one owner on the bus, and
refuses a read of a bucket its owner grants nobody. The seat table expects
the node-engine's compose-candidate verb (ADR 0593 §7; D5).

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 18:34:54 +01:00
parent 4c4380b94b
commit 525d7374b2
3 changed files with 208 additions and 6 deletions
@@ -0,0 +1,87 @@
package judge
import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// knowledge is mesh-knowledge's manifest as ADR 0592 has it: an object bucket granted by its trusted value readers (a
// trusted file takes it), and
// two streams that keep their history.
const knowledge = `{"module":"mesh-knowledge","version":"1",
"resources":[{"id":"state","type":"directory","mode":"0700","place":"."},
{"id":"readers","trusted":true,"type":"file","path":"${dir:state}/readers.json","mode":"0600",
"merge":"json","takes-settings":["readers"],"content":"{\"readers\": {}}"}],
"objects":[{"name":"sections","max-mib":2048,"grant":{"config":"readers"}}],
"streams":[{"name":"versions","retention":"history","max-mib":4096,"events":["version.stored"]},
{"name":"reviews","retention":"history","events":["review.stored"]}]}`
// ADR 0592 §5, §6, §25: a module's own object bucket and a stream that keeps its history are taken as declared; each
// bound and form the record names is refused, saying which.
func TestObjectBucketsAndHistoryStreamsAreJudgedAsADR0592Says(t *testing.T) {
if _, err := ParseManifest([]byte(knowledge)); err != nil {
t.Fatalf("mesh-knowledge's manifest was refused: %v", err)
}
for _, c := range []struct{ change, want string }{
{`"max-mib":2048,"grant"`, `"max-mib":0,"grant"`}, // no size: there is no default
{`"max-mib":2048,"grant"`, `"max-mib":8193,"grant"`}, // over the bound
{`"name":"sections"`, `"name":"Sections"`}, // not a plain local name
{`"name":"sections"`, `"name":"sections"},{"name":"sections","max-mib":1`}, // one bucket per name
{`"grant":{"config":"readers"}`, `"grant":{"config":"nobody"}`}, // a grant not by a trusted value
{`"retention":"history","max-mib":4096`, `"retention":"history","max-age-days":30`},
{`"retention":"history","max-mib":4096`, `"retention":"history","max-deliveries":3`},
{`"retention":"history","max-mib":4096`, `"retention":"history","duplicates":2`},
{`"retention":"history","max-mib":4096`, `"retention":"history","max-mib":8193`},
{`"retention":"history","max-mib":4096`, `"retention":"forever"`},
} {
raw := strings.Replace(knowledge, c.change, c.want, 1)
if raw == knowledge {
t.Fatalf("the change %q found nothing to change", c.change)
}
if _, err := ParseManifest([]byte(raw)); err == nil {
t.Errorf("%s was taken", c.want)
}
}
// A bucket whose name on the bus starts with what the mesh derives names with is refused.
if _, err := ParseManifest([]byte(`{"module":"kv","version":"1","objects":[{"name":"x","max-mib":1}]}`)); err == nil ||
!strings.Contains(err.Error(), "KV_") {
t.Errorf("a bucket kv_x was taken: %v", err)
}
}
// ADR 0592 §5, §9: a bucket name has one owner on the bus, and a module reads another's bucket only where the owner
// grants it; a history stream is one of the bus's predefined forms.
func TestAnObjectBucketIsReadOnlyThroughItsOwnersGrant(t *testing.T) {
parse := func(raw string) catalogue.Manifest {
t.Helper()
m, err := ParseManifest([]byte(raw))
if err != nil {
t.Fatal(err)
}
return m
}
owner := parse(knowledge)
reader := parse(`{"module":"records-page","version":"1","reads":["mesh-knowledge.sections"]}`)
shelf := catalogue.Shelf{"mesh-knowledge": owner, "records-page": reader}
if p := objectProblemsOf(CatalogueProblems(shelf)); len(p) > 0 {
t.Fatalf("a granted read was refused: %v", p)
}
ungranted := parse(strings.Replace(knowledge, `,"grant":{"config":"readers"}`, "", 1))
shelf["mesh-knowledge"] = ungranted
if p := objectProblemsOf(CatalogueProblems(shelf)); len(p) != 1 || !strings.Contains(p[0], "records-page") {
t.Errorf("a read of a bucket its owner grants nobody: %v", p)
}
}
// objectProblemsOf are the problems that name an object bucket or a history stream.
func objectProblemsOf(all []string) []string {
var out []string
for _, p := range all {
if strings.Contains(p, "object bucket") || strings.Contains(p, "history") {
out = append(out, p)
}
}
return out
}
+118 -3
View File
@@ -2623,6 +2623,8 @@ func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.M
problems = append(problems, LogProblems(m)...)
// And the streams it owns, and who may hear them (streams.go, novox/hq ADR 0363).
problems = append(problems, StreamProblems(m)...)
// And the object buckets it owns (novox/hq ADR 0592 §5, §9).
problems = append(problems, ObjectsProblems(m)...)
problems = append(problems, WorkQueueStreamProblems(m)...)
// And the defaults it gives its settings (setting_defaults.go, novox/hq ADR 0262), or, written in the
// config form, the whole declaration of each value (config.go, novox/hq ADR 0348).
@@ -4550,6 +4552,8 @@ func CatalogueProblems(shelf catalogue.Shelf) []string {
var problems []string
// A stream name has one owner (novox/hq ADR 0363).
problems = append(problems, StreamNameProblems(shelf)...)
// An object bucket's name has one owner, and another module reads it only through its owner's grant (ADR 0592 §5, §9).
problems = append(problems, ObjectsCatalogueProblems(shelf)...)
// A module that hears a restricted stream runs as an account of its own (novox/hq ADR 0376 §2).
problems = append(problems, RestrictedHearingProblems(shelf)...)
// A work-queue stream is never asked for by another module's consumes (novox/hq ADR 0436 §6).
@@ -5378,7 +5382,8 @@ func StateProblems(m catalogue.Manifest) []string {
func PredefinedFormProblems(m catalogue.Manifest) []string {
var out []string
for _, s := range m.Streams {
if !slices.Contains(catalogue.StreamsKeptUntilMoved[m.Module], s.Name) {
// A stream that keeps its history is one of the bus's predefined forms (novox/hq ADR 0592 §6).
if !s.KeepsHistory() && !slices.Contains(catalogue.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))
}
@@ -5489,10 +5494,18 @@ func StreamProblems(m catalogue.Manifest) []string {
// meaningless, and refused, without it. A work-queue stream is read by its one consumer, never granted.
switch s.Retention {
case "", catalogue.StreamRetentionWorkQueue:
case catalogue.StreamRetentionHistory:
// A stream that keeps its history (novox/hq ADR 0592 §6) keeps every message with no maximum age and
// refuses a new one when full: an age is meaningless on it, and refused.
if s.MaxAgeDays != 0 {
problems = append(problems, fmt.Sprintf(
"%s's stream %q keeps its history and declares max-age-days %d; a stream that keeps its history keeps "+
"every message with no maximum age (novox/hq ADR 0592 §6)", m.Module, s.Name, s.MaxAgeDays))
}
default:
problems = append(problems, fmt.Sprintf(
"%s's stream %q declares retention %q; a stream's retention is %q or nothing (novox/hq ADR 0436 §6)",
m.Module, s.Name, s.Retention, catalogue.StreamRetentionWorkQueue))
"%s's stream %q declares retention %q; a stream's retention is %q, %q or nothing (novox/hq ADR 0436 §6, "+
"ADR 0592 §6)", m.Module, s.Name, s.Retention, catalogue.StreamRetentionWorkQueue, catalogue.StreamRetentionHistory))
}
switch {
case s.Retention == catalogue.StreamRetentionWorkQueue &&
@@ -8774,3 +8787,105 @@ func second(_, b []string) []string { return b }
// reservedStreamPrefixes are the starts of the stream names the mesh derives, which no module's stream may take (moved
// from mesh-sdk's catalogue, ReservedStreamPrefixes: a rule's table is the judge's, novox/hq ADR 0578 §4).
var reservedStreamPrefixes = []string{"SEAT_", "LOG_", "KV_", "OBJ_", "DEAD_"}
// ObjectsProblems is what is wrong with a manifest's own object buckets, judged on the one manifest (novox/hq ADR 0592
// §5, §9, §25): each named locally as a state is, once, never a name its own state takes, its name on the bus taking
// none of the prefixes the mesh derives names with, its size between catalogue.ObjectsLeastMiB and ObjectsMostMiB (no
// default), and a grant only by a trusted config value of the module.
func ObjectsProblems(m catalogue.Manifest) []string {
if len(m.Objects) == 0 {
return nil
}
var problems []string
if !stateName.MatchString(m.Module) {
problems = append(problems, fmt.Sprintf(
"%s keeps an object bucket, and a module's name is part of its buckets' names, which take one plain name — "+
"no dot (novox/hq ADR 0592 §5)", m.Module))
}
seen := map[string]bool{}
states := map[string]bool{}
for _, st := range m.State {
states[st.Name] = true
}
terminal := catalogue.TerminalKeys(m)
for _, o := range m.Objects {
switch {
case !stateName.MatchString(o.Name):
problems = append(problems, fmt.Sprintf(
"%s keeps object bucket %q: an object bucket is named locally — lower-case letters, digits and hyphens, "+
"no dot and no underscore; the mesh derives the bucket (novox/hq ADR 0592 §5)", m.Module, o.Name))
case seen[o.Name]:
problems = append(problems, fmt.Sprintf("%s keeps object bucket %q twice: one bucket per name (novox/hq ADR 0592 §5)", m.Module, o.Name))
case states[o.Name]:
problems = append(problems, fmt.Sprintf(
"%s keeps both a state and an object bucket %q: a read names one of them, so each name is one (novox/hq "+
"ADR 0592 §9)", m.Module, o.Name))
default:
for _, prefix := range reservedStreamPrefixes {
if strings.HasPrefix(strings.ToUpper(o.Bucket(m.Module)), prefix) {
problems = append(problems, fmt.Sprintf(
"%s keeps object bucket %q, whose name on the bus %s starts with %s, which the mesh derives names "+
"with (novox/hq ADR 0592 §5)", m.Module, o.Name, o.Bucket(m.Module), prefix))
}
}
}
seen[o.Name] = true
if o.MaxMiB < catalogue.ObjectsLeastMiB || o.MaxMiB > catalogue.ObjectsMostMiB {
problems = append(problems, fmt.Sprintf(
"%s caps object bucket %q at %d MiB; an object bucket says its size, between %d and %d MiB (novox/hq ADR "+
"0592 §5, §25)", m.Module, o.Name, o.MaxMiB, catalogue.ObjectsLeastMiB, catalogue.ObjectsMostMiB))
}
if g := o.Grant; g != nil && !slices.Contains(terminal, g.Config) {
problems = append(problems, fmt.Sprintf(
"%s's object bucket %q is granted by %q, which is not a trusted config value of the module, set only at "+
"the controller's terminal or on the operator's Approve: a grant of what a module reads is the "+
"operator's (novox/hq ADR 0348 §8, ADR 0592 §9)", m.Module, o.Name, g.Config))
}
}
return problems
}
// ObjectsCatalogueProblems are the rules of object buckets no single manifest can be judged against (novox/hq ADR 0592
// §5, §9): a bucket's name on the bus has one owner, never another module's stream or bucket; and a module that reads
// another's bucket (`reads` naming it) reads it only through the owner's grant, a bucket with none being read by its
// owner alone. Which modules the grant's value names is the operator's, set at the controller's terminal, and checked
// where the read is granted, not here.
func ObjectsCatalogueProblems(shelf catalogue.Shelf) []string {
var problems []string
owner := map[string]string{} // a name on the bus, upper-cased, to the module declaring it
for _, module := range shelfOrder(shelf) {
for _, s := range shelf[module].Streams {
if _, taken := owner[strings.ToUpper(s.BusName())]; !taken {
owner[strings.ToUpper(s.BusName())] = module
}
}
}
for _, module := range shelfOrder(shelf) {
for _, o := range shelf[module].Objects {
name := strings.ToUpper(o.Bucket(module))
if first, taken := owner[name]; taken && first != module {
problems = append(problems, fmt.Sprintf(
"%s keeps object bucket %s, a name %s already takes on the bus; an object bucket's name has one owner "+
"(novox/hq ADR 0592 §5)", module, o.Bucket(module), first))
continue
}
owner[name] = module
}
}
for _, module := range shelfOrder(shelf) {
for _, r := range shelf[module].Reads {
from, local, err := catalogue.ReadState(r)
if err != nil {
continue // StateProblems says why
}
o, ok := shelf[from].ObjectsNamed(local)
if !ok || from == module || o.Grant != nil {
continue
}
problems = append(problems, fmt.Sprintf(
"%s reads %s's object bucket %q, which grants no reader: an object bucket is read by another module only "+
"through its owner's grant, a trusted config value naming the reader (novox/hq ADR 0592 §9)", module, from, local))
}
}
return problems
}
@@ -5012,8 +5012,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
t.Errorf("the health seat %s is %v, %+v", c.seat, ok, s)
}
}
// The node seat node-engine is the node's engine's (novox/hq ADR 0563 §1): at node scope, delivering nothing, its six
// verbs and three events, held by no assignment, and a module's claim of it refused.
// The node seat node-engine is the node's engine's (novox/hq ADR 0563 §1): at node scope, delivering nothing, its verbs
// (compose-candidate since ADR 0593 §7) and three events, held by no assignment, and a module's claim of it refused.
if engine, ok := catalogue.SeatNamed(catalogue.EngineSeat); !ok || engine.Scope != catalogue.ScopeNode || engine.Delivers != "" || !engine.HeldByTheEngine {
t.Errorf("node-engine is %+v, not a node seat delivering nothing that the engine holds", engine)
} else {
@@ -5025,7 +5025,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
if props, _ := engine.Serves[5].Input["properties"].(map[string]any); props["id"] == nil {
t.Errorf("acts takes no id: %v", engine.Serves[5].Input)
}
if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages,composition,compose" ||
if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages,composition,compose,compose-candidate" ||
strings.Join(engine.Emits, ",") != "asked,done,refused" {
t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits)
}