Skip to content

A schema can declare behaviour and semantic types, not just data - #155

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

A schema can declare behaviour and semantic types, not just data#155
matt-edmondson merged 3 commits into
mainfrom
claude/peaceful-mayer-oo6l2u

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Two extensions to what a schema can say. Both exist so that a fact about a type stops being stated somewhere other than the schema.


1. Semantic types

An entity id is a number. So is a texture id. Adding one to the other is nonsense that compiles, and no schema could say otherwise — a member was an Int or it was not, and every Int was every other Int.

SchemaSemanticType says they are different things: both stored as a Long, neither interchangeable with the other or with a bare number. A Semantic type refers to one, alongside Object for a class.

{ "underlyingType": { "TypeName": "Float" }, "unit": "m", "name": "Metres" }

What it refuses is the point, and the refusals are conventions the schema holds rather than settings each declaration restates:

  • Crossing into or out of the underlying type is always explicit — a bare number never becomes an EntityId by accident, and getting the number back out is a named call.
  • A semantic type may refine another, widening implicitly and narrowing explicitly. A Weight is a ForceMagnitude; not every force is a weight.

Only something whose values would otherwise be interchangeable may be shimmed:

Underlying Why
Primitives, vectors, colours Their values would otherwise be interchangeable — the case this exists for
Another semantic type Refinement
Object, Interface, Enum Already distinct types; naming one again leaves it ambiguous which conversions apply
Array, Span, Handle, Result, Optional, Void Describe how a value is carried, not what it is

Refinement cycles are reported, because a cycle is represented as nothing.

On metadata: some of the six properties belong to a type rather than a use of it — a Metres is metres everywhere it appears, so declaring the unit once stops every member restating it. A range is more often contextual and usually stays on the member. Rather than a second copy of the rules for each, ISchemaMetadataCarrier is the shape validation reads, so a unit on something that measures nothing is refused identically wherever it was declared.


2. Interfaces, functions and parameters

A class says what data is. There was no way to say what a program can do, so an API declared in a schema stopped at its components and every function was written by hand.

SchemaInterface holds SchemaFunctions, each holding ordered SchemaParameters and a return type, and a generator turns one into the header an implementation is written against.

The design question is how to stop expressing things

Every interface language that let a signature answer ownership, lifetime and error handling individually grew annotations until it was a worse version of the language it described. Four conventions answer them globally instead:

Question Answered by Not by
Can this call fail? The return type is Result a throws clause
May the callee keep this? Handle yes, Span no an ownership annotation
Who frees it? Nobody — a handle is an identifier, a span is a borrow valid for the call a lifetime annotation
Is this argument read-only? direction const

The cost is real and deliberate: a program wanting two error conventions cannot express the second.

Validation enforces the corollaries rather than trusting them. An Array parameter is refused (an array is owned; a borrowed sequence is a Span). A Result parameter is refused (fallibility describes a call, not an argument). None is refused anywhere generatable, distinctly from Void, which is a decision rather than an unfinished edit.

Direction on a Span describes the elements, not the view

In  Span<Velocity>  →  std::span<const Velocity>
Out Span<Position>  →  std::span<Position>

So an ECS system that reads one component and writes another is an ordinary signature needing no special concept.


One structural change

A type resolved its class by walking ParentMember to the schema — and a type in a signature has no member, so every class reference in an API would have silently resolved to nothing. BaseType now carries the schema association directly. Additive: ISchemaType.ParentMember and every existing caller are untouched, and tests pin the new path.

New types

Void (returns nothing — a decision, unlike None), Interface, Semantic, and four wrappers sharing a WrapperType base: Span (a borrow), Handle (an opaque generation-counted reference), Result (a call that can fail), Optional (absence is not failure).

Versioning

formatVersion moves to 3. Additive — a version 2 file loads as a schema with neither interfaces nor semantic types — so the migration is the existing no-op stamp.

Verification

  • dotnet build -c Release0 warnings, 0 errors across the solution
  • 357 tests pass in Schema.Test (334 before, 23 new), 201 in SchemaEditor.Test, 0 failures
  • SonarCloud quality gate passed, with 80.5% coverage on new code and 0 duplication

A correction to what the first two commits claimed. They said there were "0 findings on any line this change adds". That was true only of SonarAnalyzer's own S-rules — the filter used locally was (warning|error) S[0-9]+, and SonarCloud also ingests the MSTest analyzer as external_roslyn:MSTEST*. Fourteen of those were reported on the two new test files, and the third commit fixes them: Assert.Contains / Assert.HasCount / Assert.AreSequenceEqual in place of Assert.IsTrue(…Any(…)) and friends, which say what is actually being asserted.

Worth recording that these rules are not reported by a local build at any severity in this repository, so no local run would have surfaced them regardless of the filter. Querying the analysis on the PR is the check that works, as CLAUDE.md already says.

That leaves the two new files using assertions the rest of the suite does not. That inconsistency is real; knowingly adding fourteen more findings of a kind already open twenty times over seemed worse. Converting the existing suite is a separate change.

Docs

docs/schema-format.md gains both halves — semantic types with their refusal table, and interfaces with the four conventions — plus the new types and version rows. CLAUDE.md matches.

What this does not include

Systems (component reads/writes and phase) and events; and the code generation that turns an interface into a C++ header, which belongs in Coder and CodeBlocker rather than here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk

A class says what data is. There was no way to say what a program can do, so
an engine API declared in a schema stopped at its components and every
function was written by hand -- which is exactly where a fact about a type
gets stated somewhere other than the schema.

Interfaces close that. A SchemaInterface holds SchemaFunctions, each holding
ordered SchemaParameters and a return type, and a generator turns one into the
header an implementation is written against.

The design question is not how to express a signature; it is how to stop
expressing one. Every interface language that let a signature answer
ownership, lifetime and error handling individually grew annotations until it
was a worse version of the language it described. So four conventions answer
them globally and no signature restates them:

  - Can this call fail?        The return type is Result. There is no throws.
  - May the callee keep this?  Handle yes, Span no.
  - Who frees it?              Nobody: a handle is an identifier, a span is a
                               borrow valid for the call.
  - Is this argument const?    direction, which every language can spell.

Validation enforces the corollaries rather than trusting them: an Array
parameter is refused because an array is an owned collection and a borrowed
sequence is a Span; a Result parameter is refused because fallibility
describes a call and not an argument to one; None is refused anywhere
generatable, distinctly from Void, which is a decision rather than an
unfinished edit.

Direction on a Span describes the elements, not the view, which is what makes
the ECS case fall out: `In Span<Velocity>` is `std::span<const Velocity>` and
`Out Span<Position>` is `std::span<Position>`, so a system that reads one
component and writes another is an ordinary signature.

One structural change was needed. A type resolved its class by walking
ParentMember to the schema, and a type in a signature has no member, so every
class reference in an API would have silently resolved to nothing. BaseType
now carries the schema association directly and reference resolution reads
that; the member association still sets it, so ISchemaType.ParentMember and
every existing caller are untouched.

formatVersion moves to 3. Additive: a version 2 file loads as a schema with no
interfaces, so the migration is the existing no-op stamp.

346 tests pass in Schema.Test (334 before, 12 new) and 201 in
SchemaEditor.Test, with 0 warnings across the solution. The SonarCloud
analyzers were run locally against every changed file: 0 findings on any line
this change adds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk
An entity id is a number. So is a texture id. Adding one to the other is
nonsense that compiles, and no schema could say otherwise: a member was an
Int or it was not, and every Int was every other Int.

SchemaSemanticType is how the schema says they are different things. Both are
stored as a Long; neither is interchangeable with the other or with a bare
number. A Semantic type refers to one, alongside Object for a class and
Interface for an interface.

What it refuses is the point, and the refusals are conventions the schema
holds rather than settings each declaration restates:

  - Crossing into or out of the underlying type is always explicit. A bare
    number never becomes an EntityId by accident, and getting the number back
    out is a named call.
  - A semantic type may refine another, widening implicitly and narrowing
    explicitly. A Weight is a ForceMagnitude; not every force is a weight.

Only something whose values would otherwise be interchangeable may be
shimmed. An Object, Interface or Enum is already a distinct type -- naming one
again buys nothing and leaves it ambiguous which conversions apply. An Array,
Span, Handle, Result or Optional describes how a value is carried rather than
what it is, so shimming one crosses two unrelated axes. Both are refused, as
is a refinement cycle, which is represented as nothing.

Some of the six metadata properties belong to a type rather than to a use of
it: a Metres is metres everywhere it appears, so declaring the unit once is
what stops every member restating it. A range is more often contextual and
usually stays on the member. Rather than a second copy of the rules for each,
ISchemaMetadataCarrier is the shape validation reads, so a unit on something
that measures nothing is refused identically wherever it was declared.

formatVersion stays at 3, which this branch introduced and has not shipped.

357 tests pass in Schema.Test (346 before, 11 new) and 201 in
SchemaEditor.Test, with 0 warnings across the solution. The SonarCloud
analyzers were run locally against every changed file: 0 findings on any line
this change adds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk
@matt-edmondson matt-edmondson changed the title A schema can declare behaviour, not just data A schema can declare behaviour and semantic types, not just data Sep 10, 2026
SonarCloud reported fourteen new issues across the two test files this branch
adds: MSTEST0037 and MSTEST0068, the newer MSTest analyzer's preference for
assertions that name what they check. `Assert.Contains(predicate, issues)`
reports which collection did not contain a match; `Assert.IsTrue(issues.Any(
predicate))` reports that false was not true. Likewise `Assert.HasCount` and
`Assert.AreSequenceEqual`.

My local verification missed all fourteen. The filter was `(warning|error)
S[0-9]+`, which matches SonarAnalyzer's own rules and nothing else -- and these
arrive as `external_roslyn:MSTEST*`. So the claim in the previous two commits
that there were no findings on any added line was true only of S-rules, and
overstated as written.

The filter is not the whole of it: these rules are not reported by a local
build at any severity in this repository, so no local run would have shown
them. Querying the analysis on the pull request is the check that works, which
is what CLAUDE.md already says.

This leaves the two new files using assertions the rest of the suite does not.
That inconsistency is real, and the alternative -- knowingly adding fourteen
more findings of a kind already open twenty times over -- is worse. Converting
the existing suite remains a separate change.

357 tests still pass.

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 c19fdea into main Sep 10, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/peaceful-mayer-oo6l2u branch September 10, 2026 08:29
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