From 2f40ae5f76791814e5269e00016a4275b42d6f76 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 01:36:45 +0000 Subject: [PATCH 1/4] Read the config form beside the settings form in a manifest (part of issue 660) The manifest's hooks for the config form: read into the fields the settings form fills, written back as read, and judged by ConfigProblems; the rules are in config.go. Co-Authored-By: Claude Opus 5.5 --- internal/catalogue/manifest.go | 49 ++++++++++++++++++++++++-- internal/catalogue/setting_defaults.go | 26 ++++++++++++++ internal/catalogue/setting_takes.go | 3 +- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 39dc1ba2..49866bdd 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -530,6 +530,11 @@ type Manifest struct { // stays refused by name until a layer sets it. The mesh's layer, then the node's, override it. Settings map[string]SettingDeclaration `json:"settings,omitempty"` + // forms is which of the two forms its config is written in, `config` or its alias `settings` (config.go, + // novox/hq ADR 0348 §1): the config form is read into Settings, a mergeable file's takes-settings and + // `${setting:}`, and written back as it was read. + forms configForms + // unknown is the first key this manifest has that this controller does not know, at any depth, when // it was read from the store (novox/hq ADR 0262): a manifest a newer controller registered. // ParseManifest refuses it; the store's catalogue still loads, and the module is left out of every @@ -1321,10 +1326,25 @@ type manifestFields Manifest // strict read there failed the whole catalogue, and with it every plan and every send, the moment a // controller was rolled back. UnknownField says what was set aside. func (m *Manifest) UnmarshalJSON(raw []byte) error { + // The config form read into the settings form's fields, so both compose by one path (config.go). + raw, forms := toSettingsForm(raw) var keys map[string]json.RawMessage if err := json.Unmarshal(raw, &keys); err != nil { return err } + if settings, has := keys[SettingsField]; has && string(settings) != "null" { + forms.settings = append(forms.settings, SettingsField) + } + if config, has := keys[ConfigField]; has { + if string(config) != "null" { + forms.config = append(forms.config, ConfigField) + } + if _, both := keys[SettingsField]; !both { + keys[SettingsField] = config + } + // Beside `settings` it is refused by name (MixedFormProblems), never merged into it. + delete(keys, ConfigField) + } plain := map[string]string{} many := map[string]map[string]string{} if secrets, ok := keys["secrets"]; ok && string(secrets) != "null" { @@ -1453,6 +1473,7 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error { } *m = Manifest(fields) m.unknown = unknown + m.forms = forms m.OptionalRequires = optionalRequires if len(plain) > 0 { m.Secrets = plain @@ -1470,8 +1491,16 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error { } // MarshalJSON writes `secrets` and `contributes` back in the shape they were read: single values, -// and objects of local names. +// and objects of local names; and its config in the form it was read in (config.go). func (m Manifest) MarshalJSON() ([]byte, error) { + raw, err := m.marshalFields() + if err != nil || !m.DeclaresConfig() { + return raw, err + } + return toConfigForm(raw) +} + +func (m Manifest) marshalFields() ([]byte, error) { raw, err := json.Marshal(manifestFields(m)) if err != nil { return nil, err @@ -1783,8 +1812,14 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, StateProblems(m)...) // And what it may call its logs, and how large it may ask them to grow (logs.go, novox/hq ADR 0297). problems = append(problems, LogProblems(m)...) - // And the defaults it gives its settings (setting_defaults.go, novox/hq ADR 0262). - problems = append(problems, SettingProblems(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). + if m.DeclaresConfig() { + problems = append(problems, ConfigProblems(m)...) + } else { + problems = append(problems, SettingProblems(m)...) + problems = append(problems, TypedSettingProblems(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). @@ -2341,6 +2376,14 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, healthProblems(m)...) // And whether a container is busy (novox/hq ADR 0335), likewise. problems = append(problems, busyProblems(m)...) + if m.DeclaresConfig() { + // Said in the words it was written in: the rules shared with the settings form say ${setting:} + // and takes-settings. + for i, p := range problems { + problems[i] = inConfigWords(p) + } + problems = append(problems, MixedFormProblems(m)...) + } if len(problems) > 0 { sort.Strings(problems) return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s", diff --git a/internal/catalogue/setting_defaults.go b/internal/catalogue/setting_defaults.go index 3f158e02..353b4063 100644 --- a/internal/catalogue/setting_defaults.go +++ b/internal/catalogue/setting_defaults.go @@ -31,6 +31,28 @@ type SettingDeclaration struct { Default any `json:"default"` // Why this default: one sentence for whoever wonders whether to change it. Why string `json:"why"` + + // The config form's declaration (novox/hq ADR 0348 §4, config.go): none of these is said under + // `settings`, and neither Kind nor Why under `config`. + + // Type is number, string or boolean; object or list only when Trusted. + Type string `json:"type,omitempty"` + // Description says what the value is, including its unit. + Description string `json:"description,omitempty"` + // Enum, Minimum, Maximum and Pattern are its validation: the values it may take, the bounds of a + // number, and the whole of a string matching the pattern. + Enum []any `json:"enum,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Pattern string `json:"pattern,omitempty"` + // Trusted is a value that decides what runs or who can reach what (ADR 0348 §8). + Trusted bool `json:"trusted,omitempty"` + // Predefined is the predefined key the value falls back to, of the same type (ADR 0348 §2, §7). + Predefined string `json:"predefined,omitempty"` + // Required is the operator's own value: it has no default (ADR 0348 §4). + Required bool `json:"required,omitempty"` + // Runtime is a value the module's code reads at runtime rather than one placed in a file (ADR 0348 §5). + Runtime bool `json:"runtime,omitempty"` } // KindPreference is the kind of a setting that may have a default. @@ -203,6 +225,10 @@ type SettingSource struct { func Effective(m Manifest, layers []Layer) []SettingSource { byKey := map[string]*SettingSource{} for key, d := range m.Settings { + if d.Default == nil { + // A required config value has no default (ADR 0348 §4); a settings declaration always has one. + continue + } byKey[key] = &SettingSource{Key: key, Value: d.Default, From: DefaultLayer, FromDefault: true, Default: d.Default, HasDefault: true} } diff --git a/internal/catalogue/setting_takes.go b/internal/catalogue/setting_takes.go index 0cb7fca4..d723de87 100644 --- a/internal/catalogue/setting_takes.go +++ b/internal/catalogue/setting_takes.go @@ -83,7 +83,8 @@ func TakenSettingsProblems(m Manifest) []string { // Said only when none of the module's mergeable files names its keys: when one does, §2 already // names every file that does not, and a file is not reported twice. func WrittenSettingsProblems(m Manifest) []string { - if len(m.Settings) == 0 { + if len(m.Settings) == 0 || m.DeclaresConfig() { + // A module in the config form is held to this where it is read, in its own words (ConfigProblems). return nil } var silent []string From 233956ef09e2cc48b8a6ea07849f903d249a870e Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 01:38:04 +0000 Subject: [PATCH 2/4] Let a module declare its config with type, description and validation (issue 660) The config form's declaration and its registration refusals, each by name with what to do (ADR 0348, design 57 package K1). Co-Authored-By: Claude Opus 5.5 --- internal/catalogue/config.go | 459 +++++++++++++++++++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 internal/catalogue/config.go diff --git a/internal/catalogue/config.go b/internal/catalogue/config.go new file mode 100644 index 00000000..d00da709 --- /dev/null +++ b/internal/catalogue/config.go @@ -0,0 +1,459 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "regexp" + "slices" + "strings" +) + +// A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660). +// +// **One concept, two spellings for a while.** "Config" replaces "settings": the manifest says `config` for +// `settings`, `takes-config` for `takes-settings` and `${config:}` for `${setting:}`. The old names stay +// accepted as aliases until every module has moved (ADR 0348 §1), and a module uses one form or the other, never +// both. The config form is read into the very fields the settings form fills — Settings, a mergeable file's +// takes-settings and `${setting:}` — so composition has one path and `${config:}` fills exactly as +// `${setting:}` does: a module rewritten from settings to config composes byte-identically. It is written +// back in the form it was read in, so what the mesh holds is what its author wrote. +// +// **A moved module is held to the whole declaration.** Under `config` every value has a type (number, string or +// boolean; an object or a list only when trusted), a description including its unit, its validation (enum, +// minimum and maximum, pattern), whether it is trusted, optionally the predefined key it falls back to, and either +// a default or `required`; it lands in a file or is read at runtime (`runtime: true`); every mergeable file names +// the keys it takes; a `reload-on` or `restart-on` names a file the module declares. A module not yet moved keeps +// today's form and today's rules (SettingProblems, TakenSettingsProblems) until the catalogue's list of such +// modules is empty (ADR 0348 §11). +// +// **A required value nothing sets** is the operator's own: it composes as an operator's setting nobody gave does +// today, refused by name at composition (UnsetSettingError). Who reports it once mesh-config answers values is the +// provisioner's (ADR 0349), not this file's. + +// ConfigField is the manifest key a module declares its config values under. +const ConfigField = "config" + +// TakesConfigField is the key a mergeable file names the config values it takes by. +const TakesConfigField = "takes-config" + +// SettingsField is the old name of ConfigField, read as its alias. +const SettingsField = "settings" + +// configRef is how a file asks for a config value: ${config:}, the same keys as settingRef. +var configRef = regexp.MustCompile(`\$\{config:([a-z0-9][a-z0-9_.-]*)\}`) + +// takesConfigKey and takesSettingsKey are the two spellings as a key of a JSON object. A string value is never +// followed by a colon, and a quote inside a string is escaped, so these match only keys. +var ( + takesConfigKey = regexp.MustCompile(`"` + TakesConfigField + `"(\s*):`) + takesSettingsKey = regexp.MustCompile(`"` + TakesSettingsField + `"(\s*):`) +) + +// The types a config value may have (ADR 0348 §4). +const ( + ConfigNumber = "number" + ConfigString = "string" + ConfigBoolean = "boolean" + ConfigObject = "object" + ConfigList = "list" +) + +// accountFacts are what the mesh places for a module's account, never config values (ADR 0348 §11). +var accountFacts = map[string]bool{"puid": true, "pgid": true} + +// PredefinedKey is one key of the catalogue's list of predefined keys, as far as a module's value falling back +// to it is judged here: its type, and whether it is trusted (ADR 0348 §7, §8). +type PredefinedKey struct { + Type string + Trusted bool +} + +// predefinedKeys is the list of predefined keys, nil until it is present. The list (`predefined.json` at the +// catalogue's root) arrives with design 57's package K2 (issue 661); until then a value naming one is refused +// saying the list is not yet present. +var predefinedKeys map[string]PredefinedKey + +// configForms is which of the two forms a manifest was written in, by the words of each it uses. +type configForms struct { + config, settings []string +} + +// DeclaresConfig is whether the manifest is written in the config form: `config`, `takes-config` or +// `${config:}` (ADR 0348 §1, §11). +func (m Manifest) DeclaresConfig() bool { return len(m.forms.config) > 0 } + +// toSettingsForm is raw with the config form's placeholders and takes-config read as the settings form's, and +// which words of each form it used. `config` itself is a top-level key, renamed where the keys are read +// (UnmarshalJSON). +func toSettingsForm(raw []byte) ([]byte, configForms) { + var f configForms + if configRef.Match(raw) { + f.config = append(f.config, "${config:…}") + } + if takesConfigKey.Match(raw) { + f.config = append(f.config, TakesConfigField) + } + if settingRef.Match(raw) { + f.settings = append(f.settings, "${setting:…}") + } + if takesSettingsKey.Match(raw) { + f.settings = append(f.settings, TakesSettingsField) + } + if len(f.config) == 0 { + return raw, f + } + raw = configRef.ReplaceAllFunc(raw, func(ref []byte) []byte { + return []byte("${setting:" + string(configRef.FindSubmatch(ref)[1]) + "}") + }) + raw = takesConfigKey.ReplaceAllFunc(raw, func(key []byte) []byte { + return []byte(`"` + TakesSettingsField + `"` + string(takesConfigKey.FindSubmatch(key)[1]) + ":") + }) + return raw, f +} + +// toConfigForm is a manifest written in the settings form's words, as JSON, in the config form's: what +// toSettingsForm and UnmarshalJSON read, written back. +func toConfigForm(raw []byte) ([]byte, error) { + var keys map[string]json.RawMessage + if err := json.Unmarshal(raw, &keys); err != nil { + return nil, err + } + if settings, has := keys[SettingsField]; has { + keys[ConfigField] = settings + delete(keys, SettingsField) + var err error + if raw, err = json.Marshal(keys); err != nil { + return nil, err + } + } + raw = settingRef.ReplaceAllFunc(raw, func(ref []byte) []byte { + return []byte("${config:" + string(settingRef.FindSubmatch(ref)[1]) + "}") + }) + raw = takesSettingsKey.ReplaceAllFunc(raw, func(key []byte) []byte { + return []byte(`"` + TakesConfigField + `"` + string(takesSettingsKey.FindSubmatch(key)[1]) + ":") + }) + return raw, nil +} + +// inConfigWords is a refusal said of a module in the config form in its words: `${config:}` and +// takes-config, where the rules shared with the settings form say `${setting:}` and takes-settings. +func inConfigWords(problem string) string { + problem = settingRef.ReplaceAllStringFunc(problem, func(ref string) string { + return "${config:" + settingRef.FindStringSubmatch(ref)[1] + "}" + }) + return strings.ReplaceAll(problem, TakesSettingsField, TakesConfigField) +} + +// MixedFormProblems is a manifest using both forms, said with the words of each (ADR 0348 §1). +func MixedFormProblems(m Manifest) []string { + if len(m.forms.config) == 0 || len(m.forms.settings) == 0 { + return nil + } + return []string{fmt.Sprintf("%s mixes the config form (%s) with the settings form (%s): a module uses one form "+ + "or the other, never both; write %s, %s and ${config:} throughout (novox/hq ADR 0348 §1)", + m.Module, strings.Join(m.forms.config, ", "), strings.Join(m.forms.settings, ", "), + ConfigField, TakesConfigField)} +} + +// configDeclaration is the config form's declaration as written: nothing of the settings form's, and nothing +// unsaid. +type configDeclaration struct { + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Enum []any `json:"enum,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Pattern string `json:"pattern,omitempty"` + Trusted bool `json:"trusted,omitempty"` + Predefined string `json:"predefined,omitempty"` + Default any `json:"default,omitempty"` + Required bool `json:"required,omitempty"` + Runtime bool `json:"runtime,omitempty"` +} + +// MarshalJSON writes a settings declaration exactly as before, and a config declaration without the settings +// form's kind and why, and without a default it does not have. +func (d SettingDeclaration) MarshalJSON() ([]byte, error) { + type plain SettingDeclaration + if d.Kind != "" || d.Why != "" { + return json.Marshal(plain(d)) + } + return json.Marshal(configDeclaration{Type: d.Type, Description: d.Description, Enum: d.Enum, + Minimum: d.Minimum, Maximum: d.Maximum, Pattern: d.Pattern, Trusted: d.Trusted, Predefined: d.Predefined, + Default: d.Default, Required: d.Required, Runtime: d.Runtime}) +} + +// configShaped is whether a declaration says anything of the config form's. +func configShaped(d SettingDeclaration) bool { + return d.Type != "" || d.Description != "" || len(d.Enum) > 0 || d.Minimum != nil || d.Maximum != nil || + d.Pattern != "" || d.Trusted || d.Predefined != "" || d.Required || d.Runtime +} + +// TypedSettingProblems is a declaration under `settings` that says what only `config` says: a value with a type, +// a description and its validation is declared in the config form (ADR 0348 §1, §4). +func TypedSettingProblems(m Manifest) []string { + var problems []string + for _, key := range sortedSettingKeys(m.Settings) { + if configShaped(m.Settings[key]) { + problems = append(problems, fmt.Sprintf("%s's setting %q says its type, description or validation "+ + "under %s: a value declared with those is declared under config, with ${config:} and "+ + "takes-config, never under %s (novox/hq ADR 0348 §1, §4)", m.Module, key, SettingsField, SettingsField)) + } + } + return problems +} + +// ConfigProblems is every way a module in the config form declares its config wrongly, each said by name with +// what to do (ADR 0348 How it is checked, design 57 package K1). +func ConfigProblems(m Manifest) []string { + if !m.DeclaresConfig() { + return nil + } + var problems []string + placed := configPlaced(m) + for _, key := range sortedSettingKeys(m.Settings) { + d := m.Settings[key] + say := func(format string, args ...any) { + problems = append(problems, fmt.Sprintf("%s's config value %q: ", m.Module, key)+fmt.Sprintf(format, args...)) + } + if !settingRef.MatchString("${setting:" + key + "}") { + say("not a usable key: lower-case letters, digits, dots, dashes and underscores") + continue + } + if meshWords[key] || accountFacts[key] { + say("%q is placement, which the mesh decides, never a config value (novox/hq ADR 0348 §11)", key) + continue + } + if d.Kind != "" || d.Why != "" { + say("kind and why are the settings form's; under config a value says its type and its description " + + "(novox/hq ADR 0348 §1, §4)") + } + switch d.Type { + case ConfigNumber, ConfigString, ConfigBoolean: + case ConfigObject, ConfigList: + if !d.Trusted { + say("is %s and not trusted: an object or a list decides more than one value can say, so it is "+ + "trusted, \"trusted\": true, or split into numbers, strings and booleans (novox/hq ADR 0348 §4)", d.Type) + } + case "": + say("no type: say \"type\": number, string or boolean, or object or list when trusted (novox/hq ADR 0348 §4)") + default: + say("type %q is none of number, string, boolean, object and list (novox/hq ADR 0348 §4)", d.Type) + } + 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: + 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)") + } + for _, wrong := range validationProblems(d) { + say("%s", wrong) + } + if word := operatorsWord(key); word != "" && d.Default != nil { + say("a key naming %q is the operator's value: declare it \"required\": true, with no default "+ + "(novox/hq ADR 0348 §4, ADR 0112)", word) + } + if d.Predefined != "" { + p, listed := predefinedKeys[d.Predefined] + switch { + case predefinedKeys == nil: + say("falls back to the predefined key %q, and the list of predefined keys (the catalogue's "+ + "predefined.json, design 57 package K2) is not yet present: no value can fall back to one "+ + "until it is; leave \"predefined\" out for now (novox/hq ADR 0348 §7)", d.Predefined) + case !listed: + say("falls back to %q, which is not on the list of predefined keys: name a key on it, or leave "+ + "\"predefined\" out (novox/hq ADR 0348 §7)", d.Predefined) + default: + if p.Type != d.Type { + say("falls back to the predefined key %q, which is a %s, and this value is a %s: a value falls "+ + "back only to a key of its own type (novox/hq ADR 0348 §4)", d.Predefined, p.Type, d.Type) + } + if p.Trusted && !d.Trusted { + say("falls back to the trusted predefined key %q and is not trusted itself: a value that falls "+ + "back to a trusted key is trusted, \"trusted\": true (novox/hq ADR 0348 §8)", d.Predefined) + } + } + } + // A template's text is read where its tree or its build is (WithTemplatesFrom, Resolve), and this is + // judged again there. + if !placed[key] && !d.Runtime && !m.Templated() { + say("placed nowhere: no file says ${config:%s} or takes it under %s, and it is not marked "+ + "\"runtime\": true for a value the module's code reads itself (novox/hq ADR 0348 §5)", key, TakesConfigField) + } + } + used := settingKeysUsedBy(m) + for _, key := range sortedKeys(used) { + if _, declared := m.Settings[key]; !declared { + problems = append(problems, fmt.Sprintf("%s says ${config:%s} and declares no such value under %s: "+ + "declare %q with its type, description and a default or required (novox/hq ADR 0348 §4, §5)", + m.Module, key, ConfigField, key)) + } + } + problems = append(problems, configTakesProblems(m)...) + problems = append(problems, reflectsProblems(m)...) + 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. +func configPlaced(m Manifest) map[string]bool { + placed := settingKeysUsedBy(m) + for _, r := range m.Resources { + for _, k := range stringsOf(r[TakesSettingsField]) { + placed[k] = true + } + } + return placed +} + +// validationProblems is what is wrong with a declaration's validation, and its default against it. +func validationProblems(d SettingDeclaration) []string { + var problems []string + isNumber := d.Type == ConfigNumber + if len(d.Enum) > 0 && d.Type != ConfigNumber && d.Type != ConfigString { + problems = append(problems, fmt.Sprintf("an enum is for a number or a string, and this is a %s", d.Type)) + } + if (d.Minimum != nil || d.Maximum != nil) && !isNumber { + problems = append(problems, fmt.Sprintf("a minimum or maximum is for a number, and this is a %s", d.Type)) + } + if d.Minimum != nil && d.Maximum != nil && *d.Minimum > *d.Maximum { + problems = append(problems, fmt.Sprintf("its minimum %v is above its maximum %v", *d.Minimum, *d.Maximum)) + } + var pattern *regexp.Regexp + if d.Pattern != "" { + if d.Type != ConfigString { + problems = append(problems, fmt.Sprintf("a pattern is for a string, and this is a %s", d.Type)) + } + var err error + if pattern, err = regexp.Compile(`^(?:` + d.Pattern + `)$`); err != nil { + problems = append(problems, fmt.Sprintf("its pattern %q is not a regular expression: %v", d.Pattern, err)) + } + } + for _, v := range d.Enum { + if !ofType(v, d.Type) { + problems = append(problems, fmt.Sprintf("its enum holds %v, which is not a %s", v, d.Type)) + } + } + if d.Default == nil { + return problems + } + if !ofType(d.Default, d.Type) { + if d.Type != "" { + problems = append(problems, fmt.Sprintf("its default %v is not a %s", d.Default, d.Type)) + } + return problems + } + if len(d.Enum) > 0 && !slices.ContainsFunc(d.Enum, func(v any) bool { return v == d.Default }) { + problems = append(problems, fmt.Sprintf("its default %v is not one of %v", d.Default, d.Enum)) + } + if n, ok := d.Default.(float64); ok { + if d.Minimum != nil && n < *d.Minimum { + problems = append(problems, fmt.Sprintf("its default %v is below its minimum %v", n, *d.Minimum)) + } + if d.Maximum != nil && n > *d.Maximum { + problems = append(problems, fmt.Sprintf("its default %v is above its maximum %v", n, *d.Maximum)) + } + } + if s, ok := d.Default.(string); ok && pattern != nil && !pattern.MatchString(s) { + problems = append(problems, fmt.Sprintf("its default %q does not match its pattern %q", s, d.Pattern)) + } + return problems +} + +// ofType is whether a value read from JSON is of a config type. +func ofType(v any, kind string) bool { + switch v.(type) { + case float64: + return kind == ConfigNumber + case string: + return kind == ConfigString + case bool: + return kind == ConfigBoolean + case map[string]any: + return kind == ConfigObject + case []any: + return kind == ConfigList + } + return false +} + +// configTakesProblems is what ADR 0348 §11 adds for a module in the config form: an account fact in +// takes-config (the mesh's own words are refused for both forms by TakenSettingsProblems), and a mergeable file +// naming no keys where none of the module's does (where one does, TakenSettingsProblems names every one that +// does not). +func configTakesProblems(m Manifest) []string { + var problems, silent []string + naming := false + for _, r := range m.Resources { + if how, _ := r["merge"].(string); how == "" { + continue + } + if _, said := r[TakesSettingsField]; !said { + silent = append(silent, fmt.Sprint(r["id"])) + continue + } + naming = true + for _, k := range stringsOf(r[TakesSettingsField]) { + if accountFacts[k] { + problems = append(problems, fmt.Sprintf("%s's %v names %q in %s, and %q is an account fact the "+ + "mesh places, never a config value (novox/hq ADR 0348 §11)", m.Module, r["id"], k, + TakesConfigField, k)) + } + } + } + if !naming { + for _, id := range silent { + problems = append(problems, fmt.Sprintf("%s's mergeable %s names no %s: a module that declares config "+ + "names the keys each mergeable file takes, an empty list for none (novox/hq ADR 0348 §11)", + m.Module, id, TakesConfigField)) + } + } + return problems +} + +// meshMadeIDs are the resources the mesh composes into a module's own set, which its `restart-on` may name +// without declaring them; meshMadePrefixes are those named after what they are for. +var ( + meshMadeIDs = []string{ComposedJailsID(), FilteringID(), CertificateID(), AuthorityID(), BusUsersID()} + meshMadePrefixes = []string{NeedID(""), SecretID(""), strings.TrimSuffix(GrantID("", ""), "-"), BoundID(""), ReceivedID(""), AccessID("/")} +) + +// reflectsProblems is a `reload-on` or `restart-on` naming a file the module does not declare (ADR 0348 §6): not +// one of its own resources, not one the mesh composes for it, and not another module's, which is named +// `.`. +func reflectsProblems(m Manifest) []string { + own := map[string]bool{} + for _, r := range m.Resources { + own[fmt.Sprint(r["id"])] = true + } + meshMade := func(id string) bool { + if slices.Contains(meshMadeIDs, id) { + return true + } + for _, p := range meshMadePrefixes { + if strings.HasPrefix(id, p) && len(id) > len(p) { + return true + } + } + return false + } + var problems []string + for _, r := range m.Resources { + for _, field := range []string{"reload-on", "restart-on"} { + for _, id := range stringsOf(r[field]) { + if strings.Contains(id, ".") || own[id] || meshMade(id) { + continue + } + problems = append(problems, fmt.Sprintf("%s's %v names %q in its %s, and %s declares no such "+ + "file: name a file of its own, or another module's as . (novox/hq ADR 0348 §6)", + m.Module, r["id"], id, field, m.Module)) + } + } + } + return problems +} From ae071e2b59ee25698582d17bfb070329e2b3d02f Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 01:39:13 +0000 Subject: [PATCH 3/4] Prove each config refusal and the byte-identical composition of a module moved to config (issue 660) One registration refusal test per rule of design 57 package K1, the forms kept apart and written back as read, and dunst rewritten from settings to config composing to the same bytes. Co-Authored-By: Claude Opus 5.5 --- internal/catalogue/config_test.go | 403 ++++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 internal/catalogue/config_test.go diff --git a/internal/catalogue/config_test.go b/internal/catalogue/config_test.go new file mode 100644 index 00000000..1c89bb4d --- /dev/null +++ b/internal/catalogue/config_test.go @@ -0,0 +1,403 @@ +package catalogue + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +// A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660): +// one registration refusal per rule of design 57's K1, the old names read as aliases, and a module rewritten +// from settings to config composing byte-identically. + +// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or +// required, placed in a file or read at runtime, and its mergeable file naming its keys. +func inConfigForm() map[string]any { + return map[string]any{ + "module": "notifier", + "version": "1", + "config": map[string]any{ + "font-size": map[string]any{"type": "number", "description": "the notification's font size, in points", + "minimum": 6, "maximum": 40, "default": 10}, + "width": map[string]any{"type": "number", "description": "the notification's width, in pixels", + "default": 250}, + "position": map[string]any{"type": "string", "description": "the corner it shows in, a word", + "enum": []any{"top-right", "top-left"}, "default": "top-right"}, + "theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name", + "pattern": "[a-z]+", "default": "dark"}, + "history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count", + "default": 20, "runtime": true}, + "sender": map[string]any{"type": "string", "description": "the address it mails a digest from", + "required": true}, + }, + "resources": []any{ + map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc", + "content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"}, + map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", + "takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"}, + map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service", + "reload-on": []any{"configuration"}, "restart-on": []any{"rules"}}, + }, + } +} + +// declaration is one value of inConfigForm's config, to change in place. +func declaration(m map[string]any, key string) map[string]any { + return m["config"].(map[string]any)[key].(map[string]any) +} + +func resourceOf(m map[string]any, id string) map[string]any { + for _, r := range m["resources"].([]any) { + if r.(map[string]any)["id"] == id { + return r.(map[string]any) + } + } + return nil +} + +func parseRaw(t *testing.T, m map[string]any) (Manifest, error) { + t.Helper() + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return ParseManifest(raw) +} + +// refused is that the manifest is refused with every one of these words. +func refused(t *testing.T, m map[string]any, words ...string) { + t.Helper() + _, err := parseRaw(t, m) + if err == nil { + t.Fatalf("registered; wanted a refusal saying %q", words) + } + for _, w := range words { + if !strings.Contains(err.Error(), w) { + t.Fatalf("refused without %q:\n%v", w, err) + } + } +} + +func TestAModuleInTheConfigFormRegisters(t *testing.T) { + m, err := parseRaw(t, inConfigForm()) + if err != nil { + t.Fatal(err) + } + if !m.DeclaresConfig() { + t.Fatal("a module declaring config is not read as in the config form") + } + d := m.Settings["font-size"] + if d.Type != "number" || d.Description == "" || d.Minimum == nil || *d.Minimum != 6 || d.Default != float64(10) { + t.Fatalf("font-size read as %+v", d) + } + if s := m.Settings["sender"]; !s.Required || s.Default != nil { + t.Fatalf("sender read as %+v", s) + } +} + +// Rule 1: a value without type or description. +func TestConfigRefusesAValueWithoutTypeOrDescription(t *testing.T) { + m := inConfigForm() + delete(declaration(m, "width"), "type") + refused(t, m, `notifier's config value "width"`, "no type") + m = inConfigForm() + declaration(m, "width")["type"] = "colour" + refused(t, m, `"width"`, `type "colour"`) + m = inConfigForm() + delete(declaration(m, "width"), "description") + refused(t, m, `"width"`, "no description") +} + +// Rule 2: a value with neither a default nor required. +func TestConfigRefusesAValueWithNeitherDefaultNorRequired(t *testing.T) { + m := inConfigForm() + delete(declaration(m, "width"), "default") + refused(t, m, `"width"`, "neither a default nor required") + m = inConfigForm() + declaration(m, "sender")["default"] = "noreply@example.org" + refused(t, m, `"sender"`, "both a default and required") +} + +// Rule 3: a default failing its validation. +func TestConfigRefusesADefaultFailingItsValidation(t *testing.T) { + for _, c := range []struct { + key string + value any + said string + }{ + {"font-size", 4, "below its minimum"}, + {"font-size", 41, "above its maximum"}, + {"font-size", "10", "not a number"}, + {"position", "bottom", "not one of"}, + {"theme", "Dark", "does not match"}, + } { + m := inConfigForm() + declaration(m, c.key)["default"] = c.value + refused(t, m, `"`+c.key+`"`, c.said) + } +} + +// Rule 4: an object or a list not trusted. +func TestConfigRefusesAnObjectOrListNotTrusted(t *testing.T) { + for _, kind := range []string{"object", "list"} { + m := inConfigForm() + def := any(map[string]any{"a": 1}) + if kind == "list" { + def = []any{"a"} + } + m["config"].(map[string]any)["rules"] = map[string]any{"type": kind, "description": "the rules, as " + kind, + "default": def, "runtime": true} + refused(t, m, `"rules"`, "is "+kind+" and not trusted") + m["config"].(map[string]any)["rules"].(map[string]any)["trusted"] = true + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("a trusted %s is refused: %v", kind, err) + } + } +} + +// withPredefined is the list of predefined keys as package K2 will give it, for the length of a test. +func withPredefined(t *testing.T, keys map[string]PredefinedKey) { + t.Helper() + was := predefinedKeys + predefinedKeys = keys + t.Cleanup(func() { predefinedKeys = was }) +} + +// Rule 5: a predefined key not on the list, or of another type. Until the list is present (package K2), a value +// naming one is refused saying so. +func TestConfigRefusesAPredefinedKeyNotOnTheListOrOfAnotherType(t *testing.T) { + m := inConfigForm() + declaration(m, "font-size")["predefined"] = "font-size" + refused(t, m, `"font-size"`, "the list of predefined keys", "not yet present") + + withPredefined(t, map[string]PredefinedKey{"font-size": {Type: "number"}, "accent": {Type: "string"}}) + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("a value falling back to a listed key of its type is refused: %v", err) + } + m = inConfigForm() + declaration(m, "font-size")["predefined"] = "font-scale" + refused(t, m, `"font-size"`, `"font-scale"`, "not on the list") + m = inConfigForm() + declaration(m, "font-size")["predefined"] = "accent" + refused(t, m, `"font-size"`, `"accent"`, "is a string") +} + +// Rule 6: an untrusted value falling back to a trusted predefined key. +func TestConfigRefusesAnUntrustedValueFallingBackToATrustedKey(t *testing.T) { + m := inConfigForm() + declaration(m, "theme")["predefined"] = "theme" + refused(t, m, `"theme"`, "not yet present") + + withPredefined(t, map[string]PredefinedKey{"theme": {Type: "string", Trusted: true}}) + refused(t, m, `"theme"`, "trusted", "is not") + declaration(m, "theme")["trusted"] = true + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("a trusted value falling back to a trusted key is refused: %v", err) + } +} + +// Rule 7: a value neither placed in a template nor read at runtime. +func TestConfigRefusesAValueNeitherPlacedNorReadAtRuntime(t *testing.T) { + m := inConfigForm() + delete(declaration(m, "history"), "runtime") + refused(t, m, `"history"`, "${config:history}", `"runtime": true`) + // Taken by a mergeable file is placed. + m = inConfigForm() + if _, err := parseRaw(t, m); err != nil { + t.Fatal(err) + } +} + +// Rule 8: a placeholder naming an undeclared value. +func TestConfigRefusesAPlaceholderNamingAnUndeclaredValue(t *testing.T) { + m := inConfigForm() + resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${config:width}\n" + + "from = ${config:sender}\ncolour = ${config:colour}\n" + refused(t, m, "${config:colour}", "declares no such value") +} + +// Rule 9: a reload-on or restart-on naming a file the module does not declare. +func TestConfigRefusesAReloadOrRestartOnNamingAnUndeclaredFile(t *testing.T) { + m := inConfigForm() + resourceOf(m, "notifier")["reload-on"] = []any{"configuration", "dunstrc"} + refused(t, m, "reload-on", `"dunstrc"`, "declares no such") + m = inConfigForm() + resourceOf(m, "notifier")["restart-on"] = []any{"rule"} + refused(t, m, "restart-on", `"rule"`) + // Another module's file, and one the mesh makes for the module, are not the module's to declare. + m = inConfigForm() + resourceOf(m, "notifier")["restart-on"] = []any{"rules", "resolver.fact-zones", "secret-mail", "composed-jails"} + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("refused: %v", err) + } +} + +// Rule 10: a moved module's mergeable file not naming its keys under takes-config. +func TestConfigRefusesAMergeableFileNotNamingItsKeys(t *testing.T) { + m := inConfigForm() + delete(resourceOf(m, "rules"), "takes-config") + // position and theme are then placed nowhere; say they are read at runtime so only the file's rule speaks. + declaration(m, "position")["runtime"] = true + declaration(m, "theme")["runtime"] = true + refused(t, m, "rules", "names no takes-config") +} + +// Rule 11: a mesh word, puid or pgid in takes-config. +func TestConfigRefusesAMeshWordOrAnAccountFactInTakesConfig(t *testing.T) { + for _, word := range []string{"ports", "expose", "reach", "endpoints", "places", "accesses", "networks", "puid", "pgid"} { + m := inConfigForm() + resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", word} + refused(t, m, `"`+word+`"`, "takes-config") + } +} + +// A module uses one form or the other, never both. +func TestAManifestMixingTheConfigAndSettingsFormsIsRefused(t *testing.T) { + m := inConfigForm() + resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${setting:width}\nfrom = ${config:sender}\n" + refused(t, m, "mixes", "${setting:", "config") + + m = inConfigForm() + delete(resourceOf(m, "rules"), "takes-config") + resourceOf(m, "rules")["takes-settings"] = []any{"position", "theme"} + refused(t, m, "mixes", "takes-settings") + + m = inConfigForm() + m["settings"] = map[string]any{"colour": map[string]any{"kind": "preference", "default": "red", "why": "red"}} + refused(t, m, "mixes", "settings") +} + +// The settings form keeps its own declaration; the config form's has no kind or why. +func TestEachFormKeepsItsOwnDeclaration(t *testing.T) { + m := inConfigForm() + declaration(m, "width")["why"] = "fits a title" + refused(t, m, `"width"`, "why") + + err := parsed(t, Manifest{Module: "notifier", + Settings: map[string]SettingDeclaration{"width": {Kind: KindPreference, Default: float64(250), + Why: "fits a title", Type: "number"}}, + Resources: []map[string]any{{"id": "c", "type": "file", "path": "/x", "content": "${setting:width}"}}}) + if err == nil || !strings.Contains(err.Error(), "under config") { + t.Fatalf("a typed value under settings: %v", err) + } +} + +// What the mesh holds is what was written: a module in the config form is written back in it, and read again +// the same. +func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) { + m, err := parseRaw(t, inConfigForm()) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + for _, said := range []string{`"config":`, `${config:font-size}`, `"takes-config":`, `"required":true`} { + if !strings.Contains(string(raw), said) { + t.Fatalf("written back without %s: %s", said, raw) + } + } + for _, unsaid := range []string{`"settings":`, `${setting:`, `"takes-settings"`, `"kind"`, `"why"`, `"default":null`} { + if strings.Contains(string(raw), unsaid) { + t.Fatalf("written back with %s: %s", unsaid, raw) + } + } + again, err := ParseManifest(raw) + if err != nil { + t.Fatal(err) + } + twice, _ := json.Marshal(again) + if !bytes.Equal(raw, twice) { + t.Fatalf("read again it is written\n%s\nwhere it was\n%s", twice, raw) + } +} + +// A module in the settings form is written back byte for byte as before. +func TestASettingsManifestIsWrittenBackAsBefore(t *testing.T) { + d := SettingDeclaration{Kind: KindPreference, Default: float64(10), Why: "readable"} + raw, err := json.Marshal(d) + if err != nil { + t.Fatal(err) + } + if string(raw) != `{"kind":"preference","default":10,"why":"readable"}` { + t.Fatalf("written as %s", raw) + } +} + +// settingsDunst is the dunst module as it is written today; configDunst the same module rewritten from settings to +// config. +const settingsDunst = `{"module": "dunst", "version": "1", + "settings": { + "font-size": {"kind": "preference", "default": 10, "why": "10 points of Inter reads well on a screen of about 100 DPI"}, + "width": {"kind": "preference", "default": 250, "why": "250 pixels fits a title of about forty characters at 10 points"} + }, + "resources": [ + {"id": "package", "type": "package", "package": "dunst"}, + {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644", + "content": "[global]\n font = Inter ${setting:font-size}\n width = ${setting:width}\n"}, + {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json", + "takes-settings": [], "content": "{\"urgency\": \"low\"}\n"}, + {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]} + ]}` + +const configDunst = `{"module": "dunst", "version": "1", + "config": { + "font-size": {"type": "number", "description": "the font size of a notification, in points", "minimum": 6, + "maximum": 40, "default": 10}, + "width": {"type": "number", "description": "the width of a notification, in pixels", "minimum": 100, + "default": 250} + }, + "resources": [ + {"id": "package", "type": "package", "package": "dunst"}, + {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644", + "content": "[global]\n font = Inter ${config:font-size}\n width = ${config:width}\n"}, + {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json", + "takes-config": [], "content": "{\"urgency\": \"low\"}\n"}, + {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]} + ]}` + +// ${config:} fills exactly as ${setting:} does: the module rewritten from settings to config composes +// to the same bytes, with no layer, with the node's, and with the mesh's under the node's. +func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) { + composed := func(manifest string, layers []Layer) []byte { + t.Helper() + m, err := ParseManifest([]byte(manifest)) + if err != nil { + t.Fatal(err) + } + r := anAdoptedAnchor() + r.Modules = append(r.Modules, m) + with := anchorRendering(false) + if layers != nil { + with.Settings["dunst"] = layers + } + c, err := r.Compose(with) + if err != nil { + t.Fatal(err) + } + if why, left := c.LeftOut["dunst"]; left { + t.Fatalf("dunst was left out: %s", why) + } + raw, err := json.Marshal(c) + if err != nil { + t.Fatal(err) + } + return raw + } + for _, layers := range [][]Layer{ + nil, + {{From: "anchor", Values: map[string]any{"font-size": float64(13)}}}, + {{From: MeshWideLayer, Values: map[string]any{"width": float64(300)}}, + {From: "anchor", Values: map[string]any{"font-size": float64(12), "width": float64(320)}}}, + } { + was, is := composed(settingsDunst, layers), composed(configDunst, layers) + if !bytes.Equal(was, is) { + t.Fatalf("composed differently with %v:\nsettings: %s\nconfig: %s", layers, was, is) + } + if !strings.Contains(string(is), "font = Inter 1") || strings.Contains(string(is), "${config:") { + t.Fatalf("not filled: %s", is) + } + } +} From 9aa03326949548dade671335b39a07296388b41a Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 05:17:26 +0000 Subject: [PATCH 4/4] Refuse an undeclared takes-config key, and say an unset required value in the config form's words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A key a mergeable file took had no type, validation or trusted mark, and the config verb could never set it (ADR 0348 §4). A required config value nothing sets was refused naming ${setting:} and settings set, words the module does not use. Review of mesh-controller#320 (issue 660). Co-Authored-By: Claude Opus 5.5 --- internal/catalogue/config.go | 16 +++++++-- internal/catalogue/config_test.go | 50 ++++++++++++++++++++++++++ internal/catalogue/declaration.go | 2 +- internal/catalogue/setting_into.go | 57 +++++++++++++++++++++--------- internal/catalogue/settings.go | 2 +- 5 files changed, 106 insertions(+), 21 deletions(-) diff --git a/internal/catalogue/config.go b/internal/catalogue/config.go index d00da709..90e9ea71 100644 --- a/internal/catalogue/config.go +++ b/internal/catalogue/config.go @@ -279,8 +279,9 @@ func ConfigProblems(m Manifest) []string { } } } - // A template's text is read where its tree or its build is (WithTemplatesFrom, Resolve), and this is - // judged again there. + // A template's text is not here yet. Resolve fills it from the build, WithTemplatesFrom from the tree; neither + // judges again itself, but WithTemplatesFrom parses its result again, and a resolved build is parsed again + // where it is registered, and this is judged then. if !placed[key] && !d.Runtime && !m.Templated() { say("placed nowhere: no file says ${config:%s} or takes it under %s, and it is not marked "+ "\"runtime\": true for a value the module's code reads itself (novox/hq ADR 0348 §5)", key, TakesConfigField) @@ -399,10 +400,19 @@ func configTakesProblems(m Manifest) []string { } naming = true for _, k := range stringsOf(r[TakesSettingsField]) { - if accountFacts[k] { + _, declared := m.Settings[k] + switch { + case accountFacts[k]: problems = append(problems, fmt.Sprintf("%s's %v names %q in %s, and %q is an account fact the "+ "mesh places, never a config value (novox/hq ADR 0348 §11)", m.Module, r["id"], k, TakesConfigField, k)) + case meshWords[k]: + // Refused for both forms by TakenSettingsProblems, in its words. + case !declared: + problems = append(problems, fmt.Sprintf("%s's %v names %q in %s and declares no such value under "+ + "%s: a value a file takes is declared like every other, with its type, description and a "+ + "default or required, or the config verb could never set it (novox/hq ADR 0348 §4, §11)", + m.Module, r["id"], k, TakesConfigField, ConfigField)) } } } diff --git a/internal/catalogue/config_test.go b/internal/catalogue/config_test.go index 1c89bb4d..1a2d8499 100644 --- a/internal/catalogue/config_test.go +++ b/internal/catalogue/config_test.go @@ -3,6 +3,7 @@ package catalogue import ( "bytes" "encoding/json" + "errors" "strings" "testing" ) @@ -401,3 +402,52 @@ func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing. } } } + +// A key a mergeable file takes is a config value like every other: one not declared under config is refused by +// name (ADR 0348 §4, review of mesh-controller#320). +func TestConfigRefusesATakesConfigKeyNotDeclared(t *testing.T) { + m := inConfigForm() + resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "urgency"} + refused(t, m, `"urgency"`, "takes-config", "declares no such value under config") +} + +// A required value nothing sets is refused at composition in the config form's words: ${config:} and the +// config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged. +func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) { + m, err := parseRaw(t, inConfigForm()) + if err != nil { + t.Fatal(err) + } + r := anAdoptedAnchor() + r.Modules = append(r.Modules, m) + composed, err := r.Compose(anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + why, left := composed.LeftOut["notifier"] + if !left { + t.Fatal("a module whose required value nothing sets was not left out") + } + for _, said := range []string{"${config:sender}", `required config value "sender"`, "config verb"} { + if !strings.Contains(why, said) { + t.Fatalf("left out without %q: %s", said, why) + } + } + for _, unsaid := range []string{"${setting:", "settings set"} { + if strings.Contains(why, unsaid) { + t.Fatalf("left out in the settings form's words (%q): %s", unsaid, why) + } + } + var unset *UnsetSettingError + if err := unsetInConfigWords(settingInto(map[string]any{"type": "file", "content": "${setting:sender}"}, nil, + "notifier"), m); !errors.As(err, &unset) || unset.Setting != "sender" { + t.Fatalf("not typed as a value nobody gave: %v", err) + } + + settingsForm := notifier() + settingsForm.Resources[0]["content"] = "from = ${setting:domain}\n" + err = unsetInConfigWords(settingInto(settingsForm.Resources[0], nil, "notifier"), settingsForm) + if err == nil || !strings.Contains(err.Error(), "`settings set notifier `") { + t.Fatalf("the settings form's refusal changed: %v", err) + } +} diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 761a5515..25e39e27 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -1003,7 +1003,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string, // **An operator's value, from the assignment** (novox/hq ADR 0112, ADR 0155): what a // definition may not carry because it is true of one installation only. Filled from // the same layers a mergeable file takes, and refused when no layer set it. - if err := settingInto(copied, WithDefaults(m, with.Settings[m.Module]), m.Module); err != nil { + if err := unsetInConfigWords(settingInto(copied, WithDefaults(m, with.Settings[m.Module]), m.Module), m); err != nil { return nil, err } // **Placed before anything reads a path.** A pathless directory receives the path diff --git a/internal/catalogue/setting_into.go b/internal/catalogue/setting_into.go index ee04ee18..1ea94437 100644 --- a/internal/catalogue/setting_into.go +++ b/internal/catalogue/setting_into.go @@ -1,6 +1,7 @@ package catalogue import ( + "errors" "fmt" "regexp" "sort" @@ -47,10 +48,30 @@ func settingsUsed(content string) []string { type UnsetSettingError struct { Module, Setting string said string + // where says what asks for it — "a file", "a service whose unit", "a directory whose owner" — and + // setToday which of the module's keys a layer sets, for saying it again in the config form's words. + where, setToday string } func (e *UnsetSettingError) Error() string { return e.said } +// unsetInConfigWords is the refusal of a required config value nothing sets, in the words of a module written in the +// config form (novox/hq ADR 0348 §1, §4): `${config:}` and the config verb, never `settings set`. Any other +// error, and any error of a module in the settings form, is returned as it was. +func unsetInConfigWords(err error, m Manifest) error { + var unset *UnsetSettingError + if err == nil || !m.DeclaresConfig() || !errors.As(err, &unset) { + return err + } + return &UnsetSettingError{Module: unset.Module, Setting: unset.Setting, where: unset.where, + setToday: unset.setToday, said: fmt.Sprintf( + "%s has %s that says ${config:%s}, and nothing sets the required config value %q for it — a "+ + "required value is the operator's own and has no default (novox/hq ADR 0348 §4): set it with "+ + "the config verb, for %s on this node, key %q%s", + unset.Module, unset.where, unset.Setting, unset.Setting, unset.Module, unset.Setting, + strings.ReplaceAll(unset.setToday, "no setting is set", "no config value is set"))} +} + // settingInto fills a file's ${setting:…} placeholders from the layers over a module. // // The last layer setting a key wins, which is the node's over the mesh's over the module's own @@ -74,12 +95,13 @@ func settingInto(resource map[string]any, layers []Layer, module string) error { for _, key := range settingsUsed(content) { value, set := settingValue(layers, key) if !set { - return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf( - "%s has a file that says ${setting:%s}, and nothing sets %q for it — an operator's "+ - "value is the assignment's, never the definition's (novox/hq ADR 0112), and only a "+ - "preference has a default in the definition (ADR 0262): "+ - "`settings set %s ` with {%q: …}%s", - module, key, key, module, key, orNoSettings(layers))} + return &UnsetSettingError{Module: module, Setting: key, where: "a file", + setToday: orNoSettings(layers), said: fmt.Sprintf( + "%s has a file that says ${setting:%s}, and nothing sets %q for it — an operator's "+ + "value is the assignment's, never the definition's (novox/hq ADR 0112), and only a "+ + "preference has a default in the definition (ADR 0262): "+ + "`settings set %s ` with {%q: …}%s", + module, key, key, module, key, orNoSettings(layers))} } content = strings.ReplaceAll(content, "${setting:"+key+"}", plainly(value)) } @@ -130,11 +152,12 @@ func settingIntoUnit(resource map[string]any, layers []Layer, module string) err for _, key := range settingsUsed(unit) { value, set := settingValue(layers, key) if !set { - return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf( - "%s has a service whose unit says ${setting:%s}, and nothing sets %q for it — an operator's "+ - "value is the assignment's, never the definition's (novox/hq ADR 0112): "+ - "`settings set %s ` with {%q: …}%s", - module, key, key, module, key, orNoSettings(layers))} + return &UnsetSettingError{Module: module, Setting: key, where: "a service whose unit", + setToday: orNoSettings(layers), said: fmt.Sprintf( + "%s has a service whose unit says ${setting:%s}, and nothing sets %q for it — an operator's "+ + "value is the assignment's, never the definition's (novox/hq ADR 0112): "+ + "`settings set %s ` with {%q: …}%s", + module, key, key, module, key, orNoSettings(layers))} } v := plainly(value) if !unitPart.MatchString(v) { @@ -229,11 +252,13 @@ func settingIntoOwner(resource map[string]any, layers []Layer, module string) er for _, key := range settingsUsed(owner) { value, set := settingValue(layers, key) if !set { - return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf( - "%s has a directory %v whose owner says ${setting:%s}, and nothing sets %q for it — an operator's "+ - "value is the assignment's, never the definition's (novox/hq ADR 0112): "+ - "`settings set %s ` with {%q: …}%s", - module, resource["id"], key, key, module, key, orNoSettings(layers))} + return &UnsetSettingError{Module: module, Setting: key, + where: fmt.Sprintf("a directory %v whose owner", resource["id"]), setToday: orNoSettings(layers), + said: fmt.Sprintf( + "%s has a directory %v whose owner says ${setting:%s}, and nothing sets %q for it — an operator's "+ + "value is the assignment's, never the definition's (novox/hq ADR 0112): "+ + "`settings set %s ` with {%q: …}%s", + module, resource["id"], key, key, module, key, orNoSettings(layers))} } v := plainly(value) id, whole := wholeID(v) diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index 45c992e8..5494c11b 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -590,7 +590,7 @@ func JudgeSettings(m Manifest, layers []Layer, adopted bool) error { for k, v := range settled { copied[k] = v } - if err := settingInto(copied, WithDefaults(m, layers), m.Module); err != nil { + if err := unsetInConfigWords(settingInto(copied, WithDefaults(m, layers), m.Module), m); err != nil { return err } }