RFD D63: Unified System Prompt Configuration
- Status: Draft
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-09-07
Summary
assistant.system_prompt and assistant.system_prompt_sections collapse into one key, assistant.system_prompts, holding an ordered list of prompt entries. The key accepts a bare string, a merge-metadata table, or a list of entries, so every shape in use today keeps working, and system_prompt survives as an alias.
Motivation
JP has two config keys for one concept. assistant.system_prompt is a mergeable string; assistant.system_prompt_sections is a mergeable list. Both end up concatenated into the same system prompt, and a user has to know which to reach for.
The split costs more than confusion. A merged string has no addressable identity: appending to it rewrites the whole value, so nothing downstream can tell what changed. A list of entries is diffable by construction — an appended element is a new element.
That matters because of how personas work. Twelve persona files and knowledge/software-laws.toml set [assistant.system_prompt], composing with strategy = "append" on top of the strategy = "replace" base in personas/default.toml. Personas are selected with --cfg=personas/<name>, and four justfile recipes apply one to an already running conversation:
jp query --id "$existing" --cfg=personas/pr-reviewer …
jp query --id "$existing" --cfg=personas/rfd-implementor …A --cfg on an existing conversation is recorded as a ConfigDelta in the stream. So switching persona mid-conversation is the most common operator-level change JP makes, and it arrives in the one shape that cannot be expressed incrementally.
The string-merge machinery is the other half of the argument. strategy, separator, dedup, and discard_when_merged exist to make a scalar behave like a list. A list needs none of them.
Design
One key, three shapes
All three forms are valid on the same key, and all three produce a list:
# A bare string: one untagged entry.
assistant.system_prompts = "You are a helpful assistant."
# Merge metadata, with a string value.
[assistant.system_prompts]
strategy = "append"
separator = "paragraph"
value = "You are a principal software architect."
# A list of entries, each with the full entry shape.
[[assistant.system_prompts]]
position = -100
tag = "voice"
content = "Write plainly."An entry is either a string or a table. A string entry becomes a table with content set and everything else defaulted, so the two are interchangeable:
assistant.system_prompts = [
"Be concise.",
{ tag = "voice", content = "Write plainly.", position = -100 },
]system_prompt is an alias
assistant.system_prompt continues to parse, addressing the same key. Every example above works spelled either way. The alias covers both entry points: #[serde(alias = ...)] for file loading, and a match arm in PartialAssistantConfig::assign for --cfg. Only the canonical name appears in the generated JSON schema and in jp init output.
Naming
| Today | After |
|---|---|
assistant.system_prompt_sections | assistant.system_prompts |
SectionConfig | SystemPromptConfig |
SectionConfigOrString | SystemPromptOrString |
assistant/sections.rs | assistant/prompts.rs |
ThreadBuilder::with_sections, build_sections | with_prompts, build_prompts |
"Section" stops describing anything once the key is not named after it, so it is retired from this area entirely and freed for later use elsewhere.
Rendering is unchanged
An entry with no tag and no title renders as its trimmed content, so a converted string produces byte-identical output. Ordering still comes from position.
Migration
Three steps, in order. The ordering is the point: converting the data while the old field still exists means nothing is ever stripped.
1. Convert the thirteen config files. Each [assistant.system_prompt] block becomes an entry in system_prompts. strategy = "append" becomes another element; position reproduces the ordering that separator = "paragraph" and declaration order give today.
2. Migrate stored conversations. A one-off pass rewrites any recorded assistant.system_prompt in base_config.json and in ConfigDelta events into an entry. This runs while system_prompt is still a schema field, so compat::deserialize_partial_config can still read it.
3. Remove the field. By this point nothing carries it, so there is nothing for strip_unknown_fields to drop.
Reversing steps 2 and 3 loses data: removing the field first makes every stored system_prompt unreadable, and old conversations replay without it.
Drawbacks
Three input shapes on one key. More parsing surface, and the disambiguation is implicit. MergeableVec dispatches on input shape via serde_untagged, which handles string-versus-list-versus-table cleanly. But a lone table has to be tried as merge metadata first and as a single entry second, and that only works because MergedVec and SystemPromptConfig have disjoint field names under deny_unknown_fields. Adding a strategy field to SystemPromptConfig later would break it silently.
Requests change shape. Twelve personas that append string-onto-string produce one system block today; as list entries they produce one block each. Same text, same order, more blocks — concatenated by Anthropic, one system message each elsewhere. That is the change that makes them individually diffable, but it means requests are not byte-identical across the migration.
The alias keeps two spellings alive. One concept, two names, which partially undercuts the simplification. The alternative is breaking every config that uses the singular, including thirteen in this repo.
Alternatives
Keep both keys. Zero work, and the persona case stays permanently undiffable — every persona applied to a running conversation costs a full cache miss.
Remove system_prompt with no alias. Simpler surface, but breaks every config using the singular for no gain the alias does not already provide.
Fold system_prompt into an entry at finalize. Keeps the field and the schema, so nothing is ever stripped. Rejected: merging happens on partials before finalize, so the fold receives one already-merged string and produces one entry. The scalar is still a scalar, and the persona case is still undiffable. Cosmetic only.
Non-Goals
- Directive behaviour. How a changed prompt reaches the provider mid-conversation is RFD 105's subject. This RFD only makes the content diffable.
assistant.instructions. A separate list with its own rendering; it already composes as a list and needs no change.- Reusing "section" elsewhere. The term is vacated here; what claims it next is a later decision.
Risks and Open Questions
MergeableVec has no AssignKeyValue. Deliberately so: the doc comment at types/vec.rs:32-37 records the reason — with a generic T, it is unclear whether --cfg key=value should parse as MergedVec or as T. This RFD has to answer that for system_prompts, since --cfg assistant.system_prompt=foo must work. The answer follows the file shapes: a bare scalar is an entry, a table with merge-metadata field names is MergedVec.
Store migration correctness. Step 2 rewrites durable conversation data. It needs to be idempotent, to leave conversations without a system_prompt untouched, and to be verifiable by replaying a migrated conversation and diffing the resolved config against the pre-migration value.
Entry-shape disambiguation. See Drawbacks. Worth a test that pins both readings of a lone table, so a future field addition to SystemPromptConfig fails loudly rather than silently reinterpreting configs.
Implementation Plan
Phase 1 — Type and key rename. SectionConfig to SystemPromptConfig, sections.rs to prompts.rs, system_prompt_sections to system_prompts with a serde alias for the old key. No behaviour change. Independently mergeable.
Phase 2 — Polymorphic input. Wire up the existing but unused SectionConfigOrString as the element type, add a .string(...) arm to MergeableVec's UntaggedEnumVisitor, add scalar-or-list handling to MergedVec::value, and implement AssignKeyValue for the field. Depends on phase 1.
Phase 3 — Convert config files. The thirteen persona and knowledge files. Verifiable by diffing rendered system prompts before and after. Depends on phase 2.
Phase 4 — Store migration. The one-off pass over base_config.json and ConfigDelta events. Depends on phase 2, and must land before phase 5.
Phase 5 — Remove system_prompt as a field, keeping it as an alias for system_prompts. Depends on phases 3 and 4.