RFD 035: Multi-Root Config Load Path Resolution
- Status: Implemented
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-03-08
Summary
This RFD extends --cfg path resolution to search across all three config roots (user-global, workspace, user-workspace), merging matches in precedence order. Currently, --cfg paths are resolved exclusively against the workspace root, meaning user-global and user-workspace config files are unreachable.
Motivation
JP loads config.toml from three locations, in precedence order:
- User-global:
$XDG_CONFIG_HOME/jp/config.toml— private to the user, shared across all workspaces. - Workspace:
<workspace_root>/.jp/config.toml— shared with the team via version control. - User-workspace:
$XDG_DATA_HOME/jp/workspace/<id>/config.toml— private to the user, scoped to a single workspace.
This layering lets teams define shared defaults while individual users override them privately. The --cfg flag extends this by loading named config fragments (like skill/web or personas/dev) from directories listed in config_load_paths.
The problem: --cfg resolution only searches config_load_paths relative to the workspace root. A user who places skill/web.toml in their user-global config directory gets an error — the file is never found. There is no way to define personal --cfg-loadable config fragments that apply across all workspaces, or personal fragments scoped to a single workspace.
This breaks the layering model. A user who wants a personal skill/web override must either modify the workspace config (polluting the shared config), or pass the full path every time (--cfg $XDG_CONFIG_HOME/jp/...).
Design
Current behavior
load_cli_cfg_args in crates/jp_cli/src/lib.rs handles --cfg path arguments. When the argument is not an existing file and not a key-value pair, it iterates config_load_paths, resolving each entry against workspace.root():
let config_load_paths = workspace.iter().flat_map(|w| {
partial.config_load_paths.iter().flatten().filter_map(|p| {
Utf8PathBuf::try_from(p.to_path(w.root()))
// ...
})
});It then calls find_file_in_load_path for each resolved path and stops at the first match (break). Only the workspace root is ever searched.
Proposed behavior
Resolve config_load_paths against three roots instead of one, searching each in precedence order (lowest to highest). If a match is found in multiple roots, all matches are loaded and merged, with later roots taking precedence.
The three roots, and how config_load_paths entries are resolved against them:
| Precedence | Source | Resolution root |
|---|---|---|
| 1 (lowest) | User-global | $XDG_CONFIG_HOME/jp/config/ |
| 2 | Workspace | <workspace_root>/ |
| 3 (highest) | User-workspace | $XDG_DATA_HOME/jp/workspace/<id>/config/ |
The user-global and user-workspace roots use a config/ subdirectory as the resolution base. This prevents config_load_paths entries from polluting the top-level directory structure of those locations, which contain non-config entries (workspace/, storage symlink, conversations/, etc.).
The workspace root does not need this sandboxing — config_load_paths entries like .jp/config are already scoped by convention.
Example
Given config_load_paths = [".jp/config"] and --cfg skill/web, the search order is:
$XDG_CONFIG_HOME/jp/config/.jp/config/skill/web.toml<workspace_root>/.jp/config/skill/web.toml$XDG_CONFIG_HOME/jp/workspace/<id>/config/.jp/config/skill/web.toml
If files exist at positions 1 and 2, both are loaded. The workspace file (2) is merged on top of the user-global file (1). If only one exists, it is loaded as-is.
Merge behavior
Within a single root, the existing first-match-wins behavior is preserved — if config_load_paths contains multiple entries, only the first entry that produces a match is used. Across roots, all matches are merged in precedence order using the existing load_partial merge function.
Code changes
The change is localized to the KeyValueOrPath::Path branch in load_cli_cfg_args (crates/jp_cli/src/lib.rs). The function already receives workspace: Option<&Workspace>, which provides access to both root() and user_storage_path(). The user-global path is available via user_global_config_path().
Sketch:
KeyValueOrPath::Path(path) => {
let home = std::env::home_dir()
.and_then(|p| Utf8PathBuf::from_path_buf(p).ok());
// Build search roots in precedence order (lowest first).
let mut roots: Vec<Utf8PathBuf> = Vec::new();
if let Some(global_dir) = user_global_config_path(home.as_deref()) {
roots.push(global_dir.join("config"));
}
if let Some(w) = workspace {
roots.push(w.root().to_owned());
}
if let Some(user_ws_dir) = workspace.and_then(Workspace::user_storage_path) {
roots.push(user_ws_dir.join("config"));
}
let mut matches: Vec<PartialAppConfig> = Vec::new();
for root in &roots {
for load_rel in partial.config_load_paths.iter().flatten() {
let Ok(load_path) = Utf8PathBuf::try_from(load_rel.to_path(root))
else {
continue;
};
if let Some(file) = find_file_in_load_path(path, &load_path) {
if let Some(p) = load_partial_at_path(file)? {
matches.push(p);
}
break; // first match within this root
}
}
}
if matches.is_empty() {
return Err(Error::MissingConfigFile(path.clone()));
}
for p in matches {
partial = load_partial(partial, p)?;
}
}Drawbacks
The resolved paths for user-global and user-workspace are deep (e.g.
$XDG_CONFIG_HOME/jp/config/.jp/config/skill/web.toml). The.jp/confignesting insideconfig/looks redundant, but it is a direct consequence of treatingconfig_load_pathsentries uniformly across all roots. Avoiding this would require per-root load path configuration, which adds complexity for marginal benefit.The
config/subdirectory convention for user-global and user-workspace roots is implicit — it is not configurable by the user. This is a deliberate trade-off to keep the design simple.
Alternatives
Resolve config_load_paths directly against all roots (no config/ subdir)
Resolve .jp/config against $XDG_CONFIG_HOME/jp/ directly, producing $XDG_CONFIG_HOME/jp/.jp/config/skill/web.toml.
Rejected because it allows config_load_paths entries to conflict with existing directory structure in those roots (e.g. a load path of workspace would clash with $XDG_CONFIG_HOME/jp/workspace/).
Hardcode config/ as the search subdir for non-workspace roots
Rather than resolving config_load_paths against the non-workspace roots, just search <root>/config/<cfg_path> directly (ignoring config_load_paths).
Rejected because it breaks the uniformity of the search — the same --cfg argument would use different resolution logic depending on the root. It also means config_load_paths has no effect on non-workspace roots, which is surprising.
Non-Goals
Changes to
config.tomlloading order. The existingload_partial_configs_from_filesfunction already loadsconfig.tomlfrom all three roots correctly. This RFD only extends--cfgpath resolution to match that same multi-root behavior.Multi-root resolution for
extends. Theextendsfield is not affected by this change. Unlikeconfig_load_paths, which is a runtime lookup mechanism where the merged list is searched at CLI invocation time,extendsis resolved per-file during config loading. Eachconfig.tomlresolves itsextendspaths relative to its own parent directory, and the merge strategy (schematic::merge::preserve) ensures the first-loaded value wins rather than accumulating across sources. This means each config root already controls its own extensions independently — the user-globalconfig.tomlcan extend files next to it, the workspaceconfig.tomlcan extend files next to it, and so on. There is no cross-root resolution problem to solve.New CLI flags or config schema changes. The
config_load_pathsfield and--cfgflag work as before. The only change is where the paths are searched.
Risks and Open Questions
Directory creation. Should
jp init(or first use) create theconfig/subdirectory in user-global and user-workspace locations? Or rely on the user to create them manually?Error reporting. When
--cfg skill/webfails to find a file, the error currently reports just the path. It should list all roots that were searched to help the user understand where to place the file.inherit = falseinteraction. If a workspaceconfig.tomlsetsinherit = false, should that suppress loading--cfgfiles from the user-global root? Currentlyinheritonly affectsconfig.tomlloading, not--cfg. This RFD preserves that behavior, but it may warrant a follow-up.
Implementation Plan
This is a single-phase change, localized to load_cli_cfg_args in crates/jp_cli/src/lib.rs:
- Compute the three search roots (user-global +
config/, workspace root, user-workspace +config/). - For each root, resolve
config_load_pathsand search for the--cfgpath. - Collect and merge all matches in precedence order.
- Improve the
MissingConfigFileerror to list searched paths. - Update
docs/configuration.mdto document the multi-root search behavior.
References
- Configuration documentation — current
--cfgandconfig_load_pathsbehavior. crates/jp_cli/src/lib.rs—load_cli_cfg_args,load_partial_configs_from_files.crates/jp_config/src/fs.rs—user_global_config_path.crates/jp_config/src/util.rs—find_file_in_load_path.