Skip to content

8. Conformance and errors

8.1 Status of this chapter

The error-code taxonomy in §8.3 is new normative content. No implementation emits it today. It is written here because conformance testing needs stable, language-independent identifiers, and comparing human-readable message strings across three languages is not a test, it is a coincidence.

The prior art, stated accurately so the migration cost is visible:

Implementation What exists now
Python (reference, beta) Validation and materialization results carry kebab-case codes: shape-mismatch, unexpected-field, cardinality, null-not-allowed, type-mismatch. lint carries its own: unsatisfiable-record, unreachable-record, duplicate-record, any-field. Everything else is a bare exception class (DocumentError, SchemaError, ParseError) with a human message and no code.
TypeScript (alpha) Diagnostic records carry a code: string field using the same kebab-case tags as Python's validation codes.
Rust (alpha) Top-level thiserror structs (DocumentError, ParseError, FormatError, WriteError) carry no code. But SchemaError's validation path has an ErrorCode enum rendering the identical kebab-case tags Python and TypeScript use (unexpected-field, cardinality, type-mismatch, null-not-allowed, shape-mismatch), attached to every ValidationError produced by both validate and materialize. An earlier version of this table said "no code field anywhere," which was wrong — Rust has reached the same partial convergence as the other two.

There is a second, separate family of prior art this table previously missed entirely: format-adjustment codes. Python's format readers/writers already emit dotted (not namespaced) codes on their WriteReport/check_* results — temporal.stringified (a temporal leaf stringified to ISO-8601 for a format with no native temporal type), null.omitted (TOML/XML, a null-valued leaf dropped since the format can't represent it), float.special (JSON, a NaN/Infinity/-Infinity substituted with null in lenient mode), key.sanitized (XML), string.ambiguous (XML, a string value that would read back as a different type), shape.empty_ambiguous, string.illegal_xml_char, string.cr_normalized, and string.line-break-char (all XML). These map directly onto §8.3.8's format.* family below — the migration here is mostly a rename, not new design, which the original wording of this section didn't make clear.

So there is partial, undocumented convergence on validation codes across all three implementations, and a second, separate area of already-shipped convergence on format-adjustment codes (Python only, so far) — and nothing at all outside those two areas. There has never been a cross-language convention. This chapter proposes one.

Migration. The kebab-case validation tags all three implementations already share are the closest prior art and map cleanly onto §8.3's validate.* family. All three SHOULD migrate to the namespaced form. None of this is required for the current release; it is required before a version of this spec declares §8.3 mandatory.

Until then, an implementation is conformant on error behavior — which inputs fail, and where — without being conformant on error codes. The test-vector format in §8.5 separates the two so that can be measured.

8.2 Code format

A code is a lowercase, dot-separated path. Each segment is [a-z][a-z0-9-]*. The first segment is the family. Codes are stable identifiers: once published, a code's meaning MUST NOT change. Retiring a code means adding a new one and leaving the old one documented as retired.

Codes are not messages. An implementation MUST emit a human-readable message alongside the code, and the message MAY be localized, reworded, or improved at any time. Conformance tests match on codes and paths, never on message text.

Every diagnostic carries at least:

Field Meaning
code The identifier from §8.3
path Location in the Document or schema; see §8.4
message Human-readable, actionable, unversioned
severity error, warning, or info

8.3 The taxonomy

8.3.1 parse.* — text to Document, stage 1

Code Raised when
parse.unexpected-token A token appears where the grammar does not allow it
parse.trailing-content Content remains after the document's single node
parse.unterminated-string A string is not closed before end of input
parse.invalid-escape An unrecognized backslash escape
parse.unpaired-surrogate A \uXXXX surrogate escape without its partner
parse.control-character A literal control character in a context that forbids it
parse.reserved-word-label null, true, or false used as a bare label
parse.bare-word A bare identifier in value position that is not null/true/false
parse.empty-array [] in OML value position
parse.nested-array An array element that is itself an array
parse.separator-in-array A newline or ; used as an array separator

8.3.2 document.* — building and limits

Code Raised when
document.limit.depth Nesting exceeds the implementation's configured depth limit
document.limit.nodes Node count exceeds the implementation's configured node limit
document.limit.int-digits An integer literal exceeds the implementation's configured digit limit
document.unlabeled-element An input construct has no label to become an edge

The three document.limit.* codes correspond to the three quantities in §2.4. There are three, they are flat, and no implementation may add a fourth or split one into tiers. The codes are fixed; the threshold that triggers each one is not — an implementation MAY configure any of the three limits to a value other than the reference default, per §2.4, but whatever value it configures, crossing it MUST raise exactly this code, never a different one and never silently.

8.3.3 schema.* — schema well-formedness

Code Raised when
schema.no-root No root declaration
schema.unknown-type A type name resolves to neither a scalar nor a defined record
schema.duplicate-record A record name is defined twice
schema.duplicate-field A label is used by two fields in one record
schema.reserved-name A record is named after a scalar kind or any
schema.invalid-cardinality Negative minimum, or maximum below minimum
schema.non-integer-cardinality A cardinality bound is not a whole number
schema.empty-cardinality [] written as a cardinality
schema.unquoted-label A bare name in field-label position
schema.nullable-ref ? applied to a reference
schema.nullable-any any?

8.3.4 validate.* — document against schema

Code Raised when Existing tag it replaces
validate.shape-mismatch A value where a node is expected, or the reverse shape-mismatch
validate.type-mismatch A value of the wrong scalar kind type-mismatch
validate.null-not-allowed null at a non-nullable scalar null-not-allowed
validate.unexpected-field A label no field of the closed record names unexpected-field
validate.cardinality An edge count outside [min, max] cardinality

8.3.5 materialize.* — schema-directed deserialization

Code Raised when
materialize.inexact-conversion A leaf cannot be converted to the declared type without loss or invention

Shape and cardinality problems found during materialization use the validate.* codes above. Materialization performs the same checks; there is no reason for a second set of names.

8.3.6 algebra.* — operations over schemas

Code Raised when
algebra.extract-invalidates-root extract's keep set removes a label the root needs
algebra.infer-no-samples infer called with zero samples
algebra.infer-scalar-root A sample's root is a value rather than a node
algebra.infer-conflicting-scalars Samples disagree on a scalar kind, other than integer/number

8.3.7 lint.* — schema diagnostics

These are the only codes that already exist in namespaced-adjacent form. The bare tags MUST become:

Code Severity
lint.unsatisfiable-record warning
lint.unreachable-record warning
lint.duplicate-record warning
lint.any-field info

8.3.8 format.* — codec adjustments

Code Severity Meaning
format.temporal-stringified warning A temporal leaf was written as an ISO-8601 string
format.float-special error NaN or an infinity was substituted with null
format.null-unrepresentable error A null leaf cannot be written in the target format
format.attribute-dropped warning An XML attribute was discarded on read
format.namespace-dropped warning An XML namespace prefix was discarded on read
format.interleaving-lost warning Cross-label interleaving could not be written
format.multiple-roots error A multi-root Document cannot be written to a single-root format

Two of these describe behavior that is currently silent — attribute and namespace dropping are not reported at all today. Adding the report is a behavior change and needs a test vector before it lands.

8.3.9 write.*

Code Raised when
write.unsupported-value A value has no representation in the target format and strict mode is in force

8.4 Paths

A path locates a diagnostic. Paths are normative and MUST be byte-identical across implementations, because conformance vectors match on them.

Document paths start at $ and descend by label. A repeated label is disambiguated by a zero-based occurrence index in brackets.

$                        the root node
$.name                   the single edge labeled `name`
$.item[0]                the first edge labeled `item`
$.item[2].sku            `sku` inside the third `item`

The index MUST be present when the label occurs more than once in that node, and MUST be absent when it occurs exactly once. This keeps the common case readable.

Unlike the codes above, this path format is not new: it is the format the Python reference already emits, verified directly — a failure at the second of two i edges reports $.i[1].q, and a failure at a singly-occurring label reports $.name with no index. The rule is documented here rather than proposed.

Schema paths are RecordName for a record-level diagnostic and RecordName.label for a field-level one. This matches what lint already reports.

Text-position paths are for parse.* diagnostics (§8.3.1) — stage 1 fails before any Document exists, so there is no $-rooted structure yet for a Document path to descend into. The format is line:col, 1-based, computed from the byte offset of the failure:

1:1                      the very first character
14:8                      line 14, column 8

This is new normative content, unlike the two path forms above. Python already computes 1-based (line, col) from a byte offset internally (oml.py's line_col) but only ever embeds it in the diagnostic's human-readable message string ("line 14, col 8: ..."), never as a structured path — which is exactly why no cross-implementation convention exists yet to document. This section specifies one rather than describing one already in use, the same way §8.3's code taxonomy does.

A parse.* diagnostic's path MUST be a text-position path. A document.*, schema.*, validate.*, materialize.*, algebra.*, or lint.* diagnostic's path MUST be a Document or Schema path, per the two forms above — never a text-position path, since a Document or Schema already exists by the time any of those families can fire.

8.5 Conformance harness protocol

A conformant implementation passes the vectors in test-suite/. Vectors are JSON, one case per object, grouped into files by operation.

8.5.1 Common envelope

{
  "name": "unique-vector-id",
  "spec": "docs/03-schema-model.md#36-validation",
  "operation": "validate",
  "input": { },
  "expect": { }
}
  • name MUST be unique across the whole suite. A harness reports results keyed on it.
  • spec points at the section the vector pins. A vector with no section to point at is a vector testing something unspecified, which is a spec defect.
  • operation selects the driver.
  • expect holds either a success value or a diagnostics list.

8.5.2 Diagnostics matching

"expect": {
  "ok": false,
  "diagnostics": [
    { "path": "$.port", "code": "validate.type-mismatch" }
  ]
}

Matching rules, all normative:

  1. Message text MUST NOT be compared.
  2. The diagnostic list MUST be compared as a set, not a sequence. Ordering of diagnostics is not specified and implementations may find problems in any order.
  3. Every expected diagnostic MUST be present, and no unexpected diagnostic may be. Partial matching is not permitted; an implementation reporting three problems where the vector expects two has failed.
  4. A harness MAY be run in code-agnostic mode, comparing only ok and the set of paths. This is the mode implementations that have not yet adopted §8.3 run in. A run MUST state which mode produced its results.

8.5.3 Operation drivers

operation input expect
parse {format, text} {ok, document} or diagnostics
validate {schema, document} {ok} or diagnostics
materialize {schema, format, text} {ok, document} or diagnostics
compatible_with {a, b} {result: bool}
equivalent {a, b} {result: bool}
normalize {schema} {osd} — canonical OSD text, compared byte for byte
prune {schema} {osd}
is_empty {schema} {result: bool}
extract {schema, keep} {osd} or diagnostics
infer {samples} {osd}
lint {schema} {findings} — set of {code, location}
write {document, format} {text} or diagnostics

Schemas in input are OSD text. Documents are given in the canonical JSON encoding of §8.5.4, not in a format-specific text, except where the vector is specifically testing a parser.

8.5.4 Canonical document encoding

A Document must be written into a JSON vector file without JSON's own map-and-array shape smuggling assumptions back in. The encoding is explicit:

{"scalar": {"kind": "integer", "value": 42}}
{"edges": [["name", {"scalar": {"kind": "string", "value": "Ann"}}],
           ["tag",  {"scalar": {"kind": "string", "value": "x"}}],
           ["tag",  {"scalar": {"kind": "string", "value": "y"}}]]}
  • A node is {"edges": [[label, target], ...]}. The outer array preserves order; repeated labels appear as repeated entries.
  • A scalar is {"scalar": {"kind": K, "value": V}} where K is one of the seven kinds. Temporal values are ISO-8601 strings. integer values are JSON numbers when they fit exactly and decimal strings otherwise.
  • null is {"scalar": {"kind": null, "value": null}}.

This is verbose on purpose. A vector file must not depend on the reader's JSON library to decide whether 1 is an integer or a number.

8.5.5 Reporting

A harness run reports, per vector: pass, fail, or skip. Skip is a first-class result. An implementation that has not built extract yet skips those vectors and reports the count. A run that hides skips as passes is worthless for tracking convergence, which is the whole point of chapter 9.