Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .changeset/dispatcher-declared-5xx-prose-withhold.md
Original file line numberDiff line numberDiff line change
@@ -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":"<producer prose>","httpStatus":503,…}}` | `{"error":{"message":"Internal server error","httpStatus":503,…}}` |
| `{ statusCode: 503, code: 'SERVICE_UNAVAILABLE' }` | `{"error":{"message":"<producer prose>",…}}` | `{"error":{"message":"Internal server error",…}}` |
| `{ statusCode: 503 }`, no `code` | `{"error":{"message":"<producer prose>",…}}` | `{"error":{"message":"Internal server error",…}}` |
| `{ status: 503, code: 'SERVICE_UNAVAILABLE' }` | `Internal server error` | `Internal server error` (unchanged) |
| a bare `Error` (declares nothing) | `<producer prose>` | `<producer prose>` (**unchanged**) |
| any declared 4xx | `<producer prose>` | `<producer prose>` (**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.
37 changes: 32 additions & 5 deletions packages/runtime/src/analytics-query-read-scope-withhold.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 () => {
Expand Down
42 changes: 30 additions & 12 deletions packages/runtime/src/dispatcher-5xx-demoted-code-withhold.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }),
);
Expand All@@ -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/);
});
});
Loading
Loading