tson.io

Non-normative report

Implementation Feedback

Revision 32·view raw ↗

Spec feedback

Issues, ambiguities, and inconsistencies found in the TSON spec while building this implementation. See CLAUDE.md for why this file exists and when to add to it. Spec quotes below are from Part 1 (https://tson.io/raw/2026/32/tson-part1-data.md), 2026 Revision 32, unless noted otherwise.

Format per entry: spec section, the problem, the interpretation this implementation chose, and a suggested resolution where there is one.


1. Multi-line token closing delimiter: is trailing whitespace after """ permitted?

Section: §7.2.3.

Problem: The opening delimiter rule is explicit about trailing whitespace: “The opening delimiter is """ followed by optional spaces and tabs and a line terminator.” The closing delimiter rule has no equivalent clause: “the closing delimiter is """ on its own line, preceded only by optional whitespace.” “Preceded only by optional whitespace” covers leading whitespace on the closing line, but says nothing about what may follow the """ before the line terminator. Read strictly, a line consisting of """ (trailing spaces after the delimiter) is unaddressed: it’s not clear whether those trailing spaces are permitted and ignored (symmetric with the opening delimiter) or make the line fail to qualify as “its own line” for the closing delimiter, causing an “unterminated token” error whose actual cause is a stray trailing space.

Interpretation chosen: Treat trailing spaces/tabs after """ on the closing line as permitted and ignored, by symmetry with the opening delimiter’s explicit rule. Implemented in Lexer.isClosingDelimiterContent.

Suggested resolution: Make the closing delimiter production explicit about trailing whitespace, mirroring the opening delimiter’s wording, e.g.: “the closing delimiter is """, optionally followed by spaces and tabs, on its own line, preceded only by optional whitespace.”


2. Multi-line common-prefix stripping: what happens to a blank line shorter than the computed prefix?

Section: §7.2.3, rule 2.

Problem: Blank lines are explicitly excluded from computing the common prefix (“Blank lines do not participate in the calculation”), but the same rule then says unconditionally “The prefix is then removed from the start of every line” — which does include blank lines. A blank line by construction contains only spaces/tabs (or is empty), so it may well be shorter than the computed prefix, or its whitespace may not match the prefix character-for-character (e.g. the file uses spaces for indentation generally, but one blank line happens to contain a single tab). The spec doesn’t say what “removed from the start” means when the prefix doesn’t fully match: is it an error, does the line contribute nothing (stays as-is), or is only the matching portion removed?

Interpretation chosen: Best-effort: remove the longest prefix of the line that matches the computed prefix character-by-character, which for a shorter or non-matching blank line may be less than the full prefix (including zero characters). Never an error. Implemented in Lexer.removePrefix.

Suggested resolution: State explicitly that prefix removal from a line shorter than (or not matching) the common prefix removes only the matching portion, is a no-op past the point of mismatch, and is never an error — or, if a stricter behavior is intended, say so and define what should happen instead.


3. The nested-annotation example @a:@b:val target cannot actually stand alone as a data-value

Section: §3.1, “Value scope”.

Problem: The prose gives two examples back to back, apparently as parallel illustrations of the same shape: @a:@b:val target and (contrasted) @a:@b val target. Mechanically tracing both against the grammar (data-value = *annotation [type-ref] core-value; annotation = "@" unquoted-token [":" data-value]) shows they are not actually parallel:

  • @a:@b val target (no colon on @b): @b is valueless, so parsing @b’s containing data-value (which is @a’s value) stops as soon as it finds a core-value — val — since a valueless annotation doesn’t keep consuming. That data-value is {annotations: [b], core: val}, fully consumed as @a’s value. Control returns to the outer level, which still needs its own core-value and finds target waiting. Outer result: {annotations: [a], core: target} — a complete data-value, exactly as the spec states (“target belongs to the surrounding context”).

  • @a:@b:val target (colon on @b): @b has a value, so parsing @b’s value recurses into a fresh data-value starting at val target, which finds val as its core-value and stops — target is not part of @b’s value. Control returns to the data-value that contains @b as an annotation (which is @a’s value) — and that data-value still needs its own core-value, which it finds: target. So @a’s value is {annotations: [b], core: target}, exactly as the spec states. But this fully consumes every remaining token. Stepping back up one more level to whatever data-value contains @a as an annotation, that data-value now also needs a core-value of its own — and there is nothing left. This is structurally identical to the spec’s own error example two sentences later: { x: @a:@b:val } is a parse error “because @a’s data-value still requires a core value after the annotation @b:val” — the same failure, just one level further out. I traced this by hand three times against the grammar (including embedded in { x: @a:@b:val target }, which fails identically once x’s value still needs its own core-value after @a consumes everything) and it holds up: @a:@b:val target, as given, cannot be a complete data-value in any position. It would need one more trailing token (e.g. @a:@b:val target extra) for the outermost core-value to be satisfied.

Interpretation chosen: Implemented the grammar exactly as written (annotation-with-value recurses into a full nested data-value, which always requires its own core-value). Verified in ParserTest.nestedAnnotationValueScopeSpecExample (using @a:@b:val target extra — the smallest extension of the spec’s own example that actually parses) and ParserTest.nestedAnnotationValueScopeAloneIsIncomplete (confirming @a:@b:val target alone fails, and why).

Suggested resolution: Either extend the first example with one more trailing token so it’s complete and directly comparable to the second (e.g. @a:@b:val target extra), or add a sentence noting that @a:@b:val target alone is intentionally incomplete and only illustrates @a’s value in isolation, not a full data-value.


4. Custom (non-built-in) type-ref matching semantics are entirely undefined in Part 1

Section: §3.2 (type annotations / type-ref), §5.1.

Problem: §3.2 requires a Class 1 processor to preserve a type annotation it does not resolve “as an uninterpreted marker attached to [its] value” — i.e. Part 1 explicitly declines to define what a custom type name like !Circle means, deferring that to Part 2’s schema/type-system layer, which doesn’t exist yet (§1.3). §5.1 does establish a matching rule, but only for the closed, built-in vocabulary: “Annotation names are case-sensitive. Only the exact names listed below are recognised.” That rule’s scope is explicitly the fixed table in §5.3–§5.6 (!uuid, !date, !int32, etc.) — it says nothing about, and by its own “only the exact names listed below” wording arguably excludes, how a name outside that table should ever be matched against anything, since Part 1 has no concept of “the set of names a schema declares.” This isn’t a bug in Part 1 — it’s explicitly out of scope by design — but it means an application-level consumer that wants to use type-ref for host-language type disambiguation (e.g. resolving a Java union member from !Circle) has zero spec guidance today, and no way to know whether the eventual Part 2 rule will be case-sensitive-only (as §5.1 is, for the built-in set) or something looser.

Interpretation chosen: TsonMapperReader.resolveUnionMember (io.ltr8.tson.compiler.mapper) treats this as purely an application-binding decision, not a spec-conformance one — the Class 1/Class 2 preservation requirement is satisfied upstream (the parser hands the type-ref through as an uninterpreted string), and everything downstream of that is this implementation’s own policy: try an exact match against a member class’s @Typename annotation first, then fall back to a case-insensitive match against the member’s simple Java class name (so !circle matches a class named Circle without requiring every fixture to carry an explicit annotation). Note this fallback is deliberately not consistent with §5.1’s case-sensitivity rule for the built-in vocabulary — there was no spec basis to be consistent with, since §5.1 doesn’t claim to govern this case at all.

Suggested resolution: Not a Part 1 defect to fix — flagging so that whenever Part 2 defines real schema-driven type-name resolution, this implementation’s ad hoc TsonMapper heuristic gets revisited and either conformed to the real rule or clearly scoped as “no schema in play” fallback behavior.


5. !email is present in the core type library but missing from Part 1’s built-in vocabulary table

Section: §5.5 (“Identifier and Network Types”), cross-referenced against the core type library (core.tn1, reachable via !!meta from a schema; see also §5.1: “schemas wanting these names import the core type library, whose entries denote the same parsing contracts defined here”).

Problem: core.tn1 groups email together with uuid, ipv4, ipv6, cidr4, cidr6, and mac under one documentation banner (“Network Types”) and gives it the same shape as its siblings — email => !email_type {}, backed by an email_type constructor in meta.tn1 pinned to RFC 5322, identical in form to uuid_type/ipv4_type/etc. Every other member of that family is promoted to a Part 1 §5.5 built-in annotation (!uuid, !ipv4, !ipv6, !cidr4, !cidr6, !mac) — !email is not; the §5.5 table has no row for it, and !email appears nowhere else in Part 1. Nothing in §5.1’s applicability rules or §5.5’s prose explains the omission (no stated rationale like “email validation is intentionally schema-only”), so a reader relying on core.tn1 as the built-in vocabulary’s source of truth (as §5.1 explicitly invites) would reasonably expect !email to exist as a schemaless annotation and be surprised to find it doesn’t parse.

Interpretation chosen: Treat the Part 1 §5.5 table as authoritative and exhaustive for the schemaless vocabulary — !email is not implemented as a built-in annotation in this implementation’s Class 1 resolver, matching the letter of §5.5. An unannotated email-shaped token, or one under an unrecognized !email annotation, falls through to ordinary base type resolution (§4) / uninterpreted-marker preservation (§3.2) respectively, same as any other non-vocabulary name.

Suggested resolution: Either add an !email row to §5.5 (if the omission is accidental), or add a sentence to §5.1/§5.5 stating explicitly that email is deliberately schema-only and not part of the schemaless built-in set — RFC 5322 email validation is notoriously heavyweight/contentious to fully implement, which would be a reasonable rationale, but the spec doesn’t currently say so.


6. §5.6’s published integer atoms are a strict subset of core.tn1’s integer_type family

Section: §5.6 (“Numeric Types”), cross-referenced against core.tn1.

Problem: §5.6’s table lists exactly four fixed-width integer annotations: !int32, !int64, !uint32, !uint64. core.tn1 defines the same integer_type constructor applied across the full int8/int16/int32/int64/int128/int256 and uint8/uint16/uint32/uint64/uint128/uint256 width ladder, plus a positive_integer/non_negative_integer/negative_integer/non_positive_integer bound-only refinement family — sixteen instances of integer_type total, of which §5.6 promotes only four to the schemaless built-in vocabulary. Confirmed (outside the spec text itself, via direct guidance) that the missing twelve are an oversight in the published table, not a deliberate narrowing of the schemaless surface relative to the core type library.

Interpretation chosen: tson-compiler’s built-in vocabulary (BuiltinTypeVocabulary, resolver.vocab package) implements the full sixteen-instance integer_type family from core.tn1int8 through int256, uint8 through uint256, and all four bound-only refinements — not just the four §5.6 currently lists. IntegerType/IntegerConstraints/IntegerSize are written generically against the constructor (arbitrary width, arbitrary signedness, optional bounds), so this cost nothing beyond populating the map with twelve more entries.

Suggested resolution: Update §5.6’s table to list the full integer_type family, matching core.tn1.


7. §5.1’s “preserved as uninterpreted marker” rule doesn’t address what a typed-binding consumer should do with it

Section: §5.1.

Problem: §5.1 requires a Class 1 processor to preserve an unrecognized type annotation “as an uninterpreted marker” rather than erroring — correct and necessary at the parsing/resolution layer, since a Class 1 processor can’t know the full universe of names some future schema or application might define, and choking on them would make the format not forward-compatible. But the rule only addresses that processing step; it says nothing about what happens next. An application built on top of a Class 1 processor that binds a value directly to a caller-declared, strongly-typed target (this implementation’s TsonMapper.toObject(source, MyRecord.class)) has a real choice to make on hitting a marker it can’t interpret: treat the value as if the annotation weren’t there (silently falling back to base type resolution), or treat an unresolvable annotation on a value it’s actively trying to type-check as an error. Getting this wrong either way has a real cost: silently ignoring means a typo like !Uuid (case-sensitive per §5.1, so not the same as !uuid) quietly disables the validation the author clearly intended; erroring unconditionally means an application that deliberately wants passthrough/lenient behavior for forward compatibility has no way to ask for it. Every implementation doing typed binding on top of TSON will face exactly this decision, and Part 1 has nothing to say about it — reasonably, since it’s application-binding policy, not format conformance, but worth recording as a gap a future implementer’s guide could usefully address.

Interpretation chosen: TsonMapper treats an atom-typed value carrying a type-ref that BuiltinTypeVocabulary doesn’t recognize as a binding error (DataBindException), not silent fallthrough to base type resolution. The Class 1 processing step itself (tson-compiler’s Parser/BaseTypeResolver) still faithfully preserves the type-ref exactly as §5.1 requires — this is a binding-layer policy choice layered on top, not a change to Part 1 conformance. Rationale: a mistyped or unimplemented type-ref on a value the caller is actively binding to a specific Java type is far more likely to be a bug worth surfacing than an intentional forward-compatibility signal, and TsonMapper has no schema layer yet to make “this annotation is legitimately not mine to interpret” a safe default assumption.

Suggested resolution: Not a Part 1 defect — flagging as guidance worth a note in a future implementer’s guide ([TSON-GUIDE]?) rather than the format spec itself: implementations binding typed values directly to host objects should consider failing on unrecognized type-refs by default, with passthrough as an explicit opt-in, rather than the reverse.


8. §5.2’s “is a parse error” phrasing for atom-format violations conflicts with §8.1’s own category description

Section: §5.2, §8.1.

Problem: §5.2 states: “A token the atom’s grammar rejects ‘is a parse error’; a parsed value violating the atom’s range ‘is a validation error’.” §8.1’s “Canonical phrasing” rule states these exact four phrases each map “unambiguously” to a category, and lists “is a parse error” as mapping to the parser category. But §8.1’s own description of parser errors is “Structural mismatches: unclosed brackets, adjacency violations, unexpected tokens, missing separators, !! without an adjacent colon form, a directive name outside the closed positional set or outside its placement (§3.3)” — nothing about an atom’s own value-format contract. A built-in vocabulary annotation’s parsing contract (§5) is checked well after the structural parser has already accepted the document as well-formed — !int32 twelve is a syntactically complete data-value (type-ref + token core-value); the failure only surfaces once something interprets the token against int32’s specific format, which is architecturally a resolver-layer concern (recognizing/binding a token against a type’s contract) in every implementation this project is aware of, not a structural-parser concern. §8.1’s resolver-error description doesn’t mention this case either (“Reference and resolution failures… an absent sentinel in map key position; a built-in type annotation on a container value (§5.1)”). So §5.2’s own use of “is a parse error” appears to invoke §8.1’s parser category by the letter of the canonical-phrasing rule, while conflicting with both categories’ own prose descriptions — most plausibly because §5.2 is using “parse error” in the ordinary-English sense (“this token failed to be interpreted”), written without cross-checking §8.1’s stricter technical claim that the exact phrase is a fixed mapping to one specific processing-layer category.

Interpretation chosen: This implementation’s atom types (resolver.vocab.AtomParseException) live in tson-compiler’s resolver package, architecturally alongside — not inside — the structural parser (Parser/ParseException), and are raised only from atom-type read() calls, never from Parser itself. For the conformance test suite (ltr8-io-tson-test-suite’s vocabulary/invalid vectors), this failure mode is tagged category: resolver, not parser, as the more architecturally coherent reading — but each such vector’s own description flags this as provisional, and the suite’s README documents the ambiguity explicitly, since a literal reading of §8.1’s canonical-phrasing table would put it under parser instead. Range/constraint violations (§5.2’s other phrase, “is a validation error”) have no such ambiguity — §8.1 unambiguously assigns “range violations by the numeric atoms” to the validation category, and both AtomValidationException and the suite’s vectors use it without qualification.

Suggested resolution: Either restate §5.2’s phrasing to use “is a resolver error” (matching where this check actually happens architecturally and avoiding the canonical-phrasing collision), or add a clause to §8.1’s canonical-phrasing rule or parser-error description explicitly carving out built-in-vocabulary format violations as parser-category despite occurring after structural parsing completes.


9. text_type exists in meta-kernel.tn1 but !text is not part of Part 1’s published built-in vocabulary

Section: §5 (all of §5.3–§5.6), cross-referenced against meta-kernel.tn1.

Problem: meta-kernel.tn1 defines text_type (min_length/max_length/length/pattern) and an instance text => !text_type {}, and several other constructors compose with it (uri_type, regex_type, email_type all extend text_type’s shape). It would be reasonable to expect !text to be promoted to a schemaless built-in annotation the same way !uuid/!date/etc. are, especially since it’s one of the simplest possible atoms — an unconstrained text check is nearly a no-op. It isn’t: !text appears nowhere in §5.3 (Binary), §5.4 (Temporal), §5.5 (Identifier and Network), or §5.6 (Numeric) — the four family tables that between them are the complete published vocabulary (confirmed by grepping the whole document for every `!name` table row). This is the same shape of gap as entry #5 (!email), but for a type that’s arguably a more natural inclusion than email, since text needs no external RFC and is the foundation uri_type/regex_type/email_type all build on. Unlike entry #6 (the integer family), this one has not been confirmed as an oversight — it may be entirely deliberate (an unannotated token already resolves to a string via base type resolution, §4.4, so a bare !text annotation would add essentially nothing beyond what’s already the default), but the spec doesn’t say so.

Interpretation chosen: !text is not implemented as a built-in annotation in this implementation’s Class 1 resolver, matching the letter of §5’s tables. An unannotated string-shaped token, or one under an unrecognized !text annotation, is handled the same as any other non-vocabulary name (§5.1: preserved as an uninterpreted marker at the Class 1 layer; a binding error at the mapper layer’s per entry #7).

Suggested resolution: Either add a !text row somewhere in §5 (there’s no obviously-correct subsection for it among the four existing family headings, which is itself a small structural observation), or add a sentence noting that text is deliberately schema-only/omitted from the schemaless vocabulary because base type resolution already covers the unconstrained case.


10. §5.3 doesn’t say whether !base64/!base64url require padding

Section: §5.3, cross-referenced against RFC 4648 §3.2.

Problem: §5.3 says only “a token that is not a valid encoding under the named scheme is a parse error” and meta.tn1 says only “Encoding alphabets are pinned to RFC 4648” — neither addresses whether a !base64/!base64url token must include the = padding characters RFC 4648 §4/§5 describe. RFC 4648 §3.2 itself says implementations “MUST include appropriate pad characters at the end of encoded data unless the specification referring to this document explicitly states otherwise” — TSON is exactly such a referring specification, and §5.3 doesn’t state otherwise, so a literal reading requires padding. But this is exactly the kind of detail an implementation could easily get wrong by trusting a standard library instead of the RFC text: java.util.Base64.getDecoder() accepts input with the padding omitted entirely ("TWE" decodes identically to the correctly-padded "TWE=") — confirmed empirically before writing BinaryType, not assumed. An implementation that just calls Base64.getDecoder().decode(text) and propagates whatever it throws would silently accept unpadded input, deviating from RFC 4648 §3.2’s MUST without any test ever catching it, since the JDK never complains.

Interpretation chosen: BinaryType’s BASE64/BASE64URL encodings (via a shared Base64Decoding helper) reject any token whose length isn’t a multiple of 4 before ever reaching java.util.Base64’s decoder — i.e. padding is required. Not similarly strict about RFC 4648 §3.5’s canonical padding-bits requirement (the unused bits in the last encoded character before = should be zero) — §3.5 makes rejecting non-canonical encodings a MAY, not a MUST, so the JDK decoder’s leniency there is left alone; BASE32’s from-scratch decoder follows the same distinction (required padding count, not required-canonical padding bits).

Suggested resolution: Add a sentence to §5.3 stating explicitly whether padding is required for !base64/!base64url, rather than leaving it to RFC 4648 §3.2’s general “unless stated otherwise” default — easy to get right by reading the RFC carefully, easy to get wrong by trusting a standard library’s decoder, which is exactly the trap this entry documents checking for empirically before writing any code, rather than after a test failure caught it (the JDK never raises an error either way, so nothing would have caught it automatically).


11. binary’s constructor name doesn’t follow the _type suffix every other constructor uses

Section: meta.tn1.

Problem: Every constraint-vocabulary constructor in meta-kernel.tn1/meta.tn1 is named xxx_typeinteger_type, float_type, decimal_type, rational_type, complex_type, uuid_type, text_type, date_type, time_type, datetime_type, duration_type, email_type, ipv4_type, ipv6_type, cidr4_type, cidr6_type, mac_type, uri_type, regex_type — nineteen constructors, one naming convention, no exceptions among the _type-suffixed group. binary (§5.3’s four encodings’ shared constructor) is the one constructor of this general shape that doesn’t follow it: not binary_type, just binary. It isn’t obviously a typo, though — meta.tn1’s own introductory doc explicitly buckets constructors into three families, and puts binary in a different bucket from the _type-suffixed ones: “1. Structural constructors: binary (with binary_encoding enum) and extern… 3. Constraint vocabulary constructors for atom families the kernel itself doesn’t need: numeric…, temporal…, identifier…, network…, and text (email_type).” So binary/extern are explicitly categorized as “structural constructors,” distinct from “constraint vocabulary constructors” — but the reason for that categorization isn’t obvious from the constructor’s own shape: binary is atom-kind like every _type constructor (unlike extern, which is sum-kind, matching its “structural” label more intuitively), and it has min_length/max_length fields playing exactly the same constraint-vocabulary role text_type’s min_length/max_length do. Nothing else about binary’s definition explains why it’s grouped with extern rather than with the nineteen _type constructors it otherwise resembles.

Interpretation chosen: Treated as the same constructor either way — this implementation’s BinaryType class (in tson-compiler’s resolver.vocab package) is named to match the established _type-suffix convention of its siblings (IntegerType, FloatType, …) rather than mirror binary’s own unsuffixed spelling, since the naming asymmetry doesn’t appear to carry semantic weight for an implementation (it’s still one atom constructor, ~atom, with a constraint-vocabulary-shaped field set).

Suggested resolution: Either rename binary to binary_type for consistency, or add a sentence explaining what distinguishes a “structural constructor” from a “constraint vocabulary constructor” beyond the naming convention itself, since as written the category boundary reads as arbitrary for binary specifically (unlike extern, whose sum-kind and schema-reference-list shape make “structural” a much more legible label).


12. Does !duration accept ISO 8601’s PnW week form, or only PnYnMnDTnHnMnS?

Section: §5.4.

Problem: §5.4’s table gives !duration’s format as “ISO 8601 duration (PnYnMnDTnHnMnS)” — a parenthetical showing one specific designator sequence. ISO 8601-1:2019 (the spec duration_type itself pins to, per meta.tn1’s spec field) also defines a second, mutually-exclusive alternative form for expressing a duration in whole weeks: PnW (e.g. P3W for three weeks), which cannot be combined with the Y/M/D/H/M/S designators in the same value. §5.4’s parenthetical doesn’t mention W anywhere, and nothing in the surrounding prose says whether that’s because the week form is deliberately excluded from the schemaless !duration atom, or because the parenthetical is a representative example of the ISO 8601 duration format rather than an exhaustive grammar (the same way, elsewhere in the document, a parenthetical sometimes illustrates rather than fully specifies). Both readings are defensible: excluding W would be consistent with !duration’s host value being modeled as year/month/day/hour/minute/second components (a week doesn’t decompose uniquely into those without picking a day-length, though P3W itself carries no such ambiguity on its own terms); including it would be consistent with simply deferring to “the ISO 8601 duration format” as a whole, of which PnW is a normal part.

Interpretation chosen: DurationType’s parser accepts only P followed optionally by Y/M/D designators, optionally followed by T and H/M/S designators, matching §5.4’s parenthetical literally — P3W is rejected as a parse error, not specially recognized. This was the more conservative reading available (implementing a format the annotation’s own table doesn’t show would be a bigger leap than declining to implement one it might have intended by reference), but it’s a real coin flip, not a confident call.

Suggested resolution: State explicitly whether PnW is part of !duration’s accepted format or not. If it is, the table’s parenthetical should show it (PnYnMnDTnHnMnS / PnW) the same way §5.6’s table spells out multiple accepted grammar forms per numeric atom explicitly rather than by implication.


13. §3.1’s uniform annotation-attachment model has no host-language object-binding equivalent for scalar positions

Section: §3.1.

Problem: §3.1 lets an annotation attach to any data-value position, uniformly and recursively: a record field’s value, an array element, a map key, either side of a map entry, and (since an annotation’s own value is itself a full data-value) recursively inside all of those — { a: { b: @foo 1 } } is legal, annotating the deeply-nested 1. This is a coherent model at the data-format layer, where every value position is represented the same way regardless of shape. It has no equivalent in a strongly-typed host-language object-binding layer: a Java String, int, or other scalar-typed field has no place of its own to carry extra metadata alongside its value the way a composite type (a class the caller controls) could be retrofitted to. So a POJO/record-style binder can only ever recover annotations attached to positions that map onto a composite type the caller owns (and even then, only that value’s own annotations — not, recursively, its children’s, without inventing a separate carrier convention per container kind: field-keyed for records, index-keyed for arrays/tuples, twice more for map keys and values). Annotations on a scalar leaf, an array/tuple element, or a map key/value are structurally unreachable from a typed object-binding layer, full stop — not a gap this implementation failed to close, but one no fixed set of host-language carrier conventions closes, since the recursion is the whole shape of the problem. §3.1 doesn’t address this at all, reasonably, since it’s a binding-layer concern rather than a format one — but it’s worth being on record about, since every implementation doing typed binding on top of TSON will hit the same wall.

Interpretation chosen: io.ltr8.annotation.Annotated, a marker on one Java record component, opts a caller into recovering only the annotations on the value the whole record itself corresponds to (TsonAnnotations, in io.ltr8.tson.compiler.mapper, wrapping the raw, ordered Annotation list) — deliberately not a general “child annotations” mechanism. A record-field-keyed (or array-index-keyed, or map-key-keyed) carrier for children’s annotations was considered and rejected: it would only push the same problem down one level without resolving the recursive case, needs a different bespoke convention per container kind, and is real API surface for a capability likely rarely exercised in practice (per the meta-kernel’s own core.tn1/meta.tn1, annotations overwhelmingly describe the thing itself@doc:"..." @ordered:TOTAL on whole type definitions — not individual scalar fields). An application that needs full-fidelity annotation access at positions @Annotated can’t reach still has one: the parsed AST directly (DataValue.annotations()), which is already fully general and doesn’t need a schema or a Java type to project onto.

Suggested resolution: Not a Part 1 defect — §3.1 correctly stays silent on host-language binding, that’s out of scope by design. Flagging as guidance worth a note in a future implementer’s guide, or a question for Part 2: could a schema declare that a field’s annotations bind to a sibling field of a specific type (the way record_field’s value/value_param split already handles a related value-vs-parameter distinction), giving typed object-binding layers a real, schema-driven answer instead of each implementation inventing its own ad hoc partial carrier convention?


14. construction-def’s ABNF (§12.1) can’t parse its own worked example from §5.8

Section: §12.1 (grammar), cross-referenced against §5.8 and §12.2.

Problem: The ABNF is:

construction-def = type-ref 1*(ws "&" ws type-ref)
                   [ws record-def] [ws removal-set]
                 / type-ref ws "&" ws record-def [ws removal-set]
                 / type-ref ws removal-set

Alternative 1’s trailing record-def has no leading "&" in front of it — only 1*(ws "&" ws type-ref) does. But §5.8’s own worked example is customer => address & contact & { loyalty_tier: text }, which has a & immediately before the {. Under alternative 1 as literally written, type-ref binds address, the 1*("&" type-ref) repetition consumes & contact (one repetition — record-def can’t itself satisfy type-ref, since type-ref’s four alternatives never start with {), and the repetition then cannot continue because the next token is & followed by {, not & followed by a type-ref. The remaining & { loyalty_tier: text } has nothing left in alternative 1 to consume it: the next slot is a bare record-def with no & in front. Alternative 2 doesn’t rescue this either — it’s fixed at exactly one leading type-ref before its own single "&" record-def, so it only covers a two-item case (address & { ... }), not the three-or-more-supertype chain the example shows. §12.2’s disambiguation notes assume the intended behavior directly: “When a { follows a &-chain, it always belongs to the construction’s record-def” — describing exactly the & { shape the ABNF’s alternative 1 fails to admit.

Interpretation chosen: Implemented per the clear intent (the worked example plus the §12.2 note), not the literal alternative-1 production: a construction’s supertype list is type-ref (ws "&" ws type-ref)*, and on each & the parser checks one token ahead — { means the trailing record-def (terminating the supertype list), anything else means another type-ref supertype. This is equivalent to alternative 1 with an implicit "&" ws inserted directly before its [record-def] slot. Implemented in tson-compiler’s SchemaParser.parseConstructionDefContinuation.

Suggested resolution: Add the missing "&" ws before record-def in alternative 1, i.e.: type-ref 1*(ws "&" ws type-ref) [ws "&" ws record-def] [ws removal-set] — at which point alternative 2 becomes redundant (a single-supertype instance of the same shape) and could be dropped.


15. §12.1’s own summary claims field-modifier reuses data-value, but its ABNF restricts it to token/absent

Section: §12.1 (introductory prose) vs. its own ABNF, cross-referenced against §5.2.

Problem: §12.1’s lead paragraph states: “data-value appears at exactly three points — constructor-application values and atom-refinement values, and field-modifier values.” But the ABNF two lines later gives field-modifier = ws ("~" / "=") ws ( token / absent ) — not data-value. The two are materially different productions: data-value is *annotation [type-ref] core-value (annotations, an optional type-ref, and any core-value, including nested records/maps/arrays), while token / absent is a single unannotated, untyped leaf. This isn’t just loose wording — §5.2 itself independently confirms the narrower ABNF is the intended rule: “Value modifiers are restricted to scalar tokens — quoted or unquoted — covering strings, numbers, booleans, and null; complex modifier values (arrays, records, maps) are not supported in v1.” So the summary sentence overstates what the grammar and §5.2’s own prose both agree field-modifier actually accepts.

Interpretation chosen: Implemented per the ABNF and §5.2 (the two mutually-consistent sources): a field modifier’s value is a bare token or the absent sentinel — no annotations, no type-ref, never a container. tson-compiler’s FieldDef.Modifier models this as a plain TokenValue/AbsentValue (reusing io.ltr8.tson.compiler.ast’s existing leaf types), not a full DataValue.

Suggested resolution: Fix the summary sentence in §12.1’s lead paragraph to read “…and field-modifier values, which are restricted to a bare token or the absent sentinel (§5.2), not full data-values” — or simply drop field-modifier from that sentence’s list, since it isn’t actually an instance of the data-value import the sentence is introducing.


16. instance/atom-refinement’s ABNF uses the full data-value, letting a constructor-application or refinement payload carry a nonsensical second annotation/type-ref layer

Section: §12.1’s ABNF for instance and atom-refinement, cross-referenced against [TSON-DATA] §2.3’s data-value production and against §12.1’s own refined-def (the schema-level ^ refinement, a different construct from atom-refinement but the same operator).

Problem: The ABNF is:

atom-refinement = "!" type-name ws "^" ws data-value
instance        = "!" type-name ws data-value

and [TSON-DATA] §2.3 defines data-value = *annotation [type-ref] core-value, with type-ref = "!" unquoted-token. Expanding data-value inline, instance literally admits "!" type-name ws *annotation ["!" unquoted-token] core-value — i.e. after the constructor’s own "!" type-name prefix, the grammar as written still permits further annotations and a second, entirely separate type-ref before the actual payload (e.g. !integer_type @foo !other_type {} parses under the literal ABNF). Neither §5.5’s prose nor any real fixture (meta-kernel.tn1/meta.tn1/core.tn1) ever uses or implies this — every real instance/atom-refinement payload is a bare core value ({}, an array, or a single token), never annotated and never separately typed. atom-refinement additionally has its own prose directly contradicting the wider grammar it’s given: “the data-value MUST be a braced record of constraint bindings” (§12.1’s own comment) — i.e. atom-refinement’s payload isn’t just “some core-value”, it’s specifically a record, which core-value’s six-way alternation (record / map / array / empty-brace / absent / token) doesn’t capture either.

The grammar contains its own corroborating evidence that this is a genuine slip, not intentional: the sibling schema-level refinement production, refined-def = type-name [ws "<" type-args ">"] ws "^" ws record-def (§12.1), already uses record-def directly for the identical ^ operator applied one level up (record/map/array refinement, §5.7) — exactly the correction atom-refinement itself needs, sitting right there in the same ABNF block.

Interpretation chosen: Implemented per the narrower, evidently-intended productions: instance = "!" type-name ws core-value (dropping the *annotation [type-ref] prefix data-value would otherwise contribute, since the constructor name is already fully supplied by "!" type-name, and core-value already covers every real positional/braced/bare-array shape instance needs). tson-compiler’s ast.schema.Instance was reshaped accordingly: it no longer carries a separate target: String field alongside a full-generality value: DataValue (the redundancy that surfaced this while designing SchemaResolver’s generalized constructor-application resolution — target and DataValue.typeRef() were two fields saying the same thing). Instead Instance(DataValue value) wraps a DataValue constructed directly from the parsed core-value, with typeRef pre-set to the constructor name and annotations always empty; target() is a thin accessor over value.typeRef(). SchemaParser widened Parser.parseCoreValue() from private to package-private (the same treatment every other grammar primitive SchemaParser reuses from Parser already has) to reach the bare production directly instead of going through parseDataValue().

atom-refinement is left as DataValue (not narrowed to record-def) for now — a real, still-open gap, deliberately not fixed in the same pass: correcting it properly needs AtomRefinement.bindings to carry a RecordDef (schema-grammar AST) rather than a DataValue (data-grammar AST) directly, a larger type change than Instance’s, and SchemaResolver’s own atom-refinement resolution (Part 2 §5.7’s !I ^ { values }) doesn’t exist yet regardless (see CLAUDE.md’s “Not yet implemented”). Revisit alongside that work.

Suggested resolution: Change §12.1’s ABNF to:

atom-refinement = "!" type-name ws "^" ws record-def
instance        = "!" type-name ws core-value

matching refined-def’s own already-correct pattern for atom-refinement, and dropping the unused-in-practice *annotation [type-ref] layer for instance.


17. Atom refinement’s own desugaring rule (“retargeting”) produces the wrong value for a chained refinement, contradicting §5.7’s own materialization rule for the analogous record case

Section: §5.6 (“Atom refinement” — the desugaring rule), cross-referenced against §5.5 (age’s own worked example, which explicitly says a refined instance “can be refined further”) and §5.7 (“Body materialisation” — record refinement’s own, structurally analogous rule).

Problem: §5.6 states: “!I ^ { values } desugars by retargeting to the instance’s source constructor,” with two worked examples, both single-hop (!integer ^ { min: 0 max: 150 }!integer_type { min: 0 max: 150 }). Read literally, “retargeting” means only the head changes (!I ^!I.source) while values carries over verbatim, unchanged — so for a chained refinement, where I is itself already the result of a prior refinement, the desugared form contains only the new refinement’s own values, with none of I’s own previously-bound fields.

Concretely: given

int8      => !integer ^ { size: { bits: 8  signed: true } }
bigNumber => !int8 ^ { min: -500  max: 5000 }

the literal “retargeting” rule gives bigNumber’s I.source as integer_type (not int8int8’s own source is already the base constructor, per §5.5’s own rule that refinement always records source: I.source), and desugars bigNumber to !integer_type { min: -500 max: 5000 }size is gone entirely. bigNumber ends up an unconstrained-width integer with bounds, not an 8-bit signed integer with bounds, even though it is declared as a refinement of int8 and nothing in the source text ever removes int8’s own size constraint.

This is a genuine internal inconsistency, not just an underspecified corner case: §5.7’s own “Body materialisation” rule, for the structurally analogous record-refinement case, is explicit that inherited constraints survive a refinement that doesn’t mention them — “the refined body re-emits the complete inherited field set… Inherited REQUIRED_FIXED and REQUIRED_DEFAULT fields appear with their pinned values even when the refinement did not refer to them.” There is no stated reason atom refinement — called “refinement,” the same word, immediately adjacent in the same section family, and explicitly said to support further chaining (§5.5: “age… can be refined further”) — should behave in the opposite way (full replacement) rather than the same way (tightening: explicit values override, everything else survives).

Interpretation chosen: Merge, not replace. tson-compiler’s SchemaResolver.resolveAtomRefinement re-serializes I’s own already-bound value back to wire form (reusing TsonMapperWriter, so no hand-written per-type merge logic is needed for any of the many atom-constraint classes), merges it field-by-field with the new refinement’s own values (explicit values in values win; every field I itself already bound but the new refinement doesn’t mention keeps I’s own value), and binds the merged record generically against the resolved constructor — the same generic binding path used for the non-chained case, since a fresh/UNCONSTRAINED source’s own serialized form contributes nothing (every field absent) and the merge is then a no-op, recovering exactly the previous (correct) non-chained behavior as a special case.

Suggested resolution: Reword §5.6’s “Atom refinement” paragraph to state a merge explicitly, analogous to §5.7’s own “Body materialisation” wording, e.g.: “!I ^ { values } desugars by retargeting to the instance’s source constructor, with values merged over I’s own already-bound field values — a field named in values overrides I’s own value for it; every field I itself bound that values does not mention keeps I’s own value.” Add a chained worked example (something like the int8/bigNumber case above) alongside the existing two, since neither current example exercises chaining at all and the ambiguity only surfaces there.


18. unit’s three real instances (value/token/void) are “distinguished by name and prose-level parsing contract, not by schema shape” — an implementation has no mechanical way to discover the right data-parsing contract from the resolved schema alone

Section: §4.2/§8.1 (the unit atom constructor), meta-kernel.tn1’s own doc comments on unit/ value/token/void (non-normative, but the only place the actual per-instance contracts are written down at all).

Problem: unit => ~atom & {} declares an atom constructor with zero constraint fields. Every instance of it — value, token, void (meta-kernel’s own three; core.tn1 adds a fourth, its own void sibling under the same name) — resolves to the byte-for-byte identical empty body (Unit). Nothing in the resolved type_definition distinguishes them. Yet the three have genuinely different, incompatible parsing contracts, stated only in prose:

  • value: “the result of base type resolution ([TSON-DATA] §4) applied to a source token… value- typed fields receive whatever [TSON-DATA] §4 produces — null, boolean, integer, float, or string.”
  • token: “the canonical NFC-normalised form of a source lexeme,” taken verbatim, no further interpretation.
  • void: “parsing contract admits only the absent sentinel _. The host value is absent.”

meta-kernel’s own doc comment for unit states this plainly: its instances “are opaque atoms distinguished by name and prose-level parsing contract, not by schema shape.” That is an explicit admission that resolving unit’s constructor tells an implementation nothing actionable — the only way to implement value/token/void correctly is to special-case each by its declared name, which works for the four names this spec itself defines but gives no guidance for a schema author who instantiates unit under a new name expecting to document their own prose contract the same way (there is no mechanism by which such prose could be machine-readable, unlike, say, a constraint field on a proper atom family).

A concrete implementation bug this caused: this codebase originally had a single shared parser for all three, which (a) accepted any token whatsoever for void (wrong — should reject everything but _) and (b) rejected _ outright for void (backwards — AtomType.read(TokenValue)’s contract only ever sees a token, and the absent sentinel _ is a distinct core-value variant, not a token at all, so void’s real contract doesn’t even fit the shape every other atom-family parser uses).

Interpretation chosen: Name-keyed, hand-picked parsers, matching this codebase’s existing precedent for other cases generic/schema-shape-driven binding can’t handle (enum’s own boolean member-collision gap, uri_type/regex_type’s schema-composed RFC defaults). token keeps the original behavior (raw NFC-normalised text, unconstrained). value now actually runs BaseTypeResolver and narrows to the natural host type. void is implemented outside the ordinary atom-parser shape entirely — it inspects the data-value’s own core-value directly and accepts only the absent-sentinel variant, returning a host null. Dispatch is keyed on the declaration’s own name, not on anything in the resolved schema; an unrecognized unit-constructed name falls back to token’s behavior (the previous, pre-split default) rather than failing, since the spec gives no way to know what such a hypothetical fourth instance should actually do.

Suggested resolution: Either (a) give unit’s three-instance parsing-contract distinction a machine-readable home — e.g. a documentation-only annotation whose value is defined to carry semantic weight for unit specifically (unusual, since annotations are elsewhere purely informative), or (b) split unit into three separate, purpose-built constructors (value_type/ token_type/void_type, one 0-field atom family each) so each instance’s contract is at least nameable in the grammar even if still prose-defined, rather than three unrelated contracts sharing one constructor purely by convention. Either way, state explicitly (the way §5.6’s atom-refinement section states its own worked examples) that an implementation MUST dispatch these three by name, not by attempting to derive behavior from the (identical, uninformative) resolved shape — the current text mentions this only in a meta-kernel source comment, not in the spec prose itself.


19. Only a meta-kernel-governed schema may declare ~-marked constructors, but this is never stated as a normative rule

Section: §2.2.2 (“The !!meta Directive”), §3.3 (“Schema Layering”), §4.2 (“Type Construction”), Part 2.

Problem: §2.2.2 draws a sharp line: “User schemas normally chain to meta.tn1. Chaining to meta-kernel.tn1 directly is a meta-programming case — an alternative type vocabulary replacing meta, or an extension of the meta layer itself… The meta layer is the format’s sanctioned extension point: new type vocabularies arrive as alternative or extended meta-schemas chaining to the kernel, never as grammar changes.” §3.3 draws the same line from the other direction: “the meta-schema defines the structural vocabulary the type-definition grammar produces… type libraries define specific types… using that vocabulary — type libraries are ordinary schemas; application schemas import type libraries and define domain types on top of them.” Both passages strongly imply that declaring a fresh ~-marked constructor (§4.2: “the ~ marker prefix declares a constructor; it sets constructor: true in resolver output”) is something only a schema chaining directly to the meta-kernel is entitled to do — an ordinary type library or application schema only applies or refines constructors it doesn’t declare itself.

But neither passage is phrased as a MUST/MUST NOT, and no other section states it as a rule the resolver or linker is required to enforce. The grammar itself admits ~ at any type-def body, regardless of the declaring document’s own !!meta target — nothing in the ABNF (§12.1) restricts it. This is corroborated empirically by the real bundled fixtures, not just the prose: meta-kernel.tn1 declares 9 constructors, meta.tn1 (governed directly by meta-kernel) declares a further 18, and core.tn1 (governed by meta.tn1, one hop further down the chain) declares zero — but nothing says a conforming implementation MUST reject a hypothetical core.tn1 that did declare one.

Interpretation chosen: Enforced as a resolver/linker-level rule, and stricter than “structurally self-referencing”: an entry with constructor: true is only valid if the declaring schema’s own !!meta target is exactly https://tson.io/2026/32/m/meta-kernel.tn1 — the one specific meta-kernel identity this implementation’s own compiled-reader machinery is built against (TsonSchemaLinker.META_KERNEL_ID), not merely “some schema whose own !!meta happens to equal its own !!id.” This distinction matters beyond pedantry: every resolved TypeDefinition.body and every !instance construction (!enum, !integer_type, …) is interpretable only because a matching type constructor is declared in this specific meta-kernel — the Java dispatch tables (TsonParserFactoryRegistry/AtomTypeParser/RecordParser) are hard-wired to this one meta-kernel’s own fixed vocabulary. A structurally self-referencing but otherwise unrelated schema could declare a completely different, incompatible record/array/… vocabulary and would pass a purely structural self-reference test while being meaningless to this implementation’s own reader machinery. A library realistically supports one meta-kernel version at a time — a revision bump would mean rebuilding that machinery, not accepting a differently-identified substitute — so the check is a fixed-identity comparison, not a structural one. Implemented in TsonSchemaLinker.isMetaKernelGoverned, checked once per locally-declared constructor entry during link’s own validation pass. A schema violating this throws TsonSchemaValidationException naming the offending entry.

Suggested resolution: State explicitly, as a MUST, that an entry with constructor: true is only valid in a schema document whose own !!meta names the meta-kernel — most naturally as a normative sentence in §2.2.2 or §4.2 rather than leaving it to be inferred from descriptive prose in two different sections. Separately, and related: the spec should clarify whether “the meta-kernel” is meant as a single canonical document every conforming implementation resolves against verbatim (one fixed identity, à la this implementation’s own TsonSchemaLinker.META_KERNEL_ID), or whether an implementation is free to define its own compatible meta-kernel under a different identity — the “one deliberate circularity” language (§1.5) and the pre-loading requirement (§3.4, §10.1) both read as assuming the former, but neither says so explicitly.


20. .tn1 is defined as the file extension for “TSON version 1,” but the spec’s own pre-release material already uses it

Section: §7.1 (“Encoding, Normalization, and Media Type”); cross-referenced against the document status header and §1.2 principle 7 (“Permanent stability”), and against Part 2’s own bundled-schema table (§9).

Problem: §7.1 states: “TSON version 1 uses the file extension .tn1 for all documents; future major versions use correspondingly numbered extensions (.tn2, …).” This reads as a positive claim of stability — reinforced by §1.2 principle 7: “TSON version 1 is a permanent specification… There is no TSON 1.1 or TSON 2… The permanence guarantee attaches to the version 1 release: 2026-series revisions of this document, including this one, may change anything.” The document’s own status header agrees: “The 2026 revision series is subject to change without compatibility guarantees. When finalised, this specification will be published as TSON version 1 and frozen… until then, revisions are released under the 2026 series.” Taken together, these three passages say plainly that “TSON version 1” — and by extension the stability the .tn1 extension is defined to signal — has not been reached yet, and may never be reached in the document’s current form.

Yet the spec’s own bundled, normative fixtures are already published using that exact extension, during this explicitly-unstable period: meta-kernel.tn1, meta.tn1, and core.tn1 (Part 2 §9) are served at https://tson.io/2026/32/m/meta-kernel.tn1, .../meta.tn1, .../core.tn1 — real, resolvable URLs an implementation is required to fetch/pre-load verbatim to be conformant at all, carrying a 2026/32 (year/revision) path segment that is, by the spec’s own words, not “TSON version 1.” A file extension defined as “this document will not change” is already load-bearing on documents the spec’s own status line says may still change in any way. Nothing in the spec addresses this gap: there is no notion of a draft/pre-release file extension, no statement that the 2026-revision-series bundled fixtures are a deliberate, acknowledged exception to §7.1’s rule, and no guidance for a downstream implementation (like this one) or an application built on this library about what extension its own schemas should use while the spec itself is still in this state — every schema this project publishes today necessarily also uses .tn1 (see CLAUDE.md’s own “Project-owned schema !!id convention”), for lack of any spec-sanctioned alternative, making the identical claim of stability the bundled fixtures make.

Interpretation chosen: None yet — this implementation currently has no choice but to follow the spec’s own bundled-fixture precedent and use .tn1 throughout (including for its own project-owned schemas), since renaming the spec’s own published meta-kernel.tn1/meta.tn1/core.tn1 identities is not something a consuming implementation can do unilaterally: those URLs are fixed, external identities this implementation must fetch and reference verbatim to interoperate at all. Whether this project’s own artifacts (as opposed to the spec’s) should adopt a different, explicitly-unstable extension while the spec remains in the 2026 revision series is an open decision, being tracked separately (BACKLOG.md) rather than resolved here — a leading candidate under discussion is an unversioned .tn extension, scoped as “no stable version guarantee” (rather than strictly “pre-v1”, so it wouldn’t need redefining if TSON ever entered a v2 draft period after v1 ships) and reserved for project-owned, non-canonical artifacts only, since the spec’s own bundled fixtures can’t be renamed regardless of what this project decides.

Suggested resolution: Add a normative statement to §7.1 (or the document status header) covering the pre-finalization case explicitly — for example, either (a) state that 2026-revision-series documents, including the spec’s own bundled meta-kernel.tn1/meta.tn1/core.tn1, are a deliberate, acknowledged exception to the “version 1” naming rule, made because there is currently no alternative extension defined, and that this will be corrected at the version 1 release; or (b) define an explicit, unversioned extension (e.g. .tn) for any document produced before the version 1 freeze, reserving .tn1 as a claim that MUST NOT be made before that freeze actually happens — the same distinction the spec already draws at the media-type level between bare application/tson (no stability claim) and application/tson; version=1 (a positive one). Either resolution should also say whether a document renamed from a pre-release extension to .tn1 at the version 1 release is expected to change its own !!id (and therefore its canonical identity, §2.2.1) — a real interoperability question for anything hash-pinned or referenced during the draft period.


21. §2.5’s “last value wins” for a duplicate record field name doesn’t say whether a shadowed occurrence’s own value must still be validated

Section: §2.5 (“Records”).

Problem: §2.5 states that when a field name is repeated within the same record, “the last value associated with that name is the field’s value” — a resolution rule for which value survives, said purely in terms of the final result. It says nothing about what a conformant processor is required (or permitted) to do with an earlier, shadowed occurrence’s own value on the way there: whether it must still be lexed/parsed/validated as a real value in its own right (so a malformed shadowed occurrence is itself a parse/validation error, independent of whether it’s ultimately kept), or whether an implementation may skip it entirely once a later occurrence of the same name is known to exist, treating it as inert, unvalidated text. Both readings are consistent with “the last value wins” as a result — the gap is about the process that gets there, and specifically about a case this implementation actually had to choose between: single-pass, forward-only, event-stream-based reading (§7.4’s own token stream is inherently single-pass) genuinely cannot know in advance, upon seeing the first occurrence of a field name, whether it will recur later — so it must either buffer that occurrence speculatively (undoing the memory/latency benefit of streaming in the first place) or decode-and-validate it immediately and simply let it be overwritten if a later occurrence turns up.

Interpretation chosen: Every occurrence of a duplicate field name is read and validated in full, forward, in source order — not just the one that ultimately wins. A malformed shadowed occurrence (one later overwritten by a subsequent occurrence of the same name) still surfaces as a real diagnostic (a thrown TsonReadException in fail-fast mode, or a collected Diagnostic in collecting mode) even though its own decoded value is discarded once the later occurrence is read. This is a deliberate, observable behavior change from an earlier version of this implementation, which scanned a record’s own (fully materialized) field list backward and skipped a field name already filled — never touching a shadowed occurrence’s own value at all, so a malformed shadowed occurrence was silently ignored. The backward-scan approach was only possible because that earlier version always had the record’s complete field list in hand before reading any of it (built from a pre-parsed Document tree); once reading moved to pull genuinely one event at a time directly off the lexer (RecordAbstractReader/TsonDataStream, “Streaming readers” in CLAUDE.md), backward iteration was no longer available at all, and the forward, validate-everything behavior was chosen as the one consistent with never buffering more than one open container’s worth of state. Verified in DuplicateFieldOverwriteTest: a record with the same field name twice, first occurrence out of range for its own atom type, second occurrence valid, confirms exactly one diagnostic is reported for the first (shadowed) occurrence and the field’s own final value is the second (surviving) occurrence’s.

Suggested resolution: State explicitly, alongside “the last value associated with that name is the field’s value,” whether a conformant processor MUST, MAY, or MUST NOT validate a shadowed duplicate occurrence’s own value — ideally phrased so a genuinely single-pass, streaming implementation (one that cannot know in advance whether a field name will recur) remains conformant either way, e.g.: “a processor MAY validate every occurrence of a duplicate field name as it is encountered, or MAY validate only the occurrence that ultimately wins; a document MUST NOT be considered invalid solely because an implementation chose to (or declined to) surface a problem in a shadowed occurrence.” This also has a direct interoperability consequence worth naming: two conformant processors reading the identical malformed-duplicate document may legitimately disagree on whether it’s valid at all, purely as a function of their own internal parsing strategy (streaming vs. buffered) — worth the spec saying so plainly rather than leaving it to be discovered.


22. regex is pinned to RFC 9485 (I-Regexp), but the spec never says whether the pin is a strict subset gate — nor that an implementation must document divergence from the RFC

Section: Part 2 — meta-kernel’s regex_type (spec: = "https://www.rfc-editor.org/rfc/rfc9485", a REQUIRED_FIXED field), the text_type/uri_type pattern: regex? fields, and the RFC 9485 reference (Part 2 references table).

Problem: The spec pins its regex atom to I-Regexp normatively — regex_type fixes spec to RFC 9485, and every pattern: constraint on text/uri is a regex — but leaves two things unstated:

  1. Is the pin a strict subset gate, or just a label? RFC 9485 defines I-Regexp as a deliberately restricted subset of common regex syntax (no anchors ^/$, no back-references, no lookaround, no non-greedy quantifiers). The spec never says whether a regex value that steps outside that subset MUST be rejected at schema load, or is tolerated. An implementation that validates well-formedness by delegating to a host engine (java.util.regex, PCRE, ECMAScript RegExp) will accept a large superset of I-Regexp, so a schema carrying a non-portable pattern loads cleanly on one implementation and is rejected by a strict one — an interoperability break invisible to hash-pinning, since both documents are “valid” locally.

  2. Nothing requires an implementation to document where its regex semantics diverge from the RFC. Even for constructs inside the I-Regexp subset, a host engine diverges observably: . line-terminator handling, \d/\w/\s ASCII-vs-Unicode membership, \p{...} category naming, and backtracking-vs-linear matching (ReDoS). Because the format’s whole premise is cross-implementation determinism (hash-pinned schemas, “identical behavior everywhere”), an undocumented divergence in regex semantics silently defeats it: two conformant implementations accept the same schema and the same data yet disagree on whether a pattern matches.

Interpretation chosen: Treat the pin as strict intent — a regex value should be valid I-Regexp or a resolver error — and commit to a native I-Regexp parser/AST/matcher so this implementation defines I-Regexp behavior, not the JVM (tracked in BACKLOG.md, “I-Regexp engine”). Until that lands, the current RegexParser/TextParser delegate well-formedness and matching to java.util.regex, which is a known, documented non-conformance: it accepts non-I-Regexp constructs and matches shared constructs with java.util.regex semantics, not RFC 9485’s.

Suggested resolution: Two additions. (a) State explicitly whether the RFC 9485 pin is a strict subset gate — recommended, consistent with the REQUIRED_FIXED pin and the interoperability premise: a regex value that is not valid I-Regexp is a resolver/validation error. (b) More importantly, add a normative requirement that an implementation MUST document any non-conformance from RFC 9485 — which I-Regexp constructs it does not enforce, where it delegates to a host regex engine, and the Unicode version its character-class semantics follow — mirroring §7.1’s existing “SHOULD document which Unicode version they support” convention. Cross-implementation determinism cannot be assumed for pattern matching unless divergences are declared, so the spec should require them to be.


23. Can TSON text read an untagged choice whose scalar variants are value-set-disjoint but share a base-type class (e.g. (positive_integer | negative_integer))?

Section: Part 2 §5.4 (Tagging + Disjointness).

Problem: §5.4 derives an encoding-independent disjoint fact and, for tagging, says a value MAY omit the !variant tag “unless the choice is disjoint under the active encoding’s discrimination… Where the tag is omitted, the variant is recovered by the same form resolution the encoding already performs — for TSON text, the single base-type-resolution pass of [TSON-DATA] §4 — never by a second, type-directed inspection of the value’s form. Where the encoding cannot separate the variants — variants that share a base-type class, such as (email | uri) (both the string class) — the tag is REQUIRED.”

Base-type resolution (§4) classifies a token only into null/boolean/number/string — it reaches the base class, not the specific variant. So for (positive_integer | negative_integer) — both the number class, and value-set-disjoint (their bound intervals don’t meet) — recovering positive vs negative requires checking the value’s sign/range against each variant, which reads exactly like the forbidden “second, type-directed inspection.” Read strictly, the tag is therefore REQUIRED even though the variants are provably disjoint. But then §5.4’s own baseline rule “same-family numerics are compared by their bound intervals” can never enable untagged TSON-text reading (every same-family numeric pair is the same number base class); its only consumers would be the @disjoint assertion check and non-text encodings. It is unclear whether that is intended, or whether TSON text is meant to use the value itself (a value inspection, not a type re-parse) to pick the range-disjoint variant — which the “never a second, type-directed inspection” clause appears to forbid.

Interpretation chosen: The disjoint fact is derived regardless (kind/family/numeric-bounds/IS-A). For TSON-text untagged reading specifically, this implementation reads §5.4 strictly: a choice’s tag is omissible only when its variants occupy distinct base-type classes (so §4’s single pass discriminates them with no type-directed inspection); a same-base-class choice — including a value-disjoint numeric one like (positive_integer | negative_integer) — requires the tag. So the numeric-bound (and unimplemented pattern) disjointness rules feed the @disjoint check and the encoding-independent fact, but do not enable untagged TSON-text reads.

Suggested resolution: State explicitly whether TSON text’s discrimination may consult a scalar value’s own resolved form (sign/magnitude for numbers, content for strings) to select among same-base-class value-disjoint variants, or whether — as “never a second, type-directed inspection” implies — same-base-class choices always require the tag. If the latter (the reading chosen here), note that the “same-family numerics compared by bound intervals” rule is, for TSON text, only ever consumed by @disjoint and other encodings, never by untagged reading — worth saying plainly, since a reader naturally expects (positive_integer | negative_integer) to be untagged-readable and it is not.


24. §5.4’s “disjoint: true when proved, absent otherwise” doesn’t accommodate the “provably not disjoint” (false) state the @disjoint refutation check requires

Section: Part 2 §5.4 (Disjointness + The @disjoint assertion); meta-kernel type_definition.disjoint: boolean?.

Problem: The Disjointness paragraph says the resolver “records the result in type_definition.disjoint: true when disjointness is proved, absent otherwise” — a two-valued description (true / absent). But the @disjoint assertion paragraph distinguishes refuted (“@disjoint present, provably not disjoint” → a resolver error) from unprovable (“neither proved nor refuted” → a warning). Telling “provably not disjoint” from “merely unproven” is a third state the two-valued “true / absent” field can’t carry. The kernel models the field as boolean?, which does admit three states (absent / true / false), so the model can hold a false; the prose just doesn’t say the derivation records one.

Interpretation chosen: Treat disjoint as genuinely three-valued (Optional<Boolean>): true proved disjoint, false provably not disjoint (an IS-A variant pair, or overlapping numeric bounds), absent otherwise — so the @disjoint check can distinguish refuted (false) from unprovable (absent) directly from the stored fact, rather than recomputing.

Suggested resolution: Amend the Disjointness paragraph to say the field records true (proved disjoint), false (provably not disjoint), or is absent (neither proved) — matching the boolean? model and the three cases the @disjoint check already enumerates. As written, “true when proved, absent otherwise” reads as two-valued and leaves the refuted state’s storage unspecified.