Skip to content

fix(rest): a crashing hook body answers the sanitised fault envelope, not a raw TypeError at 400 (#7543) - #7632

Merged
os-help merged 3 commits into
mainfrom
claude/issue-7543-typeerror-in-400-envelope
Aug 11, 2026
Merged

fix(rest): a crashing hook body answers the sanitised fault envelope, not a raw TypeError at 400 (#7543)#7632
os-help merged 3 commits into
mainfrom
claude/issue-7543-typeerror-in-400-envelope

Conversation

@os-help

@os-help os-help commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7543

The seam — located, not assumed

The card's investigation note is a negative result: the observed body — 400, raw message, an object key, no code — "matches no branch of mapDataError". That note is wrong, and finding out how is the fix.

It matches two branches: the sandbox unwraps, which are the only ones in the file that emit { error, object } with no code at 400. Reproduced byte-for-byte on main @ 4ed4160 (i.e. after PR #7575 landed declaredHttpStatus, which does not touch these branches):

mapDataError(SandboxError("hook 'showcase_normalize_task_title' threw: TypeError: not a function",
                          innerMessage: 'TypeError: not a function'), 'showcase_task')
=> {"status":400,"body":{"error":"TypeError: not a function","object":"showcase_task"}}

The full chain:

  1. examples/app-showcase/src/data/hooks/index.ts NormalizeTaskTitleHook ran if (ctx.input.title) ctx.input.title = ctx.input.title.trim();. 12345 is truthy and has no .trim ⇒ QuickJS throws.
  2. quickjs-runner.ts wraps it as SandboxError("hook '<name>' threw: …", userFacingMessage(…)). userFacingMessage strips only a leading Error: , so innerMessage keeps TypeError: not a function.
  3. mapDataError's innerMessage branch answers 400 with that string and deliberately no code.

This also answers the note's "the throw is upstream or downstream of validateOne"upstream. The hook is beforeInsert, so it throws before the validator's safe String(value) ever sees the record.

Landing package is packages/rest ⇒ this lane. The packages/objectql branch of the lane boundary does not apply: objectql never gets to run.

The fix

Both unwrap branches now separate a body that reported something from a body that faulted, by the thrown error's constructor name — the sandbox stringifies a throw as <name>: <message>, so a leading TypeError: / ReferenceError: / RangeError: / SyntaxError: / URIError: / EvalError: / InternalError: / AggregateError: is structural evidence, not a keyword heuristic over prose.

A crash answers the same sanitised 500 INTERNAL_ERROR the mapper's terminal branch already gives. That is not new policyUNCLASSIFIED_FAULT's own docblock (#5489) names this exact case: "or a plain handler bug (TypeError: x is not a function) … server faults that a caller cannot fix and a caller SHOULD retry." The unwraps simply sat above that branch and intercepted the crash first.

Both doors are guarded, not one. The innerMessage branch and the raw-message regex fallback emit byte-identical bodies, so guarding one would make the envelope depend on whether the SandboxError instance survived a rethrow. Mutation D below proves this.

It also resolves a standing disagreement between two exits

runtime/src/domains/actions.ts's #3913 classifier already told a TypeError body-throw apart by name and answered a server fault — and cited @objectstack/rest as the source of that signal. But rest read the same evidence ("non-default names signal a genuine script bug") and then shipped the name to the client as a 400 anyway. The two exits shared the signal and drew opposite conclusions. They now agree; the stale citation in actions.ts is updated (comment only, no behaviour change).

What did not move

A deliberate refusal still reaches the caller verbatim at 400 with no code (load-bearing: older bundled @objectstack/client builds prepend any code to the message). The fix changes which errors take that branch, not what it emits.

Accepted cost: a body expressing a business rule as throw new RangeError('…') is now sanitised. That is not the documented authoring style, and the fail-safe direction is the one that does not ship runtime faults to clients.

The operator still gets the full text — verified in the live dogfood run, not just asserted:

ERROR [BodyRunner] sandboxed hook threw
  {"hook":"hef_buggy_guard","error":{"message":"hook 'hef_buggy_guard' threw: TypeError: boom", ...}}

⚠️ Where I diverge from the accept bar, with a measurement

The dispatch's accept bar says {"title": 12345} must join its neighbours — 400 VALIDATION_FAILED with fields[]. It cannot, and asserting that it does would pin a false statement. packages/objectql/src/validation/record-validator.ts:503-504:

if (t === 'text' || t === 'textarea' || ) {
  const s = typeof value === 'string' ? value : String(value);   // ← coerced

The value is coerced, every length/format check runs against "12345", and the branch returns null. The shape guard above it (invalid_value_shape) only refuses filter-operator objects like { $in: [...] }, not scalars.

So {"title": 12345} is a valid request by the platform's own declared contract — there is no offending field to name, and VALIDATION_FAILED would be a lie about the caller. The premise behind the fields[] half of the accept bar is that this body is invalid input; the validator says it isn't.

Both contract breaks the card actually names are fixed: no raw runtime error text on the wire, and the body now carries a code. §4 of the new test pins the three control-table bodies side by side, each asserted for what it truthfully is.

With the showcase hook fixed, the reported request now succeeds rather than erroring — the correct outcome given the coercion contract.

Tests

  • New packages/rest/src/rest-hook-script-fault-envelope.test.ts — 18 cases across four sections: the defect via the innerMessage door, the same via the regex fallback, the business-refusal behaviour that must not move, and the card's control table guarded as one family.
  • Reversed in place (not deleted) — two pins of the behaviour this card calls a defect, each of which never asked identifiable to whom:
    • packages/rest/src/rest.test.ts — the unit pin.
    • packages/qa/dogfood/test/hook-error-format.dogfood.test.tsthe E2E pin, caught by CI on the first push. This one drives the whole chain for real (QuickJS → ObjectQL triggerHooksmapDataError → HTTP) and is now the only end-to-end proof that a crashing body is sanitised on the wire. A ground-truth case was added beside it: the status changed, but onError: abort semantics must not — a sanitised envelope must not be mistaken for a soft failure that let the write through.

Mutation table — all 18 unit cases proven able to fail

mutation measured
A BASELINE (fix reverted) 10 red — §1 6/6, §2 1/3, §3 0/4, §4 3/5
B isScriptFaultMessage → always true 6 red — the over-reach; §3 + §2's refusal + §4's distinctness
C isScriptFaultMessage → always false 10 red — same as baseline; the call sites are the only carriers
D innerMessage door unguarded, regex door kept 10 red — incl. §2's byte-equality; justifies guarding both
E ^ anchor dropped 1 red (after strengthening — see below)
F VALIDATION_FAILED's fields[] 2 red — covers §4's two control rows

Two predictions were wrong and are recorded as measured rather than rewritten to fit:

  • §2 on baseline predicted 3/3 red, measured 1/3. The two cross-door agreement cases are direction-insensitive by construction — both doors are broken the same way, so they still agree. Mutation D moves them, which is exactly the partial fix they exist to catch.
  • Mutation E predicted 1 red, measured 0. The (?::|$) limb already refuses prose like "produced a TypeError in your template", so the original assertion never exercised the anchor. The test was strengthened with a message quoting a native name with its colon mid-sentence ("rejected with TypeError: check the template"), which only ^ refuses. Re-measured: 1 red.

Verification

Re-run after merging origin/main (b3de0dd) and rebuilding:

  • pnpm lint — clean
  • pnpm typecheck — 126/126 packages
  • @objectstack/rest1421 passed (88 files)
  • @objectstack/runtime2011 passed (126 files), the sandbox producer side
  • @objectstack/dogfood548 passed, 3 skipped (88 files) — the suite whose shard 1/3 caught the E2E pin
  • @objectstack/example-showcase171 passed (18 files)

Changeset included. No content/docs/releases/** edits.

… not a raw `TypeError` at 400 (#7543)

`POST /api/v1/data/showcase_task` with `{"title": 12345}` answered
`400 {"error":"TypeError: not a function","object":"showcase_task"}` — a JS
runtime error as the client-facing message, in a body with no `code`.

The seam, since the card asked for it to be located rather than assumed: its
investigation note says the shape "matches no branch of `mapDataError`". It
matches two — the sandbox-unwrap branches, the only ones in the file that emit
`{error, object}` with no `code` at 400. They exist for a hook body that runs
`throw new Error('业务消息')`, whose message IS the remedy and is answered
verbatim. A body that CRASHES arrives as a thrown error too, so it took the same
branch. The note's "the throw is upstream or downstream of `validateOne`" is
answered too: upstream — the hook is `beforeInsert`, so it throws before the
validator's safe `String(value)` ever sees the record.

Both branches now separate a body that reported something from a body that
faulted, by the thrown error's constructor name (the sandbox stringifies a throw
as `<name>: <message>`, so the name is structural evidence, not a keyword
heuristic). A crash answers the same sanitised `500 INTERNAL_ERROR` the mapper's
terminal branch gives — not new policy: that branch's docblock (#5489) names
this exact case. Both doors are guarded, since they emit byte-identical bodies
and guarding one would make the envelope depend on whether the `SandboxError`
instance survived a rethrow.

Unchanged: a deliberate refusal still reaches the caller verbatim at 400 with no
`code`. The operator still gets the full text — 500 is outside
`isExpectedDataStatus`, so `handleRouteError` logs it.

`rest.test.ts`'s "keeps non-default error names … genuine script bugs stay
identifiable" is REVERSED in place rather than deleted: it pinned the behaviour
this card calls a defect, and never asked identifiable to whom.

Showcase: `NormalizeTaskTitleHook` guarded its trim with truthiness, so `12345`
passed the guard and had no `.trim`. Now `typeof … === 'string'`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYt5skZ9r78Hnnza2b1jnK
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 10:00am

Request Review

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/runtime.

24 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest, @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx (via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx (via @objectstack/rest, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/permissions/system-context.mdx (via packages/rest, packages/runtime)
  • content/docs/plugins/index.mdx (via @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/rest, @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/rest, @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)

3 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/rest, @objectstack/runtime)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v17.mdx (via @objectstack/rest, @objectstack/runtime)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 11, 2026
claude added 2 commits August 11, 2026 09:49
…ror` on the wire (#7543)

CI caught a second pin of the behaviour this card calls a defect, at the level
that matters most: `hook-error-format.dogfood.test.ts` drives the whole chain
for real (QuickJS → ObjectQL triggerHooks → mapDataError → HTTP) and asserted
`400` + `body.error === 'TypeError: boom'`. Reversed in place like its unit
counterpart, not deleted — it is now the only end-to-end proof that a crashing
body is sanitised on the wire.

The local run also CONFIRMS the operator-visibility claim the fix rests on:

  ERROR [BodyRunner] sandboxed hook threw
    {"hook":"hef_buggy_guard","error":{"message":"hook 'hef_buggy_guard' threw: TypeError: boom", ...}}

— full text in the server log, sanitised envelope on the wire.

Added a ground-truth case alongside it: the status changed, the transactional
outcome must not. `onError` defaults to abort, and a sanitised envelope must not
be mistaken for a soft failure that let the write through.

`domains/actions.ts`: comment only. Its #3913 fault classifier already told a
`TypeError` body-throw apart BY NAME and answered a server fault, and it cited
rest's comment as the shared signal — while rest drew the opposite conclusion
from that same evidence and shipped the name to the client as a 400. The
citation is updated to record that the two exits now agree on the reading, not
just the signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYt5skZ9r78Hnnza2b1jnK
@os-help
os-help marked this pull request as ready for review August 11, 2026 10:47
@os-help
os-help added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 284e7d2 Aug 11, 2026
41 of 42 checks passed
@os-help
os-help deleted the claude/issue-7543-typeerror-in-400-envelope branch August 11, 2026 11:12
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A raw TypeError: not a function leaks in the 400 envelope for {"title": 12345} — no code, no fields[]

2 participants