From 4ebc73d317a45d91346c00a4b09a600d060634f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 18:34:57 +0000 Subject: [PATCH 1/4] wip(rest): move the sandbox crash terminal above the code-gated arms Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- packages/rest/src/error-response.ts | 72 ++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index f5e6e1b370..9aab3df051 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -732,6 +732,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 +1207,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 +1303,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 From 936391557a3e1d484f5682b2c54aae9001a1259f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 18:51:26 +0000 Subject: [PATCH 2/4] =?UTF-8?q?wip(rest):=20flip=20the=20=C2=A74=20pin,=20?= =?UTF-8?q?name=20the=20door=20residue,=20add=20the=20changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- ...andbox-crash-outranks-declared-code-arm.md | 33 +++ ...error-response-sandbox-arm-message.test.ts | 223 +++++++++++++++--- ...esponse-structured-arm-door-parity.test.ts | 60 +++++ 3 files changed, 280 insertions(+), 36 deletions(-) create mode 100644 .changeset/sandbox-crash-outranks-declared-code-arm.md 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..f6fe877c3c --- /dev/null +++ b/.changeset/sandbox-crash-outranks-declared-code-arm.md @@ -0,0 +1,33 @@ +--- +"@objectstack/rest": patch +--- + +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) + +**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'; From d4f4a329b50744ec67c4012dff338c47ac850183 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 19:44:38 +0000 Subject: [PATCH 3/4] fix(rest): grade the changeset minor and declare the wire break The level axis (#16055) refuses a clause-\xe2\x91\xa1 PR that grades every package it moves at patch. The declaration is the maintainer's (batch #27), so the level was the wrong half: @objectstack/rest goes to minor, and the BREAKING banner carries the breaking-ness the launch window keeps off the level. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- .../sandbox-crash-outranks-declared-code-arm.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.changeset/sandbox-crash-outranks-declared-code-arm.md b/.changeset/sandbox-crash-outranks-declared-code-arm.md index f6fe877c3c..4ecad6d3f4 100644 --- a/.changeset/sandbox-crash-outranks-declared-code-arm.md +++ b/.changeset/sandbox-crash-outranks-declared-code-arm.md @@ -1,9 +1,19 @@ --- -"@objectstack/rest": patch +"@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 From 5b342a17fd684f199d5e3df540e12918e7b0585d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:58:53 +0000 Subject: [PATCH 4/4] docs(rest): retire the stale crash-divergence note on `armSentence` Contract review F1: the "What this deliberately does NOT decide" block in `armSentence`'s docblock still described the pre-#15071 world, and after this PR every clause of it was false. It said a sandboxed CRASH reaches an arm and is answered at the arm's own declared status, that the divergence against the unwrap door's sanitised 500 is UNCHANGED, that the pin records it as an accepted divergence, and that it "carries its own decision card" -- while the card is this one and it has been executed: the crash terminal now sits above the code-gated arms, so no crash reaches this function on either door. Rewritten as a cross-reference rather than a second statement of the ruling. `isSandboxCrash`'s own docblock carries the maintainer ruling, its fence and its negative control; a file that states one rule twice is the drift this finding is made of, so the block now points there and stops. The second paragraph keeps the surviving divergence visible: what converged is the no-declared-status case. A crash that DECLARED a 4xx still leaves `resolveErrorResponse` at that status with the QuickJS wrapper, through a passthrough this card did not touch -- pinned as an ACCEPTED DIVERGENCE in `error-response-structured-arm-door-parity.test.ts`. The prose must not read as "all divergence is gone", because it is not. Comment lines only -- no executable byte moves. Proven at parser level: both revisions re-printed with `removeComments: true` hash identically (sha256 5daab82cdd23e0b93a1dfb420b2a9e3c83786975248504bdbf9907b321005fa7), with a control leg that flips one identifier and is correctly rejected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- packages/rest/src/error-response.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index 9aab3df051..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}