Skip to content

Commit 499f55e

Browse files
os-zhuangclaude
andauthored
fix(rest): refuse when findReferencesToMeta is absent instead of answering "nothing depends on this item" (#9326) (#9425)
GET /api/v1/meta/:type/:name/references feature-detects findReferencesToMeta on the resolved protocol. When the method was absent the route answered 200 { references: [] }, so a capability gap reached the wire as the statement "nothing depends on this item" — ADR-0110 D3 collapsed, with the admin "Used by" panel rendering that empty case as "Nothing in the metadata graph points at this item. Safe to delete." for an operator about to delete something. The branch now answers 501 with the ADR-0112 nested envelope the sibling /meta 501 refusals converged on (#7035), so body.error.code is readable by the same consumer code that already reads the others. A protocol that has the method is untouched: empty and non-empty results both still pass through as 200. Refusing at the route rather than asserting at assembly is deliberate. findReferencesToMeta is not a member of RestProtocol and is not declared in @objectstack/spec at all — it is an ADR-0076 D9 server-only extension reached through a runtime cast — so a boot-time assertion would promote an undeclared optional extension into a required one, which is a spec contract decision. Co-authored-by: Claude <noreply@anthropic.com>
1 parent f099dca commit 499f55e

3 files changed

Lines changed: 301 additions & 1 deletion

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): a missing `findReferencesToMeta` capability is refused, not answered as "nothing depends on this item" (#9326)
6+
7+
`GET /api/v1/meta/:type/:name/references` feature-detects `findReferencesToMeta`
8+
on the resolved protocol. When the method was absent the route answered
9+
`200 { references: [] }` — so a **capability gap** reached the wire as the
10+
statement **"nothing depends on this item"**.
11+
12+
Per ADR-0110 D3 those are different facts, and here they have opposite
13+
consequences. The consumer is the admin "Used by" panel, whose empty state reads,
14+
verbatim from `objectui`'s `metadata-admin/i18n.ts`:
15+
16+
```
17+
'engine.edit.refsEmptyDesc': 'Nothing in the metadata graph points at this item. Safe to delete.'
18+
```
19+
20+
An operator about to delete something was shown that sentence on a deployment
21+
where the question had never actually been asked.
22+
23+
The branch now refuses:
24+
25+
```
26+
501 { error: { code: 'NOT_IMPLEMENTED',
27+
message: 'protocol.findReferencesToMeta() is not available in this kernel' } }
28+
```
29+
30+
— the ADR-0112 nested envelope the sibling `/meta` 501 refusals converged on
31+
(#7035), so `body.error.code` is readable by the same one line of consumer code
32+
that already reads the others.
33+
34+
**Does any caller's observed response change? Yes, on one deployment shape, and
35+
only there.** A protocol that *has* the method is untouched: both an empty and a
36+
non-empty result still pass through verbatim as `200`. What changes is the
37+
answer given when the protocol has no such method — previously `200` with an
38+
empty list, now `501`. No assembly in this repo produces such a protocol today:
39+
`ObjectStackProtocolImplementation` is the only implementation registered under
40+
the `protocol` service and it defines the method unconditionally. The branch is
41+
reachable rather than dead because `findReferencesToMeta` is **not** a member of
42+
`RestProtocol` (`= DataProtocol & MetadataProtocol`) and is not declared in
43+
`@objectstack/spec` at all — it is an ADR-0076 D9 server-only extension reached
44+
through a runtime cast. A host that implements the declared contract exactly, or
45+
that points `protocolServiceName` at its own service, is a *conforming*
46+
deployment that lands on this branch with no type error.
47+
48+
Refusing at the route rather than asserting at assembly is deliberate: a
49+
boot-time assertion would promote an undeclared optional extension into a
50+
required one, which is a contract decision for `@objectstack/spec` rather than a
51+
route one, and it would reject the partial protocol doubles that legitimately
52+
exist today.
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#9326] `GET /meta/:type/:name/references` — a MISSING capability is not an
5+
* EMPTY reference list.
6+
*
7+
* ## The defect
8+
*
9+
* The handler feature-detects `findReferencesToMeta` on the resolved protocol
10+
* and, when it is absent, used to answer:
11+
*
12+
* ```ts
13+
* res.json({ references: [] })
14+
* ```
15+
*
16+
* That is ADR-0110 D3 collapsed — *a miss and a fault are different facts*.
17+
* The two conditions it merged are:
18+
*
19+
* 1. the graph WAS walked and nothing points at this item, and
20+
* 2. the graph could not be walked at all, because this deployment's
21+
* protocol has no such method.
22+
*
23+
* They arrived on the wire as the same `200 { references: [] }`, so no consumer
24+
* could tell them apart. The consumer is the admin "Used by" panel, whose empty
25+
* state reads, verbatim from `objectui`'s `metadata-admin/i18n.ts`:
26+
*
27+
* ```
28+
* 'engine.edit.refsEmptyDesc': 'Nothing in the metadata graph points at this item. Safe to delete.'
29+
* ```
30+
*
31+
* — shown to an operator about to delete something, on a deployment where the
32+
* question was never actually asked.
33+
*
34+
* ## Why the fix is a refusal at the route
35+
*
36+
* `findReferencesToMeta` is NOT a member of `RestProtocol`
37+
* (`= DataProtocol & MetadataProtocol`); it is not declared anywhere in
38+
* `packages/spec` at all. It is an ADR-0076 D9 server-only extension, which is
39+
* why the handler reaches it through a runtime cast rather than a typed call.
40+
* A host that implements the DECLARED contract exactly is therefore a
41+
* CONFORMING deployment that lands on this branch with no type error — which is
42+
* what makes the branch worth answering honestly rather than asserting away at
43+
* boot. Promoting an undeclared optional extension into a required one is a
44+
* `packages/spec` contract decision and is deliberately not taken here.
45+
*
46+
* ## What these cases assert, and why not `toThrow`
47+
*
48+
* This handler *sends*, it never throws, so a `toThrow`-shaped assertion could
49+
* not separate "answered with the wrong body" from "did not refuse at all" —
50+
* and the wrong body IS the defect. Every case asserts the ADR-0112 pair,
51+
* `status` AND `body.error.code`, at the **nested** position (#7035), plus the
52+
* load-bearing one this file exists for: the capability gap and a genuine zero
53+
* do not produce the same answer.
54+
*/
55+
56+
import { describe, it, expect, vi } from 'vitest';
57+
// `.js` on purpose — NodeNext resolution requires the extension, and this
58+
// package's TEST_DEBT ceiling has no margin for another TS2835 (#7248).
59+
import { RestServer } from './rest-server.js';
60+
61+
const REFERENCES_PATH = '/api/v1/meta/:type/:name/references';
62+
63+
function mockServer() {
64+
return {
65+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
66+
use: vi.fn(),
67+
listen: vi.fn().mockResolvedValue(undefined),
68+
close: vi.fn().mockResolvedValue(undefined),
69+
};
70+
}
71+
72+
function mockRes() {
73+
const res: any = {
74+
statusCode: 200,
75+
json: vi.fn(function (this: any, body: any) { this._body = body; return this; }),
76+
send: vi.fn(),
77+
status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }),
78+
header: vi.fn(),
79+
};
80+
return res;
81+
}
82+
83+
/**
84+
* The read side every `/meta` route needs to register, and nothing more. The
85+
* `findReferencesToMeta` slot is filled by the caller precisely because its
86+
* presence or ABSENCE is the whole variable under test — anything else this
87+
* stub gained would weaken what the cases below prove.
88+
*/
89+
function baseProtocol() {
90+
return {
91+
getDiscovery: vi.fn().mockResolvedValue({
92+
version: 'v0',
93+
routes: { data: '', metadata: '', ui: '', auth: '/auth' },
94+
}),
95+
getMetaTypes: vi.fn().mockResolvedValue([]),
96+
getMetaItems: vi.fn().mockResolvedValue([]),
97+
getMetaItem: vi.fn().mockResolvedValue({ type: 'object', name: 'account', item: {}, lock: 'none' }),
98+
findData: vi.fn().mockResolvedValue([]),
99+
getData: vi.fn().mockResolvedValue({}),
100+
createData: vi.fn().mockResolvedValue({ id: '1' }),
101+
updateData: vi.fn().mockResolvedValue({}),
102+
deleteData: vi.fn().mockResolvedValue({ success: true }),
103+
};
104+
}
105+
106+
function boot(protocol: Record<string, unknown>) {
107+
const rest = new RestServer(
108+
mockServer() as any,
109+
protocol as any,
110+
{ api: { requireAuth: false } } as any,
111+
);
112+
// `isSystem` clears the capability gates that fire before the protocol is
113+
// probed, so the request reaches the branch under test.
114+
(rest as any).resolveExecCtx = async () => ({ isSystem: true });
115+
rest.registerRoutes();
116+
117+
const found = (rest as any).getRoutes().find(
118+
(r: any) => r.method === 'GET' && r.path === REFERENCES_PATH,
119+
);
120+
if (!found) throw new Error(`route not registered: GET ${REFERENCES_PATH}`);
121+
122+
return async () => {
123+
const res = mockRes();
124+
await found.handler(
125+
{ query: {}, headers: {}, body: {}, params: { type: 'object', name: 'account' } },
126+
res,
127+
);
128+
return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] };
129+
};
130+
}
131+
132+
/** A protocol that CAN walk the graph and found nothing. The honest empty. */
133+
const answersEmpty = () => boot({
134+
...baseProtocol(),
135+
findReferencesToMeta: vi.fn().mockResolvedValue({ references: [] }),
136+
});
137+
138+
/** A protocol with no such method. The capability gap. */
139+
const hasNoCapability = () => boot(baseProtocol());
140+
141+
/** A protocol that CAN walk the graph and found something. */
142+
const answersHits = () => boot({
143+
...baseProtocol(),
144+
findReferencesToMeta: vi.fn().mockResolvedValue({
145+
references: [{ type: 'view', name: 'account_list', path: 'object' }],
146+
}),
147+
});
148+
149+
describe('#9326 — a missing `findReferencesToMeta` is refused, not answered as "nothing depends on this item"', () => {
150+
it('an absent capability answers 501 NOT_IMPLEMENTED at the ADR-0112 nested position', async () => {
151+
const answer = await hasNoCapability()();
152+
153+
expect(answer.status).toBe(501);
154+
// The pair ADR-0112 declares. `body.error.code` — NESTED, because the
155+
// flat-sibling position is what makes `error.code` read `undefined`
156+
// (#7035).
157+
expect(answer.body?.error?.code).toBe('NOT_IMPLEMENTED');
158+
expect(answer.body?.error?.message).toBe(
159+
'protocol.findReferencesToMeta() is not available in this kernel',
160+
);
161+
// Dialect 1 retired: `code` as a sibling of `error`.
162+
expect(answer.body).not.toHaveProperty('code');
163+
// Dialect 2 retired: `error` as a bare string.
164+
expect(typeof answer.body?.error).toBe('object');
165+
});
166+
167+
it('⭐ THE PIN — the capability gap and a genuine zero are not the same answer', async () => {
168+
// This is the whole defect in one assertion. Both of these used to be
169+
// `200 { references: [] }`, so a consumer had no way to ask which one it
170+
// was holding. If a future edit re-merges them — by restoring the empty
171+
// body, or by teaching the honest-empty path to 501 — exactly one of the
172+
// three expectations below goes red.
173+
const gap = await hasNoCapability()();
174+
const genuineZero = await answersEmpty()();
175+
176+
expect(gap.status).not.toBe(genuineZero.status);
177+
expect(gap.body).not.toEqual(genuineZero.body);
178+
// And the direction, so "different" cannot be satisfied by breaking the
179+
// healthy side instead of fixing the broken one.
180+
expect(genuineZero.status).toBe(200);
181+
expect(genuineZero.body).toEqual({ references: [] });
182+
});
183+
184+
it('a protocol that CAN answer is untouched — empty and non-empty both pass through verbatim', async () => {
185+
// The refusal is scoped to the capability probe and nothing else: this
186+
// route's success path is the same one it always had. Without this case
187+
// the pin above could be satisfied by a route that refuses more often
188+
// than it should.
189+
const zero = await answersEmpty()();
190+
expect(zero.status).toBe(200);
191+
expect(zero.body).toEqual({ references: [] });
192+
193+
const hits = await answersHits()();
194+
expect(hits.status).toBe(200);
195+
expect(hits.body).toEqual({
196+
references: [{ type: 'view', name: 'account_list', path: 'object' }],
197+
});
198+
});
199+
200+
it('the refusal is machine-readable by the SAME read as its `/meta` 501 siblings', async () => {
201+
// `err.error.code` is the one position ADR-0112 declares, and #7035
202+
// converged the `/meta` write refusals onto it. A consumer written
203+
// against those reads this one with no second branch — which is the
204+
// property that makes the refusal usable rather than merely loud.
205+
const answer = await hasNoCapability()();
206+
expect(answer.body?.error?.code).toBe('NOT_IMPLEMENTED');
207+
expect(typeof answer.body?.error?.message).toBe('string');
208+
// Not an empty-collection body under any spelling: the shapes a client
209+
// might reasonably probe for a "no references" answer are all absent.
210+
expect(answer.body).not.toHaveProperty('references');
211+
expect(answer.body).not.toHaveProperty('items');
212+
});
213+
});

packages/rest/src/rest-server.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4203,7 +4203,42 @@ export class RestServer {
42034203
const environmentId = isScoped ? req.params?.environmentId : undefined;
42044204
const p = await this.resolveProtocol(environmentId, req);
42054205
if (typeof (p as any).findReferencesToMeta !== 'function') {
4206-
res.json({ references: [] });
4206+
// [#9326 / ADR-0110 D3] A MISS and a FAULT are
4207+
// different facts, and this branch is the second
4208+
// one: the resolved protocol cannot compute the
4209+
// reference graph AT ALL, so the question was never
4210+
// asked. Answering `{ references: [] }` reported it
4211+
// as the first — "nothing depends on this item" —
4212+
// and the admin "Used by" panel renders that empty
4213+
// case as "Nothing in the metadata graph points at
4214+
// this item. Safe to delete.", shown to an operator
4215+
// about to delete something.
4216+
//
4217+
// Refusing HERE rather than asserting at assembly is
4218+
// deliberate. `findReferencesToMeta` is not a member
4219+
// of `RestProtocol` (= `DataProtocol &
4220+
// MetadataProtocol`) — it is an ADR-0076 D9
4221+
// server-only extension, which is why it is reached
4222+
// through a runtime cast at all. So a host that
4223+
// implements the DECLARED contract exactly is a
4224+
// CONFORMING deployment that lands here with no type
4225+
// error, and a boot-time assertion would promote an
4226+
// undeclared optional extension into a required one
4227+
// — a `packages/spec` contract decision, not a
4228+
// route one. What the route owes is that an
4229+
// unanswerable question comes back unanswered.
4230+
//
4231+
// Envelope per #7035: the ADR-0112 NESTED
4232+
// `{ error: { code, message } }` that the sibling
4233+
// `/meta` 501 refusals converged on — never the
4234+
// bare-string or sibling-`code` dialects, which make
4235+
// `body.error.code` read `undefined`.
4236+
res.status(501).json({
4237+
error: {
4238+
code: 'NOT_IMPLEMENTED',
4239+
message: 'protocol.findReferencesToMeta() is not available in this kernel',
4240+
},
4241+
});
42074242
return;
42084243
}
42094244
const result = await (p as any).findReferencesToMeta({

0 commit comments

Comments
 (0)