Repository navigation
fix(rest): an undeclared hook abort answers the data door with HOOK_ABORTED - #27667
objectstack-fleet[bot] wants to merge 5 commits into
Conversation
…BORTED 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufziZEEbJZ3DUMwdVJWX
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufziZEEbJZ3DUMwdVJWX
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufziZEEbJZ3DUMwdVJWX
📓 Docs Drift CheckThis PR changes 3 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 172 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e13d1220209d565862f8f00bca857ce04b578ea0 && git checkout e13d1220209d565862f8f00bca857ce04b578ea0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d7625ea4500aa244c792d55b5d7668c80dbc10fe af33de9cabf3473734a4167cacd727c385f4901a && git checkout -B drift-repro d7625ea4500aa244c792d55b5d7668c80dbc10fe && git merge --no-ff af33de9cabf3473734a4167cacd727c385f4901a
node scripts/docs-audit/affected-docs.mjs --json d7625ea4500aa244c792d55b5d7668c80dbc10fe
|
… 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufziZEEbJZ3DUMwdVJWX
|
triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK Contract-review-claim: session_01L6bhgV4fMGJHSdSdx3u9mK · head 28b6f71 · 2026-10-29T17:40Z Generated by Claude Code |
|
triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK Contract reviewServed-tier: Inputs read: card #27597 body and all four comments (triage 6500443789, claim, dev report, seat amendment of 2026-10-29T17:00Z); rulings 6431311681 (#26170 letter A) and 6498814060 (#27508 split); PR #27667 body, 21-file list and net diff against ① Derived judgments
② Semver level
Clause-②: yes (widening) — matches what the diff publishes (items 1 to 4 above). ③ Boundary flags
④ Design judgment
The row holds. Its comment states the condition (a sandboxed hook body aborted with free text and declared no code), the status rule (the declared 4xx, else 400), the sentence rule (the body's own), and both stamping doors by name, which is what the ledger header asks of a row whose name does not carry the meaning alone. Read against the consumer: Attempts to break it, and what the tree answers:
Must-fix: none. Should-fix: the two named above (door convergence as a follow-up card; one comment clause on the Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Heads-up from the Generated by Claude Code |
|
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP 维护者速读 (席位终稿)
席位意见:接受。分诊契约复核 PASS(6504284767,就在当前 head 上),没有 must-fix。两条 should-fix:其他门收敛已立后续卡 #27672;action 包装回退的注释少写了一句,不阻塞,记为偏差。 席位核对 · ACCEPT ·
|
Conflict: packages/rest/src/rest-declared-client-refusal-log.test.ts. Main (#27690) removed the loud "un-coded business-rule 400" case and added the #27508 quiet block. Took main's side, and flipped that block's two wire pins to carry the door-added HOOK_ABORTED. The log verdict reads the thrown error, so it stays quiet. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufziZEEbJZ3DUMwdVJWX
|
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP 席位核对 · head moved by a main merge · 2026-10-29T22:41ZACCEPT 6507486801 was on
Auto-merge stays armed, and the queue takes it on green CI. |
|
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U #27345: auto-merge is armed on a head no PASS names · 2026-10-29T22:54Z⛔ Not a claim, and not a review: the review seat takes reviews.
Generated by Claude Code |
domain:spec#3 · os-support-ai · session_01WGufziZEEbJZ3DUMwdVJWX
Requested by Yibo Wang · project thread
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 ridesdeclaredCode; it states no wirecodefor 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 witherroralone"), is corrected here.Proposed code:
HOOK_ABORTED(the maintainer names it at approval; the name lives in one ledger row, one constantUNDECLARED_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), andABORTEDis the hook contract's own word (onError: 'abort', the default), the shape ofBATCH_ABORTED.error.standardSynonymOf('HOOK_ABORTED')isundefined(no member carriesHOOK), so it needs no waiver.HOOK_REFUSED(reads like a declared refusal, which is therefusalscatalogue's job);HOOK_UNDECLARED_REFUSAL(names the author's omission rather than the event the client sees); the standardVALIDATION_ERRORthatstandardErrorCodeForHttpStatus(400)derives, which rest: a write refused by an unevaluable hook condition answers a generic500 INTERNAL_ERRORinstead of a coded refusal (#24619 row b) #24662 chose for an unevaluable hook condition. Rejected because the card asks for a code a client can tell apart from every other 400, andVALIDATION_ERRORis what any malformed body also answers.维护者速读
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/main178b5238A sandboxed hook refusal with no declared code (the shape
quickjs-runnerthrows:.messagethehook 'guard' threw: Error: boom abortwrapper,.innerMessageboom abort, nocode) answered every/dataexit with nocode:mapDataError(err, 'obj')→400 {"error":"boom abort","object":"obj"}POST /data/:object,PATCH /data/:object/:id,POST /data/:object/batch→ the same, nocodestatus: 422orstatusCode: 422→422, nocode400, nocodeHow the door tells a declared refusal from an undeclared one today: by the error's own
code.thrownCodeFields(viaresolveThrownHttpError) answers{}when the producer declared no stringcode, and{ code, declaredCode? }otherwise. A hook with arefusalscatalogue never reaches the door code-less:resolveHookRefusalturns a declared code into aSandboxErrorcarryingcodeandstatus, 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 (sandboxBusinessMessageanswers) whosethrownCodeFieldsis{}.The ledger row (spec)
HOOK_ABORTEDunder@objectstack/typesinERROR_CODE_LEDGER, the package whose constant stamps it (the provenance gate reads a constant at its definition).ErrorCodeand the generated reference pages (content/docs/references/api/*.mdx,gen:docs) pick it up. NoFieldErrorCodechange: this is a top-level code.The emitter (types, rest)
@objectstack/types:UNDECLARED_HOOK_REFUSAL_CODE = 'HOOK_ABORTED', typedErrorCodeso a ledger without the row stops the package compiling.sandboxRefusalCodeFields(error, status)returnsthrownCodeFields' 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 thestatusandstatusCodespellings answer one code. Every other producer keepsthrownCodeFields.logUnexpectedRouteError), the import runner's row codes (it adopts onlyINVALID_FIELD,UNIQUE_VIOLATION,VALIDATION_FAILEDfrom 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 for400,VALIDATION_ERROR. They now carryHOOK_ABORTEDtoo, so one abort answers one code on every rest door that reads this classification:/dataflat routes (POST, PATCH, DELETE, batch, createMany, updateMany, deleteMany, clone)codeHOOK_ABORTEDVALIDATION_ERRORHOOK_ABORTED/analytics/dataset/query(its ①b arm asksclassifiedRefusalAnswer)codeHOOK_ABORTED/data/:object/import/:jobId/undofailures listVALIDATION_ERRORHOOK_ABORTEDThe 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: noui/**or@objectstack/clientcode branches onVALIDATION_ERRORfor these routes (the console renderserror/userMessage; the client passescodethrough).Client SDK:
@objectstack/clientcarries no per-code mapping; it lifts the body'scodestring ontoerr.code(packages/client/src/index.ts, thefetchfailure path), soerr.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) editspackages/rest/src/error-response.tsin the log verdict (isSandboxBodyRefusal,logUnexpectedRouteError), lines this PR does not touch, so there is no textual conflict. There is a semantic one: itsisSandboxBodyRefusalexempts the abort only whenresolved.body.code === undefined, and after this PR the abort carriesHOOK_ABORTED, so the abort would still be logged as[REST] Unhandled error. Whichever lands second makes that condition read the new code (for examplecode === undefined || code === UNDECLARED_HOOK_REFUSAL_CODE). Per the order, this PR does not touch the log half.Tests
packages/rest/src/rest-undeclared-hook-abort-code.test.ts(12 cases): §1mapDataErroranswers the code; §2 the POST, PATCH and batch routes; §3 a declaredstatus/statusCodewith no code keeps the status and gains the code, alike at both doors; §4 the wrapper fallback is byte-identical to theinnerMessagedoor; §5 CONTROLS: arefusalscatalogue answer keepsdeclaredCodebesideVALIDATION_ERROR, a registeredRECORD_LOCKEDstays its own code, a non-sandbox 400 with no code still gets none, a crash is still500 INTERNAL_ERROR; §6 the ledger row is live (registered under@objectstack/types, parses asErrorCode).packages/qa/dogfood/test/hook-error-format.dogfood.test.ts: the end-to-end pin that assertedbody.codeundefined for a free-textthrow new Error(...)now assertsHOOK_ABORTED.codeabsent on the flat door,VALIDATION_ERRORon 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 gainsundeclaredCode),rest-share-declared-code,rest-share-user-message,rest-write-route-hook-refusal-sentence.ledger(15 routes move toHOOK_ABORTED_400; package publish keepsREFUSED_400),rest-declared-client-refusal-log(still logs; only the code line moves),import-undo-controls. No declared-code case moved.Acceptance notes
resolveThrownHttpErrorinstead of this classification still answer the same abort with the status-derivedVALIDATION_ERROR:POST /api/v1/packages/publish(pinned inrest-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 teachingresolveThrownHttpErrorthe same rule, which moves the runtime door and its conformance suites; reported to the seat as a follow-up, not done here./actionsdoor). The wrapper fallback matchesaction '…' threw:too, and answers the same code, to stay byte-identical with theinnerMessagedoor, which cannot tell the kind.Verification
All readings on head
339443cd, on the shared container underos-verify-lock.Before / after of the new test
origin/main178b5238, types dist rebuilt, marker count in dist 0):Tests 8 failed | 4 passed (12). Red: §1–§4 (every undeclared abortcodeundefined;mapDataErroranswered{ error: 'boom abort', object: 'obj' }) and §6's constant. Green: the four §5 controls. Restore proven bygit diff HEADempty and the dist marker back (5).Tests 12 passed (12).Package suites
@objectstack/typestest: 30 files, 798 passed; typecheck exit 0.@objectstack/resttest: 336 files, 6599 passed, 326 skipped; typecheck exit 0.@objectstack/specsrc/api/error-code-ledger.test.ts,contract.test.ts,retired-error-codes.test.ts: 98 passed.@objectstack/dogfoodtest/hook-error-format.dogfood.test.ts(real QuickJS hook through the stack): 4 passed; the free-textthrow new Error(...)delete answers400withcode: 'HOOK_ABORTED'.mapDataError(@objectstack/typesconsumers): coreimport-runner-sandbox-{fault,refusal}-row55 passed, metadata-protocolprotocol.batch-row-sandbox-refusal+protocol.marked-refusal-classification37 passed, objectqlhook-withheld-readonly-fault11 passed, runtimesandbox-plain-object-throw-client-sentence,sandbox/body-throw-log-level,sandbox/capability-denial-is-a-refusal,sandbox/hook-refusal-catalogue45 passed.!in the title and no(narrowing)arm.Derived gates (`dispatch-gates --commands`, 107 families)
de85fc29/c16322a4): 104 exit 0;check:dispatcher-error-vocabularyexit 1 ([unresolved-constant]: it could not read theas const satisfies ErrorCodeinitializer) fixed in339443cdby a plainErrorCodeannotation;check:skill-examplesandcheck:dual-build-cjs-loadsexit 3 (client dists not yet built).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 --eventwith this body: exit 0 (Clause-②: yes (widening),Metadata-protocol: none, every changeset entrypatch).Generated by Claude Code