diff --git a/.changeset/rest-single-door-sandbox-arm-message.md b/.changeset/rest-single-door-sandbox-arm-message.md new file mode 100644 index 0000000000..9f3f2dcce2 --- /dev/null +++ b/.changeset/rest-single-door-sandbox-arm-message.md @@ -0,0 +1,60 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): the by-id `/data` door stops shipping the QuickJS debug wrapper out of a declared-code structured arm (#14704) + +**Response-contract change on a shipped public route**, in the direction of the +answer the other door already gives. A sandboxed hook or action body refusing a +write throws a `SandboxError` whose `.message` is the +` '' threw: ` **debug wrapper** written for the server log and +whose `.innerMessage` is the sentence the author addressed to the end user. The +bespoke structured arms in `classifyDataError` are surfaced ABOVE the sandbox +unwrap door on purpose — "so the structured fields survive the generic +catch-alls" — and every one of them built its sentence from `error.message`. The +unwrap door that would have read `.innerMessage` sits below them and was never +reached, so one refusal came back as two different sentences depending on the +route: + +| door | answer | +|---|---| +| `sendThrownError` / `handleRouteError` (bulk, metadata, UI) | `409` — `{"error":"Opportunity is closed.","code":"DELETE_RESTRICTED"}` | +| `mapDataError` (single-record `/data`) | `409` — `{"error":"hook 'guard' threw: Error: Opportunity is closed.","code":"DELETE_RESTRICTED",…}` | + +The bulk door is the reference and does not move: #11588 named +`sandboxBusinessMessage` and taught the declared-status passthrough to read it, +and #14541 declines the shared arm consult outright for a sandbox-origin error. +This is that same rule reaching the arms — **asked once**, as `armSentence`, +rather than re-opined per arm, because a third local opinion at this boundary is +how the two doors came to disagree. + +**What callers see change** — only on the single-record `/data` door, and only +the human sentence. Status, `code` and every structured field are unchanged: + +- `DELETE_RESTRICTED`, `CONCURRENT_UPDATE`, `ERR_DATASOURCE_UNAVAILABLE`, + `VALIDATION_FAILED`, `FEEDS_DISABLED` / `FILES_DISABLED`, + `ATTACHMENT_PARENT_ACCESS` / `ATTACHMENT_DELETE_DENIED`, + `RECORD_NOT_ACCESSIBLE` and `PERMISSION_DENIED` thrown from a sandboxed body + now answer with the author's sentence instead of the wrapper. +- `VALIDATION_FAILED` is the ordinary case: an app-authored hook writing + `throw Object.assign(new Error('Amount must be positive'), { code: 'VALIDATION_FAILED', fields: […] })` + answered `400 {"error":"hook 'guard' threw: Error: Amount must be positive"}` + and now answers `400 {"error":"Amount must be positive"}`. + +**Unchanged, deliberately:** + +- Every non-sandbox producer. The rule is a READ of the field the sandbox + populated, never a pattern-strip of the wrapper off `.message`, so an error + with no `.innerMessage` relays byte for byte what it relayed before. +- Every bulk / metadata / UI route. Those reach the arms through + `resolveErrorResponse`, which declines the consult for a sandbox-origin error. +- A sandboxed **CRASH** carrying a declared code. `sandboxBusinessMessage` + declines a crash (#7543), so such an error still answers with the arm's status + and the wrapper prose, where the unwrap door's terminal for the same crash is + the sanitised `500`. Choosing between those two answers is fault + classification rather than message sourcing; it is pinned as a named + divergence and carried as its own decision card. +- The `DUPLICATE_RECORD` arm. It is gated on the engine's envelope class + (`name === 'DuplicateRecordError'`) and `SandboxError` sets `name` + unconditionally, so no sandboxed producer reaches it; converging its GATE + would change the wire for two producer populations and reverse #14389 §5. diff --git a/packages/rest/src/error-response-sandbox-arm-message.test.ts b/packages/rest/src/error-response-sandbox-arm-message.test.ts new file mode 100644 index 0000000000..d233f2dcbb --- /dev/null +++ b/packages/rest/src/error-response-sandbox-arm-message.test.ts @@ -0,0 +1,361 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #14704 — the single-record `/data` door must not ship the QuickJS debug + * wrapper out of a declared-code structured arm. + * + * ## What was measured, on `origin/main` @ `99b4deba49` + * + * A `SandboxError` carries the caller-addressed sentence on `.innerMessage` + * and a ` '' threw: ` DEBUG WRAPPER on `.message` + * (`runtime/src/sandbox/quickjs-runner.ts`). Every arm in + * `structuredCodeAnswer` — and the `PERMISSION_DENIED` arm just below the + * consult — built its sentence from `error?.message`, and those arms are asked + * BEFORE the sandbox unwrap door in `classifyDataError`. So one hook refusal + * came back as two different sentences depending on the route: + * + * sendThrownError / handleRouteError (bulk / metadata / UI) + * 409 {"error":"Opportunity is closed.","code":"DELETE_RESTRICTED"} + * mapDataError (single-record /data) + * 409 {"error":"hook 'guard' threw: Error: Opportunity is closed.", …} + * + * The bulk door is right because #11588 taught `resolveErrorResponse`'s + * declared-status passthrough to read `sandboxBusinessMessage`, and because + * #14541 excludes a sandbox-origin error from the shared consult entirely. The + * single door reached the arms and shipped the wrapper — #11588's own defect, + * one door over, with the direction reversed rather than closed. + * + * ## What this file pins + * + * §1 per arm, by NAME: a sandboxed BUSINESS refusal carrying that arm's + * declared code answers with `.innerMessage` on the single door, and the + * wrapper never reaches the wire — status and `code` asserted with it + * (ADR-0112), never `toThrow()` alone; + * §2 the arm's structured fields still ride, so the repair is a SENTENCE + * change and nothing else; + * §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; + * §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; + * §6 the drift guard: every arm in the shared classification that relays a + * PRODUCER sentence asks the shared rule, so the next arm cannot + * reintroduce the raw relay silently. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mapDataError, sendThrownError } from './error-response.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** 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. */ +const WRAPPER = `hook 'guard' threw: Error: ${BUSINESS}`; + +/** + * A `SandboxError`-shaped refusal, assembled exactly as + * `quickjs-runner.ts` builds it: the wrapper on `.message`, the business + * sentence on `.innerMessage`, `name` fixed to `SandboxError` (the class sets + * it unconditionally — which is why the `DUPLICATE_RECORD` arm's + * `name === 'DuplicateRecordError'` gate excludes a sandbox producer + * outright). + */ +function sandboxRefusal(extra: Record): any { + const e: any = new Error(WRAPPER); + e.name = 'SandboxError'; + e.innerMessage = BUSINESS; + return Object.assign(e, extra); +} + +/** The same shape for a body that CRASHED — no business sentence exists. */ +function sandboxCrash(extra: Record): any { + const e: any = new Error("hook 'guard' threw: TypeError: x is not a function"); + e.name = 'SandboxError'; + e.innerMessage = 'TypeError: x is not a function'; + return Object.assign(e, extra); +} + +function bulkDoor(error: unknown, object?: string): { status: number; body: Record } { + let status = 0; + let body: Record = {}; + const res = { + status(s: number) { status = s; return this; }, + json(b: Record) { body = b; return this; }, + }; + sendThrownError(res, error, object); + return { status, body }; +} + +/** + * One row per code-gated arm triage named. `declares` is what the PRODUCER + * writes onto the thrown error; `status` / `code` are what that arm answers. + */ +interface Arm { + /** The arm, by the name it is known by in `error-response.ts`. */ + readonly arm: string; + readonly declares: Record; + readonly status: number; + readonly code: string; + /** Structured fields the arm must still ship (§2). */ + readonly keeps?: Readonly>; +} + +const ARMS: readonly Arm[] = [ + { + arm: 'DELETE_RESTRICTED', + declares: { code: 'DELETE_RESTRICTED', status: 409, object: 'account', dependentObject: 'contact', dependentCount: 3 }, + status: 409, + code: 'DELETE_RESTRICTED', + keeps: { dependentObject: 'contact', dependentCount: 3, object: 'account' }, + }, + { + arm: 'CONCURRENT_UPDATE', + declares: { code: 'CONCURRENT_UPDATE', status: 409, currentVersion: 7 }, + status: 409, + code: 'CONCURRENT_UPDATE', + keeps: { currentVersion: 7, object: 'account' }, + }, + { + arm: 'ERR_DATASOURCE_UNAVAILABLE', + declares: { code: 'ERR_DATASOURCE_UNAVAILABLE', datasource: 'warehouse', kind: 'blocked' }, + status: 503, + code: 'ERR_DATASOURCE_UNAVAILABLE', + keeps: { datasource: 'warehouse', reason: 'blocked', object: 'account' }, + }, + { + arm: 'VALIDATION_FAILED', + declares: { code: 'VALIDATION_FAILED', status: 400, fields: [{ name: 'amount', message: 'must be positive' }] }, + status: 400, + code: 'VALIDATION_FAILED', + keeps: { fields: [{ name: 'amount', message: 'must be positive' }], object: 'account' }, + }, + { + arm: 'FEEDS_DISABLED', + declares: { code: 'FEEDS_DISABLED', status: 403, object: 'account' }, + status: 403, + code: 'FEEDS_DISABLED', + keeps: { object: 'account' }, + }, + { + arm: 'FILES_DISABLED', + declares: { code: 'FILES_DISABLED', status: 403, object: 'account' }, + status: 403, + code: 'FILES_DISABLED', + keeps: { object: 'account' }, + }, + { + arm: 'ATTACHMENT_PARENT_ACCESS', + declares: { code: 'ATTACHMENT_PARENT_ACCESS', status: 403, object: 'account' }, + status: 403, + code: 'ATTACHMENT_PARENT_ACCESS', + keeps: { object: 'account' }, + }, + { + arm: 'ATTACHMENT_DELETE_DENIED', + declares: { code: 'ATTACHMENT_DELETE_DENIED', status: 403, object: 'account' }, + status: 403, + code: 'ATTACHMENT_DELETE_DENIED', + keeps: { object: 'account' }, + }, + { + arm: 'RECORD_NOT_ACCESSIBLE', + declares: { code: 'RECORD_NOT_ACCESSIBLE', status: 403, object: 'account' }, + status: 403, + code: 'RECORD_NOT_ACCESSIBLE', + keeps: { object: 'account' }, + }, + { + arm: 'PERMISSION_DENIED', + declares: { code: 'PERMISSION_DENIED', status: 403 }, + status: 403, + code: 'PERMISSION_DENIED', + keeps: { object: 'account' }, + }, +]; + +describe('#14704 · the single `/data` door never ships the QuickJS wrapper out of a declared-code arm', () => { + describe('§1 per arm — a sandboxed BUSINESS refusal answers with `innerMessage`', () => { + for (const arm of ARMS) { + it(`${arm.arm} answers ${arm.status} ${arm.code} with the business sentence`, () => { + 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); + // ⛔ The wrapper is for the server log, never the wire. + expect(String(wire.body.error)).not.toContain('threw:'); + }); + } + }); + + describe('§2 the structured fields still ride — only the SENTENCE moves', () => { + for (const arm of ARMS) { + it(`${arm.arm} keeps ${Object.keys(arm.keeps ?? {}).join(', ')}`, () => { + const wire = mapDataError(sandboxRefusal(arm.declares), 'account'); + for (const [key, value] of Object.entries(arm.keeps ?? {})) { + expect(wire.body[key]).toEqual(value); + } + }); + } + }); + + describe('§3 the non-sandbox control — a plain producer keeps `error.message` verbatim', () => { + for (const arm of ARMS) { + it(`${arm.arm} is byte-identical for a producer with no innerMessage`, () => { + 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'); + }); + } + }); + + /** + * ⛔ 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." + * + * 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. + */ + 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"); + }); + + it('the control: the SAME crash with no declared code reaches the sanitised fault terminal', () => { + const wire = mapDataError(sandboxCrash({}), 'account'); + expect(wire.status).toBe(500); + expect(String(wire.body.error)).not.toContain('threw:'); + expect(String(wire.body.error)).not.toContain('TypeError'); + }); + }); + + describe('§5 the bulk-door control — nothing moves on `resolveErrorResponse`', () => { + it('DELETE_RESTRICTED: the bulk door already answered the business sentence (#11588)', () => { + const wire = bulkDoor(sandboxRefusal({ code: 'DELETE_RESTRICTED', status: 409, object: 'account', dependentObject: 'contact' }), 'account'); + expect(wire.status).toBe(409); + expect(wire.body.code).toBe('DELETE_RESTRICTED'); + expect(wire.body.error).toBe(BUSINESS); + }); + + it('a sandbox-origin error never reaches the shared consult there (#14541)', () => { + // Proof by the consequence #14541 recorded: the arms' structured + // fields are absent on this door for a sandbox producer. + const wire = bulkDoor(sandboxRefusal({ code: 'DELETE_RESTRICTED', status: 409, object: 'account', dependentObject: 'contact' }), 'account'); + expect(wire.body).not.toHaveProperty('dependentObject'); + }); + }); + + /** + * The drift guard triage guard 3 asks for, one card on: an arm added to the + * shared classification tomorrow that relays a producer sentence must ask + * the shared rule, or say in this list why it does not. + */ + describe('§6 drift guard — every producer-sentence relay asks the shared rule', () => { + const RAW_RELAY_ALLOWED: ReadonlyArray<{ code: string; why: string }> = [ + { + code: 'DUPLICATE_RECORD', + why: 'gated on the ENVELOPE (`name === \'DuplicateRecordError\'`) and `SandboxError` sets ' + + '`name` unconditionally, so a sandbox producer cannot reach this arm at all — the ' + + 'two-read rule here would be a check that evaluates never. Converging the GATE is a ' + + 'wire change for two producer populations and reverses #14389 §5; escalated, not taken.', + }, + { + code: 'OBJECT_NOT_FOUND', + why: 'ships a FIXED sentence, never `error.message`, and carries #14541\'s `!isSandboxOrigin` ' + + 'clause besides — no wrapper can reach the wire through it.', + }, + { + code: 'INVALID_FIELD', + why: 'fenced by #14541\'s explicit `!isSandboxOrigin` clause, which routes a sandboxed ' + + 'producer to the unwrap door before this arm is reached. Both spellings already ' + + 'produce the right answer, and triage ruled this arm out of scope.', + }, + ]; + + 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); + expect(a).toBeGreaterThan(-1); + expect(b).toBeGreaterThan(a); + return SOURCE.slice(a, b); + } + + /** Split the classification into one chunk per arm, keyed by its first code literal. */ + function arms(slice: string): Array<{ code: string; text: string }> { + const marks: Array<{ code: string; at: number }> = []; + for (const m of slice.matchAll(/if \(\s*error\?\.code === '([A-Z_]+)'/g)) { + marks.push({ code: m[1], at: m.index ?? 0 }); + } + return marks.map((mark, i) => ({ + code: mark.code, + text: slice.slice(mark.at, i + 1 < marks.length ? marks[i + 1].at : slice.length), + })); + } + + it('the scan really sees the arms (a zero-match scan is a green that measured nothing)', () => { + expect(arms(sharedClassification()).length).toBeGreaterThanOrEqual(8); + }); + + it('every arm relaying a producer sentence reads `sandboxBusinessMessage` first', () => { + const excused = new Map(RAW_RELAY_ALLOWED.map((e) => [e.code, e.why])); + const offenders: string[] = []; + for (const arm of arms(sharedClassification())) { + if (!arm.text.includes('error?.message') && !arm.text.includes('error.message')) continue; + if (arm.text.includes('armSentence(')) continue; + if (excused.has(arm.code)) continue; + offenders.push(arm.code); + } + expect(offenders).toEqual([]); + }); + + it('the allowlist is not a dumping ground: every entry is a live arm with a real reason', () => { + const slice = sharedClassification(); + for (const entry of RAW_RELAY_ALLOWED) { + expect(slice).toContain(`error?.code === '${entry.code}'`); + expect(entry.why.length).toBeGreaterThan(60); + } + }); + + it('the `PERMISSION_DENIED` arm below the consult reads the shared rule too', () => { + const a = SOURCE.indexOf('function classifyDataError('); + const b = SOURCE.indexOf("if (typeof error?.innerMessage === 'string'", a + 1); + expect(b).toBeGreaterThan(a); + const aboveTheUnwrapDoor = SOURCE.slice(a, b); + expect(aboveTheUnwrapDoor).toContain("error?.code === 'PERMISSION_DENIED'"); + expect(aboveTheUnwrapDoor).toContain('armSentence('); + }); + }); +}); 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 65c5f74f7f..f355a13cdd 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 @@ -45,8 +45,10 @@ * labelled CONVERGED or ACCEPTED DIVERGENCE — a declared 5xx keeps the * passthrough's prose-withholding arm (#5437 / #5582 / #5907), a 5xx ARM * never displaces a declared 4xx, a sandboxed producer keeps the unwrap - * door's sentence on the bulk door (#11588 / #7543; the mirror on the - * single door is filed as #14704), and the one status this card DOES move + * door's sentence on BOTH doors (#11588 / #7543; the single door's mirror + * defect was closed by #14704, which FLIPPED that case's verdict here from + * ACCEPTED DIVERGENCE to CONVERGED rather than deleting it), and the one + * status this card DOES move * — a sandboxed 5xx carrying `OBJECT_NOT_FOUND` / `INVALID_FIELD` — is * pinned rather than described; * §5 the drift guard, over BOTH halves of `classifyDataError`: every @@ -474,7 +476,25 @@ describe('#14541 · structured arms are consulted by BOTH doors', () => { expect(bulk.body).toHaveProperty('datasource', 'warehouse'); }); - it('ACCEPTED DIVERGENCE (#14704): a sandboxed producer — bulk door unwraps, single door does not', () => { + /** + * FLIPPED by #14704, deliberately and in that card's PR, from + * `ACCEPTED DIVERGENCE` to `CONVERGED (sentence)`. ⛔ The case 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 divergence it recorded was the SENTENCE: the bulk door read + * `sandboxBusinessMessage` (#11588) while the single door reached the + * arm and shipped `error.message` — the QuickJS debug wrapper. #14704 + * gave the code-gated arms the same two-read rule (`armSentence`), so + * both doors now answer the business sentence for one hook refusal. + * + * ⚠️ What remains different is the KEY SET, and it is not this card's: + * #14541's `isSandboxOrigin` guard declines the shared consult on the + * bulk door outright, so the arm's structured fields never ride there. + * That is stated below rather than left implied — a case labelled + * CONVERGED whose bodies are unequal has to say where and why. + */ + it('CONVERGED (sentence, #14704): a sandboxed producer — both doors ship the business sentence', () => { const err: any = new Error("hook 'guard' threw: Error: Opportunity is closed."); err.innerMessage = 'Opportunity is closed.'; err.code = 'DELETE_RESTRICTED'; @@ -489,12 +509,15 @@ describe('#14541 · structured arms are consulted by BOTH doors', () => { // the business sentence; ⛔ never the QuickJS debug wrapper. expect(bulk.body.error).toBe('Opportunity is closed.'); expect(String(bulk.body.error)).not.toContain('threw:'); - // The single door reaches the arm, which ships `error.message` — - // the wrapper. That is the mirror defect, filed as #14704 and - // deliberately NOT closed here: closing it means deciding what an - // arm answers for a sandboxed CRASH. Pinned so it cannot drift - // unnoticed in either direction. - expect(single.body.error).toBe("hook 'guard' threw: Error: Opportunity is closed."); + // [#14704] The single door now reads the same rule through the + // arm. This assertion IS the flip — it read + // `"hook 'guard' threw: Error: Opportunity is closed."` before. + expect(single.body.error).toBe('Opportunity is closed.'); + expect(String(single.body.error)).not.toContain('threw:'); + // The residue, named: #14541's sandbox guard keeps the arm's + // structured fields off the bulk door. Owned there, not here. + expect(single.body).toHaveProperty('dependentObject', 'contact'); + expect(bulk.body).not.toHaveProperty('dependentObject'); }); it('ACCEPTED DIVERGENCE (guard 1): a producer-declared 5xx keeps the passthrough on the bulk door', () => { diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index 16c5239683..9e0787b8b8 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -274,6 +274,58 @@ export function sandboxBusinessMessage(error: any): string | undefined { return error.innerMessage; } +/** + * [#14704] The sentence a declared-code structured arm relays to the caller: + * {@link sandboxBusinessMessage} first, `error.message` second. + * + * ## The defect this retires + * + * `classifyDataError` surfaces the bespoke arms ABOVE its sandbox unwrap door, + * deliberately, "so the structured fields survive the generic catch-alls" — + * and every arm built its sentence from `error?.message`. For a sandboxed + * producer `error.message` IS the ` '' threw: ` debug wrapper, + * and the unwrap door that would have read `.innerMessage` sits below the arms + * and was never reached. So one hook refusal came back as two sentences + * depending on the route: the bulk door answered `Opportunity is closed.` and + * the single-record `/data` door answered + * `hook 'guard' threw: Error: Opportunity is closed.` + * + * #11588 repaired exactly this class one door over — it named + * {@link sandboxBusinessMessage} and taught {@link resolveErrorResponse}'s + * declared-status passthrough to read it. The arms were not in that card's + * scope, so the wrapper kept reaching a client with the direction reversed + * rather than closed. This is that same rule, asked once here instead of + * re-opined per arm — the third local opinion is what produced the divergence. + * + * ## Why the bulk door does not change + * + * {@link resolveErrorResponse} declines the shared consult outright for a + * sandbox-origin error (#14541's `isSandboxOrigin` guard), so this read is + * unreachable from that door and every bulk / metadata / UI route answers + * byte-for-byte what it answered before. The repair lands on + * {@link mapDataError} alone, which is where the defect was. + * + * ## ⛔ 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. + * + * ⛔ Deliberately a READ of the field the sandbox populated, never a + * pattern-strip of the wrapper off `.message` — {@link sandboxBusinessMessage} + * carries that argument, and this function must not grow a second one. + */ +function armSentence(error: any): unknown { + return sandboxBusinessMessage(error) ?? error?.message; +} + /** * [#5462] Does a driver's missing-relation message name the very object this * request asked for? @@ -810,7 +862,7 @@ function structuredCodeAnswer( return { status: 409, body: { - error: error?.message ?? 'Cannot delete: dependent records exist', + error: armSentence(error) ?? 'Cannot delete: dependent records exist', code: 'DELETE_RESTRICTED', // [#7307] `error` is the END USER's half — localized, labels // only — because Console renders it verbatim in a toast. @@ -839,7 +891,7 @@ function structuredCodeAnswer( return { status: 409, body: { - error: error?.message ?? 'Record was modified by another user', + error: armSentence(error) ?? 'Record was modified by another user', code: 'CONCURRENT_UPDATE', ...(error?.currentVersion ? { currentVersion: error.currentVersion } : {}), ...(error?.currentRecord ? { currentRecord: error.currentRecord } : {}), @@ -937,7 +989,7 @@ function structuredCodeAnswer( return { status: 503, body: { - error: error?.message ?? 'The datasource for this object is not available', + error: armSentence(error) ?? 'The datasource for this object is not available', code: 'ERR_DATASOURCE_UNAVAILABLE', ...(error?.datasource ? { datasource: error.datasource } : {}), ...(error?.kind ? { reason: error.kind } : {}), @@ -953,7 +1005,7 @@ function structuredCodeAnswer( return { status: 400, body: { - error: error?.message ?? 'Validation failed', + error: armSentence(error) ?? 'Validation failed', code: 'VALIDATION_FAILED', fields: Array.isArray(error?.fields) ? error.fields : [], ...(object ? { object } : {}), @@ -971,7 +1023,7 @@ function structuredCodeAnswer( return { status: 403, body: { - error: error?.message ?? 'This capability is disabled for the target object', + error: armSentence(error) ?? 'This capability is disabled for the target object', code: error.code, ...(error?.object || object ? { object: error?.object ?? object } : {}), }, @@ -985,7 +1037,7 @@ function structuredCodeAnswer( return { status: 403, body: { - error: error?.message ?? 'Attachment access denied', + error: armSentence(error) ?? 'Attachment access denied', code: error.code, ...(error?.object || object ? { object: error?.object ?? object } : {}), }, @@ -1003,7 +1055,7 @@ function structuredCodeAnswer( return { status: 403, body: { - error: error?.message ?? 'Record access denied', + error: armSentence(error) ?? 'Record access denied', code: 'RECORD_NOT_ACCESSIBLE', ...(error?.object || object ? { object: error?.object ?? object } : {}), }, @@ -1094,7 +1146,7 @@ function classifyDataError(error: any, object?: string): { status: number; body: return { status: 403, body: { - error: error?.message ?? 'Permission denied', + error: armSentence(error) ?? 'Permission denied', code: 'PERMISSION_DENIED', ...(object ? { object } : {}), },