TSON Part 2: Type System and Schema
2026 Revision 38
Status: Working revision. The 2026 revision series is subject to change without compatibility guarantees. When finalised, this specification will be published as TSON version 1 and frozen; until then, revisions are released under the 2026 series.
Series: TSON Specification, Part 2 of 3
Copyright: © 2026 Litterat Pty Ltd. This document is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License (CC BY-SA 4.0): https://creativecommons.org/licenses/by-sa/4.0/
1. Introduction
TSON is a schema system, and this document is its centre: a type system of immutable, hash-pinned schemas whose definitions are themselves data, resolving down a verified chain — document → schema → meta-schema → kernel — so that one hash authenticates a document together with its entire contract. The text format of [TSON-DATA] is this system’s notation and its reference encoding: schemas are written in it, resolver output is serialized in it, and §7 states its encoding rules — the first encoding to carry the type system’s values, with others defined against the same encoding-independent model.
This document defines the TSON type system and schema layer: the schema grammar and its type-definition forms, the type system and its operations, the schema chain and its resolution model, the resolver output contract, the text encoding rules, and the schema-layer directive operations.
[TSON-DATA] defines the lexer, the data grammar, base type resolution, and the built-in type vocabulary. This document introduces no lexical changes: a schema document is parsed by a second body grammar over the same lexer, selected by the document header ([TSON-DATA] §2.2), and every operator it uses is a token that lexer already emits — the reserved special tokens of [TSON-DATA] §7.2.5 receive their meaning here. The schema grammar imports [TSON-DATA]‘s value grammar at exactly two points — the constructor-application payload, a core-value, and the atom-refinement body, its braced subset (§5.5, §5.6, §12.1), the second desugaring into the first; every other value position is deliberately narrower (a record-refinement body is a braced record-def, §5.7; a field-modifier value is a bare token, or the selector =? with none, §5.2) — and the coupling is one-directional: nothing in the data grammar depends on this document.
1.1 The TSON Specification Series
- Part 1: Text Data Format [TSON-DATA] — the notation and reference encoding: the lexer, the data grammar, base type resolution, and the built-in type vocabulary.
- Part 2: Type System and Schema (this document) — the centre of the series: the schema grammar, the type system, the schema chain, and the operations of the
schema,meta, andimportdirectives. - Part 3: JSON Encoding [TSON-JSON] — the JSON encoding of the same model: the second encoding to carry the type system’s values, with its own encoding rules stated against §7’s shape, and the
TSON-Schematransport field.
[TSON-DATA] §1.3 gives the series architecture: the schema chain — the type system — on one axis, and the notation and encodings that carry it on the other.
1.2 Design Principles
In addition to the principles of [TSON-DATA] §1.2:
-
Schema-value separation — A document never references its own definitions via type annotations. Every type reference (
!name) resolves against an external schema identified by the document’s!!schemadirective (§3). -
Purpose-built representation — A schema document shares the data format’s lexicon — the lexer, its tokens, annotations, and directives — but has its own body grammar, selected by the document header (§12.1). The semantics remain shared: a schema document resolves to an ordinary TSON value (a schema value, §2.1), and resolver output is an ordinary data document (§8).
-
Permanent stability — The schema grammar, the meta-schema, the core type library, and the resolver output contract are frozen once published, on the same terms as [TSON-DATA] §1.2 principle 6. New types are added through new type libraries, not through changes to this document.
1.3 Conformance
[TSON-DATA] §1.5 defines the series’ two conformance classes. Class 1 (data-format processor) is defined there in full; its sole schema-layer obligation is to reject schema documents with a categorized diagnostic. This document defines Class 2.
A Class 2 processor (schema-aware processor) conforms to [TSON-DATA] and additionally implements the schema grammar (§4–§5, §12), the directive operations (§2.2), name resolution and the schema library (§3, §10), schema compilation and resolver output (§8), atom token parsing (§7.4), and validation. Such a processor:
- MUST pre-load the meta-kernel and meta-schema (§3.4, §10.1) and SHOULD pre-load the core type library and the network library;
- MUST resolve type annotations through the active schema when one is in scope, and MUST NOT apply the [TSON-DATA] §5 built-in vocabulary or [TSON-DATA] §4 base type resolution in schema scope: under a schema every value is typed by its position or by its tag (§7.1, §7.3);
- MUST produce, for every valid schema document, a resolved schema value — a value of the kernel’s
schema, a namespace from each name to the value its entry resolves to (§8) — with every type’schaincomputed and every kind’smemberscomputed (§8.1). Serializing the resolved schema value as a data document is OPTIONAL; output, when produced, MUST conform to §8’s serialization contract; - MAY implement ingest of resolver output (§8, §10.1); an implementation that does MUST apply §8.1’s derived-field treatment and §10.1’s source rules;
- MUST resolve annotations (
@name,@name:value) one hop against the governing target’s namespace (§3.3.3) and validate each annotation’s value against the named type’s contract (§6); an annotation whose name does not resolve is a resolver error (§6), and annotations are preserved in resolver output (§8.1); - MUST enforce the identity-agreement rule (§10.1), the import-level rule (§2.2.3) and verify hash-pinned references per [TSON-DATA] §2.2.1 (§10.2);
- MUST match every declared type name, parameter name, referenced name, and constructor head against the identifier grammar ([TSON-DATA] §7.7) and reject a failure as a parse error (§12.1); and MUST hold every declared field name and enum member to its record’s or enum’s name type, rejecting a failure as a resolver error (§5.2, §7.4);
- MUST implement the name-hygiene checks of [TSON-DATA] §8.2 over the schema-layer scopes (§11.4), enforce them by default, and report a refusal as [TSON-DATA] §8.1’s refused outcome — distinguishable from a verdict, in the same report; and MUST report a schema it could not obtain as the undetermined outcome, and one it will not obtain as a refusal (§10.1, §11.2);
- MUST enforce the schema-side resource limits of §11.5 on [TSON-DATA] §9.1’s terms;
- MUST report errors in the categories and phrasings of [TSON-DATA] §8.1.
Resolved-output consumers. A consumer that ingests only resolved schema values (§8, §10.1) — never schema source — is fully conforming with no support for parameters or substitution (§5.10). An open entry appears in output as its value, in one of two forms (§5.10, §8.1). A record template is a !record with parameters: an ordinary record body whose fields may carry a parameter in type or value, with its discriminators stated as the family’s, its extension each application’s, and its chain.parents the parents as written. Every other open entry is a !template — the kernel constructor whose instances are held bodies: the parameters, each with the type an argument for it is read as, and the application as text. The closed-entry rule (§5.10) has one deliberate exception: a type position may name a record template as a family base, since no value is ever read against it — a value there is a value of some member, and every member is closed. Such a consumer MUST therefore support naming a record template at a type position, recognising it by its parameters and dispatching on its discriminators, and MUST never need to read a field that carries a parameter: the selector names are stated on the record for exactly that reason. It can check an application against either form on the same terms: each parameter’s recorded type, and its bound where it has one, says what an argument must be (§5.10). Every other entry a data document’s type can reach is closed, so the wire never presents substitution machinery to a data-path consumer, and a consumer that ingests a !template’s held text needs the schema parser §8.1’s ingest already requires of it.
1.4 Terminology
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
- A schema document is the document kind whose header carries
!!meta([TSON-DATA] §2.2) and whose body is a schema map (§2.1, §12.1) — the source artifact that is authored, published, hash-pinned, and resolved. - A schema is what a schema document defines: a named, immutable collection of type declarations, identified by URL and referenced by the
!!schema,!!meta, and!!importdirectives. - A schema value is the resolved form of a schema: a value of the kernel’s
schematype, the namespace fromtype_nametotop(§4.1, §9) — each entry mapped to the value it resolves to — produced by resolution and optionally serialized as a data document (§8). Schema values are derived artifacts, never schema sources (§10.1). - A kind is an atom whose values are the names of the types built with a constructor (§4.2):
type_kindis the kind of every type a value can have, and every slot that names the type of a value is typed by it. - A namespace is a constructor whose instances are maps keyed by names (§4.1): the schema map and a record’s fields are the kernel’s two, and a meta-schema may declare others.
- A meta-schema is a schema in its governing role — the target of a
!!metadirective.meta.tnis the canonical meta-schema (§9); the meta-kernel is the root of the governing chain (§3.4). - The schema grammar is the body grammar of schema documents (§12.1); the type-definition grammar is its declaration right-hand side — the
type-defproduction that eachname => type-defdeclaration activates.
1.5 Companion Artifacts
This document is published with nine companion artifacts. The normative artifacts are pinned by content hash at publication (§13.2). Per §3.4, implementations pre-load the kernel and meta-schema as in-memory structures; the artifact documents are descriptions of those structures, and the in-memory model is authoritative.
| Artifact | Status | Content |
|---|---|---|
meta-kernel.tn | Normative | The self-referencing bootstrap layer (§9) |
meta.tn | Normative | The canonical meta-schema (§9) |
core.tn | Normative | The core type library (§9, [TSON-DATA] §5) |
net.tn | Normative | The network library: the address, network, host-name, host and media-type families (§9, [TSON-DATA] §5.5) |
policy.tn | Normative | The processor policy’s vocabulary: the identifier and token policies and the resource limits ([TSON-DATA] §8.2, §9.1) |
meta-kernel-resolved.tn | Non-normative | Resolver-output fixture for the meta-kernel (§8) |
meta-resolved.tn | Non-normative | Resolver-output fixture for the meta-schema (§8) |
core-resolved.tn | Non-normative | Resolver-output fixture for the core type library (§8) |
net-resolved.tn | Non-normative | Resolver-output fixture for the network library (§8) |
1.6 A Complete Example
A schema document declaring three types, governed by the canonical meta-schema and importing the core type library:
!!id:"https://example.com/task.tn"
!!meta:"https://tson.io/2026/38/m/meta.tn"
!!import:"https://tson.io/2026/38/m/core.tn"
@doc:"Task-tracking example schema."
{
priority => !integer ^ { min: 1 max: 5 }
title_text => !text ^ { min_length: 1 }
status => !enum [OPEN ACTIVE DONE]
flagged => <T, N> { entry: T priority?: priority ~ N }
task => {
id: uuid
title: title_text
priority?: priority ~ 3
status?: status ~ OPEN
due?: date
tags?: [text]
history?: [flagged<status, 2>]
}
}
priority and title_text refine core’s integer and text instances (§5.5) — core declares no bounded sibling of either, a bound being a line the schema that wants it writes for itself (§9); status applies the enum constructor, reached through the structure namespace supplied by the !!meta target (§3.3.1); task is a fresh record whose field types resolve through the type-name namespace (§3.3.2) — id and title must be written, priority and status may be omitted and are then injected, due, tags and history may be omitted and are then missing (§5.2). flagged is a record template — a record with parameters, one a type parameter and one a value parameter (§5.10): a fresh record whose priority field is defaulted by parameter (~ N on a marked name, as a literal default is written, §5.7), built entirely from names in the schema’s own namespace, and a family base a position may name. flagged<status, 2> is its fully-bound application, which the resolver materialises as its expansion once per distinct application (§8.2); history wraps it in the plain array sugar. A data document binds the schema with !!schema (§7.1) and instantiates its types:
!!schema:"https://example.com/task.tn"
!task {
id: 550e8400-e29b-41d4-a716-446655440000
title: "Ship revision 38"
priority: 3
status: OPEN
due: 2026-08-01
tags: [spec editorial]
history: [{ entry: OPEN } { entry: ACTIVE priority: 4 }]
}
Resolution derives a schema value from the schema document, optionally serialized as resolver output — a data document governed by the meta-schema in which every entry is its value, desugared to the canonical !C { bindings } form with its chain computed and its record fields a map keyed by name (§5.6, §8): priority => !integer_type { chain: { supertypes: [integer] } min: 1 max: 5 }, task => !record { fields: { id => !record_field { type: uuid } … } }. The companion fixtures (§1.5) and [TSON-GUIDE] give a full worked resolver-output example.
2. Schema Documents
A schema document is the source artifact of the schema layer (§1.4). This section defines the document’s structure and the operations of its header directives. The !!schema directive, which binds a schema to a data document, is defined in §7.1.
2.1 Schema Document Structure
A schema document is a fixed-shape header followed by a braced map of declarations — the schema map. The header carries the schema’s identity (!!id, first line; optional in the grammar, required for publication and hash-pinning, §2.2.1), its governing meta-schema (!!meta, mandatory, exactly once), and its dependencies (!!import, repeatable, declaration order significant); annotations that bind to the schema sit after the header, immediately before the opening brace. Header order and cardinality are grammar productions, not conventions ([TSON-DATA] §2.2, §3.3; §12.1):
!!id:"https://example.com/people.tn"
!!meta:"https://tson.io/2026/38/m/meta.tn"
!!import:"https://tson.io/2026/38/m/core.tn"
@doc:"Minimal example schema."
{
person => { name: text age: integer }
employee => person & { department: text }
status => !enum [ACTIVE INACTIVE SUSPENDED]
}
Entries are separated like data-map entries — whitespace or a comma, and a comma may follow the last ([TSON-DATA] §2.4) — and the map MUST contain at least one entry (§12.1). Each declaration binds a type name to a type definition with the => operator — a compound token the lexer already emits; the schema grammar introduces no reserved words — its operators are the reserved special tokens of the shared lexer ([TSON-DATA] §7.2.5).
The body is a map, in syntax and in semantics. The document resolves to a value of the kernel’s schema type — the namespace from type_name to top, each entry mapped to the value it resolves to (§4.1, §8, §9) — and the braces carry the document’s annotation anchor: annotations precede the value they bind to ([TSON-DATA] §3.1), and the schema map is the value the document-level annotations bind to. The !schema type-ref never appears in schema-document source — the document kind and !!meta already say what the body is; !schema marks data representations of resolved schema structure, most notably resolver output (§8). The !! prefix is always a directive; the ! prefix is always a type annotation.
Annotation binding. Annotations immediately before the opening brace bind to the schema — the header directives carry no annotations ([TSON-DATA] §7.4 gives them no annotation slot). Inside the braces the data-map convention applies unchanged ([TSON-DATA] §3.1): an annotation immediately preceding a declaration’s name binds to the key — the name itself (§6) — and annotations after => and before the type-def bind to the type definition. Resolver output preserves the placement (§8).
2.2 Directives
[TSON-DATA] §3.3 defines the directive set: four names — id, schema, meta, import — each legal only at fixed positions, with order and cardinality enforced by the grammar. The set is closed: any other directive name, or a legal name outside its position, is a parse error. There is no directive registry and no unknown-directive category.
This document defines the directive operations — what each directive means to the resolver. Every directive value is an IRI-reference ([TSON-DATA] §3.3); these are logical identifiers resolved through the schema library (§10), never fetch instructions.
| Operation | Directive | Placement ([TSON-DATA] §3.3) | Defined in |
|---|---|---|---|
| Identity declaration | !!id | first header line, either kind; optional in the grammar — required for published schemas | §2.2.1 |
| Schema binding | !!schema | data-document header; record field values; map entry values; array elements | §7.1 |
| Meta binding | !!meta | schema-document header, immediately after !!id; exactly once | §2.2.2 |
| Type-library import | !!import | schema-document header, after !!meta; repeatable | §2.2.3 |
2.2.1 The !!id Directive
!!id declares the authoritative identity of a document — the name other documents use to reference it, compared as a canonical identity ([TSON-DATA] §2.2.1). It connects the file’s content to its logical name in the schema library (§10) and anchors content addressing: the hash input for a document’s content hash is every byte after the id line ([TSON-DATA] §2.2.1; §10.2).
!!id is optional in the grammar and, when present, must be the first line. For schema documents it is required by policy, not grammar: publishing a schema — registering it under its own name for reference by other documents, or pinning it by content hash — REQUIRES !!id. An id-less schema is a development artifact; it may be registered under an application-supplied URL (§10.1) but has no published identity and cannot be hash-pinned.
2.2.2 The !!meta Directive
!!meta names a schema document’s governing meta-schema: the contract the schema’s declarations are validated against. It appears exactly once, as the first directive after the optional !!id; its position carries the document-kind dispatch bit ([TSON-DATA] §2.2).
The !!meta target supplies two things:
- The validation contract. Each declaration resolves to its value, a
top(§8); the meta-schema’s vocabulary defines what those values may be. A schema document is valid only if its resolved form conforms to its meta-schema. - The structure namespace. The
!!metatarget’s namespace supplies the constructors the schema builds with and the structural vocabulary the resolver uses to materialise output. Resolution is one hop; §3.3.1 defines the rule.
The ladder of governing relations terminates at the meta-kernel, whose !!meta references its own URL. The self-reference is never resolved: the kernel and the meta-schema are pre-loaded (§3.4, §10.1), and the ladder is closed by pre-loading, not resolution.
User schemas normally chain to meta.tn. Chaining to meta-kernel.tn directly is a meta-programming case — an alternative type vocabulary replacing meta, or an extension of the meta layer itself (§9). The meta layer is the format’s sanctioned extension point: new type vocabularies arrive as alternative or extended meta-schemas chaining to the kernel, never as grammar changes.
Constructor declarations are a meta-schema privilege. A constructor is an entry that IS-A top (§4.1, §4.2) — one that composes, directly or transitively, with type, a base kind or namespace, or refines an entry that does. Such an entry may be declared only in a schema document whose own !!meta names the meta-kernel; in any other schema the declaration is a resolver error. User schemas and type libraries apply and refine constructors’ instances; they do not declare constructors — declaring one is what makes a document a meta-schema, and the ladder keeps that role explicit rather than emergent. The rule is asked of every declared entry by its resolved supertype chain, so an ordinary type library cannot reach constructor level by composing its way there, and no marker is needed to say which declarations it governs.
The same privilege covers a namespace type (§4.1): api => namespace & { key_type: = identifier value_type: = method } is a constructor, IS-A top through namespace, and a meta-schema is the only document that may declare one. What follows is that a meta-schema owns the vocabulary its constructors use — the method a namespace’s values are, the operation a method holds — and declares it beside them, and a schema it governs holds a value of the namespace type as an entry, orders => !api { … }, written and read as the map the type describes (§4.1, §5.12). Such an entry is a value and never a type: a position of a data document cannot name it, and only a qualified name (§5.12) enters it. A schema governing data therefore declares types alone, which is why a reference type (ref<T>, §4.2) is kernel vocabulary: it names an entry that holds a value, and only a kernel-governed schema has one.
2.2.3 The !!import Directive
!!import imports type entries from an external schema into the importing schema. The directive value is an IRI-reference identifying a published schema. The directive loads the referenced schema and makes its locally-declared entries available as if they were declared in the importing schema: imported entries are available to all local declarations (including for recursive references), and local declarations may refine or compose with imported types.
Imports are transitive. An !!import contributes the imported schema’s entire namespace — the entries it declares and the entries it imported — exactly as !!meta contributes its target’s namespace (§3.3.1). A schema’s namespace is therefore flat: one name denotes one type throughout a resolution, and every name in the import closure is reachable by the data documents the schema governs, so the types §7.2’s subsumption admits at a position are always nameable where the position is used.
An import stands at the importer’s governing level. A schema is kernel-governed when its own !!meta is the meta-kernel, and ordinary otherwise — governed by a meta-schema. An imported schema MUST stand at the importer’s level: both kernel-governed, or neither, judged once per import edge against the imported schema’s own !!meta; an edge between levels is a resolver error naming both documents and both !!metas. The rule is the level and not one !!meta — an API description governed by a meta layer may import the records it describes, governed by meta.tn, both being ordinary — and it keeps §3.1’s rungs apart in every closure: an ordinary schema cannot import meta.tn, a meta layer or the kernel to type a field by record_field or by a namespace type, and a meta layer cannot take its constructors’ vocabulary from core.tn or net.tn. Three consequences follow: a meta-schema declares the vocabulary its constructors use rather than importing it; a field typed by a namespace type or a kernel record exists only in a kernel-governed document; and an ordinary schema reaches a meta-schema’s constructors only through its !!meta. A kind is not among them — core declares a kind of its own, so an ordinary schema types a field kind<C> through its core import (§4.2, §9) — and a reference is: core declares no ref, since a reference names an entry that holds a value and only a kernel-governed schema has one (§2.2.2, §4.2). The bundled chain imports at one level throughout: meta imports the kernel, and core, net and policy import at the ordinary level.
Multiple !!import directives are permitted and are loaded in declaration order. Collisions are decided by entry identity, not by name occurrence. One schema reached through more than one route contributes its entries once — re-arrival unifies, so the diamond (two imports that each import core) is ordinary rather than an error, and naming one schema twice, or under two spellings of one canonical identity ([TSON-DATA] §2.2.1), is redundant rather than a conflict. A ?sha256= pin is verification metadata ([TSON-DATA] §2.2.1, §10.2), never part of identity: routes that disagree about pinning still name one schema, and a pin disagreeing with that schema’s content is a verification failure, not a second identity. It is a resolver error for two different schemas to declare the same name anywhere in the import closure, or for a local declaration to reuse a name the closure already binds — no hiding and no redefinition: the schema fails to load, and the diagnostic names both declaring schemas. Imports populate only the type-name namespace: an import name that happens to match a name in the structure namespace (§3.3.1) is not a collision — the two are consulted at different grammar positions.
Two costs of the flat namespace are deliberate and worth knowing in advance. Every name in the closure is reserved — importing two peers and the core library spends id, name, value, text and the rest, with no aliasing to escape with; that is the price of a bare name meaning one thing everywhere. And some conflicts are unfixable by the importer: if two imported schemas transitively reach different schemas that both declare id, the importing schema is rejected over names it never wrote and can only be repaired upstream. One consequence follows without a separate rule: schema identities carry the specification revision, so an import closure reaching two revisions of one library is rejected at namespace construction — the collision rule firing on genuinely different documents — rather than allowing two identically-spelled types to coexist and surface later as an inexplicable field conflict.
Merged entries keep their home resolution. An imported entry’s internal references — reference targets, field types, the parents in its chain, its name_type — are names in its defining schema’s namespace, fixed when that schema was resolved; the merge does not re-resolve them against the importer. Operationally, the library holds one resolved namespace per schema, and a consumer following an imported entry’s references follows them in the defining schema’s resolution (§3.4.1).
Import references MUST be acyclic. A schema’s reference closure — every document reachable through !!meta and !!import — is resolved dependencies-first (§3.4.1), and a cycle admits no such order: an !!import reaching, directly or transitively, a schema whose resolution is in progress is a resolver error — import cycle — reported with the cycle path. The prohibition costs nothing in practice: a hash-pinned cycle is unauthorable, since each document’s pin would have to be computed over bytes that already contain the other’s, so a cycle can arise only among unpinned development references. Mutual coupling has sanctioned routes: shared types belong in a third schema both partners import, and data-level coupling to a foreign schema is a scoped instance such as extern (§7.8), which admits a foreign schema’s values without loading it into resolution.
The imported schema MUST itself be a valid schema document, resolved independently.
3. The Schema Chain
A TSON document never resolves type annotations against its own definitions. This is the fundamental rule of schema-value separation: every type annotation (!name) in a document resolves against an external schema — the schema identified by the document’s !!schema directive. Definitions in a schema document exist only for consumers of the published schema. This rule has no exceptions: it applies to data documents, user schemas, extended meta-schemas, and the meta layer itself.
Inside a schema document, type names used in type-ref positions within the type-definition grammar resolve against the type-name namespace (§3.3.2); structural forms resolve against the structure namespace supplied by the !!meta target (§3.3.1). In data mode, !name type annotations resolve only against the type-name namespace of the schema identified by !!schema.
3.1 The Schema Ladder
Every TSON document that uses type annotations sits on the schema ladder — the chain of governing relations. Each rung binds the same relation — validate me against X — with the directive of its document kind:
data document
└─ !!schema → user schema
└─ !!meta → meta-schema
└─ !!meta → meta-kernel
└─ !!meta → itself (pre-loaded)
A data document’s !!schema names the user schema its type annotations resolve against. A user schema’s !!meta names the meta-schema, and its !!import directives bring in type-library entries. The meta-schema (meta.tn) chains to the meta-kernel together with an !!import of the kernel — the import supplies the kernel types meta’s own declarations use and, because resolution is one hop, places the kernel’s structural vocabulary in meta’s namespace, where every meta-governed schema finds it (§3.3.1, §9). The meta-kernel’s !!meta points at its own URL; both meta and the kernel resolve to pre-loaded Schema objects (§3.4).
3.2 Schema-Value Separation
A published schema is an immutable artifact: once published at a URL with a content hash, its type definitions do not change (§3.5). Because type references always resolve against an external schema, !text in a given document means exactly what the referenced schema defines it to mean — two documents referencing the same schema have identical type vocabularies. A document with no !!schema directive has no type vocabulary: base type resolution and the built-in annotations of [TSON-DATA] apply there, and only there (§7.1).
3.3 Schema Layering
The directive architecture separates three layers: the meta-schema defines the structural vocabulary the type-definition grammar produces (record, record_field, field_map, array, enum, type_chain, …); type libraries define specific types (integer, text, uuid, …) using that vocabulary — type libraries are ordinary schemas; application schemas import type libraries and define domain types on top of them.
Name resolution is built from one primitive. The namespace of a schema is its local declarations plus the entries of its imports (§2.2.3) — nothing more. Every document has a governing target: a schema document’s is its !!meta target; a data document’s is its !!schema target. All resolution against the governing target is one hop: the target’s namespace is consulted directly, and no further rung of the ladder is ever walked. Two namespaces are active when a schema document resolves, consulted at different grammar positions.
3.3.1 The Structure Namespace
The structure namespace of a schema document is the namespace of its !!meta target — the target’s local declarations plus its imports. One hop: because resolution is one hop, the target’s namespace here means the target’s full closure (§2.2.3 — imports are transitive), which is the complete vocabulary it offers everything it governs. The structure namespace is consulted at exactly two constructor roles, both marked or grammar-supplied:
- Constructor-application targets — the name after
!when no^follows (!enum [...],!integer_type {}), resolved through the structure namespace only: a miss is an unresolved-constructor error, and the found entry MUST betopor IS-A it (§4.1) — a hit whose entry is not is a resolver error (the diagnostic SHOULD point at the refinement form,!name ^ { ... }, where the entry is an atom instance). IS-A stops at construction, so the predicate separates a constructor from a part of one:top,type, the base kinds, every constructor beneath them,namespaceand the namespace types that compose it (field_mapandschemaamong them), and the kernel’sreferenceandtemplateare constructors by that measure and may be applied —!reference { target: T }is the explicit spelling of the aliasT(§8.3), open or closed;!template { … }is resolver vocabulary a source declaration never applies directly (§8.1);!schema { … }is the application resolver output writes at its root (§8);top,type, the base kinds andnamespaceareabstract(§4.1), so a direct application of one is refused as any abstract record’s is, naming the subtypes that would satisfy it — whilerecord_field,type_ref,type_argument,template_param,tuple_element,field_group,integer_size,atom_specificationandtype_chainare record-bodied like a constructor and are not, and are refused at the spec level rather than by whatever a host model does with them. Two slots beside a!head may name a constructor, and consult this namespace to do it:reference.target, since an alias of a constructor is applied as its target is (§8.3), andkind_type.of, which names the constructor whose types a kind collects (§4.2). Local and imported declarations never participate, so no declaration can capture a!target. The kernel’s self-hosted case needs no ordering rule: when a schema is its own meta, the two namespaces are the same entry set. The direction of service is the invariant: a schema’s own constructors serve the layers it governs; its!targets come from the meta that governs it. - The implicit desugar targets of the sugar forms —
[T]and the sized forms toarray,[T, U]totuple,(A | B)tochoice,{K => V}tomap(§5.3, §5.6) — which are grammar-supplied and never author-written.
Bare names and generic-application heads never consult the structure namespace: name<args> resolves its head through the type-name namespace only — parameters, then locals, then imports — and an unresolved head is an unresolved-type error; a head resolving to a parameter is a resolver error (§5.10’s no-head-abstraction boundary), diagnosed rather than applied. Implementations SHOULD emit a migration diagnostic when a generic head fails type-name resolution but matches a parameterless constructor in the structure namespace, suggesting the sugar spelling or the !C { … } form (map<text, text> → “did you mean {text => text}?”). The structure namespace is likewise never consulted for bare type-refs — a schema governed by meta cannot write field: enum or reach the kernel’s integer as a field type (§3.3.2).
Atom refinement is not a constructor role. With ^, !I ^ { values } is atom refinement (§5.5): I resolves against the type-name namespace only and MUST be an atom-family instance — an entry whose body is an atom application (integer carries !integer_type {}), not the family’s constructor, whose body is the vocabulary record describing one (integer_type carries !record { … }); the constructor it desugars to is the one the instance’s own value applies — its ! tag, never a name the refining schema resolves, so refinement works even where the constructor is not name-visible. A name found in no permitted namespace is an unresolved-type error.
Import what you expose. Because resolution is one hop, a meta-schema’s namespace is the complete vocabulary it offers everything it governs. A meta-schema MUST therefore import, at its own level (§2.2.3), every schema whose entries it intends to expose — this is why meta.tn imports the meta-kernel: the import is the delivery mechanism for the kernel’s structural vocabulary (enum, the sugar-form targets, type_chain, field_map, …) to every meta-governed schema and its resolver output.
3.3.2 The Type-Name Namespace
The type-name namespace provides the names a schema author can use as type-refs in their own definitions — field types, type arguments, choice variants, composition targets, and refinement sources. Lookup walks, in order:
- Parameters of the enclosing definition (§5.10).
- Local declarations of the current schema.
- Entries brought in by
!!importdirectives, in declaration order.
The type-name namespace is NOT extended by the structure namespace: names available through !!meta are available at constructor roles only, never as type-refs. This is why application schemas import core — the types that fill record fields (text, integer, uuid) must come from the schema’s own namespace. Name collisions across the import closure and with local entries are decided by entry identity (§2.2.3): one schema reached twice contributes once; two different schemas binding one name reject the load.
3.3.3 Annotation Resolution
An annotation @name or @name:value resolves against the governing target’s namespace — the !!meta target for a schema document, the !!schema target for a data document — one hop, locals plus imports. Neither the local declarations of the document being authored nor any further rung of the ladder participates.
The one-hop rule determines where annotation types must live:
- Annotations for schema documents live in the meta layer.
meta.tndeclaresdeprecated,title,comment,examples,read_only,write_only,ordering,bounded,exact,numeric, anddisjointlocally, and carries the kernel’sdoc,annotation, andsyntheticthrough its kernel import. - Annotations for data documents live in the governing user schema’s namespace. Core declares its own
doc— a fresh sibling of the kernel’s entry, the same@annotation text(§3.3.5) — so that data documents governed by core-importing schemas can write@doc, and declares no other annotation type. An annotation type declared locally in a user schema is usable by that schema’s data documents — but not within the declaring schema document itself, whose governing target is meta. Custom annotations for schema documents therefore require an extended meta-schema; writing one whose type is declared only locally is a resolver error (§6), not a silently valueless annotation. Custom annotations for data documents require only a declaration in the user schema.
3.3.4 Data Documents and Schema Layering
A data document’s !!schema points to a single user schema, and that schema’s namespace — locals plus imports, one hop — is the document’s entire vocabulary. Type annotations and annotations resolve against it; nothing else is reachable. If a data document needs a core type like uuid, that type must be imported into the user schema.
A consequence: the structural vocabulary is invisible from ordinary data. !enum, !record, and !schema are not names in an application schema’s namespace, so ordinary data documents cannot express resolved-schema structure; only a data document governed by a meta-schema (resolver output, §8) can, because the meta-schema’s namespace carries that vocabulary through its kernel import. This pairs with §10.1’s rule that resolved-form documents are never schema sources.
A family is open across schemas, and is judged over the closure that holds it. A schema’s namespace is its declarations plus its imports, so a record a schema imports may gain subtypes there: the subtypes index is computed over the namespace of the schema being resolved (§8.1), so an importing schema that composes onto an imported record declares a further subtype of it, a further member of its family where it is the base of one (§5.2). Nothing closes a family at its declaration. What a family is is therefore relative to a closure, and its checks run over every member the closure holds: two members brought together only by an import merge — each from a schema that linked cleanly — are judged where they meet, and a pair that collides is the importing schema’s resolver error, reported with both origins named. Judging only the families with a member declared locally would let that schema load and dispatch on a mapping that is not a function.
3.3.5 Duplicate Names Across Layers
A type name defined in both the meta-schema and a type library is not a conflict: the two namespaces are consulted at different grammar positions. The kernel’s text is not visible as a type-ref in a meta-governed schema — only an imported text (core’s) is, and core’s text is a fresh definition, not a view of the kernel’s. No position consults both: a ! target resolves through the structure namespace only, and every other reference through the type-name namespace only (§3.3.1), so a declaration can never capture a constructor name and a constructor can never shadow a type.
3.4 The Meta-Schema Bootstrap
The ladder terminates at the meta-kernel, whose !!meta references its own URL. The kernel’s types are pre-loaded into the schema library by the implementation: they exist as in-memory structures before any document is parsed. When the kernel document is parsed, its type annotations resolve against these pre-loaded structures through the normal library lookup. The meta-schema (meta.tn) is also pre-loaded; its constructors are resolved against the pre-loaded kernel before being registered as pre-loaded entries themselves.
The kernel defines its own core types (integer, text, boolean, …) directly so that its own constraint-field declarations can reference them locally. The kernel’s local entries are the structure namespace of every schema the kernel directly governs — meta itself, and any alternative meta-schema; meta-governed schemas receive the same vocabulary one hop away, through meta’s kernel import (§3.3.1). These types are NOT automatically available as type-refs in governed schemas (§3.3.2); schemas that want integer or text as type-refs import a type library that defines them (typically core.tn).
The kernel and meta documents are descriptions of the pre-loaded types, not the source of them. Parsing them validates that the document’s description matches the implementation’s in-memory model; if they disagree, the document is invalid — the in-memory model is authoritative.
3.4.1 Two-Pass Resolution
Schema resolution proceeds per schema as Parse → Desugar → Pass 1 → Pass 2, with imports fully resolved before any phase runs on the importing schema.
Desugar is purely syntactic and per-declaration: sugar forms rewrite to canonical constructor applications, and every nested form lifts to a synthetic declaration — closed for a concrete form, open for a parameter-bearing one (§5.3) — entering Pass 1 alongside declared names. A template declaration contributes its own name plus one synthetic per open form it holds.
Pass 1 — Name population. The resolver collects all declaration names in the schema document, synthetic declarations included. The schema’s type-name namespace is populated with skeleton entries keyed by name. Bodies are not yet validated.
Pass 2 — Body resolution and validation. The resolver resolves each entry’s body against the populated namespace. Forward references between local entries work in this pass. The resolver validates that references resolve, that composition and refinement rules hold, that type arguments match parameter arities and types (§5.10), that every record’s and enum’s names are values of its name type (§5.2, §7.4), and that every name at a kind-typed slot is a member of the kind (§4.2). It computes each type’s transitive IS-A chain (chain.supertypes), derives the inverse (chain.subtypes), and computes each kind’s members (§8.1).
Imports run first. Resolution is defined over the schema’s reference closure: the set of schema documents reachable through !!meta and !!import references, collected transitively. The order is:
- Collect the closure. Starting from the schema under resolution, follow every
!!metaand!!importreference, deduplicating by canonical identity ([TSON-DATA] §2.2.1): each canonical identity names one schema, loaded once, however many references reach it. - Verify per identity. Verification attaches to canonical identities, not to individual references: for each identity in the closure, collect the set of digests declared across all references reaching it. An empty set resolves without verification. A single digest is verified against the identity’s content per §10.2 — once, at this step, before any schema in the closure resolves — and every reference to the identity, pinned and plain alike, thereafter resolves to the verified instance; a verification failure is a resolver error for the identity across the whole resolution, and no reference falls back to the rejected content. Two or more distinct digests for one identity is a resolver error (§10.2) — at most one can describe the real bytes.
- Order the closure. The reference relation over the deduplicated closure MUST be acyclic (§2.2.3); the resolver computes a topological order — dependencies first — and reports the cycle path as a resolver error where none exists.
- Resolve in order. Each schema in the closure is resolved exactly once, in topological order, by its own two passes; when a schema’s turn arrives, every schema it references is already resolved. For each schema: merge each
!!import’s local entries, in declaration order, into the accumulating type-name namespace — merged entries stay bound to their defining schema’s namespace (§2.2.3), and collisions between imports are resolver errors (§2.2.3); then Desugar rewrites sugar and lifts synthetics, then Pass 1 collects local entry names, a collision between a local name and an already-merged import being a resolver error (synthetic names are resolver-chosen and fresh by construction, so they never collide with declared names; a synthetic arriving through an import whose identity an own application re-derives denotes the merged entry, §8.2); then Pass 2 resolves bodies against the populated namespace.
Pre-loaded schemas (the kernel and meta, §3.4) enter the closure already resolved and occupy the leaves of every order. Collection follows references only into schemas not yet resolved: a reference to an already-resolved schema terminates there — which is how the kernel’s self-referencing !!meta grounds the ladder (§3.4) rather than forming a cycle. Digest sets (step 2) are collected over the references observed during this collection: a terminating reference still contributes its digest to its target identity’s set, while an already-resolved schema contributes no outgoing references — its dependencies were verified when it was resolved (or shipped, for pre-loaded entries, whose pins are checked against the implementation-held digest per §10.2). Whether a dependency happens to be pre-loaded therefore never changes whether a closure loads.
Forward references are permitted within a schema: a definition may reference any other declaration in the same schema, declared earlier or later. Annotations resolve against the governing target’s namespace (§3.3.3), not through the resolving schema’s own Pass 1 namespace or imports.
3.5 Schema Evolution
Each published schema version is immutable; a schema’s identity is its exact byte content. Version N and version N+1 are independent artifacts with different content hashes and different URLs. TSON defines no version negotiation, migration rules, or compatibility checks; accepting data validated against multiple schema versions is a deployment concern. See §7.2 for the records-are-closed rule.
4. The Type System
The type system is defined by the meta-kernel’s vocabulary — top, type and the base kinds, namespace, the constructors, and the supporting records — and every construct of the type-definition grammar (§5) is notation over it: each declaration form resolves to its value, the canonical constructor form !C { bindings } (§5.6, §8), and a schema map is a namespace from each name to the value its entry resolves to.
4.1 Kinds
The kernel defines top as the structural root, type as the kind of the kinds that describe data values, and three base kinds — atom, product and sum — each composing with type:
top => abstract {}
type => abstract top & { chain?: type_chain }
atom => abstract type & {}
product => abstract type & { access_pattern: product_access_type size_type: product_size_type }
sum => abstract type & {}
namespace => abstract top & { key_type: identifier_kind value_type: type_kind ordered?: boolean ~ false }
An entry is a type exactly when it IS-A type. Every constructor of a data value’s shape, in the kernel and in meta, composes, directly or through another constructor, with one base kind, and each base kind IS-A type IS-A top — so every such constructor transitively IS-A type and IS-A top. Three kernel constructors compose top directly and are not types: reference, whose instances are hops (§8.3); template, whose instances are held bodies (§5.10); and namespace, whose instances are maps (below). IS-A does not extend below construction: !T {} transfers kind, not supertypes (§5.5), so constructor instances (integer, value, dynamic) and fresh records (person) carry no supertypes and are not IS-A top. That boundary is what makes IS-A top, or being top, the predicate for being a constructor: an entry that is top or IS-A it describes a type or a namespace and may be applied with ! unless it is abstract (§3.3.1, §4.2); an entry that is not — an instance, a fresh record, or one of the kernel’s supporting records — describes a value or a part of a type. top has two families of constructors, type’s, whose instances are types, and namespace’s, whose instances are maps; a chain reaching namespace and a base kind is refused as reaching two, as a chain reaching two base kinds is.
top, type, the base kinds and namespace are abstract. None has instances of its own, so a direct application of one — !top {}, !atom {}, !namespace {} — is refused as any abstract record’s is (§5.2, §12.1), the diagnostic naming the instantiable subtypes that would satisfy the position. Constructors beneath them stay OPEN: !record { … } and !integer_type {} are direct instances, as are !reference { … } and !template { … }. The schema map is read by this rule: schema maps each name to a top, so every entry’s value is a tagged value at a top-typed position, dispatched by tag among top’s subtypes, the family open across schemas (§3.3.4) — a meta-schema’s !interface { … } admitted through namespace’s chain.
A type carries its IS-A chain. type’s one field is chain, a type_chain:
type_chain => {
parents?: [type_ref] -- the parents as written; a record template's, one of which may be an application
supertypes?: [type_name] -- the closed chain: every entry this one IS-A, complete, each a bare name
subtypes?: [type_name] -- the inverse, over the entries of this schema map only (§3.3.4)
}
parents is what was written and is the only list that ever holds an application: it is a record template’s slot (§5.10), kept on the template entry once its parents resolve — a closed instantiation keeps none — since <T> result<T> & { … } reaches an IS-A edge to result<text> and not to result<int32>. supertypes is the closed chain, complete to a fixed point through applied parents (§5.8), sorted, and empty for a fresh record, an instance and a subtracted record alike. subtypes is the inverse, over the entries of the schema being resolved. A declaration never writes chain — !record { chain: { … } } in source is a resolver error, as writing choice.disjoint is — and a constructor never declares a field of that name: a meta-schema’s widget => atom & { chain?: … } is refused where it is declared, the name being type’s. The resolver computes the chain from composition and refinement and the linker inverts it; on ingest it is read as stated and never recomputed, because a flat body and its chain cannot be recomputed from each other (§8.1, §10.1). Provenance is not recorded: which operator a refinement used, and what a subtracted record was subtracted from, are what was written, nothing reads them after resolution, and the source document has them.
What is universal is kind membership — every closed entry has exactly one kind, derived from where its value’s constructor reaches the kernel’s order:
- Atom — scalar types. An atom constructor composes with
atomviaatom & {...}; its record of constraint fields describes the narrowable vocabulary available to instances. The kernel and meta define the series’ atom constructors (§9);value_typeandvoid_typeare the atoms with no constraint vocabulary, andkind_typeandref_typethe atoms whose values are names (§4.2). Atom instances are produced by constructor application —!<ctor> {}(empty) or!<ctor> { values }— and refined with!<instance> ^ { values }(§5.5). - Product — structural types.
record,array,set_type,map, andtuplecompose withproduct, fixingaccess_patternandsize_type; the container constructors expose their type slots as requiredtype_kind-typed fields (§4.2). Bare{...}definitions without explicit composition resolve to PRODUCT by structural default, and so does an entry composing withtypeortopalone. - Sum — types in which the value names its own type.
choicein the kernel is the closed sum, enumerating its variants;scopedin meta is the open sum, naming the namespaces its variants are drawn from (§7.8). Core’sdeclared,externanddynamicarescopedinstances over the three admitting subsets. - Namespace — the constructor whose instances are maps keyed by names. A value of a type IS-A
namespaceis written and read as a map from itskey_typeto itsvalue_type—orderedas pinned and unordered where nothing pins it, its values never void and its size unbounded — and never as a record of those facets: the facets describe the map, and a namespace type pins them by composition.value_typeis pinned (= V);key_type, an identifier family — which is what a qualified name’s segment is, so an enum-keyed map is no namespace — is either pinned (= K), the family every key is read and judged under, or stated by default (?: ~ K), the family that stands where the value’s host supplies none, the host supplying it otherwise. A namespace type that is not abstract and states either facet neither way describes no map and is a resolver error at link; an abstract one is admitted, a family over the namespace types that pin it. The kernel declares two:schema => namespace & { key_type: = type_name value_type: = top }, the schema map, andfield_map => namespace & { key_type?: ~ field_name value_type: = record_field ordered?: = true }, a record’s fields, whose host is the record and whose key family the record’sname_type(§5.2). A meta-schema may declare others (§2.2.2):interface => namespace & { key_type: = method_name value_type: = method }. An application of a namespace type builds a map value, and an entry whose value it is is no type: a schema the meta-schema governs holdsorders => !interface { place_order => { … } }, read as the mapinterfacedescribes — each key underkey_type, each value undervalue_type, nothing in it lifting or minting, so a kind-typed slot there holds one bare name — and naming that entry where a type is expected ({ s: orders }) is a resolver error, as naming any non-type entry there is. A qualified name’s segment after the entry’s selects a key of that map, and the value reached is a value of the type’svalue_type(§5.12). The{K => V}sugar keeps meaningmap, which no qualified name enters. - Reference — an entry whose value is a pointer to another entry:
!reference { target: T }, wheretargetnames an entry (§8.3). References are aliasing relationships, not IS-A: a reference is a hop, stated as written in resolver output and collapsed only when a processor compiles for reading (§8.3).referencecomposes withtopdirectly and is applicable (§3.3.1); its entries take the REFERENCE kind from the alias form rather than from a base kind, and carry no chain.
A kind is derived, never recorded. An entry’s kind is its value’s type’s kind — a fact the value’s tag and chain already determine — and resolver output does not write it (§8.1): a consumer derives it by the rule stated there, reading atom, product, sum or namespace off the chain by which entry of the kernel it reaches, each counted only as the kernel’s own entry, so that a schema’s own record named namespace, composed into payload => namespace & { … }, is PRODUCT — a value conforms to where it is defined. An open entry — a record with parameters, or one whose value is a held !template (§5.10) — is a type where it is a record and otherwise has no kind until it closes; the derivation names a held body TEMPLATE for the resolver’s own bookkeeping, and a closed instantiation takes its expansion’s kind. The kernel’s template constructor, like reference, composes with top directly and describes no value’s shape.
4.2 Type Construction
Type constructors are factories that produce types. A constructor is top or an entry that IS-A it (§4.1): the root, or one that composes with type, a base kind, namespace or another constructor, or refines one — the position in the IS-A chain is what makes it applicable with !, and nothing is asserted by an author beyond the composition itself. There is no marker and no constructor field: constructorness is inherited through composition, derivable from a type’s chain wherever it is wanted (§8.1), and never a property a declaration states apart from its chain. There is no construction in data: !C { bindings } produces a new type only in the schema grammar (§7.2). The kernel’s container constructors are parameterless: a type slot (array’s element_type, map’s key_type and value_type) is an ordinary required field typed type_kind (below), filled by the construction — or by the sugar’s desugaring (§5.3) — the way data supplies any required field, so a closed application’s binding record is ordinary data of the constructor’s vocabulary (§7.2). Where a parameter must route into a slot, the route arises at the application site inside a template body (§5.10), never in a constructor’s own declaration.
One declaration-time rule governs constructors: placement. An entry that IS-A top may be declared only in a meta-schema — a schema document whose own !!meta names the meta-kernel (§2.2.2); elsewhere it is a resolver error at the declaration. The rule is asked of every declared entry after its chain resolves, so it covers what a level rule would otherwise have to: an ordinary schema composing with or refining a constructor has declared an entry that IS-A top and is refused for that; in a meta-schema the same composition is exactly what the author is doing — extending a constructor’s vocabulary — and the two IS-A relations stay apart by placement, one layer up, with nothing left for a marker to repeat. With the import-level rule (§2.2.3) an ordinary schema’s closure holds no kernel entry, so it has no base kind to compose with and the refusal is reached by no loaded document. A constructor’s parameters are read as any template’s are (§5.10): each takes its type from the slot it stands in, so a parameter in a slot typed type_kind takes a type where one in an atom-typed slot takes a value, and no rule confines a constructor’s parameters to one channel.
Kinds: the names of the types built with a constructor. The kernel declares an atom whose values are type names:
kind_type => atom & { of: type_name members?: [type_name] }
kind => <T> !kind_type { of: T } -- the inline spelling: kind<identifier_type>
kind_type is the atom whose values are the names of every entry in the governing closure built with the constructor of — every type, and for a namespace constructor every namespace type and namespace entry (below). Its place in the order is top → type → atom → kind_type, beside enum_type: an enum is an atom whose value set is a listed set of names from an identifier family, and a kind is an atom whose value set is a computed set of names from type_name. One lists, the other walks.
- Membership is one walk. An entry is a member of
!kind_type { of: C }iff, past its alias hops (§8.3), the constructor its value applies isCor IS-AC— a constructor template followed to the constructor its body applies; a namespace type’s!recordtag is not followed, a value of it being a map, and it is a member oftype_kindand of the kind of each namespace constructor it is or IS-A (below) — and the entry is not itself a constructor whose instances are types, which is to say not IS-Atopunless it is a namespace constructor, whose instances are maps. Membership reads construction and the chain, never applicability:top, a constructor by §3.3.1 whose own chain names no supertype, is walked as the fresh abstract record its value is. A record written in braces is built withrecord, and so is a record template, which is a type (§5.10), and so istopitself: a fresh abstract record IS-A nothing, so it is a member of every kind whoseofisrecordor above it,type_kindamong them, which is what admits the kernel’s ownschema => namespace & { value_type: = top … }at a kind-typed slot. A template over another constructor is no type and no member; what an application of one expands to is (§8.2); an alias of a member is listed beside it. A namespace type is a member oftype_kindand of the namespace kinds (kind<namespace>,kind<interface>) and of no other: it resolves to a record of its facets, but a value of it is a map, sokind<record>andkind<product>exclude it, and nothing builds it withmap. A namespace entry (§4.1) is built with its namespace type, sokind<interface>andkind<namespace>listorders => !interface { … }; it is no type, and no kind of types lists it. Membership is by construction, so a kind holds an abstract type —kind<record>holdspet => abstract { … }, andkind<namespace>holdsnamespaceitself where the closure holds it, which only a kernel-governed schema’s can (§2.2.3) — a document writing such a name at a kind-typed slot naming a type no value is read through until a member is chosen.kind<record>lists a record template’s family base too, the walk reading construction; naming one at a kind-typed slot is nevertheless refused as an open entry (§5.12), a document naming there the application a declaration gives it —bt => box<int32>, thenrequest: bt. The walk runs over resolved entries, not names, and reads construction, never a kind’s members. ofnames a constructor. It resolves in the writer’s structure namespace (§3.3.1), as a constructor role does, and never a type of the writer’s type-name namespace:identifier_typeis the kernel’s, reached through the meta chain.ofMUST betypeornamespace, or a constructor at or below either;kind<reference>names no type a value can have, and is a resolver error at the declaration.ofis typedtype_nameand not by a kind: its values are names of constructors, which no kind covers, and the slot’s store is stated here in prose, as every constructor role’s is.- A kind is not a bound. “Any type IS-A
stock_id” names a type of the type-name namespace and walks supertypes (template_param.bound, §5.10); a kind names a constructor of the structure namespace and walks construction. Two stores, two mechanisms, andkind<stock_id>— a type, not a constructor — is a resolver error. - A value of a kind is a type-name token ([TSON-DATA] §7.7’s grammar; a JSON string in [TSON-JSON]), and conforms when it resolves, where the slot’s prose says a name there resolves, to a member. The referent is found first and judged after: a kind never selects among candidates, it refuses the one found. In a data document a name resolves in the type-name namespace of the governing schema — unless that schema is a meta-schema, whose data documents describe a schema (§3.3.4, resolver output among them): there a name is a token of the described schema, whose namespace the document does not carry, so the slot’s reader checks the grammar and membership is judged at ingest (§8.1); in a schema document it resolves where the slot holding it says (
name_type’s by §7.4), and is judged a member when that schema links. Nothing of the kind reaches the data the referent types:NewYorkconforms toidentifier, and the kind only judged which identifier family was allowed to stand there. The value is the name, never a value of the type it names. - Members are computed at link, over the governing schema’s closure, and are static once linked: two schemas importing different sets have different members for the same
kind<identifier_type>, and a document is judged under its own schema’s. A kind never reaches past the closure, and a kind with no members admits no value, which §5.10.1’s productivity rule refuses. Resolved output carries the members as the derived indexmembers, onsubtypes’ terms — computed by the resolver, discarded and recomputed on ingest, never authored (§8.1). A kind has nodisjoint: that is a sum’s fact, and a set of names has none. - The constructor is the thing; the template is sugar. A type position admits no inline application (§5.3), so the kernel declares the kinds its own slots point into by name, and an author writes
kind<T>where a one-off is wanted, which materialises the same instance as a constructor template with one value parameter (§5.10). An instance can be declared, named and refined like any type.
The kernel declares three named kinds and types its own name slots by them:
identifier_kind => !kind_type { of: identifier_type } -- the identifier families, which a name_type names
atom_kind => !kind_type { of: atom } -- the atoms
type_kind => !kind_type { of: type } -- the types a value can have
Every slot that names the type of a value is typed type_kind and, in a closed entry, holds a bare name: record_field.type, tuple_element.element_type, array.element_type, map.key_type and value_type, choice.variants, template_param.type and bound; enum_type.name_type and record.name_type are identifier_kind; namespace.key_type is identifier_kind and its value_type type_kind. A constructor named as a type (field: record) is refused there, being no member. map.key_type is type_kind and not the narrower atom_kind: a map’s keys may be records, tuples or arrays. type_ref — the structured reference carrying arguments — is the application form and stands only where an application may: type_chain.parents, an open record’s record_field.type (§5.10), type_argument.name, the @application annotation, held text, and reference.target, which with kind_type.of and a !C head is a position that may name a constructor (§3.3.1, §8.1). A type parameter is one whose type is type_kind (§5.10). Core declares a kind of its own, a sibling of the kernel’s as it has set (§9), so an ordinary schema types a field kind<uuid_type> through its core import and a data document holds a type’s name there — stock_id, never a UUID. The value-level case an author may want — a slot taking a value of any type built with uuid_type — is a choice whose variant set is a kind, not a kind standing as a type, and the series does not yet define it.
References: the names of values. Beside kind_type the kernel declares its sibling, an atom whose values are qualified names (§5.12):
ref_type => atom & { of: type_name }
ref => <T> !ref_type { of: T } -- the inline spelling: ref<method>
A value of ref<T> is a qualified name — segments each a [TSON-DATA] §7.7 identifier, joined by . — naming a value whose type is T or IS-A it. The first segment resolves in the writer’s type-name namespace and names an entry; each further segment selects a key in a namespace value or a field in a record value, and a map that is no namespace is not entered. of names a type of the writer’s own namespace, never a constructor: ref<record> is a resolver error at the declaration, kind<record> being the spelling for the names of the record types. of is typed type_name, as kind_type.of is, and not by a kind: ref’s T stands in an atom-typed slot and so is a value parameter (§5.10), its argument a name resolved in the writer’s type-name namespace and judged by the rule just stated. The slot declares the spelling: a slot typed ref<T> holds the name and nothing else; a slot typed T holds a value inline and nothing else, so two hosts of one meta-schema admit one set of documents. The referent is judged when the schema links — its type before its value, so a name wrong by type is refused for its type whatever the value holds — and a host receives the name as written with the value delivered at link. A reference is kernel vocabulary. A reference names an entry that holds a value, and an entry holds a value only where a namespace type can be declared (§2.2.2), which the import-level rule confines to kernel-governed schemas (§2.2.3); a schema that governs data declares only types, so a reference there would name nothing. core.tn therefore declares no ref, and a reference — ref<T> or !ref_type { of: T } alike — is declared only in a schema the meta-kernel governs; elsewhere it is a resolver error at the declaration.
The ~ character has one use in the type-definition grammar: the default-value modifier in a field definition after a type-ref (port?: integer ~ 8080, §5.2), beside = for a fixed value and =? for a selector the members pin (§5.2). Constructor refinement — a constructor refining another, as set_type refines array — is written with ^ like any refinement; it is a meta-level operation by placement, and, unlike regular refinement, MAY replace fixed values (§5.7, §5.8).
Constraint-vocabulary atom pairs. Atom families whose instances can be narrowed with constraint values are defined as pairs: a constructor carrying the constraint vocabulary (atom & {...} listing the family’s narrowable fields) and a canonical empty instance (!<constructor> {}) that carries the constructor as its tag but establishes no IS-A. Refinements use the ^ operator — age => !integer ^ { min: 0 max: 150 } — which DOES establish IS-A with the refined instance (§5.5). Spec-bound constructors additionally compose with the kernel’s atom_specification mixin and pin their spec field. §9 inventories the families.
Atoms with no constraint vocabulary. Two kernel atoms have nothing to narrow, and each has a constructor of its own with an empty vocabulary — value_type => atom & {} and void_type => atom & {}. Every atom in the series is thereby a constructor/instance pair, and a processor recognises value and void as it recognises every other family: by the constructor the resolved body names, never by the entry’s name.
value => !value_type {}— the token, uninterpreted, read by the type the position hands it to: avalue-typed field is read by the declared type it stands for (§7.4), never by base type resolution, which applies only in schemaless documents ([TSON-DATA] §4.1); its host inhabitants are a boolean, an integer, a float or a string. The escape hatch for fields whose type the schema language cannot express at the point of declaration. It is not narrowable, its vocabulary being empty, and avalueposition is a single token and not a scope: it admits no nested!!schema, having no tag of its own for one to resolve (§7.8).void => !void_type {}— the type of a slot that holds no value: its sole value is the void sentinel_, and nothing else is admitted at avoid-typed position (§7.3).voidis the target type for bare annotations (§6). As a field type it means “no value”, and the field’s own marks say the rest (§5.2):a?: void?may be missing or written_,a: void?must be written and as_,a?: voidmay only be missing, anda: voidadmits no document at all and is refused (§5.10.1). It is not a valid choice variant (§5.4): whether a slot may be missing or void is a property of the position, not a type.
Core declares its own void under the same name — a fresh sibling (§3.3.5): the same !void_type {} construction, and so the same contract, a distinct type entity — so that data documents governed by core-importing schemas can target it (§9, §7.3), and both meta and core declare set — the set<T> template over the kernel’s parameterless set_type — on the same pattern (§5.3, §9). The contract comes with the constructor: a schema that wants a void type of its own writes !void_type {} and has the kernel’s, and there is no atom a schema can construct without a contract behind it.
identifier is a text family. The type of every naming position the grammar itself reads — type names and parameter names, through the roles type_name and param_name — is identifier => !identifier_type { continue_add: "-" }: an instance of a constructor that composes text_type and states a UAX #31 identifier profile as data (§7.4). It is declared the way uri and regex are, so a rule stated on it reaches every naming position, it carries the text facets, and a schema may construct another instance to type names of its own. A record’s field names and an enum’s members are values of a name type — record.name_type and enum_type.name_type, each a member of identifier_kind — which is field_name, the kernel’s identifier, unless a record or enum states another (§5.2, §7.4): !identifier_type { start_add: "@$" } for a vocabulary translated from JSON-LD or JSON Schema, !identifier_type { medial: " " } for words. Core declares no sibling of identifier (§9).
4.3 Operations
TSON’s type operations fall into two families. Construction operations compute a new field set and declare their own contract: a bare record claims none, constructor application transfers kind only, composition grants IS-A per parent, and subtraction revokes IS-A for every parent, leaving no supertypes (§5.9 states why the break is total). Refinement inherits an existing contract and tightens within it, always preserving IS-A.
Both families consume vocabulary bodies. The source of a refinement and every operand of a composition or subtraction MUST, after following its reference chain (§8.3), be a definition whose body is a !record — a shape with fields to tighten or merge. A definition whose body is a binding record — a top-level constructor application (§5.6), or an alias resolving to one — is finished and admits neither operator; a choice is likewise inadmissible — it has variants, not fields, so there is nothing to tighten and nothing to merge. The test is on the body and on nothing else: a record template’s instantiation (§8.2) closes to a !record with fields and composes and refines like any record, however it was spelled, while vector<text, 3> closes to !array and is refused for having none. A FINAL record (§5.2) has fields and is refused all the same, by a rule about the IS-A edge rather than the body: composition and refinement both mint one, and FINAL forbids it. §5.7 and §5.8 apply these rules to their own operand forms.
Each operation is defined with its grammar form in §5:
| Operation | Syntax | Section | IS-A | Adds fields | Removes fields | Tightens fields |
|---|---|---|---|---|---|---|
| Record construction | { ... } | §5.2 | none | yes | n/ | n/ |
| Constructor application | !C value | §5.5 | none (kind transfers) | n/ | n/ | n/ |
| Composition | A & B & { ... } | §5.8 | preserved (each parent); refused on a FINAL parent | yes | no | yes |
| Subtraction | head - { fields } | §5.9 | broken (no supertypes); admissible on a FINAL head | via the & body | yes | via the & body |
| Refinement | T ^ { ... } | §5.7 | preserved (source); refused on a FINAL source | no | no | yes |
| Atom refinement | !I ^ { values } | §5.5 | preserved (source) | no | no | yes |
| Instantiation | !T value in data | §7.2 | n/ | n/ | n/ | n/ |
Instantiation is data-level and terminal. A refined definition remains a definition: it can be refined further or instantiated. All parameters in a definition MUST be bound before instantiation (§5.10).
5. The Type Definition Grammar
Each declaration in a schema document binds a type name to a type definition: name => type-def (§2.1). Everything to the right of => is parsed by the type-definition grammar; the complete ABNF is in §12.1. Each construct is defined by its syntax, its rules, and the canonical value it resolves to — a form’s meaning is its resolution (§8).
Inside the type-definition grammar, type positions are determined by grammar context — the ! prefix marks constructor application and atom refinement (§5.5), not type reference. Type names used as type-refs resolve against the type-name namespace (§3.3.2).
5.1 Declarations
The right-hand side of a declaration takes one of the following forms; each resolves to the entry’s value (§8) as defined in the sections below.
person => { name: text age: integer } ; record construction (§5.2, §5.3)
employee => person & contact & { department: text } ; supertype composition (§5.8)
account_public => account - { password } ; subtraction (§5.9)
production => config ^ { host: = "prod.example.com" } ; record refinement (§5.7)
pet => abstract { pet_type: text =? name: text } ; abstract base with a selector (§5.2)
dog => pet & { pet_type: = "dog" breed: text } ; a member, pinning the selector (§5.2, §5.8)
status => !enum [ACTIVE INACTIVE SUSPENDED] ; constructor application (§5.5)
age => !integer ^ { min: 0 max: 150 } ; atom refinement (§5.5)
set_type => array ^ { ordered?: = false } ; constructor refinement (§4.2), in a meta-schema
id => uuid ; type reference (§8.3)
scores => [integer; 1..] ; array type (§5.3)
point => [number, number] ; tuple type (§5.3)
contact_method => (email | phone | address) ; choice type (§5.4)
translations => {text => text} ; map type sugar (§5.3)
5.2 Field Definitions
A field in a record definition answers three independent questions, and the spelling has one slot for each:
name?: type? ~ default
name?: type? = fixed
name: type =?
- May the key be omitted?
?on the name. Unmarked, the key MUST be written; marked, it MAY be omitted. This is the input question and nothing else — what omission then yields is the modifier’s business (below). Required by default is the right choice for a typed notation, and the one structured-output validation wants. - May a written value be
_??on the type. It means at a field exactly what it means at every other position that admits it — an array element, a map entry value, a tuple position (§5.3, §7.6): the void sentinel is admitted here. A field whose type carries?is voidable. The record field is the one position where an earlier revision’s?also answered the omission question, and that overload was the source of four states no spelling could reach. - What may a written value be? The modifier. No modifier — the value is free;
~ token— the value is a default, supplied when the key is omitted and overridable by a written value;= token— the value is fixed, and a written value MUST equal it;=?— the value is pinned by the members of a discriminated family, a selector, and is discussed below. Whitespace around~,=and=?is optional.
config => {
host: text ; key written, _ refused, any value
port?: integer ~ 8080 ; key may be omitted; omission yields 8080
debug?: boolean = false ; key may be omitted; omission yields false; a written value must be false
label?: text ; key may be omitted; _ refused
timeout: duration? ; key written; _ admitted — "no timeout" is a thing this document can say
proxy?: uri? ~ "http://gw" ; omitted: the default; _: none; a value: the value
version: text = "2.0" ; a marker the document must write, and must write as "2.0"
}
What omission yields is derived, never stored. A field whose name is unmarked MUST have its key written — a missing key there is a validation error. A field whose name is marked yields, where its key is missing, its value if it has one (~ v or = v, injected) and nothing otherwise: the field stays missing. So the three slots read as one table:
| Spelling | Key in the data | _ in the data | Value in the data | A missing key yields |
|---|---|---|---|---|
a: T | written | refused | any | — (an error) |
a?: T | may be omitted | refused | any | nothing |
a: T? | written | admitted | any | — (an error) |
a?: T? | may be omitted | admitted | any | nothing |
a?: T ~ v | may be omitted | refused | any | v |
a?: T? ~ v | may be omitted | admitted | any | v |
a?: T = v | may be omitted | refused | v only | v |
a: T = v | written | refused | v only | — (an error) |
a?: void? | may be omitted | admitted | none | nothing |
a: void? | written | admitted | none | — (an error) |
a?: void | may be omitted | refused | none | nothing |
The last three are the void type in the type slot rather than field states of their own: void admits nothing but _ (§4.2, §7.3), so a void-typed field is one that may carry no value. a?: void? is a field that may be missing or written as _ and nothing else — the spelling an earlier revision wrote = _. a?: void is a field that may be omitted and never written, which empties the field and leaves the record inhabitable; it is legal, since a declared field still participates in closure (§7.2) and a removal clause can name it (§5.9). a: void (key written, _ refused, no value admitted) empties the record — no document satisfies it — and is a resolver error under §5.10.1’s productivity rule.
Four spellings are refused, as rules rather than omissions:
- A default on an unmarked name —
a: T ~ v,a: T? ~ v. An unmarked name says the key is always written, and~ vis a value only omission can reach; the diagnostic SHOULD suggesta?: T ~ v. - A pin on a voidable type —
a: T? = v,a?: T? = v. A pin names the only value the field admits, and_is not it: the_question is settled before the pin is consulted, and admitting the pair would let question 2 admit a value question 3 refuses. Nothing in any measured corpus asks for the cell. - A modifier on a
void-typed field, and the sentinel as a modifier value (~ _,= _).voidhas nothing to default or pin, and_is what the type slot spells. - The selector
=?on anything but an unmarked name and a non-voidable type (below).
Which fields may carry a value. A ~ or = value is admitted only on a field whose declared type resolves, after following its reference chain (§8.3), to an atom-family instance or an enum — the types a single scalar token denotes directly. On a field of any other declared type — a record, container, tuple, choice, void, or a scoped instance — a value modifier is a resolver error at the declaration, whatever token stands beside it. The positional form (§5.6) is a spelling rule for data values and does not make a record a token: admitting p?: point ~ 3 would make a field’s eligibility for a default depend on another declaration’s field count, so that adding a second field to point silently invalidated a default written elsewhere. The refusal costs two spellings and buys a rule that fits in one line. Value modifiers are restricted to scalar tokens — quoted or unquoted — covering strings, numbers, booleans and enum members; complex modifier values (arrays, records, maps) are not supported in v1 (§12.1).
Value conformance. A fixed or default value MUST conform to the field’s declared type — the dependency the kernel’s record_field.value slot cannot express structurally is stated here normatively. The check is the field’s own parsing contract (§7.4): a value is accepted exactly when a read would accept the same token at that position. It applies wherever the value came from: written literally ({ first?: int32 ~ "nope" } is refused at the declaration), or routed from a template parameter and checked at materialisation (retry => <N> { attempts?: int32 ~ N } applied as retry<text> is refused identically, §5.10). The same rule is where a routed parameter’s type comes from: a parameter standing in a value slot is recorded with the field’s declared type, so conformance is checked at the application, against the parameter list, before anything is substituted (§5.10).
Eager resolution. Default and fixed value tokens are resolved and validated at schema-load time. The token is parsed by the field’s type — by the atom’s parser; a value-typed field is read by the atom the position stands for once that atom is in scope (§7.4) — and stored as the resolved host value. A default or fixed value that fails parsing or the type’s constraints is a resolver error, reported at schema load. This matches §7.4’s eager-conversion rule for constraint-field values. Inside a template body a value slot may instead hold a parameter (§5.7), which is resolved when the template closes.
Injection. When an optional field with a value — a?: T ~ v or a?: T = v — is omitted, the decoder injects the value into the output: decoded values are fully populated, and consumers do not consult the schema to retrieve defaults. A pin on an unmarked name is never injected: a: T = v is a marker a document states itself — a "jsonrpc": "2.0", an apiVersion, a format version — and exists for a reader that does not hold the schema, which is the reader injection cannot serve; omitting it is the missing-field error. A pin whose only information is presence — present and pinned, missing and not injected — is spelled where §5.11 says that information earns its keep: a restated member of a field group, which the group governs and never injects. A written _ at a field that is not voidable is a validation error whatever the modifier; at a?: T ~ v the fix is to omit the field, and omission remains the injection route (§7.6). At a?: T? ~ v — the one declaration where omission, _ and a value are three different outcomes — a written _ is void and a missing key is v, so timeout: _ means no timeout and an omitted timeout means the default.
Encoders. Encoders SHOULD write values for defaulted fields — a document that states its defaults reads without its schema; omitting a field whose value equals its default is a wire-size optimisation an encoder MAY offer, lossless only because the decoder injects the value back on read. Resolver output is exempt from this SHOULD: it omits fields at their default values (§8.1). Two declarations bind an encoder that has the schema, because a field with no value has one spelling to choose and the right one is the declaration’s: at a: T? a void value MUST be written as _, omission being the missing-field error; at a?: T? ~ v a void value MUST be written as _, omission decoding to the default. An encoder without the schema leaves out a field that has no value, which is right wherever the two spellings mean the same and is why those two declarations need the schema to be written at all ([TSON-DATA] §2.9).
In data, a FIXED field may be provided with a value matching the fixed value, or — where its name is marked — omitted. A written value at a FIXED field MUST be verified against the fixed value, as values (§5.5): a contradicting value is a validation error, never a value the decoder silently overwrites — an implementation that seeds fixed fields and skips the check produces decoded output that differs from the bytes with no diagnostic.
Delivery is the mode’s, and only admission is the field’s. The three slots decide what a document may say and what the schema supplies. What a reader then hands over is the mode’s: a bound value has one null, so a: T?, a?: T and a?: T? deliver alike and differ only in what they refuse; a tree keeps which spelling arrived — a field written _ is present with a void value, a field never written is missing ([TSON-DATA] §2.9) — in every encoding. Patch semantics (set, clear, leave unchanged) need the three outcomes a tree has and a bound value does not, and are a capability of the mode, not of any declaration.
Positional form. The single-required-field positional form of §5.6 counts fields whose name is unmarked, whatever their modifier: a voidable field must still be written, and so must a marker. A record has a positional form exactly when one field’s name is unmarked.
Field names: name_type. A record’s field names are values of its name type, the kernel’s record.name_type?: identifier_kind — an identifier family (§7.4), and the same mechanism an enum’s members are held to by enum_type.name_type. The three rules are one on both constructors: the type is an identifier family, every declared name is a value of it, and no two are one value under its equality. The default is the series’ own field_name, the kernel’s identifier, so nothing changes unless a record or a supertype says so; a schema translated from JSON-LD, OpenAPI or JSON Schema — whose objects are records with members such as @context, @id or $ref — names a relaxed type the translation declares for itself, and the series ships none:
json_name => !identifier_type { start_add: "@$" }
json_record => !record { name_type: json_name extension: ABSTRACT fields: {} }
node => json_record & { "@id": text label: text }
- The brace form states a type by composition, and has no syntax of its own. A relaxed name type is a translation’s tool, not the go-to form for a TSON record, so there is no brace-form spelling for it: a translation declares its type once, on a fieldless abstract base, and its records compose it. The cost is an IS-A edge to the base, which asks nothing of a position typed by a member —
nodereads untagged. A declared field name is any single-line token (§12.1), quoted where it is no identifier, and is judged at link by the record’s name type: a brace-form{ "first name": text }under the default is a resolver error, not a parse error. - Inheritance keeps one name type, as the constructor’s rule. A record’s
name_typeis set where it is declared and a subtype cannot change it: a record with supertypes takes their one type when it states none; one that states a type beside supertypes MUST state theirs; and supertypes with different name types —json_record & address, whereaddresssays nothing and so isfield_name’s — do not compose and are a resolver error (§5.8). The rule is the constructor’s and not&’s: the kernel’sname_type?: identifier_kindcarries no~ field_namedefault, since a default cannot depend on supertypes, so the explicit!record { … }and a template’s closed application are held to it exactly asjson_record & { … }is. Taking the non-default type instead would re-judgeaddress’s names by a type they were never declared under; the record to compose is one declared over the same type,address => json_record & { city: text }. A refinement keeps its source’s, so the brace form can restate an inherited relaxed name (tightened => node ^ { "$ref": text }). Two name types are one by the entry they resolve to, not by spelling:jn => json_nameisjson_name, and the kernel’sfield_nameis itsidentifier, so a record statingidentifiercomposes with one stating nothing. A name type a record states resolves in the author’s own namespace first, as an enum’s does (§7.4), and resolved output writesname_typeexactly where it was stated. - A name type’s form implies NFC. A reader matches a document’s name against the declared ones in the type’s
normalization(§5.5), as an enum matches its members: under a case-folding name typeNAMEis the fieldname, the two spellings in one record are a duplicate field, and a family’s selector is found however its name is cased. Under the default, NFC, nothing changes. SoNONEis refused at load — two canonically equivalent spellings would be two names that render alike — andASCII_CASEFOLD, which leaves text beyond ASCII as written, is admitted only for a type whose every character is ASCII;NFKC_CASEFOLDis the fold for any other (§7.4). - Hygiene follows the type, over a floor no profile lifts. Every field name and enum member meets [TSON-DATA] §8.2’s rules under its type’s own profile, whose added characters are its own (
json_name’s@and$, amedial: " "for words), and the collision relation over the scope (§11.4). Anidentifier_typeMUST NOT add a Default_Ignorable_Code_Point, a control, or a space other than U+0020 (§7.4): without the floor a soft hyphen incontinue_addmakes two names no rule tells apart, and a profile derived from a hostile source’s names would carry it in. - A schema’s own references to its fields match exactly. A group’s members, a refinement’s or removal’s restated field and a
discriminatorsentry name a declared field by its declared spelling, under a folding name type too: the schema is one author’s text, so a reference spelled differently is a mistake to report, and only a document’s names, which another party writes, are matched in the type’s form.record.discriminatorsis therefore[text; 1..]andfield_group’s member slots aretext, each judged by the record’s name type (§5.11). - A document’s field names are judged by the record that reads them. A name the record does not declare is an unrecognised field (§7.2), at its own pointer, after the name type has judged it: one that is a value of the name type is a name, and meets the identifier policy ([TSON-DATA] §8.2) under the type’s profile before it is reported as unrecognised, so a look-alike of a declared name is refused and never reported as an unknown field; one that is not can be no declared field, since every declared name is one, and is unrecognised without reaching the policy. Both encodings report an unrecognised field with the same code. Under the default the message is the plain one any undeclared name draws; under a declared name type it names the type and its reason, since those are the rules the author has to look up. A field that is no name meets no hygiene rule, and an unrecognised field’s value is skipped unjudged — no type governs it, so the one problem is the field itself, reported once. A record nested in a relaxed one judges its own names, so the relaxation does not leak into a value it holds. A lookahead does not judge a name: a sealed family’s discriminator scan crosses a member’s names before the member is chosen, and only the member knows its type, so a name the scan crosses is judged when the member reads it. A document that names a schema has its field names judged by that schema and only by it; a processor reading it without the schema holds them to no name rule ([TSON-DATA] §1.5, §2.5), and one that cannot obtain the schema leaves the document undetermined rather than judging it by the schemaless rule.
- A name binds and writes as the record states it. A host component binds a relaxed name through the name it states, as it binds any other, and a writer quotes a name that is no identifier, so the document reads back; a template’s field names are judged at each application, under the closed record’s name type.
Definition marks: how a record may be realised. A record states, by one of two words written between => and its type definition — before the parameter list where there is one (§5.10) — which values it has and whether others may build on it. The fact is the kernel’s record.extension, over record_extension_type => !enum [ABSTRACT FINAL OPEN]:
pet => abstract { name: text age: integer }
dog => pet & { breed: text }
leaf => final { id: uuid payload: bytes }
- OPEN — the record has direct instances, and any schema in the closure may compose onto or refine it. Every record with no mark, and the default.
- ABSTRACT — no direct instances: no value’s effective type is this record, and a position typed by it admits exactly its subtypes (§7.2). The tag is REQUIRED at such a position in every encoding unless the record names discriminators (below), and a tag naming the base itself is refused, since no value satisfies it.
- FINAL — direct instances, and no subtype: composition or refinement naming it as a source is a resolver error, in the declaring schema and in any schema that imports it. Subtraction stays admissible against a FINAL record: §5.9 empties the chain and mints no IS-A edge, and the IS-A set is the only thing FINAL constrains. A record template composing or refining a FINAL record is refused at its own declaration, its written parents being closed entries (§5.10).
Two marks on one declaration are ungrammatical — the slot holds one word — and a mark on a non-record definition is a resolver error. The member is never inherited: dog above is OPEN whether pet is ABSTRACT or OPEN, and must be, or no concrete subtype of an abstract base could exist. There is no transition table for it; the whole of the rule is that nothing may compose or refine onto a FINAL record, and every other declaration states its own member. Inhabitance gains no case: an ABSTRACT record with no subtype in its own closure is the ordinary shape of a library schema whose importers supply the members (§3.3.4 makes subtypes open across schemas), and refusing it at load would make the library unpublishable for having deferred exactly what it meant to defer. A document reaching such a position gets a validation error naming the remedy — no schema in this closure declares a subtype of the base, so the schema that does is missing from the imports. extension participates in a declared entry’s meaning as any body field does, is omitted from output at its default (§8.1), and the two words are ordinary identifiers everywhere but this one slot ([TSON-DATA] §7.7): abstract => { … } declares a type of that name, and f: abstract references it; what cannot be written is a declaration whose whole body is the bare word, which is a declaration missing its definition. The mark is grammar rather than an annotation because it changes which values conform, which §6 says no annotation may do.
The selector =?: a discriminated family. A field written =? reads pinned, but not here — the members pin it: the record is the base of a family whose members each fix the field to a distinct value, and a value at a position typed by the base is placed by reading the field rather than by a tag.
pet => abstract { pet_type: text =? name: text }
dog => pet & { pet_type: = "dog" breed: text }
cat => pet & { pet_type: = "cat" indoor: boolean }
The fact is the kernel’s record.discriminators, the base’s own statement of which fields select its members, in declaration order; whether the list is present is what says how a subtype is selected — by reading those fields where it is, by the tag where it is not — and that reading is derived from the body where it is needed, in the manner of choice.disjoint (§5.4), never a member of the extension enum. =? implies ABSTRACT: a record its members are selected from has no values of its own. Writing abstract beside a selector asserts what the body already says and is admitted, on @disjoint’s terms; final beside one is refused, the members it selects being subtypes that could then never exist.
The base cannot pin the field itself: §5.7’s identity rule forbids a FIXED restatement from changing the value, so were pet to write pet_type: text = "pet" no subtype could pin its own. The base therefore declares the field free and unpinned, each subtype tightens it to FIXED — the ordinary FREE → FIXED move — and there is no other arrangement. What the arrangement buys is that the selectors’ declared types are the base’s, uniform across the family by construction, so a decoder parses them with the one set of types it knows before dispatch, and the check list is short. At the base: the marked field’s declared type resolves, after its reference chain, to an atom-family instance or an enum; its name is unmarked and its type non-voidable — a?: T =? is a selector that may be missing, selecting nothing, and a: T? =? one that may be _, likewise; it is not a member of a field group (a member reachable by refinement included, §5.11); it carries no value, =? taking none. Over the family: every entry in the base’s subtypes, transitively, restates each marked field FIXED — a: = v, the marker that must be written, or a?: = v, the injected one — and the pins are pairwise distinct as values under the field type’s own equality (§5.5): = 255 and = 0xFF collide, = 1 and = 1.0 collide, and text pins compare in the field type’s normalization form, so two that fold to one value collide. A base may mark several fields; the pins are then distinct as tuples, taken in the base’s declaration order, and a combination no member pins is the ordinary unmatched-value error, per document. The family is judged over the closure that holds it (§3.3.4): an importing schema that adds a member which fails to pin, or whose pin collides with an imported one, is refused — in the importing schema, which is the schema that broke it — and so is one that declares no member and merely imports two schemas whose members collide. A family discriminates one level: a subtype of a member inherits the member’s pin and §5.7 forbids changing it, so it dispatches to its parent and relies on §7.2 and the encoding’s tag below that; an intermediate type meaning to dispatch its own members declares a selector of its own. A base whose members are its own applications — a record template — is §5.10’s case, with the same checks, except that a selector’s name mark is each member’s: a selector a parameter pins may carry either mark, the base reading it required (§5.10).
A family is open across schemas. §3.3.4 makes subtypes open, so an importing schema may declare a further member under the pinning obligation above, and the family is judged over the closure that holds it: two members an import merge brings together are the importing schema’s error (§3.3.4). A host language’s closed-set construct generated from a family — a sealed interface over records, a discriminated union — is relative to one closure and not a promise the schema makes. The word “discriminator” names the marked field in every diagnostic and index, since a symbol is not searchable; what it names is written =?.
A family member is declared; a minted record is a member of nothing. An application nothing declares is its expansion (§8.2), and a minted record whose chain would name a declared type is a resolver error at the declaration that wrote the application: over dog_of => <T> pet & { pet_type?: = "dog" breed: T }, a field written k: dog_of<text> would mint a member of pet that no declaration names, and so would a parent application a declaration owns. The diagnostic is located at the declaration that wrote the application — or, where the application sits in a template’s held body, at the declaration whose closing minted the member, kt for kt => kennel_of<text> over kennel_of => <T> { d: dog_of<T> } — and SHOULD name the fix: dogs => dog_of<text>, then k: dogs. This is what §3.3.4 and §8.2 already imply together: a member changes what every position typed by its base does, in every schema that imports it, and is read, tagged and bound by name, while a minted name may never be written (§8.2), so a minted member could be selected and then neither reported, tagged nor mapped. The rule is drawn at membership rather than at member dispatch — a minted member of a tag-dispatched family is as unreachable as one a selector picks, an untagged value at its base needing a tag it cannot carry — and it leaves every other use-site application alone: arrays, sets, choices, applications of a family’s base, which compose onto nothing, and an application of a record template written at a use site, which is a type read where it is written and no member of the template’s family (§5.10). A minted record gains no IS-A edge later, and a declaration’s operand still reaches it: vip => customer & box<int32>, beside a use site’s box<int32>, is IS-A that synthetic and stands where crate<int32> is expected, the two applications being one type (§8.2).
Resolution. A record definition resolves to !record { fields: { … } } (PRODUCT-kinded by derivation, §8.1), its fields a field_map — a namespace from each field’s name to its record_field, in declaration order, which is part of the value (§4.1). A field’s name is written once, by its spelling, as the key, and the name carries no slot of its own in the record_field: { type optional? voidable? role? value? } is the field’s type and the four facts a reader consults (§8.1): type is a type_kind value, the bare name as written (a reference is a hop, §8.3); optional is the name’s ?, voidable the type’s, role one of FREE, DEFAULT, FIXED for the modifier, and value the eagerly-resolved default or fixed value, present exactly when role is not FREE. The defaults — false, false, FREE — are omitted in output. A duplicate is refused by the layer that can see it: a spelling written twice is the map’s duplicate key ([TSON-DATA] §2.6), refused where the map is read and before any name type is consulted; two spellings that are one value of the record’s name type are the linker’s resolver error. A definition mark lowers into the body’s extension (omitted at OPEN); the selector fields lower into discriminators, the marked fields themselves staying FREE, unmarked and unpinned; a stated name type lowers into name_type, written exactly where it was stated. An empty record {} is the zero-field case, !record { fields: {} } — the shape of the kernel’s top, abstract.
Type-name resolution. Type names used as type-refs (field positions, type arguments, choice variants, composition targets, refinement sources) and generic-application heads resolve against the type-name namespace; constructor-application targets and the desugar targets of the sugar forms resolve through the structure namespace per §3.3.1. Bare names always refer to types — there is no field-name shadowing of type names.
Inline atom refinements and bare records are prohibited. Atom refinements (!number ^ { min: -273.15 max: 10000 }) and bare records ({ name: text }) MUST be introduced via named declarations and referenced by name; they MAY NOT appear inline in field-type, field-group-member, tuple-element, array-element, choice-variant, type-argument, or composition positions. The container sugar forms carry no positional restriction (§5.3): a size specifier or an element/position ? is legal wherever the bracket or map form is. Implementations MAY enforce a configurable nesting-depth limit on inline forms as a resource bound in the manner of [TSON-DATA] §9.1; where such a limit is enabled, exceeding it is an error.
5.3 Type Expressions
The type-definition grammar has one form per container, legal at every type-ref position — field types, group members, tuple elements, array elements, choice variants, type arguments, and declaration bodies alike. There is one bracket form and one map form, each admitting a size specifier after ; and an element/position ? wherever the form appears; nesting is the recursion already present in the element position (§12.1). Whether a form sits as a declaration’s own body or inline at a use site changes what the resolver does with it — a declaration’s body is the construction in place, everything else lifts to a synthetic entry (below) — never what may be written.
config => {
tags: [text] ; plain array
meta: {text => integer} ; map sugar
point: [number, number] ; tuple, no position voidable
contact: (email | phone) ; choice
index: {text => [order]} ; nested forms
aliases?: [text] ; the ? on the name is FIELD omission (§5.2)
sparse2: [text]? ; the ? on the type admits _ at the field (§5.2)
scores: [integer; 1..] ; size specifier, legal at a field
sparse: [text, text?] ; voidable tuple position, legal at a field
batch: order_batch ; a named declaration remains good style
}
order_batch => [order; 1..100] ; bounded range as a declaration body
matrix9 => [number; 9] ; exact size
opt_items => [text?] ; voidable elements
Array types use [type] with an optional size specifier after ;. The size specifier is a grammar production over the range token ([TSON-DATA] §7.2.4): a bound, optionally followed by .. and an optional upper bound, or .. followed by a bound (§12.1). Each bound is an unquoted token whose text MUST match the decimal-natural production of [TSON-DATA] §7.6 — a non-negative decimal integer without leading zeros — or, within a template body (§5.10), a value-parameter name. Classification is unambiguous — parameters cannot be numeric — and a non-numeric bound token that resolves to no value parameter is a resolver error. Five forms result: N (exactly N elements), N..M (bounded range), N.. (at least N), ..M (at most M), and absent (unconstrained). [T; N..N] and [T; N] are two spellings of the same binding and land on the same entry (§8.2); the N form is RECOMMENDED. A lower bound of 0 with no upper bound (0..) is a resolver error — every array satisfies it, and because identity is structural (§8.2) the spelling would mint an entry distinct from [T] that means the same thing; the diagnostic SHOULD say that the unconstrained array is written [T].
Bound coherence. A size specifier desugars to the min_items/max_items binding pair (the table below), and one rule governs the pair wherever it arises, arrays and maps identically: when both bounds are present, min_items MUST be less than or equal to max_items, checked at the point the bounds are concrete — a resolver error at schema load where the bounds are literal, at materialisation where parameter-bound (§8.2).
The element position accepts an optional ? suffix, producing voidable: true on the resolved array. Under [T?], an element at any position MAY be the void sentinel _ (§7.6); void elements occupy positional slots — [a _ c] has three elements and satisfies a [T?; 3] size constraint. Without the suffix voidable is false, and a void element is a validation error when a schema is in scope. An element is never missing: a container’s part is a value or it is void, so the container constructors carry voidable and no optional (§5.2, §8.1).
Order is a facet. array carries ordered, true unless a body says otherwise, and map carries it too, false unless a body says otherwise (below). The facet says whether two values differing only in order are one value. It never changes what a document may write, and output keeps the order written either way (§7.5).
Sets. The kernel’s set_type constructor refines array and pins three facets: ordered: = false, unique_items: = true, and voidable: = false — a void member has no meaning in an unordered collection of unique members. Its bounds are array’s: the empty set is a set, and a set that must be non-empty says min_items: 1. Sets have no bracket sugar of their own; the spelling an author writes is the template set<T>, which meta and core each declare as set => <T> !set_type { element_type: T } (§9) — a §5.10 application, admitted at a field position where a ! construction is not (§5.2). The template carries no bound, so a bounded set at a field is a named entry — tag_set => !set_type { element_type: text min_items: 1 }, then tags: tag_set — which is how the kernel and meta state every set they rely on being non-empty: enum_set, integer_member_set, text_member_set, scheme_set, and meta’s scope_set and decimal_member_set.
Map types use {key => value}, mirroring the data notation, with an optional size specifier after ; ({text => order; 1..}) desugaring to min_items/max_items under the same grammar, coherence, and diagnostic rules as arrays. The sugar’s key position accepts a type-name optionally carrying type arguments — not a paren or bracket form: composite map key types deserve a named declaration, and the explicit !map { key_type: … } form remains available for them. The sugar takes exactly one key => value entry — a map type has one key type and one value type; the sugar mirrors the data’s shape, not its arity, and the diagnostic for a second entry SHOULD say so. The value side admits ?, marking the value voidable exactly as [T?] marks an array element: {text => order?} produces voidable: true on the resolved map, under which an entry’s value MAY be the void sentinel _ (§7.6) — the entry is present with a void value ([TSON-DATA] §2.9) and counts toward the size bounds like any other. Without the suffix a void entry value is a validation error. A map is unordered unless it says otherwise, as a JSON object is: two maps holding the same entries in different orders are one value. !map { key_type: K value_type: V ordered: true } declares one whose entry order is part of its value, so those two are distinct, and a host binds it to a map that keeps its order; the sugar has no spelling for it. The key side does not admit ?: a void key is a resolver error ([TSON-DATA] §2.9), and map-key has no ? to write (§12.1). Annotations inside the sugar braces are a parse error; the declaration is the annotation anchor. Brace dispatch between the record and map readings is by one consumed token plus one of lookahead, reusing [TSON-DATA] §2.8’s machinery (§12.2).
Tuple types use [type, type, ...] with comma or whitespace separation between individually typed positions, and a comma may follow the last ([TSON-DATA] §2.4). A tuple requires at least two element type expressions: two or more type-refs separated by whitespace or comma inside brackets is always a tuple; a semicolon after a single type-ref introduces an array size specifier; a single type-ref with no semicolon is an unconstrained array — never a one-element tuple. [text,] is therefore an unconstrained array of text, the trailing comma following a value and changing nothing. A tuple of exactly one position has no bracket spelling and is written through two templates core declares (§9): tuple1<T> — tuple1<text> admits [a] and refuses [], [a b] and [_] — and voidable_tuple1<T>, whose one position may be void, so that voidable_tuple1<text> admits [a] and [_]. There are two because a type argument cannot carry the ? that would make the position voidable, and an application carries no facet an author could set instead. The need is rare in a schema written as TSON and real in one converted from another schema language, where a converter needs a spelling it can emit at a field without minting a declaration.
A tuple position answers one question — may its value be void? — recorded as tuple_element.voidable. A position, like an array’s element, is never missing, so it has no optional; a record field answers both questions (§5.2). Tuples are fixed-length: every position MUST be present in the data. A voidable position may carry the void sentinel _, but the slot itself MUST appear — a tuple value with fewer elements than the type’s position count is a validation error however many trailing positions are voidable. Given sparse_pair => [text, text?]: [a, b] and [a, _] are valid; [a] is a validation error. Authors wanting a tail that may be left off should use an array ([text; 1..]).
Choice types use (type | type | ...) — see §5.4.
Arguments use name<arg, arg>, binding positional arguments to the declared parameters of a template (§5.10); the head resolves through the type-name namespace only (§3.3.1). The argument count MUST match the parameter count. An argument is a type reference — which may nest (box<[integer]>, pair<text, {text => order}>) — or, for a template with value parameters, a concrete value: vector<pixel, 1920>. Number and quoted-string arguments are unambiguously values; an unquoted token argument that satisfies the identifier grammar — true and false included — is read as its parameter’s recorded type says — a type reference where that type is type_kind, a literal (an enum member, for instance) where it is anything else (§5.10). Bare references to a parameterized type without <> are resolver errors — parameter binding is mandatory at every use site.
Desugaring. Every sugar form desugars to a canonical constructor application (§5.6):
| Source form | Desugaring |
|---|---|
[T] | !array { element_type: T } |
[T; N] | !array { element_type: T min_items: N max_items: N } |
[T; N..] | !array { element_type: T min_items: N } |
[T; ..M] | !array { element_type: T max_items: M } |
[T; N..M] | !array { element_type: T min_items: N max_items: M } |
[T?], [T?; ...] | the corresponding form with voidable: true bound directly |
[T, U, ...] | !tuple { elements: [...] } |
(A | B) | !choice { variants: [A B] } |
{K => V} | !map { key_type: K value_type: V } |
{K => V ; spec} | !map { key_type: K value_type: V min_items/ |
{K => V?}, {K => V?; …} | the corresponding map form with voidable: true bound directly |
set<T> | the template application !set_type { element_type: T } through meta’s or core’s set (§9) |
C<args> | a template application (§5.10, §8.2); C resolves through the type-name namespace only (§3.3.1) |
Array forms record element_type, voidable from the element’s ? suffix, and min_items/max_items from the size specifier; tuple forms record elements with a tuple_element per position, each carrying its own voidable; map forms record key_type, value_type, voidable from the value’s ? suffix, and the bounds.
Nested forms and synthetic entries. Every sugar form lifts at desugar time to a resolver-materialised entry — a synthetic entry — except a declaration’s own body, which never lifts: it is the declaration (ids => [order] is the construction in place, not a reference to one). The lift rule’s dividing line is closed versus open: a concrete form lifts to a closed synthetic entry with an ordinary constructor body; a parameter-bearing form — one mentioning a parameter of the enclosing declaration — lifts to an open synthetic entry capturing those parameters in declaration order, whose body is the constructor application as written, held until materialisation (§5.10, §8.2). The rule needs no case for whether the enclosing declaration has parameters: a concrete [order] inside <T> { a: T b: [order] } lifts closed, like any other concrete form. Materialisation creates no synthetic entries — it closes open ones, innermost-out (§8.2). A use-site sugar form mints where a declared application does not (§8.2), and the asymmetry is the design: a non-record form — an array, set, map or tuple — is the same definition wherever it is written, nothing subtypes it and no tag dispatches to it, so a content-keyed synthetic is exactly the entry every location writing [text] should reach; a record takes part in IS-A, and a family’s subtypes index and a tag both key on its entry, so a declared name has to be the entry for pet.subtypes to read [dogs cats] and for !dogs to select it. Synthetic names follow §8.2’s internal-name rules: resolver-chosen, fresh by construction, disjoint from declared names, and unreachable from source — and the entries themselves are an implementation’s own: their shape is unconstrained beyond producing the required resolved output, and a processor is free to mint one per schema, share them, or normalise them however it likes (§8.2). The lift targets are grammar-supplied and never kernel-declared, so desugaring stays purely syntactic and independent of the import set. Because every form lifts to an entry, nesting needs no special channel — an inner form’s marks, sizes, and bindings live on its own entry, and the outer form references it by name — and the positional restrictions earlier revisions imposed on inline forms are gone: a size specifier or ? is legal wherever the form is.
A use-site application never resolves in place: an inline constructor application, sugared or explicit, resolves to a reference to its (synthetic) entry, and a fully-bound application of a template resolves to a reference to its expansion — the closed synthetic entry the substituted form lands on, keyed on its content, which any other spelling of the same form lands on too (§8.2). Nothing records the application at the use site. type_ref.arguments therefore appears in resolver output only where an application is still open or is the identity of an entry — inside held template bodies and a record template’s fields (§5.10), in a record template’s chain.parents (§4.1), in an open alias’s reference.target (§8.3), and in the @application annotation on an entry that closes an application (§8.2) — and means “an application”, nothing else.
5.4 Choice Types
A choice type declares that a value may conform to any one of a set of alternative types:
contact_method => (email | phone | address)
A choice MUST contain at least two variants; each variant is a type reference. The choice name is then used like any other type name in field definitions, and choices MAY also appear inline in type-ref positions: contact: (email | phone).
A variant MUST NOT resolve to void — judged after following the reference chain (§8.3), so an alias of void is caught too; the declaration is a resolver error, and the diagnostic SHOULD say why: optionality is not choice — whether a slot may hold no value is the position’s own fact, so the author marks the position (§5.2’s ?), or types the field void outright where a slot that only ever holds the void value is genuinely meant. (T | void) would create a second, worse spelling of T?: void is the one thing the format spells one way ([TSON-DATA] §2.9), and it is a fact of a position, never a variant.
Disjointness is a total, two-valued fact of the declarations: discrimination-class distinctness. Every type has at most one discrimination class, derived after following its reference chain (§8.3):
| Class | Types |
|---|---|
boolean | the boolean family |
number | every numeric family — integer, decimal, and float tiers alike |
string | every text-form family — text, the identifier families, uuid, uri and uri_reference, iri and iri_reference, regex, email, the temporal families, bytes, the network-address families, and their refinements |
brace | records and maps (both {...}) |
bracket | arrays and tuples (both [...]) |
An enum’s class is read off its members under its name_type (§7.4), always an identifier family — enum itself, and an enum over a profile a schema declares: it is its members’ shared class read off each member’s own token ([true false] is boolean-class; a name of the series’ own profile never begins with a digit, so no member of an enum is number-class, and mixed members yield none). A closed set of text that is no name is the text family’s own members facet, ports => !text ^ { members: ["80" "443"] }, string-class as every text atom is, so ( ports | integer ) is disjoint where ( ports | text ) is not. A kind and a reference (§4.2) are string-class, their values being names. A map keyed by an identifier family is brace-class like any map keyed by an atom. A type with no single class — rational and complex (whose typed forms straddle classes), an approximate atom whose allow_nan or allow_infinity is true (core’s float32 and float64 as declared: a NaN or an infinity has no number spelling in some encodings and a string one, so the family’s forms straddle classes exactly as rational’s do), a map whose key type, after following its reference chain, is not a type a single scalar token denotes by its content — an atom-family instance or an enum, value and void excepted, value being read by the type its position hands it to and void having no key at all (no single scalar token denotes a compound key, so an encoding without a delimiter pair for such a map spells it in the bracket class, [TSON-JSON] §6.4), value (a token read by whatever type the position stands for), a nested choice, a scoped instance (§7.8), or an unresolvable or cyclic reference — has none, and a classless variant makes its choice not disjoint. identifier is not among them: it is a text family and string-class, as every identifier profile is, so ( identifier | int32 ) is disjoint, and ( identifier | text ), or a choice over two profiles, is not. The two straddling kinds are what make disjoint class-stable: a choice’s untagged values are the same set in every encoding, at the price that ( float64 | text ) carries a tag in text too unless the float narrows allow_nan and allow_infinity to false. The rule: a choice is disjoint: true if and only if every variant has a class and no class appears twice; false otherwise. The procedure is closed and normative: a resolver MUST record exactly this — it MUST NOT prove more (value-set separation such as disjoint numeric bounds or disjoint patterns does not make a choice disjoint) or less. The classes are [TSON-DATA] §4’s own host-type partition plus the two delimiter forms — three scalar classes and no more, deliberately ([TSON-DATA] §4.5): a class for a text-form family such as date would narrow what a text variant admits untagged in every schema that never mentions a date — and every text-class encoding shares them, so the fact is portable across encodings by construction. Every declared choice records disjoint; there is no absent state for a choice.
A narrower constructor leaves values unreachable. Discrimination recovers the variant from the value’s class and nothing else, so a value dispatched to a variant that then refuses it is refused outright — there is no second attempt at another variant. A constructor narrower than its base-type class therefore leaves values of that class unreachable in any untagged choice it appears in: core’s number is decimal-only where [TSON-DATA] §4’s number production admits 0xFF, 0b1010, 0o377, .inf and .nan, so ( number | text ) classifies 0xFF as number-class, dispatches it to number, and fails, never reaching text; ( int32 | text ) refuses 99999999999999 the same way, and a pattern-refined text beside another string-class variant refuses everything the pattern excludes. This is a consequence to know rather than a rule to add, and it is one more reason the labelled form (below) is the shape for alternatives an author expects to overlap.
Tagging. At the data level, a value matching a choice type carries a type annotation (!variant) selecting the variant. The tag is REQUIRED when the choice is not disjoint, and MAY be omitted when it is: disjoint means precisely that the encoding’s own form resolution — for TSON text, the single base-type-resolution pass of [TSON-DATA] §4, plus the brace/bracket delimiter — recovers the variant, so an omitted tag is recovered from the value’s class, never by a second, type-directed inspection of the value’s form; the once-only reading of form ([TSON-DATA] §2.4) is preserved. A value omitting the tag at a non-disjoint choice is a validation error. For an emitter the rule is one sentence: if two variants share a class, tag every value of the choice; a tag is never wrong.
The @disjoint assertion. An author MAY annotate a choice definition with @disjoint (defined in meta.tn as a void-targeted marker, written bare per §6 — presence is the assertion) to assert that its variants are distinguishable by the encoding’s form resolution — the property the derivation checks, and a weaker and more useful one than mutual exclusion of inhabitance. Overlap in inhabitance is ordinary and expected: [TSON-DATA] §2.4’s form is not meaning makes text admit every token, so 42 inhabits both variants of ( int32 | text ), and the choice is disjoint all the same because the token’s class recovers the variant. The annotation carries no decode force — the resolver computes the choice body’s disjoint whether or not it is present — and is a checked annotation (§6) with exactly two outcomes against the derived fact: verified (disjoint: true; silent), or a resolver error (disjoint: false): the author asserted a property the declarations lack, in the manner of N > M in a size range (§5.3). The derivation is total, so there is no unprovable state and no warning tier. A choice whose variants are separated only by value sets — bounded numerics, disjoint patterns, disjoint enum member sets — derives false and rejects the assertion; nothing operational is lost, since no encoding could drop those tags, and the labelled form (below) is the recommended model for such alternatives. A resolver never diagnoses a choice merely for being non-disjoint — overlap is often intended, and the tag serves it.
Resolution. (A | B | ...) desugars to !choice { variants: [A B ...] disjoint: … } — a SUM-kinded entry when declared, and a synthetic entry when inline (§5.3), referenced by name from the use site, so the entry carries the disjoint fact wherever the choice appears. The fact sits in the choice body, beside the variant list it is derived over (§8.1): choice is the only body that has one, so “recorded on every choice and on nothing else” is structural rather than a rule a document could break. Each variant is a type name (a type_kind value, §4.2, §8.1) and may itself be a sugar form, which lifts to its own entry (§5.3). The resolver validates that each variant resolves to a distinct type.
A choice discriminates by variant type name; for labelled disjunction — mutually exclusive alternatives distinguished by field label, including alternatives of the same underlying type — see field groups (§5.11). A choice has no discriminator field: an author reaching for member dispatch — the value placed by a field it carries rather than by a tag — wants the composed family of §5.2, an abstract base whose members pin a field written =?, and a position typed by the base rather than by a choice over the members. The labelled form is the recommended resolution wherever the tag would otherwise be mandatory: a choice whose variants share a base-type class, or whose disjointness is unprovable, is often better written as a single-group record (§5.11), which discriminates by label and needs no derived disjointness.
5.5 Constructor Application and Atom Refinement
Within the type-definition grammar, the ! prefix always takes top or an entry that IS-A it — a constructor (§4.2); the invariant the data format teaches therefore holds in every grammar of the series: !T x describes a value shaped by T — in schema source, in data documents, and in resolver output alike. Two forms follow the prefix, distinguished by the ^ operator; the name after ! resolves per §3.3.1.
Constructor application — !C value. Produces a constructor instance filled with specific values. The core value after !C (§12.1) is a record of bindings interpreted against the constructor’s record shape — the field list C declared as its narrowable vocabulary — or the positional form of §5.6. This form does NOT establish IS-A: construction transfers only the constructor’s kind; the result carries C as its tag and no supertypes. Applying an entry that is neither top nor IS-A it after a bare ! is a resolver error (§3.3.1), and so is applying one that is abstract — top, type, the base kinds and namespace (§4.1). The kernel’s reference is applicable like any other: !reference { target: X } denotes the alias X — REFERENCE-kinded, carrying no chain — and not a value of the head’s own kind, open (<B> !reference { … }, §5.10) and closed alike. An application of a namespace type (§4.1) builds a map value and not a type: orders => !interface { … } is read as the map interface describes, and the entry is a value no type position may name.
integer => !integer_type {}
boolean => !enum [true false]
bytes => !bytes_type {}
Kind determination. A constructor’s kind is settled at definition time by the base kind — atom, product or sum, or namespace, excluding top and type (§4.1) — reachable through its transitive chain, each counted only as the kernel’s own entry. Zero base kinds in the chain → PRODUCT by structural default; exactly one → that kind; two or more → resolver error, since the kinds are categorically distinct, namespace beside a base kind included. !C {} simply inherits C’s settled kind — an instance of a namespace type is a map value and is not a type (§4.1).
Bodies are closed. A construction or refinement body is a record whose type is known — the constructor’s own constraint-field vocabulary — and is validated as an ordinary closed record (§7.2): each member binds against a declared field, and a member the vocabulary does not declare is a resolver error at the declaration, naming the member and the constructor’s real fields. This is stated here, where bodies are written, because the silent alternative is the dangerous one: an implementation that binds field-by-field and ignores unmatched members reports success while discarding the constraint the author wrote (!integer ^ { minimum: 1 maximum: 100 } — JSON Schema’s spellings — would compile clean and constrain nothing).
Atom refinement — !I ^ { values }. Refines an atom-family instance by tightening values on its constructor’s constraint fields. I MUST resolve to an atom-family instance — an entry whose body is an atom application, not the constructor whose body is the vocabulary record describing one (§3.3.1). The test is on the body and on nothing else: an instance and its constructor are both ATOM-kinded, so kind does not separate them, “IS-A atom” says the opposite of what a reader expects (true of the constructor, false of every instance, IS-A stopping at construction), and a plain record has no supertypes either — where integer carrying !integer_type {} against integer_type carrying !record { … } is one test that neither of those makes alone. The body is the braced subset of the payload a constructor application takes — a data-grammar record or empty-brace (§12.1) read against the constructor’s vocabulary: a bare value, a second type-ref, an annotation, or a map in body position is a parse error, and the positional form of §5.6 does not apply. The body is data, not a record of field declarations: size: { bits: 8 signed: true } binds a nested value, and min: 1 binds a number, exactly as they would after !integer_type. This form DOES establish IS-A: the new type carries I’s constructor as its tag, chain.supertypes holding I and everything I IS-A, and a body in the constructor’s canonical form (§5.6). A refinement head admits no removal clause (§5.9).
age => !integer ^ { min: 0 max: 150 }
non_empty_text => !text ^ { min_length: 1 }
positive_integer => !integer ^ { min: 1 }
age resolves to !integer_type { chain: { supertypes: [integer] } min: 0 max: 150 } and can be refined further; a refinement of a refinement carries the complete chain (small => !int32 ^ { … } carries [int32 integer]). Founding and refining are distinguished at the head, by the operator: !integer_type {} applies the constructor (fresh family, no IS-A); !integer ^ { min: 0 } refines the instance (IS-A integer).
Construction creates siblings, not subtypes. One constructor may found any number of nominally distinct families: dogs => !integer_type {} is a fresh atom family with the same body as integer and no relation to it, and small_dog_count => !dogs ^ { min: 0 max: 5 } refines dogs, not integer. Identity is nominal for a declaration (§8.2): stock_id => !uuid_type {} and other_id => !uuid_type {} have byte-identical resolved bodies and are two types, and !other_id is refused at a stock_id position, as is !uuid. The only IS-A the ! forms ever create is the refinement’s hop to its instance — recorded in the type’s chain and deliberately nowhere else in the body: the canonical form (§5.6) erases the surface distinction, so chain.supertypes is the sole carrier of the atom family’s IS-A fact (§8.1).
Single-required-field positional form. When a constructor has exactly one field whose name is unmarked, the core value after !C fills that field directly; see §5.6. The positional form applies to constructor application only — a refinement body is always a braced record.
Facets with a stated meaning. Each constraint family’s facets are defined by the constructor’s documentation in meta-kernel.tn and meta.tn (§9), with the tightening rules of §5.7 applying per facet kind. What every family’s facets rest on, and what the artifacts state per family, is one clause:
A type denotes a value space. An encoding defines a lexical space over it and one canonical form per value. Equality, ordering, refinement, disjointness and content addressing are defined over value spaces only, never over spellings.
Every rule in the series that compares two decoded values — the set duplicate rule (§7.5), map-key identity ([TSON-DATA] §2.6), the check of a written value against a FIXED one (§5.2), the identity of a value argument (§8.2), and the digest a pinned reference verifies (§10.2) — delegates to the element type’s equality, and this clause is what that delegation reaches. For the numeric families [TSON-DATA] §4.3 already states it (255 and 0xFF are one value); for every family whose value space differs from its lexical space the companion artifacts state it in the family’s own @doc, and the consequences that decide interoperation are these:
bytesis an octet sequence. The RFC 4648 alphabet a text encoding spells it in is abytes_typeselector (encoding, defaulting toBASE64), which picks a spelling and can never change what two values compare as: case in hex and padding in base64 are lexical,"abcd"and"ABCD"underHEXare one value, and the length facets count octets —length: 32is a 32-byte digest whether it arrives as 64 hex characters, 44 base64 characters, or 32 raw bytes. An encoding whose values are octets ignores the selector and writes them raw, and a round trip through it is value-preserving: the alphabet is re-spelled from the schema, not recovered from the bytes. The selector is a facet rather than an annotation because a container element has no annotation position —[hexdigest]works only if the element’s own type carries the alphabet — and it is not refinable: an alphabet narrows nothing, since every octet string is writable in every one of them, sohexbytes => !bytes ^ { encoding: HEX }would claim an IS-A carrying no narrowing at positions no base64 reader could honour, and is a resolver error (§5.7). Another alphabet is another instance,hexdigest => !bytes_type { encoding: HEX length: 4 }; refining for length inherits the alphabet,sha256 => !bytes ^ { length: 32 }being a base64 sha256.bytes_typecarries nospec: RFC 4648 governs spellings, not octets. Core declares onebytesand no spelled siblings, and [TSON-DATA] §5.3’s!bytesis base64, so the name denotes one type in both conformance classes.numberand the exact numeric tiers are exact values with no scale:1,1.0and1.00are one value, and whether a spelling’s trailing zeros survive a round trip is an encoding’s promise, not the type’s.rationalis the fraction —2/4and1/2are one value.- A text value is its token’s text put into the type’s form.
text_typecarriesnormalization, overnormalization => !enum [NONE NFC NFKC NFKC_CASEFOLD ASCII_CASEFOLD]: a value is the token’s text, unquoted, unescaped, and then put into that form — rewritten, never refused for not already being in it — and every other facet judges the value and never the spelling (lengths,pattern,members, an identifier’s profile), as does every comparison of two values: pins, map keys, set uniqueness, the duplicate and look-alike rules. This is the series’ own posture, stated for text: an atom maps a spelling to a value (0x10is 16), and a quoted token at an identifier position is normalised before it is matched ([TSON-DATA] §7.2.1). No comparison goes below NFC. Two text values are one value when, each in its type’s form, they are NFC-equal, so a composed and a decomposedÉare one value under every member —NONEandNFCadmit the same equality and differ in what the value is, the text as written or its composed form, which is what the other facets judge and a round trip writes. NFC can carry identity where name hygiene cannot ([TSON-DATA] §8.2): Unicode’s normalization stability policy keeps two NFC-equal texts NFC-equal under every later version, so the verdict never moves, and an identifier or token policy relaxed to nothing leaves it standing.NONEkeeps the text as written.ASCII_CASEFOLDmapsA–Ztoa–zand changes nothing else — it runs no Unicode normalisation on the value — and is the comparison URI schemes, HTTP field names and DNS names state for themselves.NFCandNFKCare Unicode’s canonical and compatibility forms.NFKC_CASEFOLDistoNFKC_Casefold, UAX #31’s form for names compared without case: a full case fold and not lowercasing (ßfolds toss, so a length changes), which also folds compatibility characters into ASCII and removes the default ignorables, the join controls among them, so [TSON-DATA] §7.7’s context rule has nothing left to judge under it. NFD and NFKD are not members: each is its composed twin’s equivalence stored decomposed, and would change a spelling and no verdict. The default isNONEontext_type, where quoted text is otherwise kept as written, andNFConidentifier_type;regex_type,uri_type,iri_typeandemail_typefixNONE, since a pattern put into another form matches something else ([A-Z]folds to[a-z]), and past the scheme and host a URI compares with case, as an IRI does and as a mailbox’s local part does.normalizationis fixed where the type is constructed (§5.7). Two consequences reach an encoding. A round trip writes the value:Content-Typeunder a folding type is written back ascontent-type, as0x10is written16. And a refusal SHOULD quote the token as written and then the value it was judged as —'PUT' (read as 'put' under ASCII_CASEFOLD)— the written spelling being what a reader, or a repair of a generated document, has to find. A schema’s own members and pins are values too, decoded as data is. - A URI is RFC 3986’s URI, and an IRI RFC 3987’s.
uri_typestates RFC 3986’s distinctions as facets.allow_relativeadmits a relative reference (RFC 3986 §4.2) beside a URI (§3), so its default is the URI-reference (§4.1) and withdrawing it requires a scheme.allow_fragmentadmits a fragment (§3.5); withdrawn besideallow_relativeit leaves the absolute-URI (§4.3).schemeslists the admitted schemes as a set of the kernel’sscheme_name, anASCII_CASEFOLDidentifier, so a scheme is held folded as RFC 3986 §3.1 compares it,[HTTP http]is a duplicate, and a value with no scheme is outside every set.uri_type, in meta, has core’suri_reference => !uri_type {}anduri => !uri_reference ^ { allow_relative: false }as its pair, souriIS-Auri_reference, every URI being a URI-reference. A URI is US-ASCII (RFC 3986 §2).iri_typehas the same three facets with the same meanings over RFC 3987’s grammar —ucscharbeyond US-ASCII, andiprivatewithin the query — and core’siri_referenceandiriare its pair; an IRI is judged through the URI it maps to (RFC 3987 §3.1).iri_typeis the kernel’s, because the kernel and meta type their own references by it: the kernel’siri => !iri_type { allow_relative: false }typesatom_specification.spec, and meta’sschema_identity => !iri_type { allow_fragment: false }the keys ofscoped.schemas(§7.8). A schema identity is an IRI-reference ([TSON-DATA] §2.2.1, §3.3) — a host or path may reach beyond US-ASCII, and a path-only reference names a library entry — and never carries a fragment; its remaining form rules — a host that is ahostvalue written in its canonical text ([TSON-DATA] §2.2.1), no userinfo or port, no dot-segments, an absolute path where there is no host — are identity’s own and are refused where an identity is formed. Meta cannot construct an instance of a constructor it declares, its constructor heads resolving in the kernel (§3.3.1), so the family both use lives there anduri_type, which neither uses, lives in meta.uriis not IS-Airi: families do not relate, and §5.4 already treats every text-form family as overlapping. A relative reference whereallow_relativeis withdrawn, a scheme outsideschemes, and a fragment whereallow_fragmentis withdrawn are validation errors — the token is inside the family’s lexical space and outside the type’s value space — and a character outside the family’s grammar is refused by the family’s parser (§7.4). The value of each is its text as written. hostnameis a domain name, and the value is the name ([TSON-DATA] §5.5): RFC 5890’s labels, LDH labels, A-labels or U-labels in any mix. ASCII case is lexical, and a U-label and its A-label are two spellings of one label, each recovered from the other by Punycode (RFC 3492), soExample.COMandexample.comare one value and so arexn--bcher-kva.exampleandbücher.example. The canonical text is the lowercase U-label form — what a writer emits, and the form an identity’s host takes ([TSON-DATA] §2.2.1).hostname_typecarries one facet beside itsspec:allow_idn, a permission (§5.7) granted by default, which withdrawn admits only a name none of whose labels is a U-label or an A-label. It judges the value and not a spelling, so!hostname ^ { allow_idn: false }refusesbücher.exampleandxn--bcher-kva.examplealike, as a validation error — the token is inside the family’s lexical space and outside the type’s value space — while a token outside RFC 5890’s grammar (uppercase beyond ASCII, a trailing dot, a last label beginning with a digit) is refused by the family’s parser (§7.4).hostis a host name or an IP address, never both ([TSON-DATA] §5.5): RFC 3987’sihostprofiled to DNS, whose lexical space is the union ofhostname’s,ipv4’s andipv6’s plus an IPv6 address in brackets. Each member keeps its own equality — a name compares as ahostnamevalue, an address numerically — and a name never equals an address, nor an IPv4 address an IPv6 one, its IPv4-mapped form included. The brackets are a spelling and not a value, so[::1]and::1are one host. The canonical text is the member’s: lowercase U-labels, a dotted quad, or RFC 5952 text unbracketed.host_typehas no facets beyond itsspec: a position that wants only names, or only addresses, is typed by the member,hostname,ipv4oripv6.media_typeis a type, a subtype and a map of parameters ([TSON-DATA] §5.5): an RFC 6838 media type, its parameters RFC 9110 §8.3.1’s. The type, subtype and parameter names compare without ASCII case, parameter order carries no meaning, a quoted parameter value equals its token form, and a parameter value keeps its case exceptcharset’s, which folds. The canonical text is the lowercase type, subtype and parameter names with thecharsetvalue folded, parameters sorted by name, each value a token where it is one and quoted otherwise, joined by;with no whitespace:text/html;charset=utf-8.media_type_typecarries three facets beside itsspec.allow_parametersis a permission (§5.7), withheld by default; withheld, a value carrying a parameter is a validation error. Net’smedia_typeadmits none, and since a refinement may withdraw a permission and never grant one, a position that takes aContent-Typevalue declares its own instance,content_type => !media_type_type { allow_parameters: true }.typeslists the admitted top-level types, andsuffixesRFC 6839’s structured-syntax suffixes, a suffixsadmitting a subtype ending+sand the subtypesitself; each list, where it is given, must admit the value, and a value outside one is a validation error. Both are member sets (§5.7) whose entries are media-type names and compare as the type and subtype do, without ASCII case — sosuffixes: [JSON]admitsapplication/ld+jsonandapplication/json, andtypes: [Text]refinestypes: [text image]: the family’s own value identity that §5.7’s subset rule compares by is the media type’s, not that of the lists’ element typetext. Each entry MUST be an RFC 6838restricted-name, a suffix holding no+, and a list MUST NOT be empty, an empty one admitting nothing; a body breaking either is a schema-load coherence error.timeanddatetimeare instants. RFC 3339 makes the offset mandatory, and the offset is a spelling: adatetimeis the instant on the UTC timeline and atimeis the time of day in UTC on[00:00:00, 24:00:00), so2026-01-01T10:00:00+01:00and2026-01-01T09:00:00Zare one value,-00:00(offset unknown, RFC 3339 §4.3) is the same instant asZ, and23:30:00-02:00is01:30:00Z. A leap second — second 60, which RFC 3339’s grammar admits — is neither a time of day on that interval nor an instant on that timeline, and both families’ parsers refuse it ([TSON-DATA] §5.4). Both families are totally ordered — the mandatory offset is what makes them so; XSD’s partial order is the cost of an optional one. A wall clock with no offset, and an appointment for which the zone is data, are other value spaces and not these types. TSON text preserves the offset as written; an encoding that stores the instant may drop it.durationis a signed exact decimal number of seconds, andperioda signed integer number of months. The split is what gives each a total order: a month has no fixed length beside a second that has one, so the two are two value spaces rather than one partially ordered one, and every ordered-bound facet on them is enforceable (§5.7).duration’s lexical form is RFC 3339 Appendix A’sdurationproduction with an optional leading-, a fraction on the seconds component only, any subset ofD/H/M/Sin order with at least one present, and noYor month-M;PnWstands alone, a week is 7 days and a day 86400 s, soPT90M,PT1H30MandP0DT5400Sare one value andPT0S,P0Dand-PT0Sare one value.period’s isPwith aYcomponent, anMcomponent, or both, and nothing else. Both ends ofduration’s space are fixed at a signed 64-bit count of nanoseconds:time-secfracis"." 1*9DIGIT— stated once, forduration,timeanddatetimealike, since no host runtime represents finer than a nanosecond and a value below that floor is not exactness anyone can use — and aduration’s magnitude does not exceed 2⁶³ − 1 ns (about 292 years), as a magnitude so that negating an admitted value yields an admitted one; a processor MUST represent every value in that range and MUST reject one outside it whether or not its own representation could hold it. A span of centuries is a calendar span and is aperiod; a quantity finer or wider than the range is anumberin the unit the schema names. The floor is lexical (a fraction reaches the value through one component and is capped at the token) and the ceiling is a value rule (the seconds count is summed fromD,H,MandS, soP400000Doverflows with no single component long enough to catch).precisionontime_type,datetime_typeandduration_typeis a constraint on the value, not on a spelling:precision: Nadmits a value that is a whole number of 10⁻ᴺ seconds —fraction_digitson the seconds count — soprecision: 3is millisecond resolution,precision: 0whole seconds, andprecision: 9the nanosecond grid; reading admits any spelling of an admitted value, trailing zeros included, so12:00:00.500is admitted underprecision: 1, and a text encoding writing the value writes at most N fractional digits,12:00:00.5. The atom is exact — nothing is truncated; a value off the grid is rejected.precisionmay not exceed 9, which falls out of the fraction production rather than being stated twice; a larger value is a schema-load coherence error. The temporal families carry no timezone facet: RFC 3339full-timeanddate-time, which the atoms’specpins, already make the offset mandatory, so a facet requiring it would be vacuous and one relaxing it would widen the atom against its own pin. All five temporal families state their bounds as §5.11 groups,( min | exclusive_min )?and( max | exclusive_max )?, so a half-open interval is spellable on a date exactly as on an integer.- The network families’
within/excludingpair MUST admit a value, and the rule is exact.ipv4_type,ipv6_type,cidr4_typeandcidr6_typeeach carrywithin(a value must lie inside some listed network; a network must be a subnet of one) andexcluding(an address must lie inside none; a network must not overlap any — overlap, not containment, so a wider value cannot smuggle an excluded block). A body whose two lists between them admit nothing is invalid at the schema level, in the voice the family already uses for a prefix outside its range:{ within: ["10.0.0.0/8"] excluding: ["10.0.0.0/8"] }is{ min: 10 max: 3 }in another spelling, and one an author reaches by editing. The check is decidable exactly — CIDR blocks are nested or disjoint and never partly overlap, so an exclusion meeting a permitted block either contains it or lies wholly inside one of its halves, and the walk terminates at the address width — and a resolver MUST implement it exactly rather than pairwise, since10.0.0.0/9and10.128.0.0/9cover10.0.0.0/8by counting and only the tiling case arises from a real edit. For a network family the prefix bounds participate: a value must be a subnet of awithinblock, so its prefix is at least that block’s, andmax_prefixcaps it from above;{ within: ["10.0.0.0/24"] excluding: ["10.0.0.5/32"] max_prefix: 24 }admits almost every address and no network at all, since the one block the prefix bounds leave overlaps the hole. The diagnostic SHOULD say which cause it found — an exclusion covering everything permitted, or the largest block the bounds leave — since the two want different edits.
5.6 Canonical Form and Desugaring
All type-definition bodies ultimately take a single canonical form:
!C { bindings }
where C names a constructor and bindings is a record literal filling the constructor’s fields. Every other form — inline type expressions, positional constructor forms, atom refinements — is syntactic sugar that desugars to this form during resolution; resolver output always records the fully expanded canonical form as the entry’s value.
Positional form. When a constructor has exactly one field whose name is unmarked — a field the document must write, whatever its modifier (§5.2) — the core value after !C may be that field’s value directly:
!enum [true false] → !enum { members: [true false] }
!array text → !array { element_type: text }
!choice [a b] → !choice { variants: [a b] }
Fields whose name carries ? do not count, whether or not they carry a value. The positional form is invalid when the type has zero, two, or more unmarked names — the resolver MUST reject such uses with a clear error. This restriction applies to the positional form only.
The positional form is general over schema-backed data. At any data position whose declared type is a record with exactly one unmarked-name field, a non-brace-delimited value fills that field directly; a braced value is always the explicit record (so record- and map-valued fills cannot go positional — the same exclusion as above). The two spellings denote the same value; which one canonical output uses is a per-type convention (§8.1 for type_ref, whose bare-token spelling is this rule). Type-definition bodies canonicalise the other way — toward the explicit bindings record, per the end state below — a deliberate pair of conventions: bodies uniform, references minimal. In a schema document the positional form keeps a bare token where the positional field is atom-typed — a type name at a type_ref slot, an enum member, a literal — and loses it where that field is product- or sum-typed: there the explicit record is the only spelling, since a bare token at a record- or choice-typed position in a schema document would otherwise be indistinguishable from a name, and a value written by name stands at a slot typed ref<T> and nowhere else (§4.2, §5.12); a bare token at a record-typed slot whose positional field is product- or sum-typed, or at one whose record has no positional form, is refused as no record. So fields: { p => text } fills record_field.type, an atom-typed positional field, in a schema document as in a data document (§8.1). A data document has no namespace to resolve a name in and reads every bare token positionally, whatever the positional field’s type, so a data document’s positional fills read as they always have. The name<args> application sugar is schema syntax only: at a type_ref-typed slot in data — a parents list in resolver output, say — page<order> is a parse error, < never being data syntax, and an application with arguments is written in type_ref’s explicit record form, { name: page arguments: [ { name: order } ] } (§8.1). At a type_kind-typed slot of a value body — a field of a namespace entry’s map (§4.1) — a braced application is refused outright: nothing lifts or mints in a value body, so the application would have no entry to be the name of. The spelling that gives such an application a name is the alias, order_page => page<order> (§8.2).
Record-bindings form. !C { ... } is the explicit form, valid for any constructor as long as the bindings cover every field whose name is unmarked. Empty bindings !C {} are valid whenever the constructor has no unmarked-name field.
Atom refinement. !I ^ { values } desugars by retargeting to the instance’s source constructor, with values merged over I’s own already-bound field values: a field named in values overrides I’s value for it, and every field I itself bound that values does not mention keeps I’s value — the atom counterpart of §5.7’s body-materialisation rule, under which inherited constraints survive a refinement that does not mention them:
!integer ^ { min: 0 max: 150 } → !integer_type { min: 0 max: 150 }
!text ^ { min_length: 1 } → !text_type { min_length: 1 }
For a fresh instance (integer => !integer_type {}) the merge is a no-op — every inherited field is absent — so the single-hop desugar above is the general rule’s degenerate case. Chained refinement is where the merge matters:
int8 => !integer ^ { size: { bits: 8 signed: true } }
bigNumber => !int8 ^ { min: -500 max: 5000 }
→ !integer_type { size: { bits: 8 signed: true } min: -500 max: 5000 }
bigNumber retargets to integer_type (an instance’s tag is always the base constructor) and keeps int8’s size binding, which nothing in its own body mentions — replacement rather than merge would silently discard the width and yield an unconstrained-width integer with bounds. Recognition is syntactic — the ^ declares the intent — and the resolver verifies it: the target MUST resolve to a non-constructor atom-family instance, and the retarget follows the instance’s tag. The result carries I’s constructor as its tag and I, with everything I IS-A, in chain.supertypes — [int8 integer] for bigNumber.
End state. After desugaring, every closed non-reference type-def body in resolver output is a binding record !C { bindings }, where C is the constructor the form applies — with the container constructors parameterless (§4.2), the nearest constructor in the source chain for every container closure is the container constructor itself: !array for every array form, !map for the map sugar, !set_type for an application of set<T> — and bindings supplies values for the vocabulary’s unmarked-name fields, plus the fields the application or refinement bound. Pins, defaults, and routes divide by where their values live: a pin or default whose value is concrete in the head’s own declaration (set_type’s ordered: = false) comes from the vocabulary and does not appear in the binding record, while a value routed by a user-template parameter (§5.10) is application-supplied information and does appear — so a validator reads any closed body with a one-hop lookup of the head, never substitution. A type slot is an ordinary required type_kind-typed field (§4.2), and the bound name appears in the binding record as a bare token (which, after lifting, is what every closed form holds; §5.3). Binding records are closed-world: they never contain parameter references. A definition whose parameters remain open is either a record template — a !record with parameters, read as the vocabulary, a parameter standing in a field’s type or value or in an application’s arguments (§5.10) — or a held constructor application, the same !C { … } text, unread against C’s vocabulary until materialisation substitutes the parameters away (§5.10, §8.2); so constructors carry !record vocabulary bodies, record templates carry records with parameters, every other open entry carries a held application, and every other non-reference entry carries a binding record. The surface abbreviations exist only in source text — except the one that names a reference: a use site names whatever entry the author named, an alias included, and output states the chain as written (§8.3).
Top-level constructor applications are constructions. A declaration whose body applies a constructor — the explicit form (lookup => !map { key_type: text value_type: integer }) or its sugar spelling (lookup => {text => integer}) — resolves as a construction: kind from the constructor’s family, the constructor as the value’s tag (§8.1), the binding record as the value, and no supertypes (construction transfers kind, not IS-A). Two such declarations are nominally distinct entries with structurally equal bodies. A declaration whose body is a fully-bound application of a non-constructor template resolves on the same footing: the declaration is the entry — the substituted binding record its value, closed in place, the canonical application recorded as @application on its key — and no parallel entry is minted beside it (§5.10, §8.2). Two such declarations are two entries, as two hand-written records with one field list are.
Named definitions required. The refinement form (!I ^ { values }) and constructor application (!C value) are valid only as the top-level body of a declaration — the inline prohibition of §5.2. A constrained atom must be introduced with its own declaration and referenced by name.
5.7 Refinement
Refinement copies an existing definition and tightens it — binding values, fixing defaults, restricting ranges — producing a new definition with its own identity that IS-A the source. It never changes the field set: no field is added and none removed. It is expressed with the ^ operator between a source type name and a record body:
config => { host: text port?: integer ~ 8080 debug: boolean }
production => config ^ { host?: = "prod.example.com" port?: = 9090 }
The operator carries the operation: ^ always means refine, preserving IS-A, at every rung of the ladder — record and map types (config ^ { ... }), constructors (array ^ { ... } in a meta-schema, §4.2), and atom instances (!integer ^ { ... }, §5.5). A source type name followed directly by a braced body, with no operator, is a parse error; the diagnostic SHOULD suggest ^ (refinement) or & (composition). A refinement head admits no removal clause: T ^ { ... } - { ... } is a parse error — an operator that promises IS-A cannot host the operation that revokes it (§5.9).
In a refinement, only existing fields may be modified: fields in the body MUST exist in the source definition, and adding fields is a resolver error. The guiding rule is that refinement can only restrict, never expand. The source name resolves in the type-name namespace; the same syntax refines local and imported types. A refinement whose source is a FINAL record is a resolver error (§5.2).
Field facts move along three orders, and never backwards. A field’s three questions (§5.2) each carry an order from least to most determined, and a restatement refines its source exactly when no question moves backwards:
- omission — nothing → required → injected: what a missing key yields, read off the declaration as
?without a value, an unmarked name, and?with a value; - voidable — true → false;
- role —
FREE→DEFAULT→FIXED, a default overridable until it is fixed.
So a: T → a?: T ~ v (required → injected, FREE → DEFAULT) is allowed; a?: T ~ v → a: T is refused, a default not being removable; a?: T ~ v → a?: T = v is allowed; a?: T? → a?: T (voidable true → false) and a?: T → a: T (nothing → required) are allowed, and each reverse is refused. The third of §5.2’s invariants shapes one move: a?: T? ~ v reaches a pin only as a?: T = v, voidable and role moving forward together, since a pin on a voidable type is refused. One consequence is worth stating: a marker written in a base, a: T = v, may be relaxed by a subtype to a?: T = v — required → injected — exactly as a: T may be relaxed to a?: T ~ v; a family that needs its marker written in every member restates it unmarked in each. Nothing stores the omission answer, and nothing needs to: the order is checked on the derived answer, which is optional and value read together (§8.1).
Restatement. Each field may be restated as itself. A restatement may change a default (defaults are overridable, §5.2) and MUST NOT change a pin — the identity case exists so a body may restate a fixed field without error, not so fixed values can be replaced. This is what forces a discriminated family’s arrangement (§5.2): the base cannot pin a selector, since no member could then pin its own.
Value tightening is per facet kind. “Refinement can only restrict” is checkable per constraint field, and each field’s rule follows its facet’s kind, declared once here for every family: an ordered bound (min, max, min_length, max_items, exclusive bounds, and kin) may move only inward — a lower bound may rise, an upper bound may fall, never the reverse — and requires its family’s value space to be totally ordered, which every family carrying one now is (§5.5; the temporal split is what made duration’s so); a step (multiple_of, wherever it appears) is strictly positive, tests the value’s magnitude with the sign ignored, and may tighten only to an integer multiple of the inherited step (15 under 5 tightens; 10 under 15 is an error); a permission facet (a boolean granting latitude: float_type’s allow_nan and its three siblings, the allow_relative and allow_fragment of uri_type and iri_type, hostname_type’s allow_idn, and media_type_type’s allow_parameters) may go from granted to withdrawn, never the reverse; a member set (an enum’s members, the members of the exact numeric tiers, the schemes of a URI or IRI family, the types and suffixes of a media-type family) may shrink to a subset, never grow or replace, compared by the family’s own value identity (§5.5, §7.4); a settable-once facet (text_type.pattern and text_type.members) may be set by a refinement where the source left it unset, restated verbatim, or left alone, and never changed — the two text facets occupy one logical position, both specifying the admitted value set, and pattern cannot be narrowed without a regular-language containment oracle the series decides nowhere, so giving the pair two rules by which spelling the author reached for would make the narrowing relation an artifact of notation; that text_type.members is settable-once where the numeric tiers’ member sets shrink is the one departure from the member-set rule, stated so that a reader does not infer it from a diagnostic; a facet fixed at construction is pinned where it is constructed — a refinement of the constructor may pin it, which is how enum states its name_type — and a refinement of an instance restates it verbatim or leaves it alone, and never sets or moves it, because moving it would change what a token means rather than narrow which values are admitted — text_type.normalization (turning a fold on in a refinement re-reads every token the source admitted); the profile facets of identifier_type, start, continue, start_add, continue_add, medial and exclude, for which settable-once would be unsound, since setting start_add where the source left it unset widens the profile (!identifier ^ { start_add: "_" } would admit _x without _x being an identifier); and an enum’s or a record’s name_type (§5.2, §7.4) — so that a different form, profile or name type is a fresh construction and never a refinement; a selector facet — a discriminant among members of a closed vocabulary (integer_type.size, complex_type.component, bytes_type.encoding, float_type.format) — may move under refinement only along the narrowing relation its members carry, which each family states: a width chain for size (int8 is inside int16, so !int8 ^ { size: { bits: 16 } } widens and fails, whatever the arithmetic direction); a partial order for component (INTEGER ⊂ NUMBER ⊂ RATIONAL and FLOAT32 ⊂ FLOAT64, the exact and approximate families incomparable since binary64 carries ±inf and NaN that no exact decimal represents, so !complex ^ { component: INTEGER } narrows and !complex ^ { component: FLOAT64 } is refused, a float complex being its own instance); and no relation at all for encoding, which a refinement may neither set nor change — an alphabet is a spelling of one value space, so a change would produce an IS-A carrying no narrowing, and another alphabet is another instance of the constructor (§5.5). A selector at the constructor’s default is not thereby free: the relation is over effective values, so an unwritten default and a written one refine identically, and there is no “set-from-default” permission apart from the relation. A fixed value follows §5.2’s rules for a pin, and a written value is compared against it as a value (§5.5). A refinement value violating its facet’s rule is a resolver error at schema load, in both record refinement and atom refinement (§5.5, §5.6). Constructor refinement in a meta-schema is the meta-level exception and MAY replace fixed values (§4.2).
Three ways to name a type after another, and what each buys. An author writing user_id => uuid and expecting a stock_id to be refused at a user_id position gets no protection, and nothing else tells them the other two forms exist:
| Spelling | Relation to uuid | !uuid at its position | a sibling’s tag |
|---|---|---|---|
user_id => uuid — reference (§8.3) | the same type, under another name | accepted | accepted |
user_id => !uuid ^ {} — refinement | IS-A uuid; usable at a uuid position | refused | refused |
user_id => !uuid_type {} — fresh instance (§5.5) | unrelated in either direction | refused | refused |
Only the first is a rename, and it is what identity dereferences (§8.2): box<user_id> and box<uuid> are one application. The empty refinement is legal — !uuid ^ {} narrows nothing and is the language’s one nominal-subtype spelling, which is why “a refinement tightens” is a description of what a refinement may do and not a MUST that a body change something.
Open modifiers. In a template body (§5.10) the value of a = or ~ modifier may be a parameter: bounded => <N> { attempts?: integer = N } routes, and retry_policy => <N> { attempts?: integer ~ N } defaults. The name’s mark is the author’s, exactly as beside a literal. The ? on a name says whether the key may be missing and says nothing about the value (§5.2), so it does not wait for the value to exist: a?: T ~ P is a default, a?: T = P an injected pin, and a: T = P a marker every document must write — and a: T ~ P is refused for the reason a: T ~ v is, a default on a key that is always written being a value nothing can reach. An earlier revision had a parametric modifier written on an unmarked name and the mark supplied when the template closed; the mark is now written where it is meant, as it is on every other field. What the declaration does not settle is the value: it arrives at application, and every application MUST bind every parameter (§5.10), so it is always supplied. While the entry is open the parameter rides the ordinary value slot of the record_field (§8.1) — no label distinguishes it from a literal: a token there is a parameter exactly when its text resolves into the enclosing record’s parameters (the shadowing rule, §5.10), in resolved output as in source. The role is the modifier’s, as beside a literal: = P is FIXED and ~ P DEFAULT where they are written, with the parameter in value, and materialisation substitutes the argument into value without changing a fact — so the materialised entry enforces what the literal form status?: int32 = 201 one line down would (a document writing status: 999 is a validation error, and an omitted status injects 201), and the bound value is checked against the field’s type by the value-conformance rule (§5.2). A FREE field never carries a value, open or closed. In a fresh record template a field pinned to a value parameter is a selector whatever its name’s mark (§5.10): one value per application is one pin per member, the base reading the field required with the pin dropped and each member FIXED to its argument, optional as the name was marked. In a record template body the modifier spellings are the only way to bind a field’s value to a parameter — min_items: S in a record body is a field declaration whose type is S, not a value binding; in a held constructor body (<S> !array { element_type: text min_items: S }) the slot is a value slot of array’s own vocabulary and the parameter stands in it directly (§5.10).
Elided type-refs. In a refinement or supertype-composition body, the type-ref in a field definition MAY be elided: when only a modifier is present (field: = value or field: ~ value), the field’s type is inherited from the source declaration and only the value state changes. A modifier-only entry is always a tightening — it names no type, so it cannot declare a new field — and a modifier-only entry whose name matches no inherited field is a resolver error. Restating the type-ref remains necessary when the tightening also narrows the field’s type. In a fresh record definition there is no inherited declaration to elide toward: every field MUST have an explicit type-ref, and the resolver MUST reject modifier-only entries there.
Refinement requires a vocabulary body (§4.3 states the rule for both operator families). The source of ^ — after following its reference chain (§8.3) — MUST be a definition whose body is a !record: a fresh or refined record, a composition, a constructor (in a meta-schema, §4.2), or an applied record template (a record template named bare at an operand is a resolver error, §5.10). A definition whose body is a binding record — a top-level constructor application (§5.6), or an alias resolving to one — is finished: its bindings are set, and ^ on it is a resolver error; a choice is likewise inadmissible (§4.3). A record instantiation is not finished: it closes to a !record with fields, and whether its source was written as box<text> inline or as a name for that application is provenance, which never gates an operator (§4.3). There is no refinement of an application head: {text => text} ^ { min_items: 1 } has no head to refine, and the use case is the size specifier on the sugar itself ({text => text; 1..}, §5.3). Record refinement chains remain open because record refinement re-emits a !record body; a declared construction (lookup => {text => integer}) is terminal — authors wanting a narrower relative write the bounds on the form (strict_lookup => {text => integer; 1..}). The refined-def grammar’s optional <type-args> head serves user-template heads only (§5.10). Atom refinement (!I ^, §5.5) is a distinct form with its own rule and is unaffected.
A refined record definition remains a definition: it can be refined further or instantiated. A refinement that narrows a field’s type to void — a?: void?, or a: void? at a restated group member (§5.11) — forbids the field’s value in the refined type while keeping the field in the contract, the IS-A-preserving counterpart of removal (§5.9); void refines every type at a voidable position, the value set {_} being a subset of every voidable position’s. Individual map entries cannot be refined because map keys are data, not definition fields.
Body materialisation. The refined body re-emits the complete inherited field set in source order; each field carries either its inherited state and value or the tightened ones. The materialised body is self-describing — consumers of resolver output do not walk the supertype chain to learn the field set. Inherited defaults and pins appear with their values even when the refinement did not refer to them.
Resolution. The refined entry’s chain.supertypes records the source and the IS-A chain through it (§8.1), its value carries the source’s constructor as its tag, and tightened fields appear in the materialised body. A refinement keeps its source’s name_type (§5.2).
5.8 Supertype Composition
Supertype composition (&) is a construction tool: it combines one or more parent types with new fields into a new definition, declaring an IS-A relationship with each listed supertype. New fields are permitted; existing fields may be tightened.
address => { street: text city: text postcode: text }
contact => { name: text email: text }
customer => address & contact & { loyalty_tier: text }
Supertype field conflicts. The supertypes MUST contribute disjoint field sets — a field name appearing in more than one supertype path is a resolver error, including diamond cases where the field traces to the same originating type through both paths. Two names are one field under the composed record’s name type (§5.2), and supertypes with different name types do not compose: json_record & address, where address states nothing and so is field_name’s, is a resolver error, and the record to compose is one declared over the same type.
The trailing body. The trailing & { ... } body is optional (customer => address & contact is valid). Body fields that match an inherited field are tightening entries and follow the refinement rules of §5.7 (including elided type-refs); body fields that match no inherited field are new fields.
Field ordering. Supertypes contribute fields in left-to-right order as listed; each supertype’s fields appear in their declared order. Tightening entries replace inherited fields in place; new fields are appended after all inherited fields.
Constructorness is inherited through composition. Composition propagates the supertype chain, so an entry composing with something IS-A top is IS-A top and is therefore a constructor (§4.2): iri_type => text_type & atom_specification & { ... } is a constructor because text_type is. A record mixin such as atom_specification lends vocabulary without conferring the level, and a composition reaching no base kind — box => <T> base & { value: T } over a plain record base — is an ordinary template, not a constructor, whatever its author intended. Placement (§2.2.2) is what keeps the level inside the meta layer.
atom_specification => { spec: iri }
iri_type => text_type & atom_specification & {
spec?: = "https://www.rfc-editor.org/rfc/rfc3987"
schemes?: scheme_set
allow_relative?: boolean ~ true
allow_fragment?: boolean ~ true
normalization?: = NONE
}
iri_type is a constructor, IS-A text_type and atom_specification directly. Its fields, in order: text_type’s six constraint fields, the last of them — normalization — tightened in place from a default to the pin NONE; atom_specification’s spec, tightened in place to an injected pin via an elided-type modifier; and the three new fields. Composition onto a FINAL record is a resolver error (§5.2), and a supertype that is ABSTRACT confers nothing on the composed entry, which states its own extension and is OPEN unless it says otherwise. A restated field that is a group member stays a member, in its own option: a ? on its name is the in-option mark, which a restatement may drop and may not add (§5.11). A declared application of a template whose body is not a record (string_triple => vector<text, 3>) closes to a binding record and has no vocabulary body to compose with; a record template’s declared application is a !record, which composes like any other (§4.3, §5.7).
A restated field’s annotations are the restatement’s own followed by the inherited ones. §6 puts an annotation on a record_field and §8.1 preserves it; composition copies inherited fields whole and §5.7 lets a body entry restate one, and the two paths must agree. The rule: a restated field — in a composition body or a refinement body, including §5.7’s modifier-only entry — carries the restatement’s own annotations, in source order, followed by the inherited field’s, in source order. Nothing is dropped and no name is treated as a key, because [TSON-DATA] §3.1 already makes an annotation name repeatable on one value and annotations are a list; and the restatement leads because order is already the precedence mechanism — every first-occurrence lookup reads it that way, so the nearer declaration wins at every such site without any of them knowing about composition. A restatement adds and never removes: a subtype rewriting an inherited @doc leaves the field carrying both, which is what “annotations are not removable” buys. Per-name replacement would need a cardinality on the declared annotation, which v1 does not have. The rule is what makes a count along a composed chain checkable, and it has read-side force wherever an annotation directs reading: a field that tightens presence and writes no annotation of its own inherits its source’s, rather than silently losing it. It also fixes which fields carry a selector over a chain (§5.2): record.discriminators is the base’s statement and the member restates the field pinned, so no per-field mark travels and none has to be cleared.
Parameterized references. Both & composition and ^ refinement operate on type-refs, which may carry arguments. A refinement of a parameterized type must re-declare its open parameters in its own <> slot (§5.10); composing with a parameterized supertype works the same way: ok => <T> result<T> & { note: text }. An operand may carry arguments, which may name the declaration’s parameters; an operand that is itself a parameter is a resolver error — <T> T & { name: text } and <T> T ^ { … } are refused under the shadowing rule, even where the schema declares an entry named T, since a parameter has no field set to flatten until the template is applied (§5.10). A record template’s chain.parents is a list of type_ref, not type_name, because a parent may be written as an application: while the composing declaration is open, result<T> stands there with its arguments, and is substituted and closed with every other reference the record holds (§5.10) — so ok<text> reaches an IS-A edge to result<text> and not to result<int32>, and a head name, which is the name of no type a position is ever typed by and holds of every instantiation at once, is never asked to carry the distinction. The closed chain supertypes records names, being computed once every parent is a type; the closed parent’s name and its own chain are folded into it when the instantiation closes. A closed type keeps no parents and carries no arguments in its chain, so a schema with no open parent produces a chain of bare names. The edge a closed application mints is to the entry its own arguments name, so §7.2 needs no second check and no comparison of bodies. An application standing at a composition operand is subsumed where it stands: its arguments are the member’s own contribution (dog => pet<"dog", dog_type> & { … } is the hand-written dog => pet & { pet_type?: = "dog" pet: dog_type } with one IS-A edge to pet), and it mints no entry (§5.10). A record template named without arguments at an operand is a resolver error: a position may name the base, but an operand is flattened, and a template has fields only once its parameters are bound (§5.10).
Resolution. The composed entry’s chain.supertypes records the listed parents and, transitively, their own chains (§8.1), complete to a fixed point; inherited fields are copied into the body’s fields map in the order above, so the body is flat and a consumer never repeats the merge. Which operands were written is not recorded for a closed record: direct parents are the chain’s minimal members, and operand order is in the flattened fields.
5.9 Subtraction
Subtraction removes fields from a construction and deliberately breaks IS-A — the resulting type is no longer source-compatible. Taxonomically it is a construction operation, not a refinement: like composition, it computes a new field set; unlike composition, it disclaims the contract. It is expressed as a trailing removal clause on a construction head:
removal-set = "-" ws "{" ws field-name *( separator field-name ) ws "}"
account => { name: text email: text password: text }
account_public => account - { password }
account_view => account & { email?: text ~ "n/a" } - { password }
staff_public => account & user & { badge: text } - { password ssn }
The clause is head-level: a reader of the declaration line knows the contract is broken without scanning the body. It attaches only to construction heads — a bare source, or an & chain with or without a trailing body; a refinement head admits no removal clause (§5.7, §12.1).
Rules:
- Resolution order. Supertypes merge first — §5.8’s disjointness rule fires here unchanged, so subtraction cannot be used to resolve diamond conflicts — the body applies second, and removals apply last.
- Removing a nonexistent field is a resolver error — symmetric with refinement’s existing-fields-only rule.
- Source path does not restrict removal. Removal operates on the merged field set, not on field provenance; since IS-A is already broken, there is no contract to violate.
- The body and the removal set are disjoint. A removal naming a field the body itself introduces is a resolver error — adding and removing a field in one declaration is incoherent — and a body entry tightening a removed field is a resolver error. Body entries tightening the remaining inherited fields follow §5.7’s rules.
- Removal and narrowing to
voidare distinct.- { field }removes the field from the contract and breaks IS-A;field?: void?(§5.2, §5.7) keeps the field in the contract, forbids its value, and preserves IS-A. - Empty subtraction does not exist, by grammar: the removal set requires at least one name, and
source & {}is composition-with-no-additions and preserves IS-A. - Groups. A removal may name a field-group member; the member leaves its option, an emptied option leaves the group, and a group the declaration rules would then refuse is rewritten as the spelling it equals (§5.11).
- Constructors and parameters. A subtracted construction in a meta-schema whose head is a constructor is not one — it has no supertypes, so it is IS-A nothing and may not be applied — and a parameterised subtraction declares a record template whose field set is the merged set minus the removals, with no parents.
- FINAL does not reach subtraction. A removal off a FINAL record is admissible: FINAL constrains the IS-A set (§5.2), which subtraction never joins, so the result is an unrelated type that happens to share a field list. Composition and refinement, which both mint an IS-A edge, are the two that FINAL refuses.
Resolution. A subtracted record is a record with no supertypes: chain.supertypes is empty — the subtracted type is not source-compatible — and nothing records what it was subtracted from, provenance being what was written and the source document having it (§4.1). A removed field’s annotations are lost with the field. For ingest (§8.1), the empty supertypes is read as stated rather than inferred by diffing field sets against the parents.
5.10 Templates and Parameters
A type definition may declare parameters using <> immediately after =>. Parameters are local names referenced in the body and bound to concrete arguments when the definition is referenced with <args>. Each parameter has a type, derived from how the body uses it, and may be written with one — <T: text, N: int8> (below). A definition with parameters is a template — it cannot be instantiated directly, and references to it MUST supply arguments for all its parameters:
container => <T> { items: [T] }
pair => <T, U> { first: T second: U }
vector => <T, N> !array { element_type: T min_items: N max_items: N }
matrix => <T, M, N> [[T; N]; M]
container<text>, vector<pixel, 1920>, and matrix<pixel, 1080, 1920> are concrete types; bare container or vector references are resolver errors. A template application’s head resolves through the type-name namespace only (§3.3.1); parameters and templates are an authoring-side feature exclusively — resolved output governing data carries none of it (§1.3, §8.2).
Template forms, and the two kinds of template. A template declaration’s body may be a record (container), a container or sugar form (matrix), a reference (a pure application body, below), or — the fourth form — an instance: a constructor application whose bindings mention parameters (vector above). The instance form is the route to a constructor with no sugar of its own (bounded_set => <N> !set_type { element_type: text min_items: N }) and the target the sugar desugars into; for the sugared constructors the compact spelling exists alongside it (vector could equally be written <T, N> [T; N]). The forms divide in two, and the division is the one C++ (alias template / class template), Haskell (type / data) and ML (abbreviation / datatype) all make. A record template — a fresh record, a composition or a refinement whose body is a !record — is a family: a type a position may name, whose open form is a base and whose applications are its members (below). Every other template — over a container, an atom constructor, a choice, a scoped, or a reference — is an abbreviation: its open form denotes nothing, and an application of it is its expansion (§8.2). The kernel states the split: a record template is a record with parameters, an ordinary record body read as the vocabulary, with a parameter standing only in the places below; every other open entry is a template — template => top & { parameters: [template_param] template: text } — the constructor application as written, held as text and unread until materialisation substitutes its parameters away. One substitution rule serves both (below).
Held bodies, and where a parameter stands. A template’s body is not a typed quotation of the constructor’s vocabulary, and it is not read against that vocabulary while a parameter remains: a sugar form is desugared per §5.3’s table and held; an explicit <T, N> !array { … } is held as written. Because a held body is not classified by slot kind, a parameter needs no channel of its own: a parameter in a value slot (min_items: N), in a type slot (element_type: T), inside a collection (variants: [T error], members: [a b M]), or at any depth is a token like any other, and collection-valued slots are parameterizable — result => <T> ( T | error ), the sum-typed result envelope, resolves and closes to an ordinary choice body. A record template is not held: <T> { x: T } is the record !record { fields: { x => !record_field { type: T } } parameters: [{ name: T type: type_kind }] }, read as the vocabulary, and a composition or refinement template is resolved against its namespace first — a tightening entry states a modifier and no type-ref, so it is not a record_field until the inherited field supplies one — and the flattened record is the entry, its chain.parents the parents as written (§5.8). In a record template a parameter stands in three places and no other: a field’s type (v: T), a field’s default or fixed value (w?: int32 ~ N, type: text = N), or an argument of an application (items: box<T>, result<T> & { … } — sugar inside a field, [T; N], lifts to an application and so is the third). A parameter anywhere else in a record body — as a field name, a group member, record_field.optional, voidable or role, record.extension, name_type or discriminators — is a resolver error at the declaration naming the slot, since the family base (below) would then have no shape its members share. A parameter is never an operand of & or ^ (§5.8). The cost of the one-rule substitution is shadowing’s usual one: inside a template, a literal spelled like a live parameter is unreachable, so an author renames the parameter.
Substitution is one rule at any depth. Binding a template’s parameters rewrites the body — the held text, or the record’s fields and parents — by replacing every token whose text resolves into the entry’s parameter list with the corresponding argument — a reference argument as a type_ref (a bare name, or the record form when the argument is itself an application), a literal argument as written — and nothing else is classified. An argument is read as its parameter’s recorded type (below) and then substituted: where that type is type_kind it must resolve as a type, where it is a value type it must be a value of it, and in an enum’s member list it is a member — e => <M> !enum [a b M] applied as e<c> admits c because M is recorded as a text value, not because of how c is spelled. When every parameter of an entry closes, a held body is read, once, against the constructor’s own vocabulary — materialisation binds against vocabulary exactly once, at the only moment binding is decidable — and a record template’s fields take their arguments in place; the result is an ordinary closed body (§8.2).
One spelling. A held body is carried as text and compared as the parsed form — never as the text, and never as bound values — and an open synthetic’s identity and content-derived name are computed over that parsed form with the parameters renamed positionally (§8.2), so the open form MUST have one spelling however many phases produce it: no-argument type names as bare tokens, type_argument and type_ref records only where an application is open, and parameter tokens unquoted — a producer that quotes every token has written a body that references no parameters at all. Whitespace is free: comparing the parsed form is what keeps “one spelling” from becoming a canonical-whitespace requirement the series has no emitter contract to support. Two of the template shapes cannot be normalised syntactically (composition and refinement need a namespace to flatten against), so “one phase” is not achievable and “one spelling” is the requirement.
A parameter’s type is derived from the positions it stands in, and recorded. Every parameter of an open entry carries the type an argument for it is read as — the kernel’s template_param => { name: param_name type: type_kind bound?: type_kind } (§8.1) — and the type is derived, never required of the author: it is the declared type of the slot the parameter stands in, in the vocabulary of the constructor the body applies. No parameter lacks one, since a parameter the body never references is refused (below) and every position has a declared type:
- A type slot — a field declared
type_kind, such asarray.element_type,map.key_typeorrecord_field.type— givestype_kind. - A value slot gives the slot’s declared type:
min_items: Ngivesnon_negative_integer, and an enum’s member list givestext. - A routed default or fixed value (
w?: int32 ~ N) gives the field’s declared type,int32— §5.2’s value conformance, read off the field rather than offrecord_field.value, whose own type isvalueand says nothing. Where the field is itself typed by a parameter,box => <T, N> { w?: T ~ N }, the derived type is that parameter:N’stypeisT. That is the one scoping rule a parameter list needs: atypemay name a parameter of the same template declared before it, and nothing else in the schema’s namespaces under that name; at an application the argument forNis then read as the typeT’s argument names. - An argument to another template gives that template’s recorded type for the position, so derivation is a fixed point across templates that apply each other. A parameter the fixed point leaves undetermined is given
type_kind: a reference template’s body is its application (reference.targetis atype_ref, §8.1), soloop => <T> loop<T>has no slot for a concrete use and itsTis a type parameter — what is wrong with that declaration is that it applies itself forever and denotes no type, and that is what its diagnostic names. - A payload in §5.6’s positional form is walked as the one field it binds:
names => <M> !enum [a b M]putsMinenum.membersexactly as!enum { members: [a b M] }does. The kernel’s ownkind => <T> !kind_type { of: T }recordsTastype_name,of’s declared type, and is a value parameter: its argument is a constructor’s name (§4.2).
The kind follows from the type. A parameter whose type is type_kind is a type parameter; any other is a value parameter. Nothing records the kind apart from the type, since a separate field would state one fact twice. Value parameters bind scalars only — numbers, booleans, identifiers, and quoted strings; type parameters bind references, simple or compound. There is no parameter arithmetic: every value argument is used as given.
Several uses must agree. A parameter’s type is the use type that IS-A every other; where the use types are not ordered by IS-A the declaration is a resolver error. <N> !array { min_items: N max_items: N } derives non_negative_integer twice and is fine; both => <T> { a: T b?: int32 ~ T } derives type_kind and int32, which no argument could satisfy, and is refused — a parameter standing in both kinds of position is this rule’s commonest case and needs none of its own. The rule follows IS-A edges only and proves nothing about value sets: two sibling refinements such as int8 and int32 are refused together though a small integer satisfies both, and the author declares the narrower type by name and uses it in both places. That is the price of a total, two-valued rule checked once at the declaration, where a list of use types checked one by one would move the verdict to every application. A declared parameter the body never references is a resolver error — <T> !array { element_type: text } is a mistake, not a degenerate template, and the rule holds for every template form (box => <T> { v: text } likewise).
A declaration may narrow what it derives. A parameter may be written with a type after its name — <T: text, N: int8>, one production for every template form (§12.1) — and the written type is read by the kind the positions give, never the reverse, so an annotation cannot contradict the uses:
- On a value parameter it narrows
type.<N: int8> { w?: integer ~ N }recordstype: int8, and the written type MUST be IS-A the type the uses derive, or the declaration is a resolver error:<N: text> !array { element_type: text min_items: N }is refused,textnot being anon_negative_integer. - On a type parameter it is
bound.boxed => <T: text> { a: T }recordstype: type_kind bound: text, and an argument MUST nametextor a type that IS-A it, judged after following its reference chain (§8.3);boxed<int32>is refused at the application.boundis present only wheretypeistype_kind, and is its own field because a type parameter carries two facts — its argument is a reference, and the reference is restricted — where narrowingtypetotextwould lose the first and leave<T: text> { a: T }and<M: text> !enum [a b M]recording alike. - An unwritten parameter keeps what it derives, so a template that writes no types resolves as it always did.
A bound is inherited through another template’s argument list, as a type is: rebox => <T> boxed<T> records its T with bound: text, so rebox<int32> is refused at its own application rather than deep inside boxed. Several inherited bounds agree by the rule for several types, and a written bound MUST be IS-A every inherited one — <T: currency_code> boxed<T> narrows it where currency_code refines text, and <T: int32> boxed<T> is refused at the declaration. A bound follows IS-A edges only: date has a text form without being text, and a bound that followed a type’s form or its discrimination class would admit it. A bound names a type, and only a type: it resolves in the schema’s type-name namespace (§3.3.2), so a name found only among the governing meta’s constructors — <T: text_type> — admits nothing and is refused at the declaration. Construction transfers kind and no supertypes (§5.5), so a nominal bound cannot say “any text-valued atom”: identifier, a profile a schema constructs, and stock_code => !text_type {} are IS-A nothing and fail <T: text>. That separation is right for values and is a limit for bounds, recorded among the boundaries below.
A value conforms to where it is defined, and a template’s form follows from it. A value parameter’s type is read where the slot it binds into is defined: a constructor’s own slot (array.min_items, scoped.schemas’ keys) is the meta’s, so the value is a meta value; a field the schema types (w?: integer ~ N) is the schema’s. A type parameter names a local type — the argument is a schema type captured into a type_kind slot, as set<T> captures one into element_type — so its bound is the schema’s whatever slot it lands in. Since a constructor template’s value parameters can only bind into the meta’s own slots, and a record template’s only into fields the schema types, the rule reads off the template’s form. A template whose held body applies a constructor of the governing meta other than record — vec => <N> !array { element_type: text min_items: N }, core’s extern_type => <S, T> !scoped { … schemas: { S => [T] } } — binds its values into the meta’s own structure, so each value parameter’s type is a type of the structure namespace (§3.3.1): N is the meta’s non_negative_integer and T the kernel’s type_name, read, recorded and checked there. A written type on such a parameter names a type there too, so <N: non_negative_integer> narrows within the meta’s vocabulary and <N: int8> is a resolver error, int8 not being in it; and a schema’s own entry under a structure name is a different type that never stands in for it — a schema declaring non_negative_integer => !integer ^ { min: 1 } still has vec<0> as a valid application, min_items: 0 being what the constructor admits. A record template’s fields are typed by the schema, so its parameters — a routed default’s type and a written type alike — read in the schema’s own namespace: <N: int8> { w?: integer ~ N } records int8. A template whose body applies another template takes that template’s form, and the entry’s value says which form it is — a !record with parameters, or a !template whose text’s first token names the constructor — so a consumer of resolved output reads a recorded type without the held body (§1.3). Uses of one parameter can still come from both namespaces — a record template passing N to vec and routing it into a field — and entries under one name in the two have no IS-A edge between them. Two same-named entries with the same resolved body are one type wherever this section compares parameter types — a written type against a derived or inherited one, the several uses of one parameter against each other, and an argument against a parameter’s type or bound — and for nothing else: everywhere else a declared entry’s identity is its name (§8.2), and a core sibling remains a distinct type from its kernel original (§3.3.5).
An application is checked at the call site. Each argument is read as its parameter’s type before anything is substituted, against the parameter list alone: vector<pixel, 1920> reads pixel as a type reference and 1920 as a non_negative_integer, and vector<pixel, "two"> is refused at the argument, not inside the body. A type argument must name a type that IS-A the parameter’s bound; a value argument must parse as the parameter’s type, or as the type an earlier parameter’s argument names. An argument the check cannot judge on its own — an application, a name that does not yet resolve — is left to the substituted body, whose verdict is the same one. The check is what lets a consumer of resolved output — an importing schema, a second resolver comparing output, a host binder generating a generic type — verify an application from the entry, without the held text (§1.3).
Scoping and shadowing. Parameters are local to the declaring definition; they do not escape and do not compose across &, and two definitions can independently use the same parameter name. Two positions that must agree share a parameter (homogeneous_pair => <T> { first: T second: T }) — sharing the name is the link. Within a held body, parameter names take precedence over the schema namespace, and a parameter that shadows a schema type is a resolver error — renaming a parameter is free, and silent capture of a type the body also means to use is the confusability hazard [TSON-DATA] §8.2 exists for. A template’s parameter list is not one of §11.4’s hygiene scopes: one author writes it whole on one line, and a deliberate confusable pair there is not a population the list should grow for.
Declaration-time checking splits by what the parameter list obscures, and nothing more. Two questions are answered at the declaration from the held body’s own field names, needing no stand-in values and so unable to fabricate a verdict: that each binding key names a field the constructor declares (<T> !array { elemen_type: T } is a typo, not a template), and that every unmarked-name field is bound. The typing of every concrete binding is checked there too (min_items: "two" is not an integer whatever T becomes), as is the unreferenced-parameter rule, from the tokens the held body names, and so are the parameter types themselves: the derivation, the agreement of several uses, and a written type or bound against what the uses derive (above). Everything value-shaped that a parameter obscures waits for materialisation, where the whole body binds through the constructor’s own reader (§8.2). An unapplied template is checked no further and receives no verdict: a resolver MUST NOT check it by substituting stand-in values, since that manufactures false errors on exactly the slots the mechanism exists for (<N> !integer_type { min: N max: 3 } is correct for every argument anyone passes and fails under a stand-in of 10). Deferring everything would make the template the one form validated at use rather than at declaration, shipping broken templates to fail at their first user’s application site; deferring only what the parameters obscure is the line.
Where a deferred diagnostic lands. A materialisation diagnostic MUST be located at the declaration whose text wrote the offending name, with the application as context — deferred checking is survivable only if the author is sent to the line they can edit. The rule is over the name, not over the template: m => <T> !map { key_type: T value_type: no_such_type }, a held body whose names are read when it closes, applied by holder belongs to m, which wrote the name — a record template is read at its declaration, so a name it writes is reported there — while box => <T> { v: T } applied as box<3> belongs to holder, which wrote the 3 — and is found there by the call-site check, before substitution. One defect earns one diagnostic, however many declarations apply the template.
Sugar inside templates. A sugar form inside a template body desugars to the same construction it would outside one — §5.3’s table unchanged — and lifts to its own synthetic entry, open if the form mentions a parameter (§5.3). Nesting needs no special member: an inner form lifts to its own open synthetic, and the outer body holds an ordinary open application of it. At its smallest, box => <T> { a: [T] } desugars as if written
array_t => <T> !array { element_type: T }
box => !record { fields: { a => !record_field { type: { name: array_t arguments: [ { name: T } ] } } } parameters: [{ name: T type: type_kind }] }
with array_t an internal name, held, and box a record with parameters. The application in box’s field rides type_ref.arguments — the channel that means “an application” and nothing else — at a type_kind slot that, while the record is open, holds an application a parameter leaves unresolved. Applications inside a held body close before its entry is named: desugar lifts innermost-first, so a form it writes already names the entry its inner form became, and a form closed at materialisation MUST agree — [[pixel; 3]; 3] written out and grid<pixel, 3> closed land on one entry for one type. Because a closed lift hashes its binding record at desugar, before inner applications are rewritten, while an open lift hashes the closed record at materialisation, the two channels can produce two candidate entries for one type ([box<text>] written directly, and [box<T>] closed with T := text); §8.2’s merge pass, which re-derives every synthetic’s identity from its resolved record after Pass 2, is therefore required, not an optimisation.
A record template is a family base, and may be named at a type position. “A template is not a type” is right for vector<T, N> and wrong for a base, and the difference is elimination: a position typed vector would have to read a value against an element type nothing has fixed, leaving a reader to infer arguments from the payload — ill-defined, since [] determines nothing and two instantiations can accept one document. None of that reaches a base, because no value is ever read against it: a value there is a value of some member, selected by a tag or by reading the selector fields, and every member is a closed entry with every argument fixed. So a record template — a fresh record, a composition, or a refinement whose body is a !record — may stand at a field, element, variant or argument position as a base, and the record template entry is the base: a type, a member of kind<record> (§4.2), what the position names, what subtypes indexes, what its own parents’ subtypes list, and what a host sealed type binds to, with no second entry minted beside it. Its members are the applications a declaration names — bt => box<int32>, or a composition dog => pet<"dog", dog_type> & { … } — and only those. An application written at a use site (k: box<text>) is a type, read structurally where it is written, and no member: no declaration names it, so it is never a candidate at a position typed by the template and never needs a name a document cannot write (§8.2). This is §5.2’s “a family member is declared”, kept by leaving the minted application out rather than by refusing the schema; an author who wants it in the family declares it. The base’s extension is ABSTRACT by construction — never OPEN, nothing being able to write a value whose type is the template rather than one of its applications, and never FINAL, its applications being subtypes by construction — and is not stated: the record’s own extension is each application’s, OPEN unless the template says otherwise, which is how a second-level base is spelled, result => abstract <T> { payload: T } making result<text> abstract over ok<text> and err<text>; final written on a template is a resolver error. Its discriminators are the base’s selector list: whatever selectors survive erasure of the parameters — a field written =?, or, in a fresh record template, a field pinned to a value parameter, whatever its name’s mark, one value per application being one pin per member (pet => <N, T> { type?: text = N pet: T }); the base reads such a selector required with the pin dropped, and each member is FIXED to its argument, optional as the name was marked (§5.7), exactly as a hand-written member restating pet_type?: = "dog" is. A refinement or composition template derives none: <T> array ^ { element_type: = T } pins a constructor’s own facet and states nothing about a family. The one condition is on a selector: its declared type MUST mention none of the template’s parameters, at any depth — not as its head and not in an argument, a value parameter as much as a type one — because a decoder parses the selectors before it knows the member and their pins are exactly what the parameters supply; type: text = N satisfies it; over capped => <M> !integer_type { max: M }, kind: capped<9> = N satisfies it (a parameter-free application is the entry it expands to, in the base as in every member), and badge => <M, N> { kind: capped<M> = N name: text } is refused at the template, since badge<9, 3> and badge<5, 4> would give the selector two types. A reference template (id => <T> T, a partial application), a container template (arr => <T> [T]), a constructor-application template (vector) and an atom template (no form, §12.1) are not bases and are refused at a type position: a reference is indirection with no contract for a base to hold, abstractness has nowhere to live on a non-record body, and vector<float32, 3> and vector<float32, 4> erase to one host type no reader could place a value within. What a base carries is what survives erasure — a value parameter’s pin dropped and the field kept, a field whose type mentions a type parameter omitted — so a base’s field list may be smaller than its members’ and may be empty; member dispatch alone has a floor, at least one selector, and a base claiming dispatch with none is refused. The base’s facts do not reach a member. The base’s ABSTRACT is the base’s alone, a member’s extension being the record’s own; and a declared member states no discriminators — closing pins each selector a parameter pins to its argument, and a =? selector is pinned by no argument, so closing does not pin it. A resolved-output consumer recognises the base by its parameters and dispatches on its discriminators, and never reads a field that carries a parameter (§1.3), which is why the selectors are stated on the record; the base is ABSTRACT by construction, and the record’s extension is each application’s. A record template named bare at an operand of & or ^ is a resolver error: a position may name the base, but an operand is flattened, and a template has fields only once its parameters are bound (§5.8).
Templates are not directly instantiable. An entry with a non-empty parameter list cannot validate data: a template with any parameter is a resolver error as a data annotation, without exception (§7.2), and a family base is reached by a position naming it, never by a tag naming it — the tag names a member.
Open bodies in output. While parameters remain unbound, an entry’s resolved form is one of two values (§8.1). A record template is !record { fields: { … } parameters: [{ name: T type: type_kind } …] } with its parameters in place — a field’s type or value holding a parameter token, or a type_ref application an argument of which names one — its discriminators stated as the family’s, its extension each application’s, and its chain.parents the parents as written. Every other open entry is an instance of the kernel’s template constructor — !template { parameters: [{ name: T type: type_kind } { name: N type: non_negative_integer }] template: "!array { element_type: T min_items: N max_items: N }" } — carrying the parameters, each with its type, and, as text, the held application under the one-spelling rule: a container as !array/!map/!tuple/!choice, an atom constructor as !integer_type, an alias as !reference with its partial application in target (§8.1, §8.3). Text is what “held” means: a parameter stands wherever a token stands — min_items: N as readily as element_type: T — so a body carrying one is not a value of any constructor’s own record shape until it closes, and writing it as though it were leaves the two halves disagreeing, a value parameter refusing to read at all and a type parameter reading as a reference to a type nobody declared; a record body has no such slot once a parameter stands in three places only, which is why it is not held. The template constructor composes with top directly and carries no chain, extension or discriminators: it is not a type, its open form denotes nothing, and an application of it is its expansion, with whatever chain the expansion’s own ^ or & gives it. Openness is one question with one answer — an entry has parameters, as a !record or a !template, or it is closed. A parameter reference appears only inside an open entry’s own body: a held text, or a record template’s fields and parents. Closed bodies are binding records and never contain parameter references (§5.6).
Materialisation. When every parameter closes, the resolver materialises the result (§8.2): materialisation is total and closes innermost-out — each open synthetic the application reaches becomes closed as its bindings go concrete, a held body whose last parameter is substituted being read against its constructor’s vocabulary. Given grid => <T, N> { x: [[T; 1..N]; 2..N] }, closing grid<pixel, 3> yields
c1 => !array { element_type: pixel min_items: 1 max_items: 3 }
c2 => !array { element_type: c1 min_items: 2 max_items: 3 }
c3 => !record { fields: { x => !record_field { type: c2 } } }
c1 and c2 are closed synthetics keyed on body structure — c1 is the same entry an independently written [pixel; 1..3] produces anywhere in the schema — and c3 is the expansion — a closed synthetic keyed on its content where a use site wrote the application; where a declaration did, the declaring entry itself, which carries @application:{ name: grid arguments: [{name: pixel} {value: 3}] } on its key (§8.2). Deferred value checks land here: substituting N := "two" yields min_items: "two", and that is the error, located per the rule above. A recursive reference inside a nested form denotes, at materialisation, the entry under construction — the open synthetic’s binding references that entry by its internal name before the entry is complete.
A declaration naming a fully-bound application is that application’s entry. string_triple => vector<text, 3> resolves to !array { element_type: text min_items: 3 max_items: 3 } under the author’s name, with @application:{ name: vector arguments: [ { name: text } { value: 3 } ] } on its key — the closed instantiation, in place, with no minted vector_text_3_… beside it and no !reference hop: the axis is construction against reference, and an application is a parameterised reference syntactically and a construction semantically, filed by what it is (§8.2). A use-site application resolves to a declaration that owns it where one exists, and to its expansion otherwise — the closed synthetic the substituted form lands on, which any other spelling of the same form lands on too, minting no instantiation entry (§8.2). Minting is keyed on naming, not on the mark: an application at a composition operand is subsumed there and mints nothing (§5.8), and result<text> written at a position and ok<text> composing onto result<T> coexist under one rule. A declaration whose body is a fully-bound application of a constructor resolves as a construction in place on the same footing (§5.6).
Partial application. When a reference to or refinement of a parameterized type leaves parameters open, it MUST re-declare the open parameters in its own <> slot — implicit parameter inheritance is not permitted; every parameter has a visible declaration site:
text_keyed_map => <V> {text => V}
uuid_pair => <B> pair<uuid, B>
A partially-applied reference is itself a template, and — being a reference — never a family base (above). The sugar case is an open synthetic-backed template whose held body is !map { key_type: text value_type: V }; the alias case is the held application !reference { target: { name: pair arguments: [ { name: uuid } { name: B } ] } } under a one-entry parameter list for B — the application in type_ref’s record form, as every application inside a !C payload is written (§12.1); reference.target is a type_ref, so an alias to an application states the arguments it already binds inside the held body (§8.1, §8.3), and the partial application is an ordinary open entry rather than the one shape that had to keep its arguments elsewhere. Reference-kind templates compose during substitution — no intermediate entry per alias hop; a use site states the name it named (§8.3). reference is applicable because it IS-A top (§3.3.1): it describes no value, but it describes a hop, which is the question a construction site asks; its entries take the REFERENCE kind from the alias form rather than from a base kind (§4.1). A resolver tells the open cases apart by the value — a !record with parameters closes to a record, the declaration’s own where a declaration names the application and a content-keyed synthetic otherwise; a held !reference composes and mints nothing; every other held constructor closes to its expansion, a synthetic (§8.2).
Heads are not parameters. Parameters bind arguments of a named head; the head itself — the type being refined, composed, or applied — is always a concrete name. <A, N> A ^ { max_items: = N }, abstracting over the refinement source, is not expressible, and a generic-application head that resolves to a parameter (weird => <map> map<text, text>) is a resolver error, diagnosed rather than applied (§3.3.1). Generic derivation over arbitrary heads is a deliberate v1 boundary, as is a parameterized atom refinement: <N> !integer ^ { min: N } is no form at all (§12.1) — the constructor spelling <N> !integer_type { min: N } is the open route.
What a parameter does not carry — a second deliberate v1 boundary, narrower than it was. A parameter carries a type and, where it is a type parameter, a nominal bound (above). It carries no bound on the constructor an argument’s type was built with, by design. A type parameter names a local type, so its bound is a local type, and a constructor is the meta’s vocabulary: <T: text_type> set<T> would judge a schema’s type by how the meta built it, the crossing the paragraph above refuses. “Any text-valued atom” is not a type, because construction creates siblings, not subtypes (§5.5): stock_code => !text_type {} founds a family of its own so that a stock code never passes where text is declared, and an author who wants a type admitted by <T: text> refines into the family — stock_code => !text ^ { pattern: … } — rather than constructing beside it. “Any scalar” is likewise unneeded: a set of records is a set, and a record map key is a compound key (§7.7) that [TSON-JSON] writes in pairs form; a schema that wants scalars states the element or key type. A parameter carries no default argument either. A type slot cannot be bounded at a field by a nominal bound as it can at a template; what a slot can say of a name it holds is which constructor built the type (request?: kind<record>, §4.2) or which type the value named is of (handler: ref<method>, §5.12), and a dependency between two fields of one constructor — record_field.value on record_field.type (§5.2) — stays a rule stated in prose rather than structure. Each is additive: it can be introduced later without changing the template model, so its absence is a recorded deferral, not a design claim.
Closed entries are parameter-free, and the rule is structural rather than a MUST over two fields that have to agree: a closed entry is one with no parameters — its value is a binding record, a !record without parameters, a !reference or a namespace value, and contains no parameter reference at any depth, there being no parameter list anywhere for a token to resolve into — and an open entry is a !record with parameters or a !template, with its parameters declared once, in the value that holds them (§8.1). Ingest verifies the one thing left to verify: that no token inside a closed body names a parameter of any enclosing template, which is a resolution failure like any other. Only entries with parameters may be open.
The _ token is not valid in field-type positions (§7.6); authors expressing “type to be filled later” use parameters.
5.10.1 Self-Referential Types
Types may reference themselves:
node => { value: text children: [node] }
linked_list => <T> { value: T next?: linked_list<T> }
tree => <T> { value: T children?: [tree<T>] }
The resolver MUST detect and handle cycles in type references. In a template body, a recursive reference is an open application like any other — tree’s children carries a reference to the open synthetic for [tree<T>], whose binding applies tree<T> (§5.3, §5.10) — and needs no special representation.
Regular recursion. Within a template body, a recursive application — direct or mutual — MUST pass each parameter through unchanged: tree<T> inside tree’s own body is well-formed; weird => <T> { next: weird<[T]>? }, whose recursive application rebuilds the argument, is a resolver error at the declaration. This is the standard regularity restriction of recursive-type theory, and it belongs beside this section’s other v1 boundaries (no head abstraction, no constructor bounds): without it, one application could demand an unbounded family of distinct instantiations, and no portable depth limit distinguishes that from legitimate depth. The check is static, at declaration time.
Productivity. A type MUST admit at least one finite value; a definition with no finite member — a required direct or mutual self-reference with no terminating alternative — is a resolver error at schema load, not a warning: item => { inner: item } describes no data, and validation against it can only ever fail, so rejecting it at the schema is the earlier, better diagnostic. A recursive reference is guarded, and the definition productive, when every cycle through it passes at least one position that can terminate: a field whose key may be omitted or whose type admits _ (? on the name or on the type, §5.2), a voidable tuple position, an array or map position whose floor admits emptiness or whose elements may be void, or a choice with at least one non-recursive variant. a: void — a field that must be written and admits nothing — is the one declaration this rule refuses on its own, the record having no member at all (§5.2); a?: void is legal, emptying only the field. An ABSTRACT record with no subtype in the declaring schema’s closure is not a productivity error (§5.2): its members are its importers’ to supply. A group that must be chosen (§5.11) terminates when at least one of its options can be chosen without recursion — one option is chosen, so one terminating option suffices — and an option terminates when every member it requires does. Every entry is judged, synthetic entries included; a template is judged with its parameters assumed inhabited (a bound names a type that is itself judged, §5.10, so an argument can always be chosen productive), and its materialisations need no re-check the entry did not already pass. Productivity is structural: whether an atom’s constraints admit any value (a min > max interval) is a separate, value-level question (§5.3, §7.4).
Template materialisation. When a template application closes, it resolves to the declaration that names it where one exists, and to its expansion otherwise (§8.2). A self-reaching expansion is the one case an application nothing declares keeps an instantiation entry of its own: nested => <T> ( T | [nested<T>] ) closed at nested<text> names itself inside its own body, so its content cannot be written without a name, and the knot is tied on the application — a !reference to the synthetic, keyed on the canonical application and carrying @application (§8.2). A self-reaching application keeps one instantiation entry per canonical application — arguments compared after following reference chains (§8.2) — so tree<text> and tree<integer> are two entries and tree<user_id> over user_id => text is tree<text>’s, and every further occurrence of the same application anywhere in the schema reuses the existing one. The entry’s name is internal (§8.2). Recursive references within the body, once bound, denote the entry being materialised; the resolver ties the knot by resolving them to the entry’s own name (§5.10). A non-recursive template’s application is its expansion alone (§8.2).
5.11 Field Groups
A record body may declare a field group: a parenthesised presence rule over fields of the record. A group lists options separated by |, each holding one or more labelled members separated as fields are, and at most one option may be chosen in conforming data. The field names are the discriminator; the wire form of instances is unchanged by grouping.
integer_type => atom & {
size?: integer_size
( min: integer | exclusive_min: integer )?
( max: integer | exclusive_max: integer )?
multiple_of?: non_negative_integer
members?: integer_member_set
}
endpoint => { ( host: text port: uint16 | socket: text ) }
contact => { name: text ( email: email | phone: text )+ }
Each member is a field-name/type-ref pair, the type optionally voidable (? on the type, §5.2) and the name optionally marked (? on the name, below). Member labels share the enclosing record’s field namespace and its name type (§5.2): a label MUST be unique across the record’s plain fields and all groups’ members, including fields contributed by supertypes (§5.8’s disjointness rule extends to member labels) — so options are disjoint, and a field sits in one option of one group.
Three rules decide validity.
- An option is chosen when any of its members is present.
- A chosen option MUST contain every member not marked
?. A?on a member’s name marks it optional within its option: it may be missing once its option is chosen. - A bare group admits exactly one chosen option. A
?after the closing parenthesis makes the group optional — it may be missing as a whole, as an optional field’s key may — and it then admits at most one. A group has no default or fixed form.
( include: I | name?: N type?: T ) therefore admits include, name, type, and name with type; it refuses an empty record, and include beside either of the others.
+ is sugar for “at least one of”. ( email: E | phone: P )+ desugars to ( email?: E phone?: P ) — a group of one option, not optional, whose members are all marked. That option must be chosen, and it is chosen when any of its members is present, so the group admits every non-empty subset of them. + is allowed only where every option is one unmarked member; the desugared form is the kernel’s, and + is the only way to write it. Like ?, it is adjacent to the ) it marks, and it is the one use the series has for a bare + ([TSON-DATA] §7.2.5).
| Rule | Spelling |
|---|---|
exactly one of a and b | ( a: A | b: B ) |
at most one of a and b | ( a: A | b: B )? |
at least one of email and phone | ( email: E | phone: P )+ |
include alone, or at least one of name and type | ( include: I | name?: N type?: T ) |
both a and b, or neither | ( a: A b: B )? |
a requires b; b may appear alone | ( b: B a?: A )? |
a and c each require b | ( b: B a?: A c?: C )? |
either host and port, or socket | ( host: H port: P | socket: S ) |
Each presence rule has one spelling. A group is refused, at schema load, wherever it restates what plain fields or another group already state, and the diagnostic SHOULD name the spelling it restates:
- The only member of an option takes no
?. It is present exactly when its option is chosen, so the mark changes nothing:( a?: A | b: B )is( a: A | b: B ). - A written group of one option is optional, with at least two members and one of them unmarked —
( b: B a?: A )?. Every other group of one option restates something. Bare with an unmarked member, it is plain fields:( b: B a?: A )isb: B a?: A. Bare with every member marked, it is what+desugars to, and is written that way. Optional with every member marked, it is optional fields:( a?: A b?: B )?isa?: A b?: B, and( a: A )?isa?: A. +takes options of one unmarked member each.( a?: A | b: B )+and( host: H port: P | socket: S )+are refused.
A declared group therefore has at least two members, and every option can be chosen on its own, so a declared group is never unsatisfiable and loading needs no search. There is no form for any number of options: options free to be chosen together are independent, and such a group is always its options written as separate groups or plain fields. Groups do not nest, and a field under two presence rules has no spelling — it sits in one option of one group.
Member positions are deliberately bare. A member takes a name, optionally marked, and a type-ref, optionally voidable, and nothing else: the ~, = and =? modifiers are parse errors on a member — selection belongs to the label, presence belongs to the group, and a group never injects. A voidable member written as _ is present and chooses its option; at a member that is not voidable a written _ is the ordinary refusal (§7.6). A group is not a type-ref: it cannot appear in field-type, element, argument, or variant positions, so multiplicity around a group ([( a: T | b: U )]) is not expressible; repetition of alternatives is written as an array of a named choice type (§5.4).
Resolution. Groups flatten. Each member becomes an ordinary record_field in the body’s fields map — in source order, contiguous with its sibling members — with optional: true and role: FREE whatever the group says, and voidable as written. The grouping is recorded in the body’s record.groups list as a field_group:
field_group => {
members: [[text; 1..]; 1..]
optional_members?: [text; 1..]
optional?: boolean ~ false
}
members holds one list per option, options and their members in source order; each slot is text because a member names a declared field by its declared spelling, judged by the record’s name type and never a different one (§5.2). optional_members names the members marked ?, and is absent rather than empty where none is marked, so a group has one spelling. optional is the group’s own ?, omitted in output at its default (§8.1). So ( min: integer | exclusive_min: integer )? resolves to !field_group { members: [[min] [exclusive_min]] optional: true }, and ( email: E | phone: P )+ to !field_group { members: [[email phone]] optional_members: [email phone] }. The in-option mark is recorded on the group and not on the field, for three reasons: record_field.optional keeps meaning “the key may be missing”, which stops being true of a member the moment its option is chosen; a consumer that ignores groups reads every member as optional, which is loose but never refuses a valid document, where the alternative would have it refuse one; and a member’s presence stays the group’s alone to state. The flattened fields-plus-groups form is canonical: group membership is fully derivable from groups in one local pass, and implementations SHOULD compile it into a per-record lookup at schema-load time, per the eager-resolution convention of §5.2 and §7.4 — the output form is canonical, not operational.
Validation. For each group of a record type, after ordinary field validation, the validator finds the chosen options, reports each chosen option’s missing members, and then counts: a group that is not optional errs with no option chosen or with two or more; an optional group errs only on two or more. A present member is validated as an ordinary field of its declared type. Diagnostics stay local, and SHOULD read in the group’s own terms: “port chose (host, port), which needs host”; “include and type choose two options; exactly one is allowed”; “none of (email, phone) is present; at least one is required”.
Refinement and composition. In a refinement or composition body, a restated member stays a member, in its own option. It may drop its ?, which narrows, and may not add one; a member of a + group is restated as it is written there, without a ?, and that changes nothing. Its type slot may move voidable true → false or narrow the type, as at any field. A restated member narrowed to void and not voidable (a: void, which admits no value and refuses _) can no longer be present: an unmarked one makes its option unchoosable, and a marked one drops out of its option. A group left with no choosable option is unsatisfiable, as a group that must be chosen with every member so narrowed always was. Narrowed to void? a member can still be written, as _, and written so it is present and chooses its option — a member that carries presence and nothing else. A restated member may take = v, which is checked when written and never injected — the one pin that does not inject, because an injected member would be present, and presence is what chooses the group’s option. ~ v stays refused on a member: a default is a value only a missing key reaches, and whether a member’s key may be missing is the group’s to say. A member reachable by refinement may not acquire a selector (§5.2). A body entry may also restate a group: the restated group MUST have the same options, with the same member labels in the same order (member type-refs restated verbatim), and may tighten it from optional to not optional; the reverse is a resolver error, and changing membership is a resolver error. Supertypes contribute their groups whole; the composed entry’s groups lists inherited groups in supertype order followed by the body’s own. A field belongs to at most one group — guaranteed by label disjointness.
Removal. A removal clause naming a member (§5.9) removes it from fields and from its option; an emptied option leaves the group, and a group with no option left is removed outright, with nothing left to occupy its logical position. A group the declaration rules above would then refuse is rewritten as the spelling it equals — plain fields or an accepted group, one of which always exists. The simplest case is a group reduced to a single one-member option: it is dissolved, the surviving field taking the group’s optional as its own (an unmarked name where the group had to be chosen, ? on the name where it was optional; the field’s own voidability is unchanged). The declaration rules are thus an invariant of resolved output as well as of source: no resolved record carries a group they refuse.
The labelled-sum pattern (informative). A record whose entire body is a single group that must be chosen, each option one field, admits exactly one field — a labelled sum in record clothing:
timestamps => { ( created: timestamp | modified: timestamp | accessed: timestamp ) }
An instance is { modified: "2026-05-21T13:05:00Z" } — quoted, since a datetime contains colons ([TSON-DATA] §7.1); the label is both the semantic role and the discriminator. Where choice (§5.4) discriminates by variant type name — via a !variant annotation, required except where the choice is disjoint under the encoding’s discrimination — and requires distinct variant types, a single-group record discriminates by label and permits variants of the same underlying type, needing no disjointness and no tag. The pattern’s kind is PRODUCT; host bindings MAY recognise the shape (one such group, no other fields) and lower it to a native sum, and where an option holds several fields the variant it lowers to is record-shaped. [TSON-GUIDE] discusses the pattern and the design history.
5.12 Values by Name
A schema may hold a value declared elsewhere and write it by its name. The slot that holds such a name is typed by a reference, ref<T> (§4.2), and the name is a qualified name: segments joined by ., the first a type name and each further one a selection inside the value the name has reached so far. The need arises in a meta-schema’s vocabulary — an HTTP binding naming the method it serves, a service naming its interfaces — and the rule is one gap closed once: a slot that holds a value declared elsewhere, written by its name.
!!meta:"https://tson.io/2026/38/m/meta-kernel.tn"
!!import:"https://tson.io/2026/38/m/meta.tn"
{
method_name => identifier
method => { request?: kind<record> response?: type_kind errors?: [kind<record>] }
interface => namespace & { key_type: = method_name value_type: = method }
http_verb => !enum [GET POST]
binding => { method: ref<method> verb: http_verb route: text }
bindings => namespace & { key_type: = identifier value_type: = binding }
}
orders => !interface { place_order => { request: place_order_request response: order } }
routes => !bindings { post_orders => { method: orders.place_order verb: POST route: "/orders" } }
The qualified name. A qualified name is one unquoted token by [TSON-DATA] §7.1’s token profile — . is a token character and the lexer emits orders.place_order as one token — parsed into segments by the slot’s type, each segment a [TSON-DATA] §7.7 identifier. The first segment resolves as a type_ref does: in the writer’s type-name namespace, locally and through imports, alias hops followed (§8.3), and names an entry. Each further segment selects in a value: a key of a namespace value (§4.1), or a field of a record value; a map that is no namespace is not entered, and nothing else is. Every hop is written — nothing is skipped and nothing is looked up lexically, so a name means the same wherever it stands in a document — and the grammar is names only: no [n] (an element of a list is not a thing a schema declares, and [ is structural in the lexer), no quoted segment (api."/x".request is three tokens, and a text key is data, declared nowhere), and no empty segment (orders..place_order is three tokens under the range rule, and a leading or trailing . is refused by the segment grammar). A name that wants a position rather than a named thing is a JSON Pointer in a quoted string, not a widening of this grammar. A segment is judged by §7.7’s grammar wherever it stands, when the name is resolved; a map is entered only where it is a value of a namespace type, whose key_type is an identifier family, so a key outside §7.7 under a wider key profile cannot be written as a segment.
The judgement. The slot declares the type: a value of ref<T> names a value whose type is T or IS-A it, judged after following reference chains and compared by canonical identity — declaring schema and entry — each type named in the namespace of the meta governing the schema that declares it. The referent is found first and judged after, never chosen among candidates, and its type is judged before its value, so a name wrong by type is refused for its type whatever the value holds. A type entry admits no further segment: projecting into a record type’s fields (order.sku) is a different capability and is not this one. An open entry is no referent, and is refused at a kind-typed slot on the same terms although a kind lists a record template (§4.2): request: box over box => <T> { v: T } is refused, a template denoting no value until applied, and what a document names there is the application a declaration gives it, bt => box<int32> then request: bt. A framed name (@doc:"x" orders, !record orders) is refused: a name’s type is its referent’s, and an annotation on it would have nowhere to go. A quoted token is text, never a name. Each refusal names its own step:
| Written | Result |
|---|---|
method: orders.place_order | accepted: orders is an interface entry, place_order a key of its map, the value a method |
method: orders | refused: an interface value, where a method is wanted |
method: orders.cancel_order | refused: orders has no key cancel_order; the keys are listed |
method: orders.place_order.nope | refused: a method value has no field nope |
method: place_order | refused: no entry place_order; a member is reached through its entry |
method: place_order_request | refused: a record type, where a method value is wanted |
method: hs.h.headers.accept, headers: {text => method} | refused: a map that is no namespace is not entered |
method: shop.reads.fallback, service.fallback?: method absent in the value under reads | refused: a field the value does not state names nothing, as a key the map lacks does |
response: orders | refused: a namespace entry is a value, where a type is expected (§4.1) |
request: { … } at request?: kind<record> | refused: a kind-typed slot of a value body holds one bare name (§5.6) |
r: ref<record> | refused at the declaration: of names a type, never a constructor; kind<record> is the spelling for the names of the record types |
A field of a record value reached past a key selects in that value as a key does in a map: the name reaches what the field holds, and is judged by the field’s declared type. shop.orders.methods.place_order, where service.methods is typed by interface, reaches the method the interface value holds under that key.
Where the rule stands. A value by name stands at a slot typed ref<T> and nowhere else: a slot typed by a record holds the record’s value inline (method: { response: order } is plain data of method), and a bare token at a record-typed slot in a schema document fills the record’s positional field where that field is atom-typed, and is refused as no record otherwise (§5.6). A reference is kernel vocabulary, declared only in a kernel-governed schema (§4.2): a schema that governs data declares only types, so a reference there would name nothing. The rule does not reach a data document. Its store is a schema’s namespace, and a data document has none, so a bare token at a record position in a data document fills the positional field whatever its type (§5.6), exactly as before, and a host component bound to a reference there receives a name, unresolved. An imported entry’s names are judged once, when its own schema links, in its own namespace. Resolved output writes a name as written (§8.1): the referent is judged at link and delivered to a host with the name, and the two readings a later revision might give a name — substitution, where the value replaces the name and nothing downstream can tell the spellings apart, and identity, where the name’s referent is the value’s identity and what a coverage check reads — both stay available. Neither is decided here: this section is the lookup, the judgement and the grammar, which both share.
What it is, against what it replaces. type_ref, type_name, a kind’s value and ref<T>’s are one thing — a reference that must resolve — and differ by slot only in the judgement: at a kind-typed slot the name is one segment and must resolve to a member of the kind; at a type_ref slot it is one segment with arguments and must resolve to a type; at a ref<T> slot it may carry segments and must resolve to a value IS-A T. type_ref is, in this light, a reference to a type with one segment, because types live only in the schema map; ref<method> takes segments because methods live in collections. The dotted-names family this spelling belongs to is the qualified name — Java’s, XML’s QName, Python’s dotted name, protobuf’s fully-qualified name, TOML’s dotted keys — where each segment is a name and nothing is a position; the address family (RFC 6901 JSON Pointer, RFC 9535 JSONPath) answers which position and is the one the series’ diagnostics already use.
6. Annotations as Types
[TSON-DATA] §3.1 defines annotation syntax and preservation. This section defines annotation semantics: an annotation is a typed metadata attachment, resolved and validated against a type reachable through the schema chain.
Annotation values are always data values — concrete values, not type definitions — both in data values and within the type-definition grammar. Placement follows [TSON-DATA] §3.1; the resolver preserves annotations in their authored positions. The type-definition grammar adds one position: in field-def (§12.1), annotations precede the field name and annotate the field itself, mapping to the record_field in resolver output.
In schema declarations, an annotation may stand at two positions with two meanings, and the resolver does not hoist between them. An annotation immediately preceding the declared name binds to the name — the schema-map key — and is metadata about the declaration: documentation of the entry, provenance, lifecycle (@deprecated name => …), and the resolver’s own derived markers (@synthetic and @application, §8.1, §8.2) live here. An annotation after => binds to the entry’s value and is metadata inside the definition. Both positions are preserved in resolver output at their authored places (§8.1): a key annotation on the output schema-map key, a value annotation on the definition value. A checked annotation (below) is an assertion in either position and is honoured at both: a processor MUST consult both spellings, and MUST NOT ignore one with no diagnostic.
Annotations are types. An annotation @T (or @T:value) names a type T and attaches it as metadata to the surrounding value. Resolution is one-hop against the governing target’s namespace (§3.3.3): the !!meta target for a schema document, the !!schema target for a data document. !!import entries and local entries of the document being authored are NOT part of the annotation namespace. The value is validated against T’s contract:
- For
void-targetedT(a type whose resolved body, after following its reference chain, isvoid— such asannotationornumeric), the annotation form is@Twith no colon and no value. Bare@Tis shorthand for@T:_; the resolver fills the implicit_and validates againstvoid’s contract (§4.2) — presence is the information. - For any non-
voidT, the form is@T:value, wherevalueis a single data-value conforming toT:@doc:"User's full name",@ordering:TOTAL,@title:"Order".
Any type in the governing target’s namespace can be used as an annotation — there is no separate annotation namespace. An annotation whose name does not resolve in that namespace is a resolver error, for the valueless form as much as the valued one: @doc in a schema whose meta does not supply doc fails exactly as @doc:"..." does — the bare form is shorthand for @T:_, and _ still needs a void-targeted T to validate against. Annotations are not free-form markers under a governing schema; the preserved-uninterpreted treatment belongs to schemaless processing alone ([TSON-DATA] §3.1). The one exception is structural: the meta-kernel’s own bootstrap self-reference (annotation => @annotation void), resolvable because the kernel’s governing target is itself and the kernel is pre-loaded before any document is parsed. An annotation typed by an abstract entry — top, type or a base kind (@top, @type, @sum, §4.1), or any other abstract record, a meta layer’s own included — is a schema error in either position, a value at an abstract position naming no subtype.
The one-hop rule is what allows that self-reference to work; it also fixes where annotation types must live (§3.3.3).
What an annotation may do. An annotation never changes a value, its type, or whether it is valid: validation is over values, and a schema with every annotation erased admits exactly the same values. That is the criterion for the home of a fact — an annotation is the right home exactly when the mark changes no value’s validity — and it is what keeps an encoding’s projection concern out of every processor’s type model. Within it an annotation may do two further things; the series has one of the first kind and none of the second:
- It may carry load-time force: a checked annotation is verified against a fact the resolver derives, with two outcomes and no third — verified, or a resolver error at schema load — and the section defining it states the check.
@disjoint(§5.4) is checked against derived disjointness, and is the category’s one member. - It may be a representation directive: it directs how a class of encodings represents a value, and force is confined to the encodings that claim it — in every other encoding, and in the model, it changes nothing. A directive’s class is named, not inferred: it is the set of encodings whose encoding-rules documents declare, by the directive’s name, that they consume it, and each such document MUST say so where it states its rules. A class is never defined by the behaviour the directive directs — “the encodings that flatten” names no class — and no encoding is privileged, TSON text being one encoding beside [TSON-JSON]. An encoding-rules document MAY bind projection behaviour to a schema-side annotation declared for it on these terms; no such annotation is declared in this revision, and the category stands empty.
Two facts that are not annotations, and why. How a record may be realised (abstract, final) and which fields its members are selected by (=?) each change which values conform — erase the first and a direct instance becomes readable at every position typed by the record; erase the second and every untagged document at a member-dispatched position stops validating, in every encoding, so there is no other encoding for the mark to change nothing in. Both are therefore §12.1 syntax lowering into the kernel’s own record.extension and record.discriminators (§5.2), never annotations: the resolver consumes them into the body, they appear in no output annotation channel, and their names are reserved at exactly one position — a definition mark between => and the type definition — and are ordinary identifiers everywhere else ([TSON-DATA] §7.7). meta.tn declares neither, so @abstract, @final, @sealed or @discriminator written as an annotation is the ordinary unresolved-name error above. An earlier revision spelled a discriminator as an annotation on a choice, where the mechanism it names cannot reach, and argued that it changed no value’s validity by counting variants rather than values; that claim is withdrawn with the spelling.
Everything else an annotation can be is advisory: @doc, @title, @comment, @examples, the void presence markers @deprecated, @read_only and @write_only (a bare mark is the whole statement — absence is the default, so there is no false to write — and @read_only with @write_only on one field is a schema-load error), and the @annotation marker itself, which signals “intended as annotation metadata” so tools can surface available annotations, carries no runtime force, and leaves a type without it no less usable as an annotation. Each has a stated reading:
@doc’s text is CommonMark (version 0.31.2), with no extensions. CommonMark is versioned where “markdown” has no specification, so two renderers agree on what a doc says, and a doc that uses one vendor’s tables, task lists or autolinks reads as their literal text. Raw HTML in a doc is never executed, and a renderer need not render it: a doc is written by whoever published the schema, and rendering one fetched from elsewhere must not become a way to run its author’s code. The rule refuses nothing — every string is valid CommonMark, so no schema stops loading and no processor checks anything; it says how the text is displayed, which is all an advisory annotation may do.@titleis plain text, being a name.@commentis a note for a schema’s maintainers rather than its readers (JSON Schema’s$comment): where@docis what a documentation tool shows,@commentis what it leaves out. Its format is left unstated.@examplesis a list of text, each an example as a reader is shown it — conventionally the value written in TSON notation, so a record’s example is a string such as"{ x: 1 }". Nothing parses an example or checks it against the type it illustrates: an annotation’s position hands its value no type to be read by, and a check would need an annotation value typed by its target, which this section does not have.@deprecatedis a yes-or-no fact; a reason, where there is one, is@doc’s to give.
Resolver-attached annotations. Two annotations are attached by the resolver rather than written by the author, and an author’s spelling of either is a resolver error. Every synthetic entry’s key in resolver output carries the bare marker @synthetic (§8.1, §8.2), so tooling can fold materialised entries back into nested display; it is derived, and on ingest it is discarded and recomputed (§8.1) — it carries no decode force and cannot be forged into a resolved document to change its meaning. An entry that closes a template application carries @application:{ name: box arguments: [{ name: int32 }] } on its key — the application the entry is the instantiation of (§8.2), a fact about the entry and not about a type, so an entry of any kind can carry it: a record template’s declared member, a namespace entry closed from a templated instance, the alias a template’s application closes to, a self-reaching application’s instantiation entry. It is derived, as @synthetic is, and never written by a declaration; unlike @synthetic it is read as stated on ingest and not recomputed, being the one fact the entry’s value does not state, and what lets an imported schema’s instantiations unify with a later schema’s (§8.1, §10.1). Both are defined in the meta-kernel and follow the same resolution rules as user-written annotations. No annotation records a reference: a use site names what the author named, and the chain is stated as written (§8.3).
7. Text Encoding Rules: Data Values Under a Schema
This section is the text format’s encoding rules: how the type system’s values are carried in TSON text. Every encoding of the type system owns a section shaped like this one — its atom lexical forms, its container syntax, its treatment of void, and its discrimination predicate over the derived disjointness fact (§5.4). This section states TSON text’s rules; every other encoding states its own against the same encoding-independent model in its own encoding-rules document — [TSON-JSON] is the first, and the two agree on every rule the model states, differing in spelling alone.
[TSON-DATA] defines the syntax of data values and their schemaless interpretation. This section defines the !!schema directive (§7.1) and how an active schema changes interpretation: type-annotation resolution (§7.2), atom parsing in place of base type resolution (§7.3, §7.4), sets (§7.5), the void sentinel (§7.6), resolver behaviours at typed positions (§7.7), and cross-schema references (§7.8).
7.1 The !!schema Directive
!!schema identifies the schema whose types are available for !name references in the value it governs. The directive value is an IRI-reference identifying a published schema.
!!schema:"https://example.com/people.tn?sha256=c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5"
!person { name: Alice age: 30 }
The referent is a schema document. A !!schema URL resolves to a schema document — never to a resolved-schema data document, whose resolver-derived fields cannot be verified from the document alone. Resolved-form documents enter the schema library only through the explicit ingest path (§8.1, §10.1).
On a data document’s header, !!schema binds the schema for the entire document. On a scoped value — a record field value, map entry value, or array element ([TSON-DATA] §2.3) — it binds the schema for that value alone: the referenced schema becomes the active scope for all !name annotations within that value and its descendants, reverting to the enclosing scope when the value ends. Directives remain excluded before map keys, field names, and annotation values ([TSON-DATA] §3.3); §7.8 defines the typed-position rules.
The directive names a namespace, not a root contract. !!schema supplies the vocabulary against which !name annotations resolve; it does not assert the governed value’s type, and a schema has no privileged entry point. The value names its own type by annotation, and under !!schema the root MUST name its type: an unannotated root under a bound schema is a validation error in every mode, since under a schema every value is typed by its position or by its tag (§7.3) and a root has no position to be typed by. There is no “vocabulary-only” reading of a governed document — a value read by base type resolution is a schemaless value, and a document is one thing or the other from its header. The same holds at every position whose type is a scoped instance: a value there names its type or is invalid (§7.8).
A scoped !!schema at a position whose type is constrained by the outer schema is admitted exactly when that type resolves to a scoped instance whose scope holds EXTERN, or a container of one. At a scoped position whose scope does not hold EXTERN it is a validation error, by the cell rule; at a position whose type is not a scoped instance at all it is a resolver error — see §7.8, where both are derived rather than listed.
Schema documents do not carry !!schema: a schema’s governing contract is declared by !!meta (§2.2.2). A document with no !!schema directive has no type vocabulary: base type resolution ([TSON-DATA] §4) applies, type annotations are limited to the built-in vocabulary of [TSON-DATA] §5, and any other type annotation is a resolver error ([TSON-DATA] §3.2). The built-in names denote the types their libraries declare — core’s for most, net.tn’s for the network rows — so a schema that wants them imports the library that declares them (§9), and a schemaless !mac keeps its meaning when its document moves under a schema that imports net.tn. Such a document opens no schema scope of its own (§7.8).
7.2 Type Annotation Resolution
In data values, a type annotation (!name) marks instantiation — the value is concrete data conforming to the named type. The name resolves against the external schema identified by the current !!schema directive — never against definitions within the same document (§3).
The built-in vocabulary does not apply in schema scope. When a schema is in scope, all type annotations MUST resolve through the schema’s type-name namespace; a built-in annotation name not defined by the active schema is an unresolved-type error. Schemas wanting uuid, bytes, datetime, and the other built-in names import the library that declares them — core, or net.tn for the network names ([TSON-DATA] §5.5) — or define them locally. This is the normative statement of the scoping rule; [TSON-DATA] §5.1 restates it for schemaless processors. A document with no !!schema falls back to schemaless processing (§7.1).
Records are closed under their type, with no exception. When a schema is in scope and a record’s type is known, the record MUST contain only fields defined by its type; fields not present in the type definition are validation errors — a field name that is no value of the record’s name type among them, since every declared name is one (§5.2), the diagnostic under a declared name type naming the type and its reason. A document’s field names are matched against the declared ones in the name type’s form (§5.5, §7.7), and the record that reads a value judges its names: a record nested in one with a relaxed name type judges its own. This applies to directly-typed records (!person { ... }) and structurally-positioned records (a record at a record-typed field position). Nothing in a schema makes a record absorb fields its type does not declare: open-ended data is carried by a declared map-typed field (extras: {text => text}), whose entries are keys and not names ([TSON-DATA] §2.5, §2.6) and are written the same way in every encoding. Schemaless records have no closure rule. Closure is what makes schema evolution a discrete operation: every published schema version is a precise, immutable contract about what fields exist (§3.5).
Type expression syntax is not available in data values ([TSON-DATA] §3.2). To annotate an array or map value with a named type, declare a named type in the schema (int_list => [integer]) and write !int_list [1 2 4 8 32].
Validation follows what the name is. !T value asserts that the value conforms to T, and conformance is determined by T’s own definition. An atom instance validates a single token by its parsing contract (§7.4): !age 42, never !age { ... }. A product validates a record against its field list; a choice validates any conforming variant. A constructor is a record-shaped type, so it validates a record against its constraint-field vocabulary: !integer_type { min: 0 max: 255 } is a record conforming to integer_type’s fields, receiving ordinary record validation; family coherence between bindings (e.g. min ≤ max) is a compilation and ingest concern (§8), not data validation.
Subsumption at typed positions. At a position whose declared type is T, a value annotated !S is valid if and only if, after following both reference chains to their terminal entries (§8.3), S is T or T appears in S’s chain.supertypes (§4.1, §8.1). A subtracted type has no supertypes and does not stand where its source is expected. An admitted value validates against S in full: closure, constraints, and defaults are S’s; the position’s T contributed admission alone. What an unannotated value at such a position means is T’s own extension fact (§5.2), and there are three readings. Where T is OPEN or FINAL, the value is exactly T: there is no structural recovery of S, per the once-only reading of form ([TSON-DATA] §2.4), so the annotation carries information precisely when S is a proper subtype — a field typed person admits !employee where the schema declares employee => person & { ... }, and without the annotation the value is a person, full stop. Where T is ABSTRACT and names no discriminators, T has no direct instances, so the tag is REQUIRED: an untagged value is a validation error naming T, whose diagnostic SHOULD name the subtypes, and a tag naming T itself is refused, no value satisfying it. Where T is ABSTRACT and names discriminators, the value is placed by reading the marked fields at the types T declares them — the one set of types known before dispatch — and matching the value, or the tuple of values in declaration order, against the pins its members state; the matched member becomes the effective type and the whole value validates against it, which re-verifies each pin as an ordinary FIXED check and makes the dispatch read and the validation read agree by construction. A missing selector, or a value no member pins, is a validation error, the latter SHOULD naming the pinned alternatives; the tag is then optional and MUST name the dispatched member or a subtype of it where written — one selector per position, and the FIXED check is what forces the two to agree. This notation reads a member-dispatched position on those terms and so does every other encoding ([TSON-JSON] §6.1.5); the two differ in spelling alone, since the selectors are the model’s facts and not any encoding’s. A record’s fields have no significant order in this notation, so a reader locates the selectors by lookahead over the record; an encoding may require them to lead. Choice-typed positions discriminate by variant membership (§5.4) and scoped positions by the namespaces the instance admits (§7.8) — the same surface gesture, an annotation selecting a more specific type than the position promises, under their own membership relations; this rule governs every other typed position. It is also what admits resolver-output entries at the top-typed values of schema: every entry’s annotation head carries top in its chain — constructors through type and their base kinds, a namespace type through namespace, reference and template through their direct composition with top (§4.1, §8.1, §9) — and top being abstract, dispatch is by tag among its subtypes.
There is no construction in data. !C { bindings } produces a new type only in the schema grammar (§5.5); the same surface shape in a data document is a record that describes constraints — which is precisely what resolver output stores as an entry’s value (§8). The two category errors are symmetric, in data as in schema: a constructor never types its family’s atom values (!integer_type 42 is a type error — the value type is the instance, !integer 42), and an instance never types records (!age { min: 0 } is a type error — the constraint vocabulary belongs to the constructor).
No parameterized annotations. Resolver-output bodies (!array { element_type: person }, !map { key_type: … }, §8.1) are annotations by ordinary parameterless constructors (§4.2), validated by ordinary record validation: each field against its declared type, a type slot against type_kind — a bare name judged a member when the governing schema links (§4.2). No special rule remains — a template with any parameter is a resolver error as a data annotation, without exception (§5.10).
Schema values. The type annotation !schema marks a map as a schema value — a regular type annotation; schema is defined in the meta-kernel as the namespace namespace & { key_type: = type_name value_type: = top } (§4.1), and a value of a namespace type is read as the map it describes. It appears on data documents that carry resolved schema structure, most notably resolver output (§8); schema-document source never carries it (§2.1).
7.3 Atom Parsing Replaces Base Type Resolution
When a schema is in scope, base type resolution ([TSON-DATA] §4) does not apply at all: every value is typed by its position or by its tag, each declared atom type owns its own parsing contract (§7.4), and there is no third way to read a token. The tokens true and false have no special status when a schema is in scope — their meaning is determined entirely by the position’s type, and at core’s boolean they are enum members like any other (§7.4). There is no null in the notation ([TSON-DATA] §4.4): the token null at a text position is the string null, at an int32 position a parse error, and at a void-typed position — any instance of void_type: the kernel’s void, core’s sibling of it (§3.3.5, §9), or one a schema constructs for itself (§4.2) — a validation error, since void admits the void sentinel _ and nothing else. A JSON document’s null reaches the model as void through the JSON reader ([TSON-DATA] §6), where the position decides whether a void value is admitted (§7.6); no keyword in this notation is needed to carry it.
7.4 Atom Token Parsing
Each atom type owns its parsing contract. When a token appears at a position whose type is an atom, the atom’s parser takes the token and produces either a typed host value or a parse error; the atom’s constraint record is applied as validation after parsing succeeds.
Parsing and validation are distinct. Parsing takes a token to a host value; validation checks the host value against the constraint record. twelve at an integer-typed field is a parse error; 300 at a field typed age (refining integer with {min: 0 max: 150}) parses as an integer, then fails validation. Implementations SHOULD distinguish these in error reporting ([TSON-DATA] §8.1).
Enum member semantics. An enum is a closed set of names, and the identifier family they are drawn from — the same two facts a record states of its field names through record.name_type (§5.2), under the same name. The kernel’s constructor states both, and one tightening of it pins the family for the common case:
enum_type => atom & {
name_type: identifier_kind
members: enum_set
}
enum => enum_type ^ { name_type?: = identifier }
!enum [OPEN ACTIVE DONE] is a vocabulary: its members are names, written unquoted, and a named scope for name hygiene (§11.4). It is the positional form (§5.6) — name_type is pinned and injected, so members is the one unmarked field. !enum_type { name_type: kebab members: [make-tea drink-tea] } is the general form, and an ordinary schema may write it: name_type is data, so a schema enumerates names of a naming vocabulary it declares itself, with no new constructor, which only a meta layer may declare (§2.2.2); !enum_type { name_type: words members: ["lightly active" "sedentary"] } over words => !identifier_type { medial: " " } enumerates names with spaces in them, with full hygiene, medial placing a character only between two others and never at an edge or doubled. members is typed by the kernel’s enum_set — !set_type { element_type: text min_items: 1 } — so members are held as text whatever name_type is, every enum has enum_type’s shape, and one reader and one host class serve them all: a processor binds a constructor that tightens another as the one it tightens. A closed set of text that is no name is no enum: it is the text family’s own members facet, !text ^ { members: ["80" "443"] } (below), string-class as every text atom is. An earlier revision’s text_enum is withdrawn, its one use being that facet.
name_type names an identifier family — typed identifier_kind (§4.2), the kind of the types built with identifier_type, so a value naming any other type is refused as no member of the kind, at the declaration. Everything that distinguishes one enum from another is a property of that type, not of enum:
derived from name_type | |
|---|---|
| members | each MUST be a value of the type, by the family’s own parsing and facets — so a name under its profile — and no two may be one value under its equality; a member the type refuses is a schema-load error (“is not an identifier” under the default, “is not a value of its name_type ‘words’” under an author’s) |
| hygiene | §11.4’s scope and all three of [TSON-DATA] §8.2’s mechanisms, under the type’s own profile, whose added characters are its own |
| discrimination class (§5.4) | the members’ shared class, read off each member’s own token |
| binding | a host enum by a mapping the binder owns (below) |
The first row is a pair of rules and not structure: members are held as text and are unique as text by the set’s own contract, and a type whose equality is coarser than text — one that folds case (§5.5) — makes two of them one name, which is refused. The class row stays with the enum rather than following the type: it reads the members’ tokens (boolean => !enum [true false] is boolean-class, where the type identifier is string-class). A name type’s normalization implies NFC, on an enum as on a record (§5.2): NONE is refused at load, and ASCII_CASEFOLD is admitted only for a type whose every character is ASCII, NFKC_CASEFOLD being the fold for any other, since a document’s name is matched against the members in the type’s form and no reader could match one under a form the type never stated.
Where a name_type resolves. A name_type an author writes names a type in the author’s own namespace. A value a constructor pinned was written in the governing meta and resolves there: !enum [A B] names the kernel’s identifier in a schema that imports no core, however the enum is reached — directly, or through a template’s held body — and an identifier the schema declares for itself is a different type that does not displace it. This is §2.2.3’s rule that a merged entry’s own references stay in its defining schema’s namespace, stated for a pinned value; the same holds of a record’s name_type (§5.2). name_type is fixed at construction (§5.7): a refinement narrows the member set and never changes the type, since narrowing it would re-judge members already admitted and widening it would admit names the source’s type refuses. A ~/= value at the slot is judged a member of identifier_kind where it is written, so a constructor pinning a non-member fails at its own meta layer’s link rather than at the first schema applying it; a name type written as a template application is admitted as the instance it expands to.
Numbers are never enums. An enum’s members are names, equal only to themselves under their type’s equality; an integer’s equality is a value equality (80 and 0x50 are one value, [TSON-DATA] §4.3), and a numeric value set is the numeric tier’s own facet — a contiguous range is !integer ^ { min: 1 max: 3 }, an arithmetic progression is multiple_of, a sparse set is members (!integer ^ { members: [80 443 8080] }, the integer_member_set-typed facet integer_type carries and the decimal_member_set-typed one decimal_type carries, §9) — and a choice is for alternatives that are not one family. A mixed source enum (["AUTO", "MANUAL", 80, 443]) becomes a choice over an identifier enum and a member-set integer, which is disjoint by class and tag-free. The split, in one sentence each: a vocabulary of names is !enum [OPEN ACTIVE DONE], or an enum_type over the author’s own identifier family where the names need a wider profile; a value set on a type needed for its other facets is that family’s members — !text ^ { length: 2 members: ["AU" "NZ"] }, !int16 ^ { members: [80 443] } — where the family’s own parsing still applies. What an enum has that a member set has not is an unquoted spelling, a class of its own, name hygiene, and name equality in place of value equality.
An enum MUST have at least one member (!enum [] fails enum_set’s min_items at schema load), and its members are unique (!enum [OPEN OPEN] is a schema-load error). There is no carve-out for true and false: the kernel defines boolean as !enum [true false], an ordinary enum of names whose class is read off its members’ tokens by [TSON-DATA] §4, so boolean stays boolean-class and [INDEX NAMED] string-class. Parsing at an enum-typed position puts the token’s decoded text into the label type’s normalization form (§5.5) and matches it against the members — a typed position never consults the form, so "true" and true are one value at boolean (as [TSON-DATA] §5.5’s !boolean states for the schemaless case). How an enum binds: to the host type of the natural parse of its members — a host boolean at boolean, host text everywhere else — with a host-language enum reached through a mapping the binder owns. Under an identifier family the members are names; that they are host-safe names is true of no host — [TSON-DATA] §7.7 admits in-progress, and the kernel’s own boolean has true and false for members — so binding is a mapping in every case (in-progress to IN_PROGRESS, "new york" to NEW_YORK), and the binder reports one that collides or yields no legal constant. A host language is what a schema maps to, never a constraint on it. members describes the permitted values, not the resolved representation.
Text member sets. text_type carries members?: text_member_set beside min_length, max_length, length, pattern and normalization, and identifier_type, uri_type, iri_type, regex_type and email_type inherit it through the composition they already use (§9): a value set on a constrained text family, where the family’s own parsing contract still applies — !uri ^ { members: ["https://a.example/" "https://b.example/"] } is two admitted URIs that are still parsed as URIs, exactly as !int16 ^ { members: [80 443] } is a sixteen-bit integer that happens to be sparse. It is a set, not an array, for three reasons — uniqueness comes from the set’s own contract, non-emptiness from its min_items, and member identity is the value’s, so two spellings of one member are a duplicate rather than two — and a plain field beside pattern rather than a group excluding it, because a pattern is two things nothing can tell apart — a|b|c is a value set and beside members is the same statement twice, [A-Z]{2} is a shape and beside members composes as a numeric range does. A member is a value: each is put into the type’s normalization form (§5.5), and two that are one value there are a duplicate and are refused, the set’s own uniqueness having compared only the text as written. The pattern MUST admit every member: meta’s uniform members rule, applied, and the one member-coherence check in the series that needs a regex match rather than a comparison. pattern and members are each settable once under refinement (§5.7).
Identifier families. identifier_type is the constraint vocabulary of names: a UAX #31 identifier profile, stated as data, with the text facets applied inside it.
identifier_type => text_type & atom_specification & {
spec?: = "https://www.unicode.org/reports/tr31/"
start?: identifier_base ~ XID
continue?: identifier_base ~ XID
start_add?: text
continue_add?: text
medial?: text
exclude?: text
normalization?: normalization ~ NFC
}
identifier => !identifier_type { continue_add: "-" }
The profile is UAX #31’s R1 default identifier syntax — Start Continue* (Medial Continue+)*, with Start = (start ∪ start_add) − exclude, Continue = (continue ∪ continue_add) − exclude and Medial = medial — where start and continue name the base each set is drawn from (identifier_base => !enum [XID ID ASCII NONE]: XID_Start/XID_Continue, ID_Start/ID_Continue, XID’s ASCII members — Start A–Z a–z, Continue adding 0–9 and _ — or the empty set) and each of the four text facets is read as the set of code points it holds. A medial character stands only between two others and never beside another medial. A floor no profile lifts: an identifier_type MUST NOT add, in start_add, continue_add or medial, a character that renders as nothing or as another character — a Default_Ignorable_Code_Point (so every Bidi_Control), a control, or a space other than U+0020 — and a profile that does fails to load ([TSON-DATA] §8.2): an added character meets no restricted-character rule, so without the floor a soft hyphen in continue_add would make two names no rule tells apart. U+0020 stays admissible, a medial space being how a name type holds words. Where a type is a name type — a record’s or an enum’s (§5.2) — its normalization MUST imply NFC: NONE is refused, and ASCII_CASEFOLD is admitted only over a profile whose every character is ASCII. The profile judges the value — the text put into the type’s normalization form, NFC unless the instance says otherwise (§5.5) — so a quoted decomposed spelling at an identifier-typed position reads as its composed value and is not refused for having been written decomposed, and a profile that folds case lists lowercase letters only and admits Content-Type. A join control (U+200C, U+200D) is admitted only in the contexts UTS #39 §3.1.1.1 permits, whatever the profile ([TSON-DATA] §7.7, rule 2). Because the family composes text_type, it takes lengths, a pattern — a naming convention such as snake_case, inside the profile — and members, a closed vocabulary of names; the member-coherence rule below counts the profile among the facets a member must satisfy, so members: [north "2nd"] under identifier’s profile fails to load.
identifier is [TSON-DATA] §7.7’s grammar exactly: XID_Start-initial, XID_Continue ∪ { - } thereafter, in NFC. Two identifiers are equal iff their NFC texts are byte-identical; identity is case-sensitive. It is the type of every naming position in the series — type_name, field_name and param_name alias it, enum pins it as its name type, and a record whose chain states no other has field_name as its (§5.2) — so a rule stated on it reaches all of them, and because every identifier lies inside the unquoted-token profile ([TSON-DATA] §7.1), the positions that admit no quoted form (! targets, @ names, and every schema-grammar name) lose nothing. A lexer and a schema parser hold that profile directly, the kernel’s own names being read before the kernel exists, and a kernel whose identifier states any other is refused at bootstrap (§3.4). What follows from the family being a type:
- A value typed by an identifier family is a name. [TSON-DATA] §8.2’s per-name mechanisms reach it under the identifier policy, at a map key and at a field’s value alike, and the keys of a map keyed by one and the elements of a set of one are look-alike scopes (§11.4). The line between a name and data runs through the type, and an earlier revision’s statement that
identifieris not used in data values is reversed. - An ordinary schema declares its own.
identifier => !identifier_type { continue_add: "-" }is one line, after which a schema writes{identifier => handler}and refines!identifier ^ { … }. Core declares no sibling and leaves the name free (§9). - Each profile is its own type. Every
!identifier_type { … }is a distinct type entity: IS-A between two identifier types comes from refinement alone, never from one profile admitting a subset of another’s names. All are string-class (§5.4). - The profile facets and
normalizationare fixed where the profile is constructed (§5.7): a refinement narrows only the text facets, and a different profile is a fresh!identifier_type. - Only
identifier’s profile is known to lie inside the unquoted-token profile. A value under another profile may need quoting, which costs nothing: the positions that admit no quoted form are typed by the kernel’s roles, which stay onidentifier, and a field name or enum member under another profile is written quoted where it must be ("@id","lightly active"). - A profile’s own additions are exempt from the
Identifier_Statusrule, and a per-segment restriction level divides a name at its profile’s own separators ([TSON-DATA] §8.2). - A record’s field names and an enum’s members are values of an identifier family, their
name_type(§5.2), so a schema translated from a JSON vocabulary declares the profile its names need —json_name => !identifier_type { start_add: "@$" }— and judges@contextand$refas names under it, with full hygiene.
The kernel’s second instance is scheme_name, a URI scheme (RFC 3986 §3.1) — a letter, then letters, digits, +, - and ., compared without case, written over the ASCII base: !identifier_type { start: ASCII continue: ASCII continue_add: "+-." exclude: "_" normalization: ASCII_CASEFOLD }. The ASCII base is what lets an ASCII protocol’s names be stated in one line — an HTTP field name is !identifier_type { start: ASCII continue: ASCII medial: "-" normalization: ASCII_CASEFOLD } — where a NONE base would list the alphabet twice.
Number-grammar reuse. Atoms that parse numeric values SHOULD use the number grammar of [TSON-DATA] §7.6 for the relevant numeric form; a single shared number parser dispatching on the atom’s declared form is the expected pattern.
Constraint fields typed as value. Some atom constructors declare constraint fields with type value because the constrained atom cannot be referenced at the point of declaration — decimal_type.min is typed value and cannot use number, which is a core instance of decimal_type (bootstrap ordering, §3.4). The kernel’s integer_type is the exception that proves the rule: its bounds, its multiple_of and its members are typed integer, available in the kernel’s own namespace, and every facet that counts something is typed by the kernel’s non_negative_integer (§9). A token at a value-typed position is the token, uninterpreted (§4.2): the resolver reads it under the atom it stands for, once that atom is in scope, and stores the result. That reading is what makes a value-typed constraint comparable at all — 1 and 1.0 at decimal_type.min are one number, not an integer beside a float — and for a value-typed set it runs before the set is formed: each member of decimal_type.members is read under the constrained atom first, so [1 1.0] is a duplicate the set catches, a member that does not parse as a decimal fails at schema load, and §5.7’s member-set tightening compares decimals rather than whatever host type a bare token would have become.
Each constrained atom’s implementation converts value-typed constraint values to its internal representation. Conversion MUST occur at schema-load time, not per-validation; an atom that cannot convert a given constraint value MUST report a resolver error at schema-load time — a schema either loads cleanly or fails with a clear diagnostic, and a half-valid schema that silently mis-validates is never produced. Which constraint-value types an implementation accepts for conversion is an implementation choice; the validation semantics after conversion are the atom’s contract.
Coherence of a body’s facets. A body’s facets MUST between them admit a value: the lower bound not exceeding the upper (across whichever inclusive or exclusive forms are present), a multiple_of strictly positive, a precision at most 9, a network family’s within/excluding pair and prefix bounds admitting a value (§5.5), an identifier profile whose Start set is not empty and whose medial characters are disjoint from its Start and Continue sets, and — where members is present — every member satisfying the body’s other facets, the range a family derives rather than stores included ({ members: [443] size: { bits: 8 } } fails the way a stated bound outside the width does). Each is a resolver error at schema load; at materialisation the same rules run again over the operands that were parameters (§8.2).
Lexical classes vs type names. The categories of [TSON-DATA] §4 base type resolution (boolean, integer, float, string) are lexical classifications of tokens a schemaless read produces, not type names: the lexical class and the core type number are distinct namespaces that share a word. A schema cannot reference a lexical class, and base resolution never produces a declared type; when a schema is in scope, the schemaless dispatch does not apply (§7.3).
7.5 Sets
A set is an unordered collection of unique values. Sets share array syntax [ ... ] at the data level ([TSON-DATA] §2.7); set-ness is a schema property declared via the kernel’s set_type constructor, most often through the set<T> template (§4.2, §5.3). Without a schema, every [ ... ] is an array.
Duplicate handling. When source data contains a value more than once at a set-typed position, the duplicate is a validation error at the repeated occurrence — a set-typed position asserts uniqueness, and silently dropping a member the author wrote would decode the document to something other than what it says (the same posture as duplicate map keys, [TSON-DATA] §2.6). Two values are duplicates if the element type’s equality contract considers them equal, and for an atom that contract is the type’s value space (§5.5): name identity for a set of identifier, value equality for a set of integer or number (1 and 1.0 are one member), octet equality for a set of bytes whatever the spelling, instant equality for a set of datetime, and equality in the type’s normalization form for a set of a text family (Content-Type and content-type are one member under a type that folds case). For an element type that is not an atom — a record, array, map or choice — two elements are duplicates if the processor’s host representation of them is equal, and which pairs that relates is implementation-defined: a processor detects at least the textual identity of [TSON-DATA] §2.6 in a tree it builds itself, and where elements are bound to host types, those types’ equality decides. The series can reduce to a value space only the atoms it implements on the host; past an atom, identity belongs to the host, and a schema that needs portable duplicate detection over a compound value keys it by an atom — a derived identifier or a canonical string — which is the only identity every encoding and every host can agree on.
Element order. Order is a facet a container states (§5.3): ordered says whether two values differing only in order are one value — true for an array unless it says otherwise, false for a set by set_type’s pin, and false for a map unless it says otherwise. It never changes what a document may write. A set is thus unordered as a value: uniqueness and set equality are what the type states, and element order carries no meaning at a set-typed position. Output keeps the order written either way. In resolved output a set’s elements, like an unordered map’s entries, appear in source declaration order — for enum_type.members, integer_type.members, decimal_type.members, text_type.members, uri_type.schemes and scoped.scope alike — so that two resolved documents are compared as §8 writes them, with no table of unordered positions to consult and no canonicalisation step; a reader keeps the author’s order, which an ordinal-assigning encoding also needs. A refinement that shrinks a member set keeps the surviving members in their inherited order.
These rules apply uniformly to every set-typed position: the kernel’s enum_type.members, the exact tiers’ members, scoped.scope, user-defined set-typed fields, and set values produced by the positional fill rule (§5.6). A void element at a set-typed position is rejected — set_type pins voidable: = false (§5.3) — and a set may be empty unless its type states min_items: 1, as set<T> does not and every member set of the kernel and meta does.
7.6 The Void Sentinel Under a Schema
[TSON-DATA] §2.9 defines the void sentinel and its data-value positions. _ is not itself a type; the type whose sole conforming value is _ is void (§4.2). It is the one spelling of void: the token null is a string, or a parse error, or a validation error, by the position’s type (§7.3).
This document extends the position rules of [TSON-DATA] §2.9:
| Position | _ permitted? | Meaning |
|---|---|---|
| Array element (schema in scope) | conditional | Permitted only when the array type is voidable, written [T?] (§5.3) |
| Tuple element (schema in scope) | conditional | Permitted only when the position is voidable (§5.3); the void element occupies its slot |
| Record field value (schema in scope) | conditional | Permitted when the field is voidable — its type carries ? (§5.2) — whatever its name’s mark; the field is then present with a void value. At a field that is not voidable, a validation error, whatever the modifier — at a defaulted field the fix is omission, which injects the default (§5.2). At a voidable group member, _ is present and chooses its option (§5.11) |
| Map key (schema in scope) | no | Resolver error — restates [TSON-DATA] §2.9’s resolver-layer rule; keys define membership and are always required |
| Map entry value (schema in scope) | conditional | Permitted only when the map type is voidable, written {K => V?} (§5.3); the entry is then present with a void value ([TSON-DATA] §2.9) and counts toward the size bounds |
| Field-type position (type-definition grammar) | no | Parse error — use type parameters (§5.10) |
| Type-ref position (type-definition grammar) | no | Parse error |
| Type-def body (declaration right-hand side) | no | Parse error — use {} for an empty record |
Field modifier value (~/=) | no | Resolver error — ~ _ and = _ are refused; “may be missing or written _, and nothing else” is the type slot’s to say, a?: void? (§5.2) |
When a schema is in scope, a void element requires the governing container type to permit it. Void elements occupy positional slots, and size constraints count all slots. A tuple’s voidable position requires an explicit _ — the tuple’s length is fixed by its type, and short tuples are validation errors (§5.3).
7.7 Resolver Behaviours at Typed Positions
Field names in the name type’s form. A document’s field name is matched against the record’s declared names in the record’s name type’s normalization form (§5.2, §5.5): under a case-folding name type NAME is the field name, two spellings of one name in one record are a duplicate field, and a name that is no value of the name type can match no declared field and is unrecognised (§7.2). The schema’s own references to its fields match the declared spelling exactly; only a document’s names are matched in the type’s form.
Typed key equality. [TSON-DATA] §2.6 defines layered duplicate-key identity: textual identity as the parser’s minimum, decoded-value identity for any processor that decodes, and declared-type identity — which can only make more keys equal — on top. A schema-aware processor decodes keys by the declared key type’s contract (§7.4), so two keys equal under that contract are one key, and a duplicate that only the declared key type relates (1 and 1.0 under a number-keyed map, two spellings of one octet string under a bytes-keyed one, Content-Type and content-type under a text key type whose normalization folds case — the textual and decoded-value duplicates of [TSON-DATA] §2.6 are already resolver errors below this layer) is a Class 2 validation error at the repeated occurrence, consistent with the set rule (§7.5): identity under a declared atom key type is over the key type’s value space (§5.5), and under a compound key type — a record, array, map or choice — it is the processor’s host equality over what it built, at least the textual identity of [TSON-DATA] §2.6 in a tree the processor builds itself, on §7.5’s terms. Which host representation the decoded keys take afterwards is the implementation’s concern; the identity contract for an atom key is not. A key typed by an identifier family is a name as well as a key, and the keys of one such map are a look-alike scope (§11.4).
Empty braces. [TSON-DATA] §2.8 defers empty-brace disambiguation to declared type information. Under a schema, the expected type at the position supplies it: the resolver transforms an empty-brace value into an empty record or an empty map per the expected type, defaulting to an empty record when the position is untyped. The rule covers exactly the two containers that share the brace form and cannot be told apart when empty; an empty map then faces the map’s size bounds like any other. At a position whose expected type is anything else — an array, a tuple, an atom, a choice with no brace-class variant — an empty brace is a validation error, reported as a value of the wrong form: an array or tuple has its own empty spelling ([]), and admitting {} there would let a brace-class value conform to a bracket-class type (§5.4).
7.8 Cross-Schema Type References
Under a schema a value’s type comes from a namespace, and two questions are independent at every position: whether the governing namespace — the schema’s own declarations and its imports (§2.2.3) — is admitted, and which foreign schemas are. The scoped constructor (defined in meta.tn) is the sum type that answers both: its instance names the namespaces a value’s type may be drawn from, and the value names its own type.
scope_kind => !enum [LOCAL EXTERN]
scope_set => !set_type { element_type: scope_kind min_items: 1 }
scoped => sum & {
scope: scope_set
schemas?: {schema_identity => [type_name; 1..]?; 1..}
}
scope says which namespaces are admitted; schemas narrows the foreign ones — missing is any foreign schema, a keyed map is those schemas, and a key’s void value ({K => V?}, §5.3) is every type that schema declares where a list is those types. The map is keyed by schema identity, so one schema cannot be listed twice, and keys are compared by canonical identity ([TSON-DATA] §2.2.1) — a key in any spelling of an identity read to its canonical form — so a pinned key and an unpinned !!schema in the data match, as do an A-label and a U-label spelling of one host, each pin verified by the loader on its own (§10.2). The state admitting nothing is unspellable rather than refused — both collections are non-empty by construction — and one coherence rule, of the family a resolver already runs over bound pairs, ties the fields together: schemas requires EXTERN in scope. Where choice is the closed sum, enumerating its variants, scoped is the open one, naming where variants are drawn from; both are sums in which the value names its own type, and disjoint is absent on scoped as on every non-choice sum (§8.1).
Core declares the three named subsets and two applications over them (§9):
| Instance | scope | Meaning |
|---|---|---|
declared | [LOCAL] | a type this schema declares or imports, chosen by the value |
extern | [EXTERN] | a type from any foreign schema, which the value names for itself |
dynamic | [LOCAL EXTERN] | either, from anywhere — the successor to what earlier revisions called unknown, under a narrower meaning: the data decides the type, and the value is validated in full against the type it names, which is why it is not called any |
extern_of<S> | [EXTERN], schemas: { S => _ } | any type of one named foreign schema |
extern_type<S, T> | [EXTERN], schemas: { S => [T] } | one named type of one named foreign schema |
The two templates are ordinary §5.10 partial applications over a meta constructor: attachment: extern_type<"https://…/claim.tn", claim> declares nothing, S standing in a key typed by meta’s schema_identity and T inside [type_name] — both atoms, so both value parameters, recorded as S: schema_identity and T: type_name and checked at the application (§5.10), where schema_identity admits any identity a document may carry and no fragment (§5.5) — and reaching one schema and one type per application; the multi-type and multi-schema rows keep the instance form or a named declaration in the user’s schema. An application’s identity is the argument as written (§8.2), so a pinned and an unpinned S are two applications where the data-side match is canonical.
The data rule. At a position whose type resolves to a scoped instance, the value’s own shape picks the cell:
- a value carrying
!!schemais EXTERN: its identity MUST be a key ofschemas, or any schema ifschemasis missing, and its!typeMUST be in that key’s list, or any of that schema’s types if the key’s value is void; - a value carrying
!typealone is LOCAL, resolved in the governing namespace exactly as at any other position; - a value carrying neither is a validation error — a value at a scoped position must name its type — in every mode, since the position promises no type for an untagged value to be read as (§7.1).
A cell the instance’s scope does not hold refuses the value it would have taken, as a validation error: nothing failed to resolve, and [TSON-DATA] §8.1’s categories make that of it at both cells — a value carrying !!schema at a declared position included, which is this rule’s case and not the restriction’s below. The read of an EXTERN value is the scope push below: load the named schema through the ordinary library (§10.1), so a schema this processor will not supply is a refusal and one it could not obtain leaves the value undetermined ([TSON-DATA] §8.1), never a verdict on the value; push the scope; resolve !type in it; validate in full; pop.
Values carry their scope. A value matched at an EXTERN cell MUST carry its own !!schema directive identifying the foreign schema and a !type annotation identifying the type within it — schema scope changes are always visible in the data, never implicit. A !!schema directive on a scoped value pushes the new schema scope for that value; when the value ends, the scope reverts:
!!schema:"https://tson.io/2026/medical/patient.tn?sha256=a4f2e8d1c3b5a7f9e2d4c6b8a0f1e3d5c7b9a2f4e6d8c0b3a5f7e9d1c4b6a8f0"
!patient_record {
patient: "1234"
attachments: [
!!schema:"https://tson.io/2026/insurance/claim.tn?sha256=f8b2a1d3c5e7f9a1b3d5e7f9a2b4d6e8f0a3b5d7e9f1a4b6d8e0f2a5b7d9e1f3"
!insurance_claim { claim_id: CLM-5678 amount: 450.00 provider: "City Medical" }
!!schema:"https://tson.io/2026/radiology/report.tn?sha256=d4e9c7f1a3b5d7e9f2a4b6d8e0f3a5b7d9e1f4a6b8d0e2f5a7b9d1e3f6a8b0d2"
!radiology_report { study_id: RAD-9012 modality: MRI findings: Normal }
]
}
Here attachments is declared [extern] — any foreign schema per element — or, where the schema means exactly those two, a narrowed instance listing both: attachments: [claim_or_report] over claim_or_report => !scoped { scope: [EXTERN] schemas: { "https://…/claim.tn" => _ "https://…/report.tn" => _ } }, which is the case the example exists to show and which no single-schema form could spell. Each directive binds to the single array element it prefixes ([TSON-DATA] §2.3, §2.7); the example follows the encoder recommendation of one directive-carrying element per line.
Composition order under a scoped element. Within a scoped value the order is fixed by the grammar: directive, then annotations, then the optional type annotation, then the core value ([TSON-DATA] §2.3, §3.1). The directive opens its scope before the element’s own augmentation resolves: annotations on a scoped element resolve against the newly scoped schema’s namespace (§3.3.3), and the type annotation resolves against its type-name namespace.
Typed-position restriction, derived. A nested !!schema directive is admitted at a position exactly when the position’s type resolves to a scoped instance whose scope holds EXTERN, containers descending as above — for a record field or map entry value the field or entry type applies; for an array element, the element type applies. Where it is not admitted, which rule refuses it depends on the position, and the two never overlap. At a position whose type is a scoped instance whose scope does not hold EXTERN, the cell rule above decides and the directive is a validation error: it is well-formed, the position is one that reads a scope’s cells, and nothing failed to resolve. At a position whose own type is not a scoped instance — a record, a choice, a value, or a container, whose elements and values are positions of their own — it is a resolver error, a push having no cell there to be refused by. The rule is no longer a list of permissive names: a position admits a scope push because its type says so, the way disjoint is a fact of a choice’s declarations (§8.1). A value-typed position in particular admits none — a single token is not a scope, and a pushed schema’s namespace is consulted only by the !name annotations a token position does not carry. The outer schema opts in to receiving foreign values at each position, by naming a type that admits them: cross-schema acceptance is authored intent, not accident.
A schemaless document opens no schema scope. A nested !!schema in a document with no !!schema of its own is a validation error naming the directive. The restriction above exists so that an outer schema can opt in, and a schemaless document has no outer schema to do the opting: admitting the push there would make a Class 1 document a Class 2 document halfway down, on the strength of the same absence that makes the check impossible, would give the one directive with no header-visible cost ceiling to a document nothing agreed to validate, and would produce a verdict on one value in a document that is otherwise not being validated at all. [TSON-DATA] §1.5’s classes stay decidable from the header: a reader knows from the first two lines which obligations it has taken on. A processor that meets such a directive reports it once, consumes it, and reads the value it prefixed schemalessly.
8. Resolver Output
The resolver’s output for a schema is a value of the kernel’s schema — a namespace from each name to the value its entry resolves to (§4.1): the output records and their conventions (§8.1), template instantiation and identity (§8.2), and references (§8.3). Each construct’s mapping to its value is defined with the construct in §4–§5. Resolved output and source are one grammar: output is source with sugar expanded, bodies flattened and chains computed, and an entry written as a constructor application resolves to itself.
8.1 Output Records: type_ref and type_argument
An entry is its value. A schema map’s entry resolves to the value its declaration denotes, a top, with no wrapper around it: person => !record { fields: { … } }, integer => !integer_type {}, age => !integer_type { chain: { supertypes: [integer] } min: 0 }, type_name => !reference { target: identifier }, vec => !template { … }, and a namespace entry orders => !interface { … }. Values are annotated with the structurally appropriate constructor: !record for vocabulary bodies (constructors, §4.2) and for every record, open or closed; a binding record headed by the applied constructor for every closed non-reference definition (§5.6); !reference { target: T } for reference-kind entries; !template { … } for an open entry that is no record (§5.10); and a namespace type’s tag for a namespace value (§4.1). schema’s value_type is top, and the subsumption rule of §7.2 admits every one with no exception: every annotation head carries top in its chain — constructors through type and their base kinds, a namespace type through namespace, reference and template by direct composition (§4.1) — and top being abstract, each entry is read by its tag among top’s subtypes.
Four things a reader might expect are deliberately not on the entry. There is no constructor field: whether an entry is a constructor is whether it is top or IS-A it, derivable from its chain (§4.2). There is no kind field: an entry’s kind is derived (below). There is no source field: a construction’s constructor is the value’s tag, a refinement’s origin is the chain’s nearest member, and the one fact a wrapper once held that nothing else states — the application an entry is the instantiation of — rides on the entry’s key as @application (§8.2). And there is no disjoint or members on the entry: each derived index lives in the body it is derived over.
A type carries its chain. Every type body — every value whose constructor IS-A type — carries type’s one field, chain, a type_chain (§4.1): supertypes, the closed IS-A chain, every element a bare name, complete to a fixed point through applied parents (§5.8) and sorted; subtypes, the inverse over the entries of this map; and, on a record template alone, parents, the parents as written, one of which may be an application (§5.10). An empty chain is omitted. A composition’s fields are flattened and its chain stands among them first. reference, template and a namespace value compose top and carry no chain — a hop, an open body and a map carry no IS-A, which is the rule. A declaration never writes chain and a constructor never declares a field of that name (§4.1).
Open entries. An open entry takes one of two forms (§5.10). A record template is !record { fields: { … } parameters: [ … ] }: an ordinary record body read as the vocabulary, with its parameters in place, a parameter standing in a field’s type or value or in an argument of an application, its discriminators stated as the family’s, its extension each application’s, and chain.parents the parents as written. Every other open entry is an instance of the kernel’s template constructor — template => top & { parameters: [template_param] template: text }, so set => !template { parameters: [{ name: T type: type_kind }] template: "!set_type { element_type: T }" }. The parameters are a list of template_param records, in declaration order — the arity, order and types an application binds against: template_param => { name: param_name type: type_kind bound?: type_kind }, where type is the type an argument is read as, derived from the body and narrowed by a written type, and bound is present only where type is type_kind (§5.10). It is a list of records rather than a map, which would lose the order arguments are positional in, or two parallel lists, which would be free to drift. type is a derived fact that may also carry what an author wrote — <N: int8> lives nowhere else — so ingest verifies it rather than recomputing it (below). A template’s application is text, the held body as written under §5.10’s one-spelling rule, since a body carrying a parameter in a value slot (min_items: N) is not a value of any constructor’s record shape and cannot be typed as one; what is compared — for identity, on ingest, and between two resolvers’ outputs — is the parsed form of the text, never the text, so whitespace is free (§5.10). A !template carries no chain, extension or discriminators: it is not a type, and an application of it is its expansion (§8.2). A consumer of closed entries never meets a !template (§1.3), and dispatches on a record template’s stated facts without reading a field that carries a parameter; a consumer that ingests a !template needs a schema parser, which ingest already requires (below). template is resolver vocabulary: nothing is ever typed by it, and a source declaration applying it directly is a resolver error — the parameter list <…> is the authored spelling of an open entry (§5.10).
Kind is derived. A consumer that needs an entry’s kind (§4.1) derives it from the entry’s value, in this order, and every conforming resolver derives the same answer: (1) the value is a !template → TEMPLATE, an open entry with no kind of its own; (2) else the value’s constructor head is reference → REFERENCE — the head lookup would say PRODUCT, since the kernel’s reference is itself a product, and §4.1 makes REFERENCE a kind the alias form confers rather than a base kind; (3) else the entry is top or IS-A it — it is a constructor — → the base-kind name among its own chain.supertypes (atom, sum, namespace), each counted only as the kernel’s own entry, or PRODUCT if none: a constructor’s kind states what its instances are, not what its own body is (integer_type has a !record body and is ATOM-kinded); (4) else → the kind of the entry the value’s constructor head names: ATOM, PRODUCT or SUM for an instance of a type constructor, NAMESPACE for a value of a namespace type. Branch (4) resolves the head through the structure namespace (§3.3.1), which a consumer able to read the body at all already holds. A kind is judged by origin, not spelling: a schema’s own record named namespace reaches none of the kernel’s entries, so a composition onto it is PRODUCT.
The type_ref and type_argument records. A slot that names the type of a value is typed type_kind (§4.2) and, in a closed entry, holds a bare name — record_field.type, tuple_element.element_type, array.element_type, map.key_type and value_type, choice.variants, template_param.type and bound — a type’s name, judged a member of the kind when the schema links. The structured reference type_ref, which carries arguments, is the application form and stands only where an application may: type_chain.parents, an open record’s record_field.type (§5.10), type_argument.name, the @application annotation, held text, and reference.target, which may name a constructor (§3.3.1):
type_ref => {
name: type_name
arguments?: [type_argument]
}
type_argument => {
( name: type_ref | value: value )
}
A type_ref’s name is the referenced type or, within a template, a parameter, and arguments carries the application’s positional arguments — present only where an application is still open or is an entry’s identity (§5.3): a use-site application resolves to a bare reference to its expansion, so arguments in output means “an application” and appears inside template bodies, in a record template’s fields and parents, in an open alias’s target and in @application only. A type_argument’s name member holds every reference — a type, an entry, or (in template bodies) a parameter of either kind — while its value member holds concrete literals only; exactly one member is present (the group is not optional and its two options hold one member each, §5.11). The reference/literal split is therefore structural: a token in a name member is always a reference, so value-typed token literals (enum members) can never be mistaken for parameter names, and member population is the open/closed signal — substitution replaces { name: P } with the bound argument ({ name: text }, { value: 1920 }) as parameters close. In a record template a parameter stands at a type_kind slot as written, resolved output included; in any other template it sits inside the template.template text.
The reference, record_field and field_map records. An alias body is !reference { target: T }, where target is a type_ref — a bare name for every closed alias, and an application with its arguments, in type_ref’s record form, where the alias is still open (<B> !reference { target: { name: pair arguments: [ { name: uuid } { name: B } ] } }, §5.10), so that a partial application states the arguments it binds. A reference is a hop: target names the immediate referent, and a use site elsewhere in the output that names the alias names the alias (§8.3). A record’s fields is a field_map — the namespace from each field’s name to its record_field (§4.1, §5.2): the name is the key, written once by its spelling, and each record_field carries its type (a type_kind value) and the four facts a reader consults — optional (the name’s ?, default false), voidable (the type’s ?, default false), role (FREE, DEFAULT or FIXED, default FREE) and value — each default omitted in output, and value present exactly when role is not FREE. Three invariants hold over them: value is present exactly when role is not FREE; DEFAULT requires optional: true, a default on a key that is always written being a value nothing can reach; and FIXED requires voidable: false, since the _ decision is taken before the pin is consulted. There is no separate parameter channel, and there is no stored omission answer: a reader derives it — a field that is not optional is missing, an optional one with a value injects it, except at a field group’s member, which the group governs and never supplies (§5.2, §5.11). In a closed entry value holds a concrete fixed or default value, read against the field’s declared type (§5.2); in a record template it may hold a parameter, unlabelled, since a token there is a parameter exactly when its text resolves into the enclosing record’s parameters (§5.10), and a closed entry has no parameters for one to resolve into. A record body also carries extension (omitted at OPEN), discriminators (present where the record is a member-dispatched base), name_type (present exactly where it was stated, §5.2), groups, and parameters where the record is a template. The field_map’s key family is the record’s name type — field_name where neither the record nor a supertype states one — and two keys that are one value under it are a resolver error (§5.2).
optional and voidable in output. The series’ two kinds of nothing have one name each wherever a record states them (§5.2). optional — the slot may be missing — is a field of record_field and of field_group, where it is the group’s own ?, beside optional_members for the members that may be missing once their option is chosen (§5.11). voidable — the slot’s value may be void — is a field of record_field, tuple_element, array and map, where it speaks of the field’s value, the position, the element and the entry’s value. A container’s part is never missing, so a container has no optional; a map’s key is never void, so map.voidable is its value’s alone; both are structural, and no rule has to refuse them. Each is a boolean defaulting to false and omitted there. There is no element_state and no state field: a binding record carrying one is refused as a member its vocabulary does not declare (§5.5), never read as the other fact.
Positional form. name is type_ref’s only unmarked-name field, so the general positional form (§5.6) applies at every type_ref-typed position: a bare name token fills name directly, and a braced record is the explicit form. Canonical output MUST use the bare token whenever arguments is absent; an explicit record without arguments is valid data but non-canonical. A type_kind slot of a closed entry holds a bare name and nothing else. type_argument deliberately has no unmarked-name field and hence no positional form: a bare token cannot self-classify as reference or literal, so its braced record is load-bearing, not ceremony. The name<args> sugar is schema syntax and not data syntax (§5.6): a data document at a type_ref slot writes an application as the explicit record, { name: page arguments: [ { name: order } ] }, and the spelling that names one is an alias in the schema (§8.2). record_field.type is type_kind, an atom, so a record_field has a positional form and a document may write fields: { x => text } for fields: { x => { type: text } }. Canonical output writes the explicit record, tagged: fields: { x => !record_field { type: text } }. The untagged record and the positional form are read as the same value, field_map’s value_type typing the position.
Reading parameter references. A parameter reference appears only inside an open entry’s own body — a held text, or a record template’s fields and parents — and every reference outside one names a type and is read directly against the schema. Within a held body, and within a record template’s fields and parents, parameters and type names share the identifier type, so a name — in any type_ref, at any depth, including type_argument name members and reference.target, and at any type_kind slot — resolves against the entry’s own parameters list first, then the schema’s type-name namespace, the same precedence used during source-level parsing (§5.10); a name at a constructor-channel position — the held body’s head, or a body-annotation head — resolves through the structure namespace (§3.3.1), the same channel that resolved it at source level. The two channels are consulted at disjoint positions and never mix (§2.2.3): type-name tokens are data scoped to the schema being described, while annotation and constructor heads belong to the governing chain. Inside a held body a parameter may also stand in a value slot — a constructor’s scalar field, an enum’s member list — and in a record template in record_field.value, read by the same precedence (§5.10). A token in a closed body that would resolve only as a parameter is an unresolved reference, the verdict every other reference form gives it.
@application is the one fact the value does not state. An entry that closes a template application carries, on its schema-map key, @application:{ name: box arguments: [{ name: int32 }] } — the canonical application, arguments with reference chains followed to their terminal entries and nested applications resolved to entry names (§8.2, §8.3). It is the entry’s identity as an instantiation, and an entry of any kind can carry it: a record template’s declared member, a namespace entry closed from a templated instance, the alias a template’s application closes to, a self-reaching application’s instantiation entry (§8.2). It is a fact about the entry and not about a type, so it lives beside the entry as @synthetic does, is derived — an author’s @application is a resolver error, as a written chain is — and is read as stated on ingest, not recomputed (§6, §10.1), which is what lets an imported schema’s instantiations unify with a later schema’s. Nothing else records provenance: which operator a refinement used, and what a subtracted record was subtracted from, are what was written, nothing reads them after resolution, and the source document has them.
The derived indexes: subtypes, members, disjoint. Three indexes are computed by the resolver and never set by a declaration, each living in the body it is derived over. chain.subtypes is the transitive inverse of supertypes across the schema’s namespace — “what is in family X?” — and supertypes answers “is this in family X?” by name; a parent written as an application contributes, once the composing entry closes, the name of the entry its arguments name — ok<text> indexes result<text>, never a head name (§5.8) — and a record template is indexed by its own name, its members being the applications a declaration names (§5.10). supertypes’ standing differs from subtypes’: subtypes is a cache, fully derivable and never trusted, while supertypes is part of a type’s serialized meaning — the chain and a flat body cannot be recomputed from each other, and for the atom family the hop lives only there, since desugaring erases the surface distinction between refining an instance (age => !integer ^ { … }, IS-A integer) and constructing a fresh sibling (port => !integer_type { … }, IS-A nothing), both serializing under the !integer_type tag with an identical body shape. Construction and expansion are not IS-A: a sugar construction (ids => [order]) carries no supertypes (§5.6), and a template’s expansion carries whatever chain the expansion’s own ^ or & gives it — a record template’s declared member IS-A the template (§5.10) — but a minted record takes no edge to a parent application nothing declares, and is a member of nothing (§8.2). members is kind_type’s index (§4.2): the names of every entry in the governing closure built with of, computed at link over resolved entries and sorted as subtypes is, so identifier_kind => !kind_type { of: identifier_type members: [field_name identifier param_name scheme_name type_name] }. disjoint is choice’s: choice => sum & { variants: [type_kind] disjoint?: boolean }, and for every choice, declared or synthetic, the resolver writes true or false beside the variants it is derived over — discrimination-class distinctness, a total, two-valued derivation (§5.4). It sits there rather than on the entry because a variant list is the only thing it is a fact about, and §7.2’s closed-record rule refuses disjoint on any other body, so the non-choice sums (the scoped instances, §7.8) need no carve-out; the @disjoint annotation (§5.4), when present, is the author’s assertion checked against this derived value at schema load, and does not appear in the value itself. Like every name in resolver output, the names in these indexes are tokens in the described schema’s own namespace (§2.2.3): an index never reaches outside the resolution that wrote it, and a consumer follows the entries in the defining schema’s output.
Ingest. When resolved output is ingested as data (§10.1): the resolved document is a data document, and its header admits only !!id and !!schema ([TSON-DATA] §2.2, §3.3) — it cannot carry !!import, and its !!schema names its own governing chain, not the schema it describes. The described schema’s identity, and the import list that constitutes the namespace in which the map’s type-name tokens are interpreted (§2.2.3), are therefore not recoverable from the resolved document and MUST be supplied to ingest explicitly; the source schema document’s header, held in the library (§10.1), is the sole authority for both. Then: chain.subtypes, every kind’s members, disjoint in every choice body, and the derived annotation @synthetic on synthetic entries’ keys (§8.2) MUST be discarded and recomputed, so none can be forged into a document to change how it reads; an entry’s kind is derived on the same terms and was never in the document to trust (above). chain.supertypes and chain.parents are read as stated, never recomputed, with integrity verified — every listed supertype must exist, the list must be closed to a fixed point, an atom’s supertypes must share the entry’s constructor tag, and a record’s must be consistent with the flattened fields — because a flat body and its chain cannot be recomputed from each other (§4.1). @application is read as stated (above). Author-written annotations, key- and value-position alike (§6), are preserved as data, a restated field’s in the order §5.8 fixes. The closed-entry rule (§5.10) MUST be verified — no token inside a closed body that resolves only as a parameter (§5.6); a !template’s held text is parsed and re-resolved as source against the supplied namespace, since a held body is never read against a vocabulary until it closes, and the parsed form MUST equal the one-spelling form §5.10 requires; the text is never compared as text. Each parameter’s recorded type and bound are verified, not recomputed: ingest derives them again and checks that a recorded type IS-A the derived one and a recorded bound IS-A every inherited one — the rule a written type already met at the declaration (§5.10) — so that a written narrowing survives and a stale or altered one is refused, where discarding and recomputing would silently drop every type an author wrote. Every entry carrying @application MUST equal the recomputation by substitution from it, and every synthetic entry MUST carry the body its constructor’s vocabulary admits — the self-describing checks. A checked annotation (§6) is re-checked on ingest as it was at load: @disjoint against the recomputed disjoint. Every record’s and enum’s names are re-judged against its name type and every kind-typed slot’s name against its kind (§4.2, §5.2). The family checks of §5.2 — every member of a member-dispatched base pinning each selector, pins distinct as values, nothing composed onto a FINAL record, no minted record a member of anything — are re-run over the ingested map and the closure that holds it (§3.3.4), the declaration rules of §5.11 are verified over every field_group, and the three record_field invariants above are verified per field. Canonical-form violations (an explicit type_ref record without arguments, a positional record_field, a chain written where none is admitted) are resolver errors at ingest. Within-family retargeting — a document claiming an entry refines a different sibling of the same constructor — is internally consistent and undetectable from the document alone; this residual gap is one reason resolved-form documents are never schema sources (§10.1) and ingest is an explicit, opt-in act.
Body patterns (informative — the construct sections govern):
| Source form | Resolved value | Examples |
|---|---|---|
Root record abstract {} | PRODUCT; !record { chain: { subtypes: [...] } fields: {} extension: ABSTRACT }; no supertypes | top |
type, a base kind, namespace | PRODUCT; !record { chain: { supertypes: [top …] … } … extension: ABSTRACT } | type, atom, sum, namespace |
Fresh record { fields } | PRODUCT; !record { fields: { … } }; no supertypes | person, record_field |
Composition A & B & { ... } | !record { chain: { supertypes: [A B …transitive] } fields: { …flattened } }; kind per §5.5 | employee |
Refinement T ^ { ... } | chain.supertypes: [T …]; materialised body | production |
Subtraction T - { f } | !record { fields: { … } }; no supertypes (§5.9) | account_public |
Atom constructor atom & { ... } | ATOM; IS-A top; chain.supertypes: [atom top type] | integer_type, enum_type, kind_type |
Product constructor product & { ... } | PRODUCT; IS-A top; chain.supertypes: [product top type] | record, array |
Sum constructor sum & { ... } | SUM; IS-A top; chain.supertypes: [sum top type] | choice, scoped |
Namespace type namespace & { key_type: = K value_type: = V } | NAMESPACE; IS-A top; chain.supertypes: [namespace top]; a record of its pinned facets | schema, field_map, interface |
Constructor refinement T ^ { ... } in a meta-schema | IS-A top through T; chain.supertypes: [T …] | set_type, enum |
Instance template <T, N> !C { ... } | !template { parameters: [{ name: T type: type_kind } { name: N type: … }] template: "!C { ... }" } (§5.10) | vector, set, kind, ref, extern_of |
Constructor instance !T {} | kind from T’s family; !T {}; no supertypes | integer, value, dynamic |
Kind instance !kind_type { of: C } | ATOM; !kind_type { of: C members: [...] }, members derived (§4.2) | identifier_kind, type_kind |
Atom refinement !I ^ { v } | !ctor { chain: { supertypes: [I …] } v } | age, non_negative_integer |
| Sugar or constructor-application body | construction in place; binding-record value; no supertypes (§5.6) | lookup => {text => integer} |
Record template <T> { ... } | !record { fields: { … } parameters: [{ name: T type: type_kind }] }, a family base nameable at a type position, ABSTRACT by construction, chain.parents as written (§5.10) | container, pair |
| Sugar form at a use site | lifts to a synthetic entry — closed (constructor body) or open (!template) per §5.3; the use site holds a bare reference | [text], {text => integer}, [T; N] in a template body |
| Template application | the declaration naming it, with @application on its key; else its expansion, a closed synthetic, beside which a self-reaching application keeps an instantiation entry, a !reference to the synthetic carrying @application (§8.2) | string_triple => vector<text, 3>, a use site’s set<text> |
Namespace value !N { k => v … } | NAMESPACE; the map as written, the type’s tag on it; no chain (§4.1) | orders => !interface { … } |
Choice (A | B) | SUM; !choice { variants: [...] disjoint: … }, the fact always recorded in the body (§5.4) | contact_method |
Field group ( a: T | b: U ) | members flattened into fields as optional: true; grouping in record.groups, one list of members per option (§5.11) | integer_type, type_argument |
Abstract base with selectors abstract { a: T =? … } | PRODUCT; !record { … extension: ABSTRACT discriminators: [a] }; members in subtypes pin a (§5.2) | pet |
Reference name | REFERENCE; !reference { target: name }; no chain — a bare name, never a fully-bound application, which is an instantiation in place | id => uuid (§8.3) |
The schema type is a namespace from type names to values; schema lookup is by name. Declared entries are keyed by their declared names; synthetic entries and self-reaching instantiation entries are keyed by their internal names, each synthetic entry’s key carrying the derived @synthetic marker (§6, §8.2).
8.2 Instantiation and Synthetic Entries
Resolver output contains internally named entries beyond the declared ones, and one identity discipline covers them all. Every application materialises, as its expansion: a sugar form or constructor application at a use site lifts to a synthetic entry — closed or open per §5.3’s lift rule — and a fully-bound application of a template materialises its expansion, the closed synthetic entry the substituted form lands on, keyed on its content (§5.10). Nothing is carried structurally in place: a use site holds a bare reference to its entry, and type_ref.arguments in output means an open application inside a template body or a record template’s fields and parents, an open alias’s target, or an @application, nothing else (§8.1). A declaration’s own body resolves in place as a construction (§5.6) and is the one thing that never lifts.
Trigger positions. Lifting and materialisation fire wherever an application is named at a type position: record field types, group member types, tuple element types, array and map element positions, choice variants, and arguments at any depth. A top-level type-def body that is a fully-bound template application makes the declaration the entry itself (below). Composition targets and refinement sources remain restricted to named type references, optionally with arguments; inline structural forms there are resolver errors (§5.7, §5.8, §12.1), and an application there is subsumed as an operand and mints nothing (§5.8). An open application inside a template body does not materialise — it closes when its template does (§5.10).
An application nothing declares is its expansion. A use site naming set<text> names the synthetic !set_type { element_type: text } lands on — the entry a [text]-style sugar or another template expanding to the same form also lands on; a use site naming box<int32> names a closed synthetic record, keyed on its content, which crate<int32> over a template with the same field list lands on too. No instantiation entry is minted and nothing records the application: the expansion records nothing a value can tell apart, since it admits exactly the synthetic’s values. The one exception is a self-reaching expansion: nested => <T> ( T | [nested<T>] ) closed at nested<text> names itself inside its own body, so its content cannot be written without a name, and the knot is tied on the application, which keeps an instantiation entry — a !reference to the synthetic, keyed on the canonical application, carrying @application (§5.10.1). The rule is total: whether closing reached the application again decides it. A minted record is a member of nothing. A minted record whose chain would name a declared type — k: dog_of<text> over dog_of => <T> pet & { … }, or a parent application a declaration owns — would be a family member with no name a read, a tag or a binding could use, and is a resolver error at the declaration that wrote the application (§5.2). A parent application nothing declares gives a minted record no chain edge: u: labelled<text> over labelled => <T> box<T> & { label: text }, beside w: box<text>, loads, the minted record IS-A nothing. A minted record gains no IS-A edge later; a declaration’s operand still reaches it — vip => customer & box<int32>, beside a use site’s box<int32>, is IS-A that synthetic and stands where crate<int32> is expected, the two applications being one type. A declaration names only itself. An unnamed form with a declaration’s content is not that declaration: beside text_list => [text], a field written t: [text] names the synthetic, and !text_list [a] there is refused by §7.2; collecting would need a new rule for every form kind and would make two declarations sharing a body ask whether they interchange, and they are two types. A diagnostic names what the author wrote, and nothing for a form with no spelling: a minted atom instance (b<10> over b => <N> !integer_type { min: N max: 100 }) has no sugar spelling and no author-written name, so a message about it is the family’s own ('5' is less than the minimum 10), located by its position, never the entry’s content-derived key; a minted record is named as the body a record is written with (unknown field 'nope' on '{ v: int32 }').
Two identities. A declared entry’s identity is its name: two declarations are two types however alike their bodies — stock_id => !uuid_type {} and other_id => !uuid_type {} are two atoms, and a purely structural rule would collapse them and destroy a distinction §7.2 enforces (§5.5). A declaration whose body denotes a type is that type’s entry, in both lift channels: text_list => [text] is the construction in place (§5.6), and bx => box<text> is the instantiation in place — the substituted binding record its value, closed under the author’s name, @application on its key, with nothing minted beside it and no !reference hop. A use-site application resolves to a declaration that owns it where one exists, so “two fully-bound applications denote the same entry” holds within a schema. Two declarations naming one application are two entries, neither privileged and neither collapsed — what a => { v: text } beside b => { v: text } already is, and so what t => <T> { v: T } with a => t<text> and b => t<text> has to be; where the pair is a discriminated family §5.2’s pin-distinctness rule refuses it, exactly as it refuses the hand-written pair. What this gives up is stated plainly: a content-derived name is a function of the form alone, which is what let two independently resolved namespaces agree on it across the import merge (below); a declared name cannot carry that, so a schema writing box<text> that has never seen the schema declaring bx lands on the content-keyed expansion while that schema calls it bx, and the merge sees two names for one form. That costs nothing a value can tell — both entries admit exactly the same values, and a use site in the importer resolves to the declaration owning the form — and it cannot reach a family, a family member being declared. A minted entry’s identity is its canonical content, since it has no declared name to be its identity; “identity is structural” is true of the minted half only, one rule per family:
- Self-reaching instantiation entries — two fully-bound applications denote the same entry if and only if their canonical applications are structurally equal: the application with every argument’s reference chain followed to its terminal entry and every nested application resolved to its entry’s name (§8.3), compared member-by-member — heads, argument order,
nameagainstname,valueagainstvalueunder the rule below. Following the chain is what makes a rename transparent:box<user_id>overuser_id => uuiddenotes the same type asbox<uuid>, because a reference is the same type under another name (§5.7’s table) — wherebox<!uuid ^ {}>andbox<!uuid_type {}>, a refinement and a fresh instance, keep entries of their own. The canonical application is exactly what@applicationrecords, so identity is a single-level structural comparison of it, and spelling variance never forks an entry; the name the author wrote is provenance, not identity, and survives at the use site that wrote it. A declared instantiation’s identity is its name (above); a use site reaches it by the canonical application, compared on these terms. - Value arguments compare as values, and are recorded as written. A literal argument is recorded in
@application(and substituted into the body) as the token the author wrote, so resolver output round-trips the spelling; identity, however, compares the value the token denotes under [TSON-DATA] §4 — exactly the equivalence §4.3 states and no wider. Radix, digit separators, and a redundant sign fall away (255,0xFF,0b1111_1111,0o377, and+255are one argument), a float’s written scale falls away (.5/0.5,1.0/1.00/1e0), and.inf/.infinityare one value; quoted strings compare by text. What does not fall away is the base type: §4 resolves1to an integer and1.0to a float, so those remain two arguments even though one magnitude covers both.vector<float32, 255>andvector<float32, 0xFF>therefore denote one entry, and a choice listing[float32; 255]and[float32; 0xFF]as two variants is the same-variant-twice error of §5.4, not an accepted non-disjoint choice. - Closed synthetic entries — structural equality of the resolved binding record: one entry per distinct concrete form schema-wide, whichever spelling or template produced it. A closed synthetic carries the constructor it builds as its tag, never the application that produced it, which is what lets every channel land on it.
- Open synthetic entries — structural equality of the parsed held body up to consistent renaming of parameters (
<T, N>and<A, B>over the same shape are the same template), normalised by renaming parameters positionally (p0,p1, …) before comparing; the text a!templatecarries is never compared as text (§5.10). This is what “one spelling” exists for:<T> { a: [T] }written twice mints one entry, and an entry’s content-derived name is computed over the renamed parsed form.
The channels dedupe against each other’s products: [order; 1..] written directly in a plain declaration and the same form arising inside a materialised template land on one closed synthetic entry, because both comparisons occur after names have meaning, over resolved structure. The moment is normative: desugar-time lifting creates a synthetic entry, but its identity settles after Pass 2, when references have resolved — eagerly-lifted synthetics that become structurally identical under resolution merge into one entry, so the one-entry-per-form rule holds schema-wide regardless of which moment produced each candidate. The merge pass is required, not an optimisation: a closed lift hashes its binding record at desugar, before its inner applications are rewritten to entry names, while an open lift hashes the closed record at materialisation, so without a pass that re-derives every synthetic’s identity from its resolved record, [box<text>] written directly and [box<T>] closed with T := text land on two entries for one type (§5.10). The first occurrence materialises; later occurrences reuse; recursive references resolve to the entry’s own name (§5.10.1). Closure consults templates and never modifies them: closing grid<pixel, 4> after grid<pixel, 3> reuses the template entry and both open synthetics untouched while producing fresh closed entries.
Synthetic entries are an implementation’s own. Their names are resolver-chosen and their shape is unconstrained beyond producing the required resolved output: a processor is free to mint one per schema, share them, alpha-normalise their parameters, or keep them however its own resolver requires, and the lift targets are never kernel-declared (§5.3). An author never writes one and never references one; the one thing an author does is name an application they want to reuse, and the spelling for that is an alias — order_page => page<order> — which is one line, gives the application an identity and a diagnostic located at the author’s own declaration, and is RECOMMENDED over repeating the application or the explicit record form (§8.1).
Entry names are internal. Minted entries are named by the resolver, and the names are not part of this specification’s conformance surface: an implementation chooses them freely, subject to two rules and one recommendation. Freshness (MUST): an internal name is a valid identifier and collides with no declared entry and no other internal entry. Stability within a resolution (MUST): all references to one entry use one name. Determinism (SHOULD): names SHOULD be derived from the entry’s identity-bearing content — the canonical application for a self-reaching instantiation, the resolved body structure for a closed synthetic, the parameter-normalised body for an open one — a readable head plus a structural hash, for instance. Content-derived names keep re-resolution output diff-stable, and they are what makes independently-resolved namespaces agree on their internal names wherever their structures agree — which is what lets the import merge (below) unify rather than collide. Internal names do not exist at source level (there is nothing to author), MUST NOT be relied on across resolutions or implementations, and resolved-form comparison between implementations is structural — equality up to renaming of internal entries, with set-typed fields compared as written, in source declaration order (§7.5). Two consequences for a consumer follow and are stated because a wrong pick is invisible until someone writes a configuration: no consumer contract may require a derived name — a binding map, a generated class table, a configuration file keys on a name some declaration gave the application, and a processor holding both SHOULD show and accept the declared name wherever it shows or accepts one, falling back to the entry’s own only for an entry no declaration names; and the declared applications of one template are recoverable from @application — a declared instantiation records the head and arguments on its key, so the family a host language spells Result<T> is a walk over entries and needs no index. A derived name written in data (!box_text_04117bb4) is a valid identifier in the namespace and is not refused; such a document was written by reading one processor’s output and fails at the next, whose spelling differs.
Entry shape. A declared instantiation: @application on its key, the canonical fully-bound application; its value the substituted binding record, headed by the applied constructor (§5.6) — closed, with the expansion’s kind by derivation and whatever chain the expansion’s own ^ or & gives it: for a record template’s member, chain.supertypes holding the template and everything it IS-A, plus the closed parent an open parents application named (§5.8), its extension the record’s own (OPEN unless the template wrote abstract), never the base’s ABSTRACT, and no discriminators — closing pins each selector a parameter pins to its argument, and a =? selector is pinned by no argument, so closing does not pin it (§5.10). A synthetic entry resolves under the top-level-construction rule: a binding-record value headed by the constructor (a !template while open), no supertypes, @synthetic at its key (§6, §8.1). Materialisation also runs the value-level checks that the held body deferred: every family coherence rule that §5.3 and §5.5 state for a literally written body applies again at materialisation, over the operands that were parameters — min_items ≤ max_items for the containers, min ≤ max and min_length ≤ max_length for the atom families, min_prefix ≤ max_prefix and the prefix range for the network families, and any rule a family states that is not a bound pair — together with the conformance of every substituted value to the slot it fills (§5.2, §5.10). A resolver needs no list: the rule an application closes onto is the rule the family already owns for a literal body, asked once more of the closed record. A violation is a resolver error located per §5.10’s rule — at the declaration whose text wrote the offending name, with the materialising application as context. For the §5.10 template vector => <T, N> !array { element_type: T min_items: N max_items: N }, a declaration hd_row => vector<pixel, 1920> resolves to
@application:{ name: vector arguments: [ { name: pixel } { value: 1920 } ] }
hd_row => !array { element_type: pixel min_items: 1920 max_items: 1920 }
while vector<pixel, 1920> written at a use site lands on hd_row, the declaration that owns it. Where no declaration names it, the use-site application and the structurally identical form written directly as [pixel; 1920] anywhere in the schema both land on one closed synthetic entry — the same body, @synthetic at its key, no @application — which is what §5.3’s “a use-site sugar form mints where a declared application does not” comes to: the sugar and the application are two spellings of one expansion. [pixel; 1920] lands on that synthetic beside hd_row too, a declaration naming only itself (above).
Non-exposure and the import merge. Minted entries are resolver-materialised, not declared: they cannot be named from any schema source, and authors reach a type by writing the application (reusing the entry) or by declaring a name for it, which is then the entry (above). Under the transitive import merge (§2.2.3) the internal entries travel with their namespace — an imported entry’s body references them, so they are part of what the import delivers — and they merge by the same structural identities as within a schema: an importing schema whose own application derives an identical entry denotes the merged entry, never a duplicate, and internal names never collide with declared names by construction (§3.4.1). A later schema’s application of a template an imported library declared and applied is unified with the library’s declared instantiation through the @application the library’s output carries (§8.1).
Diagnostics and cross-schema identity. Internal names MUST NOT be the primary form shown to users: diagnostics surface the source application or form — recoverable from the originating source position, and from @application where an entry carries one — located at the declaration whose text wrote the offending name (§5.10). Cross-schema identity of declared types is through named declarations or not at all; internal entries carry structural identity only.
8.3 References
A type-def body that is purely a type reference — a bare name or a template application, with no body record, no &, and no construction — produces a REFERENCE-kind entry whose value is !reference { target: T }, where target names an entry (§8.1):
- Simple leaf (
id => uuid):id => !reference { target: uuid }. Thetargetis the immediate referent, not the ultimate type — reference chains (user_id → id → uuid, overid => uuidanduser_id => id) appear as distinct entries, each pointing one hop forward. - Fully-bound template application (
string_triple => vector<text, 3>) is not a reference: the declaration is the instantiation entry itself, closed in place with the application as@applicationon its key (§5.10, §8.2), exactly as a sugar-form body —string_triple => [text; 3]— is a construction in place (§5.3, §5.6). A!referencebody is a bare name or a still-open application, never a closed one — with the one exception of a self-reaching expansion’s instantiation entry, which is a!referenceto its own synthetic (§8.2). - Open template application (
same_tree => <T> tree<T>,uuid_pair => <B> pair<uuid, B>— an application of a named template with at least one parameter still open): the declaration is itself a template (§5.10);targetis the application itself, arguments included —reference.targetis atype_ref, so the alias states what it already binds. Reference-kind templates compose during substitution without materialising intermediate entries. - An alias of a constructor (
my_record => record, in a meta-schema):targetmay name a constructor, the one slot besidekind_type.ofand a!head that may (§3.3.1), and the alias is applied as its target is.
A top-level constructor application is not a reference — it resolves as a construction in place (§5.6). !reference { target: X } is the explicit spelling of the alias X and denotes exactly that (§5.5).
The correction is worth its sentence: an earlier revision filed a declared application by its syntax — a parameterised reference — and made the declaration a hop to something else’s identity, so that pet.subtypes read a minted name where the author had written dogs, and every surface that showed a name had to follow the hop back. Filed by what it is — a construction — the declared name is the entry, which is what a family’s subtypes index, a tag, and a diagnostic all key on.
A reference is a hop, not a rewrite. Resolved output states a reference chain as the author wrote it: a use site — a field type, tuple element, choice variant, argument, or binding-record type slot — that names a REFERENCE entry names that entry, and target names the next. Nothing is flattened, and no annotation records a source-level name, because the chain itself is the record: an earlier revision rewrote each use site to the chain’s terminal entry and attached an @alias naming the first hop, which was a second, lossy representation of a walk that happens anyway — a chain of two hops kept the name of the one that carried nothing — and every pass that needs the terminal entry (reader compilation, discrimination-class classification, inhabitance, variant distinctness, kind membership) walks the chain regardless. A processor MAY collapse a chain after linking, when it compiles for reading — a REFERENCE entry’s reader is its target’s, named for the entry doing the referring, which is what gives a diagnostic the alias the author wrote for free — and MUST NOT collapse it in resolved output, which is what two conforming processors compare. The walk stops at an argument-bearing target — that is an application, not a hop to another entry, and there is no entry at the end of it until materialisation produces one.
Where a chain is followed, and where it is not. Identity of an application follows its arguments’ chains to their terminal entries, because a reference is the same type under another name (§8.2); subsumption at a typed position follows both chains (§7.2); the source of a refinement or composition, a choice variant judged for void, a discrimination class, a kind’s membership and a qualified name’s first segment are all read after following the chain (§4.2, §4.3, §5.4, §5.7, §5.12). What the chain never does is carry meaning of its own: nothing that changes how a value reads may live on a reference, and the meta layer declares no annotation that does — a fact that decides reading, such as an alphabet, lives in the type (§5.5), where every position already names it. That is what makes a rename safe to see through.
References are not supertypes. REFERENCE-kind entries carry no chain and do not contribute to the supertypes/subtypes graph: uuid.subtypes does not list id; id has no supertypes. The three ways to name a type after another, and what each buys, are tabled in §5.7.
Instantiation entries are not references. A declared instantiation (§8.2) carries its expansion’s kind and a binding-record value, not a !reference record; a REFERENCE entry may point at one by name (alias => string_triple), and the instantiation itself is a closed, concrete definition. The self-reaching instantiation entry of §8.2, and an alias a template’s application closes to (my => id<text> over id => <T> T), are the !reference entries that carry @application.
9. The Meta Layer
Two pre-loaded schemas form the meta layer, and one library beside core instances meta’s network constructors. Implementations MUST pre-load the kernel and meta (§3.4, §10.1). The normative source for all three is carried in the companion artifacts (§1.5), pinned by content hash at publication; the pre-loaded in-memory model is authoritative and the documents are descriptions of it.
| Schema | Role | Declares |
|---|---|---|
2026/ | Self-referencing bootstrap; its !!id and !!meta reference its own URL | top, abstract; type => abstract top & { chain?: type_chain }, the kind of the kinds that describe data values, with type_chain => { parents?: [type_ref] supertypes?: [type_name] subtypes?: [type_name] } (§4.1); the base kinds atom/product/sum, abstract, each composing type; namespace => abstract top & { key_type: identifier_kind value_type: type_kind ordered?: boolean ~ false }, the constructor whose instances are maps keyed by names, with its two compositions schema => namespace & { key_type: = type_name value_type: = top } and field_map => namespace & { key_type?: ~ field_name value_type: = record_field ordered?: = true } (§4.1, §5.2, §8.1); reference (§8.3); the parameterless record/array/set_type/map/tuple/choice constructors (§4.2) — every type slot typed type_kind, array and map carrying voidable and ordered, set_type refining array with voidable, ordered and unique_items pinned and no bound of its own (§5.3); record carrying fields: field_map, extension?: record_extension_type ~ OPEN over record_extension_type => !enum [ABSTRACT FINAL OPEN], discriminators?: [text; 1..], name_type?: identifier_kind and parameters?: [template_param] (§5.2, §5.10); the record_field supporting record with its type and four facts optional/voidable/role/value over field_role => !enum [FREE DEFAULT FIXED], carrying no name (§5.2, §8.1); tuple_element with voidable, and field_group with members as a list of options of text, optional_members and optional (§5.11); the type_ref/type_argument supporting records (§8.1), with choice carrying disjoint; the template constructor (top & { parameters: [template_param] template: text }) whose instances are held bodies, with template_param => { name type: type_kind bound?: type_kind } (§5.10); the kinds kind_type => atom & { of: type_name members?: [type_name] }, its inline spelling kind => <T> !kind_type { of: T }, and the three named kinds identifier_kind, atom_kind and type_kind (§4.2); the reference atom ref_type => atom & { of: type_name } and its inline spelling ref => <T> !ref_type { of: T } (§4.2, §5.12); the enum constructor enum_type (name_type: identifier_kind, members: enum_set) and its tightening enum (§7.4); the non-empty member sets enum_set, integer_member_set, text_member_set and scheme_set; the atom-constructor/value_type/value and void_type/void (§4.2), integer_type/integer (with the integer_size supporting record, size, the bound groups, multiple_of and members), text_type/text (with pattern and members, each settable once, and normalization over the normalization enum, §5.5, §5.7), identifier_type/identifier (the UAX #31 profile facets over identifier_base => !enum [XID ID ASCII NONE], §7.4) with its second instance scheme_name over the ASCII base, iri_type/iri (RFC 3987: schemes, allow_relative, allow_fragment, the kernel’s iri requiring a scheme and typing atom_specification.spec, §5.5) and regex_type/regex; the identifier roles type_name/field_name/param_name (naming positions over identifier); non_negative_integer (!integer ^ { min: 0 }, the type of every counting facet); boolean; atom_specification; the internal enums; the annotation types annotation, doc, and the resolver’s derived synthetic and application => @annotation type_ref (§6, §8.2) |
2026/ | Canonical meta-schema; chains to the kernel and imports it | set (the set<T> template over set_type, §5.3); bytes_type (with bytes_encoding, §5.5); scoped (with scope_kind, the non-empty scope_set, and schema_identity => !iri_type { allow_fragment: false } typing its schemas keys, §5.5, §7.8); the constraint-vocabulary constructors for numeric (float_type with ieee_format, decimal_type with members over decimal_member_set, rational_type, complex_type with complex_component — a partial order under refinement, §5.7), temporal (date_type, time_type, datetime_type, duration_type, period_type — all five with the exclusive bound groups, the last three of time/datetime/duration carrying precision, duration and period carrying multiple_of, §5.5), identifier (uuid_type, and uri_type with the kernel’s iri_type’s facets over RFC 3986, §5.5), network (ipv4_type, ipv6_type, cidr4_type, cidr6_type, mac_type, hostname_type with allow_idn ~ true over RFC 5890, host_type over RFC 3987’s ihost, and media_type_type with allow_parameters ~ false, types and suffixes over RFC 6838 — each constructor’s instance living in net.tn), and text (email_type) families; the annotation types — the classifying markers ordering, bounded, exact, numeric; the checked disjoint; the documentary deprecated, title, comment, examples, read_only, write_only (§6) — and no representation directive (§6) |
2026/ | The network library; governed by meta, importing nothing | The instances of meta’s network constructors, one each: ipv4, ipv6, cidr4, cidr6, mac, hostname, host and media_type, each the type [TSON-DATA] §5.5’s row of the same name defines; the schemaless annotations !ipv4 … !media_type denote these entries ([TSON-DATA] §5.5) |
Each atom family is a constructor/instance pair (§4.2): the constructor lists the family’s constraint fields, and the canonical instance lives in the kernel, in core or in net (value, void, integer, text, identifier, iri, regex, the three kinds in the kernel; number, float32, float64, rational, complex, date, time, datetime, duration, period, bytes, uri, uuid, email and the rest in core.tn; ipv4, ipv6, cidr4, cidr6, mac, hostname, host and media_type in net.tn). Two rules hold across every family and are stated once in meta’s header: multiple_of, wherever it appears, is strictly positive, tests the value’s magnitude with the sign ignored, tightens only to an integer multiple, and composes with every other facet; and members, wherever it appears, has every member satisfying the body’s other facets — text_type’s pattern included — and a refinement shrinks the set on the numeric tiers and sets it once on text_type (§5.7, §7.4). Every set-typed field is emitted in source declaration order (§7.5). Meta’s kernel import is doubly load-bearing: it supplies the kernel types meta’s own declarations use and delivers the kernel’s structural vocabulary to every meta-governed schema (§3.3.1). Annotation types live in the chain because of the one-hop rule (§3.3.3, §6).
Core holds only what a schema cannot do without. §2.2.3 makes it a resolver error for a local declaration to reuse a name the import closure binds, with no hiding, so every name core declares is one no importing schema may declare, and a name core added in a later revision would break every schema already using it. Core therefore declares the types a schema cannot reasonably write for itself: the atoms — number, rational, complex, float32, float64, bytes, the five temporal families, uuid, email, uri_reference and uri, iri_reference and iri — the integer widths int8–int256 and uint8–uint256, the scoped instances declared, extern and dynamic with the templates extern_of and extern_type (§7.8), and the templates set, kind, tuple1 and voidable_tuple1 (§4.2, §5.3). It leaves to the schema that wants one any name that stands for a single line it can write itself: a sign-bounded integer or a non-empty text is a refinement declared where it is wanted (count => !integer ^ { min: 0 }), and a schema that types a value or a map key as a name constructs its own identifier (§7.4). A family most schemas never use belongs in a library outside core, where its names cost only the schemas that import it: the network families — ipv4, ipv6, cidr4, cidr6, mac, hostname, host and media_type — are net.tn’s, so a schema importing core alone may declare a mac or a hostname of its own, and one that wants the network types imports net.tn as well. Core declares fresh siblings of kernel and meta entries under the same names — void, boolean, integer, text, regex, iri, set and kind, and one annotation type, doc: the same constructions and contracts, distinct type entities (§3.3.5) — so that data documents and user schemas governed by or importing core can use them. Its iri is the one sibling written differently from its original — as a refinement of iri_reference, so that every iri IS-A iri_reference — and its resolved body is the kernel’s. Core declares no ref (§4.2): a reference names an entry that holds a value, which only a kernel-governed schema has. There is one bytes, with no spelled siblings (§5.5).
JSON. A JSON document is read through the JSON encoding of [TSON-JSON], a second encoding of this model and not a mode of the TSON notation ([TSON-DATA] §6): it maps a JSON number to number, JSON null to void — admitted where the position is voidable (§7.6) — and a JSON object to a record where its keys are names its record admits, identifiers by default (§5.2). Every type doc in the artifacts describes its value space without reference to any encoding and names TSON text where it means it; the family facts of §5.2 and the disjoint fact of §5.4 are the model’s, and both encodings read them.
Users normally chain to meta.tn. Schemas that chain to meta-kernel.tn directly are alternative type vocabularies replacing meta, or extensions of the meta layer — the format’s sanctioned extension point (§2.2.2).
Guidance for extension meta-schemas. Three rules keep extended vocabularies coherent with the kernel’s. First, a slot that names a type is typed by a kind, a slot that names a value by a reference, and type_ref is for a slot that holds an application (§4.2, §5.12): a field whose value is the name of a type a value can have is type_kind, or a narrower kind — request?: kind<record> admits the names of the record types and refuses uuid’s — which is what makes the slot judged a member when the schema links and visible to every reference mechanism (§8.1, §8.3), where a type_name-typed slot would carry a bare token the resolver treats as data; a field whose value is a value declared elsewhere, written by its name, is ref<T>, which declares the spelling and the judgement at once; and type_ref stands only where an application may. The kernel’s own enum_type.name_type and record.name_type are identifier_kind, and kind_type.of is typed type_name, since its values are constructors’ names, which no kind covers (§4.2); so is ref_type.of, whose parameter T is thereby a value parameter whose argument is a name resolved in the type-name namespace (§4.2, §5.10), and so are the resolver-derived name lists kind_type.members, type_chain.supertypes and subtypes (§4.1, §4.2). Second, a meta layer’s own vocabulary — the records its constructors’ instances hold — is declared beside them, as a namespace type declares the map a governed schema holds a value of (interface => namespace & { key_type: = method_name value_type: = method }, §4.1), and never imported from an ordinary library (§2.2.3): a meta layer declares the text, boolean or uri its vocabulary needs, or reaches the kernel’s through its import. Third, the kernel’s regex atom is pinned to a specification (atom_specification, §4.2), and the pin is a strict gate: an implementation MUST implement the pinned dialect as specified — not a host library’s near-relative — and MUST document any divergence it cannot avoid; a pattern using a feature outside the pinned dialect is a resolver error at schema load, not a silently host-dependent behaviour. The same discipline applies to every spec-pinned atom an extension declares: the spec pin is the contract, and “whatever the platform regex engine does” is never a conforming reading.
10. Schema Resolution Model
Schema URLs are logical identifiers first: a URL names a schema, and resolving it does not by itself require a network request. Implementations resolve schema references through a schema library — a local store mapping canonical identities ([TSON-DATA] §2.2.1) to schema content. Fetching is a permitted but opt-in way to populate the library (§10.1, §11.2), not the meaning of a reference.
10.1 The Schema Library
When the resolver encounters a !!schema, !!meta, or !!import directive, it looks the reference up in the library — the reference read to its canonical identity in any spelling ([TSON-DATA] §2.2.1); if not found, the resolver does not attempt to fetch unless fetching is enabled (§11.2), and reports one of two outcomes, neither a finding about validity ([TSON-DATA] §8.1). A schema this processor will not supply — one it does not hold and will not fetch, a host off its allowlist, an illegal identity, a missing required pin, or one past its size limit (§11.2) — is a refusal: a decision of this deployment’s configuration, as deployment-specific as an identifier policy, which a processor configured otherwise does not make and no rerun changes, so the document is rejected here and the sender’s fix is to name a schema this deployment will supply. A closed library’s miss is a refusal, never “not found”: the library is the deployment’s whole configuration, so a schema outside it is one this deployment will not supply. A schema this processor could not obtain — a source that looks beyond its configuration (an origin, a directory) and found nothing at the reference, a host that did not answer, a fetch that timed out — leaves the document undetermined: nothing has read the schema, so nothing is known about whether the document conforms, and whoever holds the fix, for nothing at the reference the sender most likely does, a reference with nothing behind it being most often a typo. Both are distinguishable from the four categories, in the same report, located at the reference, and name their reason (not held, not permitted, too large; not found, unreachable, timed out), since a sender acting on a report needs to tell “your document is wrong” from “I will not look here” from “I could not look”. A document with both an undetermined import and an ordinary error reports both, and a rejection settles the summary whatever else went unjudged ([TSON-DATA] §8.1). The library is populated through three mechanisms, in order of precedence:
- Pre-loaded schemas. The implementation ships the meta-kernel and meta-schema as pre-loaded entries and SHOULD ship the core type library and the network library. Pre-loaded schemas are authoritative — their in-memory representation takes precedence over any external source (§3.4).
- Registered schemas. Applications register schemas before parsing documents that reference them, from local files, embedded resources, or any application-specific source.
- Fetched schemas (optional). Implementations MAY support fetching by URL; fetching MUST be explicitly enabled by the application — never the default — and is subject to §11.2. Production systems SHOULD register all required schemas at startup rather than fetch at runtime.
Identity agreement. Registration whose target identity differs from the canonical identity of the content’s declared !!id is an error — the library MUST reject it as identity confusion. The comparison is over canonical identities, so scheme variance agrees; a differing host or path does not. Content with no !!id MAY be registered under an application-supplied identity (a development-mode convenience, §2.2.1); content with an !!id is registered under that identity and no other.
Schema sources are schema documents. Whenever the library is populated from a document — registered or fetched, and for the targets of !!schema, !!meta, and !!import alike — that document MUST classify as a schema document ([TSON-DATA] §2.2). A resolved-schema data document (resolver output, §8) is not a valid schema source: its derived fields cannot be verified from the document alone; it is non-canonical and not hash-pinnable; and, being a data document, its header cannot carry !!import — the described schema’s namespace is not expressible in it, only in the source schema’s own header (§8.1’s ingest rule). An implementation MUST reject a data document supplied as the content of a schema URL, with a categorized diagnostic. Resolved-form documents MAY enter the library only through the explicit ingest path (§8.1), which does not take derived indexes on trust: the derived annotation @synthetic and the derived indexes subtypes, members and disjoint are discarded and recomputed; chain.supertypes, chain.parents and @application are read as stated and verified; author-written key and value annotations are preserved as data; and a namespace entry (§4.1) ingests like any other entry — its value is the map its type describes, each value ordinary data of the meta-schema’s vocabulary validated against it, with the rule that no type position names it (§4.1) re-run on the ingested map.
10.2 Hash-Pinned References
The hash-parameter URI convention — the algorithm-named query parameter, lowercase full-length hex, the query-is-hash-parameters-only rule, canonical identity, and the mismatch rule — is defined in [TSON-DATA] §2.2.1. This section defines how the library applies it.
Library keys are canonical identities: a plain reference, its http/https variant, and its hash-pinned form all name the same entry — the pinned form additionally demands verification. When a hashed reference resolves, the implementation MUST verify the library’s content against the declared hash before use; a mismatch is a resolver error — a verdict, unlike a refused or undetermined schema (§10.1), because the schema was obtained, its bytes were read, and they are not the bytes the document’s reference commits to, which is a finding about the reference the document carries — and the library MUST NOT silently substitute mismatched content. When registration supplies a document whose declared !!id itself carries a hash, the implementation SHOULD verify at registration time.
Hashes attach to the canonical entry. Verification is a property of the canonical identity, not of each reference. Within a single resolution’s reference closure (§3.4.1), the digests declared across all references to one identity form a set, and the set determines the identity’s treatment. An empty set resolves unverified. A single digest is verified once against the entry’s content, before any reference to the identity resolves; every reference — pinned and plain alike — then resolves to the verified entry. A plain reference alongside a pinned one is therefore not a conflict but an inheritance: it rides the pin, and it inherits the pin’s failure — a verification failure applies to the identity for the whole resolution, and a plain reference MUST NOT proceed with content a declared digest has rejected. Two distinct digests for one identity is a resolver error: at most one describes the real bytes, and the library MUST NOT choose between them. A verified entry, once cached under its canonical identity, is immutable. A failed verification SHOULD NOT be cached under the canonical identity; an implementation MAY maintain a separate negative cache keyed on the full reference string.
Pins to pre-loaded identities. Pre-loaded schemas resolve to in-memory structures (§3.4, §10.1), but a pinned reference to a pre-loaded identity is an ordinary pinned reference and MUST be verifiable: an implementation MUST hold, for each pre-loaded schema, the published content digest (or the canonical document bytes) of the artifact its structures implement, and MUST verify declared pins against it — a mismatch is the ordinary verification error. One reference in the series can never carry a pin: the meta-kernel’s self-referencing !!meta, whose hash input would contain the pin itself. It needs none — it resolves by pre-loading (§3.4) — and under the per-identity rule its plain spelling composes without conflict with pinned references to the kernel elsewhere in a closure.
An implementation MAY offer a strict resolution mode that rejects any closure containing an identity whose digest set is empty (pre-loaded identities excepted), for deployments requiring a fully verified chain; the per-identity rule above is the default and the interchange baseline.
When no digest is declared for an identity anywhere in the closure, it resolves without integrity verification — appropriate for development, NOT RECOMMENDED for production interchange. References that cross a trust boundary SHOULD be hash-pinned: a schema’s !!meta and !!import values pin its contract and dependencies; a data document’s !!schema pins its vocabulary. Because pinning is per-identity, one pinned reference raises the guarantee for every reference to that identity in the closure, and a published plain reference never prevents downstream pinning. Pinning composes into the verification chain of [TSON-DATA] §2.2.1 — a consumer holding a single hashed reference can verify a document together with its schema, that schema’s meta-schema, and the kernel, provided each rung’s own references are pinned; published schemas SHOULD pin every reference they carry, the kernel’s self-reference being the sole exception.
!!schema:"https://example.com/people.tn?sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
10.3 Canonical Identity
Reference identity is defined by [TSON-DATA] §2.2.1: two references name the same document if and only if their canonical identities — the host’s canonical text plus the path, scheme and query removed — are byte-for-byte identical. The library applies this as its key rule:
http://tson.io/2026/38/m/core.tnandhttps://tson.io/2026/38/m/core.tn— same entry; the scheme is a transport hint.https://tson.io/2026/38/m/core.tnand the same URL with?sha256=…— same entry; the pinned form additionally requires verification (§10.2).https://tson.io/2026/38/m/core.tnandhttps://elsewhere.example/2026/38/m/core.tn— different entries; the host is load-bearing, so a fetch-endpoint change cannot silently redirect a name.https://xn--bcher-kva.example/sch%c3%a9mas/x.tnandhttps://bücher.example/schémas/x.tn— same entry; a reference in any spelling of an identity is read to the canonical form, the host as ahostvalue and the path’s percent-encoded UTF-8 read back ([TSON-DATA] §2.2.1).
The identity profile holds a document’s own !!id to one spelling — the canonical form, with no userinfo, port, percent-encoded unreserved character, dot-segment or fragment — and reads a reference in any spelling of the same identity to that form before comparison; what has no IRI form is an error, never a candidate for normalization ([TSON-DATA] §2.2.1). A fetcher that reaches a host beyond US-ASCII does so in the URI spelling, and a report of such a fetch SHOULD name both the identity and the URI requested. The pin rides in the query so that it reaches the origin and the cache, and the fragment is reserved for a later intra-document reference ([TSON-DATA] §2.2.1). Content addressing is over bytes, and every identity rule above a digest is over value spaces (§5.5): two spellings of one octet string hash the same document to one identity and are one map key, and a content-addressed reference is never encoding-dependent.
Confusable identities. Because identity selects a document’s entire type vocabulary, a look-alike identity is a higher-value spoof than a confusable field name. Implementations processing untrusted references SHOULD surface, and MAY reject, two registered identities that differ only in ways the profile does not already forbid — e.g. visually confusable host labels (UTS #39; [TSON-DATA] §9.4).
11. Security Considerations
The security considerations of [TSON-DATA] §9 apply; this section adds the schema layer’s.
11.1 Schema Validation
Documents without !!schema carry no type guarantees — only base type resolution applies. Applications processing untrusted TSON input SHOULD validate against a schema before use.
11.2 External References
Schema URLs are logical identifiers resolved through the schema library (§10), not fetch instructions; no network access occurs unless the application explicitly enables it. Implementations that support optional fetching MUST treat it as opt-in, disabled by default, and when enabled SHOULD enforce:
- Transport security. Fetched references MUST use an authenticated, encrypted transport (in practice,
https); a fetcher MUST NOT downgrade to a cleartext scheme for an unpinned reference. - Allowlists. Restrict fetchable references to approved hosts or path prefixes, matched on canonical identity (§10.3) so scheme variance cannot evade the list; a host is matched as a
hostvalue ([TSON-DATA] §5.5), so an allowlist entry in either label form admits an identity’s host in either, and a look-alike host (schemаs.example.comwith a Cyrillicа) is refused unless the operator listed it, the comparison being by name.!!schemaand!!metareferences are particularly sensitive: a malicious reference could load an untrusted schema that redefines expected types. A reference the allowlist refuses is reported as a refusal ([TSON-DATA] §8.1; §10.1), and the report SHOULD NOT disclose the allowlist: telling an untrusted sender which hosts this processor will fetch from is reconnaissance for request forgery, where a name policy’s statement serves the sender and costs nothing. - Content hash verification. Require hashes on fetched references and reject mismatches — preventing tampering and silent schema drift; a reference whose pin is required and missing is a refusal.
- Size limits on fetched content; a schema past the limit is a refusal.
- No recursive fetching. A fetched schema’s own
!!importdirectives MUST NOT trigger further fetches; transitive dependencies must be pre-registered or pre-fetched. - Caching. Cache verified schemas locally, keyed on canonical identity; a verified hash-matching schema is immutable. A failed verification MUST NOT overwrite or poison the canonical-identity entry (§10.2).
Production systems SHOULD pre-register all required schemas at startup and disable runtime fetching entirely.
11.3 Directive Security
Directives are a control channel that affects interpretation. Applications processing untrusted input SHOULD restrict which directives are accepted; !!meta and !!import are particularly sensitive because they select and extend the type vocabulary — production systems MAY restrict which meta and import URLs are permitted. See [TSON-DATA] §9.3.
11.4 Name Hygiene at the Schema Layer
[TSON-DATA] §8.2 defines the name-hygiene mechanisms — skeleton distinctness within a scope, Identifier_Status, and a restriction level — as policy that every conforming processor implements and enforces by default, never as validity. This section supplies the schema layer’s scopes and the one rule the scope structure adds.
The named scopes at this layer are: the members of one enum (§7.4), under its name_type’s profile — all three mechanisms, every enum’s name type being an identifier family; the field names of one record definition (member labels of its groups included, §5.11), under the record’s name_type’s profile (§5.2), which is the same scope the data layer applies to a schemaless record ([TSON-DATA] §2.5, §8.2) — a profile’s own added characters (json_name’s @ and $, a medial space) are exempt from mechanism 2 and read by mechanisms 1 and 3, and the floor on what a profile may add is §7.4’s; the declared names of one schema; and the merged namespace at !!import — the sharpest, because §2.2.3 decides collisions by exact identity, which a confusable pair passes by construction: two entries a reviewer reads as one name are, to the resolver, two names. Choice variants are not a scope: a variant is a reference to a declared name, so two confusable variants are two confusable entries in the namespace and are reported there. A template’s parameter list is not a scope either (§5.10): one author writes it whole on one line, and the list stays short so that it stays reviewable. Mechanisms 2 and 3 are per-name and reach every identifier position without a scope ([TSON-DATA] §8.2); only mechanism 1 needs the list above.
Values that are names. A value whose declared type is an identifier family (§7.4) is a name wherever it stands, and the schema layer is what makes it one. Mechanisms 2 and 3 reach every such value, in every encoding, under the identifier policy and under the family’s own profile: a character the profile adds meets no Identifier_Status rule, and a per-segment level divides the value at the profile’s own separators ([TSON-DATA] §8.2). Mechanism 1 gains two data scopes, each a set the type itself delimits: the key set of one map whose key type is an identifier family, and the element set of one array whose elements are unique — a set_type instance, or any array stating unique_items — and whose element type is one. An array that admits repetition is not a scope, since nothing says two of its elements name two things. A pair is refused at the second key or element, as a pair of field names is, and like every other scope these are skipped where the identifier policy switches skeleton distinctness off. A value a schema writes is judged the same way, under the same policy: a field’s default or fixed value of such a type when the schema loads, since a default reaches every document that omits the field, and a value in a payload the scopes above do not cover — the keys of a namespace entry’s map (orders => !interface { place_order => … }, whose key_type is an identifier family, §4.1), a qualified name’s segments (§5.12), or an annotation’s value. A kernel constructor’s own identifier-typed values are the scopes listed above, or references to a declaration, and are judged there; a kind’s or a reference’s value is a name of a declared entry and is judged where the entry is declared. A map keyed by text remains data: admin and аdmin may be two legitimately distinct keys, and nothing about a map position says they are names — the key’s type is the schema saying so.
Because identity selects a document’s entire type vocabulary, the import merge is exactly where the compositional hazard bites — two independently published, independently pinned schemas, each clean alone, can collide on a skeleton when one imports the other (list_item beside Iist_item), and the fix is a rename in a document the importing author may neither control nor republish. That is the strongest single reason the mechanism is policy rather than validity: as a validity rule it would make such an import impossible to write; as a policy rule the operator relaxes the check, visibly and in code, and proceeds. A refusal at the merge is reported at the second arrival with both declaring schemas named, in the manner of §2.2.3’s collision diagnostic, as [TSON-DATA] §8.1’s refused outcome — in the same report as the four categories, distinguishable from them — and the report carries the UCD version and the identifier and token policies it was judged under ([TSON-DATA] §8.2). The policy is not a property of a schema and no schema carries one: the merged namespace is what the check runs over, and only the importing site knows it.
A data document’s field names need no scope of their own under a schema: a data field name is valid only if it matches a declared one, matched in the record’s name type’s form (§5.2, §7.2), so it inherits the declaration’s verdict; a name the record does not declare is judged by the name type first: a value of it is a name, and meets the identifier policy under the type’s profile before closure reports it as unrecognised, and one that is not is unrecognised and meets no name rule. Its values typed by an identifier family are the data scopes above. The confusable-identity concern for schema references themselves is §10.3’s.
11.5 Resource Limits for Schemas
[TSON-DATA] §9.1 bounds what a processor will spend on a document under one limits policy — every limit with a default, configurable or documented, its threshold reported on refusal, and a refusal that is not a verdict. A schema is untrusted input too wherever it is accepted over the wire or reached through !!import, and a schema document is a document, so §9.1’s counters apply to it as written. What §9.1 cannot see is the work resolution adds, and this section names those limits on the same terms, as part of the same policy and reported through the same surfaces:
| Limit | Applies to | Default |
|---|---|---|
| import closure | schema documents reachable from one header through !!meta and !!import (§3.4.1) | 64 |
| entries | declarations in one schema map, synthetic and instantiation entries included | 65,536 |
| reference chain | hops from a use site to a terminal entry (§8.3) | 64 |
| supertype chain | length of one type’s chain.supertypes (§8.1) | 64 |
| materialisation depth | nested open synthetics closed for one application (§5.10, §8.2) | 64 |
Exceeding one is a limit refusal, not a resolver error: the schema may be well-formed, load cleanly on the next processor along, and be refused here because this deployment declined to spend the resources, and the diagnostic names the limit and the configured threshold. The defaults are set at the tight end on [TSON-DATA] §9.1’s argument — a schema that fits them loads everywhere — and a deployment raises them. The !!schema scope pushes a data document can provoke are counted on the document side, under §9.1’s foreign-schemas limit.
12. ABNF: The Schema Grammar
This section defines the schema grammar — the schema document’s body grammar, the second of the two body grammars behind the shared document header ([TSON-DATA] §2.2, §7.4). This grammar adds no token of its own ([TSON-DATA] §7.3): the productions below consume the same token stream, and the reserved special tokens of [TSON-DATA] §7.2.5 receive their meaning here — +, which that section emits as a special token from Revision 37, among them.
12.1 The Schema Grammar
The schema-document header is defined entirely by [TSON-DATA]‘s grammar; this document defines the schema body: schema-map, the annotated, braced declaration map that [TSON-DATA]‘s schema-doc production delegates here. ws, ws1, separator, token, unquoted-token, annotation, field-name, record, empty-brace, identifier, and core-value are imported from [TSON-DATA] §7.3, §7.4, and §7.7; the data grammar’s value productions appear at exactly two points — the full core-value as the constructor-application payload (instance, §5.6), and its braced subset (record / empty-brace) as the atom-refinement body (atom-refinement, §5.5), the same text under two heads, which is the desugar §5.6 states. No production of this grammar uses the full data-value: a record-refinement body is a braced record-def (§5.7), and a field-modifier value is restricted to a bare token — or, for the selector =?, nothing at all — (§5.2), never the void sentinel, annotations, a type-ref, or a container.
A schema-map copies the shape of [TSON-DATA]‘s map production but requires at least one entry — {} at schema-body position is a parse error. An entry is called a declaration. Annotations before the opening brace bind to the schema; annotations at the head of an entry bind to the key; annotations after => bind to the type definition (§2.1, §6).
; ── Schema Map (schema body) ──────────────────────────────
schema-map = *( annotation ws ) "{" ws schema-map-entry
*( separator schema-map-entry ) [ ws "," ] ws "}"
; a comma may follow a value ([TSON-DATA] §2.4);
; every list in this grammar admits one after its
; last element
schema-map-entry = *( annotation ws ) type-name ws "=>" ws
*( annotation ws ) [ definition-mark ws ] type-def
definition-mark = "abstract" / "final"
; how a record may be realised (§5.2): one word,
; read unconditionally at this one position and an
; ordinary identifier everywhere else; a bare mark
; with no type-def is a declaration missing its
; definition; a mark on a non-record definition is
; a resolver error; written before the parameter
; list of a template (§5.10)
; ── Type Definition (declaration right-hand side) ─────────
type-def = atom-refinement ; never parameterised
/ instance
/ [type-params] structural-def
/ [type-params] type-ref
; there is no constructor marker: an entry is a
; constructor by being IS-A `top` (§4.2), and `~` is
; a special token with no role at type-def position
type-params = "<" ws type-param *( separator type-param ) [ ws "," ] ws ">"
type-param = param-name [ ws ":" ws type-ref ]
; the written type narrows a value parameter's type
; or bounds a type parameter, by the kind the body's
; uses give (§5.10)
param-name = type-name ; same lexical class as type-name
structural-def = refined-def
/ construction-def
/ record-def
refined-def = type-name [ws "<" type-args ">"] ws "^" ws record-def
; record refinement, and constructor refinement in
; a meta-schema (§5.7, §4.2); the optional <type-args>
; head serves user-template heads only (§5.7). No
; removal clause.
construction-def = supertype-ref 1*(ws "&" ws supertype-ref)
[ws record-def] [ws removal-set]
/ supertype-ref ws "&" ws record-def [ws removal-set]
/ supertype-ref ws removal-set
supertype-ref = type-name [ws "<" type-args ">"]
; composition and subtraction operands are named
; type references, optionally with arguments —
; never paren, bracket, or map forms (§4.3, §5.8)
removal-set = "-" ws "{" ws field-name
*( separator field-name ) [ ws "," ] ws "}"
record-def = "{" ws [record-entry *(separator record-entry)
[ ws "," ]] ws "}"
record-entry = field-def / group-def
atom-refinement = "!" type-name ws "^" ws ( record / empty-brace )
; atom refinement (§5.5): the constructor's own
; constraint bindings — the braced subset of the
; core-value payload `instance` takes, read by the
; same data grammar; the target MUST resolve to an
; atom-family instance (§3.3.1)
instance = [type-params ws] "!" type-name ws core-value
; constructor application (§5.5): the target MUST
; resolve to `top` or an entry that IS-A it (§3.3.1),
; the kernel's `reference` included (§5.10). The
; payload is a core value — braced bindings or the
; positional form (§5.6) — never annotations or
; directives. With a parameter list the body is held
; unread until materialisation (§5.10), except a
; `!record` body, which is a record template read as
; vocabulary at its declaration; open and closed share
; one production and one payload grammar. What no payload
; can spell is an application: `box<text>` is schema
; grammar, and inside a `!C` payload an application is
; written in `type_ref`'s own record form,
; `{ name: box arguments: [ { name: text } ] }`.
; ── Field Definitions ─────────────────────────────────────
field-def = *annotation field-name ["?"] ws ":" ws
( field-type field-modifier
/ field-type
/ field-modifier )
; the "?" on the name answers omission (§5.2);
; "a?:" lexes as name, "?", ":" with no lexer change
field-type = type-ref ["?"]
; the "?" on the type answers a written "_" — the
; field is voidable (§5.2)
field-modifier = ws ("~" / "=") ws token
/ ws "=" ws "?"
; "~" default, "=" fixed: the value is a single
; scalar token, never a compound value and never
; the void sentinel (§5.2); "=?" is the selector
; a discriminated family's members pin (§5.2) —
; two special tokens, no value
; ── Field Groups (§5.11) ──────────────────────────────────
group-def = *annotation "(" ws group-option
*( ws "|" ws group-option ) ws ")" [ "?" / "+" ]
group-option = group-member *( separator group-member )
group-member = *annotation field-name ["?"] ws ":" ws type-ref ["?"]
; "|" separates options and a separator the members
; of one option. A "?" on a member's name marks it
; optional within its option; the "?" on its type
; makes it voidable; no modifier is admitted, a
; group never injecting. The group's own "?" makes
; it optional; "+" is sugar for at-least-one and is
; admitted only where every option is one unmarked
; member. Which groups restate plain fields or
; another group, and are refused for it, is the
; resolver's check (§5.11).
; ── Type References (any type position) ───────────────────
type-ref = paren-type
/ bracket-type
/ map-type
/ type-name "<" type-args ">"
/ type-name
; ── Compound Type Expressions ─────────────────────────────
paren-type = "(" type-ref "|" type-ref *("|" type-ref) ")" ; choice, 2+ variants
bracket-type = "[" element-type [ ws ";" ws size-spec ] ws "]" ; array
/ "[" element-type 1*(separator element-type) "]" ; tuple
map-type = "{" ws map-key ws "=>" ws element-type
[ ws ";" ws size-spec ] ws "}" ; map sugar (§5.3)
map-key = type-name [ "<" type-args ">" ]
; a simple ref, optionally with arguments — never
; paren, bracket, or map forms (§5.3)
element-type = type-ref [ "?" ]
; nesting is this recursion: [[T; N]; N] and
; {text => [order; 1..]} work by the same rule
; that makes [[T]] work
size-spec = size-bound [ ws ".." ws [ size-bound ] ]
/ ".." ws size-bound
; a size-bound is decimal-natural or, within a
; template body, a value-parameter name (§5.3)
; One production per container, at every position: an
; element's "?" belongs to element-type inside the brackets,
; and a field's own "?" to field-type, so "xs: [T?]?" is
; unambiguous — element voidable, field voidable.
; ── Terminals ─────────────────────────────────────────────
type-args = type-arg *(separator type-arg) [ ws "," ]
; separator = ws "," ws / ws1; a trailing comma
; follows a value and changes nothing
type-arg = type-ref / value-literal
value-literal = token
; a single scalar lexeme: a number, quoted string,
; or other non-name token. An unquoted token that
; satisfies identifier (`true` and `false` included)
; parses as a type-ref; it is read as its parameter's
; recorded type (§5.10), so a name bound to a
; parameter standing in an enum's member list is a
; member and one bound to a type parameter must
; resolve as a type.
size-bound = unquoted-token
; text MUST match the decimal-natural production
; of [TSON-DATA] §7.6 or, within a template body,
; be a value-parameter name (§5.3)
type-name = identifier
; [TSON-DATA] §7.7: an unquoted token whose decoded
; text matches the identifier grammar — XID_Start-
; initial, so no name begins with a digit, a sign, or
; a dot, which is what makes numbers undeclarable
; (param-name shares the rule, as do every referenced
; name and every `!` head). A token in name position
; that fails the grammar is a parse error. field-name
; is any unquoted or single-line token ([TSON-DATA]
; §2.5, §7.4) — in field-def, group-member and
; removal-set as in data — and is judged not by this
; grammar but at link, by the record's name type
; (§5.2): a name that is no value of it is a resolver
; error, and under the default name type that is the
; identifier grammar. The schema grammar and the data
; grammar share the production.
; Resolver-materialised synthetic and instantiation
; entries (§8.2) carry internal names in this same
; class; the resolver keeps them disjoint from
; declared names by construction (freshness, §8.2),
; and they are unreachable from source because they
; do not exist at source level.
Notes:
- The
type-paramsslot declares type parameters (§5.10), each optionally followed by:and a type-ref — a written type, decided on the one token after the name; parameters take precedence over schema-namespace lookup, and references to a parameterized type MUST supply matching type arguments — except that a record template may be named bare at a type position as a family base (§5.10), never at an operand of&or^(§5.8). definition-markis read unconditionally:pet => abstract base & { … }is a marked composition, and a type namedabstractcannot stand alone as a declaration’s whole body, which is the sole reservation the two words carry (§5.2, [TSON-DATA] §7.7). ABSTRACT’s rule — no direct instances, a value at a position typed by it a value of some subtype, a tag naming the base itself refused — is the rule the kernel’s owntop,type,atom,product,sumandnamespaceare read by, each markedabstract(§4.1), so a direct application of one is refused as any abstract record’s is.=?is two tokens the lexer already emits —=then?— admitted byfield-modifieronly with no value;a: T =? vis a parse error. It is refused by the resolver on a name carrying?, on a voidable type, on a group member, and on a type that is not an atom-family instance or an enum (§5.2).paren-typeproduces choice types; choices require at least two variants —(T)is a parse error.group-defproduces field groups (§5.11): one or more options separated by|, each one or more members. That a group has at least two members, and the rules giving each presence rule one spelling, are the resolver’s (§5.11). Inside a record body,(at entry position (after any leading annotations) opens a group;(after afield-name ":"opens aparen-type. The two never collide — a group is an entry, a choice is a type-ref. A?or a+after the closing)marks the group; member positions reject modifiers by grammar.- A
?after a field’s name recordsoptional: trueon therecord_field; a?after a type at a field, tuple position, array element, map value or group member records that the position admits_(voidable: trueon arecord_field,tuple_element,arrayormap); a?after a group member’s name lists it in the group’soptional_members; a?after a group’s closing)recordsoptional: trueon thefield_group, and a+there lowers to the one-option form (§5.11). Those are the only positions, and there is no generic “optional type” in TSON. type-defreaches the bracket and map forms throughtype-reflike any other position; there is no separate declaration-level container production, and no positional restriction on size specifiers or element/position?(§5.3).instanceis decidable on one token after the optional parameter list:!opens aninstance, with or without a preceding<…>;<only ever startstype-params, so consuming it first costs no lookahead. Inside the!branch a following^separatesatom-refinementfrominstance;atom-refinementadmits no parameter list — a parameterised refinement of an atom instance is no form (§5.10), and<…> ! name ^is a parse error.- The trailing record-def in
construction-defis optional (customer => address & contactis valid). When a{follows a&-chain, it always belongs to the construction’s record-def. - The refined-def target and every
supertype-refoperand are restricted to a bare type-name, optionally with type-args; inline structural forms cannot precede^,&, or-by grammar (§4.3). A^whose resolved target has no refinable body (a choice, for example) is a resolver error reported with the target’s kind. - The removal clause attaches to construction heads only; a refinement head admits none —
T ^ { ... } - { ... }is a parse error (§5.7, §5.9). - After a bare type-ref in type-def position,
{is a parse error; the diagnostic SHOULD suggest^(refinement) or&(composition). - Parameters and type arguments inside
<>alike separate by comma or whitespace — the general separator convention ([TSON-DATA] §7.4):<T, MIN>and<T MIN>,map<text, integer>andmap<text integer>are all valid. _is not valid in type-ref, type-def body or field-modifier positions (§5.2, §7.6); empty records use{}, and a field that may only be omitted or_is spelleda?: void?.- A comma may follow the last element of every list in this grammar — declarations, fields, groups, removal sets, parameters, arguments, tuple elements ([TSON-DATA] §2.4);
[text, int32, ]andpair<uuid, B, >are legal. A comma following nothing, or another comma, is a parse error, as in data.
12.2 Disambiguation Summary
This section is informative.
; schema body (after the header):
; @ → annotation; before "{" it binds to the schema,
; inside the braces to the entry key (name) or,
; after "=>", to the type definition
; { → schema map opens
; name => → declaration (two-token lookahead)
; } → schema map closes; end of document
; anything else → parse error
;
; type-def position (after =>):
; abstract / final → definition-mark (§5.2); consumed, then
; dispatch continues on what follows
; < → type-params (each name optionally followed
; by ":" and a type-ref); then dispatch continues:
; ! → instance, held open (§5.10)
; otherwise → templated structural-def / type-ref
; ! name ^ → atom refinement (§5.5)
; ! name → constructor application (§5.5)
; name ^ → refined-def (§5.7)
; name & → construction-def (composition, §5.8)
; name - → construction-def (subtraction, §5.9)
; name { → parse error (write ^ or &)
; { → brace form; consume "{" and dispatch on
; content (reusing [TSON-DATA] §2.8's
; consume-one-then-inspect machinery):
; "}" → empty record ({}, top's shape)
; "(" → record-def (leading field group)
; "@" → record-def (annotations precede field
; names, §6; the map sugar admits no
; interior annotations, §5.3, so "@"
; commits to a record)
; name ":" → record-def (field)
; name "?" → record-def (field with an omittable key)
; name "=>" → map-type
; name "<" → map-type (generic key; consume the
; arguments, expect "=>")
; name (other) → parse error
; ( → paren-type (choice)
; [ → bracket-type (array or tuple, full syntax)
; name ? / name → type-ref
;
; type-ref position (field types, array elements, etc.):
; ( → paren-type (choice)
; [ → bracket-type (full syntax at every position)
; { → map-type: "{" name … "=>" required;
; "{" name ":" remains a parse error — bare
; records must be declared (§5.2); the
; diagnostic SHOULD say so and distinguish
; the two brace meanings
; name < → generic
; name ? / name → simple ref
;
; record-def entry position (after leading annotations):
; ( → group-def (field group, §5.11)
; name ":" → field-def
; name "?" ":" → field-def, key omittable (§5.2)
;
; inside a group-def (§5.11):
; name ":" → member
; name "?" ":" → member, optional within its option
; "|" → next option
; ")" → group closes; an adjacent "?" or "+" marks it
;
; after a field's type-ref:
; ? → voidable (§5.2)
; ~ token → default
; = token → fixed
; = ? → selector (§5.2); no value follows
; name "=>" → parse error ("record body expected; =>
; begins a map type only at type positions")
;
; map-type internal rules:
; exactly one key => value entry ("a map type is a single
; key => value entry" — the data grammar's multi-entry habit
; does not carry over, §5.3); a "?" on the key side of "=>"
; is a parse error, and the value side admits it (§5.3); the
; key "<" case is the one place the
; generic-key rule is reachable — after the arguments close,
; ":" here is a parse error whose diagnostic SHOULD read
; "expected =>; if you meant a record field, a generic key
; cannot name one"
;
; bracket-form internal disambiguation:
; [type sep type → tuple (whitespace or comma)
; [type ; spec → array with size constraint
; [type ] → unconstrained array
; [type ? ... → element "?"
; [[ ... → nested bracket-type (full syntax)
; [{ ... → nested map-type
;
; after a construction body "}":
; - → removal clause (§5.9)
; otherwise → declaration boundary rules below
;
; declaration boundary (resync): after a bare type-ref in
; type-def position, one/two-token lookahead decides:
; ^ → refinement body of the current type-def
; < → type arguments of the current type-ref
; & → composition continues the current type-def
; - → removal clause of the current type-def
; "," → current declaration complete
; name "=>" → current declaration complete; a new one begins
; "}" → current declaration complete; map closes
; "{" → parse error (write ^ or &)
; name (other) → parse error
Each case in the type-def block is decided by at most two tokens of lookahead at the start of the production — the brace form by one consumed token plus one token of lookahead, the same budget in the same sense as the data grammar’s §2.8 dispatch; inside a bracket form, the choice between tuple, sized array, and unconstrained array is made by one-token lookahead after the complete preceding element type. A field-type can itself be nested ((email | [phone])?) and parses without backtracking via the disambiguation above; the outer ? there makes the field voidable (§5.2). The schema body requires at most two tokens of lookahead to detect a declaration boundary. The parser never backtracks at any level.
12.3 Adjacency Rules
The following rows extend the adjacency table of [TSON-DATA] §7.5 for the operators of the type-definition grammar; as there, the rules are enforced by the parser via source-position comparison.
| Operator | Type | Context | Rule |
|---|---|---|---|
! | prefix | type-def body (constructor application, atom refinement) | MUST be adjacent to the following unquoted-token (constructor or instance name) |
? | suffix | field type, tuple position, array/ | MUST be adjacent to the preceding token (type name or closing bracket/ |
& | binary | composition | whitespace on either side optional |
^ | binary | refinement (§5.5, §5.7) | whitespace on either side optional |
- | prefix | removal clause (§5.9) | at least one whitespace character MUST separate the preceding token from -; whitespace optional before the following { |
~ | modifier | default value | whitespace optional |
= | modifier | fixed value | whitespace optional |
=? | modifier | selector (§5.2) | whitespace optional before = and between = and ? |
? | suffix | field name (omittable key, §5.2); group-member name (optional within its option, §5.11) | MUST be adjacent to the preceding field name |
+ | suffix | field group (at least one of, §5.11) | MUST be adjacent to the preceding ) |
: | separator | parameter type (§5.10) | whitespace optional on both sides |
| | separator | choice variant; field-group option | whitespace optional |
; | separator | array size spec; map size spec (§5.3) | whitespace optional |
.. | binary | size-spec range (§5.3) | whitespace on either side optional |
=> | separator | schema declaration; data map entry; map type sugar (§5.3) | whitespace optional (compound token from lexer) |
The whitespace requirement before removal - is a lexer fact restated as a rule: hyphen-minus continues an unquoted token ([TSON-DATA] §7.2.4 — hyphenated names are legal), so in account- { password } the hyphen is absorbed into account- and no removal clause exists — the same footgun class as [1-2] lexing as one token. When a token ending in - appears at a type-def position followed by {, the diagnostic SHOULD note the absorbed hyphen and suggest whitespace before -.
13. References
13.1 Normative References
| Reference | Title | URL |
|---|---|---|
| RFC 2119 | Key words for use in RFCs to Indicate Requirement Levels | https:/ |
| RFC 5234 | Augmented BNF for Syntax Specifications (ABNF) | https:/ |
| RFC 3339 | Date and Time on the Internet: Timestamps (Appendix A is the duration and period grammar, §5.5) | https:/ |
| RFC 3986 | Uniform Resource Identifier (URI): Generic Syntax | https:/ |
| RFC 3987 | Internationalized Resource Identifiers (IRIs) (iri_type, host_type’s ihost, §5.5) | https:/ |
| RFC 3492 | Punycode: A Bootstring encoding of Unicode for IDNA (the A-label ↔ U-label mapping, hostname_type, §5.5) | https:/ |
| RFC 4291 | IP Version 6 Addressing Architecture | https:/ |
| RFC 4632 | Classless Inter-domain Routing (CIDR) | https:/ |
| RFC 4648 | The Base16, Base32, and Base64 Data Encodings (the text spellings of bytes, §5.5) | https:/ |
| RFC 5322 | Internet Message Format (email address syntax) | https:/ |
| RFC 5890 | IDNA: Definitions and Document Framework (hostname_type, §5.5) | https:/ |
| RFC 5891 | IDNA: Protocol | https:/ |
| RFC 5892 | The Unicode Code Points and IDNA (U-label validity) | https:/ |
| RFC 5893 | Right-to-Left Scripts for IDNA | https:/ |
| RFC 5952 | A Recommendation for IPv6 Address Text Representation (host’s canonical text) | https:/ |
| RFC 6838 | Media Type Specifications and Registration Procedures (media_type_type, §5.5) | https:/ |
| RFC 6839 | Additional Media Type Structured Syntax Suffixes (media_type_type.suffixes) | https:/ |
| RFC 9110 | HTTP Semantics (§8.3.1, media-type parameters) | https:/ |
| RFC 9485 | I-Regexp: An Interoperable Regular Expression Format | https:/ |
| RFC 9542 | IANA Considerations and IETF Protocol and Documentation Usage for IEEE 802 Parameters (EUI-48) | https:/ |
| RFC 9562 | Universally Unique IDentifiers (UUIDs) | https:/ |
| RFC 9651 | Structured Field Values for HTTP (the TSON-Schema field, [TSON-JSON] §3.5) | https:/ |
| UAX #15 | Unicode Normalization Forms (the normalization facet, §5.5) | https:/ |
| UAX #31 | Unicode Identifier and Pattern Syntax (identifier_type’s profile and toNFKC_Casefold, §5.5, §7.4) | https:/ |
| UTS #39 | Unicode Security Mechanisms (name hygiene, §11.4; [TSON-DATA] §7.7, §8.2) | https:/ |
| CommonMark 0.31.2 | CommonMark Spec (the text of @doc, §6) | https:/ |
| UCD | Unicode Character Database (the data files behind [TSON-DATA] §8.2, and the version a refusal reports) | https:/ |
| IEEE 754-2019 | Standard for Floating-Point Arithmetic | https:/ |
13.2 Series References
13.3 Informative References
| Reference | Title | URL |
|---|---|---|
| RFC 8820 | URI Design and Ownership | https:/ |
| ISO/ | General Purpose Datatypes | https:/ |
| ISO 8601-1:2019 | Date and time — Representations for information interchange (the informative source behind RFC 3339 Appendix A) | https:/ |
| JSON Schema 2020-12 | JSON Schema: A Media Type for Describing JSON Documents | https:/ |
| W3C XSD Part 2 | XML Schema Part 2: Datatypes Second Edition | https:/ |
| Resolver Output Fixtures | meta-kernel-resolved.tn, meta-resolved.tn, core-resolved.tn, net-resolved.tn (non-normative) | <published alongside this document> |