Skip to content

Commit 74ab2bd

Browse files
committed
fix(core-internal): pipe tolerance requires bare-transform OUT; uniform ref-aware id strip; prose sync
- The pipe branch of hasStructuralMissingKeyTolerance claims IN-side tolerance only when the OUT side is a bare transform (nothing re-validates the filled value - the pinned async-transform shape): a validating OUT side may reject it (.default(0).pipe(z.number().min(1)); .optional().pipe(z.coerce.number()) coerces undefined to NaN), so the walk claims nothing there and the validate(undefined) probe decides - .required()-style truthful required advertisements, no spurious loosened flag. The preprocess direction keeps its structural claim (deferring to the probe would mis-require async-refined preprocess fields) - the undefined-unsafe-fn residual is documented in the Known residual gaps list. - The draft-04 id strip is now uniformly ref-aware: the INPUT path defers the strip like the output paths and applies it post-hoc, and the gate is tightened from fragment-ness to the guard's registry-shape test - cfworker resolves even fragment pointers INSIDE an id resource relative to that base, so ANY hand-authored ref (URI-form or fragment-form) keeps the id (exact pre-fix parity; Ajv rejected those documents pre-fix too), while registry-only documents keep getting the strip for Ajv compilability. - Prose sync: retitle the stale allOf-push-stamps test to the strict-snapshot mechanism, fix the matching isProvablyObjectShapedRoot comment, and document the id strip in the changeset (what disappears, why, the cfworker caveat). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 37d8eb4 commit 74ab2bd

3 files changed

Lines changed: 127 additions & 30 deletions

File tree

.changeset/zod-tojsonschema-wire-truthful.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ and so do dynamic catch values, `.catch(ctx => …)`; the `.catch()` degrade cov
1313
fallback values only. And a misregistered non-object ROOT — `z.bigint()` or `z.map()` as
1414
the whole `inputSchema`/`outputSchema` — still fails `tools/list` loudly by design,
1515
preserving the pre-fix error instead of listing a permanently-broken tool.)
16+
Registry metadata (`.meta({id: 'X'})`) no longer emits the draft-04 `id` keyword on either
17+
io path — Ajv v8 hard-rejects it at compile time ('NOT SUPPORTED: keyword "id"'), so the
18+
SDK's own client could never validate such advertisements. The key is kept only when the
19+
document carries a hand-authored ref beyond zod's registry shapes: those may resolve
20+
through the `id` base-URI on the `@cfworker/json-schema` engine (URI-form refs, and
21+
fragment pointers inside an `id` resource), so stripping would break them — such documents
22+
ship with `id` intact, exactly as pre-fix.
1623
Output schemas no longer advertise constraints the server doesn't enforce on the raw
1724
`structuredContent` it ships: fields that may be legitimately absent (`.default()`,
1825
undefined-accepting types) are dropped from `required` — on objects and enum-keyed records —

packages/core-internal/src/util/standardSchema.ts

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,12 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12';
223223
* catchProcessor before this hook runs ("Dynamic catch values are not supported
224224
* in JSON Schema"), so one such tool still fails the entire `tools/list` — the
225225
* degrade below covers static `.catch(value)` only.
226+
* - An undefined-unsafe `z.preprocess` fn wrapping a tolerant inner (`z.preprocess(
227+
* v => (v as string).length, z.number().default(7))`) still advertises the field
228+
* as droppable even though a missing key throws in the fn: the structural walk
229+
* cannot evaluate the fn, and deferring the preprocess spelling to the
230+
* validate(undefined) probe would mis-require async-refined preprocess fields
231+
* (the probe goes async and conservatively claims nothing).
226232
* - Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still advertise the
227233
* post-transform shape (`io: 'output'`) even though the server validates and ships
228234
* the raw pre-transform value — rewriting pipe nodes to their input side per-node
@@ -279,10 +285,12 @@ function zodConversionOptions(
279285
// help), so the SDK's own client could never validate the advertisement.
280286
// Registry `$ref`s are path-based (#/$defs/Name) and cannot dangle;
281287
// renaming to `$id` would change base-URI resolution, so plain removal.
282-
// The zod OUTPUT flow defers this strip on its strict pass — a
283-
// guard-shipped document with URI-form refs needs the `id` base the
284-
// cfworker engine resolves them through (see the guard branch in
285-
// standardSchemaToJsonSchema).
288+
// The strict and input passes DEFER this strip and apply it
289+
// post-hoc only when no hand-authored ref exists — refs beyond
290+
// zod's registry shapes may resolve through an `id` base on the
291+
// cfworker engine (see standardSchemaToJsonSchema). The loosen
292+
// pass strips in-hook: the guard already vouched the document
293+
// carries no hand-authored reference construct.
286294
delete ctx.jsonSchema.id;
287295
}
288296
if (def.type === 'date') {
@@ -522,27 +530,34 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set<unknown> = new Set()): voi
522530
const ZOD_REGISTRY_REF_PATTERN = /^#\/\$defs\/[^/]+$/;
523531

524532
/**
525-
* Whether any reference keyword in the document carries a non-fragment value — a
526-
* ref that may resolve through a base URI (`$id`, or the draft-04 `id` the
527-
* cfworker engine also registers) rather than by same-document pointer/anchor.
528-
* Position-aware like the guard walk.
533+
* Whether any reference keyword in the document carries a hand-authored value —
534+
* anything but zod's own registry shapes (`#`, `#/$defs/<name>`). Zod's registry
535+
* refs are root-base JSON Pointers that never resolve through a draft-04 `id`
536+
* base, so stripping `id` around them is safe; every OTHER ref may depend on an
537+
* `id` base on the cfworker engine (URI-form refs resolve through
538+
* `schema.$id || schema.id` registration, and even a fragment pointer INSIDE an
539+
* `id` resource resolves relative to that base). Position-aware like the guard
540+
* walk.
529541
*/
530-
function hasNonFragmentRefs(document: Record<string, unknown>): boolean {
542+
function hasHandAuthoredRefValues(document: Record<string, unknown>): boolean {
531543
return someSchemaNode(document, record =>
532544
['$ref', '$dynamicRef', '$recursiveRef'].some(refKey => {
533545
const value = record[refKey];
534-
return typeof value === 'string' && !value.startsWith('#');
546+
if (value === undefined) return false;
547+
return typeof value !== 'string' || (value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value));
535548
})
536549
);
537550
}
538551

539552
/**
540553
* Deletes every keyword-position draft-04 `id` in the document — the post-hoc
541-
* spelling of the override hook's strip, for the guard-strict path where the
554+
* spelling of the override hook's strip, for the conversion paths where the
542555
* override runs with the strip deferred. Only safe when
543-
* {@linkcode hasNonFragmentRefs} is false: with fragment-only refs the `id` keys
544-
* are inert bases nothing resolves through, while Ajv v8 hard-rejects the
545-
* keyword at compile time.
556+
* {@linkcode hasHandAuthoredRefValues} is false: with only zod-registry refs the
557+
* `id` keys are inert bases nothing resolves through, while Ajv v8 hard-rejects
558+
* the keyword at compile time. Any hand-authored ref — URI-form or
559+
* fragment-form — keeps the `id` (exact pre-#2464 parity: Ajv rejected those
560+
* documents then too, and the cfworker engine needs the base).
546561
*/
547562
function stripLegacyIdKeywords(document: Record<string, unknown>): void {
548563
someSchemaNode(document, record => {
@@ -892,10 +907,21 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet
892907
}
893908
}
894909
if (def.type === 'pipe' && def.in !== undefined) {
895-
if (hasStructuralMissingKeyTolerance(def.in, path)) return true;
896-
// `z.preprocess(fn, inner)` builds the opposite pipe — the transform sits at
897-
// `def.in` and the tolerant node (e.g. a default) at `def.out`.
898910
const inDef = (def.in as { _zod?: { def?: { type?: string } } })._zod?.def;
911+
const outDef = (def.out as { _zod?: { def?: { type?: string } } } | undefined)?._zod?.def;
912+
// IN-side tolerance survives the pipe only when the OUT side is a BARE
913+
// TRANSFORM (nothing re-validates the filled/passed value — the pinned
914+
// `.default(7).transform(async …)` shape, where the async stage is exactly
915+
// why the probe cannot be used). A validating OUT side may reject the
916+
// filled value (`.default(0).pipe(z.number().min(1))` rejects 0;
917+
// `.optional().pipe(z.coerce.number())` coerces undefined to NaN), so the
918+
// walk claims nothing there and the validate(undefined) probe decides.
919+
if (outDef?.type === 'transform' && hasStructuralMissingKeyTolerance(def.in, path)) return true;
920+
// `z.preprocess(fn, inner)` builds the opposite pipe — the transform sits at
921+
// `def.in` and the tolerant node (e.g. a default) at `def.out`. The fn's
922+
// own undefined-safety is NOT checked (a Known residual gap): deferring to
923+
// the probe would mis-require async-refined preprocess fields the pinned
924+
// tests keep droppable.
899925
if (inDef?.type === 'transform' && def.out !== undefined) {
900926
return hasStructuralMissingKeyTolerance(def.out, path);
901927
}
@@ -1006,9 +1032,13 @@ export function standardSchemaToJsonSchema(
10061032
result = convert(undefined);
10071033
} else if (io !== 'output' || std.vendor !== 'zod') {
10081034
// The loosen family rewrites only zod OUTPUT advertisements — every other
1009-
// conversion runs once, with the sanitizing overrides (date rewrite,
1010-
// draft-04 `id` strip) alone for zod inputs.
1011-
result = convert(zodConversionOptions(io, loosened, false));
1035+
// conversion runs once, with the sanitizing overrides alone for zod
1036+
// inputs. The draft-04 `id` strip is deferred and applied post-hoc under
1037+
// the same hand-authored-ref gate as the output paths: an input document
1038+
// whose refs resolve through an `id` base must keep it for the cfworker
1039+
// engine.
1040+
result = convert(zodConversionOptions(io, loosened, false, false));
1041+
if (std.vendor === 'zod' && !hasHandAuthoredRefValues(result)) stripLegacyIdKeywords(result);
10121042
} else {
10131043
// Wire-truthfulness loosening is guarded by reference-construct detection:
10141044
// first emit STRICTLY (sanitizing overrides only) and inspect the natural
@@ -1025,11 +1055,14 @@ export function standardSchemaToJsonSchema(
10251055
// — stripping would break a working pre-#2464 registration.
10261056
const strict = convert(zodConversionOptions(io, loosened, false, false));
10271057
if (hasHandAuthoredReferenceConstructs(strict)) {
1028-
// With only fragment-form refs the `id` keys are inert bases — strip
1058+
// With only zod-registry refs the `id` keys are inert bases — strip
10291059
// them post-hoc so Ajv keeps compiling registry-id documents (its v8
1030-
// engine hard-rejects the keyword; it rejected URI-form-ref documents
1031-
// pre-#2464 too, so keeping `id` for those is pre-fix parity).
1032-
if (!hasNonFragmentRefs(strict)) stripLegacyIdKeywords(strict);
1060+
// engine hard-rejects the keyword). ANY hand-authored ref keeps the
1061+
// `id`: URI-form refs resolve through it, and even a fragment pointer
1062+
// inside an `id` resource resolves relative to that base on the
1063+
// cfworker engine (Ajv rejected such documents pre-#2464 too, so
1064+
// keeping `id` is pre-fix parity).
1065+
if (!hasHandAuthoredRefValues(strict)) stripLegacyIdKeywords(strict);
10331066
result = strict;
10341067
} else {
10351068
// The 2025-era wrap-stamp decision must match main, which read the RAW
@@ -1489,8 +1522,9 @@ function isProvablyObjectShapedRoot(schema: Record<string, unknown>): boolean {
14891522
// nullable union carrying a user `.meta({allOf: [{type: 'object'}]})`) stayed
14901523
// typeless and 2025-era-wrapped on main, so a later key must not prove what
14911524
// the first cannot. The loosen rewrite's allOf-push does not rely on this
1492-
// proof seeing its relocated conjunct: it stamps `type: 'object'` itself, and
1493-
// its internal proof argument carries only `anyOf`.
1525+
// proof seeing its relocated conjunct: the output epilogue decides the root
1526+
// stamp from the STRICT pre-loosen snapshot, where the emitted oneOf is
1527+
// still the first present key.
14941528
for (const key of ['oneOf', 'anyOf', 'allOf'] as const) {
14951529
const members = schema[key];
14961530
if (!Array.isArray(members) || members.length === 0) continue;

packages/core-internal/test/util/standardSchema.test.ts

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,6 +1176,62 @@ describe('zod conversion options (#2464)', () => {
11761176
expect(filled.required).toEqual(['b']);
11771177
});
11781178

1179+
test('pipe tolerance needs the OUT side to be a bare transform', () => {
1180+
// A validating OUT side may reject the filled/passed value, so the walk
1181+
// claims nothing and the validate(undefined) probe decides — the filled 0
1182+
// fails min(1), and optional-through-coerce yields NaN.
1183+
const validatingOut = z.object({ p: z.number().default(0).pipe(z.number().min(1)), name: z.string() });
1184+
expect(standardSchemaToJsonSchema(validatingOut, 'output').required).toEqual(['p', 'name']);
1185+
const coerced = z.object({ p: z.string().optional().pipe(z.coerce.number()), name: z.string() });
1186+
expect(standardSchemaToJsonSchema(coerced, 'output').required).toEqual(['p', 'name']);
1187+
// A defaulted value satisfying the OUT side keeps the field droppable via
1188+
// the probe...
1189+
const satisfied = z.object({ p: z.number().default(5).pipe(z.number().min(1)), name: z.string() });
1190+
expect(standardSchemaToJsonSchema(satisfied, 'output').required).toEqual(['name']);
1191+
// ...and the bare-transform OUT side keeps the structural shortcut (the
1192+
// async stage below is exactly why the probe cannot be used there).
1193+
const bareTransform = z.object({
1194+
p: z
1195+
.number()
1196+
.default(7)
1197+
.transform(async value => value + 1),
1198+
name: z.string()
1199+
});
1200+
expect(standardSchemaToJsonSchema(bareTransform, 'output').required).toEqual(['name']);
1201+
});
1202+
1203+
test('input conversions keep draft-04 id when hand-authored refs need its base', () => {
1204+
// The input branch defers the strip under the same gate as the output
1205+
// paths — no reference guard runs on input, but the hand-authored-ref
1206+
// test does.
1207+
const reg = z.object({ q: z.string() }).meta({ id: 'RegIn' });
1208+
const inResult = standardSchemaToJsonSchema(
1209+
z.object({ x: reg, alias: z.unknown().meta({ $ref: 'RegIn' }), name: z.string() }),
1210+
'input'
1211+
);
1212+
expect(((inResult.$defs as Record<string, Record<string, unknown>>).RegIn ?? {}).id).toBe('RegIn');
1213+
const validate = new CfWorkerJsonSchemaValidator().getValidator(inResult);
1214+
expect(validate({ x: { q: 'a' }, alias: { q: 'b' }, name: 'n' }).valid).toBe(true);
1215+
expect(validate({ x: { q: 'a' }, alias: { nope: 1 }, name: 'n' }).valid).toBe(false); // ref enforces
1216+
// Registry-only inputs still get the strip (Ajv compilability).
1217+
const registryOnly = standardSchemaToJsonSchema(z.object({ x: reg, y: reg, name: z.string() }), 'input');
1218+
expect(((registryOnly.$defs as Record<string, Record<string, unknown>>).RegIn ?? {}).id).toBeUndefined();
1219+
expect(new AjvJsonSchemaValidator().getValidator(registryOnly)({ x: { q: 'a' }, y: { q: 'b' }, name: 'n' }).valid).toBe(true);
1220+
});
1221+
1222+
test('fragment refs inside an id resource keep the id (base-relative resolution)', () => {
1223+
// cfworker resolves a fragment pointer inside a draft-04 id resource
1224+
// relative to THAT base — stripping the id would retarget it to the root
1225+
// and dangle. Any hand-authored ref, fragment-form included, keeps the id.
1226+
const reg = z.object({ q: z.string(), alias: z.unknown().meta({ $ref: '#/properties/q' }) }).meta({ id: 'RegY' });
1227+
const result = standardSchemaToJsonSchema(z.object({ x: reg, name: z.string() }), 'output');
1228+
1229+
expect(((result.$defs as Record<string, Record<string, unknown>>).RegY ?? {}).id).toBe('RegY');
1230+
const validate = new CfWorkerJsonSchemaValidator().getValidator(result);
1231+
expect(validate({ x: { q: 'a', alias: 'b' }, name: 'n' }).valid).toBe(true);
1232+
expect(validate({ x: { q: 'a', alias: 1 }, name: 'n' }).valid).toBe(false); // resolves against the RegY base
1233+
});
1234+
11791235
test('guard-shipped documents keep draft-04 id when URI-form refs need its base', () => {
11801236
// cfworker resolves URI-form refs through `schema.$id || schema.id` base
11811237
// registration — the strict emission must keep the `id` the ref resolves
@@ -1526,12 +1582,12 @@ describe('zod conversion options (#2464)', () => {
15261582
expect(Array.isArray(standardSchemaToJsonSchema(z.date().nullable(), 'output').anyOf)).toBe(true);
15271583
});
15281584

1529-
test('the allOf-push stamps the sound type when user conjuncts defeat the proof', () => {
1585+
test('user conjuncts defeating the post-loosen proof still get the root stamp (strict snapshot)', () => {
15301586
// A .meta() carrying BOTH a non-all-object anyOf AND a non-object-provable
15311587
// allOf conjunct defeats every() on every composition key after the push —
1532-
// the pushed members still prove the value is an object, so the rewrite
1533-
// stamps the explicit type itself (pre-#2464 these roots were stamped via
1534-
// their emitted oneOf).
1588+
// but the epilogue's strictRootProven snapshot read the STRICT emission,
1589+
// whose first present key was the DU's all-object oneOf, so the root is
1590+
// stamped exactly as pre-#2464 (the push branch itself stamps nothing).
15351591
const du = z
15361592
.discriminatedUnion('t', [
15371593
z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }),

0 commit comments

Comments
 (0)