3. The Schema model¶
3.1 Mental model¶
Non-normative.
A schema is a graph of named records. Each record lists the labels it allows, how many times each may appear, and what shape sits at the other end of each edge.
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 whole order sits under a single top-level order key. That is deliberate:
a Document with one top-level edge is the only shape XML can carry, so a
single-rooted schema is the one that round-trips through every format
(chapter 7).
As a graph, root Root looks like this. An edge label carries its cardinality;
the node it points to is either a scalar kind or another record.
graph LR
Root((Root)) -->|"order [1,1]"| Order((Order))
Order -->|"id [1,1]"| string1(("string"))
Order -->|"status [1,1]"| string2(("string"))
Order -->|"total [1,1]"| number1((number))
Order -->|"address [1,1]"| Address((Address))
Order -->|"items [1,∞]"| LineItem((LineItem))
Order -->|"coupon [0,1]"| string3(("string"))
Address -->|"street [1,1]"| string4(("string"))
Address -->|"city [1,1]"| string5(("string"))
LineItem -->|"sku [1,1]"| string6(("string"))
LineItem -->|"qty [1,1]"| integer1((integer))
LineItem -->|"price [1,1]"| number2((number))
Four things are worth noticing before the formal rules.
Every record is closed. Order allows id, status, total, address,
items, and coupon, and nothing else. A stray shipping edge is invalid.
There is no wildcard.
Cardinality does all the multiplicity work. [1,1] is required and is the
default when you write no bracket. [0,1] is optional. [1,] is a non-empty
array. [,1] is at most one. There is no array type, because a repeated label
is already the array.
Records are named and referenced. There is no inline record body inside a field. Reuse and recursion then work the same way as every other reference, and the schema is a graph of named definitions rather than a nested tree.
A field's type is exactly one thing. One scalar kind, or one reference, or
any. Never a choice between candidates.
That last point is worth dwelling on, because it is the constraint that shapes the whole model.
3.2 What this model refuses, and why¶
Omnist does not support structural unions ({a} | {b}), value enums
("red" | "green"), or open/wildcard maps ({ [string]: T }). These are
refusals, not omissions awaiting a future release.
The reason is one sentence, and it is the same reason in all three cases:
The moment a value can match more than one candidate, there is no principled way to decide what it really is.
Follow it through. If a field could be "an integer or the literal string
unlimited", then when a reader materializes a value it must pick a type, and a
value that matches both candidates — or neither cleanly — leaves it guessing.
If a record could be {a} | {b}, then a node matching both admits two different
answers to "which record is this," and every operation downstream inherits the
ambiguity. If a record has an open key set, the label alphabet the algebra
reasons over is no longer finite, and compatible_with, normalize, and
extract stop being decidable.
The rest of this specification does not repeat this argument. Where a refusal appears below, this is the reason.
The single sanctioned opening is any (§3.7), which is designed to not create
this problem: it opens a value, never a label alphabet, and it is written
literally in the schema text so it can be found by grep and audited by a human.
The closed alternative to an open map. Refusing { [string]: T } doesn't
mean open-ended key/value data is unmodelable — it means it has to be
authored as a record of entries instead of a map:
record Env { "entry" [0,]: EnvEntry }
record EnvEntry { "key": string, "value": string }
root Env
This gives up something real, on purpose: duplicate keys now validate (two
entry edges can carry the same "key" value — nothing stops it), and data
must be authored in this entry-list shape from the start. A third party's
{"A": "1", "B": "2"} can't be validated as-is; it has to be restructured
into entries first, which only works when the caller owns that
restructuring step. That's exactly the case any exists for (§3.7) — data
whose shape isn't the caller's to restructure at all.
3.3 Formal definition¶
Schema = (root: Ref, env: Name -> Record)
Record = { Field, ... } ; CLOSED: only these labels
Field = (label: String, type: Type, cardinality: [min, max])
Type = Scalar | Ref | Any
Scalar = (kind, nullable: Boolean)
kind in { string, integer, number, boolean, date, time, datetime }
Ref = Name ; resolved in env
Any = the singleton `any` type
min = non-negative integer
max = non-negative integer | unbounded
Constraints on a well-formed schema:
- S-1. Exactly one root MUST be declared. The root MUST be a
Ref, never a scalar and neverany. - S-2.
minMUST be a non-negative integer.max, when bounded, MUST be a non-negative integer withmax >= min. A negative bound or an inverted range is an error. - S-3. A record name MUST NOT be one of the seven scalar kind keywords, and
MUST NOT be
any. Type position resolves a bare name to a builtin first, so such a record could never be referenced. This comparison is exact and case-sensitive —Stringdoes not collide with thestringkeyword. This was previously unstated (every implementation already agreed on case-sensitive matching independently, verified 2026-09-13 by reading all five ports' source; this codifies existing, already-uniform behavior rather than changing anything). - S-4. A record name MUST be unique within
env. - S-5. A field's label MUST be unique within its record. Two fields naming the same label is an error, not an implicit merge.
- S-6. A
RefMUST resolve to a name present inenv. Forward references and mutual recursion are legal; a dangling reference is an error. - S-7.
nullableMAY be set only on aScalar. A nullableRefand a nullableanyare both errors. - S-8. A
Name(a record name, or aRef's target name) MUST match[A-Za-z_][A-Za-z0-9_]*. This was previously enforced only implicitly, by OSD text's own tokenizer (§5.3) — the only surface that could ever construct aNamebefore OSD-OML existed. OSD-OML's Document-shaped input is not grammar-constrained the same way, so it is the first surface that can actually construct aSchemaviolating this without a stated rule to catch it. This is a field label is a value, a record/ref name is an identifier distinction — the same one §5.2 already draws for OSD text; S-8 applies it to the abstract model directly so every syntax surface inherits it uniformly. Field labels are unaffected — they were never identifiers in this sense and remain arbitrary non-empty strings (S-5, and the bracket/empty-string rules in §5.4).
A schema MAY be recursive. env is finite, so every operation in
chapter 6 terminates.
These constraints govern a Schema however it was built. A Schema may be produced by parsing OSD text (chapter 5), by parsing OSD-OML (extensions), by an algebra operation (chapter 6), or by direct programmatic construction through an implementation's own API — the same way §2.4 treats a Document builder as a peer of the OML parser rather than an afterthought. S-1 through S-8 are properties of the model, not of any one route into it.
This matters because the routes are not equally constrained. A text grammar
can make a violation unwritable, and where it does, the corresponding check
has historically gone unstated — S-8 existed only inside OSD's tokenizer
until OSD-OML needed it, and max = 0 (§3.4) is
representable programmatically while both text surfaces reject it. An
implementation MUST enforce S-1 through S-8 at construction, not rely on its
parsers to have made violations unreachable.
Canonical serialization order. env is a set — S-4 guarantees unique
names, and nothing above assigns meaning to the order records or fields were
declared in; this is a distinct concern from the Document model's own edge
order (D-1/D-3, §2.3),
which governs data, not schema declarations. An implementation's internal
representation MAY store records and fields however it likes, but writing
a Schema back to text or to another syntax requires picking some order, and
this was never actually specified — an omission, not a decision, discovered
when a byte-exact round-trip vector first exposed it. The following five
principles govern every current and future Schema-producing operation,
Core or extension:
- Preserve real order where one exists. If every output record and field traces back to exactly one original (nothing merged), the writer MUST preserve that original's declaration or construction order.
- Fall back to content-derived order where none exists. If an operation can merge multiple original records into one output record, "original order" is undefined for the merge — the writer MUST instead order records by name and fields by label.
- The fallback compares by Unicode codepoint (scalar value), never by
locale, and never by UTF-16 code unit. These are two different traps,
not one: locale-aware collation (e.g. Swedish sorting
åafterz) depends on OS/host-language settings and MUST NOT be used; comparing by UTF-16 code unit instead of codepoint — the default in some languages — silently disagrees with codepoint order for characters outside the Basic Multilingual Plane (surrogate pairs sort differently under the two schemes). An implementation whose default string comparison is UTF-16-code-unit-based MUST compare by codepoint explicitly for this fallback, not rely on that default. - A caller-supplied collection that affects output order MUST be documented as an ordered sequence, not a set — otherwise principle 1 has nothing well-defined to preserve.
- Classifying any operation — present or future, Core or extension — reduces to one question: can it merge N>1 original records into 1? Yes → principle 2. No → principle 1. No operation needs its own bespoke rule.
Determinism. Independent of which principle applies, an implementation's output for a given operation and input MUST be identical across repeated invocations. This is a baseline correctness property, not a new ordering choice — an implementation whose output for the same input varies from run to run (for example, by iterating a hash map with no defined order) violates this regardless of which order it happens to produce on any given run.
Applying these principles to the operations in
chapter 6: §6.5
prune only removes records and fields, never merging anything, so
principle 1 applies — the surviving records and fields MUST keep their
original declaration order. §6.8
normalize merges structurally-identical records into one representative,
so principle 2 applies — its output MUST be alphabetical.
§6.9 extract is itself defined
as prune followed by normalize, so it inherits normalize's
alphabetical order by delegation, not by having its own merging step.
§6.10 infer never merges two
different labels into one output position — it only aggregates values
within one already-positioned label across samples — so principle 1
applies: output order follows the labels' first-seen order across
samples, and principle 4 requires samples itself to be an ordered
sequence for that to be well-defined.
3.4 Cardinality¶
Cardinality is a closed integer range [min, max] where max may be unbounded.
It bounds the count of edges carrying the field's label in a node. It says
nothing about their positions.
The OSD surface forms:
| Written | min | max | Meaning |
|---|---|---|---|
| (omitted) | 1 | 1 | Exactly one. The default. |
[n] |
n | n | Exactly n |
[m,n] |
m | n | Between m and n inclusive |
[m,] |
m | unbounded | At least m |
[,n] |
0 | n | At most n |
[,] |
0 | unbounded | Any count, including zero |
[] |
— | — | Error. Empty cardinality. |
Common cases in the shorthand above: [1,1] required, [0,1] optional,
[0,] array, [1,] non-empty array, [2,5] bounded array.
The grammar in chapter 5 accepts every row of this table
except the last, which it rejects with a specific error. In particular the
comma-first forms [,n] and [,] are legal: a minimum bound before the comma
is not required.
A field with max = 0 means the label may never appear. min <= max forces
min = 0 too, so max = 0 only ever means [0,0] — and [0,0] MUST NOT be
written directly in OSD source (§5.5,
schema.invalid-cardinality): a field that can never appear is
indistinguishable from one that was never declared, so allowing both would be
a second spelling for one thing. OSD-OML forbids it
identically.
max = 0 is nevertheless representable in the model, since S-2 requires
only max >= min and 0 >= 0 holds. The single route to it is direct
programmatic construction — building a Field through an implementation's
own API rather than parsing either text surface. prune (§6.5)
is what cleans it up.
Two consequences worth stating plainly, because both are easy to get wrong:
- No operation in chapter 6 produces
max = 0. There is no intersection, meet, or range-narrowing operation in this spec;max = 0is consumed in several places and produced in none. An earlier version of this section justified the value as "an intermediate result of algebra operations that narrow a cardinality range" — no such operation exists, and that justification was wrong. - No conformance vector can reach it. Every vector supplies schemas as
OSD text, and OSD text cannot express
[0,0], soprune'smax = 0rule is normative but unverifiable through the suite as currently designed. An implementation could omit that rule entirely and still pass.
Whether the model should permit max = 0 at all — rather than tightening
S-2 to forbid it and deleting the handling from
§6.5 and
§6.6 alike — is
omnist-spec#83, and is
deliberately left open here rather than settled in passing.
3.5 Nullable versus optional¶
These are different questions and they use different mechanisms. Conflating them is the most common modeling error in Omnist.
| Question | Mechanism | Written |
|---|---|---|
| May the edge be missing? | cardinality min = 0 |
"coupon" [0,1]: string |
May the value present be null? |
nullable scalar | "coupon": string? |
| Both? | both | "coupon" [0,1]: string? |
? applies to scalars only. A reference MUST NOT take ?; "this subtree may be
absent" is cardinality [0,1]. any MUST NOT take ? either, since any
already admits null.
A record-or-null field would need a type that is half scalar and half reference. The model has no such type, by §3.2.
3.6 Validation¶
A node n conforms to a record R in schema S if and only if all three hold.
- Cardinality. For every field
(label, type, [min, max])inR, letcbe the number of edges innwhose label equalslabel. Thenc >= min, and ifmaxis bounded,c <= max. - Closedness. Every edge label in
nis the label of some field ofR. - Targets. For every edge in
n, its target conforms to the type of the field whose label it matches.
A target conforms to a type as follows:
- to
Scalar(kind, nullable): the target is a value whose kind iskind, or the target isnullandnullableis true. A node never conforms to a scalar. - to
Ref(name): the target is a node conforming toenv[name]. A value never conforms to a reference. - to
any: always. Descent stops; nothing below is checked.
A Document conforms to a schema S if its root node conforms to env[S.root].
Order MUST be ignored at every step (invariant D-3). Validation counts edges; it never sequences them.
Validation checks, it never converts. A value either has the declared kind
or it does not. Converting a value to match a declared type is materialization,
a separate operation defined in
chapter 7. This is distinct from
subtyping, which is not conversion: the one sanctioned scalar subtype
relation (§6.3, integer <:
number) applies at the value level inside validate too, via
matches_kind below -- an integer-kinded value already has a kind that
satisfies a number-typed field, with nothing upgraded or changed. Verified
against the reference implementation: validate on an integer value against
a number-typed field returns ok: true with no diagnostics.
Validation MUST report every failure, not just the first. A validation
result is a list of (path, code, message) entries; an empty list means valid.
Codes are defined in chapter 8.
3.6.1 validate(document, schema) — pseudocode¶
Uses the notation of §6.2:
S.resolve(t), R.field(label), f.min/f.max.
function validate(document, S):
result = ValidationResult() # a list of (path, code, message)
conform(document.root, S, S.root, "$", result)
return result
function conform(node, S, t, path, result):
d = S.resolve(t)
if d is Any:
return # descent stops; accepted unchecked
if d is Scalar:
conform_scalar(node, d, path, result)
else: # d is Record
conform_record(node, S, d, path, result)
function conform_scalar(node, s, path, result):
if node is a node (not a leaf):
result.add(path, "shape-mismatch", "expected a scalar value, got an object")
return
if node.value is null:
if not s.nullable:
result.add(path, "null-not-allowed", "null not allowed here")
return # null never checked against kind
if not matches_kind(node.value, s.kind):
result.add(path, "type-mismatch", "value does not match declared kind")
function matches_kind(value, declared_kind):
if value.kind == declared_kind:
return true
# The one sanctioned scalar subtype relation (§6.3) applies here too,
# not only between schemas: an integer VALUE satisfies a number-typed
# field directly. This is not materialization -- no conversion happens,
# the value's own kind is simply a subtype of the declared one.
if value.kind == "integer" and declared_kind == "number":
return true
return false
function conform_record(node, S, rec, path, result):
if node is a leaf (not a node):
result.add(path, "shape-mismatch", "expected an object, got a value")
return
counts = {}
for (label, child) in node.edges: # in edge order; order not otherwise used
i = counts.get(label, 0)
counts[label] = i + 1
child_path = path + "." + label + (("[" + i + "]") if i > 0 else "")
f = rec.field(label)
if f is none:
result.add(child_path, "unexpected-field", "field not declared on this record")
# no further descent: an undeclared field has no type to check against
else:
conform(child, S, f.type, child_path, result)
for f in rec.fields:
c = counts.get(f.label, 0)
if c < f.min or not le(c, f.max):
result.add(path, "cardinality",
"field " + f.label + " occurs " + c + " time(s), "
+ "expected [" + f.min + "," + f.max + "]")
Two details that are easy to miss and change the result if skipped:
- An undeclared field's target is never descended into. It is reported and left alone; conformance of its subtree is meaningless once the field itself is invalid.
- The cardinality error's path is the parent node, not the field's edges. "This record is missing a required field" has no single edge to point at when the count is zero, so the path is always the record's own path, even when the count is nonzero-but-wrong.
validate MUST run within the depth limit of §2.4.
Exceeding it is a resource-cap error, not a validation finding, and is defined
there, not in this section.
3.7 The any type¶
any is the model's one sanctioned opening. It accepts every legal Document
value: any scalar of any kind, null, and any node of any shape.
Its scope is deliberately narrow.
What any buys: one schema for a variant payload. A common shape in
practice — webhooks, message-queue events, CloudEvents — is a rigorously
typed envelope wrapping a payload whose shape depends on a sibling field.
Without unions, that payload is out of reach entirely; any makes it
reachable:
record Event {
"id": string,
"type": string,
"created": datetime,
"data": any,
}
root Event
Both of these validate against that one schema:
{"id": "evt_1", "type": "user.created",
"created": "2026-07-01T09:30:00",
"data": {"name": "Ann", "email": "ann@example.com"}}
{"id": "evt_2", "type": "payment.settled",
"created": "2026-07-01T09:31:00",
"data": {"amount_cents": 1250, "currency": "EUR"}}
This does not smuggle a union back in. The schema never chooses between
candidates — application code dispatches on type explicitly, then
validates data against a per-event-type schema of its own, also closed.
The choice lives in code, where it's visible and debuggable, never in the
algebra.
What any opens. The value at one declared field, and everything beneath
it.
What any does not open. The record's label alphabet. A field typed any
still has a label and still has a cardinality; the record remains closed. Adding
an any field does not let unknown labels through anywhere.
Semantics of any in each operation:
| Operation | Behavior at an any boundary |
|---|---|
| validate | Descent stops. The subtree is accepted unchecked. |
compatible_with |
If the right-hand type is any, the answer is true — any absorbs everything. If the left-hand type is any and the right-hand type is not, the answer is false. |
| materialize | The subtree passes through untouched. No leaf upgrades happen inside it. |
infer |
infer MUST NOT emit any unless explicitly requested. When requested, every opening it introduces MUST be reported. |
lint |
Every any field is reported as an informational finding, so a human can audit the schema's openings. |
Restrictions:
any?MUST be rejected.anyalready includesnull.- A record MUST NOT be named
any. - Only the exact lowercase spelling is reserved.
Anyis an ordinary name and therefore a reference. - Cardinality is orthogonal to
any:"data" [0,]: anyis legal.
The cost of
anyis stated plainly in §6.2: compatibility checking is vacuous inside ananyboundary, so a change made beneath one is invisible tocompatible_with. That is the trade the escape hatch buys, and it is whylintinventories every occurrence.