Skip to content

Port fix of nullable allof ref type check to the 2.x branch - #1280

Merged
stevehu merged 18 commits into
networknt:2.xfrom
wiremock-inc:fix/nullable-allof-ref-type-check-2.x
Sep 14, 2026
Merged

stevehu merged 18 commits into
networknt:2.xfrom
wiremock-inc:fix/nullable-allof-ref-type-check-2.x

Conversation

@tomakehurst

Copy link
Copy Markdown
Contributor

No description provided.

tomakehurst and others added 3 commits August 27, 2026 12:48
A property declared nullable: true and composed via allOf containing
only a $ref (e.g. OpenAPI 3.0 style composition) incorrectly rejected
null values. The schema owning the failing type keyword is resolved
through $ref and is a cached object whose lexical parent reflects
where it is declared in the document, not where it was referenced
from, so the existing one-hop parent/grandparent nullable check never
found the nullable declaration on the referencing schema.

Walk the dynamic evaluation stack instead, following through any
chain of $ref schemas, to find the referencing schema and check it
(and its lexical parent) for nullable: true.

Ported from the equivalent fix on master.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per PR review feedback (networknt#1279), the
dynamic evaluation stack walk added to support nullable allOf + $ref
compositions incorrectly honored nullable: true declared as a direct
sibling of $ref. That's a Reference Object, and the OpenAPI 3.0
specification requires siblings of $ref to be ignored, so it must
never grant nullability on its own.

Track whether the current frame in the walk was reached via $ref and,
if so, skip checking that frame's own nullable — only its lexical
parent (the schema actually composing it, e.g. the allOf owner) is
consulted. The allOf + $ref case that motivated the original fix
still passes.

Ported from the equivalent fix on master.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirms, per maintainer feedback on the upstream issue, that the
dynamic evaluation stack walk added for nullable allOf + $ref
compositions has no effect outside dialects that enable the nullable
keyword (currently only OpenAPI 3.0.x) — null is still rejected for
the same schema shape under draft-07.

Ported from the equivalent fix on master.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@stevehu

stevehu commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

No ReportFindings tool is available in this environment (I searched the deferred tool list — only GitNexus/Monitor/WebFetch etc. are exposed), so the findings are stated in full here.

What I did

  • Resolved the target: networknt/json-schema-validator PR Port fix of nullable allof ref type check to the 2.x branch #1280, fix/nullable-allof-ref-type-check-2.x → base 2.x (a port of the master fix 8716b5b / PR Fix nullable allOf ref type check #1279). Diff: JsonNodeTypes.java (+72) plus two test files.
  • Built worktrees of both the PR head and origin/2.x, compiled both, and ran targeted differential probes plus the full test suite to confirm behaviour changes empirically.
  • Full suite on the PR head: Tests run: 8424, Failures: 0, Errors: 74 — all 74 errors are GraalJS/ECMAScript regex NoClassDefFoundError from the local JDK 25 environment, unrelated to the diff. No existing test guards the regression below.

Findings

  1. src/main/java/com/networknt/schema/utils/JsonNodeTypes.java:107 — HIGH. nullable: true on a container schema now leaks into every $ref-ed child (properties / items / additionalProperties), accepting null where 2.x rejects it.

After findReferencingSchema hops to the $ref holder, line 107 unconditionally checks that holder's lexical parent for nullable. The javadoc assumes that parent is "the schema actually composing it (e.g. the allOf owner)", but the holder's lexical parent is identical in shape for allOf: [{$ref}] and for properties: {x: {$ref}} / items: {$ref} / additionalProperties: {$ref} — the code cannot tell them apart, so the parent's nullable is applied to the referenced value instead of to the container. Verified differential behaviour (OpenAPI 3.0 dialect, base 2.x vs PR head):

  • {"order": {"nullable": true, "type":"object", "required":["customer"], "properties": {"customer": {"$ref": "#/components/schemas/Customer"}}}} with input {"order":{"customer":null}} → base: /order/customer: null found, object expected; PR: 0 errors. customer is not nullable.
  • {"list": {"nullable": true, "type":"array", "items": {"$ref": ".../Money"}}} with {"list":[null]} → base: error; PR: 0 errors. This nullable array-of-$ref shape is extremely common in generated OpenAPI specs.
  • Same for additionalProperties: {$ref} ({"m":{"k":null}}), and it also propagates through multi-hop $ref chains.

Net effect: null values silently validate against non-nullable referenced components. (For fairness: the inline equivalent — items: {"type":"object"} under a nullable array — already leaked on 2.x via the old grandparent check, so this is a widening of a pre-existing quirk rather than a brand-new class; but for $ref children it is a real regression against the base branch, and $ref is the dominant OpenAPI style.) Fix direction: ExecutionContext.getEvaluationSchemaPath() is maintained in lockstep with evaluationSchema (see AbstractKeywordValidator:133-153), so the walk can require that the keyword linking parent→holder is a composition keyword (allOf/anyOf/oneOf/not) before honouring the parent's nullable.

  1. src/main/java/com/networknt/schema/utils/JsonNodeTypes.java:110 — LOW. The walk stops at the first frame without $ref, so the fix misses the very pattern it targets one level deeper.

findReferencingSchema returns null unless the immediately preceding frame's node has $ref, so the walk cannot cross an intermediate composition frame. Both of these still fail on the PR head (same as base):

  • value: {nullable: true, allOf: [{$ref: Money}]} where Money: {"allOf":[{"type":"object"}]} (a component that itself extends via allOf) → {"value":null} still reports null found, object expected.
  • value: {nullable: true, allOf: [{allOf: [{$ref: Money}]}]} → same.

Not a regression, but the heuristic is arbitrary in the "too narrow" direction while being over-broad in the direction of finding 1; a keyword-aware walk would address both.

  1. src/test/java/com/networknt/schema/oas/OpenApi30Test.java:115 — LOW. The javadoc on the new nullableAllOfRef test describes the pre-fix bug in the present tense, contradicting what the test now asserts.

It states equalsToSchemaType "only consults the schema that owns the failing type keyword and that schema's own parent" and that "the null-friendly check never fires and validation incorrectly fails" — that is exactly the code the same PR deletes. A reader hitting this test later will conclude the implementation still has the one-hop limitation. It should describe the expected behaviour, not the old defect.

Line-summary

  • src/main/java/com/networknt/schema/utils/JsonNodeTypes.java:107 — HIGH: unconditional parent-nullable check after the $ref hop makes nullable: true on an object/array propagate to $ref-ed properties, items and additionalProperties; {"order":{"customer":null}} and {"list":[null]} now validate where 2.x rejected them.
  • src/main/java/com/networknt/schema/utils/JsonNodeTypes.java:110 — LOW: walk halts at the first non-$ref frame, so nullable + allOf: [{$ref}] still fails when the referenced component itself uses allOf, or when the $ref sits inside a nested allOf.
  • src/test/java/com/networknt/schema/oas/OpenApi30Test.java:115 — LOW: test javadoc documents the now-removed buggy behaviour as current, contradicting the assertion in the test body.

tomakehurst and others added 3 commits September 2, 2026 09:26
…/negatives

Per PR review feedback (networknt#1280), the
nullable walk had two bugs: it trusted a schema's lexical parent
unconditionally, so nullable: true on a container leaked into any
$ref-ed property/item/additionalProperties it held (a false accept);
and it only crossed a single $ref hop, so nullable + allOf + $ref
still failed to validate null when the referenced component itself
used allOf internally (a false reject).

Both stem from the same cause: getParentSchema() is set the same way
for a composing keyword (allOf/oneOf/anyOf/not, which describes the
same value) and a containing keyword (properties/items/
additionalProperties, which describes a distinct nested value), so
the walk couldn't tell them apart. Determine this structurally from
each schema's own schemaLocation instead, and only trust/continue
through a parent when the connecting keyword is a composing one,
falling back to the existing $ref hop otherwise. The walk is no
longer capped at one hop in either direction.

Add regression tests for the leaked-nullable case and for two levels
of both composing depth and reference depth, confirming the fix
generalizes rather than just patching the reported repro.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
isComposingKeyword read the wrong path segment for keywords whose
sub-schema is not array-indexed (additionalProperties, not, and
OpenAPI 3.0's single-schema items): their location ends directly in
the keyword name, unlike allOf/oneOf/anyOf array items which append
an index after it. This meant the check was actually reading the name
of whatever contained the keyword, not the keyword itself.

In the ordinary case this went unnoticed since that misread segment
rarely collides with a composing-keyword name, but a property or
schema literally named "allOf"/"oneOf"/"anyOf" containing an
additionalProperties/items $ref caused nullable to incorrectly leak
into it. Distinguish the two shapes by checking whether the schema's
own last path element is an array index (composing) or already the
keyword name itself (single-schema).

Also drop "not" from the composing-keyword set: not: {$ref: X} means
the value must NOT match X, not that X also describes it, so there's
no basis for propagating nullable through it.

Add a dedicated test per composing keyword (allOf/oneOf/anyOf/not)
and per containing keyword (properties/items/additionalProperties),
plus regression tests for the naming-collision leak.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tail

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tomakehurst

Copy link
Copy Markdown
Contributor Author

This now properly covers both the cases your review has highlighted plus a few extra. I'll await the next review before opening another PR to port the additional fixes back to main.

@stevehu

stevehu commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Reviewed networknt/json-schema-validator PR #1280 (fetched refs/pull/1280/head, base 2.x-line at ae64ed6; real change scope is cb258af~1...0a6feab, touching JsonNodeTypes.java plus two test classes). I built and ran the branch, plus the same probes against the pre-PR baseline, to confirm each finding empirically. Full existing suite: Tests run: 8434, Failures: 0 (74 errors are pre-existing GraalJS NoClassDefFound env issues, identical on baseline).

Note: no ReportFindings tool is exposed in this environment (ToolSearch returns no match), so the findings are stated below.

Findings (3)

  • /home/steve/workspace/json-schema-validator/src/main/java/com/networknt/schema/utils/JsonNodeTypes.java:130 — high. isComposingKeyword's non-index branch (lastElement instanceof Number ? fragment.getParent() : fragment) can never legitimately fire: allOf/oneOf/anyOf are all array-valued, so a real composing branch always ends in an index. The branch therefore only matches a property or definition literally named allOf/oneOf/anyOf, and treats it as a composing site. Confirmed regression: schema {"type":"object","properties":{"outer":{"nullable":true,"type":"object","properties":{"allOf":{"$ref":"#/components/schemas/Money"}}}}} with instance {"outer":{"allOf":null}} returns 0 errors on this branch but correctly returns /outer/allOf: null found, object expected on the pre-PR baseline. The same schema without the $ref (inline {"type":"string"}) also wrongly passes. The PR's own nullableAdditionalPropertiesDoNotNotLeakedWhenPropertyNamedAllOf / nullableItemsNotLeakedWhenPropertyNamedAllOf tests only pass because they insert an extra items/additionalProperties level between the collision and the type owner, so they give false confidence that the collision is handled. Fix: drop the non-index branch (require lastElement instanceof Number) now that not is excluded.
  • /home/steve/workspace/json-schema-validator/src/main/java/com/networknt/schema/utils/JsonNodeTypes.java:128 — medium. isComposingKeyword derives the relationship from the static schema.getSchemaLocation(), but Schema's constructor rewrites schemaLocation via resolve(...) whenever the subschema declares the dialect's id keyword — which is plain id for the OpenAPI 3.0 dialect (OpenApi30.ID_KEYWORD). When a composing branch carries an id, its fragment no longer ends in allOf/, the check returns false, and nullable on the composing parent is silently dropped. Confirmed regression: {"type":"object","properties":{"value":{"allOf":[{"id":"http://example.com/inner","type":"string"}],"nullable":true}}} with {"value":null} yields /value: null found, string expected on this branch but 0 errors on the pre-PR baseline. Using the dynamic executionContext.getEvaluationSchemaPath() / evaluation path (as the neighbouring isEnumObjectSchema already does) avoids the id-rewrite hazard; the same static-vs-dynamic mismatch also means a $ref resolved by Schema.getSubSchema (which sets the resolved schema's parentSchema to the document root, not its lexical container, while keeping the full fragment) can read nullable off the wrong schema.
  • /home/steve/workspace/json-schema-validator/src/main/java/com/networknt/schema/utils/JsonNodeTypes.java:29 — medium. Adding oneOf to COMPOSING_KEYWORDS makes every branch's type keyword accept null, so a nullable oneOf with more than one branch now fails with a confusing "more than one match" instead of validating. Confirmed: {"properties":{"value":{"oneOf":[{"$ref":".../A"},{"$ref":".../B"}],"nullable":true}}} with {"value":null} reports /value: must be valid to one and only one schema, but 2 are valid with indexes '0, 1' — i.e. null is still rejected for the common two-branch nullable-oneOf shape. The added nullableOneOfRef test uses a single branch and so masks this; either add a multi-branch oneOf test and handle null above the branch loop (short-circuit in OneOfValidator when the composing schema is nullable), or document that only single-branch oneOf is supported.

Not flagged (checked and found sound): the $ref hop via findReferencingSchema picks the most recent stack occurrence, which is correct under recursion (Node→child→Node probes returned the right answers and did not loop); the deliberate exclusion of not is genuinely exercised by nullableNotDoesNotPropagateThroughNot; getElement(-1) on a root fragment returns null rather than throwing, so the null guard at line 131 is safe.

@tomakehurst

Copy link
Copy Markdown
Contributor Author

Hi @stevehu please could you share the configuration of the review agent/skill you're using?

I feel like this would be a lot easier if I could run it myself after making changes.

tomakehurst and others added 2 commits September 9, 2026 08:32
…tion

The static schema-location fragment could be rewritten by a dialect's
id keyword, silently dropping a legitimately nullable allOf/oneOf/anyOf
branch, and could also be mistaken for a composing keyword when a
property happens to be literally named allOf/oneOf/anyOf. Checking
whether the schema node is actually one of the parent's composing
branches avoids both problems.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each branch's type check independently treats null as a match when the
oneOf is nullable, which only yields a valid oneOf result when there's
exactly one branch — with two or more, every branch matches and oneOf
reports "more than one match". Handling nullable directly in the
validator avoids relying on that per-branch mechanism.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@stevehu

stevehu commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

@tomakehurst The above review is just built in from Claude CLI with /review command. As this library is just a standalone component. For my other projects, I am using light-agent from light-fabric to drive codex and claude to mutually review each others work. The light-workflow is orchestrate all the activities.

https://github.com/networknt/light-fabric

https://www.networknt.com/light-fabric/

tomakehurst and others added 2 commits September 9, 2026 16:47
…diate parent

The short-circuit only checked the oneOf schema's own node for "nullable",
so a oneOf wrapped in allOf/anyOf/$ref with nullable declared further up
was invisible to it, causing null to incorrectly fail with "multiple
schemas matched" even though each branch's own type check already
treats null as valid via the ancestor walk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Returning before the branch loop for a null instance under a nullable
oneOf meant branch schema.validate()/walk() never ran, silently
dropping annotations and WalkListener invocations for that instance
location even though the pass/fail verdict was unchanged. Branches now
always run; only the "exactly one branch matched" check is skipped for
the nullable-null case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@stevehu

stevehu commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Code review(medium · 3 findings)
src/main/java/com/networknt/schema/utils/JsonNodeTypes.java
● 94 [behavior-regression] Gating the parent hop on isComposingKeyword silently removes the long-standing propagation of nullable from a schema to its properties/items/additionalProperties subschemas, a behavior change on the 2.x maintenance branch that also diverges from the main-branch fix (#1279) this PR claims to port.
src/main/java/com/networknt/schema/keyword/OneOfValidator.java
● 201 [correctness] !nullableNode suppresses the oneOf assertion for any null under a nullable ancestor, including when no branch matched, so branch constraints that legitimately reject null (enum, const, not) are silently dropped — and the same schema under anyOf still rejects.
● 69 [test-coverage] The new comment states "Branches are still evaluated normally so their annotations and walk listeners still fire", but the existing canShortCircuit break after two valid branches still applies, so with three or more branches the remaining branches are never walked; the added test uses exactly two branches and cannot catch this.

Ran 1 shell command

I reviewed PR #1280 (fix/nullable-allof-ref-type-check-2.x → 2.x) by building both the PR head and the 2.x base (ae64ed6) and diffing actual validation behavior. The full existing suite passes on the PR head (the only 74 errors are GraalJS/regex tests failing on Java 25, unrelated).

Findings

  • src/main/java/com/networknt/schema/utils/JsonNodeTypes.java:94 — Gating the parent hop on isComposingKeyword drops the old nullable propagation into properties/items/additionalProperties: {"outer":{"nullable":true,"type":"object","properties":{"name":{"type":"string"}}}} + {"outer":{"name":null}} is valid on 2.0.7 and now fails; main's merged Fix nullable allOf ref type check #1279 keeps that check, so 2.x ends up stricter than the branch this "port" comes from.
  • src/main/java/com/networknt/schema/keyword/OneOfValidator.java:201 — !nullableNode suppresses the oneOf error even when zero branches matched: {"nullable":true,"oneOf":[{"enum":["a"]},{"enum":["b"]}]} + null now yields 0 errors, while the same schema with anyOf still reports both enum errors. Narrowing to !(nullableNode && numberOfValidSchema > 1) keeps the intended fix.
  • src/main/java/com/networknt/schema/keyword/OneOfValidator.java:69 — The comment promises branches still walk, but the pre-existing short-circuit after two valid branches still applies; with a third branch the walk listener fires twice, not three times. The new test uses exactly two branches, so it can't catch this.

Verified as non-issues along the way: no NPE path into OneOfValidator.validate with a Java-null node (the shouldValidateSchema && node != null guard matches AllOfValidator), no runaway loop or measurable cost in isNullableAncestor under deep/mutual $ref recursion (200 levels, ~2 ms), nullable does not leak into a $ref'd branch reused from a non-nullable site, and the id-rewrite test is meaningful since the OAS 3.0 dialect really does set idKeyword("id").

tomakehurst and others added 5 commits September 10, 2026 14:07
…cture

The walk climbed parent pointers, identified composing branches by
JsonNode reference identity, and inferred $ref hops by testing whether
the previous frame's node had a $ref member. It could revisit a frame it
had already left, so a recursive schema made it spin forever, and the
$ref test misread a properties hop out of a schema that also carried a
$ref, leaking nullable into a nested value.

The engine already records both the schemas entered and the keyword each
was entered by. Reading that instead makes the walk a single descent of
a finite stack, so it cannot loop, and gives the hop keyword exactly
rather than by inference. This also picks up if/then/else, $dynamicRef
and $recursiveRef, and re-checks the dialect at each hop so a nullable
member in a resource whose dialect lacks the keyword is not honoured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The enum keyword decided whether null was permitted when the validator
was constructed, by reading nullable off its immediate parent. That
missed nullable declared further up, so a nullable allOf referencing an
enum component still rejected null even though type accepted it. Enum
components behind allOf and $ref are the common OpenAPI shape, so the
keyword disagreed with type on the schemas most likely to use it.

Whether null is permitted depends on the ancestors the value is reached
through, which is only known once validation is under way, so the check
moves there. The constructor keeps reporting null in the message when
the owning schema declares it, leaving existing messages unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suppressing the whole error block meant that if no branch matched, the
branch errors explaining why were discarded and the value passed
silently. Only the "exactly one branch matched" requirement conflicts
with nullable, so only that is relaxed now, and a null that matches
nothing reports its errors as before.

Stopping the loop once two branches match also cannot apply here. That
short-circuit assumes a second match settles the outcome, which is no
longer true once the count is allowed to exceed one, and it left the
remaining branches without their annotations and walk listeners.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replacing the two-level ancestor check with a walk that stops at
keywords describing a nested value also stopped nullable on a container
from reaching its inline properties, items and additionalProperties
children. That is the intended reading, but nothing pinned it: the
existing tests all reach their children through $ref and pass equally
well against the previous behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The nullable tests repeated the same components block nine times and the
same registry setup twenty-four times, and the allOf, oneOf and anyOf
cases differed only in that one word. Sharing the block and the setup,
and driving the three composing keywords from one parameterised test,
leaves each test showing only what makes it distinct.

Also corrects a doubled negative in a method name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tomakehurst

Copy link
Copy Markdown
Contributor Author

Separately from the review feedback, I did a deeper pass over this branch and found a cluster of problems in the ancestor-walk implementation that hadn't been raised. Rather than patch them one at a time, I rewrote the walk on top of information the engine already tracks.

The change

isNullableAncestor reconstructed how a schema had been reached by climbing getParentSchema() pointers, identifying composing branches by JsonNode reference identity (branch == schemaNode), and inferring $ref hops by testing whether the previous stack frame's node happened to carry a $ref member.

None of that is necessary. ExecutionContext already maintains evaluationSchema and evaluationSchemaPath in lockstep — the schemas entered, and the keyword each one was entered by (pushed at Schema.java:623, and already consumed this way by AbstractKeywordValidator.hasAdjacentKeywordInEvaluationPath). Reading that makes the walk a single descent of a finite stack, with the hop keyword known exactly rather than guessed.

Net −101 lines in src/main: three heuristics collapse into one linear pass.

Bugs this fixes

Infinite loop on recursive schemas. findReferencingSchema resolved a schema to its topmost occurrence on the evaluation stack, so when a schema appeared at two depths the walk could jump back up and oscillate. There was no visited set, depth cap or cycle guard.

{"$ref": "#/$defs/X",
 "$defs": {"T": {"title": "t"},
           "X": {"$ref": "#/$defs/T", "type": "object",
                 "properties": {"c": {"$ref": "#/$defs/X"}}}}}

against {"c": {"c": null}}, on Draft 2020-12 plus a nullable NonValidationKeyword: base ae64ed6 returns one type error in under a millisecond, this branch before the rewrite never returned at all — 100% CPU, no exception, no stack overflow. A second allOf-shaped reproducer behaved the same way. Both are now in NullableAncestorTest and fail by timeout against the pre-rewrite code.

Worth noting this needs a specific shape — a $ref target that also carries a $ref sibling plus a recursive property. A plain deep $ref chain doesn't trigger it, which is presumably why it went unnoticed.

enum was never rewired. The fix only ever applied to type. EnumValidator computed its accepted-value set in the constructor from parentSchema's own node, so the headline case still failed whenever the referenced component was an enum:

{"properties": {"value": {"allOf": [{"$ref": "#/components/schemas/E"}], "nullable": true}},
 "components": {"schemas": {"E": {"type": "string", "enum": ["a", "b"]}}}}

{"value": null} reported enum @ /value, while the same schema with the enum inlined beside nullable passed. Enum components behind allOf/$ref are the common OpenAPI shape, and doc/1.x/config.md states nullable affects both enum and type. The check now happens during validation, where the ancestor chain is known; existing error messages are unchanged.

This also turned out to fix the composing-keyword inconsistency raised in review at a deeper level than the suggested narrowing: because branches now accept null themselves, oneOf, anyOf and allOf agree on identical nullable schemas rather than returning 0/2/1 errors.

Keyword coverage gaps. if/then/else satisfy the "describes the same value" definition but were omitted, so {"nullable": true, "if": …, "then": …, "else": …} spuriously rejected null. $dynamicRef and $recursiveRef truncated the walk because only a literal $ref member was recognised. Both now have tests that fail against the pre-rewrite code.

$ref into a composing branch. A $ref such as #/allOf/0 resolves to a schema whose parent is the document root, whose allOf array contains that very node — so the root's nullable was applied at an unrelated instance location. Using the recorded hop keyword closes this; also covered by a test that fails against the old code.

Dialect boundaries. isNullableKeywordEnabled() was consulted once, against the starting schema. With an unbounded walk that let a stray "nullable": true in a resource whose dialect has no such keyword suppress type assertions — plausible in hand-converted OAS 3.0 → 3.1 bundles. It is now re-checked at every hop.

Correctness of the not exclusion. The exclusion was documented but unenforceable: the old check only matched array-valued keywords, and not takes a single schema, so it could never have matched either way. It is now a real keyword check.

Also in this branch

  • findReferencingSchema rescanned the whole deque per hop, roughly O(depth²) per null value; now a single pass. Three lines of dead code removed alongside it.
  • isNullableAncestor now takes only the ExecutionContext, so its dependence on live evaluation state is visible in the signature rather than an undocumented precondition on a public method in an exported package.
  • OpenApi30Test repeated the same components block nine times and the same registry setup twenty-four times; those are shared now, and allOf/oneOf/anyOf are driven from one @ParameterizedTest.

Verification

Full suite at each commit: 8453 tests, 0 failures. The 74 errors are the pre-existing GraalJS/regex failures caused by the JDK blocking GraalVM's native access (JDK 26 locally), unrelated to this change and equally present on the base commit.

The behavioural claims above were checked by running the same probes against base ae64ed6 and this branch side by side. Five of the seven cases in the new NullableAncestorTest fail against the pre-rewrite code — the two hangs, if/then/else, $dynamicRef, and the $ref-into-branch case.

Two items I addressed by construction but did not independently reproduce first, so flagging them as such: the $ref-member test misreading a sibling properties hop, and validation outcomes varying with JsonNode instance sharing. Both are eliminated by using the recorded hop keyword rather than inferring provenance, but I can't claim to have demonstrated either as a live bug.

@stevehu

stevehu commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Re-reviewed the 5 new commits (384b23b…b540a79) against a fresh build. Full suite green: 8396 tests, 0 failures, 0 errors (Graal/regex classes excluded — they fail on Java 25 for unrelated reasons).

All three earlier findings are resolved, and the fixes go further than patching symptoms:

  • The ancestor walk was rewritten to descend the evaluationSchema / evaluationSchemaPath stacks instead of the lexical schema structure. The alignment it depends on holds — both stacks are pushed only in Schema.validate/walk, strictly nested, so sizes are equal whenever a keyword validator is executing, and the root schema correctly falls off the end. This also removes the identity-comparison heuristic and fixes the $ref-into-a-composing-branch false positive I'd noted as pre-existing (now covered by refPointingIntoComposingBranchDoesNotInheritNullable).
  • oneOf now relaxes only the cardinality check (nullableNode && numberOfValidSchema > 1), and the short-circuit is disabled for a nullable null so all branches walk — the extended 3-branch test now genuinely fails against the old code. The companion EnumValidator change makes oneOf/anyOf/allOf agree on null under a nullable ancestor, which is what made the narrowing safe.
  • The properties/items/additionalProperties behavior change is now documented in CHANGELOG with a migration note, and covered by tests.

Two remaining items:

  • src/test/java/com/networknt/schema/OneOfValidatorTest.java:536 — nullableOneOfStillReportsErrorsWhenNoBranchMatches passes on the sibling not error; the single branch actually matches, so the fix it guards can be reverted with the test still green. {"oneOf":[{"not":{"type":"null"}}],"nullable":true} exercises the real path.
  • src/main/java/com/networknt/schema/utils/JsonNodeTypes.java:135 — viaReference drops a nullable sibling of $ref in every dialect, but siblings are only ignorable pre-2019-09. On the 2020-12+nullable dialect the new test file introduces, one nullable: true is honoured for the holder's own type and ignored for the target's.

Neither blocks the OpenAPI 3.0 behavior the PR targets; the first is the one I'd fix before merge, since it's the regression guard for the change made in 392b127.

tomakehurst and others added 2 commits September 11, 2026 15:52
The schema's single branch matched the null, so oneOf reported nothing
and the assertion was satisfied entirely by the sibling not keyword.
Reverting the cardinality narrowing left the test green, so it guarded
nothing. The branch now rejects null on its own terms, and the
assertion looks for the oneOf error specifically rather than any error
at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Members declared alongside a reference are only dropped before 2019-09,
but the walk skipped the referencing schema's nullable in every dialect.
On a 2020-12 dialect with nullable enabled that made one declaration
mean two things: honoured for the holder's own type check, ignored for
the referenced schema's.

The skip is now gated on the same specification version the engine uses
when it assembles a schema's validators. OpenAPI 3.0 is draft-04 based
and unaffected; it is the only shipped dialect registering nullable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@stevehu

stevehu commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Re-reviewed at d2741ab (2 new commits since the last pass: 131f4be, d2741ab). Built the head and base ae64ed6 side by side, ran differential probes, the full suite, and mutation-tested each new guard.

Full suite: 8454 tests, 0 failures. The 74 errors are the pre-existing GraalJS/NoClassDefFound failures from the local JDK, identical on base.

Both prior items are fixed — and the tests genuinely guard them

I mutation-tested rather than trusting the green suite, since "the test guards nothing" was the substance of the first item:

Reverted change Test that fails
nullMatchedMoreThanOneBranch && … → !nullableNode OneOfValidatorTest.nullableOneOfStillReportsErrorsWhenNoBranchMatches:547
!(viaReference && ignoresReferenceSiblings(schema)) → !viaReference NullableAncestorTest.nullableBesideRefIsHonouredWhenDialectKeepsSiblings
numberOfValidSchema > 1 && !nullableNode → numberOfValidSchema > 1 OneOfValidatorTest.nullableMultiBranchStillWalksBranches

ignoresReferenceSiblings mirrors Schema.java:565 exactly — same expression, same threshold — so the walk and validator assembly can't drift apart.

Two new findings, both low

1. CHANGELOG.md:14 — the oneOf cardinality relaxation isn't in the changelog. The two bullets cover the ancestor walk (type, enum) and the container change, but not the OneOfValidator behaviour that 392b127 settled, which is the largest user-visible improvement here. {"nullable":true,"oneOf":[{"$ref":".../A"},{"$ref":".../B"}]} with null goes from 3 errors (oneOf + both branch types) on 2.0.7 to valid. A reader upgrading from a "more than one match" error won't find it described. The not change is in the same position ({"nullable":true,"not":{"type":"string"}} + null goes invalid → valid) but it is at least pinned by nullableNotDoesNotPropagateThroughNot.

2. src/main/java/com/networknt/schema/keyword/EnumValidator.java:79 — typeLoose + nullable + enum changes for the string "null". Dropping nodes.add(NullNode.getInstance()) also removed NullNode from the set isTypeLooseContainsInEnum scans, and that method compares n.asText(), which is "null" for a NullNode. Confirmed on the OAS 3.0 dialect with typeLoose(true):

{"properties":{"v":{"enum":["a"],"nullable":true}}}  +  {"v":"null"}
  2.0.7 → valid        d2741ab → enum error

Head is the more defensible answer — the literal string "null" should not satisfy enum: ["a"] — but it's an unguarded behaviour change on a maintenance branch under a non-default config, so it's worth either a test or a changelog line.

Checked and sound

Member order around $ref/nullable doesn't affect the result in either dialect; $dynamicRef and $recursiveRef siblings behave like $ref; a component shared between a nullable and a non-nullable site stays strict at the non-nullable one; required/minimum/pattern/minLength under a nullable allOf+$ref still skip null correctly and still fail a bad object; the recursive-schema case terminates; enum messages are byte-identical to 2.0.7; no public API was removed, so the branch stays binary compatible. Performance is unchanged — 20,000 validations of a 60-level nullable/allOf nest with a null leaf ran in 271 ms on head vs 280 ms on base.

Neither finding blocks the OpenAPI 3.0 behaviour the PR targets. I'd merge after a changelog line for the oneOf change.

…se one

Three user-visible changes were missing from the changelog: the oneOf
cardinality relaxation, which is the largest of them, nullable no
longer reaching into a not subschema, and the typeLoose enum case.

That last one was an unintended consequence of moving the null decision
out of the enum constructor. The accepted set no longer holds a
NullNode, and the typeLoose comparison matched on its text, so the
string "null" used to satisfy an enum on a nullable schema. Rejecting
it is the better answer, but it was unguarded, so it gets a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@stevehu

stevehu commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

I found no bugs in PR #1280, either in the latest commit (09deacf, which adds changelog entries and a typeLoose test) or in the nullable changes it builds on.

How I checked:

  • The new stack walk: I read the isNullableAncestor walk in JsonNodeTypes. The schema stack and keyword stack are only pushed and popped together in Schema.validate and Schema.walk, so they stay in step. The keyword names it checks (allOf, anyOf, oneOf, if, $ref, $dynamicRef, $recursiveRef) are the names those validators actually put on the stack.
  • $ref siblings: ignoring a nullable written next to $ref in pre-2019-09 dialects matches how Schema already drops those siblings.
  • Base vs. PR: I built both the 2.x base and this PR and ran the same set of OpenAPI 3.0 cases against each. Every behaviour change matches what the new changelog entries say:
    • A nullable container no longer makes its properties, items or additionalProperties children accept null.
    • A nullable oneOf now accepts null, including with fail-fast on.
    • {"nullable": true, "not": {"type": "string"}} now accepts null.
    • With typeLoose on, the string "null" is now rejected by a nullable enum, while a real null is still accepted.
    • allOf + $ref, nested anyOf/allOf, and enum reached through $ref all now accept null.
    • nullable written next to $ref is still ignored, as before.
    • walk with validation gives the same results as validate.
  • Full test suite: 8,455 tests ran with no failures. The 74 errors are all in the GraalJS regex tests, which can't load their regex engine on this machine; they have nothing to do with this PR.

stevehu pushed a commit that referenced this pull request Sep 17, 2026
Resolves the OpenAPI nullable keyword by descending the engine's own
evaluation stacks rather than reconstructing provenance from schema
structure. The previous walk climbed lexical parents and sniffed for
$ref members, which misattributed nullable across reference sites,
was sensitive to schema-node identity, and missed $dynamicRef and
$recursiveRef entirely.

The walk pairs evaluationSchema with evaluationSchemaPath to recover
the keyword each schema was entered by, propagating nullable through
allOf/oneOf/anyOf, if/then/else and the three referencing keywords,
and stopping at any keyword that moves to a different value. As a
result nullable no longer leaks into a container's properties, items
or additionalProperties children, and no longer reaches inside not.

Applies the same walk to enum, which previously decided at
construction time whether null was accepted, and relaxes only the
oneOf cardinality check for a nullable null, leaving branch errors
reported when nothing matches.

Ported from #1280. Two adaptations
were needed for this branch rather than a straight copy:

  - isNodeNullable now calls asBoolean(false). Jackson 3 throws on a
    node it cannot coerce where Jackson 2 returns false, and the walk
    reads nullable on every composing ancestor, so a malformed member
    anywhere in the chain would abort the validation.

  - The typeLoose enum change concerns the empty string here, not the
    string "null". NullNode.asString() is "" in Jackson 3, so that is
    the value the accepted set silently permitted.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants