Skip to content

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 never any.
  • S-2. min MUST be a non-negative integer. max, when bounded, MUST be a non-negative integer with max >= 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-sensitiveString does not collide with the string keyword. 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 Ref MUST resolve to a name present in env. Forward references and mutual recursion are legal; a dangling reference is an error.
  • S-7. nullable MAY be set only on a Scalar. A nullable Ref and a nullable any are both errors.
  • S-8. A Name (a record name, or a Ref'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 a Name before 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 a Schema violating 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:

  1. 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.
  2. 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.
  3. 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 å after z) 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.
  4. 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.
  5. 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 = 0 is 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], so prune's max = 0 rule 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.

  1. Cardinality. For every field (label, type, [min, max]) in R, let c be the number of edges in n whose label equals label. Then c >= min, and if max is bounded, c <= max.
  2. Closedness. Every edge label in n is the label of some field of R.
  3. 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 is kind, or the target is null and nullable is true. A node never conforms to a scalar.
  • to Ref(name): the target is a node conforming to env[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. any already includes null.
  • A record MUST NOT be named any.
  • Only the exact lowercase spelling is reserved. Any is an ordinary name and therefore a reference.
  • Cardinality is orthogonal to any: "data" [0,]: any is legal.

The cost of any is stated plainly in §6.2: compatibility checking is vacuous inside an any boundary, so a change made beneath one is invisible to compatible_with. That is the trade the escape hatch buys, and it is why lint inventories every occurrence.