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. NaNor an infinity has no legal spelling in the target format (e.g. JSON, whose only sentinel for this isnull) —format.float-special. Confirmed live: writing a genuinenullvalue and writingNaNproduce the identical JSON token, and both read back as the identical Document value — there is no way, after the fact, to tell a substitutedNaNfrom an originalnull.- 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
, 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 (and \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:
- The value is never altered. A writer MUST NOT convert a
datetimeor atimeto 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:00is written with+01:00and the clock reading12:00:00, not as2024-01-15T11:00:00or…Z. - The seconds are always written. A
timeordatetimeis written with its seconds field in every format that spells them: a value read from OML's12:00, which §4.2.4 admits, is written as12:00:00, never12: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. - A native literal is used where one holds the value exactly. OML has
one for all three kinds. TOML has one for a
date, atimewithout an offset (local time), adatetimewithout an offset (local date-time) and adatetimewith an offset (offset date-time), written in its ISO-8601 spelling with theTseparator (2024-01-15T12:00:00+01:00). YAML has one for adateand for adatetime, written the same way, with theTseparator and no quotes, the form YAML 1.1's timestamp resolves. - 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), atimewith an offset in TOML (TOML has no offset time), and everytimein YAML (YAML 1.1 has no time-of-day type, and an unquoted12:00:00would be read as a sexagesimal integer and12:00:00.5as 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 asname,a b,$,éor0stays 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,\rand\tfor those three characters, and\ufollowed 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.
colis 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: ina: "followed by U+1F600 and" nope, thenis at1:8, not at1:11(bytes, where U+1F600 is four) and not at1: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 countString.length. - E-29.
linestarts at 1 and advances after eachLF(U+000A). ACRLFis one line break, asnewlineingrammars/oml.abnfdefines it (CRLF / LF), and itsCRbelongs to the line it ends. A loneCRis not a line break in either grammar — OML'snewlinedoes not admit it and OSD'swstreats it as ordinary whitespace, with comments running to the nextLFin both — so it never advancesline, and where a grammar admits it at all it is one code point of the column like any other character. Where OML rejects a loneCR— outside a string and outside a comment — it isparse.unexpected-tokenat theCRitself. TheCRis not whitespace in OML (the grammar'shspaceadmits space and tab only) and not anewline, 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 nextLF, so a loneCRinside one is comment text), and MUST report the position of theCR, not of the character after it.a: 1,CR,}is thereforeparse.unexpected-tokenat1:5, and a leadingCRis the same code at1:1. ACRinside a double-quoted or multiline string staysparse.control-character, positioned as any control character in a string is (E-23), andCRLFstays one line break.oml-grammar/lone-cr/*pins these; OSD, whosewsadmitsCR, is unchanged. - The origin is the text the grammar sees: a leading
U+FEFFthat 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-syntaxposition 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— aparse.codec-syntaxdiagnostic'spathMUST be a text-position path (E-11) that lies inside the input:lineat least 1 and at most one more than the number ofLFcharacters (E-29), andcolat 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 report1:1; it MUST NOT omit the diagnostic, omit its path, or report0: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}, whosexis 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:1is 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 reports1:1for 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's1:1stays fixed and stays compared byte for byte,parse.invalid-encodingstays1:1(D-14), and E-11 still requires a text position on everyparse.*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, andschema.invalid-nameMUST use a Document path, rooted at the node the violation occurs on —$.record[0]for a record node missing itsname,$.record[1].field[2].type.kindfor an invalidkindvalue. The indexes in these two examples assume a document with severalrecordedges and severalfieldedges; E-10 decides the index in every case, so a lonerecordedge is$.record. The one exception isschema.invalid-namereached 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, andschema.unknown-record. The other two arealgebra.*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 pathR(forschema.reserved-name, the bare record name, which is the same thing). This is whyschema.quoted-typeisRwhileschema.unknown-typeisR.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-typeis 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.
nameMUST be unique across the whole suite. A harness reports results keyed on it. specpoints 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.
operationselects 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.
purposeMUST be one ofhappy-path,edge-case,error-case, ordeterminism-regression— what a vector is actually pinning, so a reader doesn't have to reverse-engineer it from the input/expect pair.happy-pathis an ordinary conforming case with nothing specific being probed;edge-casedeliberately exercises a specific rule, invariant, or boundary (regardless of whether the outcome is success or failure);error-caseis primarily testing that an invalid input is correctly rejected;determinism-regressionpins a specific ordering/reproducibility regression tied to a known bug. expectholds either a success value or adiagnosticslist.
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
codeisparse.codec-syntax, in a vector whoseoperationisparseand whoseinput.formatisjson,yaml,tomlorxml, and only whenexpect.diagnosticshas 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 exact1: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
codewhosepathis 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'sinput; it MUST NOT compare the value any closer. A diagnostic with another code, a missing path or a path such as0:0fails 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
pathis 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:colto 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
writevector 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'sstr.isspace()is true (which include U+0009, U+000A, U+000D and U+0020), and U+FEFF, which JavaScript's\smatches and Python's does not. The empty string is not whitespace-only and is fine.tools/check_vectors.pychecks 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.parsevectors informats-xml/whitespacepin 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}}whereKis one of the seven kinds of §2.2.1:string,integer,number,boolean,date,timeordatetime. Temporal values are ISO-8601 strings. Forintegervalues see the threshold rule below; for anumberthat is not finite see E-33. nullis{"scalar": {"kind": null, "value": null}}.nullis a value, not a kind (§2.2.1), so this is the one scalar whosekindis 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
passonce 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.