Repository navigation
Commit 21c4bdd
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
File tree
- .changeset
- content/docs
- protocol/kernel
- references/api
- packages
- qa/dogfood/test
- rest/src
- spec/src/api
- types/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
164 | 164 | | |
165 | 165 | | |
166 | 166 | | |
167 | | - | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
168 | 170 | | |
169 | 171 | | |
170 | 172 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
347 | 347 | | |
348 | 348 | | |
349 | 349 | | |
350 | | - | |
| 350 | + | |
351 | 351 | | |
352 | 352 | | |
353 | 353 | | |
| |||
520 | 520 | | |
521 | 521 | | |
522 | 522 | | |
| 523 | + | |
523 | 524 | | |
524 | 525 | | |
525 | 526 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | | - | |
| 31 | + | |
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
| |||
208 | 208 | | |
209 | 209 | | |
210 | 210 | | |
| 211 | + | |
211 | 212 | | |
212 | 213 | | |
213 | 214 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
375 | 375 | | |
376 | 376 | | |
377 | 377 | | |
| 378 | + | |
378 | 379 | | |
379 | 380 | | |
380 | 381 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
11 | | - | |
12 | | - | |
13 | | - | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
14 | 17 | | |
15 | 18 | | |
16 | 19 | | |
| |||
115 | 118 | | |
116 | 119 | | |
117 | 120 | | |
118 | | - | |
119 | | - | |
| 121 | + | |
| 122 | + | |
120 | 123 | | |
121 | | - | |
| 124 | + | |
122 | 125 | | |
123 | 126 | | |
124 | 127 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
65 | 65 | | |
66 | 66 | | |
67 | 67 | | |
| 68 | + | |
68 | 69 | | |
69 | 70 | | |
70 | 71 | | |
| |||
585 | 586 | | |
586 | 587 | | |
587 | 588 | | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
588 | 596 | | |
589 | 597 | | |
590 | 598 | | |
591 | 599 | | |
592 | | - | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
593 | 603 | | |
594 | 604 | | |
595 | 605 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
328 | 328 | | |
329 | 329 | | |
330 | 330 | | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
331 | 334 | | |
332 | | - | |
333 | | - | |
| 335 | + | |
| 336 | + | |
334 | 337 | | |
335 | 338 | | |
336 | 339 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
273 | 273 | | |
274 | 274 | | |
275 | 275 | | |
276 | | - | |
| 276 | + | |
277 | 277 | | |
278 | 278 | | |
279 | | - | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
280 | 282 | | |
281 | 283 | | |
282 | 284 | | |
283 | 285 | | |
284 | 286 | | |
285 | 287 | | |
286 | | - | |
| 288 | + | |
| 289 | + | |
287 | 290 | | |
288 | 291 | | |
289 | 292 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
350 | 350 | | |
351 | 351 | | |
352 | 352 | | |
353 | | - | |
354 | | - | |
355 | | - | |
356 | | - | |
357 | | - | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
358 | 358 | | |
359 | 359 | | |
360 | 360 | | |
361 | | - | |
| 361 | + | |
362 | 362 | | |
363 | 363 | | |
364 | 364 | | |
| |||
426 | 426 | | |
427 | 427 | | |
428 | 428 | | |
| 429 | + | |
| 430 | + | |
429 | 431 | | |
430 | 432 | | |
431 | | - | |
| 433 | + | |
432 | 434 | | |
433 | 435 | | |
434 | 436 | | |
| |||
0 commit comments