0039 — Layered config: an ordered `extends` stack with explicit array append
0039 — Layered config: an ordered extends stack with explicit array append
TL;DR. The two-layer merge (embedded defaults under the user file)
becomes an ordered stack: defaults, then layers named by a top-level
extends = ["path-or-name"] key, then the user file. Layers may extend
further layers up to a small fixed depth; cycles and missing files are
hard errors naming the offending file. Arrays still replace wholesale;
a -append key suffix opts a specific array into appending.
Status: Accepted Date: 2026-07-09
Context
ADR-0023 committed phux to
pure-config: an embedded default.toml is the live base layer and the
user’s config.toml is a sparse overlay merged leaf-by-leaf on top.
That merge supports exactly two layers. A curated distribution — a
lazyvim-style starter that ships opinionated keybindings, a status-bar
arrangement, and a plugin set — has nowhere to sit: it must either
replace the user’s file (freezing the distro at copy time, the same
anti-pattern ADR-0023 rejects for scaffolds) or be pasted inline and
drift. Separately, whole-array replacement means any layer that touches
[[plugins]] or a status slot silently erases what the layer below
contributed, so even a hand-rolled include mechanism could not compose
plugin sets.
Decision
-
Ordered layer stack. Effective config = embedded
default.toml<- extended layers (depth-first, in listed order) <- the declaring file. Later layers win per leaf; tables merge recursively as before. -
extendsis an explicit top-level key, an array of strings in any config file. An entry containing a path separator or ending in.tomlis a path, resolved relative to the directory of the file that declares it (absolute paths pass through). A bare namenresolves tolayers/n.tomlbeside the declaring file.extendsis consumed during layer resolution and never reaches the typed schema;parse_str(no defaults, no I/O) continues to reject it as an unknown field. -
Recursion is bounded and acyclic. Layers may themselves declare
extends, at most 4 levels below the user file. A file already on the current resolution chain is a cycle error; a file already merged via another branch (diamond) is skipped, so its values and appends apply exactly once. Every resolution error — unreadable layer, badextendsvalue, cycle, depth — names the offending file. -
Arrays replace wholesale by default;
-appendopts in. A keyx-appendwhose value is an array appends its elements to the layer stack’s current value ofx(creating it when absent). This exists for the arrays a distro must contribute to rather than own:[[plugins]]and[[satellites]](a distro adds manifests without erasing the user’s or a lower layer’s),status.left/center/right(add a widget beside inherited ones), and[[hooks.<name>]](add a hook without clobbering lower-layer hooks for the same event). Keybindings need no append mechanism:prefix-tableandglobalare tables and already merge per chord. Appending to a non-array, a non-array-appendvalue, or setting bothxandx-appendin the same file is an error naming that file. The-appendsuffix is reserved at every table level; a free-form key (for example a[theme]slot) may not end in-append.
Why
An extends stack preserves ADR-0023’s core property at every level:
each file carries only overrides, so a distro update reaches its users
the same way a phux default change does, and the user file stays a
sparse diff on top of the distro. Making the reference explicit and
top-level keeps “what is my config” answerable by reading one file.
Append-by-suffix rather than merging all arrays keeps replacement — the predictable, documented behavior — as the default, while giving the composition-shaped arrays a way to compose. The suffix is visible in the file itself, so intent survives copy-paste; no schema annotation or out-of-band mode switch is needed. The depth cap and cycle guard exist because config loading runs on every CLI invocation and must fail fast and legibly, not walk an unbounded include graph.
Tradeoffs
- No element identity, so no override-in-place or removal. Append
can only add; a user who wants to drop a distro’s plugin or widget
must replace the whole array (plain
x =wins over inherited appends). Element-level identity (merge by plugin id) is deliberately out of scope. - A reserved key suffix.
-appendis claimed at every table depth, which constrains free-form key/value tables like[theme]. phux config showoutput grows less obvious. The effective merge now spans N files;showstill renders the final table but cannot attribute a value to a layer. Attribution shipped asphux config show --layers(phux-r82.4).- Diamond dedupe is positional. A layer included twice keeps its first position in the stack; a later re-include cannot re-raise its precedence. Simple, but occasionally surprising.
Alternatives
Imperative mutation (set-option, “run this snippet to install the
distro”). Rejected in ADR-0023 and rejected again here: it forks the
source of truth and turns distro install into a one-shot copy that
drifts immediately.
Implicit deep-merge of all arrays. Rejected: TOML arrays have no per-element identity, so element-wise merging either duplicates entries (hooks, plugins listed twice) or invents matching heuristics; and it removes the ability to replace a list at all, which the status-bar slots rely on today.
Schema-annotated merge modes (the Rust schema marks which fields append). Rejected: the behavior would be invisible in the file being edited, and every schema addition would need a merge-mode decision; the suffix keeps the semantics local to the config text.
Distro as a replacement default.toml. Rejected: it forks the
embedded defaults, loses phux-release default updates, and still
supports only one distro with no user-side stacking.