Found while implementing #11637 (making the same method parse its api sub-object). Filed, not fixed — #11637 deliberately puts one narrowing in front of contract review rather than five, since each sub-object refuses a different set of previously-accepted configs.
What was measured
On origin/main @ 7899f5745. RestServer.normalizeConfig opens with five casts, one per sub-object:
constapi=(config.api??{})asPartial<RestApiConfig>;constcrud=(config.crud??{})asPartial<CrudEndpointsConfig>;constmetadata=(config.metadata??{})asPartial<MetadataEndpointsConfig>;constbatch=(config.batch??{})asPartial<BatchEndpointsConfig>;constroutes=(config.routes??{})asPartial<RouteGenerationConfig>;#11637 replaces the first line's unvalidated status by running RestApiConfigSchema before it. The other four are untouched, and each has declared constraints that consequently never execute (packages/spec/src/api/rest-server.zod.ts):
| declared constraint | what the cast admits today |
|---|
batch.maxBatchSize: z.number().int().min(1).max(1000).default(200) | 0, -5, 10_000, 2.5 — the value is used as the batch cap, so an out-of-range one is a live policy, not a typo |
routes.nameTransform: z.enum(['none','plural','kebab-case','camelCase']) | any string; an unrecognised one silently falls through the transform switch |
crud.objectParamStyle: z.enum(['path','query']) | any string |
metadata.cacheTtl: z.number().int().default(3600) | a float, or a negative TTL |
Same seam, same mechanism, same reason nothing runs them: both hops into @objectstack/rest are casts and the plugin declares no configSchema (see #11637's chain, and #11982 for why the kernel validator could not have covered it either).
api.version was the urgent member of the family because it is spliced into getApiBasePath() and therefore into the mount of every route — api.version: '' mounted the whole API at /api//. These four are ordinary knobs by comparison, which is why they were split out rather than folded in.
Not prejudged
The obvious shape is to extend #11637's assertDeclaredApiConfig to the four siblings — but it is not a mechanical repeat, and each needs its own measurement first:
Each of these is contract accept/reject behaviour change, so whatever lands wants a changeset naming the newly-refused shapes and the contract-review tier, exactly as #11637 did.
Generated by Claude Code
Found while implementing #11637 (making the same method parse its
apisub-object). Filed, not fixed — #11637 deliberately puts one narrowing in front of contract review rather than five, since each sub-object refuses a different set of previously-accepted configs.What was measured
On
origin/main@7899f5745.RestServer.normalizeConfigopens with five casts, one per sub-object:#11637 replaces the first line's unvalidated status by running
RestApiConfigSchemabefore it. The other four are untouched, and each has declared constraints that consequently never execute (packages/spec/src/api/rest-server.zod.ts):batch.maxBatchSize: z.number().int().min(1).max(1000).default(200)0,-5,10_000,2.5— the value is used as the batch cap, so an out-of-range one is a live policy, not a typoroutes.nameTransform: z.enum(['none','plural','kebab-case','camelCase'])crud.objectParamStyle: z.enum(['path','query'])metadata.cacheTtl: z.number().int().default(3600)Same seam, same mechanism, same reason nothing runs them: both hops into
@objectstack/restare casts and the plugin declares noconfigSchema(see #11637's chain, and #11982 for why the kernel validator could not have covered it either).api.versionwas the urgent member of the family because it is spliced intogetApiBasePath()and therefore into the mount of every route —api.version: ''mounted the whole API at/api//. These four are ordinary knobs by comparison, which is why they were split out rather than folded in.Not prejudged
The obvious shape is to extend #11637's
assertDeclaredApiConfigto the four siblings — but it is not a mechanical repeat, and each needs its own measurement first:RestServerConfigSchemacarries its own tombstone (openApi31, retired in RestServerConfig.openApi31(OpenApi31Extensions / Callback / OpenApiWebhookEvent)declared ≠ enforced:没有任何运行时读取它 —— ADR-0049 enforce-or-remove 候选 #4579).RestApiConfigSchemaconstrainsapi.versionwith a regex the REST server never runs — the seam casts instead of parsing, soapi.version: ''is accepted and mounts the whole API at/api//#11637.omit()ed theapiobject'srequireAuthtombstone rather than enforce it, because 把 public 从"全局开关的副产品"升级为声明式能力,然后删掉 api.requireAuth 开关 #3963 chose warn-and-ignore for that key and flipping it to a boot failure is that card's decision. A whole-RestServerConfigparse inherits the same question foropenApi31.metadata.maskObjectFieldsis honoured by the runtime and declared nowhere (api.enableSearchandmetadata.maskObjectFieldsare live REST config keys that no schema inpackages/specdeclares — read throughas any, and stripped by any parse of their own config object #11983), so a parse whose output is consumed would strip it.RestApiConfigSchemaconstrainsapi.versionwith a regex the REST server never runs — the seam casts instead of parsing, soapi.version: ''is accepted and mounts the whole API at/api//#11637's validate-but-do-not-consume shape sidesteps this; a fix here should decide whether to keep paying that cost or landapi.enableSearchandmetadata.maskObjectFieldsare live REST config keys that no schema inpackages/specdeclares — read throughas any, and stripped by any parse of their own config object #11983 first.RestApiConfigSchemaconstrainsapi.versionwith a regex the REST server never runs — the seam casts instead of parsing, soapi.version: ''is accepted and mounts the whole API at/api//#11637's was one in-repo site. Nobody has counted the fixtures that pass an out-of-rangemaxBatchSizeor an unknownnameTransform.Each of these is contract accept/reject behaviour change, so whatever lands wants a changeset naming the newly-refused shapes and the contract-review tier, exactly as #11637 did.
Generated by Claude Code