RFD D61: Root-Qualified Extends Paths
- Status: Draft
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-08-18
Summary
This RFD lets an extends entry name the config root its path is resolved against:
extends = [{ root = "workspace", path = "crates/my-project/.jp/config.toml" }]Today every extends path resolves relative to the directory of the declaring file, so a config file in one root cannot reliably reach a file in another.
Motivation
JP resolves the two load-time path mechanisms against different anchors:
| Mechanism | Resolved relative to |
|---|---|
config_load_paths | the workspace root |
extends | the directory of the declaring file |
That asymmetry is invisible until a user wants a higher-precedence root to pull in a file that lives in the workspace. The concrete case: a second project is developed inside another workspace, in a directory excluded from the repository. Its workspace config lives with it, and the user-workspace config makes it reachable:
# user-workspace config.toml
config_load_paths = ["crates/my-project/.jp/config"]jp q -c my-project then finds my-project.toml there, and the project's config can be edited in place by the assistant, because it sits inside the workspace.
What that cannot express is a fragment that must load on every invocation rather than on -c my-project. Deferred loading only happens when --cfg names an entry (RFD 079), so an always-on fragment has to come from an implicitly loaded source, and the only non-committed one is the user-workspace config. The user therefore has to inline those settings into the user-workspace config itself, which is:
- outside the workspace, so the assistant cannot read or edit it;
- outside version control of any kind, so the settings are unbacked;
- separated from the rest of the project's config, which lives together in the workspace.
The natural expression is for the user-workspace config to extend the workspace-relative file, but extends cannot address it. Relative paths from $XDG_DATA_HOME/jp/<workspace_id>/ to a checkout are neither stable nor knowable, and ExtendingRelativePath is typed as RelativePathBuf, so an absolute path is not expressible either.
This is an ergonomics gap, not a capability gap
The workspace file can be reached today, by symlinking it into the workspace and granting an external = true access rule (RFD 076, RFD D43). Approvals are keyed on (rule_path, canonical_target) with no tool dimension, so a single --mount seeds an approval that every tool's rules then compile against, and a bare mount expands over the whole enabled-local tool set. Once RFD D43 Phase 3 lands (the deferred trust-on-first-use prompt), even that bootstrap disappears: a hand-authored external = true rule prompts once and is remembered.
So this RFD unlocks nothing. It removes indirection: no symlink, no external grant, no approval record, and no per-machine artifact, for what is only JP reading its own config file. The cost of doing nothing is a config layout harder to read and explain than the thing it expresses, not one that is impossible.
Design
User-facing configuration
extends accepts a third entry form: a table with root and path.
extends = [
# Unchanged: relative to this file's directory.
"fragments/tools.toml",
# Unchanged: relative, with an explicit merge strategy.
{ path = "fragments/model.toml", strategy = "after" },
# New: resolved against a named config root.
{ root = "workspace", path = "crates/my-project/.jp/config.toml" },
{ root = "workspace", path = "crates/my-project/.jp/config.toml", strategy = "after" },
]root accepts the implicitly loaded sources named in RFD 079:
root | Anchor |
|---|---|
workspace | the workspace root (the directory holding .jp/) |
user-workspace | $XDG_DATA_HOME/jp/<workspace_id>/ |
user-global | the platform user config dir for jp |
path stays a relative path; root decides what it is relative to. Omitting root preserves today's behavior exactly, so every existing config file is unaffected.
Type change
ExtendingRelativePath gains a variant alongside Path and WithStrategy:
pub enum ExtendingRelativePath {
Path(RelativePathBuf),
WithStrategy(RelativePathWithStrategy),
WithRoot(RelativePathWithRoot),
}
pub struct RelativePathWithRoot {
pub root: ConfigRoot,
pub path: RelativePathBuf,
pub strategy: ExtendingStrategy,
}ConfigRoot is a ConfigEnum over the three roots above. The enum is #[serde(untagged)] already, so { root, path } is distinguished from { path, strategy } by the presence of root.
Resolution moves from "join onto the declaring file's directory" to "join onto the anchor named by the entry, defaulting to the declaring file's directory". load_config_file_with_extends in jp_config::util is the single place that resolves these paths; it needs the anchor set passed in.
Failure behavior
A root that cannot be resolved for the current invocation is an error naming the root and the path, not a silent skip. The workspace root is absent when JP runs outside a workspace, and user-workspace requires a workspace id; both are knowable at resolution time.
A missing file at a resolved root-qualified path follows the existing extends failure behavior (RFD 079) rather than inventing a second rule.
Cycles and depth
Root-qualified entries join the same ExtendsStack used for relative entries, so the existing cycle and depth checks cover them. Cycles across roots are now expressible (a workspace file extending a user-workspace file that extends it back) and are caught by the same mechanism, because detection keys on the resolved absolute path.
Drawbacks
It widens the reach of implicitly loaded config. Today a user-workspace config can only pull in files near itself. After this change it can pull in arbitrary workspace files, including committed ones. That is the point, but it means a workspace file can become load-bearing for an invocation without appearing in the workspace's own config chain.
Precedence becomes harder to read. A root-qualified entry is merged at the declaring file's position in the order, not its target's. A workspace-rooted file extended from the user-workspace config is therefore merged after the real workspace config, which is the opposite of where its path suggests it sits. The strategy field already exposes this hazard for relative entries; this change makes the surprise more available.
Three roots is a vocabulary the user has to learn for a feature most users will never need. It also hardcodes the current source list: adding a fifth implicit source later means extending the enum.
Alternatives
Absolute paths in extends. Changing RelativePathBuf to accept absolute paths is a smaller diff, and would solve the motivating case immediately. It was rejected because it makes config files machine-specific and non-portable, and because the relative-path type is a deliberate constraint that keeps config shareable. A root qualifier keeps paths portable within a role.
Make extends workspace-relative everywhere. Consistent with config_load_paths, and simpler to explain. Rejected as a breaking change to every existing extends directive, for no gain in the common case where a fragment sits next to its parent.
A config_load_paths entry that always loads. An "eager" flag on a search path would also make an in-workspace fragment load unconditionally. Rejected because it conflates two mechanisms: search paths answer "where do I look for named entries", extends answers "what else does this file pull in". Loading eagerly from a search path would make --cfg resolution and implicit loading share a list with per-entry semantics.
Symlink the out-of-workspace file into the workspace. Requires no JP change, and is the recommended interim answer. The fs tools canonicalize paths and reject targets outside the workspace root, so the symlink needs an external = true rule with an approved target (RFD 076, RFD D43). The approval is tool-agnostic, so one --mount covers every tool, and RFD D43 Phase 3 would remove that step as well. Not adopted as the permanent shape because it spends a symlink, an external grant and a trust-on-first-use record on reaching JP's own config file, and because the resulting mount path says nothing about what it points at.
Non-Goals
- Not changing the default resolution of existing
extendsentries. - Not introducing a way to extend a path outside all known roots. Arbitrary absolute paths stay unexpressible.
- Not changing the implicit source order or precedence rules from RFD 079. This RFD only changes how a path inside an
extendsentry is anchored. - Not addressing cross-workspace config inheritance (a second workspace inheriting a first workspace's personas, skills and tool config). That is a larger question about workspace relationships; this RFD is only about addressing a file.
Risks and Open Questions
- Does
rootbelong on the entry or the list? A per-list default (extends_root = "workspace") would cut repetition when several entries share a root. Per-entry is proposed because mixed lists are the expected case. - Interaction with RFD D35. D35 renames
extendstoloader.extendsand introduces{ root = "workspace", path = ... }as a source identifier forloader.overrides.extends. This RFD uses the same shape as a locator. The two should share one type and onerootenum. Whichever lands second must adopt the other's naming; ideally D35 absorbs this design rather than the two shipping separate vocabularies. - Is
user-globalworth including? It completes the set, but there is no known use for it. Shipping onlyworkspaceanduser-workspacewould be narrower and still solve the motivating case. - Precedence surprise. Whether the merge position described under Drawbacks needs a diagnostic (a warning when a
workspace-rooted entry is extended from a higher-precedence root) or just documentation.
Implementation Plan
Phase 1: type and resolution. Add ConfigRoot and the WithRoot variant to ExtendingRelativePath; thread an anchor set into load_config_file_with_extends; resolve root-qualified entries against it. Existing entries keep the current anchor. Independently mergeable, with unit tests over the resolver.
Phase 2: diagnostics. Errors for an unresolvable root, and the cycle message updated to render root-qualified entries in the form the user wrote. Depends on Phase 1.
Phase 3: documentation. docs/configuration.md and the RFD 079 source table, including the precedence note from Drawbacks. Depends on Phase 1.
No measurable cost implications: resolution happens once per config file per invocation, and the work is a path join.
References
- RFD 079 — config sources and load order; the four implicit sources and the existing
extendssemantics. - RFD D35 —
loadernamespace and entry-scoped extends overrides; origin of the{ root, path }shape. - RFD D43 — tool access to external paths via workspace symlinks; the
externalrule, the approval store, and the deferred Phase 3 prompt that makes the symlink route self-bootstrapping. - RFD 035 — multi-root config load path resolution.
crates/jp_config/src/types/extending_path.rs—ExtendingRelativePath.crates/jp_config/src/util.rs—load_config_file_with_extends.