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. -
Explicit workspace roots are authoritative for console runtime loading: console config and dotenv lookup use the selected workspace root, and the filesystem data backend is bound to that same root. Console sessions are selected by workspace and cannot be reopened from another workspace.
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.