diff --git a/.changeset/dispatcher-declared-5xx-prose-withhold.md b/.changeset/dispatcher-declared-5xx-prose-withhold.md new file mode 100644 index 0000000000..9145d560f9 --- /dev/null +++ b/.changeset/dispatcher-declared-5xx-prose-withhold.md @@ -0,0 +1,53 @@ +--- +"@objectstack/runtime": patch +--- + +fix(runtime): withhold the message of EVERY declared 5xx at the dispatcher exit, aligning to `/data` (#12281) + +**Clause-②: yes** — this changes an answer on a public REST door. + +`errorResponseBase` (`packages/runtime/src/dispatcher-plugin.ts`) serves the +dispatcher's mounted routes: `/analytics`, `/auth`, `/i18n`, `/automation`, +`/notifications`, `/mcp`, `/packages`. It gated its 5xx message withhold on +`declaresServerFault` — `status >= 500` **and** a non-empty string `code` — while +`/data` gates on `declaredHttpStatus`, which reads `status ?? statusCode` and +never consults `code`. Two bands of declared 5xx were therefore withheld at +`/data` and legible here. + +FROM → TO, on the wire, for a route served by the dispatcher plugin: + +| thrown by the producer | before | after | +|---|---|---| +| `{ status: 503 }`, no `code` | `{"error":{"message":"","httpStatus":503,…}}` | `{"error":{"message":"Internal server error","httpStatus":503,…}}` | +| `{ statusCode: 503, code: 'SERVICE_UNAVAILABLE' }` | `{"error":{"message":"",…}}` | `{"error":{"message":"Internal server error",…}}` | +| `{ statusCode: 503 }`, no `code` | `{"error":{"message":"",…}}` | `{"error":{"message":"Internal server error",…}}` | +| `{ status: 503, code: 'SERVICE_UNAVAILABLE' }` | `Internal server error` | `Internal server error` (unchanged) | +| a bare `Error` (declares nothing) | `` | `` (**unchanged**) | +| any declared 4xx | `` | `` (**unchanged**) | + +Only the `message` field changes. `code`, `httpStatus`, `declaredCode` and +`details` are untouched, so nothing a machine branches on moves, and the +untouched error still reaches the operator through the `__obsRecordedError` +side-channel and the log. + +Maintainer ruling 2026-08-27 on #12509 (option D), propagated to #12281: +`errorResponseBase` adopts the structural withhold for every declared 5xx +message, aligning to `/data`'s rule; the author-facing text channel is +`userMessage` (#9934), never the raw message. The judgement is **inherited**, +not re-derived: the door now reads `serverFaultProvenance` from +`@objectstack/types` — the same function `demotedDeclaredCode` already reads for +the code channel — so "one rule, every door inherits" (#12509) holds by +construction rather than by three doors agreeing. + +⛔ The gate is the **declared** status, never the resolved `httpStatus` (which +falls back to 500 for a throw that declared nothing). #5667's undeclared-5xx +tiering is preserved exactly: a bare `Error` from our own code stays legible and +still goes through the `looksLikeInternalErrorLeak` heuristic alone. + +Measured before the change and unchanged by it: the population that changes +hands at this door today is **empty** — `metadata-protocol`'s `deleteMetaItem` +reaches only the REST `/meta` door (the dispatcher plugin mounts neither `/meta` +nor `/data`), and `action-execution.ts`'s seven `statusCode` throws are all +caught before this exit. The alignment is a no-op on today's tree, which is why +now was the cheapest moment to make it: it costs no legibility that exists and +buys the invariant forward. diff --git a/packages/runtime/src/analytics-query-read-scope-withhold.test.ts b/packages/runtime/src/analytics-query-read-scope-withhold.test.ts index c0be935beb..b222b08937 100644 --- a/packages/runtime/src/analytics-query-read-scope-withhold.test.ts +++ b/packages/runtime/src/analytics-query-read-scope-withhold.test.ts @@ -44,6 +44,15 @@ * withhold got broader" and "the withhold swallowed everything" are one edit * apart. * + * ⚠️ [#12281] The DECLARED half of that predicate has since widened, and this + * file's half-envelope case was reversed with it. `declaresServerFault` required + * a non-empty string `code` beside the 5xx and read the `status` spelling only, + * so this exit withheld a NARROWER band than `/data`. Ruled 2026-08-27 on #12509 + * (option D): the door now reads `serverFaultProvenance` — "the producer named + * this 5xx itself", `status ?? statusCode`, with `code` not consulted — and + * withholds EVERY declared 5xx message. The UNDECLARED tiering below is untouched + * by that change and is exactly what it must not break. + * * ## Why this file boots the REAL analytics service * * Producer and boundary are different facts, and a hand-written `Object.assign(new @@ -260,14 +269,32 @@ describe('[#5811] POST /analytics/query — a read-scope failure says nothing ab expect(String(res.body.error.message)).toMatch(/no strategy can handle query/); }); - it('a 5xx with only HALF an envelope stays readable — a code is required, not just a status', async () => { - // Guards the predicate's second half at the boundary. A producer that - // ships a status without a code has not declared anything; inventing the - // withhold for it would be the consumer-side leniency PD #12 removes. + it('[#12281] a 5xx with only HALF an envelope is ALSO withheld — a status alone declares it', async () => { + // ⚠️ REVERSED, deliberately. Until #12281 this case asserted the opposite + // ("a code is required, not just a status"), on the reasoning that a + // producer shipping a status without a code "has not declared anything". + // + // The maintainer ruled otherwise on #12509, 2026-08-27 (option D), + // propagated to #12281: `errorResponseBase` adopts the structural + // withhold for EVERY declared 5xx message, aligning to `/data` — whose + // `declaredHttpStatus` never looked at `code` at all. Naming a 5xx status + // IS the declaration; the code is a second, independent channel (#9106), + // and requiring it here is what left the no-code half of the band on + // `looksLikeInternalErrorLeak` alone — the phrasing heuristic #5811's own + // argument found insufficient, which is why the withhold was made + // structural in the first place. + // + // ⛔ This is NOT the consumer-side leniency PD #12 removes: nothing is + // invented for the half that was not declared. The code channel still + // reports exactly what the producer spelled (here: nothing, so the + // status-derived `SERVICE_UNAVAILABLE`), and only the prose is withheld. + // The full text still reaches the operator through `__obsRecordedError`. const err = Object.assign(new Error('analytics engine unavailable'), { status: 503 }); const res = await postAnalyticsQuery({ query: async () => { throw err; } }, query); expect(res.statusCode).toBe(503); - expect(res.body.error.message).toBe('analytics engine unavailable'); + expect(res.body.error.message).toBe(INTERNAL_ERROR_MESSAGE); + // The operator still gets the untouched sentence. + expect(String((res as any).__obsRecordedError?.message)).toBe('analytics engine unavailable'); }); it('a DECLARED 4xx is untouched — the withhold is 5xx-only', async () => { diff --git a/packages/runtime/src/dispatcher-5xx-demoted-code-withhold.test.ts b/packages/runtime/src/dispatcher-5xx-demoted-code-withhold.test.ts index 494ec75cff..a471fad082 100644 --- a/packages/runtime/src/dispatcher-5xx-demoted-code-withhold.test.ts +++ b/packages/runtime/src/dispatcher-5xx-demoted-code-withhold.test.ts @@ -260,10 +260,11 @@ describe('[#12509] the exits read the shared rule, they do not restate it', () = // 4. The prose axis, pinned AS IT STANDS — #12281's card, not this one // --------------------------------------------------------------------------- -describe('[#12509] the MESSAGE is untouched here — #12281 owns that axis', () => { +describe('[#12509] the MESSAGE axis — now widened by #12281, and the code axis is unaffected', () => { it('a declared 5xx WITH a code still has its prose withheld at errorResponseBase', async () => { - // `declaresServerFault` needs both a 5xx status and a string code, and - // this shape has both, so the withhold already fires. + // Unchanged by #12281: this shape declared a 5xx, so it was withheld + // under `declaresServerFault` and is withheld under + // `serverFaultProvenance`. Kept as the no-regression end of the band. const answer = await postAnalyticsQuery( thrown('the acme ledger service is down', { status: 503, code: 'ACME_LEDGER_OFFLINE' }), ); @@ -274,18 +275,35 @@ describe('[#12509] the MESSAGE is untouched here — #12281 owns that axis', () expect(answer.body.error.declaredCode).toBe('ACME_LEDGER_OFFLINE'); }); - it('⚠️ a declared 5xx with NO code keeps its prose — this is what #12281 changes', async () => { - // The population measurement the ruling asked for is non-empty: - // `action-execution.ts` throws `{ statusCode: 503, message: 'Data - // service not available' }` at six sites and a 501 at a seventh, all - // code-less. `declaresServerFault` is false for them, so their prose - // travels today. When #12281 lands this expectation flips to the - // generic sentence — deliberately pinned so that lands as a CHANGE - // rather than as drift nobody sees. + it('[#12281] a declared 5xx with NO code ALSO has its prose withheld now', async () => { + // ⚠️ FLIPPED, as this file said it would be. Until #12281 this asserted + // `'Data service not available'` on the wire, with the note: "When + // #12281 lands this expectation flips to the generic sentence — + // deliberately pinned so that lands as a CHANGE rather than as drift + // nobody sees." This is that landing. + // + // Ruled 2026-08-27 on #12509 (option D), propagated to #12281: + // `errorResponseBase` adopts the structural withhold for EVERY declared + // 5xx message. `serverFaultProvenance` reads `status ?? statusCode` and + // does not consult `code`, so this shape — `action-execution.ts`'s + // code-less `statusCode` throw — is now on the withheld side by BOTH of + // the axes that used to exclude it. const answer = await postAnalyticsQuery({ statusCode: 503, message: 'Data service not available' }); expect(answer.status).toBe(503); - expect(answer.body.error.message).toBe('Data service not available'); + expect(answer.body.error.message).toBe(INTERNAL_ERROR_MESSAGE); // Nothing to withhold on the code channel: the producer declared none. + // The code axis this file owns is genuinely unaffected by the flip. expect(answer.body.error).not.toHaveProperty('declaredCode'); }); + + it('[#12281] an UNDECLARED 5xx still keeps its prose — the two axes stay independent', async () => { + // The control that keeps the flip above honest. This file's own subject + // is `demotedDeclaredCode`, which withholds the CODE on an undeclared + // 5xx; #12281 withholds the MESSAGE on a DECLARED one. They read + // opposite limbs of `serverFaultProvenance`, so an edit that collapsed + // them into "5xx ⇒ withhold everything" would go red here. + const answer = await postAnalyticsQuery(thrown('no strategy can handle query for cube "pipeline"', {})); + expect(answer.status).toBe(500); + expect(String(answer.body.error.message)).toMatch(/no strategy can handle query/); + }); }); diff --git a/packages/runtime/src/dispatcher-plugin.declared-5xx-prose-withhold.test.ts b/packages/runtime/src/dispatcher-plugin.declared-5xx-prose-withhold.test.ts new file mode 100644 index 0000000000..25bf02cf8a --- /dev/null +++ b/packages/runtime/src/dispatcher-plugin.declared-5xx-prose-withhold.test.ts @@ -0,0 +1,285 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12281] `errorResponseBase` withholds the message of EVERY **declared** 5xx, + * aligning this exit to `/data` — and still keeps an **undeclared** 5xx legible. + * + * ## The ruling this pins + * + * Maintainer, 2026-08-27, on #12509 (option D), propagated verbatim to #12281: + * + * > `errorResponseBase` adopts the **structural withhold for every declared 5xx + * > message**, aligning to `/data`'s rule; the author-facing text channel is + * > `userMessage` (#9934), never the raw message. + * + * ## The two axes it closes + * + * The predicate this exit used, `declaresServerFault`, is `status >= 500` **and** + * a non-empty string `code`. `/data`'s `declaredHttpStatus` reads + * `status ?? statusCode` and does not look at `code` at all. So two bands of + * declared 5xx were withheld one door over and shipped their prose here: + * + * 1. **no `code`** — the card's title case. The structural half of #5811's + * withhold required a code, so the no-code half of the declared band fell + * back to `looksLikeInternalErrorLeak` alone — the heuristic over SQL/driver + * *phrasing* that #5811's own argument found insufficient, which is why the + * withhold was made structural in the first place. + * 2. **the `statusCode` spelling** — wider, and NOT named in the card's body. A + * producer declaring `{ statusCode: 503, code: 'SERVICE_UNAVAILABLE' }` is + * *fully* ADR-0112-compliant and was still withheld at `/data` and legible + * here, purely because `declaresServerFault` read the `status` key only. + * + * Both are now one read: `serverFaultProvenance(thrown) === 'declared'`, the + * shared judgement #12509 landed in `@objectstack/types`, already read by + * `demotedDeclaredCode` for the code channel. ⛔ Not re-derived at this door — + * "one rule, every door inherits" is the point of #12509. + * + * ## ⛔ Why every case below DRIVES the shape rather than asserting the predicate + * + * The #12281 measurement established that the population reaching this door is + * **EMPTY** today: `metadata-protocol`'s `deleteMetaItem` reaches only the REST + * `/meta` door (the dispatcher plugin mounts neither `/meta` nor `/data`), and + * `action-execution.ts`'s seven `statusCode` throws are all caught before this + * exit. This change is therefore a **no-op on today's tree** — which is exactly + * why it was the cheapest moment to make it, and exactly why a green suite + * proves nothing by itself. Nothing here can be established by a passing route + * test that never reaches the ternary, so every case throws its shape through the + * REAL mounted `POST /api/v1/analytics/query` route (the throw-transparent + * instrument `analytics-query-read-scope-withhold.test.ts` established) and reads + * the answer off the wire. + * + * Two controls make each reading falsifiable rather than merely green: + * + * - a **positive control on the same instrument** — this door demonstrably CAN + * ship prose (the 4xx case, and the undeclared-5xx case), so "withheld" is a + * measured difference and not an instrument that always says the same thing; + * - the withheld term is never a substring of any probe's prose, so a case + * cannot pass by the two strings accidentally coinciding. + * + * ## ⛔ The gate is the DECLARED status, never the resolved one + * + * `errorResponseBase`'s own `httpStatus` falls back to **500** for a throw that + * declared nothing, so a naive `httpStatus >= 500` test would silently delete + * #5667's undeclared-5xx tiering — a bare `Error` from our own code is the + * operator's own bug report, names nothing tenant-sensitive, and stays readable. + * That is the one way this change could do real harm, so it is pinned in both + * directions below. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +// ── harness (the shape the sibling withhold test uses) ─────────────────────── + +function makeFakeServer() { + const handlers: Record any> = {}; + const rec = (verb: string) => (path: string, handler: any) => { + handlers[`${verb} ${path}`] = handler; + }; + return { + handlers, + server: { + get: rec('GET'), + post: rec('POST'), + put: rec('PUT'), + delete: rec('DELETE'), + patch: rec('PATCH'), + }, + }; +} + +function makeCtx(fakeServer: any, analytics: unknown) { + const kernel = { + getService: (name: string) => (name === 'analytics' ? analytics : undefined), + getServiceAsync: async (name: string) => (name === 'analytics' ? analytics : undefined), + }; + return { + getKernel: () => kernel, + getService: (name: string) => (name === 'http.server' ? fakeServer : undefined), + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, + on: () => {}, + } as any; +} + +function makeRes() { + const res: any = { + statusCode: undefined as number | undefined, + body: undefined as any, + status(c: number) { res.statusCode = c; return res; }, + header() { return res; }, + json(b: any) { res.body = b; return res; }, + }; + return res; +} + +/** + * Drive the REAL `POST /api/v1/analytics/query` route with an analytics service + * that throws `thrown`. + * + * The route is throw-transparent — `HttpDispatcher.dispatch`'s foot catch handles + * `PermissionDenied` and rethrows everything else — so the error reaching + * `errorResponseBase` is the one this stub actually threw. + */ +async function throwFromAnalyticsQuery(thrown: unknown) { + const { server, handlers } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(makeCtx(server, { query: async () => { throw thrown; } })); + + const handler = handlers['POST /api/v1/analytics/query']; + expect(handler, 'POST /api/v1/analytics/query must be mounted').toBeTypeOf('function'); + + const res = makeRes(); + await handler({ body: { cube: 'pipeline', measures: ['revenue'] }, query: {} }, res); + return res; +} + +/** A thrown shape carrying `props`, with prose that is not a driver dump. */ +function declaring(props: Record, message: string) { + return Object.assign(new Error(message), props); +} + +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#12281] a DECLARED 5xx has its prose withheld at the dispatcher exit', () => { + let logSpy: ReturnType; + beforeEach(() => { logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); + afterEach(() => { logSpy.mockRestore(); }); + + /** + * One row per way a producer can DECLARE a 5xx. `secret` is the detail the + * prose names and the wire must not carry. + * + * ⚠️ No probe message contains the withheld string as a substring, so a case + * cannot pass by coincidence; and none of them *sounds* like a driver dump, + * so `looksLikeInternalErrorLeak` cannot be what withholds them — the + * declaration has to be doing the work. Rows 1 and 4 differ ONLY in the + * presence of `code`; rows 1 and 3 differ ONLY in the status spelling. + */ + const DECLARED: Array<{ name: string; props: Record; message: string; secret: string }> = [ + { + name: 'AXIS 1 — `status`, NO code (the card\'s title case)', + props: { status: 503 }, + message: 'Upstream warehouse pool exhausted for tenant acme_prod.', + secret: 'acme_prod', + }, + { + name: 'AXIS 2 — `statusCode` WITH a registered code (a fully compliant producer)', + props: { statusCode: 503, code: 'SERVICE_UNAVAILABLE' }, + message: 'Upstream warehouse pool exhausted for tenant acme_prod.', + secret: 'acme_prod', + }, + { + name: 'BOTH axes at once — `statusCode`, no code', + props: { statusCode: 503 }, + message: 'Upstream warehouse pool exhausted for tenant acme_prod.', + secret: 'acme_prod', + }, + { + name: 'REGRESSION — `status` WITH a code (what #5811 already withheld)', + props: { status: 503, code: 'SERVICE_UNAVAILABLE' }, + message: 'Upstream warehouse pool exhausted for tenant acme_prod.', + secret: 'acme_prod', + }, + { + name: 'LIVE SHAPE — `metadata-protocol`\'s deleteMetaItem sentence, {status:500}, no code', + props: { status: 500 }, + message: 'Failed to delete customization overlay for object/crm_account_secret_overlay.', + secret: 'crm_account_secret_overlay', + }, + { + name: 'LIVE SHAPE — `action-execution`\'s bare `statusCode` throw', + props: { statusCode: 503 }, + message: 'Data service not available for datasource warehouse_replica_eu.', + secret: 'warehouse_replica_eu', + }, + { + name: 'a declared 5xx above 503 is not special-cased', + props: { status: 504 }, + message: 'Aggregation timed out against shard shard_finance_07.', + secret: 'shard_finance_07', + }, + ]; + + for (const c of DECLARED) { + it(`${c.name} → prose withheld`, async () => { + const res = await throwFromAnalyticsQuery(declaring(c.props, c.message)); + + expect(res.statusCode).toBe(c.props.status ?? c.props.statusCode); + expect(res.body.success).toBe(false); + expect(res.body.error.message).toBe(INTERNAL_ERROR_MESSAGE); + + // Asserted over the WHOLE body, not just `message`: a leak that moved + // to another key would still be a leak. + const wire = JSON.stringify(res.body); + expect(wire).not.toContain(c.secret); + // The probe prose and the withheld string are disjoint, so the + // assertion above cannot be satisfied by them coinciding. + expect(c.message).not.toContain(INTERNAL_ERROR_MESSAGE); + + // The classification still travels — only the prose is withheld. + expect(typeof res.body.error.code).toBe('string'); + expect(res.body.error.code.length).toBeGreaterThan(0); + + // …and the untouched error still reaches the operator through the + // `__obsRecordedError` side-channel. "Withheld" is only acceptable + // because the full text is still somewhere. + expect(String((res as any).__obsRecordedError?.message)).toContain(c.secret); + }); + } + + it('POSITIVE CONTROL — the same door SHIPS prose on a declared 4xx', async () => { + // Without this, every case above could pass for a reason that has nothing + // to do with the withhold — a door that answered `INTERNAL_ERROR_MESSAGE` + // to everything would satisfy all seven. `serverFaultProvenance` answers + // `undefined` below 500, so a 4xx is untouched and the caller reads the + // refusal it is entitled to. + const res = await throwFromAnalyticsQuery( + declaring({ status: 409, code: 'RECORD_LOCKED' }, 'Cube pipeline is locked by an in-flight rebuild.'), + ); + expect(res.statusCode).toBe(409); + expect(res.body.error.message).toBe('Cube pipeline is locked by an in-flight rebuild.'); + expect(res.body.error.message).not.toBe(INTERNAL_ERROR_MESSAGE); + }); + + it('⛔ an UNDECLARED 5xx keeps #5667 tiering — the gate is the DECLARED status', async () => { + // The second positive control, and the one that matters most: this is the + // case a naive `httpStatus >= 500` rewrite would silently delete. The + // throw declares NO status, so `errorResponseBase`'s own `httpStatus` + // falls back to 500 — but `serverFaultProvenance` reads `declaredStatus`, + // which is absent, and answers `'undeclared'`. The prose survives. + const res = await throwFromAnalyticsQuery( + new Error('[Analytics] no strategy can handle query for cube "pipeline"'), + ); + expect(res.statusCode).toBe(500); + expect(String(res.body.error.message)).toMatch(/no strategy can handle query/); + expect(res.body.error.message).not.toBe(INTERNAL_ERROR_MESSAGE); + }); + + it('an UNDECLARED 5xx that SOUNDS like a driver dump is still withheld by the heuristic', async () => { + // The heuristic limb is untouched by this change and still carries the + // undeclared band. Pinned so a later simplification cannot drop it while + // the declared limb keeps the suite green. + const res = await throwFromAnalyticsQuery( + new Error('SQLITE_ERROR: no such column: crm_account.secret_policy_field'), + ); + expect(res.statusCode).toBe(500); + expect(res.body.error.message).toBe(INTERNAL_ERROR_MESSAGE); + expect(JSON.stringify(res.body)).not.toContain('secret_policy_field'); + }); + + it('a declared 4xx with NO code is untouched — the widened rule stays 5xx-only', async () => { + // `declaresServerFault` could never reach a 4xx because it required + // `status >= 500`; `serverFaultProvenance` must not either. Pinned + // because "the withhold got broader" and "the withhold swallowed + // everything" are one edit apart. + const res = await throwFromAnalyticsQuery( + declaring({ status: 422 }, 'Measure revenue is not additive across the stage dimension.'), + ); + expect(res.statusCode).toBe(422); + expect(res.body.error.message).toBe('Measure revenue is not additive across the stage dimension.'); + }); +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index fa7f5ccaa5..cce89f61e3 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Plugin, PluginContext, IHttpServer, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; -import { looksLikeInternalErrorLeak, declaresServerFault, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, demotedDeclaredCode } from '@objectstack/types'; +import { looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, serverFaultProvenance, demotedDeclaredCode } from '@objectstack/types'; import { DispatcherErrorCode } from '@objectstack/spec/api'; import type { IAuthService, IMetadataService } from '@objectstack/spec/contracts'; import type { CounterStore } from '@objectstack/plugin-auth/rate-limit-storage'; @@ -535,10 +535,23 @@ function sendResultBase( * this in #5367/#5808 by keying on the DECLARATION rather than the prose, but the * rule was written in-line there because one consumer does not justify a shared * surface. This exit is the second consumer, so it was promoted: - * {@link declaresServerFault}, next to the heuristic it complements, read by both + * `declaresServerFault`, next to the heuristic it complements, read by both * boundaries. ⛔ It is NOT "withhold every 5xx" — #5667 kept UNDECLARED 5xx * legible on purpose, and a bare `Error` still goes through the heuristic alone. * + * [#12281] A fifth, and the reason that predicate is now {@link + * serverFaultProvenance}: `declaresServerFault` required a non-empty string + * `code` beside the 5xx and read the `status` spelling only, so this exit + * withheld a NARROWER band than `/data` — a declared 5xx with no code, and a + * declared 5xx spelled `statusCode`, both shipped their prose here and were + * withheld one door over. Ruled 2026-08-27 on #12509 (option D): this exit + * adopts the structural withhold for EVERY declared 5xx message, and reads the + * shared judgement rather than growing a second copy of it. Measured before the + * change: the population that changes hands today is EMPTY (the two repo-wide + * no-code 5xx producer families cannot reach this door), which is precisely why + * now was the cheapest moment — the alignment costs no legibility that exists + * and buys the invariant forward. + * * The code still travels, and `READ_SCOPE_COMPILE_FAILED` reaches the client * untouched — so what a machine reads is unchanged and only the prose is withheld, * into `errorReporter` and the log. @@ -586,12 +599,44 @@ function errorResponseBase(err: any, res: any, securityHeaders?: Record= 500`, so it can never - // reach a 4xx answer the caller is entitled to read. + // [#3842] A thrown error's own `.code` finally has somewhere to go — see the + // `declaredCode` note below for WHICH spelling travels. Resolved HERE, above + // the message ternary, because [#12281] that ternary now reads the same + // resolver answer: one read of the throw, one set of facts, so the prose rule + // and the code rule can never be looking at different errors. + const thrown = resolveThrownHttpError(err, 500); + // [#5811/#12281] Two independent reasons to withhold, both 5xx-only. The + // declaration comes first because it needs no guess about the text. + // + // [#12281] The declaration limb is `serverFaultProvenance(thrown) === + // 'declared'` — the ONE definition of "the producer named this 5xx itself" + // (`@objectstack/types`), ruled 2026-08-27 (option D) and already read by + // `demotedDeclaredCode` for the code channel. ⛔ Not re-derived here: "one + // rule, every door inherits" is the point of #12509, and a per-door variant + // is the divergence this family has now been repaired for twice. + // + // It replaces `declaresServerFault`, which withheld on `status >= 500` AND a + // non-empty string `code`, and closes the TWO axes that read apart from + // `/data`'s `declaredHttpStatus`: + // 1. a declared 5xx carrying NO `code` fell through to the heuristic alone + // — the half #5811's own argument found insufficient; + // 2. `declaresServerFault` read `status` ONLY, so a fully ADR-0112-compliant + // producer that merely spells `statusCode` shipped its prose here while + // `/data` withheld it. `declaredStatus` reads `status ?? statusCode`. + // + // ⛔ Still NOT "withhold every 5xx". The gate is the DECLARED status, never + // the resolved `httpStatus` above (which falls back to 500 for a throw that + // declared nothing): `serverFaultProvenance` answers `'undeclared'` exactly + // when `declaredStatus` is absent, so #5667's tiering survives intact — a + // bare `Error` from our own code is the operator's own bug report and still + // goes through the heuristic alone. A naive `httpStatus >= 500` test would + // silently delete that, which is the one way this change could do harm. + // + // The author-facing text channel is `userMessage` (#9934), never the raw + // message — a producer whose 5xx prose is addressed to a human declares it + // there and it survives the withhold on its own channel. const message = - declaresServerFault(err) || (httpStatus >= 500 && looksLikeInternalErrorLeak(raw)) + serverFaultProvenance(thrown) === 'declared' || (httpStatus >= 500 && looksLikeInternalErrorLeak(raw)) ? INTERNAL_ERROR_MESSAGE : raw || 'Internal Server Error'; // [#3842] A thrown error's own `.code` finally has somewhere to go — the @@ -607,7 +652,6 @@ function errorResponseBase(err: any, res: any, securityHeaders?: Record