@@ -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
522530const ZOD_REGISTRY_REF_PATTERN = / ^ # \/ \$ d e f s \/ [ ^ / ] + $ / ;
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 */
547562function 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 ;
0 commit comments