RFD D35: Extends Overrides and Loader Namespace
- Status: Draft
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-05-09
- Requires: RFD 035, RFD 038, RFD 079
Summary
This RFD adds loader.overrides.extends, an entry-scoped way to exclude a config source while resolving the extends tree for a specific --cfg entry. It also moves existing load-time controls into a loader namespace: extends becomes loader.extends, config_load_paths becomes loader.search_paths, and inherit becomes loader.inherit.
Motivation
JP lets users compose named config files with extends. A workspace can define a shared dev entry that extends other config files:
# .jp/config/entries/dev.toml
[loader]
extends = [
"../fragments/web-access.toml",
"../fragments/local-context.toml",
]A user may want to keep using jp q -c dev but remove the web-related fragment from that entry in their private config. Higher-precedence config can override final field values, but it cannot currently say "do not load this extended source as part of this entry." That matters when the extended source contributes multiple fields, such as tool configuration and prompt sections. Disabling one field after the merge leaves the rest of the source's contribution behind.
The desired behavior is source-level and entry-scoped:
jp q -c devfilters the web-related source from the dev entry, while:
jp q -c dev -c fragments/web-accessstill loads the web-related source explicitly as its own entry.
The design also has to survive indirection. If dev.toml later extends an intermediate file, and that intermediate file extends web-access.toml, the user still wants to filter web-access.toml only when the active entry is dev.toml. Filtering the immediate edge would either miss the source or affect other entries that share the intermediate file.
Design
Entry loading
This RFD uses entry loading to mean config loaded explicitly through --cfg. RFD 079 calls this deferred loading. An entry is one concrete config file resolved from a --cfg <name> argument. Because multi-root resolution can find multiple files for one argument, each resolved file is its own entry with its own source identity and extends tree.
loader.overrides.extends applies only while resolving these explicit entries. It does not affect implicit base config loading.
User-facing configuration
Add entry-scoped extends override rules under loader.overrides.extends:
[[loader.overrides.extends]]
within = { root = "workspace", path = ".jp/config/entries/dev.toml" }
exclude = [
".jp/config/fragments/web-access.toml",
]within identifies the concrete entry whose extends tree is patched. exclude lists sources to skip anywhere inside that entry's tree.
The rule means:
When resolving the
extendstree for the workspacedev.tomlentry, skip the workspaceweb-access.tomlsource anywhere inside that tree. Do not skipweb-access.tomlwhen it is loaded as its own entry or through another entry.
This is entry-scoped and transitive. A rule for dev.toml applies anywhere inside dev.toml's extends tree, including through intermediate config files.
Source selectors
within uses a strict root-qualified source selector:
{ root = "workspace", path = ".jp/config/entries/dev.toml" }root is one of the config roots used when resolving named --cfg files:
| Root | Path base |
|---|---|
user-global | <user-config-dir>/config/ |
workspace | <workspace-root>/ |
user-workspace | <user-workspace-dir>/config/ |
path is relative to that root. Absolute paths are rejected. Paths that escape the root are rejected. Matching uses the normalized source identity after file resolution.
exclude paths are root-relative paths resolved inside within.root:
exclude = [".jp/config/fragments/web-access.toml"]The root is inferred from within because loader.extends is a same-root composition mechanism. A config file in the workspace root can extend other workspace-root files; a config file in the user-workspace root can extend other user-workspace-root files.
Exclude behavior
Given this rule:
[[loader.overrides.extends]]
within = { root = "workspace", path = ".jp/config/entries/dev.toml" }
exclude = [".jp/config/fragments/web-access.toml"]JP behaves as follows:
| Invocation | Behavior |
|---|---|
jp q -c dev | web-access.toml is excluded from the workspace dev.toml entry. |
jp q -c dev -c fragments/web-access | web-access.toml is excluded from dev, then loaded as its own entry. |
jp q -c research | web-access.toml loads if research.toml extends it. |
jp q -c dev -c research | web-access.toml is excluded from dev, but can still load through research. |
The rule applies through indirection:
# .jp/config/entries/dev.toml
[loader]
extends = ["../bundles/standard.toml"]
# .jp/config/bundles/standard.toml
[loader]
extends = ["../fragments/web-access.toml", "../fragments/local-context.toml"]When the active entry is dev.toml, web-access.toml is skipped even though the direct edge is standard.toml -> web-access.toml. When the same intermediate file is reached through a different entry, the rule does not fire.
Where override rules come from
Override rules are read only from the invocation's base partial:
implicit files + environment variablesThis matches loader.search_paths: lookup controls are read once when the config pipeline is constructed. Rules introduced by a --cfg file, conversation config, or command-specific CLI shortcuts do not affect --cfg resolution in the same invocation.
This avoids confusing left-to-right behavior such as:
jp q -c my-overrides -c devwhere my-overrides would affect only later --cfg entries.
Loader model
ConfigPipeline::new extracts loader.overrides.extends rules from the base partial and passes them into resolve_cfg_args. Resolved file arguments keep source identity alongside the loaded partial:
struct ResolvedCfgEntry {
identity: Option<ConfigSourceIdentity>,
path: Utf8PathBuf,
partial: PartialAppConfig,
}Named --cfg entries receive an identity after find_file_in_load_path resolves them. Explicit filesystem paths receive an identity only when they normalize under a known config root. Paths outside known roots still load, but have no active entry identity for override matching.
The recursive loader gains an optional override context containing the active entry identity, the base-layer rules, and the known roots. The implicit config-loading path passes no context and keeps today's behavior. At each extended source candidate, JP normalizes the candidate source, skips candidates that match the active entry's exclude list, and loads the remaining sources with normal before / after strategy handling.
Glob-expanded files are normal candidates. Each concrete file produced by a glob is normalized and checked independently. overrides does not support glob-pattern selectors; users name concrete sources.
Loader namespace cleanup
Because this RFD introduces the loader namespace for loader.overrides.extends, it also moves the existing root-level loader fields into that namespace:
[loader]
inherit = true
search_paths = [".jp/config", ".jp/config/entries"]
extends = [
"config.d/**/*",
{ path = "./foo/baz.toml", strategy = "after" },
]The field mapping is:
| Current field | New field | Meaning |
|---|---|---|
extends | loader.extends | Files this config source extends. |
config_load_paths | loader.search_paths | Directories searched by named --cfg arguments. |
inherit | loader.inherit | Whether implicit file loading continues after this source. |
RFD 038 defines loader.reset, which uses the same namespace but is not designed here.
Persistence
loader fields are load-time metadata. Config files may contain them, but they must not be persisted into conversation deltas or base_config.json. Conversation-targeted jp config set for this namespace should fail with a clear error rather than storing values that cannot affect future loader construction.
Drawbacks
Entry-scoped overrides add action at a distance. A base-layer config can change how another entry's extends tree resolves. The within selector and structured diagnostics are required to make this understandable.
This is also a breaking config-schema change. Users must move existing root-level loader fields into [loader].
Alternatives
Keep root-level loader fields
JP could keep extends, config_load_paths, and inherit at the document root and add only config.extends.exclude. This minimizes migration work, but keeps the existing schema confusion and gives the new override behavior an awkward home.
Target-only source exclusion
A broader target-only shape was considered:
[config.sources]
exclude = [".jp/config/fragments/web-access.toml"]This is too broad. It would suppress web-access.toml from dev, but also from any other entry or intermediate file that extends it. It also risks blocking a later explicit -c fragments/web-access load unless direct entries are specially protected.
Generic loader.overrides.*
A generic override system for every loader field was considered:
[loader.overrides]
# hypothetical future shapeRejected for this RFD. loader.extends, loader.search_paths, and loader.inherit run in different phases. extends is a graph and supports entry-scoped graph patching cleanly. search_paths affects whether named entries can be found before those entries exist, and inherit controls the implicit file-source cascade. They need separate designs if real use cases appear.
Include overrides
loader.overrides.extends could also support injecting sources into another entry's extends tree:
[[loader.overrides.extends]]
within = { root = "workspace", path = ".jp/config/entries/dev.toml" }
include = [
{ path = ".jp/config/fragments/local-dev.toml", strategy = "after" },
]This is useful, but it needs more design work around ordering, placement, and cross-root behavior. This RFD reserves the include field for a future proposal but does not define it.
JSON Patch
A generic JSON Patch system was considered. JSON Patch is useful for document surgery, but it operates on serialized config shape rather than loader source identity. JP already has typed layered config for ordinary application-setting changes. The missing behavior here is specifically source-level extends tree filtering, so a domain-specific override is clearer.
Edge-scoped exclusion
An edge-scoped rule would name the immediate parent and child. This works while entry files directly extend every fragment. It breaks when an intermediate file is introduced between the entry and the fragment: dev.toml no longer has a direct edge to web-access.toml, while filtering the intermediate file's edge would affect all other entries that use the same file.
Field-level removal
Removing prompt sections or tools by identity is useful in its own right, but it requires the user to know every field contributed by the source. If fragments/web-access.toml later adds more config, the removal silently becomes incomplete.
Non-Goals
- A generic override mechanism for every loader field.
- Include overrides for
loader.extends. - Wildcards, omitted roots, or path-only selectors for
within. - Cross-root excludes.
- A global source denylist.
- Exact edge-scoped filtering.
- Field-level tombstones or vector element removal.
loader.reset; it is defined by RFD 038.
Risks and Open Questions
Diagnostics. Users need visibility into why a source was skipped. Future jp config explain work could surface extends overrides in the resolved config explanation.
Rule merge behavior. Extends override rules should accumulate across base layers with order-preserving deduplication. There is no RFD mechanism to remove an inherited override rule.
RFD 070 attribution. This RFD assumes RFD 070's current model where extended files are attributed to the parent --cfg source for claim history. If RFD 070 later records extended-file claims separately, the -C alternative should be revisited, but this RFD remains loader-time graph patching rather than a conversation-history revert.
Implementation Plan
- Add
loader.overrides.extends, source selectors, and config-root identifiers tojp_config. - Extract
loader.overrides.extendsfrom the base partial inConfigPipeline::newand pass the rules intoresolve_cfg_args. - Track source identities while resolving
--cfgpaths across config roots. Each resolved file becomes aResolvedCfgEntrywith its path, partial, and optional active entry identity. - Thread an optional override context through the recursive config loader. The implicit config-loading path passes no context;
--cfgentry loading passes the active entry identity and rule set. - Implement exclude handling for normal extends, glob-expanded candidates, and both
beforeandafterstrategies. - Move root-level
extends,config_load_paths, andinheritintoloader.extends,loader.search_paths, andloader.inherit. - Add migration diagnostics for old root-level fields. Because this RFD is a breaking reset, JP may reject old fields with clear replacement messages rather than accepting aliases indefinitely.
- Keep all
loaderfields out of persisted conversation deltas andbase_config.json. Reject conversation-targetedjp config setwrites forloader.*with a clear error. - Add tests for direct exclusion, indirection, glob-expanded
loader.extends,strategy = "after", explicit later-c fragments/web-access, another entry extending the same source, multi-root--cfg devwhere only one resolved entry is patched, strict root identity, explicit paths under known roots, explicit paths outside known roots, invalid selectors, unmatched selectors, and rule accumulation with order-preserving deduplication. - Update RFD 079,
docs/configuration.md, examples, and project config files to use the new loader namespace.
References
- RFD 035 — multi-root config load path resolution.
- RFD 038 — config reset keywords and
loader.reset. - RFD 070 — negative config deltas and
-Cclaim attribution. - RFD 079 — config sources and load order.
crates/jp_cli/src/config_pipeline.rs—ConfigPipeline::newandresolve_cfg_args.crates/jp_config/src/util.rs— recursiveextendsloading.