Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Limitations & stability

Alpha status: 0.12.0-alpha, per this project’s versioning rule

The Rust port’s first feature-complete milestone (issue #28) plus its own conformance-test harness against omnist-spec (issue #82 – see Conformance against omnist-spec for the real, measured results) are both now in place, and the maintainer has signed off on moving past 0.0.x to mark that milestone. It still ships -alpha, though: there is no beta until the maintainer explicitly signs off on the scoping decisions below (the any-type gap chief among them); accumulating further features or fixes alone never moves it past -alpha on its own. Treat every public API in this crate as subject to change without a deprecation cycle until that further sign-off happens.

Document paths quote a label that needs it (E-36)

Every Document path this crate prints (DocumentError.path, ValidationError.path, Adjustment.path) writes a label as .label, unless the label is empty or holds . [ ] " \, a control character, U+007F, U+0085, U+2028 or U+2029: then it is ["label"] with no dot, escaping " as \", \ as \\, \n, \r, \t, and any other of those as lowercase \u001f. The E-10 index follows either form ($["a.b"][1]). Schema paths (Record.label) keep the raw label.

Programmatic-schema rules from spec v0.28.0-beta (S-8, S-22, S-23, S-24)

These four rules take a schema built through the API, which no conformance vector can supply (DIV-5), so the unit tests in omnist/tests/spec_v028.rs are the only pin.

  • S-8 and S-24/OSD-16 are implemented. Schema::new reports a bad record or Ref target name as schema.invalid-name at $; osd::to_osd fails with write.unsupported-value at the record path on a max = 0 field. Which violation is reported when several exist is unspecified.
  • S-22 (schema.invalid-label) is vacuous in Rust. A field label is a String, so it cannot hold invalid UTF-8. Every public way to give a label takes impl Into<String> (Field::new, Field::required, …); there is no OsString, Vec<u8> or Cow<[u8]> route into a schema, so there is nothing to check and no code path emits schema.invalid-label. Input bytes are a different rule (D-14, parse.invalid-encoding).
  • S-23 (schema.unknown-record) has no Rust surface. No public function takes a caller-supplied ordering of records: to_osd and the other writers emit Schema::env() in its own order, and extract’s keep argument is a set of field labels, not records. schema.unknown-record is therefore never emitted.
  • No OSD-OML schema writer. The port has no OSD-OML schema reader or writer, so the OSD-OML half of OSD-16/S-24 does not apply.

The any-type support (landed)

Python’s schema model has an AnyType/ANY type and an allow_any option several APIs (osd, schema algebra, inference) use as a fallback when a precise type can’t otherwise be resolved. In this Rust port, FieldType::Any is fully supported across omnist::schema, omnist::osd parsing (record X { "a": any }), and omnist::infer (with allow_any fallback mode when schemas have ambiguous types or mixed structures, also wired into the CLI’s infer --allow-any flag).

Safety limits and the YAML alias expansion factor (D-10, D-11)

Every limit below is finite, documented here, and reported with its document.limit.* code (spec section 2.4):

LimitDefaultConfigurable
Maximum nesting depth200yes: Limits::max_depth (0 = default, at most 250)
Maximum node count1,000,000 (a node is a container, not a key or a scalar value)yes: Limits::max_nodes (0 = default, at most 10,000,000)
Maximum integer digits4,300yes: Limits::max_int_digits (0 = default, at most 43,000)
Maximum input size (bytes, D-23)64 MiB (67,108,864 bytes)yes: Limits::max_input_bytes (0 = default, at most 1 GiB); CLI --max-input-bytes N
Maximum alias expansion factor (YAML)50yes: YamlReadOptions::max_alias_expansion (0 = default, at most 10000)
Maximum expanded size (YAML, inputs with an alias or merge key)1,000,000 value slotsyes: YamlReadOptions::max_expanded_slots (0 = default, at most 10,000,000)

The first four are omnist::limits::Limits, taken by the explicit-limits readers read_oml_with, read_json_with, read_toml_with, read_xml_with, by YamlReadOptions::with_limits (for read_yaml_with) and by Doc::from_raw_with / Doc::of_with; the plain read_* functions use the defaults, unchanged. As for the YAML options, 0 selects the default (a zero or unset value never widens a limit) and a value above its ceiling is refused, not clamped, by Limits::validate (an uncoded DocumentError). The ceilings are this port’s own, because the spec recommends none for these three; the depth ceiling is what the recursive readers survive on a 2 MB thread stack in a debug build (measured: 300 levels pass, 400 overflow; a release build passes 1,000), and all three bound the work a caller can opt into. On a smaller stack, lower the depth. A limit equal to the document’s depth, node count or digit length accepts it and one past refuses it. A Doc remembers the limits it was built under, so a later add or set enforces the same ones. write_oml and the other writers, which take a bare RawNode or an existing Doc, still refuse nesting past the default depth of 200.

A limit crossed is a DocumentError (spec E-11), never a ParseError: document.limit.depth and document.limit.nodes carry path $ (they are properties of the whole input, as for document.limit.expanded-size), and document.limit.int-digits carries the Document path of the over-long integer ($.n, $.a.n, and an index only when the label repeats: $.n[1]). Through 0.6.1-alpha the OML scanner and the JSON, TOML and YAML readers raised these as a ParseError with a line:col position. read_oml, whose signature is unchanged and returns a ParseError, still reports them that way, with the position; read_oml_with and the registry’s oml format report the Document form. A syntax error anywhere in the input wins over an over-long integer.

Two readers have a ceiling of their own below ours: toml_edit refuses nesting past about 80 levels (inline tables, arrays, dotted keys and table headers alike) and holds every integer in 64 bits, so a larger max_depth or a max_int_digits above 19 does not make the TOML reader accept more than toml_edit does. Its recursion cap is reported as document.limit.depth at $, with a message naming toml_edit’s own cap (through 0.8.0-alpha a table header deeper than 80 levels panicked the reader, omnist-rs#197, and the other spellings were parse.codec-syntax). The XML reader counts an element as a node once it has a child element, so a million leaf elements are one node; its text is never an integer, so max_int_digits governs only the schema-guided pretyping of read_xml_with_schema_and_limits: an integer leaf over it is document.limit.int-digits at the leaf’s path (through 0.11.0-alpha it was materialize.inexact-conversion, omnist-rs#204). A number leaf is exempt however long its integer part (D-28) and reads as an infinity past binary64 (D-29); through 0.11.1-alpha a number leaf over the limit stayed a string (DIV-42).

The alias expansion factor bounds the materialized-to-written value-slot ratio of every anchored node, every other mapping and sequence, and the document root; see YAML for the counting rules and for what the default accepts (for example, merging a 150-key anchor into a mapping that writes one key of its own reads E ~ 50.67 and is rejected at the default). It is a ratio, so it does not replace the node cap: a scalar-heavy document is bounded as a whole only by max x S(root), and the YAML node cap stays in force. Only YAML has an anchor mechanism, so the factor binds no other codec.

The ratio does not bound absolute size, so the reader also enforces a maximum expanded size (D-22): an input that contains at least one alias or merge key and whose root materializes more than 1,000,000 value slots is refused with document.limit.expanded-size, after the ratio check (an input that fails both reports document.limit.alias-expansion). Ratio and size are separate options, and neither implies the other. An input with no alias and no merge key is exempt however large, which is a cliff by design: a plain file of two million slots passes, and adding one alias subjects it to the cap. See YAML. The YAML node cap counts what D-9 counts, containers only, and defaults to the spec’s 1,000,000 (it is Limits::max_nodes, set through YamlReadOptions::with_limits), so the spec’s own 1,000-service x 60-key compose example (W = 62,063) is accepted (it was refused with document.limit.nodes through 0.6.0-alpha, DIV-11, omnist-rs#189). A malformed merge (a scalar merge value, a scalar or sequence member of a merge sequence, an alias to a sequence of scalars) is parse.codec-syntax and wins over both limits.

Maximum input size (D-23, D-24)

The spec says an implementation SHOULD enforce a finite maximum input size in bytes (D-23), names no default (D-24), and asks the implementer to measure the slowest codec at the size chosen. This port enforces one, 64 MiB by default (the same number the Python port chose), configurable with Limits::max_input_bytes and, on the command line, --max-input-bytes N on format, convert, check, validate and infer.

  • Bytes, not characters: é is two, and a leading byte-order mark is three, because the length is taken before the mark is stripped (D-15) and before any decoding or parsing. An input of exactly the maximum is accepted, one byte more is refused with document.limit.input-size at $.
  • First: the check precedes every other diagnostic, so an oversized input that is also invalid UTF-8, malformed or doubly BOM-marked reports document.limit.input-size. It covers all five formats, an alias-free YAML input included, and Doc::from_format for a registered (plugin) format too. read_oml, which returns a ParseError, reports it at 1:1; every other reader, and read_oml_with, report the DocumentError form with path $.
  • Streaming: the CLI reads a file or stdin through a reader limited to the maximum plus one byte, so it refuses an oversized input after reading max + 1 bytes rather than buffering the rest. The refusal says how to raise the limit.
  • Not bounded: an OSD schema text (D-25 asks for a schema-size bound but gives it no code and no vector; the CLI’s schema files and --schema are read whole), and a Document built from native values (it has no input bytes).

Why 64 MiB, and what it costs. The cap bounds parse cost; it does not make a parse fast. Measured on 2026-10-05 (release build, one run each, a flat mapping of integer entries filling 64 MiB; TOML and YAML also measured at 8 and 16 MiB and scale about linearly): OML 2.4 s and 0.5 GB peak, XML 7.1 s and 0.6 GB, JSON 6.1 s and 1.0 GB, TOML 15.8 s and 2.5 GB, YAML 18.8 s and 2.7 GB. The spec gives no default (D-24) and asks an implementation to document its value and to measure its slowest codec on a worst-case input before raising it; these measurements are why the default is documented here. A deployment that reads untrusted TOML or YAML on a small host should set a lower maximum (the same inputs at 10 MiB: TOML 2.1 s and 0.43 GB, YAML 2.7 s and 0.46 GB, the others under 1.3 s and 0.17 GB). Before 0.9.0-alpha a reader took any size, so a caller that reads more than 64 MiB now has to raise the limit explicitly: that is a behaviour change, not only a new option.

C-9: writers refuse a string with no UTF-8 encoding (vacuous here)

Spec C-9 (v0.32.0-beta) says every writer MUST fail with write.unsupported-value when a Document holds a string value or an edge label with no UTF-8 encoding. In this port the rule cannot fire. A Rust String, &str and char always encode, so no Document, RawNode or Value can hold such a string, and every API that takes text takes &str or String. The audit of 2026-10-05 found:

  • No library entry point takes bytes, an OsString or a Cow<[u8]>. The only byte entry points are the CLI crate’s read_document_bytes(_with), read_oml_bytes(_with) and parse_schema_bytes, which decode with String::from_utf8 and refuse invalid input with parse.invalid-encoding (D-14), never repairing it.
  • No lossy conversion on input. No production code calls String::from_utf8_lossy on bytes that could be invalid, to_string_lossy, or an _unchecked constructor, except two from_utf8_lossy calls in the XML reader, which work on slices of an already valid &str cut at ASCII delimiters (a CDATA body, an attribute value) and so never see an invalid byte. Every reader refuses a lone surrogate escape (JSON \ud800, TOML, OML, YAML, XML &#xD800;) as a syntax error and never turns one into U+FFFD, which is the Go-style bug.
  • A tripwire test (omnist/tests/c9_vacuous.rs) fails if a new lossy conversion appears in production code, so a future route is audited, not silent.

No vector pins C-9 (an input is UTF-8 text or bytes_hex, neither of which can carry a surrogate), so the ledger’s row for it is “vacuous” for this port.

Scalar::Int is arbitrary-precision (issue #104)

omnist::document::Scalar::Int and Value::Int are backed by num_bigint::BigInt, not a fixed-width integer – matching omnist-spec section 2.2’s requirement that integer be arbitrary-precision, and Python’s/Go’s own representations (int, *big.Int). This was previously i64 (max ~19 significant decimal digits) – a real spec-conformance bug, not a disclosed permitted variation, since a 20+ digit literal under the shared 4,300-digit security cap was rejected outright with no declared_max_int_digits override in play (omnist-spec ledger entry D-9). Fixed; see each format’s own page for anything still worth knowing:

  • formats/toml.md – TOML integers read at full precision too: toml_edit, the crate this port’s TOML codec is built on, holds an integer in 64 bits and refuses a longer literal, so the reader works the literal out itself (any radix) and puts the value back. The digit limit counts the decimal digits of the value (spec D-28). No format has a 64-bit ceiling on read or on write.

TOML integers past 64 bits: cost of reading them

toml_edit refuses an integer past 64 bits, so the reader finds those literals itself (see TOML). Doing that one literal per reparse was quadratic: in a release build, an array of N copies of a 20-digit integer took 10.6 s at N = 4,000 (84 KB), 38 s at 8,000, 147 s at 16,000 and 324 s at 32,000, where the previous release refused the first such literal at once. A single scan now finds every such literal and the text is parsed once more, so the cost is linear: 20,000 big integers in an array, 20,000 inline tables each holding one, and 20,000 1e999 floats read together in 0.41 s (release; the test many_big_integers_read_in_linear_time bounds it at 60 s in a debug build). Before the fix the same three shapes at 6,000 each took 282 s.

Temporal kinds have no arithmetic

Scalar/Value carry real Date(String)/Time(String)/Datetime(String) variants (added in issue #105), each holding an already shape-validated, canonical ISO spelling – but the string is opaque data, not a chrono/ time value. There is no date arithmetic, comparison, or component extraction anywhere in this crate; the algebra never needed it (mirroring the same no-arithmetic reasoning Scalar::Int’s BigInt backing already applies to integers). This means:

  • omnist::infer::infer infers date/time/datetime only from a genuinely temporal-kinded sample (one already read as Scalar::Date/ Time/Datetime – e.g. from OML’s or TOML’s own native temporal grammar); a plain ISO-shaped string sample still infers as string, matching Python’s own strict value_kind() exactly.
  • omnist::schema::matches_kind, by contrast, accepts either a real temporal variant or a shape-matching plain string for a Date/Time/Datetime-typed field – also matching Python’s own hybrid matches_kind exactly. A schema-directed materialize upgrade is what promotes a matching string to the real typed variant.
  • Formats with a native temporal type on the wire (TOML’s four temporal literal forms, YAML’s looser timestamp grammar) now construct the real typed variant directly on read and write it back bare on write – no more silent collapse to Scalar::Str; see each format’s own page for the exact behavior (particularly formats/toml.md, whose write-side shape-guessing divergence from Python is now resolved).

Architecture-freedom disclosures already made per codec

Beyond the two structural gaps above, each format module documents its own disclosed, live-checked divergences from the Python reference (namespace resolution in XML, ASCII-only digit parsing in XML’s coercion, strict vs. non-strict OML-Extended string spellings, and more) – see formats/ for the specifics, all checked against a live Python interpreter or the Python reference’s own merged PR history, not assumed from memory.