Skip to content

fix(rest): a hook's onError abort is no longer logged as an unhandled error - #27690

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-27508-hook-abort-not-unhandled
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-27508-hook-abort-not-unhandled

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Sales · project thread

Refs #27508 (log half)

Clause-②: no
Metadata-protocol: none
Skills-docs: none

维护者速读

  • 改了什么:hook 以 onError: 'abort'(默认)抛出业务错误中止写入时,服务端不再把它记成 [REST] Unhandled error;线上应答(400、正文、信封)一个字节都不变。
  • 为什么改:这是路由已经答复给调用方的拒绝,不是故障;运维在 error 日志里分不清「有意中止」和「真崩溃」。分诊裁定只做日志这一半,错误码那一半另走 spec 车道。
  • 风险与回滚:只影响日志判定,不动应答;豁免只认「沙箱体抛出、非崩溃、未声明 code、4xx」的拒绝,崩溃和其他来源的无码 4xx 照旧记录。回滚即 revert 本 PR。
  • 先看:packages/rest/src/error-response.ts 的 isSandboxBodyRefusal 与 logUnexpectedRouteError 调用处;packages/rest/src/rest-declared-client-refusal-log.test.ts 新增的 describe 块。
  • 两分钟验证:pnpm --filter @objectstack/rest exec vitest run src/rest-declared-client-refusal-log.test.ts,预期 16 条全绿(其中 hook 中止两扇门都无 Unhandled 行,三条对照照旧记录)。
  • 需要你做的:无,席位落地。

What changes

  • packages/rest/src/error-response.ts: a new predicate, isSandboxBodyRefusal, joins the three reads logUnexpectedRouteError already makes. It is true when all three hold:
    1. the answer is in the 4xx band;
    2. the error carries a sandboxed body's business sentence (sandboxBusinessMessage: innerMessage set, and not a native-error crash); and
    3. the body declared no code (resolveThrownHttpError(...).declaredCode is absent). This is read off the thrown error, not the answer, so a code the door itself adds to an undeclared abort later does not make the abort loud again.
  • Nothing on the wire moves. resolveErrorResponse, mapDataError and packages/types are untouched. The ADR-0112 ledger is not touched and no code is minted; that half belongs to the spec lane.
  • Operators keep a line for each abort: the BodyRunner already logs every body refusal at info ([BodyRunner] sandboxed hook refused). The error-level line removed here was the second line for one ordinary refusal.
  • Docblocks on isExpectedRouteError and isDeclaredClientRefusal that said the sandbox unwrap's un-coded 400 "is still logged" now point at the new read.
  • Changeset: @objectstack/rest patch.

Tests

  • packages/rest/src/rest-declared-client-refusal-log.test.ts. The standing control "an un-coded business-rule 400 (a sandboxed hook refusal) logs" pinned the old verdict, so it moves into a new block that pins what triage ruled. It goes through the real route handlers (POST /api/v1/data/:object through mapDataError + logUnexpectedRouteError, and PUT /api/v1/meta/:type/:name through handleRouteError):
    • a hook abort answers exactly { error: 'boom abort', object: 'qa_media' } at 400 on the data door and { error: 'boom abort' } at 400 on the meta door, both toEqual, and logs no [REST] Unhandled error line;
    • CONTROL: a genuine TypeError at the same door is a 500 and is logged, checked by identity;
    • CONTROL: an un-coded 400 from a producer that is not a sandboxed body is still logged;
    • CONTROL: a sandboxed body that threw an unregistered code is still logged;
    • the existing loud controls are unchanged, including the sandbox crash that declared a 4xx envelope (500, logged) and the registered code on an undeclared status.
  • packages/qa/dogfood/test/object-hook-lifecycle-scratch.dogfood.test.ts acceptance[6] is the real door with a real QuickJS SandboxError: a beforeInsert hook declared onError: 'abort' throws. It now also asserts the exact body { error, object } and that the request logs no [REST] Unhandled error line.
Ablation 1: unit route pin (remove the call-site exemption, then restore)

scripts/ablation-replace.mjs, with the anchor on the && !isSandboxBodyRefusal(error, resolved) call-site line, at head 2e4954fd:

ablation-replace: ok mutation landed: anchor 1 -> 0, blob 10b6edec5c0b -> 416dfa06aa9b
     × POST /data/:object answers the same code-less 400 and prints no "[REST] Unhandled error" 13ms
     × the handleRouteError door agrees: same answer it always gave, no unhandled line 3ms
AssertionError: expected [ [ '[REST] Unhandled error:', …(1) ] ] to have a length of +0 but got 1
      Tests  2 failed | 14 passed (16)
ablation-replace: ok restored: blob == HEAD (10b6edec5c0b) and `git diff HEAD` is empty

Both tests fail only at the log assertion. Their status and exact-body assertions come first and pass under the ablation, which is the pre-change logic. So the wire answer is the same as before.

Ablation 2: real-door dogfood pin (mutate, rebuild rest dist, preflight, run, restore, rebuild, preflight)
ablation-replace: ok mutation landed: anchor 1 -> 0, blob e019a04c3a09 -> fcf8c33d2a68
MUTATE-BUILD-EXIT=0
✓ dist/: marker absent from all 6 built files -- the artifact the suite consumes no longer carries it.
MUTATED-RUN-EXIT=1
     × acceptance[6], negative[0]: a throwing beforeInsert under abort refuses the POST and lands no row 150ms
AssertionError: expected [ Array(1) ] to have a length of +0 but got 1
      Tests  1 failed | 3 passed (4)
ablation-replace: ok restored: blob == HEAD (e019a04c3a09) and `git diff HEAD` is empty
RESTORE-BUILD-EXIT=0
✓ dist/: marker present in 2 built files
✓ tree: working tree clean against HEAD

This ran at 17713e7b. The later commit 2e4954fd changes only condition 3 of the predicate, not the call-site anchor; ablation 1 was re-run on it.

Acceptance notes

  • Scope of the marker. SandboxError carries no body kind, so a sandboxed action's code-less business refusal answered at a data door is quiet too. It is the same shape and the same answer. A message-prefix read (hook '…' threw:) would tell the two apart, but that would be a text heuristic, so it was not used.
  • Overlap with the code half. The open draft fix(rest): an undeclared hook abort answers the data door with HOOK_ABORTED #27667 (the spec-lane card for the code half) edits the same test file. It changes the control this PR moves (it asserts HOOK_ABORTED and "still logs"), so a text conflict in rest-declared-client-refusal-log.test.ts is expected for whichever PR lands second. The predicate reads the thrown error's code, not the answer's, so a door-assigned HOOK_ABORTED keeps the abort quiet. When that PR rebases, its "still logs" line should become "quiet".
  • The undeclared-code-under-a-catalogue case (undeclaredHookRefusalAtRun) becomes a SandboxError with no innerMessage. That is the sandbox's own fault (500), so it stays loud, unchanged.

Verification

All local readings below are at head 2e4954fd unless noted.

Package tests, typecheck, lint
  • pnpm --filter @objectstack/rest test: Test Files 336 passed (336), Tests 6602 passed | 326 skipped (6928), exit 0.
  • pnpm --filter @objectstack/rest typecheck: exit 0.
  • pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/object-hook-lifecycle-scratch.dogfood.test.ts: Tests 4 passed (4), exit 0.
  • eslint (--no-inline-config) on packages/rest and the dogfood test file: exit 0.
Derived gate families

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 64 families at the final change set. Each was run with its exit code captured before any pipe, and all 64 exited 0. The run was split across a container restart: the families not yet run were re-run at the same head, 2e4954fd. check:type-check-debt was run whole under the verify lock after a queue timeout gave it PREREQUISITE NOT MET (exit 3). Its re-run exited 0. check:dual-build-cjs-loads exited 0 once a full build had run, after an earlier PREREQUISITE NOT MET (exit 3).

Reconciliation, --ran:

Run reconciliation — 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero — all 64 recorded an exit code and none of them is 3).

The full dogfood battery is not derived: there is no ! in the title and no (narrowing) arm. The repo-wide pnpm lint and the rest of the farm are left to CI.


Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F

…ndled error

A hook that aborts its write under onError: 'abort' with a business throw is a
refusal the route already answered (the code-less 400). logUnexpectedRouteError
now recognises it by the sandbox marker every door reads
(sandboxBusinessMessage: innerMessage set, not a crash) and the code-less
4xx answer, and no longer prints '[REST] Unhandled error' for it. The wire
answer is unchanged.

Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F
Co-authored-by: Claude <noreply@anthropic.com>
…wire answer unchanged

Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F
Co-authored-by: Claude <noreply@anthropic.com>
…with no unhandled-error line

Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F
Co-authored-by: Claude <noreply@anthropic.com>
…ot the answer

A code the door adds to an undeclared abort's answer is the door's, not the
body's, so it must not turn the refusal back into a logged fault.

Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 7 documentable anchor(s).

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query, selected by route anchor /:object/query; the route ledger binds it to POST /data/:object/query, selected by route anchor /:object/query))
  • content/docs/api/data-api.mdx (via /:object/query (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))
  • content/docs/api/wire-format.mdx (via /:object/query (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))
  • content/docs/data-modeling/analytics.mdx (via /:object/query (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))
  • content/docs/data-modeling/queries.mdx (via /:object/query (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))
  • content/docs/deployment/cli.mdx (via /:object/query (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))
  • content/docs/getting-started/quick-reference.mdx (via /:object/query (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))
  • content/docs/kernel/runtime-services/data-service.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query, selected by route anchor /:object/query; the route ledger binds it to POST /data/:object/query, selected by route anchor /:object/query), /:object/query (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))
  • content/docs/kernel/services-checklist.mdx (via /:object/query (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))
  • content/docs/permissions/field-level-security.mdx (via data.fieldAccess (sdk, the route ledger binds it to GET /api/v1/data/:object/:id/field-access, selected by route anchor /:object/:id/field-access), fieldAccess (sdk, the bare tail of client method data.fieldAccess, bound to GET /api/v1/data/:object/:id/field-access), /:object/:id/field-access (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))
  • content/docs/protocol/objectql/query-syntax.mdx (via /:object/query (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))
  • content/docs/protocol/objectql/security.mdx (via /:object/:id/field-access (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-4.mdx (via /:object/query (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))
  • content/docs/releases/v17/17-5.mdx (via /:object/query (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))
  • content/docs/releases/v17/17-6.mdx (via /:object/query (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))
  • content/docs/releases/v17/17-7.mdx (via /:object/query (route, bridged from symbol logUnexpectedRouteError — its route source's handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json da7bcc8a89c68c7d6579a3ca6ed11157e815e244 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a9c0c1355d2ce0fb06156a0f5ab32d2aed0ab680 — the merge of head 2e4954fd5331b6776346c421ad07d7835ca5303e into base da7bcc8a89c68c7d6579a3ca6ed11157e815e244, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a9c0c1355d2ce0fb06156a0f5ab32d2aed0ab680 && git checkout a9c0c1355d2ce0fb06156a0f5ab32d2aed0ab680
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin da7bcc8a89c68c7d6579a3ca6ed11157e815e244 2e4954fd5331b6776346c421ad07d7835ca5303e && git checkout -B drift-repro da7bcc8a89c68c7d6579a3ca6ed11157e815e244 && git merge --no-ff 2e4954fd5331b6776346c421ad07d7835ca5303e

node scripts/docs-audit/affected-docs.mjs --json da7bcc8a89c68c7d6579a3ca6ed11157e815e244

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs da7bcc8a89c68c7d6579a3ca6ed11157e815e244 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

维护者速读(席位终稿)

  • 改了什么:沙箱里的钩子按 onError: 'abort' 用业务 throw 拒绝写入时,REST 不再把它记成 [REST] Unhandled error;返回给调用方的状态码和正文一字不变。
  • 为什么改:这是路由正常回给调用方的拒绝,却在错误日志里当成未处理故障,运维告警被普通业务拒绝刷屏。分诊 6498814060 把日志这一半单独裁出。
  • 风险与回滚:只改日志判断;识别用现有的沙箱来源标记,不靠文案匹配;真崩溃(500)、非沙箱的无码 400、带未登记码的沙箱拒绝仍照旧记错误日志;BodyRunner 仍保留一条 info 级日志。回滚 = revert 本 PR。
  • 先看:packages/rest/src/error-response.ts 的 isSandboxBodyRefusal 和 logUnexpectedRouteError 里多出的一个条件。
  • 两分钟验证:pnpm --filter @objectstack/rest exec vitest run src/rest-declared-client-refusal-log.test.ts。
  • 需要你做的:无。非受管面,席位落地。

席位意见: ACCEPT。只动日志不动线上答复;「要不要给这种拒绝一个登记码」是 spec 车道的另一半(#27597 / PR #27667),本 PR 的判断读抛出的错误而不是答复,那一半落地后也不会重新变吵。

席位核对

ACCEPT · head 2e4954fd5331b6776346c421ad07d7835ca5303e · 2026-10-29T19:12Z

  • 形态: 4 files, +141/−18: error-response.ts (one predicate + one condition + docblocks), the rest log test, one dogfood acceptance case, one changeset. No packages/spec, no packages/types, no new code; Tier S. Refs #27508 (log half): the card stays open for the code half.
  • Clause-②: no — the wire answer (status, body, envelope) is byte-identical; only an error-level log line goes away. Metadata-protocol: none.
  • File surface: the claim plus amendment 6505501031 (the dogfood file, test-only).
  • 前提实测: an onError: 'abort' refusal rethrows the SandboxError unchanged (runWithErrorPolicy, resolveHookRefusal without a catalogue) and was logged as unhandled; there is no dedicated abort marker, so the sandbox-origin marker is the recognizer (it cannot tell a hook from an action: declared, and the same refusal answers the same way).
  • 抽查读数: the seat read the hunk: 4xx band only; sandboxBusinessMessage set (sandbox origin, not a crash); declaredCode read off the THROWN error, so a code the door later adds to the answer does not re-arm the log, and a body that threw an unregistered code is still logged.
  • 消融: call site removed → 2 failed / 14 passed, failing only at the log assertion while status and body stay green (the wire is unchanged); dogfood ablation through dist → 1 failed / 3 passed; both restored to the HEAD blob. Controls: a TypeError 500, a non-sandbox un-coded 400 and a sandbox body with an unregistered code are each still logged.
  • 门禁 check-runs: 49 on this head: 35 success, 14 skipped, 0 other (read 2026-10-29T19:12Z); mergeable_state: clean. Derived gates 64/64, 0 NOT-MEASURED; rest 6602 passed / 326 skipped.
  • 接受的偏差: the os-dev-report (6505451704) carries the seat signature on line 1 and os-dev-report on line 2 (post-stamped requires the signature first).
  • 范围外去向: the open spec-lane code-half PR fix(rest): an undeclared hook abort answers the data door with HOOK_ABORTED #27667 conflicts with this one in rest-declared-client-refusal-log.test.ts; when it rebases, its control "un-coded sandboxed hook refusal logs" should become quiet. The seat leaves one note on fix(rest): an undeclared hook abort answers the data door with HOOK_ABORTED #27667.
  • 落地条款: relay pr_ready + automerge_enable through the merge queue.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 19:13
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 29, 2026 19:13
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit a75ddba Oct 29, 2026
50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-27508-hook-abort-not-unhandled branch October 29, 2026 19:33
os-support-ai pushed a commit that referenced this pull request Oct 29, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant