diff --git a/.changeset/declared-refusal-relay.md b/.changeset/declared-refusal-relay.md new file mode 100644 index 00000000000..c8860b58f68 --- /dev/null +++ b/.changeset/declared-refusal-relay.md @@ -0,0 +1,18 @@ +--- +'@objectstack/types': minor +'@objectstack/rest': patch +'@objectstack/runtime': patch +'@objectstack/metadata-protocol': patch +--- + +A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. + +`ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. + +The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. + +**What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. + +**What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. + +**For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index e3d0743a772..b9dcaa53840 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -21968,6 +21968,21 @@ export class ObjectStackProtocolImplementation implements ); (err as any).code = 'NOT_IMPLEMENTED'; (err as any).status = 501; + // [#16146] The producer-side declaration ruled by decision batch #58 + // (2026-09-06, option C): `ApiErrorSchema.refusal` says "the 5xx I + // declared is a deliberate REFUSAL whose `message` is authored for + // the caller", so the boundary keeps that message instead of + // withholding it as a fault. THIS is the throw the ruling names — + // the sentence three lines up is prescriptive per ADR-0110 D3 and + // reached the wire as "Internal server error" until a route-local + // patch caught it one route down. Setting it here is what retires + // that patch's refusal/fault opinion: the transport now reads what + // the protocol DECLARED instead of matching this route's literals. + // + // ⛔ Platform and driver code never sets this on a fault, and no + // rewrap in this file carries it — a refusal crossing the + // overlay-delete rewraps is withheld as a fault, deliberately. + (err as any).refusal = true; throw err; } diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index 551fab4e0fc..c972fd4acae 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -55,6 +55,7 @@ import { resolveThrownHttpError, demotedDeclaredCode, declaredUserMessage, + declaredRefusalMessage, INTERNAL_ERROR_MESSAGE, } from '@objectstack/types'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; @@ -576,16 +577,43 @@ function withoutDeclaredCodePrefix(message: string, error: any): string { * this body was lifted from. Gating on `declaresServerFault` instead would * silently keep collapsing that shape onto `500`, which is the very defect, * one case narrower. + * + * ## [#16146] …and the PROSE is withheld only from a FAULT + * + * The paragraph above is about the STATUS and is unchanged. What this arm + * could not see until #16335 landed is that a producer-declared 5xx may be a + * deliberate REFUSAL whose message is authored FOR the caller — the + * `/meta/:type/:name/references` door's ADR-0110 D3 `501` is the measured one, + * and it reached the wire as `"Internal server error"`. The director seat + * ruled that distinction a producer-side DECLARATION on the published ADR-0112 + * envelope (decision batch #58, 2026-09-06, option C), ⛔ not a status + * heuristic and ⛔ not a second allow-list. So this arm asks + * {@link declaredRefusalMessage} — the ONE read all three withhold arms make + * (`@objectstack/types`) — and keeps that message under the same + * {@link truncateClientMessage} bound a 4xx message gets (#5423: truncate, + * never replace). + * + * ⛔ NOT "declared 5xx prose is relayed now". Absent the declaration this arm + * answers the bytes it always answered, which is what keeps the default + * fail-closed: a rewrap that drops the flag, a driver that never set it, and a + * producer that declared only `status` + `code` are all still withheld. + * `declaresServerFault` keeps the live call below and gains a second live + * reader inside that shared function. */ export function declaredServerFaultAnswer( error: any, ): { status: number; body: Record } | undefined { const declaredStatus = declaredHttpStatus(error); if (declaredStatus === undefined || declaredStatus < 500) return undefined; + // [#16146] The declaration is read HERE, INSIDE the shared arm, never in + // the `withDeclaredUserMessage` wrapper one frame up: the analytics dataset + // door (`rest-server.ts`, #11718) calls this function BARE, so a relay + // written into the wrapper would cover `/data` and miss that door. + const refusal = boundedDeclaredRefusalMessage(error); return { status: declaredStatus, body: { - error: INTERNAL_ERROR_MESSAGE, + error: refusal ?? INTERNAL_ERROR_MESSAGE, ...(declaresServerFault({ status: declaredStatus, code: error?.code }) ? thrownCodeFields(error, declaredStatus) : {}), @@ -718,6 +746,34 @@ export function boundedDeclaredUserMessage(error: unknown): string | undefined { return userMessage === undefined ? undefined : truncateClientMessage(userMessage); } +/** + * [#16146] This package's wire VALUE for a producer-DECLARED 5xx refusal: the + * message the producer authored for its caller, with #5423's bound applied — + * or `undefined` when the throw declared a fault, which is the default. + * + * Same split as the pair above, for the same reason. `declaredRefusalMessage` + * (`@objectstack/types`) decides PRESENCE — that is the ONE definition all + * three withhold arms read, and the runtime dispatcher exit reads it directly + * — and {@link truncateClientMessage} decides the BOUND, which is a per-DOOR + * question: the ruling's own text says a kept refusal is "bounded exactly as a + * 4xx message is", and in this package a 4xx message is bounded at + * {@link CLIENT_MESSAGE_MAX} by truncation, never by replacement. + * + * ⛔ Nothing here re-derives the declaration. Both REST arms and the + * `/meta/:type/:name/references` door call THIS, so the bound is applied at + * every mark rather than at some of them — the drift + * {@link boundedDeclaredUserMessage}'s own docblock was written against. + * + * ⚠️ Truncation cuts the TAIL, and a refusal is the one message shape that + * routinely back-loads its remedy ("Ask the owning object instead: …"). The + * measured `/references` sentence is 410 characters, so it survives whole; a + * producer writing a longer one owes the caller a front-loaded remedy. + */ +export function boundedDeclaredRefusalMessage(error: unknown): string | undefined { + const refusal = declaredRefusalMessage(error); + return refusal === undefined ? undefined : truncateClientMessage(refusal); +} + /** * [#11588 / #7543 / #14541] Did this error come out of a sandboxed body? * @@ -2177,11 +2233,24 @@ function resolveErrorResponse(error: any, object?: string): { status: number; bo // see {@link withDeclaredUserMessage}. On the 5xx arm the PROSE is // still withheld (#5437); the marked channel is authored user text, not // the message being withheld, so carrying it is not a re-opening. + // + // [#16146] …unless the producer DECLARED the 5xx to be a refusal. This + // is the second of the three arms that withhold BECAUSE the status was + // declared, and it reads the same {@link declaredRefusalMessage} the + // first one does rather than re-deriving the condition — "one rule, + // every door inherits" (#12509). The accepted cost recorded eight + // paragraphs up — a self-authored 5xx sentence reaching the client as + // the generic one — is now paid off for exactly the producers that + // declare `refusal: true`, and unchanged for every producer that does + // not. Note the two facts are independent: `userMessage` still rides + // both branches, addressed to the END USER, while this releases the + // DIAGNOSTIC `message` to the caller who asked. if (error.status >= 500) { + const refusal = boundedDeclaredRefusalMessage(error); return withDeclaredUserMessage(error, { status: error.status, body: { - error: INTERNAL_ERROR_MESSAGE, + error: refusal ?? INTERNAL_ERROR_MESSAGE, ...thrownCodeFields(error, error.status), }, }); @@ -2480,9 +2549,25 @@ export function isExpectedRouteError(status: number, body: Record }): void { - if (!isExpectedRouteError(resolved.status, resolved.body)) { + if (!isExpectedRouteError(resolved.status, resolved.body) && declaredRefusalMessage(error) === undefined) { logError('[REST] Unhandled error:', error); return; } diff --git a/packages/rest/src/rest-declared-refusal-relay.test.ts b/packages/rest/src/rest-declared-refusal-relay.test.ts new file mode 100644 index 00000000000..b5cfe1af32d --- /dev/null +++ b/packages/rest/src/rest-declared-refusal-relay.test.ts @@ -0,0 +1,325 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16146] A producer-DECLARED 5xx REFUSAL keeps its prose at every REST door; + * a declared FAULT still loses it. + * + * ## The ruling this pins + * + * Director seat, decision batch #58, 2026-09-06, maintainer 「同意」, option C: + * + * > Refusal versus fault is a producer-side declaration on the published + * > ADR-0112 envelope, not a status heuristic and not a second allow-list. One + * > optional field (spec card #16335) says "this message is authored for the + * > caller"; `declaredServerFaultAnswer` keeps the message verbatim only when + * > it is present and withholds it otherwise, exactly as today. + * + * The declaration is `ApiErrorSchema.refusal` (`@objectstack/spec`), on the + * tree since #16335, and the ONE read of it is `declaredRefusalMessage` + * (`@objectstack/types`). This package holds TWO of the three arms that + * withhold a declared 5xx's prose BECAUSE it was declared; the third is + * `errorResponseBase` in `@objectstack/runtime` and is pinned in that package. + * + * ## ⛔ Why every case DRIVES a real mounted route + * + * The defect this closes was invisible to a unit test on the arm: the arm was + * doing exactly what it said, and the prose died between a producer that + * authored it and a caller that never read it. So each case throws its shape + * from a REAL producer through a REAL route in `rest.getRoutes()` and reads the + * answer off the response object the door wrote — never by calling the arm. + * + * ## The two arms, driven apart + * + * They compose the same bytes, which is why the door-to-door proof needs both: + * + * - **arm 1**, `declaredServerFaultAnswer` — reached here through the + * analytics dataset door, which calls it BARE (#11718), i.e. without the + * `withDeclaredUserMessage` wrapper `/data` puts around it. That is the + * door a relay written into the wrapper would have missed. + * - **arm 2**, `resolveErrorResponse`'s own 5xx passthrough — reached here + * through `GET /meta/:type/:name/references` via `handleRouteError`. A + * throw spelling `status` takes the status passthrough into this arm; a + * `statusCode`-spelled one falls to `mapDataError` and arm 1. + * + * ## Controls + * + * Each kept-prose case has a DIFFERENTIAL twin: byte-identical throw, minus + * `refusal`. Without it a door that shipped every 5xx message would satisfy + * the positive half, and a door that withheld every one would satisfy the + * negative half — the pair is what makes each reading a measurement. No probe + * message contains `INTERNAL_ERROR_MESSAGE` as a substring, so no case can + * pass by the two strings coinciding. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; + +const META = '/api/v1/meta'; + +/** Prose a producer authored FOR the caller. Names nothing tenant-sensitive. */ +const AUTHORED = 'Cube pipeline cannot be rebuilt while a migration holds it. Retry after the migration completes, or ask for cube pipeline_v2.'; + +/** The detail a FAULT's prose names and the wire must never carry. */ +const SECRET = 'warehouse_replica_eu'; +const FAULT_PROSE = `Upstream warehouse pool exhausted for datasource ${SECRET}.`; + +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.send = vi.fn((b: any) => { res._body = b; return res; }); + res.header = vi.fn(() => res); + res.setHeader = vi.fn(() => res); + res.end = vi.fn(() => res); + return res; +} + +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([]), + }; +} + +/** + * A valid inline dataset + selection, so the route reaches its analytics + * service instead of refusing at the door. Same fixture shape the sibling + * `analytics-dataset-refusal-envelope.test.ts` uses. + */ +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'] }; + +/** A thrown shape carrying `props`, exactly as a producer composes one. */ +function declaring(props: Record, message: string) { + return Object.assign(new Error(message), props); +} + +// ── arm 1: the analytics dataset door, which calls the arm BARE ────────────── + +function datasetRoute(thrown: 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: async () => { throw thrown; } })) as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const route = rest.getRoutes().find((r) => r.method === 'POST' && r.path.endsWith('/analytics/dataset/query')); + expect(route, 'POST /analytics/dataset/query must be mounted').toBeTruthy(); + return route!; +} + +async function postDataset(thrown: unknown) { + const res = mockRes(); + await datasetRoute(thrown).handler( + { method: 'POST', params: {}, headers: {}, query: {}, body: { dataset: DATASET, selection: SELECTION } } as any, + res, + ); + return { status: res.statusCode, body: res._body as any }; +} + +// ── arm 2: the /references door, reached through handleRouteError ──────────── + +/** + * The narrowest engine the /references reads bottom out on: no rows anywhere. + * + * ⛔ READ-ONLY on purpose — no `delete`, `update` or `insert` member exists, + * because nothing this file drives writes, so it adds no write double for + * `check:engine-double-contract` to police. Its `findOne` IS pinned: a fake + * looser than `ObjectQL.findOne` is how a dead REST route once shipped with + * its suite green, so this one answers "no rows" to the same dispatch + * predicate the real engine enforces. + */ +function emptyEngine(): any { + return { + find: async () => [], + async findOne(table: string, opts: { where: Record }) { + assertEngineFindOnePredicate(table, opts); + return null; + }, + count: async () => 0, + aggregate: async () => [], + registry: { + listItems: () => [], getItem: () => undefined, getObject: () => undefined, + getPackage: () => undefined, getArtifactItem: () => undefined, isPackageDisabled: () => false, + }, + }; +} + +async function getReferences(thrown: unknown) { + const protocol: any = new ObjectStackProtocolImplementation(emptyEngine(), () => new Map()); + protocol.getDiscovery = async () => ({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }); + protocol.findReferencesToMeta = async () => { throw thrown; }; + + const rest = new RestServer( + { get() {}, post() {}, put() {}, patch() {}, delete() {}, use() {} } as any, + protocol as any, + { api: { requireAuth: false } } as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: ['manage_metadata'], tenantId: 'org_alpha' }); + rest.registerRoutes(); + + const route = (rest as any).getRoutes().find( + (r: any) => r.method === 'GET' && r.path === `${META}/:type/:name/references`, + ); + expect(route, 'GET /meta/:type/:name/references must be mounted').toBeTruthy(); + const res = mockRes(); + await route.handler({ method: 'GET', path: '', params: { type: 'object', name: 'account' }, query: {}, headers: {}, body: {} } as any, res); + return { status: res.statusCode, body: res._body as any }; +} + +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#16146] arm 1 — the analytics dataset door, which calls declaredServerFaultAnswer BARE', () => { + let logSpy: ReturnType; + beforeEach(() => { logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); + afterEach(() => { logSpy.mockRestore(); }); + + it('a DECLARED REFUSAL keeps its prose verbatim, and the code still travels', async () => { + const { status, body } = await postDataset( + declaring({ status: 503, code: 'SERVICE_UNAVAILABLE', refusal: true }, AUTHORED), + ); + expect(status).toBe(503); + expect(String(body.error ?? body.message)).toBe(AUTHORED); + expect(body.code).toBe('SERVICE_UNAVAILABLE'); + }); + + it('DIFFERENTIAL CONTROL — the same throw WITHOUT `refusal` is withheld', async () => { + const { status, body } = await postDataset( + declaring({ status: 503, code: 'SERVICE_UNAVAILABLE' }, AUTHORED), + ); + expect(status).toBe(503); + expect(String(body.error ?? body.message)).toBe(INTERNAL_ERROR_MESSAGE); + expect(body.code).toBe('SERVICE_UNAVAILABLE'); + // The two cases differ in ONE key, so the reading above is the field's. + expect(AUTHORED).not.toContain(INTERNAL_ERROR_MESSAGE); + }); + + it('a declared FAULT is withheld and its detail never reaches the wire', async () => { + const { status, body } = await postDataset( + declaring({ status: 503, code: 'SERVICE_UNAVAILABLE' }, FAULT_PROSE), + ); + expect(status).toBe(503); + expect(JSON.stringify(body)).not.toContain(SECRET); + }); + + it('the `statusCode` spelling declares the same refusal — no per-spelling dialect', async () => { + // #7525's axis. A producer that spells `statusCode` is fully + // ADR-0112-compliant, so its refusal must not depend on which field it + // reached for; the shared read looks at `status ?? statusCode`. + const { status, body } = await postDataset( + declaring({ statusCode: 503, code: 'SERVICE_UNAVAILABLE', refusal: true }, AUTHORED), + ); + expect(status).toBe(503); + expect(String(body.error ?? body.message)).toBe(AUTHORED); + }); + + it('⛔ `refusal: true` with NO `code` declares nothing — the shape is `status` + `code` + the flag', async () => { + const { status, body } = await postDataset(declaring({ status: 503, refusal: true }, AUTHORED)); + expect(status).toBe(503); + expect(String(body.error ?? body.message)).toBe(INTERNAL_ERROR_MESSAGE); + }); + + it('⛔ `refusal` is `true` or nothing — a guessed spelling is not a declaration', async () => { + for (const value of ['yes', 1, false, {}] as unknown[]) { + const { body } = await postDataset( + declaring({ status: 503, code: 'SERVICE_UNAVAILABLE', refusal: value }, AUTHORED), + ); + expect(String(body.error ?? body.message), `refusal: ${JSON.stringify(value)}`).toBe(INTERNAL_ERROR_MESSAGE); + } + }); + + it('⛔ SECURITY FLOOR — a refusal cannot buy leaky prose past the driver/SQL heuristic', async () => { + // The declaration says the prose is ADDRESSED to the caller; it does + // not say the prose is SAFE. `looksLikeInternalErrorLeak` stays + // unconditional, so a producer that declares a refusal over a driver + // dump is withheld exactly as a fault is. + const leak = 'SQLITE_ERROR: no such column: crm_account.secret_policy_field'; + const { body } = await postDataset( + declaring({ status: 503, code: 'SERVICE_UNAVAILABLE', refusal: true }, leak), + ); + expect(String(body.error ?? body.message)).toBe(INTERNAL_ERROR_MESSAGE); + expect(JSON.stringify(body)).not.toContain('secret_policy_field'); + }); + + it('⛔ the flag QUALIFIES a declared status — it never invents one', async () => { + // No `status`/`statusCode` at all: the throw declared no HTTP answer, + // so the undeclared-5xx heuristic row runs and the flag is inert. A + // leaky message therefore stays withheld. + const leak = 'SQLITE_ERROR: no such column: crm_account.secret_policy_field'; + const { body } = await postDataset(Object.assign(new Error(leak), { refusal: true })); + expect(String(body.error ?? body.message)).toBe(INTERNAL_ERROR_MESSAGE); + }); +}); + +describe('[#16146] arm 2 — resolveErrorResponse\'s 5xx passthrough, through handleRouteError', () => { + let logSpy: ReturnType; + beforeEach(() => { logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); + afterEach(() => { logSpy.mockRestore(); }); + + it('a DECLARED REFUSAL keeps its prose verbatim at a door the route-local patch never covered', async () => { + // `SERVICE_UNAVAILABLE` / 503, so the `/references` door's own + // `501 NOT_IMPLEMENTED` envelope adapter declines and this reaches + // `handleRouteError` — the arm the card's driven proof actually takes. + const { status, body } = await getReferences( + declaring({ status: 503, code: 'SERVICE_UNAVAILABLE', refusal: true }, AUTHORED), + ); + expect(status).toBe(503); + expect(String(body.error)).toBe(AUTHORED); + expect(body.code).toBe('SERVICE_UNAVAILABLE'); + }); + + it('DIFFERENTIAL CONTROL — the same throw WITHOUT `refusal` is withheld', async () => { + const { status, body } = await getReferences( + declaring({ status: 503, code: 'SERVICE_UNAVAILABLE' }, AUTHORED), + ); + expect(status).toBe(503); + expect(String(body.error)).toBe(INTERNAL_ERROR_MESSAGE); + }); + + it('a declared FAULT still loses its prose, and its detail never reaches the wire', async () => { + const { body } = await getReferences(declaring({ status: 503, code: 'SERVICE_UNAVAILABLE' }, FAULT_PROSE)); + expect(JSON.stringify(body)).not.toContain(SECRET); + }); + + it('LOGGING — a declared refusal is NOT logged as `[REST] Unhandled error`', async () => { + // The card's second symptom, and the ruling's third constraint: + // "logging follows the same field". Driven, because the log line and + // the wire body are decided at two different call sites. + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await getReferences(declaring({ status: 501, code: 'NOT_IMPLEMENTED', refusal: true }, AUTHORED)); + const refusalLines = spy.mock.calls.map((c) => String(c[0])); + expect(refusalLines.some((l) => l.includes('[REST] Unhandled error'))).toBe(false); + + // …and the CONTROL, on the same instrument: drop the flag and the + // identical throw is logged as an unhandled fault again. Without + // this, a spy that never sees anything would pass the assertion + // above for reasons that have nothing to do with the field. + spy.mockClear(); + await getReferences(declaring({ status: 501, code: 'NOT_IMPLEMENTED' }, AUTHORED)); + const faultLines = spy.mock.calls.map((c) => String(c[0])); + expect(faultLines.some((l) => l.includes('[REST] Unhandled error'))).toBe(true); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index efdd46f79a9..94b8f34c242 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -328,6 +328,7 @@ import { sandboxBusinessMessage, classifiedRefusalAnswer, boundedDeclaredUserMessage, + boundedDeclaredRefusalMessage, declaredHttpStatus, declaredServerFaultAnswer, sendThrownError, @@ -1457,9 +1458,37 @@ async function wiredEngineOrLoud( * SECOND refusal code on this route would fall back to the flat fault answer * until whoever adds it comes here. That is a visible, one-line extension, * not a silent gap. - * - **A non-empty message.** This arm exists to relay PROSE; with none - * declared there is nothing to relay, and inventing one is the half - * {@link declaredServerFaultAnswer} refuses to invent too. + * - **A DECLARED refusal.** [#16146] This was "a non-empty message", and it + * was this arm's own opinion about which producer-declared 5xx keeps its + * prose — the very question the relay could not answer when this was + * written. It can now: the director seat ruled the distinction a + * producer-side declaration on the published ADR-0112 envelope (decision + * batch #58, 2026-09-06, option C) and the producer sets it + * (`findReferencesToMeta`, `metadata-protocol`). So the condition is + * {@link boundedDeclaredRefusalMessage} — the shared relay's own answer, + * bound and all — and this route holds no refusal/fault opinion of its own + * any more. + * + * ## [#16146] What was RETIRED here, and the one half that could not be + * + * The ruling says to retire this route-local patch once the relay handles + * `/references`, and its PROSE half is retired exactly as ruled: the sentence + * now reaches the wire because the relay keeps it at EVERY door, the bound is + * the shared one rather than this arm's unbounded pass-through, and deleting + * the call below would change no message on this route. + * + * ⚠️ What deleting it WOULD change is the ENVELOPE, and that is a different + * decision. This function also re-dresses the answer into the NESTED ADR-0112 + * envelope this door's B exit publishes; the relay is flat + * (`{ error, code }` through `handleRouteError`), so removing this arm would + * put `body.error.code` back to `undefined` on the A exit and re-open the + * SECOND half of the defect #15685 measured and pinned positionally in + * `rest-server-meta-references-refusal-envelope.test.ts`. Envelope POSITION is + * owned by the `check:route-envelope` ratchet and is explicitly a separate + * line from vocabulary (ADR-0112's #9232 amendment says so in as many words), + * so it is not folded into a prose ruling. What remains here is therefore a + * pure position adapter over the shared answer — ⛔ not a second withhold arm, + * and ⛔ not a place to add a refusal rule. * * ⛔ And it does not re-derive `REFERENCE_SITES.unanswerableTargetTypes` to * decide whether the target was answerable. That set, its canonical-type fold @@ -1473,8 +1502,8 @@ function notImplementedRefusalAnswer( ): { status: number; body: { error: { code: string; message: string } } } | undefined { if (declaredHttpStatus(error) !== 501) return undefined; if (error?.code !== 'NOT_IMPLEMENTED') return undefined; - const message = typeof error?.message === 'string' ? error.message : ''; - if (message.length === 0) return undefined; + const message = boundedDeclaredRefusalMessage(error); + if (message === undefined) return undefined; return { status: 501, body: { error: { code: 'NOT_IMPLEMENTED', message } } }; } 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 index 25bf02cf8a6..106f8b1dfe1 100644 --- a/packages/runtime/src/dispatcher-plugin.declared-5xx-prose-withhold.test.ts +++ b/packages/runtime/src/dispatcher-plugin.declared-5xx-prose-withhold.test.ts @@ -283,3 +283,109 @@ describe('[#12281] a DECLARED 5xx has its prose withheld at the dispatcher exit' expect(res.body.error.message).toBe('Measure revenue is not additive across the stage dimension.'); }); }); + +/** + * [#16146 / #17153] The ONE exception the prose limb above now has, and the + * reason this file's seven cases stay red-proof: none of them declares it. + * + * ## What changed, and what did not + * + * `serverFaultProvenance` answers WHO named this 5xx. It cannot answer WHAT + * KIND it is, so this exit withheld a deliberate REFUSAL — prose a producer + * authored FOR its caller — exactly as it withholds a driver fault. The + * director seat ruled that distinction a producer-side DECLARATION on the + * published ADR-0112 envelope (decision batch #58, 2026-09-06, option C): + * `ApiErrorSchema.refusal`, which this exit's own `ErrorResponseSchema` nests. + * + * This exit is the THIRD of three arms that withhold on a declaration, and the + * only one outside `@objectstack/rest`. It reads the same + * `declaredRefusalMessage` (`@objectstack/types`) the other two read — ⛔ not a + * second copy, for the reason `dispatcher-plugin.ts:691` already gives about + * this exact family. + * + * ## Every case here is a DIFFERENTIAL against a case above + * + * The `DECLARED` table's row 4 (`{ status: 503, code: 'SERVICE_UNAVAILABLE' }`) + * is the fault twin of the first case below: the two throws differ in exactly + * one key. That is what makes the reading the field's, rather than the + * instrument's. + */ +describe('[#16146] …unless the producer DECLARED the 5xx to be a refusal', () => { + let logSpy: ReturnType; + beforeEach(() => { logSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); + afterEach(() => { logSpy.mockRestore(); }); + + /** Prose authored FOR the caller; names nothing tenant-sensitive. */ + const AUTHORED = 'Cube pipeline cannot be rebuilt while a migration holds it. Ask for cube pipeline_v2 instead.'; + + it('a DECLARED REFUSAL keeps its prose verbatim — the fault twin one describe up does not', async () => { + const res = await throwFromAnalyticsQuery( + declaring({ status: 503, code: 'SERVICE_UNAVAILABLE', refusal: true }, AUTHORED), + ); + expect(res.statusCode).toBe(503); + expect(res.body.error.message).toBe(AUTHORED); + expect(res.body.error.message).not.toBe(INTERNAL_ERROR_MESSAGE); + // The classification is untouched — only the prose moved. + expect(typeof res.body.error.code).toBe('string'); + // Disjoint strings, so the assertion cannot be satisfied by coincidence. + expect(AUTHORED).not.toContain(INTERNAL_ERROR_MESSAGE); + }); + + it('the `statusCode` spelling declares the same refusal — no per-spelling dialect (#7525)', async () => { + const res = await throwFromAnalyticsQuery( + declaring({ statusCode: 503, code: 'SERVICE_UNAVAILABLE', refusal: true }, AUTHORED), + ); + expect(res.statusCode).toBe(503); + expect(res.body.error.message).toBe(AUTHORED); + }); + + it('⛔ `refusal: true` with NO `code` is not the declared shape — still withheld', async () => { + // The spec's own table row is `status >= 500`, a `code`, and the flag. + // Fail-closed: a producer that declares half the shape declares nothing. + const res = await throwFromAnalyticsQuery(declaring({ status: 503, refusal: true }, AUTHORED)); + expect(res.body.error.message).toBe(INTERNAL_ERROR_MESSAGE); + }); + + it('⛔ `true` is the only value — a guessed spelling declares nothing', async () => { + for (const value of ['yes', 1, false] as unknown[]) { + const res = await throwFromAnalyticsQuery( + declaring({ status: 503, code: 'SERVICE_UNAVAILABLE', refusal: value }, AUTHORED), + ); + expect(res.body.error.message, `refusal: ${JSON.stringify(value)}`).toBe(INTERNAL_ERROR_MESSAGE); + } + }); + + it('⛔ SECURITY FLOOR — a refusal cannot buy leaky prose past the heuristic this exit has run since #3867', async () => { + const res = await throwFromAnalyticsQuery( + declaring( + { status: 503, code: 'SERVICE_UNAVAILABLE', refusal: true }, + 'SQLITE_ERROR: no such column: crm_account.secret_policy_field', + ), + ); + expect(res.body.error.message).toBe(INTERNAL_ERROR_MESSAGE); + expect(JSON.stringify(res.body)).not.toContain('secret_policy_field'); + }); + + it('⛔ the flag QUALIFIES a declared status — it never invents one', async () => { + // No `status`/`statusCode`: `errorResponseBase`'s own `httpStatus` + // falls back to 500 and `serverFaultProvenance` answers `'undeclared'`, + // so #5667's tiering runs and the flag is inert. A leaky message is + // therefore still withheld by the heuristic limb. + const res = await throwFromAnalyticsQuery( + Object.assign(new Error('SQLITE_ERROR: no such column: crm_account.secret_policy_field'), { refusal: true }), + ); + expect(res.statusCode).toBe(500); + expect(res.body.error.message).toBe(INTERNAL_ERROR_MESSAGE); + }); + + it('the untouched error still reaches the operator on a relayed refusal', async () => { + // The withhold's compensation is not lost when the withhold is: the + // `__obsRecordedError` side-channel still hands `errorReporter` the + // original throw, so an operator reading a refusal sees the same object + // they see for a fault. + const res = await throwFromAnalyticsQuery( + declaring({ status: 503, code: 'SERVICE_UNAVAILABLE', refusal: true }, AUTHORED), + ); + expect(String((res as any).__obsRecordedError?.message)).toBe(AUTHORED); + }); +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index b78584d412e..3f450870862 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, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, serverFaultProvenance, demotedDeclaredCode, logServerFault, describeFaultRequest } from '@objectstack/types'; +import { looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, serverFaultProvenance, declaredRefusalMessage, demotedDeclaredCode, logServerFault, describeFaultRequest } from '@objectstack/types'; import { DispatcherErrorCode } from '@objectstack/spec/api'; import type { IAuthService, IMetadataService, Logger } from '@objectstack/spec/contracts'; import type { CounterStore } from '@objectstack/plugin-auth/rate-limit-storage'; @@ -715,10 +715,36 @@ function errorResponseBase( // 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. + // + // [#16146 / #17153] A sixth, and the ONE exception to the declaration limb + // above. `serverFaultProvenance` answers WHO named this 5xx; it cannot + // answer WHAT KIND it is, so this exit withheld a deliberate REFUSAL — + // prose the producer authored for its caller — exactly as it withholds a + // driver fault. The director seat ruled the distinction a producer-side + // DECLARATION on the published ADR-0112 envelope (decision batch #58, + // 2026-09-06, option C): `ApiErrorSchema.refusal`, which this exit's own + // `ErrorResponseSchema` nests. `declaredRefusalMessage` is that read, + // ⛔ not a second copy — this exit is the THIRD arm of three that withhold + // on a declaration, and the other two (`declaredServerFaultAnswer` and + // `resolveErrorResponse`'s 5xx passthrough, `@objectstack/rest`) call the + // same function, which is what keeps "one rule, every door inherits" + // (#12509) a construction rather than three suites agreeing about a field + // name. + // + // ⛔ Still NOT a widening of the `'declared'` limb. Absent the flag this + // expression is byte-identical, so #12281's structural withhold is intact + // for every producer that declares a FAULT — which is the default, and the + // only thing a rewrap can carry. The shared read is fail-closed on its own + // account too: it requires a declared in-band 5xx, a non-empty string + // `code`, prose, and prose that does not trip `looksLikeInternalErrorLeak` + // — so a refusal cannot buy its way past the driver/SQL filter this exit + // has applied since #3867, and the heuristic limb below stays unconditional. + const refusalMessage = declaredRefusalMessage(err); const message = - serverFaultProvenance(thrown) === 'declared' || (httpStatus >= 500 && looksLikeInternalErrorLeak(raw)) + refusalMessage + ?? (serverFaultProvenance(thrown) === 'declared' || (httpStatus >= 500 && looksLikeInternalErrorLeak(raw)) ? INTERNAL_ERROR_MESSAGE - : raw || 'Internal Server Error'; + : raw || 'Internal Server Error'); // [#3842] A thrown error's own `.code` finally has somewhere to go — the // declared field, so the same SDK method reports the same code whichever // exit answered. [#9106] WHICH spelling goes there is the shared resolver's diff --git a/packages/types/src/thrown-http-error-refusal.test.ts b/packages/types/src/thrown-http-error-refusal.test.ts new file mode 100644 index 00000000000..163b5cd0c39 --- /dev/null +++ b/packages/types/src/thrown-http-error-refusal.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16146] `declaredRefusalMessage` — the ONE read of the producer-side refusal + * declaration, which all three withhold arms make. + * + * The WIRE proof lives at the doors (`rest-declared-refusal-relay.test.ts`, + * `dispatcher-plugin.declared-5xx-prose-withhold.test.ts`), because the defect + * this closes was a message dying between a producer and a caller and no unit + * test on the arm could see it. This file pins the CONDITIONS instead, in one + * place, so a door never grows its own answer to any of them — the per-door + * divergence `serverFaultProvenance` and `demotedDeclaredCode` were extracted + * to end (#12509). + */ + +import { describe, it, expect } from 'vitest'; +import { declaredRefusalMessage } from './thrown-http-error.js'; +import { declaresServerFault } from './error-leak.js'; + +const AUTHORED = 'References to a `field` item cannot be computed. Ask the owning object instead.'; + +function declaring(props: Record, message = AUTHORED) { + return Object.assign(new Error(message), props); +} + +describe('[#16146] declaredRefusalMessage — the declared shape', () => { + it('answers the prose for `status` + `code` + `refusal: true`', () => { + expect(declaredRefusalMessage(declaring({ status: 501, code: 'NOT_IMPLEMENTED', refusal: true }))) + .toBe(AUTHORED); + }); + + it('reads BOTH status spellings — a producer answer never depends on the field it reached for (#7525)', () => { + expect(declaredRefusalMessage(declaring({ statusCode: 501, code: 'NOT_IMPLEMENTED', refusal: true }))) + .toBe(AUTHORED); + }); + + it('`status` wins over `statusCode` where both are present, exactly as the resolver reads them', () => { + // A 4xx `status` beside a 5xx `statusCode` declares a 4xx, and the + // field is redundant on a 4xx — so nothing is relayed and no arm is + // reached. Pinned because the opposite precedence would silently widen + // the band. + expect(declaredRefusalMessage(declaring({ status: 409, statusCode: 503, code: 'RECORD_LOCKED', refusal: true }))) + .toBeUndefined(); + }); + + it('answers `undefined` for a declared FAULT — the default, and the whole second row of the table', () => { + expect(declaredRefusalMessage(declaring({ status: 503, code: 'SERVICE_UNAVAILABLE' }))).toBeUndefined(); + }); +}); + +describe('[#16146] declaredRefusalMessage — each condition, alone', () => { + it('⛔ `true` is the only value: presence IS the declaration', () => { + for (const refusal of [false, 'true', 'yes', 1, {}, null] as unknown[]) { + expect(declaredRefusalMessage(declaring({ status: 501, code: 'NOT_IMPLEMENTED', refusal })), String(refusal)) + .toBeUndefined(); + } + }); + + it('⛔ it QUALIFIES a declared status and never invents one', () => { + expect(declaredRefusalMessage(Object.assign(new Error(AUTHORED), { code: 'NOT_IMPLEMENTED', refusal: true }))) + .toBeUndefined(); + }); + + it('⛔ a 4xx declares no server fault to except — the field is redundant there', () => { + expect(declaredRefusalMessage(declaring({ status: 403, code: 'PERMISSION_DENIED', refusal: true }))) + .toBeUndefined(); + }); + + it('⛔ an out-of-band status is not a declaration, so the withhold below it is untouched', () => { + // `declaresServerFault` has no upper bound, so a nonsense `status: 700` + // with a code still reaches the analytics door's generic-500 branch and + // must keep the withhold it has had since #5367. Bounding the read at + // 599 — the same band `packages/rest`'s `declaredHttpStatus` uses — is + // what keeps that branch unreachable by a refusal. + expect(declaredRefusalMessage(declaring({ status: 700, code: 'NOT_IMPLEMENTED', refusal: true }))) + .toBeUndefined(); + expect(declaresServerFault({ status: 700, code: 'NOT_IMPLEMENTED' })).toBe(true); + }); + + it('⛔ the `code` half is asked through `declaresServerFault`, not restated', () => { + // The ruling's fourth constraint says that predicate keeps its live + // production caller; this read is a SECOND one, so an ADR-0049 remove + // pass now breaks two things instead of one. + expect(declaredRefusalMessage(declaring({ status: 501, refusal: true }))).toBeUndefined(); + expect(declaredRefusalMessage(declaring({ status: 501, code: '', refusal: true }))).toBeUndefined(); + expect(declaresServerFault({ status: 501, code: '' })).toBe(false); + }); + + it('⛔ there must be prose to relay — nothing is invented', () => { + expect(declaredRefusalMessage(declaring({ status: 501, code: 'NOT_IMPLEMENTED', refusal: true }, ''))).toBeUndefined(); + expect(declaredRefusalMessage(declaring({ status: 501, code: 'NOT_IMPLEMENTED', refusal: true }, ' '))).toBeUndefined(); + }); + + it('⛔ SECURITY FLOOR — the declaration says ADDRESSED, the heuristic says SAFE', () => { + expect(declaredRefusalMessage(declaring( + { status: 501, code: 'NOT_IMPLEMENTED', refusal: true }, + 'SQLITE_ERROR: no such column: crm_account.secret_policy_field', + ))).toBeUndefined(); + }); + + it('a non-object throw declares nothing', () => { + for (const value of [undefined, null, 'boom', 42] as unknown[]) { + expect(declaredRefusalMessage(value), String(value)).toBeUndefined(); + } + }); +}); diff --git a/packages/types/src/thrown-http-error.ts b/packages/types/src/thrown-http-error.ts index ded6de181ed..df27e00d340 100644 --- a/packages/types/src/thrown-http-error.ts +++ b/packages/types/src/thrown-http-error.ts @@ -91,6 +91,7 @@ */ import { ErrorCode, standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; +import { declaresServerFault, looksLikeInternalErrorLeak } from './error-leak.js'; import { validationFailureDetails, VALIDATION_FAILED_STATUS } from './validation-failure.js'; /** The HTTP answer a thrown error declares. See {@link resolveThrownHttpError}. */ @@ -326,6 +327,93 @@ export function serverFaultProvenance(thrown: ThrownHttpError): ServerFaultProve return thrown.declaredStatus === undefined ? 'undeclared' : 'declared'; } +/** + * [#16146] The prose a producer DECLARED to be a deliberate 5xx REFUSAL + * addressed to its caller — or `undefined` for everything else, which is the + * default and stays the default. + * + * ## What it is + * + * `ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side + * declaration ruled by the director seat in decision batch #58 (2026-09-06, + * option C): refusal versus fault is a declaration on the published ADR-0112 + * envelope, NOT a status heuristic and NOT a second allow-list. A producer + * that composes a 5xx FOR its caller sets `refusal: true` beside the `status` + * and `code` it already declares; a fault declares nothing here and its prose + * is withheld exactly as before. That is the third row of the table in that + * field's own docblock; the first two rows are unchanged and the second is + * still what an undeclared field means. + * + * ## Why this is ONE function and not a read at each arm + * + * Three arms withhold a declared 5xx's prose BECAUSE it was declared — + * `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in + * `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime` — and + * the 2026-08-27 ruling on #12509 that governs this family says the rule is + * "implemented once at the shared resolver layer so all doors inherit one + * rule; no per-registrar variants". A `refusal` read re-derived at each arm is + * exactly the divergence {@link serverFaultProvenance} and + * {@link demotedDeclaredCode} were extracted to end. ⛔ Do not probe + * `error.refusal` at a door; ask here. + * + * ## The four conditions, and why each one is load-bearing + * + * - **`refusal === true`, and only `true`.** The spec declares the key as + * `z.literal(true).optional()`: presence IS the declaration, so a + * `refusal: 'yes'` or `refusal: 1` from a producer that guessed at the + * shape declares nothing and is withheld like any other fault. + * - **A DECLARED status in the 5xx band**, read through both spellings + * (`status` then `statusCode`) and bounded 500-599 — the same band and the + * same two-spelling read `packages/rest`'s `declaredHttpStatus` applies, so + * a producer's answer cannot depend on which field it reached for (#7525), + * and a nonsense `status: 700` is not a declaration here any more than it + * is there. The field QUALIFIES a declared status; it never invents one, so + * a throw that declared no status is untouched and still goes through the + * undeclared-5xx heuristic (#5667). + * - **A non-empty string `code`**, asked through {@link declaresServerFault} + * rather than restated — the same "the producer declared this shape" + * predicate the relay already trusts to decide whether `code` travels, and + * the shape the spec's own table row names (`status >= 500`, a `code`, and + * `refusal: true`). It is why this read gives that function a SECOND live + * production caller rather than replacing it. + * - **The message does not trip {@link looksLikeInternalErrorLeak}.** The + * floor under the whole channel: the declaration decides whether prose is + * ADDRESSED to the caller, and the heuristic decides whether it is SAFE to + * send — a producer cannot buy its way past the driver/SQL filter by + * declaring a refusal. It costs nothing that exists (a refusal is authored + * prose, not a driver dump) and it means the answer here is fail-closed in + * both directions: no declaration ⇒ withheld, a declaration over leaky + * prose ⇒ withheld and logged by the arm exactly as a fault is. + * + * ⛔ It answers the MESSAGE, not a boolean, for the reason + * {@link ThrownHttpError.userMessage} is a text-carrying field: the mark and + * the text it releases are one value here too, so no arm can hold "this is a + * refusal" while composing a body from some other string. What each arm then + * applies is its OWN door's caller-addressed bound — `truncateClientMessage` + * in `packages/rest`, none at the dispatcher exit, which is what "bounded + * exactly as a 4xx message is" (#5423) means per door. + */ +export function declaredRefusalMessage(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const e = error as { + refusal?: unknown; + status?: unknown; + statusCode?: unknown; + code?: unknown; + message?: unknown; + }; + if (e.refusal !== true) return undefined; + const declaredStatus = + typeof e.status === 'number' ? e.status + : typeof e.statusCode === 'number' ? e.statusCode + : undefined; + if (declaredStatus === undefined || declaredStatus < 500 || declaredStatus >= 600) return undefined; + if (!declaresServerFault({ status: declaredStatus, code: e.code })) return undefined; + const message = typeof e.message === 'string' ? e.message : ''; + if (message.trim().length === 0) return undefined; + return looksLikeInternalErrorLeak(message) ? undefined : message; +} + /** * The producer's spelling a boundary should surface as the wire's * `declaredCode` beside the closed `code` — or `undefined` when there is diff --git a/scripts/adr-anchors/packages__types__src__thrown-http-error.ts.json b/scripts/adr-anchors/packages__types__src__thrown-http-error.ts.json index e76a726f293..0c0dffd1836 100644 --- a/scripts/adr-anchors/packages__types__src__thrown-http-error.ts.json +++ b/scripts/adr-anchors/packages__types__src__thrown-http-error.ts.json @@ -3,5 +3,5 @@ "adrs": [ "ADR-0112" ], - "invariant": "This is the ONE definition of how a thrown error becomes an HTTP answer, and the two spellings it returns are not a redundancy to tidy away. ADR-0112 makes `error.code` a CLOSED vocabulary (`StandardErrorCode` union the registered ledger), and the 2026-08-16 ruling on #9106 extended that from the REST package door to every door this function serves: `code` is always a union member — a throw whose `.code` is unregistered falls to the member the status derives — while `declaredCode` keeps the producer's verbatim string. `demotedDeclaredCode()` is the single rule for which spelling a DOOR surfaces beside the closed one (scoped from 'a boundary' to 'a door' on 2026-08-29, #12948 — see the declared non-door relay at the end of this invariant), and it answers `undefined` for a registered code on purpose: emitting both would put two spellings of one fact on every refusal, and `ApiErrorSchema.declaredCode`'s documented semantics are that PRESENCE MEANS DEMOTION. Do not 'simplify' a boundary by writing `thrown.declaredCode` into `error.code` — that is the pre-#9106 dispatcher behaviour, and it re-opens the tenant-authored limb #9106 closed: a metadata app's action code crosses the QuickJS sandbox carrying the app's own `.code` (#7867, a capability deliberately granted and preserved), so `error.code` would again carry strings authored by tenants at runtime, which no ledger can enumerate and no gate can sweep. The author's spelling is not dropped — it rides the wire's open `declaredCode` channel instead. [#12509, ruled 2026-08-27] The channel also has a 5xx SCOPE, and it is the SECOND thing `demotedDeclaredCode()` answers: on a 5xx the producer did NOT declare, the demoted spelling came off an undeclared producer -- a driver errno, measured on the wire -- and is withheld along with the prose; an AUTHOR-DECLARED code survives at every status. `serverFaultProvenance()` is the ONE definition of that distinction, and the discriminator is the STATUS channel because it is the only structural one: a driver errno and an app's own spelling both arrive on `.code` as a plain string, so anything that told them apart by LOOKING at the string would be a heuristic over an open channel. Do not re-derive the condition at a door and do not gate it on whether `looksLikeInternalErrorLeak` fired -- that predicate reads a DIFFERENT channel, and gating on it leaks the errno for exactly the dialects whose prose the heuristic misses. The 'declared' limb of the same function is what #12281's prose rule will read; it is deliberately not applied yet. DECLARED NON-DOOR EMISSION (2026-08-29, #12948): exactly one site in the repo emits `declaredCode` without calling either function, and it is exempt on purpose rather than un-migrated — `packages/cloud-connection/src/cloud-connection-plugin.ts:382` hand-writes the field on a hard-coded 400 to RELAY an upstream RFC 8628 device-authorization spelling (`expired_token`, `access_denied`, …) verbatim, beside the registered `DEVICE_CODE_FAILED` its own route chose. Do NOT 'finish the migration' by routing it through this pair: that was measured on 2026-08-29 and CHANGES THE WIRE on 5 of 5 realistic inputs, because `resolveThrownHttpError` DERIVES the closed code from the throw or the status, no RFC 8628 spelling is a ledger member (all 0 hits; positive control `DEVICE_CODE_FAILED` 1), and the derived answer is `standardErrorCodeForHttpStatus(400)` = `VALIDATION_ERROR` — a generic 400 bucket replacing a registered, domain-meaningful code, on a route the Console polls. This pair DERIVES the code a boundary emits; a relay legitimately CHOOSES one, so the shapes do not match. ⚠️ THE TRAP, worth more than the exemption: that relay is safe today for ONE structural reason — it emits a 4xx — while the server-fault withholding keys on 5xx (`declaresServerFault` is `status >= 500 && typeof code === 'string' && code.length > 0`, `error-leak.ts`). It is outside that scope BY CONSTRUCTION, not by luck, so if that route ever grows a 5xx limb the limb will SILENTLY bypass the shared rule, and no gate will say so: `check:dispatcher-error-vocabulary`, `check:route-envelope` and `check:nul-bytes` were all green on 2026-08-29 with the hand-built emission already in the tree, and route-envelope carries that file in its own registry — it sees the FILE and still says nothing about this field. Add a 5xx exit to `bind/poll` only by routing it through this pair or re-opening the exemption." + "invariant": "This is the ONE definition of how a thrown error becomes an HTTP answer, and the two spellings it returns are not a redundancy to tidy away. ADR-0112 makes `error.code` a CLOSED vocabulary (`StandardErrorCode` union the registered ledger), and the 2026-08-16 ruling on #9106 extended that from the REST package door to every door this function serves: `code` is always a union member — a throw whose `.code` is unregistered falls to the member the status derives — while `declaredCode` keeps the producer's verbatim string. `demotedDeclaredCode()` is the single rule for which spelling a DOOR surfaces beside the closed one (scoped from 'a boundary' to 'a door' on 2026-08-29, #12948 — see the declared non-door relay at the end of this invariant), and it answers `undefined` for a registered code on purpose: emitting both would put two spellings of one fact on every refusal, and `ApiErrorSchema.declaredCode`'s documented semantics are that PRESENCE MEANS DEMOTION. Do not 'simplify' a boundary by writing `thrown.declaredCode` into `error.code` — that is the pre-#9106 dispatcher behaviour, and it re-opens the tenant-authored limb #9106 closed: a metadata app's action code crosses the QuickJS sandbox carrying the app's own `.code` (#7867, a capability deliberately granted and preserved), so `error.code` would again carry strings authored by tenants at runtime, which no ledger can enumerate and no gate can sweep. The author's spelling is not dropped — it rides the wire's open `declaredCode` channel instead. [#12509, ruled 2026-08-27] The channel also has a 5xx SCOPE, and it is the SECOND thing `demotedDeclaredCode()` answers: on a 5xx the producer did NOT declare, the demoted spelling came off an undeclared producer -- a driver errno, measured on the wire -- and is withheld along with the prose; an AUTHOR-DECLARED code survives at every status. `serverFaultProvenance()` is the ONE definition of that distinction, and the discriminator is the STATUS channel because it is the only structural one: a driver errno and an app's own spelling both arrive on `.code` as a plain string, so anything that told them apart by LOOKING at the string would be a heuristic over an open channel. Do not re-derive the condition at a door and do not gate it on whether `looksLikeInternalErrorLeak` fired -- that predicate reads a DIFFERENT channel, and gating on it leaks the errno for exactly the dialects whose prose the heuristic misses. The 'declared' limb of the same function is the PROSE axis, and it is no longer a future tense: #12281 landed it at the runtime dispatcher exit (`errorResponseBase`, `packages/runtime/src/dispatcher-plugin.ts`), which withholds the message of every declared 5xx. [#16146, ruled 2026-09-06, decision batch #58 option C] That prose limb now has exactly ONE exception, and it is a PRODUCER-SIDE DECLARATION rather than a heuristic or a second allow-list: `ApiErrorSchema.refusal` (`packages/spec/src/api/contract.zod.ts`) says the 5xx a producer declared is a deliberate REFUSAL whose message is authored for the caller, and `declaredRefusalMessage()` -- in THIS file, beside `serverFaultProvenance()` -- is the ONE read of it, made by all three withhold arms (`declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `packages/rest`, `errorResponseBase` in `packages/runtime`). It is fail-closed by construction (a declared in-band 5xx, a non-empty string `code` asked through `declaresServerFault`, non-empty prose, and prose that does not trip `looksLikeInternalErrorLeak`) and it changes NOTHING on the CODE channel this invariant governs: `serverFaultProvenance()` and `demotedDeclaredCode()` answer exactly what they answered before, for a refusal exactly as for a fault. Do not re-derive the refusal read at a door, for the same reason the code read is not re-derived at one. DECLARED NON-DOOR EMISSION (2026-08-29, #12948): exactly one site in the repo emits `declaredCode` without calling either function, and it is exempt on purpose rather than un-migrated — `packages/cloud-connection/src/cloud-connection-plugin.ts:382` hand-writes the field on a hard-coded 400 to RELAY an upstream RFC 8628 device-authorization spelling (`expired_token`, `access_denied`, …) verbatim, beside the registered `DEVICE_CODE_FAILED` its own route chose. Do NOT 'finish the migration' by routing it through this pair: that was measured on 2026-08-29 and CHANGES THE WIRE on 5 of 5 realistic inputs, because `resolveThrownHttpError` DERIVES the closed code from the throw or the status, no RFC 8628 spelling is a ledger member (all 0 hits; positive control `DEVICE_CODE_FAILED` 1), and the derived answer is `standardErrorCodeForHttpStatus(400)` = `VALIDATION_ERROR` — a generic 400 bucket replacing a registered, domain-meaningful code, on a route the Console polls. This pair DERIVES the code a boundary emits; a relay legitimately CHOOSES one, so the shapes do not match. ⚠️ THE TRAP, worth more than the exemption: that relay is safe today for ONE structural reason — it emits a 4xx — while the server-fault withholding keys on 5xx (`declaresServerFault` is `status >= 500 && typeof code === 'string' && code.length > 0`, `error-leak.ts`). It is outside that scope BY CONSTRUCTION, not by luck, so if that route ever grows a 5xx limb the limb will SILENTLY bypass the shared rule, and no gate will say so: `check:dispatcher-error-vocabulary`, `check:route-envelope` and `check:nul-bytes` were all green on 2026-08-29 with the hand-built emission already in the tree, and route-envelope carries that file in its own registry — it sees the FILE and still says nothing about this field. Add a 5xx exit to `bind/poll` only by routing it through this pair or re-opening the exemption." } diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 4c5ad9ca5e0..c21f7fdd238 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3266,6 +3266,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/rest/src/rest-declared-refusal-relay.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts", "verb": "findOne",