diff --git a/docs/rfd/drafts/D38-root-qualified-extends-paths.md b/docs/rfd/drafts/D38-root-qualified-extends-paths.md new file mode 100644 index 000000000..bace40d49 --- /dev/null +++ b/docs/rfd/drafts/D38-root-qualified-extends-paths.md @@ -0,0 +1,281 @@ +# RFD D38: Root-Qualified Extends Paths + +- **Status**: Draft +- **Category**: Design +- **Authors**: Jean Mertz +- **Date**: 2026-08-18 + +## Summary + +This RFD lets an `extends` entry name the config root its path is resolved +against: + +```toml +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: + +```toml +# 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//` 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`. + +```toml +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//` | +| `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`: + +```rust +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 `extends` entries. +- **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 `extends` entry 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 `root` belong 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 `extends` to `loader.extends` and + introduces `{ root = "workspace", path = ... }` as a *source identifier* for + `loader.overrides.extends`. + This RFD uses the same shape as a *locator*. + The two should share one type and one `root` enum. + Whichever lands second must adopt the other's naming; ideally D35 absorbs this + design rather than the two shipping separate vocabularies. +- **Is `user-global` worth including?** It completes the set, but there is no + known use for it. + Shipping only `workspace` and `user-workspace` would 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 `extends` semantics. +- [RFD D35] — `loader` namespace and entry-scoped extends overrides; origin of + the `{ root, path }` shape. +- [RFD D43] — tool access to external paths via workspace symlinks; the + `external` rule, 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`. + +[RFD 035]: ../035-multi-root-config-load-path-resolution.md +[RFD 076]: ../076-tool-access-grants.md +[RFD 079]: ../079-config-sources-and-load-order.md +[RFD D35]: D35-loader-namespace-and-extends-overrides.md +[RFD D43]: D43-tool-access-to-external-paths-via-workspace-symlinks.md