From dad32a8f456778c90b4255b0e721a8ce3c971357 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:22:02 +0000 Subject: [PATCH 1/6] fix(rest): consult the structured arms before the declared-status passthrough Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- packages/rest/src/error-response.ts | 224 +++++++++++++++++++++++----- 1 file changed, 185 insertions(+), 39 deletions(-) diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index 04de830699..0bea8c7883 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -654,7 +654,83 @@ export function boundedDeclaredUserMessage(error: unknown): string | undefined { return userMessage === undefined ? undefined : truncateClientMessage(userMessage); } -function classifyDataError(error: any, object?: string): { status: number; body: Record } { +/** + * [#11588 / #7543 / #14541] Did this error come out of a sandboxed body? + * + * `SandboxError.innerMessage` is the QuickJS side-channel: `message` carries a + * ` '' threw: ` DEBUG WRAPPER written for the server log, and + * the sentence addressed to the caller is `innerMessage`. Every arm in + * {@link structuredCodeAnswer} ships `error.message`, so a sandboxed producer + * is a DIFFERENT producer for their purposes and is answered by the unwrap + * door instead — the rule #14389 already wrote into the `DUPLICATE_RECORD` + * arm's `name` gate, stated once here for the arms that need it by POSITION. + * + * Deliberately NOT {@link sandboxBusinessMessage}: that one declines a CRASH + * (#7543) so the crash reaches the fault terminal, and a crash carrying a + * bespoke `code` must reach the unwrap door too rather than an arm that would + * dress the wrapper up as a refusal. + */ +function isSandboxOrigin(error: any): boolean { + return typeof error?.innerMessage === 'string' && error.innerMessage.length > 0; +} + +/** + * [#14541] The bespoke structured arms, in ONE place, so BOTH REST error doors + * can ask them FIRST. + * + * ## The defect this retires + * + * `classifyDataError` has always surfaced these arms ahead of its own + * declared-status passthrough, "so the structured fields survive the generic + * catch-alls". {@link resolveErrorResponse} — the door every route reporting + * through {@link handleRouteError} / {@link sendThrownError} uses (createMany, + * updateMany, deleteMany, batch, clone, the import/export routes and the + * metadata / UI families) — takes its OWN declared-status passthrough BEFORE + * delegating here, so on those routes the arms were never reached at all. One + * refusal, two bodies, decided by which route caught it: + * + * engine `DELETE_RESTRICTED` (status 409) → `developerMessage`, + * `dependentObject`, `dependentCount`, `object` all dropped + * `ConcurrentUpdateError` (status 409) → `currentVersion`, + * `currentRecord`, `object` dropped + * `DuplicateRecordError` (status 409) → `field`, `object`, + * `developerMessage` dropped, and `code` left as the engine spelling + * `DUPLICATE_RECORD` instead of the wire's `UNIQUE_VIOLATION` + * `FEEDS_DISABLED` / `FILES_DISABLED` / `ATTACHMENT_PARENT_ACCESS` / + * `ATTACHMENT_DELETE_DENIED` / `RECORD_NOT_ACCESSIBLE` (status 403) + * → `object` dropped + * engine `INVALID_FIELD` (status 400) → `field`, `object` dropped + * + * ## Why a shared classification rather than a second exclusion list + * + * The passthrough already carried one exclusion, and it is this same argument + * accepted once for one code: + * + * > [#3770] `OBJECT_NOT_FOUND` is deliberately excluded from this + * > status-passthrough: `mapDataError` owns its canonical envelope, and + * > short-circuiting here would ship a second wire code for the same condition + * > depending on which route caught it. + * + * That exclusion never grew past its first case, which is what produced the + * five rows above. A list that must be extended by hand for every new arm + * fails the same way again; a classification both doors ASK cannot, because + * adding an arm here fixes both doors at once. Pinned door-to-door in + * `error-response-structured-arm-door-parity.test.ts` rather than asserted. + * + * ## The boundary + * + * Every arm here is decided on what the PRODUCER DECLARED — its `code`, or the + * error `name` where a class is the contract. Nothing here reads message TEXT + * to decide WHICH condition this is; that is the line, and it is why the + * `PERMISSION_DENIED` arm (whose third limb sniffs a `[Security] Access denied` + * prefix) and the sandbox unwrap door stay in {@link classifyDataError} below, + * ahead of nothing. Answering `undefined` means "no bespoke arm knows this + * error" — the caller decides what that means for its own door. + */ +function structuredCodeAnswer( + error: any, + object?: string, +): { status: number; body: Record } | undefined { // Referential-integrity restrict on delete → 409 with the dependent count. // Surfaced FIRST so the structured fields survive the generic catch-alls. if (error?.code === 'DELETE_RESTRICTED') { @@ -860,6 +936,65 @@ function classifyDataError(error: any, object?: string): { status: number; body: }, }; } + // [#14541] Gated on `!isSandboxOrigin` because this arm used to sit BELOW + // the sandbox unwrap door and now sits above it. The clause is that + // position, written down: the sentence this arm ships is `error.message`, + // which for a sandboxed producer is the QuickJS DEBUG WRAPPER, and the + // unwrap door owns that producer (#11588 / #7543). Without it, lifting the + // arm would ship the wrapper on both doors. + // [#3770] Object does not exist — thrown by the protocol's registry gate + // (`assertObjectRegistered`, which covers every data entry point) and by + // `cloneData`. Mapped to the SAME envelope the driver-string branch below + // produces, so one condition has exactly one wire code (`OBJECT_NOT_FOUND`, + // a `StandardErrorCode` member) no matter which layer detected it — the + // point of #3770 is that this 404 no longer depends on a driver erroring + // on a missing table. Must precede the generic 4xx passthrough, which + // would otherwise ship the internal SCREAMING_CASE code verbatim. + if (error?.code === 'OBJECT_NOT_FOUND' && !isSandboxOrigin(error)) { + const name = error?.object ?? object; + return { + status: 404, + body: { + error: name ? `Object '${name}' is not registered` : 'Object not found', + code: 'OBJECT_NOT_FOUND', + ...(name ? { object: name } : {}), + }, + }; + } + // [#14541] Gated on `!isSandboxOrigin` because this arm used to sit BELOW + // the sandbox unwrap door and now sits above it. The clause is that + // position, written down: the sentence this arm ships is `error.message`, + // which for a sandboxed producer is the QuickJS DEBUG WRAPPER, and the + // unwrap door owns that producer (#11588 / #7543). Without it, lifting the + // arm would ship the wrapper on both doors. + // [#4134] Unknown field named by a READ — the protocol's list normalizer + // refusing to lower a query parameter that matches no field into an + // implicit filter that could only ever match zero rows. Emitted in the SAME + // envelope as the driver-string branch below (which catches the write-path + // form of the identical mistake), so one condition has one wire shape no + // matter which layer noticed it. Must precede the generic 4xx passthrough, + // which would ship the message but drop `field`. + if (error?.code === 'INVALID_FIELD' && !isSandboxOrigin(error)) { + const name = error?.object ?? object; + return { + status: 400, + body: { + error: String(error?.message ?? 'Request references a field that does not exist'), + code: 'INVALID_FIELD', + ...(typeof error?.field === 'string' && error.field ? { field: error.field } : {}), + ...(name ? { object: name } : {}), + }, + }; + } + return undefined; +} + +function classifyDataError(error: any, object?: string): { status: number; body: Record } { + // [#14541] The bespoke structured arms first, exactly as they were inline + // here — same arms, same order, same position — now stated once so + // {@link resolveErrorResponse} can ask them before ITS passthrough too. + const structured = structuredCodeAnswer(error, object); + if (structured !== undefined) return structured; // Short-circuit: explicit security denial → 403. Match by `code` / // `name` to avoid pulling a runtime dependency on plugin-security. if ( @@ -983,44 +1118,6 @@ function classifyDataError(error: any, object?: string): { status: number; body: // the passthrough for this producer exactly as they do for every // other — the #7525 §5 pins.) } - // [#3770] Object does not exist — thrown by the protocol's registry gate - // (`assertObjectRegistered`, which covers every data entry point) and by - // `cloneData`. Mapped to the SAME envelope the driver-string branch below - // produces, so one condition has exactly one wire code (`OBJECT_NOT_FOUND`, - // a `StandardErrorCode` member) no matter which layer detected it — the - // point of #3770 is that this 404 no longer depends on a driver erroring - // on a missing table. Must precede the generic 4xx passthrough, which - // would otherwise ship the internal SCREAMING_CASE code verbatim. - if (error?.code === 'OBJECT_NOT_FOUND') { - const name = error?.object ?? object; - return { - status: 404, - body: { - error: name ? `Object '${name}' is not registered` : 'Object not found', - code: 'OBJECT_NOT_FOUND', - ...(name ? { object: name } : {}), - }, - }; - } - // [#4134] Unknown field named by a READ — the protocol's list normalizer - // refusing to lower a query parameter that matches no field into an - // implicit filter that could only ever match zero rows. Emitted in the SAME - // envelope as the driver-string branch below (which catches the write-path - // form of the identical mistake), so one condition has one wire shape no - // matter which layer noticed it. Must precede the generic 4xx passthrough, - // which would ship the message but drop `field`. - if (error?.code === 'INVALID_FIELD') { - const name = error?.object ?? object; - return { - status: 400, - body: { - error: String(error?.message ?? 'Request references a field that does not exist'), - code: 'INVALID_FIELD', - ...(typeof error?.field === 'string' && error.field ? { field: error.field } : {}), - ...(name ? { object: name } : {}), - }, - }; - } // Generic passthrough for domain errors that already carry an explicit // HTTP status (e.g. plugin-sharing's record-scope denial: status 403 + // code FORBIDDEN) — mirrors sendThrownError's `.status` handling, which the @@ -1700,11 +1797,60 @@ function logWithheldServerFault( * drift this whole seam exists to prevent (#4886). */ function resolveErrorResponse(error: any, object?: string): { status: number; body: Record } { + // [#14541] The bespoke structured arms are asked BEFORE this door's + // declared-status passthrough, because that ordering is the whole defect + // this card reports: an engine envelope declaring `status: 409` left + // through the passthrough and never reached the arm that owns its wire + // body, so `DELETE_RESTRICTED` lost `developerMessage` / + // `dependentObject` / `dependentCount`, `ConcurrentUpdateError` lost + // `currentVersion` / `currentRecord`, and `DuplicateRecordError` lost + // `field` and kept the engine's `code` — on every route reporting through + // {@link handleRouteError} / {@link sendThrownError}, while the + // single-record `/data` routes calling `mapDataError` directly got the + // curated envelope. One refusal, two bodies. + // + // This GENERALISES the exclusion the arm below already carried rather than + // adding a second one — see {@link structuredCodeAnswer}, which holds the + // #3770 ruling this applies and the measured per-code delta. + // + // Answered through `mapDataError` rather than by returning the arm's body + // here, so the two doors are identical BY CONSTRUCTION — same arm, same + // {@link withDeclaredUserMessage} wrapper, nothing for a future edit to + // desynchronise. The second classification pass is on an error path and + // the function is pure. + // + // Three guards, each one a boundary this card was fenced away from: + // + // - a producer-declared **5xx** keeps the passthrough's 5xx arm. Its + // unconditional prose-drop (#5437 / #5582 / #5907, argued at length + // below) is load-bearing and is NOT narrowed here; this card is about + // ordering for 4xx codes that have a bespoke arm, nothing else. + // - an arm that answers a **5xx** (`ERR_DATASOURCE_UNAVAILABLE`'s 503) + // never displaces a declared 4xx either — the same band, fenced from + // the other side. Measured: its producer declares no `status` at all, + // so this guard changes nothing today and states the boundary anyway. + // - a **sandbox** producer keeps the unwrap answer it has today. The arms + // ship `error.message`, which for a sandboxed body is the QuickJS debug + // wrapper #11588 exists to keep off this wire; the passthrough below + // reads {@link sandboxBusinessMessage} instead and is the right door + // for it. + const structured = isSandboxOrigin(error) ? undefined : structuredCodeAnswer(error, object); + const declaresServerBand = typeof error?.status === 'number' && error.status >= 500 && error.status < 600; + if (structured !== undefined && structured.status < 500 && !declaresServerBand) { + return mapDataError(error, object); + } // [#3770] `OBJECT_NOT_FOUND` is deliberately excluded from this // status-passthrough: `mapDataError` owns its canonical envelope // (`OBJECT_NOT_FOUND`), and short-circuiting here would ship a second wire // code for the same condition depending on which route caught it. // + // [#14541] The consult above now answers that for every DECLARED-code + // producer, so this clause survives for exactly one residue: a SANDBOXED + // body throwing `OBJECT_NOT_FOUND`, which the consult declines. Measured: + // without the clause that error takes the 4xx arm below and loses + // `object`. ⛔ Not a list to extend — a new bespoke code belongs in + // {@link structuredCodeAnswer}, where both doors read it. + // // [#7525] Deliberately still a `status`-only read HERE. An error that // declares its status as `statusCode` instead is not skipped — it falls to // `mapDataError` below, whose {@link declaredHttpStatus} gate reads both From d223e54445a2781bf9672118ad75256ad471686f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:03:58 +0000 Subject: [PATCH 2/6] test(rest): pin both error doors against each other per structured arm Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- ...rest-structured-arms-before-passthrough.md | 88 ++++ ...esponse-structured-arm-door-parity.test.ts | 488 ++++++++++++++++++ 2 files changed, 576 insertions(+) create mode 100644 .changeset/rest-structured-arms-before-passthrough.md create mode 100644 packages/rest/src/error-response-structured-arm-door-parity.test.ts diff --git a/.changeset/rest-structured-arms-before-passthrough.md b/.changeset/rest-structured-arms-before-passthrough.md new file mode 100644 index 0000000000..4adc6b5df8 --- /dev/null +++ b/.changeset/rest-structured-arms-before-passthrough.md @@ -0,0 +1,88 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): the bulk / metadata / UI error door consults the bespoke structured arms BEFORE its declared-status passthrough (#14541) + +**Response-body change on published bulk doors.** One refusal used to produce +two different bodies depending on which route caught it. `resolveErrorResponse` +— the door behind `handleRouteError` / `sendThrownError`, used by `createMany`, +`updateMany`, `deleteMany`, `batch`, `clone`, the import/export routes and the +metadata / UI families — took its own declared-status passthrough BEFORE +delegating to `mapDataError`, which the single-record `/data` routes call +directly. An engine envelope that DECLARES `status` therefore short-circuited, +and every bespoke structured arm behind the delegation was unreachable from +those routes. + +The project had already ruled on this exact shape, for one code: + +> [#3770] `OBJECT_NOT_FOUND` is deliberately excluded from this +> status-passthrough: `mapDataError` owns its canonical envelope, and +> short-circuiting here would ship a second wire code for the same condition +> depending on which route caught it. + +That exclusion never grew past its first case. This change generalises the +ruling instead of adding a second exclusion: the structured arms are lifted +into one `structuredCodeAnswer` classification that BOTH doors ask first, and +the bulk door answers a match by delegating to `mapDataError`, so the two +bodies are identical by construction rather than by coincidence. + +**What callers on the bulk doors see change** — measured door-to-door, in +process, against the real producer shapes: + +| producer (declares) | before, bulk door | after, bulk door | +| :-- | :-- | :-- | +| engine `DELETE_RESTRICTED` (409) | `{error, code}` | `+ developerMessage, dependentObject, dependentCount, object` | +| `ConcurrentUpdateError` (409) | `{error, code}` | `+ currentVersion, currentRecord, object` | +| `DuplicateRecordError` (409) | `{error: the engine's sentence, code: "DUPLICATE_RECORD"}` | `{error: the curated sentence, code: "UNIQUE_VIOLATION", developerMessage, field, object}` | +| `FEEDS_DISABLED` / `FILES_DISABLED` (403) | `{error, code}` | `+ object` | +| `ATTACHMENT_PARENT_ACCESS` / `ATTACHMENT_DELETE_DENIED` / `RECORD_NOT_ACCESSIBLE` (403) | `{error, code}` | `+ object` | +| engine `INVALID_FIELD` (400) | `{error, code}` | `+ field, object` | + +In every row the STATUS is unchanged — the doors already agreed on it — and no +key is removed. The added keys are the ones the single-record `/data` door has +always shipped for the same refusal, and all of them are already declared on +`ApiErrorSchema`. + +**One `code` VALUE changes, and it is a restoration.** On the bulk doors an +insert/update unique conflict answered `code: "UNIQUE_VIOLATION"` until #14095 +wrapped the driver error in `DuplicateRecordError`: the raw driver error +declared no `status`, so it fell through to `mapDataError`'s +`isUniqueViolationError` arm. The envelope DOES declare one, so from #14095 the +bulk doors started answering the engine spelling `DUPLICATE_RECORD` while the +single-record door kept `UNIQUE_VIOLATION` (#14389 restored that door +explicitly). This change puts `UNIQUE_VIOLATION` back on the bulk doors — the +spelling every consumer branching on this conflict already reads, and the one +the same doors answered before #14095. `DUPLICATE_RECORD` stays the in-process +code on the thrown envelope, exactly as each dialect's code always has. + +**Three boundaries this deliberately does NOT cross:** + +- **The passthrough's 5xx half is untouched.** A producer-declared 5xx still + takes that arm, with the unconditional prose-drop intact (#5437 / #5582 / + #5907). Fenced from the other side too: an arm that answers a 5xx + (`ERR_DATASOURCE_UNAVAILABLE`'s 503) never displaces a status a producer + declared in the 4xx band. +- **A sandboxed producer keeps the unwrap door's answer.** The arms ship + `error.message`, which for a QuickJS body is the DEBUG WRAPPER #11588 exists + to keep off this wire, so the consult declines an error carrying + `innerMessage` and the passthrough's `sandboxBusinessMessage` read answers it + as before. +- **`classifyDataError` is behaviour-identical.** The lifted arms are the same + arms in the same order at the same position; the two that used to sit below + the sandbox unwrap door (`OBJECT_NOT_FOUND`, `INVALID_FIELD`) carry that + position as an explicit `!isSandboxOrigin` clause rather than losing it to + the move. + +**Unchanged:** `OBJECT_NOT_FOUND` (its #3770 exclusion already gave it door +parity, and its body is pinned byte-identical), `VALIDATION_FAILED` and +`ERR_DATASOURCE_UNAVAILABLE` (measured: their producers declare no `status`, so +the passthrough never fired for them), `PERMISSION_DENIED` (measured: its class +declares `statusCode`, not `status`, so it too already reached its arm — and its +third limb is a message-TEXT gate, which this ordering fix deliberately does not +lift above the passthrough), the statuses on every door, and the `#5423` 4xx +truncation and `#9934` `userMessage` channel, which apply exactly as before. + +Both doors are now pinned against each other per producer, plus a drift guard +that fails when an arm is added to the shared classification without a parity +case — `error-response-structured-arm-door-parity.test.ts`. 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 new file mode 100644 index 0000000000..3cf0ac5deb --- /dev/null +++ b/packages/rest/src/error-response-structured-arm-door-parity.test.ts @@ -0,0 +1,488 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #14541 — the two REST error doors must answer one refusal with one body. + * + * ## What was measured, on `origin/main` @ `9b30cc18d9` + * + * `resolveErrorResponse` — the door behind `handleRouteError` / + * `sendThrownError`, which every bulk and metadata route reports through — + * took its own declared-status passthrough BEFORE delegating to + * `mapDataError`, the door the single-record `/data` routes call directly. An + * engine envelope that DECLARES `status` therefore short-circuited, and every + * bespoke structured arm behind the delegation was unreachable from those + * routes. Per producer, measured at the throw site: + * + * engine `DELETE_RESTRICTED` status 409 → `developerMessage`, + * `dependentObject`, `dependentCount`, `object` dropped + * `ConcurrentUpdateError` status 409 → `currentVersion`, + * `currentRecord`, `object` dropped + * `DuplicateRecordError` status 409 → `field`, `object`, + * `developerMessage` dropped; `code` left as the engine spelling + * `DUPLICATE_RECORD` rather than the wire's `UNIQUE_VIOLATION`; the + * engine's sentence rather than the curated one + * `FEEDS_DISABLED` / `FILES_DISABLED` / `ATTACHMENT_PARENT_ACCESS` / + * `ATTACHMENT_DELETE_DENIED` / `RECORD_NOT_ACCESSIBLE` status 403 + * → `object` dropped + * engine `INVALID_FIELD` status 400 → `field`, `object` dropped + * + * The project had already ruled on this shape for ONE code (#3770, + * `OBJECT_NOT_FOUND`): "`mapDataError` owns its canonical envelope, and + * short-circuiting here would ship a second wire code for the same condition + * depending on which route caught it." The exclusion never grew past that + * first case, which is what the rows above are. + * + * ## What this file pins + * + * §1 door parity: for every producer the shared classification recognises, + * `mapDataError` and `sendThrownError` answer the SAME status and the + * SAME body — the invariant, stated once per producer; + * §2 the restored fields, named per code, so a body that silently loses one + * again is a red test rather than a re-measurement; + * §3 `handleRouteError` and `sendThrownError` agree with each other (they + * share `resolveErrorResponse`; the pin keeps that true); + * §4 the three guards the fix is fenced by — a declared 5xx keeps the + * passthrough's prose-withholding arm (#5437 / #5582 / #5907), a 5xx ARM + * never displaces a declared 4xx, and a sandboxed producer keeps the + * unwrap door's sentence (#11588 / #7543); + * §5 the drift guard: every `code` literal inside `structuredCodeAnswer` is + * covered by a §1 case. Adding an arm without a parity case fails here, + * which is the whole point — an exclusion list is what this card is the + * bill for, and a coverage list nobody checks is the same defect. + * + * Refusal assertions state `code` AND `status` (ADR-0112) — never + * `toThrow()` alone, which is green for a curated envelope and for a raw + * driver error alike. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DuplicateRecordError } from '@objectstack/objectql'; +import { ConcurrentUpdateError } from '@objectstack/metadata-protocol'; +import { mapDataError, sendThrownError, handleRouteError } from './error-response.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +type Wire = { status: number; body: Record }; + +/** Drive one of the sending doors and capture what reached the wire. */ +function through(send: (res: any, error: any, object?: string) => void) { + return (error: unknown, object?: string): Wire => { + let status = 0; + let body: Record = {}; + const res = { + status(s: number) { status = s; return this; }, + json(b: Record) { body = b; return this; }, + }; + send(res, error, object); + return { status, body }; + }; +} + +const bulkDoor = through(sendThrownError); +const routeDoor = through(handleRouteError); +const singleDoor = (error: unknown, object?: string): Wire => mapDataError(error, object); + +/** + * The producers, each assembled to the shape its THROW SITE builds — the + * envelope classes where a class is the contract, the same property writes + * otherwise. Every entry names where the shape comes from, so a producer that + * changes shape is traceable from here. + */ +interface Case { + /** The `code` (or `name`, where the class is the gate) the arm keys on. */ + readonly covers: readonly string[]; + readonly what: string; + readonly error: () => unknown; + readonly object?: string; + /** Fields the bulk door dropped before this fix. */ + readonly restored: readonly string[]; + readonly expect: Wire; +} + +const DELETE_RESTRICTED_DEV = + "Cannot delete account (a1): 3 dependent contact record(s) reference it via account_id. " + + "Delete or reassign them first, or set deleteBehavior:'cascade' on contact.account_id."; + +const CASES: readonly Case[] = [ + { + covers: ['DELETE_RESTRICTED'], + what: "engine.ts's referential-integrity restrict (`err.code`/`err.status`/`err.object`/" + + '`err.dependentObject`/`err.dependentCount`/`err.developerMessage`)', + error: () => { + const err: any = new Error('Cannot delete this 客户 because 3 联系人 still reference it.'); + err.developerMessage = DELETE_RESTRICTED_DEV; + err.code = 'DELETE_RESTRICTED'; + err.status = 409; + err.object = 'account'; + err.dependentObject = 'contact'; + err.dependentCount = 3; + return err; + }, + // The `DELETE_RESTRICTED` arm names the object from the door's + // `object` ARGUMENT (not `error.object`), which every affected route + // supplies as `req.params?.object` — so the case supplies it too. + object: 'account', + restored: ['developerMessage', 'dependentObject', 'dependentCount', 'object'], + expect: { + status: 409, + body: { + error: 'Cannot delete this 客户 because 3 联系人 still reference it.', + code: 'DELETE_RESTRICTED', + developerMessage: DELETE_RESTRICTED_DEV, + dependentObject: 'contact', + dependentCount: 3, + object: 'account', + }, + }, + }, + { + covers: ['CONCURRENT_UPDATE'], + what: "metadata-protocol's ConcurrentUpdateError (readonly `status = 409`)", + error: () => new ConcurrentUpdateError({ + currentVersion: '2026-09-02T10:00:00.000Z', + currentRecord: { id: 'r1', name: 'after' }, + message: 'Record account/r1 was modified by another user ' + + '(current version 2026-09-02T10:00:00.000Z, expected 2026-09-02T09:00:00.000Z)', + }), + object: 'account', + restored: ['currentVersion', 'currentRecord', 'object'], + expect: { + status: 409, + body: { + error: 'Record account/r1 was modified by another user ' + + '(current version 2026-09-02T10:00:00.000Z, expected 2026-09-02T09:00:00.000Z)', + code: 'CONCURRENT_UPDATE', + currentVersion: '2026-09-02T10:00:00.000Z', + currentRecord: { id: 'r1', name: 'after' }, + object: 'account', + }, + }, + }, + { + covers: ['DUPLICATE_RECORD'], + what: "objectql's DuplicateRecordError (#14095/#14389; readonly `status = 409`)", + error: () => new DuplicateRecordError('duly_note', new Error('SQLITE_CONSTRAINT_UNIQUE'), 'email'), + restored: ['field', 'object', 'developerMessage'], + expect: { + status: 409, + body: { + error: 'A record with this email already exists', + code: 'UNIQUE_VIOLATION', + developerMessage: "Duplicate record refused on 'duly_note': a unique constraint on " + + "'email' already holds this value. No record was written.", + field: 'email', + object: 'duly_note', + }, + }, + }, + { + covers: ['FEEDS_DISABLED'], + what: "plugin-audit's enable.feeds gate (`err.status = 403`, `err.object`)", + error: () => { + const err: any = new Error("Comments are disabled for object 'account' (enable.feeds: false)"); + err.code = 'FEEDS_DISABLED'; + err.status = 403; + err.object = 'account'; + return err; + }, + restored: ['object'], + expect: { + status: 403, + body: { + error: "Comments are disabled for object 'account' (enable.feeds: false)", + code: 'FEEDS_DISABLED', + object: 'account', + }, + }, + }, + { + covers: ['FILES_DISABLED'], + what: "plugin-audit's enable.files gate (`err.status = 403`, `err.object`)", + error: () => { + const err: any = new Error("File attachments are not enabled for object 'account'"); + err.code = 'FILES_DISABLED'; + err.status = 403; + err.object = 'account'; + return err; + }, + restored: ['object'], + expect: { + status: 403, + body: { + error: "File attachments are not enabled for object 'account'", + code: 'FILES_DISABLED', + object: 'account', + }, + }, + }, + { + covers: ['ATTACHMENT_PARENT_ACCESS'], + what: "service-storage's forbid() (`err.status = 403`, `err.object`)", + error: () => { + const err: any = new Error('You cannot attach files to a record you cannot see.'); + err.code = 'ATTACHMENT_PARENT_ACCESS'; + err.status = 403; + err.object = 'account'; + return err; + }, + restored: ['object'], + expect: { + status: 403, + body: { + error: 'You cannot attach files to a record you cannot see.', + code: 'ATTACHMENT_PARENT_ACCESS', + object: 'account', + }, + }, + }, + { + covers: ['ATTACHMENT_DELETE_DENIED'], + what: "service-storage's forbid() (`err.status = 403`, `err.object`)", + error: () => { + const err: any = new Error('Only the uploader or a parent editor can delete this file.'); + err.code = 'ATTACHMENT_DELETE_DENIED'; + err.status = 403; + err.object = 'account'; + return err; + }, + restored: ['object'], + expect: { + status: 403, + body: { + error: 'Only the uploader or a parent editor can delete this file.', + code: 'ATTACHMENT_DELETE_DENIED', + object: 'account', + }, + }, + }, + { + covers: ['RECORD_NOT_ACCESSIBLE'], + what: "plugin-audit's / service-storage's deny (`err.status = 403`, `err.object`)", + error: () => { + const err: any = new Error('Record access denied'); + err.code = 'RECORD_NOT_ACCESSIBLE'; + err.status = 403; + err.object = 'account'; + return err; + }, + restored: ['object'], + expect: { + status: 403, + body: { error: 'Record access denied', code: 'RECORD_NOT_ACCESSIBLE', object: 'account' }, + }, + }, + { + covers: ['INVALID_FIELD'], + what: "engine.ts's unknown-field refusal (`err.status = 400`, `err.field`, `err.object`)", + error: () => { + const err: any = new Error("Unknown field 'emial' on object 'account'"); + err.status = 400; + err.code = 'INVALID_FIELD'; + err.field = 'emial'; + err.fields = ['emial']; + err.object = 'account'; + return err; + }, + restored: ['field', 'object'], + expect: { + status: 400, + body: { + error: "Unknown field 'emial' on object 'account'", + code: 'INVALID_FIELD', + field: 'emial', + object: 'account', + }, + }, + }, + { + // The CONTROL, and the ruling this card generalises: this code has had + // door parity since #3770, by a named exclusion on the passthrough. + // Its body must be byte-identical before and after. + covers: ['OBJECT_NOT_FOUND'], + what: "metadata-protocol's registry gate (`err.status = 404`, `err.object`) — #3770 control", + error: () => { + const err: any = new Error("Object 'nope' not found"); + err.code = 'OBJECT_NOT_FOUND'; + err.status = 404; + err.object = 'nope'; + return err; + }, + restored: [], + expect: { + status: 404, + body: { error: "Object 'nope' is not registered", code: 'OBJECT_NOT_FOUND', object: 'nope' }, + }, + }, + { + // A CONTROL of the other kind: measured, this producer declares NO + // `status`, so the passthrough never fired for it and both doors + // already agreed. The parity case exists so that stays true if a + // future producer starts declaring one. + covers: ['VALIDATION_FAILED'], + what: '@objectstack/types `validationFailure()` — declares no `status` (control)', + error: () => { + const err: any = new Error('email is required'); + err.name = 'ValidationError'; + err.code = 'VALIDATION_FAILED'; + err.fields = [{ field: 'email', code: 'required', message: 'email is required' }]; + return err; + }, + object: 'account', + restored: [], + expect: { + status: 400, + body: { + error: 'email is required', + code: 'VALIDATION_FAILED', + fields: [{ field: 'email', code: 'required', message: 'email is required' }], + object: 'account', + }, + }, + }, + { + // The third control: a 5xx ARM. Its producer declares no `status` + // either, so both doors reach it today; §4 pins that it can never + // displace a status a producer DID declare. + covers: ['ERR_DATASOURCE_UNAVAILABLE'], + what: "objectql's DatasourceUnavailableError — declares no `status` (control)", + error: () => { + const err: any = new Error("Datasource 'warehouse' is declared but not connected"); + err.code = 'ERR_DATASOURCE_UNAVAILABLE'; + err.datasource = 'warehouse'; + err.kind = 'blocked'; + return err; + }, + object: 'account', + restored: [], + expect: { + status: 503, + body: { + error: "Datasource 'warehouse' is declared but not connected", + code: 'ERR_DATASOURCE_UNAVAILABLE', + datasource: 'warehouse', + reason: 'blocked', + object: 'account', + }, + }, + }, +]; + +describe('#14541 · structured arms are consulted by BOTH doors', () => { + describe('§1 door parity — one refusal, one body', () => { + for (const c of CASES) { + it(`${c.covers.join('/')} — ${c.what}`, () => { + const single = singleDoor(c.error(), c.object); + const bulk = bulkDoor(c.error(), c.object); + expect(bulk.status).toBe(single.status); + expect(bulk.body).toEqual(single.body); + // ADR-0112: the refusal is asserted by code AND status, never + // by "it errored". + expect(bulk.status).toBe(c.expect.status); + expect(bulk.body).toEqual(c.expect.body); + }); + } + }); + + describe('§2 the fields the bulk door used to drop', () => { + for (const c of CASES.filter((x) => x.restored.length > 0)) { + it(`${c.covers.join('/')} carries ${c.restored.join(', ')}`, () => { + const bulk = bulkDoor(c.error(), c.object); + for (const key of c.restored) { + expect(bulk.body).toHaveProperty(key, (c.expect.body as any)[key]); + } + }); + } + }); + + describe('§3 the two sending doors agree with each other', () => { + for (const c of CASES) { + it(`${c.covers.join('/')}`, () => { + expect(routeDoor(c.error(), c.object)).toEqual(bulkDoor(c.error(), c.object)); + }); + } + }); + + describe('§4 the guards this fix is fenced by', () => { + it('a producer-declared 5xx still takes the passthrough, prose withheld (#5437/#5582)', () => { + const err: any = new Error('Cannot delete: dependent records exist'); + err.code = 'DELETE_RESTRICTED'; + err.status = 503; + err.object = 'account'; + err.dependentObject = 'contact'; + const bulk = bulkDoor(err, 'account'); + expect(bulk.status).toBe(503); + expect(bulk.body.code).toBe('DELETE_RESTRICTED'); + // The 5xx arm's whole point: the producer's own sentence never + // reaches the client, and nothing about that is narrowed here. + expect(bulk.body.error).not.toBe('Cannot delete: dependent records exist'); + expect(bulk.body).not.toHaveProperty('dependentObject'); + }); + + it('a 5xx ARM never displaces a status the producer declared', () => { + const err: any = new Error("Datasource 'warehouse' is declared but not connected"); + err.code = 'ERR_DATASOURCE_UNAVAILABLE'; + err.status = 400; + err.datasource = 'warehouse'; + const bulk = bulkDoor(err, 'account'); + expect(bulk.status).toBe(400); + expect(bulk.body.code).toBe('ERR_DATASOURCE_UNAVAILABLE'); + }); + + it('a sandboxed producer keeps the unwrap door’s sentence (#11588/#7543)', () => { + const err: any = new Error("hook 'guard' threw: Error: Opportunity is closed."); + err.innerMessage = 'Opportunity is closed.'; + err.code = 'DELETE_RESTRICTED'; + err.status = 409; + err.object = 'account'; + err.dependentObject = 'contact'; + const bulk = bulkDoor(err, 'account'); + expect(bulk.status).toBe(409); + expect(bulk.body.error).toBe('Opportunity is closed.'); + // ⛔ never the QuickJS debug wrapper. + expect(String(bulk.body.error)).not.toContain('threw:'); + }); + + it('a sandboxed OBJECT_NOT_FOUND keeps its `object` (the surviving #3770 clause)', () => { + const err: any = new Error("hook 'guard' threw: Error: Object 'nope' not found"); + err.innerMessage = "Object 'nope' not found"; + err.code = 'OBJECT_NOT_FOUND'; + err.status = 404; + err.object = 'nope'; + const bulk = bulkDoor(err, 'nope'); + expect(bulk.status).toBe(404); + expect(bulk.body.code).toBe('OBJECT_NOT_FOUND'); + expect(bulk.body).toHaveProperty('object', 'nope'); + }); + + it('a producer-marked `userMessage` rides the restored body (#9934)', () => { + const err: any = new Error('Cannot delete: dependent records exist'); + err.code = 'DELETE_RESTRICTED'; + err.status = 409; + err.object = 'account'; + err.userMessage = '请先处理关联的联系人。'; + const bulk = bulkDoor(err, 'account'); + const single = singleDoor(err, 'account'); + expect(bulk.body).toEqual(single.body); + expect(bulk.body).toHaveProperty('userMessage', '请先处理关联的联系人。'); + }); + }); + + describe('§5 drift guard — every arm in the shared classification is covered', () => { + it('no `code` literal in structuredCodeAnswer is missing a §1 case', () => { + const source = readFileSync(resolve(HERE, 'error-response.ts'), 'utf8'); + const start = source.indexOf('function structuredCodeAnswer('); + const end = source.indexOf('function classifyDataError(', start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + const slice = source.slice(start, end); + const declared = new Set(); + for (const m of slice.matchAll(/error\?\.code === '([A-Z_]+)'/g)) declared.add(m[1]); + const covered = new Set(CASES.flatMap((c) => c.covers)); + expect(declared.size).toBeGreaterThan(0); + expect([...declared].filter((code) => !covered.has(code))).toEqual([]); + }); + }); +}); From e8871f398aff19de7c9024d5c040121c71350dca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:28:56 +0000 Subject: [PATCH 3/6] docs(rest): site the sandbox-origin clause note beside the gate it explains Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- packages/rest/src/error-response.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index 0bea8c7883..ef84282f7c 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -936,12 +936,6 @@ function structuredCodeAnswer( }, }; } - // [#14541] Gated on `!isSandboxOrigin` because this arm used to sit BELOW - // the sandbox unwrap door and now sits above it. The clause is that - // position, written down: the sentence this arm ships is `error.message`, - // which for a sandboxed producer is the QuickJS DEBUG WRAPPER, and the - // unwrap door owns that producer (#11588 / #7543). Without it, lifting the - // arm would ship the wrapper on both doors. // [#3770] Object does not exist — thrown by the protocol's registry gate // (`assertObjectRegistered`, which covers every data entry point) and by // `cloneData`. Mapped to the SAME envelope the driver-string branch below @@ -950,6 +944,12 @@ function structuredCodeAnswer( // point of #3770 is that this 404 no longer depends on a driver erroring // on a missing table. Must precede the generic 4xx passthrough, which // would otherwise ship the internal SCREAMING_CASE code verbatim. + // [#14541] Gated on `!isSandboxOrigin` because this arm used to sit BELOW + // the sandbox unwrap door and now sits above it. The clause is that + // position, written down: the sentence this arm ships is `error.message`, + // which for a sandboxed producer is the QuickJS DEBUG WRAPPER, and the + // unwrap door owns that producer (#11588 / #7543). Without it, lifting the + // arm would ship the wrapper on both doors. if (error?.code === 'OBJECT_NOT_FOUND' && !isSandboxOrigin(error)) { const name = error?.object ?? object; return { @@ -961,12 +961,6 @@ function structuredCodeAnswer( }, }; } - // [#14541] Gated on `!isSandboxOrigin` because this arm used to sit BELOW - // the sandbox unwrap door and now sits above it. The clause is that - // position, written down: the sentence this arm ships is `error.message`, - // which for a sandboxed producer is the QuickJS DEBUG WRAPPER, and the - // unwrap door owns that producer (#11588 / #7543). Without it, lifting the - // arm would ship the wrapper on both doors. // [#4134] Unknown field named by a READ — the protocol's list normalizer // refusing to lower a query parameter that matches no field into an // implicit filter that could only ever match zero rows. Emitted in the SAME @@ -974,6 +968,9 @@ function structuredCodeAnswer( // form of the identical mistake), so one condition has one wire shape no // matter which layer noticed it. Must precede the generic 4xx passthrough, // which would ship the message but drop `field`. + // [#14541] `!isSandboxOrigin`: the same clause as the arm above, for the + // same reason — this arm ships `error.message`, and the unwrap door owns + // the sandboxed producer. if (error?.code === 'INVALID_FIELD' && !isSandboxOrigin(error)) { const name = error?.object ?? object; return { From c8b49edeb11a7c1184ab170f895c2cbb0f6da80b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 20:44:16 +0000 Subject: [PATCH 4/6] fix(rest): both doors ask one rule for a 5xx arm vs a declared 4xx; correct three docblock claims Contract-review conditions 2, 4(A), 5 and 7 on #14541. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- packages/rest/src/error-response.ts | 123 ++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 14 deletions(-) diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index ef84282f7c..16c5239683 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -674,6 +674,34 @@ function isSandboxOrigin(error: any): boolean { return typeof error?.innerMessage === 'string' && error.innerMessage.length > 0; } +/** + * [#14541, contract-review condition 4] A structured arm answering a **5xx** + * never displaces a status the producer declared in the **4xx** band — asked by + * BOTH doors, so the answer cannot depend on which one caught the error. + * + * The 5xx band is fenced out of this card in both directions: a producer-declared + * 5xx keeps {@link resolveErrorResponse}'s prose-withholding arm (#5437 / #5582 / + * #5907), and — this rule — a 5xx-answering arm never overrides a caller-facing + * 4xx the producer named. Only one arm answers a 5xx today + * (`ERR_DATASOURCE_UNAVAILABLE`'s 503) and its producer declares no `status` at + * all, so this changes nothing on the wire; it is here because the review + * measured the two doors giving `503` and `400` for the same synthesised error, + * which made "identical by construction" false in a shape no test held. + * + * ⛔ Deliberately NOT the guard-1 rule as well. A producer-declared 5xx meeting a + * 4xx arm is a DIFFERENT question, answered per door on purpose and pinned as a + * named divergence in `error-response-structured-arm-door-parity.test.ts` §4 + * rather than silently converged here. + */ +function fiveXxArmDisplacesDeclared4xx( + error: any, + structured: { status: number } | undefined, +): boolean { + if (structured === undefined || structured.status < 500) return false; + const declared = error?.status; + return typeof declared === 'number' && declared >= 400 && declared < 500; +} + /** * [#14541] The bespoke structured arms, in ONE place, so BOTH REST error doors * can ask them FIRST. @@ -723,9 +751,54 @@ function isSandboxOrigin(error: any): boolean { * error `name` where a class is the contract. Nothing here reads message TEXT * to decide WHICH condition this is; that is the line, and it is why the * `PERMISSION_DENIED` arm (whose third limb sniffs a `[Security] Access denied` - * prefix) and the sandbox unwrap door stay in {@link classifyDataError} below, - * ahead of nothing. Answering `undefined` means "no bespoke arm knows this - * error" — the caller decides what that means for its own door. + * prefix) stays in {@link classifyDataError} below rather than being lifted. + * Answering `undefined` means "no bespoke arm knows this error" — the caller + * decides what that means for its own door, and + * {@link fiveXxArmDisplacesDeclared4xx} is the one condition BOTH doors put on + * taking the answer. + * + * ## What the `UNIQUE_VIOLATION` answer restores, per driver + * + * Corrected under the #14541 contract review (condition 7), which measured the + * earlier statement backwards. + * + * On the **SQL** drivers the bulk doors answered `409 UNIQUE_VIOLATION` with the + * curated sentence and `field` until #14095: the raw driver error declares no + * `status`, so it fell past the declared-status passthrough into + * `isUniqueViolationError` below. #14095's envelope DOES declare one, so from + * then on those doors answered the engine spelling. This restores them. + * + * On **driver-memory** the wire CODE was already `UNIQUE_VIOLATION` before + * #14095 and never moved: its raw refusal declares `code = 'UNIQUE_VIOLATION'` + * and `status = 409` itself (`memory-unique-constraint.ts`, `conflictRefusal`), + * so it took the passthrough — which relays a REGISTERED code verbatim through + * {@link thrownCodeFields}. What changes for that driver is the SENTENCE: its + * raw message quotes the offending values as JSON, and the curated one does + * not. ⛔ So "the code is new there" is the wrong way round; the withheld value + * is the change. + * + * ## ⚠️ A vocabulary fork this lifts onto routes where the other side lives + * + * Disclosed under the #14541 contract review (condition 2) rather than implied + * away by the "one condition, one wire code" framing above, which is true of + * the DOORS and not of the rows beside them. + * + * The `DUPLICATE_RECORD` arm answers the wire spelling `UNIQUE_VIOLATION` + * (#14389's ruling). A batch or import ROW does not go through this + * classification at all: `metadata-protocol`'s `toRowApiError` puts a thrown + * REGISTERED code on the row verbatim, and `import-runner`'s row report does + * the same, so a `DuplicateRecordError` row reports `DUPLICATE_RECORD` — + * deliberately, per #14095. ⇒ after this change a WHOLE-REQUEST failure on + * `POST /data/:object/batch` or `POST /data/:object/import` answers + * `UNIQUE_VIOLATION` while a ROW failure on the SAME route answers + * `DUPLICATE_RECORD`. Neither half is new and neither is a regression; what is + * new is that the two now sit side by side in one route's responses. + * + * ⛔ Not decided here, and deliberately not decided by this file: the ledger's + * "if it merely re-spells a standard member, that registration is a recorded + * waiver" and ADR-0112's one-name-per-concept both bear on it, and moving + * either spelling is a published-contract change rather than a door's call. + * **#14723 carries the decision.** */ function structuredCodeAnswer( error: any, @@ -944,12 +1017,24 @@ function structuredCodeAnswer( // point of #3770 is that this 404 no longer depends on a driver erroring // on a missing table. Must precede the generic 4xx passthrough, which // would otherwise ship the internal SCREAMING_CASE code verbatim. - // [#14541] Gated on `!isSandboxOrigin` because this arm used to sit BELOW - // the sandbox unwrap door and now sits above it. The clause is that - // position, written down: the sentence this arm ships is `error.message`, - // which for a sandboxed producer is the QuickJS DEBUG WRAPPER, and the - // unwrap door owns that producer (#11588 / #7543). Without it, lifting the - // arm would ship the wrapper on both doors. + // [#14541, corrected under contract-review condition 5] Gated on + // `!isSandboxOrigin` because this arm used to sit BELOW the sandbox unwrap + // door and now sits above it. The clause is that POSITION, written down — + // and position is its WHOLE justification here. ⛔ Not the sibling arm's + // reason: this arm ships a FIXED sentence (`Object '…' is not registered`), + // never `error.message`, so no debug wrapper could reach the wire through + // it. What the clause preserves is which DOOR answers a sandboxed producer: + // on `origin/main` the unwrap door (#11588 / #7543) got there first and + // shipped `innerMessage` with the producer's declared status, and without + // this clause the lift would have taken that answer away from it. + // + // One measured consequence, stated rather than left to be rediscovered: a + // sandboxed producer declaring a **5xx** with this code used to fall PAST + // the unwrap door (declared >= 500) into this arm and answer `404`. It now + // keeps its declared 5xx with the prose withheld, on both doors. That is + // #5582's rule rather than this arm's, and it is a status move on the + // single-record door — pinned in + // `error-response-structured-arm-door-parity.test.ts` §4. if (error?.code === 'OBJECT_NOT_FOUND' && !isSandboxOrigin(error)) { const name = error?.object ?? object; return { @@ -968,9 +1053,11 @@ function structuredCodeAnswer( // form of the identical mistake), so one condition has one wire shape no // matter which layer noticed it. Must precede the generic 4xx passthrough, // which would ship the message but drop `field`. - // [#14541] `!isSandboxOrigin`: the same clause as the arm above, for the - // same reason — this arm ships `error.message`, and the unwrap door owns - // the sandboxed producer. + // [#14541] `!isSandboxOrigin`: the same clause as the arm above, and here + // it carries the sentence reason TOO — this arm really does ship + // `error.message`, which for a sandboxed producer is the QuickJS debug + // wrapper #11588 exists to keep off this wire. The same declared-5xx status + // move recorded above applies to this code as well. if (error?.code === 'INVALID_FIELD' && !isSandboxOrigin(error)) { const name = error?.object ?? object; return { @@ -990,8 +1077,13 @@ function classifyDataError(error: any, object?: string): { status: number; body: // [#14541] The bespoke structured arms first, exactly as they were inline // here — same arms, same order, same position — now stated once so // {@link resolveErrorResponse} can ask them before ITS passthrough too. + // + // The one condition on taking their answer is + // {@link fiveXxArmDisplacesDeclared4xx}, asked at BOTH doors: a 5xx arm does + // not override a 4xx the producer declared. Without it this door answered + // `503` where the other answered the declared `400`, for the same error. const structured = structuredCodeAnswer(error, object); - if (structured !== undefined) return structured; + if (structured !== undefined && !fiveXxArmDisplacesDeclared4xx(error, structured)) return structured; // Short-circuit: explicit security denial → 403. Match by `code` / // `name` to avoid pulling a runtime dependency on plugin-security. if ( @@ -1833,7 +1925,10 @@ function resolveErrorResponse(error: any, object?: string): { status: number; bo // for it. const structured = isSandboxOrigin(error) ? undefined : structuredCodeAnswer(error, object); const declaresServerBand = typeof error?.status === 'number' && error.status >= 500 && error.status < 600; - if (structured !== undefined && structured.status < 500 && !declaresServerBand) { + if (structured !== undefined + && !fiveXxArmDisplacesDeclared4xx(error, structured) + && structured.status < 500 + && !declaresServerBand) { return mapDataError(error, object); } // [#3770] `OBJECT_NOT_FOUND` is deliberately excluded from this From 55cee2bf4d9ab06f6ffe4244e7e619d1fa73bb8a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 21:05:41 +0000 Subject: [PATCH 5/6] test(rest): assert both doors in every guard case, scan both halves for drift, pin the refusal families Contract-review conditions 3, 4, 5 and 6 on #14541. The extended drift guard found a real tenth instance on its first run (RECORD_NOT_FOUND), recorded as a known-gap entry citing #14725 rather than silently excused. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- ...esponse-structured-arm-door-parity.test.ts | 349 ++++++++++++++++-- 1 file changed, 310 insertions(+), 39 deletions(-) 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 3cf0ac5deb..65c5f74f7f 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 @@ -41,14 +41,26 @@ * again is a red test rather than a re-measurement; * §3 `handleRouteError` and `sendThrownError` agree with each other (they * share `resolveErrorResponse`; the pin keeps that true); - * §4 the three guards the fix is fenced by — a declared 5xx keeps the + * §4 the guards the fix is fenced by, EVERY case asserting BOTH doors and + * 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, and a sandboxed producer keeps the - * unwrap door's sentence (#11588 / #7543); - * §5 the drift guard: every `code` literal inside `structuredCodeAnswer` is - * covered by a §1 case. Adding an arm without a parity case fails here, - * which is the whole point — an exclusion list is what this card is the - * bill for, and a coverage list nobody checks is the same defect. + * 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 + * — a sandboxed 5xx carrying `OBJECT_NOT_FOUND` / `INVALID_FIELD` — is + * pinned rather than described; + * §5 the drift guard, over BOTH halves of `classifyDataError`: every + * declared-code arm — inside the shared classification AND below the + * consult, the position that produced this card — is either a §1 parity + * case or a NAMED single-door arm carrying its reason; + * §6 the two families that reach this door through `classifiedRefusalAnswer` + * rather than a route catch: the analytics dataset face, whose body + * genuinely gains `field` and `object`, pinned at KEY level because its + * own envelope tests assert only `code` and a message shape; and the + * record-share family, whose key set does not move. + * + * §4, §5 and §6 in these shapes are the contract review's conditions 3, 4, 5 + * and 6 on this card (verdict `PASS WITH CONDITIONS`, 2026-09-02). * * Refusal assertions state `code` AND `status` (ADR-0112) — never * `toThrow()` alone, which is green for a curated envelope and for a raw @@ -61,7 +73,12 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { DuplicateRecordError } from '@objectstack/objectql'; import { ConcurrentUpdateError } from '@objectstack/metadata-protocol'; -import { mapDataError, sendThrownError, handleRouteError } from './error-response.js'; +import { + mapDataError, + sendThrownError, + handleRouteError, + classifiedRefusalAnswer, +} from './error-response.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -405,33 +422,59 @@ describe('#14541 · structured arms are consulted by BOTH doors', () => { } }); - describe('§4 the guards this fix is fenced by', () => { - it('a producer-declared 5xx still takes the passthrough, prose withheld (#5437/#5582)', () => { - const err: any = new Error('Cannot delete: dependent records exist'); - err.code = 'DELETE_RESTRICTED'; - err.status = 503; - err.object = 'account'; - err.dependentObject = 'contact'; - const bulk = bulkDoor(err, 'account'); - expect(bulk.status).toBe(503); - expect(bulk.body.code).toBe('DELETE_RESTRICTED'); - // The 5xx arm's whole point: the producer's own sentence never - // reaches the client, and nothing about that is narrowed here. - expect(bulk.body.error).not.toBe('Cannot delete: dependent records exist'); - expect(bulk.body).not.toHaveProperty('dependentObject'); - }); - - it('a 5xx ARM never displaces a status the producer declared', () => { + /** + * [contract-review condition 4 / 5] Every guard case asserts BOTH doors. + * + * The review measured §4 pinning the bulk door alone in two shapes where + * the doors are known to diverge, which pins the divergence silently — the + * opposite of what triage guard 3 asks for. So each case below states its + * verdict as one of two things and never as a single door's answer: + * + * CONVERGED — both doors answer the same status AND body; + * ACCEPTED DIVERGENCE — they differ, on purpose, with the reason and the + * card that owns it named in the case itself. + */ + describe('§4 the guards this fix is fenced by — both doors, every case', () => { + it('CONVERGED: a 5xx ARM never displaces a status the producer declared in the 4xx band', () => { + // Reviewer probe (A). Before the patch round the doors answered + // 503 (single) and 400 (bulk) for this one error, because the + // `structured.status < 500` guard existed only in + // `resolveErrorResponse`. `fiveXxArmDisplacesDeclared4xx` is now + // asked at both. No producer declares a status on this code, so + // nothing moves on the wire. const err: any = new Error("Datasource 'warehouse' is declared but not connected"); err.code = 'ERR_DATASOURCE_UNAVAILABLE'; err.status = 400; err.datasource = 'warehouse'; const bulk = bulkDoor(err, 'account'); + const single = singleDoor(err, 'account'); expect(bulk.status).toBe(400); + expect(single.status).toBe(400); + expect(single.body.code).toBe('ERR_DATASOURCE_UNAVAILABLE'); expect(bulk.body.code).toBe('ERR_DATASOURCE_UNAVAILABLE'); + // ⚠️ The bodies still differ by ONE key, and it is not this card's + // defect: `classifyDataError`'s GENERIC declared-status passthrough + // appends `object` from the door's argument and + // `resolveErrorResponse`'s does not. Same door-disagreement class, + // one arm over, untouched here and filed as #14725 — pinned so the + // residue is visible rather than implied. + expect(single.body).toHaveProperty('object', 'account'); + expect(bulk.body).not.toHaveProperty('object'); + }); + + it('CONVERGED: the arm still answers 503 when the producer declared NO status', () => { + const err: any = new Error("Datasource 'warehouse' is declared but not connected"); + err.code = 'ERR_DATASOURCE_UNAVAILABLE'; + err.datasource = 'warehouse'; + err.kind = 'blocked'; + const bulk = bulkDoor(err, 'account'); + const single = singleDoor(err, 'account'); + expect(bulk.status).toBe(503); + expect(bulk.body).toEqual(single.body); + expect(bulk.body).toHaveProperty('datasource', 'warehouse'); }); - it('a sandboxed producer keeps the unwrap door’s sentence (#11588/#7543)', () => { + it('ACCEPTED DIVERGENCE (#14704): a sandboxed producer — bulk door unwraps, single door does not', () => { const err: any = new Error("hook 'guard' threw: Error: Opportunity is closed."); err.innerMessage = 'Opportunity is closed.'; err.code = 'DELETE_RESTRICTED'; @@ -439,25 +482,103 @@ describe('#14541 · structured arms are consulted by BOTH doors', () => { err.object = 'account'; err.dependentObject = 'contact'; const bulk = bulkDoor(err, 'account'); + const single = singleDoor(err, 'account'); expect(bulk.status).toBe(409); + expect(single.status).toBe(409); + // The bulk door reads `sandboxBusinessMessage` (#11588) and ships + // the business sentence; ⛔ never the QuickJS debug wrapper. expect(bulk.body.error).toBe('Opportunity is closed.'); - // ⛔ never the QuickJS debug wrapper. 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."); + }); + + it('ACCEPTED DIVERGENCE (guard 1): a producer-declared 5xx keeps the passthrough on the bulk door', () => { + const err: any = new Error('Cannot delete: dependent records exist'); + err.code = 'DELETE_RESTRICTED'; + err.status = 503; + err.object = 'account'; + err.dependentObject = 'contact'; + const bulk = bulkDoor(err, 'account'); + const single = singleDoor(err, 'account'); + // Guard 1: the 5xx half is NOT narrowed — status kept, prose + // dropped unconditionally (#5437 / #5582 / #5907). + expect(bulk.status).toBe(503); + expect(bulk.body.code).toBe('DELETE_RESTRICTED'); + expect(bulk.body.error).not.toBe('Cannot delete: dependent records exist'); + expect(bulk.body).not.toHaveProperty('dependentObject'); + // The single door keeps reaching the arm for this shape, exactly + // as it did before this card. Converging it would MOVE a status on + // a published door, which is outside this card's fence — so it is + // named here rather than silently pinned on one side. + expect(single.status).toBe(409); + expect(single.body).toHaveProperty('dependentObject', 'contact'); }); - it('a sandboxed OBJECT_NOT_FOUND keeps its `object` (the surviving #3770 clause)', () => { + it('CONVERGED: a sandboxed OBJECT_NOT_FOUND keeps its `object` (the surviving #3770 clause)', () => { const err: any = new Error("hook 'guard' threw: Error: Object 'nope' not found"); err.innerMessage = "Object 'nope' not found"; err.code = 'OBJECT_NOT_FOUND'; err.status = 404; err.object = 'nope'; const bulk = bulkDoor(err, 'nope'); + const single = singleDoor(err, 'nope'); expect(bulk.status).toBe(404); + expect(bulk.body).toEqual(single.body); expect(bulk.body.code).toBe('OBJECT_NOT_FOUND'); expect(bulk.body).toHaveProperty('object', 'nope'); }); - it('a producer-marked `userMessage` rides the restored body (#9934)', () => { + /** + * [contract-review condition 5] The one status this change DOES move, + * pinned rather than left as a sentence. + * + * On `origin/main` a sandboxed producer declaring a 5xx with code + * `OBJECT_NOT_FOUND` / `INVALID_FIELD` fell PAST the unwrap door + * (declared >= 500) into the arm below it and answered 404 / 400. The + * arms now carry `!isSandboxOrigin`, so the same shape keeps the + * declared 5xx with the prose withheld — #5582's rule, on both doors. + * Better, but a status move on the single-record door, and the + * changeset says so. + */ + it('MOVED (stated): a sandboxed 5xx + OBJECT_NOT_FOUND keeps the declared 5xx on both doors', () => { + const err: any = new Error("hook 'guard' threw: Error: Object 'nope' not found"); + err.innerMessage = "Object 'nope' not found"; + err.code = 'OBJECT_NOT_FOUND'; + err.status = 503; + err.object = 'nope'; + const bulk = bulkDoor(err, 'nope'); + const single = singleDoor(err, 'nope'); + expect(bulk.status).toBe(503); + expect(single.status).toBe(503); + expect(bulk.body).toEqual(single.body); + expect(bulk.body.code).toBe('OBJECT_NOT_FOUND'); + // #5437/#5582: the 5xx band never ships the producer's prose. + expect(bulk.body.error).not.toContain('nope'); + expect(bulk.body).not.toHaveProperty('object'); + }); + + it('MOVED (stated): a sandboxed 5xx + INVALID_FIELD keeps the declared 5xx on both doors', () => { + const err: any = new Error("hook 'guard' threw: Error: Unknown field 'emial'"); + err.innerMessage = "Unknown field 'emial'"; + err.code = 'INVALID_FIELD'; + err.status = 502; + err.field = 'emial'; + err.object = 'account'; + const bulk = bulkDoor(err, 'account'); + const single = singleDoor(err, 'account'); + expect(bulk.status).toBe(502); + expect(single.status).toBe(502); + expect(bulk.body).toEqual(single.body); + expect(bulk.body.code).toBe('INVALID_FIELD'); + expect(bulk.body).not.toHaveProperty('field'); + }); + + it('CONVERGED: a producer-marked `userMessage` rides the restored body (#9934)', () => { const err: any = new Error('Cannot delete: dependent records exist'); err.code = 'DELETE_RESTRICTED'; err.status = 409; @@ -470,19 +591,169 @@ describe('#14541 · structured arms are consulted by BOTH doors', () => { }); }); - describe('§5 drift guard — every arm in the shared classification is covered', () => { - it('no `code` literal in structuredCodeAnswer is missing a §1 case', () => { - const source = readFileSync(resolve(HERE, 'error-response.ts'), 'utf8'); - const start = source.indexOf('function structuredCodeAnswer('); - const end = source.indexOf('function classifyDataError(', start); - expect(start).toBeGreaterThan(-1); - expect(end).toBeGreaterThan(start); - const slice = source.slice(start, end); - const declared = new Set(); - for (const m of slice.matchAll(/error\?\.code === '([A-Z_]+)'/g)) declared.add(m[1]); + /** + * [contract-review condition 3] The drift guard has to cover BOTH halves + * of `classifyDataError`, not just the lifted one. + * + * The first version scanned only the `structuredCodeAnswer` slice. A new + * bespoke arm added to `classifyDataError` BELOW the consult — exactly + * where `OBJECT_NOT_FOUND` and `INVALID_FIELD` sat before this card — + * reproduces the card's defect (reachable from one door only) and nothing + * would have fired. Triage guard 3 asks that "the next code with a + * structured arm cannot diverge silently again", so the scan now collects + * every declared-code literal in the whole function and requires each to + * be either a §1 parity case or a NAMED single-door arm with its reason. + */ + describe('§5 drift guard — every declared-code arm is covered or explained', () => { + /** + * Arms reachable from `mapDataError` only. Two KINDS, deliberately kept + * apart — an allowlist that cannot tell "by design" from "not fixed + * yet" is the same defect as no allowlist: + * + * `by-design` the arm answers one door on purpose, and the entry + * says why the shared classification does not want it; + * `known-gap` the arm IS an instance of this card's defect, left + * open on purpose, and the entry MUST cite the card + * that carries it. Green by disclosure, never by + * omission. + * + * ⛔ Adding an entry is a decision, not a way to quiet the test. + */ + const SINGLE_DOOR_ONLY: ReadonlyArray<{ + code: string; + kind: 'by-design' | 'known-gap'; + card?: number; + why: string; + }> = [ + { + code: 'PERMISSION_DENIED', + kind: 'by-design', + why: 'its third limb is gated on message TEXT (`[Security] Access denied`), ' + + 'and the shared classification is declared-code only — lifting a text ' + + 'sniff above the passthrough is what `resolveErrorResponse` argues against. ' + + 'Measured: its producer declares `statusCode`, not `status`, so the ' + + 'passthrough never fires for it and both doors already agree.', + }, + { + code: 'RECORD_NOT_FOUND', + kind: 'known-gap', + card: 14725, + why: 'a REAL instance of this card\'s defect, found by this very guard: ' + + '`recordNotFoundError` (@objectstack/core) declares code, `status = 404` ' + + 'and `object`, so the bulk doors take the passthrough and drop `object` ' + + 'while the single door reaches the arm. Not lifted here because (a) its ' + + 'second limb is a message-TEXT gate, outside the declared-code boundary, ' + + 'and (b) this PR\'s wire delta was measured and contract-reviewed over a ' + + 'fixed set of codes — an eleventh body change after that verdict would be ' + + 'an unreviewed delta. #14725 carries it.', + }, + ]; + + function sliceOf(source: string, from: string, to: string): string { + const a = source.indexOf(from); + const b = source.indexOf(to, a + 1); + expect(a).toBeGreaterThan(-1); + expect(b).toBeGreaterThan(a); + return source.slice(a, b); + } + + function codeLiterals(slice: string): Set { + const out = new Set(); + for (const m of slice.matchAll(/error\?\.code === '([A-Z_]+)'/g)) out.add(m[1]); + return out; + } + + const SOURCE = readFileSync(resolve(HERE, 'error-response.ts'), 'utf8'); + + it('every arm in the SHARED classification has a §1 parity case', () => { + const declared = codeLiterals( + sliceOf(SOURCE, 'function structuredCodeAnswer(', 'function classifyDataError('), + ); const covered = new Set(CASES.flatMap((c) => c.covers)); expect(declared.size).toBeGreaterThan(0); expect([...declared].filter((code) => !covered.has(code))).toEqual([]); }); + + it('every arm BELOW the consult is a §1 case or a named single-door arm', () => { + // The whole function, so an arm added below the consult — the + // position that reproduced this card's defect — is seen too. + const declared = codeLiterals( + sliceOf(SOURCE, 'function classifyDataError(', '\nexport function sendThrownError('), + ); + const covered = new Set(CASES.flatMap((c) => c.covers)); + const excused = new Set(SINGLE_DOOR_ONLY.map((e) => e.code)); + expect(declared.size).toBeGreaterThan(0); + expect([...declared].filter((code) => !covered.has(code) && !excused.has(code))).toEqual([]); + }); + + it('the allowlist is not a dumping ground: every entry is a live arm, reasoned, and a gap cites its card', () => { + const whole = sliceOf(SOURCE, 'function classifyDataError(', '\nexport function sendThrownError('); + for (const entry of SINGLE_DOOR_ONLY) { + // An entry for an arm that no longer exists is stale cover. + expect(whole).toContain(`error?.code === '${entry.code}'`); + expect(entry.why.length).toBeGreaterThan(60); + // A known gap without a card is exactly the silence this + // guard exists to break. + if (entry.kind === 'known-gap') expect(typeof entry.card).toBe('number'); + } + }); + }); + + /** + * [contract-review condition 6] The two families that reach + * `resolveErrorResponse` through {@link classifiedRefusalAnswer} rather + * than through a route's `handleRouteError` catch. + * + * `POST /api/v1/analytics/dataset/query` spreads every classified key onto + * its own envelope (`const { error: refusalText, ...refusalFields } = + * refusal.body`), so a widened classification widens that body too — and + * its existing envelope tests assert `code` and a message regex only, so + * nothing held the KEYS. `service-analytics` throws `INVALID_FIELD` with + * `status`, `field` and `object` at three sites, which is exactly the arm + * this card lifted. + * + * Pinned against `classifiedRefusalAnswer` itself rather than by booting + * the route: that function IS what the route spreads, and `rest-server.ts` + * is held by another branch. + */ + describe('§6 the classifiedRefusalAnswer families', () => { + it('the analytics refusal body gains `field` and `object` for an INVALID_FIELD producer', () => { + const err: any = new Error("Unknown measure 'amout_sum' on object 'invoice'"); + err.code = 'INVALID_FIELD'; + err.status = 400; + err.field = 'amout_sum'; + err.object = 'invoice'; + err.param = 'measures'; + const refusal = classifiedRefusalAnswer(err); + expect(refusal).toBeDefined(); + expect(refusal!.status).toBe(400); + // The KEY SET is the contract here — the existing analytics + // envelope tests assert only `code` and a message shape. + expect(Object.keys(refusal!.body).sort()).toEqual(['code', 'error', 'field', 'object']); + expect(refusal!.body.code).toBe('INVALID_FIELD'); + expect(refusal!.body.field).toBe('amout_sum'); + expect(refusal!.body.object).toBe('invoice'); + // ⛔ `param` is NOT relayed: the arm ships what it declares, and a + // producer key nobody classified does not reach the wire. + expect(refusal!.body).not.toHaveProperty('param'); + }); + + it('the record-share family keeps its own key set; only the SENTENCE moves', () => { + // That family re-dresses `code` / `declaredCode` / `userMessage` / + // `error` into the nested ADR-0112 D5 envelope, so the widened + // classification cannot add keys there. What it does change is the + // sentence a DuplicateRecordError produces. + const err: any = new Error("Duplicate record refused on 'sys_record_share'"); + err.name = 'DuplicateRecordError'; + err.code = 'DUPLICATE_RECORD'; + err.status = 409; + err.object = 'sys_record_share'; + err.field = 'token'; + const refusal = classifiedRefusalAnswer(err); + expect(refusal).toBeDefined(); + expect(refusal!.status).toBe(409); + expect(refusal!.body.code).toBe('UNIQUE_VIOLATION'); + expect(refusal!.body.error).toBe('A record with this token already exists'); + }); }); }); From fad46ff20c3a6e404ffabe49eb370c0cb6e2f466 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 21:17:33 +0000 Subject: [PATCH 6/6] docs(changeset): correct four published claims the contract review measured wrong Conditions 1, 2, 5, 6 and 7 on #14541: the added keys are not ApiErrorSchema members; the row/door vocabulary fork is disclosed and carried by #14723; the two status moves are stated with per-door measurements; the analytics and record-share families are enumerated; the driver-memory direction is corrected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- ...rest-structured-arms-before-passthrough.md | 134 +++++++++++------- 1 file changed, 86 insertions(+), 48 deletions(-) diff --git a/.changeset/rest-structured-arms-before-passthrough.md b/.changeset/rest-structured-arms-before-passthrough.md index 4adc6b5df8..b1c263af65 100644 --- a/.changeset/rest-structured-arms-before-passthrough.md +++ b/.changeset/rest-structured-arms-before-passthrough.md @@ -25,7 +25,7 @@ That exclusion never grew past its first case. This change generalises the ruling instead of adding a second exclusion: the structured arms are lifted into one `structuredCodeAnswer` classification that BOTH doors ask first, and the bulk door answers a match by delegating to `mapDataError`, so the two -bodies are identical by construction rather than by coincidence. +bodies are the same by construction rather than by coincidence. **What callers on the bulk doors see change** — measured door-to-door, in process, against the real producer shapes: @@ -39,50 +39,88 @@ process, against the real producer shapes: | `ATTACHMENT_PARENT_ACCESS` / `ATTACHMENT_DELETE_DENIED` / `RECORD_NOT_ACCESSIBLE` (403) | `{error, code}` | `+ object` | | engine `INVALID_FIELD` (400) | `{error, code}` | `+ field, object` | -In every row the STATUS is unchanged — the doors already agreed on it — and no -key is removed. The added keys are the ones the single-record `/data` door has -always shipped for the same refusal, and all of them are already declared on -`ApiErrorSchema`. - -**One `code` VALUE changes, and it is a restoration.** On the bulk doors an -insert/update unique conflict answered `code: "UNIQUE_VIOLATION"` until #14095 -wrapped the driver error in `DuplicateRecordError`: the raw driver error -declared no `status`, so it fell through to `mapDataError`'s -`isUniqueViolationError` arm. The envelope DOES declare one, so from #14095 the -bulk doors started answering the engine spelling `DUPLICATE_RECORD` while the -single-record door kept `UNIQUE_VIOLATION` (#14389 restored that door -explicitly). This change puts `UNIQUE_VIOLATION` back on the bulk doors — the -spelling every consumer branching on this conflict already reads, and the one -the same doors answered before #14095. `DUPLICATE_RECORD` stays the in-process -code on the thrown envelope, exactly as each dialect's code always has. - -**Three boundaries this deliberately does NOT cross:** - -- **The passthrough's 5xx half is untouched.** A producer-declared 5xx still - takes that arm, with the unconditional prose-drop intact (#5437 / #5582 / - #5907). Fenced from the other side too: an arm that answers a 5xx - (`ERR_DATASOURCE_UNAVAILABLE`'s 503) never displaces a status a producer - declared in the 4xx band. -- **A sandboxed producer keeps the unwrap door's answer.** The arms ship - `error.message`, which for a QuickJS body is the DEBUG WRAPPER #11588 exists - to keep off this wire, so the consult declines an error carrying - `innerMessage` and the passthrough's `sandboxBusinessMessage` read answers it - as before. -- **`classifyDataError` is behaviour-identical.** The lifted arms are the same - arms in the same order at the same position; the two that used to sit below - the sandbox unwrap door (`OBJECT_NOT_FOUND`, `INVALID_FIELD`) carry that - position as an explicit `!isSandboxOrigin` clause rather than losing it to - the move. - -**Unchanged:** `OBJECT_NOT_FOUND` (its #3770 exclusion already gave it door -parity, and its body is pinned byte-identical), `VALIDATION_FAILED` and -`ERR_DATASOURCE_UNAVAILABLE` (measured: their producers declare no `status`, so -the passthrough never fired for them), `PERMISSION_DENIED` (measured: its class -declares `statusCode`, not `status`, so it too already reached its arm — and its -third limb is a message-TEXT gate, which this ordering fix deliberately does not -lift above the passthrough), the statuses on every door, and the `#5423` 4xx -truncation and `#9934` `userMessage` channel, which apply exactly as before. - -Both doors are now pinned against each other per producer, plus a drift guard -that fails when an arm is added to the shared classification without a parity -case — `error-response-structured-arm-door-parity.test.ts`. +In every row the STATUS is unchanged for that producer, and no key is removed. + +**Where the added keys come from, and how documented each is.** They are the +keys the single-record `/data` door has always shipped for the same refusal — +⛔ *not* keys declared on `ApiErrorSchema`, which declares exactly `code`, +`declaredCode`, `message`, `userMessage`, `category`, `httpStatus`, `details` +and `requestId` and none of these. Their published status, measured on +`origin/main`: + +- `field`, `object` — documented, `content/docs/protocol/kernel/http-protocol.mdx`, the 409 "Constraint Violations" body. +- `developerMessage`, `dependentObject`, `dependentCount` — documented, `content/docs/protocol/objectql/types.mdx` ("Required foreign keys") and `content/docs/api/data-api.mdx`. +- `currentVersion` — documented, `content/docs/api/wire-format.mdx` §7, "Concurrent Update — 409 Conflict". +- `currentRecord` — **shipped but undocumented**: no `content/docs/**` page describes it (the only textual match is the unrelated `currentRecordCount` tenant quota). It has been on the single-record door's 409 since the arm existed; this change puts it on the bulk doors too, still undocumented. + +**One `code` VALUE changes, and what it restores differs by driver.** On the +SQL drivers the bulk doors answered `409 UNIQUE_VIOLATION` with the curated +sentence and `field` until #14095: the raw driver error declares no `status`, +so it fell past this passthrough into `mapDataError`'s `isUniqueViolationError` +arm. #14095's `DuplicateRecordError` DOES declare one, so from then on those +doors answered the engine spelling `DUPLICATE_RECORD` while the single-record +door was restored explicitly by #14389. This puts `UNIQUE_VIOLATION` back on +the bulk doors. On **driver-memory** the wire code was **already** +`UNIQUE_VIOLATION` before #14095 and never moved — its raw refusal declares +`code = 'UNIQUE_VIOLATION'` and `status = 409` itself, so it took the +passthrough, which relays a registered code verbatim. What changes for that +driver is the SENTENCE: its raw message quotes the offending values as JSON and +the curated one does not. + +**⚠️ A vocabulary fork this puts side by side, disclosed rather than implied +away.** The `DUPLICATE_RECORD` arm answers the wire spelling +`UNIQUE_VIOLATION`. A batch or import ROW does not go through this +classification at all — `metadata-protocol`'s `toRowApiError` puts a thrown +registered code on the row verbatim, and `import-runner`'s row report does the +same, deliberately per #14095 — so after this change a **whole-request** failure +on `POST /data/:object/batch` or `POST /data/:object/import` answers +`UNIQUE_VIOLATION` while a **row** failure on the same route answers +`DUPLICATE_RECORD`. Neither half is new and neither is a regression; what is new +is that both spellings now appear in one route's responses. This change does not +pick a winner — the ledger's "if it merely re-spells a standard member, that +registration is a recorded waiver" and ADR-0112's one-name-per-concept both bear +on it, and moving either spelling is a published-contract change. **#14723 +carries the decision.** + +**Which doors.** Every route whose catch calls `handleRouteError` / +`sendThrownError`, plus each environment-scoped twin: `POST +/api/v1/data/:object/createMany`, `/updateMany`, `/deleteMany`, `/batch`, `POST +/api/v1/batch`, `POST /api/v1/data/:object/:id/clone`, `GET +/api/v1/data/:object/export`, the `POST /api/v1/data/:object/import` and +`/api/v1/data/import/jobs/…` family, `GET /api/v1/discovery`, `GET +/api/v1/openapi.json`, the `/api/v1/meta/**` family and `GET +/api/v1/ui/view/:object/:type`. Two more families reach the same door through +`classifiedRefusalAnswer` rather than a route catch, and one of them changes: + +- **`POST /api/v1/analytics/dataset/query`** (and its environment-scoped twin) spreads every classified key onto its own envelope, and `service-analytics` throws `INVALID_FIELD` with `status`, `field` and `object` at three sites — so **that route's error body gains `field` and `object`** exactly as the bulk doors do. Newly pinned at key level, because its own envelope tests assert `code` and a message shape only. +- **The record-share family** re-dresses only `code` / `declaredCode` / `userMessage` / `error` into the nested ADR-0112 D5 envelope, so its key set does not move; its `error` SENTENCE would change for a `DuplicateRecordError`. + +The single-record `/data` CRUD routes and `GET /api/v1/data/:object` call +`mapDataError` directly and are the reference this converges on; none of their +bodies move. `packages/rest/src/package-routes.ts` defines its own local +`sendThrownError` and is unaffected. + +**Three boundaries this does NOT cross:** + +- **The passthrough's 5xx half is untouched.** A producer-declared 5xx still takes that arm, prose dropped unconditionally (#5437 / #5582 / #5907). +- **A sandboxed producer keeps the unwrap door's answer** on the bulk door: the arms ship `error.message`, which for a QuickJS body is the debug wrapper #11588 exists to keep off this wire, so the consult declines an error carrying `innerMessage`. +- **The shared classification is declared-code only.** Nothing in it reads message TEXT to decide which condition an error is, which is why the `PERMISSION_DENIED` arm stays where it is. + +**⚠️ Two exceptions to "nothing else moves", stated because they are true and +were measured rather than reasoned:** + +1. **A sandboxed producer declaring a 5xx with `OBJECT_NOT_FOUND` or `INVALID_FIELD` now keeps that 5xx.** Before, it fell past the sandbox unwrap door (declared ≥ 500) into the arm below it; the arms now carry an explicit sandbox-origin clause, so it answers the declared status with the prose withheld — #5582's rule. Measured on `origin/main` and on this branch, both doors, per code: + + - `OBJECT_NOT_FOUND` + declared `503`: **both** doors answered `404 {"error":"Object 'nope' is not registered","code":"OBJECT_NOT_FOUND","object":"nope"}` and now answer `503 {"error":"Internal server error","code":"OBJECT_NOT_FOUND"}`. A status move on **both** doors, not one. + - `INVALID_FIELD` + declared `502`: the single-record door answered `400` with the QuickJS debug wrapper as `error` and now answers `502` with the prose withheld; the bulk door already answered `502` and does not move. + + So "behaviour-identical" is not strictly true and is not claimed. No producer in this repo throws either code from a sandboxed body with a declared 5xx, so no wire in service moves — the shapes are synthesised, and they are pinned rather than described. +2. **A structured arm answering a 5xx no longer overrides a 4xx the producer declared, on either door.** Only `ERR_DATASOURCE_UNAVAILABLE`'s 503 answers a 5xx, and its producer declares no `status` at all, so nothing moves on the wire — but the single-record door used to answer `503` for a synthesised `{code, status: 400}` where the bulk door answered `400`, and the two now agree. + +Both doors are pinned against each other per producer, every guard case asserts +BOTH doors as either a convergence or a *named* accepted divergence, and a +drift guard scans **both** halves of `classifyDataError` so an arm added below +the shared consult — the position that produced this card — cannot diverge +silently. That guard found one on its first run: `RECORD_NOT_FOUND` is a real +remaining instance, left open on purpose and recorded as a known gap citing +#14725 rather than quietly excused.