JSON Schema: The Composition Trap, the Draft Skew, and the LLM Subset

The schema was correct. I had a base Event object, a PurchaseEvent that extended it through allOf, and additionalProperties: false on the base to keep junk out. Every valid payload was rejected — not with a useful message, just property amount is not allowed. The property was right there, declared in the subschema, three lines below.

That is the single most common JSON Schema bug in production, and it is not a bug in the validator. additionalProperties only sees properties declared in the same subschema. It has no idea allOf exists. Once you internalise that one sentence, half the confusing rejections in your logs explain themselves — and the other half turn out to be draft version skew, which is worse because it fails silently instead of loudly.

additionalProperties cannot see inside allOf

Here is the shape that breaks. It looks like inheritance and behaves like nothing of the sort:

{
  "allOf": [
    { "$ref": "#/$defs/Event" },
    {
      "type": "object",
      "properties": { "amount": { "type": "number" } }
    }
  ],
  "$defs": {
    "Event": {
      "type": "object",
      "properties": { "id": { "type": "string" }, "ts": { "type": "string" } },
      "additionalProperties": false
    }
  }
}

allOf means every subschema must pass independently. The Event subschema is evaluated on the whole instance, sees amount, checks its own properties list, does not find it, and rejects. Nothing about being combined with a sibling changes that — combination is an outer operation, and additionalProperties is resolved strictly inside its own object.

The fix arrived with draft 2019-09 and is called unevaluatedProperties. Unlike additionalProperties, it is evaluated after the whole schema runs and knows about every property that any subschema matched — including through $ref, allOf, anyOf, oneOf and if/then/else:

{
  "allOf": [
    { "$ref": "#/$defs/Event" },
    { "type": "object", "properties": { "amount": { "type": "number" } } }
  ],
  "unevaluatedProperties": false,
  "$defs": {
    "Event": {
      "type": "object",
      "properties": { "id": { "type": "string" }, "ts": { "type": "string" } }
    }
  }
}

Two rules fall out of that, and I now apply them without thinking: never put additionalProperties: false on a schema you intend to extend, and put unevaluatedProperties: false at the top level of the composed schema instead. A base schema sealed with additionalProperties: false is a base schema nobody can extend, which is usually the opposite of why you wrote a base schema.

The drafts disagree, and two of the changes fail silently

JSON Schema is not one specification. There is draft-04 (2013), draft-06 (April 2017), draft-07 (November 2017), 2019-09 and 2020-12, and the ecosystem is spread across all of them. Most differences produce a loud error when a validator meets an unknown keyword. Two do not — they change what an existing keyword means, so the schema keeps validating and starts being wrong.

Change Draft-04 / draft-07 meaning 2020-12 meaning
exclusiveMinimum boolean modifying minimum a number, standalone
items as an array tuple validation, positional invalid — tuples use prefixItems
$ref with siblings siblings ignored entirely siblings apply normally
definitions the conventional location renamed $defs
id the identifier keyword renamed $id

The $ref row is the one that has cost me the most time. In draft-07 and earlier, {"$ref": "#/$defs/Event", "description": "..."} throws the description away — and it throws away a sibling additionalProperties, type or minLength just as silently. Under 2019-09 and later, those siblings suddenly take effect. A schema copied from a draft-07 codebase into a 2020-12 validator can start rejecting data it accepted the day before, with no keyword having changed.

Declare $schema on every schema you write. It costs one line, it tells the validator which dialect to apply rather than letting it guess a default, and it makes the copy-paste failure loud instead of silent:

{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object" }

Then check what your tooling actually implements before you rely on anything from 2019-09 onward. OpenAPI is the specific trap here: OpenAPI 3.0 used a modified subset of an old draft, and only OpenAPI 3.1 aligned with 2020-12, so a schema that works in your 3.1 spec may not survive the 3.0 generator on the other side of the contract. Ajv requires an explicit import for 2020-12 rather than defaulting to it.

JSON Schema validates shape, not meaning

Everything JSON Schema can express is structural. There is no keyword for "endDate must be after startDate", none for "this ID exists in the accounts table", and none for "the total equals the sum of the line items". dependentRequired (2019-09) gets you if credit_card is present then billing_address is required, and that is genuinely useful, but it is the ceiling — presence, not value comparison.

This matters for where you put the schema in your stack. I keep it at the boundary, rejecting malformed payloads before any domain code runs, and I do not let it near business rules — every time I have seen someone stretch a schema toward semantics, it ended in a wall of if/then/else that nobody could read six months later. In practice that means schema validation first, and only then the parse into typed objects that enforce the real invariants.

It also composes badly with the other reason JSON breaks in production, which is that the payload was never valid JSON to begin with. A schema validator never sees a truncated body, a BOM, or an HTML error page served with a JSON content type — the parser fails first, and the parse errors that actually reach production look nothing like validation failures. Worth checking which of the two you are actually looking at before you go digging through $refs. Related, and more insidious: parsers disagree about big integers, duplicate keys and nesting depth, so a payload can pass validation in one runtime and be silently mangled in another before your schema is ever consulted.

The subset LLM structured outputs accept is much narrower

This is the newest reason JSON Schema knowledge goes stale, and the one that has surprised the most people I have worked with. When you hand a schema to a model — as a response format, or as a tool's input_schema — you are not handing it to a validator. You are handing it to a constrained decoder, and constrained decoding supports far fewer keywords than validation does.

On the Claude API the supported set is: the basic types (object, array, string, integer, number, boolean, null), enum, const, anyOf, allOf, $ref with $defs, the string format values (date-time, time, date, duration, email, hostname, uri, ipv4, ipv6, uuid), and additionalProperties: false. Rejected: recursive schemas, numeric constraints (minimum, maximum, multipleOf), string length constraints (minLength, maxLength), complex array constraints, and additionalProperties set to anything except false.

Read that list twice, because three things in it invert the advice above.

additionalProperties: false goes from optional to mandatory. Strict tool use requires it on every object, alongside an explicit required. The composition guidance flips with it: you cannot seal with unevaluatedProperties here, because unevaluatedProperties is not in the subset at all.

Your validation constraints do not travel. minLength, maximum and multipleOf are the keywords people most want when constraining a model, and they are exactly the ones that do not survive. The Python and TypeScript SDKs handle this by stripping unsupported constraints from the schema sent to the API and validating them client-side — so the constraint is enforced after generation, not during it. That is a retry, not a guarantee, and it is invisible unless you know to look for it.

Recursion is out. A comment tree, a nested category, an expression AST — any schema that $refs itself validates fine and cannot be used as an output format. Flatten it to a list with parent IDs and reassemble on your side.

Note also that oneOf is absent from the supported list while anyOf and allOf are present. If you have been using oneOf for discriminated unions — the idiomatic choice, since it enforces exactly-one-match — that is a rewrite to anyOf before the schema will compile.

Keyword Validation LLM structured output
additionalProperties: false optional, breaks allOf required on every object
unevaluatedProperties the fix for composition not supported
minLength / maximum enforced stripped, checked client-side by the SDK
recursive $ref fine rejected
oneOf idiomatic for unions not in the supported set — use anyOf

The practical consequence is that the schema you validate with and the schema you constrain a model with are two artefacts, even when they describe the same object. Generate the strict one from the rich one rather than maintaining both by hand, and keep the numeric and length constraints on the validation side where they are actually enforced. If you are wiring the model call itself, the structured output pattern and the four prompt patterns that stopped earning their keep cover what to do with the schema once it compiles.

Debugging a rejection

Validator error messages are famously unhelpful under composition — a failure inside anyOf typically reports every branch that failed rather than the one you meant. Three things cut the search space fast:

  1. Reduce the instance, not the schema. Delete properties from the payload until it passes, then add back the one that breaks it. Faster than reading a $ref chain.
  2. Check the effective draft. Print what the validator resolved $schema to. A silent default is the cause more often than the schema is.
  3. Diff the payload against a known-good one. Structural differences — a string where a number belongs, an extra nesting level — surface immediately in a JSON diff, and formatting a minified body with a JSON formatter first makes the error's JSON Pointer path actually navigable.

When you are choosing where schema enforcement lives at all, it is worth remembering that this is one of the real differences between JSON and YAML as config formats — the tooling maturity is not comparable, and a YAML config validated by a JSON Schema is validated after a parse that has its own opinions.

Pin the Draft, Then Compose

Three rules cover almost everything that goes wrong. Write $schema on every schema so the dialect is never guessed. Never seal a base schema with additionalProperties: false — put unevaluatedProperties: false on the composed result instead. And treat the schema you send to a model as a separate, narrower artefact, generated from the real one rather than shared with it.

The keyword that will bite you next is items. If any schema in your codebase still uses it as an array for tuple validation, it means something different under 2020-12, and nothing will tell you.

More Articles

WebMCP Lets a Page Hand Agents Its Tools — If the Page Holds a Trial Token

WebMCP is in a Chrome origin trial until 16 November 2026. The document.modelContext API, declarative forms, what broke for early adopters, and the risks.

11 October, 2026

The Lockfile Hash Checked Out. The Package Was Still Malware.

What integrity: sha512 in package-lock.json verifies, why axios, TanStack and ChainDrop all passed it, and the npm settings that actually help.

7 October, 2026

The Agent Skills That Pay Off Are Scripts With a Manual Attached

Curated skills add 16.6pp, agent-written ones add nothing, and half never trigger. What makes a SKILL.md pay off, and how to vet third-party skills.

3 October, 2026