From 41c657ba01d96007041304f03d61af991d9e8f85 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:55:39 +0000 Subject: [PATCH 1/5] test(metadata-protocol): pin a sandboxed hook refusal on the batch row sink (red before the fix) Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude --- ...protocol.batch-row-sandbox-refusal.test.ts | 379 ++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 packages/metadata-protocol/src/protocol.batch-row-sandbox-refusal.test.ts diff --git a/packages/metadata-protocol/src/protocol.batch-row-sandbox-refusal.test.ts b/packages/metadata-protocol/src/protocol.batch-row-sandbox-refusal.test.ts new file mode 100644 index 00000000000..539dbe3d298 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.batch-row-sandbox-refusal.test.ts @@ -0,0 +1,379 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24561] A batch row answers a sandboxed hook's business refusal in the + * hook's own words, with the code every data door gives it. + * + * ## What was measured broken + * + * On a live boot (18.0 release QA, subject `5b14b7f3`), a sandboxed + * `beforeInsert` hook body throwing `new Error('QA-B: locked codes cannot be + * created.')`: + * + * - `POST /api/v1/data/:object`, `/createMany` and `/import` answered the + * sentence (`400 VALIDATION_ERROR` on the first two, the row's `error` on the + * third); + * - `POST /api/v1/data/:object/batch` answered `200` with the row + * `{ code: 'INTERNAL_ERROR', message: 'The create of this record failed. The + * reason is in the server log.' }`, and the server logged the withhold. + * + * The row sink (`toRowApiError` / `clientFacingRowFailureText`) decided + * disclosure only from a declared 4xx, and a sandboxed body's bare `throw` + * declares none. So it was withheld as if it were a driver fault. It is not + * one: the QuickJS runner puts the body's sentence on `.innerMessage` for the + * caller and keeps the `hook '' threw: …` wrapper on `.message` for the + * log. Every sibling door reads that sentence through `sandboxBusinessMessage` + * (`@objectstack/types`), and the row now makes the same read. + * + * ## Why the pin lives here + * + * `packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts` + * lists `/batch` as answering the sentence, but its double makes + * `protocol.batchData` reject as a whole, so the per-row catch never runs + * there. This file drives the REAL per-row catches of all three loops that + * share the sink (`batchData`, `updateManyData`, `deleteManyData`). + * + * ## The doubles + * + * The thrown values are the shapes `runtime/src/sandbox/quickjs-runner.ts` + * produces (`SandboxError`: `.message` the wrapper, `.innerMessage` the + * sentence, `status` / `code` / `fields` only when the body declared them), + * built here because `@objectstack/runtime` depends on this package and the + * import would close a cycle. They are the same doubles the import-runner and + * REST ledger pins use. §6 runs the PRODUCTION recognisers and the data door's + * own classification (`mapDataError`) over each one, so a double that stopped + * being the shape it stands for turns this file red rather than passing for + * the wrong reason. + */ + +import { describe, it, expect, vi, type MockInstance } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { mapDataError, sandboxBusinessMessage, isSandboxOwnFault } from '@objectstack/types'; +import { BatchOperationResultSchema, standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const OBJECT = 'showcase_qa_uniq'; +const SENTENCE = 'QA-B: locked codes cannot be created.'; +const WRAPPER = `hook 'qa_lock_codes' threw: Error: ${SENTENCE}`; +/** Any trace of the runner's debug wrapper. */ +const WRAPPER_RE = /threw:|hook '/; + +const SCHEMA = { + name: OBJECT, + fields: { code: { name: 'code', type: 'text' } }, +}; + +/** The rows' withheld sentences, one per verb (`rowOperationFailureFallback`). */ +const WITHHELD = { + create: 'The create of this record failed. The reason is in the server log.', + update: 'The update of this record failed. The reason is in the server log.', + upsert: 'The upsert of this record failed. The reason is in the server log.', + delete: 'The delete of this record failed. The reason is in the server log.', +}; + +const WITHHOLD_PREFIX = "[Protocol] Withheld a caught error's text from a batch row"; + +// ─── The producer's shapes ─────────────────────────────────────────────────── + +/** A body's deliberate `throw`: the sentence on `.innerMessage`, anything it declared beside it. */ +function sandboxRefusal(extra: Record = {}): Error { + const err = new Error(WRAPPER); + err.name = 'SandboxError'; + return Object.assign(err, { innerMessage: SENTENCE }, extra); +} + +/** A body that CRASHED: the runner puts the native error text on `.innerMessage`. */ +function sandboxCrash(extra: Record = {}): Error { + const err = new Error("hook 'qa_lock_codes' threw: TypeError: boom"); + err.name = 'SandboxError'; + return Object.assign(err, { innerMessage: 'TypeError: boom' }, extra); +} + +/** A refusal that declared a server-band status: the doors answer it as a fault. */ +function sandboxDeclared5xx(): Error { + const err = new Error("hook 'qa_lock_codes' threw: Error: The ledger service is down."); + err.name = 'SandboxError'; + return Object.assign(err, { innerMessage: 'The ledger service is down.', status: 503, code: 'SERVICE_UNAVAILABLE' }); +} + +/** The runner's OWN fault (a budget, a denied capability): no `.innerMessage` at all. */ +function sandboxOwnFault(): Error { + const err = new Error("hook 'qa_lock_codes' exceeded CPU budget of 50ms (after 3 pump iterations)"); + err.name = 'SandboxError'; + return err; +} + +/** A driver fault, the population the withhold exists for. */ +function driverFault(): Error { + return Object.assign(new Error(`SQLITE_ERROR: no such table: ${OBJECT}`), { code: 'SQLITE_ERROR' }); +} + +// ─── Harness ───────────────────────────────────────────────────────────────── + +/** The engine every case drives; `throwOn` decides which call fails and with what. */ +function makeEngine(throwOn: (verb: string, id: unknown) => unknown | undefined) { + const rows = new Map([ + ['r1', { id: 'r1', code: 'A-1' }], + ['r2', { id: 'r2', code: 'A-2' }], + ]); + const handle = { id: 'trx-1' }; + const engine: any = { + registry: { getObject: (n: string) => (n === OBJECT ? SCHEMA : undefined) }, + findOne: vi.fn(async (_o: string, opts?: any) => { assertEngineFindOnePredicate(_o, opts); return rows.get(opts?.where?.id) ?? null; }), + insert: vi.fn(async (_o: string, data: any) => { + const boom = throwOn('insert', data?.id); + if (boom) throw boom; + const rec = { id: data.id ?? `new-${rows.size + 1}`, ...data }; + rows.set(rec.id, rec); + return rec; + }), + update: vi.fn(async (_o: string, data: any, opts?: any) => { + assertEngineUpdateDispatch(data, opts); + const id = opts?.where?.id; + const boom = throwOn('update', id); + if (boom) throw boom; + const next = { ...rows.get(id), ...data }; + rows.set(id, next); + return next; + }), + delete: vi.fn(async (_o: string, opts?: any) => { + assertEngineDeleteDispatch(opts); + const id = opts?.where?.id; + const boom = throwOn('delete', id); + if (boom) throw boom; + if (!rows.has(id)) return false; + rows.delete(id); + return { deleted: 1 }; + }), + getDefaultDriverName: () => 'default', + getDriverByName: () => ({ beginTransaction: async () => handle }), + transaction: vi.fn(async (cb: (ctx: any) => Promise, base?: any) => { + const snapshot = new Map(rows); + try { + return await cb({ ...(base ?? {}), transaction: handle }); + } catch (err) { + rows.clear(); + for (const [k, v] of snapshot) rows.set(k, v); + throw err; + } + }), + }; + return { protocol: new ObjectStackProtocolImplementation(engine) as any }; +} + +function captureWarn(): MockInstance { + return vi.spyOn(console, 'warn').mockImplementation(() => {}); +} + +/** The sink's withhold lines only, so an unrelated warning cannot satisfy or break a count. */ +function withholdCalls(warn: MockInstance): unknown[][] { + return warn.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].startsWith(WITHHOLD_PREFIX)); +} + +/** One failed create through `POST /data/:object/batch`'s protocol method — the card's request. */ +async function batchCreate(error: Error): Promise { + const { protocol } = makeEngine((verb) => (verb === 'insert' ? error : undefined)); + return protocol.batchData({ object: OBJECT, request: { operation: 'create', records: [{ data: { code: 'LOCK-9' } }] } }); +} + +// ─── §1 ────────────────────────────────────────────────────────────────────── + +describe('[#24561] §1 the card: a sandboxed refusal on a batch create reads as the hook\'s sentence', () => { + it('the row carries the sentence and the 400 floor code, and no withhold warning is written', async () => { + const warn = captureWarn(); + try { + const res = await batchCreate(sandboxRefusal()); + + expect(res.results[0].success).toBe(false); + expect(res.results[0].errors).toEqual([{ code: 'VALIDATION_ERROR', message: SENTENCE }]); + // No `httpStatus`: the body declared none, and the limb reports only a + // declared status (#8570). The row's shape is what it was. + expect(res.results[0].errors[0]).not.toHaveProperty('httpStatus'); + expect(() => BatchOperationResultSchema.parse(res.results[0])).not.toThrow(); + expect(JSON.stringify(res)).not.toMatch(WRAPPER_RE); + expect(withholdCalls(warn)).toHaveLength(0); + } finally { + warn.mockRestore(); + } + }); + + it('the row agrees with the data door: the same sentence, and the code the door\'s status names', async () => { + const refusal = sandboxRefusal(); + const door = mapDataError(refusal, OBJECT); + const res = await batchCreate(refusal); + + expect(door.status).toBe(400); + expect(res.results[0].errors[0].message).toBe(door.body.error); + expect(res.results[0].errors[0].code).toBe(standardErrorCodeForHttpStatus(door.status)); + }); +}); + +// ─── §2 ────────────────────────────────────────────────────────────────────── + +describe('[#24561] §2 every loop that shares the row sink answers the same way', () => { + const refusing = () => makeEngine(() => sandboxRefusal()).protocol; + const cases: Array<[string, (p: any) => Promise]> = [ + ['batchData create', (p) => p.batchData({ object: OBJECT, request: { operation: 'create', records: [{ data: { code: 'LOCK-9' } }] } })], + ['batchData update', (p) => p.batchData({ object: OBJECT, request: { operation: 'update', records: [{ id: 'r1', data: { code: 'LOCK-9' } }] } })], + ['batchData upsert', (p) => p.batchData({ object: OBJECT, request: { operation: 'upsert', records: [{ id: 'r1', data: { code: 'LOCK-9' } }] } })], + ['batchData delete', (p) => p.batchData({ object: OBJECT, request: { operation: 'delete', records: [{ id: 'r1' }] } })], + ['updateManyData', (p) => p.updateManyData({ object: OBJECT, records: [{ id: 'r1', data: { code: 'LOCK-9' } }] })], + ['deleteManyData', (p) => p.deleteManyData({ object: OBJECT, ids: ['r1'] })], + ]; + + it.each(cases)('%s', async (_label, run) => { + const warn = captureWarn(); + try { + const res = await run(refusing()); + expect(res.results[0].errors).toEqual([{ code: 'VALIDATION_ERROR', message: SENTENCE }]); + expect(JSON.stringify(res)).not.toMatch(WRAPPER_RE); + expect(withholdCalls(warn)).toHaveLength(0); + } finally { + warn.mockRestore(); + } + }); +}); + +// ─── §3 ────────────────────────────────────────────────────────────────────── + +describe('[#24561] §3 a refusal that declared its own code or status keeps them, with the sentence instead of the wrapper', () => { + it('a declared 409 and registered code: the code and status ride, the sentence replaces the wrapper', async () => { + const refusal = sandboxRefusal({ code: 'RECORD_LOCKED', status: 409 }); + const res = await batchCreate(refusal); + + expect(res.results[0].errors).toEqual([{ code: 'RECORD_LOCKED', message: SENTENCE, httpStatus: 409 }]); + expect(JSON.stringify(res)).not.toMatch(WRAPPER_RE); + const door = mapDataError(refusal, OBJECT); + expect(door.status).toBe(409); + expect(door.body.error).toBe(SENTENCE); + }); + + it('a body that threw VALIDATION_FAILED with fields: 400 VALIDATION_FAILED, in the body\'s words', async () => { + const refusal = sandboxRefusal({ code: 'VALIDATION_FAILED', fields: [{ field: 'code', code: 'pattern', message: SENTENCE }] }); + const res = await batchCreate(refusal); + + expect(res.results[0].errors).toEqual([{ code: 'VALIDATION_FAILED', message: SENTENCE, httpStatus: 400 }]); + expect(JSON.stringify(res)).not.toMatch(WRAPPER_RE); + expect(mapDataError(refusal, OBJECT).body.error).toBe(SENTENCE); + }); + + it('an UNREGISTERED code with no status: the code is the floor of the 400 the door serves', async () => { + const refusal = sandboxRefusal({ code: 'QA_LOCKED_CODE' }); + const res = await batchCreate(refusal); + + expect(res.results[0].errors).toEqual([{ code: 'VALIDATION_ERROR', message: SENTENCE }]); + expect(mapDataError(refusal, OBJECT).status).toBe(400); + expect(() => BatchOperationResultSchema.parse(res.results[0])).not.toThrow(); + }); +}); + +// ─── §4 ────────────────────────────────────────────────────────────────────── + +describe('[#24561] §4 the boundary does not widen: what the doors answer as a fault stays withheld, and warns', () => { + const faults: Array<[string, () => Error, RegExp, { code: string; httpStatus?: number }]> = [ + ['a body that CRASHED', () => sandboxCrash(), /TypeError|boom/, { code: 'INTERNAL_ERROR' }], + ['a refusal that declared a 5xx', sandboxDeclared5xx, /ledger service/, { code: 'SERVICE_UNAVAILABLE', httpStatus: 503 }], + ["the sandbox's OWN fault", sandboxOwnFault, /CPU budget/, { code: 'INTERNAL_ERROR' }], + ['a driver fault (no sandbox at all)', driverFault, /SQLITE_ERROR|no such table/, { code: 'INTERNAL_ERROR' }], + ]; + + it.each(faults)('%s: the fallback sentence, its code unchanged, one withhold warning with the original error', async (_label, make, leak, wire) => { + const warn = captureWarn(); + try { + const error = make(); + const res = await batchCreate(error); + + expect(res.results[0].errors).toEqual([{ ...wire, message: WITHHELD.create }]); + expect(JSON.stringify(res)).not.toMatch(leak); + expect(JSON.stringify(res)).not.toMatch(WRAPPER_RE); + const calls = withholdCalls(warn); + expect(calls).toHaveLength(1); + expect(calls[0][1]).toBe(error); + } finally { + warn.mockRestore(); + } + }); + + it('a CRASH that declared a 4xx is still a crash: its wrapper is withheld, as every door withholds it', async () => { + // Before this card the declared 400 sent this row down the 4xx branch, + // which quoted `.message` — the runner's wrapper with the native error + // text in it — where every data door answers `500` (a crash is a fault + // whatever it declares). Only the text is decided here; see the PR's + // acceptance notes for the row's `code` / `httpStatus`. + const warn = captureWarn(); + try { + const crash = sandboxCrash({ status: 400 }); + const res = await batchCreate(crash); + + expect(res.results[0].errors[0].message).toBe(WITHHELD.create); + expect(JSON.stringify(res)).not.toMatch(/TypeError|boom/); + expect(JSON.stringify(res)).not.toMatch(WRAPPER_RE); + expect(withholdCalls(warn)).toHaveLength(1); + expect(mapDataError(crash, OBJECT).status).toBe(500); + } finally { + warn.mockRestore(); + } + }); + + it('control: the SAME sentence thrown in-process (no sandbox) is still withheld — the read keys on the producer, never the text', async () => { + const warn = captureWarn(); + try { + const res = await batchCreate(new Error(SENTENCE)); + + expect(res.results[0].errors).toEqual([{ code: 'INTERNAL_ERROR', message: WITHHELD.create }]); + expect(withholdCalls(warn)).toHaveLength(1); + } finally { + warn.mockRestore(); + } + }); +}); + +// ─── §5 ────────────────────────────────────────────────────────────────────── + +describe('[#24561] §5 the rows that quote the causal row carry the sentence, never the wrapper', () => { + it('NOT_ATTEMPTED (a stopped run) and ROLLED_BACK (an atomic one)', async () => { + const stopped = makeEngine((verb, id) => (verb === 'update' && id === 'r1' ? sandboxRefusal() : undefined)); + const stoppedRes = await stopped.protocol.updateManyData({ + object: OBJECT, + records: [{ id: 'r1', data: { code: 'LOCK-9' } }, { id: 'r2', data: { code: 'B-2' } }], + }); + expect(stoppedRes.results[0].errors[0].message).toBe(SENTENCE); + expect(stoppedRes.results[1].errors[0].code).toBe('NOT_ATTEMPTED'); + expect(stoppedRes.results[1].errors[0].message).toContain(SENTENCE); + expect(JSON.stringify(stoppedRes)).not.toMatch(WRAPPER_RE); + + const atomic = makeEngine((verb, id) => (verb === 'update' && id === 'r2' ? sandboxRefusal() : undefined)); + const atomicRes = await atomic.protocol.updateManyData({ + object: OBJECT, + records: [{ id: 'r1', data: { code: 'B-1' } }, { id: 'r2', data: { code: 'LOCK-9' } }], + options: { atomic: true }, + }); + expect(atomicRes.results[0].errors[0].code).toBe('ROLLED_BACK'); + expect(atomicRes.results[0].errors[0].message).toContain(SENTENCE); + expect(JSON.stringify(atomicRes)).not.toMatch(WRAPPER_RE); + }); +}); + +// ─── §6 ────────────────────────────────────────────────────────────────────── + +describe('[#24561] §6 anti-vacuity: each double is classified as the producer it stands for', () => { + it('the shared read recognises the refusal and declines the crash and the runner\'s own fault', () => { + expect(sandboxBusinessMessage(sandboxRefusal())).toBe(SENTENCE); + expect(sandboxBusinessMessage(sandboxCrash())).toBeUndefined(); + expect(sandboxBusinessMessage(sandboxOwnFault())).toBeUndefined(); + expect(isSandboxOwnFault(sandboxOwnFault())).toBe(true); + expect(isSandboxOwnFault(sandboxRefusal())).toBe(false); + // The declared-5xx refusal IS read as a sentence; only the status band + // keeps it withheld, so §4's row for it tests the gate, not the read. + expect(sandboxBusinessMessage(sandboxDeclared5xx())).toBe('The ledger service is down.'); + }); + + it('the data door answers each double the way §1–§4 hold the row to', () => { + expect(mapDataError(sandboxRefusal(), OBJECT).status).toBe(400); + expect(mapDataError(sandboxCrash(), OBJECT).status).toBe(500); + expect(mapDataError(sandboxCrash({ status: 400 }), OBJECT).status).toBe(500); + expect(mapDataError(sandboxOwnFault(), OBJECT).status).toBe(500); + expect(mapDataError(sandboxDeclared5xx(), OBJECT).status).toBe(503); + expect(standardErrorCodeForHttpStatus(400)).toBe('VALIDATION_ERROR'); + }); +}); From de0660dab90ebd85f16bdd09fa969902306f0acd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:00:05 +0000 Subject: [PATCH 2/5] fix(metadata-protocol): a batch row answers a sandboxed hook's refusal in the hook's words The batch row sink withheld a sandboxed hook body's bare throw as a server fault (INTERNAL_ERROR, "the reason is in the server log"), while /data, /createMany and /import answer the body's sentence as a 400 refusal. The row now reads the sentence through sandboxBusinessMessage first, takes the 400 floor code the doors serve, and never quotes a sandboxed error's .message (the runner's debug wrapper). Crashes, the sandbox's own faults, declared-5xx refusals and non-sandbox faults stay withheld, with the warning. Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 72 ++++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 73903acee29..8521061eda8 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -12,7 +12,7 @@ import { type AnonymousFlowDoorCandidate, } from '@objectstack/spec/api'; import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError, relationConditionSites } from '@objectstack/core'; -import { declaredUserMessage, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types'; +import { declaredHttpStatus, declaredUserMessage, isSandboxOrigin, isSandboxOwnFault, resolveTenancyPosture, resolveThrownHttpError, sandboxBusinessMessage } from '@objectstack/types'; // [#6285] ADR-0105 D1's authority on "does this deployment wall organizations?". // `resolveMultiOrgEnabled()` is DEMOTED and its own doc comment says answering // this question with it is a bug (cloud#1020, #5233) — so the posture, and only @@ -3630,6 +3630,21 @@ type BatchDataRowResult = BatchOperationResult; * boundary. And a producer that merely SPEAKS `DUPLICATE_RECORD` — a hook * throwing the registered code without being the engine's class — keeps its * own code on the row, the same discrimination the whole-request arm makes. + * + * ## [#24561] A sandboxed body's refusal is not a server fault in row form + * + * The `INTERNAL_ERROR` default assumes an error that declared nothing is a + * fault. A sandboxed hook body's bare `throw new Error('…')` declares no status + * or code either, yet every data door (`/data`, `/createMany`, `/import`) + * serves it as a `400` refusal in the body's words, so a batch row said + * `INTERNAL_ERROR` beside the doors' `VALIDATION_ERROR` for the same refusal. + * {@link sandboxRefusalSentence} is the one test both limbs ask: when it + * answers, the `code` limb derives from the `400` the doors serve (the + * `declared ?? 400` of `classifyDataError`, `@objectstack/types`). A code or + * status the body DID declare still wins, as for every other producer. + * + * ⛔ `httpStatus` is NOT stamped for it. That limb carries only a status the + * producer declared (above), and this body declared none. */ function toRowApiError(err: any, fallback: string): ApiError { const thrown = isEngineDuplicateRecordEnvelope(err) @@ -3638,8 +3653,9 @@ function toRowApiError(err: any, fallback: string): ApiError { ? (err.code as ApiError['code']) : undefined; const { declaredStatus } = resolveThrownHttpError(err); + const servedStatus = declaredStatus ?? (sandboxRefusalSentence(err) !== undefined ? 400 : undefined); return { - code: thrown ?? (declaredStatus !== undefined ? standardErrorCodeForHttpStatus(declaredStatus) : 'INTERNAL_ERROR'), + code: thrown ?? (servedStatus !== undefined ? standardErrorCodeForHttpStatus(servedStatus) : 'INTERNAL_ERROR'), message: clientFacingRowFailureText(err, fallback), ...(declaredStatus !== undefined ? { httpStatus: declaredStatus } : {}), }; @@ -3868,6 +3884,29 @@ export function clientFacingFailureText(err: unknown, fallback: string): string return fallback; } +/** + * [#24561] The sentence a sandboxed hook body's business refusal says on a + * batch row, or `undefined` when this error is not one. + * + * The read is `sandboxBusinessMessage` (`@objectstack/types`), the one every + * sibling door makes (the import runner, `/data`, `/createMany`), never a + * pattern-strip of the wrapper off `.message`. It already declines a body + * that CRASHED and the sandbox's own fault. The one condition added here is + * the doors' own: a refusal that declared a server-band status is answered as + * a fault with its text withheld (`classifyDataError`'s `declared < 500`, read + * through the same `declaredHttpStatus`), so it is not a sentence for a row + * either. + * + * Both limbs of {@link toRowApiError} ask this, so the row's `code` and + * `message` cannot disagree about whether a refusal was disclosed. + */ +function sandboxRefusalSentence(err: unknown): string | undefined { + const sentence = sandboxBusinessMessage(err); + if (sentence === undefined) return undefined; + const declared = declaredHttpStatus(err); + return declared === undefined || declared < 500 ? sentence : undefined; +} + /** * [#8502] The per-row `errors[].message` a bulk data write puts on its * response — the producer's own refusal when it declared one, otherwise a @@ -3969,18 +4008,43 @@ export function clientFacingFailureText(err: unknown, fallback: string): string * this function owes is one-directional and is pinned in BOTH directions by * that integration file: it logs when it WITHHOLDS, and is silent when it * does not. + * + * ## [#24561] A sandboxed body's refusal is read the way every sibling door reads it + * + * The undeclared hook above is an IN-PROCESS hook. A sandboxed (QuickJS) hook + * body is a different producer: its runner puts the body's own sentence on + * `SandboxError.innerMessage` for the caller and keeps the + * `hook '' threw: …` debug wrapper on `.message` for the log. `/data`, + * `/createMany` and `/import` answer that sentence; this sink withheld it as a + * fault, so `POST /data/:object/batch` alone said "the reason is in the server + * log" for a refusal the hook wrote for the caller. The sentence is now read + * through {@link sandboxRefusalSentence} FIRST, which is + * `sandboxBusinessMessage` (`@objectstack/types`), the import runner's read. + * + * And a sandboxed error's `.message` is never quoted, whatever it declared. + * It is the wrapper, written for the log: a refusal that declared a 4xx used + * to be quoted from there, wrapper and all, and so did a body that CRASHED + * with a declared 4xx, which every data door answers `500` with its text + * withheld. "Sandboxed" is the import runner's test (a sandbox origin or the + * sandbox's own fault). What stays withheld is what the doors withhold: a + * crash, the runner's own fault, a refusal that declared a 5xx, and every + * non-sandbox error that declared no 4xx. */ function clientFacingRowFailureText(err: unknown, fallback: string): string { + const sentence = sandboxRefusalSentence(err); + if (sentence !== undefined) return sentence; // 500 as the fallback status: an error that declared nothing is a server // fault in row form, exactly what `toRowApiError`'s own code limb assumes. const { status } = resolveThrownHttpError(err, 500); - if (status >= 400 && status < 500) { + const sandboxed = isSandboxOrigin(err) || isSandboxOwnFault(err); + if (status >= 400 && status < 500 && !sandboxed) { const declared = (err as { message?: unknown } | null | undefined)?.message; if (typeof declared === 'string' && declared.length > 0) return declared; } console.warn( '[Protocol] Withheld a caught error\'s text from a batch row: the producer declared no ' - + 'client-facing refusal (no 4xx status or statusCode, and not the VALIDATION_FAILED shape), so its ' + + 'client-facing refusal (no 4xx status or statusCode, not the VALIDATION_FAILED shape, and not a ' + + 'sandboxed body\'s business refusal), so its ' + 'sentence must not be quoted back on response data. The row says: ' + `"${fallback}" — cause (withheld from the response):`, err, From 57f72ea62c1d6f244624e6ee5c57f93ba3ccdef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:08:23 +0000 Subject: [PATCH 3/5] refactor(metadata-protocol): guard the row's 4xx quote on a sandbox origin only The runner's own faults never declare a status, so the isSandboxOwnFault half of the guard could not be reached. Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8521061eda8..39376b1a261 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -12,7 +12,7 @@ import { type AnonymousFlowDoorCandidate, } from '@objectstack/spec/api'; import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError, relationConditionSites } from '@objectstack/core'; -import { declaredHttpStatus, declaredUserMessage, isSandboxOrigin, isSandboxOwnFault, resolveTenancyPosture, resolveThrownHttpError, sandboxBusinessMessage } from '@objectstack/types'; +import { declaredHttpStatus, declaredUserMessage, isSandboxOrigin, resolveTenancyPosture, resolveThrownHttpError, sandboxBusinessMessage } from '@objectstack/types'; // [#6285] ADR-0105 D1's authority on "does this deployment wall organizations?". // `resolveMultiOrgEnabled()` is DEMOTED and its own doc comment says answering // this question with it is a bug (cloud#1020, #5233) — so the posture, and only @@ -4021,13 +4021,13 @@ function sandboxRefusalSentence(err: unknown): string | undefined { * through {@link sandboxRefusalSentence} FIRST, which is * `sandboxBusinessMessage` (`@objectstack/types`), the import runner's read. * - * And a sandboxed error's `.message` is never quoted, whatever it declared. + * And a sandbox origin's `.message` is never quoted, whatever it declared. * It is the wrapper, written for the log: a refusal that declared a 4xx used * to be quoted from there, wrapper and all, and so did a body that CRASHED * with a declared 4xx, which every data door answers `500` with its text - * withheld. "Sandboxed" is the import runner's test (a sandbox origin or the - * sandbox's own fault). What stays withheld is what the doors withhold: a - * crash, the runner's own fault, a refusal that declared a 5xx, and every + * withheld. (The runner's own faults never declare a status, so the 4xx + * branch cannot reach them.) What stays withheld is what the doors withhold: + * a crash, the runner's own fault, a refusal that declared a 5xx, and every * non-sandbox error that declared no 4xx. */ function clientFacingRowFailureText(err: unknown, fallback: string): string { @@ -4036,8 +4036,7 @@ function clientFacingRowFailureText(err: unknown, fallback: string): string { // 500 as the fallback status: an error that declared nothing is a server // fault in row form, exactly what `toRowApiError`'s own code limb assumes. const { status } = resolveThrownHttpError(err, 500); - const sandboxed = isSandboxOrigin(err) || isSandboxOwnFault(err); - if (status >= 400 && status < 500 && !sandboxed) { + if (status >= 400 && status < 500 && !isSandboxOrigin(err)) { const declared = (err as { message?: unknown } | null | undefined)?.message; if (typeof declared === 'string' && declared.length > 0) return declared; } From ce357ab00e1bdffd52896097215c27ac2d17b817 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:09:29 +0000 Subject: [PATCH 4/5] chore(changeset): metadata-protocol patch for the batch row's sandboxed refusal sentence Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude --- .changeset/24561-batch-hook-refusal-sentence.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/24561-batch-hook-refusal-sentence.md diff --git a/.changeset/24561-batch-hook-refusal-sentence.md b/.changeset/24561-batch-hook-refusal-sentence.md new file mode 100644 index 00000000000..2a2aef0c482 --- /dev/null +++ b/.changeset/24561-batch-hook-refusal-sentence.md @@ -0,0 +1,11 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): a batch row answers a sandboxed hook's refusal in the hook's own words + +Clause-②: no (no accept set or route moves, and a row keeps its shape: a sandboxed hook's refusal now carries the sentence and code the other data doors already answer) + +- **A sandboxed hook's refusal reads as its sentence on a batch row.** When a sandboxed hook body refused a row with `throw new Error('…')`, `POST /api/v1/data/:object/batch` answered that row with `code: 'INTERNAL_ERROR'` and `The create of this record failed. The reason is in the server log.` `POST /data/:object`, `/createMany` and `/import` answer the hook's sentence. The row now carries that sentence and `code: 'VALIDATION_ERROR'`, the code those doors give a refusal that declared no code or status. Rows of `updateMany` and `deleteMany` answer the same way. The row still has no `httpStatus`, because the hook declared none. +- **A code or status the hook declared still rides.** For a refusal that declared a 4xx status, the row used to quote the `hook 'NAME' threw: Error: …` debug wrapper. It now quotes the hook's sentence. +- **Unchanged:** a hook body that crashed, a refusal that declared a 5xx, the sandbox's own faults (a time or CPU budget, a denied capability) and driver errors keep the withheld sentence and the server-log warning. A crashed body that declared a 4xx is now withheld as well. Before, the row quoted its wrapper with the native error text. From 70c3eb8418ce782ddbb87b969849cce4275586d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:28:37 +0000 Subject: [PATCH 5/5] chore(scripts): record the new batch-row sandbox-refusal pin's engine doubles in the pinned ledger Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 1bf23ff7c5a..f4e229ab485 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -376,6 +376,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.batch-row-sandbox-refusal.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.batch-row-sandbox-refusal.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.batch-row-sandbox-refusal.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.batch-verb-driver-code.test.ts", "verb": "delete",