From 09123bf250e20c62bbbbe42464d96b32fcadca41 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:14:36 +0000 Subject: [PATCH] fix(rest): /analytics/dataset/query carries the producer's userMessage on its three hand-built terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route builds its error envelopes by hand and shares no exit with the /data door, so #9934's producer-marked `userMessage` — applied there once at the exit through `withDeclaredUserMessage`, branch-agnostically — was applied at none of them. Scope is by ARM: ① (declared 4xx passthrough), ③a (declared 5xx relay) and ③b (generic 500) all dropped it; ①b already carried it, because its body comes from `resolveErrorResponse`, and it is deliberately untouched. The value is `boundedDeclaredUserMessage` (#12693), resolved once for the whole catch. Exactly one optional key is added, only when the producer marked one; no existing key moves or changes value at any arm. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --- .../analytics-dataset-query-user-message.md | 75 ++++ .../src/analytics-fault-user-message.test.ts | 412 ++++++++++++++++++ packages/rest/src/rest-server.ts | 40 +- 3 files changed, 524 insertions(+), 3 deletions(-) create mode 100644 .changeset/analytics-dataset-query-user-message.md create mode 100644 packages/rest/src/analytics-fault-user-message.test.ts diff --git a/.changeset/analytics-dataset-query-user-message.md b/.changeset/analytics-dataset-query-user-message.md new file mode 100644 index 0000000000..6f09f28926 --- /dev/null +++ b/.changeset/analytics-dataset-query-user-message.md @@ -0,0 +1,75 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): `/analytics/dataset/query` carries a producer-marked `userMessage` on its three hand-built terminals (#12710) + +`POST /api/v1/analytics/dataset/query` (and its environment-scoped twin) builds +its error envelopes by hand and shares no exit with the `/data` door, so #9934's +producer-marked `userMessage` — a channel that door applies once at its exit, +branch-agnostically, through `withDeclaredUserMessage` — was applied at none of +them. A producer's caller-facing sentence reached the client on +`POST /data/:object` and vanished here for the identical throw. + +**Scope is by ARM.** Four terminals live in that route's catch; three dropped +the mark and one did not: + +| arm | envelope | before | +| :-- | :--- | :--- | +| ① declared 4xx ADR-0112 passthrough | hand-built `{ code, message }` | ⛔ no mark | +| ①b `classifiedRefusalAnswer` re-dress | `{ ...refusalFields, message }` | ✅ carried it | +| ③a declared 5xx relay | `declaredServerFaultAnswer`'s body, sent verbatim | ⛔ no mark | +| ③b generic `500 ANALYTICS_QUERY_FAILED` | hand-built `{ code, error }` | ⛔ no mark | + +①b already carried it because its body comes from `resolveErrorResponse`, whose +arms ride the mark already. The other three hold no classification to ride on. + +Measured on `4af6c4419` before the repair, one marked producer per arm, driven +through the real route against the flat `/data` door for the identical throw: + +```text +throw { code: 'INVALID_FILTER', status: 400, userMessage: 'Check the filter…' } + ① analytics : 400 {"code":"INVALID_FILTER","message":"…"} — no mark + /data door : 400 {"error":"…","code":"INVALID_FILTER", + "userMessage":"Check the filter…"} — mark carried + +throw { code: 'READ_SCOPE_COMPILE_FAILED', status: 500, userMessage: '…' } + ③a analytics: 500 {"error":"Internal server error", + "code":"READ_SCOPE_COMPILE_FAILED"} — no mark + /data door : 500 {…, "userMessage":"…"} — mark carried + +throw Error('[Analytics] no strategy can handle query …') + userMessage + ③b analytics: 500 {"code":"ANALYTICS_QUERY_FAILED","error":"…"} — no mark + /data door : 500 {"code":"INTERNAL_ERROR","userMessage":"…"} — mark carried +``` + +Nothing invalid shipped — every body parsed as `ApiErrorSchema`, which already +declares the optional field — and that is what made the loss silent and +one-directional: a console told by ADR-0112 to render `userMessage` verbatim +found nothing at these three arms and fell back to its generic substitution, for +the same throw the twin door rendered. + +**What callers see change:** exactly one optional key is ADDED, and only when +the producer marked one. No existing key moves or changes value, at any of the +four arms — pinned as an explicit key-order assertion per arm for an unmarked +producer. + +The value comes from `boundedDeclaredUserMessage` (exported by #12693) — +`declaredUserMessage`'s presence answer with #5423's bound applied — resolved +once for the whole catch rather than at each terminal, so this door has one +answer to "is there a mark, and how long may it be" and shares it with `/data` +rather than copying it. ①b is deliberately untouched: a second application there +would be one rule applied twice. + +**Unchanged:** the prose withhold (#5367/#5437/#5811) — a declared server fault's +message is still replaced by the generic sentence and still reaches the operator +in full through the `logError` line that runs before every arm; the statuses and +`code`s all four arms answer; and #5667's tiering, which leaves a self-authored +undeclared fault readable. + +**Not reachable from in-repo producers today.** Censused at claim: no package +under `packages/services/**` sets a `userMessage` of any kind, and +`service-analytics` dispatches no sandbox hook, so the QuickJS side-channel — the +other in-repo carrier — does not reach this door either. This wires up a declared +channel the published contract already promises on this route's envelope; the +intended producer is an app author's analytics datasource or strategy. diff --git a/packages/rest/src/analytics-fault-user-message.test.ts b/packages/rest/src/analytics-fault-user-message.test.ts new file mode 100644 index 0000000000..c32de90258 --- /dev/null +++ b/packages/rest/src/analytics-fault-user-message.test.ts @@ -0,0 +1,412 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12710] `POST /analytics/dataset/query` carries the producer's `userMessage` + * on the THREE terminals it builds by hand — the ADR-0112 envelope passthrough + * (①), the declared-fault relay (③a) and the generic `ANALYTICS_QUERY_FAILED` + * (③b). + * + * ## Scope is by ARM, and the arm count was measured here, not inherited + * + * The card was filed for the two 5xx terminals and reported this door's + * classified arm as already correct. Measured on `4af6c4419`, that is true of + * ONE of its two classified arms and false of the other, so this file pins + * three arms rather than two: + * + * ```text + * throw { code: 'INVALID_FILTER', status: 400, userMessage: 'Check the filter…' } + * ① analytics (rest-server.ts, the `{ code, message }` it builds by hand) + * → 400 {"code":"INVALID_FILTER","message":"…"} ⛔ no mark + * /data door → 400 {"error":"…","code":"INVALID_FILTER", + * "userMessage":"Check the filter…"} + * ``` + * + * ①b — the arm that re-dresses `classifiedRefusalAnswer`'s body with + * `...refusalFields` — IS the arm the card measured, and it does carry the mark + * because that body comes from `resolveErrorResponse`, whose arms already ride + * it (`withDeclaredUserMessage`, #9934). §5 pins ①b as untouched, so the two + * classified arms are not flattened into one story in either direction. + * + * What ①, ③a and ③b have in common is that none of them holds a classified body + * to ride on: ① and ③b build their envelope by hand, ③a receives + * `declaredServerFaultAnswer`'s hand-shaped one and sends it verbatim. So the + * value comes from `boundedDeclaredUserMessage` (#12693) — the same rule + * (`declaredUserMessage`'s presence answer with #5423's bound) asked of the RAW + * thrown error rather than of a classification. §6 pins that the two doors agree + * on that value. + * + * ## Why the repair is at the CALL SITE and not inside `declaredServerFaultAnswer` + * + * Censused on `4af6c4419`: that shared function has exactly **two** consumers in + * `packages/rest/src` — + * + * 1. `classifyDataError` (`error-response.ts`), whose only caller is the + * exported `mapDataError`, which IS `withDeclaredUserMessage(error, + * classifyDataError(…))`. That door already carries the mark, applied one + * layer OUT and branch-agnostically over every arm — the shape #9934 chose + * deliberately, and the reason the shared body-builder carries no mark of + * its own. + * 2. this route's ③a. + * + * So "put the mark in the shared function" would be a no-op duplicate for + * consumer 1 (its wrapper adds the identical value from the identical helper) + * and would still miss ① and ③b, which never call that function at all. Moving + * the mark inward would also split a one-per-door wrapper rule across two + * layers. The mark stays a door-exit decision; this door's exit is these three + * arms, and the route resolves it once for all of them. + * + * ## Reproduced before it was repaired, on `4af6c4419` + * + * One marked producer per arm, driven through the REAL route, against the flat + * `/data` door (`handleRouteError`) for the identical throw: + * + * ```text + * throw { code: 'READ_SCOPE_COMPILE_FAILED', status: 500, userMessage: '…' } + * ③a analytics → 500 {"error":"Internal server error", + * "code":"READ_SCOPE_COMPILE_FAILED"} + * /data door → 500 {"error":"Internal server error", + * "code":"READ_SCOPE_COMPILE_FAILED","userMessage":"…"} + * + * throw Error('[Analytics] no strategy can handle query …') + userMessage + * ③b analytics → 500 {"code":"ANALYTICS_QUERY_FAILED","error":"no strategy …"} + * /data door → 500 {"code":"INTERNAL_ERROR","userMessage":"…"} + * ``` + * + * §4 is that comparison, and it is the reproduction: its `/data` half is the + * positive control that makes the analytics door's silence a DISAGREEMENT rather + * than two doors agreeing that the mark does not belong on a fault terminal. + * + * ## What reds when the repair is ablated + * + * §1, §2, §3, §4 and §6 — every assertion that a MARKED producer's sentence + * reaches the body. §5's ABSENCE assertions stay green, deliberately and by + * construction: an unrepaired door also emits no `userMessage`. They are the + * Clause ② criterion (no existing key moves or changes value), not the pin, and + * an ablation that reds only §5 would mean the pin had stopped measuring. + * + * ## Producer census (this door, at claim — ⛔ not inherited from #12693) + * + * ZERO in-repo producers can reach these terminals with a mark: + * `packages/services/**` contains no `userMessage` of any kind (positive + * controls in the same tree: 102 `code:` hits, 278 `status` hits), and + * `service-analytics` dispatches no sandbox hook (positive control: 1013 + * `await ` hits), so the QuickJS side-channel — the other in-repo carrier — does + * not reach here either. This is a DECLARED channel that was not wired up at + * these three arms, not a report that anyone is being harmed today. The + * published contract does already promise the field on this door's envelope + * (`content/docs/references/api/analytics.mdx`, `ApiError.userMessage`), and the + * intended producer is an out-of-repo one: an app author's analytics datasource + * or strategy. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; +import { RestServer } from './rest-server'; +import { handleRouteError } from './error-response.js'; + +// ── harness (the shape the sibling analytics envelope tests use) ────────────── + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} +function mockProtocol() { + return { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + }; +} +function mockRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.end = vi.fn(() => res); + return res; +} + +/** The REAL `/analytics/dataset/query` route, with a provider that rejects. */ +function buildRoute(rejectWith: unknown) { + const rest = new RestServer( + mockServer() as any, mockProtocol() as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + async () => ({ queryDataset: vi.fn().mockRejectedValue(rejectWith) }), + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + return rest.getRoutes().find((r) => r.method === 'POST' && r.path.endsWith('/analytics/dataset/query'))!; +} + +const dataset = { + name: 'pipeline', + label: 'Pipeline', + object: 'crm_opportunity', + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }], +}; +const selection = { dimensions: ['stage'], measures: ['revenue'] }; + +/** Drive the analytics door with a producer that throws `error`. */ +async function analyticsDoor(error: unknown) { + const route = buildRoute(error); + const res = mockRes(); + await route.handler({ method: 'POST', params: {}, headers: {}, body: { dataset, selection } } as any, res); + return { status: res.statusCode as number, body: res.body as Record }; +} + +/** The wire answer the flat `/data` door gives for the same error. */ +function dataDoor(error: unknown) { + const res = mockRes(); + handleRouteError(res, error); + return { status: res.statusCode as number, body: res.body as Record }; +} + +const MARK = 'Ask an admin to review the dataset read policy.'; +const FILTER_MARK = 'Check the filter on the "stage" column.'; + +/** A producer that marked its refusal, with a declared server fault. */ +function declaredMarked(extra: Record = {}) { + return Object.assign( + new Error('[read-scope-sql] unsafe field identifier "secret_policy_field" — refusing to build read scope (fail-closed).'), + { code: 'READ_SCOPE_COMPILE_FAILED', status: 500, userMessage: MARK, ...extra }, + ); +} + +let logSpy: ReturnType; +beforeEach(() => { logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); +afterEach(() => { logSpy.mockRestore(); }); + +// ───────────────────────────────────────────────────────────────────────────── +// §1 arm ① — the ADR-0112 envelope this route re-shapes by hand +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#12710] §1 ① — a declared 4xx envelope carries the mark', () => { + it('a declared 400 with a registered code', async () => { + const err = Object.assign(new Error('Unsupported filter operator "$sortOf" on "stage".'), { + code: 'INVALID_FILTER', status: 400, userMessage: FILTER_MARK, + }); + const { status, body } = await analyticsDoor(err); + expect(status).toBe(400); + expect(body.code).toBe('INVALID_FILTER'); + // ①'s own dialect — `message`, not `error` — is untouched by this card. + expect(String(body.message)).toMatch(/\$sortOf/); + expect(body.userMessage).toBe(FILTER_MARK); + }); + + it('a declared 404 too — the arm is status-agnostic across the 4xx band', async () => { + const err = Object.assign(new Error('Cube "pipeline" is not registered.'), { + code: 'CUBE_NOT_FOUND', status: 404, userMessage: 'That report was removed. Pick another from the list.', + }); + const { status, body } = await analyticsDoor(err); + expect(status).toBe(404); + expect(body.code).toBe('CUBE_NOT_FOUND'); + expect(body.userMessage).toBe('That report was removed. Pick another from the list.'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §2 arm ③a — the DECLARED 5xx relay +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#12710] §2 ③a — a relayed declared server fault carries the mark', () => { + it('a declared 500 with a registered code', async () => { + const { status, body } = await analyticsDoor(declaredMarked()); + expect(status).toBe(500); + expect(body.code).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(body.userMessage).toBe(MARK); + // ⛔ the mark never buys the PROSE past #5437's withhold. + expect(body.error).toBe(INTERNAL_ERROR_MESSAGE); + expect(String(body.error)).not.toContain('secret_policy_field'); + // …and the withheld text still reaches the operator. + const logged = logSpy.mock.calls.map((a: unknown[]) => a.map(String).join(' ')).join('\n'); + expect(logged).toContain('secret_policy_field'); + }); + + it('a declared 503 — the relay #11718 exists for — keeps status, code and mark', async () => { + const err = Object.assign(new Error('warehouse connection reset'), { + code: 'SERVICE_UNAVAILABLE', status: 503, userMessage: 'The warehouse is restarting; retry in a minute.', + }); + const { status, body } = await analyticsDoor(err); + expect(status).toBe(503); + expect(body.code).toBe('SERVICE_UNAVAILABLE'); + expect(body.userMessage).toBe('The warehouse is restarting; retry in a minute.'); + }); + + it('a HALF declaration — 5xx status, no code — relays the status and the mark, and invents no code', async () => { + // `declaredServerFaultAnswer`'s gate is `declaredHttpStatus >= 500`, NOT + // `declaresServerFault`, and that difference is load-bearing (its docblock). + // The mark rides the same arm, so the two halves of the envelope stay + // independent: nothing is invented for the half that was not declared. + const err = Object.assign(new Error('warehouse connection reset'), { + status: 503, userMessage: 'The warehouse is restarting; retry in a minute.', + }); + const { status, body } = await analyticsDoor(err); + expect(status).toBe(503); + expect(body.code).toBeUndefined(); + expect(body.userMessage).toBe('The warehouse is restarting; retry in a minute.'); + }); + + it('an UNREGISTERED declared code still demotes (#9232) and still carries the mark', async () => { + const { status, body } = await analyticsDoor(declaredMarked({ code: 'WAREHOUSE_UNAVAILABLE', status: 503 })); + expect(status).toBe(503); + expect(body.declaredCode).toBe('WAREHOUSE_UNAVAILABLE'); + expect(body.userMessage).toBe(MARK); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §3 arm ③b — the generic 500 for a fault nobody declared +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#12710] §3 ③b — the generic ANALYTICS_QUERY_FAILED carries the mark', () => { + it('an undeclared fault that marked a sentence', async () => { + const err = Object.assign(new Error('[Analytics] no strategy can handle query for cube "pipeline"'), { + userMessage: 'This report is unavailable right now — ask an admin to check the dataset.', + }); + const { status, body } = await analyticsDoor(err); + expect(status).toBe(500); + // ⛔ The mark moves neither the status nor the code, and #5667's tiering — + // a self-authored fault stays readable — is untouched. + expect(body.code).toBe('ANALYTICS_QUERY_FAILED'); + expect(String(body.error)).toMatch(/no strategy can handle query/); + expect(body.userMessage).toBe('This report is unavailable right now — ask an admin to check the dataset.'); + }); + + it('…and a marked fault whose prose IS withheld still carries the sentence', async () => { + // The withhold arm of ③b: `declaresServerFault` true for a `status: 700` + // that `declaredHttpStatus`'s <600 bound keeps out of ③a → generic prose. + const err = Object.assign(new Error('SQLSTATE[42P01]: relation "crm_opportunity" does not exist'), { + code: 'WAREHOUSE_FAULT', status: 700, userMessage: MARK, + }); + const { status, body } = await analyticsDoor(err); + expect(status).toBe(500); + expect(body.code).toBe('ANALYTICS_QUERY_FAILED'); + expect(body.error).toBe(INTERNAL_ERROR_MESSAGE); + expect(body.userMessage).toBe(MARK); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §4 door-to-door — the reproduction +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#12710] §4 both doors carry the same producer mark for the same throw', () => { + const CASES: Array<{ name: string; error: () => unknown; mark: string }> = [ + { + name: '① declared 400', + error: () => Object.assign(new Error('Unsupported filter operator "$sortOf" on "stage".'), { + code: 'INVALID_FILTER', status: 400, userMessage: FILTER_MARK, + }), + mark: FILTER_MARK, + }, + { name: '③a declared 500', error: () => declaredMarked(), mark: MARK }, + { + name: '③b undeclared fault', + error: () => Object.assign(new Error('[Analytics] no strategy can handle query for cube "pipeline"'), { userMessage: MARK }), + mark: MARK, + }, + ]; + + for (const c of CASES) { + it(`${c.name}: analytics door and /data door both carry it`, async () => { + const flat = dataDoor(c.error()); + // POSITIVE CONTROL — without this the analytics assertion below could pass + // for the wrong reason (two doors agreeing the mark does not belong on + // this terminal). #9934 rules that it does; `/data` is where that ruling + // already lives. + expect( + flat.body.userMessage, + `positive control — the /data door must carry the mark: ${JSON.stringify(flat.body)}`, + ).toBe(c.mark); + const analytics = await analyticsDoor(c.error()); + expect( + analytics.body.userMessage, + `the analytics door dropped the producer's userMessage: ${JSON.stringify(analytics.body)}`, + ).toBe(c.mark); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §5 the Clause ② criterion, and the arm that was already right +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#12710] §5 no existing key moves or changes value', () => { + it('① for an UNMARKED producer — byte-identical to before', async () => { + const err = Object.assign(new Error('Unsupported filter operator "$sortOf" on "stage".'), { + code: 'INVALID_FILTER', status: 400, + }); + const { status, body } = await analyticsDoor(err); + expect(status).toBe(400); + expect(Object.keys(body)).toEqual(['code', 'message']); + expect(body).toEqual({ code: 'INVALID_FILTER', message: 'Unsupported filter operator "$sortOf" on "stage".' }); + }); + + it('③a for an UNMARKED producer — byte-identical to before', async () => { + const err = Object.assign(new Error('[read-scope-sql] unsafe field identifier (fail-closed).'), { + code: 'READ_SCOPE_COMPILE_FAILED', status: 500, + }); + const { status, body } = await analyticsDoor(err); + expect(status).toBe(500); + expect(Object.keys(body)).toEqual(['error', 'code']); + expect(body).toEqual({ error: INTERNAL_ERROR_MESSAGE, code: 'READ_SCOPE_COMPILE_FAILED' }); + }); + + it('③b for an UNMARKED producer — byte-identical to before', async () => { + const { status, body } = await analyticsDoor(new Error('[Analytics] no strategy can handle query for cube "pipeline"')); + expect(status).toBe(500); + expect(Object.keys(body)).toEqual(['code', 'error']); + expect(body).toEqual({ + code: 'ANALYTICS_QUERY_FAILED', + error: '[Analytics] no strategy can handle query for cube "pipeline"', + }); + }); + + it('a blank or non-string mark is NOT a declaration — no arm invents a key', async () => { + const a = await analyticsDoor(declaredMarked({ userMessage: ' ' })); + expect(a.body.userMessage).toBeUndefined(); + const b = await analyticsDoor(Object.assign(new Error('no strategy'), { userMessage: 42 })); + expect(b.body.userMessage).toBeUndefined(); + const c = await analyticsDoor(Object.assign(new Error('bad filter'), { + code: 'INVALID_FILTER', status: 400, userMessage: '', + })); + expect(c.body.userMessage).toBeUndefined(); + }); + + it('arm ①b was already right and is NOT touched by this repair', async () => { + // ①b is the arm the card measured: it re-dresses `classifiedRefusalAnswer`'s + // body, which already rides the mark. Reached here by the `statusCode` + // spelling (#7525), which ①'s `error.status`-only read cannot see. A repair + // that reached this arm would be one rule applied twice. + const err = Object.assign(new Error('Unsupported filter operator "$sortOf" on "stage".'), { + code: 'INVALID_FILTER', statusCode: 400, userMessage: FILTER_MARK, + }); + const { status, body } = await analyticsDoor(err); + expect(status).toBe(400); + expect(body.code).toBe('INVALID_FILTER'); + expect(body.userMessage).toBe(FILTER_MARK); + // …and the mark appears exactly once, from ①b's own spread. + expect(Object.keys(body).filter((k) => k === 'userMessage')).toHaveLength(1); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §6 one rule, not two agreeing copies +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#12710] §6 the two doors agree on the VALUE, bound included', () => { + it('a mark past #5423 bound is truncated identically at both doors', async () => { + const long = 'x'.repeat(900); + const err = () => Object.assign(new Error('warehouse down'), { + code: 'SERVICE_UNAVAILABLE', status: 503, userMessage: long, + }); + const flat = dataDoor(err()); + const analytics = await analyticsDoor(err()); + expect(typeof flat.body.userMessage).toBe('string'); + expect((flat.body.userMessage as string).length).toBeLessThan(long.length); + expect(analytics.body.userMessage).toBe(flat.body.userMessage); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index c2fec3659a..d23f5c009b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -9136,6 +9136,40 @@ export class RestServer { // sits between the two arms and moves exactly that class. // The sentence this line computes is unchanged. const clientMsg = sandboxBusinessMessage(error) ?? msg; + + // ── [#12710] The producer's marked sentence, resolved once ─ + // #9934's `userMessage` channel is STATUS- and BRANCH-agnostic + // by construction: `withDeclaredUserMessage` applies it ONCE at + // the `/data` door's exit, over whatever envelope classification + // chose. This door has no such wrapper — it builds ①, ③a and ③b + // by hand — so the rule was applied at none of them, and one + // producer's sentence reached the client on `POST /data/:object` + // and vanished here for the identical throw (measured door to + // door in `analytics-fault-user-message.test.ts` §3). + // + // Resolved ONCE here rather than at each terminal so this door + // has a single answer to "is there a mark, and how long may it + // be": {@link boundedDeclaredUserMessage} is that pair + // (`declaredUserMessage`'s presence answer + #5423's bound) + // shared with the `/data` door rather than copied beside it, + // the same way the record-share family's two hand-built exits + // ask it (#12693). + // + // ⛔ Deliberately NOT spread onto ①b below. That arm re-dresses + // {@link classifiedRefusalAnswer}'s body, which already carries + // the mark, so a second application there would be one rule + // applied twice. Scope here is by ARM, not by door. + // + // ⛔ And riding it across the FAULT terminals ③a/③b does not + // re-open #5367/#5437/#5811. Those withhold prose the producer + // never addressed to the caller — driver text, a crash's + // `TypeError` — while this field exists on an error only + // because an author deliberately wrote caller-facing text onto + // it. The prose stays withheld byte for byte, neither the + // status nor the `code` moves, and an UNMARKED throw's envelope + // is byte-identical to before (pinned, §4). + const marked = boundedDeclaredUserMessage(error); + const markExtra = marked === undefined ? {} : { userMessage: marked }; // ── [#5352] ① The ADR-0112 envelope, read FIRST ────────── // A thrown error that already carries `code` + a 4xx // `status` has ANSWERED the classification question. This @@ -9167,7 +9201,7 @@ export class RestServer { const envelopeStatus = typeof error?.status === 'number' ? error.status : undefined; const envelopeCode = typeof error?.code === 'string' && error.code.length > 0 ? error.code : undefined; if (envelopeStatus !== undefined && envelopeStatus >= 400 && envelopeStatus < 500 && envelopeCode) { - return res.status(envelopeStatus).json({ code: envelopeCode, message: clientMsg.slice(0, 1000) }); + return res.status(envelopeStatus).json({ code: envelopeCode, message: clientMsg.slice(0, 1000), ...markExtra }); } // ── [#11684] ①b The refusal ① could not read ───────────── // ① answers a refusal that declared BOTH halves of the @@ -9345,7 +9379,7 @@ export class RestServer { // this route's answer for an UNDECLARED fault, below. const declaredFault = declaredServerFaultAnswer(error); if (declaredFault) { - return res.status(declaredFault.status).json(declaredFault.body); + return res.status(declaredFault.status).json({ ...declaredFault.body, ...markExtra }); } // ── ③b The generic 500, for a fault nobody declared ────── // `declaresServerFault` is kept in the withhold test rather @@ -9356,7 +9390,7 @@ export class RestServer { const outward = declaresServerFault(error) || looksLikeInternalErrorLeak(msg) ? INTERNAL_ERROR_MESSAGE : clientMsg.slice(0, 500); - res.status(500).json({ code: 'ANALYTICS_QUERY_FAILED', error: outward }); + res.status(500).json({ code: 'ANALYTICS_QUERY_FAILED', error: outward, ...markExtra }); } }, metadata: { summary: 'Run a semantic-layer dataset (preview/query)', tags: ['analytics'] },