Skip to content

Commit 21c4bdd

Browse files
fix(rest): an undeclared hook abort answers the data door with HOOK_ABORTED (#27667)
domain:spec#3 · os-support-ai · session_01WGufziZEEbJZ3DUMwdVJWX <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBYEoqNTitmBCn1mkpaKyxVd)_ Fixes #27597 Clause-②: yes (widening) Metadata-protocol: none Skills-docs: none — `skills/objectstack-data/references/data-hooks.md` (Error Handling, Declared refusals) teaches that a throw aborts and that a declared code rides `declaredCode`; it states no wire `code` for a free-text throw, so nothing it says turns false. `skills/objectstack-api/SKILL.md` (Error Envelopes) teaches the ledger generically. The one published sentence this PR makes false, `content/docs/protocol/kernel/error-handling.mdx` ("a refusal that declared a status but no code arrives with `error` alone"), is corrected here. **Proposed code: `HOOK_ABORTED`** (the maintainer names it at approval; the name lives in one ledger row, one constant `UNDECLARED_HOOK_REFUSAL_CODE`, two test files and one doc line): - `HOOK_` is the domain prefix the ledger already uses for hook conditions (`HOOK_UNSCOPED_DATA_ACCESS`, `ERR_HOOK_TARGET_REBIND`), and `ABORTED` is the hook contract's own word (`onError: 'abort'`, the default), the shape of `BATCH_ABORTED`. - It names what happened (a hook stopped the operation), not why; the why is the hook's own sentence in `error`. - It is not a standard-catalog synonym: `standardSynonymOf('HOOK_ABORTED')` is `undefined` (no member carries `HOOK`), so it needs no waiver. - Alternatives considered: `HOOK_REFUSED` (reads like a declared refusal, which is the `refusals` catalogue's job); `HOOK_UNDECLARED_REFUSAL` (names the author's omission rather than the event the client sees); the standard `VALIDATION_ERROR` that `standardErrorCodeForHttpStatus(400)` derives, which #24662 chose for an unevaluable hook condition. Rejected because the card asks for a code a client can tell apart from every other 400, and `VALIDATION_ERROR` is what any malformed body also answers. ## 维护者速读 - 改了什么:沙箱 hook 体用自由文本中止写入(`throw new Error('…')`,没声明 code、也没有 `refusals` 目录)时,`/data` 门现在回 `HOOK_ABORTED`;以前回的 400 没有 `code`。新码登记在 ADR-0112 台账 `@objectstack/types` 名下。 - 为什么改:这是 `/data` 门上唯一不带 `code` 的拒绝,控制台、SDK 和 AI 代理没法按 `code` 分支,分不清是 hook 有意中止还是别的未分类 400。 - 风险与回滚:`/data` 门是加宽,响应多一个 `code` 键,状态码和 `error` 文本不变;分享规则、安全建议绑定、审批和导入撤销这几个门对同一中止原来回通用的 `VALIDATION_ERROR`,现在也回 `HOOK_ABORTED`(改了已有值,请席位判断仍算 widening);声明了 code 的拒绝(`refusals` 目录、注册码、降级到 `declaredCode` 的码)照旧,非 hook 的无码拒绝照旧不加码,崩溃仍是 500。回滚即还原本 PR。码名请在批准时定(默认 `HOOK_ABORTED`)。 - 先看:`packages/types/src/data-error-classification.ts`(`sandboxRefusalCodeFields` 与两处调用),`packages/rest/src/error-response.ts`(声明状态透传分支),`packages/spec/src/api/error-code-ledger.zod.ts`(台账行)。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run src/rest-undeclared-hook-abort-code.test.ts`,应 12/12 全绿。 - 需要你做的:批准时确认码名 `HOOK_ABORTED`(或给出别名)。 ## What changes ### Measured first, on `origin/main` `178b5238` A sandboxed hook refusal with no declared code (the shape `quickjs-runner` throws: `.message` the `hook 'guard' threw: Error: boom abort` wrapper, `.innerMessage` `boom abort`, no `code`) answered every `/data` exit with no `code`: - `mapDataError(err, 'obj')` → `400 {"error":"boom abort","object":"obj"}` - `POST /data/:object`, `PATCH /data/:object/:id`, `POST /data/:object/batch` → the same, no `code` - with `status: 422` or `statusCode: 422` → `422`, no `code` - the wrapper fallback (instance lost, message only) → `400`, no `code` How the door tells a declared refusal from an undeclared one today: by the error's own `code`. `thrownCodeFields` (via `resolveThrownHttpError`) answers `{}` when the producer declared no string `code`, and `{ code, declaredCode? }` otherwise. A hook with a `refusals` catalogue never reaches the door code-less: `resolveHookRefusal` turns a declared code into a `SandboxError` carrying `code` and `status`, and turns an undeclared or missing code into the sandbox's own fault (`500`). So "undeclared" at the door means: a sandboxed body's business refusal (`sandboxBusinessMessage` answers) whose `thrownCodeFields` is `{}`. ### The ledger row (spec) `HOOK_ABORTED` under `@objectstack/types` in `ERROR_CODE_LEDGER`, the package whose constant stamps it (the provenance gate reads a constant at its definition). `ErrorCode` and the generated reference pages (`content/docs/references/api/*.mdx`, `gen:docs`) pick it up. No `FieldErrorCode` change: this is a top-level code. ### The emitter (types, rest) - `@objectstack/types`: `UNDECLARED_HOOK_REFUSAL_CODE = 'HOOK_ABORTED'`, typed `ErrorCode` so a ledger without the row stops the package compiling. `sandboxRefusalCodeFields(error, status)` returns `thrownCodeFields`' answer when the producer declared a code, else `{ code: UNDECLARED_HOOK_REFUSAL_CODE }`. `classifyDataError`'s sandbox unwrap door and its wrapper fallback read it. - `@objectstack/rest`: `resolveErrorResponse`'s declared-status 4xx passthrough reads it when the error is a sandbox refusal (`businessMessage !== undefined`), so the `status` and `statusCode` spellings answer one code. Every other producer keeps `thrownCodeFields`. - Not changed: the 5xx arms, the log verdict (`logUnexpectedRouteError`), the import runner's row codes (it adopts only `INVALID_FIELD`, `UNIQUE_VIOLATION`, `VALIDATION_FAILED` from the door). ### The same abort at the doors that re-dress this classification (measured by the existing suites) The families that re-dress `/data`'s answer (`classifiedRefusalAnswer` → the nested envelope) already filled the missing code with the catalog floor for `400`, `VALIDATION_ERROR`. They now carry `HOOK_ABORTED` too, so one abort answers one code on every rest door that reads this classification: | Door | Before | After | |---|---|---| | `/data` flat routes (POST, PATCH, DELETE, batch, createMany, updateMany, deleteMany, clone) | no `code` | `HOOK_ABORTED` | | sharing rules (define, delete, evaluate), security suggested-bindings (confirm, dismiss, reopen), approvals (approve, reject, recall, revise, resubmit, reassign, remind, request-info, comment) | `VALIDATION_ERROR` | `HOOK_ABORTED` | | `/analytics/dataset/query` (its ①b arm asks `classifiedRefusalAnswer`) | no `code` | `HOOK_ABORTED` | | `/data/:object/import/:jobId/undo` failures list | `VALIDATION_ERROR` | `HOOK_ABORTED` | The second and fourth rows move an existing value (`VALIDATION_ERROR` → `HOOK_ABORTED`) rather than adding a key, and the seat should weigh that against the claimed `(widening)` arm. Measured consumers: no `ui/**` or `@objectstack/client` code branches on `VALIDATION_ERROR` for these routes (the console renders `error`/`userMessage`; the client passes `code` through). Client SDK: `@objectstack/client` carries no per-code mapping; it lifts the body's `code` string onto `err.code` (`packages/client/src/index.ts`, the `fetch` failure path), so `err.code === 'HOOK_ABORTED'` with no SDK change. ### Before and after Before: `POST /api/v1/data/OBJ {"name":"throwabort"}` → `400 {"error":"boom abort","object":"OBJ"}` After: `POST /api/v1/data/OBJ {"name":"throwabort"}` → `400 {"error":"boom abort","code":"HOOK_ABORTED","object":"OBJ"}` ### In-flight intersection with #27508 (log half, `domain:server`) #27508's branch `claude/issue-27508-hook-abort-not-unhandled` @ `e9d789c2` (no PR yet) edits `packages/rest/src/error-response.ts` in the log verdict (`isSandboxBodyRefusal`, `logUnexpectedRouteError`), lines this PR does not touch, so there is no textual conflict. There is a semantic one: its `isSandboxBodyRefusal` exempts the abort only when `resolved.body.code === undefined`, and after this PR the abort carries `HOOK_ABORTED`, so the abort would still be logged as `[REST] Unhandled error`. Whichever lands second makes that condition read the new code (for example `code === undefined || code === UNDECLARED_HOOK_REFUSAL_CODE`). Per the order, this PR does not touch the log half. ## Tests - New `packages/rest/src/rest-undeclared-hook-abort-code.test.ts` (12 cases): §1 `mapDataError` answers the code; §2 the POST, PATCH and batch routes; §3 a declared `status` / `statusCode` with no code keeps the status and gains the code, alike at both doors; §4 the wrapper fallback is byte-identical to the `innerMessage` door; §5 CONTROLS: a `refusals` catalogue answer keeps `declaredCode` beside `VALIDATION_ERROR`, a registered `RECORD_LOCKED` stays its own code, a non-sandbox 400 with no code still gets none, a crash is still `500 INTERNAL_ERROR`; §6 the ledger row is live (registered under `@objectstack/types`, parses as `ErrorCode`). - `packages/qa/dogfood/test/hook-error-format.dogfood.test.ts`: the end-to-end pin that asserted `body.code` undefined for a free-text `throw new Error(...)` now asserts `HOOK_ABORTED`. - Eleven existing rest suites pinned the old answer (29 cases: `code` absent on the flat door, `VALIDATION_ERROR` on the nested families and the undo list) and are flipped in the second commit, each with a `[#27597]` note: `rest.test.ts`, `rest-hook-refusal-code-parity`, `rest-hook-refusal-message-parity` (§8b), `rest-hook-script-fault-envelope`, `rest-sandbox-declared-status`, `rest-thrown-code-vocabulary` (the sandbox arm gains `undeclaredCode`), `rest-share-declared-code`, `rest-share-user-message`, `rest-write-route-hook-refusal-sentence.ledger` (15 routes move to `HOOK_ABORTED_400`; package publish keeps `REFUSED_400`), `rest-declared-client-refusal-log` (still logs; only the code line moves), `import-undo-controls`. No declared-code case moved. ## Acceptance notes - Doors that resolve through `resolveThrownHttpError` instead of this classification still answer the same abort with the status-derived `VALIDATION_ERROR`: `POST /api/v1/packages/publish` (pinned in `rest-write-route-hook-refusal-sentence.ledger.test.ts`, unchanged) and the runtime dispatcher's exits (for example a hook abort relayed through an action body at `/actions`). Converging them means teaching `resolveThrownHttpError` the same rule, which moves the runtime door and its conformance suites; reported to the seat as a follow-up, not done here. - An action body's refusal never reaches these doors (actions answer at the runtime `/actions` door). The wrapper fallback matches `action '…' threw:` too, and answers the same code, to stay byte-identical with the `innerMessage` door, which cannot tell the kind. ## Verification All readings on head `339443cd`, on the shared container under `os-verify-lock`. <details><summary>Before / after of the new test</summary> - Before (types and rest src reverted to `origin/main` `178b5238`, types dist rebuilt, marker count in dist 0): `Tests 8 failed | 4 passed (12)`. Red: §1–§4 (every undeclared abort `code` undefined; `mapDataError` answered `{ error: 'boom abort', object: 'obj' }`) and §6's constant. Green: the four §5 controls. Restore proven by `git diff HEAD` empty and the dist marker back (5). - After: `Tests 12 passed (12)`. </details> <details><summary>Package suites</summary> - `@objectstack/types` test: 30 files, 798 passed; typecheck exit 0. - `@objectstack/rest` test: 336 files, 6599 passed, 326 skipped; typecheck exit 0. - `@objectstack/spec` `src/api/error-code-ledger.test.ts`, `contract.test.ts`, `retired-error-codes.test.ts`: 98 passed. - `@objectstack/dogfood` `test/hook-error-format.dogfood.test.ts` (real QuickJS hook through the stack): 4 passed; the free-text `throw new Error(...)` delete answers `400` with `code: 'HOOK_ABORTED'`. - Downstream readers of `mapDataError` (`@objectstack/types` consumers): core `import-runner-sandbox-{fault,refusal}-row` 55 passed, metadata-protocol `protocol.batch-row-sandbox-refusal` + `protocol.marked-refusal-classification` 37 passed, objectql `hook-withheld-readonly-fault` 11 passed, runtime `sandbox-plain-object-throw-client-sentence`, `sandbox/body-throw-log-level`, `sandbox/capability-denial-is-a-refusal`, `sandbox/hook-refusal-catalogue` 45 passed. - Full dogfood not run: no `!` in the title and no `(narrowing)` arm. </details> <details><summary>Derived gates (`dispatch-gates --commands`, 107 families)</summary> - Round 1 (heads `de85fc29`/`c16322a4`): 104 exit 0; `check:dispatcher-error-vocabulary` exit 1 (`[unresolved-constant]`: it could not read the `as const satisfies ErrorCode` initializer) fixed in `339443cd` by a plain `ErrorCode` annotation; `check:skill-examples` and `check:dual-build-cjs-loads` exit 3 (client dists not yet built). - Round 2 on `339443cd`, after a full build: 107 of 107 exit 0. `dispatch-gates --ran`: 107 derived, 107 run, 0 NOT-MEASURED, 0 UNRUN. - `check:error-code-provenance --report`: `[listed] @objectstack/types → HOOK_ABORTED (constdef) packages/types/src/data-error-classification.ts`. - `check-changeset-no-major --event` with this body: exit 0 (`Clause-②: yes (widening)`, `Metadata-protocol: none`, every changeset entry `patch`). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufziZEEbJZ3DUMwdVJWX)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8f25cee commit 21c4bdd

21 files changed

Lines changed: 361 additions & 58 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/types': patch
4+
'@objectstack/rest': patch
5+
---
6+
7+
fix(rest): a hook that aborts with free text answers the data door with `HOOK_ABORTED` instead of no `code`
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
- **Before.** A sandboxed hook body that aborted a write with free text (`throw new Error('…')`, no `code`, no `refusals` catalogue) answered `/api/v1/data` with `400 {"error":"…","object":"…"}` and no `code`, the one refusal on that door a client could not branch on.
13+
- **Now.** The same abort answers `400 {"error":"…","code":"HOOK_ABORTED","object":"…"}`; a body that declared a 4xx `status` (or `statusCode`) keeps that status and gains the same code. `HOOK_ABORTED` is registered in the ADR-0112 ledger under `@objectstack/types` (`@objectstack/spec`), stamped by `mapDataError`'s sandbox unwrap door and its wrapper fallback (`@objectstack/types`, exported as `UNDECLARED_HOOK_REFUSAL_CODE`) and by the route door's declared-status passthrough (`@objectstack/rest`).
14+
- **Moved.** The same undeclared abort also changes value on doors that already carried a code, from the status-derived `VALIDATION_ERROR` to `HOOK_ABORTED`: the nested refusal envelopes of the sharing rules routes (`POST /api/v1/sharing/rules`, `DELETE /api/v1/sharing/rules/:idOrName`, `POST /api/v1/sharing/rules/:idOrName/evaluate`), the security suggested-bindings routes (`POST /api/v1/security/suggested-bindings/:id/confirm`, `…/dismiss`, `…/reopen`), the approvals request routes (`POST /api/v1/approvals/requests/:id/approve`, `…/reject`, `…/recall`, `…/reassign`, `…/comment`, `…/remind`, `…/request-info`, `…/resubmit`, `…/revise`), and each row of the `failures` list that `POST /api/v1/data/import/jobs/:jobId/undo` returns.
15+
- **Unchanged.** A refusal that declared a code keeps it: a `refusals` catalogue entry still answers `declaredCode` beside the status-derived `code`, and a registered code a body throws is still its own wire `code`. A non-hook producer that declared no code still gets none, and a crashed body is still the sanitised `500 INTERNAL_ERROR`. The server log line for the abort is not changed here.

‎content/docs/protocol/kernel/error-handling.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,9 @@ nested envelope would nest under `details` rides as **top-level siblings** here.
164164
- `error` — the human-readable message, **a string**.
165165
- `code` — the machine-readable code. **Optional**: ADR-0112 invents nothing for the half
166166
a producer did not name, so a refusal that declared a status but no code arrives with
167-
`error` alone. Branch on `code` only after checking it is there.
167+
`error` alone. Branch on `code` only after checking it is there. One producer is named
168+
instead: a sandboxed hook body that aborts with free text and no code answers
169+
`HOOK_ABORTED`, at the 4xx status it declared, else `400`.
168170
- `object` — the object the refusal is about, when the door knows it.
169171
- `declaredCode` / `userMessage` — the same two optional channels as above, as top-level
170172
siblings.

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ const result = AutomationApiErrorCode.parse(data);
347347

348348
| Property | Type | Required | Description |
349349
| :--- | :--- | :--- | :--- |
350-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +351 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
350+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +352 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
351351
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
352352

353353
### Allowed Values: `FlowInnerRefusal.code`
@@ -520,6 +520,7 @@ const result = AutomationApiErrorCode.parse(data);
520520
* `FORBIDDEN`
521521
* `FORM_NOT_FOUND`
522522
* `FORM_RESOLVE_FAILED`
523+
* `HOOK_ABORTED`
523524
* `HOOK_UNSCOPED_DATA_ACCESS`
524525
* `IMPORT_JOB_CREATE_FAILED`
525526
* `IMPORT_ROW_FAILED`

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +351 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +352 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -208,6 +208,7 @@ const result = ApiErrorSchema.parse(data);
208208
* `FORBIDDEN`
209209
* `FORM_NOT_FOUND`
210210
* `FORM_RESOLVE_FAILED`
211+
* `HOOK_ABORTED`
211212
* `HOOK_UNSCOPED_DATA_ACCESS`
212213
* `IMPORT_JOB_CREATE_FAILED`
213214
* `IMPORT_ROW_FAILED`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ const result = ErrorCode.parse(data);
375375
* `FORBIDDEN`
376376
* `FORM_NOT_FOUND`
377377
* `FORM_RESOLVE_FAILED`
378+
* `HOOK_ABORTED`
378379
* `HOOK_UNSCOPED_DATA_ACCESS`
379380
* `IMPORT_JOB_CREATE_FAILED`
380381
* `IMPORT_ROW_FAILED`

‎packages/qa/dogfood/test/hook-error-format.dogfood.test.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
// deliberate business rule (e.g. referential-integrity "记录被引用,删除被
99
// 阻断"). The console shows the REST body's `error` string verbatim in its
1010
// toast, so that string must be ONLY the author's message — not the sandbox
11-
// debug wrapper (`hook 'x' threw: Error: …`, which belongs in server logs)
12-
// and not a `code` field: `throw new Error(...)` declares no code, and
13-
// ADR-0112 invents nothing for a producer that declared none.
11+
// debug wrapper (`hook 'x' threw: Error: …`, which belongs in server logs).
12+
//
13+
// [#27597] The body still carries a `code`, and not one the hook declared:
14+
// `throw new Error(...)` declares none, so the abort answers the ledgered
15+
// `HOOK_ABORTED`, the one code a client branches on for "a hook refused this
16+
// write in its own words". It used to carry no `code` at all.
1417
//
1518
// [#7543] Non-default error names (`TypeError: …`) are the OPPOSITE case, and
1619
// this file used to assert they were KEPT on the wire "as useful context". They
@@ -115,10 +118,10 @@ describe('objectstack verify: sandboxed hook error message format (#hef)', () =>
115118
// The console toast renders this string verbatim — it must be exactly
116119
// what the hook author threw.
117120
expect(body.error).toBe(BUSINESS_MSG);
118-
// No sandbox debug wrapper, no branding, and no `code` — the hook threw
119-
// no code, and ADR-0112 invents nothing for a producer that declared none.
121+
// No sandbox debug wrapper and no branding. [#27597] The hook threw no
122+
// code, so the abort answers the ledgered `HOOK_ABORTED`.
120123
expect(JSON.stringify(body)).not.toMatch(/threw:|hook '|\[ObjectStack\]/);
121-
expect(body.code).toBeUndefined();
124+
expect(body.code).toBe('HOOK_ABORTED');
122125
});
123126

124127
it('ground truth: the blocked delete did not remove the record', async () => {

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import {
6565
mapDataError,
6666
resolveThrownHttpError,
6767
sandboxBusinessMessage,
68+
sandboxRefusalCodeFields,
6869
structuredCodeAnswer,
6970
thrownCodeFields,
7071
truncateClientMessage,
@@ -585,11 +586,20 @@ function resolveErrorResponse(error: any, object?: string): { status: number; bo
585586
// route that passes nothing still answers exactly the bytes it answers
586587
// today (`classifiedRefusalAnswer` calls this door with no object at
587588
// all, and its families' key sets are unmoved).
589+
//
590+
// [#27597] A sandboxed body's refusal that declared no code answers
591+
// `UNDECLARED_HOOK_REFUSAL_CODE`, read through the same
592+
// `sandboxRefusalCodeFields` the unwrap door in `mapDataError` reads,
593+
// so a hook that sets `status` and the one that sets `statusCode` (or
594+
// neither) still answer one code. Every other producer keeps
595+
// `thrownCodeFields`' answer, which invents nothing.
588596
return withDeclaredUserMessage(error, {
589597
status: error.status,
590598
body: {
591599
error: safeMsg,
592-
...thrownCodeFields(error, error.status),
600+
...(businessMessage !== undefined
601+
? sandboxRefusalCodeFields(error, error.status)
602+
: thrownCodeFields(error, error.status)),
593603
...(Array.isArray(error.issues) ? { issues: error.issues } : {}),
594604
...(object ? { object } : {}),
595605
},

‎packages/rest/src/import-undo-controls.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,9 +328,12 @@ describe('[#24380] an import undo runs under the object\'s declared controls for
328328

329329
const res = await ctx.undo(jobId);
330330
expect(res._json).toMatchObject({ success: false, deleted: 0, restored: 0, failed: 2 });
331+
// [#27597] The freeze is a sandboxed hook's free-text abort, so each row
332+
// carries the ledgered `HOOK_ABORTED` (the status-derived `VALIDATION_ERROR`
333+
// before).
331334
expect(res._json.failures).toEqual([
332-
{ id: 'c_new', action: 'delete', code: 'VALIDATION_ERROR', error: FROZEN },
333-
{ id: 'c_old', action: 'restore', code: 'VALIDATION_ERROR', error: FROZEN },
335+
{ id: 'c_new', action: 'delete', code: 'HOOK_ABORTED', error: FROZEN },
336+
{ id: 'c_old', action: 'restore', code: 'HOOK_ABORTED', error: FROZEN },
334337
]);
335338
expect(await ctx.record('ou_case', 'c_new')).toMatchObject({ title: 'fresh', status: 'closed' });
336339
expect(await ctx.record('ou_case', 'c_old')).toMatchObject({ title: 'after', status: 'closed' });

‎packages/rest/src/rest-declared-client-refusal-log.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,17 +273,20 @@ describe('[#27508] a hook `onError: abort` is a refusal the route answered — q
273273
innerMessage: 'boom abort',
274274
});
275275

276-
it('POST /data/:object answers the same code-less 400 and prints no "[REST] Unhandled error"', async () => {
276+
it('POST /data/:object answers the same 400 and prints no "[REST] Unhandled error"', async () => {
277277
const res = await postRecord(serverRejecting(hookAbort()));
278278
expect(res.statusCode).toBe(400);
279-
expect(res.body).toEqual({ error: 'boom abort', object: 'qa_media' });
279+
// [#27597] The door adds the ledgered `HOOK_ABORTED` to an undeclared
280+
// abort's answer; the log verdict reads the thrown error, so it stays quiet.
281+
expect(res.body).toEqual({ error: 'boom abort', code: 'HOOK_ABORTED', object: 'qa_media' });
280282
expect(unhandledLogs()).toHaveLength(0);
281283
});
282284

283285
it('the handleRouteError door agrees: same answer it always gave, no unhandled line', async () => {
284286
const res = await putMeta(serverRejecting(hookAbort()));
285287
expect(res.statusCode).toBe(400);
286-
expect(res.body).toEqual({ error: 'boom abort' });
288+
// [#27597] Same door-added code as above.
289+
expect(res.body).toEqual({ error: 'boom abort', code: 'HOOK_ABORTED' });
287290
expect(unhandledLogs()).toHaveLength(0);
288291
});
289292

‎packages/rest/src/rest-hook-refusal-code-parity.test.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -350,15 +350,15 @@ describe('[#10345] the sandbox unwrap is the branch, and it was never status-sha
350350
// ---------------------------------------------------------------------------
351351

352352
describe('[#10345] the pinned defaults the fix must not disturb', () => {
353-
it('a refusal that declares NO code still carries none — nothing is invented', () => {
354-
// ADR-0112 D4 governs the semantic-CODE channel — the producer names
355-
// the condition on the CODE axis, and the ADR rules no HTTP status for
356-
// an undeclared throw. Narrowing or widening the
357-
// vocabulary must never start ADDING codes to bodies that carried none.
353+
it('a refusal that declares NO code answers the one ledgered HOOK_ABORTED, and declares nothing', () => {
354+
// [#27597] This used to pin `code` absent ("nothing is invented"). The
355+
// undeclared abort now answers one registered code naming the event —
356+
// a hook aborted with free text — and still no `declaredCode`: the
357+
// producer declared none, so nothing was demoted.
358358
const r = mapDataError(sandboxRefusal('month-end close is in progress'), 'crm_account');
359359
expect(r.status).toBe(400);
360360
expect(r.body.error).toBe('month-end close is in progress');
361-
expect(r.body).not.toHaveProperty('code');
361+
expect(r.body.code).toBe('HOOK_ABORTED');
362362
expect(r.body).not.toHaveProperty('declaredCode');
363363
});
364364

@@ -426,9 +426,11 @@ describe('[#10345 / #9232] the unwrap door speaks the closed ADR-0112 vocabulary
426426
});
427427

428428
it('CONTROL — a numeric errno and an empty string are not declarations', () => {
429+
// [#27597] So the refusal is an undeclared abort: `HOOK_ABORTED`, never
430+
// the errno or the empty string.
429431
for (const code of [1062, '']) {
430432
const r = mapDataError(sandboxRefusal('refused', { code, status: 409 }), 'crm_account');
431-
expect(r.body.code, String(code)).toBeUndefined();
433+
expect(r.body.code, String(code)).toBe('HOOK_ABORTED');
432434
expect(r.body.declaredCode, String(code)).toBeUndefined();
433435
}
434436
});

0 commit comments

Comments
 (0)