Skip to content

validate.mjs: boundSchema depth limit corrupts schemas with three or more allOf levels #134

Description

@simontaurus

The boundSchema depth guard in scripts/validate.mjs replaces any node deeper than maxDepth = 6 with {}. In a three-level inheritance chain that truncation lands inside a keyword whose value has a required type, so ajv rejects a schema that is actually valid.

Reproduction

Three schemas, each extending the previous one, with examples on a property of the base:

// A.schema.json
{ "$id": "A.schema.json", "type": "object",
  "properties": { "v": { "type": "number", "examples": [1] } } }

// B.schema.json
{ "$id": "B.schema.json", "allOf": [{ "$ref": "A.schema.json" }], "properties": {} }

// C.schema.json
{ "$id": "C.schema.json", "allOf": [{ "$ref": "B.schema.json" }], "properties": {} }
node scripts/validate.mjs <dir>
GEN-ERROR  C.schema.json: schema is invalid: data/allOf/0/allOf/0/properties/v/examples must be array
RT-ERROR   C.schema.json: schema is invalid: data/allOf/0/allOf/0/properties/v/examples must be array
13/15 checks passed

Cause

After dereferencing, the path to the base annotation is
allOf(1) [0](2) allOf(3) [0](4) properties(5) v(6) examples(7).

At depth 7 walk() returns {}, so examples: [1] becomes examples: {}, and ajv fails it as must be array. Two levels of inheritance stay under the limit, three do not, which is why this has not shown up on the examples in this repository.

Why it matters

It is not the annotation that is at fault. Any keyword requiring a non-object value (examples, enum, required, x-enum-varnames) becomes {} at that depth, so the failure mode is a valid schema reported as invalid, and the message points at the schema rather than at the depth guard. Reference-schema libraries reach three levels quickly: a base value type, a per-quantity restriction, and a specialisation of that.

Possible fixes

  • Count depth in $ref/allOf hops rather than raw object nesting, since the guard exists for reference cycles, not for deep-but-finite documents.
  • Or raise maxDepth and rely on the existing cycle detection (path.has(node)), which already terminates cycles on its own.
  • Or bound only where a cycle is actually possible, leaving acyclic subtrees intact.

A workaround for schema authors is to move examples to the schema root, where it is a whole example instance and sits at depth 1, but that only avoids the symptom for one keyword.

Found while building three-level inheritance (QuantityValue -> Length -> Diameter) in the reference schemas.

Activity

  1. simontaurus commented on Aug 23, 2026

    @simontaurus
    ContributorAuthor

    Could not reproduce on current main with the three schemas as written: the run reports RT-LOSSY for the missing @context, not examples must be array. The guard is still boundSchema(root, maxDepth = 6) at scripts/validate.mjs:144, but line 180 cuts on minDepth.get(node) ?? 0 - the shallowest depth a shared node was reached at. Validating a directory that also contains A.schema.json reaches that subtree at depth 1 from A itself, so the memoised minimum stays under the limit. Worth confirming whether the original run validated C.schema.json alone, which would not have that shortcut.

    Independently of that: the Python port does not have this failure mode. Same DEFAULT_MAX_DEPTH = 6, and examples and enum survive intact through six levels of inheritance:

    B (2 levels): examples=[1] enum=[1, 2]  intact
    C (3 levels): examples=[1] enum=[1, 2]  intact
    D (4 levels): examples=[1] enum=[1, 2]  intact
    E (5 levels): examples=[1] enum=[1, 2]  intact
    F (6 levels): examples=[1] enum=[1, 2]  intact
    

    So if the corruption is real under some invocation, it is validate.mjs-specific and the migration retires it rather than needing a fix in both places. Given validate.mjs is being frozen and extracted to oold-js, the question is whether it is worth fixing there at all, or whether this closes when the swap lands.

    What would settle it: the exact command from the original run, and whether it targeted the single file or the directory.

  2. simontaurus commented on Aug 29, 2026

    @simontaurus
    ContributorAuthor

    Not reproducible in oold-python, which now does the validation: bound_schema counts instance-nesting depth, not raw object nesting, so a subclass chain does not consume the budget. A six-level chain (L0..L5) with examples on the base passes: 156 ok, 0 failed across 6 targets.

    That is the first of the two fixes suggested here, already in place on the Python side.

    scripts/validate.mjs still has it. It is frozen and only runs as the parity reference (make validate-reference), so this travels with the extraction to oold-js (#91).

  3. simontaurus commented on Aug 30, 2026

    @simontaurus
    ContributorAuthor

    Correction to my earlier comment: I said scripts/validate.mjs still had this. It does not, and had not when this was filed.

    boundSchema counts instance depth, not raw object nesting - INSTANCE_KEYWORDS / INSTANCE_MAP_KEYWORDS, where allOf and $ref are depth-neutral. That is the first of the two fixes proposed here, and it landed in #103 on 2026-07-29, two weeks before this issue.

    Measured on the exact reproduction above, and on a six-level chain:

    node src/validate.mjs /tmp/i134   --meta .../meta   ->  no GEN-ERROR, no "must be array"
    node src/validate.mjs /tmp/depth5 --meta .../meta   ->  31/31 checks passed
    

    The path in the "Cause" section - allOf(1) [0](2) allOf(3) [0](4) properties(5) v(6) examples(7) - is raw nesting. Under the accounting the code actually uses, only properties costs budget, so v sits at depth 1 and nothing is cut. So either the reproduction was run against a checkout predating #103, or there is a variant that still fails and the repro above is not it.

    The code has since moved to OO-LD/oold-js (#156). Worth closing unless you have a case that still reproduces there, in which case it should be reopened on that repository.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions