RFD D64: Config Values Sourced from Files
- Status: Draft
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-08-20
- Requires: RFD 079
Summary
A config field can take its value from another file instead of spelling it out:
[conversation.tools.ticket_create.parameters.labels.items]
enum = { file = "../../../docs/ticket/.labels.json", pointer = "/package/values" }The reference resolves at load time, relative to the declaring file, before merging. Nothing is executed, and the result is the same on every invocation.
Motivation
Every config source JP has today is a static byte stream someone typed: the four file locations, the extends targets, JP_CFG_*, --cfg entries. RFD 079 documents them; RFD 080 adds the editor as another.
That covers config a human writes. It does not cover config that is a function of project data the repository already holds.
The case that forced the question: the in-repo ticket board (RFD 100) has a closed label vocabulary in docs/ticket/.labels.json, declaring which keys exist and which values each accepts. The ticket_* tools need that same list as a JSON Schema enum, so the assistant is offered valid labels rather than inventing them. The vocabulary cannot move into the tool declarations: jp ticket and the docs build both read it, neither loads JP's tool config, and the file has to travel with the tickets when --dir moves them. So the list exists twice, and the copies drift.
The available answers are all bad in the same way. A test comparing the two turns "add a label" into "add a label and update a second file or CI goes red", which taxes an edit that should cost nothing. A generator recipe moves the tax rather than removing it, and only helps the people who remember to run it. Dropping the enum costs a corrective round trip every time the model guesses, and on ticket_create that round trip is an approval prompt for a call that cannot land.
None of that is specific to labels. Any config value that mirrors project data has it.
Doing nothing means each such case grows its own bespoke sync, and the generic capability never arrives because no single case justifies it alone.
Design
What the user writes
Any field that opts in accepts either a literal or a reference object:
# Literal, exactly as today.
enum = ["client=cli", "client=web"]
# Reference.
enum = { file = "../../../docs/ticket/.labels.json", pointer = "/client/values" }The reference has three fields:
| Field | Required | Meaning |
|---|---|---|
file | yes | Path, relative to the file declaring it |
pointer | no | RFC 6901 JSON Pointer into the document; defaults to root |
select | no | value (default), or keys to take an object's keys |
file is read with the same format detection the config loader already uses, so a reference can point at TOML, JSON, or YAML regardless of the declaring file's format.
select = "keys" exists because "the names in this map" is the common shape for data files that pair a name with a description. Without it, every such file needs a parallel array.
Resolution
References resolve during load, per file, in the same pass that resolves extends:
- Read the declaring file into a raw document.
- Walk it for reference objects.
- For each, resolve
filerelative to the declaring file's directory, read it, applypointer, applyselect, substitute the result. - Hand the substituted document to the partial parser.
Resolving before parsing rather than after keeps the partial types unchanged: by the time PartialAppConfig exists, every value is a literal. Resolving before merging means a reference is a property of the file that declares it, not of the merged result, so a higher layer overrides a reference-derived value exactly as it overrides a literal one.
Referenced files do not participate in extends and are not themselves config: they are data, read once, at one pointer.
Failure behavior
Matching extends, which warns and continues on a missing non-glob target:
| Condition | Behavior |
|---|---|
file missing | Warn, leave the field unset |
file unparseable | Error |
pointer names nothing | Error |
select = "keys" on a non-object | Error |
| Resolved value has the wrong type | Error, from the existing parser |
A missing file is the lenient case because a reference can legitimately point at something optional. Everything else means the reference is wrong, and a silently unset field would be harder to diagnose than a failed load.
Opting a field in
Not a change to the schematic derive, and not a capability every field gets. A field opts in by declaring a type that accepts the reference form, the way extends opts into ExtendingRelativePath today:
pub enum Sourced<T> {
Literal(T),
Reference(FileReference),
}This keeps the blast radius to the fields that want it, keeps the merge, delta, and fill machinery untouched, and makes "can this field be sourced from a file?" answerable from its type.
Provenance
Each resolved reference records the file it came from, so RFD 060 can report enum ← docs/ticket/.labels.json#/client/values rather than a bare value.
Drawbacks
A query language wants to grow here. pointer and select are two knobs; the third request will be filter, the fourth map. The line this RFD draws: a reference addresses one location in one document and takes it whole. Anything that transforms content belongs in the file being referenced, or in whatever writes it. If a case genuinely needs transformation, that is evidence for the command-execution design this RFD rejects, not for growing pointer.
Two ways to write the same thing. Every opted-in field now has a literal form and a reference form, in docs, in examples, in error messages. That is a real cost paid by every reader, for a feature most fields never use.
Config stops being self-contained. Reading a config file no longer tells you its values. Provenance in --explain mitigates this for anyone who thinks to look; nothing mitigates it for someone reading the file directly.
It is one consumer today. Labels is the forcing case. The other candidates named below are plausible, not requested. If none of them materialize, this is machinery serving a single caller, which is the midlayer mistake.
Alternatives
Command output as a config source. extends = [{ command = "..." }], with the source being a process's stdout. Strictly more general: it covers secrets from a keychain, per-machine paths, anything a file cannot answer. Rejected because the cost is not proportional to this problem. Config loads on every invocation, so this executes repository-supplied commands on jp --help in a freshly cloned checkout, before any user intent. providers.mcp.*.command and tool command already run repo-supplied code, but only when a tool is actually invoked, and RFD 077 exists to govern exactly that. A load-time equivalent needs its own trust design, plus caching (because a process spawn per source per invocation is an inner-loop cost), plus cache invalidation, plus an answer for what --explain reports when the output differs between runs. If dynamism is ever wanted, it should be its own RFD carrying its own trust argument. Nothing here forecloses it: a command variant slots into the same resolution step.
A drift test. Duplicate the value, compare in CI. Zero new machinery, and the duplication becomes visible rather than silent. Rejected because it taxes the edit: changing project data now fails an unrelated test until a second file is updated. That is the wrong incentive on data that should be cheap to change.
A generator recipe. just ticket-labels-sync rewrites the derived file. Rejected as a permanent answer for the same reason, one step removed: it does not fail, but it still requires remembering. Accepted as the interim for labels, precisely because it is deletable when this RFD lands.
Make the data file a config partial. Shape .labels.json as an AppConfig fragment and extends it. Rejected: the vocabulary is ticket-board data, and this would put it in JP's config namespace where jp ticket and the docs build cannot reasonably read it, and where per-label descriptions have no natural home.
Self-describing tools (RFD D06). A local tool could return its own schema at call time, computing the enum itself. This solves the labels case and nothing else, does not help any non-tool field, and is a much larger change to the tool protocol.
Non-Goals
- Executing anything. See Alternatives.
- Writing back. A reference is read-only. RFD D02's lossless editing does not follow the reference to edit the target.
- Watching for changes. Resolution happens at load. A long-running host that wants to notice a changed data file is RFD D36's problem.
- Referencing across the network. Local paths only.
- Environment or shell expansion inside
file. A path is a path.
Risks and Open Questions
Is select = "keys" the right primitive, or a special case in disguise? It exists for one shape (a map of name to description). An array of objects with a name field is just as common and keys does not help there. Either the answer is "reference an array and shape the data file accordingly", or select needs a third value, and that is the slope this RFD claims not to slide down.
How does a reference interact with --cfg deltas and conversation config? A reference resolves at load; the resolved value is what lands in a recorded ConfigDelta. That means a conversation replays the value as it was, not as the file now reads. Probably correct (a recorded conversation should be stable), but it means a delta can disagree with the file, and --explain should say so.
Relative paths are long. ../../../docs/ticket/.labels.json from inside .jp/mcp/tools/ticket/ is fragile against moving either end. Workspace-root anchoring (//docs/ticket/.labels.json) would help. Deferred rather than decided, since RFD D35 is already reworking loader path semantics and the two should agree.
Does the warn-on-missing case hide typos? A misspelled file is indistinguishable from an intentionally optional one. The alternative is erroring on missing files, which diverges from extends. Worth revisiting if it bites.
Implementation Plan
Phase 1: the reference type and resolver. FileReference and Sourced<T> in jp_config::types, plus resolution in jp_config::util alongside the extends walk. No field uses it yet; tested directly. Mergeable alone.
Phase 2: opt in the first field. enum on tool parameters (conversation.tools.*.parameters.*.items.enum). Delete the label mirror file and the just ticket-labels-sync recipe added for RFD 100. Depends on phase 1.
The ticket vocabulary is nested (key, then values and retired), and the tokens the schema wants are key=value strings rather than either list verbatim. A pointer plus select cannot build them, so phase 2 either accepts a flatter mirror shape or concedes that this consumer needs a transformation the design refuses. Resolve that before starting: it is the sharpest test of whether the pointer-only line holds.
Phase 3: provenance. Record the source path per resolved reference and surface it in --explain. Depends on phase 1 and on RFD 060; mergeable independently of phase 2.
Candidate consumers after phase 2, none of them committed to here: model alias lists, assistant.instructions shared across workspaces, plugin registries.
References
- RFD 079: Config Sources and Load Order — the load pipeline this extends.
- RFD 080: Editor as a Config Source — the other in-flight source addition.
- RFD 100: In-Repo Ticket Tracking — the forcing case.
- RFD 060: Config Explain — consumer of the provenance in phase 3.
- RFD 077: Plugin Configuration and Trust Policy — the trust model a command-execution variant would have to extend.
- RFC 6901: JSON Pointer.
crates/jp_config/src/types/extending_path.rs— the opt-in-by-type precedent.crates/jp_config/src/util.rs— whereextendsresolution lives.