ADR 0023 — Global vs local configuration separation¶
Status: Accepted
Context¶
ADR 0021 gave mklang its XDG layout and a clean first-hit-wins config chain, but four surfaces still broke the separation between the global host (user/system roots) and the local project:
- A project
.envshadowed the user.enventirely (elifinconfig.load_provider): a project defining onlyDEEPSEEK_API_KEYhid anANTHROPIC_API_KEYthat lived in the user host. mklang-mcppinnedconfig/runtime.example.yamlas its default--config— a checkout-relative path passed as explicit, which short-circuitsresolve_config. Spawned outside a checkout (how MCP clients launch it), the server never found the user host despite the docs claiming it would.- The console workspace defaulted to
./machinesunconditionally, even when no such directory existed — authored machines never landed in the user machines root thatinit --userseeds. --hitlrefused to run without an explicit--checkpoint, although the ADR 0021 table names the state root as the home of checkpoints.
There was also no way to see which layer won: setup failures surfaced only at run time, one at a time.
Decision¶
- One resolution principle: local wins per surface, global fills the gaps.
| Surface | Local (project) | Global (user/system) | Semantics |
|---|---|---|---|
runtime.yaml |
./config/runtime.yaml |
user config root, /etc/mklang |
first hit wins (whole file) |
.env |
nearest .env up from cwd |
<config root>/.env |
per key: project wins, user fills |
| machines | ./machines / --dir |
user machines, /usr/share/mklang |
later layer wins per machine name |
| checkpoints | explicit --checkpoint path |
<state root>/checkpoints/ (default) |
explicit wins |
runtime.yaml stays first-hit-wins on purpose: a run is governed by exactly
one config file, never a deep merge of several. .env is the one per-key
surface because keys are independent secrets, not one document.
-
Every entry point uses the same chain.
mklang-mcp's default--configbecomesNone, flowing throughresolve_configexactly like the CLI (explicit →$MKLANG_CONFIG→ project → user →/etc→ bundled).resolve_config_with_layernames the winning layer for diagnostics. -
The console workspace follows the local-then-global rule:
./machineswhen present, else the user machines root. An explicit--workspacealways wins. -
--hitlwithout--checkpointsuspends into<state root>/checkpoints/<machine>-<stamp>-<uuid>.jsoninstead of erroring; the suspension message prints the path,mklang resumetakes it from there. -
mklang init --userreaches parity with project init: it also copiesruntime.schema.jsonnext toruntime.yaml, so the example's yaml-language-server header validates in both locations. -
mklang doctormakes the separation observable: resolved config path and winning layer, which.envfiles loaded, per-provider key status (active-provider gaps are errors,localis exempt like the run-time key gate), machine roots with counts, and the state paths. Exit 1 when the active provider cannot run.
Consequences¶
- A host configured once with
mklang init --usernow behaves identically for the CLI, the console, and the MCP server, from any directory. - Project overrides stay possible everywhere without hiding the global host:
dropping a one-line
.envinto a project no longer disables every other provider key. - Tests must isolate the user host (
MKLANG_CONFIG_DIR/MKLANG_DATA_DIR/MKLANG_STATE_DIR) — a developer's real~/.config/mklangmust never leak into assertions. The suites were hardened accordingly. mklang-mcpclients can drop the manual--config /abs/pathworkaround.