Skip to content

Commit 83ea3ce

Browse files
committed
chore(changeset): 17265 nested hook refusal answers 4xx
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
1 parent 88e998f commit 83ea3ce

1 file changed

Lines changed: 42 additions & 0 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
A sandboxed hook's business refusal reached through a script action answers 4xx, not `500 INTERNAL_ERROR`
6+
7+
`POST /api/v1/actions/:object/:action` answered **`500 INTERNAL_ERROR`** when a
8+
`beforeUpdate` hook refused a state transition for a business reason and the
9+
refusal travelled out through the action body's `ctx.api` write. The same refusal
10+
has answered **`400`**, with the hook's sentence verbatim, on `/data` since
11+
objectstack#11588. A 500 tells every client "the platform broke", so a
12+
well-behaved one retries, alerts or pages for a guard that will never say yes.
13+
14+
**Where the producer was.** Not in the action route's classifier — that read the
15+
shape it was handed correctly, and both sides of the line it pins (`a deliberate
16+
REJECTION is a 400` / `an unexpected FAULT is a 500`) are unchanged. The refusal
17+
arrived already stripped of every mark that says "a body reported this on
18+
purpose", one VM hop earlier: `hostErrorToVm` marked **every** `SandboxError`
19+
crossing into the action body's VM as the sandbox's OWN fault (objectstack#4431)
20+
on an `instanceof` test — and a nested sandboxed hook's refusal *is* a
21+
`SandboxError`, wrapped by the same runner one level down. The pump branch that
22+
reads that marker then discarded `innerMessage`, `code`, `status` and `fields`,
23+
and the classifier read the missing business message as a crash.
24+
25+
**What changed.** The marker now asks the question the `/data` door asks —
26+
`sandboxBusinessMessage`, objectstack#11588 — instead of testing the error's
27+
class. Both of that predicate's conditions travel, because both are load-bearing:
28+
a capability denial carries no business message and stays a fault, and a nested
29+
body that **crashed** carries `TypeError: …` and stays a fault too.
30+
31+
**No status was picked for this route.** It matches what `/data` already answers
32+
for the same producer: the status the body declared, or `400` when it declared
33+
none. A refusal that declares `{ status: 409, code: 'RECORD_LOCKED' }` now
34+
reaches the caller as `409 RECORD_LOCKED` instead of losing both.
35+
36+
**The sentence a caller receives is byte-identical to what the 500 carried**
37+
this moves the status, not the prose. The flattened `SandboxError: ` name prefix
38+
is stripped on the rejection path by the same helper the fault path already used.
39+
40+
No authorable key, accept set or export surface moves; no consumer needs a
41+
change. Clients branching on 5xx to decide whether to retry will stop retrying
42+
these refusals.

0 commit comments

Comments
 (0)