Skip to content

CLI --format json failure envelopes drop the ADR-0112 error code — 48 sites emit only error.message, so a script has to substring-match English #13347

Description

@os-trump

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') {
    await emitJson({
      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 contain metadata_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.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions