Skip to content

Two things a schema could not say about the code it describes - #159

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/peaceful-mayer-oo6l2u
Sep 10, 2026
Merged

Two things a schema could not say about the code it describes#159
matt-edmondson merged 1 commit into
mainfrom
claude/peaceful-mayer-oo6l2u

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Both promote a fact that was previously the generator's to invent. They are the two gaps the C++ target document exposed that were genuinely the schema's rather than a language's.

A class can promise it travels as bytes

{ "members": [ ... ], "travelsAsBytes": true, "name": "RigidBody" }

An instance is copied whole — across a language boundary, into a save file, onto the wire — without anyone reading a field on the way. Member order stops being merely meaningful and becomes load-bearing: reordering members changes the binary layout of every saved instance and every packet carrying one, so it is a change to the class rather than a tidy-up.

The promise constrains the members, and validation enforces it:

Refused Why
String text is held elsewhere and reached by reference
Array a collection's elements are held elsewhere
Span a view onto memory the class does not own
Optional, Result trivially copyable in some languages and not others, and in none of them is the position of what it wraps something the schema can promise
Interface a reference to whatever implements it
Object naming a class that makes no such promise the same problem one level down

A Handle is accepted: an index and the generation its slot had is bytes whatever it identifies, which is the whole reason a component holds one rather than a reference. A Vector is accepted when its components are, and a Semantic when its representation is.

Two ways this could have looped forever

The check reads the flag off a named class rather than walking into it, so two classes holding each other validates rather than hanging the validator.

Reading through a semantic type needed the same care for a different reason, and this one I got wrong first: a refinement cycle makes Representation() return another Semantic, so following it recursed with no bottom. It now stops at the first representation that is still semantic — which is exactly the case ValidateUnderlyingType already reports as a cycle. Both cases have a test, and I confirmed the semantic one crashes with a stack overflow without the guard rather than assuming it would.

The schema names the enum a failed Result carries

{ "errorType": "ErrorCode", "enums": [ { "name": "ErrorCode", "values": [ "NotFound", "OutOfCapacity" ] } ] }

Held once for every fallible signature, for the same reason the other four conventions are global: a signature that could choose its own error type is one that has to be read to find out, and every interface language that allowed that grew per-declaration annotations until it was a worse version of the language it described.

An enum rather than any type, because an error is one of a closed set of reasons — which is what an enum is — and a caller switching on the reason needs the set to be enumerable.

A schema that never returns a Result needs none, and declaring none is not a mistake in itself. A schema that does is reported at the signature rather than at the root: that is the declaration whose meaning is incomplete, since a generator reaching it has a Result to emit and nothing to put in its error position.

Keeping the C# round trip honest

Nothing about a CLR type says either thing, so generated C# carries SchemaTravelsAsBytesAttribute and ClrTypeImporter reads it back — the same arrangement the member metadata already uses. The generate-compile-reimport test now carries a class that makes the promise and fails if either half stops doing its part; I checked that by breaking the importer and watching it fail, rather than trusting a passing test to be a meaningful one.

The attribute records the promise rather than enforcing it. A sequential-layout struct would enforce it in C# and would also turn every generated component from a class into a value type — a decision about the C# API rather than about the schema, and not one this attribute is entitled to make.

Format version

formatVersion moves to 5. Both are additive and the class flag is omitted when false, but a version 4 reader would drop either and go on generating: a type that quietly stopped promising its layout, or a fallible call with nothing to say when it fails.

Testing

397 tests pass in Schema.Test (21 new across TravelsAsBytesTests and ErrorTypeTests), 201 in SchemaEditor.Test, whole solution builds with 0 warnings.

Noticed, not touched

Schema.CanResolvePaths is a computed property that is being serialised into the file. It predates this change and fixing it would alter every written document, so it is out of scope here — flagging it rather than folding it in.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk


Generated by Claude Code

Both promote a fact that was previously the generator's to invent.

A class can promise it travels as bytes. SchemaClass.TravelsAsBytes says an
instance is copied whole - across a language boundary, into a save file, onto
the wire - without anyone reading a field on the way. Member order stops being
merely meaningful and becomes load-bearing: reordering members changes the
binary layout of every saved instance and every packet carrying one.

The promise constrains the members, and validation enforces it: a String, an
Array, a Span, an Optional, a Result, an Interface, or an Object naming a class
that makes no such promise are all refused, because each would arrive as an
address that means nothing where it landed. A Handle is accepted - an index and
the generation its slot had is bytes whatever it identifies, which is the whole
reason a component holds one rather than a reference. A Vector is accepted when
its components are and a Semantic when its representation is.

The check reads the flag off a named class rather than walking into it, so two
classes holding each other validates rather than hanging the validator. Reading
through a semantic type needed the same care for a different reason: a
refinement cycle makes Representation() return another Semantic, so following
it recursed with no bottom. It stops at the first representation that is still
semantic, which is exactly the case ValidateUnderlyingType already reports.

The schema names the enum a failed Result carries. Schema.ErrorType holds it
once for every fallible signature, for the same reason the other four
conventions are global: a signature that could choose its own error type is one
that has to be read to find out. An enum rather than any type, because an error
is one of a closed set of reasons and a caller switching on the reason needs the
set to be enumerable. A schema that never returns a Result needs none; one that
does is reported at the signature, which is the declaration whose meaning is
incomplete.

Nothing about a CLR type says either thing, so generated C# carries
SchemaTravelsAsBytesAttribute and ClrTypeImporter reads it back, the same
arrangement the member metadata uses. The round-trip test now carries a class
that makes the promise, and fails if either half stops doing its part - checked
by breaking the importer and watching it fail rather than assuming.

The attribute records the promise rather than enforcing it. A sequential-layout
struct would enforce it and would also turn every generated component from a
class into a value type, which is a decision about the C# API rather than about
the schema.

formatVersion moves to 5: both are additive and the class flag is omitted when
false, but a version 4 reader would drop either and go on generating - a type
that quietly stopped promising its layout, or a fallible call with nothing to
say when it fails.

397 tests pass in Schema.Test (21 new), 201 in SchemaEditor.Test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 34bd0ac into main Sep 10, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/peaceful-mayer-oo6l2u branch September 10, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants