ADR-126: A named JSON-Schema subset, enforced strict 

Status

Accepted

Date

2026-08-05

Context 

ADR-082 shipped structured completions with a three-keyword structural matcher — type, required, properties — and deliberately excluded enum, pattern, oneOf and the rest: a full validator would add a runtime dependency. The matcher is fail-open: unknown keywords are silently ignored, an empty schema accepts everything.

That posture is correct for the paths the matcher also guards — the ADR-105 tool-input gate and the resume paths depend on its exact semantics — but wrong for structured completions, where a caller who writes enum deserves either enforcement or an error, never silent acceptance. The roadmap asked for "enum, pattern, oneOf, full draft support".

Decision 

The validator gains a second, STRICT mode next to the untouched lenient one. Its contract is a named subset, owned by StrictSchemaSubset :

  • Enforced: type (incl. union arrays), enum, const, pattern, string lengths, numeric bounds (incl. numeric exclusive*), integer multipleOf, items/prefixItems and the array assertions, properties/required/additionalProperties (bool and schema forms), oneOf/anyOf/allOf/not.
  • Annotations accepted and ignored: description, title, default, examples, format (annotation-only in 2020-12), $schema, $id.
  • Everything else — notably $ref/$defs — is out of subset, and out of subset is fail-closed: the schema is rejected as a whole, so the subset is enforceable rather than aspirational. "Full draft support" is deliberately not built; reference resolution is the point at which a subset becomes a JSON-Schema implementation, and ADR-082's no-runtime-dependency decision stands.

completeStructured() pre-flights the schema against the subset and throws (code 1784500003) before the first provider call: an out-of-subset schema fails for every possible response, and discovering that after the repair round-trip would cost two paid requests.

The lenient mode is byte-identical to before. The two modes are tied together by an invariant a fuzzy test pins: anything strict accepts, lenient accepts. Strict only ever rejects more, which is what makes it safe to introduce next to a mode that guards a security boundary.

Named deviations from JSON Schema 2020-12 

Each deliberate, none silent:

  1. pattern is compiled as PCRE (delimiters added, u modifier), not ECMA-262. A non-compiling pattern is out of subset; a match aborted by the backtracking limit rejects — fail-closed either way.
  2. multipleOf is honoured for positive integers only. Float steps need epsilon arithmetic that quietly lies about conformance (fmod(0.3, 0.1) ≠ 0).
  3. 5.0 is not an integer. Primitive matching byte-mirrors the lenient matcher's PHP-native checks — the price of the strict-implies-lenient invariant, and cheaper than two subtly different type systems.
  4. String lengths count code points (mb_strlen).
  5. enum/const/uniqueItems compare JSON values: maps key-order- insensitive, lists order-sensitive, scalars with === — so 1 and "1" differ, and 1 vs 1.0 differ although JSON has one number type. {} and [] decode identically in PHP and compare equal; the ambiguity is the same one the lenient matcher documents.

Consequences 

  • External callers of CompletionServiceInterface::completeStructured() whose schemas carry out-of-subset keywords ($schema and friends are fine — they are annotations) now get an immediate typed error instead of silent partial validation. Breaking, recorded in the CHANGELOG; there are no in-repo callers.
  • The ADR-105 input gate, the resume paths and the evaluation grader keep the lenient mode untouched. Migrating any of them to strict is a separate, deliberate decision with its own compatibility analysis — suspended runs persisted under lenient semantics must never start failing on resume.
  • ADR-082's "no enum/pattern/oneOf" consequence is superseded by this ADR; its no-runtime-dependency decision and the repair round-trip stand.
  • Provider-native structured output (OpenAI json_schema, Gemini responseSchema, Ollama format) was the separate follow-up ADR-082 named; ADR-128 delivered it, with the subset walker as its pre-flight gate.