Skip to content

Commit fd6bdf8

Browse files
hotlongclaude
andauthored
fix(metadata-protocol): saveMetaItem's missing-item refusal declares 400 INVALID_REQUEST instead of answering 500 (#8840)
* fix(metadata-protocol): declare saveMetaItem's missing-item refusal (400 INVALID_REQUEST) `saveMetaItem`'s opening guard was the one refusal in the method carrying neither `code` nor `status`, so `clientFacingFailureText` withheld its sentence and `handleRouteError` — with no status to read — served it as `500 INTERNAL_ERROR`. Measured end to end against a live server before changing anything: the guard IS reachable from the wire as an authoring refusal. `PUT /api/v1/meta/:type/:name` unwraps the `{ item }` / `{ metadata }` envelope shapes before calling, so `{"item": null}` and `{"metadata": null}` arrive as `item: null` and land on it — both answered 500 before this change. A missing, empty or literal-`null` body does NOT reach it: the route folds those to `{}`, which is truthy, and the per-type Zod parse refuses them with 422 INVALID_METADATA. `INVALID_REQUEST`/400, matching `rollbackMetaItem`'s structurally identical opening guard, rather than the `INVALID_METADATA`/422 the sibling refusals use — those describe a body that exists and failed the per-type parse, and each carries structured `issues`, which a missing body has none of. Both codes are already registered to this package in the ADR-0112 ledger; no new code is minted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 * chore(changeset): saveMetaItem missing-item refusal declares 400 INVALID_REQUEST Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 950bd94 commit fd6bdf8

4 files changed

Lines changed: 210 additions & 5 deletions

File tree

.changeset/hungry-donkeys-shout.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
Declare `saveMetaItem`'s missing-item refusal as a real ADR-0112 envelope: `400` / `INVALID_REQUEST`, was an undeclared throw served as `500 INTERNAL_ERROR`.
6+
7+
`PUT /api/v1/meta/:type/:name` unwraps the `{ item }` / `{ metadata }` envelope shapes before calling the protocol, so a caller sending `{"item": null}` or `{"metadata": null}` reached a guard that declared neither `code` nor `status` — the only refusal in the method that did not. With no status to read, the REST boundary defaulted to a server fault, so an authoring mistake was reported as `500 INTERNAL_ERROR` and the guard's own sentence was withheld by the ADR-0112 disclosure rule and replaced with a generic fallback. Callers now receive `400` with the refusal quoted and the remedy named.
8+
9+
Unchanged: a missing, empty or literal-`null` request body never reached this guard and still answers `422 INVALID_METADATA` from the per-type schema parse. No new error code is introduced — `INVALID_REQUEST` is already registered to this package in the ADR-0112 ledger, and is what the structurally identical opening guard in `rollbackMetaItem` already uses.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #8818 — `saveMetaItem`'s opening guard was the ONE refusal in the method
5+
* that declared no ADR-0112 envelope, so consumers applying the rule withheld
6+
* its sentence and the REST boundary served it as a server fault.
7+
*
8+
* ## What was measured, end to end, before the fix
9+
*
10+
* The card was filed as an observation with an explicitly unverified premise
11+
* ("read from source, no victim measured"), so the premise was probed against
12+
* a real server (`pnpm dev:crm -- --fresh`, `PUT /api/v1/meta/view/:name`,
13+
* authenticated as the seeded platform admin) before a line was changed:
14+
*
15+
* | request body | reaches this guard? | answered (before) |
16+
* |---|---|---|
17+
* | *(no body at all)* | no | `422 INVALID_METADATA` |
18+
* | `null` | no | `422 INVALID_METADATA` |
19+
* | `{}` | no | `422 INVALID_METADATA` |
20+
* | `{"item": null}` | **YES** | **`500 INTERNAL_ERROR`** |
21+
* | `{"metadata": null}` | **YES** | **`500 INTERNAL_ERROR`** |
22+
*
23+
* So the honest outcomes the card itself listed — "unreachable, leave it
24+
* alone" and "a programming-error guard, not an authoring refusal" — are both
25+
* FALSE, and the reason is precise: `PUT /meta/:type/:name` unwraps the
26+
* `{ item }` / `{ metadata }` envelope shapes before calling, so an
27+
* explicitly-null envelope arrives as `item: null` and lands here, while a
28+
* missing/empty/`null` BODY folds to `{}` (truthy) and is refused downstream
29+
* by the per-type Zod parse. The reachable population is caller-authored JSON
30+
* only; every internal caller passes a concrete document.
31+
*
32+
* The cost was also LARGER than filed. The card predicted the sentence would
33+
* degrade to a consumer's generic fallback; what the wire actually did was
34+
* answer **500 `INTERNAL_ERROR`** — because `handleRouteError` has no status
35+
* to read and defaults to a server fault. A 500 does not merely tell the
36+
* author less, it tells them something FALSE: that the server broke and the
37+
* request is worth retrying, when it can never succeed unchanged.
38+
*
39+
* ## Why the `clientFacingFailureText` assertion is the point of this file
40+
*
41+
* A test asserting only `code`/`status` on the thrown error would pass without
42+
* demonstrating the thing the card is about. `declaresClientRefusal` is a
43+
* POSITIVE list keyed on a 4xx `status`, so the assertion that matters is that
44+
* the sentence now SURVIVES the rule rather than being replaced by the
45+
* caller's fallback — and that assertion is only evidence next to its control:
46+
* the same helper, handed the bare `Error` this guard used to throw, still
47+
* withholds. Both directions are asserted below; drop the control and the pin
48+
* would stay green against a `clientFacingFailureText` that had stopped
49+
* withholding anything at all.
50+
*/
51+
52+
import { describe, it, expect, vi } from 'vitest';
53+
import { ObjectStackProtocolImplementation, clientFacingFailureText } from './protocol.js';
54+
55+
/**
56+
* A protocol over an engine whose every verb is a tripwire: this guard is the
57+
* FIRST statement of `saveMetaItem`, so a refusal that touched the engine at
58+
* all would mean the check had moved behind something with side effects.
59+
*/
60+
function makeProtocol() {
61+
const findOne = vi.fn(async () => null);
62+
const find = vi.fn(async () => [] as unknown[]);
63+
const engine = {
64+
registry: { getObject: () => undefined },
65+
findOne,
66+
find,
67+
};
68+
return { p: new ObjectStackProtocolImplementation(engine as any), findOne, find };
69+
}
70+
71+
/** The refusal a rejected request produced, plus proof the engine was untouched. */
72+
async function refusalFor(request: Record<string, unknown>) {
73+
const { p, findOne, find } = makeProtocol();
74+
let answered: unknown;
75+
try {
76+
answered = await (p as any).saveMetaItem(request);
77+
} catch (e) {
78+
expect(findOne, 'the engine was reached before the refusal').not.toHaveBeenCalled();
79+
expect(find, 'the engine was reached before the refusal').not.toHaveBeenCalled();
80+
return e as Error & { code?: string; status?: number };
81+
}
82+
throw new Error(
83+
`${JSON.stringify(request)} was ACCEPTED (answered ${JSON.stringify(answered)}) instead of refused`,
84+
);
85+
}
86+
87+
describe('#8818 — a save with no item declares the ADR-0112 envelope', () => {
88+
// The three spellings that reach this guard. `item: null` is the one the
89+
// REST route actually produces (from `{"item": null}` / `{"metadata":
90+
// null}`); the other two are the same condition reached through the SDK
91+
// and the protocol interface, where `item` is an optional parameter.
92+
it.each<[string, Record<string, unknown>]>([
93+
['an explicitly null item (what the wire produces)', { type: 'app', name: 'a', item: null }],
94+
['an absent item', { type: 'app', name: 'a' }],
95+
['an explicitly undefined item', { type: 'app', name: 'a', item: undefined }],
96+
])('refuses %s with 400 INVALID_REQUEST', async (_label, request) => {
97+
const err = await refusalFor(request);
98+
99+
// The envelope, not merely the throw. A bare `toThrow()` here would be
100+
// permanently green: the UNFIXED guard threw too — that was the whole
101+
// defect — so the throw carries no information and only the
102+
// declaration does.
103+
expect(err.code).toBe('INVALID_REQUEST');
104+
expect(err.status).toBe(400);
105+
});
106+
107+
it('names the remedy in the message, so the refusal is self-correcting', async () => {
108+
const err = await refusalFor({ type: 'view', name: 'my_view', item: null });
109+
110+
expect(err.message).toContain("requires an 'item' body");
111+
// The address the author got wrong is echoed back to them.
112+
expect(err.message).toContain('view/my_view');
113+
});
114+
115+
it('does not refuse a request that DOES carry an item', async () => {
116+
// What a refusal is cheapest to break. Green in both directions on its
117+
// own — it is a guard, not evidence — but an over-broad guard (say, one
118+
// testing `'item' in request`) would turn it red.
119+
const { p } = makeProtocol();
120+
await expect(
121+
(p as any).saveMetaItem({ type: 'app', name: 'a', item: { name: 'a' } }),
122+
).rejects.not.toMatchObject({ code: 'INVALID_REQUEST' });
123+
});
124+
});
125+
126+
describe('#8818 — the refusal now SURVIVES the ADR-0112 disclosure rule', () => {
127+
it('is quoted back to the author instead of degrading to the fallback', async () => {
128+
const err = await refusalFor({ type: 'app', name: 'test_app', item: null });
129+
130+
const shown = clientFacingFailureText(err, 'save failed');
131+
132+
// THE POINT OF THE CARD: a consumer applying the #8086/#8136 rule now
133+
// shows the producer's own sentence.
134+
expect(shown).not.toBe('save failed');
135+
expect(shown).toBe(err.message);
136+
expect(shown).toContain("requires an 'item' body");
137+
});
138+
139+
it('CONTROL — the bare Error this guard used to throw is still withheld', () => {
140+
// Byte-for-byte what `origin/main` threw at this site. Without this
141+
// control the assertion above would also pass against a
142+
// `clientFacingFailureText` that had stopped withholding ANYTHING,
143+
// which is the regression that would silently re-open #8086.
144+
const undeclared = new Error('Item data is required');
145+
146+
expect(clientFacingFailureText(undeclared, 'save failed')).toBe('save failed');
147+
});
148+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11458,8 +11458,47 @@ export class ObjectStackProtocolImplementation implements
1145811458
}
1145911459

1146011460
async saveMetaItem(request: { type: string, name: string, item?: any, organizationId?: string, parentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string }) {
11461+
// [#8818] The ADR-0112 envelope this refusal always owed. Every OTHER
11462+
// refusal in this method declares `code` AND `status`
11463+
// (`NOT_OVERRIDABLE`/403, `NOT_CREATABLE`/403, `ITEM_LOCKED`/403,
11464+
// `OBJECT_OVERLAY_PACKAGE_MISMATCH`/422, the org-scope and
11465+
// destructive-change refusals, the parent-version conflict/409); this
11466+
// one declared neither, and an undeclared throw is withheld by
11467+
// {@link clientFacingFailureText} (a positive list keyed on 4xx
11468+
// `status`) and rendered `500 INTERNAL_ERROR` by `handleRouteError` —
11469+
// a SERVER FAULT for what is purely the caller's mistake, telling the
11470+
// author less than the producer knew and inviting a pointless retry.
11471+
//
11472+
// MEASURED reachable from the wire — this is an AUTHORING refusal, not
11473+
// a programming-error guard. `PUT /api/v1/meta/:type/:name` unwraps the
11474+
// `{ item }` / `{ metadata }` envelope shapes before calling here, so
11475+
// `{"item": null}` and `{"metadata": null}` arrive as `item: null` and
11476+
// land exactly here (measured end to end against a live server: both
11477+
// answered `500 INTERNAL_ERROR` before this change). ⚠️ A missing,
11478+
// empty or literal-`null` BODY does NOT reach this guard: the route
11479+
// folds it to `{}`, which is truthy, and the per-type Zod parse below
11480+
// refuses it with `422 INVALID_METADATA` — so this guard's whole
11481+
// reachable population is the explicitly-null envelope.
11482+
//
11483+
// `INVALID_REQUEST`/400 rather than the `INVALID_METADATA`/422 the
11484+
// sibling refusals use, and NEITHER mints a code: both are already
11485+
// registered to this package in the ADR-0112 ledger (D3). The 422
11486+
// sites all describe a body that EXISTS and failed the per-type parse,
11487+
// and every one carries structured `issues`; here there is no body to
11488+
// validate and no issues to report, so a 422 would misdescribe the
11489+
// failure and break that convention. The structural twin is
11490+
// {@link rollbackMetaItem}'s own opening guard — same class, same
11491+
// position, a malformed REQUEST ENVELOPE rather than an off-spec
11492+
// document — which is `[invalid_request]`/400.
1146111493
if (!request.item) {
11462-
throw new Error('Item data is required');
11494+
const err: any = new Error(
11495+
`[invalid_request] saveMetaItem requires an 'item' body for '${request.type}/${request.name}'. `
11496+
+ `Send the metadata document as the request body, or wrap it as {"item": {...}} / {"metadata": {...}}. `
11497+
+ `An explicitly null item is refused rather than persisted as an empty document.`,
11498+
);
11499+
err.code = 'INVALID_REQUEST';
11500+
err.status = 400;
11501+
throw err;
1146311502
}
1146411503
// #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}.
1146511504
request = canonicalizeMetaRequestType(request);

packages/objectql/src/protocol-meta.test.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,10 +225,19 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
225225
});
226226

227227
describe('saveMetaItem', () => {
228-
it('should throw when item data is missing', async () => {
229-
await expect(
230-
protocol.saveMetaItem({ type: 'app', name: 'test_app' })
231-
).rejects.toThrow('Item data is required');
228+
// [#8818] WAS `rejects.toThrow('Item data is required')` — a bare
229+
// message match that stayed green while the refusal declared no
230+
// ADR-0112 envelope at all, so `clientFacingFailureText` withheld the
231+
// sentence and the REST boundary served `500 INTERNAL_ERROR`. The
232+
// envelope is the contract; assert it.
233+
it('refuses a missing item with the ADR-0112 envelope (400 INVALID_REQUEST)', async () => {
234+
const err: any = await protocol.saveMetaItem({ type: 'app', name: 'test_app' })
235+
.then(() => { throw new Error('saveMetaItem ACCEPTED a request with no item'); })
236+
.catch((e: any) => e);
237+
238+
expect(err.code).toBe('INVALID_REQUEST');
239+
expect(err.status).toBe(400);
240+
expect(err.message).toContain("requires an 'item' body");
232241
});
233242

234243
it('writes the saved body through to the SchemaRegistry for non-object types (#4521)', async () => {

0 commit comments

Comments
 (0)