Skip to content

Commit 02d56b4

Browse files
Elon Muskclaude
andauthored
fix(metadata-protocol): rethrow the request-classification 400 out of getMetaDiagnostics (#8924) (#10520)
* fix(metadata-protocol): rethrow the request-classification 400 out of getMetaDiagnostics (#8924) The per-type catch now propagates BOTH producer-classified verdicts: the metadata-store 503 (#8855) and the canonicalizeMetaRequestType 400 (INVALID_REQUEST) for an unrecognised spelling of a declared type. A genuine, unclassified listing failure still skips the one type. Maintainer ruling 2026-08-20: option 1 — rethrow the 400. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM * chore: changeset for the diagnostics request-arm 400 (#8924) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dfe3c45 commit 02d56b4

3 files changed

Lines changed: 189 additions & 1 deletion

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): `getMetaDiagnostics` refuses an unrecognised `type` spelling with the producer's 400 instead of answering "scanned 1 type, 0 problems" (#8924)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) The same one-method
8+
`catch` #8855 narrowed is narrowed once more: the request-classification 400
9+
`canonicalizeMetaRequestType` already raises inside `getMetaItems` is rethrown
10+
the same way the 503 is. No authorable key is added, renamed, retired or
11+
tombstoned, no stored shape changes, and the response payload type is
12+
byte-identical — there is nothing for a conversion entry to convert. -->
13+
14+
This is a **narrowing that makes an already-classified 400 reach the caller**.
15+
`GET /api/v1/meta/diagnostics?type=<unrecognised-plural-of-a-declared-type>`
16+
(and the SDK method `client.meta.getDiagnostics({ type })`) used to answer
17+
`200 {"entries":[],"total":0,"scannedTypes":1,"scannedItems":0,"stats":{}}` —
18+
"scanned 1 type, no issues" — for a spelling every sibling `/meta` door
19+
refuses with a 400 that names both accepted spellings. The producer had
20+
already classified the mistake (`status: 400`, `code: 'INVALID_REQUEST'`,
21+
raised by `canonicalizeMetaRequestType` inside `getMetaItems`); the
22+
diagnostics sweep's per-type `catch` swallowed the verdict into a benign
23+
skip, and `scannedTypes: 1` then published a sweep that scanned nothing as
24+
coverage. Maintainer ruling 2026-08-20: rethrow the 400 the same way #8855's
25+
fix rethrows the 503.
26+
27+
**Measured on a booted kernel (real HTTP), before → after:**
28+
29+
```
30+
GET /api/v1/meta/diagnostics?type=fieldes 200 {"scannedTypes":1,"stats":{}} → 400 [invalid_request] "… Address it as 'field' or 'fields'."
31+
GET /api/v1/meta/diagnostics?type=fields 200 (recognised plural) → 200 unchanged
32+
GET /api/v1/meta/fieldes 400 → 400 unchanged
33+
```
34+
35+
What is unchanged: recognised plurals (`fields`, `views`, …) still fold and
36+
answer; a name that is a plural of nothing (`fieldz`) still answers an honest
37+
`count: 0` entry; a genuine, unclassified listing failure still skips that
38+
one type instead of failing the sweep; the whole-corpus sweep (no `?type=`)
39+
cannot produce the refusal at all — its target set comes canonical out of the
40+
registry. A caller that treated the old `200`-with-empty-stats answer as
41+
"clean" now hears the refusal that names the accepted spellings.

‎packages/metadata-protocol/src/protocol.diagnostics-store-outage.test.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,25 @@
6363
// benign and selective controls with it; a fix that only looked like it worked
6464
// would leave the outage cases green. The two halves are in one file so a
6565
// future edit that re-widens the catch is a diff nobody can read as harmless.
66+
//
67+
// ---------------------------------------------------------------------------
68+
// [#8924] The SECOND classified verdict: the request-arm 400
69+
// ---------------------------------------------------------------------------
70+
// The same catch swallowed one more producer-classified error: the refusal
71+
// `canonicalizeMetaRequestType` raises inside `getMetaItems` (`status: 400`,
72+
// `code: 'INVALID_REQUEST'`) for an unrecognised spelling of a DECLARED type.
73+
// Only the `request.type` arm can reach it — the full-sweep arm's target set
74+
// comes canonical out of the registry. Measured on a booted kernel before the
75+
// fix: `GET /api/v1/meta/diagnostics?type=fieldes` answered
76+
// `200 {"entries":[],"total":0,"scannedTypes":1,"scannedItems":0,"stats":{}}`
77+
// — "scanned 1 type, no issues" — while the sibling door `/meta/fieldes`
78+
// refused the same spelling with the 400 that names both accepted spellings.
79+
// Ruled 2026-08-20 (option 1): rethrow the 400 the same way the 503 is
80+
// rethrown. The [#8924] describe below pins the refusal, the two spellings
81+
// that must KEEP answering (a recognised plural, a plural-of-nothing), and —
82+
// the positive control — that a genuine listing failure still skips even when
83+
// it wears an unrelated status, so the guard admits exactly the two
84+
// classified verdicts and never widens into a blanket throw.
6685

6786
import { describe, it, expect, vi } from 'vitest';
6887
import { ErrorCode } from '@objectstack/spec/api';
@@ -249,3 +268,84 @@ describe('[#8855] the discrimination is selective, not a blanket refusal', () =>
249268
expect(res.scannedTypes).toBe(SWEPT_TYPES.length);
250269
});
251270
});
271+
272+
describe('[#8924] a caller error the producer classified is refused loudly, never published as "0 problems"', () => {
273+
/** A healthy engine — the only failure in play is the caller's spelling. */
274+
function healthyEngine() {
275+
return {
276+
registry: emptyRegistry(),
277+
find: vi.fn(async () => []),
278+
findOne: vi.fn(async () => null),
279+
} as any;
280+
}
281+
282+
it("an unrecognised spelling of a declared type rejects with the producer's 400, not 200-with-empty-stats", async () => {
283+
const p = new ObjectStackProtocolImplementation(healthyEngine());
284+
285+
// Before the fix this RESOLVED with
286+
// `{ entries: [], total: 0, scannedTypes: 1, scannedItems: 0, stats: {} }`
287+
// — the exact payload objectui's DiagnosticsPage renders as "All clear".
288+
const caught = await rejection(() => p.getMetaDiagnostics({ type: 'fieldes' }));
289+
290+
// The ADR-0112 envelope is the contract: code + status, and the code
291+
// must be in the declared wire vocabulary.
292+
expect(caught?.status).toBe(400);
293+
expect(caught?.code).toBe('INVALID_REQUEST');
294+
expect(ErrorCode.safeParse(caught?.code).success).toBe(true);
295+
// The refusal TEACHES: it names both accepted spellings at the moment
296+
// of the mistake. Asserted on top of code+status, never instead.
297+
expect(caught.message).toContain("'field'");
298+
expect(caught.message).toContain("'fields'");
299+
});
300+
301+
it('a recognised plural still answers — what is refused is the unrecognised spelling, not the plural form', async () => {
302+
const p = new ObjectStackProtocolImplementation(healthyEngine());
303+
304+
const res: any = await p.getMetaDiagnostics({ type: 'fields' });
305+
expect(res.total).toBe(0);
306+
expect(res.scannedTypes).toBe(1);
307+
expect(res.stats.fields).toEqual({ count: 0, locked: 0, packages: [] });
308+
});
309+
310+
it('a name that is a plural of NOTHING still answers benignly — the second verdict (#8421) lives on the mint door, not here', async () => {
311+
const p = new ObjectStackProtocolImplementation(healthyEngine());
312+
313+
// `metaUrlSpellingRefusal('fieldz')` is null: 'fieldz' reaches for no
314+
// declared type, so the request-boundary refusal deliberately stays
315+
// silent and the sweep answers an honest count-0 entry for it.
316+
const res: any = await p.getMetaDiagnostics({ type: 'fieldz' });
317+
expect(res.total).toBe(0);
318+
expect(res.scannedTypes).toBe(1);
319+
expect(res.stats.fieldz).toEqual({ count: 0, locked: 0, packages: [] });
320+
});
321+
322+
it('a genuine listing failure wearing an UNRELATED status still skips — the guard admits exactly the two classified verdicts', async () => {
323+
// The positive control for the fix itself, constructed deliberately
324+
// because the benign-skip population is empty in kernel scope (six
325+
// booted scopes, zero skips) — nothing exercises this by accident.
326+
// A widened guard (`if (status)` / rethrow-everything) turns every
327+
// benign skip into a sweep failure; this test is what goes red.
328+
const unlistable = SWEPT_TYPES.find((t) => t !== 'object')!;
329+
const engine = {
330+
registry: emptyRegistry({
331+
listItems: (type: string) => {
332+
if (type === unlistable) {
333+
throw Object.assign(
334+
new Error(`type ${unlistable} is not listable in this kernel scope`),
335+
{ status: 500 },
336+
);
337+
}
338+
return [];
339+
},
340+
}),
341+
find: vi.fn(async () => []),
342+
findOne: vi.fn(async () => null),
343+
} as any;
344+
const p = new ObjectStackProtocolImplementation(engine);
345+
346+
const res: any = await p.getMetaDiagnostics({});
347+
expect(res.stats[unlistable]).toBeUndefined();
348+
expect(Object.keys(res.stats)).toHaveLength(SWEPT_TYPES.length - 1);
349+
expect(res.stats.object).toEqual({ count: 0, locked: 0, packages: [] });
350+
});
351+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5105,7 +5105,11 @@ export class ObjectStackProtocolImplementation implements
51055105
* layer on top of spec validation).
51065106
*
51075107
* `type` may be either a singular (`'view'`) or plural (`'views'`)
5108-
* identifier; the underlying `getMetaItems` already normalises.
5108+
* identifier; the underlying `getMetaItems` already normalises. An
5109+
* UNRECOGNISED spelling of a declared type (`'viewes'`) is refused with
5110+
* the 400 `getMetaItems` raises for it, not swallowed into a
5111+
* "scanned 1 type, 0 problems" answer — see the [#8924] note in the
5112+
* per-type catch below.
51095113
*
51105114
* Implementation note: leverages the `_diagnostics` already
51115115
* decorated onto items by `getMetaItems()` to avoid running
@@ -5203,6 +5207,49 @@ export class ObjectStackProtocolImplementation implements
52035207
// nobody can re-ask, while a false "real" costs one 503 the
52045208
// caller can retry.
52055209
if ((error as { status?: unknown } | null | undefined)?.status === 503) throw error;
5210+
// [#8924] TWO error classes leave through this catch — the 503
5211+
// above and the 400 below — under ONE rule: an error the
5212+
// producer already stamped with a status is a CLASSIFIED
5213+
// VERDICT, and this sweep may not overrule a verdict into
5214+
// silence. Everything else is a genuine listing failure nobody
5215+
// classified, and for those the benign skip below is the ruled
5216+
// disposition (maintainer, 2026-08-20, option 1).
5217+
//
5218+
// * 503 (#8855, above): the store outage — "whether rows
5219+
// exist is UNKNOWN". Swallowing it published an outage as
5220+
// "0 problems".
5221+
// * 400 (#8924, here): the request-classification refusal
5222+
// `canonicalizeMetaRequestType` raises INSIDE `getMetaItems`
5223+
// (`status: 400`, `code: 'INVALID_REQUEST'`) for an
5224+
// unrecognised spelling of a DECLARED type (`fieldes`,
5225+
// `viewes`, `capabilitys`, …). Only the `request.type` arm
5226+
// can produce it — the full-sweep arm's `targetTypes` come
5227+
// canonical out of the registry — so rethrowing it cannot
5228+
// fail a whole-corpus sweep. Swallowing it answered
5229+
// `200 {"scannedTypes":1,"stats":{}}` — "scanned 1 type,
5230+
// no issues" — to a spelling every sibling `/meta` door
5231+
// refuses with a 400 that names both accepted spellings
5232+
// (measured: 27 such spellings on a booted kernel, while
5233+
// objectui's DiagnosticsPage paints that payload as "All
5234+
// clear"). The producer classified the caller's mistake;
5235+
// the caller gets to hear it. Declared = enforced.
5236+
//
5237+
// What still takes the `continue`: a listing failure with no
5238+
// status, or any status that is not one of the two verdicts
5239+
// above (the registry listing is not a guarded read, so its
5240+
// failures arrive unclassified). That skip is genuine — a
5241+
// kernel scope that cannot enumerate one type must not fail
5242+
// the whole governance sweep — and measured to have NO
5243+
// producer in kernel scope today (six booted scopes, zero
5244+
// skips), which is exactly why it stays a guard rather than
5245+
// becoming a payload field (`skippedTypes` was considered and
5246+
// ruled out as motiveless).
5247+
//
5248+
// By `status` and not by `code`, mirroring the 503 test above
5249+
// and its direction argument: a false "real" is loud and
5250+
// correctable at the caller; a false "benign" silently
5251+
// mis-answers a question nobody re-asks.
5252+
if ((error as { status?: unknown } | null | undefined)?.status === 400) throw error;
52065253
continue;
52075254
}
52085255
const items: any[] = Array.isArray(listed?.items)

0 commit comments

Comments
 (0)