@@ -436,6 +436,59 @@ const DATA_STORE_FAULT = (): { status: number; body: Record<string, unknown> } =
436436 body : { error : 'Internal data error' , code : 'DATABASE_ERROR' } ,
437437} ) ;
438438
439+ /**
440+ * [#5489] The envelope for "nothing in this mapper recognised the error": a
441+ * sanitised 500 carrying the catalog's `INTERNAL_ERROR`.
442+ *
443+ * This is `mapDataError`'s TERMINAL branch, and until now it answered
444+ * `{ status: 400, error: <the raw message> }`. Both halves of that were wrong
445+ * in the same direction:
446+ *
447+ * - **400 says the CALLER is at fault**, and an SDK reads it as "do not
448+ * retry, fix the request". The errors that actually reach here are the ones
449+ * no branch above could attribute to the request at all — a metadata store
450+ * that cannot be read (`matchEndpoint` throws rather than answering an empty
451+ * set, precisely so an outage does not masquerade as a miss; ADR-0110 D3),
452+ * or a plain handler bug (`TypeError: x is not a function`). Both are server
453+ * faults that a caller cannot fix and a caller SHOULD retry. Measured on
454+ * `GET /api/v1/meta/api` with a store that throws
455+ * `Error('metadata store unreachable')`: HTTP 400 (#5224 / PR #5487 left the
456+ * assertion at `>= 400` rather than pin this as intended).
457+ * - **The raw message shipped verbatim**, which is the exact discipline
458+ * #5437/#5464 closed one branch up: a declared 5xx drops its prose because
459+ * length was never a proxy for leakage. An error that matched no heuristic
460+ * is the LEAST attributable text in the file — this branch is reached only
461+ * because `looksLikeInternalErrorLeak` said nothing, and #5462 already
462+ * recorded that a negative from a keyword heuristic is not evidence of
463+ * safety. The words still reach the operator: 500 is outside
464+ * `isExpectedDataStatus`, so `handleRouteError` prints `[REST] Unhandled
465+ * error` with the whole error, and `sendError`'s `logWithheldServerFault`
466+ * covers the routes that bypass it.
467+ *
468+ * `INTERNAL_ERROR` rather than {@link DATA_STORE_FAULT}'s `DATABASE_ERROR`, and
469+ * the distinction is deliberate: `DATA_STORE_FAULT` is emitted where the
470+ * evidence NAMES a store failure (a driver's missing-relation phrasing, a
471+ * `looksLikeInternalErrorLeak` hit), so it can honestly say "database". Here
472+ * the defining fact is that there is no evidence of anything — sending a
473+ * handler `TypeError` back as `DATABASE_ERROR` would point an operator at a
474+ * database that is fine. `INTERNAL_ERROR` is not a third vocabulary either: it
475+ * is what `standardErrorCodeForHttpStatus(500)` yields (`HttpStatusErrorCodeMap`
476+ * in `@objectstack/spec`) — the catalog's own floor for "500 with no more
477+ * specific code" — and the message is the same `INTERNAL_ERROR_MESSAGE` the
478+ * declared-5xx branch of {@link resolveErrorResponse} already emits.
479+ *
480+ * What did NOT move: every branch above this one. A client error is a 4xx here
481+ * because a producer DECLARED `status` in the 4xx band or because a branch
482+ * matched it by `code`/name/phrasing — validation, permission, unknown object,
483+ * unknown field, not-null drift, unique violation, the sandbox unwraps. This
484+ * branch is the one that had nothing to go on, and "no idea" is a server-side
485+ * answer, not a client-side one.
486+ */
487+ const UNCLASSIFIED_FAULT = ( ) : { status : number ; body : Record < string , unknown > } => ( {
488+ status : 500 ,
489+ body : { error : INTERNAL_ERROR_MESSAGE , code : 'INTERNAL_ERROR' } ,
490+ } ) ;
491+
439492/**
440493 * [#5462] Does a driver's missing-relation message name the very object this
441494 * request asked for?
@@ -899,7 +952,7 @@ export function mapDataError(error: any, object?: string): { status: number; bod
899952 }
900953 return DATA_STORE_FAULT ( ) ;
901954 }
902- return { status : 400 , body : { error : raw || 'Bad request' } } ;
955+ return UNCLASSIFIED_FAULT ( ) ;
903956}
904957
905958/**
@@ -1086,10 +1139,17 @@ function isExpectedQueryRejection(body: Record<string, unknown> | undefined): bo
10861139 * - `isExpectedQueryRejection` — the client-caused 400 vocabulary
10871140 * - `VALIDATION_FAILED` — the per-field 400 envelope
10881141 *
1089- * It is deliberately NOT "any 4xx". `mapDataError`'s final fallback degrades an
1090- * error it recognised nothing about to an un-coded 400, and that bucket is
1091- * where a genuine handler bug (a `TypeError`, say) lands — silencing it would
1092- * be the mirror-image of the defect this fixes.
1142+ * It is deliberately NOT "any 4xx". [#5489] That used to be argued from
1143+ * `mapDataError`'s final fallback, which degraded an error it recognised
1144+ * nothing about to an UN-CODED 400 — the bucket a genuine handler bug (a
1145+ * `TypeError`, say) landed in, so a predicate widened to "any 4xx is expected"
1146+ * would have silenced it. That fallback is now {@link UNCLASSIFIED_FAULT}'s
1147+ * 500, which this predicate cannot treat as expected at all
1148+ * (`isExpectedDataStatus` names 502/503 and nothing else in the 5xx band), so
1149+ * the handler bug is loud STRUCTURALLY rather than by this sentence. The
1150+ * narrowness still matters for what remains in the un-coded 4xx band — the
1151+ * sandbox unwraps' business-rule 400s — and for the next author tempted to
1152+ * simplify the predicate down to a status range.
10931153 *
10941154 * [#4886] Every route catch now decides through this one function. Before, the
10951155 * metadata family logged unconditionally — the designer's `?state=draft` probe
0 commit comments