Skip to content

Commit dc530b4

Browse files
os-zhuangclaude
andauthored
test(spec): envelopeViolations — the conformance check BaseResponseSchema cannot express (#4090)
* test(spec): envelopeViolations — the conformance check BaseResponseSchema cannot express Every conformance suite from the #3843 line leads with `BaseResponseSchema.safeParse(body)` under a comment calling it "the contract itself, imported — not a restatement of it". That overclaimed, and the gap is demonstrable: BaseResponseSchema.safeParse({ success: true }) // passes BaseResponseSchema.safeParse({ success: true, data: link, link }) // passes The schema declares no `data` (each response type adds its own via `.extend({ data })`) and a plain z.object strips unknown keys rather than rejecting them. So it catches the one drift it was added for — a missing or non-boolean `success`, the flag `unwrapResponse` keys on — and nothing else. The second body is exactly the duplicate-payload drift /share-links shipped until #4038/#4049 removed it, and safeParse passed it the whole time. What was actually catching that drift was the assertions each suite hand-wrote beside it, which works only for as long as whoever writes the next suite remembers to. `envelopeViolations(body)` returns every departure as readable reasons, empty when conformant: `success` must be a boolean; a success body must carry `data` (`undefined` only — null/[]/{}/0/'' are payloads, not absences); a failure body must carry `error` with a string code and message; and no top-level key outside success/data/error/meta, which is the general form of the duplicate-payload drift. It deliberately does NOT check the shape of `data` — that is each route's own payload schema, and conflating the two is what let SettingsNamespacePayload describe a whole body before #3843 and only `data` after it. Ten suites now assert it beside safeParse, and their comments say what each of the two actually proves. Verified the pairing is load-bearing: reintroducing the /share-links duplicate key fails the new assertion and still passes the old one. Verified the rules are not too strict by running every existing suite unchanged — spec 6951, rest 505, runtime 914, storage 220, settings 189, datasource 154, i18n 62, sharing 225, client 200, all green, nothing flagged. Placed in contract.zod.ts beside the schema it completes, alongside the other plain predicates spec/api already exports (standardErrorCodeForHttpStatus, readServiceSelfInfo). No new package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z * chore(spec): regenerate the API-surface snapshot for envelopeViolations `check:api-surface` is a ratchet on `@objectstack/spec`'s public exports, and the new predicate is an addition to it: ./api + envelopeViolations (function) 0 breaking (removed/narrowed), 1 added. One line, alphabetically beside the other predicates that module already exports. Two things this caught that are worth writing down. The gate lives in the TypeScript Type Check job, which runs TEN steps — I had found four of them by grepping a window of lint.yml that cut off before the rest, and ran only those. The full list is: spec tsc, check:docs, check:skill-refs, check:react-blocks, a workspace build, examples typecheck, downstream-contract typecheck, check:api-surface, check:skill-examples, and the two i18n checks. All ten pass locally now. And `gen:api-surface` reads `dist/index.d.ts`, so it fails outright under `OS_SKIP_DTS=1` — the flag I had been using to keep local builds fast. Rebuilding spec with declarations is a prerequisite for regenerating the snapshot, not an optional speed-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 707dae1 commit dc530b4

14 files changed

Lines changed: 346 additions & 17 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
**`envelopeViolations` — the conformance check `BaseResponseSchema` cannot express.**
6+
7+
Every conformance suite from the #3843 line leads with
8+
`BaseResponseSchema.safeParse(body)`, under a comment claiming it is "the contract
9+
itself, imported — not a restatement of it". That overclaimed, and the gap is
10+
demonstrable:
11+
12+
```ts
13+
BaseResponseSchema.safeParse({ success: true }) // passes
14+
BaseResponseSchema.safeParse({ success: true, data: link, link }) // passes
15+
```
16+
17+
The schema declares no `data` — each response type adds its own via
18+
`.extend({ data })` — and a plain `z.object` strips unknown keys rather than
19+
rejecting them. So it catches the one drift it was added for (a missing or
20+
non-boolean `success`, the flag `unwrapResponse` keys on) and nothing else. The
21+
second body above is exactly the duplicate-payload drift `/share-links` shipped
22+
until #4038 / #4049 removed it, and `safeParse` passed it the whole time.
23+
24+
`envelopeViolations(body)` returns every departure from the declared envelope as
25+
readable reasons, empty when conformant:
26+
27+
- `success` must be a **boolean**
28+
- a success body must carry `data` (`undefined` only — `null`, `[]`, `{}`, `0`
29+
and `''` are payloads, not absences)
30+
- a failure body must carry `error` with a string `code` and `message` — the
31+
nested form, not the pre-#3675 bare string
32+
- no top-level key outside `success` / `data` / `error` / `meta`, which is the
33+
general form of the duplicate-payload drift
34+
35+
It deliberately does **not** check the shape of `data`: that is each route's own
36+
payload schema, and conflating the two is what let `SettingsNamespacePayload`
37+
describe a whole body before #3843 and only `data` after it.
38+
39+
The ten conformance suites now assert it beside `safeParse`, and their comments
40+
say what each of the two actually proves. Reintroducing the `/share-links`
41+
duplicate key is caught by the new assertion and still passes the old one — which
42+
is the demonstration that the pairing is the point.
43+
44+
Placed in `contract.zod.ts` beside the schema it completes, alongside the other
45+
plain predicates `spec/api` already exports (`standardErrorCodeForHttpStatus`,
46+
`readServiceSelfInfo`). No new package.

packages/plugins/plugin-sharing/src/share-link-envelope.conformance.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
*/
3535

3636
import { describe, expect, it, vi } from 'vitest';
37-
import { BaseResponseSchema } from '@objectstack/spec/api';
37+
import { BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api';
3838
import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
3939
import { registerShareLinkRoutes } from './share-link-routes';
4040
import type { ShareLinkService } from './share-link-service';
@@ -214,9 +214,15 @@ describe('share-link envelope (#3983) — success bodies', () => {
214214
const { status, body } = await c.run();
215215
expect(status).toBe(c.status);
216216

217-
// The contract itself, imported — not a restatement of it.
217+
// The envelope SKELETON, imported. It is not the whole contract: it declares
218+
// no `data` and strips unknown keys, so on its own it passes `{ success: true }`
219+
// and passes a payload duplicated into a stray top-level key. What it DOES
220+
// catch is the missing `success` flag — the drift this line was added for.
218221
const parsed = BaseResponseSchema.safeParse(body);
219222
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
223+
// The declared envelope in full — `safeParse` alone passes a body with no
224+
// `data`, or a payload duplicated into a stray top-level key (#4049).
225+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
220226
expect(body.success).toBe(true);
221227
expect(body.error).toBeUndefined();
222228
c.expectData(body.data);
@@ -424,6 +430,9 @@ describe('share-link envelope (#3983) — error bodies', () => {
424430

425431
const parsed = BaseResponseSchema.safeParse(body);
426432
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
433+
// The declared envelope in full — `safeParse` alone passes a body with no
434+
// `data`, or a payload duplicated into a stray top-level key (#4049).
435+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
427436

428437
expect(body.success).toBe(false);
429438
expect(body.error.code).toBe(c.code);

packages/rest/src/external-datasource-envelope.conformance.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
*/
3434

3535
import { describe, it, expect, vi } from 'vitest';
36-
import { BaseResponseSchema } from '@objectstack/spec/api';
36+
import { BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api';
3737
import type { IHttpServer, RouteHandler } from '@objectstack/spec/contracts';
3838
import { registerExternalDatasourceRoutes } from './external-datasource-routes.js';
3939

@@ -127,9 +127,15 @@ describe('external-datasource envelope (#3843) — success bodies', () => {
127127
const { status, body } = await c.run();
128128
expect(status).toBe(c.status);
129129

130-
// The contract itself, imported — not a restatement of it.
130+
// The envelope SKELETON, imported. It is not the whole contract: it declares
131+
// no `data` and strips unknown keys, so on its own it passes `{ success: true }`
132+
// and passes a payload duplicated into a stray top-level key. What it DOES
133+
// catch is the missing `success` flag — the drift this line was added for.
131134
const parsed = BaseResponseSchema.safeParse(body);
132135
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
136+
// The declared envelope in full — `safeParse` alone passes a body with no
137+
// `data`, or a payload duplicated into a stray top-level key (#4049).
138+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
133139
expect(body.success).toBe(true);
134140
expect(body.error).toBeUndefined();
135141

@@ -205,6 +211,9 @@ describe('external-datasource envelope (#3843) — error bodies', () => {
205211

206212
const parsed = BaseResponseSchema.safeParse(body);
207213
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
214+
// The declared envelope in full — `safeParse` alone passes a body with no
215+
// `data`, or a payload duplicated into a stray top-level key (#4049).
216+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
208217

209218
expect(body.success).toBe(false);
210219
expect(body.error.code).toBe(c.code);

packages/rest/src/package-envelope.conformance.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
*/
3737

3838
import { describe, it, expect } from 'vitest';
39-
import { BaseResponseSchema } from '@objectstack/spec/api';
39+
import { BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api';
4040
import type { RouteHandler } from '@objectstack/spec/contracts';
4141
import { registerPackageRoutes } from './package-routes.js';
4242

@@ -164,9 +164,15 @@ describe('packages envelope (#3843) — success bodies', () => {
164164
const { status, body } = await c.run();
165165
expect(status).toBe(c.status);
166166

167-
// The contract itself, imported — not a restatement of it.
167+
// The envelope SKELETON, imported. It is not the whole contract: it declares
168+
// no `data` and strips unknown keys, so on its own it passes `{ success: true }`
169+
// and passes a payload duplicated into a stray top-level key. What it DOES
170+
// catch is the missing `success` flag — the drift this line was added for.
168171
const parsed = BaseResponseSchema.safeParse(body);
169172
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
173+
// The declared envelope in full — `safeParse` alone passes a body with no
174+
// `data`, or a payload duplicated into a stray top-level key (#4049).
175+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
170176
expect(body.success).toBe(true);
171177
expect(body.error).toBeUndefined();
172178

@@ -297,6 +303,9 @@ describe('packages envelope (#3843) — error bodies', () => {
297303

298304
const parsed = BaseResponseSchema.safeParse(body);
299305
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
306+
// The declared envelope in full — `safeParse` alone passes a body with no
307+
// `data`, or a payload duplicated into a stray top-level key (#4049).
308+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
300309

301310
expect(body.success).toBe(false);
302311
expect(body.error.code).toBe(c.code);

packages/runtime/src/error-envelope.conformance.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
import { describe, it, expect, vi } from 'vitest';
2929
import { readFileSync } from 'node:fs';
30-
import { ApiErrorSchema, BaseResponseSchema, DispatcherErrorCode } from '@objectstack/spec/api';
30+
import { ApiErrorSchema, BaseResponseSchema, DispatcherErrorCode, envelopeViolations } from '@objectstack/spec/api';
3131
import { HttpDispatcher } from './http-dispatcher.js';
3232
import { buildApiError, splitSemanticCode } from './error-envelope.js';
3333

@@ -45,6 +45,7 @@ function expectConformantError(response: { status: number; body: any } | undefin
4545
const body = response!.body;
4646

4747
expect(BaseResponseSchema.safeParse(body).success).toBe(true);
48+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
4849
expect(body.success).toBe(false);
4950

5051
const parsed = ApiErrorSchema.safeParse(body.error);

packages/runtime/src/i18n-success-envelope.conformance.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141

4242
import { describe, it, expect, vi, beforeEach } from 'vitest';
4343
import { HttpDispatcher } from './http-dispatcher.js';
44-
import { BaseResponseSchema } from '@objectstack/spec/api';
44+
import { BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api';
4545
import {
4646
GetLocalesResponseSchema,
4747
GetTranslationsResponseSchema,
@@ -86,6 +86,9 @@ describe('/i18n success-envelope conformance (dispatcher domain)', () => {
8686
function expectEnvelope(body: unknown) {
8787
const parsed = BaseResponseSchema.safeParse(body);
8888
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
89+
// The declared envelope in full — `safeParse` alone passes a body with no
90+
// `data`, or a payload duplicated into a stray top-level key (#4049).
91+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
8992
expect((body as { success?: boolean }).success).toBe(true);
9093
}
9194

packages/services/service-datasource/src/__tests__/envelope.conformance.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
*/
3636

3737
import { describe, it, expect, vi } from 'vitest';
38-
import { BaseResponseSchema } from '@objectstack/spec/api';
38+
import { BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api';
3939
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
4040
import { registerDatasourceAdminRoutes } from '../admin-routes.js';
4141

@@ -128,9 +128,15 @@ describe('datasource-admin envelope (#3843) — success bodies', () => {
128128
const { status, body } = await c.run();
129129
expect(status).toBe(c.status);
130130

131-
// The contract itself, imported — not a restatement of it.
131+
// The envelope SKELETON, imported. It is not the whole contract: it declares
132+
// no `data` and strips unknown keys, so on its own it passes `{ success: true }`
133+
// and passes a payload duplicated into a stray top-level key. What it DOES
134+
// catch is the missing `success` flag — the drift this line was added for.
132135
const parsed = BaseResponseSchema.safeParse(body);
133136
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
137+
// The declared envelope in full — `safeParse` alone passes a body with no
138+
// `data`, or a payload duplicated into a stray top-level key (#4049).
139+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
134140
expect(body.success).toBe(true);
135141
expect(body.error).toBeUndefined();
136142

@@ -205,6 +211,9 @@ describe('datasource-admin envelope (#3843) — error bodies', () => {
205211

206212
const parsed = BaseResponseSchema.safeParse(body);
207213
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
214+
// The declared envelope in full — `safeParse` alone passes a body with no
215+
// `data`, or a payload duplicated into a stray top-level key (#4049).
216+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
208217

209218
expect(body.success).toBe(false);
210219
expect(body.error.code).toBe(c.code);

packages/services/service-i18n/src/error-envelope.conformance.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
*/
3232

3333
import { describe, it, expect, vi } from 'vitest';
34-
import { BaseResponseSchema } from '@objectstack/spec/api';
34+
import { BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api';
3535
import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
3636
import { I18nServicePlugin } from './i18n-service-plugin';
3737

@@ -167,6 +167,9 @@ describe('i18n error envelope (#3675)', () => {
167167

168168
const parsed = BaseResponseSchema.safeParse(body);
169169
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
170+
// The declared envelope in full — `safeParse` alone passes a body with no
171+
// `data`, or a payload duplicated into a stray top-level key (#4049).
172+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
170173

171174
expect(body.success).toBe(false);
172175
expect(body.error.code).toBe(c.code);
@@ -186,6 +189,7 @@ describe('i18n error envelope (#3675)', () => {
186189
const { status, body } = await drive(routes, `${BASE}/locales`);
187190
expect(status).toBe(500);
188191
expect(BaseResponseSchema.safeParse(body).success).toBe(true);
192+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
189193
expect(body.error.message).toBe('Internal error');
190194
});
191195
});

packages/services/service-settings/src/envelope.conformance.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
*/
3333

3434
import { describe, expect, it, vi } from 'vitest';
35-
import { BaseResponseSchema } from '@objectstack/spec/api';
35+
import { BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api';
3636
import { SettingsNamespacePayloadSchema } from '@objectstack/spec/system';
3737
import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
3838
import { SettingsService } from './settings-service';
@@ -152,9 +152,15 @@ describe('settings envelope (#3843) — success bodies', () => {
152152
const { status, body } = await c.run();
153153
expect(status).toBe(200);
154154

155-
// The contract itself, imported — not a restatement of it.
155+
// The envelope SKELETON, imported. It is not the whole contract: it declares
156+
// no `data` and strips unknown keys, so on its own it passes `{ success: true }`
157+
// and passes a payload duplicated into a stray top-level key. What it DOES
158+
// catch is the missing `success` flag — the drift this line was added for.
156159
const parsed = BaseResponseSchema.safeParse(body);
157160
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
161+
// The declared envelope in full — `safeParse` alone passes a body with no
162+
// `data`, or a payload duplicated into a stray top-level key (#4049).
163+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
158164
expect(body.success).toBe(true);
159165
expect(body.error).toBeUndefined();
160166

@@ -269,6 +275,9 @@ describe('settings envelope (#3843) — error bodies', () => {
269275

270276
const parsed = BaseResponseSchema.safeParse(body);
271277
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
278+
// The declared envelope in full — `safeParse` alone passes a body with no
279+
// `data`, or a payload duplicated into a stray top-level key (#4049).
280+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
272281

273282
expect(body.success).toBe(false);
274283
expect(body.error.code).toBe(c.code);

packages/services/service-storage/src/error-envelope.conformance.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { describe, it, expect } from 'vitest';
3838
import { promises as fs } from 'node:fs';
3939
import { join } from 'node:path';
4040
import { tmpdir } from 'node:os';
41-
import { BaseResponseSchema } from '@objectstack/spec/api';
41+
import { BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api';
4242
import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
4343
import { LocalStorageAdapter } from './local-storage-adapter';
4444
import { StorageMetadataStore } from './metadata-store';
@@ -286,9 +286,15 @@ describe('storage error envelope (#3675)', () => {
286286
const { status, body } = await c.run();
287287
expect(status).toBe(c.status);
288288

289-
// The contract itself, imported — not a restatement of it.
289+
// The envelope SKELETON, imported. It is not the whole contract: it declares
290+
// no `data` and strips unknown keys, so on its own it passes `{ success: true }`
291+
// and passes a payload duplicated into a stray top-level key. What it DOES
292+
// catch is the missing `success` flag — the drift this line was added for.
290293
const parsed = BaseResponseSchema.safeParse(body);
291294
expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true);
295+
// The declared envelope in full — `safeParse` alone passes a body with no
296+
// `data`, or a payload duplicated into a stray top-level key (#4049).
297+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
292298

293299
expect(body.success).toBe(false);
294300
expect(body.error.code).toBe(c.code);

0 commit comments

Comments
 (0)