Goal
Give a command a supported way to contribute structured fields to the --json lifecycle envelope,
instead of only being able to emit text that the framework nests as an escaped string.
Background
In JSON mode the lifecycle captures the command's stdout and places it in details.stdout as a
JSON string (_emit_json_success() / _emit_json_error(),
lib/python/base_cli/_run.py:591-640). docs/json-contracts.md explains the rationale, and it is
sound: "A command's human output is represented as a JSON string, so it cannot introduce prose or
ANSI escapes as a second stdout record."
The gap is that there is no other channel. The same document says:
The lower-level success_envelope(), error_envelope(), dumps_envelope(), and
redact_json_value() helpers are public for commands that need to publish their own structured
details records.
But a command that does that while --json is active gets its envelope captured and re-encoded
inside the framework's envelope — two envelopes, one stringified inside the other. So the
documented affordance for structured details only works when JSON mode is off, which is the
opposite of when a consumer wants it.
Verified evidence
Reviewed 2026-09-30 at a58ec109349fa3f3d03eae5b0de078b39ea361a2. A command printing one
structured line under --json:
{"schema_version":1,"schema":"base-cli.output","code":"ok","type":"success","message":"Success",
"details":{"exit_code":0,"stdout":"{\"records\": [{\"host\": \"web-1\", \"status\": \"ok\"}]}\n"},
"run_id":"..."}
A machine consumer must parse JSON, extract details.stdout, then parse that string as JSON again.
There is no supported alternative: Context exposes json_output so a command can detect the
mode, but nothing to write into.
Scope
Add a lifecycle-owned structured result channel, for example:
Context.set_result(mapping) / Context.add_details(**fields) that the envelope merges into
details (under a reserved sub-key such as details.result to keep exit_code and stdout
stable), with redact_json_value() applied as it already is; or
- a documented
StructuredResultWriter binding on Context that routes to details in JSON mode
and to stdout in NDJSON/text mode, so one command body serves all output contracts.
Whichever shape is chosen, the field must be additive to the frozen v1 envelope, schema-validated,
and versioned.
Acceptance criteria
- A command can attach structured data that appears as real JSON in the envelope, not as an escaped
string, without disabling stdout capture.
- The existing v1 envelope keys (
schema_version, schema, code, type, message,
details.exit_code, details.stdout, run_id) are unchanged for commands that do not use the
new channel.
docs/schemas/v1/output.schema.json and error.schema.json are updated, with golden fixtures.
docs/json-contracts.md shows one recipe that produces the same data in --json, ndjson, and
human text.
- Redaction applies to the new channel and is tested.
Validation
Validate updated fixtures with both the Python validator and scripts/validate_contract_fixtures.mjs,
per the existing cross-language contract check.
Non-goals
- Do not remove or change the meaning of
details.stdout.
- Do not let a command emit a second top-level envelope.
- Do not make this the NDJSON replacement; large record sets still belong in NDJSON.
Goal
Give a command a supported way to contribute structured fields to the
--jsonlifecycle envelope,instead of only being able to emit text that the framework nests as an escaped string.
Background
In JSON mode the lifecycle captures the command's stdout and places it in
details.stdoutas aJSON string (
_emit_json_success()/_emit_json_error(),lib/python/base_cli/_run.py:591-640).docs/json-contracts.mdexplains the rationale, and it issound: "A command's human output is represented as a JSON string, so it cannot introduce prose or
ANSI escapes as a second stdout record."
The gap is that there is no other channel. The same document says:
But a command that does that while
--jsonis active gets its envelope captured and re-encodedinside the framework's envelope — two envelopes, one stringified inside the other. So the
documented affordance for structured details only works when JSON mode is off, which is the
opposite of when a consumer wants it.
Verified evidence
Reviewed 2026-09-30 at
a58ec109349fa3f3d03eae5b0de078b39ea361a2. A command printing onestructured line under
--json:A machine consumer must parse JSON, extract
details.stdout, then parse that string as JSON again.There is no supported alternative:
Contextexposesjson_outputso a command can detect themode, but nothing to write into.
Scope
Add a lifecycle-owned structured result channel, for example:
Context.set_result(mapping)/Context.add_details(**fields)that the envelope merges intodetails(under a reserved sub-key such asdetails.resultto keepexit_codeandstdoutstable), with
redact_json_value()applied as it already is; orStructuredResultWriterbinding onContextthat routes todetailsin JSON modeand to stdout in NDJSON/text mode, so one command body serves all output contracts.
Whichever shape is chosen, the field must be additive to the frozen v1 envelope, schema-validated,
and versioned.
Acceptance criteria
string, without disabling stdout capture.
schema_version,schema,code,type,message,details.exit_code,details.stdout,run_id) are unchanged for commands that do not use thenew channel.
docs/schemas/v1/output.schema.jsonanderror.schema.jsonare updated, with golden fixtures.docs/json-contracts.mdshows one recipe that produces the same data in--json,ndjson, andhuman text.
Validation
Validate updated fixtures with both the Python validator and
scripts/validate_contract_fixtures.mjs,per the existing cross-language contract check.
Non-goals
details.stdout.