From 2f40ae5f76791814e5269e00016a4275b42d6f76 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 01:36:45 +0000 Subject: [PATCH] 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