From de85fc299f9f620c060ebb22065e141b58d01542 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:39:28 +0000 Subject: [PATCH 1/4] fix(rest): an undeclared hook abort answers the data door with HOOK_ABORTED A sandboxed hook body that aborted a write with free text and no declared code answered /data with a 400 that carried no `code`. It now answers the ADR-0112 ledger code HOOK_ABORTED (registered under @objectstack/types), stamped by mapDataError's sandbox unwrap door and wrapper fallback and by the route door's declared-status passthrough. Declared codes keep their own answer; non-hook producers still get nothing invented. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziZEEbJZ3DUMwdVJWX --- .../27597-undeclared-hook-abort-code.md | 14 ++ .../docs/protocol/kernel/error-handling.mdx | 4 +- .../docs/references/api/automation-api.mdx | 3 +- content/docs/references/api/contract.mdx | 3 +- .../docs/references/api/error-code-ledger.mdx | 1 + .../test/hook-error-format.dogfood.test.ts | 15 +- packages/rest/src/error-response.ts | 12 +- .../rest-undeclared-hook-abort-code.test.ts | 186 ++++++++++++++++++ .../spec/src/api/error-code-ledger.zod.ts | 7 + .../types/src/data-error-classification.ts | 45 ++++- 10 files changed, 279 insertions(+), 11 deletions(-) create mode 100644 .changeset/27597-undeclared-hook-abort-code.md create mode 100644 packages/rest/src/rest-undeclared-hook-abort-code.test.ts diff --git a/.changeset/27597-undeclared-hook-abort-code.md b/.changeset/27597-undeclared-hook-abort-code.md new file mode 100644 index 00000000000..b46dd3779f0 --- /dev/null +++ b/.changeset/27597-undeclared-hook-abort-code.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': patch +'@objectstack/types': patch +'@objectstack/rest': patch +--- + +fix(rest): a hook that aborts with free text answers the data door with `HOOK_ABORTED` instead of no `code` + +Clause-②: yes (widening) +Metadata-protocol: none + +- **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. +- **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`). +- **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. diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx index 18e0b4c6313..599bc5f438c 100644 --- a/content/docs/protocol/kernel/error-handling.mdx +++ b/content/docs/protocol/kernel/error-handling.mdx @@ -164,7 +164,9 @@ nested envelope would nest under `details` rides as **top-level siblings** here. - `error` — the human-readable message, **a string**. - `code` — the machine-readable code. **Optional**: ADR-0112 invents nothing for the half a producer did not name, so a refusal that declared a status but no code arrives with - `error` alone. Branch on `code` only after checking it is there. + `error` alone. Branch on `code` only after checking it is there. One producer is named + instead: a sandboxed hook body that aborts with free text and no code answers + `HOOK_ABORTED`, at the 4xx status it declared, else `400`. - `object` — the object the refusal is about, when the door knows it. - `declaredCode` / `userMessage` — the same two optional channels as above, as top-level siblings. diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 5ccb3a512d3..b24a6e7d9fd 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -347,7 +347,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **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 | +| **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 | | **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds | ### Allowed Values: `FlowInnerRefusal.code` @@ -520,6 +520,7 @@ const result = AutomationApiErrorCode.parse(data); * `FORBIDDEN` * `FORM_NOT_FOUND` * `FORM_RESOLVE_FAILED` +* `HOOK_ABORTED` * `HOOK_UNSCOPED_DATA_ACCESS` * `IMPORT_JOB_CREATE_FAILED` * `IMPORT_ROW_FAILED` diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 17517625f87..7138eae9e3c 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **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) | +| **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) | | **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) | | **message** | `string` | ✅ | Readable error message | | **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); * `FORBIDDEN` * `FORM_NOT_FOUND` * `FORM_RESOLVE_FAILED` +* `HOOK_ABORTED` * `HOOK_UNSCOPED_DATA_ACCESS` * `IMPORT_JOB_CREATE_FAILED` * `IMPORT_ROW_FAILED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 549e302b03c..f12b6cf2292 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -375,6 +375,7 @@ const result = ErrorCode.parse(data); * `FORBIDDEN` * `FORM_NOT_FOUND` * `FORM_RESOLVE_FAILED` +* `HOOK_ABORTED` * `HOOK_UNSCOPED_DATA_ACCESS` * `IMPORT_JOB_CREATE_FAILED` * `IMPORT_ROW_FAILED` diff --git a/packages/qa/dogfood/test/hook-error-format.dogfood.test.ts b/packages/qa/dogfood/test/hook-error-format.dogfood.test.ts index 4ebc804f824..5ffe6b90006 100644 --- a/packages/qa/dogfood/test/hook-error-format.dogfood.test.ts +++ b/packages/qa/dogfood/test/hook-error-format.dogfood.test.ts @@ -8,9 +8,12 @@ // deliberate business rule (e.g. referential-integrity "记录被引用,删除被 // 阻断"). The console shows the REST body's `error` string verbatim in its // toast, so that string must be ONLY the author's message — not the sandbox -// debug wrapper (`hook 'x' threw: Error: …`, which belongs in server logs) -// and not a `code` field: `throw new Error(...)` declares no code, and -// ADR-0112 invents nothing for a producer that declared none. +// debug wrapper (`hook 'x' threw: Error: …`, which belongs in server logs). +// +// [#27597] The body still carries a `code`, and not one the hook declared: +// `throw new Error(...)` declares none, so the abort answers the ledgered +// `HOOK_ABORTED`, the one code a client branches on for "a hook refused this +// write in its own words". It used to carry no `code` at all. // // [#7543] Non-default error names (`TypeError: …`) are the OPPOSITE case, and // 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)', () => // The console toast renders this string verbatim — it must be exactly // what the hook author threw. expect(body.error).toBe(BUSINESS_MSG); - // No sandbox debug wrapper, no branding, and no `code` — the hook threw - // no code, and ADR-0112 invents nothing for a producer that declared none. + // No sandbox debug wrapper and no branding. [#27597] The hook threw no + // code, so the abort answers the ledgered `HOOK_ABORTED`. expect(JSON.stringify(body)).not.toMatch(/threw:|hook '|\[ObjectStack\]/); - expect(body.code).toBeUndefined(); + expect(body.code).toBe('HOOK_ABORTED'); }); it('ground truth: the blocked delete did not remove the record', async () => { diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index 77862fd5460..63592435206 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -65,6 +65,7 @@ import { mapDataError, resolveThrownHttpError, sandboxBusinessMessage, + sandboxRefusalCodeFields, structuredCodeAnswer, thrownCodeFields, truncateClientMessage, @@ -585,11 +586,20 @@ function resolveErrorResponse(error: any, object?: string): { status: number; bo // route that passes nothing still answers exactly the bytes it answers // today (`classifiedRefusalAnswer` calls this door with no object at // all, and its families' key sets are unmoved). + // + // [#27597] A sandboxed body's refusal that declared no code answers + // `UNDECLARED_HOOK_REFUSAL_CODE`, read through the same + // `sandboxRefusalCodeFields` the unwrap door in `mapDataError` reads, + // so a hook that sets `status` and the one that sets `statusCode` (or + // neither) still answer one code. Every other producer keeps + // `thrownCodeFields`' answer, which invents nothing. return withDeclaredUserMessage(error, { status: error.status, body: { error: safeMsg, - ...thrownCodeFields(error, error.status), + ...(businessMessage !== undefined + ? sandboxRefusalCodeFields(error, error.status) + : thrownCodeFields(error, error.status)), ...(Array.isArray(error.issues) ? { issues: error.issues } : {}), ...(object ? { object } : {}), }, diff --git a/packages/rest/src/rest-undeclared-hook-abort-code.test.ts b/packages/rest/src/rest-undeclared-hook-abort-code.test.ts new file mode 100644 index 00000000000..3d5b85daabb --- /dev/null +++ b/packages/rest/src/rest-undeclared-hook-abort-code.test.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#27597] A sandboxed hook that aborts a write with free text and declares no +// refusal code answers the `/data` door with ONE ledgered ADR-0112 code, +// `HOOK_ABORTED`, instead of a 400 that carries no `code` at all. +// +// Measured on `main` @ `178b5238` with this file and the `@objectstack/types` +// dist built from `main`: §1–§4 RED (every undeclared abort answered with +// `code` undefined — the card's `POST /data/OBJ` → `400 {"error":"boom +// abort","object":"OBJ"}`), §5 and §6's constant-free half GREEN. After the +// change, the whole file is GREEN. +// +// The CONTROLS in §5 are the other half of the card: a refusal that DECLARED a +// code keeps that code (a `refusals` catalogue entry, a registered code, an +// unregistered spelling demoted to `declaredCode`), a non-sandbox producer +// that declared no code still gets none — nothing is invented for it — and a +// crash is still the sanitised 500. + +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { UNDECLARED_HOOK_REFUSAL_CODE } from '@objectstack/types'; +import { ErrorCode, REGISTERED_ERROR_CODES, ERROR_CODE_LEDGER } from '@objectstack/spec/api'; +import { mapDataError, handleRouteError } from './error-response.js'; +import { RestServer } from './rest-server.js'; + +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + +let errorSpy: ReturnType; +beforeEach(() => { errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); +afterEach(() => { errorSpy.mockRestore(); }); + +const DATA_COLLECTION = '/api/v1/data/:object'; +const DATA_ITEM = '/api/v1/data/:object/:id'; + +/** The shape `runtime/src/sandbox/quickjs-runner.ts` throws for a body's own `throw`. */ +function sandboxRefusal(businessMessage: string, extra: Record = {}, hook = 'guard') { + const err: any = new Error(`hook '${hook}' threw: Error: ${businessMessage}`); + err.name = 'SandboxError'; + err.innerMessage = businessMessage; + return Object.assign(err, extra); +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(() => res); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); + return res; +} + +function setup(protocolOverrides: Record = {}) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([{ name: 'obj' }]), + getMetaItem: vi.fn().mockResolvedValue({}), + findData: vi.fn().mockResolvedValue([]), + createData: vi.fn().mockResolvedValue({}), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({}), + batchData: vi.fn().mockResolvedValue({}), + ...protocolOverrides, + }; + const rest = new RestServer( + { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined) } as any, + protocol, + { api: { requireAuth: false } } as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); + rest.registerRoutes(); + return rest; +} + +async function call(rest: any, method: string, path: string, req: Record) { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + if (!route) throw new Error(`${method} ${path} route not registered`); + const res = makeRes(); + await route.handler({ method, query: {}, headers: {}, ...req }, res); + return res; +} + +/** The answer `resolveErrorResponse` gives, reached through its exported caller. */ +function throughRouteDoor(error: any, object?: string): { status: number; body: any } { + const res = makeRes(); + handleRouteError(res, error, object); + return { status: res.statusCode, body: res.body }; +} + +describe('[#27597] §1 an undeclared free-text abort answers HOOK_ABORTED at mapDataError', () => { + it('400, the hook\'s own sentence, the object, and the code', () => { + const { status, body } = mapDataError(sandboxRefusal('boom abort'), 'obj'); + expect(status).toBe(400); + expect(body).toEqual({ error: 'boom abort', code: 'HOOK_ABORTED', object: 'obj' }); + }); + + it('the constant is the wire spelling', () => { + expect(UNDECLARED_HOOK_REFUSAL_CODE).toBe('HOOK_ABORTED'); + }); +}); + +describe('[#27597] §2 the /data routes answer it, whichever exit caught the abort', () => { + it('POST /data/:object (the card\'s reproduction)', async () => { + const rest = setup({ createData: vi.fn().mockRejectedValue(sandboxRefusal('boom abort')) }); + const res = await call(rest, 'POST', DATA_COLLECTION, { params: { object: 'obj' }, body: { name: 'throwabort' } }); + expect(res.statusCode).toBe(400); + expect(res.body.code).toBe('HOOK_ABORTED'); + expect(res.body.error).toBe('boom abort'); + }, 60_000); + + it('PATCH /data/:object/:id', async () => { + const rest = setup({ updateData: vi.fn().mockRejectedValue(sandboxRefusal('Opportunity is closed.')) }); + const res = await call(rest, 'PATCH', DATA_ITEM, { params: { object: 'obj', id: 'r1' }, body: { name: 'x' } }); + expect(res.statusCode).toBe(400); + expect(res.body.code).toBe('HOOK_ABORTED'); + }, 60_000); + + it('POST /data/:object/batch (handleRouteError)', async () => { + const rest = setup({ batchData: vi.fn().mockRejectedValue(sandboxRefusal('Opportunity is closed.')) }); + const res = await call(rest, 'POST', `${DATA_COLLECTION}/batch`, { + params: { object: 'obj' }, body: { operation: 'update', records: [{ id: 'r1', name: 'x' }] }, + }); + expect(res.statusCode).toBe(400); + expect(res.body.code).toBe('HOOK_ABORTED'); + expect(res.body.error).toBe('Opportunity is closed.'); + }, 60_000); +}); + +describe('[#27597] §3 a body that declared a status but no code: the status stays, the code joins', () => { + it('`status` (the route door\'s passthrough) and `statusCode` (mapDataError) answer alike', () => { + const viaStatus = throughRouteDoor(sandboxRefusal('Closed.', { status: 422 }), 'obj'); + const viaStatusCode = mapDataError(sandboxRefusal('Closed.', { statusCode: 422 }), 'obj'); + expect(viaStatus).toEqual({ status: 422, body: { error: 'Closed.', code: 'HOOK_ABORTED', object: 'obj' } }); + expect(viaStatusCode).toEqual(viaStatus); + }); +}); + +describe('[#27597] §4 the wrapper fallback (the SandboxError instance lost) answers the same body', () => { + it('byte-identical to the innerMessage door', () => { + const lost = new Error("hook 'guard' threw: Error: boom abort"); + expect(mapDataError(lost, 'obj')).toEqual(mapDataError(sandboxRefusal('boom abort'), 'obj')); + expect(mapDataError(lost, 'obj').body.code).toBe('HOOK_ABORTED'); + }); +}); + +describe('[#27597] §5 CONTROLS — a declared refusal keeps its declared code; nothing is invented elsewhere', () => { + it('a `refusals` catalogue entry keeps its declaredCode beside the status-derived code', () => { + // `resolveHookRefusal`'s answer for a declared `CREDIT_LIMIT` (status 400 by default). + const declared = sandboxRefusal('Over the credit limit.', { + code: 'CREDIT_LIMIT', status: 400, userMessage: 'Over the credit limit.', + }); + for (const answer of [mapDataError(declared, 'obj'), throughRouteDoor(declared, 'obj')]) { + expect(answer.status).toBe(400); + expect(answer.body.code).toBe('VALIDATION_ERROR'); + expect(answer.body.declaredCode).toBe('CREDIT_LIMIT'); + } + }); + + it('a registered code a body threw is its own wire code', () => { + const locked = sandboxRefusal('Record is locked.', { code: 'RECORD_LOCKED', status: 409 }); + expect(mapDataError(locked, 'obj').body.code).toBe('RECORD_LOCKED'); + expect(throughRouteDoor(locked, 'obj').body.code).toBe('RECORD_LOCKED'); + }); + + it('a non-sandbox producer that declared a 400 and no code still answers no code', () => { + const plain = Object.assign(new Error('Closed.'), { status: 400 }); + expect(throughRouteDoor(plain, 'obj').body.code).toBeUndefined(); + expect(mapDataError(plain, 'obj').body.code).toBeUndefined(); + }); + + it('a body that CRASHED is still the sanitised 500, not an abort', () => { + const crash = sandboxRefusal('TypeError: not a function'); + expect(mapDataError(crash, 'obj')).toMatchObject({ status: 500, body: { code: 'INTERNAL_ERROR' } }); + }); +}); + +describe('[#27597] §6 the ledger row is live', () => { + it('HOOK_ABORTED is registered under the package that stamps it, and parses as a wire code', () => { + expect(ERROR_CODE_LEDGER['@objectstack/types']).toContain('HOOK_ABORTED'); + expect(REGISTERED_ERROR_CODES).toContain('HOOK_ABORTED'); + expect(ErrorCode.safeParse(UNDECLARED_HOOK_REFUSAL_CODE).success).toBe(true); + }); +}); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 54204a38763..196861611c9 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1117,6 +1117,13 @@ export const ERROR_CODE_LEDGER = { // runner adopts its verdict for each failed row. 'CONCURRENT_UPDATE', 'ERR_DATASOURCE_UNAVAILABLE', + // [#27597] A sandboxed hook body aborted the operation with free text and + // declared no code — no `refusals` catalogue entry, no `code` on its + // throw. Answered at the status the body declared, else 400, with the + // body's own sentence: `mapDataError`'s sandbox unwrap door and + // `@objectstack/rest`'s declared-status passthrough both stamp it + // (`UNDECLARED_HOOK_REFUSAL_CODE`). A declared code keeps its own answer. + 'HOOK_ABORTED', // [#22114] The `/meta` item write doors' optimistic-lock refusal, carrying // `currentVersion`: the arm `structuredCodeAnswer` stamps for a // `METADATA_CONFLICT` whose producer (metadata-protocol's conflict builder) diff --git a/packages/types/src/data-error-classification.ts b/packages/types/src/data-error-classification.ts index ccfc4e52955..7f28a685a28 100644 --- a/packages/types/src/data-error-classification.ts +++ b/packages/types/src/data-error-classification.ts @@ -36,6 +36,7 @@ import { } from './thrown-http-error.js'; import { isNativeErrorName } from './native-error-name.js'; import { unevaluableHookConditionRefusal } from './hook-condition-refusal.js'; +import type { ErrorCode } from '@objectstack/spec/api'; /** * How many characters of a domain error's OWN message reach the client. @@ -473,6 +474,41 @@ function thrownCodeFields(error: any, status: number): { code?: string; declared return { code: thrown.code, ...(demoted !== undefined ? { declaredCode: demoted } : {}) }; } +/** + * [#27597] The wire `code` a sandboxed hook body's business refusal answers + * with when the body declared none: the hook aborted the operation (its + * `onError: 'abort'`, the default) with free text, so the caller learns that + * fact from `code` instead of from the absence of one. + * + * Registered under `@objectstack/types` in the ADR-0112 ledger, and typed + * against `ErrorCode` so a ledger without the row stops this file compiling. + */ +export const UNDECLARED_HOOK_REFUSAL_CODE = 'HOOK_ABORTED' as const satisfies ErrorCode; + +/** + * [#27597] {@link thrownCodeFields} for a sandboxed body's business refusal + * ({@link sandboxBusinessMessage} answered): the producer's own code when it + * declared one — a registered code, a demoted `declaredCode` beside the + * status-derived member, or a hook's `refusals` catalogue entry, each exactly + * as before — else {@link UNDECLARED_HOOK_REFUSAL_CODE}. + * + * Before this, an undeclared abort answered with no `code` at all, the one + * refusal on the `/data` door a client could not branch on. Ruling + * `6431311681` (#26170, letter A) stands: a hook that declares `refusals` + * refuses by code only, and a throw its catalogue cannot answer is refused as + * a fault before it reaches here. This names only what a hook WITHOUT a + * catalogue answers when it throws free text. + * + * Read by every door that answers a sandbox refusal — the unwrap door and its + * wrapper fallback in {@link classifyDataError}, and `@objectstack/rest`'s + * declared-status passthrough — so one abort carries one code whichever route + * caught it. + */ +function sandboxRefusalCodeFields(error: any, status: number): { code?: string; declaredCode?: string } { + const declared = thrownCodeFields(error, status); + return declared.code !== undefined ? declared : { code: UNDECLARED_HOOK_REFUSAL_CODE }; +} + /** * [#12975] The caller-facing half of an ADR-0111 `CODE: message` throw — the * message with the leading restatement of the producer's OWN declared `code` @@ -1522,7 +1558,9 @@ function classifyDataError(error: any, object?: string): { status: number; body: status, body: { error: error.innerMessage, - ...thrownCodeFields(error, status), + // [#27597] …and a body that declared no code answers + // {@link UNDECLARED_HOOK_REFUSAL_CODE} instead of none. + ...sandboxRefusalCodeFields(error, status), ...(object ? { object } : {}), }, }; @@ -1826,6 +1864,10 @@ function classifyDataError(error: any, object?: string): { status: number; body: status: 400, body: { error: msg, + // [#27597] Byte-identical to the `innerMessage` door above, the + // code included. A rethrow that kept the wrapper but lost the + // instance lost any declared code with it. + ...sandboxRefusalCodeFields(error, 400), ...(object ? { object } : {}), }, }; @@ -2272,6 +2314,7 @@ export function driverNotNullRefusalAnswer( export { truncateClientMessage, thrownCodeFields, + sandboxRefusalCodeFields, withoutDeclaredCodePrefix, withDeclaredUserMessage, isSandboxOrigin, From c16322a4c061f1063972c806b0764d2c1034696a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:51:56 +0000 Subject: [PATCH 2/4] test(rest): flip the pins that held an undeclared hook abort code-less Eleven rest suites pinned the abort with no `code` (the flat /data door) or with the catalog floor VALIDATION_ERROR (the nested share, security and approvals envelopes, the import-undo failures list). The same abort now answers HOOK_ABORTED at each of them; every declared-code control is unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziZEEbJZ3DUMwdVJWX --- .../rest/src/import-undo-controls.test.ts | 7 +++- .../rest-declared-client-refusal-log.test.ts | 4 +- .../src/rest-hook-refusal-code-parity.test.ts | 16 ++++---- .../rest-hook-refusal-message-parity.test.ts | 10 ++--- .../rest-hook-script-fault-envelope.test.ts | 8 +++- .../src/rest-sandbox-declared-status.test.ts | 12 ++++-- .../rest/src/rest-share-declared-code.test.ts | 4 +- .../rest/src/rest-share-user-message.test.ts | 7 ++-- .../src/rest-thrown-code-vocabulary.test.ts | 7 +++- ...route-hook-refusal-sentence.ledger.test.ts | 37 +++++++++++-------- packages/rest/src/rest.test.ts | 11 ++++-- 11 files changed, 78 insertions(+), 45 deletions(-) diff --git a/packages/rest/src/import-undo-controls.test.ts b/packages/rest/src/import-undo-controls.test.ts index 7bbbf9980a8..88a7280eb64 100644 --- a/packages/rest/src/import-undo-controls.test.ts +++ b/packages/rest/src/import-undo-controls.test.ts @@ -328,9 +328,12 @@ describe('[#24380] an import undo runs under the object\'s declared controls for const res = await ctx.undo(jobId); expect(res._json).toMatchObject({ success: false, deleted: 0, restored: 0, failed: 2 }); + // [#27597] The freeze is a sandboxed hook's free-text abort, so each row + // carries the ledgered `HOOK_ABORTED` (the status-derived `VALIDATION_ERROR` + // before). expect(res._json.failures).toEqual([ - { id: 'c_new', action: 'delete', code: 'VALIDATION_ERROR', error: FROZEN }, - { id: 'c_old', action: 'restore', code: 'VALIDATION_ERROR', error: FROZEN }, + { id: 'c_new', action: 'delete', code: 'HOOK_ABORTED', error: FROZEN }, + { id: 'c_old', action: 'restore', code: 'HOOK_ABORTED', error: FROZEN }, ]); expect(await ctx.record('ou_case', 'c_new')).toMatchObject({ title: 'fresh', status: 'closed' }); expect(await ctx.record('ou_case', 'c_old')).toMatchObject({ title: 'after', status: 'closed' }); diff --git a/packages/rest/src/rest-declared-client-refusal-log.test.ts b/packages/rest/src/rest-declared-client-refusal-log.test.ts index f49d7942476..16904ffc89d 100644 --- a/packages/rest/src/rest-declared-client-refusal-log.test.ts +++ b/packages/rest/src/rest-declared-client-refusal-log.test.ts @@ -220,7 +220,9 @@ describe('[#23932] controls that stay LOUD — the verdict is not "any 4xx"', () }); for (const res of await expectLoudAtBothDoors(hookRefusal)) { expect(res.statusCode).toBe(400); - expect(res.body.code).toBeUndefined(); + // [#27597] It carries `HOOK_ABORTED` now; the log verdict is not + // that card's, so it still logs. + expect(res.body.code).toBe('HOOK_ABORTED'); } }); diff --git a/packages/rest/src/rest-hook-refusal-code-parity.test.ts b/packages/rest/src/rest-hook-refusal-code-parity.test.ts index b8fc66f782f..644385e0bac 100644 --- a/packages/rest/src/rest-hook-refusal-code-parity.test.ts +++ b/packages/rest/src/rest-hook-refusal-code-parity.test.ts @@ -350,15 +350,15 @@ describe('[#10345] the sandbox unwrap is the branch, and it was never status-sha // --------------------------------------------------------------------------- describe('[#10345] the pinned defaults the fix must not disturb', () => { - it('a refusal that declares NO code still carries none — nothing is invented', () => { - // ADR-0112 D4 governs the semantic-CODE channel — the producer names - // the condition on the CODE axis, and the ADR rules no HTTP status for - // an undeclared throw. Narrowing or widening the - // vocabulary must never start ADDING codes to bodies that carried none. + it('a refusal that declares NO code answers the one ledgered HOOK_ABORTED, and declares nothing', () => { + // [#27597] This used to pin `code` absent ("nothing is invented"). The + // undeclared abort now answers one registered code naming the event — + // a hook aborted with free text — and still no `declaredCode`: the + // producer declared none, so nothing was demoted. const r = mapDataError(sandboxRefusal('month-end close is in progress'), 'crm_account'); expect(r.status).toBe(400); expect(r.body.error).toBe('month-end close is in progress'); - expect(r.body).not.toHaveProperty('code'); + expect(r.body.code).toBe('HOOK_ABORTED'); expect(r.body).not.toHaveProperty('declaredCode'); }); @@ -426,9 +426,11 @@ describe('[#10345 / #9232] the unwrap door speaks the closed ADR-0112 vocabulary }); it('CONTROL — a numeric errno and an empty string are not declarations', () => { + // [#27597] So the refusal is an undeclared abort: `HOOK_ABORTED`, never + // the errno or the empty string. for (const code of [1062, '']) { const r = mapDataError(sandboxRefusal('refused', { code, status: 409 }), 'crm_account'); - expect(r.body.code, String(code)).toBeUndefined(); + expect(r.body.code, String(code)).toBe('HOOK_ABORTED'); expect(r.body.declaredCode, String(code)).toBeUndefined(); } }); diff --git a/packages/rest/src/rest-hook-refusal-message-parity.test.ts b/packages/rest/src/rest-hook-refusal-message-parity.test.ts index d6053d91075..4ee6a2b5f5b 100644 --- a/packages/rest/src/rest-hook-refusal-message-parity.test.ts +++ b/packages/rest/src/rest-hook-refusal-message-parity.test.ts @@ -693,15 +693,15 @@ describe('[#11588] the analytics dataset face answers in the hook\'s words too', // `rest-share-refusal-classification.test.ts`. No live pin was found // on the other side of the question, so the fork clause did not fire. // - // No code, deliberately: the producer declared none and nothing is - // invented for it, which is `thrownCodeFields`' answer on `/data` and - // ADR-0112's rule. `ANALYTICS_QUERY_FAILED` is ③'s code and ③ is not - // where this refusal belongs any more. + // [#27597] The code is `/data`'s too: the producer declared none, so + // the abort answers the ledgered `HOOK_ABORTED` (it carried no code + // before). `ANALYTICS_QUERY_FAILED` is ③'s code and ③ is not where + // this refusal belongs any more. const res = await analyticsRefusal(sandboxRefusal('month-end close is in progress')); expect(res.statusCode).toBe(400); expect(res.body.message).toBe('month-end close is in progress'); - expect(res.body.code).toBeUndefined(); + expect(res.body.code).toBe('HOOK_ABORTED'); expect(JSON.stringify(res.body)).not.toMatch(WRAPPER_RE); }, 60_000); diff --git a/packages/rest/src/rest-hook-script-fault-envelope.test.ts b/packages/rest/src/rest-hook-script-fault-envelope.test.ts index 5777f0fcfc6..8d3f85ddd6b 100644 --- a/packages/rest/src/rest-hook-script-fault-envelope.test.ts +++ b/packages/rest/src/rest-hook-script-fault-envelope.test.ts @@ -324,7 +324,7 @@ describe('[#7543] a hook that deliberately refuses still speaks in its own words expect(r.body.error).not.toBe(INTERNAL_ERROR_MESSAGE); }); - it('the refusal envelope carries no `code` when the hook declared none', () => { + it('the refusal envelope carries `HOOK_ABORTED` when the hook declared no code', () => { // [commit cad8b42f0] Re-read, not rewritten to fit. This assertion always tested a // producer that declares NO code, so it pins ADR-0112's "nothing is // invented for a half-declaration" and is green on both sides of that @@ -332,8 +332,12 @@ describe('[#7543] a hook that deliberately refuses still speaks in its own words // — is that the door withholds a code the producer DID declare; that // claim was the defect, and its "older clients prepend the code to the // message" rationale is retired (see `error-response.ts`). + // + // [#27597] Flipped: the undeclared abort answers the ledgered + // `HOOK_ABORTED`, and still no `declaredCode`. const r = mapDataError(hookRefusal('h', 'month-end close is in progress'), 'showcase_task'); - expect(r.body).not.toHaveProperty('code'); + expect(r.body.code).toBe('HOOK_ABORTED'); + expect(r.body).not.toHaveProperty('declaredCode'); }); it('an English refusal that merely MENTIONS a native error name is still a refusal', () => { diff --git a/packages/rest/src/rest-sandbox-declared-status.test.ts b/packages/rest/src/rest-sandbox-declared-status.test.ts index e1689a4484e..0a4c4166c64 100644 --- a/packages/rest/src/rest-sandbox-declared-status.test.ts +++ b/packages/rest/src/rest-sandbox-declared-status.test.ts @@ -99,7 +99,9 @@ describe('[#9967] mapDataError: a sandboxed body that declares a 4xx status keep const undeclared = mapDataError(sandboxRefusal(), 'showcase_task'); expect(declared.body).toEqual(undeclared.body); - expect(declared.body.code).toBeUndefined(); + // [#27597] Both carry `HOOK_ABORTED` now (no `code` before): the + // body declared a status and no code, so the code is the abort's. + expect(declared.body.code).toBe('HOOK_ABORTED'); }); it('the whole client band is served, off either spelling — same read as every other exit', () => { @@ -166,12 +168,13 @@ describe('[#9967] a sandboxed body that declares a 5xx takes the sanitised 5xx a const CRASH_WORDS = '__OS_SANDBOX_FAULT_7f3a9c__'; describe('[#9967] the pinned defaults are untouched', () => { - it('an UNDECLARED body throw keeps the verbatim-message 400 with no code', () => { + it('an UNDECLARED body throw keeps the verbatim-message 400, coded HOOK_ABORTED', () => { const r = mapDataError(sandboxRefusal(), 'showcase_task'); expect(r.status).toBe(400); expect(r.body.error).toBe('close-period lock'); - expect(r.body.code).toBeUndefined(); + // [#27597] It carried no code before. + expect(r.body.code).toBe('HOOK_ABORTED'); }); it('a body CRASH stays the sanitised 500 even when it carries a stray `status`', () => { @@ -252,7 +255,8 @@ describe('[#9967] the reported request on the real data route', () => { expect(res.statusCode).toBe(403); expect(res.body.error).toBe('close-period lock'); - expect(res.body.code).toBeUndefined(); + // [#27597] The status is the body's; the code is the undeclared abort's. + expect(res.body.code).toBe('HOOK_ABORTED'); }, 60_000); it('the declared refusal is not logged as an unhandled fault — 403 is an expected outcome', async () => { diff --git a/packages/rest/src/rest-share-declared-code.test.ts b/packages/rest/src/rest-share-declared-code.test.ts index 35b6317d108..234ac4e71c3 100644 --- a/packages/rest/src/rest-share-declared-code.test.ts +++ b/packages/rest/src/rest-share-declared-code.test.ts @@ -293,9 +293,11 @@ const ABSENT: Array<{ name: string; error: unknown; status: number; code: string status: 403, code: 'FORBIDDEN', }, { + // [#27597] `code` is the undeclared abort's `HOOK_ABORTED` (the + // status-derived `VALIDATION_ERROR` before); still nothing to declare. name: 'a sandboxed body that declared NO code has nothing to declare', error: sandboxRefusal('sharing is frozen until the quarterly access review closes'), - status: 400, code: 'VALIDATION_ERROR', + status: 400, code: 'HOOK_ABORTED', }, { name: "the ADR-0111 `CODE: message` prefix idiom declares no envelope at all", diff --git a/packages/rest/src/rest-share-user-message.test.ts b/packages/rest/src/rest-share-user-message.test.ts index 108df957c69..e1e014056e2 100644 --- a/packages/rest/src/rest-share-user-message.test.ts +++ b/packages/rest/src/rest-share-user-message.test.ts @@ -302,14 +302,15 @@ const MARKED: Array<{ }, { // The sandbox limb with NO declared status: `classifyDataError`'s unwrap - // door answers 400 and the nested envelope fills the required `code` - // from the catalog floor. The mark rides that arm too — commit 79c46da90's rule is + // door answers 400 with the undeclared abort's `HOOK_ABORTED` (#27597; + // the nested envelope filled the catalog floor `VALIDATION_ERROR` + // before). The mark rides that arm too — commit 79c46da90's rule is // branch-agnostic by construction. name: 'a sandboxed hook body with a sentence and NO declared status', error: sandboxRefusal('sharing is frozen until the access review closes', { userMessage: 'The quarterly access review closes on Friday.', }), - status: 400, code: 'VALIDATION_ERROR', + status: 400, code: 'HOOK_ABORTED', userMessage: 'The quarterly access review closes on Friday.', }, { diff --git a/packages/rest/src/rest-thrown-code-vocabulary.test.ts b/packages/rest/src/rest-thrown-code-vocabulary.test.ts index cf8b41396e7..dbfc093590e 100644 --- a/packages/rest/src/rest-thrown-code-vocabulary.test.ts +++ b/packages/rest/src/rest-thrown-code-vocabulary.test.ts @@ -164,6 +164,9 @@ const ARMS = [ return { status: r.status, body: r.body }; }, status: 409, + // [#27597] The one arm that names a code for a producer that declared + // none: a sandboxed hook's undeclared abort. + undeclaredCode: 'HOOK_ABORTED', }, ] as const; @@ -256,7 +259,9 @@ describe('#9232 §3 — the halves that must not move', () => { const { status, body } = arm.answer(undefined); expect(status).toBe(arm.status); - expect(body.code).toBeUndefined(); + // [#27597] …except the sandbox unwrap, whose undeclared abort + // answers the ledgered `HOOK_ABORTED`. Nothing is DECLARED for it. + expect(body.code).toBe('undeclaredCode' in arm ? arm.undeclaredCode : undefined); expect(body.declaredCode).toBeUndefined(); // ADR-0112 D4 governs the semantic-CODE channel — the producer // names the condition on the CODE axis, and the ADR rules no HTTP diff --git a/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts b/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts index 08352d168ba..02356a27cb3 100644 --- a/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts +++ b/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts @@ -283,6 +283,13 @@ const jobRow = async (_res: any, b: Booted) => (await terminalJobPatch(b)).resul */ const REFUSED_400 = { status: 400, code: 'VALIDATION_ERROR' } as const; +/** + * [#27597] The same refusal where the family re-dresses the `/data` door's + * classification whole: a sandboxed hook's undeclared abort answers the + * ledgered `HOOK_ABORTED` there (the floor's `VALIDATION_ERROR` before). + */ +const HOOK_ABORTED_400 = { status: 400, code: 'HOOK_ABORTED' } as const; + /** * Rows that answer a refusal in the hook's own words. ⛔ The two import rows * are this card's defect and are driven here, never exempted. @@ -349,21 +356,21 @@ const ANSWERS_THE_SENTENCE: Record = { // `.message`, and emits the answer in the nested envelope. Was `500` with // the family's own code (`RULE_*_FAILED`, `SUGGESTION_*_FAILED` / // `INTERNAL`, `APPROVAL_*_FAILED`). - 'POST /api/v1/sharing/rules': { called: 'sharingRules.defineRule', body: {}, answers: REFUSED_400 }, - 'DELETE /api/v1/sharing/rules/:idOrName': { called: 'sharingRules.deleteRule', answers: REFUSED_400 }, - 'POST /api/v1/sharing/rules/:idOrName/evaluate': { called: 'sharingRules.evaluateRule', answers: REFUSED_400 }, - 'POST /api/v1/security/suggested-bindings/:id/confirm': { called: 'security.confirmAudienceBindingSuggestion', answers: REFUSED_400 }, - 'POST /api/v1/security/suggested-bindings/:id/dismiss': { called: 'security.dismissAudienceBindingSuggestion', answers: REFUSED_400 }, - 'POST /api/v1/security/suggested-bindings/:id/reopen': { called: 'security.reopenAudienceBindingSuggestion', answers: REFUSED_400 }, - 'POST /api/v1/approvals/requests/:id/approve': { called: 'approvals.decide', body: {}, answers: REFUSED_400 }, - 'POST /api/v1/approvals/requests/:id/reject': { called: 'approvals.decide', body: {}, answers: REFUSED_400 }, - 'POST /api/v1/approvals/requests/:id/recall': { called: 'approvals.recall', body: {}, answers: REFUSED_400 }, - 'POST /api/v1/approvals/requests/:id/revise': { called: 'approvals.sendBack', body: {}, answers: REFUSED_400 }, - 'POST /api/v1/approvals/requests/:id/resubmit': { called: 'approvals.resubmit', body: {}, answers: REFUSED_400 }, - 'POST /api/v1/approvals/requests/:id/reassign': { called: 'approvals.reassign', body: {}, answers: REFUSED_400 }, - 'POST /api/v1/approvals/requests/:id/remind': { called: 'approvals.remind', body: {}, answers: REFUSED_400 }, - 'POST /api/v1/approvals/requests/:id/request-info': { called: 'approvals.requestInfo', body: {}, answers: REFUSED_400 }, - 'POST /api/v1/approvals/requests/:id/comment': { called: 'approvals.comment', body: {}, answers: REFUSED_400 }, + 'POST /api/v1/sharing/rules': { called: 'sharingRules.defineRule', body: {}, answers: HOOK_ABORTED_400 }, + 'DELETE /api/v1/sharing/rules/:idOrName': { called: 'sharingRules.deleteRule', answers: HOOK_ABORTED_400 }, + 'POST /api/v1/sharing/rules/:idOrName/evaluate': { called: 'sharingRules.evaluateRule', answers: HOOK_ABORTED_400 }, + 'POST /api/v1/security/suggested-bindings/:id/confirm': { called: 'security.confirmAudienceBindingSuggestion', answers: HOOK_ABORTED_400 }, + 'POST /api/v1/security/suggested-bindings/:id/dismiss': { called: 'security.dismissAudienceBindingSuggestion', answers: HOOK_ABORTED_400 }, + 'POST /api/v1/security/suggested-bindings/:id/reopen': { called: 'security.reopenAudienceBindingSuggestion', answers: HOOK_ABORTED_400 }, + 'POST /api/v1/approvals/requests/:id/approve': { called: 'approvals.decide', body: {}, answers: HOOK_ABORTED_400 }, + 'POST /api/v1/approvals/requests/:id/reject': { called: 'approvals.decide', body: {}, answers: HOOK_ABORTED_400 }, + 'POST /api/v1/approvals/requests/:id/recall': { called: 'approvals.recall', body: {}, answers: HOOK_ABORTED_400 }, + 'POST /api/v1/approvals/requests/:id/revise': { called: 'approvals.sendBack', body: {}, answers: HOOK_ABORTED_400 }, + 'POST /api/v1/approvals/requests/:id/resubmit': { called: 'approvals.resubmit', body: {}, answers: HOOK_ABORTED_400 }, + 'POST /api/v1/approvals/requests/:id/reassign': { called: 'approvals.reassign', body: {}, answers: HOOK_ABORTED_400 }, + 'POST /api/v1/approvals/requests/:id/remind': { called: 'approvals.remind', body: {}, answers: HOOK_ABORTED_400 }, + 'POST /api/v1/approvals/requests/:id/request-info': { called: 'approvals.requestInfo', body: {}, answers: HOOK_ABORTED_400 }, + 'POST /api/v1/approvals/requests/:id/comment': { called: 'approvals.comment', body: {}, answers: HOOK_ABORTED_400 }, // Package publish: the classification decides the status the door falls // back to, the shared read the sentence. Was `500 INTERNAL_ERROR`. 'POST /api/v1/packages/publish': { diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 551bf228146..3c4f8b66f7b 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -2585,16 +2585,19 @@ describe('mapDataError — schema/constraint envelopes', () => { // [commit cad8b42f0] No `code` because this producer DECLARED none — not because // the unwrap door withholds it. That door carries a declared code now; the // old "older bundled clients prepend any code to the message" rationale was - // retired with the measurement in `error-response.ts`. What stays pinned - // here is ADR-0112's half: nothing is invented for a half-declaration. - expect(r.body.code).toBeUndefined(); + // retired with the measurement in `error-response.ts`. + // [#27597] The undeclared abort answers the ledgered `HOOK_ABORTED` + // (it carried no `code` before), and nothing is DECLARED for it. + expect(r.body.code).toBe('HOOK_ABORTED'); + expect(r.body.declaredCode).toBeUndefined(); }); it('strips the sandbox debug wrapper from the raw message when innerMessage was lost', () => { const r = mapDataError(new Error("hook 'pm_ref_base' threw: Error: 删除被阻断,请先解除引用"), 'pm_base'); expect(r.status).toBe(400); expect(r.body.error).toBe('删除被阻断,请先解除引用'); - expect(r.body.code).toBeUndefined(); + // [#27597] The same code the `innerMessage` door answers. + expect(r.body.code).toBe('HOOK_ABORTED'); }); // [#7543] REVERSED. This case used to assert the opposite — that a non-default From 339443cd35765cd9220a3ec2f5ce563a7ed6ec7c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:58:02 +0000 Subject: [PATCH 3/4] fix(types): spell the undeclared-abort code constant as a typed literal `check:dispatcher-error-vocabulary` could not resolve the `as const satisfies ErrorCode` initializer to a literal and reported it. A plain `ErrorCode` annotation keeps the compile-time tie to the ledger and is a spelling both code-vocabulary gates read. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziZEEbJZ3DUMwdVJWX --- packages/types/src/data-error-classification.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/types/src/data-error-classification.ts b/packages/types/src/data-error-classification.ts index 7f28a685a28..67c92289d85 100644 --- a/packages/types/src/data-error-classification.ts +++ b/packages/types/src/data-error-classification.ts @@ -481,9 +481,9 @@ function thrownCodeFields(error: any, status: number): { code?: string; declared * fact from `code` instead of from the absence of one. * * Registered under `@objectstack/types` in the ADR-0112 ledger, and typed - * against `ErrorCode` so a ledger without the row stops this file compiling. + * `ErrorCode` so a ledger without the row stops this file compiling. */ -export const UNDECLARED_HOOK_REFUSAL_CODE = 'HOOK_ABORTED' as const satisfies ErrorCode; +export const UNDECLARED_HOOK_REFUSAL_CODE: ErrorCode = 'HOOK_ABORTED'; /** * [#27597] {@link thrownCodeFields} for a sandboxed body's business refusal From 28b6f7107925f6630f6e75f07c29d6d0e493df54 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:03:14 +0000 Subject: [PATCH 4/4] docs(changeset): name the nested doors and undo failures that move to HOOK_ABORTED The release text named only the /data door. An undeclared hook abort also moves from the status-derived VALIDATION_ERROR to HOOK_ABORTED on the sharing rules, security suggested-bindings and approvals request routes, and on each row of the import undo failures list. Add a Moved bullet that lists them. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziZEEbJZ3DUMwdVJWX --- .changeset/27597-undeclared-hook-abort-code.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/27597-undeclared-hook-abort-code.md b/.changeset/27597-undeclared-hook-abort-code.md index b46dd3779f0..074db8267ad 100644 --- a/.changeset/27597-undeclared-hook-abort-code.md +++ b/.changeset/27597-undeclared-hook-abort-code.md @@ -11,4 +11,5 @@ Metadata-protocol: none - **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. - **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`). +- **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. - **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.