Skip to content

8. Conformance and errors

8.1 Status of this chapter

The error-code taxonomy in §8.3 is normative and mandatory. It exists because conformance testing needs stable, language-independent identifiers — comparing human-readable message strings across languages is not a test, it is a coincidence. An implementation is conformant on error behavior — which inputs fail, and where — and on error codes: it MUST report, for every failure, the code §8.3 assigns to it, at the path §8.4 assigns to it. This is what E-1, E-2 and the other rules of this chapter already say; this section no longer carries a transition exception to them. See the §8.3 error codes row in §9.3 for current per-port status.

§8.5 still separates behavior from codes, so that a failing run can say which of the two is wrong. That separation is a diagnostic aid, not a conformance level: §8.5.2 rule 4 keeps a code-agnostic mode for bringing up a port that has not yet adopted §8.3, and results from it are not a conformance claim.

8.2 Code format

E-1. 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.

E-2. 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 — inside {...} or [...], or mid-edge, including an unterminated array whose newline or ; is followed by no element (OML-28); not content after the document has ended, which is parse.trailing-content
parse.trailing-content Content remains after the document has ended — after its single scalar (OML-25) or after a complete top-level edge (OML-26), with or without a separator in front of it
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: it stands where a comma was owed and an element follows it (OML-28). With nothing following it the code is parse.unexpected-token
parse.leading-zero A NUMBER/INTEGER literal's integer part has a leading zero
parse.invalid-date A DATE or the date portion of a DATETIME is not a valid calendar date
parse.invalid-time A TIME, the time portion of a DATETIME, or a tz-offset is out of its valid clock range
parse.codec-syntax Input a codec cannot accept: not well-formed in its own source format (JSON, YAML, TOML, XML), or refused by a precondition this spec imposes ahead of the codec — see the note below. Not the code for invalid UTF-8, which has its own row
parse.invalid-encoding Input is not valid UTF-8 (D-14). Its path is always 1:1, on every surface, whatever byte failed and wherever it sits

E-25. parse.trailing-content and parse.unexpected-token divide on whether the document has ended, not on what is missing. The two rows above are one line apart and the same input can look like either, so the boundary is stated here as well as in chapter 4. Content standing after a complete top-level document body — a bare scalar (OML-25) or a complete edge (OML-26) — is trailing content, because the body is finished and what follows is a second document. A separator in front of the content makes no difference: a newline or ; is a legal end to the body and the document may already be over, so a: 1 newline } is trailing content exactly as a: 1 } is. (The one thing that is not leftover is a token that can begin an edge standing after a separator: that is the next edge, per OML-26.) Inside {...} or [...] a closing delimiter is still owed, so the same missing separator is a token the grammar does not allow there and MUST be reported as parse.unexpected-token (OML-27). Neither code is a general fallback for the other. One case inside [...] has a code of its own: a newline or ; where a comma was owed, followed by an element, is parse.separator-in-array; followed by nothing an element could start, it is parse.unexpected-token (OML-28).

E-3. Six of these codes also cover OSD's own lexical stage: parse.unexpected-token, parse.trailing-content, parse.unterminated-string, parse.invalid-escape, parse.unpaired-surrogate, parse.control-character. OSD produces a Schema rather than a Document, but tokenizing OSD source text is the same kind of operation as tokenizing OML source text — a lexical failure before any semantic well-formedness checking has begun (§8.3.3 covers well-formedness, not lexing) is the same class of error regardless of what stage-2 structure a successfully-tokenized input eventually becomes. An unterminated string or an unexpected character in OSD source MUST be reported with the matching parse.* code above, never a schema.* code and never an implementation-invented code outside this taxonomy. The remaining five codes (reserved-word-label, bare-word, empty-array, nested-array, separator-in-array) describe OML's value grammar specifically and have no OSD equivalent.

Codec read failures use parse.codec-syntax — input that is not well-formed in its own source format, and, since D-21, input a byte-level precondition this spec imposes ahead of the codec refuses (E-24 below; D-21's second-leading-U+FEFF check is the only such precondition today). Every other code in this family describes OML or OSD specifically, so before this existed there was nothing to report for malformed JSON, YAML, TOML or XML, and an implementation had to invent a name for the most common failure a codec has. The message SHOULD name the format and carry through whatever the underlying parser said, since that detail is what makes the failure actionable; §8.5.2 never compares message text, so this costs nothing in conformance.

E-24. A precondition this spec imposes ahead of a codec reports here too, even where the text would have been well-formed in its own format. There is exactly one such precondition today: D-21's rejection of a second leading U+FEFF, which YAML and XML would otherwise accept and JSON's own rules leave open. This is a deliberate widening — of the table row above and of the paragraph before it, both of which said only "not well-formed in its own source format" before D-21 landed. It belongs here because of where the check sits: it runs on the input text after D-14 has established that the input is valid UTF-8 and D-15 has stripped the leading mark, and before the codec's own parsing begins, so nothing format-specific has happened yet. It fails the read outright with ok: false, and no other family in this taxonomy describes a refusal at that point. Putting it anywhere else would mean a new code that conveys nothing the message does not already say — the same argument that keeps one parse.codec-syntax across all four formats rather than four.

D-14 is not one of these preconditions, though it is checked earlier than any of them — on the bytes, which is why E-24's subject is a precondition on the text and D-14 is not stated as one. It has its own code: a codec handed bytes that are not valid UTF-8 MUST report parse.invalid-encoding, not parse.codec-syntax, whatever its parsing library would have said about them — and at 1:1, per D-14. The two are distinguishable by when they run. D-14 fires on the bytes, before any text exists for a codec or a grammar to have an opinion about; D-21 fires on text that has already decoded cleanly. So D-21 is the one precondition E-24 widens parse.codec-syntax to cover, not a general home for every byte-level refusal.

One code covers all four formats deliberately. Splitting it per format would add three codes that convey nothing the message does not already say, and nothing in the algebra or the harness branches on which codec failed.

Distinguish this from the codec-adjustment codes in §8.3.8: those describe something about the shape Omnist would have to build from input that is well-formed in its source format — either refusing it as outside the profile Omnist supports (format.dtd-forbidden, format.entity-forbidden, format.mixed-content, format.tag-forbidden, all error severity) or recording what the shape cost (format.attribute-dropped, format.namespace-dropped, warnings). parse.codec-syntax means the read failed before any such shape existed: the input is genuinely malformed, or it fell at the one precondition E-24 names. Reporting a profile refusal as a syntax error, or the reverse, sends a user looking in the wrong place.

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 The value of an integer literal has more decimal digits than the implementation's configured digit limit (D-28)
document.limit.alias-expansion The expansion factor of an anchored definition, of any other mapping or sequence, or of the document root exceeds the implementation's configured maximum, in a format with an anchor/reference mechanism
document.limit.expanded-size The number of value slots an input that contains an alias or merge key materializes, W of the document root, exceeds the implementation's configured maximum, in a format with an anchor/reference mechanism
document.limit.input-size The input is larger, in bytes, than the implementation's configured maximum input size, in any format (D-23)
document.unlabeled-element An input construct has no label to become an edge

E-4. These six document.limit.* codes correspond exactly to the six quantities in §2.4 and §2.4.2 — no seventh, no tiers. The codes are fixed; the threshold that triggers each one is not — an implementation MAY configure any of the six 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. The expanded size has a code of its own, rather than sharing document.limit.alias-expansion, because this rule gives each quantity exactly one code and because the two refusals say different things: an alias-expansion input amplifies, an expanded-size input is large, and a caller answers them differently. The input size is the one quantity whose enforcement is a SHOULD (D-23); the code is fixed all the same, so an implementation that enforces a maximum raises exactly document.limit.input-size when it is crossed.

E-4a. The first three apply to every Document on every route into the model. When the Document is read from JSON, YAML, TOML or XML text, the diagnostic is the one the OML reader raises for the same Document: document.limit.depth and document.limit.nodes at $, and document.limit.int-digits at the Document path of the integer literal, indexed per E-10, never a text position (E-11). The schema-directed pretyping of an XML leaf is one of these routes (§7.1): an XML leaf the schema types as integer, whose text is an integer literal with more digits than the limit, is refused with document.limit.int-digits at the Document path of that leaf, the diagnostic the OML reader gives for the same Document. It is not left a string for materialization to reject as materialize.inexact-conversion, and it is not an uncoded failure. The digits counted are those of the value (D-28), as on every other route. Worked, and prose only: no vector pins this, since the parse driver takes no schema and materialize takes a Document, so a port implements it from this text. Under the schema record R { "n": integer }, record Root { "r": R }, root Root (the shape of the XML worked example: the document element is a field of the root record), the document <r><n> followed by 4 301 digits and </n></r> is refused with document.limit.int-digits at $.r.n; if n repeats, each occurrence over the limit is at its indexed path ($.r.n[0], $.r.n[1], E-10). The path is the leaf's, never $ and never a text position, and the code is never materialize.inexact-conversion. The check runs whenever the schema types the leaf as integer, in the library and in a command-line materialize alike, and the limit is the configured digit limit (D-9), not a separate one for XML. document.limit.alias-expansion and document.limit.expanded-size are different in reach, not in kind: per D-18 and D-22 they are raised only by a codec for a format that has an anchor/reference mechanism — YAML alone among the five formats today — and cannot arise from a programmatic construction, which has no anchors to expand. An implementation whose codecs have no such mechanism will never raise them; an implementation whose codecs do MUST raise exactly the matching code when its configured maximum is crossed, and MUST NOT report an over-expansion as document.limit.nodes on the grounds that a large expansion would have hit the node cap eventually. When one input crosses both, the code is document.limit.alias-expansion (D-22). document.limit.expanded-size is never raised for an input with no alias and no merge key, however large. document.limit.input-size differs from all five: it applies to every read of a Document from text or bytes, in every format, and is raised before decoding or parsing, so it is reported ahead of parse.invalid-encoding and every other diagnostic. Its path is $: the violation is a property of the input as a whole, its length, found before any Document structure exists to descend into. It cannot arise from a programmatic construction. Its path is a Document path, per E-11, and it is $ — the violation is a property of the input's reference graph as a whole, detected before any Document structure exists to descend into.

8.3.3 schema.* — schema well-formedness

Code Raised when
schema.no-root No root declaration
schema.duplicate-root More than one root declaration
schema.unknown-type A type name resolves to neither a scalar nor a defined record — covers a dangling ref, and equally a scalar naming something outside the fixed keyword set
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.empty-label A field label is the empty string (S-25)
schema.bracket-in-label A field label contains [ or ], which could collide with the repeated-label diagnostic-path convention (§3.6.1) (S-26)
schema.quoted-type A quoted string in type position
schema.nullable-ref ? applied to a reference
schema.nullable-any any?
schema.missing-key A required key of the schema-construction input (a field's label or type, a record's name, a type's kind) is absent entirely
schema.invalid-type A value that must be a specific kind (a label, a name) is present but of the wrong kind
schema.invalid-name A record name, or a Ref's target name, does not match [A-Za-z_][A-Za-z0-9_]* (S-8)
schema.invalid-label A field label is not valid UTF-8, in a schema built programmatically (S-22)
schema.unknown-record A caller-supplied record ordering names a record absent from env (S-23)
schema.unknown-key An unrecognized key appears anywhere in the schema-construction input

A schema with several well-formedness violations is rejected with at least one of them; which are reported is implementation-defined. Every existing parse_schema and parse_schema_oml vector carries exactly one.

schema.invalid-label and schema.unknown-record have no text or Document analog either: OSD text and OSD-OML both arrive as valid UTF-8 (D-14) and fix record order by declaration, so only direct programmatic construction reaches them, which is why the suite has no vector for either (§8.5).

schema.unquoted-label and schema.quoted-type are the two directions of §5.2's quoting rule — a bare name belongs only in type position, a quoted string only in label position, and each direction gets its own code.

schema.missing-key, schema.invalid-type, and schema.unknown-key are general Schema-construction rules with no OSD-text analog — OSD's own grammar makes them structurally impossible to trigger from OSD text (a field with no type, a non-string label, or a stray key cannot be written in OSD's syntax at all). They become reachable only through an input whose structure isn't grammatically constrained the way OSD text is — for example, Extensions: OSD-OML, which builds a Schema from a generic OML Document instead of an OSD parse tree. They are defined here, not in that extension's own chapter, because the schema-construction validator that raises them is shared, unchanged, Core machinery — the extension supplies a second input shape, not a second set of rules.

8.3.4 validate.* — document against schema

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

Non-normative. These five are the codes a user meets most often, since they fire on ordinary data rather than on a malformed schema or a broken implementation. Each diagnostic pairs one with a path (§8.4) locating the offending edge — $.order.items[2].sku names the sku inside the third items edge — so the useful reading order is path first, then code. What each typically means in practice:

Code Usually means
validate.shape-mismatch A scalar where the schema expects a record or the reverse — often a value that should have been wrapped in an object, or a single-field object flattened by an upstream tool
validate.type-mismatch Right shape, wrong scalar kind — most often a number that arrived as a string, common when data came from CSV, form input, or any format without typed literals
validate.null-not-allowed An explicit null where the field is not nullable. If absence is what you meant, the fix is usually cardinality [0,1], not a nullable type — see §3.5
validate.unexpected-field A label the record does not declare. Records are closed, so this is a typo, version skew between producer and schema, or a field that genuinely needs adding
validate.cardinality Too few or too many edges carrying one label — a missing required field, or repetition where the schema allows one

The two most often confused are null-not-allowed and cardinality. "May be absent" and "may be null" are different questions with different mechanisms, and reaching for the wrong one is the most frequent modelling error in Omnist; §3.5 exists for exactly that.

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, so there is no separate set of names for them.

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
algebra.infer-mixed-shape Samples disagree on whether a label's value is a node or a scalar

8.3.7 lint.* — schema diagnostics

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 (date, time or datetime) was written as an ISO-8601 string
format.dtd-forbidden error A DOCTYPE declaration, outside the data-XML profile (XML)
format.entity-forbidden error An entity reference other than XML's five predefined ones
format.mixed-content error Text alongside child elements in one element, which has no Document shape
format.tag-forbidden error A YAML node tagged !!pairs, !!omap or !!set, outside the supported profile (C-13)
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
format.string-line-break-char warning A label or value contains U+0085 (NEL); written quoted so it round-trips (in YAML, with the \N escape, E-34)
format.value-stringified warning A non-string, non-temporal scalar was written as text in a format with no native typed literals for it, so it reads back as a string; a temporal leaf takes format.temporal-stringified instead, never both

E-5. format.attribute-dropped, format.namespace-dropped, and format.interleaving-lost MUST be emitted wherever the codec adjustment they describe occurs, with a conformance vector for each. The same holds for format.value-stringified: a writer MUST emit it wherever a non-string scalar is written as text, once per leaf, at the Document path of the leaf (indexed per E-10); formats-xml/stringified/* pins it. The same holds for format.temporal-stringified in XML (E-37 generalises it to every format that writes a temporal as a string): an XML writer MUST emit it, once per leaf and at the same path, wherever a date, time or datetime leaf is written as its ISO-8601 text (<d>2024-01-15</d>), and MUST NOT also emit format.value-stringified for that leaf: a leaf takes one of the two codes, the temporal one for the three temporal kinds and the value one for the rest. Per-implementation status lives in §9.3's table and nowhere else.

The same holds for format.string-line-break-char, which a YAML writer MUST emit, once per string value, at the Document path of the leaf, wherever it writes a value containing U+0085 (E-34).

Every code above describes a write that still succeeds, and — this is the test that matters, not merely "is there only one available fallback" — none of them can produce output indistinguishable from some other, genuinely different, independently-valid Document. format.value-stringified and format.temporal-stringified look like exceptions on the surface (XML has no typed literals at all, so an integer and the string that looks like it do write identically, with no way to tell them apart even with a schema at read time — confirmed live), but that ambiguity is unavoidable for every typed scalar XML ever writes, not a choice Omnist's writer is making; failing here would mean XML could never write a single typed value, not that it handles a rare edge case more strictly. That's the same category format.interleaving-lost is already in.

E-6. Five adjustments that used to be in this table are not like that, and MUST NOT be treated as ones a writer can choose an arbitrary fallback for and still succeed — each one collides with a genuinely different, independently-valid input, in a way that is narrow (it doesn't affect every write of that kind, only a specific case) and therefore avoidable by simply failing on that case, rather than disabling the format broadly:

  • A label isn't a legal identifier in the target format (e.g. a space in an XML tag name) — format.key-sanitized. Confirmed live: two different labels can sanitize to the same tag ("my label" and "my_label" both becoming <my_label>), producing, on read-back, a Document that looks like one label legitimately repeated twice.
  • A string contains a character the target format cannot represent at all (e.g. a raw C0 control character XML 1.0 forbids) — format.string-illegal-char.
  • A null leaf cannot be represented in the target format at all (e.g. TOML, which has no null token of any kind, and XML, whose only candidate spelling, the empty element <a/>, reads back as the empty string, C-10) — format.null-unrepresentable. There is no substitute value at all here, only the option to silently drop the edge entirely, which erases the edge's existence rather than merely altering its value.
  • NaN or an infinity has no legal spelling in the target format (e.g. JSON, whose only sentinel for this is null) — format.float-special. Confirmed live: writing a genuine null value and writing NaN produce the identical JSON token, and both read back as the identical Document value — there is no way, after the fact, to tell a substituted NaN from an original null.
  • An empty internal node has no distinct spelling from an empty string leaf in the target format (e.g. XML's self-closing <x/>) — format.shape-empty-ambiguous. Confirmed live: the internal-node-ness is gone on read-back, silently, with no way to recover it.

A previous version of this spec had all five succeed anyway. The correct behavior for all five is write.unsupported-value (below): the write fails, unconditionally, not only under strict.

E-7. format.dtd-forbidden, format.entity-forbidden, format.mixed-content and format.tag-forbidden are a different shape from the rest of this table. They are read-side refusals, not adjustments: the input is well-formed XML that Omnist declines to support, per the data-XML profile (format.tag-forbidden: well-formed YAML, declined per C-13). Their path is $. They sit here because they are codec-specific conditions, which is what this family is for — but note two consequences. They are error severity and the read fails, so there is no adjusted result to report alongside them. And they MUST NOT be reported as syntax errors: the document is valid XML, and telling a user otherwise sends them hunting for a defect that is not there.

E-8. A sixth code, format.string-cr-normalized, used to exist in this table for a related reason — XML mandates line-ending normalization on parse, so a literal \r byte and a literal \n byte, written as-is, are indistinguishable on read-back, the same collision shape as the five above. That code no longer exists, but not because the write now fails: a literal \r MUST be written as the numeric character reference &#13;, not as a raw byte. XML's mandatory normalization applies only to literal line-ending bytes in the source text, not to a numeric character reference — confirmed live that &#13; (and &#13;\n for \r\n) survives a compliant parser and reads back as the exact original byte sequence. Once the writer does this, the write is genuinely lossless, not merely reported-as-lossy — there is nothing left to adjust or warn about, so no diagnostic code is needed for this case at all.

E-34. A YAML writer writes U+0085 (NEL) as the escape \N. A string value or label containing U+0085 is written as a double-quoted YAML scalar in which each NEL is the two characters \N, never as the raw character: "x\Ny" for the string x NEL y. YAML 1.1 reads a raw NEL inside a scalar as a line break and folds it, so the raw character does not round-trip, and \N is the one YAML escape defined for it (YAML 1.2 keeps \N), which any YAML reader resolves back to the single character. The writer MUST also report format.string-line-break-char (warning) once per such string value, at the Document path of the leaf, indexed per E-10; the path of the diagnostic for a label holding NEL is not decided here. The \N escape is the whole adjustment, so a reader sees the original string back and the write succeeds. The code is reported although the write is lossless, unlike E-8's carriage return, which has none: the code already existed in this table, and the escape is the one place a YAML writer overrides its default scalar style for a character that YAML 1.1 consumers fold, which a caller auditing the output wants to see. It is a warning, not a loss. How the other writers treat U+0085, and whether they report the code, is not decided here.

E-37. A writer never alters a temporal value, always writes its seconds, and stringifies, with format.temporal-stringified, where the format has no exact literal. A date, time or datetime (§2.2.1) is written in every format by these rules:

  1. The value is never altered. A writer MUST NOT convert a datetime or a time to UTC or to any other offset, drop or add an offset, drop or round a fractional second, or change the date. 2024-01-15T12:00:00+01:00 is written with +01:00 and the clock reading 12:00:00, not as 2024-01-15T11:00:00 or …Z.
  2. The seconds are always written. A time or datetime is written with its seconds field in every format that spells them: a value read from OML's 12:00, which §4.2.4 admits, is written as 12:00:00, never 12:00. The offset is written as the value holds it (+01:00), after the seconds and any fraction. How many digits a fractional second is written with is not pinned (OML-30), but a writer MUST write every digit the value holds, so a fraction of six digits with a non-zero last digit has one spelling.
  3. A native literal is used where one holds the value exactly. OML has one for all three kinds. TOML has one for a date, a time without an offset (local time), a datetime without an offset (local date-time) and a datetime with an offset (offset date-time), written in its ISO-8601 spelling with the T separator (2024-01-15T12:00:00+01:00). YAML has one for a date and for a datetime, written the same way, with the T separator and no quotes, the form YAML 1.1's timestamp resolves.
  4. Everywhere else the writer writes the value's ISO-8601 text as a string and reports format.temporal-stringified, a warning, once per leaf at the Document path of the leaf indexed per E-10, and does not fail (the write succeeds, the value is intact). That is: every temporal in JSON and in XML (E-5), a time with an offset in TOML (TOML has no offset time), and every time in YAML (YAML 1.1 has no time-of-day type, and an unquoted 12:00:00 would be read as a sexagesimal integer and 12:00:00.5 as a float). A string TOML or YAML writes here is double-quoted, as E-34 writes its YAML strings ("12:00:00+01:00"). A writer MUST NOT instead fail, crash, drop the offset, or write a literal its target grammar would read as another value.

The spelling pins of rule 3 and rule 4 (the T separator, double quotes) exist only so that write vectors are byte-comparable: a reader accepts the equivalent spellings (RFC 3339's space separator, YAML single quotes). What "the same value" means here is the clock reading: an omitted seconds field is 00, trailing zeros of a fraction are insignificant, and the offset is kept; whether Z and +00:00 are the same offset is not specified.

Rule 4 generalises E-5, which stated the XML case, and E-37 does not touch what a reader does with such a string: it reads it back as a string, which is what the code warns of. The suite pins this with write vectors under formats-*/temporal-writing/* and formats-oml/temporal/*, using values with seconds, an offset and a six-digit fraction so that every writer's spelling of each is forced.

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, or a label/string/null leaf/special-float/empty-node cannot be represented at all in the target format's own syntax without colliding with some other, distinct, valid input (unconditional, regardless of strict; a null leaf in TOML or XML, C-10), or a string value or label has no UTF-8 encoding (C-9, unconditional)

E-26. "Target format" includes OSD, not only the four codecs. The schema-writing surfaces are writers like any other and this is the code they use: an OSD writer handed a schema whose field label carries a C0 control character MUST fail with write.unsupported-value, unconditionally, per OSD-14 — §5.3.1 leaves that label no OSD spelling, so there is nothing to emit that any conformant OSD reader would accept. The second clause of the row above already covers it ("a label … cannot be represented at all in the target format's own syntax"); what was missing was anyone saying OSD is one of the formats that clause ranges over. OSD-16 applies the same rule, at the same path, to a field of max = 0. The path is the Schema path of the record holding the unwritable field — R, not R.<label> — because a schema path has no way to quote a label (E-36 quotes Document-path labels only) and the label here is precisely the thing with no spelling; putting the byte in a byte-compared path would restate the problem rather than report it.

8.4 Paths

E-9. A path locates a diagnostic. Paths are normative and MUST be byte-identical across implementations, because conformance vectors match on them. The one exception is the exact value of a parse.codec-syntax position, which is implementation-defined within the bound E-31 states.

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`

E-10. The index MUST be present on every edge of a label that occurs more than once in that node, the first included, and MUST be absent when the label occurs exactly once. The count is of the edges that node holds, not of the cardinality a schema declares: a field declared [1,] whose label occurs once paths as $.item, and the same label occurring twice paths as $.item[0] and $.item[1]. The rule is applied per node, so $.item[1].tag and $.item[0].tag[1] can both appear in one result. It governs every Document path, an undeclared field's unexpected-field included. A cardinality diagnostic is at the parent node's path (§3.6), so it carries no index of its own.

E-36. A label that would make a Document path ambiguous or unsafe to print is written bracket-quoted. A label is any Unicode string (§2.2.2), but the path grammar above spends ., [ and ] as punctuation, so a raw label could make two different edges print the same path (b inside a and a.b beside it both printed $.a.b), could pass for an index (item[0]), and could carry a line break into a log. A label segment is therefore written one of two ways, chosen by the label alone:

  • Bare, as .label, for every label that does not need quoting. This is the form every example above uses, and it is unchanged: a label such as name, a b, $, é or 0 stays bare.
  • Quoted, as ["label"], with no dot before the bracket, for a label that needs quoting: one that is empty, or that contains any of . [ ] " \, or any control character (U+0000 to U+001F), U+007F, U+0085 (NEL), U+2028 (LS) or U+2029 (PS). The label sits between double quotes with these escapes and no others: \" for ", \\ for \, \n, \r and \t for those three characters, and \u followed by exactly four lowercase hexadecimal digits (C-14) for every other control character, U+007F, U+0085, U+2028 and U+2029 (\u0000, \u001f, \u007f, \u0085). Every other character, including ., [, ] and all non-ASCII text, is written as it is. Each label has exactly one spelling.

An E-10 occurrence index follows the segment in either form ($["a.b"][0], $.item[1]), and a quoted segment may stand wherever a bare one may ($.order["a.b"].sku, $[""]). A label that is not valid UTF-8 (a lone surrogate) has no path spelling and cannot occur in a path, as it cannot in a conformance vector. Schema paths are not affected: RecordName.label keeps the raw label, which is why E-26 reports an unwritable field at the record's path. The suite pins this rule with validate vectors (validate/label-quoting/*) that report an undeclared field at a quoted path.

Schema paths are RecordName for a record-level diagnostic and RecordName.label for a field-level one.

The whole-schema fallback is $. Some diagnostics have no specific record or field to name: schema.no-root, schema.duplicate-root, a dangling root reference, schema.unknown-record, and algebra.infer-no-samples/algebra.infer-scalar-root (these fail before any schema exists). All six use $ — the same sentinel Document paths use for the whole node — as the schema-side/pre-schema equivalent of "the whole thing, not a part of it."

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

1:1                      the very first character
14:8                     line 14, column 8
  • E-28. col is the number of Unicode code points before the failing character on its line, plus one. It is not a count of UTF-8 bytes and not a count of UTF-16 code units, so a character outside the ASCII range advances it by exactly one, whatever its encoded width: in a: " followed by U+1F600 and " nope, the n is at 1:8, not at 1:11 (bytes, where U+1F600 is four) and not at 1:9 (UTF-16 units, where it is two). The failure is located by the byte offset in the input — that is where the reader stopped — but the column it reports is derived from that offset by counting code points back to the start of the line, so a byte-oriented implementation MUST convert and a UTF-16 one MUST NOT count String.length.
  • E-29. line starts at 1 and advances after each LF (U+000A). A CRLF is one line break, as newline in grammars/oml.abnf defines it (CRLF / LF), and its CR belongs to the line it ends. A lone CR is not a line break in either grammar — OML's newline does not admit it and OSD's ws treats it as ordinary whitespace, with comments running to the next LF in both — so it never advances line, and where a grammar admits it at all it is one code point of the column like any other character. Where OML rejects a lone CR — outside a string and outside a comment — it is parse.unexpected-token at the CR itself. The CR is not whitespace in OML (the grammar's hspace admits space and tab only) and not a newline, so it can begin no token: a reader MUST NOT treat it as horizontal space, MUST NOT let it end a comment (a # comment runs to the next LF, so a lone CR inside one is comment text), and MUST report the position of the CR, not of the character after it. a: 1, CR, } is therefore parse.unexpected-token at 1:5, and a leading CR is the same code at 1:1. A CR inside a double-quoted or multiline string stays parse.control-character, positioned as any control character in a string is (E-23), and CRLF stays one line break. oml-grammar/lone-cr/* pins these; OSD, whose ws admits CR, is unchanged.
  • The origin is the text the grammar sees: a leading U+FEFF that D-15 strips is not counted, which is what D-21's "computed on the text that remains" already says.

E-28 and E-29 fix the unit of a position in OML and OSD text, the two grammars this section can speak for. A codec's own parser blames a character of its own choosing, which E-31 settles.

  • E-31. A parse.codec-syntax position is a real position inside the input, and which one is implementation-defined. Except for the one case whose position this specification fixes — D-21's rejected second leading mark, 1:1 — a parse.codec-syntax diagnostic's path MUST be a text-position path (E-11) that lies inside the input: line at least 1 and at most one more than the number of LF characters (E-29), and col at least 1 and at most one more than the number of code points on that line, since the position just past the last character is where a failure at end of input stands. Where the codec's library reports no position at all, the implementation MUST report 1:1; it MUST NOT omit the diagnostic, omit its path, or report 0:0, which is not a position. Which character the codec blames is implementation-defined. The libraries recover differently and this specification does not ask an implementation to post-process their errors: for the JSON text {"a": "e", "b": x}, whose x is column 17, the mainstream parsers blame column 17 or column 18. A conformance runner therefore does not compare the value (E-32). E-28 still governs a column the implementation converts itself from an offset it holds; a column taken straight from a library that counts in another unit is the library's, and the only requirement on it is the bound above. 1:1 is a valid answer for every input, and that is intended. The placeholder checks that a position is present and well-formed, not that it is accurate: an implementation that reports 1:1 for every codec syntax error satisfies E-31 and E-32. The bound "inside the input" is a requirement on implementations that a runner MAY check (E-32b); the well-formedness pattern cannot. Nothing else changes: D-21's 1:1 stays fixed and stays compared byte for byte, parse.invalid-encoding stays 1:1 (D-14), and E-11 still requires a text position on every parse.* diagnostic.

parse.invalid-encoding is the one parse.* code whose path is a fixed value rather than a computed one: it is always 1:1. The rule behind it, D-14, fails the decoding of the input as a whole, so there is no decoded text for a line:col position to be resolved against, and naming the offending byte's own position would require this spec to decide which byte of an ill-formed sequence is the failing one — which it deliberately does not do. 1:1 is how this spec names a whole-input text refusal already (D-21 reports there too). E-11 below is unchanged by this: the path is still a text-position path, and a runner comparing paths byte-for-byte compares this one like any other. One input produces at most one such diagnostic, no matter how many malformed sequences it contains.

E-11. A parse.* diagnostic's path MUST be a text-position path. A document.*, schema.*, validate.*, materialize.*, algebra.*, write.*, or lint.* diagnostic's path MUST be a Document or Schema path — never a text-position path, since a Document or Schema already exists by the time any of those families can fire. write.* is the one family whose two kinds are chosen by surface rather than by code: a Document writer's diagnostic takes a Document path (the existing write.unsupported-value vectors in test-suite/formats-* all do), and a schema writer's takes a Schema path. Which Schema path is settled per case, the way §8.4.1 settles it for schema.*; OSD-14 and OSD-16 are the cases today and both use the record's. document.limit.input-size and document.limit.expanded-size are found before any Document exists and take the path $ (E-4a).

E-23. A string-body error reports the string's opening quote. parse.control-character, parse.invalid-escape, parse.unterminated-string and parse.unpaired-surrogate — on OML, and on OSD via E-3 — MUST report the text position of the " or ' that opens the string, not the position of the offending character inside it. The failing unit is the string, naming its start names that unit; the position is stable whether the implementation scans the body raw or after unescaping; and it is the only choice that answers the question at all for parse.unterminated-string, which has no offending character to point at. This was implicit before v0.19.0-beta — every OML vector in the suite already followed it — and stating it matters because an implementation that reports the offending character instead is wrong on a path compared byte-for-byte, while a runner in code-agnostic mode (§8.5.2 rule 4) still compares paths and will catch it.

8.4.1 Which kind each schema.* code uses

Saying "a Document or Schema path" leaves the choice open, and paths are compared byte-for-byte, so the choice has to be pinned per code rather than left to the implementation.

For most schema.* codes the answer was only ever implicit: OSD text's grammar establishes a record or field's identity before those checks can run, so a Schema path is the only thing that could be built. That stops being true for the four codes reachable only from a Document-shaped surface such as OSD-OML, which is not grammar-constrained the same way. A record node whose name key is missing has no RecordName from which to build a Schema path at all.

  • E-12. schema.missing-key, schema.invalid-type, schema.unknown-key, and schema.invalid-name MUST use a Document path, rooted at the node the violation occurs on — $.record[0] for a record node missing its name, $.record[1].field[2].type.kind for an invalid kind value. The indexes in these two examples assume a document with several record edges and several field edges; E-10 decides the index in every case, so a lone record edge is $.record. The one exception is schema.invalid-name reached by direct programmatic construction, where no Document exists: it takes $, with the offending name in the message only (S-8). A Document-shaped surface such as OSD-OML keeps the Document path.
  • E-13. Every other schema.* code MUST use a Schema path, unchanged from OSD text: these checks never run until the enclosing record or field has a valid name or label to build one from.
  • The whole-schema cases above keep $ and take precedence over both rules. Four of §8.4's six fall under this section: schema.no-root, schema.duplicate-root, a dangling root reference, and schema.unknown-record. The other two are algebra.* codes, outside this section's scope and unaffected by it.

  • E-30. Which Schema path a code takes is fixed per code, by what the diagnostic is about. A diagnostic about a field's type, nullability or cardinality, where a well-formed field exists, MUST be reported at the field path R.a. A diagnostic about the lexical shape of a field declaration or about its label, or about a record as a whole, MUST be reported at the record path R (for schema.reserved-name, the bare record name, which is the same thing). This is why schema.quoted-type is R while schema.unknown-type is R.a, though both look at a field's type position: a quoted string there means the declaration is not a well-formed field at all, so there is no field to name, and the two directions of the quoting rule (§5.2) are lexical; schema.unknown-type is raised on a field that is well formed and merely names a type that does not exist. Root problems take $, and the four schema-construction codes of E-12 take a Document path.

Code Path kind Why
schema.no-root $ no root declaration exists to name
schema.duplicate-root $ the whole schema, not one record
schema.unknown-type field R.a; $ for a dangling root the field's type names nothing; a root has no field
schema.nullable-ref field R.a nullability of a well-formed field
schema.nullable-any field R.a nullability of a well-formed field
schema.invalid-cardinality field R.a cardinality of a well-formed field
schema.non-integer-cardinality field R.a cardinality of a well-formed field
schema.empty-cardinality field R.a cardinality of a well-formed field
schema.unquoted-label record R lexical: the declaration is not a field yet
schema.quoted-type record R lexical: the declaration is not a field yet
schema.empty-label record R about the label, and R. names nothing
schema.bracket-in-label record R about the label, which would make an ambiguous path
schema.duplicate-field record R about two fields at once, so the record they share
schema.duplicate-record record R a record as a whole
schema.reserved-name bare record name a record's own name
schema.missing-key Document path (E-12) schema-construction input: the node lacking the key
schema.invalid-type Document path (E-12) schema-construction input: the wrong-kind value
schema.unknown-key Document path (E-12) schema-construction input: the unrecognized key
schema.invalid-name Document path (E-12); $ when built programmatically schema-construction input: the record's name; programmatically the name may be malformed, so it stays out of the path
schema.invalid-label record R; $ when R is not a valid name about the label, which cannot be put in a path
schema.unknown-record $ the name is not a record, so no record exists to name

This applies to any surface, present or future, that can construct a Schema without a grammar fixing identity first — it is a property of the codes, not of the extension that first made them reachable.

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",
  "purpose": "happy-path",
  "input": { },
  "expect": { }
}
  • E-14. 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.
  • E-15. operation selects the driver. It MUST be one of the operation names listed on the Operations & Models Reference page — that page is the vocabulary's single source, not a free-text field vectors can spell differently across files.
  • E-16. purpose MUST be one of happy-path, edge-case, error-case, or determinism-regression — what a vector is actually pinning, so a reader doesn't have to reverse-engineer it from the input/expect pair. happy-path is an ordinary conforming case with nothing specific being probed; edge-case deliberately exercises a specific rule, invariant, or boundary (regardless of whether the outcome is success or failure); error-case is primarily testing that an invalid input is correctly rejected; determinism-regression pins a specific ordering/reproducibility regression tied to a known bug.
  • 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:

E-17. 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 a bring-up aid for an implementation that has not yet adopted §8.3 (§8.1), and no more: a result obtained in that mode is not a conformance claim, since §8.3 is mandatory. A run MUST state which mode produced its results.

E-32. A vector MAY give a parse.codec-syntax diagnostic's path as the placeholder "line:col", meaning "compare the code; do not compare the path, only that it is a well-formed text position". It exists for exactly one purpose: E-31 makes the blamed character of a codec syntax error implementation-defined, so no single path value could be expected of every implementation.

"expect": {
  "ok": false,
  "diagnostics": [
    { "path": "line:col", "code": "parse.codec-syntax" }
  ]
}
  • E-32a. Where it is allowed. The E-32 placeholder stands only on an entry whose code is parse.codec-syntax, in a vector whose operation is parse and whose input.format is json, yaml, toml or xml, and only when expect.diagnostics has exactly one entry (a syntax error stops the read, so one input yields one). It MUST NOT stand on any other code, on an OML or OSD diagnostic, on a Document or Schema path, or on a vector pinning a fixed position — D-21's second leading mark keeps its exact 1:1. Anywhere else it is a defect in the vector.
  • E-32b. How it matches. The E-32 entry is satisfied by a reported diagnostic with the same code whose path is a well-formed text position: two decimal numbers of at least 1, no sign, no leading zero, no whitespace, joined by : — ^[1-9][0-9]*:[1-9][0-9]*$. A runner MAY also check the bound in E-31 against the vector's input; it MUST NOT compare the value any closer. A diagnostic with another code, a missing path or a path such as 0:0 fails the vector. Rules 2 and 3 above apply unchanged: the reported list is still a set with no extra diagnostic.
  • In code-agnostic mode (rule 4) the code is not compared, so the entry is satisfied by any reported diagnostic whose path is well-formed.
  • Every other path in the suite stays compared byte for byte (E-9). The placeholder is a literal string that no real path can equal, so it is unambiguous, and it relaxes one vector's one entry, not a rule about codecs in general.
  • A runner that does not implement the placeholder fails these vectors: it would compare the string line:col to a real path. Until it does it reports them as E-20 "not yet implemented" skips, per §9.4.

8.5.3 Operation drivers

operation input success expect
parse {format, text} or {format, bytes_hex} (E-27) {ok, document}; on failure a parse.codec-syntax entry's path MAY be the placeholder "line:col" (E-32)
parse_schema {text} or {bytes_hex} (E-27) {ok} — schema: <canonical OSD text> MAY additionally be present, compared byte for byte per §3.3/§5.9, for a vector specifically pinning declaration-order or formatting round-trip fidelity rather than mere acceptance
validate {schema, document} {ok}
materialize {schema, document} {ok, document}
write {document, format}, and optionally strict and (OML only) compact (E-35) {ok, text} — diagnostics MAY be present alongside a successful {ok: true, ...} result (a write can succeed with a reported adjustment, e.g. format.temporal-stringified; success and a diagnostics list are not mutually exclusive here the way they are for every other operation)
compatible_with {a, b} {result: bool}
equivalent {a, b} {result: bool}
normalize {schema} {schema: <canonical OSD text>} — compared byte for byte per §5.9's canonical-output requirement
prune {schema} {schema: <canonical OSD text>}
is_empty {schema} {empty: bool}
extract {schema, keep} {ok, schema}
infer {samples, allow_any} {ok, schema} — allow_any defaults to false when absent
infer_with_report {samples, allow_any} {ok, schema, fallbacks} — fallbacks is a list of {location, reason}, always present on success (empty when nothing was opened)
lint {schema} {ok, findings} — findings is a list of {code, severity, location}; message text is never compared (§8.5.2 rule 1) so no message field is required
schema_from_document {document} {ok, schema: <canonical OSD text>} — compared byte for byte, same rule as parse_schema's implicit round-trip (§3.3, principle 1: nothing merges, so declaration order is preserved from the input Document's own edge order)
parse_schema_oml {text} or {bytes_hex} (E-27) {ok, schema: <canonical OSD text>} — same comparison as schema_from_document; this operation is schema_from_document(read_oml(text)), per §E.11
schema_to_document {schema} {ok, document} — compared as a Document (§8.5.4's canonical encoding), which is order-sensitive per D-1/D-3; this is where §3.3's order principles actually become observable for this direction
write_schema_oml {schema} {ok, text} — compared byte for byte as OML text, same rule as write's Document-writer vectors; this is write_oml(schema_to_document(schema)), per §E.11

Every operation's failure expect is {ok: false, diagnostics: [...]}, per §8.5.2 (lint is the exception: its failure expect is {ok: false, findings}) — write is the only operation whose own result can carry diagnostics beside ok: true, noted above; parse of XML can also, for the read-side warnings of §8.3.8 (format.attribute-dropped, format.namespace-dropped).

How each field is compared, per operation. The rows above that say "byte for byte" name the canonical comparison: the implementation's canonical output text against the expected text, with no re-parse. infer and infer_with_report are the one place a schema is not compared that way: §6.10 never normalizes infer's output and its record names are implementation-derived, and conformance-harness.md §4 compares infer output up to a renaming of records for that reason; this section itself states no comparison for their schema. The Track 1 fixtures' exact mode is a third, structural comparison that Track 2 does not use. The per-operation list, for both tracks, is in Porting a Conformance Runner.

The 4 OSD-OML operations split across the same two comparison shapes every other operation already uses, not a third one. schema_from_document/ parse_schema_oml return a Schema, compared as canonical OSD text — identical in kind to normalize/prune's existing byte-for-byte rule, not a new mechanism. schema_to_document/write_schema_oml return Document-shaped output, compared the same way write's vectors already are. This is what makes §3.3's order principles actually conformance-checkable for OSD-OML specifically: before this table entry existed, a vector like extensions-osd-oml/parse/worked-example-round-trips-to-equivalent-osd had no documented comparison rule at all, so nothing guaranteed a harness would catch a declaration-order regression even though the vector's own expect.schema field encoded one.

materialize's input is a canonical-JSON Document, not raw format text plus a format field. This is deliberate: per Operations & Models Reference, materialize's abstract signature is materialize(node, S) -> node — it operates on an already-parsed Document, matching §7.1's two-stage separation. A vector wanting to exercise the full parse-then-materialize pipeline for a specific format uses two vectors — one parse, one materialize — not one vector conflating both stages.

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.

E-18. write vector text comparison for XML MUST ignore insignificant inter-tag whitespace. Unlike JSON/YAML/TOML/OML, this spec places no normative requirement anywhere on XML writer output formatting (no indentation, no line-break convention) — codecs and deserialization (§7.3) and the XML format page (docs/formats/xml.md) both describe only the structure a writer must produce, never its whitespace. Two conformant writers may legitimately disagree on this and both be correct. Before comparing a write vector's text against a target's actual output for XML, a harness MUST first strip whitespace that occurs strictly between > and < from both strings, then compare what remains. Whitespace in element text that has a non-whitespace character beside it (x, a b) is not strictly between > and < and is compared as written: that is data, not formatting.

E-18 is safe for everything except one case, because an omnist writer never produces XML with mixed content — the Document model (§2) gives every node either child edges or a single scalar value, never both at once, so an element's content is either entirely sub-elements or entirely one text value. The exception is a whitespace-only string leaf: <a> </a> has whitespace strictly between > and <, so the strip removes it, and a harness following this rule cannot tell the string " " from the empty string "". The earlier wording of this paragraph ("whitespace inside element text MUST NOT be touched") was therefore false for that leaf, and the rule is now stated as it holds:

  • A write vector for XML MUST NOT contain a whitespace-only string leaf (E-18): a non-empty string made up only of whitespace, that is, of characters the strip below removes: the characters for which Python's str.isspace() is true (which include U+0009, U+000A, U+000D and U+0020), and U+FEFF, which JavaScript's \s matches and Python's does not. The empty string is not whitespace-only and is fine. tools/check_vectors.py checks this. The write side of such a leaf is not pinned by any vector, and no port is relieved of writing it faithfully.
  • The read side is pinned instead. A reader keeps the text of an element that has no child elements exactly as written, whitespace included: <a> </a> reads as (a, " "), and an element holding only a line feed reads as the one-character string. Whitespace between child elements is formatting and is dropped. parse vectors in formats-xml/whitespace pin both (xml.md).

General XML documents with mixed content have no such guarantee, which is why this rule is scoped to XML vectors in this conformance suite, not stated as a general-purpose XML-comparison algorithm. One reference implementation: strip via text.replace(/>\s+</g, "><") (or each language's equivalent), then apply the existing outer-whitespace trim every write vector comparison already does.

E-35. A write vector MAY ask for OML's compact layout with compact. The write driver's input takes an optional boolean compact, default false. compact: true asks the writer for the compact mode of OML-22: the whole Document on one line, edges separated by ;. It is meaningful only when format is oml, the one format whose specification defines a compact layout, and a vector MUST NOT set it on any other format. A runner MUST pass it to the implementation's OML writer (the writer's compact option; a runner calls the library for this key, since not every CLI offers a compact flag) and MUST NOT ignore it: a runner that drops the key compares the expanded layout with the compact text and fails every compact vector for a reason that is the runner's. strict is likewise optional, boolean, and absent means false. The text of a compact vector is compared like any other write text, byte for byte after the outer-whitespace trim every write comparison does. With compact absent or false the canonical expanded layout of OML-19 applies, as for every existing vector.

E-27. A read-side vector MAY give its input as bytes, as bytes_hex. The three drivers above whose input carries the source text of a document or a schema — parse, parse_schema and parse_schema_oml — accept bytes_hex in place of text. Exactly one of the two MUST be present: never both, and never neither. bytes_hex is the input's bytes written as lowercase hexadecimal, two digits per byte, with no separators and no prefix, so its length is always even; the empty string is a legal (empty) input. Every other field of the vector is unaffected — format still selects the codec, and a success expect compares exactly as it would for a text vector.

E-27 exists for one requirement, and adds nothing the suite could already express: a vector file is JSON, a JSON string holds text, and bytes that are not valid UTF-8 are exactly the bytes no JSON string can carry. So D-14 was a MUST no vector could reach until this form existed. Tooling over the suite MUST NOT require a bytes_hex value to decode as UTF-8 — the vectors that matter here do not decode at all, and one that does is a control proving the reader still accepts well-formed multi-byte input.

The remaining drivers take no bytes_hex, and the reason is narrower than "they have no text". Several of them do: a validate vector's schema is OSD text, as the note above says, and so is normalize's and prune's. But in those the text is a fixture — the means of writing down a Schema the operation then works on — not the input under test, and reading it is another operation's subject. Only parse, parse_schema and parse_schema_oml have the reading of a source text as the operation, so they are the only three where a rule about the input's bytes can be pinned at all.

How a runner presents bytes_hex to the implementation under test. E-27 binds this as much as it binds the field's spelling. Decode the hex to bytes and hand those bytes to a byte-oriented entry point, unchanged. Any entry point the implementation provides that reads a file, standard input, or a byte stream is one — including its CLI — whatever the signature of the reader sitting behind it, and so is a bytes-taking reader. A runner MUST use one if the implementation has one anywhere; the reader's own signature does not excuse it, because D-14 binds the place the bytes enter.

E-27 forbids the tempting shortcut: a runner MUST NOT decode the bytes into a string with U+FFFD replacement, with a surrogate-escape scheme, or with any other lossy recovery, and then run the vector as though that string were the input. It is not the input, the result says nothing about the rule, and for a vector expecting parse.invalid-encoding it reports a pass or a fail on a question never asked.

The two ways not to run these vectors are different results and MUST NOT be conflated. A runner that has not yet learned the bytes_hex form reports skip under E-20, "not yet implemented" — no ledger citation needed, and it becomes a pass or a real fail once the work lands. An implementation that offers no byte-oriented entry point at all — no bytes-taking reader, no file or stdin or stream entry point, and no CLI — reports skip under E-21, with its own §9.4 ledger entry; that is a structural limit of the target language, which is what E-21 is for, and §8.5.5 makes reporting it a first-class result rather than a loss. Having a byte-oriented entry point that fails these vectors is neither: it is a fail.

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 of §2.2.1: string, integer, number, boolean, date, time or datetime. Temporal values are ISO-8601 strings. For integer values see the threshold rule below; for a number that is not finite see E-33.
  • null is {"scalar": {"kind": null, "value": null}}. null is a value, not a kind (§2.2.1), so this is the one scalar whose kind is not one of the seven.

E-19. The integer threshold is exactly ±(2^53 − 1). An integer whose absolute value is at most 9007199254740991 MUST be encoded as a JSON number; any integer outside that range MUST be encoded as a decimal string. Both a vector author and a vector reader apply the same fixed bound, in both directions.

The bound is stated as a literal rather than by reference to any language's behavior, deliberately. It is the largest integer every IEEE-754 double can represent exactly, which makes it the one threshold that is a property of the interchange format rather than of whichever JSON library a given implementation happens to use — and §2.4's integer-digit cap allows integers far larger than this, so the case is reachable, not theoretical.

This is verbose on purpose, and the threshold is part of that purpose. A vector file must not depend on the reader's JSON library to decide whether 1 is an integer or a number, and it must not depend on that library to decide where "fits exactly" stops either.

E-33. A non-finite number is encoded as a string. A scalar of kind number whose value is NaN, positive infinity or negative infinity MUST be encoded with value the JSON string "NaN", "Infinity" or "-Infinity" respectively, never a bare JSON number: JSON has no token for any of the three, so a vector file could not hold one otherwise. The string form is unambiguous because the value of every other number is a JSON number and never a JSON string, and it applies to kind number only: a string scalar whose text is NaN is an ordinary string. A vector author and a vector reader apply the same three spellings, as they apply the integer threshold of E-19. A runner compares two NaN values as equal when it matches a decoded number against what the implementation read: the language's own floating-point equality says a NaN is not equal to itself, and a runner that applies it fails every NaN vector whatever the implementation read.

8.5.5 Reporting

Building your own runner? Porting a Conformance Runner collects what the five existing ports learned, including this section's skip/CI discipline in practice.

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.

E-20. A skip MUST cite a reason, and the reason determines what else is required:

  • Not yet implemented. The operation or feature doesn't exist yet in this implementation, or the rule or behaviour a vector pins has not been adopted yet. Temporary by nature — expected to become a pass once the work lands. No ledger entry is required for this category on its own (E-20), though the usual issue tracker SHOULD have something open for it.
  • E-21. Documented divergence. The vector's outcome depends on a capability this implementation's target language or design genuinely cannot provide — not a missing feature, a structural limit. This category MUST have a corresponding entry in chapter 9's divergence ledger (see §9.4 for the current open entries), and the skip reason a harness reports for the affected vectors MUST cite that entry by number (e.g. "skip: DIV-N") — a skip with no citable reason, or a reason invented ad hoc instead of pointing at a ledger entry, is not acceptable reporting under this section. A divergence this narrow is closed, and its entry removed from the ledger, once the implementation adds real support — it doesn't stay listed once resolved.

E-20a. A runner MUST NOT run a vector that carries a declared_* key it does not understand against the implementation's own default. It MUST report such a vector as a fail, or as an E-20 "not yet implemented" skip. A run that ignores the key can pass a boundary vector without testing the boundary, which is the false pass test-suite/README.md describes.

E-22. CI gating. A conformant CI run MUST fail the build when the fail count is nonzero. A conformant CI run MUST NOT fail the build merely because the skip count is nonzero — an implementation with real, cited reasons for every skip has passed conformance in the sense this section defines. Passing CI is a statement about fail, not about reaching zero skips; chapter 9 is where the skip count's honesty is checked, not the CI gate.

8.6 OML/OSD conformance harness

§8.5's harness exercises operations through a canonical JSON encoding (§8.5.4) specifically to avoid the representational ambiguity of format text — the same Document can be written as OML or OSD in more than one way (indentation, array sugar versus repeated labels, field declaration order) and still be the identical model. That is the right design for testing operation correctness, but it never touches the OML/OSD text an implementation actually reads and writes, which is a second, independent thing worth testing on its own: codec fidelity at the surface a human or another tool actually uses.

conformance-harness.md specifies a second, complementary conformance track for exactly that: a CLI wrapper contract every implementation exposes one command per operation under, a directory-per-fixture format for OML/OSD input and expected output, and a structural-equality comparison algorithm (not text diffing) for judging whether an implementation's actual output matches. It reuses this chapter's operation vocabulary (§8.5.3) and diagnostic envelope (§8.2) rather than defining either a second time.

This track's fixtures and orchestrator live in this repository, under conformance/, not in a separate repository — alongside the spec text they pin, for the same reason test-suite/ does: chapter 10's Spec-TDD workflow (§10.2) requires a vector and the prose it tests to land in the same PR, and that is only practical when both live in one place.