From 27c3b9a860a39bb523c8985d9cb721f3fb49d625 Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 13 Nov 2026 00:21:07 +0100 Subject: [PATCH 1/6] V1 judge-if-config: a resource's if-config names a declared config value and places it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 42 zfs-owns-zfs §2; answer A-24 --- modules/mesh-delivery-indexer/judge/rules.go | 56 ++++++++++++++++++- .../mesh-delivery-indexer/judge/rules_test.go | 32 +++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index 8caf1284..12cf6863 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -813,10 +813,60 @@ func TypedSettingProblems(m catalogue.Manifest) []string { return problems } -// configPlaced is every value a file, a unit, a directory's owner, a contribution or a served fact asks for, and -// every value a mergeable file takes. +// ifConfigKeys is the keys a resource's `if-config` names, each `` or `=` by its key, and what is +// not a list of strings (design 42 zfs-owns-zfs §2). +func ifConfigKeys(r map[string]any) (keys []string, malformed bool) { + raw, said := r[catalogue.IfConfigField] + if !said { + return nil, false + } + list, ok := raw.([]any) + if !ok { + return nil, true + } + for _, e := range list { + s, ok := e.(string) + if !ok { + malformed = true + continue + } + key, _, _ := strings.Cut(s, "=") + keys = append(keys, key) + } + return keys, malformed +} + +// IfConfigProblems are the resources whose `if-config` names a key the module does not declare under `config`, on a +// resource of any type (design 42 zfs-owns-zfs §2, answer A-24). +func IfConfigProblems(m catalogue.Manifest) []string { + var out []string + for _, r := range m.Resources { + keys, malformed := ifConfigKeys(r) + if malformed { + out = append(out, fmt.Sprintf("%s's resource %v says %s as something but a list of and = "+ + "strings", m.Module, r["id"], catalogue.IfConfigField)) + } + for _, key := range keys { + if _, declared := m.Settings[key]; !declared { + out = append(out, fmt.Sprintf("%s's resource %v says %s with %q, and the module declares no such value "+ + "under %s: declare it, or name a declared one (design 42 zfs-owns-zfs §2)", m.Module, r["id"], + catalogue.IfConfigField, key, catalogue.ConfigField)) + } + } + } + return out +} + +// configPlaced is every value a file, a unit, a directory's owner, a contribution or a served fact asks for, every +// value a mergeable file takes, and every value a resource is declared under (`if-config`). func configPlaced(m catalogue.Manifest) map[string]bool { placed := settingKeysUsedBy(m) + for _, r := range m.Resources { + keys, _ := ifConfigKeys(r) + for _, k := range keys { + placed[k] = true + } + } for _, r := range m.Resources { for _, k := range takenKeyList(r) { placed[k] = true @@ -2783,6 +2833,8 @@ func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.M problems = append(problems, SettingProblems(m)...) problems = append(problems, TypedSettingProblems(m)...) } + // And the config values its resources are declared under (design 42 zfs-owns-zfs §2). + problems = append(problems, IfConfigProblems(m)...) // And what it requires optionally, and the files it delivers only while bound (novox/hq ADR 0330). problems = append(problems, OptionalProblems(m)...) // And which setting keys each mergeable file takes (setting_takes.go, novox/hq ADR 0326). diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index 2632de4b..da7661c9 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -3532,6 +3532,37 @@ func caseConfigStillRefusesAValueNoItemPlaces(t *testing.T) { refused(t, m, `"k"`, "placed nowhere") } +// Design 42 zfs-owns-zfs §2, V1/AC1 to AC3 (A-24): `if-config` names a declared value, on a resource of any type, and +// places it. +func caseIfConfigNamesADeclaredValueAndPlacesIt(t *testing.T) { + m := inConfigForm() + resourceOf(m, "notifier")["if-config"] = []any{"nowhere"} + refused(t, m, "notifier", `resource notifier`, "if-config", `"nowhere"`) + m = inConfigForm() + resourceOf(m, "rules")["if-config"] = []any{"theme=dark", "nowhere=on"} + refused(t, m, `"nowhere"`) + m = inConfigForm() + resourceOf(m, "rules")["if-config"] = "theme" + refused(t, m, "if-config", "list") + // and = of a declared key, on a file, a service and a directory. + m = inConfigForm() + resourceOf(m, "configuration")["if-config"] = []any{"width", "theme=dark"} + resourceOf(m, "notifier")["if-config"] = []any{"position=top-left"} + m["resources"] = append(m["resources"].([]any), map[string]any{"id": "cache", "type": "directory", "path": "/x/c", + "mode": "0755", "if-config": []any{"theme"}}) + if _, err := parseRaw(t, m); err != nil { + t.Fatal(err) + } + // A value placed only by if-config is not placed nowhere. + m = inConfigForm() + m["config"].(map[string]any)["zed"] = map[string]any{"type": "string", "description": "whether the daemon runs", "default": "off"} + refused(t, m, `"zed"`, "placed nowhere") + resourceOf(m, "notifier")["if-config"] = []any{"zed=on"} + if _, err := parseRaw(t, m); err != nil { + t.Fatal(err) + } +} + // Consumer data said to live in a provision the module requires is protected only as well as that // provision's provider protects its consumers' data. func caseConsumerDataInARequiredProvisionIsJudgedAgainstItsProvider(t *testing.T) { @@ -6126,6 +6157,7 @@ func TestRulesConfig(t *testing.T) { t.Run("ConfigRefusesAValueWithNeitherDefaultNorRequired", caseConfigRefusesAValueWithNeitherDefaultNorRequired) t.Run("ConfigRefusesAValueWithoutTypeOrDescription", caseConfigRefusesAValueWithoutTypeOrDescription) t.Run("ConfigStillRefusesAValueNoItemPlaces", caseConfigStillRefusesAValueNoItemPlaces) + t.Run("IfConfigNamesADeclaredValueAndPlacesIt", caseIfConfigNamesADeclaredValueAndPlacesIt) } // TestRulesClaims runs the tests of one kind as subtests, each by its former name. -- 2.54.0 From ff76ee95ad7b84ebec0602f28ff6f9575af5e488 Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 13 Nov 2026 00:22:05 +0100 Subject: [PATCH 2/6] V2 judge-config-lines: the form ${config-lines::[:mib-bytes]} is judged and places its key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 42 zfs-owns-zfs §3; answers A-4, A-5, A-24 --- modules/mesh-delivery-indexer/judge/rules.go | 76 +++++++++++++++++++ .../mesh-delivery-indexer/judge/rules_test.go | 32 ++++++++ 2 files changed, 108 insertions(+) diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index 12cf6863..b79f12c5 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -872,6 +872,9 @@ func configPlaced(m catalogue.Manifest) map[string]bool { placed[k] = true } } + for _, u := range configLinesUses(m) { + placed[u.key] = true + } return placed } @@ -2835,6 +2838,7 @@ func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.M } // And the config values its resources are declared under (design 42 zfs-owns-zfs §2). problems = append(problems, IfConfigProblems(m)...) + problems = append(problems, ConfigLinesProblems(m)...) // And what it requires optionally, and the files it delivers only while bound (novox/hq ADR 0330). problems = append(problems, OptionalProblems(m)...) // And which setting keys each mergeable file takes (setting_takes.go, novox/hq ADR 0326). @@ -7306,6 +7310,10 @@ var fillers = []filler{ {namespace: "port", patterns: []*regexp.Regexp{ofPort}, fields: map[string][]string{"file": {"content"}, "container": {"env"}, "process": {"env"}}}, {namespace: "seat", patterns: []*regexp.Regexp{ofSeat, ofSeatReach}, fields: map[string][]string{"file": {"content"}, "container": {"env"}, "process": {"env"}}}, {namespace: "machine", patterns: []*regexp.Regexp{ofMachine}, fields: map[string][]string{"*": {"path", "owner", "content", "name", "user", "root", "home"}}}, + // The module's own config values written into a file (design 42 zfs-owns-zfs §3, §9), filled by the node-engine + // and judged by ConfigLinesProblems. + {namespace: "config-lines", patterns: []*regexp.Regexp{ofConfigLines}, fields: map[string][]string{"file": {"content"}}}, + {namespace: "config-json", patterns: []*regexp.Regexp{ofConfigJSON}, fields: map[string][]string{"file": {"content"}}}, // Placed in a file's content by their holders, and judged there by their own rules // (placeholderProblems, seatPlaceholderProblems). {namespace: "environment", patterns: []*regexp.Regexp{ofEnvironment}, fields: map[string][]string{"*": {"content"}}}, @@ -7348,6 +7356,74 @@ func whereFilled(f filler) string { var ofKnownNamespace = regexp.MustCompile(`(?im)\$\{(?:` + strings.Join(namespacesOf(fillers), "|") + `):(?:[^-=+?}\n][^}\n]*\}?|\}|$)`) +// ofConfigLines is a `${config-lines::}` or `${config-lines:::mib-bytes}` whose prefix is +// literal text with no `}`, `$`, newline or `:` (design 42 zfs-owns-zfs §3, answers A-4, A-5). +var ofConfigLines = regexp.MustCompile(`\$\{config-lines:[a-z0-9][a-z0-9_.-]*:[^}$\n:]*(?::mib-bytes)?\}`) + +// ofConfigJSON is the module's config values as one JSON object (design 42 zfs-owns-zfs §9, answer A-15). +var ofConfigJSON = regexp.MustCompile(`\$\{config-json\}`) + +var configLinesToken = regexp.MustCompile(`\$\{config-lines:([^}]*)\}`) + +// configLinesUse is one `${config-lines:…}` of a file's content, split into its parts. +type configLinesUse struct { + resource any + key string + prefix string + extra []string // the parts after the prefix + parts int +} + +// configLinesUses is every `${config-lines:…}` in the content of the module's files, to the first `}`. +func configLinesUses(m catalogue.Manifest) []configLinesUse { + var out []configLinesUse + for _, r := range m.Resources { + content, _ := r["content"].(string) + if fmt.Sprint(r["type"]) != "file" { + continue + } + for _, t := range configLinesToken.FindAllStringSubmatch(content, -1) { + parts := strings.Split(t[1], ":") + u := configLinesUse{resource: r["id"], key: parts[0], parts: len(parts)} + if len(parts) > 1 { + u.prefix = parts[1] + u.extra = parts[2:] + } + out = append(out, u) + } + } + return out +} + +// ConfigLinesProblems are the `${config-lines::[:mib-bytes]}` a file says wrongly: a prefix holding `$`, a +// newline or `:`, a fourth part other than `mib-bytes`, a key the module does not declare, and `mib-bytes` on a value +// that is not a number (design 42 zfs-owns-zfs §3; answers A-4, A-5). A `}` ends the token where it stands. +func ConfigLinesProblems(m catalogue.Manifest) []string { + var out []string + for _, u := range configLinesUses(m) { + say := func(format string, args ...any) { + out = append(out, fmt.Sprintf("%s's resource %v says ${config-lines:%s…}: ", m.Module, u.resource, u.key)+ + fmt.Sprintf(format, args...)+" (design 42 zfs-owns-zfs §3)") + } + d, declared := m.Settings[u.key] + if !declared { + say("the module declares no value %q under %s", u.key, catalogue.ConfigField) + } + switch { + case u.parts < 2: + say("it has no prefix: ${config-lines::}") + continue + case strings.ContainsAny(u.prefix, "$\n"): + say("the prefix is literal text with no }, $ or newline") + case len(u.extra) > 1 || len(u.extra) == 1 && u.extra[0] != "mib-bytes": + say("the prefix holds no ':', and the only fourth part is mib-bytes") + case len(u.extra) == 1 && declared && d.Type != catalogue.ConfigNumber: + say("mib-bytes is for a number, and %q is a %s", u.key, d.Type) + } + } + return out +} + var namespaceShaped = regexp.MustCompile(`\$\{[a-z][a-z0-9_-]*:[A-Za-z0-9][^\s{}$]*\}`) func namespacesOf(fs []filler) []string { diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index da7661c9..e920e46e 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -3563,6 +3563,37 @@ func caseIfConfigNamesADeclaredValueAndPlacesIt(t *testing.T) { } } +// Design 42 zfs-owns-zfs §3, V2/AC1 to AC3 (A-4, A-5, A-24): `${config-lines::[:mib-bytes]}`. +func caseConfigLinesNamesADeclaredValueAndAPlainPrefix(t *testing.T) { + withLines := func(content string) map[string]any { + m := inConfigForm() + m["config"].(map[string]any)["opts"] = map[string]any{"type": "list", "trusted": true, "description": "options, one per line", "default": []any{}} + m["resources"] = append(m["resources"].([]any), map[string]any{"id": "options", "type": "file", "path": "/x/o.conf", "content": content}) + return m + } + // A prefix holding $, a newline or ':' is refused, naming the key; so is a fourth part but mib-bytes. + for _, c := range []string{"${config-lines:opts:a$b}", "${config-lines:opts:a\nb}", "${config-lines:opts:a:b}", + "${config-lines:opts:a:mib-bytes:x}", "${config-lines:opts}"} { + refused(t, withLines(c), "${config-lines:opts", "design 42 zfs-owns-zfs §3") + } + // Accepted, and the key is placed (opts is placed by nothing else). + for _, c := range []string{"${config-lines:opts:options zfs x=}", "${config-lines:opts:}"} { + if _, err := parseRaw(t, withLines(c)); err != nil { + t.Fatalf("%s: %v", c, err) + } + } + if _, err := parseRaw(t, withLines("# none\n")); err == nil || !strings.Contains(err.Error(), "placed nowhere") { + t.Fatalf("a list value placed nowhere was accepted: %v", err) + } + // mib-bytes is for a number; a key not declared is refused. + m := withLines("${config-lines:opts:o=}\n${config-lines:width:w=:mib-bytes}") + if _, err := parseRaw(t, m); err != nil { + t.Fatal(err) + } + refused(t, withLines("${config-lines:opts:o=:mib-bytes}"), "mib-bytes is for a number", `"opts" is a list`) + refused(t, withLines("${config-lines:nowhere:o=}"), `no value "nowhere"`) +} + // Consumer data said to live in a provision the module requires is protected only as well as that // provision's provider protects its consumers' data. func caseConsumerDataInARequiredProvisionIsJudgedAgainstItsProvider(t *testing.T) { @@ -6158,6 +6189,7 @@ func TestRulesConfig(t *testing.T) { t.Run("ConfigRefusesAValueWithoutTypeOrDescription", caseConfigRefusesAValueWithoutTypeOrDescription) t.Run("ConfigStillRefusesAValueNoItemPlaces", caseConfigStillRefusesAValueNoItemPlaces) t.Run("IfConfigNamesADeclaredValueAndPlacesIt", caseIfConfigNamesADeclaredValueAndPlacesIt) + t.Run("ConfigLinesNamesADeclaredValueAndAPlainPrefix", caseConfigLinesNamesADeclaredValueAndAPlainPrefix) } // TestRulesClaims runs the tests of one kind as subtests, each by its former name. -- 2.54.0 From e9eac06e5d9e5b978cfd684989eb0fd0ce6676ea Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 13 Nov 2026 00:22:19 +0100 Subject: [PATCH 3/6] V3 judge-config-json: a file holding ${config-json} places every config value of its module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 42 zfs-owns-zfs §9; answers A-15, A-24 --- modules/mesh-delivery-indexer/judge/rules.go | 8 ++++++++ .../mesh-delivery-indexer/judge/rules_test.go | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index b79f12c5..5aae9322 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -875,6 +875,14 @@ func configPlaced(m catalogue.Manifest) map[string]bool { for _, u := range configLinesUses(m) { placed[u.key] = true } + // A file holding ${config-json} writes every config value of the module (design 42 zfs-owns-zfs §9, A-15). + for _, r := range m.Resources { + if content, _ := r["content"].(string); fmt.Sprint(r["type"]) == "file" && ofConfigJSON.MatchString(content) { + for key := range m.Settings { + placed[key] = true + } + } + } return placed } diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index e920e46e..77e31198 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -3594,6 +3594,25 @@ func caseConfigLinesNamesADeclaredValueAndAPlainPrefix(t *testing.T) { refused(t, withLines("${config-lines:nowhere:o=}"), `no value "nowhere"`) } +// Design 42 zfs-owns-zfs §9, V3/AC1 (A-15, A-24): a file holding ${config-json} places every config value. +func caseConfigJSONPlacesEveryValueOfTheModule(t *testing.T) { + m := map[string]any{"module": "m", "version": "1", + "config": map[string]any{ + "a": map[string]any{"type": "string", "description": "a word nothing else says", "default": "x"}, + "b": map[string]any{"type": "number", "description": "a count nothing else says", "default": 1}, + }, + "resources": []any{map[string]any{"id": "c", "type": "file", "path": "/x/c.json", "content": "# none\n"}}} + refused(t, m, `"a"`, `"b"`, "placed nowhere") + resourceOf(m, "c")["content"] = "${config-json}" + if _, err := parseRaw(t, m); err != nil { + t.Fatal(err) + } + // Only in a file's content. + m["resources"] = append(m["resources"].([]any), map[string]any{"id": "u", "type": "service", "unit": "u.service", "restart-on": []any{"c"}}) + resourceOf(m, "u")["unit"] = "x ${config-json}" + refused(t, m, "${config-json}") +} + // Consumer data said to live in a provision the module requires is protected only as well as that // provision's provider protects its consumers' data. func caseConsumerDataInARequiredProvisionIsJudgedAgainstItsProvider(t *testing.T) { @@ -6190,6 +6209,7 @@ func TestRulesConfig(t *testing.T) { t.Run("ConfigStillRefusesAValueNoItemPlaces", caseConfigStillRefusesAValueNoItemPlaces) t.Run("IfConfigNamesADeclaredValueAndPlacesIt", caseIfConfigNamesADeclaredValueAndPlacesIt) t.Run("ConfigLinesNamesADeclaredValueAndAPlainPrefix", caseConfigLinesNamesADeclaredValueAndAPlainPrefix) + t.Run("ConfigJSONPlacesEveryValueOfTheModule", caseConfigJSONPlacesEveryValueOfTheModule) } // TestRulesClaims runs the tests of one kind as subtests, each by its former name. -- 2.54.0 From e1db8701b59e4732bcfe6f2f7080aa77e28ac9a7 Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 13 Nov 2026 00:22:38 +0100 Subject: [PATCH 4/6] V4 judge-not-managed: a config value with neither a default nor required is accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 42 zfs-owns-zfs §11; answer A-14 --- modules/mesh-delivery-indexer/judge/rules.go | 8 +++----- modules/mesh-delivery-indexer/judge/rules_test.go | 7 +++++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index 5aae9322..f390cb24 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -724,13 +724,11 @@ func ConfigProblems(m catalogue.Manifest) []string { if strings.TrimSpace(d.Description) == "" { say("no description: say in a sentence what the value is, including its unit (novox/hq ADR 0348 §4)") } - switch { - case d.Required && d.Default != nil: + // A value with neither a default nor `required` is not managed until it is set (design 42 zfs-owns-zfs §11, + // answer A-14). + if d.Required && d.Default != nil { say("both a default and required: a required value is the operator's own and has no default; give " + "one or the other (novox/hq ADR 0348 §4)") - case !d.Required && d.Default == nil: - say("neither a default nor required: give a default, or say \"required\": true for a value that is " + - "the operator's own (novox/hq ADR 0348 §4)") } if as := renamed[key]; as != "" && d.Required { say("is laid into its file as %q, and only when it is set, so the file and its code fall back when "+ diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index 77e31198..da9eefba 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -3497,11 +3497,14 @@ func caseConfigRefusesAValueNeitherPlacedNorReadAtRuntime(t *testing.T) { } } -// Rule 2: a value with neither a default nor required. +// Rule 2: a value with both a default and required is refused; one with neither is not managed until set (design 42 +// zfs-owns-zfs §11, V4/AC1, answer A-14). func caseConfigRefusesAValueWithNeitherDefaultNorRequired(t *testing.T) { m := inConfigForm() delete(declaration(m, "width"), "default") - refused(t, m, `"width"`, "neither a default nor required") + if _, err := parseRaw(t, m); err != nil { + t.Fatal(err) + } m = inConfigForm() declaration(m, "sender")["default"] = "noreply@example.org" refused(t, m, `"sender"`, "both a default and required") -- 2.54.0 From 3aca67b9d08dfca7423087aacc594a00b0b39722 Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 13 Nov 2026 01:03:49 +0100 Subject: [PATCH 5/6] V5 judge-process-bus-requires: a process's bus may name the events and states its module asks for in requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 42 zfs-owns-zfs §25, §26, A-37, A-38 --- modules/mesh-delivery-indexer/judge/rules.go | 14 ++++++++++++++ .../mesh-delivery-indexer/judge/rules_test.go | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index f390cb24..1b69c235 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -3998,6 +3998,20 @@ func ProcessBusProblems(m catalogue.Manifest) []string { for _, e := range m.EmitsAll() { emits[e] = true } + // What the module asks for in `requires` is its own as well (novox/hq ADR 0595 §1, §2): the events of an events stream + // by template, and a key-value state by template (design 42 zfs-owns-zfs, lead answer A-38). + for _, e := range m.BusState { + switch { + case e.Template == catalogue.TemplateEvents && e.Storage == catalogue.StorageStream: + var events []string + _ = json.Unmarshal(e.Options["events"], &events) + for _, ev := range events { + emits[ev] = true + } + case e.Template == catalogue.TemplateState && e.Storage == catalogue.StorageKeyValue: + state[e.Name] = true + } + } for _, r := range m.Resources { if fmt.Sprint(r["type"]) != "process" { continue diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index da9eefba..abc533d4 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -2455,6 +2455,24 @@ func caseAProcessBusNamesOnlyTheModulesOwn(t *testing.T) { t.Errorf("%s: %v; want a refusal naming %q", name, err, c.says) } } + // Design 42 zfs-owns-zfs, A-38 (V5): what the module asks for in `requires` by template is its own too, an event of + // 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",`+ + `"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 }{ + "an event of the events stream asked for": {`{"emits":["mail.send.queued"]}`, ""}, + "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"}, + } { + _, err := ParseManifest([]byte(asked(c.bus))) + if (c.says == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.says)) { + t.Errorf("%s: %v; want a refusal naming %q", name, err, c.says) + } + } } // 1179/AC4: a bus declaring serves, holds, claims or a seat is refused naming the key; a bus with invokes of -- 2.54.0 From ad7e580918014931d9a46c6bee115d9578053ddf Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 13 Nov 2026 01:04:50 +0100 Subject: [PATCH 6/6] G1 grants-process-bus-requires: a process's account publishes on the events stream and writes the states its module asks for in requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 42 zfs-owns-zfs §25, §26, A-37, A-38 --- .../mesh-grants/internal/buscompose/nats.go | 3 ++ .../internal/buscompose/process_grants.go | 11 ++++ .../mesh-grants/internal/buscompose/users.go | 5 +- .../internal/grants/processbus_test.go | 50 +++++++++++++++++++ .../mesh-grants/internal/grants/records.go | 28 ++++++++++- 5 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 modules/mesh-grants/internal/grants/processbus_test.go diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index 40f16ddb..8f2a7b57 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -213,6 +213,9 @@ type Principal struct { // Asks says a KindProcess principal reads the records of its module's own asks (novox/hq ADR 0436 §1), // on whichever seat of Uses keeps them (novox/hq ADR 0259 §3) — never set for any other kind. Asks bool + // BusStreams, for a KindProcess principal, are the local names of its module's events streams asked for in `requires` + // it publishes on (design 42 zfs-owns-zfs, A-38). + BusStreams []string // SnapshotsTheBus is the bus's own module, the one holding mesh-broker (novox/hq ADR 0235). Its // whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing diff --git a/modules/mesh-grants/internal/buscompose/process_grants.go b/modules/mesh-grants/internal/buscompose/process_grants.go index e6509a3c..41ca39f8 100644 --- a/modules/mesh-grants/internal/buscompose/process_grants.go +++ b/modules/mesh-grants/internal/buscompose/process_grants.go @@ -1,5 +1,7 @@ package buscompose +import "strings" + // The bus account of one process of a module that declares bus (novox/hq ADR 0436 §1, §2, §3, §5). // // **Never the module's own account's grants, never the tool runner's.** A process's authority is read @@ -17,6 +19,15 @@ func processGrants(p Principal) (pub, sub []string, err error) { pub = append(pub, own+".event."+e) } + // 1b. The events of its module's events streams asked for in `requires` (design 42 zfs-owns-zfs, lead answer A-38; ADR + // 0595 §1, §2): publish on each stream's subjects, the subject boundOf gives the module's own account, and nothing of + // its reading. A name that cannot be one token is granted nothing. + for _, local := range p.BusStreams { + if safeSubject.MatchString(p.Module) && safeSubject.MatchString(local) && !strings.Contains(local, "_") && !strings.Contains(p.Module, "_") { + pub = append(pub, "mesh.bus."+p.Module+"."+local+".>") + } + } + // 2. The one durable consumer of each work-queue stream its bus names (novox/hq ADR 0436 §1, §2, §6): // asked about, pulled from and acknowledged, by the same name ConsumersOf composes the consumer under // (QueueConsumer) — and no other stream's. diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index 839339a7..42718e1f 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -129,6 +129,9 @@ type ProcessPrincipal struct { // Removes are what its account may remove from the bus, as its bus declares them (the indexer's judge refuses any other; novox/hq // ADR 0564 Limits 4): a tools bundle's account, whose whole authority is then BusRemovalGrants. Removes []string + // BusStreams are the local names of the events streams its module asks for in `requires` that this process publishes + // on, as its bus names their events (design 42 zfs-owns-zfs, A-38; novox/hq ADR 0595 §1, §2). + BusStreams []string // State is the module's own state, by local name, this process reads and writes. State []string // Asks says whether this process reads the records of its module's own asks (novox/hq ADR 0259 §3), on @@ -255,7 +258,7 @@ func Users(r Records) ([]Principal, error) { for _, pr := range d.Processes { out = append(out, Principal{ Kind: KindProcess, Node: node, Module: d.Module, Process: pr.Process, - Emits: pr.Emits, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks, + Emits: pr.Emits, BusStreams: pr.BusStreams, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks, Invokes: pr.Invokes, Removes: pr.Removes, }) // The process of the module holding mesh-broker whose bus declares `keeps` keeps every module's bus state diff --git a/modules/mesh-grants/internal/grants/processbus_test.go b/modules/mesh-grants/internal/grants/processbus_test.go new file mode 100644 index 00000000..592509dd --- /dev/null +++ b/modules/mesh-grants/internal/grants/processbus_test.go @@ -0,0 +1,50 @@ +package grants + +import ( + "encoding/json" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "mesh-grants/internal/buscompose" +) + +// Design 42 zfs-owns-zfs, A-38 (G1): a process whose bus names an event of the events stream its module asks for in +// `requires` is granted publish on that stream's subjects, and one naming a state asked for there the reads and writes of +// its bucket; a name in the module's own `emits` is granted as before, and what its bus does not name is granted nothing. +func TestAProcessBusNamesWhatItsModuleAsksFor(t *testing.T) { + var m catalogue.Manifest + raw := `{"module":"zfs","version":"1","emits":["scrub.requested"],"requires":[` + + `{"name":"events","storage":"stream","template":"events","options":{"events":["config.applied","config.refused"]}},` + + `{"name":"applied","storage":"key-value","template":"state"},` + + `{"name":"findings","storage":"key-value","template":"state"}],"resources":[` + + `{"id":"zfs-keeper","type":"process","run":["./zfs"],"user":"zfs-keeper","bus":` + + `{"emits":["config.applied","scrub.requested"],"state":["applied"]}}]}` + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatal(err) + } + users, err := buscompose.Users(buscompose.Records{Nodes: []string{"ace"}, Assigned: map[string][]buscompose.Declared{ + "ace": {{Module: "zfs", Emits: m.EmitsAll(), Processes: processesOf(m)}}}}) + if err != nil { + t.Fatal(err) + } + var perms buscompose.Permissions + for _, u := range users { + if u.Kind == buscompose.KindProcess { + if perms, err = buscompose.PermissionsFor(u); err != nil { + t.Fatal(err) + } + } + } + for subject, want := range map[string]bool{ + "mesh.bus.zfs.events.config.applied": true, + "mesh.mod.zfs.event.config.applied": false, + "mesh.mod.zfs.event.scrub.requested": true, + "$KV.zfs_applied.pool.tank": true, + "$KV.zfs_findings.pool.tank": false, + "mesh.bus.zfs.findings.config.applied": false, + } { + if got := buscompose.MayPublish(perms, subject); got != want { + t.Errorf("publish on %s: %v, want %v", subject, got, want) + } + } +} diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index b4a78958..aed4f0df 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -1,6 +1,7 @@ package grants import ( + "encoding/json" "fmt" "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" "maps" @@ -401,12 +402,37 @@ func processesOf(m catalogue.Manifest) []buscompose.ProcessPrincipal { return nil } queues := queueOwnersOf(m) + // The events of each events stream the module asks for in `requires` by template, by the stream's local name: an event + // of a process's bus found there, and not among the module's own `emits`, is published on that stream's subjects, and a + // state asked for there is granted through `State` as a module's own is (design 42 zfs-owns-zfs, lead answer A-38; ADR + // 0595 §1, §2). + streamOf := map[string]string{} + for _, e := range m.BusState { + if e.Template != catalogue.TemplateEvents || e.Storage != catalogue.StorageStream { + continue + } + var events []string + _ = json.Unmarshal(e.Options["events"], &events) + for _, ev := range events { + streamOf[ev] = e.Name + } + } + own := m.EmitsAll() var out []buscompose.ProcessPrincipal // Every own bus account, processes first and then tools bundles (catalogue.BusAccounts): a tools bundle's account carries // what it may remove from the bus (ADR 0564 Limits 4, issue 1952 S5). for _, a := range accounts { - pr := buscompose.ProcessPrincipal{Process: a.ID, Emits: a.Bus.Emits, State: a.Bus.State, Asks: a.Bus.Asks, + pr := buscompose.ProcessPrincipal{Process: a.ID, State: a.Bus.State, Asks: a.Bus.Asks, Invokes: a.Bus.Invokes, Removes: a.Bus.Removes, Tools: a.Tools, Keeps: a.Bus.Keeps} + for _, e := range a.Bus.Emits { + if local, asked := streamOf[e]; asked && !slices.Contains(own, e) { + if !slices.Contains(pr.BusStreams, local) { + pr.BusStreams = append(pr.BusStreams, local) + } + continue + } + pr.Emits = append(pr.Emits, e) + } for _, q := range queues { if q.Process == a.ID { pr.Queues = append(pr.Queues, q) -- 2.54.0