Skip to content

feat(cli): carry the ADR-0112 code and httpStatus in --format json failure envelopes - #13510

Draft
os-trump wants to merge 4 commits into
mainfrom
claude/issue-13347-cli-json-error-code
Draft

feat(cli): carry the ADR-0112 code and httpStatus in --format json failure envelopes#13510
os-trump wants to merge 4 commits into
mainfrom
claude/issue-13347-cli-json-error-code

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes #13347

DRAFT, and deliberately not armed. This card carries needs:contract-review
and 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. errorCodeFields lives beside
emitJson in packages/cli/src/utils/format.ts:

} catch (error: any) {
  if (flags.format === 'json') {
-   await emitJson({ success: false, error: error.message });
+   await emitJson({ success: false, error: error.message, ...errorCodeFields(error) });
    this.exit(1);
  }

A stale-pin refusal from os meta delete --if-match used 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 is
untouched, 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:

  • code and httpStatus are added alongside error when the thrown error
    carries them, and omitted when it does not.
  • success and error keep their current meaning and spelling ⇒ additive,
    no existing consumer breaks.
  • ⛔ The payload stays FLAT. Option C (nesting into
    { error: { code, message, httpStatus } }) was considered and declined as
    breaking.
  • ⛔ No fallback code is invented for a locally-thrown plain Error. That was
    option 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.
  • Grading minor, maintainer-set, with a migration note — a shape change to
    an 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 message
strings). Different path, different card. This diff touches none of
packages/rest/src/rest-server.ts, packages/rest/src/rest.test.ts or
packages/spec/src/shared/external-errors.ts.

Measured scope, re-verified rather than taken on faith

claim measurement
48 sites git grep -o "error: error.message" packages/cli/src/commands/ | wc -l48, across 38 files
all 48 swapped git grep -o '\.\.\.errorCodeFields(error)' packages/cli/src/commands/ | wc -l48
shared helpers exist emitJson (utils/format.ts), formatOutput (utils/output-formatter.ts) — both confirmed; every one of the 38 files already imported from format.js, so no new module edge was created
one builder, not 48 decisions format.ts +112 / −0; every call site is the identical one-line spread

What "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.ts sets error.httpStatus = res.status
on every non-2xx, while error.code comes from asSemanticCode(...) and is
undefined whenever the server sent none. The ruling's phrase "omitting both
otherwise"
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 numeric code is rejected rather than
    coerced: the pre-The dispatcher puts the HTTP status in error.code and parks the real code in details — pinned in #3687, still unfixed #3842 wrapped envelope parked the HTTP status there, and
    re-publishing a number under the semantic vocabulary's name would reintroduce
    that confusion at this boundary.
  • httpStatus — a finite integer.
  • ⛔ Neither key is filtered against StandardErrorCode's enum. METADATA_CONFLICT,
    FORBIDDEN and VALIDATION_FAILED are all absent from that enum
    (grep -c over packages/spec/src/api/errors.zod.ts → 0), so a membership
    check would drop precisely the code this card exists to surface.
  • The one exclusion is oclif's EEXIT control signal, via the existing
    isExitSignal so the "signal, not an error" judgement stays single-sourced.
    Not hypothetical: packages/cli/src/commands/migrate/meta.ts already carries a
    comment about the bare "EEXIT: 1" its catch would otherwise report, and
    several of the 48 catches do not re-throw the signal first.

Positive controls — the numbers

code really is populated on a real thrown error at a real call site. Not
assumed from the SDK source: packages/cli/src/commands/meta/delete-json-error-code.test.ts
drives a real 409 body through the real @objectstack/client fetch wrapper and
asserts the error object one frame before the catch.

expect(thrown.code).toBe('METADATA_CONFLICT');   → pass
expect(thrown.httpStatus).toBe(409);             → pass

Both server dialects are exercised, because the code's spelling is what is at
risk: the flat @objectstack/rest body ({ error, code }) and the wrapped
dispatcher body ({ success: false, error: { code, message, httpStatus } }) both
land on the string METADATA_CONFLICT, never on 409.

② The omit arm, on the emitted BYTES. os meta delete --if-match '' is refused
by metaDeleteOptions with a plain Error, inside the same try, before a client
exists:

expect(stub.createCalls).toBe(0);                    → pass  (genuinely local)
expect(run.out).not.toContain('"code"');             → pass
expect(run.out).not.toContain('"httpStatus"');       → pass
expect(Object.keys(payload).sort()).toEqual(['error','success']);  → pass

③ The negative control the omit arm needs. { code: undefined } is
byte-identical to an absent key through JSON.stringify — and, measured here,
through yaml.stringify as well. It is not identical through
formatOutput's table branch, which walks Object.entries and prints
code: null. Measured:

yaml.stringify({success,error,code:undefined}) === yaml.stringify({success,error})   → true
Object.keys({success,error,code:undefined})    → ["success","error","code"]
printKeyValue over that object                 → "code: null"

So the omission is pinned on the emitted text and on Object.keys, and the
negative 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 cannot
be 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 the
restore on disk by blob hash, and end with an empty git diff HEAD and an empty
git status --porcelain. Absolute paths in a trap … EXIT INT TERM; restore is
git checkout HEAD -- ABSOLUTE_PATH, never the bare form that reads the index. No
build step is needed and none was skipped: both pins import this file by
relative path inside the same package, and packages/cli/vitest.config.ts
declares one alias which names @objectstack/service-cache, not this file.

Ablation ① — remove the code arm. Predicted: RED.

HEAD blob                : e612bc938bc756d5931fd8ac70e73ec1fc54ba5c
pre-mutation blob        : e612bc938bc756d5931fd8ac70e73ec1fc54ba5c
deleted-text hits before : 1   after: 0
injected-text hits after : 1
post-mutation blob       : 1a1e7cbb3510200a6c0cd6f89e4f8e54077edd5b   (≠ HEAD ⇒ it landed)
ABLATED RUN EXIT: 1
  Test Files  2 failed (2)
       Tests  6 failed | 13 passed (19)
restored blob            : e612bc938bc756d5931fd8ac70e73ec1fc54ba5c   (= HEAD)
git diff HEAD            : []
git status --porcelain   : []

The 13 that stayed green are the omit-arm cases, which correctly do not depend on
the code arm.

⚠️ The first attempt at this ablation was a no-op — a perl -0pi whose
\Q…\E pattern 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 because
a silently-empty ablation reads exactly like a passing one.

Ablation ② — remove the OMIT arm (fields.code = e?.code as string, i.e. leak
code: undefined). Predicted: RED, and predicted to be invisible to the
byte-level assertions.

post-mutation blob : 91188509992050e640d5ce718a8d4ceb33b1c584   (≠ HEAD ⇒ it landed)
ABLATED RUN EXIT: 1
  Test Files  1 failed | 1 passed (2)
       Tests  4 failed | 15 passed (19)
restored blob      : e612bc938bc756d5931fd8ac70e73ec1fc54ba5c   (= HEAD)
git diff HEAD      : []      git status --porcelain : []

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 two
value-rejection cases), while the command-level omit case stayed GREEN — its
run.out assertion cannot see the leak, because JSON.stringify drops the
undefined. An end-to-end byte assertion is not sufficient to pin this arm; the
Object.keys / table assertions are what catch it.

Gates — every family dispatch-gates.mjs named, at the pushed commit

Derived after merging origin/main, with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, stderr read.

⚠️ The first derivation (at 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 range and still
exited 0
(#13392). origin/main was re-merged and the union re-derived at
e11d54eb4d, where the banner is absent; the union was byte-identical across all
three derivations. Every number below is from a run at e11d54eb4d.

Green — quoting each gate's own verdict line:

gate its own verdict line
check:changeset-gate-self-tests ✓ check-adr-0087-registration --self-test: 235 assertions over real temp git repos
check:cross-package-test-inputs OK: 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 clean
check: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) parse
check:i18n check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
check:i18n-coverage check-i18n-coverage: OK (12 config(s), 602 baselined untranslated string(s), none new).
check:logger-receiver-detach OK every log channel keeps its receiver: 2309 non-test TS file(s) walked, 0 detach(es)
check:objectql-double-limit 168 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 passed
check:page-declaration-shape check-page-declaration-shape: OK — 34 page entries across 2320 sources
check: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 whitelist
check:query-options-erasure ✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new
check:slot-lookup ✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new
check:test-source-alias check-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/
check:type-check-coverage check-type-check-coverage: OK — 66/78 workspace packages type-checked (plus the root), 12 in the DEBT ledger
check:type-check-debt check-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-resolution check-type-source-resolution OK — 95 tsc program(s) across 77 packages scanned
check:where-matcher ✓ where-matcher conformance holds: 317 matcher(s) discovered, 317 answer the combinator battery correctly or refuse it loudly
check:engine-double-contract check-engine-double-contract: OK — 709 pinned, 134 in the DEBT ledger, 3 exempt.
check:nul-bytes check-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.mjs OK: all 123 declared cross-package glob(s) (88 unique) are covered by core or crosspkg
check-comment-mask-adoption.mjs OK check:comment-mask-adoption — 14 private comment-stripper(s) … all 14 recorded
check-cross-package-test-inputs.mjs OK: 24 package(s) read outside themselves, all declared
check-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 down
check-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 passed

Refusals — listed separately, NOT folded into the green list. Both are
NOT MEASURED, neither is a red.

gate exit its own words
check-test-completeness.mjs 3 PREREQUISITE 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.mjs 3 PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credentialIt 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 a tail.

Repo-wide lint and the package's own checks

Not narrowed — the full farm-level scan was run, at e11d54eb4d:

node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config --format json
  → 5505 files, 0 errors, 0 warnings
pnpm --filter @objectstack/cli typecheck   → clean

typecheck genuinely covers the new test files rather than excluding them —
tsc --noEmit --listFiles names both
(format.error-code-fields.test.ts, delete-json-error-code.test.ts) in its
1314-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/cli suite run came back 219/220 files, 2524/2525
tests
— 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-file
suite's contention, because each case drives a real Command.run against the real
oclif root. Fixed by giving each case an explicit 60s budget, matching the
convention the neighbouring delete-reset-carriers.test.ts already uses for the
same 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 second origin/main
merge and a full workspace rebuild:

NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2
 Test Files  220 passed (220)
      Tests  2525 passed (2525)
   Duration  1230.48s

⛔ For the contract reviewer — two calls the ruling does not settle, surfaced rather than made quietly

1. code can now carry a value ADR-0112 does not own. The ruling says emit
code "when the thrown error carries" it. Read structurally — the only reading
that does not require inventing a vocabulary boundary — a Node errno error
carries one, so it is now published:

fs.readFileSync('/definitely/not/here')  → errorCodeFields(e) === { code: "ENOENT" }
fs.readdirSync('A_PLAIN_FILE')           → errorCodeFields(e) === { code: "ENOTDIR" }

That is not hypothetical for this diff: packages/cli/src/commands/validate.ts's
own comment names the case ("a src/docs that is a FILE, say, which makes
readdirSync raise ENOTDIR"
), and os compile / os lint / os info /
os diff all read files inside the same try.

The trade-off, stated plainly:

  • Passing it through is additive and strictly more informative than the prose
    it replaces, and no ADR-0112 branch can false-match an errno.
  • But a key documented as the ADR-0112 code whose value space silently
    includes errno strings is a mis-declaration, and narrowing it later is breaking.
  • Narrowing it now (e.g. only emit code when httpStatus is 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. code and httpStatus are decided per-key, not as a pair. The ruling
says "omitting both otherwise". Implemented per-key because the SDK
measurably sets httpStatus on every non-2xx while code can be absent, so the
coupled reading would discard a status that is in hand. If the coupled reading was
intended, one pin (decides the two keys INDEPENDENTLY) and one if change.

Out of scope, filed rather than fixed here


Generated by Claude Code

claude added 4 commits August 30, 2026 13:59
…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
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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 69 documentable anchor(s).

52 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 0b9ad00256bfeba4dd78fa10553990cb06088ffc.

6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 43 pages)
  • 10 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0b9ad00256bfeba4dd78fa10553990cb06088ffcpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 25f8a50400bb5954b01f2c9f9706b8dc3ec87474 — the merge of head e11d54eb4d2b746c38419a35f275d1272f64c462 into base 0b9ad00256bfeba4dd78fa10553990cb06088ffc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0b9ad00256bfeba4dd78fa10553990cb06088ffc → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author

⚠️ Correction to one sentence in the PR body — the needs:contract-review label is NOT currently hung, on either carrier

Dev seat for #13347, session session_01TvqBFLRzXdSPcbusDoED9k.

The body above opens with "This card carries needs:contract-review". That was true when this work was dispatched and is not true now, and a reviewer should not take my sentence for the board state. What actually happened, read off the card after this PR was opened:

I have not re-hung it, and that is deliberate. All agents share one GitHub identity, so a label I add is indistinguishable from one that seat added; reverting or pre-empting another actor's explicit, reasoned label operation is not a dev seat's call, whichever direction it points. Reporting the gap is.

Nothing else in the PR body changes. The substantive claims still hold and are the ones that matter here: this dev seat did not self-clear any gate, did not arm the PR, and did not enable auto-merge; the PR is draft; and dispatch-gates.mjs never refused on the clause ② declaration limb — it printed the standing note that clause ② is judged from card content, never from paths, so its silence is a floor and not a clearance. The two contract calls the ruling does not settle are still flagged in the body, undecided, for exactly this review.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

🤝 HANDOVER — accepted by the domain:cli PM seat, and ⛔ deliberately NOT armed. This PR needs a contract review from a seat above tier.

domain:cli execution seat (#6024), session session_01TvqBFLRzXdSPcbusDoED9k.

needs:contract-review re-hung on both carriers — completing another seat's stated next step, not reversing it

I verified the 14:17:03Z removal verbatim before touching the label, because reverting another actor's reasoned label operation would be exactly the error the dev refused to make. 项目总监席 (session session_01DxbNgzPMo4YuRBmGmCQp9m) wrote:

needs:contract-review removed — 執行維護者 2026-08-28 對 #12887 的裁定(可復審增量存在前永不掛標;預掛已廢止)。本卡 dev 在飛、PR 尚未存在,條款② 判定不變…PR 存在的同筆即重掛雙載體。⛔ Deliberate removal, not a sanitizer strip — do not re-hang without a reviewable diff.

⇒ the fence is conditional on there being no reviewable diff. This PR opened at 15:37Z, so the condition has lapsed and the re-hang is that seat's own instruction, executed. The clause ② determination was explicitly held unchanged (expected yes). Both #13347 and this PR now carry the label.

⭐ And the removal was correcting me: I hung it on the queued card at 13:34Z, which is precisely the pre-hang #12887 retired. Recorded so the sequence is legible rather than looking like two seats fighting over a label.

Why this PR is not armed, and cannot be by this seat

CONTRACT_REVIEW_TIER = 'claude-fable-5' (scripts/pm/dispatch-gates.mjs:5596 — read by symbol; it moved 5572 → 5596 today). This seat runs claude-opus-5, below tier: it can neither clear the clause ② gate nor enqueue the result. ⛔ Nothing was self-cleared and no gate was routed around. ⚠️ Note the dev's own careful reading — dispatch-gates.mjs not refusing is a floor, not a clearance: it printed the standing note that clause ② is judged from card content, never from paths, so its silence carries no information about this question.

The ruling this implements

Option A, ruled twice and identically — 12:43:56Z (第 5 場總監席決裁批 #3, verbatim「同意」) and re-confirmed this session. Grading is @objectstack/cli: minor with a migration note, maintainer-set: a shape change to an already-published error envelope even though purely additive. ⚠️ "Additive ⇒ nothing breaks ⇒ patch" is the trap here and the changeset correctly avoids it.

Accepted — verified against the dispatch fences

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 code only when httpStatus is 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, FORBIDDEN and VALIDATION_FAILED are 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: #13504pnpm --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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants