Skip to content

Commit 53f2be5

Browse files
committed
fix(core-internal): checks-aware tolerance; plain-object fill gate; registry-shape carve-out inside id resources; serialization walk parity
- The structural tolerance walk is checks-aware: a .refine()/.check() attaches to the SAME node and re-validates the filled/accepted value (.default(0).refine(v => v >= 1) rejects a missing key), so NO acceptance-based structural claim is sound on a checks-carrying node - it defers to the validate(undefined) probe (sync verdicts correct both ways; async checks conservatively stay required, folded into the async residual-gaps bullet). Symbol/function serialization tolerance is exempt (a value that passes its checks still never reaches the wire). Four pinned async spellings updated: async stages move DOWNSTREAM of the check-free tolerant node (bare-transform pipes, still structurally dropped) and the checks-on-node spellings pin the conservative stay-required posture. - plainObjectDefaultFill mirrors zod's isPlainObject gate: Dates/Maps/class instances have zero own enumerable keys (vacuously disjoint) yet zod merges them only when equal - non-plain fills defer to the probe (correct both directions per the same-timestamp Date control). - The id gates exempt #/$defs/<name> root-base pointers INSIDE id resources again: zod itself emits them there (z.lazy self-refs, cross-registered schemas), they resolve through the document root, and keeping the id made every SDK-client callTool fail at Ajv compile while disabling the whole loosen family. Bare '#' inside an id resource stays hand-authored (the round-34 fix's actual target). - hasSerializationDroppedLeaf mirrors the main walk: ANY-member union recursion and lazy-getter following (try/catch + cycle bound), so union- and lazy-nested symbols keep their wire-drop tolerance through nonoptional. - Synced the stale hasStructuralMissingKeyTolerance/fieldAcceptsMissingKey headers (prefault probe-decided; intersections only the provably-mergeable disjoint-object-defaults shape; checks-aware claims). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a0a0252 commit 53f2be5

2 files changed

Lines changed: 207 additions & 66 deletions

File tree

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

Lines changed: 97 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,13 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12';
233233
* tolerant `.default(5).pipe(z.number().min(1).refine(async () => true))` stays
234234
* advertised as required — the probe goes async, and claiming IN-side tolerance
235235
* structurally would wrongly drop `.default(0).pipe(z.number().min(1).refine(
236-
* async …))`. Neither structural direction is sound there, so the conservative
237-
* stay-required posture (byte-parity with the pre-#2464 emission) wins.
236+
* async …))`. The same trade applies to async CHECKS attached to the
237+
* tolerance-granting node itself (`.default(0).refine(async () => true)`,
238+
* `z.any().refine(async …)`): the check re-validates the fill, so no
239+
* structural claim is sound and the async probe cannot decide. In both shapes
240+
* the conservative stay-required posture (byte-parity with the pre-#2464
241+
* emission) wins; async stages DOWNSTREAM of a check-free tolerant node
242+
* (`.default(0).transform(async …)`) keep the structural drop.
238243
* - Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still advertise the
239244
* post-transform shape (`io: 'output'`) even though the server validates and ships
240245
* the raw pre-transform value — rewriting pipe nodes to their input side per-node
@@ -538,23 +543,26 @@ const ZOD_REGISTRY_REF_PATTERN = /^#\/\$defs\/[^/]+$/;
538543
/**
539544
* Whether any reference keyword in the document carries a hand-authored value.
540545
* Hand-authored-ness is decided by value SHAPE and lexical CONTEXT together:
541-
* outside `id` resources, anything but zod's own registry shapes (`#`,
542-
* `#/$defs/<name>` — root-base JSON Pointers that never resolve through a
543-
* draft-04 `id` base) is hand-authored; INSIDE a draft-04 `id`-carrying
544-
* resource, EVERY ref counts — resolution there is base-relative on the
545-
* cfworker engine (`schema.$id || schema.id` registration), so even a
546-
* registry-shaped `$ref: '#'` addresses the resource, a spelling zod's emitter
547-
* never produces at that position. `$recursiveRef` counts regardless of value
548-
* or position — zod never emits the keyword at all.
546+
* `#/$defs/<name>` root-base JSON Pointers are always zod-exempt — zod's own
547+
* emitter places them INSIDE draft-04 `id`-carrying entries too (recursive
548+
* z.lazy self-refs, cross-registered schemas), they resolve through the
549+
* document root rather than the `id` base, and stripping the `id` around them
550+
* is strictly safe on both engines (Ajv compiles; on cfworker the kept `id`
551+
* would make them dangle base-relatively). Bare `#` is exempt only OUTSIDE
552+
* `id` resources: inside one it addresses the resource base-relatively on the
553+
* cfworker engine, and zod never emits that spelling there — it is
554+
* hand-authored and observes the strip. `$recursiveRef` counts regardless of
555+
* value or position — zod never emits the keyword at all.
549556
*/
550557
function hasHandAuthoredRefValues(document: Record<string, unknown>): boolean {
551558
return someSchemaNode(document, (record, insideIdResource) =>
552559
['$ref', '$dynamicRef', '$recursiveRef'].some(refKey => {
553560
const value = record[refKey];
554561
if (value === undefined) return false;
555562
if (refKey === '$recursiveRef') return true;
556-
if (typeof value !== 'string' || insideIdResource) return true;
557-
return value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value);
563+
if (typeof value !== 'string') return true;
564+
if (value === '#') return insideIdResource;
565+
return !ZOD_REGISTRY_REF_PATTERN.test(value);
558566
})
559567
);
560568
}
@@ -669,12 +677,18 @@ function hasHandAuthoredReferenceConstructs(document: Record<string, unknown>):
669677
// loosening as a tightening the rename walk's lexical polarity skip
670678
// cannot see through the ref indirection.
671679
if (underPolarityBoundary) return true;
672-
// And they are loosen-safe only OUTSIDE draft-04 `id` resources: the
673-
// cfworker engine resolves refs base-relatively there, and zod never
674-
// emits a bare `#` inside an id-carrying entry — such refs are
675-
// hand-authored and observe the strip/loosening.
676-
if (inIdResource) return true;
677-
if (value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value)) return true;
680+
// Bare `#` is zod-exempt only OUTSIDE draft-04 `id` resources: the
681+
// cfworker engine resolves it base-relatively there, and zod never
682+
// emits that spelling inside an id-carrying entry — it is
683+
// hand-authored and observes the strip/loosening. `#/$defs/<name>`
684+
// pointers stay exempt everywhere: zod itself emits them inside id
685+
// entries (recursive/cross-registered schemas), and they resolve
686+
// through the document root.
687+
if (value === '#') {
688+
if (inIdResource) return true;
689+
continue;
690+
}
691+
if (!ZOD_REGISTRY_REF_PATTERN.test(value)) return true;
678692
}
679693
if (record.$anchor !== undefined || record.$dynamicAnchor !== undefined || record.$id !== undefined) return true;
680694
// 2019-09 recursion keywords: zod never emits them, and the SDK's
@@ -852,22 +866,24 @@ const ENFORCED_JSON_SCHEMA_KEYWORDS: ReadonlySet<string> = new Set([
852866

853867
/**
854868
* Whether a raw payload that omits this field still passes validation (zod treats a
855-
* missing key as `undefined` — true for `.default()`/`.prefault()`, `z.any()`,
856-
* `z.unknown()`, `z.undefined()`, and unions with them), so the wire schema must not
857-
* advertise the field as `required`. A probe that throws, rejects, or goes async (a
858-
* `.transform()` choking on `undefined` does all three depending on the zod version)
859-
* cannot demonstrate tolerance, so such fields conservatively stay required.
869+
* missing key as `undefined` — true for a check-free `.default()`, `z.any()`,
870+
* `z.unknown()`, `z.undefined()`, and unions with them; `.prefault()` and any
871+
* checks-carrying node re-validate the filled value, so the probe decides those),
872+
* so the wire schema must not advertise the field as `required`. A probe that
873+
* throws, rejects, or goes async (a `.transform()` choking on `undefined` does all
874+
* three depending on the zod version) cannot demonstrate tolerance, so such fields
875+
* conservatively stay required.
860876
*/
861877
function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean {
862878
if (field === undefined) return false;
863-
// Defaulted fields accept a missing key by construction (zod fills the default
864-
// before any refinement or transform runs) — decide structurally, since an async
865-
// stage (`.refine(async ...)`, `.transform(async ...)`) would push the probe
866-
// below to a Promise and wrongly keep the field required. The walk also covers
867-
// static `.catch()`, undefined-accepting leaves (`z.any()`/`z.unknown()`),
868-
// Symbol-/function-typed fields (JSON.stringify drops such keys entirely), union
869-
// members, and defaults hidden inside a pipe (`.default(7).transform(...)`,
870-
// `z.preprocess(fn, z.number().default(7))`).
879+
// CHECK-FREE defaulted fields accept a missing key by construction (zod fills
880+
// the default; nothing re-validates it) — decide those structurally, since an
881+
// async DOWNSTREAM stage (`.transform(async ...)` after the default node)
882+
// would push the probe below to a Promise and wrongly keep the field
883+
// required. The walk also covers check-free static `.catch()` and
884+
// undefined-accepting leaves, Symbol-/function-typed fields (JSON.stringify
885+
// drops such keys entirely, checks or not), union members, and defaults
886+
// hidden inside a bare-transform pipe.
871887
if (hasStructuralMissingKeyTolerance(field)) return true;
872888
try {
873889
const result = field['~standard'].validate(undefined);
@@ -884,14 +900,17 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean {
884900

885901
/**
886902
* Whether the field's def chain carries a node that makes a missing key tolerable by
887-
* construction — `default`/`prefault` (the default fills), a static `catch` (any
888-
* input, including `undefined`, is replaced by the fallback), `optional`, an
889-
* undefined-accepting leaf (`z.any()`/`z.unknown()`/`z.undefined()`/`z.void()`, or a
890-
* literal whose values include `undefined`), or a Symbol-/function-typed leaf
891-
* (JSON.stringify drops such keys from the payload entirely) — unwinding pipe sides,
892-
* lazies, transparent wrappers, union members (ANY tolerant member suffices: zod
893-
* tries members and the tolerant one succeeds), and intersections with EVERY-side
894-
* semantics (`undefined` must parse through both sides). Deciding structurally matters
903+
* construction — a CHECK-FREE `default` (the default fills; `.prefault()`
904+
* re-validates the fill and defers to the probe), a check-free static `catch` (any
905+
* input, including `undefined`, is replaced by the fallback), `optional`, a
906+
* check-free undefined-accepting leaf (`z.any()`/`z.unknown()`/`z.undefined()`/
907+
* `z.void()`, or a literal whose values include `undefined`), or a
908+
* Symbol-/function-typed leaf (JSON.stringify drops such keys from the payload
909+
* entirely, checks or not) — unwinding bare-transform pipe sides, lazies,
910+
* transparent wrappers, and union members (ANY tolerant member suffices: zod
911+
* tries members and the tolerant one succeeds). Intersections claim tolerance
912+
* only for the provably-mergeable disjoint-key plain-object-defaults shape —
913+
* everything else defers to the probe. Deciding structurally matters
895914
* because an async stage (`.refine(async ...)`, `.transform(async ...)`) pushes the
896915
* validate-probe to a Promise. All checks err loosen-only: a false positive merely
897916
* drops a field from the advertised `required`, which can never make a validating
@@ -913,11 +932,24 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet
913932
left?: unknown;
914933
right?: unknown;
915934
values?: unknown;
935+
checks?: unknown;
916936
};
917937
};
918938
}
919939
)._zod?.def;
920940
if (def === undefined || typeof def.type !== 'string') return false;
941+
// Serialization-based tolerance first, checks or not: a symbol/function value
942+
// that PASSES its checks still never reaches the wire (JSON.stringify drops
943+
// the key).
944+
if (def.type === 'symbol' || def.type === 'function') return true;
945+
// A `.refine()`/`.check()` attaches to the SAME node (`def.checks`) and
946+
// re-validates the filled/accepted/passed-through value —
947+
// `.default(0).refine(v => v >= 1)` rejects a missing key — so NO
948+
// acceptance-based structural claim below is sound on a checks-carrying node:
949+
// defer to the validate(undefined) probe (sync verdicts are correct both
950+
// ways; async checks conservatively keep the field required, the documented
951+
// posture — see the async bullets in the Known residual gaps list).
952+
if (Array.isArray(def.checks) && def.checks.length > 0) return false;
921953
// `catch` and `optional` must be recognized BEFORE the wrapper unwind below
922954
// would step past the very node granting tolerance (bare `.optional()` fields
923955
// are already excluded from `required` by zod's emitter, but one inside a pipe
@@ -932,7 +964,6 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet
932964
return false;
933965
}
934966
if (def.type === 'any' || def.type === 'unknown' || def.type === 'undefined' || def.type === 'void') return true;
935-
if (def.type === 'symbol' || def.type === 'function') return true;
936967
if (def.type === 'literal' && Array.isArray(def.values) && def.values.includes(undefined)) return true;
937968
const path = new Set(ancestors);
938969
path.add(field);
@@ -1013,12 +1044,27 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet
10131044
* Used where VALIDATION-based tolerance must not propagate but
10141045
* SERIALIZATION-based tolerance still applies (the `nonoptional` re-forbid).
10151046
*/
1016-
function hasSerializationDroppedLeaf(field: unknown): boolean {
1017-
if (typeof field !== 'object' || field === null) return false;
1018-
const def = (field as { _zod?: { def?: { type?: string; innerType?: unknown } } })._zod?.def;
1047+
function hasSerializationDroppedLeaf(field: unknown, ancestors: ReadonlySet<unknown> = new Set()): boolean {
1048+
if (typeof field !== 'object' || field === null || ancestors.has(field)) return false;
1049+
const def = (field as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; options?: unknown } } })._zod?.def;
10191050
if (def === undefined || typeof def.type !== 'string') return false;
10201051
if (def.type === 'symbol' || def.type === 'function') return true;
1021-
if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) return hasSerializationDroppedLeaf(def.innerType);
1052+
const path = new Set(ancestors);
1053+
path.add(field);
1054+
// Mirror the main walk's coverage: ANY-member union semantics (whenever the
1055+
// symbol member is the matched one, the key vanishes from the wire) and
1056+
// lazy-getter following with the same cycle bound.
1057+
if (def.type === 'union' && Array.isArray(def.options)) {
1058+
return def.options.some(option => hasSerializationDroppedLeaf(option, path));
1059+
}
1060+
if (def.type === 'lazy' && typeof def.getter === 'function') {
1061+
try {
1062+
return hasSerializationDroppedLeaf((def.getter as () => unknown)(), path);
1063+
} catch {
1064+
return false;
1065+
}
1066+
}
1067+
if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) return hasSerializationDroppedLeaf(def.innerType, path);
10221068
return false;
10231069
}
10241070

@@ -1043,6 +1089,13 @@ function plainObjectDefaultFill(side: unknown): Record<string, unknown> | undefi
10431089
if (def?.type !== 'default') return undefined;
10441090
const fill = def.defaultValue;
10451091
if (typeof fill !== 'object' || fill === null || Array.isArray(fill)) return undefined;
1092+
// Mirror zod's own isPlainObject gate in mergeValues: Dates, Maps, and class
1093+
// instances have zero own enumerable keys (vacuously "disjoint") yet zod
1094+
// throws 'Unmergable intersection' on them unless the values are equal —
1095+
// undecidable structurally, so the probe decides (correct both directions:
1096+
// distinct-timestamp Date fills stay required, same-timestamp ones drop).
1097+
const proto: unknown = Object.getPrototypeOf(fill);
1098+
if (proto !== Object.prototype && proto !== null) return undefined;
10461099
return fill as Record<string, unknown>;
10471100
}
10481101

0 commit comments

Comments
 (0)