Skip to content

5. OSD grammar

OSD (Omnist Schema Definition) is the text format for Schemas. The machine-readable grammar is grammars/osd.abnf. This chapter is its normative prose companion.

5.1 Shape

# Service topology
record Database {
    "type":            string,
    "server":          string,
    "port":            integer,
}
record Service {
    "host":            string,          # cardinality [1,1] by default
    "port":            integer,
    "databases" [1,]:  Database,        # at least one
    "tags" [0,]:       string,          # any count, including zero
    "owner" [0,1]:     string?,         # may be absent, may be null
    "payload":         any,             # one declared opening
}
root Service

Declarations may appear in any order. root need not come last, though canonical output places it there.

5.2 The quoting rule

This is the single most important disambiguation in OSD, and it has no exceptions.

Spelling Means
"quoted" a data string. In this grammar that is only ever a field label.
unquoted name a schema name: a scalar keyword, any, or a reference to a record.

The two are never interchangeable. A bare name in label position is an error. A quoted string in type position is an error.

5.3 Tokens

The tokenizer is a single ordered alternation. Whitespace and comments are discarded before the parser sees anything, so a comment may appear anywhere whitespace may — between declarations, inside a record body, after a field.

# starts a comment that runs to end of line.

An OSD name is [A-Za-z_][A-Za-z0-9_]* — this is §3.3's S-8 Name domain, enforced here by the tokenizer itself; OSD text cannot tokenize a name outside this set in the first place, so no separate check is needed at the schema-construction stage the way OSD-OML needs one. Note the difference from OML's IDENT: OSD names do not permit a hyphen.

5.3.1 String unescaping

An OSD string's value is computed by stripping the quotes and replacing every backslash pair \X with the single character X. There is no named-escape table. \n becomes the letter n, not a newline. \\ becomes \ and \" becomes ", which are the only two cases where the rule matches intuition.

This is deliberately weaker than OML's string escaping and MUST NOT be "upgraded" by an implementation. A label like "a\nb" is the three-character string anb. Conformance vectors cover this.

A raw control character below U+0020 inside an OSD string is an error (parse.control-character, §8.3.1), the same restriction OML's double-quoted strings have (§4.5). Weak unescaping only changes how backslash sequences are interpreted — it says nothing about which raw bytes are legal in the string body to begin with, and there is no reason for OSD to be laxer than OML on that separate question.

The ban applies to every raw byte in the string body, escape context included. A control character immediately after a backslash is still a control character in the body, so "a\<U+0001>b" is an error exactly as "a<U+0001>b" is. This was previously written as "a literal control character", which invited the reading that only an unescaped one was forbidden — and grammars/osd.abnf's escape alternative was written as "\" %x00-10FFFF, which admitted the escaped form. The grammar now excludes %x00-1F there, so the two artifacts agree.

5.4 Records and fields

record-def = "record" name "{" [ field *( "," field ) [ "," ] ] "}"
field      = string [ cardinality ] ":" type

A trailing comma after the last field is legal, and is what canonical output emits. Fields are otherwise comma-separated, with no leading comma.

An empty record body — record R { } — is legal. It describes a node with no edges.

A field label MUST NOT be the empty string. "" is a legal value for an OML/OSD string generally, but a label is an identifier, not a value — an empty label names nothing a caller could ever reference, and any real input that produced one represents a data-quality problem, not an intentional schema. "": string is rejected with schema.empty-label.

A field label MUST NOT contain [ or ]. §3.6.1's diagnostic-path convention appends [i] to a repeated label's second and later occurrences (the first occurrence of "a" paths as $.a, the second as $.a[1]). A label that itself contains a literal bracket — "a[1]": string declared as its own field, alongside a repeatable "a" — can produce that exact same path for a genuinely different field, making the two indistinguishable in a diagnostic. This is rejected outright with schema.bracket-in-label, the same "don't allow two different things to collide into one spelling" principle as schema.empty-label above and the format.* write-side fixes in §8.3.8 — here applied to the label vocabulary itself rather than to a written value.

5.5 Cardinality

cardinality = "[" ( int [ "," [ int ] ] / "," [ int ] ) "]"
int         = 1*DIGIT

Every accepted form, and what each means:

Written min max
(omitted) 1 1
[3] 3 3
[1,5] 1 5
[5,] 5 unbounded
[,5] 0 5
[,] 0 unbounded

The grammar above accepts all five bracketed rows, including the comma-first forms. It rejects [], which is the "empty cardinality" error.

Three further checks sit above the grammar, and MUST be applied:

  • A bound containing . is rejected: cardinality must be a whole number.
  • A negative minimum is rejected. This spec's int production above accepts only unsigned digits, but a conformant tokenizer's number token MAY include an optional leading - (the reference implementation's does, since the same token also reads negative field values elsewhere in the grammar). Either way, [-1] MUST NOT be reported as a syntax error: whether the - is rejected at the token boundary or accepted into the token and rejected one step later at field construction, the observable result is the same invalid-cardinality error, not a parse failure.
  • A leading + is a syntax error, unlike -. [+1] MUST be rejected as parse.unexpected-token, never accepted and silently normalized to [1,1]. The latitude granted to - above exists only because a tokenizer's number token legitimately reads negative values elsewhere in the grammar; + has no such role, so there is nothing for a conformant tokenizer to accept it into. The same goes for a repeated sign such as [--1]. Stated because - was addressed carefully and + was not, leaving three defensible readings — reject at the token boundary, accept then reject at construction, or accept and normalize — of which the third differs observably from the other two.
  • An inverted range, max < min, is rejected. [1,0] tokenizes fine and is rejected on field construction.
  • A cardinality of exactly [0,0] is rejected. A field that must occur zero times is indistinguishable, in every observable respect, from a field that was never declared at all — records are closed by default, so an undeclared label present in a document is already an error (§8.3.4's validate.unexpected-field). [0,0] would be a second spelling for that same thing, which this spec does not permit anywhere else (an empty array and an absent label are likewise never two different things — §4.3.1). [0,0] tokenizes fine and is rejected at field construction, the same schema.invalid-cardinality error as a negative bound or an inverted range.

5.6 Types

type        = scalar-type / any-type / ref-type
scalar-type = scalar-name [ "?" ]
scalar-name = string | integer | number | boolean | date | time | datetime
any-type    = any
ref-type    = name

Scalars. Exactly seven keywords. A trailing ? makes the scalar nullable; omitting it means non-nullable.

any. Reserved in its exact lowercase spelling only. any? MUST be rejected: any already includes null, so the suffix is redundant rather than meaningful. Capitalized Any is not this production — it is an ordinary name and therefore a reference, which produces an unknown-type error if no record by that name exists.

References. Any name that is not a scalar keyword and not any. Resolution is by lookup in the schema's environment, so forward references and mutual recursion both work. ? MUST NOT follow a reference; the error MUST point the author at cardinality [0,1] instead.

5.7 Reserved names

A record MUST NOT be defined with a name that is one of the seven scalar keywords, and MUST NOT be named any. In both cases the reason is the same: a bare name in type position resolves to the builtin first, so such a record could never be referenced. Per §3.3 S-3, this check is exact and case-sensitive — record String { ... } does not collide with the string keyword.

Defining the same record name twice is an error.

5.8 Root

Exactly one root declaration MUST be present. A schema with no root is an error (schema.no-root). A schema with more than one root declaration is also an error (schema.duplicate-root): a second root MUST NOT silently override the first.

5.9 Canonical output

An OSD writer is canonical if, for every schema, it emits text that parses back to an equal schema, following the canonical form below. Two conformant implementations parsing the same source and immediately writing it back MUST produce byte-identical text — that guarantee comes from §3.3's order principles plus the formatting rules below, together. It does not extend to two different texts that happen to describe the same schema (for example, the same schema written in OSD versus OSD-OML) — each preserves its own input's declaration order, so equivalent-but-different source can legitimately produce different, still-canonical output. Canonical form:

  • record and field order per §3.3's canonical serialization order invariant;
  • one record per record block, fields one per line, four-space indent;
  • a trailing comma after every field, including the last;
  • root last;
  • cardinality omitted when it is [1,1];
  • labels always quoted, types never quoted.
record R {
    "a" [0,3]: string?,
}
root R

A compact mode with no indentation is permitted and MUST round-trip: record R { "a": string } root R.

5.10 Worked examples

Every row MUST hold for a conformant implementation.

Input Result
record R { "a\nb": string } label is the literal three-character string anb
"a" [1,5]: string cardinality (1, 5)
"a" [5,]: string cardinality (5, unbounded)
"a" [,5]: string cardinality (0, 5)
"a" [,]: string cardinality (0, unbounded)
"a" []: string error: empty cardinality
"a" [-1]: string error: invalid cardinality
"a" [1,0]: string error: invalid cardinality
"a" [0,0]: string error: invalid cardinality (redundant with not declaring the field)
"a" [1.5]: string error: cardinality must be a whole number
"a": string? nullable scalar field
"a": Other? error: ? cannot apply to a reference; use [0,1]
record string { "a": string } error: reserved scalar name
record any { "a": string } error: reserved type name
record R{"a":string} twice error: duplicate definition
record R{"a":string} with no root error: a schema must declare a root
record R{"a":string} record S{"b":string} with two root declarations error: a schema MUST NOT declare more than one root
record R{a:string} error: expected a quoted field name
record R{"": string} error: a field label MUST NOT be empty
record R{"a": "string"} error: a quoted string cannot appear in type position
record R { "a": string, } valid; trailing comma accepted
record R { "data": any } field type is any
record R { "data" [0,]: any } valid; cardinality is orthogonal to any
record R { "data": any? } error: any already includes null
record R { "data": Any } with no record Any error: unknown type Any