diff --git a/.changeset/26031-not-null-drift-every-door.md b/.changeset/26031-not-null-drift-every-door.md new file mode 100644 index 00000000000..50b14283b07 --- /dev/null +++ b/.changeset/26031-not-null-drift-every-door.md @@ -0,0 +1,20 @@ +--- +'@objectstack/types': patch +'@objectstack/runtime': patch +--- + +fix(runtime,types)!: a NOT NULL refusal on a column no field declares answers `RestServer`'s `400 VALIDATION_FAILED` body at the dispatcher `/data` and actions doors too + +Since #24768 the engine refuses a NOT NULL violation on a field the object declares as its own `ValidationError`, which every door answers alike. A column no field declares (drift: the field left the metadata and its NOT NULL column stayed in the table) still leaves the engine as the driver threw it, and the doors answered that refusal three ways. `RestServer`'s `POST /data/:object` answered `mapDataError`'s NOT NULL arm: `400 VALIDATION_FAILED`, the column's sentence, its `required` field entry and the drift `hint`. The runtime dispatcher's `POST /data/:object` let it reach the Hono exits, which answered `500 INTERNAL_ERROR`. The actions door answered `400 VALIDATION_ERROR` with the driver's text (the physical table and column) as the message and the driver's code as `declaredCode`. + +Clause-②: yes (widening: `@objectstack/types` adds the export `driverNotNullRefusalAnswer`) +Metadata-protocol: none + + + +**BREAKING** (a wire-answer change), shipped as `patch`: no metadata protocol kind moves. + +- **`@objectstack/types`.** New export `driverNotNullRefusalAnswer(error, object?)`: `mapDataError`'s NOT NULL arm answer (`{ status, body }`) when classification answers the error with that arm, `undefined` for every other answer. Every arm ahead of it (a declared status, a sandboxed body's throw, a unique violation, an unknown column, the record validator's `ValidationError`) still decides first. `mapDataError`'s own answers are unchanged. +- **`@objectstack/runtime`.** The dispatcher's `/data/:object` routes answer that refusal themselves with `400`, `error.code: 'VALIDATION_FAILED'`, the column's sentence as `error.message`, and `fields`, `hint` and `object` in `error.details` (was a thrown `500 INTERNAL_ERROR`). The actions door answers the same body (was `400 VALIDATION_ERROR` with the driver's text and `declaredCode`). The driver's text and code no longer reach either wire. +- A `PATCH` cannot reach such a column: naming it is refused before the driver as an undeclared field (`400 INVALID_FIELD`), on both `/data` doors alike, as before. +- The answers for a field the object declares are unchanged. diff --git a/packages/runtime/src/dispatcher-not-null-drift-26031.test.ts b/packages/runtime/src/dispatcher-not-null-drift-26031.test.ts new file mode 100644 index 00000000000..6b510f65b38 --- /dev/null +++ b/packages/runtime/src/dispatcher-not-null-drift-26031.test.ts @@ -0,0 +1,239 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26031] A database NOT NULL refusal on a column the object does NOT declare + * (drift: the column stayed in the table after its field left the metadata) + * answers one body on every door: `RestServer`'s, `400 VALIDATION_FAILED`, the + * column named, and the drift `hint`. + * + * ## What was wrong + * + * #24768 (ruling A) moved a refusal on a DECLARED field into the engine, which + * now refuses it as its own `ValidationError`. A column no field declares has + * no field to name, so the engine lets the driver's error out, and each door + * classified it its own way. Measured on the rig below, at `origin/main` + * `40bba68c` (which carries #26026): + * + * - `RestServer` `POST /data/:object`: `mapDataError`'s NOT NULL arm, + * `400 VALIDATION_FAILED` with the column sentence and the drift `hint`; + * - the dispatcher's `POST /data/:object`: the raw `SqliteError` left + * `dispatch()`, and the Hono exit (`declaredEnvelopeForThrow`) declined it, + * so the door answered `500 INTERNAL_ERROR`; + * - `POST /actions/:object/:action`, a handler whose engine insert leaves the + * column out: `400 VALIDATION_ERROR`, the driver's text as the `message` + * (the physical table and column) and `declaredCode: + * 'SQLITE_CONSTRAINT_NOTNULL'`. + * + * Both dispatcher exits now ask the arm itself (`driverNotNullRefusalAnswer`, + * `@objectstack/types`), so the body is `RestServer`'s, read off one + * classification, in this transport's envelope: the sentence as `message`, the + * code as `code`, and the arm's `fields`, `hint` and `object` in `details`. + * + * ## `PATCH` cannot reach the column + * + * A `PATCH` naming the dropped field is refused before any driver runs, as an + * undeclared field (`400 INVALID_FIELD`), on both `/data` doors alike, and so + * is a `beforeUpdate` hook writing it; an update that leaves the column alone + * keeps the row's stored value, which is not null. So the `PATCH` case below is + * the control: one answer on both doors, unchanged. + * + * ## The rig + * + * A REAL store: better-sqlite3 `:memory:` under `driver-sql`, the real + * `ObjectQL` engine and protocol. The object is registered with the column, + * the table is built, and the object is registered again without it: the + * metadata the doors serve has drifted from the table. The dispatcher is + * reached the way `@objectstack/hono`'s catch-all reaches it, `dispatch()` + * over a real Fetch `Request`, with the identity step the one stubbed seam; a + * throw out of `dispatch()` is rendered by the Hono exit's own recogniser. + * `RestServer` is the same stack's REST door, its routes called directly. + * + * Every body is asserted whole. The `hint`'s words are `RestServer`'s, read + * off its answer rather than spelled here. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { declaredEnvelopeForThrow } from '@objectstack/plugin-hono-server'; +import { RestServer } from '@objectstack/rest'; +import { HttpDispatcher } from './http-dispatcher.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const PREFIX = '/api/v1'; +/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ +const TEST_PACKAGE_ID = 'objectstack-test'; +const OBJECT = 'drift_line_26031'; + +/** The metadata the table was built from: `legacy_code` is a NOT NULL column. */ +const BUILT = { + name: OBJECT, + label: 'Drift line', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + legacy_code: { name: 'legacy_code', type: 'text' as const, label: 'Legacy code', storage: { notNull: true } }, + }, + actions: [ + { name: 'add_line', label: 'Add line', type: 'script', target: 'add_line' }, + ], +}; + +/** The metadata the doors serve afterwards: `legacy_code` is no longer declared. */ +const SERVED = (() => { + const { legacy_code: _dropped, ...fields } = BUILT.fields; + return { ...BUILT, fields }; +})(); + +const MEMBER = { userId: 'u_member', isSystem: false }; + +let engine: ObjectQL; +let dispatcher: HttpDispatcher; +let rest: RestServer; + +afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } +}); + +beforeAll(async () => { + engine = new ObjectQL(); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registry.registerObject(BUILT as any, TEST_PACKAGE_ID); + await engine.syncSchemas(); + // A real `single` install's Default Organization (ADR-0131 D9). + await installDefaultOrganization(engine); + await engine.insert(OBJECT, { id: 'e1', name: 'existing', legacy_code: 'L1' } as any); + engine.registry.registerObject(SERVED as any, TEST_PACKAGE_ID); + (engine as any).registerAction(OBJECT, 'add_line', async (ctx: any) => { + await ctx.engine.insert(OBJECT, { id: 'a1', name: 'from the action' }); + return { written: true }; + }); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const services: Record = { objectql: engine, data: engine, protocol }; + const get = (n: string) => services[n] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + dispatcher = new HttpDispatcher(kernel); + (dispatcher as any).timedResolveExecutionContext = async () => ({ ...MEMBER }); + + const noop = () => {}; + const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; + rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: MEMBER.userId }); + rest.registerRoutes(); +}, 60_000); + +/** + * A request as `@objectstack/hono`'s catch-all hands it to `dispatch()`: the + * answer `dispatch()` returned, or, for a throw, what the Hono exit makes of it + * (`undefined` there is its bare `500`). + */ +async function door(method: string, path: string, body: unknown) { + const request = new Request(`http://localhost${PREFIX}${path}`, { + method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + try { + const res: any = await dispatcher.dispatch(method, path, body, {}, { request } as any, PREFIX); + return { returned: res?.response as { status: number; body: any } | undefined }; + } catch (thrown) { + return { thrown, hono: declaredEnvelopeForThrow(thrown) }; + } +} + +/** A `RestServer` route, called as its host calls it. */ +async function restDoor(method: string, path: string, body: unknown, params: Record) { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + expect(route, `${method} ${path}`).toBeDefined(); + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (json: any) => { res._json = json; return res; }, + }; + await route!.handler({ params, body, query: {}, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; +} + +/** `RestServer`'s answer for the drifted column, asserted whole. */ +async function restAnswer() { + const answer = await restDoor('POST', `${PREFIX}/data/:object`, { id: 'r1', name: 'through rest' }, { object: OBJECT }); + expect(answer.status, JSON.stringify(answer.body)).toBe(400); + expect(answer.body).toEqual({ + error: 'legacy_code is required', + code: 'VALIDATION_FAILED', + fields: [{ field: 'legacy_code', code: 'required', message: 'legacy_code is required' }], + hint: expect.any(String), + object: OBJECT, + }); + return answer.body as Record; +} + +/** The dispatcher envelope carrying `RestServer`'s body: the sentence, the code, the rest in `details`. */ +function dispatcherEnvelopeOf(restBody: Record) { + const { error: message, code, ...details } = restBody; + return { success: false, error: { code, message, httpStatus: 400, details } }; +} + +function expectNoDriverText(body: unknown) { + expect(JSON.stringify(body)).not.toMatch(/constraint failed|SQLITE_|insert into/); +} + +const rowCount = async (id: string) => (await engine.find(OBJECT, { where: { id } })).length; + +describe('[#26031] a NOT NULL refusal on a drifted column answers RestServer\'s body on every door', () => { + it('`RestServer` `POST /data/:object`: the arm\'s body, the reference', async () => { + const body = await restAnswer(); + expectNoDriverText(body); + expect(await rowCount('r1')).toBe(0); + }); + + it('the dispatcher `POST /data/:object`: the same body, returned, not a thrown 500', async () => { + const reference = await restAnswer(); + const answer = await door('POST', `/data/${OBJECT}`, { id: 'n1', name: 'through the dispatcher' }); + expect(answer, JSON.stringify(answer)).toEqual({ + returned: { status: 400, body: dispatcherEnvelopeOf(reference) }, + }); + expectNoDriverText(answer); + expect(await rowCount('n1')).toBe(0); + }); + + it('`POST /actions/:object/:action`, a handler whose insert leaves the column out: the same body', async () => { + const reference = await restAnswer(); + const answer = await door('POST', `/actions/${OBJECT}/add_line/e1`, {}); + expect(answer, JSON.stringify(answer)).toEqual({ + returned: { status: 400, body: dispatcherEnvelopeOf(reference) }, + }); + expectNoDriverText(answer); + expect(await rowCount('a1')).toBe(0); + }); + + it('the control: a `PATCH` naming the dropped field is refused before the driver, alike on both `/data` doors', async () => { + const viaRest = await restDoor('PATCH', `${PREFIX}/data/:object/:id`, { legacy_code: null }, { object: OBJECT, id: 'e1' }); + expect(viaRest).toEqual({ + status: 400, + body: { error: `Unknown field 'legacy_code' on object '${OBJECT}'`, code: 'INVALID_FIELD', field: 'legacy_code', object: OBJECT }, + }); + const viaDispatcher = await door('PATCH', `/data/${OBJECT}/e1`, { legacy_code: null }); + expect((viaDispatcher as { hono?: unknown }).hono).toEqual({ + status: 400, + body: { + success: false, + error: { + code: 'INVALID_FIELD', + message: `Unknown field 'legacy_code' on object '${OBJECT}'`, + details: { field: 'legacy_code', object: OBJECT }, + }, + }, + }); + const stored = await engine.findOne(OBJECT, { where: { id: 'e1' } }) as any; + expect(stored?.name).toBe('existing'); + }); +}); diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index bbfb76f7d4c..a82995ffd99 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -65,7 +65,7 @@ import { // generic replacement text — the same two `@objectstack/rest` composes into // `isScriptFaultMessage` / `UNCLASSIFIED_FAULT`, so this door's crash reading // cannot drift from the `/data` door's. ⛔ Never re-inline either. -import { isNativeErrorName, INTERNAL_ERROR_MESSAGE, sandboxBusinessMessage } from '@objectstack/types'; +import { isNativeErrorName, INTERNAL_ERROR_MESSAGE, sandboxBusinessMessage, driverNotNullRefusalAnswer } from '@objectstack/types'; // [ADR-0029 D9.6] The ONE "does a code package ship this?" reading, which the // activation write below asks to name the ledger row's package. import { isCodeArtifactBody, isTenantAuthored } from '@objectstack/metadata-core'; @@ -1019,6 +1019,24 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string return { handled: true, response: deps.errorFromThrown(err, 500) }; } + // [#26031] A driver's NOT NULL refusal that the engine let out (a + // column no field declares, i.e. drift) is the caller's write refused, + // and `RestServer` answers it `400 VALIDATION_FAILED` with the column's + // `required` entry and the drift hint. This door relayed it through the + // terminal below instead: the driver's text as the message (the + // physical table and column), the status-derived `VALIDATION_ERROR`, + // and the driver's code as `declaredCode`. The body is now the + // classification's own NOT NULL arm (`driverNotNullRefusalAnswer`), + // the one the dispatcher's `/data` exit answers too, in this + // transport's envelope; nothing here reads the driver's text. Asked + // before the fault predicate so a driver class with no string `code` + // is still the refusal it is, never the sanitised 500. + const notNullRefusal = driverNotNullRefusalAnswer(err, objectName); + if (notNullRefusal) { + const { error: refusal, ...details } = notNullRefusal.body; + return { handled: true, response: deps.error(String(refusal), notNullRefusal.status, details) }; + } + // [#3913 follow-up] …and it does not cover an UNEXPECTED FAULT either. // "A failed action is a normal outcome" is a statement about the action // REJECTING — a business rule saying no. A `TypeError` in a handler, a diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 9391716da06..cba679736fb 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -17,7 +17,7 @@ import { AuthzStoreUnavailableError, type AuthGate, } from '@objectstack/core'; -import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, demotedDeclaredCode, declaredUserMessage, inProcessSessionReadInput, isEngineDuplicateRecordEnvelope, structuredCodeAnswer } from '@objectstack/types'; +import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, demotedDeclaredCode, declaredUserMessage, inProcessSessionReadInput, isEngineDuplicateRecordEnvelope, structuredCodeAnswer, driverNotNullRefusalAnswer } from '@objectstack/types'; import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; import { CoreServiceName, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system'; import type { AuthSessionApi, IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; @@ -71,6 +71,7 @@ import { import { permissionDeniedErrorDetails, describeDeniedDiagnostics, + routeObjectFromPath, } from './security/permission-denied-envelope.js'; // randomUUID moved to ./domains/auth.ts with its only consumer (D11③ PR-7). @@ -3167,6 +3168,24 @@ export class HttpDispatcher { ), }; } + // [#26031] A driver's NOT NULL refusal that the engine let out (a + // column no field declares, i.e. drift) is answered HERE, with + // `RestServer`'s body for it. Thrown on, it reached the Hono exits, + // which serve only a throw that declares its status and code, and + // `dispatcher-plugin`'s, which reads the validation shape: both + // answered `500` where `RestServer` answers + // `400 VALIDATION_FAILED`. The body is the classification's own + // NOT NULL arm (`driverNotNullRefusalAnswer`), never a reading of + // the driver's text here, and its object is the route's, as + // `RestServer`'s is. Only the `/data/:object` routes ask: they are + // the routes `RestServer` answers this arm for, and every other + // throw leaves exactly as before. + const routeObject = routeObjectFromPath(cleanPath); + const notNullRefusal = routeObject !== undefined ? driverNotNullRefusalAnswer(e, routeObject) : undefined; + if (notNullRefusal) { + const { error: message, ...details } = notNullRefusal.body; + return { handled: true, response: this.error(String(message), notNullRefusal.status, details) }; + } throw e; } } diff --git a/packages/types/src/data-error-classification.not-null-label.test.ts b/packages/types/src/data-error-classification.not-null-label.test.ts index edfcc2686a8..0eb58061920 100644 --- a/packages/types/src/data-error-classification.not-null-label.test.ts +++ b/packages/types/src/data-error-classification.not-null-label.test.ts @@ -16,7 +16,7 @@ */ import { describe, it, expect } from 'vitest'; -import { mapDataError, notNullViolationSite } from './data-error-classification.js'; +import { mapDataError, notNullViolationSite, driverNotNullRefusalAnswer } from './data-error-classification.js'; const driverError = (message: string, fields: Record) => Object.assign(new Error(message), fields); @@ -96,3 +96,43 @@ describe('[#24715] the classification\'s answer for a column no field declares', expect(drift[0], hint).toMatch(/^If the object declares neither `required` nor `storage: \{ notNull: true \}`/); }); }); + +describe('[#26031] driverNotNullRefusalAnswer — the NOT NULL arm\'s answer, exactly where classification gives it', () => { + it.each([ + ['better-sqlite3', SQLITE], ['libsql', LIBSQL], ['sql.js', SQLJS], + ['Postgres', POSTGRES], ['Postgres, naming no relation', POSTGRES_NO_RELATION], ['MySQL', MYSQL], + ])('%s: mapDataError\'s answer, the same body', (_driver, error) => { + const answer = driverNotNullRefusalAnswer(error, 'line_item'); + expect(answer).toEqual(mapDataError(error, 'line_item')); + expect(answer?.body.code).toBe('VALIDATION_FAILED'); + expect(typeof answer?.body.hint).toBe('string'); + }); + + it('with no object, the body names none, as mapDataError\'s does', () => { + expect(driverNotNullRefusalAnswer(SQLITE)).toEqual(mapDataError(SQLITE)); + expect(driverNotNullRefusalAnswer(SQLITE)?.body).not.toHaveProperty('object'); + }); + + it('every other arm answers undefined, the NOT NULL text in its message notwithstanding', () => { + const text = 'NOT NULL constraint failed: line_item.unit_price'; + // The engine's own refusal on a declared field: the validation arm answers it. + const declared = Object.assign(new Error('Sales Price is required'), { + name: 'ValidationError', code: 'VALIDATION_FAILED', + fields: [{ field: 'unit_price', code: 'required', message: 'Sales Price is required' }], + cause: SQLITE, + }); + // A throw that declares its own status: the declared-status arm answers it. + const declaredStatus = Object.assign(new Error(text), { status: 409, code: 'CONFLICT' }); + // A sandboxed body's deliberate throw: the unwrap answers it. + const sandboxed = Object.assign(new Error(`action 'x' threw: ${text}`), { innerMessage: text }); + // node-postgres's fields with no NOT NULL text: classification never reaches the arm. + for (const error of [declared, declaredStatus, sandboxed, POSTGRES_FIELDS_ONLY]) { + expect(mapDataError(error, 'line_item').body.hint, String(error)).toBeUndefined(); + expect(driverNotNullRefusalAnswer(error, 'line_item'), String(error)).toBeUndefined(); + } + expect(driverNotNullRefusalAnswer(driverError('UNIQUE constraint failed: line_item.email', { code: 'SQLITE_CONSTRAINT_UNIQUE' }))) + .toBeUndefined(); + expect(driverNotNullRefusalAnswer(new Error('connection refused'))).toBeUndefined(); + expect(driverNotNullRefusalAnswer(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/types/src/data-error-classification.ts b/packages/types/src/data-error-classification.ts index 1994553ff50..68e3d40ab2f 100644 --- a/packages/types/src/data-error-classification.ts +++ b/packages/types/src/data-error-classification.ts @@ -1947,16 +1947,11 @@ function classifyDataError(error: any, object?: string): { status: number; body: // label (`notNullViolationSite` below is what it asks). What is left // here is the column no field declares, where the column is all there // is to name, and any driver error a non-engine write lets out. - return { - status: 400, - body: { - error: `${field} is required`, - code: 'VALIDATION_FAILED', - fields: [{ field, code: 'required', message: `${field} is required` }], - hint: notNullRefusalHint(field), - ...(object ? { object } : {}), - }, - }; + // + // [#26031] The dispatcher's doors answer this arm too, by asking for it + // ({@link driverNotNullRefusalAnswer}), so the answer is built where + // that question can recognise it. + return notNullArmAnswer(field, object); } // [#5462] A driver saying "that relation is missing" is an unknown-OBJECT @@ -2203,6 +2198,59 @@ function notNullRefusalHint(field: string): string { + `(or reset the dev database).`; } +/** + * [#26031] The answers {@link classifyDataError}'s NOT NULL arm built, held by + * identity so {@link driverNotNullRefusalAnswer} can tell that arm's answer + * from every other arm's without reading its shape. + */ +const NOT_NULL_ARM_ANSWERS = new WeakSet(); + +/** The NOT NULL arm's answer: the column's sentence, its `required` field entry and the hint. */ +function notNullArmAnswer(field: string, object: string | undefined): { status: number; body: Record } { + const answer = { + status: 400, + body: { + error: `${field} is required`, + code: 'VALIDATION_FAILED', + fields: [{ field, code: 'required', message: `${field} is required` }], + hint: notNullRefusalHint(field), + ...(object ? { object } : {}), + }, + }; + NOT_NULL_ARM_ANSWERS.add(answer); + return answer; +} + +/** + * [#26031] `RestServer`'s answer to a driver's NOT NULL refusal that nothing + * translated, for a door that does not classify the rest of what it catches: + * the NOT NULL arm's `{ status, body }` when classification answers `error` + * with that arm, `undefined` for anything else. + * + * #24768 (ruling A) has the engine refuse a NOT NULL violation on a field the + * object declares as its own `ValidationError`, which every door already + * answers alike. What still leaves the engine as the driver threw it is a + * column no field declares (drift: the field left the metadata and its column + * stayed NOT NULL), and `RestServer` answered that with this arm while the + * dispatcher's `/data` exit answered `500` and its actions door relayed the + * driver's text as a `400 VALIDATION_ERROR`. Those doors ask this, and answer + * the body it returns in their own envelope. + * + * The question is classification's whole answer, not a second reading of the + * driver's text: everything ahead of the arm (a declared status, a sandbox + * wrapper, a unique violation, an unknown column, …) still decides first, so + * this answers exactly where {@link mapDataError} would. The producer's + * `userMessage` (the wrapper's half) is not part of it: no driver error carries + * one. + */ +export function driverNotNullRefusalAnswer( + error: unknown, + object?: string, +): { status: number; body: Record } | undefined { + const answer = classifyDataError(error, object); + return NOT_NULL_ARM_ANSWERS.has(answer) ? answer : undefined; +} + // The members above that are not `export`ed where they are declared, but that // `@objectstack/rest`'s emission half (`error-response.ts`) composes. Listed // here rather than by editing their declarations, so the moved block stays