RFD D34: Versioned Machine Output Envelope
- Status: Draft
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-07-28
- Extends: RFD 048
Summary
Every JSON machine record JP emits is wrapped in a three-key envelope — {"format", "version", "data"} — that names the record and its version. The line-oriented ID formats have no in-band place for a version and are the stated exception: they keep their current bytes and are versioned out-of-band. All existing shapes start at version 0, which explicitly promises nothing and exists so the envelope can ship before any shape is stabilized. A --format-version flag lets consumers pin what they parse, validated before the command runs, with reserved exit codes 20 and 21 for "too old" and "too new."
Motivation
jp -F json today is not one format. It is seven unrelated shapes, three of which are byproducts of human rendering:
| Producer | Shape | Where the shape comes from |
|---|---|---|
jp c ls | array of objects | rendered table headers — keys are ID, #, Activity ▼; columns appear and vanish with data |
jp c show, attachment ls | object | rendered detail labels, ANSI-stripped |
jp c grep | array of objects | hand-built json! in render_json |
jp c grep -l, new, fork | array of ID strings | deliberate, per RFD 050 |
jp query | NDJSON of ConversationEvent | direct serialization of the persisted event type |
| errors | {message, metadata, code} | parse_error, on stderr |
Printer::print / println | {"message":"…"} per call | Printer::wrap_json, on stdout |
list_json keys each object by strip_ansi_escapes::strip_str(header_cell). Renaming a column or adding a sort arrow silently changes the JSON, and columns appear and vanish with the data (expires_at and local are emitted only when some row has a value). The (3 hidden) row becomes an object. Hyrum's Law already applies: anyone parsing jp c ls -F json depends on all of it.
The last row is the widest hole. Printer::print and Printer::println wrap their content as {"message":"…"} and write it to stdout, so any command that prints through either emits unannounced JSON on the data channel. That includes real data: conversation path prints filesystem paths this way, config show prints TOML this way, and attachment ls prints a message on the empty branch but a details object on the non-empty branch.
RFD 086 builds jp c grep -l … | jp c archive - pipelines on this output, and those pipelines are the intended workflow, not an accident. Doing nothing means every future change to a table column is an unannounced break in a documented pipeline, detectable only by the consumer's parser producing wrong answers.
The insurance has two halves, and shipping one without the other is close to worthless:
- Announcement — the output says what it is. A consumer can detect that it received something it does not understand.
- Negotiation — the consumer says what it wants. JP honors it or refuses loudly, before doing any work.
Design
What is and isn't a machine record
Everything in this RFD hangs off one boundary, so it is drawn once, here.
A machine record is a structured command result on the stdout data channel, plus the structured post-parse error record on stderr — the data a command was asked to produce, or the failure explaining why it could not. Within that scope, records are classified by what the producer intends, not by whether the bytes happen to parse as JSON and not by which OutputFormat is active — print_json already emits JSON under Text and TextPretty to serve the no-flag pipelines in RFD 029, so "JSON output" cannot mean OutputFormat::Json.
JP emits other machine-readable output that is deliberately outside this scope. That is an ownership boundary, not a claim that the output is unparseable.
Every machine record has a registry address. Every JSON machine record also carries that address in band, inside an envelope:
- every JSON machine record on stdout, in every output format;
- the
errorrecord on stderr, under JSON output modes.
The line-oriented encodings are the exception: they have a registry address but no in-band announcement, and are covered out-of-band by --format-version alone.
These are not machine-readable output at all, and carry no envelope:
- Chrome. Progress, tool headers, status, and countdowns are human-facing output on stderr. How they are serialized per output mode is RFD 048's concern and this RFD does not change it.
- Human renderings. The pretty and plain tables
-F text-prettyand-F textproduce are presentation, not data. They cannot be pinned. - Argument-parsing diagnostics.
Cli::try_parsefailures exit throughe.exit()before the output format is resolved, so clap's usage errors,--help, and--versionnever reach the envelope.
These are machine-readable, but belong to another contract with its own owner and version namespace:
- Observability output. Tracing events — written to the log file, or to stderr via
--log-file=-— and the announcement of where the log was written. JP does not today commit to the trace event shape; if RFD D15 and RFD D32 give it typed events, whether those join this registry is their call, not this RFD's. The log-path announcement belongs with them too:run()currently writes{"trace_log": "<path>"}to stderr and in-repo tooling parses it — genuinely a record for a parser — but RFD D15 already folds that path into a richer post-run log report. Registering it here would freeze one shape while another RFD replaces it. - Plugin output. Text a plugin sends via
PrintMessageis relayed to stdout by JP verbatim, and may itself be JSON. JP owns the channel but not the meaning; giving it a format address requires a protocol extension (see Plugins).
Errors therefore fall into three tiers, and only the third is a machine record:
| Failure | Surface |
|---|---|
Argument-parsing errors, --help, --version | clap diagnostics, unversioned |
| Post-parse failure under a text output mode | human error rendering |
| Post-parse failure under a JSON output mode | error record, enveloped |
Preflight version rejections bypass clap and use the normal post-parse error renderer for the resolved output mode: an enveloped error record under json and json-pretty, human error text under the text modes. Either way the run exits 20 or 21 before the workspace is loaded and writes nothing to stdout, which is what the line-oriented consumers' negotiation contract actually rests on.
No JSON record from a built-in command reaches stdout unannounced. Output that is a status line rather than data gets the message address at version 0 — which is exactly what Printer::wrap_json already produces, now labelled. message is a migration artifact, not a design: it exists because Printer::print and println will wrap any string as JSON, which is how conversation path and config show currently emit real data. It is deleted, not stabilized, once every command declares a real address — see Negotiation happens before the command runs, which is the mechanism that makes escaping it impossible.
The envelope
{
"format": "conversation.summary_list",
"version": 0,
"data": [ … ]
}Three required keys. The envelope is repeated on every line of an NDJSON stream, so its size is a recurring cost:
{"format":"conversation.event","version":0,"data":{"type":"chat_request","content":"…"}}
{"format":"conversation.event","version":0,"data":{"type":"chat_response","message":"…"}}Payloads that are arrays or bare values — jp c grep, jp c grep -l, jp query --schema — gain a place to carry their version for the first time.
The line-oriented ID format that RFD 086 consumes has no in-band place for a version and keeps its current bytes; it is versioned out-of-band by --format-version alone. This is a second reason the flag is not optional: it is the only versioning that reaches the text machine formats.
Format addresses
format is a dot-delimited name identifying a semantic record contract, independent of the channel that carries it. It is the address a version number attaches to.
A contract may have more than one encoding. conversation.id_list is a JSON array under the JSON formats and one ID per line under the text formats; both are the same contract at the same version.
The version governs two things: the record's semantic contract — its meaning, field set, and documented guarantees — and, for each encoding, that encoding's parse-relevant grammar: delimiters, quoting, ordering, and how many records a line carries. Presentation-only differences are not versioned, which is why compact and pretty JSON share a number: every conforming JSON parser accepts both. A line-oriented encoding has no such parser floor, so its grammar is contract — quoting each ID or switching from newlines to commas breaks consumers without changing a single semantic field, and is a version bump.
If two encodings drift far enough apart to stop representing the same semantic record, the answer is a new address, not a version bump.
The name is format, not type, deliberately. EventKind already serializes with #[serde(tag = "type")], so an envelope keyed type would produce {"type": …, "data": {"type": "chat_request"}} — two different types one level apart is a reader trap.
Addresses are owned by the shape, not by the command that emits it. Several commands may emit the same address, and one command may emit several. Both already happen: the error format comes out of every command in the CLI, and jp query -F json emits an event stream (JsonEmitter) alongside the structured schema result (print_json) — one access point, two unrelated shapes.
Addresses name semantic records, not structural coincidences. details_json produces the same outer {title, details} structure for conversation details and attachment listings; those are two addresses, not one.
Naming convention
Addresses are <subject>.<shape>, lowercase and snake_case:
- Subject is the domain entity the record describes, using the ubiquitous language term where one exists. When the record is not about an entity, the domain area is acceptable —
query.resultdescribes the outcome of a request, not a thing. - Shape names what the record is. Never the command that produced it (
list, notls) and never the encoding it arrived in (no.json, no.ndjson). - Collections end in
_list, and the segment before it names the element. Where the element has its own address the two names match —conversation.summary_listis a list ofconversation.summary— which is how the scheme makes shared payload types visible instead of inviting a second summary type for the list. - Single-segment addresses are reserved for cross-cutting records with no subject. There are two,
errorandmessage, and a third needs justification. - JP owns unprefixed addresses.
plugin.*is reserved so a future plugin-format scheme cannot collide with a JP address.
_list is used in preference to an English plural because conversation.summary and conversation.summaries differ by two characters in a string literal that consumers match exactly.
Two segments cover every address in the registry. Deeper nesting is not forbidden, but the first case that wants it should justify it at stabilization rather than being anticipated now.
Version 0 addresses may be renamed; version 1 addresses may not. That makes the convention cheap to apply now and expensive to retrofit later, which is why the registry below already follows it.
The version 0 registry
| Address | Producers | Encoding | Primary | Negotiable |
|---|---|---|---|---|
conversation.summary_list | jp c ls | JSON document | yes | yes |
conversation.summary | jp c show | JSON document | yes | yes |
conversation.match_list | jp c grep | JSON document | yes | yes |
conversation.id_list | jp c grep -l (planned: c new, c fork) | JSON array or lines | yes | yes |
conversation.event | jp query | JSON record (NDJSON under json) | without --schema | yes |
query.result | jp query --schema | JSON document | with --schema | yes |
attachment.summary_list | jp attachment ls | JSON document | yes | yes |
error | every command, post-parse | JSON document (JSON modes) | n/a | no |
message | any command via Printer::print / println | JSON record (NDJSON under json) | no | no |
c new and c fork are listed as planned producers. There is no conversation new command today and fork prints a status message rather than an ID; both adopt this address when RFD 050 lands.
Two ownership notes:
query.resultversions JP's result container, never the caller-supplied JSON Schema inside it. JP does not own that payload and makes no promise about it.conversation.eventnames the record JP emits, which is not the persisted stream entry. The emitter serializesConversationEventdirectly;InternalEventbase64- encodes content fields at rest and carriesconfig_deltaandcompactionentries the emitter never produces.
message and error are not negotiable and have no primary status. A command whose only output is message has no machine format, so --format-version is rejected for it — which is the intended pressure to give it a real address. error is scoped to post-parse failures under JSON output modes; the other two error tiers are outside the contract (see What is and isn't a machine record).
The two non-negotiable addresses are non-negotiable for different reasons, and neither is a category:
errorcannot be pinned because pinning the record that reports a failed negotiation is circular.messagecannot be pinned because there is no contract to pin — it is a migration artifact scheduled for deletion, not a shape.
Both are announced and versioned like everything else, and both are always emitted at their current version, since there is no pin with which to request an older one.
Version 0 means no promises
Every shape starts at version 0. Version 0 states, as a contract, that there is no contract: the shape may change in any release without notice. It exists so the envelope can ship immediately, before a single shape has been designed.
Version 0 waives the data contract only. version is the version of data at format; the envelope itself carries no version and never will (see Extension policy). A consumer that could not rely on the announcement could not perform the announcement check, which is the entire mechanism.
A shape leaves 0 by being stabilized: given a typed Serialize source rather than being derived from rendered output, reviewed, documented, and promoted to version 1. This turns the work into a visible per-shape backlog rather than a prerequisite for shipping anything.
When a shape reaches version 1, its version 0 is dropped, not maintained. Nothing was promised, so nothing is owed. jp c ls --format-version=0 then exits 20, which is the deprecation mechanism working as intended and is why version 0 is disposable.
What pinning promises
--format-version is negotiation, not merely a fail-fast check, and the difference is a retention rule the flag's meaning depends on.
For negotiable formats:
- Version
0is disposable. It is dropped the moment its shape reaches1. - A stabilized version (
1or higher) stays emitted after later versions ship. It is removed only by completing a deprecation process, never as a side effect of the next version arriving.
Without the second clause a future version 2 could drop version 1 on the day it landed, and a pin would buy a clean error instead of continued operation — which is the weaker of the two things this flag could mean.
For non-negotiable formats, retention does not apply and cannot: there is no pin, so there is no way to request an older version and no point in emitting one. They emit exactly their current version, announced in the envelope like anything else.
That has a consequence worth stating rather than discovering. Because a consumer cannot pin error, a breaking change to it after stabilization is unmitigable — there is no older version to fall back to. Two things follow. error is stabilized once, deliberately, and evolves additively from then on under the extension policy's rules. And a consumer must never parse the error payload to decide whether a command failed: the exit code answers that at every version, forever, and the payload only answers why.
message never stabilizes, because it is deleted rather than promoted.
How retained versions are emitted side by side, and what the deprecation process is, is deferred (see Non-Goals). The retention invariant is not, because it is what --format-version means to a consumer.
Negotiation happens before the command runs
run_inner loads the workspace and sanitizes it — which can repair persisted state — before dispatching the command, and jp query calls the LLM and persists events long before any emitter writes. Validating a pin when the first record is written is too late: JP would exit 20 or 21 having already done the work, possibly with partial stdout.
So each command declares its addresses statically: the set it may emit, and which one is primary. Immediately after argument parsing, and before the workspace is loaded, a preflight step resolves that declaration against the invocation and validates every --format-version pin. All version rejections happen there and nowhere else.
A runtime branch must stay inside the declared set. An empty result uses the same address as a non-empty one — attachment ls returning zero attachments emits attachment.summary_list with an empty payload, not a message.
The declaration is a pure function of the parsed invocation, not a per-command constant:
parsed command + resolved output options -> emitted address set + optional primaryArguments change which contract is primary. jp c grep emits conversation.match_list; jp c grep -l emits conversation.id_list. jp query is primarily conversation.event; jp query --schema is primarily query.result, because the result is what the caller asked for and the event stream is the byproduct — RFD 029 treats it as noise in scripted use. The event stream stays in the emitted set and remains pinnable by explicit address. Those are two commands with two primaries each, so a constant cannot resolve the bare pin. Output mode matters for the same reason: an invocation that produces only a human rendering emits no machine record and has no primary at all.
The function must stay independent of workspace data and runtime results, which is what keeps it callable during preflight. A command emitting two addresses names one primary and requires the explicit form for the other.
Pinning a version
jp c ls -F json --format-version=0 # command-scoped shorthand
jp query --format-version=conversation.event=0 # explicit addressThe bare form pins the invoked command's primary format and covers the ordinary case; most users never type an address. The explicit form exists for commands that emit more than one format.
| Situation | Behavior |
|---|---|
| Flag omitted | Emit current versions (the default) |
-F auto / text / text-pretty / json / json-pretty | Identical version semantics |
json vs json-pretty | Same version — the number names the shape, not the serialization |
| Line-oriented text machine formats | Covered; the version is out-of-band only |
| Invocation emits only a human rendering | Usage error, exit 2 |
| Requested version is higher than any JP emits | Exit 21 |
| Requested version was emitted once, now dropped | Exit 20 |
| Address unknown, or not emitted by this invocation | Usage error, exit 2 |
| Command emits no machine format in any invocation | Usage error, exit 2 |
| Explicit pins | Repeatable, one per address |
| Bare pin | At most once; duplicate or conflicting pins rejected at parse |
The flag never changes the output mode. It is orthogonal to -F: it versions the machine record wherever that record is emitted, including print_json under a text format. What it cannot do is turn a human rendering into a machine record — jp c ls --format-version=0 without -F json is a usage error, because the table it would otherwise print is presentation. Erroring is deliberate: a pin that silently succeeded against a human table would tell a script it was protected when it was not. jp c grep -l --format-version=0 stays valid in text mode, because the line-oriented ID list is a machine record there.
json-pretty records span multiple lines and are therefore not NDJSON, even though compact json is. The version does not distinguish them.
The error format is announced but not negotiable. Pinning the format that reports a failed negotiation is circular. It carries a format and version like everything else, and --format-version never applies to it.
Exit codes
| Code | Meaning |
|---|---|
20 | The requested format version is too old (no longer emitted). |
21 | The requested format version is too new (not yet supported). |
The numbers match notmuch's convention. Both are reserved by this RFD; only 21 can fire until a second version of any shape exists.
Reserving them requires narrowing JP's exit-code namespace. cmd/plugin/dispatch.rs forwards a plugin's exit code verbatim via cmd::Error::from(exit.code), so a plugin exiting 21 is indistinguishable from a version rejection. Any nonzero plugin exit is mapped to 1, with the original recorded in the error metadata:
{"format":"error","version":0,"data":{"message":"plugin failed","metadata":{"plugin_exit_code":20},"code":1}}This is a deliberate narrowing, not a free win. The numeric value survives as diagnostic data, but the plugin's process-status contract does not: a script matching case $? in 10) … 11) … against a plugin can no longer recover that value without parsing stderr. JP takes full ownership of its exit-code namespace in exchange.
Plugins
Plugin output carries no format address, and --format-version is rejected before the plugin is spawned. The reason is worth stating, because it is not the one a reader might assume.
JP owns the channel here, not the meaning. A plugin's own stdout is the protocol: message_loop parses every line as PluginToHost, so arbitrary plugin stdout is a protocol error, and user-facing text reaches the terminal only because JP relays a PrintMessage. What is missing is a semantic contract — PrintMessage cannot declare a format address and DescribeResponse cannot declare which formats a plugin emits. Supplying one means extending the protocol, which is RFD 072 and RFD D19 scope, not this RFD's.
RFD 072 states that JP handles plugin output formatting. That RFD is in Discussion, so this is not a blocking conflict, but implementing that clause reopens this boundary and should be designed together with the protocol extension above. What this RFD owns is the exit-code mapping.
Extension policy
Stated up front, because without it every added key is a version bump and the scheme collapses under its own ceremony.
The envelope is frozen. Three required keys — format, version, data — these names, this nesting, permanently. There is no envelope version and never will be — a consumer would have to read a version to learn how to read the version, so some layer has to be fixed, and this is it. Optional root keys may be added, and only if a consumer that ignores them stays correct. Removing or renaming a required key is not a versionable change; it is out of bounds. JP owns the root namespace; payload fields never appear there, flattened or otherwise. A change that would need a required root key is answered with a new format address or a field inside data, never with an envelope change — the versioned layer sits directly underneath, which is why the freeze costs so little.
The payoff is that the mechanism is permanent: on any JP release, forever, including a stream from a version the script has never seen, .format and .version are readable and .data holds the payload. A specific address literal is only as durable as the address — a version 0 address may still be renamed (see Risks), so jq 'select(.format == "conversation.event")' becomes permanent when that address reaches version 1.
Payloads are versioned by version at format:
- Adding a key to a payload object is not breaking.
- Consumers must ignore keys they do not recognize.
- Breaking is: removing a key, renaming a key, changing a value's type, or changing documented semantics — the meaning of a value, a guaranteed ordering, or whether
null, an absent field, and an empty array are equivalent. - For a non-JSON encoding, breaking also covers its parse-relevant grammar: delimiters, quoting, and records per line.
Changing jp c ls's activity column from "3 minutes ago" to an RFC 3339 timestamp breaks consumers without changing a single JSON type. So does switching the default ordering from creation time to activity time. Those are version bumps.
Open enums are opt-in per format, not a global guarantee. A format may declare that unknown variants inside data are safely ignorable, and consumers of that format may then match with a default arm. conversation.event is the counterexample: a future redaction or replacement event would change how earlier records must be interpreted, so a consumer reconstructing a transcript cannot skip it and stay correct.
This is the same compatibility statement cargo metadata publishes, minus the blanket enum clause. It is also what lets a later root-level addition — a deprecation notice, for one — land as an ignorable key rather than as a change to a frozen envelope.
Nested payload versions
A payload that is also an interchange format in its own right may carry its own inner version, governing that inner layer only.
Two numbers over one document only works if the outer one treats part of data as opaque, so a format that does this declares the delegation explicitly:
- A format may declare all or part of
dataan independently versioned document. - The outer version governs the container: the placement of that document, the fields around it, and how it is to be interpreted.
- The inner version governs the document's own fields and semantics.
- Changing only the opaque document does not bump the outer version.
Without the opacity clause the inner document's fields are also outer wire shape, every inner change bumps both numbers, and the second number buys nothing.
The case that forces this is a payload round-tripped as input — a conversation export consumed by a matching import command has a reader on the other side, and that reader's compatibility rules are not the CLI's. query.result is the degenerate case: its data is the caller-supplied schema's result, wholly opaque to JP, so the outer version promises only that data is the requested result and says nothing about its fields.
A payload that is only ever output and wholly owned by JP does not get a second number; the envelope's is the only one.
Drawbacks
It is a breaking change to every JSON consumer, in one shot. Anything parsing jp c ls -F json today gets an object where it expected an array. The mitigation is timing: JP is pre-1.0 and the known consumers are the RFD 086 pipelines and our own tooling. The alternative is a trickle of smaller breaks with no announcement mechanism at all.
Envelope overhead on NDJSON. ~50 bytes per line on a jp query stream. This is the reason the envelope is three keys and not six, and the reason the timestamp and jp version discussed during design were cut.
--format-version is a promise to keep old versions alive. The retention invariant is a stated contract, not an implication: once a shape is stabilized, JP owes its consumers that version until a deprecation completes. Nothing is owed today because version 0 is disposable, but the obligation lands in full the first time a shape reaches version 2, and it arrives as multi-version emission machinery. That cost is deferred, not avoided — choosing the fail-fast reading of the flag instead would be the way to avoid it, and this RFD declines to.
Version 0 is honest but unhelpful. A consumer pinning --format-version=0 pins nothing. The flag only starts paying the day a shape reaches 1, and scripts written before then get a clean failure rather than continued operation.
Plugins lose their exit-code channel. Covered above; it is a real narrowing of an interface that works today.
Alternatives
Version field only, no flag
Add version to the output, skip negotiation. Rejected: it is a receipt, not insurance. A consumer learns after the fact that it received something it cannot parse; it still cannot ask for what it can parse.
Version by access point instead of by format
Version the command rather than the shape: all output from jp c ls is at version N, and --format-version=N pins the command. No addresses needed.
Rejected on two existing cases. The error format is emitted by every command, so it would either be versioned once per access point — changing the error shape bumps ~30 commands on the same day — or exempted, which builds format addressing for one unnamed special case. And jp query -F json already emits two unrelated shapes from one access point.
Shared payload types make it worse rather than better: if ls, show, and grep all emit a stabilized conversation summary, changing that summary forces three renumberings for one change, and the multi-version machinery still has to keep one old summary type alive for all three.
The ergonomics of the access-point model are worth keeping, which is why the bare --format-version=N form is command-scoped. Only the addressing moves into the payload.
A single global version for all machine output
Rejected because it is incompatible with incremental stabilization: a single number cannot express "conversation.summary_list is stable at 1 while conversation.event is still 0," which is the entire migration strategy.
Remap only the plugin exit codes that collide
Forward plugin exit codes as today, except 20 and 21. Narrower, and preserves most of the plugin interface.
Rejected because partial forwarding is a stranger contract than either extreme: a plugin's exit 19 passes through while 20 becomes 1, so a downstream script cannot reason about any code without knowing JP's reserved set, and the reservation has to be relitigated the next time JP needs a code. Full namespace ownership is the smaller long-term surprise.
Full multi-version emission now
Ship --format-version with JP maintaining every historical version from day one, notmuch-style. Rejected as the Second-System trap: two emitters per shape, forever, for formats we already know are wrong. The flag reserves the option; the obligation is bought when a real second version exists.
Non-Goals
- Designing the stable shapes. No shape is promoted past version
0here. The typed-Serializerewrite oflist_json/details_json, the stabilization procedure, and per-format compatibility policies (including which formats declare open enums) are separate, per-shape work that this RFD's version-0baseline exists to unblock. - Multi-version emission and deprecation. How JP emits versions
1and2side by side, and how it warns before dropping a retained version, is designed when a second stabilized version first exists. Version0never coexists with1— it is dropped on stabilization. The extension policy reserves envelope keys for the deprecation notice; exit code20is reserved for the removal. - Unifying the plugin and server protocols with this envelope. Those are RPC transports with correlation, handshakes, and verb-shaped messages; this envelope frames one-way documents. The registry is built so a later RFD can share payload types across transports without sharing framing or version namespaces — but that RFD is not this one, and
jp_plugin::PROTOCOL_VERSIONstays independent of format versions. - Versioning JSON input. Accepting a machine-readable request format is a different axis with a different number.
- Versioning observability output. Tracing events and the log-path announcement belong to RFD D15 and RFD D32. If those RFDs give tracing a typed, committed shape, whether it earns a format address is their decision; this RFD neither claims nor forecloses it.
- Versioning chrome. Chrome is not a machine record and gets no envelope. How it is serialized under each output mode is RFD 048's contract, which this RFD extends rather than amends.
Risks and Open Questions
Envelope shape for NDJSON. Repeating
formatandversionon every line keeps each line self-contained andjq-friendly, at a per-line cost. A single header line would be cheaper and would breakjq -cstreaming andtail -f. Per-line is the proposal; the cost should be measured on a longjp querystream before it is settled.Element identity before stabilization. The naming convention assumes
jp c lsandjp c showwill share aconversation.summaryelement, which is why the list isconversation.summary_list. If stabilization finds the two genuinely need different elements, that address is renamed — cheap at version0, impossible at version1.Exit-code narrowing is user-visible. Plugins that today exit with a meaningful code lose it.
jp-pathandjp-serve-webdo not appear to rely on this, but external plugins might.The
messageaddress is a holding pen. It keeps the invariant honest, but a command whose only output ismessageis not scriptable. If it becomes a permanent home for output that should have been typed, it has failed at its purpose.RFD 085 embeds
"schema_version": 1insideQueryPreviewand sends it throughprint_json. Under this RFD that output is also an envelope payload at version0, so one document would carry two numbers governing the same contract. The nested-version rule covers payloads that are independently interchange formats; a preview is not one, so RFD 085 should drop its inner number in favor of the envelope's. That edit belongs in RFD 085.
Implementation Plan
The three phases are separately reviewable but ship in one release. Announcement without negotiation is the half-measure the Motivation rejects: a release carrying only Phase 1 would break every JSON consumer and give them no way to pin what replaced it. And the exit-code guarantees are not true until Phase 3 — while plugin codes are forwarded verbatim, a plugin exiting 20 or 21 is indistinguishable from a version rejection.
Phase 1: Envelope, addresses, and migration
Introduce the envelope type and the address registry. Route print_json, print_table, print_details, JsonEmitter, Printer::wrap_json, and parse_error through it.
Then inventory every machine-output path, not only the ones that go through a helper: all of stdout in every output format, plus the error record on stderr. The audit cannot stop at the Printer — run() writes to stderr directly with eprintln! after the command returns, and a helper-only sweep would miss any such path. Direct writes and empty-result branches are in scope. Every address registers at version 0. Existing non-empty payloads keep their version-0 shape and are wrapped. Empty and status-message branches are normalized to the command's declared address where the declaration requires it — attachment ls with zero attachments emits attachment.summary_list with an empty payload instead of a message, because the declared address set cannot depend on workspace data. The inventory will surface more of these; each one is listed in the migration notes.
Ship the migration material in the same phase, because this is the breaking change and a consumer should not have to infer the fix after release: document the envelope, the extension policy, and the meaning of version 0 in docs/usage.md; show old and new jq filters (.data for singletons, .data[] for arrays); add format address, machine record, and format version to docs/architecture/ubiquitous-language.md, defined as data-shape concepts rather than stdout concepts.
Any typed payload type introduced here lives in its domain crate (jp_conversation and friends), not in jp_cli, so a future plugin host or server can use it without depending on the CLI.
Independently reviewable and mergeable. This is the breaking change; everything after it is additive.
Phase 2: Preflight, --format-version, and exit codes
Add the address-declaration function and the preflight validation step, placed after argument parsing and before workspace load. Add the global flag with the bare and explicit forms. Accept only currently-emitted versions; exit 21 for anything higher, usage error for unknown or non-emitted addresses. Reserve 20. Wire the error format out of negotiation and reject the flag for external subcommands.
Depends on Phase 1. Exit 20 is unreachable until a shape reaches version 1, and exit 21 is only unambiguous once Phase 3 lands.
Phase 3: Plugin exit-code mapping
Map any nonzero plugin exit to 1 in cmd/plugin/dispatch.rs, recording plugin_exit_code in the error metadata. Update RFD 072's Lifecycle section, which currently states that JP exits with the plugin-provided code.
Depends on Phase 2: the narrowing is only justified once the reserved codes exist.
References
- RFD 048: Four-Channel Output Model — establishes stdout as the data channel; this RFD defines what that channel carries.
- RFD 050: Scripting Ergonomics for Conversation Management — defines the line-oriented / JSON-array ID output convention.
- RFD 086: Line-oriented stdin input for CLI arguments — the pipelines that consume that convention and depend on JP's machine output being stable.
- RFD 029: Scriptable Structured Output — the
jp query -F jsonnoise problem, and why JSON output is not gated onOutputFormat::Json. - RFD 085: Query Explain — carries a nested
schema_versionthat this RFD's envelope supersedes. - RFD 072: Command Plugin System and RFD D19: Structured Plugin Help Protocol — where plugin output formats would be defined, if ever.
- RFD D15: Structured Logging Infrastructure and RFD D32: JP Tracing Infrastructure — own the observability channel, including the log-path announcement and any future typed trace event shape.
- notmuch structured output versioning —
--format-version, exit20/21. cargo metadatacompatibility — the "adding fields is not breaking" policy this RFD adopts.