tson.io

Revision 39Current working draft·All revisions

TSON Developer Guide

Draft 2026·Working Draft·view raw ↗

TSON Developer Guide

Status: Non-normative companion to the TSON specification series, aligned with 2026 Revision 39. This guide carries the standing rationale, extended worked examples, and deployment guidance that the specifications reference but do not carry. It describes the design as it stands; it is not a change history — what changed between revisions, and why, lives in each revision’s change log. Nothing here is normative; where this guide appears to disagree with [TSON-DATA], [TSON-SCHEMA] or [TSON-JSON], the specifications govern.

1. What TSON Is

TSON is a schema system with its own notation. At its centre is a type system ([TSON-SCHEMA]): immutable, hash-pinned schemas whose definitions are themselves data, resolving down a verified chain of imports — document → schema → the libraries it imports → the pre-loaded kernel — so that one hash authenticates a document together with its entire contract. The TSON text format ([TSON-DATA]) is that system’s notation and its reference encoding: a Unicode-first notation in the JSON family, pleasant enough to use on its own, typed even without a schema — and not a JSON superset, a claim the series gave up deliberately (§2.8). Schemas are the point; the text format is how they are written down — and the first of the encodings that carry them.

If you arrived here thinking “another JSON dialect”, that is a reasonable first impression and a wrong one. The unquoted names and optional commas are real, and they matter for daily use — but they are the notation’s manners, not the system’s identity, and JSON itself is read through a reader of its own, as a second encoding of the same model (§1.4). The nearest relatives are not JSON5 or JSONC but Avro and ASN.1: systems where the schema is the product and wire formats serve it. TSON takes that architecture and adds three things those systems never had together: schemas that are ordinary, hash-verifiable documents in the same notation as the data; a schemaless mode that is still typed; and an extension model where new type vocabularies arrive as data, never as grammar changes.

1.1 The chain, walked once

Everything in TSON hangs off one picture. Here is a data document:

!!import:"https://example.com/people.tn?sha256=c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5"
!person { name: "Ada Lovelace"  born: 1815-12-10 }

The !!import directive names the contract, pinned by content hash. !person names which of the schema’s types this value instantiates. Note what born looks like: an unquoted token, no annotation. The schema declares the field as a date, so the date atom parses it — the data stays clean because the contract carries the types.

The schema it names is itself a TSON document:

!!id:"https://example.com/people.tn"
!!import:"https://tson.io/2026/39/m/core.tn"
!schema {
  person => { name: text  born: date }
}

Same lexer, same tokens, same tooling — a second body grammar behind the same header, selected by the root’s tag. And the schema stands on the same relation it offers: its !!import brings in core.tn, which brings in the three data-format libraries, which each bring in the kernel, meta.tn — and text and date and the meta.record that person is a value of all resolve in that one namespace. There is one directive and one relation. The kernel imports nothing; its own declarations wait on each other, and the circle is closed not by resolution but by pre-loading — implementations ship the kernel’s resolved structure, and the kernel document is the TSON encoding of it. The in-memory model is authoritative; the file describes it.

Every rung is immutable and hash-pinnable, and hashes attach to canonical identities rather than URLs, so a consumer holding one hashed reference can verify a document together with its schema, the libraries that schema imports, and the kernel — the entire contract, authenticated from a single identifier, with no network access required or trusted (§3.3–§3.4 below detail the identity and verification discipline). Schema URLs are names resolved through a local library; fetching is an opt-in way to populate it, never the meaning of a reference.

That is the product. Everything else in the series — the grammar, the resolver, the encoding rules — exists to make that picture true.

1.2 Immutable means versioned

The schema in §1.1 will never change. Not should not — cannot: a schema’s identity is its exact byte content ([TSON-SCHEMA] §3.5), and the hash in the data document’s !!import pins those bytes. Editing people.tn does not update the schema; it creates a different document that no existing reference names.

So when the contract needs to grow, you publish:

!!id:"https://example.com/people-v2.tn"
!!import:"https://tson.io/2026/39/m/core.tn"
!schema {  person => { name: text  born: date  email: text }
}

Look at what just happened: email is required. In every mutable-schema system, that is the forbidden edit — protobuf marks required “Do not use” because a required field added to a shared definition breaks every document already written; Avro demands a default; GraphQL says deprecate, never remove or tighten. Every one of those rules exists because a single definition must serve every document ever written against it. Here it doesn’t have to: the §1.1 document names people.tn by hash and validates against it forever, untouched by v2’s existence; new documents name people-v2.tn. Two contracts coexist, each at full strength. Neither is “the” schema — the schema is not a concept in this system; a document’s schema is. (The -v2 in the name is a convention for humans; the system’s notion of version is identity plus hash, and nothing parses the suffix.)

Once that lands, the ripples reach nearly everything:

  • Required means required. No leaving fields optional for future-proofing, no reserved field numbers, no tombstone fields carried forever. Records are closed under their type ([TSON-SCHEMA] §7.2): a v2 document without email is invalid, and a v1 document is never asked for one.
  • Unknown fields need no tolerating. The “must ignore unknown fields” robustness rule exists to survive in-place evolution; with no in-place evolution, closure is safe — and a typo in a field name becomes an error instead of silently discarded data.
  • Acceptance is explicit. A reader declares which versions it accepts by registering exactly those schemas in its library — and because the version key sits on the document’s opening lines, readable before any value parsing, a front door can route v1 and v2 traffic to different servers without opening the body.
  • Migration is a function between two known values. Both schemas are data (§1.3), so “what changed between v1 and v2” is a structural diff a tool can compute, and a v1→v2 transform has a precise input contract and a precise output contract.

Every design choice in §1.1 was quietly serving this moment: hashes attach to canonical identities so a pin survives mirroring; the id line is excluded from its own hash so a document can carry its name; the header and the root’s tag classify a document before its value is parsed, so routers never parse bodies. The full versioning story — including why routing by schema beats binding one server to every version, and the compatibility-rulebook history this model retires — is §9.1.

1.3 Schemas are data

A schema document resolves to a value: a map from type names to values, every one of them expressible in ordinary TSON, and every entry simply being the value it denotes. The person declaration above resolves to:

person => !meta.record { fields: {
  name => { type: text }
  born => { type: date }
} }

Output is source with the sugar expanded, bodies flattened and IS-A chains computed — a refinement such as age => !integer ^ { min: 0 } comes back as !numeric.integer_type { chain: { supertypes: [integer] } min: 0 } — and a record’s fields are a map keyed by name, since a name is what a field is reached by. The constructors are named by their paths: meta.record is the member record of the schema map meta nested in the kernel’s root, and numeric.integer_type the member integer_type of its numeric map, which numeric.tn adds to.

This reflexivity is load-bearing, not decorative. It means resolver output can be serialized, diffed, and shipped as fixtures; it means the kernel can describe itself; and it means the system grows by publishing new schema documents — new type libraries, constructors of a schema’s own — rather than by amending a grammar. The kernel and the grammar are frozen at version 1; any schema may declare a constructor, under the names its closure leaves free, and extensions travel as data down the same verified chain as everything else.

It also dissolves the old bootstrap riddle. “What validates the kernel?” has a concrete answer here: the pre-loaded kernel, which exists as implementation structure before any document is parsed. The kernel’s header is its !!id alone; it imports nothing, and its one wait cycle — the entries around meta.record — is what pre-loading closes.

The research behind the design calls this discipline the proto-schema (§2): every schema in the system, walked down its imports, flattens to the kernel — a handful of kinds, a few constructors, and one abstract root, meta.top, whose subtypes describe every entry in existence including themselves — the kernel’s meta.record is a !meta.record. The closest ancestor in spirit is Lisp. Where Lisp reduced computation to its purest form — programs written in the language’s own data structures, an evaluator expressible in the language it evaluates — the proto-schema attempts the same reduction for description: data described by data, all the way down to a kernel that describes itself. Homoiconicity, for contracts rather than code.

1.4 Typed without a schema

Most schema systems make you choose: protobuf will not run without an IDL; JSON Schema leaves untouched data untyped. TSON is gradually typed. With no schema in scope, base type resolution gives you JSON’s value model plus real numbers — hex, binary, digit separators, arbitrary precision — and the built-in type vocabulary gives you common typed values by annotation alone:

{
  id:     !uuid 550e8400-e29b-41d4-a716-446655440000
  price:  !number 19.99
  placed: !datetime "2026-07-18T09:30:00Z"
  digest: !bytes "TyqQ3hHDt6Y="
  lead:   !duration PT36H
}

Each annotation invokes a parsing contract — the value becomes a UUID, an exact decimal, an instant, octets, a count of seconds — with no schema anywhere. The schemaless built-ins and the bundled libraries denote the same contracts and the same value spaces — !uuid is net.tn’s uuid, !number is numeric.tn’s, !datetime is temporal.tn’s, !bytes the kernel’s — so annotations added on day one mean the same thing under the schema bound on day ninety. The adoption path runs from “annotate the values that matter” to “bind a schema and pin it by hash”, each step optional and each step reversible.

JSON sits beside this rather than underneath it. A JSON document is not a TSON document — it may carry null, which this notation has no word for, and keys that are not names — so a processor that reads JSON does so through a JSON reader: one more encoding of the same model, whose rules are [TSON-JSON]‘s — JSON null is the void sentinel’s spelling (the position’s declaration decides whether a void value is admitted), a JSON number is number, an object is a record where its keys are declared fields and a map where the schema says so ([TSON-DATA] §6). Under a schema the reader validates on the same terms as the text notation; schemaless, it reads on base type resolution’s — and base resolution itself is stated over what types a position rather than over the header, so a processor binding a schemaless document into a host type that names a family Part 1 defines reads by that family’s contract, exactly as a schema declaring it would ([TSON-DATA] §4.1). What the notation gives up by not being a superset is one keyword and a handful of rules that existed only for the claim; what it gains is that JSON compatibility becomes a reader’s job, done properly, instead of a property the notation had to keep at the cost of its own consistency (§2.8).

1.5 One model, many encodings

The type system stands above any particular wire form. Inside the specification this shows up as a discipline: a token’s quoting is lexical necessity, never meaning (!number 10.2 and !number "10.2" are the same value); a set is a schema property carried in array syntax, because “unordered” is an instruction to the reader, not a property of linear data; whether a !variant tag may be omitted rests on a disjointness fact the resolver derives independently of any encoding, with each encoding stating its own discrimination rule over it.

Part 2’s §7 — the Text Encoding Rules — is the first instance of the pattern: how the type system’s values are carried in TSON text. Other encodings state their own rules against the same model. A JSON encoding must decide what a tuple, a typed map key, or the void sentinel become in a poorer notation; those decisions are parameters, and because schemas are data, the parameters themselves can travel as schema-governed documents beside the schemas they configure. The type system decides what the value is; an encoding decides how it is spelled — and Revision 35 made that sentence normative as the value-space clause ([TSON-SCHEMA] §5.5): a type denotes a value space, an encoding defines a lexical space over it, and equality, ordering, refinement, disjointness and content addressing are defined over values only. Part 3 ([TSON-JSON]) is the second instance, and the pattern held: it states atom forms, container syntax, void and a discrimination predicate against the same model, and where the two encodings looked as though they would differ — a choice untagged in text and tagged in JSON — the difference turned out to be a fact the model had mis-stated, and moved into Part 2 (disjoint carries class stability, [TSON-SCHEMA] §5.4). The clause also sorts encodings into classes — TSON text and JSON both spell bytes in an alphabet and an instant with an offset; an octet encoding writes the bytes and the instant and neither. A representation directive in a schema would bind a class, never one member of it, and the class is named by the encoding-rules documents that claim the directive ([TSON-SCHEMA] §6); the series declares none today, its one candidate (@rest) having failed to name a class at all, and the facts that were briefly annotations — how a record may be realised, which fields select its members — turned out to change what conforms and became grammar (§2.10).

1.6 Reading the series

The series is three documents and eleven artifacts:

  • [TSON-DATA] — Part 1: Text Data Format — stands alone: the lexer (shared by the whole series), the data grammar, base type resolution, and the built-in type vocabulary. If you are writing a parser, an editor mode, or a formatter, it is the whole job — start here and stay here until it passes the data-format test suite. A Class 1 processor needs nothing from Part 2 and remains a complete, useful tool: a notation with the manners of JSON and types of its own.
  • [TSON-SCHEMA] — Part 2: Type System and Schema — the centre of the series: a second body grammar over the same lexer, the type system, the import chain, and resolver output. If you are writing a validator or resolver, this is your contract. Read §3 (one namespace, and the resolution order) first — it is the picture above, made normative — then §4–§5 for the type system and its grammar, §8 for what resolution produces, and §7 for how typed values are carried in text.
  • [TSON-JSON] — Part 3: JSON Encoding — the second encoding: how schema-governed values are carried in RFC 8259 JSON, with three $ members — used, not reserved — for what JSON has no slot for, null as the void sentinel, a sealed family dispatched on its discriminator members, and the TSON-Schema header field that names a schema out of band. Read it after Part 2 §7, which is the shape it follows.
  • The companion artifacts — the kernel meta.tn, the three data-format libraries numeric.tn, temporal.tn and net.tn, the bundle core.tn, the policy vocabulary policy.tn, and the five resolved fixtures — are the normative vocabulary and the reference answers: the system describing itself. Implementations pre-load the kernel as an in-memory structure and should pre-load the libraries; the documents are descriptions of those structures, and round-tripping them is the first serious integration test. Reading the kernel after Part 2 §4 is the fastest way to make the type system concrete: its root is the thirteen names an author writes bare, and everything else sits under six schema maps named for concepts — meta, atom, numeric, unicode, net, binary.

A useful reading order for implementers: Part 1 §7 (the lexer and grammars), Part 1 §2–§4 (documents and base resolution), Part 2 §3 (one namespace, the wait order), Part 2 §5 (the type-definition grammar), Part 2 §8 (resolver output), then the fixtures. This guide carries what the specifications exclude on principle — rationale, design history, worked examples, deployment guidance — and nothing in it is normative.

2. Design Rationale

TSON’s schema model was derived rather than assembled from precedent. The proto-schema research series (tson.io/research/proto-schema/) starts from the physical constraints of serialized data — linear, immutable, finite, divisible — and derives what a schema can be: Sequence and Choice as the structural primitives; Tuple, Record, Array, Map, and Set as the configurations of Sequence worth naming; templates as definitions with blanks on a spectrum of completeness; composition with narrowing rules that preserve substitutability; required-by-default multiplicity with void as a sentinel rather than a type. The kernel is that conclusion made executable.

This section carries the standing rationale for the decisions the specifications state without argument — why each rule is what it is today. It is deliberately not a decision archive: superseded designs, the paths not taken, and what changed between revisions live in each revision’s change log, not here.

2.1 No comments

TSON has no comment syntax. This is not an oversight inherited from JSON but a decision made deliberately: comments are metadata, and TSON already has a metadata channel with defined semantics — annotations. A comment is invisible to the data model, gets lost on round-trip unless implementations invent preservation rules, and inevitably becomes a side-channel for machine-readable content (as happened with JSON parsers that accept // and the ecosystem of magic comments in YAML). @doc:"..." does everything a comment does, survives round-trips by rule, is attached to a specific value rather than a lexical position, and is typed and validatable when a schema is in scope. The cost — you cannot annotate nothing, or comment out a region — is accepted: TSON documents are interchange artifacts, not source code.

2.2 No anchors, references, or merge operators

The locality principle ([TSON-DATA] §1.2 principle 5) says a value is fully local: what appears at a position is the complete value. YAML’s anchors and merge keys are the counter-model, and their costs are well documented — reference cycles (the “billion laughs” family), non-local reasoning (you cannot review a value without scrolling to its anchor), and merge semantics that differ across implementations. TSON’s position is that reuse belongs to the producing application, not the wire format. Where genuine cross-document reference is needed, it is explicit and typed: a !uri value plus an application-level dereference policy, or a value named by a reference over its type, meta.ref<T>, where the referent is a value the schema itself declares (§6.8).

2.3 Why ! is an instance

In the type-definition grammar, !C { … } is an instance of C — in a schema as in data and in resolved output — and whether the entry it declares is a type follows from C’s chain and not from the grammar: !numeric.integer_type {} is a value of a constructor and so a type; !operation { … } is a value of the record operation and so a value entry, marked by nothing but its tag ([TSON-SCHEMA] §4.1, §5.5). ^ is the refinement operator, and it refines any value under the rules its type states — T ^ { ... } for a record under meta.record’s field rules, and for a constructor, which is a record; !I ^ { values } for an atom instance under its constructor’s facet rules, and for the kernel’s own reference instances (meta.type_ref => !meta.field_type_ref ^ { allow_sibling: false }), while meta.namespace => meta.map ^ { … } is the record form over a constructor; a type that states no rule for a field the body names refuses the refinement at ^, so a container instance and a namespace value are finished. Revision 39 made both statements total, where Revision 38 had said ! takes constructors and ^ refines atoms and records: the two forms the grammar has are !C x, a value of C, and x ^ { … }, a tightening of x, and what each produces is the type’s business. The division still buys what it bought: a reader never needs the resolver’s lookup order to know what a declaration does — the prefix says instance, ^ says refine, and IS-A creation is visible at the head of the line. There is no marker for constructor-ness, and Revision 35 removed the one there was: every question the ~ marker was asked — may this be applied, may this be refined as an atom, who may declare it — turned out to be answerable from the supertype chain, and better, since the marker had been asserting a level over chains that did not support it and needed a hard-coded exception for the kernel’s alias body, which describes no value and is nonetheless applicable. It also preserves one invariant across every grammar in the series — !T x describes a value shaped by T, in schema source, in data documents, and in resolver output alike — because the alternative (letting ! also target instances for narrowing) gives one surface string opposite meanings in the schema and data grammars. Bare juxtaposition is excluded on the same grounds: name { is a parse error whose diagnostic suggests ^ or &, so the difference between preserved and absent IS-A is two explicit operators, never one easily-missed ampersand.

2.4 Subtraction breaks IS-A on purpose, at the head

Subtraction (account - { password } — a removal clause on a construction head) is the operation type systems usually refuse to provide, because removing a field from a subtype violates substitutability. TSON provides it and makes it break IS-A, which dissolves the objection: account_public is not claiming to be usable where account is expected — the resolver leaves its chain empty precisely so no consumer can treat it as one. Where it came from is in the source document, which has it; Revision 37 kept a second “lineage” list in the body for it, and Revision 38 removed it on finding that nothing read it after resolution (§8.2) — what a type promises is the chain, and what it was derived from is provenance a resolver need not carry. The practical use cases — view types, redaction shapes, public projections of internal records — are common enough that forcing authors to re-declare near-duplicate records would be the worse outcome.

The clause sits at the head, not in the body, so the contract break is visible on the declaration line: an & head promises IS-A per parent, and nothing three lines down may silently revoke it. Taxonomically subtraction is composition’s sibling — a construction that computes a field set and disclaims the contract — so it declares itself where constructions do, ^ heads reject it outright (an operator that promises IS-A cannot host the operation that revokes it), and empty subtraction is ungrammatical rather than merely prohibited. The clause also keeps removal a full grammar position away from its semantic opposite, field?: void? (keep the field, forbid its value, preserve the contract). Subtraction is also the one operation a FINAL record admits (§2.10): FINAL forbids the IS-A edge, and subtraction never mints one.

2.5 Why elided modifiers are safer than they look

In a refinement body, field: = value (no type expression) inherits the field’s type and changes only its state. Allowing this looks like a convenience with a trap — what if the author typos the field name? — but the grammar closes the trap: a modifier-only entry names no type, so it cannot declare a new field, and an unmatched name is a resolver error rather than a silent addition. Compare the alternative, requiring the type to be restated: now a refinement that tightens spec: uri to a fixed value must repeat uri, and if the source declaration later changes the field’s type, every downstream refinement silently pins the old type or errors at a distance. Elision makes the common tightening robust against upstream type changes; restating the type is reserved for the case where the tightening genuinely narrows the type, which is exactly when the author should be explicit.

2.6 Field groups, and the labelled sum they make derivable

A lower bound that is inclusive or exclusive — one location, one occupant, discriminated by field name — is a labelled disjunction, which is a shape, and a schema language that cannot state its own recurring shapes in shape is under-serving its meta layer. Field groups ([TSON-SCHEMA] §5.11) make the illegal state ({ min: 0 exclusive_min: 0 }) unrepresentable, and they sort coherence rules into a clean taxonomy: exclusion among fields is a group; co-presence dependency is a factored sub-record (integer_size pairs bits with signed structurally); value-level relations (min ≤ max) remain schema-load checks, because no shape can capture a relation between values. In resolver output, groups flatten — members become ordinary fields with optional: true and the grouping recorded beside them in record.groups — following the general storage rule: canonical output stores derived data only when derivation is non-local (subtypes earns its storage; group membership, locally derivable, compiles into a per-record table at load).

Groups are also why TSON ships only one closed sum primitive (the open one, meta.declared, draws its variants from the closure rather than listing them, §6.4). choice is the unlabelled form — distinct variant types, discriminated by the !variant tag or by discrimination class where the variants are distinguishable by form ([TSON-SCHEMA] §5.4); the assertion @meta.disjoint makes is that weaker, more useful one — distinguishable, not mutually exclusive in inhabitance, since text admits every token and 42 inhabits both variants of a disjoint ( int32 | text ). The labelled form needs no primitive because groups derive it: a record whose entire body is one group that must be chosen, each option one field, admits exactly one field, and timestamps => { ( created: datetime | modified: datetime | accessed: datetime ) } is the labelled sum — named, referenceable, discriminated by label, encoding as the single-field record { modified: … }. The untagged-untyped corner — JSON Schema’s anyOf, discrimination by trying every variant against the structure — is deliberately unoccupied. Two residues: the labelled-sum pattern resolves to kind PRODUCT, so lowering it to a native sealed variant is a code-generator recognition (one such group, no other fields); and because records refine where choice cannot, the labelled sum acquires a subtyping story — a refinement can pin or forbid variants — that a primitive sum would not have had. The third shape, alternatives told apart by a member they carry (OpenAPI’s discriminator, serde’s internally tagged enum), is neither a choice nor a group but a record family with a selector field (§2.10).

A group is a presence rule, and “exactly one of” and “at most one of” are only two of the presence rules real contracts state. “A contact needs an email, a phone, or both” had no spelling: a group that must be chosen refuses both present, optional fields admit neither, and a choice of records needs one variant per non-empty subset — three for a pair, thirty-one for five — every one of them brace-class and so tagged in JSON, which changes the data. A hand-classified census of the presence rules in SchemaStore found 113 sites of this family: at-least-one-of a set of keys, one key requiring another, and compounds such as “include alone, or at least one of name and type”. The answer was not a third group state but a second dimension: an option may hold several fields. | separates options, whitespace separates the members of one, a ? on a member’s name marks it optional once its option is chosen, and three rules decide the rest — an option is chosen when any member is present, a chosen option must hold every unmarked member, and a group admits exactly one chosen option, or at most one where it is optional ([TSON-SCHEMA] §5.11). ( host: H port: P | socket: S ) is either-pair-or-single; ( b: B a?: A )? is “a requires b”; ( a: A b: B )? is both-or-neither. “At least one of” is the one-option group whose members are all marked — that option must be chosen, and any member chooses it — and it is written ( email: E | phone: P )+, the sugar being the only spelling so that the kernel form never appears in source. That + is the one lexer change of the revision: a bare + joins the special tokens under -’s boundary rule, +5 unchanged.

Two disciplines keep the generality honest. Each presence rule has one spelling: a group that restates plain fields, or another group, is refused with the spelling it restates — ( a?: A | b: B ) is ( a: A | b: B ), ( a: A )? is a?: A — a rule set checked by enumerating every group over two, three and four members, so that no accepted group equals plain fields or another accepted group, and every option of a declared group can be chosen on its own. And the in-option mark belongs to the group, not the field: a member’s record_field.optional stays true, so that optional keeps meaning “the key may be missing”, a consumer that ignores groups reads every member as optional — loose, never wrong about a valid document — and presence logic is stated in one place. What the form does not reach is stated rather than stretched to: a requirement triggered by a key’s absence, a field under two rules, a chain of three, and a condition on a value stay sealed families or migration cost, and nested groups were declined, one site in the census against a recursive grammar, a recursive kernel type and recursive sum types in every generator. 97 of the 113 sites are spelled, and all 55 of the at-least-one shape.

2.7 Templates, sugar, and synthetic entries

The container sugar is grammar over parameterless constructors: [T; 1..100], {K => V ; 1..}, [T?], {K => V?}, and their kin desugar to !meta.array and !meta.map binding records by a fixed table — the two containers carry the same voidable facet, so a void value is opt-in for a map’s value exactly as for an array’s element — a type slot is an ordinary required field typed by a reference over a constructor (meta.type_ref, §6.8) and filled like any other required field, and the value-level residue is the family’s own coherence rule (min_items ≤ max_items and its kin) — checked at schema load for literal bounds and again at materialisation for parameter-bound ones, since an open bound has no value to relate ([TSON-SCHEMA] §5.3, §8.2). Abstraction lives in templates: parameters route into slots at application sites inside template bodies, and the container constructors themselves stay parameterless. Resolved output does carry open entries — a record template appears as a !meta.record_template with its parameters labelled in its fields, every other template as !meta.template { parameters: […] template: "…" }, the held application as text — but every entry a data document’s type can reach is closed, so a consumer of closed entries never meets one ([TSON-SCHEMA] §1.3, §5.10, §8.1).

Every application denotes an entry. A sugar form at a use site lifts to a synthetic entry — closed for a concrete form, open (a held application) for a parameter-bearing one — and a fully-bound template application denotes its expansion — the closed synthetic the substituted form lands on, or the declaration that names the application; a use site holds a bare reference to its entry, so a consumer walks names, never a second structural interpreter, and recursion has the entry it needs to tie the knot ([TSON-SCHEMA] §5.3, §8.2). Revision 36 settled whose entry an instantiation is: a declaration that names an application — bx => box<text> — is the entry, closed in place under the author’s name, and only an application no declaration names mints one. The axis is construction against reference, and an application had been filed by its syntax (a parameterised reference) when it is a construction in substance; the cost of the old filing was a subtypes index reading a minted hash where the author had written dogs. A sugar form keeps minting, deliberately: an array or map is the same definition wherever it is written and takes part in no IS-A, so a content-keyed entry is the right one to share, where a record’s declared name has to be its entry for a family and a tag to reach it. Identity is structural for the minted half — closed synthetics by body, open ones up to parameter renaming, a declared instantiation by the canonical application recorded as @meta.application on its key, with an argument’s alias chain followed so that a rename is transparent — so spelling variance never forks an entry and the two channels dedupe against each other’s products; minted names are internal and carry no information, and no consumer contract may key on one (§8.1 below). A declared entry’s identity is its name, however alike two bodies are.

The theoretical basis is the spectrum of completeness developed in the proto-schema research series (Part 5, Templates — tson.io/research/proto-schema/part-5-templates/): data and schemas are one continuum distinguished by how many blanks remain, and a template is a definition awaiting completion. The design makes the spectrum mechanical, and Revision 34 made it uniform: an open definition’s body is the constructor application as written, held unread until its parameters are substituted away — <T, N> !meta.array { element_type: T min_items: N max_items: N } is stored as exactly that text, and closing it means replacing the parameter tokens and reading the result, once, against meta.array’s vocabulary ([TSON-SCHEMA] §5.10). The alternative — quoting an open body slot by slot into a typed vocabulary with a labelled channel per slot kind — has to grow a spelling for every kind of slot a parameter can reach, and a collection slot has no natural one, which is how a design can end up unable to write result => <T> ( T | error ), the most ordinary generic of all. Holding needs no vocabulary and has no such boundary: a parameter in a value slot, a type slot, or a variant list is a token like any other, substitution is one walk at any depth, and the only cost is shadowing’s usual one (a literal spelled like a live parameter is unreachable — rename the parameter). What the typed quotation bought — that body identity not depend on a spelling choice — holding gets by requirement instead: the open form has one spelling, however many phases produce it. The completeness coordinate is then simply which tokens still name parameters; nothing else changes shape as a definition closes. In output an open body is carried as text inside the kernel’s meta.template constructor, because that is what “held” means: a parameter stands wherever a token stands — min_items: N as readily as element_type: T — so a body holding one is not a value of any constructor’s record shape until it closes, and an earlier draft of this revision that wrote it as one validated [TSON-SCHEMA] §5.10’s own vector example as two errors and read extern_of’s S as a relative URI. What is compared is always the parsed form, never the text, so whitespace is free and “one spelling” stays a rule about structure. The same cleanup removed two fields a document could lie about: an entry’s kind is now derived from its value’s tag and chain by a four-branch rule every resolver agrees on, and a choice’s disjoint lives in the !choice body beside the variant list it is derived over, where a closed record makes “on every choice and nothing else” structural rather than prose. Two questions are answered no for v1, deliberately: a parameter carries no bound on the constructor its argument was built with, and every reference binds fully — with one exception Revision 36 admitted on its own reasoning rather than as a type-family reading. A record template may stand bare at a type position as a family base: no value is ever read against it, since a value there is a value of some member selected by a tag or by a discriminator field, and every member is closed. pet => <N, T> { type: text = N pet: T } with dog => pet<"dog", dog_type> & { … } is the labelled sum with a payload type per member; result => abstract <T> { payload: T } with ok => <T> result<T> & { … } is abstract class Result<T>. What stops the bare name meaning box<?> is elimination, not policy: a box position would have to infer arguments from a payload, and [] determines nothing (§2.10, [TSON-SCHEMA] §5.10). A third was settled by removing a rule: a parameter nothing grounds is a type parameter, since a value parameter is one that stands in a scalar slot, and loop => <T> loop<T> is refused for applying itself forever rather than for its parameter.

A parameter carries its type. An open entry once listed its parameters as names, and everything else about an application waited for substitution: a literal where the body wanted a type failed as type_ref.name rejecting a non-identifier, located inside the template body with the application as context, and a consumer of resolved output — an importing schema, a second resolver, a host binder generating a generic — could not check an application at all without parsing the held text the format otherwise keeps it from needing. The resolver was already working out what each parameter was and then discarding it. Now it records it: meta.template_param => { name type bound? }, where type is the declared type of the slot the parameter stands in — meta.type_ref for a type slot, numeric.count for min_items: N, the field’s type for a routed default (w?: int32 ~ N gives int32), a callee’s recorded type through a fixed point across templates that apply each other ([TSON-SCHEMA] §5.10). The kind is not recorded, because it follows: a parameter whose type is meta.type_ref is a type parameter. An application is then checked at the call site, against the parameter list alone — vector<pixel, "two"> is refused at the argument — and e<c> over e => <M> !enum [a b M] reads c as a member because the recorded type says so, not because of how it is spelled. A list of records rather than a map, since arguments are positional; rather than two parallel lists, which drift.

The same record is where a bound lives. <T: text> restricts a type parameter to types that are IS-A text, and <N: int8> narrows a value parameter; the written type is read by the kind the uses give, so it can narrow and never contradict. bound is its own field because a type parameter carries two facts — the argument is a reference, and the reference is restricted — and folding the second into type would make <T: text> { a: T } and <M: text> !enum [a b M] record alike. Bounds are inherited through another template’s argument list, so rebox<int32> fails at rebox and not deep inside boxed. Two edges are worth knowing. A bound follows IS-A edges only, and construction grants none, so <T: text> refuses meta.identifier and every fresh !unicode.text_type {} — right for values, where a stock_code must not pass as text, and a limit for bounds, which ask what a body can do with T; a bound on the constructor (<T: unicode.text_type>) is the recorded answer and is not taken. And a slot-derived type and a written one now resolve in one namespace, so <N: int8> over a numeric.count-typed slot is refused for int8 not being a numeric.count, and over an integer-typed default admitted — the “structure namespace” an alias template’s parameters once read in is gone with the hop (§4.2). A type slot bounded at a field by a nominal bound is the larger step, a dependent record the series has not had; what a field gets instead is a reference — name_type: meta.identifier_ref says the slot holds the name of a type built with unicode.identifier_type, handler: meta.ref<method> that it holds a value declared elsewhere by its qualified name (§6.8) — and, from Revision 39, a sibling, value?: type!, the type a sibling field’s value names (§6.10). Revision 38 split the templates in two along the line every language with both draws (C++‘s alias and class templates, Haskell’s type and data), and Revision 39 gave the split its own constructor: a record template is a meta.record_template — a family, read as the vocabulary, its fields meta.record_template_fields that label a parameter standing for a whole type (type_param) or a whole value (value_param), since a base must have a shape its members share and a reader built for a meta.record must never be handed one — and every other template is a held !meta.template, an abbreviation whose application is its expansion and nothing more ([TSON-SCHEMA] §5.10, §8.2). meta.record is closed by construction, so no field of one carries a parameter, and a literal value under a parameter-typed field is refused where it is written: a template’s verdict is its own. A parameter is never an operand of & or ^, a selector’s type names no parameter of either kind, a routed = P is FIXED where it is written, and a parameter pin is a selector whatever its name’s mark.

2.8 Not a JSON superset, and the rules that went with the claim

For many revisions Part 1 said that every valid JSON document was valid TSON, and the claim was true in the way such claims are: at the structural level, outside two character-level exceptions, and only in schemaless reading. Revision 35 withdrew it, and the removal was worth doing for what it exposed rather than for the sentence itself. Five rules had been carried for the superset and nothing else, and each was decided on its own merits once the claim was gone:

  • null is gone. It had been a second spelling of void that only Class 1 could see: under a schema, null was already the string null at a text position and a synonym for _ at a void position, so the value four sections of prose defended was one no Class 2 processor could observe. The first implementation had quietly modelled the two as one node because no consumer had a use for the difference. Now _ is the format’s one spelling of void, null is an ordinary string schemaless and whatever the position’s type makes of it under a schema, and a JSON null reaches the model as void through the JSON reader — which is where JSON’s habits belong. It would have been a mistake to guard the change with a reserved word; “there are no reserved words” now holds without a footnote.
  • \/ went, and the surrogate pairs went. A solidus needs no escape anywhere in the format. The four-hex-digit escape could not name a supplementary character, so the format had inherited JSON’s UTF-16 workaround and then needed three MUST clauses to forbid its ill-formed halves. Now there are two spellings of one scalar value — \uXXXX and \u{1*6HEXDIG} — and one predicate, the value denoted must be a scalar value; a surrogate pair is two errors, plane 14’s variation selectors are spellable by an ASCII-safe generator, and “TSON strings are well-formed” is a property the grammar cannot violate instead of a rule the lexer enforces.
  • A field name is a name. Class 1 field names had been lexical — { "first name": 1 } was a record — because a JSON object key can be anything. That cost two name rules, a per-class hygiene walk, and a carve-out for "_id". Now a record’s fields are the named members of a shape, which is what makes them declarable, and a key that is not a name belongs in a map: { "Content-Type" => "text/plain" } was always the honest spelling. Normalisation runs before the match, so a decomposed spelling is a duplicate rather than a malformed name, and _id is not writable in a schemaless record at all. Revision 38 kept the principle and moved the judge: a field name is a value of its record’s name type, an identifier by default and always in a schemaless document, and a schema may declare another identifier family for a record translated from a vocabulary whose names begin with @ or $ (§2.11). The grammar asks only that a name be a single-line token; the resolver, or the schema, says which names a record admits.
  • A comma may follow a value. The trailing-comma ban was RFC 8259’s, inherited whole, and it prevents an ambiguity TSON does not have: JavaScript’s grammar has elision, so [1, , 2] is three elements with a hole and a trailing comma is genuinely ambiguous between two and three; TSON’s void value is spellable and occupies a slot, so [1, 2,] is two elements and [1 2 _] is three and nothing is confusable. The other coherent position — drop the comma altogether — fails on the type expressions, where pair<uuid, B> and [text, int32] parse through the same separator rule, so the comma had to stay and the ban did not. [, 1] and [1, , 2] are still refused, not by a rule but because a comma is not a value.
  • What stayed, and why. true and false keep their keyword reading under base type resolution — not for JSON’s sake but because BOOLEAN is a discrimination class only while the two tokens match ahead of the number grammar, and Part 2’s derived disjointness reads that class. The near-miss fall-through stays: 007 is the string 007 because a zero-padded token is data whose zeros are significant, and no digit-initial rule can refuse 007 while admitting 2025-03-13, the most ordinary unquoted token in configuration data. And records and maps go on sharing { }, because the shared brace was a good idea independently of where it came from.

The one-line summary: the notation is JSON-like by design, and everything JSON-shaped that stays — braces, brackets, "-strings, the \n \r \t \\ \" escapes, base type resolution as a mechanism, number for a bare numeric token — stays on reasons that survived the claim.

2.9 A field answers three questions

Through Revision 35 a record field had one of six states, spelled by two marks — ? on the type for presence, ~/= for the value — and the states were named for what a consumer receives: REQUIRED_DEFAULT was “required” because the reader always got a value, though the document could leave the key out. The names described delivery, and delivery is what a bound object sees. What they could not describe was the document. Pulled apart, a field answers three independent questions — what a missing key means (an error, nothing, or an injected value), what a written _ means (an error, or a void value), and what a written value may be (anything, only a pin, or nothing) — and with two marks answering three questions, four cells real schemas use had no spelling. Measured over 963 SchemaStore schemas, the commonest field shape in JSON Schema — key optional, null refused — was one of them, at four fifths of all declarations; a required key that may be null (fromWeight: null for “no lower bound”, against an omitted fromWeight for an incomplete record) was another; a nullable default (timeout: _ for “no timeout”, omission for “thirty seconds”) was the one that changes what decodes; and a marker the document must write ("jsonrpc": "2.0") had only an injecting spelling, which helps the one reader that needed it least.

Revision 36 gives each question its own slot ([TSON-SCHEMA] §5.2): ? on the name answers omission, ? on the type answers _ — the same thing ? already meant at an array element, a map value and a tuple slot, the record field having been the one position where it also answered omission — and the modifier answers the value. a: T, a?: T, a: T?, a?: T? are the four free cells (TypeScript’s a: T, a?: T, a: T | null, a?: T | null, three of them to the character); a?: T ~ v and a?: T? ~ v are the two defaults; a?: T = v is the injected pin and a: T = v the marker. The resolved meta.record_field stores the four facts a reader consults — optional, voidable, role, value — and derives what omission yields, which is why no field_state enum of any size survives: a field that is not optional is missing, an optional one with a value injects it, and the reader’s three decisions (a written _, a written value, a key never written) each read exactly the facts they need. Three spellings are refused as rules rather than gaps: a default on an unmarked name (a value only omission can reach, on a key that is always written), a pin on a voidable type (_ is settled before the pin is consulted), and a modifier on void. = _ is gone because the type already spells it — a?: void? is a field that may be missing or written _ and nothing else — and refinement is three orders, one per question, rather than a five-by-five matrix: omission moves nothing → required → injected, voidable true → false, role FREE → DEFAULT → FIXED, and a refinement moves no question backwards. Every cell of the old matrix falls out, and the one cell it disagreed with itself about dissolves.

Delivery is then the mode’s, not the field’s. A bound object has one null, so a: T?, a?: T and a?: T? hand over the same thing and differ only in what they refuse; a tree keeps which spelling arrived, in every encoding, because a tree is the document and [TSON-DATA] §2.9 says a field written _ is present with a void value. And an encoder without the schema cannot write every cell: omitting a field with no value is right wherever the two spellings mean the same and wrong at exactly two declarations — a: T?, where the re-read document is missing a required key, and a?: T? ~ v, where it silently reads back as the default — so those two bind a schema-directed encoder and a schema-less one omits ([TSON-SCHEMA] §5.2, [TSON-JSON] §6.1.2). Part 1’s old sentence that _ versus omission “is a serialisation concern” was true exactly where the two answers coincide, and now says so.

Two kinds of nothing, one name each. The field syntax kept the two apart from the day it had three slots, and meta.record_field named them optional and voidable. Everywhere else one enum, element_state, served both, under the word the record used for the other fact: at an array’s element, a map’s value and a tuple position OPTIONAL meant _ may stand there, nothing ever being left out; at a field group it meant the group may be missing. A reader who learned optional from a record read [T?] as “an element may be omitted”, which it never means. Revision 37 gives each fact one name wherever it applies and nowhere else: optional — the slot may be missing — on a record field and a field group; voidable — the slot’s value may be void — on a record field, an array’s element, a map’s value and a tuple position. A container’s part is never missing, so a container has no optional; a map’s key is never void, so there is no key voidable; both are structural and no rule has to refuse them. The prose follows the type: the token is the void sentinel, the value is void, a key is missing or present — which retires “absent”, whose everyday sense was the other fact and is why Part 1 had to say “present with an absent value”. Three alternatives lost. Giving optional to the value, as Swift’s, Rust’s and Java’s Optional do, is as consistent and goes against every schema system — JSON Schema’s required, JSON Type Definition’s optionalProperties beside nullable, XML Schema’s minOccurs beside nillable, TypeScript’s a?: — and it moves a word rather than retiring one, so a stale binding record would still read and mean the other fact. Making voidability part of the type, T? as T with void added, deletes the facet and costs a synthetic choice per T?, an IS-A rule between T and T?, and a rule against void map keys that is structural today. And nullable names the concept after one encoding’s spelling of it.

The same reasoning settled a smaller case. A default or pin routed from a template parameter — w?: int32 ~ N — had been written on an unmarked name, the mark supplied when the template closed, on the argument that nothing is fixed until the value exists. But the name’s ? is the field’s presence statement and says nothing about the value, so it does not wait for one: w?: int32 ~ N is a default, w?: int32 = N an injected pin, w: int32 = N a marker, exactly as their literal spellings are, FIXED or DEFAULT where they are written with the parameter labelled value_param ([TSON-SCHEMA] §5.7); and in a fresh record template a field pinned to a value parameter is a selector whatever its name’s mark, the mark being each member’s.

2.10 Records that say how they may be realised

A base with no instances of its own, a closed set of subtypes each identified by one constant field, and a consumer that switches exhaustively over them is the shape every host language has a word for — sealed interface over records, sealed class, a Rust enum with payloads, TypeScript’s discriminated union — and the shape OpenAPI’s discriminator describes. Revision 35 put the discriminator on a choice, as an annotation naming a field by string, and it could not reach the shape: a choice has no expected record type, so nothing related the variants’ copies of the field, the name had to be written out, and the field’s type had to be checked uniform across variants because a decoder must parse the member before it knows which one it has. Revision 36 moves the mark to where the relation already is. The base is a record, the members compose onto it, and the selector is field syntax beside ~ and =: pet => abstract { pet_type: text =? name: text } reads pinned, but not here — the members pin it, and dog => pet & { pet_type: = "dog" breed: text } tightens it FREE → FIXED, the one arrangement [TSON-SCHEMA] §5.7’s identity rule permits, since a base that pinned the field would leave nothing for a member to pin ([TSON-SCHEMA] §5.2).

Two facts came with it, and both are kernel fields rather than annotations for one reason: erase them and the set of conforming documents changes. meta.record.extension says how a record may be realised — OPEN (the default, and every record until now), ABSTRACT (no direct instances; a position typed by it admits exactly its subtypes, and the tag is required there unless discriminators say otherwise), FINAL (nothing may compose or refine onto it, in any schema). meta.record.discriminators lists the selector fields, and whether it is present is what says how a subtype is selected — by reading the fields, or by the tag — so no fourth enum member states it a second time for the two to disagree. =? implies ABSTRACT, abstract beside it is an admitted assertion on @meta.disjoint’s terms, and final beside it is a refusal. The marks are words at exactly one grammar position and ordinary identifiers everywhere else, so “no reserved words” still holds without a footnote. What the spelling buys over a mapping table is that there is nothing left to disagree: OpenAPI’s mapping evaporates into the pins and is reconstructed from them on the way back out, a pin is a value so a wire spelling that is not an identifier ("dog-v2", "urn:acme:dog") survives, and the pins are compared as values under the field type’s own equality, so = 255 and = 0xFF collide as they should. The family is open across schemas — an importer may add a member under the obligation to pin, refused in the importer if it does not — so a host language’s permits clause generated from one is relative to a closure, not a promise the schema makes. An abstract base with no member in its own closure is not an error: it is what a library schema publishes when its importers supply the members, and a document that reaches such a position gets a diagnostic naming the missing import rather than “one of ()”. Openness has two corollaries the first implementation met as bugs. A family is judged over the closure that holds it: two members brought together only by an import merge, each from a schema that linked cleanly, are the importing schema’s error, since judging only families with a local member would let that schema dispatch on a mapping that is not a function. And a family member is declared: a template application at a use site that composes onto a record would mint a member no declaration names — selectable at its base, and then unreportable, untaggable and unbindable, a minted name being one a document may never write — so it is refused, with the fix in the diagnostic (dogs => dog_of<text>). Revision 38 states the same rule from the other side — a minted record is a member of nothing — and it gained one document: a minted record composing an application only a use site mints (u: vip<text> over vip => <T> box<T> & { … }, beside w: box<text>) had been a member of that minted entry and refused; it is a member of nothing now, and loads. That rule also retired the one open question the declared-application rule had left: with members always declared, the duplicate forms an import merge can produce are never family members, both entries of such a pair admit the same values, and no merge key beside a declared name is needed.

Two consequences that read like exceptions are not. Subtraction off a FINAL record is admissible, because FINAL constrains the IS-A set and subtraction never joins it. And a record template may be a base (§2.7): its extension is ABSTRACT by construction and its own extension is each application’s, its selectors are whatever survives erasure of the parameters — a =? field, or a field pinned to a value parameter, one value per application being one pin per member — and the one condition is that a selector’s declared type mention no parameter of the template at any depth, a value parameter as much as a type one, since a decoder parses the selectors before it knows the member and their pins are exactly what the parameters supply. Both encodings read a member-dispatched position the same way — the marked fields at the base’s types, the value or tuple against the pins, the matched member re-verified as an ordinary FIXED check — and differ in spelling alone; JSON asks the selectors to lead the object so that lookahead is bounded by the schema, and text finds them by lookahead over a record whose fields have no order.

2.11 A name has a type

What a name is. A field name was always a name and never a key: the named member of a shape, which is what makes it declarable, where a key is data that happens to index a map. Revision 35 made that a grammar rule — a field name is an identifier — and the rule served TSON’s own data well. It failed a schema translated from somewhere else. JSON-LD’s objects are records with members @context, @id and @type; OpenAPI’s and JSON Schema’s are records with $ref, $id and $defs; a tool-calling contract’s enum holds lightly active. Each is a fixed set of declared members — a record — and each was unwritable: a map loses the declared shape, and renaming the member loses the round trip.

What Revision 38 does. It copies a mechanism the enum already had. An enum names the family its members are drawn from; a record now names the family its field names are drawn from, in the same slot under the same name: meta.record.name_type?: meta.identifier_ref, and meta.enum_type.name_type (renamed from type). The three rules are one on both — the type is an identifier family, every declared name is a value of it, no two are one value under its equality — and the default is the series’ own meta.field_name, so nothing changes unless a record says so. A translation declares the profile its names need and composes it in:

json_name   => !unicode.identifier_type { start_add: "@$" }
json_record => !meta.record { name_type: json_name  extension: ABSTRACT  fields: {} }
node        => json_record & { "@id": text  label: text }
words       => !unicode.identifier_type { medial: " " }
activity    => !meta.enum_type { name_type: words  members: ["sedentary" "lightly active"] }

The brace form gained no syntax of its own: a relaxed name type is a translation’s tool, not the go-to form for a TSON record, so a translation declares its type once on a fieldless abstract base and its records compose it, at the cost of one IS-A edge that asks nothing of a position typed by a member. Three spellings lost — inferring text from a quoted name (which cannot state a narrower type), a mark at the declaration head (which reserves a word as abstract does), and an annotation (which would make metadata change what a record admits).

What it makes possible. Four things, each a document that could not be written before: a record for a JSON vocabulary whose member names begin with @ or $, round-tripped as the record it is; words as names — lightly active, a display string that is still a name, judged by hygiene, equal only to itself; names compared without case, under a name type whose normalization folds, so Content-Type and content-type are one field and two spellings of it in one record are a duplicate; and a house style, a profile that admits exactly a project’s naming convention and refuses the rest at schema load.

When it is the right tool, and when it is not. It is the right tool when the names are given — by a vocabulary, a protocol, a system of record — and the schema’s job is to describe them faithfully. It is the wrong tool for a TSON schema written fresh, where the series’ own profile is the better convention and the default costs nothing. And it is never the tool for a key: a member name no identifier profile can state — the empty string, a leading or doubled space — is data, and belongs in a map, as [TSON-DATA] §2.5 always said.

What it costs. One IS-A edge to the base, and one rule to know: inheritance keeps one name type. A record’s type is 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 types (json_record & address, where address is meta.field_name’s) do not compose. Taking the non-default type instead would re-judge address’s names by a type they were never declared under, and under a type that must begin with @ none would be a value of it; the record to compose is one declared over the same type. A host component binds a relaxed name through the name the record states (@Field("@id")), and a writer quotes a name that is no identifier, so the document reads back. A schema’s own references to its fields — a group’s members, a restated field, a discriminators entry — match the declared spelling exactly, under a folding 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.

The safety that stays on. Every name is still judged by [TSON-DATA] §8.2’s rules, under its type’s profile — the profile’s own added characters exempt from Identifier_Status, read by the look-alike and script rules — and over a floor no profile lifts: an identifier family may not add a character that renders as nothing or as another character (a Default_Ignorable_Code_Point, a control, a space other than U+0020), since without the floor a soft hyphen in continue_add makes pass­word and password two names no rule tells apart, and a profile derived from a hostile source’s names would carry it in. A name type’s form implies NFC: NONE is refused, and ASCII_CASEFOLD is admitted only over an all-ASCII profile, since a reader must match a document’s name in the type’s form and could choose none the type never stated. A document that names a schema has its field names judged by that schema alone; a Class 1 processor reading it holds them to no name rule, since the rule is the schema’s — Part 1 decides which documents its own rule reaches and leaves the rest to the layer that owns the type.

A worked conversion. A JSON-LD node object, { "@context": "https://schema.org", "@id": "…", "@type": "Person", "name": "Ada" }, converts as json_name above, json_record, and person => json_record & { "@context": iri "@id": iri "@type": text name: text }. In TSON text the names are written quoted, since @ is outside the token profile; in JSON they are the member names they always were ([TSON-JSON] §3.2), and $ref at a record position is the record’s field $ref where it declares one. Part 3’s three annotation members stay — $import, $type, $value — used rather than reserved: a record that declares one obscures its own field, and a schema SHOULD NOT, which is a weaker claim than the old reservation and the only one a $-admitting name type leaves true.

3. Lexical Design

3.1 The unquoted profile, derived

The unquoted-token profile is UAX #31’s identifier profile plus exactly three characters — -, +, . — and the digit category Nd. Every addition is traceable to a production of the number grammar: Nd because scalar tokens, unlike identifiers, include numbers; - and + because they are the sign and exponent-sign characters; . because it is the decimal point and the leading character of .inf, .infinity, and .nan. Nothing is speculative, and all three extension characters are Pattern_Syntax, hence immutable across Unicode versions — the profile itself is frozen while its property-based components grow monotonically with new scripts. Their bare single-character forms, though, are not tokens: - alone is the subtraction operator, + alone the at-least-one mark of a field group (§2.6), .. is the range token, and a bare . is a lexer error — as tokens they would be the accidental single-character strings "-", "+", ".". That the same extensions would otherwise let a schema declare 42 or - as a type name is the identifier profile’s business, not the lexer’s (§3.5).

The exclusions follow a single rule: a content kind the profile cannot cover totally is excluded entirely, so that its quoting rule is “always”, never a per-character scan. Paths need ~, \, and spaces; URIs need :; monetary amounts need currency symbols, grouping separators, and spaces; rationals and CIDR networks need /; percentages need %; ranges need .., which the lexer claims as the range token. Admitting any of these characters would let some paths or some URIs go unquoted — and a rule of the form “quote paths, except those without spaces, unless…” is precisely the cognitive load the profile exists to eliminate.

3.2 Quoting by kind

The payoff of the total-coverage rule is that quoting becomes a property of what a value is:

Never quoted: numbers, true/false, identifier- and enum-like names, full-date temporals (2026-08-01), durations and periods (PT36H, P3M), UUIDs, hyphen-form MAC addresses, version strings.

Always quoted: anything containing whitespace or prose, times and datetimes (colons — a full-date is bare, but the moment a clock time joins it the colon ends the token), URIs with a scheme (colons), email addresses (@), paths, rationals and CIDR networks (/), IPv6 addresses (colons), monetary amounts and percentages, ranges (..), the single-character strings -/+/., and the string "null" where the bare token would be misread by a reader with JSON reflexes (bare null is a legal string all the same). Base64 bytes values are quoted by convention — = padding is outside the profile anyway.

Inside a quoted token, a character is escaped by its scalar value in either of two spellings — \u0041 or \u{41}, the braced form reaching any plane directly — and there are no surrogate pairs and no \/ (§2.8). A leading-underscore name is not a name at any layer: _id belongs in a map.

A generator needs only two clauses: quote if any character falls outside the profile, and quote if the bare token would resolve to something other than the intended string ("true", "42", "0x71C7…"). A human writing TSON internalises the kind-level rule after a few documents and never thinks about individual characters again.

ZWNJ and ZWJ deserve their footnote, because the series changed its mind about them. They are XID_Continue — UAX #31 made them default identifier characters when it withdrew the old “restricted contexts” requirement and moved the safety rule to UTS #39 — and the token profile admits them. Earlier revisions excluded them and told authors whose orthography needs them to quote, which got the risk exactly backwards: a Persian کتاب‌ها was unspellable bare, while a Latin ad<ZWNJ>min sailed through the quoted route the same sentence recommended. The joiners are invisible only where they do no shaping work, so the right rule is contextual, and it lives on the identifier layer (§3.5): UTS #39’s joining-control contexts admit a joiner where the neighbouring letters give it a job and refuse it where they do not.

3.3 Canonical identity: restrict, don’t normalize

Reference identity ([TSON-DATA] §2.2.1) stays at RFC 3986’s cheapest comparison rung — simple string comparison — by restricting the input rather than normalizing it. A document’s own !!id must already be in canonical form — the host in its canonical text, port-free, userinfo-free, dot-segment-free, fragment-free, the path NFC with no percent-encoding of unreserved characters; anything else is an error, not a candidate for cleanup.

The host is a host value. Revision 37 made an identity an IRI-reference, so a host may be written in U-labels (bücher.example) and a path in characters beyond US-ASCII, and the rules written for US-ASCII stopped meaning anything: “lowercase host” has no case mapping beyond ASCII, a precomposed and a decomposed é read alike and compared unequal, one DNS host had two identities (bücher.example and xn--bcher-kva.example), and a non-ASCII identity could be named in a document and in no HTTP header, an RFC 9651 String holding printable ASCII only. Revision 38 closed all four without retreating to ASCII, by defining the host once, as a type the series already needed: an identity’s host is RFC 3987’s ihost profiled to DNS, which is exactly Part 1’s !host row — a hostname in either label form, equal by name, canonical in lowercase U-labels; an IPv4 address; or an IPv6 address in RFC 5952 text, bracketed as the IRI grammar requires — and the path is NFC. The one-spelling rule then applies to a document’s own name and the any-spelling rule to a reference to it: !!id is written in canonical form and only so, which is what serving a document at its identity path relies on, while !!import:"https://xn--bcher-kva.example/sch%c3%a9mas/x.tn" is read to bücher.example/schémas/x.tn before comparison — A-labels, ASCII case, an uncompressed IPv6 address and percent-encoded UTF-8 all being spellings of one identity. What has no IRI form (%FF, an encoded bidi control, a percent-encoded unreserved character) stays an error. The cost is real and stated: an implementation needs RFC 5892’s property to compare identities and can borrow no platform URL parser that applies UTS #46’s mapping, since the WHATWG parser answers differently. An identity atom — a family whose equality is canonical identity — was built and withdrawn: every atom pins an external standard where this one would pin §2.2.1 itself, identity is a name a bundled root should not take, and a processor that emits identities in one spelling already gives a text field the right equality.

RFC 3986 §6.1 supplies the risk asymmetry that motivates the conservatism: for a schema system, a false positive — two distinct documents judged identical — validates data against the wrong contract, silently; a false negative merely costs a redundant registration or fetch. Every rung of normalization ladder an implementation climbs (case folding, path resolution, percent-decoding, scheme-specific rules) buys convenience by increasing false-positive risk and by widening the room for two implementations to disagree about identity. TSON climbs one deliberate rung — the scheme is dropped, so http/https variance names the same document — because transport choice demonstrably should not fork a document’s identity, and stops there. The host, by contrast, is load-bearing on purpose: a fetch-endpoint change cannot silently redirect a name.

Why the pin rides in the query. Two objections have been raised to ?sha256=: that a query is request data where a hash is client-side verification, so the fragment is the honest component; and that integrity should not be in the URI at all but in a structured { url, hash } value. Both rest on a premise that is the wrong way round — sending the pin is the point. A pin in the query reaches the origin and the cache, so a content-addressed store can answer a pinned reference with the exact bytes it names long after the unpinned URL has moved on, and a cache can keep a pinned URL forever; a fragment is never sent, and a structured value separates locator from integrity so cleanly that the integrity never reaches the party able to satisfy it — either can detect drift and never repair it. The fragment is also spoken for: it is the natural home of an intra-document reference (…/core.tn#name), which the series will want next, so Part 1 reserves it. Canonical identity strips the pin because the two answer different questions — the pin identifies a byte sequence where the rest of the URI identifies a resource — which is a consequence to state, not a smell to design away.

References restrict; values compare. Reference identity restricts its input because a URI is a spelling with no value space behind it to compare. Every other identity rule in the series goes the other way, and Revision 35 said so once ([TSON-SCHEMA] §5.5): a type denotes a value space, an encoding a lexical space over it, and equality, ordering, refinement, disjointness and content addressing are over values. The clause was forced by a bug: the first implementation compared bytes by array identity, so a set of digests accepted every duplicate there was, and fixing it meant deciding — with nothing in the series to decide it from — that two spellings of one octet string are one value, while the temporal families in the same processor compared the offset and called one instant two values. One missing sentence, one processor, two contradictory answers. Now every family states its value space in its own @doc: bytes is octets and the alphabet a spelling; 1, 1.0 and 1.00 are one number; 2/4 and 1/2 one rational; time and datetime are instants, the mandatory offset a spelling, so 10:00:00+01:00 and 09:00:00Z are one value and both families are totally ordered; duration is seconds and period is months. A content-addressed reference is thereby never encoding-dependent, which was the property hash pinning existed to provide.

3.4 Content addressing and the verification chain

The hash-parameter convention (?sha256=…, verification metadata rather than identity) composes into a Merkle-style dependency graph in the manner of content-addressed stores: a data document pins its schema, the schema pins the libraries it imports, the libraries pin the kernel, and the chain grounds in the pre-loaded kernel. A consumer holding a single hashed reference can therefore verify a document together with its entire contract chain — every byte that determines how the document will be interpreted — without trusting any intermediary. The rule that the hash input is every byte after the !!id line exists to make this work: a document can carry its own name without the name’s spelling perturbing its hash, so registrars and mirrors can relocate content while the identity-plus-hash pair stays verifiable.

Mixed references combine per identity. In practice, references to one schema will arrive in mixed spellings — one document pins, another does not — and the resolution model is built for that. Within a closure, verification attaches to the canonical identity, not to individual references (the closure already loads one instance per identity, so there is nothing else it could attach to): the digests declared across all references to an identity form a set, and an empty set resolves unverified, a single digest verifies the content once — at collection, before any reference resolves — and two distinct digests are a conflict reported as an error, never chosen between. A plain reference in a closure that also pins the identity therefore rides the pin: it resolves to the verified instance, and if verification fails, it fails too — leniency in what authors may write, never a fallback to unverified content. The practical consequence is that pinning is additive rather than viral: any one document in a closure can raise an identity’s guarantee for the whole resolution, and no published plain reference can lower it.

Where the chain grounds. Through Revision 38 the kernel’s !!meta named its own URL, and that one reference could never carry a hash — the hash input included the line, so the pin would have had to hash bytes containing itself. With one directive the kernel’s header is its !!id alone: it references nothing, every reference to it may be pinned, and the verification chain hands over to the bootstrap at a document with no outgoing edge rather than at a self-reference (§1.3). The kernel’s !!id line, being excluded from the hash input, can carry the kernel’s published digest — a useful registration cross-check, verified at registration per [TSON-SCHEMA] §10.2 — but an embedded hash authenticates nothing by itself: an attacker who can rewrite the body can rewrite the id line to match. Trust always flows from the referencing side or from the pre-load; the embedded digest is a convenience for registrars, not a root.

Publication discipline: pin everything pinnable. The mixed-reference rule makes plain references legal everywhere; the published artifacts nonetheless ship maximally pinned, because the Merkle guarantee holds only along edges that are actually pinned — a consumer holding a pinned reference to core has verified the kernel through core only if core’s !!imports carry the libraries’ digests and theirs carry the kernel’s. The artifact hashes are computed bottom-up, each stage’s pins in place before the next stage hashes over them: the kernel’s body first (its !!id carrying the resulting digest), then the three libraries with their kernel import pinned, then core with its three library imports pinned; policy.tn pins the kernel beside them. Publishers of type libraries should follow the same discipline: pin every !!import at publication, so that one hashed reference to the library verifies its entire chain.

The specifications stop at verification deliberately. Ordering, consensus, and mutability policy — everything a content-addressed store adds on top — are application concerns; TSON supplies the identity discipline they need and nothing more.

3.5 Names are not tokens

An unquoted token is a spelling; a name is a thing spelled. TSON’s lexer cannot tell them apart — in { name: Alice } both words are unquoted tokens, and the profile of §3.1 has to admit digits, signs and dots so that a number can be one — which is why, for many revisions, the series had no place to state a rule about names at all. The kernel typed every naming position with an atom that carried no contract, the grammar governed those positions with four different productions (one of which, field names, admitted quoted spellings and thereby every character the profile excluded), and the security section could only advise.

Revision 34 gives names their own layer ([TSON-DATA] §7.7). An identifier is the decoded text of a token — after unquoting, escaping and NFC — matched in full against a profile that is the token profile minus the number grammar’s extensions: XID_Start to begin, XID_Continue plus - to continue. The subtraction is the whole design. A name never begins with a digit, a sign or a dot, which makes “numbers are not declarable names” a consequence rather than a rule; + goes because it only ever served exponents; . goes so that it can be a separator — which Revision 38 made it, in the qualified name orders.place_order a reference slot parses, and Revision 39 widened to every position that names a type — a tag, an annotation name, a member of a nested schema map by its path, geo.address (§4.3, §6.8) — rather than two identifiers that happen to contain dots. And because the identifier profile sits strictly inside the token profile, every identifier is spellable bare — the annotation and type-annotation positions, which admit no quotes, lose nothing by it. Enum members are names, and the enum says which names. An enum is a closed set of names and the identifier family they are drawn from: meta.enum_type => meta.atom & { name_type: meta.identifier_ref members: meta.enum_set }, with the kernel’s root enum pinning meta.identifier — every member a name under the series’ own profile, carrying the hygiene rules. Revision 36 had spelled this as a two-member profile, and no enumeration of profiles can close: everything the profile switched on — which spellings are admitted, when two names are one, whether hygiene runs — is a property of a naming vocabulary, and a schema describing protobuf’s names or HTTP’s tokens has a vocabulary of its own. So the type is held as data, and an ordinary schema writes !meta.enum_type { name_type: kebab members: [make-tea drink-tea] } with no new constructor. Revision 37 had let the type be any text family and shipped text_enum for members that were texts rather than names; Revision 38 withdrew it, because what it admitted needed no text family — !unicode.identifier_type { medial: " " } admits lightly active and refuses a leading, trailing or doubled space, with full hygiene — and a closed set of text that is no name is text’s own members facet, !text ^ { members: ["80" "443"] }. The field was renamed from type to name_type to match the record’s (§2.11), the bare type having said nothing about what it typed. Nothing an author had written changes: !enum [OPEN DONE] is still the positional form, name_type being pinned and injected. A pinned type resolves where it was written — the kernel — so !enum [A B] names the kernel’s meta.identifier whatever the schema imports, and one that declares its own identifier does not displace it. Numbers are never enum members (!enum [1 2 3] is refused; write !integer ^ { members: [1 2 3] }): a name is equal only to itself, where 80 and 0x50 are one integer. The kernel’s own boolean => !enum [true false] is untouched, because true is a perfectly good identifier and its base-type class is still read off the token by Part 1 §4. One claim was withdrawn with the profile: that an identifier member is a host-safe constant. It is true of no host — in-progress is an identifier, and true is refused as a Java constant — so binding is a mapping the binder owns, and a host language is what a schema maps to, never a constraint on it. Measured over real tool-calling contracts, a third of string enums cannot be identifiers, almost all for a space in a display string, and an enum_type over an identifier family with a medial space is the spelling that admits them as names.

identifier is a type now, and that is what lets a value be a name. For as long as identifier was an atom with no vocabulary, the grammar and the hygiene rules reached the kernel’s naming positions through prose, and a map key — a value — was reached by neither: admin and аdmin as two keys of one {identifier => handler} map were admitted, where the same pair as two fields of a record was refused. A rule about maps would have been wrong, since at a map position two look-alike keys may be legitimately distinct data; what says they are names is the key’s type. So identifier became a text family — unicode.identifier_type => unicode.text_type & atom.specification & { … }, a UAX #31 profile stated as data, with meta.identifier => !unicode.identifier_type { continue_add: "-" } reproducing the series’ own grammar exactly — and the line between a name and data moved from the position to the type: a value typed by an identifier family meets the per-name mechanisms, and the keys of a map keyed by one, and the elements of a set of one, are look-alike scopes ([TSON-SCHEMA] §11.4). A document admitted before can be refused, and that is the point. Stating the profile as data buys the second half: a schema describing another system’s names declares its own instance — a start set, a continue set, a medial set, a normalization form — and a profile’s own additions are exempt from the restricted-character rule, the profile being the statement that they belong. The profile facets are fixed where the profile is constructed, because settable-once would be unsound here: setting start_add in a refinement widens the profile. No bundled root declares identifier (§4.5); the schema that wants one writes the line.

The layer now sits on every naming position, the data grammar’s field names included. Revision 34 had placed it on declarations only, leaving a Class 1 field name lexical so that JSON’s quoted keys stayed valid; that bought the superset claim at the price of two name rules, a hygiene walk that ran differently by conformance class, and a carve-out for "_id" that no declared field could bear. With the claim gone (§2.8) the asymmetry went with it: { "first name": 1 } is refused in a schemaless document, the two spellings of a field name are two spellings of one set of names, and the position that knows it holds a name normalises to NFC and then matches — so a decomposed spelling is the same name, a duplicate rather than a malformed one, and the quoted route is never stricter than the bare one. A key that is not a name belongs in a map, which the format always had. Revision 38 moved the match from the parser to the resolver, and under a schema to the record’s own name type (§2.11): the grammar admits any single-line token at a field name, and the layer that owns the record says which names it admits. The one visible cost is the deliberate one: _ is XID_Continue only, so _id is not an identifier, and admitting it to the series’ own profile would be a change for every naming position at once — argued on its own merits, and not cheaply, since the lexer takes _ greedily as the sentinel and every identifier is meant to be spellable bare; a schema that must hold _id declares a name type that admits it.

Two layers, and why the line is where it is. Above the identifier grammar sits a second layer ([TSON-DATA] §8.2): skeleton distinctness within each closed scope (no two names in one record, enum, schema, or import closure may share a UTS #39 skeleton), Identifier_Status=Allowed, and a script restriction level. All three are implemented by every conforming processor and enforced by default — and none of them decides validity. The reason is a property, not a preference: the identifier grammar rests on Unicode properties the Consortium has frozen, so a content-addressed schema returns one verdict under every implementation at every Unicode version, which is the promise a hash pin makes; the hygiene data (confusables.txt, IdentifierStatus.txt) is explicitly unstable between versions, so a verdict resting on it could change under a routine UCD refresh, and those same pinned bytes would flip from valid to invalid. Skeleton distinctness has a second disqualification: it is a relation over a set that spans !!import, so two independently published, independently pinned schemas — list_item in one, Iist_item in the other — can each be fine alone and collide when one imports the other, with the fix in a document the importing author does not control. As validity that makes the import impossible to write; as policy the operator relaxes it, in code, visibly, and proceeds. So a document failing the second layer is refused by this processor: the refused outcome, a rejection here that is never a finding about validity, distinguishable from the four error categories but reported in the same report as them — a consumer repairs a document in one pass, and the distinction is carried by which rule refused, which the report must state anyway. What travels with the report is a property of the processor, not of the refusal: the UCD version of the data files (the tables are published as part of the Unicode Character Database and carry its version, not UTS #39’s own revision number, which names the prose and is stable across exactly the refreshes the rule exists to make visible) and the two policies the series now names — the identifier policy (mechanisms 1 and 2, and the level and unit of mechanism 3, at identifier positions) and the token policy (a restriction level over every token, off by default, subsuming the identifier policy when stricter). A processor should be able to state both with no document in hand: a sender that reads the policy before writing never writes the name that would be refused, where one that learns it from a refusal has already spent the round trip the format exists to avoid. A deployment may relax any of the three — but only through the implementation’s own configuration, never through the environment, and never silently.

Where the policy lives, and where it may not. Two homes suggest themselves and both are wrong. Not the schema: a schema declaring its own strictness would let the artifact being checked choose the check — a homograph-laden schema would declare the level that admits it — and a published schema is immutable and hash-pinned while strictness must move as confusables.txt updates, so raising a policy would mint a new identity that every document pinning the old one would never see. Not an API description either, which is a schema and inherits both objections. Skeleton distinctness adds a third reason: it does not compose across !!import, so the policy is a property of the merged namespace at the importing site, which no single schema is positioned to declare. The home is a third artifact kind — a deployment descriptor that is data rather than a schema, named at the call site and never discovered, never resolvable by identity — and the companion artifact policy.tn declares its vocabulary: the identifier and token policies and the resource limits, in the shape a processor also states its policy in. No document may name, import or otherwise select the policy it is judged under, which is the self-certification the paragraph above refuses ([TSON-DATA] §8.2).

Why skeleton distinctness and not the restriction level for names. The restriction level is free — Script is a property every platform exposes — and it is what browsers use for domain names. But a browser judges a name in isolation because it cannot enumerate what the name might be confused with; TSON always can, because every scope is closed and known at the moment the check runs. A per-name rule must guess from the name alone, and it guesses wrong on ordinary names: Highly Restrictive over a whole name refuses id_пользователя, url_адрес and χ_index, which are the common way a developer working outside Latin script names things — the Latin abbreviations of the trade inside a name in their own script. The skeleton check fires only on a colliding pair, so it costs such an author nothing while catching every homograph, including the whole-script aec/аес that no script rule can see. The restriction level still ships, defaulting to Highly Restrictive, because it catches the within-word mixing a skeleton check cannot until a second name arrives; the relaxation to reach for first is its unit — apply the level per _/- delimited segment, which keeps аdmin refused and admits id_пользователя — not its level. Values no type makes names are the mirror image: they have no scope to be distinct within, so the per-string mechanism is the only one available, and its default is off, because data may legitimately be anything.

4. One Namespace, Narratively

4.1 What schema-value separation buys

The fundamental rule — a document never resolves type annotations against its own definitions — looks austere until you list what it purchases:

  • Stable meaning. !text in a document means what the imported closure says, full stop. Two documents importing the same schemas have identical vocabularies; no document can locally shadow a type and change the meaning of data that quotes it.
  • Reviewability. A schema is a self-contained published artifact. Reviewing a data document requires its imports and nothing else; reviewing a schema requires its imports and nothing else. There is no “definitions section” whose scope leaks into the data.
  • Immutability with teeth. Because schemas are external, hash-pinnable artifacts, “the contract cannot change under you” is a verifiable property rather than a convention.
  • Uniformity all the way up. The same rule governs data documents, user schemas, the libraries and the kernel itself, so the resolver has one resolution model rather than a special case per layer.

4.2 Why one namespace replaced one hop

Through Revision 38 a schema document stood on two relations: !!meta named the meta-schema whose constructors it might apply, and !!import the libraries whose types it might name, and a name resolved in one of two stores by the grammar position it stood at — a constructor head in the meta’s namespace, a field type in the schema’s own. Four rules kept each document on its rung: imports populated only the type-name store, a meta layer alone declared constructors, an import stood at the importer’s level, and the two stores could hold one name twice. Each rule was right about something, and together they were a tax every reader paid: a reader of !enum [A B] had to know which store enum lived in, a schema wanting a constructor of its own needed a meta layer and a !!meta line, and core shipped same-named copies of kernel entries so that data documents could reach them — a copy of void, boolean, integer, text, regex, iri, set and kind, each a distinct type with the same contract, facing the other way.

Revision 39 collapsed the two relations into the one the data document already had. A schema’s namespace is its own entries and every entry of its import closure, one flat set, and every name resolves there — a constructor head, a refinement source, a field type, a reference’s of, a template bound, an annotation ([TSON-SCHEMA] §3.3). The kernel is a schema document like any other, reached by import, and pre-loaded only because its own declarations wait on each other. Any schema may declare a constructor: widget_type => meta.atom & { … } is a constructor by its chain, in whatever document writes it, and the import that brings meta.atom is the only licence it needs. The four rules went, and so did the copies: core declares no void of its own, because the kernel’s reaches a data document through the schema’s imports exactly as text always did.

What one namespace costs is stated plainly. Every name in a closure is reserved: a schema importing core.tn reserves the kernel’s thirteen root names, the libraries’ roots and the seven map names — meta, atom, numeric, unicode, net, binary, temporal — with no aliasing to escape with. That is the price of a bare name meaning one thing everywhere, and it is why the bundled documents keep their roots to the vocabulary an author writes (text, enum, set, doc, int32, date, uuid) and their machinery under those maps (meta.record, numeric.decimal_type, net.uuid_type), so that record, identifier, field_name, bounded, value and kind stay free for a schema to take (§4.5). Resolution within a schema is then by wait order ([TSON-SCHEMA] §3.4.1), never by the order declarations are written in: reading a value against a type waits on the type being ready, a reference orders nothing, a held value orders nothing, and a wait cycle is refused — which is what made the two-store design’s “forward references need no rule” sentence a theorem rather than a promise.

4.3 Nested maps, and names from the root

With one namespace, a schema map could nest. geo => !schema { address => { … } } declares as the root does: its members are entries of the document, each named by its path, geo.address, and every name resolves from the root — a bare address inside geo names the root’s address or fails, and a sibling is written by its path ([TSON-SCHEMA] §4.1, §5.12). There is no lexical scope, deliberately: a name means the same thing wherever in a document it stands, which is what lets !geo.address in a data document, @atom.ordering:TOTAL on a declaration, "$type": "geo.address" in JSON and meta.ref<geo.address> in a slot all be one spelling of one thing. The maps are open: a map of one path declared by several documents of a closure is the union of their members, so numeric.tn adds numeric.decimal_type to the kernel’s numeric, net.tn adds net.uuid_type to the kernel’s net, and a schema may add a member to any map on the ordinary collision rule. The kernel’s six are the proof of the convention: the type system under meta, the ISO/IEC 11404 datatype properties under atom, the integer constructor and numeric.count under numeric, the text families under unicode, the IRI family under net, the octet family under binary.

Lexical scope — a bare name inside a map resolving in that map first, then outward, with a shadowing rule and a way to say “from the root” — is recorded open in the change log. It is the natural next step and it is not free: it would make a name’s meaning depend on where it is written, which is the one thing the flat namespace exists to prevent, and the kernel’s own maps would then need a rule for atom inside meta meaning the root’s atom or meta.atom.

4.4 Where annotations live

Annotations resolve in the annotating document’s namespace — for a data document the closure its !!imports bind, for a schema document its own entries and its import closure ([TSON-SCHEMA] §3.3.3, §6). The asymmetry Revision 38’s guide had to explain — an annotation declared in a user schema usable in that schema’s data documents but not in the schema document itself — is gone with the hop that caused it: there is one namespace, and no rung for the one to be on that the other is not. A schema that wants @expires on its own declarations declares expires and writes it.

What the bundled documents declare is kept to what a schema states, and split by who needs it. The kernel’s root holds doc, whose text is CommonMark — versioned, where “markdown” has no specification — with no extensions and raw HTML never executed, a doc being written by whoever published the schema. The kernel’s atom map holds the four classifying markers the libraries state their atoms with — @atom.ordering, @atom.bounded, @atom.exact, @atom.numeric — and its meta map the checked assertion @meta.disjoint and the two resolver-attached markers, @meta.synthetic and @meta.application. core.tn’s root holds the author annotations and nothing else: @deprecated (a bare marker, deprecation being a yes-or-no fact in every host language and a reason being @doc’s to give), @title, @comment (the maintainers’ note JSON Schema calls $comment, which @doc would show to everyone), @examples (text, since an annotation’s position hands its value no type to be read by), @read_only and @write_only. They live in core so that a schema importing a library alone may declare title or deprecated for itself; whether they belong in the kernel, or in a library of their own, is recorded open.

4.5 Why the roots are small

[TSON-SCHEMA] §2.2.3 makes it an error for a local declaration to reuse a name the import closure binds, with no hiding. So every root name a bundled document declares is reserved in every schema that imports it, and a name a library adds at its root breaks every schema already using it — found when that schema migrates. The evidence was Revision 38’s own draft: a core identifier stopped twelve converted schemas compiling, because identifier is a name a schema reaches for its own type. The roots therefore hold what a schema cannot reasonably write for itself and would write bare — the atoms, the integer widths, the formats, the enum constructor, the set<T> template, doc — and leave out any name that stands for one line: positive_integer and its siblings, non_empty_text, identifier, a bound being a refinement declared where it is wanted (count => !integer ^ { min: 0 }). The built-in vocabulary of Part 1 lost the sign bounds with it — a schemaless name denotes the type its library declares under that name, so a name no library declares has no meaning to keep, and such a name is a resolver error rather than a marker (§6.1). The machinery that a schema never writes bare — the constructors, the facet enums, the member sets, the references — sits under the maps, where it reserves one name per document instead of one per entry.

Four libraries, one bundle. The same cost argued for splitting the data-format families into libraries a schema imports when it wants them: numeric.tn (the exact and approximate tiers and the sized integers), temporal.tn (the five RFC 3339 families), net.tn (the address, network, host-name, host, media-type, URI, UUID and email families), each importing the kernel alone. A schema importing temporal.tn alone has date and the kernel’s vocabulary, does not see int32, and may declare int32 as an entry of its own; one importing core.tn, which imports the three, has every bundled type and reserves every root. uuid, uri, uri_reference and email moved from core to net.tn, since they are identifiers and addresses and not what every schema needs; iri stayed in the kernel, which types its own spec citations and every schema identity by it. The principle is stated in [TSON-SCHEMA] §9: a family most schemas never use belongs in a library outside the kernel, where its names cost only the schemas that import it. Part 1’s schemaless vocabulary keeps every name, each denoting the type its library declares, so !mac means the same thing when its document moves under a schema that imports net.tn — which is §5.6’s guarantee with the library named. The §9.5 note at the end of this guide is the choosing table.

5. A Tour of the Type Operations

The type system’s operations form a spectrum of completeness — each operation takes a definition some distance further toward concrete data.

Construction (!C { bindings } in the schema grammar) creates a new value from a type, and a new type where C is a constructor. It is the origin point of every atom family and every structural type, and it transfers kind, never IS-A: dogs => !numeric.integer_type {} founds a fresh atom family with the same constraint vocabulary as integer and no relation to it. Construction creating siblings rather than subtypes is the load-bearing choice — it is what lets one constructor found unrelated nominal families (integer and dogs do not accidentally unify), and it is why the atom family’s IS-A facts live in supertypes rather than being derivable from bodies. Where C is an ordinary type — a record, a namespace type — the same form builds a value, and the entry is a value entry: orders => !api { … } holds a map the schema describes, marked by nothing but its tag, nameable by a reference over api and typed by nothing ([TSON-SCHEMA] §4.1). One form, one meaning: !T x is a value shaped by T, in a schema as in data.

Refinement (T ^ { ... } or !I ^ { values }) copies a value and tightens it — binding values, fixing defaults, restricting ranges — while preserving IS-A with the source, under the rules the value’s type states ([TSON-SCHEMA] §5.5, §5.7): a record under meta.record’s field rules, an atom instance under its constructor’s facet rules, the kernel’s reference instances under theirs, and a type that states no rule for a field refuses the refinement at ^. Refinement never adds fields; each of a field’s three questions moves along its own order and never backwards (omission nothing → required → injected, voidable true → false, role FREE → DEFAULT → FIXED), a restated field’s type narrows by IS-A, a default may be restated with a different value and a pin may not, and a refinement whose source is FINAL is refused outright. A chain of refinements is a chain of promises, each stronger than the last, and any consumer expecting the source type can accept the refined one.

Composition (A & B & { ... }) is construction with declared ancestry: new fields are permitted, each listed supertype contributes IS-A — refused where a parent is FINAL (§2.10), and written as an application where a parent is a template, ok => <T> result<T> & { … }, a record template’s chain.parents being a list of references so that ok<text> is IS-A result<text> and not result<int32>, and IS-A result too, as its hand-written expansion would be — and the disjointness rule (no field name from two supertype paths) keeps the merge order-independent and diamond-free. The IS-A is cashed out at the data level by subsumption ([TSON-SCHEMA] §7.2), judged after following both alias chains: !employee is valid at a person-typed field, and validates in full as an employee — closure, constraints, and defaults are the subtype’s — while an unannotated value at that field is exactly a person, so the annotation is the sole carrier of the more specific type. That is the point of composition in an application schema: a field typed by a parent admits the whole family the schema declares beneath it, each member naming itself on the wire when it appears. Subtraction stays outside the family by construction — the check reads the type’s chain, which subtraction empties (§2.4) — and choice discrimination is the same surface gesture under a different membership relation: an annotation selecting a more specific type than the position promises, variant-of for choices, IS-A here.

Subtraction (head - { fields }) removes fields and breaks IS-A — see §2.4 above for why this is a feature and why the clause sits at the head.

Alias (user_id => uuid) is not an operation on the type at all: it is the same type under another name, and resolved output says so by stating the chain as written, !meta.alias { target: uuid }. An earlier design rewrote every use site to the chain’s terminal entry and attached an annotation naming the first hop — a second, lossy representation of a walk every consumer makes anyway. Now a use site names what the author named, target names the next hop, and a processor collapses the chain when it compiles readers, after linking, where the diagnostic gets the alias the author wrote for free. Revision 39 renamed the body from reference to alias, so that “reference” keeps one meaning in the series — a name held in a slot (§6.8) — and an entry whose value points at another is an alias and nothing else. What the design exposed was that there are three ways to name a type after another and only one is a rename: user_id => uuid accepts !uuid and a sibling’s tag at its positions; user_id => !uuid ^ {} — the empty refinement, legal and the language’s one nominal-subtype spelling — IS-A uuid and refuses both; user_id => !net.uuid_type {} is a fresh, unrelated instance and refuses both too. Identity follows the first (box<user_id> is box<uuid>’s entry) and respects the other two, and an author wanting a stock_id refused at a user_id position now has a table telling them which line to write ([TSON-SCHEMA] §5.7, §8.2, §8.3).

Instantiation is the data-level end of the spectrum: !T value in a data document produces a concrete value, terminal by definition. There is no construction in data; the same surface shape (!C { bindings }) in a data document is a record that describes constraints — which is precisely what resolver output stores. An application in data is a value: t: set<text> at a reference-typed slot is the reference production of [TSON-DATA] §7.4, written unquoted, read bottom-up to the entry the schema closed it to — and the way to give an application a name is a declaration in the schema, order_page => page<order>, which is then the instantiation entry itself, not a hop to one (§2.7). What a tag never carries is an argument list: !box<text> { … } is a parse error, a generic type reaching a tag through a name some declaration gave it.

The one-way flow — construct, refine, instantiate, stop — is what makes the resolver’s job tractable: every definition has a finite derivation ending at a constructor, every body desugars to one canonical form, and the data level never feeds back into the type level.

6. Atoms in Depth

6.1 void, meta.identifier and the reference atom

Three kernel atoms were once instances of one constructor, unit, whose resolved shapes were identical and which a processor had to tell apart by name — the one place the series identified a type by what it was called. Each then got a constructor of its own, and Revision 39 removed one of the three: void => !meta.void_type {} stands; meta.identifier is an instance of the text family unicode.identifier_type (§3.5); and value, the escape hatch, is gone, every facet it typed now typed by its family’s own instance (§6.2) and a field’s default or pin read against the field’s own type through a dependent field (§6.10). Beside them the kernel declares one atom whose values are names, meta.reference_type, and §6.8 is its.

A schemaless annotation outside the vocabulary is an error. For as long as Part 1 preserved !nonesuch as an uninterpreted marker, the schemaless document was the one place in the series where a name that resolved to nothing was accepted silently: !uiid 550e… and !Date 2026-01-01 passed a check that reported nothing and validated nothing, and the typo surfaced at the first schema-bound consumer or never. Every reason for the marker had gone — the vocabulary does not grow (new types arrive through schemas, never as built-in names), gradual adoption runs through the built-ins, and the vocabulary is a property of the revision rather than the deployment, so the rejection is a portable finding about the document. A host-typed read is no exception: a tag naming the class a caller happens to bind would make the verdict on a document depend on who read it, so a caller who wants its host types read as a document’s type definitions says so explicitly, which is a schema in all but form ([TSON-DATA] §3.2).

meta.identifier is the series’ own name type: the decoded text of a token matching the identifier grammar ([TSON-DATA] §7.7), put into NFC, equal iff byte-identical. It is a text family, so it is string-class and takes lengths, a pattern and a member set; what it adds to text is its profile — an identifier can always be rendered back as an unquoted lexeme, which is why the kernel uses it for meta.type_name, meta.field_name and meta.param_name, pins it as enum’s name type, makes it the default name type of every record (§2.11), and why a rule stated on it reaches every name in the series (§3.5). Its normalization is NFC, a form a value is put into: a quoted decomposed spelling reads as its composed value, so “same name” can never depend on Unicode composition happenstance. A segment of a qualified name is one: geo.address is two identifiers the position splits at the dot.

void is the type of a slot that holds no value: one inhabitant, the void sentinel _. Its two jobs are being the target type of bare annotations (@deprecated is sugar for @deprecated:_ — presence is the information) and expressing “no value” as a field type, where the field’s own marks say the rest: a?: void? may be missing or written _, a: void? must be written as _, a?: void may only be missing, and a: void admits no document and is refused (§2.9). It is deliberately not a valid choice variant: optionality is a property of a position, not a type, so “optional T” is spelled with ?, never (T | void) ([TSON-SCHEMA] §5.4). void admits _ and nothing else: the null-at-void concession earlier revisions carried for JSON-shaped data is gone with null itself (§2.8), and a JSON null reaches a void position — or any voidable one — as void through the JSON reader.

6.2 A facet is typed by its family

numeric.decimal_type.min could not be typed number while number was a core instance of a meta constructor: the constraint field existed before the type that would naturally fill it, two documents apart on the ladder, so several constructors typed their bounds by the kernel’s value — the token, uninterpreted, read by the type the position hands it to once that type was in scope — and the kernel’s integer_type was the exception that proved the rule, its bounds typed integer because integer lived in the kernel’s own namespace. Revision 39 dissolved the ordering twice over. One namespace puts a constructor and its canonical instance in one document, so numeric.decimal_type and number are both numeric.tn’s and the field is simply min?: number; and the wait order says why that is sound: a name at a type position orders nothing ([TSON-SCHEMA] §3.4.1, rule 2), so a constructor whose facet is typed by its own instance waits on nothing, as tree => { t: tree } waits on nothing. Every facet holding a value of the constrained family is now typed by that family’s instance, declared beside the constructor in the same document — decimal_type’s bounds, step and members are number, float_type’s bounds are number (exact, judged before rounding), rational_type’s are rational, each temporal constructor’s are its own instance, ipv4_type.within is [cidr4] — and every facet that counts something is a numeric.count, the non-negative half of int64 ([TSON-SCHEMA] §5.5, §7.4, §9).

The consequence for implementers is that the eager-conversion rule became the ordinary field reader: a facet is read at schema load by its type, in the wait order, and a value the type refuses is a resolver error with the family’s own diagnostic — 'P1Y' is not a valid duration … a month is a period — rather than a special pass over value-typed slots. The rule has a sharper form for a set: numeric.decimal_type.members is a numeric.decimal_member_set, a set of number, so [1 1.0] is one member and a duplicate because the set’s own element type says so, where a set of uninterpreted tokens would have found an integer beside a float. A schema either loads cleanly or fails with a clear diagnostic; there is no half-valid schema that parses but mis-validates on the millionth record.

A count is a fixed range. non_negative_integer had been !integer ^ { min: 0 } over an arbitrary-precision integer, so a conforming schema could declare max_length: 10^30 and every implementation held counts in a type that could not. numeric.count => !int64 ^ { min: 0 } is 0 to 2⁶³−1, which every implementation holds natively (JavaScript in a BigInt); int64 moved to the kernel’s root to type it, and a bound past what a processor’s values can reach is met or never reached, never a schema error. integer_type.multiple_of and integer_size.bits are integer, positive by coherence rather than by type — a zero step and a zero width are schema-load errors, not range violations — and a resource limit is a positive int64, policy.limit.

members, and why an enum still refuses numbers. !enum [80 443 8080] is a schema-load error — an enum’s members are labels of a text family, names under its default type (§3.5) — and the sparse case is a facet, not a constructor: !integer ^ { members: [80 443 8080] } is an integer, refines further, and composes with its bounds as facets of one body, where a numeric_enum constructor would mint a family with no IS-A into the numeric tiers. Three things had to land right, and the enum precedent answered each: a set, not an array, so uniqueness comes from the set’s contract and non-emptiness from min_items; coherence, so every member satisfies the body’s other facets, the derived width included; and identity by value, so [80 0x50] is a duplicate. numeric.float_type takes no members on the same grounds it takes no multiple_of — a member set is a claim about a binary grid that cannot be made exactly. unicode.text_type has the same facet, since text identity is as well defined as integer identity and the pattern fallback (a|b|c, regex-escaped by a converter) was the only spelling for three admitted URIs that are still URIs. It sits beside pattern rather than excluding it, because a pattern is two things nothing can tell apart — a|b|c is a value set and [A-Z]{2} is a shape — and the two share one refinement rule, settable once: set where unset, restated verbatim, never changed, since narrowing a pattern would need a regular-language containment oracle the series decides nowhere. That makes text the one tier whose member set does not shrink under refinement, stated so that a reader does not infer it from a diagnostic.

6.3 Exact and approximate numeric tiers

The numeric vocabulary is split into an exact tier (number, rational, the fixed-width integers) and an approximate tier (float32, float64), and the split is annotated in the libraries with @atom.exact. The design intent: a bare numeric token carries no width or precision commitment, so it maps to number — exact, with scale no part of the value (1, 1.0 and 1.00 are one number; whether the spelling survives a round trip is an encoding’s promise) — and lossiness enters only when a consumer chooses an approximate type, at which point rounding onto the IEEE 754 grid is that type’s documented contract rather than a surprise. The special values (.inf, .nan, signed zeros) belong to the approximate tier only, and NaN payloads are deliberately not information: every NaN denotes the canonical quiet NaN, so value preservation holds by definition and payload-dependent round-trip bugs are excluded by fiat. complex sits across the tiers: its component selector is a partial order — INTEGER ⊂ NUMBER ⊂ RATIONAL, FLOAT32 ⊂ FLOAT64, the two families incomparable since binary64 carries ±inf and NaN no exact decimal holds — so !complex ^ { component: INTEGER } is a refinement and a float complex is its own instance, not a refinement of complex.

A float’s bounds exclude NaN and judge the infinities. numeric.float_type carries min and max, and [TSON-SCHEMA] §5.7 had asked a bound’s family to be totally ordered, which a float with NaN is not — the text said one thing and the kernel another. Revision 39 read the bound the way every bound reads: a bound admits a value inside its interval, NaN is inside none, so a float body with any bound admits no NaN and allow_nan decides NaN only where no bound is written; ±infinity are ordered values a bound judges as any other, so max: 100 refuses +.inf and min: 0 admits it where allow_infinity does. “Totally ordered” became “ordered”, which is what the rule needs. The bounds are numbers, exact, validated against the value as written before rounding, so typing them by the grid would round a bound the family says is unrounded.

6.4 Open sums: meta.declared

meta.choice is the closed sum — it enumerates its variants. The open sum is meta.declared_type, whose one instance, meta.declared, admits every type of the governing closure — the schema’s own declarations and its imports — and the value names its own type by its tag ([TSON-SCHEMA] §7.8). It is what a schema map’s entries are: meta.schema’s value_type is meta.declared, so every entry of a schema map names its type by its tag, a constructor for a type and an ordinary type for a value, which is how a resolved form reads and how a data document of meta.schema is read at all.

Revisions 35 to 38 had carried a wider construct, scoped, whose instance named the namespaces a value’s type might come from — the governing one, a foreign schema a nested !!schema on the value opened, or either — with core naming three subsets (declared, extern, dynamic) and two templates (extern_of<S>, extern_type<S, T>). The scope push was the one place in the series where a value changed the namespace it was read in: a !!schema on a field opened a second set of names in one document, and either a tag’s meaning depended on its position or the data carried a prefix. Revision 39 removed it. A foreign type is used by importing its schema — a data document’s namespace is the union of its imports’ closures, and a position admits a value of a foreign schema exactly when the document imports it — and a directive at a value position is a parse error. What extern and dynamic bought, an attachments field admitting a claim from one partner or a report from another, is written by importing both schemas and typing the field meta.declared, or a choice over the two; what nothing buys any more is a value naming a schema the document did not import, which is the design. The permissive-type list of [TSON-SCHEMA] §7.8 reduced to one sentence — a value at a declared position must name its type — and the foreign-schemas limit left Part 1 with the push.

meta.namespace, a refinement of map. scoped named where a value’s type might come from; meta.namespace is the constructor of the maps keyed by names — meta.namespace => meta.map ^ { key_type: meta.identifier_ref voidable?: = false }, a map whose key_type is narrowed to the identifier families and whose entries are never void. A namespace type is an instance of it, as a map type is one of meta.map: the kernel’s meta.schema => !meta.namespace { key_type: meta.type_name value_type: meta.declared }, or a schema’s api => !meta.namespace { key_type: meta.identifier value_type: method }. Revision 38 had made namespace an abstract constructor beside top, composed by schema and field_map; Revision 39 saw that a namespace is a map whose keys happen to be names, made it a refinement, and let meta.field_map be a plain !meta.map { key_type: text … } — a field name being text within its own record, judged by the record’s name type, and no qualified name selecting a field declaration. §6.8 takes it up with the references it exists to serve.

6.5 bytes, and the design that replaced its own first fix

Revision 34 had four binary types — base64, base64url, base32, hex — as siblings over one value space, differing in a selector facet and with no IS-A between them. An author who did not care about the spelling could not say so (encoding was required), a choice did not recover it (the four share a class), and a binary encoding writing a base64 field and a hex field produced identical output while the two were different types with different identities — a hash pinned over the schema differed for streams indistinguishable on the wire. The complaint was right and the obvious fix — bytes with the four as refinements — was built and abandoned: all four alphabets have the same value space, so hex ^ bytes narrowed nothing and the four subtypes partitioned nothing, and a refinement of bytes became unreadable because !hex is not its subtype. A directive on the reference was built next and abandoned too: a container element has no annotation position, so [hexdigest] cannot carry an alphabet an annotation holds, and a pure rename’s directive vanished at box<hexdigest> — four hex octets read as six base64 ones, silently.

What runs is the third design. One binary.bytes_type whose encoding is a selector defaulting to base64; one bytes at the kernel’s root and no spelled siblings; another alphabet is another instance (hexdigest => !binary.bytes_type { encoding: HEX length: 4 }) and never a refinement, because a spelling narrows nothing and hexbytes ^ bytes would claim an IS-A no base64 reader could honour; length facets count octets, so length: 32 is a sha256 in any alphabet; and the type says nothing about spelling in the model, as text says nothing about UTF-8, so an octet encoding ignores the selector and writes the bytes. Part 1 follows with !bytes as its only binary tag, base64, a schemaless document having no schema to carry a selector. The lesson generalised into [TSON-SCHEMA] §5.7’s selector rule: a selector may move under refinement only along the narrowing relation its members carry — a chain for a width, a partial order for complex.component, none at all for an alphabet.

6.6 Text: a value is put into its form

normalization began as a facet of the identifier family alone, and as a requirement: text not in the form was refused. That left nothing in the kernel able to say that Content-Type and content-type are one name, and it was the outlier in a series that otherwise rewrites — an atom maps a spelling to a value (0x10 is 16), and Part 1 normalises a quoted token at an identifier position before matching it. So the facet moved to unicode.text_type, where every text family inherits it, and it now states the form a value is put into: a value is its token’s text, unquoted, unescaped, then put into the form, and every facet and every comparison — lengths, pattern, members, pins, map keys, set uniqueness, look-alikes — judges the value and never the spelling ([TSON-SCHEMA] §5.5). Five members, five distinct equalities: code points (NONE), code points without ASCII case (ASCII_CASEFOLD), canonical (NFC), compatibility (NFKC), and compatibility without case (NFKC_CASEFOLD). The ASCII fold is the one the case-insensitive naming systems actually state — HTTP field names, URI schemes, DNS names, charset names fold ASCII letters and nothing else — and it needs no Unicode table; JSON Schema documents fake it with patterns of the form [Pp][Rr][Ee]…, of which SchemaStore holds over a thousand. NFKC_CASEFOLD is looser than any of them — it folds a full-width C and the Kelvin sign into ASCII — and is the form for names compared without case across Unicode. NFD and NFKD are not members: each is its composed twin’s equivalence stored decomposed, and would change a spelling and no verdict.

The facet is fixed where the type is constructed, because turning a fold on in a refinement changes what a token means rather than narrowing which values are admitted. unicode.regex_type, net.uri_type, net.iri_type and net.email_type pin NONE: a pattern put into another form matches something else, and past the scheme and host a URI compares with case, as a mailbox’s local part does. The costs are stated: a round trip writes the value, so Content-Type comes back content-type, as 0x10 comes back 16; a refusal quotes the token as written and then the value it was judged as, since the written spelling is what a reader has to find; and NFKC_CASEFOLD’s fold is not lowercasing beyond ASCII (ß becomes ss, so a length changes). The alternatives were a form rule — the schema-language consensus, which refuses the spellings HTTP/1.1 writes — and an equality facet that keeps the spelling, which has no precedent and needs a second comparison at every place values are compared.

URIs and IRIs. RFC 3986 names two productions with two value spaces — a URI, which has a scheme, and a URI-reference, which may be relative — and the vocabulary had named the first while implementations read the second. They are now two atoms, uri and uri_reference, the first a refinement of the second in net.tn, and net.uri_type states RFC 3986’s distinctions as facets: allow_relative, allow_fragment (withdrawn together they leave the absolute-URI an identity needs), and schemes, a set in place of one scheme — a set of an ASCII-folding identifier, so the comparison RFC 3986 states in prose is structure. The kernel’s own iri requires a scheme, which types spec and every schema identity by the value space identity already demanded. A URI is US-ASCII; RFC 3987’s IRI is a family of its own, net.iri_type, the kernel’s because the kernel types its own references by it, with iri and iri_reference at the kernel’s root refining as uri and uri_reference do, because JSON Schema and OpenAPI name all four and a !uri that admits é through a lenient parser is an IRI type by accident — one document passing on one processor and failing on the next. A directive argument is an IRI-reference, so a schema published at a path beyond US-ASCII is written as itself. The error split is the usual one: a relative reference under uri is inside the lexical space and outside the value space, a validation error, as a negative integer is under uint32; a character outside the grammar is the parser’s refusal.

A leap second is refused. RFC 3339’s grammar admits second 60; a time is a time of day on [00:00:00, 24:00:00) and a datetime an instant on a UTC timeline that counts no leap seconds, so the token has no value in either space, and both parsers refuse it rather than leave it between two statements that disagree.

6.7 Containers: order, emptiness, and the one-position tuple

array carried unordered, which set_type pinned true and which read as a double negative at its default; map carried nothing, so nothing said whether a map’s entry order is part of its value. Order is now a facet both state, named for the property: ordered is true for an array and false for a map unless a body says otherwise, and set_type pins it false. It says one thing — whether two values differing only in order are one value — and never changes what a document may write; output keeps the order written either way, which is what lets two resolved documents be compared as written. An ordered map, !meta.map { … ordered: true }, is the case a host binds to an order-keeping map.

A set was the one container whose empty value had to be asked for: set_type defaulted min_items to 1, so tags: set<text> could not hold “no tags”, and the spelling that could — min_items: 0 on a !set_type body — is one the set<T> template does not offer. The default is gone: a set’s bounds are an array’s, and a set that must be non-empty says min_items: 1. Every set the kernel and the libraries rely on being non-empty says so, as a named entry, a bounded set at a field being a named entry for the same reason any !C { … } at a field is.

And [T] is an array, so a tuple of exactly one position had no sugar — rare in a schema written as TSON, and real in one converted from JSON Schema, where prefixItems with one entry is exactly that and a converter needs a spelling it can emit at a field without minting a declaration. The kernel declares meta.tuple1<T> and meta.voidable_tuple1<T>: two, because a type argument cannot carry the ? that would make the position voidable.

6.8 One reference atom, applications as values, and names through maps

Revision 38 answered one question three ways — how does a slot say what kind of name it holds? — with three atoms: kind_type, whose values were the names of the types built with a constructor; ref_type, whose values were qualified names of values; and a structured type_ref record for an application. Revision 39 found they were one.

The predicate. Every entry is a name and a value, and the value has a type, its tag: order => { … } holds a value of meta.record, int32 => !integer ^ { … } a value of numeric.integer_type, orders => !api { … } a value of api. A reference is an atom whose values are names, and a name conforms exactly when the entry it names, past its alias hops, holds a value whose type is the reference’s of or IS-A it. That one predicate serves a field’s type position, a meta.ref<meta.record> slot and a meta.ref<method> slot alike; they differ only in of. So the kernel declares one atom, meta.reference_type => meta.atom & { of: meta.type_ref allow_sibling?: boolean ~ true }, with meta.ref<T> as its inline spelling, and the slot’s meaning comes from of and never from a spelling: where of is a constructor — meta.top or IS-A it — the names it admits are types’ names, and the admitted set is a kind (meta.type_ref the kind of the types a value can have, meta.identifier_ref the identifier families, meta.ref<net.uuid_type> the types built with uuid_type); where of is any other type the names are values’ qualified names (meta.ref<order> names values of order, meta.ref<api> names myapi). A constructor is an ordinary record type, which is what let the two collapse: net.uuid_type’s declaration is a value of meta.record, so meta.type_ref admits it as it admits uuid, and meta.ref<meta.record> holds every record type, every constructor whose declaration is a record, and meta.top itself. Revision 38’s open remainder — that kind_type.of wanted a classifier of constructors the series lacked — closed with it: of is typed meta.type_ref, a constructor being a type.

An application is a value. type_ref had been a record, { name: page arguments: [{ name: order }] }, and an application in data had to be written as that record; @application carried one; a record template’s parents held them. Revision 39 made an application a value of the reference atom, written unquoted wherever a value stands — t: set<text>, target: pair<uuid B>, @meta.application:box<int32> — the data production of [TSON-DATA] §7.4: a qualified name, an adjacent <, arguments that are references or tokens, and >. Identity is the parsed form, so whitespace inside the list is free and box<user_id> over user_id => uuid is box<uuid>’s entry. < and > thereby gained a grammar role in data values, beside ! and @, over tokens the lexer already emitted; a tag still carries no argument list. The type_ref and type_argument records are gone, alias.target, type_chain.parents and meta.application are typed meta.type_ref, and meta.type_ref and meta.identifier_ref are what type_kind and identifier_kind were, now refinements of one atom — meta.identifier_ref => !meta.type_ref ^ { of: unicode.identifier_type }, a refinement narrowing of to a type IS-A the source’s so that every name the refinement admits the source admits too. A members list is derived at link and never written.

Names through maps. With one namespace and nested schema maps (§4.3), the qualified name does three jobs with one grammar. Its first segment names an entry of the root; each further segment selects a member of a nested !schema map (geo.address), a key of a namespace value (orders.place_order), or a field of a record value (geo.home.at) — and nothing else: a map that is no namespace is not entered, so field_map’s keys are never selected and foo.fields.bar is refused, and a type entry admits no further segment, so order.sku is not a thing ([TSON-SCHEMA] §5.12). The dotted-names family is the one every language with qualified names uses — Java’s, XML’s QName, protobuf’s fully-qualified names, TOML’s dotted keys — where each segment is a name and nothing is a position; the address family (JSON Pointer, JSONPath) answers which position and is what the series’ diagnostics already use, so authors.0 is not a name and never will be. The judgement is found first, judged after: the referent’s type is judged before its value, a name wrong by type is refused for its type, and nothing selects among candidates. The rule reaches a data document: which: o2 at meta.ref<order> reads where o2 is a value entry of order in the closure the document’s imports bind, and a host receives the referent with the name. Resolved output writes a name as written, and the two readings a later revision might give it — substitution, where the value replaces the name, and identity, where the referent is the value’s identity — both stay available.

Put together. The service schema the conformance corpus writes, in one document that imports the kernel alone:

!!id:"https://example.com/service.tn"
!!import:"https://tson.io/2026/39/m/meta.tn"
!schema {
  method_name => meta.identifier
  method      => { request?: meta.ref<meta.record>  response?: meta.type_ref  errors?: [meta.ref<meta.record>] }
  interface   => !meta.namespace { key_type: method_name  value_type: method }
  http_verb   => !enum [GET POST]
  binding     => { method: meta.ref<method>  verb: http_verb  route: text }
  bindings    => !meta.namespace { key_type: meta.identifier  value_type: binding }
}

A schema importing it declares orders => !interface { place_order => { request: place_order_request response: order } }, where request: place_order_request is a slot typed by a reference over a constructor holding the name of a record type — request: user_id is refused, uuid_type building no record — and a second schema declares routes => !bindings { post_orders => { method: orders.place_order verb: POST route: "/orders" } }, where method is a reference over values reaching the method under a key of a namespace value. response: orders is refused (a value where a type is wanted), method: orders is refused (an interface where a method is wanted), method: orders.cancel_order is refused with the keys listed, and request: { … } is refused because a reference-typed slot of a value body holds one bare name and nothing in a value body lifts or mints. No !!meta line, no meta layer, no import-level rule: interface and bindings are namespace types any schema may declare, since meta.namespace is a constructor its import brings. Every one of those checks was once the HTTP layer’s to run at startup; each is now the schema’s.

6.9 Host names, hosts and media types

Three atoms joined the series in Revision 38, all in net.tn (§4.5), and each is an instance of a constructor pinning an RFC — net.hostname_type, net.host_type, net.media_type_type, members of the net map net.tn adds to.

hostname is a domain name as RFC 5890 defines one — LDH labels, A-labels or U-labels in any mix — and its value is the name: Example.COM and example.com are one value, and so are xn--bcher-kva.example and bücher.example, two views of one value recoverable from each other by Punycode over a valid label. No unicode.text_type could say this: U-label validity is RFC 5892’s property, which no pattern states, and the equivalence of a U-label and its A-label is Punycode, which no normalization reaches. Four boundaries an operator will meet are stated in Part 1 so that none looks like a bug: uppercase beyond ASCII is refused (BÜCHER.example is no host name, since IDNA2008 has no uppercase in a U-label and mapping one would bring UTS #46 into validity — the diagnostic names the lowercase spelling), a trailing dot is refused, the last label does not begin with a digit (so a dotted quad is never a host name and ( hostname | ipv4 ) has no value in both), and percent-encoding is refused as URI syntax. Homographs are the allowlist’s to stop, and it does: an allowlist typed [hostname] compares by name, so schemаs.example.com with a Cyrillic а is refused unless the operator listed it. The one facet is allow_idn, false admitting only names with no internationalized label in either spelling — a property of the name, for a system that cannot carry one, not a restriction on spelling. Nothing is mapped, so UTS #46’s tables never enter, and RFC 5892’s property grows monotonically with the Unicode version, which is what lets an identity’s host be a host name (§3.3).

host is a host name or an IP address in one string-class atom — RFC 3987’s ihost profiled to DNS. A listener binds localhost, 127.0.0.1 or ::1, and ( hostname | ipv4 | ipv6 ) is not disjoint ([TSON-SCHEMA] §5.4 proves nothing from patterns, deliberately), so every value at the choice needed a tag in a file an operator writes by hand; the answer is one type, not a choice. Brackets are a second spelling of an IPv6 address, since operators copy [::1] out of URLs and listen [::1]:80; [::1] and ::1 are one value, the canonical text unbracketed, and ipv6 itself still refuses the brackets, an address on its own having no port to separate. Each member keeps its own equality — a name by name, an address numerically — and a name never equals an address, nor the two address families each other: 127.0.0.1 and ::ffff:127.0.0.1 are two values. It has no facets yet; the likely first is allow_zone, for a listener binding a link-local address, recorded in the change log and not taken.

media_type is an RFC 6838 media type: type and subtype as restricted-names, parameters per RFC 9110 §8.3.1 only where an instance says allow_parameters: true, so media_type proper is a media type and not a Content-Type value. The equality is the one text could not state: type, subtype and parameter names compare without ASCII case, parameters are a map so order carries no meaning and a repeated name is refused, a quoted value equals its token form, and a parameter value keeps its case except charset’s — a closed list of one, since “unless its registration says otherwise” would leave every processor holding the registry and two at different registry states disagreeing. types and suffixes are member sets (suffixes: [json] admits application/json and application/ld+json), and a media range (text/*) is Accept’s syntax, a pattern over values and not a value — the same line uri_template was refused on. It lives in net.tn rather than the kernel so that an OpenAPI conversion’s Media Type Object keeps its name.

6.10 Dependent fields

A field whose type is chosen by a sibling’s value is the shape every tagged payload has — a setting whose kind says what value holds, a cell whose of says what code type v is — and the shape the kernel itself needed: a record field’s default or pin must be read against the field’s own declared type, and record_field.value had been typed by value, the uninterpreted token, because nothing could say “the type type names”. Revision 39 says it: value?: type!. A sibling reference is an identifier with an adjacent ! followed by whitespace, and a field typed by one is a dependent field, read in each instance against the type that instance’s sibling names ([TSON-SCHEMA] §5.2). The spelling is data grammar and schema grammar alike — kind! lexes as a token and the special token ! the lexer already emitted, told from the prefix ! by adjacency — and it stands at exactly one slot, a record field’s type, whose reference meta.field_type_ref leaves allow_sibling at its default; meta.type_ref and every meta.ref<T> set it false, so an array element, a tuple position, a choice variant and an argument refuse one.

Three ways a type is known. A reader reaches a dependent field with the type in hand in one of three ways: a pinned sibling’s pin, wherever the sibling is written or whether it is; a discriminator’s, member dispatch having selected a member that pins it before any field is read; or a free sibling’s value, written before the field, or its default where it is not yet written. The third is the one with a cost. A free sibling must be written before the dependent field — a dependent field read before its sibling arrives has no type, and holding it back until the sibling arrives would give every dependent record a second decoding path and make a streamed reader buffer what it cannot type. So [TSON-DATA] §2.5 gains its one exception to “field order carries no meaning”, and [TSON-JSON] §6.1.6 its one ordering constraint after the selectors — one JSON’s own model does not have, so a producer that sorts its keys writes a document the rule refuses. A schema meant for such producers types its dependent members by a pinned sibling or a discriminator, which order nothing; a free sibling is for producers that control member order, as a text encoder does.

The sibling’s rules are the ones that keep the type knowable: it is declared before the dependent field (the fields are a telescope, so no cycle can form; flattening puts inherited fields first); it is typed by a reference over a constructor, past its alias hops, since a value names no type; it is there to read whenever the dependent field is — required, defaulted, pinned, or a group member beside an optional dependent field; its pin or default names a type a value can be read against; and the dependent field is never voidable, carries no value of its own, and is restated only as itself. A dependent field written without its group-member sibling is a validation error naming both, in a schema document as in data.

What is deliberately absent is reaching a type through a value: { exchange: meta.ref<exchange> body: exchange!.request } would read body against a field of the exchange the value addresses, evaluating a path through a value at read time, so that a read would wait on value entries being resolved and the schema’s vocabulary would stop being names and closed forms. A body tied to its exchange is written as an envelope template applied once per exchange, with the exchange pinned in each application — which is what a record template is for (§2.7). Seven smaller edges are recorded open in the change log: whether the end of a document counts as following whitespace, a sibling in a group with its dependent field in another option, a field whose name needs quoting ("name"! is no sibling reference), which host component a bound dependent field takes, and a sibling in a template field typed by a parameter.

7. A Complete Worked Example

The task-tracking schema from [TSON-SCHEMA] §1.6, extended with a budget and a digest field and taken all the way through resolution. The schema document:

!!id:"https://example.com/task.tn"
!!import:"https://tson.io/2026/39/m/core.tn"
@doc:"Task-tracking example schema."
!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
    budget?:   duration
    digest?:   bytes
    tags?:     [text]
    history?:  [flagged<status, 2>]
  }
}

priority and title_text refine the kernel’s integer and text instances — no bundled library declares a bounded sibling of either (§4.5); status applies the enum constructor, the kernel’s root entry, reached through core’s imports; task is a fresh record whose field types resolve in the same one namespace — uuid from net.tn and date from temporal.tn through core, priority, title_text and status from the local declarations. id and title must be written; priority and status may be omitted and are then injected (? on the name, ~ a default — a default is admitted only on a name that may be omitted, §2.9); due, budget, digest, tags and history may be omitted and are then missing, and none admits _ (no ? on the type). tags?: [text] is a field whose array sugar lifts to a synthetic entry, referenced by name from the field ([TSON-SCHEMA] §5.3, §8.2). budget is elapsed time in seconds and digest an octet sequence spelled in base64 in text — both value spaces, not spellings (§3.3, §6.5). flagged is a record template — a meta.record_template, one type parameter and one value parameter — a fresh record whose priority field is defaulted by parameter (priority?: priority ~ N, the ? written as it is beside a literal default, [TSON-SCHEMA] §5.7), built entirely from the schema’s own names — and [flagged<status, 2>] wraps its fully-bound application in the array sugar ([TSON-SCHEMA] §5.10). Because flagged is a record template it is also a family base: a field could be typed flagged bare, its declared applications being the members, and a value there would be placed by its tag — ~ N is a default, overridable, and so no selector; had the template written priority?: priority = N, the pin would have been one, one value per application being one pin per member, whatever the name’s mark (§2.10).

A data document imports the schema and instantiates:

!!import:"https://example.com/task.tn"
!task {
  id:       550e8400-e29b-41d4-a716-446655440000
  title:    "Ship revision 39"
  priority: 3
  status:   OPEN
  due:      2026-08-01
  budget:   P2W
  digest:   "3q2+7w=="
  tags:     [spec editorial]
  history:  [{ entry: OPEN }  { entry: ACTIVE  priority: 4 },]
}

Note that priority and status restate their default values (and that the trailing comma in history is legal, a comma following a value): a document that states its defaults reads without its schema, and omitting a defaulted field is an encoder optimisation — lossless only because the decoder injects the value back on read.

Resolution derives a schema value, serialized as resolver output — a data document with the source’s header, in which every entry is its value, desugared to the canonical !C { bindings } form with its chain computed and its fields a map keyed by name, and fields at their default values are omitted (optional: false, voidable: false, role: FREE, extension: OPEN, an empty chain):

!!id:"https://example.com/task-resolved.tn"
!!import:"https://tson.io/2026/39/m/core.tn"
!schema {
  priority   => !numeric.integer_type { chain: { supertypes: [integer] }  min: 1  max: 5 }
  title_text => !unicode.text_type { chain: { supertypes: [text] }  min_length: 1 }
  status     => !enum { members: [OPEN ACTIVE DONE] }
  flagged => !meta.record_template {
    fields: {
      entry    => !meta.record_template_field { type_param: T }
      priority => !meta.record_template_field { type: priority  optional: true  role: DEFAULT  value_param: N }
    }
    parameters: [{ name: T  type: meta.type_ref } { name: N  type: priority }]
  }
  task => !meta.record { fields: {
    id       => !meta.record_field { type: uuid }
    title    => !meta.record_field { type: title_text }
    priority => !meta.record_field { type: priority  optional: true  role: DEFAULT  value: 3 }
    status   => !meta.record_field { type: status    optional: true  role: DEFAULT  value: OPEN }
    due      => !meta.record_field { type: date      optional: true }
    budget   => !meta.record_field { type: duration  optional: true }
    digest   => !meta.record_field { type: bytes     optional: true }
    tags     => !meta.record_field { type: array_text_xxhash  optional: true }
    history  => !meta.record_field { type: array_record_entry_status_xxhash  optional: true }
  } }
  @meta.synthetic record_entry_status_xxhash => !meta.record { fields: {
    entry    => !meta.record_field { type: status }
    priority => !meta.record_field { type: priority  optional: true  role: DEFAULT  value: 2 }
  } }
  @meta.synthetic array_text_xxhash => !meta.array { element_type: text }
  @meta.synthetic array_record_entry_status_xxhash => !meta.array { element_type: record_entry_status_xxhash }
}

(The trailing xxhash in the synthetic names stands for a content hash of the resolved binding record — a placeholder, since name spelling is the resolver’s own business and only the shape is a conformance point. record_entry_status_xxhash is the expansion of flagged<status, 2>, keyed on its content: a use site’s application mints no instantiation entry of its own and records nothing of the application, so a second template with the same field list applied the same way would land on the same entry. Had the schema declared flagged_status => flagged<status, 2>, that declaration would be the entry, under that name, with @meta.application:flagged<status 2> on its key — the application written as the reference it is — and chain: { supertypes: [flagged] } in its value — a member of the family, where the minted record is a member of nothing — §2.7, §8.1.)

Reading the output:

  • priority shows refinement: the surface form !integer ^ { ... } retargeted to the instance’s constructor, which is the value’s tag (!numeric.integer_type, by its path), with IS-A recorded in the type’s own chain (chain: { supertypes: [integer] }). This is the case where the chain carries information the body cannot: compare a hypothetical sibling port => !numeric.integer_type { min: 0 max: 65535 }, which would serialize under the same tag with an identical body shape and no chain.
  • status shows construction: the enum constructor’s ATOM kind is inherited, the tag is !enum (the kernel’s root refinement of meta.enum_type that pins the name type to meta.identifier, so name_type is at its pin and omitted), and there is no chain — construction transfers kind, not IS-A. The positional sugar !enum [OPEN ACTIVE DONE] has desugared to the explicit binding { members: [...] }.
  • task shows the four field facts and the field map: fields is a meta.field_map — a plain ordered map keyed by text, each key judged by the record’s name type — from each name to its meta.record_field, the name written once as the key and nowhere inside, and each meta.record_field carries optional (the name’s ?), voidable (the type’s ?), role and value, the defaults omitted, and the eagerly-resolved default values, so consumers read defaults from the output without re-parsing modifier tokens. Every type is a meta.field_type_ref value — a bare name, judged a member of the kind of the types when the schema links — and every value is read against the type its own type names, the kernel’s value?: type! at work (§6.10). What omission yields is nowhere in the output: a reader derives it — a missing id is an error, a missing priority injects 3, a missing due yields nothing — from optional and value (§2.9).
  • tags shows a synthetic entry: the sugar [text] lifts to array_text_xxhash — a closed entry headed by the constructor it builds, marked @meta.synthetic at its key so tooling can fold it back into nested display — and the field holds a bare reference to it. Any other [text] anywhere in the schema lands on the same entry: identity is structural equality of the resolved body, one entry per distinct concrete form ([TSON-SCHEMA] §8.2).
  • flagged and record_entry_status_xxhash show the template machinery end to end. The record template is a !meta.record_template — its own constructor, not a held text and not a meta.record with an optional list of parameters — read as the vocabulary, and its fields are meta.record_template_fields that label a parameter: entry’s type_param: T says the whole type is the parameter, and priority’s value_param: N says the whole value is, where a literal would stand in type or value. The labels are why no literal spelled like a live parameter can be mistaken for one, and why a reader built for a meta.record can never be handed a template by accident — the tag tells them apart. T stands in a type slot and is recorded meta.type_ref; N is routed into a default, so its type is the field’s declared type, priority, and flagged<status, 9> is refused at the application, 9 being no priority, before anything is substituted (§2.7). The default ~ N is DEFAULT where it is written, with the field marked as the author marked it, and only the value waits for the closing ([TSON-SCHEMA] §5.7, §5.10, §8.1). Nothing on the entry says extension: ABSTRACT: a record template’s base is ABSTRACT by construction, and the template’s own extension is each application’s — OPEN here, since the template wrote no abstract — so a consumer holding only resolved output can name flagged at a type position, dispatch on it by tag (there are no discriminators, ~ N being a default and not a pin), and never read a field that carries a parameter (§2.10). The expansion is the closed form: substitution turns type_param: T into type: status and value_param: N into value: 2, each field now a meta.record_field, and the result is a !meta.record keyed on its content and a member of nothing — not of flagged’s family, since no declaration names it, which is what keeps a minted member from being selected at a flagged position and then found unreportable and unbindable. history’s field then references a second synthetic — the array around the expansion — showing the two lifts composing, both @meta.synthetic. For an instance-form template closing over a constructor with value slots, see [TSON-SCHEMA] §8.2’s vector example.

8. Resolver Output for Consumers

8.1 Internal entries: two identities, and names that carry nothing

Resolver output contains internally named entries beyond the declared ones, and a consumer should hold two facts about them. First, every application denotes an entry: a sugar form at a use site lifts to a synthetic entry — closed for a concrete form, open (a held application) for a parameter-bearing one inside a template — and a fully-bound application of a non-constructor template denotes its expansion — the declaration naming it where one exists, and otherwise the closed synthetic the substituted form lands on, keyed on its content, which any other spelling of the same form lands on too; no instantiation entry is minted and nothing records the application, the one exception being a self-reaching expansion, which keeps an entry keyed on the application so that the knot can be tied ([TSON-SCHEMA] §5.3, §8.2). Revision 39 added one trigger position: a refinement source — x => box<text> ^ { … } — materialises its application, or unifies with the declaration that owns it in either declaration order, and the refinement IS-A the entry it denotes, as §5.7 had promised and the resolver had not quite delivered. A use site always holds a bare name, so a consumer walking a resolved schema needs exactly one interpreter — follow references, read bodies against constructor vocabularies one hop at a time — and never a second, in-place structural one. The rule also gives recursion the entry it needs: tree<text>’s fully expanded form is infinite (its children’s element type is itself), and a tree-shaped document cannot express that cycle without a name; the entry is the knot.

Second, there are two identities, and the names carry no information. Revision 39 states the pair in one sentence beside the pin rule ([TSON-SCHEMA] §8.2, §10.2): bytes identify a document, for distribution and pinning; names and parsed structure identify an entry, for typing — a whitespace edit changes a document’s digest and none of its entries, and two spellings of one form in a schema are one entry. A declared entry’s identity is its declaring document and its path — two declarations are two types however alike their bodies, which is what makes stock_id => !net.uuid_type {} and other_id => !net.uuid_type {} refuse each other’s values, as they should. A minted entry has no declared name to be its identity, so identity is structural per family: a declared instantiation, and a self-reaching one, compares by the canonical, fully-bound application recorded as @meta.application on its key — the application written as the reference it is, nested arguments unreduced, @meta.application:pair<box<text> int32>; closed synthetics by the resolved binding record, one entry per distinct concrete form schema-wide, synthetics minting at the root wherever in a document’s nested maps the sugar was written; open synthetics by body structure up to parameter renaming. Identity is the parsed form: a reference is compared as its head and its arguments, never as text and never by the channel a processor filed an argument in, so box< text, > and box<text> close to one entry. Spelling variance therefore never forks an entry — [id] and [uuid] land together because identity follows an argument’s alias chain and an alias is the same type under another name, vector<pixel, 255> and vector<pixel, 0xFF> are one application because a literal argument is compared as the value it denotes even though it is recorded as written (base type stays a boundary: 1 and 1.0 are two arguments), and the same form written directly or arising inside a materialised template lands on one entry, because the resolver re-derives every synthetic’s identity from its resolved record after names have meaning — while two conforming resolvers may disagree on every internal name and still agree structurally, which is the sense in which resolved output is compared across implementations: equality up to renaming of internal entries. What is canonicalised is identity, not provenance: the name the author wrote survives at the use site that wrote it, since output states alias chains as written. Names should nonetheless be content-derived (a readable head plus a structural hash — the _xxhash convention in the shipped fixtures, where the hash spelling is a placeholder precisely because it is not a conformance point, and a dotted name contributing its text with each dot written _): content-derived names keep re-resolution output diff-stable and make independently resolved namespaces agree wherever their structures agree, which is what lets internal entries merge cleanly under the transitive import ([TSON-SCHEMA] §2.2.3, §8.2).

Telling the families apart needs no name either: a synthetic entry’s value is headed by the bare constructor it builds and its schema-map key carries the derived @meta.synthetic marker (discarded and recomputed when a resolved form is read), while a declared instantiation’s key carries @meta.application, the application itself, read as stated since it is the one fact the value does not state. The marker exists for display — a tool can fold synthetics back into the nested spelling the author wrote — and never for decoding. Synthetic entries are, beyond that, an implementation’s own: their shape is unconstrained past producing the required output, a processor may mint one per schema or share them, and the lift targets are never kernel-declared (a proposal to declare them was declined — it would hard-code one resolver’s bookkeeping into an immutable, hash-pinned vocabulary for an artifact no author writes). Two disciplines this asks of tooling, both learned by writing the wrong thing first. A consumer contract — a binding map, a generated class table, a configuration file — may never key on a minted name: its spelling is not stable across implementations and moves within one whenever the form’s content does, so it keys on the name some declaration gave the application, and a processor holding both shows and accepts the declared name wherever it shows or accepts one. And a diagnostic never surfaces a minted name as the primary form; the source form is recoverable from the originating position, and from @meta.application where an entry carries one, and it is what the user wrote — a minted record is named as the body a record is written with (unknown field 'nope' on '{ v: int32 }'), and a minted atom instance by its position and the family’s own words. The declared applications of one template are recovered from @meta.application — the family a host language spells Result<T> is a walk over entries — which is what makes “a template is not a type” workable for a generator. An author who wants an application to have a name of its own declares it, order_page => page<order>, and that declaration is the entry ([TSON-SCHEMA] §8.2) — and where the application composes onto a record, and so is a member of a family, declaring it is required (§2.10). A minted name written in data is not refused: such a document was written by reading one processor’s output, and it fails at the next.

8.2 The chain, once more

Revision 38 removed the wrapper every entry once wore — person => !type_definition { source: record supertypes: […] subtypes: […] body: !record { … } } — because each of its four facts had a home of its own: the body is the entry’s value; supertypes and subtypes are facts about a type, so they are fields of meta.type, which every !meta.record, !numeric.integer_type, !meta.array and !meta.choice body composes and meta.alias, meta.template and a value entry do not; source for a construction was the body’s tag said twice, for a refinement the chain’s nearest member, and for an application the entry’s identity under another name, which moved to the key as @meta.application. Consumers should internalise what remains: chain.supertypes is the contract (the complete IS-A chain, closed to a fixed point — use it for “can a value of X go where Y is expected”), and it is primary data rather than a cache, because a flat body and its chain cannot be recomputed from each other and the atom family makes the point sharply (a refinement and a sibling construction serialize identically except for the chain). chain.parents survives only on a record template, where a parent may still be an application (<T> result<T> & { … }), and is closed with the rest when the template closes — and a declared application of a template whose parent is an application IS-A that template too, so ok<text> lands in result’s family as its hand-written expansion would. chain.subtypes and a choice’s disjoint are the two recomputable caches, never trusted when a resolved form is read; chain.supertypes, chain.parents and @meta.application are read as stated and verified, and a parameter’s recorded type and bound are verified rather than recomputed, so a type an author wrote survives the read. A reference’s admitted set — what Revision 38 wrote as members — is derived at link and never written at all.

A resolved form reads under its own header. Revision 38’s fixtures could not carry an !!import, and the namespace their names resolved in was “supplied at ingest”; with one directive a resolved form carries its source’s !!id and !!imports, nothing in the document marks it resolved, and a processor reads it as a resolved form because its caller says so ([TSON-SCHEMA] §8.1, §10.1). The kernel’s own fixture carries its !!id alone and reads under the pre-loaded kernel. Where a resolver once asked whether an entry was a type by excluding a kind, it now asks whether its value’s constructor IS-A meta.type — and meta.top, meta.type and the base kinds being abstract, the schema map itself reads by the rule every abstract record without discriminators has: dispatch by tag among the subtypes of a base that has no instances of its own. meta.schema’s value_type is meta.declared, which is that rule stated as a type (§6.4).

8.3 Error messages

The specification’s error categories are minimal by design; implementations compete on diagnostics. Some conventions that have worked well: report parse failures (“twelve is not an integer”) separately from validation failures (“300 exceeds age’s max of 150”) since users fix them differently; never surface internal entry names as the primary form (show the source form, recovered from the originating position and, for a declared instantiation, its @meta.application — the user wrote [text; 1..]; show them [text; 1..], and a minted entry by its application as written, 'b<10>': '5' is less than the minimum 10); on unresolved-type errors under a schema, say which namespace was searched — “no uuid in schema X (did you mean to import net.tn, or core.tn?)” turns the most common beginner error into a one-line fix, and with four libraries the question “which import” is the one a reader will ask; say “alias chain” where a chain of hops is too long or has no terminal, since “reference” now names a slot’s value; locate a template’s deferred failure at the declaration that wrote the offending name (the template for a bad type inside its body, the applier for a bad argument), not at whichever entry happened to be materialising ([TSON-SCHEMA] §5.10); name a wait cycle in wait order ('a' waits on 'b' waits on 'a'), since the order declarations were written in is not what the resolver read; and keep the two outcomes beside the categories — refused, a rejection by this processor under its own configuration (name hygiene, a resource limit, a schema it does not hold and will not fetch), and undetermined, a schema it could not obtain — visibly apart from a validity error and from each other while reporting them in the same list, distinguished by the rule or limit that refused or the reason the schema could not be obtained, with the report carrying the UCD version and the policies it was judged under (§3.5). The consumer the series is built for — a developer, or a model, checking a document against a server’s schema and policy — asks will this be accepted, to which the two give opposite answers: a refusal is a certain rejection whose fix the sender holds, and an unobtainable schema is no answer at all. A report therefore summarises acceptance as accepted, rejected (by a category error or a refusal) or undetermined, with validity carried by the categories alone; a closed library’s miss is a refusal and never “not found”, the library being the deployment’s whole configuration; and a fetch refusal names its rule but not the allowlist, since telling an untrusted sender which hosts a processor will fetch from is reconnaissance for request forgery. An undetermined schema in particular is not a resolver error, whatever the category rule would otherwise assign it: nothing was read to judge by, the next processor along may hold the schema and accept the document whole, and a command line that exits “invalid” on it sends the sender to fix the wrong thing — where a pin that was obtained and does not match is a verdict about bytes the document committed to, and stays one ([TSON-SCHEMA] §10.1, §10.2). A limit refusal in particular must never surface as a validity error — the fallback an unspecified category invites (a stack overflow escaping every handler and exiting with “your document is invalid”) is the one verdict a document that is merely large must not get; Part 1 §9.1 gives every limit a default and a category so it does not have to.

9. Deployment and Encoding Guidance

9.1 Versioning: publish, don’t mutate

Most schema systems version by mutating a shared definition and policing the mutation with compatibility rules. TSON versions by publishing: a schema’s identity is its exact byte content, so version N and version N+1 are independent, immutable artifacts with different hashes and different identities ([TSON-SCHEMA] §3.5). Nothing evolves in place. A data document locks to the one contract it was written against — the hash-pinned !!import on its opening lines — and that binding never drifts, because neither side can change.

Acceptance then becomes explicit, and it lives where it belongs: at the boundary. A server’s code binds to the set of schema versions it accepts, registering each in its schema library, and every request is validated against exactly the contract the request names. Because the version key sits in the header, dispatch is cheap: document kind and schema identity are readable from the opening bytes — the header, the root’s leading annotations and its tag — before any value parsing ([TSON-DATA] §2.2, §7.1).

For major upgrades there is a stronger pattern than binding one server to every version: run version-specific servers and route on the schema reference. A front door reads !!import, matches canonical identity and hash, and dispatches; each server behind it holds exactly one contract at full strength, and retiring a version is deleting a route. This is the architecture the industry converged on from the other direction — Stripe’s date-versioned API with server-side translation between pinned versions is the best-documented example (Stripe, APIs as infinite versions) — but there the version key is a bespoke HTTP header; here it is a first-class, verifiable property of the document itself.

What this retires is the compatibility rulebook — the folklore every mutable-schema system accumulated because one definition had to serve every point in time:

  • Protocol Buffers. required is marked “Do not use” in the proto2 language guide, the maxim being “required is forever”; Buf documents it as the root cause of early Google outages (Tip of the Week #8: never use required). The Updating A Message Type rules add the rest of the liturgy: never change a field number, never reuse one, mark removals reserved, add only optional or repeated fields. Proto3 went further — deleted required outright and made every field optional with zero-value defaults — then spent years restoring the field presence that deletion had cost.
  • Avro. Reader and writer schemas are reconciled pairwise under the specification’s Schema Resolution rules — new fields must carry defaults, renames need aliases — and an entire product category, the schema registry, exists to police mutation with BACKWARD / FORWARD / FULL and transitive compatibility modes.
  • GraphQL. The official best practice is a versionless API under “continuous evolution”: never remove, only @deprecated.
  • Beneath them all, Postel’s robustness principle — “be liberal in what you accept” (RFC 761) — which the IAB has since formally walked back as a long-term hazard to protocol health (RFC 9413, Maintaining Robust Protocols).

Every rule on that list is the same trade: weaken the contract so the timeline stays compatible. Fields end up optional forever, unknown fields must be tolerated, removal is forbidden — and the guarantees the schema no longer makes migrate into application code as defensive checks, validation pushed from a place where it is declared once to a place where it must be reimplemented everywhere. (Part 8 of the proto-schema research traces this history in detail.)

TSON’s model dissolves the dilemma rather than picking a side. Records are closed under their type ([TSON-SCHEMA] §7.2) and required means required — per version. A field can be required in version 12 and gone in version 13, because 12 and 13 are different artifacts and no rule forces one definition to satisfy both. Compatibility between two versions becomes something tooling can check — resolver output is data, so two versions diff structurally — and a policy a team may choose at a given boundary, never a constraint the schema language imposes on every author to keep a mutable timeline coherent.

9.2 Schema libraries in practice

The library model (lookup, never fetch-by-default) maps onto deployments straightforwardly: production systems register every schema at startup — from files, embedded resources, or an internal registry — and disable runtime fetching entirely; development setups may enable fetching with an allowlist and treat it as a cache-population convenience. Registering under an application-supplied identity (for !!id-less development schemas) is handy in tests but should never survive into interchange: publish with !!id, pin with hashes at trust boundaries. A reference a deployment will not supply — a closed library’s miss, a host off the allowlist, a missing required pin — is a refusal, as deployment-specific as a name policy and reported as one; only a reference nothing was behind leaves a document undetermined ([TSON-SCHEMA] §10.1). An allowlist matches hosts as host values, so an operator lists a host in either label form and an identity’s host matches by name, and a look-alike host is refused unless it was listed; a fetch refusal names its rule and not the list, since the list is what a forger would most like to learn. Every target of an !!import is a schema document, one whose root is tagged !schema; a resolved form is one by that test, and the library still never resolves an import to one — reading a resolved form as the resolver’s output is the caller’s explicit choice, never the library’s inference ([TSON-SCHEMA] §10.1).

Chain-wide strictness is a resolver policy. The mixed-reference rule (§3.4) deliberately leaves “every identity in my closure is verified” out of the format; deployments that want it enforce it at the boundary. Two mechanisms cover the need: a require-pinned resolver mode, which fails resolution when any identity’s digest set is empty (the pre-loaded kernel excepted, or checked as below); and a resolution report listing which identities resolved unpinned, for audits that prefer visibility to refusal. Together they give a deployment “my entire chain is verified” as a choice made where trust boundaries are actually known — per deployment — rather than a constraint the format imposes on every author in every closure.

Policies are the deployment’s, not the schema’s. The identifier and token policies of [TSON-DATA] §8.2 and the limits policy of §9.1 are configuration of the processor instance, set in code at the call site, and a processor should be able to state all of them with no document in hand — policy on a command line, a method on a reader — so that a counterparty can learn what will be accepted before writing. A deployment that writes its policy down writes a data document of policy.processor, the type policy.tn declares under its policy map, every limit a positive int64 (policy.limit). No schema carries a policy and none may (§3.5); a .well-known projection of an origin’s acceptance profile is the shape discovery will most likely take, and the refusal remains the one report that cannot be stale.

Ship digests with pre-loaded schemas. A pinned reference to a pre-loaded identity (!!import:"…meta.tn?sha256=…") must be checkable, but the pre-loaded model is an in-memory structure, not bytes. Implementations should therefore ship, alongside the kernel and any library they pre-load, its published canonical digest (or the canonical document bytes), and compare declared pins against it — a mismatch is the ordinary verification error, not an unverifiable assertion. Without this, pins to the kernel degrade into decoration exactly where the chain is supposed to ground. The kernel’s header is its !!id alone, so there is no unpinnable edge left: every reference to the kernel may be pinned, and the kernel references nothing.

9.3 Defaults on the wire

Encoders should write values for defaulted fields. A document that states its defaults reads without its schema — priority: 3 means 3 to every reader, schema in hand or not — whereas omission makes the document’s meaning schema-relative. Omitting fields at their default values is a legitimate wire-size optimisation precisely because the decoder injects the value back on read, but it should be an explicit encoder option, not the default posture. Two things are not options at all: a marker written on an unmarked name (version: text = "2.0") must be written, since it exists for readers that do not hold the schema; and a void value at a: T? or a?: T? ~ v must be spelled _ (JSON null), because omission there is a missing key or the default — the two declarations a schema-less encoder cannot serve, and the reason an encoder that must serve them is schema-directed (§2.9). A third is an ordering, not a value: a dependent field’s free sibling is written before it (§6.10), so an encoder that emits fields in declaration order is already right, and one that sorts them is wrong at exactly those records. Resolver output is the sanctioned exception to the first rule: it omits fields at defaults because its consumers are, by definition, schema-aware, and the compression materially improves fixture readability.

9.4 A disjoint choice is written in its variant’s form

At a disjoint choice the one consultation of form the series allows — base type resolution’s — is what picks the variant, so an emitter’s form is its tag: a text value whose content would resolve to another class (42, true) is written quoted, and a boolean or number variant’s value is written bare, so that a reader recovers the variant the writer meant ([TSON-DATA] §2.4; [TSON-SCHEMA] §5.4). The schemaless writers already do this — every host string quoted, every number and boolean bare — and a schema-directed writer should too, since a 42 written bare at ( int32 | text ) is the number whatever the host value was. The rule is text’s; in JSON the value kind carries it ([TSON-JSON] §8.2).

9.5 Choosing libraries

A schema imports what it uses, and what an import brings is stated in one table ([TSON-SCHEMA] §9):

ImportBringsReserves at the rootReserves as maps
meta.tn alonethe type system and the common atoms: void, boolean, integer, int64, text, bytes, iri, iri_reference, regex, doc, enum, set, schemathose thirteenmeta, atom, numeric, unicode, net, binary
numeric.tnthe kernel, plus number, rational, complex, the sized integers, float32, float64the kernel’s and thosethe kernel’s; adds to numeric
temporal.tnthe kernel, plus date, time, datetime, duration, periodthe kernel’s and thosethe kernel’s, plus temporal
net.tnthe kernel, plus email, ipv4, ipv6, cidr4, cidr6, mac, hostname, host, media_type, uri_reference, uri, uuidthe kernel’s and thosethe kernel’s; adds to net
core.tnthe three libraries, plus deprecated, title, comment, examples, read_only, write_onlyeverything aboveeverything above
policy.tnthe kernel, plus the policy vocabularythe kernel’sthe kernel’s, plus policy

Three consequences for an author. A schema that needs only dates imports temporal.tn and may declare int32 for itself; one that needs a UUID and a sum of money imports net.tn and numeric.tn, their closures meeting at the kernel by identity with no conflict; one that wants everything imports core.tn and accepts every root above as reserved. A schema importing a library alone has no @title or @deprecated and declares them if it wants them, which is why the author annotations are core’s and not the kernel’s. And a constructor is reached by its path wherever it is declared — !numeric.decimal_type {}, !net.uuid_type {}, !temporal.date_type {}, !unicode.identifier_type { … } — so a schema declaring an instance of its own never collides with the family’s canonical instance at the root, and record, identifier, type_ref, field_name, bounded, value, kind and core stay free under every import.

References

ReferenceTitle
TSON-DATATSON Part 1: Text Data Format
TSON-SCHEMATSON Part 2: Type System and Schema
TSON-JSONTSON Part 3: JSON Encoding
RFC 3986Uniform Resource Identifier (URI): Generic Syntax
RFC 3987Internationalized Resource Identifiers (IRIs)
CommonMark 0.31.2CommonMark Spec (the text of @doc)
UAX #31Unicode Identifiers and Syntax
UTS #39Unicode Security Mechanisms
UTS #55Unicode Source Code Handling
RFC 3339Date and Time on the Internet: Timestamps (Appendix A: durations)
RFC 3492Punycode: A Bootstring encoding of Unicode for IDNA
RFC 4648The Base16, Base32, and Base64 Data Encodings
RFC 5890IDNA: Definitions and Document Framework
RFC 5891IDNA: Protocol
RFC 5892The Unicode Code Points and IDNA
RFC 5893Right-to-Left Scripts for IDNA
RFC 5952A Recommendation for IPv6 Address Text Representation
RFC 6838Media Type Specifications and Registration Procedures
RFC 6839Additional Media Type Structured Syntax Suffixes
RFC 9110HTTP Semantics
RFC 8259The JavaScript Object Notation (JSON) Data Interchange Format
RFC 761DoD Standard Transmission Control Protocol (origin of the robustness principle)
RFC 9413Maintaining Robust Protocols
RFC 9562Universally Unique IDentifiers (UUIDs)
RFC 9651Structured Field Values for HTTP (the TSON-Schema field)
ISO/IEC 11404:2007General Purpose Datatypes (the atom.* classifying annotations)