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::newreports a bad record orReftarget name asschema.invalid-nameat$;osd::to_osdfails withwrite.unsupported-valueat the record path on amax = 0field. Which violation is reported when several exist is unspecified. - S-22 (
schema.invalid-label) is vacuous in Rust. A field label is aString, so it cannot hold invalid UTF-8. Every public way to give a label takesimpl Into<String>(Field::new,Field::required, …); there is noOsString,Vec<u8>orCow<[u8]>route into a schema, so there is nothing to check and no code path emitsschema.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_osdand the other writers emitSchema::env()in its own order, andextract’skeepargument is a set of field labels, not records.schema.unknown-recordis 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):
| Limit | Default | Configurable |
|---|---|---|
| Maximum nesting depth | 200 | yes: Limits::max_depth (0 = default, at most 250) |
| Maximum node count | 1,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 digits | 4,300 | yes: 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) | 50 | yes: YamlReadOptions::max_alias_expansion (0 = default, at most 10000) |
| Maximum expanded size (YAML, inputs with an alias or merge key) | 1,000,000 value slots | yes: 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 withdocument.limit.input-sizeat$. - 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, andDoc::from_formatfor a registered (plugin) format too.read_oml, which returns aParseError, reports it at1:1; every other reader, andread_oml_with, report theDocumentErrorform 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
--schemaare 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
OsStringor aCow<[u8]>. The only byte entry points are the CLI crate’sread_document_bytes(_with),read_oml_bytes(_with)andparse_schema_bytes, which decode withString::from_utf8and refuse invalid input withparse.invalid-encoding(D-14), never repairing it. - No lossy conversion on input. No production code calls
String::from_utf8_lossyon bytes that could be invalid,to_string_lossy, or an_uncheckedconstructor, except twofrom_utf8_lossycalls in the XML reader, which work on slices of an already valid&strcut 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�) as a syntax error and never turns one intoU+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::inferinfersdate/time/datetimeonly from a genuinely temporal-kinded sample (one already read asScalar::Date/Time/Datetime– e.g. from OML’s or TOML’s own native temporal grammar); a plain ISO-shaped string sample still infers asstring, matching Python’s own strictvalue_kind()exactly.omnist::schema::matches_kind, by contrast, accepts either a real temporal variant or a shape-matching plain string for aDate/Time/Datetime-typed field – also matching Python’s own hybridmatches_kindexactly. A schema-directedmaterializeupgrade 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.