From ab389b3385498a8a3571054ca1d74c1d0b097bb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:35:00 +0000 Subject: [PATCH 1/2] fix(runtime): the dispatcher answers the engine's DuplicateRecordError as 409 UNIQUE_VIOLATION, with the arm's fields (#23686) HttpDispatcher.errorFromThrown, the one writer the dispatcher domains hand a caught throw to, passed the engine's registered in-process DUPLICATE_RECORD through unchanged. It now decides the code for the engine's class before writing it, keyed exactly as RestServer's arm and the Hono doors key it (isEngineDuplicateRecordEnvelope: registered code AND class name), and carries the REST arm's own fields in details (read off structuredCodeAnswer, agreement-gated). Status stays 409; message and declaredCode unchanged. Pins: the actions door (handler write, declarative update, class with a named field), both activation doors (a ledger write losing a race), and two controls (a producer speaking DUPLICATE_RECORD; DELETE_RESTRICTED). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG --- .../23686-dispatcher-unique-violation.md | 16 + ...patcher-unique-violation-wire-code.test.ts | 301 ++++++++++++++++++ packages/runtime/src/http-dispatcher.ts | 82 ++++- 3 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 .changeset/23686-dispatcher-unique-violation.md create mode 100644 packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts diff --git a/.changeset/23686-dispatcher-unique-violation.md b/.changeset/23686-dispatcher-unique-violation.md new file mode 100644 index 00000000000..70f1f83674d --- /dev/null +++ b/.changeset/23686-dispatcher-unique-violation.md @@ -0,0 +1,16 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): the HTTP dispatcher answers the engine's `DuplicateRecordError` with the wire code `UNIQUE_VIOLATION`, not the engine's in-process `DUPLICATE_RECORD`, with `developerMessage`, `object` and `field` in `error.details` (#23686) + +**Wire change at the dispatcher's doors: `409 DUPLICATE_RECORD` becomes `409 UNIQUE_VIOLATION`.** It applies wherever a dispatcher domain hands a caught engine throw to its shared error writer (`HttpDispatcher`'s thrown-error envelope). Measured: `POST /actions/:object/:action`, for a handler or a declarative `operation: 'update'` action whose write repeats a unique value, and the activation doors `POST /actions/_activation/:object/:action` and `POST /security/_activation/:type/:name`, for a flip that loses a race on the activation ledger's unique key. A client of these doors (`@objectstack/hono`'s `createHonoApp`, or any host serving the dispatcher) that matched `code === 'DUPLICATE_RECORD'` on that refusal must match `code === 'UNIQUE_VIOLATION'` instead. That is what the REST server and both Hono doors already answer for the same refusal. + +Why: the maintainer ruled one wire spelling for a unique-constraint refusal on every route, `UNIQUE_VIOLATION` (recorded in commit 65846bc46). The REST server, the batch and import rows and the Hono doors already reported it. The dispatcher's own writer reported the engine's spelling. + +- FROM `{"success":false,"error":{"code":"DUPLICATE_RECORD","message":"Duplicate record refused on 'udoc': …","httpStatus":409}}` +- TO `{"success":false,"error":{"code":"UNIQUE_VIOLATION","message":"Duplicate record refused on 'udoc': …","httpStatus":409,"details":{"developerMessage":"Duplicate record refused on 'udoc': …","object":"udoc"}}}` + +`details` carries what the REST server's answer carries beside its code, each field only when the producer stated it: `developerMessage`, `object`, and `field` when the driver named the conflicting column. The status stays `409` and the `message` is unchanged. Only the engine's `DuplicateRecordError` is translated, recognised by its registered code and its class name together. A handler or hook that throws the registered `DUPLICATE_RECORD` without being that class keeps its own code. So does a sandboxed script body that lets the engine's refusal propagate: it reaches this door as the sandbox's own error, not the engine's class, exactly as it reaches the REST and Hono doors. Inside the engine, `DuplicateRecordError.code` is still `DUPLICATE_RECORD`, so an in-process caller (a hook, a flow node) is unaffected. Other `409` answers at these doors (`DELETE_RESTRICTED`, `CONCURRENT_UPDATE`) are unchanged. + +Clause-②: no diff --git a/packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts b/packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts new file mode 100644 index 00000000000..0f7ce2c29b6 --- /dev/null +++ b/packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts @@ -0,0 +1,301 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23686] The runtime dispatcher's own catch arms answer the engine's + * `DuplicateRecordError` with the ruled wire spelling, `409 UNIQUE_VIOLATION`. + * + * Maintainer ruling (2026-09-03, recorded in commit 65846bc46): one wire + * spelling on every route, `UNIQUE_VIOLATION`; the row reports it too. + * + * ## What was wrong + * + * `HttpDispatcher.errorFromThrown` is the one writer the dispatcher domains + * hand a caught throw to. It passed the engine's registered in-process code + * through unchanged, so a door whose catch arm reaches it answered + * `409 DUPLICATE_RECORD` for a repeated unique value, while `RestServer`'s + * arm, the batch and import rows and both Hono doors answer the same throw as + * `UNIQUE_VIOLATION`. Measured before the fix on the rig below: + * + * - `POST /actions/:object/:action`, a handler whose engine write repeats a + * unique value, and a declarative `operation: 'update'` action doing the + * same: `409 {"code":"DUPLICATE_RECORD", …, "httpStatus":409}`, no `details`; + * - `POST /security/_activation/:type/:name` and + * `POST /actions/_activation/:object/:action`, a flip whose ledger read + * misses the row a concurrent flip is inserting (two real concurrent + * flips reproduce it): the same answer, for `sys_metadata_activation`'s + * `(metadata_type, name)` key. + * + * ## The rig + * + * A REAL store per describe: better-sqlite3 `:memory:`, the real `ObjectQL` + * engine, so every duplicate here is the engine's own envelope over a real + * driver refusal, not a double's. 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; the activation + * doors are entered at their domain handlers, as their sibling pins do. + * + * Codes and key presence are asserted, never wording. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL, DuplicateRecordError, ObjectStoreActionActivationStore } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataActivation } from '@objectstack/platform-objects/system'; +import { HttpDispatcher } from './http-dispatcher.js'; +import type { HttpProtocolContext } from './http-dispatcher.js'; +import { bindTestSecurityCatalog } from './security/security-catalog.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 LEDGER = 'sys_metadata_activation'; + +/** The value `r1` holds; every write below that repeats it is refused by the unique key. */ +const TAKEN = 'taken@example.com'; + +const UDOC = { + name: 'udoc', + label: 'Unique doc', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + email: { name: 'email', type: 'text' as const, label: 'Email', unique: true }, + }, + actions: [ + { name: 'add_duplicate', label: 'Add duplicate', type: 'script', target: 'add_duplicate' }, + { name: 'take_email', label: 'Take email', type: 'script', operation: 'update', patch: { email: TAKEN } }, + { name: 'throw_named_duplicate', label: 'Throw named duplicate', type: 'script', target: 'throw_named_duplicate' }, + { name: 'speak_duplicate', label: 'Speak duplicate', type: 'script', target: 'speak_duplicate' }, + { name: 'delete_parent', label: 'Delete parent', type: 'script', target: 'delete_parent' }, + ], +}; + +const UPARENT = { + name: 'uparent', + label: 'Parent', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + }, +}; + +const UCHILD = { + name: 'uchild', + label: 'Child', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + parent: { name: 'parent', type: 'lookup' as const, reference: 'uparent', deleteBehavior: 'restrict', label: 'Parent' }, + }, +}; + +const MEMBER = { userId: 'u_member', isSystem: false }; + +const ADMIN = (): HttpProtocolContext => ({ + request: {}, + environmentId: 'platform', + executionContext: { userId: 'u_admin', systemPermissions: ['manage_metadata'] }, +} as unknown as HttpProtocolContext); + +const liveEngines: ObjectQL[] = []; +afterAll(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function bootEngine(objects: readonly object[]): Promise { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + for (const o of objects) engine.registry.registerObject(o as any, TEST_PACKAGE_ID); + await engine.syncSchemas(); + return engine; +} + +function dispatcherOver(services: Record): HttpDispatcher { + const get = (n: string) => services[n] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + (dispatcher as any).timedResolveExecutionContext = async () => ({ ...MEMBER }); + return dispatcher; +} + +/** `POST /api/v1/actions/` as `@objectstack/hono`'s catch-all hands it to `dispatch()`. */ +async function postAction(dispatcher: HttpDispatcher, rest: string) { + const path = `/actions/${rest}`; + const request = new Request(`http://localhost${PREFIX}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + const res: any = await dispatcher.dispatch('POST', path, {}, {}, { request } as any, PREFIX); + return res.response as { status: number; body: any }; +} + +/** The answer a repeated unique value earns at this door: the ruled code, and the REST arm's fields. */ +function expectUniqueViolation(response: { status: number; body: any }, detailKeys: readonly string[], object: string) { + expect(response.status, JSON.stringify(response.body)).toBe(409); + const error = response.body?.error; + expect(error?.code).toBe('UNIQUE_VIOLATION'); + expect(error?.httpStatus).toBe(409); + expect(typeof error?.message).toBe('string'); + expect(Object.keys(error?.details ?? {}).sort()).toEqual([...detailKeys].sort()); + expect(error.details.object).toBe(object); + expect(typeof error.details.developerMessage).toBe('string'); + // `DUPLICATE_RECORD` is a registered member, so nothing was demoted. + expect(error).not.toHaveProperty('declaredCode'); + // The in-process spelling does not cross, and neither does the driver's text. + const wire = JSON.stringify(response.body); + expect(wire).not.toContain('DUPLICATE_RECORD'); + expect(wire).not.toContain('UNIQUE constraint failed'); +} + +describe('[#23686] the actions door answers the engine\'s duplicate as 409 UNIQUE_VIOLATION', () => { + let dispatcher: HttpDispatcher; + let engine: ObjectQL; + + beforeAll(async () => { + engine = await bootEngine([UDOC, UPARENT, UCHILD]); + const ql = engine as any; + ql.registerAction('udoc', 'add_duplicate', async (ctx: any) => { + await ctx.engine.insert('udoc', { id: 'r_dup', name: 'second', email: TAKEN }); + return { written: true }; + }); + ql.registerAction('udoc', 'throw_named_duplicate', async () => { + // The engine's own class, stating the column the driver named. + throw new DuplicateRecordError('udoc', new Error('driver refusal'), 'email'); + }); + ql.registerAction('udoc', 'speak_duplicate', async () => { + // A producer that SPEAKS the registered code without being the engine's class. + throw Object.assign(new Error('That address is already on file'), { code: 'DUPLICATE_RECORD', status: 409 }); + }); + ql.registerAction('udoc', 'delete_parent', async (ctx: any) => { + await ctx.engine.delete('uparent', 'p1'); + return { deleted: true }; + }); + await engine.insert('udoc', { id: 'r1', name: 'first', email: TAKEN }); + await engine.insert('udoc', { id: 'r2', name: 'other', email: 'other@example.com' }); + await engine.insert('uparent', { id: 'p1', name: 'kept' }); + await engine.insert('uchild', { id: 'c1', name: 'dependent', parent: 'p1' }); + const protocol = new ObjectStackProtocolImplementation(engine as any); + dispatcher = dispatcherOver({ objectql: engine, data: engine, protocol }); + }, 60_000); + + it('a handler whose engine write repeats a unique value', async () => { + const response = await postAction(dispatcher, 'udoc/add_duplicate'); + + // The driver names no single column: the index is the tenancy-scoped composite. + expectUniqueViolation(response, ['developerMessage', 'object'], 'udoc'); + // Non-vacuity: the refusal is real, nothing was written. + expect(await engine.find('udoc', { where: { email: TAKEN } })).toHaveLength(1); + }); + + it('a declarative `operation: \'update\'` action whose write repeats a unique value', async () => { + const response = await postAction(dispatcher, 'udoc/take_email/r2'); + + expectUniqueViolation(response, ['developerMessage', 'object'], 'udoc'); + expect(((await engine.findOne('udoc', { where: { id: 'r2' } })) as any)?.email).toBe('other@example.com'); + }); + + it('the engine\'s class stating a column: `field` rides `details` beside the other two', async () => { + const response = await postAction(dispatcher, 'udoc/throw_named_duplicate'); + + expectUniqueViolation(response, ['developerMessage', 'field', 'object'], 'udoc'); + expect(response.body.error.details.field).toBe('email'); + }); + + it('CONTROL: a producer that merely speaks `DUPLICATE_RECORD` keeps its own code and body', async () => { + const response = await postAction(dispatcher, 'udoc/speak_duplicate'); + + expect(response.status).toBe(409); + expect(response.body?.error?.code).toBe('DUPLICATE_RECORD'); + expect(response.body?.error).not.toHaveProperty('details'); + expect(response.body?.error).not.toHaveProperty('declaredCode'); + }); + + it('CONTROL: another engine 409 (`DELETE_RESTRICTED`) keeps its code and its body', async () => { + const response = await postAction(dispatcher, 'udoc/delete_parent'); + + expect(response.status).toBe(409); + expect(response.body?.error?.code).toBe('DELETE_RESTRICTED'); + expect(response.body?.error).not.toHaveProperty('details'); + expect(await engine.find('uparent', { where: { id: 'p1' } })).toHaveLength(1); + }); +}); + +/** + * The engine as a flip that races another sees it: its read of the activation + * ledger misses the row the other flip has inserted, so its own insert is the + * one the ledger's unique `(metadata_type, name)` key refuses. Every other + * call reaches the real engine, the insert included, so the refusal is the + * engine's own envelope over a real driver error. Two real concurrent flips + * reproduce the same answer; this holds the race window open so the pin does + * not depend on scheduling. + */ +function ledgerReadMissingTheRacer(engine: ObjectQL): ObjectQL { + return new Proxy(engine, { + get(target, prop) { + if (prop === 'find') { + return async (object: string, options?: unknown) => + object === LEDGER ? [] : (target as any).find(object, options); + } + const value = Reflect.get(target, prop, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} + +describe('[#23686] the activation doors answer a ledger write that loses a race as 409 UNIQUE_VIOLATION', () => { + let engine: ObjectQL; + let racing: ObjectQL; + let dispatcher: HttpDispatcher; + + beforeAll(async () => { + engine = await bootEngine([UDOC, SysMetadataActivation]); + bindTestSecurityCatalog(engine, { + positions: [{ name: 'sales_rep', permissionSets: ['sales_access'] }], + permissions: [ + { name: 'sales_access', systemPermissions: ['cap_sales'] }, + { name: 'admin_full_access', systemPermissions: ['manage_metadata'] }, + ], + }); + (engine as any).registerAction('udoc', 'add_duplicate', async () => ({ ok: true })); + racing = ledgerReadMissingTheRacer(engine); + const store = new ObjectStoreActionActivationStore(racing as any); + await store.probe(); + (engine as any).setActionActivationStore(store); + dispatcher = dispatcherOver({ objectql: racing, data: racing }); + }, 60_000); + + const ledgerRows = async (type: string, name: string) => + engine.find(LEDGER, { where: { metadata_type: type, name } }); + + it('`POST /security/_activation/position/:name` (catalog activation)', async () => { + const flip = () => dispatcher.handleSecurity('/_activation/position/sales_rep', 'POST', { enabled: false }, {}, ADMIN()); + + const first: any = await flip(); + expect(first.response?.status, JSON.stringify(first.response?.body)).toBe(200); + const racer: any = await flip(); + + expectUniqueViolation(racer.response, ['developerMessage', 'object'], LEDGER); + expect(await ledgerRows('position', 'sales_rep')).toHaveLength(1); + }); + + it('`POST /actions/_activation/:object/:action` (action activation)', async () => { + const flip = () => dispatcher.handleActions('/_activation/udoc/add_duplicate', 'POST', { enabled: false }, ADMIN()); + + const first: any = await flip(); + expect(first.response?.status, JSON.stringify(first.response?.body)).toBe(200); + const racer: any = await flip(); + + expectUniqueViolation(racer.response, ['developerMessage', 'object'], LEDGER); + expect(await ledgerRows('action', 'add_duplicate')).toHaveLength(1); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 22d1fe62b33..77430131ece 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 } from '@objectstack/types'; +import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, demotedDeclaredCode, declaredUserMessage, inProcessSessionReadInput, isEngineDuplicateRecordEnvelope, structuredCodeAnswer } from '@objectstack/types'; import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; import { CoreServiceName, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system'; import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; @@ -379,6 +379,37 @@ type ClassifiedServiceLookup = | { readonly outcome: 'never-registered' } | { readonly outcome: 'no-instance-in-scope'; readonly scopeId: string }; +/** + * [#23686] The structured context `RestServer`'s duplicate-record arm states + * beside its `UNIQUE_VIOLATION`, for the dispatcher envelope's `details`. + * Asked only for the engine's own `DuplicateRecordError` (see + * `HttpDispatcher.errorFromThrown`, "One wire spelling"). + * + * ONE classification, not a third spelling: the fields are read off the arm + * `structuredCodeAnswer` (`@objectstack/types`) answers the same throw + * through, so this door carries what the REST door and the Hono doors carry, + * each field only when the producer stated it, and an arm that grows a field + * grows it here too. ⛔ Never list the fields here. The arm's own sentence + * (`error`) and `code` are dropped because the envelope already carries both, + * as `message` and `code`. + * + * The arm must AGREE with the envelope already decided, as at the Hono doors: + * its status must be the thrown one and its code `UNIQUE_VIOLATION`, so it can + * only add context, never change the answer. Disagreeing, it adds nothing. + * + * ⛔ Deliberately not every arm. The dispatcher's other 409s + * (`CONCURRENT_UPDATE`, `DELETE_RESTRICTED`) keep the body they have; widening + * the context this door carries for them is a change of its own. + */ +function duplicateArmContext(thrown: unknown, status: number): Record | undefined { + const arm = structuredCodeAnswer(thrown); + if (arm === undefined || arm.status !== status || arm.body.code !== 'UNIQUE_VIOLATION') return undefined; + const context = Object.fromEntries( + Object.entries(arm.body).filter(([key]) => key !== 'error' && key !== 'code'), + ); + return Object.keys(context).length > 0 ? context : undefined; +} + /** * The HTTP dispatch engine — translates an inbound (method, path, body, ctx) * request into a kernel response. Used directly by the framework's HTTP adapters @@ -1107,8 +1138,10 @@ export class HttpDispatcher { * `details` — the precedence `buildApiError` already declares. Only * {@link errorFromThrown} passes it, because the shared resolver it * delegates to has already answered the code question for both package - * doors; every other call site still expresses its code the way it did (a - * `details.code` to promote, or none at all and let the status derive one). + * doors (and, for the engine's `DuplicateRecordError` alone, the method + * passes the ruled wire spelling instead — #23686); every other call site + * still expresses its code the way it did (a `details.code` to promote, or + * none at all and let the status derive one). * * [#9106] `extra` carries declared siblings of `code` (today only the * demoted `declaredCode`); again only {@link errorFromThrown} passes it. @@ -1158,9 +1191,50 @@ export class HttpDispatcher { * The one thing that stayed here is DISCLOSURE: `this.error` withholds a * leaky 5xx message (#3867), which is a property of this surface's answer * rather than of the throw. + * + * ## One wire spelling for a unique-constraint refusal (#23686) + * + * Maintainer ruling (2026-09-03, recorded in commit 65846bc46): one wire + * spelling on every route, `UNIQUE_VIOLATION`; the row reports it too. + * + * The engine's `DuplicateRecordError` declares `409` and its in-process + * identity, `DUPLICATE_RECORD`, which an in-process caller (a hook, a flow + * node) branches on and which is ⛔ not renamed. The shared resolver + * passes that registered code through unchanged, so every domain whose + * catch arm hands the throw here (an action body's write, an action or + * catalog activation flip losing a race on the ledger's unique key) + * answered `409 DUPLICATE_RECORD`, while `RestServer`'s arm, the batch and + * import rows and both Hono doors answer the same throw as + * `UNIQUE_VIOLATION`. + * + * So this method decides the code for that class before writing it, keyed + * exactly as those doors key it: the registered code AND the class name + * ({@link isEngineDuplicateRecordEnvelope}, `@objectstack/types`), never + * message text. A producer that merely SPEAKS `DUPLICATE_RECORD` (a + * handler or hook throwing the registered code without being the + * engine's class) keeps its own code and its own body. Beside the code, + * the envelope's `details` carries what `RestServer`'s arm carries beside + * its own, read off that one classification ({@link duplicateArmContext}) + * rather than listed here: `developerMessage`, `object`, and `field` when + * the driver named the column. `message` stays the thrown one, as on every + * other code at this door and at the Hono doors; `declaredCode` stays + * absent, as `DUPLICATE_RECORD` is a registered member. + * + * ⚠️ `dispatcher-plugin`'s `errorResponseBase`, the exit for a throw that + * LEAVES `dispatch()`, does not carry this translation. No domain it + * serves was measured handing it the engine's class (every write those + * routes reach is caught in its domain and answered here), so the + * divergence is declared rather than closed by a second translator. */ private errorFromThrown(e: any, fallbackStatus = 500) { const thrown = resolveThrownHttpError(e, fallbackStatus); + // [#23686] The code this envelope carries: the resolver's, except the + // engine's unique-constraint envelope, which crosses HTTP as the one + // ruled wire spelling (see "One wire spelling" above). + const engineDuplicate = isEngineDuplicateRecordEnvelope(e); + const code = engineDuplicate ? 'UNIQUE_VIOLATION' : thrown.code; + const armContext = engineDuplicate ? duplicateArmContext(e, thrown.status) : undefined; + const details = armContext ? { ...thrown.details, ...armContext } : thrown.details; // [#9106] `code`, the NARROWED spelling — the same one the REST door // has served since #8016, so the two doors now agree on `error.code` // unconditionally (maintainer ruling 2026-08-16: `error.code` is a @@ -1193,7 +1267,7 @@ export class HttpDispatcher { ...(thrown.userMessage !== undefined ? { userMessage: thrown.userMessage } : {}), }; return this.error( - thrown.message, thrown.status, thrown.details, thrown.code, + thrown.message, thrown.status, details, code, Object.keys(extra).length > 0 ? extra : undefined, ); } From 8ea8bbcf80fa4b0ce69d58336286b261812b5098 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:43:35 +0000 Subject: [PATCH 2/2] docs(changeset): drop the sandboxed-relay sentence from the #23686 changeset The sentence shipped, as intended behaviour, a reading that is still open: whether an uncaught relay of the engine's refusal out of a sandboxed body counts as the engine's class under the ruling (commit 65846bc46). That question is tracked on its own card; the changeset no longer states it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG --- .changeset/23686-dispatcher-unique-violation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/23686-dispatcher-unique-violation.md b/.changeset/23686-dispatcher-unique-violation.md index 70f1f83674d..13fa0b20c32 100644 --- a/.changeset/23686-dispatcher-unique-violation.md +++ b/.changeset/23686-dispatcher-unique-violation.md @@ -11,6 +11,6 @@ Why: the maintainer ruled one wire spelling for a unique-constraint refusal on e - FROM `{"success":false,"error":{"code":"DUPLICATE_RECORD","message":"Duplicate record refused on 'udoc': …","httpStatus":409}}` - TO `{"success":false,"error":{"code":"UNIQUE_VIOLATION","message":"Duplicate record refused on 'udoc': …","httpStatus":409,"details":{"developerMessage":"Duplicate record refused on 'udoc': …","object":"udoc"}}}` -`details` carries what the REST server's answer carries beside its code, each field only when the producer stated it: `developerMessage`, `object`, and `field` when the driver named the conflicting column. The status stays `409` and the `message` is unchanged. Only the engine's `DuplicateRecordError` is translated, recognised by its registered code and its class name together. A handler or hook that throws the registered `DUPLICATE_RECORD` without being that class keeps its own code. So does a sandboxed script body that lets the engine's refusal propagate: it reaches this door as the sandbox's own error, not the engine's class, exactly as it reaches the REST and Hono doors. Inside the engine, `DuplicateRecordError.code` is still `DUPLICATE_RECORD`, so an in-process caller (a hook, a flow node) is unaffected. Other `409` answers at these doors (`DELETE_RESTRICTED`, `CONCURRENT_UPDATE`) are unchanged. +`details` carries what the REST server's answer carries beside its code, each field only when the producer stated it: `developerMessage`, `object`, and `field` when the driver named the conflicting column. The status stays `409` and the `message` is unchanged. Only the engine's `DuplicateRecordError` is translated, recognised by its registered code and its class name together. A handler or hook that throws the registered `DUPLICATE_RECORD` without being that class keeps its own code. Inside the engine, `DuplicateRecordError.code` is still `DUPLICATE_RECORD`, so an in-process caller (a hook, a flow node) is unaffected. Other `409` answers at these doors (`DELETE_RESTRICTED`, `CONCURRENT_UPDATE`) are unchanged. Clause-②: no