diff --git a/.changeset/sandbox-crash-outranks-declared-code-arm.md b/.changeset/sandbox-crash-outranks-declared-code-arm.md new file mode 100644 index 0000000000..4ecad6d3f4 --- /dev/null +++ b/.changeset/sandbox-crash-outranks-declared-code-arm.md @@ -0,0 +1,43 @@ +--- +"@objectstack/rest": minor +--- + +fix(rest): a hook that crashes after declaring a code now answers 500 UNCLASSIFIED_FAULT instead of the declared status with the crash text (#15071) + + + +**BREAKING** — the answer this published door gives moves for existing inputs. +No export, signature or declared type changes; what changes is the response an +existing call observes, and a client branching on `error.code` for the affected +shape now falls to its 5xx path instead of its refusal path. Shipped as `minor` +under the launch-window convention (`major` is refused while the fixed group +versions in lockstep), so this banner — not the level — is the breaking-ness +signal. + +**What changes for an operator.** A sandboxed hook or action body that declared a +refusal code and then CRASHED — `throw`-ing nothing, but hitting a bug on a later +line — used to answer the single-record `/api/v1/data` routes with the code's own +business status and the QuickJS debug sentence as the client-facing message, for +example `409 DELETE_RESTRICTED · "hook 'guard' threw: TypeError: x is not a +function"`. It now answers `500 UNCLASSIFIED_FAULT` with the sanitised message +and no crash text, which is what the same crash carrying no declared code has +always answered. The full wrapper still reaches the server log through the +existing `[REST] Unhandled error` / withheld-fault path, so nothing an operator +diagnoses with is lost. + +**What does NOT change.** An ordinary declared refusal — a hook that throws a +business error carrying a code and does not crash — is untouched: same status, +same code, same sentence, same structured fields. So is every non-sandbox +producer of those codes, and so is the `developerMessage` channel, which keeps +the rule it already had for a fault. + +**Why.** A declared code is the author's statement about the failure mode they +handled; a crash is not that mode. Answering one with a business status shipped +an internal, stack-shaped sentence to an end user and told the client the wrong +thing about what happened, while the door one branch down already sanitised the +identical crash. Maintainer ruling, 2026-09-04, decision batch #27, on #15071. + +**If you were relying on the old answer,** the affected shape is a hook that +declares one of the classification's ten code-gated refusals and then faults: it +now surfaces as a 5xx to clients and retry policies rather than as a 4xx. That is +the point of the change — the crash was never the refusal the code named. diff --git a/packages/rest/src/error-response-sandbox-arm-message.test.ts b/packages/rest/src/error-response-sandbox-arm-message.test.ts index d233f2dcbb..6e4b40c0cd 100644 --- a/packages/rest/src/error-response-sandbox-arm-message.test.ts +++ b/packages/rest/src/error-response-sandbox-arm-message.test.ts @@ -36,13 +36,15 @@ * §3 the non-sandbox control: a plain producer on the same codes keeps * `error.message` byte for byte — the two-read rule is a read of a field * the sandbox populated, never a strip of the wrapper off `.message`; - * §4 ACCEPTED DIVERGENCE, fenced by triage: a sandboxed CRASH carrying a - * declared code keeps TODAY's answer — the arm's status and the wrapper — - * where the unwrap door's terminal for the same crash is the sanitised - * 500. `sandboxBusinessMessage` declines a crash (#7543), so the two-read - * rule leaves this byte-identical on purpose. Choosing between those two - * answers is FAULT CLASSIFICATION, not message sourcing; it is named here - * rather than decided, and carried as a follow-up decision card; + * §4 CONVERGED (#15071, maintainer ruling 2026-09-04 / batch #27, option B): + * a sandboxed CRASH carrying a declared code reaches the unwrap door's + * sanitised `500 UNCLASSIFIED_FAULT` whatever code it declares — the + * terminal moved above the arms (`isSandboxCrash`). This section was the + * ACCEPTED DIVERGENCE the follow-up decision card was carried on; the + * verdict flipped, the section did not go away. Three legs: the flip per + * arm, the surviving positive control (the same crash with NO declared + * code), and the negative control the ruling makes mandatory — "only the + * crash branch moves", so an ordinary declared refusal is untouched; * §5 the bulk-door control: this change is unreachable from * `resolveErrorResponse`, which declines the consult for a sandbox-origin * error (#14541), so nothing moves on those routes; @@ -59,6 +61,14 @@ import { mapDataError, sendThrownError } from './error-response.js'; const HERE = dirname(fileURLToPath(import.meta.url)); +/** + * The classification's own source, read once: §4-derivation and §6 both scan it + * — one re-derives the arm list from the tree (the #15071 ruling's execution + * constraint), the other guards the sentence rule. Same package, so the read + * does not escape it (AGENTS.md → cross-package test inputs). + */ +const SOURCE = readFileSync(resolve(HERE, 'error-response.ts'), 'utf8'); + /** The business sentence a hook author addressed to the end user. */ const BUSINESS = 'Opportunity is closed.'; /** What QuickJS puts on `.message` for that same throw. */ @@ -223,41 +233,184 @@ describe('#14704 · the single `/data` door never ships the QuickJS wrapper out }); /** - * ⛔ NOT decided here. Triage fenced the crash question out of this card - * explicitly: "If a sandboxed CRASH (`isScriptFaultMessage`, #7543) reaches - * a code-gated arm, leave today's behaviour exactly as it is, implement the - * business-message read only, and name the site and the divergence." + * FLIPPED by #15071, deliberately and in that card's PR, from + * `ACCEPTED DIVERGENCE` to `CONVERGED` — the same discipline PR #15065 used + * on its own §4 one file over. ⛔ The section is not DELETED: it is the only + * thing that would notice the divergence coming back, and what changes is + * its verdict, not its existence. + * + * ## The reason, quoted beside the flip + * + * Maintainer ruling, 2026-09-04, decision batch #27, verbatim 「同意」 on + * option B: *"A declared code is the author's statement about the failure + * mode they **handled**. A crash (`isScriptFaultMessage`, #7543) is not that + * mode, so it is classified as a fault at both doors: `mapDataError`'s + * code-gated arms … hand a sandboxed crash to the same sanitised terminal + * `classifyDataError`'s unwrap door already produces — `500`, + * `UNCLASSIFIED_FAULT`, no wrapper prose on the wire."* ⛔ Not A: *"an + * internal stack-shaped sentence at a business status is both a leak and a + * lie to the client about what happened."* ⛔ Not C: *"it adds a mechanism to + * keep answering a crash with a business status."* * - * The site is `structuredCodeAnswer` (and the `PERMISSION_DENIED` arm below - * the consult) reached from `mapDataError`. The divergence: the arm answers - * a CRASH with its own declared status and the QuickJS wrapper prose, where - * `classifyDataError`'s unwrap door answers the same crash with the - * sanitised 500 fault terminal. The two-read rule keeps this byte-identical - * because `sandboxBusinessMessage` declines a crash by design — so the - * divergence is UNCHANGED by this card, and pinned so that choosing an - * answer for it is a visible edit rather than a drift. + * ## What the section pins now, in three legs + * + * - **the flip**, per arm and by NAME over {@link ARMS} — the list the + * ruling required be RE-DERIVED from the tree rather than copied from + * #14704, and `§4-derivation` below is the guard that keeps it derived; + * - **the positive control STAYS** and is still a control: the same crash + * carrying NO declared code reaches the same sanitised 500, so a green + * flip leg cannot be read as "the terminal swallowed everything"; + * - **the negative control**, which is the condition a plausible-but-wrong + * implementation fails. The ruling: *"Ordinary declared refusals (a hook + * that throws a business error carrying a code, no crash) are + * **untouched** — only the crash branch moves."* An implementation that + * degraded anything carrying a code to 500 would turn the flip leg green + * while deleting the whole declarative-refusal surface, so the refusal + * leg is asserted HERE per arm as well, not merely inherited from §1. */ - describe('§4 ACCEPTED DIVERGENCE — a sandboxed CRASH carrying a declared code is unchanged', () => { - it('DELETE_RESTRICTED: the arm still answers 409 with the wrapper prose, not the 500 terminal', () => { - const wire = mapDataError(sandboxCrash({ code: 'DELETE_RESTRICTED', status: 409, object: 'account' }), 'account'); - expect(wire.status).toBe(409); - expect(wire.body.code).toBe('DELETE_RESTRICTED'); - expect(wire.body.error).toBe("hook 'guard' threw: TypeError: x is not a function"); - }); - - it('VALIDATION_FAILED: same shape, the most ordinary authored refusal code', () => { - const wire = mapDataError(sandboxCrash({ code: 'VALIDATION_FAILED', status: 400 }), 'account'); - expect(wire.status).toBe(400); - expect(wire.body.code).toBe('VALIDATION_FAILED'); - expect(wire.body.error).toBe("hook 'guard' threw: TypeError: x is not a function"); - }); + describe('§4 CONVERGED (#15071) — a sandboxed CRASH reaches the fault terminal whatever code it declares', () => { + for (const arm of ARMS) { + it(`${arm.arm}: a crash carrying it answers the sanitised 500, not ${arm.status}`, () => { + const wire = mapDataError(sandboxCrash(arm.declares), 'account'); + // ADR-0112 envelope: both halves asserted, never a status alone. + expect(wire.status).toBe(500); + expect(wire.body.code).toBe('INTERNAL_ERROR'); + // ⛔ The stack-shaped sentence is the leak the ruling names. + expect(String(wire.body.error)).not.toContain('threw:'); + expect(String(wire.body.error)).not.toContain('TypeError'); + // The arm's declared status is gone with it — a crash is not + // the failure mode the author declared. + expect(wire.status).not.toBe(arm.status); + expect(wire.body.code).not.toBe(arm.code); + // …and so are the arm's structured fields: the sanitised + // terminal says status and code and nothing else. + for (const key of Object.keys(arm.keeps ?? {})) { + expect(wire.body, `${arm.arm} leaked ${key}`).not.toHaveProperty(key); + } + }); + } - it('the control: the SAME crash with no declared code reaches the sanitised fault terminal', () => { + it('the positive control STAYS: the same crash with no declared code reaches the same terminal', () => { const wire = mapDataError(sandboxCrash({}), 'account'); expect(wire.status).toBe(500); + expect(wire.body.code).toBe('INTERNAL_ERROR'); expect(String(wire.body.error)).not.toContain('threw:'); expect(String(wire.body.error)).not.toContain('TypeError'); }); + + describe('§4-negative — «only the crash branch moves»', () => { + for (const arm of ARMS) { + it(`${arm.arm}: a sandboxed BUSINESS refusal is completely unaffected`, () => { + const wire = mapDataError(sandboxRefusal(arm.declares), 'account'); + expect(wire.status).toBe(arm.status); + expect(wire.body.code).toBe(arm.code); + expect(wire.body.error).toBe(BUSINESS); + for (const [key, value] of Object.entries(arm.keeps ?? {})) { + expect(wire.body[key]).toEqual(value); + } + }); + + it(`${arm.arm}: a NON-sandbox producer on the same code is untouched too`, () => { + const plain: any = Object.assign(new Error('Plain producer sentence'), arm.declares); + const wire = mapDataError(plain, 'account'); + expect(wire.status).toBe(arm.status); + expect(wire.body.code).toBe(arm.code); + expect(wire.body.error).toBe('Plain producer sentence'); + }); + } + + it('a crash-SHAPED sentence a non-sandbox producer wrote is NOT a sandbox crash', () => { + // `isSandboxCrash` is gated on the sandbox side-channel first. + // A plain producer whose own message happens to read like a + // native error name never had an `innerMessage`, so the arm + // answers it exactly as before — the crash rule reaches only + // what the sandbox unwrapped. + const plain: any = Object.assign(new Error('TypeError: x is not a function'), { + code: 'DELETE_RESTRICTED', status: 409, object: 'account', + }); + const wire = mapDataError(plain, 'account'); + expect(wire.status).toBe(409); + expect(wire.body.code).toBe('DELETE_RESTRICTED'); + expect(wire.body.error).toBe('TypeError: x is not a function'); + }); + }); + + /** + * The ruling's own execution constraint: *"the seat re-derives the arm + * list from the tree, not from #14704's list."* Re-deriving once is a + * reading that rots; this leg is the same re-derivation asked + * mechanically, so the next arm added to the shared classification is + * either covered above or excused here BY NAME. + * + * Measured re-derivation on this tree: thirteen declared-code literals + * sit above the unwrap door — ten reachable by a sandboxed producer + * (the {@link ARMS} rows) and three that are not, each for a reason the + * source states in the arm itself. + */ + describe('§4-derivation — the arm list is DERIVED from the tree, not copied', () => { + /** Code literals a sandboxed producer provably cannot reach. */ + const UNREACHABLE_BY_A_SANDBOX_PRODUCER: ReadonlyArray<{ code: string; why: string }> = [ + { + code: 'DUPLICATE_RECORD', + why: 'gated on the ENVELOPE — `name === \'DuplicateRecordError\'` — and `SandboxError` sets ' + + '`name` unconditionally, so no sandbox producer, crashed or not, reaches this arm.', + }, + { + code: 'OBJECT_NOT_FOUND', + why: 'carries #14541\'s `!isSandboxOrigin` clause, which routes every sandboxed producer ' + + 'past the arm to the unwrap door — where #15071\'s terminal now sits above it anyway.', + }, + { + code: 'INVALID_FIELD', + why: 'the same `!isSandboxOrigin` clause as the arm above, for the same reason.', + }, + ]; + + /** The whole region asked BEFORE the unwrap door: the shared classification plus the arms below the consult. */ + function aboveTheUnwrapDoor(): string { + const shared = SOURCE.indexOf('function structuredCodeAnswer('); + const door = SOURCE.indexOf("if (typeof error?.innerMessage === 'string' && error.innerMessage) {", shared); + expect(shared).toBeGreaterThan(-1); + expect(door).toBeGreaterThan(shared); + return SOURCE.slice(shared, door); + } + + function declaredCodeLiterals(): string[] { + return [...aboveTheUnwrapDoor().matchAll(/error\?\.code === '([A-Z_]+)'/g)].map((m) => m[1]); + } + + it('the scan really sees the arms (a zero-match scan is a green that measured nothing)', () => { + expect(new Set(declaredCodeLiterals()).size).toBeGreaterThanOrEqual(13); + }); + + it('every declared-code arm above the unwrap door is either covered here or named unreachable', () => { + const covered = new Set(ARMS.map((a) => a.arm)); + const excused = new Set(UNREACHABLE_BY_A_SANDBOX_PRODUCER.map((e) => e.code)); + const uncovered = [...new Set(declaredCodeLiterals())] + .filter((code) => !covered.has(code) && !excused.has(code)); + expect(uncovered).toEqual([]); + }); + + it('the excuse list is not a dumping ground: every entry is a live arm with a real reason', () => { + const region = aboveTheUnwrapDoor(); + for (const entry of UNREACHABLE_BY_A_SANDBOX_PRODUCER) { + expect(region).toContain(`error?.code === '${entry.code}'`); + expect(entry.why.length).toBeGreaterThan(60); + } + }); + + it('the crash terminal is asked ONCE, above the arms — not duplicated into them', () => { + // The move is the change: one `isScriptFaultMessage` gate on + // this path, and it out-ranks the consult. A second copy inside + // an arm would be the mechanism option C was refused for. + const fn = SOURCE.indexOf('function classifyDataError('); + const consult = SOURCE.indexOf('const structured = structuredCodeAnswer(error, object);', fn); + expect(consult).toBeGreaterThan(fn); + expect(SOURCE.slice(fn, consult)).toContain('isSandboxCrash(error)'); + expect(SOURCE.slice(fn, consult)).toContain('UNCLASSIFIED_FAULT()'); + expect(aboveTheUnwrapDoor()).not.toContain('isScriptFaultMessage('); + }); + }); }); describe('§5 the bulk-door control — nothing moves on `resolveErrorResponse`', () => { @@ -303,8 +456,6 @@ describe('#14704 · the single `/data` door never ships the QuickJS wrapper out }, ]; - const SOURCE = readFileSync(resolve(HERE, 'error-response.ts'), 'utf8'); - function sharedClassification(): string { const a = SOURCE.indexOf('function structuredCodeAnswer('); const b = SOURCE.indexOf('function classifyDataError(', a + 1); diff --git a/packages/rest/src/error-response-structured-arm-door-parity.test.ts b/packages/rest/src/error-response-structured-arm-door-parity.test.ts index efa2cb11fd..6c4adc4797 100644 --- a/packages/rest/src/error-response-structured-arm-door-parity.test.ts +++ b/packages/rest/src/error-response-structured-arm-door-parity.test.ts @@ -521,6 +521,66 @@ describe('#14541 · structured arms are consulted by BOTH doors', () => { expect(bulk.body).not.toHaveProperty('dependentObject'); }); + /** + * [#15071] The crash sibling of the case above — CONVERGED where the + * producer declared no status, and named as a DIVERGENCE where it did. + * + * The maintainer ruling (2026-09-04, batch #27, option B) moved the + * crash terminal above `classifyDataError`'s code-gated arms, so the + * single door answers a sandboxed CRASH with the sanitised 500 whatever + * code it declares. The bulk door never reached those arms for a + * sandbox producer (#14541's `isSandboxOrigin` guard), so nothing the + * ruling names moved there — its answer for a crash comes from + * `resolveErrorResponse`'s declared-status passthrough, which + * `sandboxBusinessMessage` declines a crash for and which therefore + * ships the QuickJS wrapper at the declared status. + * + * ⚠️ That passthrough gap is NOT this card's, and it is not new: it is + * pinned as MEASURED AND NOT REPAIRED in + * `rest-hook-refusal-message-parity.test.ts` §7 for the same crash + * carrying no code, with the reason ("making the two agree means moving + * the STATUS the passthrough decided, which is a contract question"). + * What this card does is WIDEN that population — the shape below used + * to agree at both doors and no longer does — so it is stated here + * rather than left for someone to rediscover, and carried to the + * contract-review tier as an open question on the PR. + */ + it('CONVERGED (#15071): a sandboxed CRASH with NO declared status is the fault terminal at both doors', () => { + const err: any = new Error("hook 'guard' threw: TypeError: x is not a function"); + err.innerMessage = 'TypeError: x is not a function'; + err.code = 'DELETE_RESTRICTED'; + err.object = 'account'; + err.dependentObject = 'contact'; + const bulk = bulkDoor(err, 'account'); + const single = singleDoor(err, 'account'); + expect(single.status).toBe(500); + expect(bulk.status).toBe(500); + expect(bulk.body).toEqual(single.body); + expect(bulk.body.code).toBe('INTERNAL_ERROR'); + expect(String(bulk.body.error)).not.toContain('threw:'); + expect(bulk.body).not.toHaveProperty('dependentObject'); + }); + + it('ACCEPTED DIVERGENCE (#15071 widens it): a sandboxed CRASH that DECLARED a 4xx status', () => { + const err: any = new Error("hook 'guard' threw: TypeError: x is not a function"); + err.innerMessage = 'TypeError: x is not a function'; + err.code = 'DELETE_RESTRICTED'; + err.status = 409; + err.object = 'account'; + const bulk = bulkDoor(err, 'account'); + const single = singleDoor(err, 'account'); + // The single door: what this card ruled — a crash is a fault. + expect(single.status).toBe(500); + expect(single.body.code).toBe('INTERNAL_ERROR'); + expect(String(single.body.error)).not.toContain('threw:'); + // The bulk door: unchanged by this card, and still the shape §7 of + // `rest-hook-refusal-message-parity.test.ts` records. ⛔ Green on + // both sides of the fix: it documents the gap, it does not bless it. + expect(bulk.status).toBe(409); + expect(bulk.body.code).toBe('DELETE_RESTRICTED'); + expect(String(bulk.body.error)).toContain('threw:'); + }); + it('ACCEPTED DIVERGENCE (guard 1): a producer-declared 5xx keeps the passthrough on the bulk door', () => { const err: any = new Error('Cannot delete: dependent records exist'); err.code = 'DELETE_RESTRICTED'; diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index f5e6e1b370..551fab4e0f 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -307,16 +307,22 @@ export function sandboxBusinessMessage(error: any): string | undefined { * * ## ⛔ What this deliberately does NOT decide * - * A sandboxed **CRASH** (#7543). {@link sandboxBusinessMessage} declines one by - * contract, so the fallback hands the arm `error.message` — the wrapper — and - * the arm answers with its own declared status, where the unwrap door's - * terminal for the same crash is the sanitised 500. That divergence is - * UNCHANGED by this rule, on purpose: choosing between those two answers is - * fault classification rather than message sourcing (triage on #14704, verbatim: - * "leave today's behaviour exactly as it is, implement the business-message - * read only, and name the site and the divergence"). It is pinned in - * `error-response-sandbox-arm-message.test.ts` §4 so that deciding it is a - * visible edit rather than a drift, and it carries its own decision card. + * Fault classification. A sandboxed **CRASH** (#7543) no longer reaches this + * function at all: #15071 put {@link isSandboxCrash} ABOVE the code-gated arms + * in {@link classifyDataError}, so a crashed body is answered by + * {@link UNCLASSIFIED_FAULT} whatever code it declared, and the other door + * declines the consult for a sandbox producer outright (the section above). + * ⛔ The ruling that decided it, its fence and its negative control are stated + * ONCE, on {@link isSandboxCrash} — read them there rather than a second time + * here. `error-response-sandbox-arm-message.test.ts` §4 records the verdict, + * now CONVERGED. {@link sandboxBusinessMessage} still declines a crash by + * contract, so this function keeps no opinion of its own about one. + * + * ⚠️ CONVERGED is the no-declared-status case, not the whole question: a crash + * that DECLARED a 4xx still leaves {@link resolveErrorResponse} at that status, + * wrapper and all, through a passthrough this card did not touch — pinned as an + * ACCEPTED DIVERGENCE, widened by #15071, in + * `error-response-structured-arm-door-parity.test.ts` §4. * * ⛔ Deliberately a READ of the field the sandbox populated, never a * pattern-strip of the wrapper off `.message` — {@link sandboxBusinessMessage} @@ -732,6 +738,51 @@ function isSandboxOrigin(error: any): boolean { return typeof error?.innerMessage === 'string' && error.innerMessage.length > 0; } +/** + * [#15071] Did a sandboxed body CRASH — as opposed to reporting a refusal? + * + * The two reads {@link sandboxBusinessMessage} already makes, asked from the + * other side: a sandbox origin ({@link isSandboxOrigin}) whose unwrapped + * sentence names a JS runtime fault ({@link isScriptFaultMessage}). One + * predicate, so the question "is this a crash" has one answer in this file + * rather than a second open-coded read — the door-disagreement shape + * #7525/#8016/#11588 keep producing whenever a boundary re-derives a read this + * file already owns. + * + * ## Why {@link classifyDataError} asks it FIRST + * + * Maintainer ruling, 2026-09-04 (decision batch #27), on this card — option B, + * verbatim 「同意」: *"A declared code is the author's statement about the + * failure mode they **handled**. A crash (`isScriptFaultMessage`, #7543) is not + * that mode, so it is classified as a fault"* — and so the crash terminal that + * lived INSIDE the unwrap door now sits above the code-gated arms, which are + * asked before that door. It is the same terminal, moved, not a second one: + * ⛔ there is exactly one `isScriptFaultMessage` gate on this path. + * + * Before this card the answer depended on whether the crashing body happened to + * declare a code an arm recognises: a crash carrying `DELETE_RESTRICTED` was + * answered `409` with the QuickJS debug wrapper as its client-facing sentence, + * while the same crash carrying no declared code reached the sanitised + * {@link UNCLASSIFIED_FAULT}. The ruling on that: *"an internal stack-shaped + * sentence at a business status is both a leak and a lie to the client about + * what happened"*. + * + * ⛔ What this deliberately does NOT touch, in the ruling's own words: *"Ordinary + * declared refusals (a hook that throws a business error carrying a code, no + * crash) are **untouched** — only the crash branch moves."* A business refusal + * fails {@link isScriptFaultMessage}, and a non-sandbox producer fails + * {@link isSandboxOrigin}, so both keep every byte of the arm's answer — + * `error-response-sandbox-arm-message.test.ts` §1-§3 are the standing controls + * and §4 pins the negative control per arm. + * + * ⛔ Nor does it widen the `developerMessage` channel: #7543's existing rule for + * a fault is what {@link UNCLASSIFIED_FAULT} emits, unchanged — status, the + * catalog's `INTERNAL_ERROR`, and no prose from the crash. + */ +function isSandboxCrash(error: any): boolean { + return isSandboxOrigin(error) && isScriptFaultMessage(error.innerMessage); +} + /** * [#14541, contract-review condition 4] A structured arm answering a **5xx** * never displaces a status the producer declared in the **4xx** band — asked by @@ -1162,6 +1213,15 @@ function structuredCodeAnswer( } function classifyDataError(error: any, object?: string): { status: number; body: Record } { + // [#15071] A sandboxed CRASH is a fault before it is anything else — above + // the arms, because the arms are asked before the unwrap door that used to + // hold this terminal. Maintainer ruling 2026-09-04 (batch #27), option B: + // a crash "reaches the unwrap door's sanitised 500 whatever code it + // declares". See {@link isSandboxCrash} for the ruling and its fence. + // + // ⛔ The terminal is not duplicated — it MOVED here from inside the unwrap + // door below, which is why that door now reads a body that REPORTED. + if (isSandboxCrash(error)) return UNCLASSIFIED_FAULT(); // [#14541] The bespoke structured arms first, exactly as they were inline // here — same arms, same order, same position — now stated once so // {@link resolveErrorResponse} can ask them before ITS passthrough too. @@ -1249,13 +1309,17 @@ function classifyDataError(error: any, object?: string): { status: number; body: // a door-to-door pin (`rest-hook-refusal-message-parity.test.ts` §4) rather // than by this comment. if (typeof error?.innerMessage === 'string' && error.innerMessage) { - // [#7543] …but only when the body REPORTED something. A body that - // CRASHED arrives here too, and its `TypeError: not a function` is an - // internal fault, not a business message — see - // {@link isScriptFaultMessage}. Deliberately FIRST: a crash outranks - // everything else about the error, including a stray declared - // `status` — a `TypeError` carrying one stays the sanitised 500. - if (isScriptFaultMessage(error.innerMessage)) return UNCLASSIFIED_FAULT(); + // [#7543] …and by the time control reaches here the body REPORTED + // something: a body that CRASHED arrives at this function too, and its + // `TypeError: not a function` is an internal fault rather than a + // business message — {@link isScriptFaultMessage}. "Deliberately FIRST: + // a crash outranks everything else about the error, including a stray + // declared `status`" is unchanged as a rule; [#15071] moved the gate + // that applies it to the TOP of this function ({@link isSandboxCrash}), + // because the code-gated arms above are asked before this door and were + // answering a crash with a business status and the wrapper prose. So + // this branch keeps its meaning and loses its guard — the guard did not + // disappear, it out-ranks more of the file than it used to. // [#9967] A body that NAMES its own HTTP status is asking to be served // with it — the same #7867 rule `domains/actions.ts` applies on the // custom-action route. The QuickJS side-channel carries a body-thrown