Skip to content

Commit cad8b42

Browse files
claude[bot]claude
andauthored
fix(rest): serve a sandboxed hook refusal's declared code at every status, not only where a bespoke arm caught it first (#11590)
* fix(rest): serve a sandboxed hook refusal's declared `code` at every status, not only where a bespoke arm caught it first `classifyDataError`'s sandbox unwrap door (`typeof error?.innerMessage === 'string'`) rendered from the raw error and emitted no `code` at all, while every arm around it renders from the resolved envelope. A hook throwing `{ code: 'RECORD_LOCKED', status: 409 }` reached the client with the status and no machine-readable code; `DELETE_RESTRICTED` at the same 409 kept its code only because it has a bespoke arm above the door. Never a status policy: the door dropped `code` on a declared 400 exactly as on 409, and kept it on 5xx by falling through to the passthrough. The code now rides via `thrownCodeFields` — the one definition the three sibling arms use — so the door joins the closed ADR-0112 vocabulary and invents nothing for a producer that declared none. Fixes #10345 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR * test(rest): record the measured ablation result beside the prediction it corrects The prediction said 13 red in this file and 2 in the vocabulary suite; the measurement was 15 and 3. Both misses are recorded rather than fitted: one §3 case asserts the code as well as the status and belongs with the defect pins, and §1 of the vocabulary suite contributes two assertions per arm, not one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a422843 commit cad8b42

7 files changed

Lines changed: 671 additions & 18 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
Serve the `code` a sandboxed hook declared on `/api/v1/data` refusals, at every
6+
status — not only where a bespoke arm happened to catch the condition first
7+
8+
Measured on a booted 17.1.0 server: a hook throwing
9+
`Object.assign(new Error(msg), { code: 'RECORD_LOCKED', status: 409 })` reached
10+
the client as `409 {"error":"…","object":"crm_opportunity"}` — the status but no
11+
machine-readable `code`. Same for `DUPLICATE_VALUE` on `POST` and `FORBIDDEN` on
12+
`403`, while `DELETE_RESTRICTED` at the same 409 and `VALIDATION_FAILED` at 400
13+
carried theirs. A client that must tell "this record is frozen, do not retry"
14+
from "this value is already taken, offer a merge" got `409` for both and had to
15+
substring-match prose that is localised and deliberately reworded over time —
16+
the failure mode the ADR-0112 `code` vocabulary exists to remove.
17+
18+
The branch is `classifyDataError`'s **sandbox unwrap door** in
19+
`error-response.ts` (`typeof error?.innerMessage === 'string'`), which rendered
20+
from the raw error and emitted no `code` at all, while every arm around it
21+
renders from the resolved envelope. That is the whole of the reported
22+
correlation between "no `code`" and "the unwrapped message": they are one
23+
branch, not cause and effect.
24+
25+
It was never the status policy it looked like from outside. The door dropped
26+
`code` on a declared **400** exactly as on a declared 409, and kept it on a
27+
declared 5xx by falling through to the passthrough below — one sandboxed
28+
producer, its code surviving 503 and lost at 409. What made the reading look
29+
status-shaped is which codes have a bespoke arm above the door:
30+
`DELETE_RESTRICTED` and `VALIDATION_FAILED` do and never reach it,
31+
`RECORD_LOCKED` / `DUPLICATE_VALUE` / `FORBIDDEN` do not and did.
32+
33+
The code now rides via `thrownCodeFields`, the one definition the three sibling
34+
arms already use, so the door joins the closed ADR-0112 vocabulary: a registered
35+
spelling arrives verbatim, an unregistered one is demoted to `declaredCode`
36+
beside the status-derived member. Nothing is invented — a producer that declared
37+
no code still gets a body carrying none.
38+
39+
Unchanged: the business message is still the unwrapped `innerMessage` (never the
40+
`hook 'x' threw: …` debug wrapper), a crashing hook body is still the sanitised
41+
`500 INTERNAL_ERROR`, a declared 5xx still withholds its prose, and a refusal
42+
that declares no status still answers 400.

‎packages/rest/src/error-response.ts‎

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,12 @@ function declaredHttpStatus(error: any): number | undefined {
362362
* reaches the flat body at all. It could not have been a legal ADR-0112 code in
363363
* any case; a number in the field callers branch on is the loudest possible
364364
* violation of a closed vocabulary. All four flat arms now ask ONE question.
365+
*
366+
* [#10345] Five arms, since the sandbox unwrap door joined them. It emitted no
367+
* `code` at all, so #9232 found nothing there to narrow and left it out — and
368+
* an exit that never speaks the vocabulary is the one a vocabulary sweep
369+
* cannot see. `rest-thrown-code-vocabulary.test.ts`'s `ARMS` table enumerates
370+
* all five.
365371
*/
366372
function thrownCodeFields(error: any, status: number): { code?: string; declaredCode?: string } {
367373
const thrown = resolveThrownHttpError(error, status);
@@ -613,9 +619,48 @@ function classifyDataError(error: any, object?: string): { status: number; body:
613619
// the custom-action route performs in http-dispatcher's handleAction.
614620
// The full wrapper still reaches server logs via the callers'
615621
// "[REST] Unhandled error" logging and the BodyRunner's own error log.
616-
// Deliberately NO `code` field: older @objectstack/client builds (still
617-
// bundled in deployed consoles) prepend any `code` to the human-readable
618-
// message, which would reintroduce the English noise this branch removes.
622+
//
623+
// [#10345] The `code` the producer declared rides too — via
624+
// {@link thrownCodeFields}, the same one definition the three arms around
625+
// it use. This branch used to omit `code` unconditionally, and that
626+
// omission is what the card measured: a QuickJS hook throwing
627+
// `{ code: 'RECORD_LOCKED', status: 409 }` reached the client with the
628+
// status and no machine-readable code, so a caller could tell "frozen
629+
// record" from "value already taken" only by substring-matching localised
630+
// prose — the failure mode the ADR-0112 enum exists to remove.
631+
//
632+
// The old rationale, recorded because it was load-bearing until it wasn't:
633+
// "older @objectstack/client builds (still bundled in deployed consoles)
634+
// prepend any `code` to the human-readable message". Both halves are now
635+
// false, measured rather than assumed:
636+
//
637+
// - The shipping client does the OPPOSITE by explicit rule. Its error
638+
// construction keeps `.message` to "the server's human-readable message
639+
// — no `[ObjectStack]` branding and no `CODE:` prefix" and attaches the
640+
// code programmatically as `error.code` (`packages/client/src/index.ts`,
641+
// the `fetch` failure path).
642+
// - "Older bundled client, current server" is not a supported pairing.
643+
// #4007 retired the compat read for exactly that combination: SDK and
644+
// server ship on one release train (a changesets fixed group), and
645+
// ADR-0112 renamed the code VALUES anyway, so a code an old console
646+
// could mis-render is a code it could no longer match either.
647+
//
648+
// And the omission was never the status policy it looked like from
649+
// outside. It dropped `code` on a declared 400 exactly as on a declared
650+
// 409, and KEPT it on a declared 5xx by falling through to the passthrough
651+
// below — so one sandboxed producer got its code on 503 and lost it on
652+
// 409. What made the card's reading look status-shaped is which codes have
653+
// a bespoke arm ABOVE this one: `DELETE_RESTRICTED` and
654+
// `VALIDATION_FAILED` do and never reach here, `RECORD_LOCKED` /
655+
// `DUPLICATE_VALUE` / `FORBIDDEN` do not and did.
656+
//
657+
// ⛔ Nothing is invented for a producer that declared no code:
658+
// `thrownCodeFields` answers `{}` there, which is ADR-0112's rule and the
659+
// answer the sibling arms already give (`rest-thrown-code-vocabulary.test.ts`
660+
// §3). Adding `code` here narrows nothing and widens nothing about the
661+
// VOCABULARY either — an unregistered spelling is demoted to
662+
// `declaredCode` by the same shared resolver, so this door stops being the
663+
// one flat exit #9232 could not reach.
619664
if (typeof error?.innerMessage === 'string' && error.innerMessage) {
620665
// [#7543] …but only when the body REPORTED something. A body that
621666
// CRASHED arrives here too, and its `TypeError: not a function` is an
@@ -638,10 +683,17 @@ function classifyDataError(error: any, object?: string): { status: number; body:
638683
// pins (`hook-error-format.dogfood.test.ts`) require.
639684
const declared = declaredHttpStatus(error);
640685
if (declared === undefined || declared < 500) {
686+
// [#10345] `status` is resolved BEFORE the code fields are asked
687+
// for, and handed to {@link thrownCodeFields} as the fallback, so
688+
// an unregistered spelling demotes against the status the client
689+
// actually receives rather than against a default — the #9232 §5
690+
// rule, applied here for the first time.
691+
const status = declared ?? 400;
641692
return {
642-
status: declared ?? 400,
693+
status,
643694
body: {
644695
error: error.innerMessage,
696+
...thrownCodeFields(error, status),
645697
...(object ? { object } : {}),
646698
},
647699
};
@@ -1424,7 +1476,8 @@ function resolveErrorResponse(error: any, object?: string): { status: number; bo
14241476
// [#9232] The surviving `code` is the NARROWED one — see
14251477
// {@link thrownCodeFields}. This arm's old gate was bare truthiness, so
14261478
// it also admitted a non-string `code`; that limb is gone with the
1427-
// narrowing, and the four flat arms now ask one question.
1479+
// narrowing, and the flat arms now ask one question (five of them since
1480+
// #10345 brought the sandbox unwrap door into the vocabulary).
14281481
// [#9934] Both passthrough arms ride a producer-declared `userMessage`
14291482
// onto the body, the same rule as the exported `mapDataError` wrapper —
14301483
// see {@link withDeclaredUserMessage}. On the 5xx arm the PROSE is

0 commit comments

Comments
 (0)