|
| 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