YAML¶
Model mapping¶
YAML maps through its JSON-compatible core. A mapping becomes a list of edges; a key whose value is a sequence becomes a repeated label. On those two shapes YAML and JSON are the same codec.
| YAML | Document |
|---|---|
a: 1 / b: 2 |
[(a,1),(b,2)] |
m: with a two-item sequence |
[(m,A),(m,B)] |
Aliases resolve at parse time. a: &x foo / b: *x reads as two
independent edges both carrying foo. Shared identity is not preserved; the
Document model has no notion of it. This is lossless in value and lossy in
structure sharing, which is the correct trade for a model whose whole point is
the fully expanded edge list.
The merge key, <<, is the one alias form that does not nest. An entry
<<: *x in a mapping flattens the referenced mapping's own entries into the
referring mapping — one level up — rather than nesting a copy of it under the
key <<. YAML 1.1 also permits a sequence of aliases, <<: [*x, *y], merging
each in turn; an empty sequence, <<: [], merges nothing and is not an
error. << itself never survives as a label; it is a merge
instruction, not an edge. So d: &d {a: 1} / e: {<<: *d, b: 2} reads as
e carrying the two edges a and b, not an edge named <<. This is the
mechanism D-18 means by
"flattened", and the reason a merge-key config expands one-to-one where a
plain alias multiplies; §2.4.1 states what each form contributes to the
expansion factor.
Merged entries come first, in source order. A Document is an ordered edge list (D-1), so a codec cannot leave this to chance the way YAML can: a YAML mapping is unordered and YAML 1.1's merge type says nothing about the resulting order, but an Omnist Document is ordered and conformance vectors compare that order byte for byte. The normative order is:
- for
<<: *x, the entries ofxinx's own written order, then the referring mapping's own entries in theirs; - for
<<: [*a, *b],a's entries, thenb's, then the referring mapping's own — sequence order, the order the aliases are written in, with no reversal at any level.
So:
base: &base
region: eu-west-1
limits: &limits
retries: 3
svc:
<<: [*base, *limits]
name: api
reads svc as the three edges region, retries, name, in that order.
Source order is normative (D-1) because it is the only order the written text
explains. Some YAML libraries flatten a merge sequence in reverse —
PyYAML's constructor does, which is how the reference implementation came to
produce retries, region, name — and a codec built on one of those MUST
reorder rather than pass the artifact through. See
omnist-spec#98. All five
implementations pass the vectors that pin source order as of v0.21.0-beta.
Key collisions resolve to one edge, at the earliest position. A key
supplied by more than one of the merged sources, or by a merged source and
the referring mapping both, produces exactly one edge. It sits at the
position of its first occurrence in the order above, and it carries the
referring mapping's own value when the mapping writes one, otherwise the
value from the earliest alias in the sequence that supplies it. Over
d: &d {a: 1, b: 2}, the mapping e: {<<: *d, a: 99} reads as a = 99 then
b = 2 — the local value in the merged key's position — and this holds
whether the local a: is written before or after the <<:. Over
p: &p {a: 1} and q: &q {a: 2}, the mapping r: {<<: [*p, *q]} reads as
the single edge a = 1: the earlier alias wins. Both rules were verified
against two independent YAML implementations — PyYAML 6.0.3 and the
JavaScript yaml library 2.9.0 — which produce the same values on every
shape tested. They agree on order too except where PyYAML's reversed
sequence flattening shows through, which is the artifact this section rules
out: given p: &p {a: 1, b: 5} and q: &q {b: 2, c: 3}, the mapping
r: {<<: [*p, *q], c: 99} reads as a = 1, b = 5, c = 99 under this
rule and in the JavaScript library, while PyYAML returns the same three
values in the order b, c, a.
A merge nests. An aliased mapping that itself contains a << is merged
recursively, and the grandparent's entries arrive first: for
g: &g {a: 1}, m: &m {<<: *g, b: 2} and n: {<<: *m, c: 3}, n reads as
a, b, c. A repeated alias in one sequence contributes once.
<<: [*p, *p] yields exactly one copy of p's entries, not two — the second
occurrence supplies only keys the first already supplied, and the collision
rule above collapses them. Both were measured in PyYAML 6.0.3 and
yaml 2.9.0, which agree on both.
This is also the case §2.4.1's D-19 flags when it calls W a conservative
bound: the expansion check runs before materialization and is deliberately
blind to collision resolution, so it counts a collided key twice where the
reader materializes it once. Erring high there is intended, and nothing here
changes it.
Which is exactly why the YAML reader MUST bound alias expansion. Fully
expanding every alias is what makes an anchor an amplifier: one written
definition materializes again at each reference, and an anchor whose
definition itself contains references multiplies. YAML's anchor/alias
mechanism is the only such construct among the five formats this spec covers,
so YAML is the only format on which
D-18 currently has
anything to do — and a conformant YAML reader MUST enforce it. Compute the
expansion factor E of every anchored definition, every other mapping and
sequence, and the root, from the anchor/alias graph
before expanding, reject the input with document.limit.alias-expansion
when any E exceeds the configured maximum (reference default 50), and
reject a self-referential anchor outright. It MUST also reject an input
containing an alias or merge key whose total expansion W of the root exceeds
the configured maximum expanded size (reference default 1 000 000 slots) with
document.limit.expanded-size
(D-22); a YAML input
with neither is outside that limit, though
D-23's input
size applies to it. A merge value that is not a mapping or a
sequence of mappings is a syntax error, parse.codec-syntax; a sequence with
no members is a sequence of mappings and merges nothing. An alias with no
preceding anchor is also a syntax error, a redefined anchor shadows the earlier
one for the aliases that follow it, and a quoted << is an ordinary key, not a
merge key; so is a << tagged !!str, while one tagged !!merge is a merge key
(D-27). §2.4.1 defines
E, gives the
reasoning behind the default, and explains why ordinary anchored YAML — merge
keys, shared constants, anchor chains — sits far below it. Nothing here
changes the value-fidelity rule above: an alias that is expanded still reads
as an independent edge carrying the value.
YAML resolves some scalars on its own. A bare ISO-8601-looking scalar
resolves to a date or datetime with no schema involved. YAML is one of two
formats whose native parser can do this without a schema — TOML is the
other, and covers all three temporal kinds (date, time, and datetime) where
YAML only resolves date and datetime; a bare time-of-day resolves to an
integer instead, not a time (see the sharp edge below).
One sharp edge. YAML's core schema has no standalone time type. A bare
12:00:00 resolves to the integer 43200 — sexagesimal, twelve hours in
seconds. That is YAML's behavior, not a choice Omnist makes, and there is no
read-side workaround: by the time the value reaches Omnist it is already an
integer. Quote it. A writer does: a time is written as a double-quoted
ISO-8601 string and reported with format.temporal-stringified, so it reads
back as a string, never as an integer or a float
(E-37).
A second sharp edge, real enough to have a name: the "Norway problem."
YAML 1.1's core schema resolves on/off/yes/no/true/false (in
various cases) as booleans, not strings — the classic case is NO parsing
as false, and on is a less-famous instance of the same rule. A bare on:
key parses to the boolean true, not the string "on". Since a label MUST
be a string (§2.2.2), a reader MUST refuse to build a Document from a
boolean-keyed mapping rather than silently coercing it — this is correct
behavior given what the YAML parser handed it, but it collides with the one
top-level key almost every real GitHub Actions workflow file needs (on:
starting the trigger block), and almost no one quotes it. Quoting the key
("on":) sidesteps the problem entirely; there is no read-side workaround
once an unquoted on: has already resolved to a boolean. See
test.yml (a real workflow that
fails at exactly this point) and
test-quoted-on.yml (the
same file with the key quoted, which reads and validates cleanly) in
../examples/.
This alias set is exactly these six words, not the raw YAML 1.1
specification's fuller list. The YAML 1.1 spec itself also resolves bare
y/Y/n/N as booleans; the reference implementation's resolver
deliberately does not (matching PyYAML's own default SafeLoader, which
omits the single-letter forms specifically because they collide too easily
with ordinary short keys and values). A bare n: or y: key stays an ordinary string label here, not a
Norway-problem case — confirmed live for n/N/y/Y in both key and
value position. Don't assume the full YAML 1.1 alias list transfers; only
on/off/yes/no/true/false (case variants included) resolve as
booleans.
Integer and float spellings are YAML 1.1's core schema, as PyYAML's default
SafeLoader resolves it (C-17):
0x1F, 0b101, 1_000, 010 (octal, 8), 190:20:30 and 1:30.5 resolve,
and 0o17, 08, 0:5, 1e3 and 1.0e3 stay strings. A repeated << merges
every time it is written, and a << after an explicit key still merges first
(C-12).
Interleaving is lost on write, as in JSON: same-label edges group into one key regardless of position.
Duplicate keys are rejected
(C-12). A mapping that
holds the same key twice, in block or flow style and at any depth, fails the
read with parse.codec-syntax: a: 1 / b: 2 / a: 3 is an error, not the
edges a = 3, b = 2. YAML 1.1 and YAML 1.2 both require the keys of a mapping
to be unique, and the mainstream libraries that enforce it (the JavaScript
yaml package, go-yaml v3) are the ones that read the specification
literally; the ones that let the last key win are the lenient exception, not
the consensus. This is not JSON's policy
(§ JSON, "Duplicate keys"): RFC 8259 only advises that names be
unique, so a JSON reader has a result to choose and Omnist chooses the one every
mainstream JSON parser already gives. A key a merge key supplies is not a
duplicate of the same key written in the mapping; the merge section above
states that collision. a and "a" are one key.
!!pairs, !!omap and !!set are refused
(C-13) with
format.tag-forbidden: well-formed YAML outside the profile, not a syntax error. They name ordered-pair and set collections the Document
model has no single reading for; write the plain sequence of single-key
mappings, or the plain mapping, instead.
A number literal too large for binary64 reads as an infinity
(D-29): a: 1.0e+999 reads as the
number +Infinity with no diagnostic. Which spellings YAML resolves as a float
is the resolver's own business; the rule is about what a float literal beyond
the range means. For an integer literal D-9's digit limit counts the digits of
the value, not of the spelling
(D-28): 0x followed by 3 600 F
is a value of 4 335 decimal digits and is refused with
document.limit.int-digits, and 1_000 has four digits, not five.
A NEL is written as \N. YAML 1.1 reads a raw U+0085 in a scalar as a line
break, so the writer quotes the string and spells the character with the \N
escape, a: "x\Ny", and reports format.string-line-break-char
(E-34); a YAML
reader resolves \N back to the one character.
Worked example¶
The schema:
record Address { "street": string, "city": string }
record LineItem { "sku": string, "qty": integer, "price": number }
record Order {
"id": string,
"status": string,
"total": number,
"address": Address,
"items" [1,]: LineItem,
"coupon" [0,1]: string,
}
record Root { "order": Order }
root Root
The same order in YAML:
order:
id: A1
status: shipped
total: 29.97
address: {street: 1 Main, city: London}
items:
- {sku: W, qty: 3, price: 9.99}
- {sku: G, qty: 1, price: 9.99}
reads to:
[ (order, [ (id, "A1"),
(status, "shipped"),
(total, 29.97),
(address, [ (street, "1 Main"), (city, "London") ]),
(items, [ (sku, "W"), (qty, 3), (price, 9.99) ]),
(items, [ (sku, "G"), (qty, 1), (price, 9.99) ]) ]) ]
Byte-for-byte identical to the Document JSON produces. The sequence under
items becomes two items edges, not one edge holding a list.
Two YAML-only notes on this example. status: shipped is an unquoted scalar
and resolves to the string "shipped", which is what "status": string
wants — but an unquoted status: no would resolve to the boolean false, and
the schema would then report a type mismatch rather than silently coercing. And
if the order carried a placed: 2024-01-01 field typed date, YAML would
resolve it to a date during stage 1, where JSON would hand stage 2 a string
to upgrade. Same schema, same final Document, different stage.
Parity gaps¶
Chapter 9's status table (§9.3) is the authority on which implementations ship a YAML codec. This page states no per-port status of its own: a copy here can only go stale relative to the ledger.
There is no YAML-specific entry in
§9.4. The resolver
behavior above — dates, and 12:00:00 as an integer — is YAML's own, and is
specified rather than divergent.