From fda71e5e5c4fa097f88d8c421696b1388688616e Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 18:50:43 +0000 Subject: [PATCH] Bring F3 up to its release branch, with both sides' store tests (part 9 of 14) Co-Authored-By: Claude Opus 5.5 --- internal/catalogue/placement.go | 67 ++++++- internal/catalogue/placement_test.go | 42 +++- internal/catalogue/seats.go | 14 +- internal/catalogue/served_by_setting_test.go | 194 +++++++++++++++++++ internal/catalogue/setting_defaults.go | 29 ++- internal/catalogue/setting_defaults_test.go | 2 +- internal/catalogue/setting_into.go | 57 ++++-- internal/catalogue/setting_takes.go | 3 +- 8 files changed, 385 insertions(+), 23 deletions(-) create mode 100644 internal/catalogue/served_by_setting_test.go diff --git a/internal/catalogue/placement.go b/internal/catalogue/placement.go index 400c5b3f..bc8bf03d 100644 --- a/internal/catalogue/placement.go +++ b/internal/catalogue/placement.go @@ -34,6 +34,69 @@ const PlacesSetting = "places" // AccessesSetting is the settings key that says where a module's accesses are on this node, by id. const AccessesSetting = "accesses" +// **Placement is not config** (novox/hq ADR 0348 clause 11, issue 651). `places`, `accesses` and +// `networks` are facts of an assignment the controller decides, held beside the assignment and apart +// from its settings: a Layer carries them in Placement, never in Values, and only Places, AccessPlaces +// and KeptNetworks read them. A person still says them as keys of a layer through `settings`; LayerOf +// splits such a layer, so what they type is routed to the placement and nothing they do changes. + +// placementKeys are the keys of a layer that are placement, not config. +var placementKeys = map[string]bool{PlacesSetting: true, AccessesSetting: true, NetworksSetting: true} + +// SplitPlacement divides a layer as a person says it into its config values and its placement. Each is +// nil when it holds nothing; the values themselves are not copied. +func SplitPlacement(values map[string]any) (config, placement map[string]any) { + for key, value := range values { + if placementKeys[key] { + if placement == nil { + placement = map[string]any{} + } + placement[key] = value + continue + } + if config == nil { + config = map[string]any{} + } + config[key] = value + } + return config, placement +} + +// LayerOf is a layer as a person says it — config values and placement in one map — split into the two. +func LayerOf(from string, values map[string]any) Layer { + config, placement := SplitPlacement(values) + if config == nil { + config = map[string]any{} + } + return Layer{From: from, Values: config, Placement: placement} +} + +// Whole is the layer as a person says it: its values and its placement in one map, a new map. +func (l Layer) Whole() map[string]any { + out := make(map[string]any, len(l.Values)+len(l.Placement)) + for k, v := range l.Values { + out[k] = v + } + for k, v := range l.Placement { + out[k] = v + } + return out +} + +// asSaid is the layers with placement folded back into the values, for what reads a layer whole as it +// did while placement was kept in it: today's mergeable file, which takes every key (ADR 0326 §3), and +// the judgement that no value holds a line end. +func asSaid(layers []Layer) []Layer { + out := make([]Layer, len(layers)) + for i, l := range layers { + out[i] = l + if len(l.Placement) > 0 { + out[i] = Layer{From: l.From, Values: l.Whole(), Default: l.Default} + } + } + return out +} + // Placement is what an assignment says about one of a module's directories. type Placement struct { // Path is where the directory is on this machine. Absolute. @@ -208,7 +271,7 @@ func Places(m Manifest, layers []Layer) (map[string]Placement, error) { } out := map[string]Placement{} for _, layer := range layers { - raw, ok := layer.Values[PlacesSetting] + raw, ok := layer.Placement[PlacesSetting] if !ok { continue } @@ -278,7 +341,7 @@ func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) { } out := map[string]string{} for _, layer := range layers { - raw, ok := layer.Values[AccessesSetting] + raw, ok := layer.Placement[AccessesSetting] if !ok { continue } diff --git a/internal/catalogue/placement_test.go b/internal/catalogue/placement_test.go index 1b02ca8d..e75d770b 100644 --- a/internal/catalogue/placement_test.go +++ b/internal/catalogue/placement_test.go @@ -23,7 +23,7 @@ func placeable() Manifest { } func placedBy(values map[string]any) Rendering { - return Rendering{Settings: SettingsBy{"arr": {{From: "node anchor", Values: values}}}} + return Rendering{Settings: SettingsBy{"arr": {LayerOf("node anchor", values)}}} } func TestAnAssignmentPlacesDirectoriesAndAccesses(t *testing.T) { @@ -179,3 +179,43 @@ func TestAnUnknownAccessReferenceIsRefusedAtParse(t *testing.T) { t.Fatalf("an access with no id and no path was accepted: %v", err) } } + +// **Placement is read from the placement, never from a layer's values** (novox/hq ADR 0348 clause 11, issue +// 651). A `places` or `accesses` among the config values places nothing; the same in Placement does. A +// mergeable file of today's form still takes every key of the layer as it is said, placement too, so it +// composes as it did while placement was kept in the layer. +func TestPlacementIsReadFromThePlacementOnly(t *testing.T) { + m := placeable() + said := map[string]any{ + PlacesSetting: map[string]any{"config": "/services/arr/config"}, + AccessesSetting: map[string]any{"series": "/storage/media/series", "spool": "/storage/downloads"}, + "log": "warn", + } + among := []Layer{{From: "anchor", Values: said}} + if placed, err := Places(m, among); err != nil || placed != nil { + t.Fatalf("a place among the config values placed %v (%v)", placed, err) + } + if _, _, err := accessesFor(m, among); err == nil { + t.Fatal("an access among the config values placed it") + } + beside := []Layer{LayerOf("anchor", said)} + if beside[0].Values["log"] != "warn" || beside[0].Values[PlacesSetting] != nil || beside[0].Placement[PlacesSetting] == nil { + t.Fatalf("LayerOf split %+v", beside[0]) + } + if placed, err := Places(m, beside); err != nil || placed["config"].Path != "/services/arr/config" { + t.Fatalf("the placement did not place: %v (%v)", placed, err) + } + + file := map[string]any{"id": "runtime", "type": "file", "path": "/x", "content": `{"a":1}`, "merge": "json"} + whole, err := ApplySettings(file, among) + if err != nil { + t.Fatal(err) + } + split, err := ApplySettings(file, beside) + if err != nil { + t.Fatal(err) + } + if whole["content"] != split["content"] || !strings.Contains(split["content"].(string), `"places"`) { + t.Fatalf("today's mergeable file composes %q, was %q", split["content"], whole["content"]) + } +} diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 8602d507..667ea4f1 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -130,7 +130,19 @@ var defaultSeats = append([]Seat{ // The vault: the controller seals every minted credential with what it provides, which is the // test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a // second claimant, refused by name, rather than a candidate for a pin. - {Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161"}, + // + // Its one verb, `get`, hands a secret on request and reply, sealed to the key the asker sends and only + // to an asker the vault allows (novox/hq ADR 0349 §11, issue 674). Optional until the vault that runs + // serves it (design 33 §7). + {Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161", + Serves: []Verb{ + {Name: "get", Optional: true, Description: "One secret, sealed to the public key the asker sends (the " + + "sealed box of ADR 0206), with its generation; answered only to an asker the vault allows, as the " + + "bus proved it. Never the value in the clear, and never to a private key.", + Input: schema(map[string]string{"name": "the secret's name", + "public_key": "the asker's X25519 public key as SPKI PEM — never a private key"}, + []string{"name", "public_key"})}, + }}, // Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as // an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes — // images and archives, by digest — which is why the provision is the artifact store and not an diff --git a/internal/catalogue/served_by_setting_test.go b/internal/catalogue/served_by_setting_test.go new file mode 100644 index 00000000..681038d6 --- /dev/null +++ b/internal/catalogue/served_by_setting_test.go @@ -0,0 +1,194 @@ +package catalogue + +import ( + "bytes" + "encoding/json" + "os" + "strings" + "testing" +) + +// influxdb and grafana as the catalogue declares them, cut to what composes the data source on a +// machine like ace (novox/hq issue 696): what influxdb serves for influxdb-api and its mergeable +// runtime configuration, and grafana's binding, data source file and credential. +const grafanaLikeTheCatalogue = `{ + "module": "grafana", "version": "1", + "requires": [{"name": "influxdb-api", "optional": true}], + "contributes": {"influxdb-api": {"access": "read"}}, + "binds": {"influxdb-api": "${dir:state}/influxdb.json"}, + "secrets": {"influxdb-api": "${dir:mesh-state}/influxdb-api"}, + "resources": [ + {"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"}, + {"id": "state", "type": "directory", "mode": "0700", "place": "."}, + {"id": "influxdb-datasources", "type": "directory", "mode": "0755", "owner": "472:0"}, + {"id": "influxdb-datasource", "type": "file", "path": "${dir:influxdb-datasources}/mesh-influxdb.yaml", "mode": "0644", + "content": "apiVersion: 1\n# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330):\n# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and\n# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because\n# the mesh resets it. The password is read from the file the mesh delivers, never written here.\ndatasources:\n - name: InfluxDB (mesh)\n uid: mesh-influxdb-api\n type: influxdb\n access: proxy\n url: ${bound:influxdb-api:scheme}://${bound:influxdb-api:at}:${bound:influxdb-api:port}\n user: ${bound:influxdb-api:as}\n isDefault: false\n editable: false\n jsonData:\n dbName: ${bound:influxdb-api:bucket}\n httpMode: POST\n secureJsonData:\n password: $__file{/run/secrets/influxdb/influxdb-api}\n", + "only-when-bound": "influxdb-api"} + ] +}` + +// influxdbServing is influxdb as the catalogue declared it before issue 696 (literal served org and +// bucket) or after (served from its settings, today's literals as their defaults). +func influxdbServing(after bool) string { + served := `"org": "mesh", "bucket": "default"` + settings, takes := ``, `` + if after { + served = `"org": "${setting:org}", "bucket": "${setting:bucket}"` + settings = `"settings": { + "org": {"kind": "preference", "default": "mesh", "why": "the org influxdb makes at its first start"}, + "bucket": {"kind": "preference", "default": "default", "why": "the bucket influxdb makes at its first start"}},` + takes = `, "takes-settings": ["bucket", "org", "url"], "trusted": true` + } + return `{ + "module": "influxdb", "version": "1", + "provides": [{"name": "influxdb-api", "scope": "mesh", "identity": {"in": "an InfluxDB v1 authorization"}}], + "listens": [{"name": "api", "port": 8086, "protocol": "tcp", "from": "mesh", "why": "queries and writes"}], + ` + settings + ` + "serves": {"influxdb-api": {"scheme": "http", "port": 8086, ` + served + `}}, + "resources": [ + {"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"}, + {"id": "runtime-config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", + "content": "{}\n", "merge": "json"` + takes + `} + ] +}` +} + +// composedLikeAce is every resource of grafana and influxdb as one machine composes them, by id, with +// influxdb's settings layer on it. +func composedLikeAce(t *testing.T, influxdb string, layers []Layer) map[string]string { + t.Helper() + var ms []Manifest + for _, raw := range []string{influxdb, grafanaLikeTheCatalogue} { + m, err := ParseManifest([]byte(raw)) + if err != nil { + t.Fatal(err) + } + ms = append(ms, m) + } + r, err := Resolve(shelf(ms...), []string{"influxdb", "grafana"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := r.Declaration(Rendering{Settings: SettingsBy{"influxdb": layers}, + Needed: map[string]map[string]string{}}) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, res := range out { + id, _ := res["id"].(string) + if !strings.HasPrefix(id, "grafana.") && !strings.HasPrefix(id, "influxdb.") { + continue + } + b, err := json.Marshal(res) + if err != nil { + t.Fatal(err) + } + got[id] = string(b) + } + return got +} + +// aceLikeLayer is a layer like influxdb's on ace: its own org and bucket (made up here), and the +// endpoint block the mesh reads itself. +func aceLikeLayer() []Layer { + return []Layer{{From: "ace", Values: map[string]any{ + "org": "made-up-org", "bucket": "made-up-bucket", + "endpoints": map[string]any{"api": map[string]any{"label": "influx", "port": float64(8086), "reach": "internal"}}, + }}} +} + +// grafana composes byte for byte as it did before issue 696, on a machine like ace and on one +// without a layer, now that influxdb serves its org and bucket from its settings and no layer +// replaces a served key. The golden files are grafana's resources as the integration branch's +// controller composed them at 71362c1 (Settle still overriding), with influxdb serving the literals. +func TestGrafanaComposesTheSameOnceInfluxdbServesItsSettings(t *testing.T) { + for name, layers := range map[string][]Layer{"ace": aceLikeLayer(), "unlayered": nil} { + raw, err := os.ReadFile("testdata/influxdb-served-by-setting/grafana-" + name + ".json") + if err != nil { + t.Fatal(err) + } + var want map[string]json.RawMessage + if err := json.Unmarshal(raw, &want); err != nil { + t.Fatal(err) + } + got := composedLikeAce(t, influxdbServing(true), layers) + seen := 0 + for id, v := range got { + if !strings.HasPrefix(id, "grafana.") { + continue + } + seen++ + w, ok := want[id] + if !ok { + t.Errorf("%s: %s composes and did not before", name, id) + continue + } + var compact bytes.Buffer + if err := json.Compact(&compact, w); err != nil { + t.Fatal(err) + } + if compact.String() != v { + t.Errorf("%s: %s changed:\n got %s\nwant %s", name, id, v, compact.String()) + } + } + if seen != len(want) { + t.Errorf("%s: %d of grafana's %d resources compose", name, seen, len(want)) + } + } +} + +// What a provider serves is its definition's, filled from settings only where it asks with +// ${setting:} (novox/hq issue 696, ADR 0348 clause 11): a layer that sets a key the provider +// serves as a literal replaces nothing, and a key it does not serve is never added. +func TestALayerNoLongerReplacesAServedKey(t *testing.T) { + served, err := Settle(map[string]any{"host": "mail", "port": 25}, + []Layer{{From: "the mesh", Values: map[string]any{"host": "post", "sitename": "Mail"}}}) + if err != nil { + t.Fatal(err) + } + if served["host"] != "mail" { + t.Errorf("a layer replaced the served host: %v", served) + } + if _, leaked := served["sitename"]; leaked { + t.Errorf("a setting for a file reached the consumers: %v", served) + } + + // And a layer setting it is said to reach nothing, rather than taken for a change. + m := Manifest{Module: "mail", Serves: map[string]map[string]any{"smtp": {"host": "mail"}}} + if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"host": "post"}}}); len(unused) != 1 { + t.Errorf("a layer setting a literal served key was not said to reach nothing: %v", unused) + } + + // influxdb as it was before issue 696, serving literals, under a layer like ace's: the layer no + // longer reaches grafana — which is why the catalogue moves influxdb first. + got := composedLikeAce(t, influxdbServing(false), aceLikeLayer()) + for _, id := range []string{"grafana.influxdb-datasource", "grafana.bound-influxdb-api"} { + if strings.Contains(got[id], "made-up") { + t.Errorf("%s still carries the layer's value over a literal served key: %s", id, got[id]) + } + } + if !strings.Contains(got["grafana.influxdb-datasource"], `dbName: default\n`) { + t.Errorf("the data source does not name the served literal: %s", got["grafana.influxdb-datasource"]) + } +} + +// influxdb's own runtime configuration takes the keys it names: org and bucket from a layer like +// ace's, and not the endpoint block the mesh reads itself, which today's form wrote there too. +func TestInfluxdbsConfigTakesItsOrgAndBucket(t *testing.T) { + got := composedLikeAce(t, influxdbServing(true), aceLikeLayer())["influxdb.runtime-config"] + var r map[string]any + if err := json.Unmarshal([]byte(got), &r); err != nil { + t.Fatal(err) + } + if want := "{\n \"bucket\": \"made-up-bucket\",\n \"org\": \"made-up-org\"\n}\n"; r["content"] != want { + t.Errorf("config.json: got %q, want %q", r["content"], want) + } + got = composedLikeAce(t, influxdbServing(true), nil)["influxdb.runtime-config"] + if err := json.Unmarshal([]byte(got), &r); err != nil { + t.Fatal(err) + } + if r["content"] != "{}\n" { + t.Errorf("a default reached the mergeable file: %q", r["content"]) + } +} diff --git a/internal/catalogue/setting_defaults.go b/internal/catalogue/setting_defaults.go index 1bc72f67..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,11 +225,16 @@ 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} } for _, layer := range layers { - for key, v := range layer.Values { + // As the layer is said, placement too: `settings` answers it as a person set it. + for key, v := range layer.Whole() { s, ok := byKey[key] if !ok { s = &SettingSource{Key: key} diff --git a/internal/catalogue/setting_defaults_test.go b/internal/catalogue/setting_defaults_test.go index f23fb5e1..d5e50dad 100644 --- a/internal/catalogue/setting_defaults_test.go +++ b/internal/catalogue/setting_defaults_test.go @@ -427,7 +427,7 @@ func TestALeftOutModulesUnplaceableBackupLineCostsOnlyThatLine(t *testing.T) { r := anAdoptedAnchor() r.Modules = append(r.Modules, holder, media, placedBadly) with := anchorRendering(false) - with.Settings["notes"] = []Layer{{From: "anchor", Values: map[string]any{PlacesSetting: "not a map"}}} + with.Settings["notes"] = []Layer{LayerOf("anchor", map[string]any{PlacesSetting: "not a map"})} composed, err := r.Compose(with) if err != nil { t.Fatalf("an unplaceable line of a left-out module failed the machine: %v", err) 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/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