Found while implementing #13024 (PR #13346), on origin/main at a286411df. Not fixed there: different defect class, and it spans the whole command family rather than the one command that card owns.
What was measured
Every CLI command that offers a machine-readable output mode builds its failure payload the same way. packages/cli/src/commands/meta/delete.ts is the shape:
}catch(error: any){if(flags.format==='json'){awaitemitJson({success: false,error: error.message,});this.exit(1);}printError(error.message||String(error));this.exit(1);}git grep -c "error: error.message" packages/cli/src/commands/ returns 48 sites.
The error that reaches that catch from @objectstack/client is not a bare Error. The SDK's fetch wrapper builds a StandardError carrying, deliberately and with a documented history (#3842, #4007), both:
err.code — the semantic ADR-0112 string (METADATA_CONFLICT, FORBIDDEN, VALIDATION_FAILED, …), normalized to the same spelling across the flat @objectstack/rest envelope and the wrapped runtime-dispatcher one;err.httpStatus — the numeric status.
Both are discarded here. The json payload carries error (a human sentence) and nothing else.
Why it matters more than it used to
Concretely, and measured against a real door in PR #13346: os meta delete TYPE NAME --if-match VERSION now answers a stale pin with a 409 metadata_conflict. Its --format json output is:
{
"success": false,
"error": "[metadata_conflict] view/race_probe has been modified since you loaded it. Expected parent sha256:… but current is sha256:…"
}That is the single outcome a script most needs to branch on — "someone else edited it, re-read and retry" versus "you are not allowed" versus "the server is down" — and the only way to tell them apart from this payload is substring-matching an English sentence that no contract pins. The code (METADATA_CONFLICT) and the status (409) were both in hand one frame earlier.
It is the failure shape AGENTS.md "Route and surface ownership" rule 4 names: a machine-readable surface must not under-report. ADR-0112 exists so consumers branch on a code vocabulary rather than on prose; the CLI is a first-party consumer that throws the vocabulary away at its own boundary.
This particular sentence happens to containmetadata_conflict because @objectstack/metadata-protocol prefixes its message with a bracketed tag. That is a property of one producer, not a contract — and #12975 is the card that argues the CODE: prefix should come OUT of user-facing message strings, which would remove even that accidental affordance.
Scope of the fix, as measured
- 48
error: error.message sites under packages/cli/src/commands/. - Two output helpers already exist and are shared:
emitJson (packages/cli/src/utils/format.ts) and formatOutput (packages/cli/src/utils/output-formatter.ts), so this is plausibly one shared error-payload builder plus 48 call-site swaps, not 48 independent decisions. - Adding keys is additive:
success and error keep their current meaning and spelling, so no existing consumer breaks.
The contract question this carries, which is why it is filed rather than folded into #13024
What exactly should the envelope declare, and is it one shape for all 48 commands?
- A — add
code and httpStatus alongside error when the thrown error carries them, omitting both otherwise. Smallest change; leaves the payload polymorphic (a caller cannot tell "no code" from "old CLI"). - B — always emit
code, falling back to a declared catalog value for a locally-thrown error (the CLI's own input refusals throw plain Errors today and have no code). Uniform shape, but it means giving the CLI's own refusals ADR-0112 codes — a vocabulary decision, and ADR-0112's ledger is the authority on who may mint one. - C — nest, as the runtime dispatcher does:
{ success: false, error: { code, message, httpStatus } }. Matches the server envelope the SDK already normalizes, and is the only option that is a breaking change to the CLI's own JSON output.
Recommendation: A, with B as a follow-up if the CLI's own refusals turn out to need codes. It is additive, it costs one shared helper, and it removes the substring-matching without opening the vocabulary question in the same card. But which one is right is a contract call for the maintainer, and the answer decides whether this is a patch or a minor with a migration note.
Not part of this
⛔ The human (table) output is fine as prose and is not what this card is about.
⛔ #12975's question — whether the CODE: prefix belongs inside the user-facing message at the producer — is a separate card and is not settled here; this one is about the CLI's own envelope either way.
Found while implementing #13024 (PR #13346), on
origin/mainata286411df. Not fixed there: different defect class, and it spans the whole command family rather than the one command that card owns.What was measured
Every CLI command that offers a machine-readable output mode builds its failure payload the same way.
packages/cli/src/commands/meta/delete.tsis the shape:git grep -c "error: error.message" packages/cli/src/commands/returns 48 sites.The error that reaches that
catchfrom@objectstack/clientis not a bareError. The SDK'sfetchwrapper builds aStandardErrorcarrying, deliberately and with a documented history (#3842, #4007), both:err.code— the semantic ADR-0112 string (METADATA_CONFLICT,FORBIDDEN,VALIDATION_FAILED, …), normalized to the same spelling across the flat@objectstack/restenvelope and the wrapped runtime-dispatcher one;err.httpStatus— the numeric status.Both are discarded here. The
jsonpayload carrieserror(a human sentence) and nothing else.Why it matters more than it used to
Concretely, and measured against a real door in PR #13346:
os meta delete TYPE NAME --if-match VERSIONnow answers a stale pin with a409 metadata_conflict. Its--format jsonoutput is:{ "success": false, "error": "[metadata_conflict] view/race_probe has been modified since you loaded it. Expected parent sha256:… but current is sha256:…" }That is the single outcome a script most needs to branch on — "someone else edited it, re-read and retry" versus "you are not allowed" versus "the server is down" — and the only way to tell them apart from this payload is substring-matching an English sentence that no contract pins. The code (
METADATA_CONFLICT) and the status (409) were both in hand one frame earlier.It is the failure shape AGENTS.md "Route and surface ownership" rule 4 names: a machine-readable surface must not under-report. ADR-0112 exists so consumers branch on a code vocabulary rather than on prose; the CLI is a first-party consumer that throws the vocabulary away at its own boundary.
This particular sentence happens to contain
metadata_conflictbecause@objectstack/metadata-protocolprefixes its message with a bracketed tag. That is a property of one producer, not a contract — and #12975 is the card that argues theCODE:prefix should come OUT of user-facing message strings, which would remove even that accidental affordance.Scope of the fix, as measured
error: error.messagesites underpackages/cli/src/commands/.emitJson(packages/cli/src/utils/format.ts) andformatOutput(packages/cli/src/utils/output-formatter.ts), so this is plausibly one shared error-payload builder plus 48 call-site swaps, not 48 independent decisions.successanderrorkeep their current meaning and spelling, so no existing consumer breaks.The contract question this carries, which is why it is filed rather than folded into #13024
What exactly should the envelope declare, and is it one shape for all 48 commands?
codeandhttpStatusalongsideerrorwhen the thrown error carries them, omitting both otherwise. Smallest change; leaves the payload polymorphic (a caller cannot tell "no code" from "old CLI").code, falling back to a declared catalog value for a locally-thrown error (the CLI's own input refusals throw plainErrors today and have no code). Uniform shape, but it means giving the CLI's own refusals ADR-0112 codes — a vocabulary decision, and ADR-0112's ledger is the authority on who may mint one.{ success: false, error: { code, message, httpStatus } }. Matches the server envelope the SDK already normalizes, and is the only option that is a breaking change to the CLI's own JSON output.Recommendation: A, with B as a follow-up if the CLI's own refusals turn out to need codes. It is additive, it costs one shared helper, and it removes the substring-matching without opening the vocabulary question in the same card. But which one is right is a contract call for the maintainer, and the answer decides whether this is a
patchor aminorwith a migration note.Not part of this
⛔ The human (
table) output is fine as prose and is not what this card is about.⛔ #12975's question — whether the
CODE:prefix belongs inside the user-facing message at the producer — is a separate card and is not settled here; this one is about the CLI's own envelope either way.