From 3d1651577184438fc81e29367f232f0000b90eab Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Tue, 13 Oct 2026 13:08:37 +0000 Subject: [PATCH] Declare preferences with a type and a predefined key, and lay mesh and node config between default and node (hq issue 630) A preference may fall back to one reviewed mesh-wide or node-wide value instead of a module layer for the whole mesh; like a default, config fills ${setting:} only. --- internal/catalogue/manifest.go | 6 + internal/catalogue/preferences.go | 270 +++++++++++++++++++++++++ internal/catalogue/setting_defaults.go | 56 +++-- internal/catalogue/setting_into.go | 2 +- internal/catalogue/setting_takes.go | 4 +- internal/catalogue/settings.go | 9 +- 6 files changed, 320 insertions(+), 27 deletions(-) create mode 100644 internal/catalogue/preferences.go diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 866daf78..6e110155 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -530,6 +530,12 @@ 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"` + // Preferences are the values this module reads that a machine may tune (novox/hq issue 630): each + // key's type, default and why, and the predefined key it falls back to, if any. The value resolves + // from the default, then mesh config and node config for the predefined key, then this module's own + // value on the node. `settings` entries of kind preference are read as preferences while they move. + Preferences map[string]Preference `json:"preferences,omitempty"` + // 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 diff --git a/internal/catalogue/preferences.go b/internal/catalogue/preferences.go new file mode 100644 index 00000000..8acd6ad2 --- /dev/null +++ b/internal/catalogue/preferences.go @@ -0,0 +1,270 @@ +package catalogue + +import ( + "bytes" + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" +) + +// A config value resolves from four sources, the last set winning (novox/hq issue 630): +// +// 1. the default in the module's manifest; +// 2. mesh config: a mesh-wide value for a predefined key, such as `font-size`; +// 3. node config: a value for a predefined key for one node; +// 4. module-assignment config: that module's own value on that node, the most specific. +// +// An assignment is one module on one node: there is no module layer for the whole mesh. Mesh and node +// config hold values only for the keys on a fixed, reviewable list — the catalogue's `predefined.json` — +// that any module may fall back to, by naming one as its preference's `predefined`. A preference without +// `predefined` reads only its default and its module-assignment config. A preference is never trusted. + +// Preference is one value a module reads that a machine may tune, as the manifest declares it under +// `preferences`. +type Preference struct { + // Type is "number", "string" or "boolean". + Type string `json:"type"` + // Default is the value when no source sets it, of Type. + Default any `json:"default"` + // Predefined names the key on the catalogue's list this preference falls back to: mesh config, + // then node config, for that key. Empty reads only the default and the module-assignment config. + Predefined string `json:"predefined,omitempty"` + // Why this default: one sentence for whoever wonders whether to change it. + Why string `json:"why"` +} + +// The types a preference and a predefined key may have. +const ( + TypeNumber = "number" + TypeString = "string" + TypeBoolean = "boolean" +) + +// PredefinedFile is the catalogue repository's root file holding the predefined keys. +const PredefinedFile = "predefined.json" + +// PredefinedKey is one key mesh and node config may hold a value for. +type PredefinedKey struct { + Type string `json:"type"` + Why string `json:"why"` +} + +// Predefined is the list of predefined keys, by key. +type Predefined map[string]PredefinedKey + +// Keys is the list's keys, sorted. +func (p Predefined) Keys() []string { + keys := make([]string, 0, len(p)) + for k := range p { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// predefinedName is what a predefined key looks like: the shape of a setting key. +var predefinedName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + +// ParsePredefined reads `predefined.json` strictly, and refuses a key whose name, type or why is wrong. +func ParsePredefined(raw []byte) (Predefined, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var list Predefined + if err := decoder.Decode(&list); err != nil { + return nil, fmt.Errorf("%s is not a list of predefined keys ({\"\": {\"type\": …, \"why\": …}}): %w", + PredefinedFile, err) + } + var problems []string + for _, key := range list.Keys() { + k := list[key] + if !predefinedName.MatchString(key) { + problems = append(problems, fmt.Sprintf("%q is not a usable key: lower-case letters, digits, dots, dashes and underscores", key)) + } + if meshWords[key] { + problems = append(problems, fmt.Sprintf("%q is a key the mesh reads itself", key)) + } + if !knownType(k.Type) { + problems = append(problems, fmt.Sprintf("%q has the type %q; a type is %q, %q or %q", key, k.Type, + TypeNumber, TypeString, TypeBoolean)) + } + if strings.TrimSpace(k.Why) == "" { + problems = append(problems, fmt.Sprintf("%q has no why: say in one sentence what the key is for", key)) + } + } + if len(problems) > 0 { + return nil, fmt.Errorf("%s: %s", PredefinedFile, strings.Join(problems, "; ")) + } + if list == nil { + list = Predefined{} + } + return list, nil +} + +func knownType(t string) bool { + return t == TypeNumber || t == TypeString || t == TypeBoolean +} + +// TypeOf is the type a JSON value has, as a preference names it, or "" for one that is none of them. +func TypeOf(v any) string { + switch v.(type) { + case float64, int, int64, json.Number: + return TypeNumber + case string: + return TypeString + case bool: + return TypeBoolean + } + return "" +} + +// PreferencesOf is every preference a module declares: those under `preferences`, and — while the +// catalogue moves to them — every `settings` entry of kind preference, its type read from its default. +func PreferencesOf(m Manifest) map[string]Preference { + if len(m.Preferences) == 0 && len(m.Settings) == 0 { + return nil + } + out := make(map[string]Preference, len(m.Preferences)+len(m.Settings)) + for key, d := range m.Settings { + out[key] = Preference{Type: TypeOf(d.Default), Default: d.Default, Why: d.Why} + } + for key, p := range m.Preferences { + out[key] = p + } + return out +} + +// DeclaresPreference is whether a module declares a preference by this key. +func DeclaresPreference(m Manifest, key string) bool { + _, has := m.Preferences[key] + if !has { + _, has = m.Settings[key] + } + return has +} + +// PredefinedProblems is every way a module's preferences disagree with the list of predefined keys: a +// `predefined` not on it, or a type other than the list's. +func PredefinedProblems(m Manifest, list Predefined) []string { + var problems []string + keys := make([]string, 0, len(m.Preferences)) + for k := range m.Preferences { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + p := m.Preferences[key] + if p.Predefined == "" { + continue + } + k, on := list[p.Predefined] + if !on { + listed := "the list holds none" + if len(list) > 0 { + listed = "the list holds " + strings.Join(list.Keys(), ", ") + } + problems = append(problems, fmt.Sprintf("%s's preference %q falls back to the predefined key %q, which "+ + "is not on the catalogue's list of predefined keys (%s; %s)", m.Module, key, p.Predefined, PredefinedFile, + listed)) + continue + } + if k.Type != p.Type { + problems = append(problems, fmt.Sprintf("%s's preference %q is a %s, and the predefined key %q it falls "+ + "back to is a %s", m.Module, key, p.Type, p.Predefined, k.Type)) + } + } + return problems +} + +// Where a value from mesh or node config is said to come from. +const MeshConfigLayer = "mesh config" + +// NodeConfigLayer is what the node config of one node is called where a value's source is said. +func NodeConfigLayer(node string) string { return "node config (" + node + ")" } + +// ConfigLayers are the layers mesh config and node config make for one module: each of its preferences +// that names a predefined key, given that key's value. Laid between the module's defaults and its +// module-assignment config; like the defaults they fill `${setting:}` only. +func ConfigLayers(m Manifest, mesh, node map[string]any, nodeName string) []Layer { + var out []Layer + for _, source := range []struct { + from string + values map[string]any + }{{MeshConfigLayer, mesh}, {NodeConfigLayer(nodeName), node}} { + values := map[string]any{} + for key, p := range m.Preferences { + if p.Predefined == "" { + continue + } + if v, set := source.values[p.Predefined]; set { + values[key] = v + } + } + if len(values) > 0 { + out = append(out, Layer{From: source.from, Values: values, Config: true}) + } + } + return out +} + +// ConfigValueProblem refuses a value of the wrong type for a predefined key or a preference. +func ConfigValueProblem(what string, want string, v any) error { + if got := TypeOf(v); got != want { + shown, _ := json.Marshal(v) + return fmt.Errorf("%s is a %s, and %s is %s", what, want, shown, aOrNothing(got)) + } + return nil +} + +func aOrNothing(t string) string { + if t == "" { + return "none of a number, a string or a boolean" + } + return "a " + t +} + +// preferenceProblems is every way a module's `preferences` are wrong in themselves. +func preferenceProblems(m Manifest) []string { + var problems []string + keys := make([]string, 0, len(m.Preferences)) + for k := range m.Preferences { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + p := m.Preferences[key] + say := func(format string, args ...any) { + problems = append(problems, fmt.Sprintf("%s's preference %q: ", m.Module, key)+fmt.Sprintf(format, args...)) + } + if _, twice := m.Settings[key]; twice { + say("declared under settings too; declare it once, under preferences") + } + if !knownType(p.Type) { + say("the type is %q; a type is %q, %q or %q", p.Type, TypeNumber, TypeString, TypeBoolean) + } else if p.Default == nil { + say("no default: a preference has one") + } else if got := TypeOf(p.Default); got != p.Type { + shown, _ := json.Marshal(p.Default) + say("it is a %s, and its default %s is %s", p.Type, shown, aOrNothing(got)) + } + if p.Predefined != "" && !predefinedName.MatchString(p.Predefined) { + say("%q is not a predefined key's name", p.Predefined) + } + } + return problems +} + +// PredefinedNamed is every predefined key a module's preferences fall back to, sorted. +func PredefinedNamed(m Manifest) []string { + seen := map[string]bool{} + var out []string + for _, p := range m.Preferences { + if p.Predefined != "" && !seen[p.Predefined] { + seen[p.Predefined] = true + out = append(out, p.Predefined) + } + } + sort.Strings(out) + return out +} diff --git a/internal/catalogue/setting_defaults.go b/internal/catalogue/setting_defaults.go index 1bc72f67..d8ebb75b 100644 --- a/internal/catalogue/setting_defaults.go +++ b/internal/catalogue/setting_defaults.go @@ -114,15 +114,23 @@ func operatorsWord(key string) string { return "" } -// SettingProblems is every way a definition's setting defaults are wrong, in its own words. +// SettingProblems is every way a definition's setting defaults and preferences are wrong, in its own +// words. The list of predefined keys is not needed here; PredefinedProblems judges against it. func SettingProblems(m Manifest) []string { - if len(m.Settings) == 0 { + declared := PreferencesOf(m) + if len(declared) == 0 { return nil } used := settingKeysUsedBy(m) - var problems []string - for _, key := range sortedSettingKeys(m.Settings) { - d := m.Settings[key] + problems := preferenceProblems(m) + keys := make([]string, 0, len(declared)) + for k := range declared { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + d := declared[key] + _, old := m.Settings[key] say := func(format string, args ...any) { problems = append(problems, fmt.Sprintf("%s's setting %q: ", m.Module, key)+fmt.Sprintf(format, args...)) } @@ -134,23 +142,27 @@ func SettingProblems(m Manifest) []string { say("the mesh reads %q itself, and a module gives it no default", key) continue } - if d.Kind != KindPreference { - say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", d.Kind, KindPreference) + if old && m.Settings[key].Kind != KindPreference { + say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", m.Settings[key].Kind, KindPreference) } if word := operatorsWord(key); word != "" { say("a key naming %q is the operator's value, and has no default — it is the "+ "assignment's, never the definition's (novox/hq ADR 0112, ADR 0262)", word) } - switch v := d.Default.(type) { - case string: - if strings.TrimSpace(v) == "" { - say("an empty default is no default; give the value, or declare nothing") + if old { + switch v := d.Default.(type) { + case string: + if strings.TrimSpace(v) == "" { + say("an empty default is no default; give the value, or declare nothing") + } + case float64, bool: + case nil: + say("no default: a key without one is the operator's, and is not declared here") + default: + say("a default is a string, a number or a boolean, and this is %T", d.Default) } - case float64, bool: - case nil: - say("no default: a key without one is the operator's, and is not declared here") - default: - say("a default is a string, a number or a boolean, and this is %T", d.Default) + } else if v, isString := d.Default.(string); isString && strings.TrimSpace(v) == "" { + say("an empty default is no default; give the value, or declare nothing") } if strings.TrimSpace(d.Why) == "" { say("no why: say in one sentence why this default") @@ -164,11 +176,12 @@ func SettingProblems(m Manifest) []string { // Defaults is the layer a module's own defaults make, or nothing when it gives none. func Defaults(m Manifest) (Layer, bool) { - if len(m.Settings) == 0 { + declared := PreferencesOf(m) + if len(declared) == 0 { return Layer{}, false } values := map[string]any{} - for key, d := range m.Settings { + for key, d := range declared { if d.Default != nil { values[key] = d.Default } @@ -189,7 +202,7 @@ func WithDefaults(m Manifest, layers []Layer) []Layer { type SettingSource struct { Key string Value any - // From is DefaultLayer, MeshWideLayer or the node's name. + // From is DefaultLayer, MeshConfigLayer, NodeConfigLayer(node), MeshWideLayer or the node's name. From string // FromDefault is whether the value is the module's default, whatever From reads. FromDefault bool @@ -199,10 +212,11 @@ type SettingSource struct { } // Effective is every key a module gives a default or a layer sets, with its value and where it came -// from: the default, then the mesh's layer, then the node's — later wins. +// from: the default, then mesh config, then node config, then the module's own layer on the node — +// later wins (novox/hq issue 630). func Effective(m Manifest, layers []Layer) []SettingSource { byKey := map[string]*SettingSource{} - for key, d := range m.Settings { + for key, d := range PreferencesOf(m) { byKey[key] = &SettingSource{Key: key, Value: d.Default, From: DefaultLayer, FromDefault: true, Default: d.Default, HasDefault: true} } diff --git a/internal/catalogue/setting_into.go b/internal/catalogue/setting_into.go index ee04ee18..8f2b6002 100644 --- a/internal/catalogue/setting_into.go +++ b/internal/catalogue/setting_into.go @@ -188,7 +188,7 @@ func wholeID(v string) (uint64, bool) { func OwnerSettingProblems(m Manifest) []string { var problems []string asked := settingKeysAskedOutsideOwners(m) - for k := range m.Settings { + for k := range PreferencesOf(m) { asked[k] = true } for _, r := range m.Resources { diff --git a/internal/catalogue/setting_takes.go b/internal/catalogue/setting_takes.go index 0cb7fca4..f239eac0 100644 --- a/internal/catalogue/setting_takes.go +++ b/internal/catalogue/setting_takes.go @@ -43,7 +43,7 @@ func onlyTaken(layers []Layer, taken map[string]bool) []Layer { values[key] = value } } - kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default}) + kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default, Config: layer.Config}) } return kept } @@ -83,7 +83,7 @@ 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(PreferencesOf(m)) == 0 { return nil } var silent []string diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index 71357453..aa89f294 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -37,6 +37,9 @@ type Layer struct { // Default marks the layer a module's own defaults make (novox/hq ADR 0262). Marked rather than // recognised by From, which is a node's name for a node's layer, and a node may be called anything. Default bool + // Config marks a layer of mesh config or node config (novox/hq issue 630): values for predefined keys, + // given to the preferences that fall back to them. Like the defaults, it fills `${setting:}` only. + Config bool } // ApplySettings produces a resource's final content from the module's own and the layers over it. @@ -136,7 +139,7 @@ func overridden(base map[string]any, layers []Layer, what string) (map[string]an values[key] = value } } - kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default}) + kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default, Config: layer.Config}) } merged, err := settle(base, kept, nil, what) if err != nil { @@ -173,7 +176,7 @@ func settle(base map[string]any, layers []Layer, protected map[string]bool, what map[string]any, error) { merged := deepCopy(base) for _, layer := range layers { - if layer.Default { + if layer.Default || layer.Config { // A module's own defaults fill ${setting:} only (novox/hq ADR 0262). A mergeable // file's content already is its defaults, and a contribution or served fact declares its // own; laid on here, a default would reach every mergeable file of its module (issue 168). @@ -363,7 +366,7 @@ func UnusedSettings(m Manifest, layers []Layer) []string { } // A key the module gives a default reaches what asks for it (novox/hq ADR 0262): setting it // overrides the default, which is how a running module gains a setting without a gap between. - for key := range m.Settings { + for key := range PreferencesOf(m) { lands[key] = true } for _, values := range m.Contributes {