Validation¶
oold can check that an OO-LD schema is well formed and that an instance document conforms to
the schema it names. It is a native Python port of the reference harness in
oold-schema (scripts/validate.mjs), so the two agree on
verdicts, and it is available three ways: as a library, as a CLI, and as an MCP server.
Install the extra:
Why more than JSON Schema¶
A property declared in properties but missing from @context is not a JSON Schema error and
not a JSON-LD error. It simply produces no RDF, and the data quietly loses meaning. There are two
distinct failure modes and only looking for the first misses the worse half:
| Mode | What happens | Reported as |
|---|---|---|
| Dropped | The term has no @context definition, so the key vanishes on expansion. |
context.predicates, roundtrip.generated |
| Suspicious | The term maps through a prefix that was never defined. JSON-LD reads schema:latitude as an absolute IRI whose scheme is literally schema, so the key survives, the round-trip is clean, and the predicate means nothing. |
context.predicates |
The second is the dangerous one, because nothing about the output looks wrong.
CLI¶
oold validate path/to/Schema.schema.json # a schema, detected from its $schema
oold validate path/to/doc.instance.json # an instance, detected from its $schema
oold validate path/to/schemas/ # every schema and instance in a directory
oold validate-instance doc.instance.json # a document against the schema it names
oold compliance path/to/compliance/ # a deterministic fixture suite
oold meta list # tracked meta-schema versions
oold meta fetch # refresh the unreleased ones into the cache
oold-validate <dir> is an alias for oold validate <dir>, matching the reference harness's
npx --yes github:OO-LD/oold-schema oold-validate <dir> so CI snippets carry across.
A single file passed to oold validate is classified by its $schema: pointing at the OO-LD
meta-schema or another JSON Schema dialect makes it a schema, any other value makes it an
instance of the document it names, and a file with no $schema at all needs --as-schema or
--as-instance to say which it is. Pass either flag to skip detection and force one reading, for
example when a schema is still being drafted and has no $schema yet.
Exit code is 0 only when no check failed. Warnings do not fail a run.
Options¶
| Option | Meaning |
|---|---|
--meta VERSION |
latest (default), a version such as 0.8.0, remote, or all. Repeatable. |
--offline |
Never fetch; use local files and the cache only. |
--verbose |
Show passing checks too, not just problems. |
--json |
Emit the report as JSON. |
--output FILE |
Write the JSON report to a file. |
Meta-schema versions¶
The meta-schemas belong to oold-schema. This package keeps a hand-curated copy of each released
version under src/oold/validation/meta/<version>/, so validation works offline and so one schema
can be checked against several versions at once.
Three families depend on the version - schema.meta, lint.pattern and the per-rule rule.*
checks - and only those are repeated per version; everything else runs once. Results carry the version they came from, so a
difference between releases is visible rather than confusing. This is reproducible against the
committed fixtures:
$ oold compliance tests/data/oold/compliance --offline --meta 0.7.0 --meta 0.8.0
FAIL tests/data/oold/compliance
meta-schema: 0.7.0, 0.8.0
138 ok, 2 failed, 0 warning(s), 0 skipped, across 52 target(s)
FAIL compliance.lint ... @type: xsd:integer is never selected on the way back ... [0.7.0]
FAIL compliance.lint ... @type: xsd:boolean and xsd:double are rejected ... [0.7.0]
Both failures are real and expected: 0.8.0 extended the no-coercion rule from xsd:string to every
natively-JSON-encoded datatype, so fixtures written for 0.8.0 assert something 0.7.0's lint cannot
catch. The [0.7.0] tag is what tells you this is a version difference rather than a broken schema.
remote fetches the unreleased main state into ~/.cache/oold/meta/ (override with
OOLD_CACHE_DIR). It never writes into the tracked history, so a released version cannot change
meaning behind your back. Adding a version is documented in
Maintaining the vendored meta-schemas and fixtures.
Rule citations¶
Every finding can cite the normative statement it enforces. Rule ids come from the specification's
catalog (meta/oold-rules.json, generated upstream from the spec prose) and are permanent, so they
can be quoted in a review or a changelog:
$ oold validate Author.schema.json --verbose
FAIL OOLD-RT-08f2 lint.container Author.schema.json: strict array property without @container
https://oo-ld.org/latest/spec/#rule-OOLD-RT-08f2
oold rules list # every rule, and the check that enforces it
oold rules list --area RT # just round-trip safety
oold rules list --unchecked # checkable rules no check enforces yet
oold rules explain OOLD-RT-08f2 # level, binding, spec text and link
The catalogue arrived in 1.0.0-rc.1. Older tracked versions predate it and, being released
tags, can never gain one. That is fully supported, and has a deliberate consequence:
| The selected version | What happens |
|---|---|
| ships a catalogue | Findings cite their rule; the rule.* checks run, with severity taken from the catalogue |
| ships none | Findings carry no citation; the rule.* checks are skipped, and coverage.rules reports skip |
Skipping rather than guessing is the point. Each rule.* check enforces one statement, and a
version that never stated it must not be judged against it - the same class of false positive as
judging a schema on its literal rather than its resolved @context.
The same gating applies within a catalogue: a rule absent from that version, or marked
deprecated, is skipped with the reason given. So upstream deprecating a rule stops the
corresponding check as soon as the new version is vendored, with no code change here. Severity
follows too - relaxing a MUST to a SHOULD upstream turns a failure into a warning by itself.
Each rule records who it binds, which decides what can enforce it:
applies_to |
Meaning |
|---|---|
document |
Checkable by validating a schema or instance. These are what the validator can enforce |
implementation |
Constrains a library rather than a document; needs a conformance suite |
advisory |
Guidance that nothing verifies automatically |
coverage.rules reports the gap between the checkable rules and the checks that exist. It is a
warning, never a failure: the gap is what the catalog exists to make visible, and an id absent
from an older catalog is indistinguishable from a typo, so failing would break validation against
older meta versions for no reason. A genuine typo is caught instead by the opt-in parity test,
which resolves every mapping against the current upstream catalog.
The checks¶
| Check | What it asserts |
|---|---|
schema.meta |
The schema validates against the OO-LD meta-schema. |
schema.refs |
Its $ref composition resolves. |
lint.pattern |
No term coerces a literal to a datatype JSON-LD produces by default from a native JSON value. Which datatypes those are is the meta-schema's business, not this package's: 1.0.0-rc.1 lists xsd:string, xsd:boolean, xsd:integer and xsd:double, having moved xsd:float out. |
lint.container |
A strictly type: array property declares @container: @set or @list, or a single-element array returns as a scalar. |
lint.iri-format |
(warning) A bare-IRI-string reference declares an iri-reference or stricter uri* format. |
generate.satisfiable |
A generated instance validates against its own schema, catching unsatisfiable schemas. |
roundtrip.generated |
That instance survives instance → RDF → instance with no property lost, and the reconstruction still validates. |
context.remote |
The schema works as a remote @context. |
context.predicates |
Every declared property produces a grounded predicate. |
variants |
Each oneOf/anyOf branch is generated and round-tripped in turn. |
instance.schema |
A committed instance validates against its schema, with format asserted. |
roundtrip.instance |
It round-trips through RDF unchanged. |
compliance.*, coverage.vocab |
Fixture suites with exact expected outcomes, plus a cross-check that every meta-schema keyword has a test. |
coverage.rules |
(warning) Which checkable rules no check enforces yet. |
rule.checks |
(skip) Recorded when the selected meta version ships no catalogue, so the per-rule checks did not run. |
Single-rule checks¶
Alongside the broad checks above, the rule.* family each enforce exactly one normative
statement and cite it. A MUST fails the run, a SHOULD warns.
| Check | Rule | Asserts |
|---|---|---|
rule.id |
OOLD-VER-001 |
The schema declares a $id. |
rule.id-fragment |
OOLD-CMP-005 |
That $id carries no non-empty fragment. |
rule.range-ref |
OOLD-EXT-005 |
References inside x-oold-range use x-oold-ref, never $ref. |
rule.instance-type |
OOLD-INS-002 |
A pinned type agrees with x-oold-instance-rdf-type. |
rule.free-text-iri |
OOLD-INS-009 |
A property whose range mixes free text with references is not coerced with @type: "@id". |
rule.closed-object |
OOLD-INS-005 |
A schema closing its objects still permits $schema and @context. |
rule.version |
OOLD-VER-002 |
(warning) The schema states x-oold-version. |
rule.id-alias |
OOLD-INS-007 |
(warning) @id is reachable through an alias such as id. |
rule.dialect |
OOLD-EXT-002 |
(warning) $schema names the OO-LD dialect meta-schema. |
rule.processing-mode |
OOLD-EXT-001 |
(warning) The context declares "@version": 1.1 as a JSON number. |
These judge the resolved context, not the schema's literal @context. OO-LD contexts inherit,
so a subclass gets @version and the id alias from its parent; checking the literal form would
report violations that are not real.
Cyclic scoped contexts¶
When a schema's @context references form a cycle - a type whose scoped context embeds itself -
a JSON-LD processor must eagerly validate the recursive context. Neither PyLD nor jsonld.js bounds
that recursion, so such a schema cannot be round-tripped by either. Affected schemas have their
roundtrip.* and context.remote checks skipped with a note; every other check still runs.
Model cyclic edges as references (@type: "@id" plus x-oold-range, no scoped context).
Library¶
from oold.validation import Options, validate_directory, validate_schema
report = validate_schema("Person.schema.json", Options(meta=("latest",), offline=True))
if not report.passed:
for check in report.failures():
print(check.id, check.target, check.message)
print(report.to_dict("summary"))
validate_instance and run_compliance follow the same shape. Every entry point returns a
Report rather than raising: a caller asking about a broken schema wants the explanation.
MCP server¶
A working config is committed at .mcp.json:
{
"mcpServers": {
"oold-validation": {
"command": "uv",
"args": ["run", "--directory", ".", "python", "-m", "oold.validation.mcp_server"]
}
}
}
Transport is stdio. Tools: validate_oold_schema, validate_oold_instance,
validate_oold_directory, run_oold_compliance, generate_oold_instance,
check_context_mapping, list_meta_versions, list_oold_rules. Each takes verbosity as "summary" (default) or
"full", and returns errors as data rather than raising.
Differences from the reference harness¶
The two are intended to agree on verdicts. Where they differ, it is deliberate. The comparison is
pinned to scripts/validate.mjs at v1.0.0-rc.2,
a tag rather than a moving path, because upstream intends to replace that script with this
implementation:
| Difference | Why |
|---|---|
Remote and cross-directory @context references resolve |
The reference's loader does no network I/O at all: it maps only names directly under its own base to local files and throws for everything else, so a genuinely remote URL and a local reference that merely leaves the directory are refused identically, and a schema whose context chain leaves the directory cannot be processed at all. --offline reproduces that same refusal here. |
| Remote references are resolved and cached on disk | The reference refuses network fetches outright, so nothing is ever fetched to cache in the first place; this package instead follows a remote @context reference and caches what it fetches, so a repeated run does not refetch it. |
context.predicates exists |
Catches undefined-prefix terms, which round-trip cleanly while meaning nothing. |
| Results are reported per meta-schema version | Multi-version validation is not available upstream. |
| Generation is deterministic | The reference's faker already populates every property (alwaysFakeOptionals); making it deterministic removes flaky CI failures. There is no --seed. |
An author-pinned id (const/enum/default) is not rewritten |
The reference rewrites every generated id unconditionally, which would make such an instance violate its own schema. |
pattern-constrained strings get a placeholder |
Neither corpus uses pattern; this avoids a regex-generation dependency. Reported as a note. |
Check counts differ too, because this port splits some of the reference's combined sections.
Verdicts must not: tests/test_validation/test_parity_live.py asserts that against a real
checkout.
Testing against the upstream repository¶
Without that variable the parity tests skip and the suite stays self-contained. With it, the full
validator runs over oold-schema's own examples/ and examples/compliance/, and the overall
verdict is compared against node scripts/validate.mjs.
The format assertions are pinned separately: tests/data/format_parity.json holds 98 outcomes
captured from ajv as the reference configures it (ajv-formats in full mode, plus the
iri/iri-reference override validate.mjs applies), and the suite asserts Python agrees on
every one. Two are easy to get wrong: in full mode time requires an offset and email requires
a dotted domain.