API engineer reviewing JSON Schema validation results on a laptop and external monitor

Why Your JSON Schema Extension Rejects Valid Fields

Thu, Sep 24, 2026

A composed object schema can produce a misleading failure signal: the API team adds a documented field, reuses a familiar base through allOf, and the validator rejects the new request as though the field were an accidental typo. The opposite repair can be worse. Removing the base closure may make the new field pass while quietly allowing unrelated keys such as debug. The release question is therefore not “does the new example validate?” It is “does the revised contract accept exactly the intended extension family while preserving the base rules and rejecting unrelated fields?”

This playbook answers that question with one deliberately small job-request contract. The base requires job_id as a string. The extension requires queue as either normal or fast. The target request is {"job_id":"job-1","queue":"fast"}. Eight negative or boundary fixtures probe presence, type, allowed values, and closure. Five schema variants expose the two common composition mistakes and the annotation-sensitive difference between omitting additionalProperties and writing additionalProperties: true.

The laboratory uses JSON Schema Draft 2020-12, CPython 3.13.5, jsonschema 4.26.0, an explicitly selected Draft202012Validator, local JSON resources, and no network reference retrieval. The evidence is narrow by design: schema revision × payload × validator result. It does not claim transport preservation, code-generation compatibility, OpenAPI migration safety, coercion behavior, authentication correctness, or universal backward compatibility.

Define the payloads the extension must accept

Start with the acceptance set, not with a keyword. A schema review is easier to reason about when the intended language of valid instances is written down before anyone edits additionalProperties. Broader API input-validation foundations are useful context, but this gate is narrower: it decides which JSON object instances belong to one contract family.

For the extension, exactly one fixture in the required matrix should pass:

Fixture

Payload

Intended extension verdict

Reason

valid-extension.json

{"job_id":"job-1","queue":"fast"}

PASS

Both required fields are present and valid

extra-debug.json

{"job_id":"job-1","queue":"fast","debug":true}

FAIL

Unknown property must remain outside the contract

missing-job-id.json

{"queue":"fast"}

FAIL

Base presence rule

missing-queue.json

{"job_id":"job-1"}

FAIL

Extension presence rule

numeric-job-id.json

{"job_id":1,"queue":"fast"}

FAIL

Base type rule

null-queue.json

{"job_id":"job-1","queue":null}

FAIL

Extension type and allowed-value rules

invalid-queue.json

{"job_id":"job-1","queue":"turbo"}

FAIL

Extension allowed-value rule

empty.json

{}

FAIL

Both required fields absent

That table is the independent oracle: a mathematically expected result derived from the stated contract, not from any validator output. It prevents a bad repair from redefining success after the fact. Variant B will demonstrate why “the new request passes” is insufficient; Variant E will show why even a seemingly explicit declaration of openness can alter annotation behavior.

Treat that oracle as the API schema release gate, not as illustrative documentation. Reviewers should be able to state the exact pass set and fail set before reading a proposed patch. There is no acceptable “unknown-field budget” in this fixture: adding queue is the only intended widening, while debug, misspellings, experimental flags, and future names remain outside the contract until another reviewed revision admits them. That discipline matters for closed object schema compatibility because a change can be directionally correct, meaning it supports the new feature, while still changing the accepted language more than intended. The gate therefore asks for both evidence of compatibility and evidence of continued rejection.

The old standalone interface is different. Its valid family contains {"job_id":"job-1"} and excludes queue. Preserving that legacy contract does not require keeping a closed reusable definition. It requires keeping the legacy wrapper closed while allowing the reusable base to participate safely in composition.

Pin the dialect, validator and local schema bundle

Dialect selection is part of the contract. Draft 2020-12 is not merely a syntax label: unevaluatedProperties belongs to an annotation-aware vocabulary whose behavior depends on successful evaluations by adjacent applicators. The normative Draft 2020-12 core specification defines that behavior; the separate normative Draft 2020-12 validation specification defines assertions such as type, enum, and required. The explanatory JSON Schema object reference describes the closed-schema extension problem and notes that unevaluatedProperties first appeared in draft 2019-09, so it should not be presented as a feature introduced in 2026.

The implementation boundary matters too. The official python-jsonschema validation documentation exposes versioned validator classes, including Draft202012Validator, and documents check_schema, is_valid, and iter_errors. A living stable documentation URL is useful reference material, not an environment lock. The jsonschema 4.26.0 release page on PyPI identifies January 7, 2026 as that release date and lists it as the latest release at the September 24, 2026 research cutoff.

For broader release discipline, API documentation and versioning practice provides context. This laboratory pins more narrowly:

Python==3.13.5
jsonschema==4.26.0
attrs==26.1.0
jsonschema-specifications==2025.9.1
referencing==0.37.0
rpds-py==2026.5.1
validator=jsonschema.Draft202012Validator
dialect=https://json-schema.org/draft/2020-12/schema
fixture_commit=d3a868947071e35b7922b6a96b7dd28f4cff8081

Those versions were actually present in the execution environment used for the observed trace in this article. The fixture commit is a local Git commit containing schemas, payloads, runner, runtime manifest, observed output, and SHA-256 evidence hashes. The local bundle uses URN $id values and preloads every referenced schema into a referencing.Registry; it installs no network retrieval callback. If a reference is missing from the local bundle, that is a test-environment defect to stop on, not an invitation to fetch a remote schema.

Pinning has two layers. The package lock freezes the implementation that interprets keywords; the schema manifest freezes the resources whose identifiers are resolved. A stable documentation selector freezes neither. The minimal schema inventory for this experiment is base-open:1.0.0 / base-open-r1, base-closed:1.0.0 / base-closed-r1, base-explicit-true:1.0.0 / base-explicit-true-r1, queue:1.0.0 / queue-r1, variants A-E at 1.0.0 / their *-r1 comments, and legacy-wrapper:1.0.0 / legacy-wrapper-r1. Recording both $id and repository revision prevents a local file replacement from masquerading as the same evidence.

For a clean checkout, keep an exact dependency file rather than relying on a transitive re-resolution:

jsonschema==4.26.0
attrs==26.1.0
jsonschema-specifications==2025.9.1
referencing==0.37.0
rpds-py==2026.5.1

Then verify python --version, install that lock in the intended build environment, and run python runner.py. Package acquisition may use whatever controlled software-distribution process the organization permits; the schema validation run itself performs no network reference retrieval. This distinction keeps a dependency installation concern from being confused with remote $ref resolution.

Write the job-request contract and its oracle

The reusable base should state what job_id means without deciding the closure policy for every future composition. The queue fragment should do the same for queue. Both resources explicitly declare Draft 2020-12 and carry stable identifiers plus fixture revision comments.

This is the useful separation for Draft 2020-12 object composition: definitions describe local facts, while a public wrapper decides where the object family closes. It also keeps the oracle independent of the implementation. The reviewer can derive the expected matrix from three propositions: job_id is required and string-valued; queue is required and limited to two strings; and no third property is allowed. The reviewer can do this without first looking at A, B, C, D, or E. That independence is what makes the controls meaningful rather than circular tests of whichever schema happens to be under review.

schemas/base-open.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "urn:refonte:job-request:base-open:1.0.0",
  "$comment": "fixture revision base-open-r1",
  "type": "object",
  "properties": {
    "job_id": {"type": "string"}
  },
  "required": ["job_id"]
}

schemas/queue.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "urn:refonte:job-request:queue:1.0.0",
  "$comment": "fixture revision queue-r1",
  "type": "object",
  "properties": {
    "queue": {
      "type": "string",
      "enum": ["normal", "fast"]
    }
  },
  "required": ["queue"]
}

Create the local payload files exactly as the oracle names them:

mkdir -p payloads
printf '%s\n' '{"job_id":"job-1","queue":"fast"}' > payloads/valid-extension.json
printf '%s\n' '{"job_id":"job-1","queue":"fast","debug":true}' > payloads/extra-debug.json
printf '%s\n' '{"queue":"fast"}' > payloads/missing-job-id.json
printf '%s\n' '{"job_id":"job-1"}' > payloads/missing-queue.json
printf '%s\n' '{"job_id":1,"queue":"fast"}' > payloads/numeric-job-id.json
printf '%s\n' '{"job_id":"job-1","queue":null}' > payloads/null-queue.json
printf '%s\n' '{"job_id":"job-1","queue":"turbo"}' > payloads/invalid-queue.json
printf '%s\n' '{}' > payloads/empty.json
printf '%s\n' '{"job_id":"job-1"}' > payloads/legacy-valid.json

The Draft 2020-12 validation specification separates these concerns. required checks whether named properties exist; type checks the instance type; enum checks equality against an allowed set. They are different assertions and should receive different fixtures. Closure is different again: it determines whether names not successfully accounted for by the intended object contract remain admissible.

Separate property presence, value and closure

A missing queue and a queue: null are not interchangeable failures. The JSON Schema object reference explicitly distinguishes a missing required property from a present property whose value has the wrong type. That distinction is operationally valuable because an extension can accidentally preserve one rule while weakening another.

Treat the oracle as four independent questions. Is job_id present? Is it a string? Is queue present and one of the two allowed strings? Are there any properties outside the composed contract? A release gate should fail if any one dimension drifts.

The known-answer request {"job_id":"job-1","queue":"fast"} is therefore necessary but not sufficient. It proves the intended field can be accepted only when paired with the debug canary, wrong-type fixtures, invalid-enum fixture, and missing-property fixtures. No production traffic, generated client, HTTP route, serializer, coercion layer, or database is needed to answer this schema question; adding them would make the evidence harder to attribute.

Reproduce the closed-base allOf failure

Variant A models the common starting point: a reusable base was originally closed with additionalProperties: false, and an extension later tries to add queue through allOf.

schemas/base-closed.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "urn:refonte:job-request:base-closed:1.0.0",
  "$comment": "fixture revision base-closed-r1",
  "type": "object",
  "properties": {
    "job_id": {"type": "string"}
  },
  "required": ["job_id"],
  "additionalProperties": false
}

schemas/A-closed-base-allof.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "urn:refonte:job-request:variant-a:1.0.0",
  "$comment": "fixture revision variant-a-r1",
  "allOf": [
    {"$ref": "urn:refonte:job-request:base-closed:1.0.0"},
    {"$ref": "urn:refonte:job-request:queue:1.0.0"}
  ]
}

The documented mechanism is straightforward. additionalProperties evaluates names that are not covered by properties or patternProperties in the same schema object. The JSON Schema object guide specifically warns that a closed subschema can block extension through allOf, because the closed branch does not gain awareness of properties declared elsewhere.

This is the canonical JSON Schema allOf additionalProperties collision behind many attempts to extend closed JSON Schema objects. An additionalProperties: false extension failure is not evidence that queue is undefined globally; it is evidence that the base branch still evaluates the complete instance and rejects a name outside that branch’s own declared property set. That distinction should be visible in review comments, because phrases such as “allOf did not merge the schemas” can otherwise lead engineers toward ad hoc property duplication instead of correcting the closure boundary.

The expected result is therefore that the valid extension request fails Variant A. In the actual execution captured for fixture commit d3a8689, iter_errors produced an error at instance path [], schema path ['allOf', 0, 'additionalProperties'], keyword additionalProperties, identifying queue as unexpected. That is actually observed output, not a fabricated trace. The exact English message is not an acceptance assertion; the path and keyword are diagnostic evidence.

Why allOf does not merge permission to add fields

The explanatory Boolean schema combination guide defines allOf as logical conjunction: the instance must validate against every subschema. It explicitly cautions that allOf is not object-oriented inheritance.

Applied to this fixture, the base branch says, in effect, “an object with required string job_id, and no properties other than those I recognize.” The queue branch says, “an object with required queue whose value is normal or fast.” Conjunction does not cause either branch to rewrite the other. The valid new request satisfies the queue branch but violates the closed base branch, so the entire allOf fails.

This is why adding an outer keyword cannot “override” that failed branch. unevaluatedProperties operates after relevant successful annotations are collected; it does not convert a subschema failure into success. The Draft 2020-12 core specification states that failed evaluations do not contribute evaluated-property annotations. Variant D will make that control explicit.

Expose the over-open repair

The tempting repair is to remove additionalProperties: false from the base and stop there. Variant B does exactly that by composing the open reusable base with the queue fragment and adding no outer closure.

schemas/B-open-no-outer-closure.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "urn:refonte:job-request:variant-b:1.0.0",
  "$comment": "fixture revision variant-b-r1",
  "allOf": [
    {"$ref": "urn:refonte:job-request:base-open:1.0.0"},
    {"$ref": "urn:refonte:job-request:queue:1.0.0"}
  ]
}

Variant B accepts {"job_id":"job-1","queue":"fast"}, so a positive-only test would call the repair successful. But JSON Schema object schemas are open to unmatched properties unless a restriction closes them. The JSON Schema object reference demonstrates that extra properties remain valid by default. The independent oracle therefore predicts that B also accepts debug: true.

That prediction was actually observed in the pinned execution: Variant B returned true for both valid_extension and extra_debug. The repair is over-open. It restores the new documented field by weakening the boundary that was supposed to reject unrelated fields.

Diagnostic Variant E is subtler. It asks whether making the reusable base’s openness explicit with additionalProperties: true is equivalent to simply omitting additionalProperties when an outer unevaluatedProperties: false later depends on annotations.

schemas/base-explicit-true.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "urn:refonte:job-request:base-explicit-true:1.0.0",
  "$comment": "fixture revision base-explicit-true-r1",
  "type": "object",
  "properties": {"job_id": {"type": "string"}},
  "required": ["job_id"],
  "additionalProperties": true
}

schemas/E-explicit-true-plus-unevaluated.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "urn:refonte:job-request:variant-e:1.0.0",
  "$comment": "fixture revision variant-e-r1",
  "allOf": [
    {"$ref": "urn:refonte:job-request:base-explicit-true:1.0.0"},
    {"$ref": "urn:refonte:job-request:queue:1.0.0"}
  ],
  "unevaluatedProperties": false
}

An omitted keyword and explicit true are not always interchangeable

At the assertion-only level, omitting additionalProperties and using an always-valid schema both allow otherwise unmatched values. Draft 2020-12 composition adds another dimension: annotations. The Draft 2020-12 core specification says additionalProperties produces an annotation containing the property names it validates, and unevaluatedProperties consults those annotation results.

In Variant E, the base branch sees queue and debug as additional properties and validates them successfully against true. Those names are therefore evaluated before the outer unevaluatedProperties rule runs. The outer closure has nothing left to reject. The pinned execution actually observed E accepting both the valid extension and the unwanted debug payload.

That is the concrete reason this playbook requires E. Do not normalize “keyword omitted” into “keyword explicitly true” during refactoring without rerunning annotation-sensitive negative cases. The two forms can be assertion-equivalent in isolation yet compositionally different for an outer unevaluatedProperties gate.

Close the composed contract at the correct boundary

Variant C is the intended design: keep the reusable base open by omitting additionalProperties, keep the queue fragment focused on its own field, and place unevaluatedProperties: false at the composed boundary that knows which branches are intended to contribute properties.

schemas/C-open-outer-unevaluated.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "urn:refonte:job-request:variant-c:1.0.0",
  "$comment": "fixture revision variant-c-r1",
  "allOf": [
    {"$ref": "urn:refonte:job-request:base-open:1.0.0"},
    {"$ref": "urn:refonte:job-request:queue:1.0.0"}
  ],
  "unevaluatedProperties": false
}

Under Draft 2020-12, unevaluatedProperties applies to property values whose names do not appear in relevant successful annotations from properties, patternProperties, additionalProperties, prior unevaluatedProperties, and in-place applicators. The Draft 2020-12 core specification requires those applicators to be evaluated before unevaluatedProperties. The JSON Schema object guide shows the same mechanism as the recommended way to extend a composed object without redeclaring every inherited property.

For the valid request, the base successfully evaluates job_id, and the queue branch successfully evaluates queue. Nothing remains unevaluated, so C passes. For the debug canary, no intended branch evaluates debug; the outer false schema applies to it, so C fails. The observed run matched both expectations.

This is the narrow JSON Schema unevaluatedProperties use case that matters here. The keyword is not a general replacement for additionalProperties, and it is not a late-stage override. It is valuable because the composed boundary can account for property annotations contributed by successful sibling applicators. The acceptance design therefore depends on base-open omitting its own catch-all and on the queue branch successfully recognizing queue. Change either of those facts and the evaluated-property set can change, which is why Variant E and the wrong-type diagnostics belong in the same experiment.

Variant D proves that adding the outer keyword while leaving the inner base closed is not a repair:

schemas/D-closed-base-plus-unevaluated.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "urn:refonte:job-request:variant-d:1.0.0",
  "$comment": "fixture revision variant-d-r1",
  "allOf": [
    {"$ref": "urn:refonte:job-request:base-closed:1.0.0"},
    {"$ref": "urn:refonte:job-request:queue:1.0.0"}
  ],
  "unevaluatedProperties": false
}

D still rejects the valid extension because the closed base still rejects queue. The outer unevaluatedProperties does not weaken or supersede the failed branch. This is an important stop condition in review: if the intended new request does not pass every inner branch, do not keep adding outer closure keywords until the symptom disappears. Redesign the closure boundary instead.

Run the complete positive and negative matrix

The runner below is the acceptance gate. It loads only local JSON files, builds a registry from their $id values, asserts the Python and package pins, asserts the validator class and dialect, calls Draft202012Validator.check_schema on every schema, evaluates each payload with is_valid, and uses iter_errors only for diagnostics. The python-jsonschema validation documentation recommends validating schemas with check_schema before relying on validator instances.

Keep the corpus as files rather than embedded Python literals so schema changes and payload changes have separate diffs. The runner is deliberately strict at startup: a package-version drift, dialect drift, missing local reference, malformed schema, or changed expected verdict stops the job. Those are evidence failures, not test warnings. The jsonschema Draft202012Validator tests here also separate two APIs on purpose: is_valid supplies the boolean release verdict, while iter_errors supplies diagnostic structure when a verdict is false. That makes it harder for a future change in error wording or multiplicity to turn a semantically correct validator into a brittle test failure.

The fixture directory should contain the ten schema files shown in this playbook, the nine payload files named in the oracle, runner.py, the dependency lock, and the evidence manifest. A reviewer can reconstruct every verdict from that checkout without a schema registry, HTTP server, OpenAPI document, generated SDK, or remote resolver. That small surface is a feature: when C passes and B fails the release criterion, the difference can be attributed to schema semantics rather than transport behavior.

from future import annotations

import importlib.metadata as md
import json
import platform
from pathlib import Path
from typing import Any

from jsonschema import Draft7Validator, Draft202012Validator
from referencing import Registry, Resource

ROOT = Path(__file__).resolve().parent
SCHEMA_DIR = ROOT / "schemas"
PAYLOAD_DIR = ROOT / "payloads"
DIALECT = "https://json-schema.org/draft/2020-12/schema"
EXPECTED_VALIDATOR = "Draft202012Validator"
PINNED_PYTHON = "3.13.5"

PINNED = {
    "jsonschema": "4.26.0",
    "attrs": "26.1.0",
    "jsonschema-specifications": "2025.9.1",
    "referencing": "0.37.0",
    "rpds-py": "2026.5.1",
}

VARIANTS = {
    "A": "A-closed-base-allof.json",
    "B": "B-open-no-outer-closure.json",
    "C": "C-open-outer-unevaluated.json",
    "D": "D-closed-base-plus-unevaluated.json",
    "E": "E-explicit-true-plus-unevaluated.json",
    "LEGACY": "legacy-job-request.json",
}

PAYLOADS = {
    "valid_extension": "valid-extension.json",
    "extra_debug": "extra-debug.json",
    "missing_job_id": "missing-job-id.json",
    "missing_queue": "missing-queue.json",
    "numeric_job_id": "numeric-job-id.json",
    "null_queue": "null-queue.json",
    "invalid_queue": "invalid-queue.json",
    "empty": "empty.json",
    "legacy_valid": "legacy-valid.json",
}

EXPECTED = {
    "A": {
        "valid_extension": False, "extra_debug": False,
        "missing_job_id": False, "missing_queue": False,
        "numeric_job_id": False, "null_queue": False,
        "invalid_queue": False, "empty": False, "legacy_valid": False
    },
    "B": {
        "valid_extension": True, "extra_debug": True,
        "missing_job_id": False, "missing_queue": False,
        "numeric_job_id": False, "null_queue": False,
        "invalid_queue": False, "empty": False, "legacy_valid": False
    },
    "C": {
        "valid_extension": True, "extra_debug": False,
        "missing_job_id": False, "missing_queue": False,
        "numeric_job_id": False, "null_queue": False,
        "invalid_queue": False, "empty": False, "legacy_valid": False
    },
    "D": {
        "valid_extension": False, "extra_debug": False,
        "missing_job_id": False, "missing_queue": False,
        "numeric_job_id": False, "null_queue": False,
        "invalid_queue": False, "empty": False, "legacy_valid": False
    },
    "E": {
        "valid_extension": True, "extra_debug": True,
        "missing_job_id": False, "missing_queue": False,
        "numeric_job_id": False, "null_queue": False,
        "invalid_queue": False, "empty": False, "legacy_valid": False
    },
    "LEGACY": {
        "valid_extension": False, "extra_debug": False,
        "missing_job_id": False, "missing_queue": True,
        "numeric_job_id": False, "null_queue": False,
        "invalid_queue": False, "empty": False, "legacy_valid": True
    },
}


def load_json(path: Path) -> dict[str, Any]:
    with path.open("r", encoding="utf-8") as f:
        return json.load(f)


def build_registry(schemas: dict[str, dict[str, Any]]) -> Registry:
    registry = Registry()
    for schema in schemas.values():
        registry = registry.with_resource(
            schema["$id"], Resource.from_contents(schema)
        )
    return registry


def error_record(error: Any) -> dict[str, Any]:
    return {
        "keyword": error.validator,
        "instance_path": list(error.path),
        "schema_path": list(error.schema_path),
        "message": error.message,
    }


def main() -> None:
    assert platform.python_version() == PINNED_PYTHON
    assert Draft202012Validator.__name__ == EXPECTED_VALIDATOR
    assert Draft202012Validator.META_SCHEMA["$id"] == DIALECT

    for package, expected in PINNED.items():
        assert md.version(package) == expected

    schemas = {
        p.name: load_json(p) for p in sorted(SCHEMA_DIR.glob("*.json"))
    }

    for schema in schemas.values():
        assert schema["$schema"] == DIALECT
        Draft202012Validator.check_schema(schema)

    registry = build_registry(schemas)
    payloads = {
        name: load_json(PAYLOAD_DIR / filename)
        for name, filename in PAYLOADS.items()
    }

    mismatches: list[str] = []

    for variant, filename in VARIANTS.items():
        validator = Draft202012Validator(
            schemas[filename],
            registry=registry,
        )

        for payload_name, payload in payloads.items():
            actual = validator.is_valid(payload)
            expected = EXPECTED[variant][payload_name]

            if actual != expected:
                mismatches.append(
                    f"{variant}/{payload_name}: "
                    f"expected {expected}, got {actual}"
                )

    for variant, payload_name in [
        ("A", "valid_extension"),
        ("C", "extra_debug"),
        ("C", "numeric_job_id"),
        ("C", "null_queue"),
        ("C", "invalid_queue"),
    ]:
        validator = Draft202012Validator(
            schemas[VARIANTS[variant]],
            registry=registry,
        )
        errors = [
            error_record(error)
            for error in validator.iter_errors(payloads[payload_name])
        ]
        print(
            "ERRORS",
            variant,
            payload_name,
            json.dumps(errors, sort_keys=True),
        )

    # Deliberately wrong configuration: diagnostic only.
    wrong = Draft7Validator(
        schemas[VARIANTS["C"]],
        registry=registry,
    )
    wrong_result = wrong.is_valid(payloads["extra_debug"])
    print("WRONG_DRAFT7_C_EXTRA_DEBUG", wrong_result)

    if wrong_result is not True:
        mismatches.append(
            "Draft7 diagnostic did not expose ignored outer rule"
        )

    if mismatches:
        raise SystemExit("MISMATCHES\n" + "\n".join(mismatches))

    print("MATRIX_OK")


if name == "__main__":
    main()

Run from the fixture root with python runner.py. In a clean rebuild, install the exact packages from the pinned requirements before executing; dependency installation is separate from JSON Schema reference resolution.

The pinned execution produced the following actually observed boolean matrix. P means accepted and F means rejected; the final MATRIX_OK line means every cell matched the independent EXPECTED oracle in the runner.

Contract

valid extension

extra debug

missing job_id

missing queue

numeric job_id

null queue

invalid queue

empty

legacy valid

A

F

F

F

F

F

F

F

F

F

B

P

P

F

F

F

F

F

F

F

C

P

F

F

F

F

F

F

F

F

D

F

F

F

F

F

F

F

F

F

E

P

P

F

F

F

F

F

F

F

LEGACY

F

F

F

P

F

F

F

F

P

Collect useful errors without brittle string matching

The gate asserts verdicts, not error prose. The Draft 2020-12 core specification defines standard output concepts such as instance location and keyword location while leaving exact error wording to implementations. The Python runner therefore records keyword, instance path, schema path, and message for diagnosis but does not require one specific ordering, message string, or error count.

That matters for C. In the observed trace, numeric_job_id produced both a type failure and an outer unevaluatedProperties failure, because the failing branch did not contribute a successful evaluated-property annotation for job_id. null_queue similarly produced type, enum, and outer closure diagnostics. Multiple errors are not evidence that the schema is “more invalid”; they are consequences of evaluation and annotation flow. Acceptance should remain boolean at the contract boundary while retaining the richer diagnostics for debugging.

Keep the original standalone contract intact

Opening the reusable base does not mean globally relaxing the old public contract. The old job-request surface can retain its exact closure policy through a dedicated wrapper that references the open base and closes at its own boundary.

schemas/legacy-job-request.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "urn:refonte:job-request:legacy-wrapper:1.0.0",
  "$comment": "fixture revision legacy-wrapper-r1",
  "allOf": [
    {"$ref": "urn:refonte:job-request:base-open:1.0.0"}
  ],
  "unevaluatedProperties": false
}

This is the key compatibility separation. base-open.json is a reusable definition, not itself the complete legacy interface. legacy-job-request.json is the public standalone contract for job-ID-only callers. The wrapper accepts {"job_id":"job-1"} and rejects queue, debug, a numeric job_id, and an empty object. The intended extension C accepts the two-field request and intentionally rejects the job-ID-only object because queue is required.

The observed matrix matched that separation: LEGACY/legacy_valid passed, LEGACY/valid_extension failed, and C/legacy_valid failed. Those are actually observed outputs in the pinned fixture. They demonstrate preservation of two interfaces, not universal backward compatibility across every consumer.

For teams still building the surrounding request lifecycle, backend request and serialization foundations provide broader context. This laboratory intentionally stops before transport and serialization. It validates already-parsed JSON-compatible Python data and makes no claim about whether an HTTP framework preserves, strips, transforms, or coerces fields before validation.

Operationally, this wrapper pattern also reduces blast radius. Consumers that depend on the old closed contract keep referencing the legacy wrapper. New composed contracts can reuse the open definition and choose their own closure boundary. The migration unit becomes an explicit schema identifier rather than an in-place behavioral mutation hidden under a reused name.

Detect a validator that silently ignores your intended rule

A schema can look correct and still be enforced by the wrong validator. unevaluatedProperties is not a Draft 7 keyword. The python-jsonschema validation documentation lists distinct validator classes for Draft 2020-12, 2019-09, Draft 7, Draft 6, Draft 4, and Draft 3. The acceptance environment therefore selects Draft202012Validator explicitly instead of relying on an inferred default.

The runner contains a deliberately wrong diagnostic: it instantiates Draft7Validator over Variant C and asks whether the extra_debug canary is valid. In the pinned environment, the result was True. That is actually observed output demonstrating that the older validator configuration did not enforce the outer Draft 2020-12 unevaluatedProperties rule. It is not a compatibility strategy and must never be used to “make” a client pass.

The positive control is explicit and mechanical. The runner asserts that every schema declares the Draft 2020-12 meta-schema URI, that the chosen class name is Draft202012Validator, and that its meta-schema $id matches the intended dialect. It then calls that class’s check_schema before any instance verdict is accepted.

A valid schema is not evidence of keyword enforcement

Schema parsing answers a different question from instance enforcement. The python-jsonschema validation documentation notes that validator instances assume the schema is valid and points users to check_schema for meta-schema validation. But a successful startup or schema check does not by itself prove that the intended runtime class enforces every keyword the release depends on.

That is why the unknown-property canary is mandatory. For the accepted design C, extra_debug must fail under Draft202012Validator. If it passes, stop the release even if imports succeed, the schema parses, and positive fixtures pass. The likely causes include a dialect/class mismatch, an unintended annotation-producing branch, the wrong schema revision, or a changed test corpus.

Do not auto-upgrade consumers silently either. If one integration is pinned to an older dialect, record that fact in the compatibility ledger and hold the extension for that integration until there is an explicit migration decision. Ignoring a keyword is not backward compatibility; it is a different contract.

Treat a JSON Schema dialect mismatch as a release configuration defect, not as a permissive fallback. A caller that validates the same text with Draft 7 has not demonstrated compatibility with the Draft 2020-12 contract, even if its happy-path payload happens to pass. The debug canary is the proof point because it exercises the keyword whose support distinguishes the intended policy. If a different validator implementation is substituted for python-jsonschema, repeat the same known-answer matrix and record that implementation’s exact version and dialect-selection mechanism before accepting equivalence.

Review the compatibility ledger by consumer

The release artifact needs a ledger that binds each consumer to a schema revision, dialect, validator capability, and payload family. This is where schema compatibility becomes an integration decision rather than an abstract claim. Keep database integration boundaries outside the validator experiment: persistence behavior matters operationally, but it is not evidence that the JSON instance was accepted by the intended schema.

Consumer

Contract

Dialect / validator

May send

Must reject

Release status

Legacy caller

legacy-wrapper:1.0.0

Draft 2020-12 / Draft202012Validator

job_id only

queue, debug, wrong types

Preserve

New queue caller

variant-c:1.0.0

Draft 2020-12 / Draft202012Validator

job_id + valid queue

unknown keys, missing fields, wrong type/value

Accept candidate

Caller on Variant A

variant-a:1.0.0

Draft 2020-12 / correct class

Intended extension

queue is wrongly rejected

Redesign

Caller on Variant B

variant-b:1.0.0

Draft 2020-12 / correct class

Intended extension plus accidental extras

debug should fail but does not

Revert/repair

Caller on Draft 7

C schema text / wrong class

Draft 7 / Draft7Validator

Behavior not equivalent to C

2020-12 closure can be ignored

Hold

For each real integration, replace the generic consumer name with an accountable system or team, record the exact contract identifier actually deployed, and attach evidence that the consumer’s runtime enforces the stated dialect. Generated clients, OpenAPI bindings, message serialization, and unknown-field transport preservation are explicitly outside this lab; they require separate evidence rather than assumptions borrowed from this matrix.

A useful ledger row also records the fixture commit used for the compatibility check, the date of that evidence, the owner who can reproduce it, and the expected payload family. “Uses JSON Schema” is too weak a compatibility statement. So is “supports Draft 2020-12” without identifying the schema revision. The unit of evidence is the full tuple: consumer × contract ID/revision × dialect × validator/version × payload family × result. Any missing member turns an apparent green row into an unproven assumption.

Keep consumer evidence directional. The legacy caller proves it can continue using the legacy wrapper; it does not prove it can send queue. The queue caller proves C accepts the new family and rejects extras; it does not prove old clients will adopt C. This avoids the common error of collapsing backward compatibility, forward compatibility, and validator capability into one undocumented “compatible” flag.

Capture release evidence and ownership

A release gate is only reproducible when the evidence identity is as precise as the code identity. Retain the schema JSON, payload corpus, runner, runtime lock, command, output, and hashes under one immutable fixture revision. For this article’s executed laboratory, that revision is Git commit d3a868947071e35b7922b6a96b7dd28f4cff8081.

The minimum evidence manifest is:

research_cutoff=2026-09-24
fixture_commit=d3a868947071e35b7922b6a96b7dd28f4cff8081
python=3.13.5
jsonschema=4.26.0
validator=jsonschema.Draft202012Validator
dialect=https://json-schema.org/draft/2020-12/schema
invocation=python runner.py
schemas=schemas/*.json
payloads=payloads/*.json
result=MATRIX_OK
reference_retrieval=local registry only; no network callback

The observed trace is evidence for this exact environment and corpus. It is not evidence for a different jsonschema release, different validator implementation, a schema copied into another dialect, or a production request path that preprocesses data.

Evidence identity should be reviewable without rerunning first. Store hashes for every schema, payload, runner, lock file, and captured output, and make the manifest name the Git commit that contains them. The release record should also say whether the output is expected or observed. In this playbook, MATRIX_OK, the A-E verdicts, and the representative error records are observed in the pinned environment; the oracle was written independently before comparison. If a later run is only described from documentation and has not been executed, label it expected instead of borrowing this run’s status.

Ownership should be explicit. The schema owner approves the intended acceptance set, schema identifiers, closure boundary, and decision on A-E. The integration owner confirms which schema/dialect pair each caller actually uses and supplies consumer-specific evidence before rollout. The release owner verifies the pinned matrix artifact is the one promoted. If a rollback is required, the same owners identify the prior tested contract/caller pair rather than substituting an unreviewed “more permissive” schema.

Do not capture confidential production bodies merely to prove this gate. The synthetic fixtures here are sufficient for mechanism-level evidence. Real consumer evidence can record schema revision, validator identity, verdict, and sanitized fixture identity without logging secrets, tokens, personal data, or unrelated request content.

Roll back without silently widening validation

Rollback is not “remove the keyword that caused errors.” That can reproduce Variant B: traffic starts passing because the contract has become wider than intended. A safe rollback returns a caller to a previously tested schema/dialect/validator combination whose accepted instance family is understood.

For this lab, the legacy caller can roll back to legacy-wrapper:1.0.0 with Draft 2020-12 and Draft202012Validator. A new queue caller cannot roll back to that contract without intentionally losing support for queue; if queue support is already a committed interface, the correct action may be to revert the release that introduced the broken schema while retaining the last known queue-capable contract, or to hold traffic until Variant C is deployed. The compatibility ledger must identify which situation applies.

The stop conditions are concrete: hold when the deployed schema identifier is unknown, the runtime validator class cannot be proven, the negative canary has not been executed, or consumer evidence conflicts with the pinned matrix. Do not infer compatibility from a similar environment.

Before a rollback is declared complete, rerun the same known-answer corpus against the restored contract and record its evidence identity. Also verify routing or configuration points at that restored identifier; changing a repository file is insufficient if a service is still loading a cached or copied schema. The rollback criterion is not merely “errors decreased.” It is that the selected caller once again exhibits the previously approved acceptance and rejection set. Anything else is another contract change requiring its own decision.

Reconcile payloads admitted by the over-open revision

If Variant B or E reached production, rollback does not erase what those policies admitted. Determine the exact revision interval during which unrelated fields were accepted. Then identify retained, authorized records or queued requests created in that interval and revalidate them against the intended contract before reuse.

This step is bounded. It does not authorize broad inspection of confidential payloads, and it does not assume every accepted extra field caused harm. It establishes whether data that passed an over-open policy would still pass C. Any downstream side effects that already occurred require their own recovery procedure; changing validation policy cannot reverse a database write, message publication, external API call, or business action.

The operational owner for reconciliation should be the integration owner for the affected data path, with the schema owner providing the exact target contract and the release owner recording the affected deployment interval. If the interval or retained-record set cannot be bounded confidently, the decision is hold and investigate, not “probably safe.”

Decide whether to accept, redesign, hold or revert

The decision rule is intentionally stricter than positive-path success. The extension is acceptable only when the intended new request passes, unrelated debug fails, every required negative fixture fails for the intended reason category, the legacy wrapper still behaves as its own contract, and the runtime evidence proves Draft 2020-12 enforcement. Risk-based QA automation practice is useful context for treating negative cases as first-class release evidence rather than optional examples.

This is the API schema release gate: it judges the accepted instance set, not whether the proposed diff looks idiomatic. An engineer reviewing an additionalProperties false extension should be able to reject A even though it is strict, reject B and E even though they accept the feature, and hold a seemingly correct C when the runtime cannot prove keyword support. Strictness is not the objective; precision is. The release candidate must admit the intended widening and no demonstrated accidental widening.

Condition

Positive request

debug canary

Contract interpretation

Decision

Accountable owner

A: closed base inside allOf

FAIL

FAIL

Extension field blocked by inner closure

REDESIGN closure boundary

Schema owner

B: closure removed, no outer closure

PASS

PASS

Contract widened beyond intended set

REVERT or REPAIR to C

Schema owner + release owner

C: open reusable base + outer unevaluatedProperties: false

PASS

FAIL

Intended composed boundary

ACCEPT, subject to consumer evidence

Schema owner + integration owner

D: outer keyword added but base remains closed

FAIL

FAIL

Outer rule cannot rescue failed base branch

REDESIGN

Schema owner

E: base has additionalProperties: true + outer closure

PASS

PASS

Base evaluates extras and defeats canary

REDESIGN/REVERT

Schema owner

Correct schema text, older validator

May PASS

PASS in diagnostic

Intended keyword not enforced

HOLD

Integration owner

Consumer schema/dialect evidence incomplete

Unknown

Unknown

Compatibility not demonstrated

HOLD

Integration owner

Observed result differs from documented/pinned result

Any

Any

Evidence conflict

HOLD, preserve artifacts, investigate

Schema owner + release owner

The classifications should remain explicit. The behavior of allOf, additionalProperties, and unevaluatedProperties described by the specifications and official guide is documented behavior. The oracle table is a mathematically expected result from the intended contract. The MATRIX_OK run and the A-E/legacy verdicts reported here are actually observed output from the pinned local execution. No network API response or synthetic service response participates in the experiment; the payloads are synthetic local JSON fixtures, not stubbed remote responses.

Variant C is therefore the acceptance candidate. That conclusion is bounded to this object family, these schema revisions, this dialect, this Python implementation, and this corpus. It does not prove every future extension is safe. In particular, conditional schemas, patternProperties, nested objects, custom vocabularies, or additional annotation-producing applicators can change what counts as evaluated and deserve their own negative fixtures.

The stop/decision vocabulary should stay disciplined. Accept means all mandatory evidence is present and matches the oracle. Redesign means the schema construction itself cannot express the intended set under the selected semantics, as with A, D, or E. Hold means the design may be correct but evidence is incomplete or contradictory, such as an unproven consumer runtime. Revert means a deployed revision widened or broke the contract relative to a previously tested pair and the prior pair can be restored safely. Those terms prevent uncertainty from being mislabeled as compatibility.

A publication or release check should also compare documentation, installed versions, and observed traces rather than forcing them to agree. If the next jsonschema release changes diagnostics, update the environment lock and rerun. If the boolean verdict changes, hold. If the JSON Schema documentation changes after the September 24, 2026 cutoff, re-read the version-sensitive sections before publishing or repinning. Never “fix” a mismatch by editing the fixture until the output looks expected; preserve the conflicting evidence first.

A final reviewer should also inspect failure categories, not merely red cells in the matrix. missing_job_id should be rejected because the base presence rule remains active; numeric_job_id should prove the base type still constrains the extension; missing_queue, null_queue, and invalid_queue should prove the new branch is not optional or weakly typed; and empty should demonstrate conjunction of the required rules. Exact error counts may vary with annotation flow, but a surprising acceptance is always a release blocker. This is the practical role of schema validation negative cases: they defend dimensions of the contract that a single happy path cannot see.

The most important acceptance criterion can fit in one sentence: the intended extension field must pass, and an unrelated field must fail, under the explicitly selected dialect-aware validator. Both halves are mandatory. A positive test without the typo canary accepts B and E. A negative-only test can accept A or D. Only the paired evidence distinguishes safe composition from rejection and over-permission.

Develop API contract skills beyond a keyword fix

The durable skill is not memorizing unevaluatedProperties. It is learning to turn contract evolution into a small, falsifiable acceptance set: pin the dialect and implementation, preserve schema identities, separate reusable definitions from public wrappers, write negative canaries before editing closure, and keep consumer evidence tied to an accountable owner. Those habits scale beyond this two-field example even though the exact schema mechanics will vary.

Refonte Learning's APIs Developer Fundamentals page lists a three-month program at 10-12 hours per week, includes API documentation/testing and versioning/deprecation among its stated competencies, and separately says admission requires working toward a bachelor's or higher-level degree. Those broader foundations are relevant to owning API contracts and release evidence, but the page does not establish that this specific Draft 2020-12 unevaluatedProperties laboratory or python-jsonschema acceptance matrix is taught.

For production engineering, keep the same discipline after this fix: own the rejection set, not only the happy-path example. A schema extension is ready when its accepted object family is demonstrated under the runtime that will enforce it, the legacy interface remains separately testable, and uncertainty is recorded as a hold rather than converted into invented compatibility.