Skip to content

Commit 177442d

Browse files
hotlongclaude
andauthored
fix(metadata-protocol): propagate the metadata-store 503 out of getMetaDiagnostics (#8855) (#8923)
The per-type read in `getMetaDiagnostics` was wrapped in an untyped `catch` that `continue`d. The comment named a benign reason ("type not listable in this kernel scope") and that reason is real — but the catch took everything else with it, including the 503 `getMetaItems` raises for a `sys_metadata` read that failed for any reason other than "not provisioned yet" (#5532). Swallowing it put the emptiness back one layer up and published it as a number: `stats[t]` was never written (the type ABSENT from the response, not zero — the Studio directory tile simply loses it) and `total`, which counts entries that failed validation, read 0 because an unreadable store contributes none. A diagnostics endpoint reported "0 problems" precisely when it could read nothing. The catch is narrowed, not deleted: a 503 is rethrown unchanged, every other failure still skips that one type. No response field is added. The envelope is propagated rather than re-classified, so the driver error stays on `cause`. ADR-0110 D3 — a miss and an outage are different facts with opposite dispositions, and a consumer must never read one as the other. Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7901b2d commit 177442d

3 files changed

Lines changed: 374 additions & 2 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): `getMetaDiagnostics` stops publishing an unreadable metadata store as "0 problems" (#8855)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) One `catch` inside one
8+
method is narrowed from untyped to "rethrow the 503 the producer already
9+
classified". No authorable key is added, renamed, retired or tombstoned, no
10+
stored shape changes, and the response type is byte-identical — so there is
11+
nothing for a conversion entry to convert. -->
12+
13+
`GET /api/v1/meta/diagnostics` sweeps every metadata type and publishes four
14+
numeric facts about the corpus. Its per-type read was wrapped in an **untyped**
15+
`catch` that `continue`d, and the comment above it named a benign reason ("type
16+
not listable in this kernel scope") that is genuinely real. The catch took
17+
everything else with it — including the one error the callee exists to raise.
18+
19+
`getMetaItems` classifies a failed `sys_metadata` read by error **type** and
20+
throws a 503 (`SERVICE_UNAVAILABLE`) for every read failure that is not "the
21+
table has not been provisioned yet" — the discrimination #5532 introduced so an
22+
outage would stop looking like emptiness. `getMetaDiagnostics` caught that 503
23+
back into emptiness one layer up, then published the emptiness as a **number**.
24+
25+
**Measured on `origin/main` @ `8664a2c99` before the fix**, prediction written
26+
down first and matched exactly. With an engine whose every read rejects:
27+
28+
```
29+
[outage: connect ECONNREFUSED 10.0.0.5:5432] RESOLVED
30+
total=0 scannedTypes=26 scannedItems=0 Object.keys(stats).length=0
31+
[benign: SQLITE_ERROR: no such table: sys_metadata] RESOLVED
32+
total=0 scannedTypes=26 scannedItems=0 Object.keys(stats).length=26
33+
```
34+
35+
Two user-visible harms from one `catch`, and the benign run is what makes them
36+
legible — it is the same payload minus the `stats`:
37+
38+
- `stats[t]` is never written, so an unreadable type is **absent** from the
39+
response rather than zero. The Studio directory tile the field's own doc names
40+
loses the type, byte-shaped like an environment that declares none of it.
41+
- `total` counts entries that **failed validation**, and a store nobody can read
42+
contributes none — so the endpoint whose whole job is reporting problems
43+
answered `total: 0` at the exact moment it could read nothing. Green was the
44+
failure mode.
45+
46+
`scannedTypes` reported the full 26 in both runs: it is computed from the intent
47+
(`targetTypes.length`, fixed before the loop) and never decremented on
48+
`continue`.
49+
50+
**The fix narrows the catch; it does not delete it.** A 503 arriving from the
51+
read is rethrown **unchanged** and the sweep fails loudly (ADR-0110 D3: a miss
52+
and an outage are different facts with opposite dispositions). Every other
53+
failure still skips that one type, so a kernel scope that cannot enumerate one
54+
type does not fail the whole governance sweep.
55+
56+
**No response field was added.** A per-type degradation marker would be a
57+
public-surface addition, and the payload type is unchanged.
58+
59+
The envelope is **propagated, not rebuilt**: re-running the driver-error
60+
classification here would re-wrap an already-shaped 503 in a second one and
61+
displace the driver error riding as `cause` — the object `logWithheldServerFault`
62+
prints for the operator. The REST boundary needs no change: the handler already
63+
routes thrown errors through `handleRouteError`, which preserves the 503.
64+
65+
The pin carries the discriminating control in the same file: an unprovisioned
66+
`sys_metadata` still answers benignly with every type present at `count: 0`, a
67+
type that is genuinely not listable is still skipped at the cost of one type,
68+
and a healthy store still counts its rows — while the outage cases throw. "0
69+
problems" is the right answer in the benign cell, and it is exactly the answer a
70+
blanket change would have kept producing in the wrong one.
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#8855] A diagnostics sweep that could not READ the store must not publish
4+
// "0 problems".
5+
//
6+
// ---------------------------------------------------------------------------
7+
// The defect, measured on origin/main @ 8664a2c99 BEFORE the fix
8+
// ---------------------------------------------------------------------------
9+
// `getMetaDiagnostics` wrapped its per-type read in an UNTYPED `catch` that
10+
// `continue`d. The comment named a benign reason ("type not listable in this
11+
// kernel scope") and that reason is real — but the catch took everything else
12+
// with it, including the one error the callee exists to raise: the 503 #5532
13+
// raised out of `getMetaItems` so that an outage would stop looking like
14+
// emptiness.
15+
//
16+
// Prediction was written down BEFORE the run; the run matched it exactly. With
17+
// an engine whose every read rejects, `getMetaDiagnostics({})`:
18+
//
19+
// [outage: connect ECONNREFUSED 10.0.0.5:5432] RESOLVED
20+
// total=0 scannedTypes=26 scannedItems=0 Object.keys(stats).length=0
21+
// [benign: SQLITE_ERROR: no such table: sys_metadata] RESOLVED
22+
// total=0 scannedTypes=26 scannedItems=0 Object.keys(stats).length=26
23+
// every stat = {"count":0,"locked":0,"packages":[]}
24+
//
25+
// Two user-visible harms out of one `catch`, and the benign run above is what
26+
// makes them legible — it is the SAME payload shape minus the missing `stats`:
27+
//
28+
// * `stats[t]` is never written, so an unreadable type is ABSENT from the
29+
// response rather than zero. The Studio directory tile the field's own doc
30+
// names ("so the Studio directory page can render tile counts") simply
31+
// loses the type, byte-shaped like an environment that declares none of it.
32+
// * `total` counts entries that FAILED validation, and a store nobody can
33+
// read contributes none — so the endpoint whose whole job is reporting
34+
// problems answered `total: 0` at the exact moment it could read nothing.
35+
// Green is the failure mode.
36+
//
37+
// `scannedTypes` stayed at the full 26 in both runs: it is computed from the
38+
// INTENT (`targetTypes.length`, fixed before the loop) and never decremented on
39+
// `continue`, so the payload asserted "I scanned 26" having scanned 0.
40+
//
41+
// ---------------------------------------------------------------------------
42+
// What the fix is, and what it deliberately is NOT
43+
// ---------------------------------------------------------------------------
44+
// NOT "delete the catch" — the benign skip is genuine and stays. The catch is
45+
// NARROWED: the 503 the producer already classified is rethrown UNCHANGED,
46+
// everything else still skips the type. No payload field was added; a per-type
47+
// degradation marker would be a public-surface addition and is explicitly not
48+
// this card's to make.
49+
//
50+
// Classification reads the envelope the PRODUCER built rather than re-deriving
51+
// it. Re-running `isMissingTableError` here would re-wrap an already-shaped 503
52+
// in another 503 and displace the driver error riding as `cause` — which is why
53+
// `expect(caught.cause).toBe(err)` below is an identity check, not a shape
54+
// check: it is what proves the envelope was propagated and not rebuilt.
55+
//
56+
// ---------------------------------------------------------------------------
57+
// Reverse verification, direction predicted BEFORE running
58+
// ---------------------------------------------------------------------------
59+
// Ordinary red, and — this is the point — red on the OUTAGE half only. Restore
60+
// the bare `} catch { continue; }` and the three outage cases go red by
61+
// RESOLVING instead of throwing, while every benign / selective / healthy case
62+
// below stays green. A blanket "diagnostics now throws" would have taken the
63+
// benign and selective controls with it; a fix that only looked like it worked
64+
// would leave the outage cases green. The two halves are in one file so a
65+
// future edit that re-widens the catch is a diff nobody can read as harmless.
66+
67+
import { describe, it, expect, vi } from 'vitest';
68+
import { ErrorCode } from '@objectstack/spec/api';
69+
import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema } from '@objectstack/spec/kernel';
70+
import { ObjectStackProtocolImplementation } from './protocol.js';
71+
72+
/**
73+
* The set the sweep walks — derived from the same two spec symbols the
74+
* implementation derives `targetTypes` from, so this pin does not rot into a
75+
* hardcoded 26 the day the registry grows.
76+
*/
77+
const SWEPT_TYPES: string[] = DEFAULT_METADATA_TYPE_REGISTRY
78+
.filter((e) => getMetadataTypeSchema(e.type))
79+
.map((e) => e.type);
80+
81+
/** A registry with nothing in it — the overlay read is the only source. */
82+
function emptyRegistry(overrides: Record<string, unknown> = {}) {
83+
return {
84+
getObject: () => undefined,
85+
getItem: () => undefined,
86+
listItems: () => [],
87+
applyNavContributions: (x: any) => x,
88+
isPackageDisabled: () => false,
89+
getObjectOwner: () => undefined,
90+
...overrides,
91+
};
92+
}
93+
94+
/** An engine whose every read REJECTS — a metadata store the protocol cannot reach. */
95+
function engineThatCannotBeRead(error: () => unknown) {
96+
const reject = vi.fn(async () => { throw error(); });
97+
return { registry: emptyRegistry(), find: reject, findOne: reject } as any;
98+
}
99+
100+
/** The real driver phrasing for "the table has not been provisioned yet". */
101+
const missingTable = () =>
102+
Object.assign(new Error('SQLITE_ERROR: no such table: sys_metadata'), { code: 'SQLITE_ERROR' });
103+
104+
/** An outage: the rows may well exist and simply were not seen. */
105+
const connectionRefused = () =>
106+
Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432'), { code: 'ECONNREFUSED' });
107+
108+
/** Capture a rejection without letting a resolve pass silently. */
109+
async function rejection(run: () => Promise<unknown>): Promise<any> {
110+
let caught: any;
111+
let resolved: unknown;
112+
let didResolve = false;
113+
try {
114+
resolved = await run();
115+
didResolve = true;
116+
} catch (e) {
117+
caught = e;
118+
}
119+
expect(
120+
didResolve,
121+
`expected a rejection, but the sweep resolved with ${JSON.stringify(resolved)}`,
122+
).toBe(false);
123+
return caught;
124+
}
125+
126+
/** Every assertion the outage envelope owes a caller (the #5532 envelope, unchanged). */
127+
function expectStoreUnavailable(caught: any, cause: unknown) {
128+
expect(caught?.status).toBe(503);
129+
expect(caught?.code).toBe('SERVICE_UNAVAILABLE');
130+
// ADR-0112: the wire code must be in the declared vocabulary, or the
131+
// envelope fails `ApiErrorSchema.parse` at the boundary that ships it.
132+
expect(ErrorCode.safeParse(caught?.code).success).toBe(true);
133+
// The words a client reads say "unknown", never "does not exist".
134+
expect(caught.message).toContain('unknown');
135+
// IDENTITY, not shape: the driver error rides as `cause` on the envelope
136+
// #5532 built. A re-classification here would have re-wrapped that envelope
137+
// and put the 503 itself in `cause`, hiding the driver line
138+
// `logWithheldServerFault` prints for the operator (#5437).
139+
expect(caught.cause).toBe(cause);
140+
}
141+
142+
describe('[#8855] an unreadable metadata store stops being published as "0 problems"', () => {
143+
it('the whole-corpus sweep propagates the 503 instead of answering total: 0, stats: {}', async () => {
144+
const err = connectionRefused();
145+
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(() => err));
146+
147+
const caught = await rejection(() => p.getMetaDiagnostics({}));
148+
expectStoreUnavailable(caught, err);
149+
});
150+
151+
it('the single-type sweep is held to the same rule', async () => {
152+
const err = connectionRefused();
153+
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(() => err));
154+
155+
const caught = await rejection(() => p.getMetaDiagnostics({ type: 'object' }));
156+
expectStoreUnavailable(caught, err);
157+
});
158+
159+
it('an outage and a first boot are now distinguishable by the caller — which is the whole point', async () => {
160+
// ADR-0110 D3, stated as the two answers side by side: the benign case
161+
// still ANSWERS (200-shaped, every type present at count 0) while the
162+
// outage THROWS. Before the fix these two produced the same status and
163+
// differed only by `stats` being empty — a difference no client is
164+
// documented to read, and one an environment that genuinely declares
165+
// nothing would produce as well.
166+
const benign = new ObjectStackProtocolImplementation(engineThatCannotBeRead(missingTable));
167+
const outage = new ObjectStackProtocolImplementation(engineThatCannotBeRead(connectionRefused));
168+
169+
const benignResult: any = await benign.getMetaDiagnostics({});
170+
expect(benignResult.total).toBe(0);
171+
expect(Object.keys(benignResult.stats)).toHaveLength(SWEPT_TYPES.length);
172+
173+
const caught = await rejection(() => outage.getMetaDiagnostics({}));
174+
expect([caught.status, caught.code]).toEqual([503, 'SERVICE_UNAVAILABLE']);
175+
});
176+
});
177+
178+
describe('[#8855] the discrimination is selective, not a blanket refusal', () => {
179+
it('an unprovisioned sys_metadata still answers benignly, every type present at count 0', async () => {
180+
// The control that matters most. `getMetaItems` classifies this by
181+
// error TYPE (`isMissingTableError`) and returns normally with
182+
// `items: []`, so it never reaches the diagnostics catch at all — and
183+
// the sweep must keep publishing a full, honest, all-zero payload.
184+
// "0 problems" is the RIGHT answer in this cell, and it is exactly the
185+
// answer a blanket change would have destroyed.
186+
const p = new ObjectStackProtocolImplementation(engineThatCannotBeRead(missingTable));
187+
188+
const res: any = await p.getMetaDiagnostics({});
189+
expect(res.total).toBe(0);
190+
expect(res.entries).toEqual([]);
191+
expect(res.scannedItems).toBe(0);
192+
expect(res.scannedTypes).toBe(SWEPT_TYPES.length);
193+
expect(Object.keys(res.stats)).toHaveLength(SWEPT_TYPES.length);
194+
for (const t of SWEPT_TYPES) {
195+
expect(res.stats[t]).toEqual({ count: 0, locked: 0, packages: [] });
196+
}
197+
});
198+
199+
it('a type that is genuinely not listable in this kernel scope is still skipped', async () => {
200+
// The benign reason the original comment named, kept alive on purpose:
201+
// this failure never went through the store-read classification (the
202+
// registry listing is not one of the guarded reads), carries no 503,
203+
// and must still cost exactly one type rather than the whole sweep.
204+
const unlistable = SWEPT_TYPES.find((t) => t !== 'object')!;
205+
const engine = {
206+
registry: emptyRegistry({
207+
listItems: (type: string) => {
208+
if (type === unlistable) {
209+
throw new Error(`type ${unlistable} is not listable in this kernel scope`);
210+
}
211+
return [];
212+
},
213+
}),
214+
find: vi.fn(async () => []),
215+
findOne: vi.fn(async () => null),
216+
} as any;
217+
const p = new ObjectStackProtocolImplementation(engine);
218+
219+
const res: any = await p.getMetaDiagnostics({});
220+
expect(res.stats[unlistable]).toBeUndefined();
221+
expect(Object.keys(res.stats)).toHaveLength(SWEPT_TYPES.length - 1);
222+
expect(res.stats.object).toEqual({ count: 0, locked: 0, packages: [] });
223+
});
224+
225+
it('a healthy store still counts the rows it holds', async () => {
226+
// The positive control on the other side: the sweep is not merely
227+
// "throws less often", it still does its job.
228+
const engine = {
229+
registry: emptyRegistry(),
230+
find: vi.fn(async (_object: string, opts: any) => (
231+
opts?.where?.type === 'object' && opts?.where?.state === 'active'
232+
? [{
233+
type: 'object',
234+
name: 'acct',
235+
state: 'active',
236+
package_id: 'crm',
237+
metadata: JSON.stringify({ name: 'acct', label: 'Account' }),
238+
}]
239+
: []
240+
)),
241+
findOne: vi.fn(async () => null),
242+
} as any;
243+
const p = new ObjectStackProtocolImplementation(engine);
244+
245+
const res: any = await p.getMetaDiagnostics({});
246+
expect(res.stats.object.count).toBe(1);
247+
expect(res.stats.object.packages).toEqual(['crm']);
248+
expect(res.scannedItems).toBe(1);
249+
expect(res.scannedTypes).toBe(SWEPT_TYPES.length);
250+
});
251+
});

0 commit comments

Comments
 (0)