feat(cli): carry the ADR-0112 code and httpStatus in --format json failure envelopes - #13510
feat(cli): carry the ADR-0112 code and httpStatus in --format json failure envelopes#13510os-trump wants to merge 4 commits into
--format json failure envelopes#13510Conversation
…ilure envelopes
Every machine-readable failure the CLI emits was `{ success: false, error:
error.message }` and nothing else — 48 sites under `packages/cli/src/commands/`.
The error reaching those `catch` blocks from `@objectstack/client` is not a bare
`Error`: the SDK's `fetch` wrapper attaches `err.code` (the semantic ADR-0112
string, normalized across both server dialects) and `err.httpStatus`. Both were
discarded at the CLI boundary, so a script had to substring-match an English
sentence that no contract pins.
One shared builder (`errorCodeFields` in `utils/format.ts`) plus 48 call-site
spreads. Additive: `success` and `error` keep their meaning and spelling, the
payload stays flat, and the two keys are ABSENT — not `undefined` — when the
thrown error did not carry them. No fallback code is invented for a
locally-thrown plain `Error`. Human `table` output is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
…i-json-error-code
Each case drives a real `Command.run` against the real oclif root — ~0.8s per case on an idle box, and measured TIMING OUT at vitest's 5s default when the file ran inside `@objectstack/cli`'s full 220-file suite on a shared container. Wall-clock only: no assertion moves. The neighbouring `delete-reset-carriers.test.ts` reaches the same conclusion the same way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
…i-json-error-code
📓 Docs Drift CheckThis PR changes 1 package(s): 52 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 25f8a50400bb5954b01f2c9f9706b8dc3ec87474 && git checkout 25f8a50400bb5954b01f2c9f9706b8dc3ec87474
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0b9ad00256bfeba4dd78fa10553990cb06088ffc e11d54eb4d2b746c38419a35f275d1272f64c462 && git checkout -B drift-repro 0b9ad00256bfeba4dd78fa10553990cb06088ffc && git merge --no-ff e11d54eb4d2b746c38419a35f275d1272f64c462
node scripts/docs-audit/affected-docs.mjs --json 0b9ad00256bfeba4dd78fa10553990cb06088ffc
|
|
🤝 HANDOVER — accepted by the
|
| fence | reading |
|---|---|
| flat payload, not nested (⛔ not option C) | ✅ |
success / error keep spelling and meaning |
✅ |
⛔ no fallback code invented for a local plain Error (⛔ not option B) |
✅ |
human table output untouched |
✅ |
| ⛔ nothing from #13095 folded in | ✅ — diff is 42 files, entirely packages/cli/ + .changeset/ |
⛔ does not touch rest-server.ts, rest.test.ts, packages/spec/src/shared/external-errors.ts |
✅ |
changeset minor + migration note |
✅ |
⭐ The omit arm is proved on the bytes, not the object — the trap I asked for and it is real: ablation 2 (leak code: undefined) left the command-level omit case GREEN, because JSON.stringify drops undefined. Only the Object.keys assertion and formatOutput's table branch (which prints code: null) catch it. The dev also corrected its own doc comment after measuring that yaml.stringify output is byte-identical with the extra undefined key — it had claimed otherwise.
⭐ And its first ablation attempt was a silent no-op (perl -0pi whose pattern never matched, exit 0, file untouched), caught by its own on-disk guard — deleted-text hits after: 1 (must be 0) — and reported rather than quietly retried.
Also confirmed live: #13392 reproduced here — a re-derivation printed STALE TREE — … at least 11 commit(s) behind … and still exited 0. The dev re-merged and re-derived; the union came out byte-identical across all three derivations.
⚠️ TWO CALLS FOR THE CONTRACT REVIEWER — flagged, not made
The dev raised both rather than picking silently. Neither is settled by the ruling, and I am not settling them either.
1. code now carries values ADR-0112 does not own. Read structurally, a Node errno error "carries a code", so os validate --json against a src/docs that is a file will emit code: "ENOTDIR". Measured: readFileSync of a missing path → {code:'ENOENT'}; readdirSync of a plain file → {code:'ENOTDIR'}; validate.ts's own comment names that exact ENOTDIR case, and os compile / os lint / os info / os diff all read files inside the same try.
- A (built) — pass through any non-empty string code. The ruling's literal shape; needs no catalog.
⚠️ Cost: the field's value space silently includes errno, which is a mis-declaration for a key documented as the ADR-0112 code, and narrowing it later is breaking. - B — emit
codeonly whenhttpStatusis also present (wire errors only). One line; drops nothing measured; keeps the value space to what producers put on the wire. Cost: a contract call the ruling did not make. - C — filter against
StandardErrorCode. ⛔ Ruled out by measurement, not preference:METADATA_CONFLICT,FORBIDDENandVALIDATION_FAILEDare all absent from that enum, so it would drop precisely the code this card exists to surface.
2. "omitting both otherwise" — coupled, or per-key? Built per-key, on a measurement: the SDK sets error.httpStatus = res.status on every non-2xx, while error.code comes from asSemanticCode(...) and is undefined whenever the server sent no code. ⇒ coupling would discard a status that is in hand, on exactly the responses whose envelope is thinnest. A pin covers the behaviour. If the coupled reading was intended it is one if and one pin.
Also filed by the dev: #13504 — pnpm --filter @objectstack/cli test is a ~24-minute serialized run holding the shared verify lock throughout, which the lock wrapper's own output flags as holder-side starvation.
This seat is going off shift. The PR is complete, green locally (220 files / 2525 tests; eslint . over 5505 files, 0 errors), draft, and waiting on a contract review it cannot receive from here.
Generated by Claude Code
Fixes #13347
⛔ DRAFT, and deliberately not armed. This card carries
needs:contract-reviewand widens a first-party machine-readable surface. The maintainer ruling below
authorises the shape; it is not a substitute for review of what was built. The
dev seat did not self-clear the gate and did not enable auto-merge.
What changed
One shared builder plus 48 call-site spreads.
errorCodeFieldslives besideemitJsoninpackages/cli/src/utils/format.ts:A stale-pin refusal from
os meta delete --if-matchused to read{ "success": false, "error": "[metadata_conflict] view/race_probe has been modified since you loaded it. …" }and now reads
{ "success": false, "error": "[metadata_conflict] view/race_probe has been modified since you loaded it. …", "code": "METADATA_CONFLICT", "httpStatus": 409 }Every removed line in this diff is either an
import … from '…/utils/format.js'or one of the 48 call sites — nothing else was touched. Human (
table) output isuntouched, and no file outside
packages/cli/and.changeset/is in the diff.The ruling this implements
Maintainer, 2026-08-30 — option A of the three the card put up, ruled twice
(12:43:56Z and again this session) and the two agree:
codeandhttpStatusare added alongsideerrorwhen the thrown errorcarries them, and omitted when it does not.
successanderrorkeep their current meaning and spelling ⇒ additive,no existing consumer breaks.
{ error: { code, message, httpStatus } }) was considered and declined asbreaking.
Error. That wasoption B, not chosen: the CLI's own input refusals get no code, because
ADR-0112's ledger is the authority on who may mint one and this card mints
nothing.
minor, maintainer-set, with a migration note — a shape change toan already-published error envelope is minor even though it is purely
additive.
The accepted cost, on the record so nobody re-opens it as a defect: the payload is
polymorphic — a consumer cannot distinguish "this failure carried no code"
from "an older CLI".
⛔ Not merged with #13095 (stripping the
CODE:prefix out of user-facing messagestrings). Different path, different card. This diff touches none of
packages/rest/src/rest-server.ts,packages/rest/src/rest.test.tsorpackages/spec/src/shared/external-errors.ts.Measured scope, re-verified rather than taken on faith
git grep -o "error: error.message" packages/cli/src/commands/ | wc -l→ 48, across 38 filesgit grep -o '\.\.\.errorCodeFields(error)' packages/cli/src/commands/ | wc -l→ 48emitJson(utils/format.ts),formatOutput(utils/output-formatter.ts) — both confirmed; every one of the 38 files already imported fromformat.js, so no new module edge was createdformat.ts+112 / −0; every call site is the identical one-line spreadWhat "carrying" a code means, and the one place I read the ruling per-key
The two keys are decided independently, and that is a measurement rather than
a preference.
packages/client/src/index.tssetserror.httpStatus = res.statuson every non-2xx, while
error.codecomes fromasSemanticCode(...)and isundefinedwhenever the server sent none. The ruling's phrase "omitting bothotherwise" reads either way; coupling them would discard a status that is in
hand on exactly the responses whose envelope is thinnest, so this ships per-key.
A pin covers it (
decides the two keys INDEPENDENTLY — a status with no code still ships), and it is flagged for review below.code— a non-empty string. A numericcodeis rejected rather thancoerced: the pre-The dispatcher puts the HTTP status in
error.codeand parks the real code indetails— pinned in #3687, still unfixed #3842 wrapped envelope parked the HTTP status there, andre-publishing a number under the semantic vocabulary's name would reintroduce
that confusion at this boundary.
httpStatus— a finite integer.StandardErrorCode's enum.METADATA_CONFLICT,FORBIDDENandVALIDATION_FAILEDare all absent from that enum(
grep -coverpackages/spec/src/api/errors.zod.ts→ 0), so a membershipcheck would drop precisely the code this card exists to surface.
EEXITcontrol signal, via the existingisExitSignalso the "signal, not an error" judgement stays single-sourced.Not hypothetical:
packages/cli/src/commands/migrate/meta.tsalready carries acomment about the bare
"EEXIT: 1"its catch would otherwise report, andseveral of the 48 catches do not re-throw the signal first.
Positive controls — the numbers
①
codereally is populated on a real thrown error at a real call site. Notassumed from the SDK source:
packages/cli/src/commands/meta/delete-json-error-code.test.tsdrives a real 409 body through the real
@objectstack/clientfetchwrapper andasserts the error object one frame before the
catch.Both server dialects are exercised, because the code's spelling is what is at
risk: the flat
@objectstack/restbody ({ error, code }) and the wrappeddispatcher body (
{ success: false, error: { code, message, httpStatus } }) bothland on the string
METADATA_CONFLICT, never on409.② The omit arm, on the emitted BYTES.
os meta delete --if-match ''is refusedby
metaDeleteOptionswith a plainError, inside the sametry, before a clientexists:
③ The negative control the omit arm needs.
{ code: undefined }isbyte-identical to an absent key through
JSON.stringify— and, measured here,through
yaml.stringifyas well. It is not identical throughformatOutput'stablebranch, which walksObject.entriesand printscode: null. Measured:So the omission is pinned on the emitted text and on
Object.keys, and thenegative control asserts the wrong implementation is visibly different. Ablation
② below shows why that mattered.
④ Zeroes get a positive control from the same population. The "no key emitted"
zeroes are asserted in the same file, same runner, alongside the case that emits
both keys (
adds BOTH carriers when the error carries both) — so a zero cannotbe a test that silently ran nothing.
Reverse verification — two ablations, direction predicted first
Both mutate
packages/cli/src/utils/format.ts, prove the mutation and therestore on disk by blob hash, and end with an empty
git diff HEADand an emptygit status --porcelain. Absolute paths in atrap … EXIT INT TERM; restore isgit checkout HEAD -- ABSOLUTE_PATH, never the bare form that reads the index. Nobuild step is needed and none was skipped: both pins import this file by
relative path inside the same package, and
packages/cli/vitest.config.tsdeclares one alias which names
@objectstack/service-cache, not this file.Ablation ① — remove the
codearm. Predicted: RED.The 13 that stayed green are the omit-arm cases, which correctly do not depend on
the code arm.
perl -0piwhose\Q…\Epattern never matched, exit 0, file untouched. The guard caught it(
deleted-text hits after : 1 (must be 0)→FAILURE: the mutation did NOT land on disk) and the run was redone with an exact-count replacement. Recorded becausea silently-empty ablation reads exactly like a passing one.
Ablation ② — remove the OMIT arm (
fields.code = e?.code as string, i.e. leakcode: undefined). Predicted: RED, and predicted to be invisible to thebyte-level assertions.
Both predictions held, and the second one is the finding: the four reds are all in
the unit file (
NEGATIVE CONTROL,formatOutput yaml/table, and the twovalue-rejection cases), while the command-level omit case stayed GREEN — its
run.outassertion cannot see the leak, becauseJSON.stringifydrops theundefined. An end-to-end byte assertion is not sufficient to pin this arm; theObject.keys/tableassertions are what catch it.Gates — every family
dispatch-gates.mjsnamed, at the pushed commitDerived after merging
origin/main, withnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, stderr read.77f2a57f84) was clean; a later re-derivation printed⚠️ STALE TREE — this answer is derived from a tree at least 11 commit(s) behind origin/main, and 12 file(s) it derives from CHANGED across that rangeand stillexited 0 (#13392).
origin/mainwas re-merged and the union re-derived ate11d54eb4d, where the banner is absent; the union was byte-identical across allthree derivations. Every number below is from a run at
e11d54eb4d.Green — quoting each gate's own verdict line:
check:changeset-gate-self-tests✓ check-adr-0087-registration --self-test: 235 assertions over real temp git reposcheck:cross-package-test-inputsOK: 24 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.check:doc-authoring✓ doc authoring guard: 14064 customer-facing string(s) across 691 spec sources cleancheck:dual-build-cjs-loads✓ check:dual-build-cjs-loads — 102 published require entry point(s) across 66 package(s) load; 610 emitted CommonJS file(s) parsecheck:i18ncheck-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).check:i18n-coveragecheck-i18n-coverage: OK (12 config(s), 602 baselined untranslated string(s), none new).check:logger-receiver-detachOK every log channel keeps its receiver: 2309 non-test TS file(s) walked, 0 detach(es)check:objectql-double-limit168 limit-blind, 32 shape-breaking and 55 unjudged double(s) in 253 grandfathered file(s); none new.check:objectui-changeset✓ objectui-changeset-digest --self-test: all checks passedcheck:page-declaration-shapecheck-page-declaration-shape: OK — 34 page entries across 2320 sourcescheck:pm-half-states✓ check-half-states self-test: 1551 cases pass.check:published-files✓ check:published-files — 69 publishable package(s) of 78 workspace member(s) declare a files whitelistcheck:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none newcheck:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none newcheck:test-source-aliascheck-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/check:type-check-coveragecheck-type-check-coverage: OK — 66/78 workspace packages type-checked (plus the root), 12 in the DEBT ledgercheck:type-check-debtcheck-type-check-coverage --re-measure: OK — 29 ledger entr(ies) re-measured in 311.9s, 1547 raw tsc error(s) total, none above its recorded number.check:type-source-resolutioncheck-type-source-resolution OK — 95 tsc program(s) across 77 packages scannedcheck:where-matcher✓ where-matcher conformance holds: 317 matcher(s) discovered, 317 answer the combinator battery correctly or refuse it loudlycheck:engine-double-contractcheck-engine-double-contract: OK — 709 pinned, 134 in the DEBT ledger, 3 exempt.check:nul-bytescheck-nul-bytes: OK (scanned 7453 text file(s) … no raw ASCII control bytes).check-adr-0087-registration.mjs✓ this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).check-changeset-no-major.mjs✓ This diff introduces no major bump.check-ci-filter-parity.mjsOK: all 123 declared cross-package glob(s) (88 unique) are covered by core or crosspkgcheck-comment-mask-adoption.mjsOK check:comment-mask-adoption — 14 private comment-stripper(s) … all 14 recordedcheck-cross-package-test-inputs.mjsOK: 24 package(s) read outside themselves, all declaredcheck-empty-changeset.mjs✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).check-keyed-text-bounds.mjs✓ 148 keyed text-family columns judged, 148 bounded.check-plugin-teardown-shape.mjs✓ 64 Plugin implementation(s) across 5047 source(s) … baseline fully burned downcheck-shard-attestation.mjs✓ 2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s).check-undeclared-dep-imports.mjs✓ 78 workspace packages … 1828 @objectstack/* specifiers; 2 ledger row(s), all evidence intact.docs-audit/check-affected-docs.mjs✓ affected-docs self-test: 487 cases pass.docs-audit/check-drift-comment.mjs✓ check-drift-comment: 56 cases pass across 5 fixture diff(s).pm/release-rehearsal-clone.mjs --self-test✓ self-test passedRefusals — listed separately, NOT folded into the green list. Both are
NOT MEASURED, neither is a red.check-test-completeness.mjsPREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named.…the local reading for this gate is NOT MEASURED. ⛔ It is not a red, and there is nothing here to fix.pm/check-half-states.mjsPREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential…It is not a clean board and it is not a dirty one — it is no reading at all.Exit codes were captured before any pipe (
cmd > log 2>&1; ec=$?), never as$?after atail.Repo-wide lint and the package's own checks
Not narrowed — the full farm-level scan was run, at
e11d54eb4d:typecheckgenuinely covers the new test files rather than excluding them —tsc --noEmit --listFilesnames both(
format.error-code-fields.test.ts,delete-json-error-code.test.ts) in its1314-file program, so "typecheck clean" is a reading about them.
A defect the full suite found in this PR's own test file
The first full
@objectstack/clisuite run came back 219/220 files, 2524/2525tests — the single red being one of this PR's new cases,
Test timed out in 5000ms. It passes in isolation (~0.8s/case) and timed out only under the 220-filesuite's contention, because each case drives a real
Command.runagainst the realoclif root. Fixed by giving each case an explicit 60s budget, matching the
convention the neighbouring
delete-reset-carriers.test.tsalready uses for thesame reason. Wall-clock only — no assertion moved, and the ablations above were
run after the fix.
Re-run in full at the pushed commit
e11d54eb4d, after the secondorigin/mainmerge and a full workspace rebuild:
⛔ For the contract reviewer — two calls the ruling does not settle, surfaced rather than made quietly
1.
codecan now carry a value ADR-0112 does not own. The ruling says emitcode"when the thrown error carries" it. Read structurally — the only readingthat does not require inventing a vocabulary boundary — a Node
errnoerrorcarries one, so it is now published:
That is not hypothetical for this diff:
packages/cli/src/commands/validate.ts'sown comment names the case ("a
src/docsthat is a FILE, say, which makesreaddirSyncraise ENOTDIR"), andos compile/os lint/os info/os diffall read files inside the sametry.The trade-off, stated plainly:
it replaces, and no ADR-0112 branch can false-match an errno.
includes errno strings is a mis-declaration, and narrowing it later is breaking.
codewhenhttpStatusis also present,i.e. only for wire errors) needs no catalog and drops nothing measured — but it
is a contract call the ruling did not make, and choosing it silently would be
the same error the escalation comment on this card names.
Built as pass-through, which is the ruling's literal shape. ⛔ Flagged, not
decided. Either verdict is one line in
errorCodeFields.2.
codeandhttpStatusare decided per-key, not as a pair. The rulingsays "omitting both otherwise". Implemented per-key because the SDK
measurably sets
httpStatuson every non-2xx whilecodecan be absent, so thecoupled reading would discard a status that is in hand. If the coupled reading was
intended, one pin (
decides the two keys INDEPENDENTLY) and oneifchange.Out of scope, filed rather than fixed here
pnpm --filter @objectstack/cli testis a ~24-minute serialized run, and on a shared agent container it holds the verify lock for the whole of it #13504 —pnpm --filter @objectstack/cli testis a ~24-minuteserialized run (220 files, 2525 tests, 2419s of test body time) that holds the
shared verify lock for the whole of it on an agent container. The lock wrapper
flags holder-side starvation at that length itself. Not this card's defect and
not touched here.
Generated by Claude Code