config: split configs per subsystem, extends: inheritance, and a directory config loader #169

Merged
art merged 8 commits from config-locality into main 2026-08-19 22:42:28 +00:00
Owner

Config locality: split configs per subsystem, add extends: and a directory config loader

Editing Sweden's roads used to mean paging through a ~2100-line conf/semap.yaml. It now means
opening conf/se/07-roads.yaml, a small file — and increasingly, not opening a file at all but
running a yq one-liner.

The layout

conf/<cc>/base.yaml          destination, table_prefix, country, include:
conf/<cc>/downloads.yaml     databases, downloads, geotorget:/lastkajen:/trafiklab:, sources
conf/<cc>/staging.yaml       the staging tables every layer query reads from
conf/<cc>/acceptance.yaml    config-level acceptance tests
conf/<cc>/NN-<layer>.yaml    one file per layer, numbered in build order
conf/shared/enums.yaml       the canonical enums + enum_rollups
conf/shared/contracts/       one file per shared layer contract
conf/shared/staging.yaml     the shared Natural Earth land staging table
conf/shared/acceptance.yaml  the cross-country config-level tests
conf/common.yaml             the thin aggregator every country includes

Reading a config path — a file or a directory of files merged in lexical order — is one function,
so a directory works in either position: importer import conf/se and
include: [conf/shared/contracts/] are the same mechanism. mergeConfig merges include: (a
latent bug), so a directory config keeps include: [conf/common.yaml].

extends: inheritance (importer/shared.go): a country layer that matches a shared contract
inherits the contract's scaffold — attr_names, attrs, generalization_strategy, stop_z,
maxz — for the fields it leaves unset. inheritSharedScaffold runs before applyCountry
injects the country column, and wireSharedLayers still enforces agreement after, with the
exact comparison against the contract's country-injected list (an earlier revision had
loosened this; with inheritance ordered correctly the strict check passes for all five configs,
so it is back). Genuinely per-country settings — roads' merge_lines/merge_from_zoom, ferries'
min_length_pixels — stay country-declared.

Making the layout survive the tool that edits it

SQL blocks are literal (query: |), never folded (query: >). This is what makes yq usable
here at all. A folded scalar is re-wrapped at 80 columns whenever anything writes the file, and one
yq -i on conf/se/07-roads.yaml moved the text after a -- comment onto its own line:

WHEN 'överfart och underfart' THEN NULL  -- both; drawn at normal level
(docs/geo-primer.md)                     ← no longer a comment; now SQL

183 blocks converted, verified three ways: every resolved config is identical outside the SQL
strings; all 155 SQL strings that changed differ only in whitespace and no change lands inside a
quoted literal
(checked with a scanner that tracks quote state and skips -- comments); and all
66 queries across the five configs PREPARE against the live database, 0 failed. A test rejects a
folded block so one cannot come back.

A file the loader would not read is an error, not a skip. loadConfigDir filtered on .yaml
while the single-file loader and the CLI both accept .yml, so renaming conf/se/07-roads.yaml
to .yml removed Sweden's entire roads layer and config-check reported OK — 14 layers.
Lexical file order is the DAG's build order, so which files are read is the map: .yml is read,
and anything else in a config directory fails loudly.

base.yaml is split the rest of the way. It was the largest file in conf/se — 635 lines, of
which staging: alone was 419 — so "editing roads is a small file" was true while "adding a
staging table" was still a 635-line read. conf/shared/contracts.yaml (1082 lines, 17 contracts)
becomes one file per contract, on the same argument: the roads contract is a thing you edit.

yq idioms are documented in docs/importer-cli.md, with the tool requirement in AGENTS.md:
mikefarah/yq v4, not the python yq that wraps jq — that one round-trips YAML through JSON
and deletes every comment (65 of 138 lines in conf/se/07-roads.yaml), and the per-layer
reasoning next to the query it justifies is the review surface. Every documented command was run
before it was written down.

Safety

  • Resolution equivalence was the hard gate and it holds: each country's fully-resolved config
    (through loadConfig + prepareConfig) is reflect.DeepEqual to what the old single-file
    loader produced, for all four countries, with the only difference being the extends: field
    itself. Re-verified after the >→| conversion, the base.yaml split and the contracts split.
  • The layer set and order per country is now asserted, permanently. It is what the NN- file
    names encode, and losing a file was otherwise silent.
  • make build, make test, config-check and check-queries pass for all five configs.
    make acceptance: Iceland 38/38, Sweden 44/44, Finland 49/49, Poland 44/45 — the one failure
    (R120: every Poland source part becomes a classified footprint, context deadline exceeded at
    its timeout: 10) reproduces identically on unmodified main and is not from this branch.
  • docs/country-requirements.md: R17 described the conf/<cc>map.yaml naming convention this
    change replaces, and R29's check was grep -n 'minz:' conf/<cc>map.yaml — a path that no
    longer exists, so the check passed without looking at anything. Both fixed; R29 now greps
    conf/ recursively for a real key.

Also

docs/superpowers/ is removed — plan artifacts from a planning tool, not project documentation.

Merges cleanly with #168; the two were built and tested merged together.

# Config locality: split configs per subsystem, add `extends:` and a directory config loader Editing Sweden's roads used to mean paging through a ~2100-line `conf/semap.yaml`. It now means opening `conf/se/07-roads.yaml`, a small file — and increasingly, not opening a file at all but running a `yq` one-liner. ## The layout ``` conf/<cc>/base.yaml destination, table_prefix, country, include: conf/<cc>/downloads.yaml databases, downloads, geotorget:/lastkajen:/trafiklab:, sources conf/<cc>/staging.yaml the staging tables every layer query reads from conf/<cc>/acceptance.yaml config-level acceptance tests conf/<cc>/NN-<layer>.yaml one file per layer, numbered in build order conf/shared/enums.yaml the canonical enums + enum_rollups conf/shared/contracts/ one file per shared layer contract conf/shared/staging.yaml the shared Natural Earth land staging table conf/shared/acceptance.yaml the cross-country config-level tests conf/common.yaml the thin aggregator every country includes ``` Reading a config path — a file or a directory of files merged in lexical order — is one function, so a directory works in either position: `importer import conf/se` and `include: [conf/shared/contracts/]` are the same mechanism. `mergeConfig` merges `include:` (a latent bug), so a directory config keeps `include: [conf/common.yaml]`. **`extends:` inheritance** (importer/shared.go): a country layer that matches a shared contract inherits the contract's scaffold — `attr_names`, `attrs`, `generalization_strategy`, `stop_z`, `maxz` — for the fields it leaves unset. `inheritSharedScaffold` runs before `applyCountry` injects the country column, and `wireSharedLayers` still enforces agreement after, with the **exact** comparison against the contract's country-injected list (an earlier revision had loosened this; with inheritance ordered correctly the strict check passes for all five configs, so it is back). Genuinely per-country settings — roads' `merge_lines`/`merge_from_zoom`, ferries' `min_length_pixels` — stay country-declared. ## Making the layout survive the tool that edits it **SQL blocks are literal (`query: |`), never folded (`query: >`).** This is what makes `yq` usable here at all. A folded scalar is re-wrapped at 80 columns whenever anything writes the file, and one `yq -i` on `conf/se/07-roads.yaml` moved the text after a `--` comment onto its own line: ``` WHEN 'överfart och underfart' THEN NULL -- both; drawn at normal level (docs/geo-primer.md) ← no longer a comment; now SQL ``` 183 blocks converted, verified three ways: every resolved config is identical outside the SQL strings; all 155 SQL strings that changed differ only in whitespace and **no change lands inside a quoted literal** (checked with a scanner that tracks quote state and skips `--` comments); and all 66 queries across the five configs `PREPARE` against the live database, 0 failed. A test rejects a folded block so one cannot come back. **A file the loader would not read is an error, not a skip.** `loadConfigDir` filtered on `.yaml` while the single-file loader and the CLI both accept `.yml`, so renaming `conf/se/07-roads.yaml` to `.yml` removed Sweden's entire roads layer and `config-check` reported `OK — 14 layers`. Lexical file order is the DAG's build order, so which files are read *is* the map: `.yml` is read, and anything else in a config directory fails loudly. **`base.yaml` is split the rest of the way.** It was the largest file in `conf/se` — 635 lines, of which `staging:` alone was 419 — so "editing roads is a small file" was true while "adding a staging table" was still a 635-line read. `conf/shared/contracts.yaml` (1082 lines, 17 contracts) becomes one file per contract, on the same argument: the roads contract is a thing you edit. **`yq` idioms are documented** in `docs/importer-cli.md`, with the tool requirement in AGENTS.md: mikefarah/yq v4, *not* the python `yq` that wraps `jq` — that one round-trips YAML through JSON and deletes every comment (65 of 138 lines in `conf/se/07-roads.yaml`), and the per-layer reasoning next to the query it justifies is the review surface. Every documented command was run before it was written down. ## Safety - **Resolution equivalence** was the hard gate and it holds: each country's fully-resolved config (through `loadConfig` + `prepareConfig`) is `reflect.DeepEqual` to what the old single-file loader produced, for all four countries, with the only difference being the `extends:` field itself. Re-verified after the `>`→`|` conversion, the base.yaml split and the contracts split. - **The layer set and order per country is now asserted**, permanently. It is what the `NN-` file names encode, and losing a file was otherwise silent. - `make build`, `make test`, `config-check` and `check-queries` pass for all five configs. `make acceptance`: Iceland 38/38, Sweden 44/44, Finland 49/49, Poland 44/45 — the one failure (`R120: every Poland source part becomes a classified footprint`, `context deadline exceeded` at its `timeout: 10`) **reproduces identically on unmodified `main`** and is not from this branch. - `docs/country-requirements.md`: **R17** described the `conf/<cc>map.yaml` naming convention this change replaces, and **R29**'s check was `grep -n 'minz:' conf/<cc>map.yaml` — a path that no longer exists, so the check passed without looking at anything. Both fixed; R29 now greps `conf/` recursively for a real key. ## Also `docs/superpowers/` is removed — plan artifacts from a planning tool, not project documentation. Merges cleanly with #168; the two were built and tested merged together.
applyCountry appends "country" to a layer's AttrNames in prepareConfig's
per-layer loop, so a bare extends: layer reached wireSharedLayers with
AttrNames == ["country"] (not nil) and the ==nil guard never fired - it would
have inherited nothing but the machinery country column, silently breaking the
Task 4 migration (an extends: roads + source block losing the contract's
attribute list).

Split the inheritance out of wireSharedLayers into inheritSharedScaffold,
which prepareConfig now calls before the applyCountry loop (on the bare layer,
where the ==nil / ==0 / =="" guards are correct), recording the resolved
contract on l.Shared. wireSharedLayers keeps only the agreement checks
(StopZ/Maxz, country-stripped attr list, attr types) and the contract-owned
MvtExclude/AbsorbBoundary/MinLengthPixels copies, reusing l.Shared. The shared
contracts' MinZoom population moves ahead of inheritance so stop_z validation
sees the resolved floor; sharedContracts and resolveContract factor the common
index/validation and resolution.

TestWireSharedLayersExtendsInheritsScaffold now drives prepareConfig end to end
and asserts the contract's attrs plus the injected country column.

No real config uses extends: yet and name-matched layers declare their full
scaffold, so real-config behavior is unchanged (TestFullPlanRealConfigs and
the per-config tests stay green).
conf/common.yaml becomes an include: aggregator over conf/shared/enums.yaml
(enums + enum_rollups), staging.yaml (the shared ne_110m_land table),
contracts.yaml (the 18 shared layer contracts) and acceptance.yaml (the
config-level acceptance tests); min_zoom/databases/downloads stay in
common.yaml. Every block moved verbatim — the resolveIncludes merge composes
to a byte-identical resolved config for all five real configs (verified by
diffing the resolved appConfig before/after the split).

The nested includes expose a test-harness gap: loadRealConfig (dag_test.go)
and TestSemapConfigParses (geotorget_test.go) resolved includes from the
importer/ cwd with a one-level manual join, which cannot reach a config that
includes a config that includes others. Both now resolve from the repo root
via loadConfig, the same way TestConfigCheckIsCheapAndSafe and
TestRealConfigsValidate already did.
The four country configs (conf/is|se|pl|fimap.yaml) become conf/<cc>/ directories:
conf/<cc>/base.yaml holds the per-country top level (destination, table_prefix,
country, secrets_file, databases, downloads, geotorget/lastkajen/trafiklab, staging,
sources, acceptance_tests) plus include: [conf/common.yaml]; one conf/<cc>/NN-<layer>.yaml
per layer, numbered so lexical order reproduces the original layer order the DAG uses.
A layer matching a shared contract now carries `extends: <contract>` instead of repeating
its attr_names/attrs/generalization_strategy/stop_z/maxz; what the contract does not own
(source, merge_lines/merge_from_zoom, country-specific min_length_pixels, zoom_filters,
min_area_pixels, strahler, per-layer reasoning) stays in the layer file. Iceland's
contours/peaks have no contract and keep their full scaffold.

Loader fix: mergeConfig never copied the Include field, so loadConfigDir dropped
base.yaml's include: [conf/common.yaml] and no shared contract reached the config.
Append src.Include in mergeConfig; extend TestLoadConfigDirectory to cover it.

Tests/Makefile/docs updated to the directory form: the bare-config hint now recognises
a config directory; loadRealConfig (and config_test) run inheritSharedScaffold before
applyCountry, which the extends: layers need; Makefile import/acceptance targets and
docs/importer-cli.md, architecture.md, schema.md, naming.md, README and the country docs
name conf/<cc>.

Verified: every country's directory config resolves identically to the old single file
(compared field-by-field through the same pipeline); TestFullPlanRealConfigs passes;
go test ./... and go vet clean; importer config-check conf/<cc> OK for all four with the
same layer/contract/sources/acceptance counts as before.
Four fixes to the config split, all of them about the same thing: making the layout
hold up under the tool that is supposed to edit it.

**A file the loader would not read is now an error, not a skip.** loadConfigDir
filtered on `.yaml` while the single-file loader and the CLI both accept `.yml`, so
renaming conf/se/07-roads.yaml to .yml removed Sweden's entire roads layer and
config-check reported `OK — 14 layers`. Lexical file order is the DAG's build order,
so which files are read *is* the map: `.yml` is read, and anything else in a config
directory (a `.orig`, a stray note, a subdirectory) fails loudly.

**SQL blocks are literal (`query: |`), not folded (`query: >`).** This is what makes
`yq` usable here at all. A folded scalar is re-wrapped at 80 columns whenever anything
writes the file, and one `yq -i` on conf/se/07-roads.yaml moved the text after a `--`
comment onto its own line:

    WHEN 'överfart och underfart' THEN NULL  -- both; drawn at normal level
    (docs/geo-primer.md)                     <- no longer a comment; now SQL

183 blocks converted. Verified equivalent three ways: every resolved config is
byte-identical outside the SQL strings; all 155 SQL strings that changed differ only
in whitespace, and no change lands inside a quoted literal (checked with a scanner
that tracks quote state and skips `--` comments); and all 66 queries across the five
configs PREPARE against the live database, 0 failed. A test now rejects a folded
block so one cannot come back.

**base.yaml is split the rest of the way.** It was the largest file in conf/se — 635
lines, of which `staging:` alone was 419 — so "editing Sweden's roads is a small file"
was true while "adding a staging table" was still a 635-line read. It is now base.yaml
(destination, prefix, country, include), downloads.yaml, staging.yaml and
acceptance.yaml, the files the design named. conf/shared/contracts.yaml (1082 lines,
17 contracts) becomes one file per contract, which is the same argument: the roads
contract is a thing you edit.

Reading a config path — file or directory — is one function now, so `include:` takes a
directory too and conf/common.yaml pulls the whole contract set in with one entry.

**The attr_names check is strict again.** The split had loosened it to compare both
sides with `country` removed; with the scaffold inherited before applyCountry runs,
the original exact comparison against the contract's country-injected list passes for
all five configs, so it is back and withoutCountry is gone.

Also: the layer set and order per country is now asserted (it is what the NN- file
names encode, and losing a file is silent otherwise), R17 describes the directory
layout it is supposed to be enforcing, R29's check greps `conf/` recursively for a
real key instead of a `conf/<cc>map.yaml` path that no longer exists — a check that
cannot fail is worse than no check — and the docs point at the file a contract
actually lives in.
Process artifacts from a planning tool, not project documentation: two plan files
and the design they came from. What was durable in them is in the docs that are
actually indexed from AGENTS.md — the config layout and the `yq` idioms in
docs/importer-cli.md, the generated style in docs/style.md — and the layout this repo
keeps is finished work in docs/, per-country in docs/country-*.md, and everything
still to do in Forgejo issues. There is no slot for a third tree of plans, and a plan
that outlives its branch is a doc that disagrees with the code.
art merged commit c051e78108 into main 2026-08-19 22:42:28 +00:00
art deleted branch config-locality 2026-08-19 22:42:28 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
art/ismap!169
No description provided.