RFD 103: Multi-Value Conversation Labels
- Status: Implemented
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-08-21
- Extends: RFD 101
Summary
A conversation label holds a set of values rather than one: crate=jp_config and crate=jp_llm coexist under the same key. jp c label splits set off from add as a distinct verb, rm accepts key=value as well as key, and filters match on set membership. Label mutation moves entirely to jp c label; --label and --reset-labels leave jp query and jp conversation fork.
Motivation
RFD 101 models a label as one value per key, following kubectl. Real usage has outgrown that.
Labels are used to categorise a conversation along dimensions with a closed vocabulary — which crate it touches, which client, which review stage. Dimensions are naturally multi-valued: a conversation that changes shared logic touches jp_config and jp_llm, and one that changes behaviour visible in more than one frontend is about web and macos at once. A single-valued key cannot say that.
Two workarounds exist today and both fail:
- Encode the set in the value (
crate=jp_config,jp_llm). Filters compare the whole string, so--label=crate=jp_llmmisses. Teaching the filter to split means the value is a set, stringly typed, with deduplication and ordering pushed onto the user. - One key per value (
crate-jp_config,crate-jp_llm). Filtering for one crate works, but "which crates does this conversation touch?" and "any conversation with a crate label" both need prefix scanning, and the grouping the producer already had is discarded and re-derived from a naming convention.
Doing nothing blocks the LLM-driven auto-tagging deferred by RFD 101, which is the case that surfaced this: a model asked to categorise a conversation against a vocabulary must be able to pick more than one value per dimension.
Design
The model
A label maps a key to an ordered, deduplicated set of values. A key never maps to an empty set: removing the last value removes the key.
Single-valued labels are the one-element case, not a separate concept. There is no per-key cardinality declaration — see Alternatives.
Managing labels
jp c label add crate=jp_config crate=jp_llm # crate = {jp_config, jp_llm}
jp c label add crate=jp_cli # crate = {jp_config, jp_llm, jp_cli}
jp c label set crate=jp_cli # crate = {jp_cli}
jp c label rm crate=jp_llm # one value
jp c label rm crate # the whole key
jp c label rm # every labeladd inserts into the key's set. set replaces the key's set. Both accept several keys in one invocation, and set replaces only the sets of the keys it names — clearing every label is a bare rm.
set is already accepted today as an alias for add, and it keeps its current meaning: make this key hold exactly this. Only add changes behaviour, from replacing to accumulating.
set reports the values it displaced alongside the ones it applied, so a set that overwrote more than the user expected can be undone from its own output. That is the same property RFD 101 gives rm, and it is why set is safe to offer as a single command rather than forcing rm followed by add.
set exists as a verb rather than as add --replace because the flag form is self-contradictory on its face, and because a verb keeps each name honest: set goes on meaning what it means under RFD 101, while add takes the behaviour that is genuinely new.
rm accepts a bare key or a key=value pair. RFD 101 rejects = in a removal argument; that restriction is lifted, and a key with an = is still invalid because the key grammar forbids it.
Argument grammar
Operands are resolved as a whole before anything is applied, so a verb naming one key several times acts on the union rather than on each operand in turn:
- Parse every operand; expand each
:namealias to the key and ordered values its rule produces. - Group values by key, in the order the operands were given, discarding a repeat of a value already seen for that key.
- Apply once per key:
addinserts the grouped values,setreplaces the key's set with them,rmremoves them.
Without step 2, jp c label set crate=jp_config crate=jp_llm would replace the set twice and leave {jp_llm}. Both verbs accept aliases, and an alias contributes all of its values to its key's group.
Filtering
key=value matches when the key's set contains that value. key matches when the key is present.
jp c ls --label=crate=jp_llm # touches jp_llm, whatever else it touches
jp c ls --label=crate # has any crate labelRepeated flags are ANDed, as they are under RFD 101, so naming one key twice requires the set to contain both values:
jp c ls --label=crate=jp_config --label=crate=jp_llm # touches bothA set-expression syntax — exact-set matching, any-of, negation — is out of scope here and belongs to the conversation query DSL. "All of" needs no syntax of its own: it falls out of the conjunction rule above.
Label mutation leaves query and fork
--label on jp query and jp conversation fork, and --reset-labels on jp conversation fork, are removed. jp c label becomes the only way to mutate labels.
Under a multi-valued model the flag has to mean either "add to the set" or "replace the set", and neither reading is safe to fix in a flag that also composes with configured rules: a rule with apply_on.new that produces branch=main alongside --label=branch=feat accumulates to a two-value set under one reading and silently discards the rule under the other.
The flag is not carrying semantic weight that a second command cannot. Nothing reads labels during a turn — Context.labels is a Non-Goal in RFD 101, and no code path consults them while a query runs — so on the successful path, labelling at creation and labelling immediately after are the same thing:
jp q --new "..." && jp c label add crate=jp_config crate=jp_llmjp query --new always activates the conversation it creates, so the second command needs no --id. jp conversation fork --activate gives the same for a fork; a non-activating fork needs the fork's ID, which RFD 050 Phase 1 prints.
The two forms diverge when the turn fails. --label applies before the provider is contacted, so a missing credential or a dropped connection today leaves a labelled conversation; under && it leaves an unlabelled one. Configured rules are unaffected either way — apply_on.new resolves during creation — so what is lost is the ad-hoc labels of a failed turn, not the automatic ones. That is the case this RFD accepts in exchange for removing the flag; see Drawbacks.
Filters (--label on jp c ls and jp c grep) stay. They read labels rather than writing them, so none of the ambiguity applies.
Configured rules
A rule's value may be a list, and a command-backed rule emits one value per line of stdout:
[conversation.labels]
team = "platform"
[conversation.labels.crate]
value = ["jp_config", "jp_llm"]
[conversation.labels.branch]
value.cmd = "git rev-parse --abbrev-ref HEAD"Line-per-value composes with ordinary shell tools and needs no escaping rule; a delimiter would reintroduce the quoting problem the CLI grammar was designed to avoid.
This is distinct from the multi-key command output that RFD 101 lists as future work: a rule still produces values for exactly one key, and that key is still the map key.
The accepted shapes, extending RFD 101's table:
| TOML | Resolved values |
|---|---|
crate = "jp_config" | {jp_config} |
crate = ["jp_config", "jp_llm"] | {jp_config, jp_llm} |
crate = { value = "jp_config" } | {jp_config} |
crate = { value = ["jp_config", ...] } | the listed values |
crate = { value = [] } | no label |
crate = { value.cmd = "..." } | one value per stdout line |
The direct array is shorthand for value, mirroring how a bare string already is. A rule's values replace rather than extend across config layers: a workspace rule naming two crates and a user rule naming one resolve to the user's one. Per-entry accumulation would leave no way to narrow an inherited list, and the map itself already carries MergeableMap's strategies for merging entries.
Command output splits on newlines; empty lines are dropped and a command that writes nothing produces no label. Empty lines cannot be kept, because the empty string is a valid bare-label value and a trailing newline would otherwise add one. This replaces RFD 101's whole-output trim.
On fork, a matching rule replaces the key's set. Inherited labels are the starting state; each apply_on.fork rule then replaces the full set for its own key, and a key with no matching rule is left alone. That is RFD 101's "re-resolved entries override the inherited values", carried over unchanged — under a set-valued model "override" has to say whether it means replace or extend, and replace is what preserves the existing precedence order. A source carrying stage={draft, review} under a rule producing {approved, ready} forks to {approved, ready}, not to all four.
Replacement is replacement when the rule produces nothing, so a matching rule that resolves to no values empties its key. That is what makes value = [] a way to strip an inherited label on fork rather than a rule that quietly does nothing. A rule that failed is a different case: it is reported and dropped, and the inherited values stand, because a broken command is not a statement about what the key should hold. Resolution therefore carries "matched, produced nothing" separately from "did not match", which a bare set of labels cannot express.
Data model
// jp_conversation
pub struct Labels(BTreeMap<String, IndexSet<String>>);The two halves are ordered differently on purpose.
IndexSet for values: order is preserved and is part of the contract. Order carries meaning within a key — "mostly jp_config, also touches jp_llm" — and a sorted view can be rendered from an ordered collection at any time, while insertion order cannot be recovered from a sorted one. The cost is that metadata.json is git-visible under RFD 031, so the same values added in different orders produce different bytes. conversation.attachments already behaves that way.
BTreeMap for keys: no equivalent argument applies. One key is not "more primary" than another, so making key order observable would add diff noise to committed metadata and buy nothing. Sorted keys also match what RFD 101 already writes, so key ordering does not change.
The field is private and two invariants are enforced by the type.
No key maps to an empty set. The invariant belongs on the map rather than on a non-empty set type: it is a property of the collection, and a NonEmptySet would still let a caller insert a key and then drain it.
The empty value never sits alongside a real one. A bare label stores the empty value to record that its key is present, and holding any real value records the same thing, so the two together say nothing the second does not. Without this, jp c label add draft draft=urgent would leave {"", "urgent"}: a state where --label=draft= matches and jp c label rm draft= changes something, while every view that hides the marker shows a key holding one value. Adding a real value drops the marker, adding the marker to a key that holds one changes nothing, and reading normalizes a hand-edited pair away.
| Method | Behaviour |
|---|---|
insert(key, value) | Adds to the key's set, creating it. |
set(key, values) | Replaces the key's set; empty input removes the key. |
remove_key(key) | Removes the key and returns its set. |
remove_value(key, value) | Removes one value, dropping the key when it empties. |
get, contains, iter | Read access. |
Labels also owns the on-disk contract, and deserializes through a validating conversion rather than a derive. A derived Deserialize would write the private field directly and could construct exactly the states the API forbids — RFD 031 supports editing metadata.json by hand, so {"crate": []} is reachable. Reading normalizes instead of failing, since a small manual slip should not cost the whole conversation:
- A scalar becomes a one-element set.
- An array is deduplicated, first occurrence winning.
- An empty array drops the key, matching
set(key, empty). - An array pairing the empty value with a real one drops the empty value.
- A non-string value is a metadata load error.
A value is read as either a scalar or an array, so conversations written before this RFD load unchanged, and is always written as an array:
{
"labels": {
"branch": [
"main"
],
"crate": [
"jp_config",
"jp_llm"
]
}
}Accepting two shapes and emitting one means no migration step. A JP built before labels existed skips the field as unknown, whatever shape its values take, so the array form costs nothing there either.
Display and machine output
jp c label ls writes one key=value per line, the way the user types them, so every line stands alone and none needs context from its neighbours:
$ jp c label ls
crate=jp_config
crate=jp_llm
draftComma-separating the values of a key would be ambiguous: RFD 101 places no restriction on values, so branch=feat,exp is one legitimate value and feat,exp, main could read as two values or three. One value per line has no such question.
Every line carries a marker column, a space here, so a reader strips one character whichever command printed the line.
A mutation reads as a diff: - for a value the key lost, + for one it gained, and a space for one it kept. The lines are data, so they go to stdout; the sentence naming what changed is chrome and goes to stderr, per RFD 048:
$ jp c label rm crate=jp_llm
Removed labels from jp-c17866928997: Tool Chaining
crate=jp_config
-crate=jp_llm$ jp c label set crate=jp_cli
Set labels on jp-c17866928997: Tool Chaining
-crate=jp_config
-crate=jp_llm
+crate=jp_cliOne rule for every command means a line can be read without knowing what produced it, which is what makes set recoverable from its own output. A label key starts with a letter, so the marker column is never mistaken for part of the label.
jp c show renders one row per key with the values as a list beneath it — the same numbered multi-line cell the details view already uses for attachments and compactions — because it is a details view rather than a listing to read from.
The JSON form carries the set:
{
"key": "crate",
"values": [
"jp_config",
"jp_llm"
]
}This replaces the {"key", "value"} shape from RFD 101.
A mutation reports per key what the key held and what it holds now, so set's recoverability is a property of the output rather than of the reader's memory:
{
"action": "set",
"conversation": { "id": "jp-c17866928997", "title": "Tool Chaining" },
"changes": [
{ "key": "crate", "before": ["jp_config", "jp_llm"], "after": ["jp_cli"] }
]
}RFD 101's flat labels array cannot carry both halves, so it is replaced. add and rm use the same shape; for them one side is a subset of the other. A bare label carries no values a reader can act on, so both sides are empty arrays rather than the empty string it is stored as.
The text form keeps set recoverable too: its - lines are the values it displaced.
Drawbacks
A shipped surface is removed.
--labelonqueryandfork, and--reset-labels, are described in RFD 101 and implemented. Removing them costs a keystroke in the common interactive case and a second command in scripts.Labelling stops being atomic with the turn.
jp q --new --label=xtoday writes the label before the provider is contacted, so it survives a failed turn;jp q --new && jp c label add xdoes not run the second command at all. A failed turn therefore leaves a conversation carrying its configured labels but none of the ad-hoc ones. Accepted because the labels that matter for categorisation come from rules, and because a flag that composes atomically has to answer the accumulate-or-replace question this RFD exists to avoid.Order-dependent bytes in committed metadata. Preserving value order means the same logical label set can produce different
metadata.jsondiffs depending on the order values were added. Keys stay sorted, so this is confined to the values within one key.Every label-shaped surface changes at once. Data model, CLI grammar, filters, config, display, and JSON all move together; there is no useful intermediate state.
addchanges meaning. It replaces today and accumulates after this RFD. Pre-release, so no migration is owed, but the word does more work than it did.
Alternatives
Encode the set in the value
crate=jp_config,jp_llm as a single string. Rejected: filters compare whole values, so drilling down needs the filter to split the string, at which point the value is a set with no type behind it and the user owns deduplication, ordering, and escaping.
One key per value
crate-jp_config=true and crate-jp_llm=true. Rejected: the dimension exists only as a naming convention, so "which crates?" and "any crate?" require prefix scanning over keys, and the grouping the producer already had is thrown away.
Per-key cardinality
conversation.labels.crate.multi = true, leaving other keys single-valued. Rejected on two counts. Storage must hold sets regardless, so this is the same data-model change plus a configuration knob. More seriously, add would insert or replace depending on the target key's configuration — the same command behaving differently according to data rather than to what the user typed — and a key set from the CLI with no configured rule would have no declared cardinality at all.
add --replace instead of a set verb
Rejected: "add" and "replace" contradict each other in one invocation, and offering both spellings for one operation runs against the consolidation RFD 101 settled on for its flags.
BTreeSet for values
Rejected: sorted storage gives identical bytes for identical sets, which is better for committed metadata, but discards ordering permanently. Order carries meaning in cases like primacy ("mostly jp_config, also touches jp_llm"), and a sorted view can always be rendered from an ordered set.
IndexMap for keys
Rejected: it would make key insertion order observable in metadata.json, in jp c show, and in jp c label ls, for no gain. The primacy argument that justifies ordered values has no counterpart across keys, and RFD 101 already writes keys sorted.
Keep --label and pick a semantic
Either accumulate or replace, documented. Rejected: the choice is permanent once released, the flag saves one &&, and its interaction with configured rules is surprising under both readings. It can be reintroduced later if the two-command form proves annoying in practice — adding a flag is cheap, removing one is not.
Non-Goals
- Set-expression filter syntax. Exact-set matching, any-of, and negation belong to the conversation query DSL. "All of" is not among them: repeated flags already AND, so it falls out of the filter semantics this RFD defines.
- Declared value vocabularies. Constraining
crateto a known list of values is the follow-up this RFD unblocks, not part of it. - LLM-driven auto-labelling. The #101 follow-up, which depends on both this RFD and vocabularies.
- Multi-key command output. A rule still produces values for one key; the
multi = truefuture-work item in RFD 101 is unchanged. - Label change history. Still no event-stream record; RFD 101's Non-Goal stands.
Risks and Open Questions
Hyrum's Law on value order. Once order is preserved, something will depend on it. That is the intent, but it means sorting later would be a breaking change; the contract should say plainly that order is insertion order.
Labelsis a new choke point. Every label read and write goes through it, includinglabel::apply, which currently manipulates the map directly. A missing method sends a caller looking for an escape hatch, which is how the empty-set invariant would erode.
Implementation Plan
Phase 1: data model
Labels in jp_conversation with the private field and the API above, including scalar-or-array read and array write. Replaces BTreeMap<String, String> on Conversation. Callers move to the API.
Mergeable independently, but only if the CLI keeps replacing: existing Set directives call set(key, one_element_set(value)), not insert. insert accumulates, so wiring it up in this phase would change jp c label add from replacing to accumulating before the verb contract that explains it lands in Phase 2.
Phase 2: CLI and filters
set as a verb, rm key=value, contains-semantics for filters, display and JSON changes. Switches add from set to insert, now that a verb exists for replacing.
Removes --label from jp query and jp conversation fork, and --reset-labels from jp conversation fork, along with the LabelDirectives clap wrapper and both of its switches — ALIASES, which separates jp query (where --label=:name resolves) from jp conversation fork (where it is rejected), and RESET, which registers --reset-labels. Alias support on jp c label is unaffected: it goes through LabelDirective::parse_set and expand_aliases, not through the wrapper.
Depends on Phase 1. Lands after RFD 050 Phase 1, which prints the forked conversation's ID: until then, removing fork --label leaves a fork that was deliberately not activated with no way to name it.
Phase 3: configured rules
List-valued value, and line-per-value stdout for command-backed rules.
Depends on Phase 1. Independent of Phase 2.
Future work (out of scope, future RFDs)
- Declared value vocabularies, with validation and ordered domains.
- LLM-driven auto-labelling against a vocabulary (the #101 follow-up).
- Set algebra in filters, via the conversation query DSL.
- Reintroducing a creation-time label flag, if the two-command form proves annoying once RFD 050 Phase 1 has landed.
References
- RFD 101: Conversation Labels — the single-valued model this extends.
- RFD 050: Scripting Ergonomics for Conversation Management — Phase 1 prints the forked conversation's ID, so a script can label a fork it chose not to activate.
- RFD 031: Durable Conversation Storage with Workspace Projection —
metadata.jsonis git-visible, which is what makes value ordering a diff-noise question. - #101: Conversation tags — the umbrella issue whose auto-tagging half this unblocks.