Restrictions

August 10, 2026 · View on GitHub

Each restriction keeps the schema exact, inspectable or constructible.

Input dataclass instance

page: expected dict, got Page instance

Input is data until build crosses the construction boundary. Defaults may be instances because they are authored recipes, not external input.

Unsupported type

unsupported type: <class 'complex'>

The vocabulary is closed so wrappers can inspect every possible shape.

Bare list

list requires an item type: list[X]

Without an item hint the core cannot validate list contents.

Bare None

Field 'x': None must be accompanied by another option

None expresses optionality; it needs a real value option.

Option identity

A union routes an incoming value by its exact outer type. When two options share that type, the value alone cannot select one, and the caller names it. The name is the option's identity: a stable, readable string the core derives from the compiled shape, never from repr().

ShapeIdentity
scalarits type name: int, float, str, bool, date, time
NoneShapeNone
enum, dataclassthe class name: Role, Fast
Listits items spelled out: list[str], list[int | None], list[list[str]]

Shape.option_id() returns it. Dataclass options use it as $type inline; everything else uses it inside the wrapper of build.md.

Duplicate discriminator name

Field 'x': duplicate discriminator name(s): Same
Field 'x': duplicate discriminator name(s): str
List.item: duplicate discriminator name(s): Same

There are two discriminators and therefore two namespaces. A dataclass names itself with an inline $type among the other dataclasses; everything else names itself inside the wrapper. Within one namespace an identity must belong to one option.

Where a discriminator is in play, the reason is immediate: it would have nothing left to name, and one of the two options would be unreachable. Where none is — int beside an enum class named int routes perfectly well by exact Python type — the identity is still the option's public name, the one that appears in Shape.option_id(), in the document to_dict writes, and in the report that lists what a field accepts. Two options answering to one name make all three ambiguous for every reader, so the rule holds either way and is checked once.

The first message is the familiar case: two different classes called Same as options of one field. The second is the same defect reached from further away — an identity is spelled from the compiled shape, so options of unrelated kinds can arrive at one. An enum class named str beside a str, one named date beside a date, one named list[str] beside a list[str]: each pair leaves two options answering to a single $type, and one of them would be unreachable.

The rule is about a namespace, not about a kind of option. A dataclass names itself among the dataclasses and everything else names itself in the wrapper, so a dataclass never competes for an identity with an option of another kind: an enum and a dataclass of one class name are admissible together, and so is a dataclass whose __name__ is "str" beside a str — both options publish "id": "str", and both branches route and build, because routing is by exact runtime type. A reader is expected to index the options of a slot by position and read an id only to fill a discriminator, which is what contract.md asks of it. Within one namespace nothing is relaxed: two options may not share an identity, and compilation rejects the pair.

The third message is the rule applied where the collision actually sits. A list's items are what its elements name themselves by, and List is public API that can be built directly, so a list refuses two items of one identity on its own rather than waiting for a field to be built around it. That is why list[Same | Same] is reported against List.item and not against the field carrying it: the shape holding the two identities is refused wherever it is built, and a field is only one of the places it can be built. Errors raised while a shape is being constructed name the shape, as they do everywhere else.

Options that share a runtime type and an identity are reported as duplicate option types instead, below — the same defect, named on its own terms.

Duplicate option types in a union

Field 'x': duplicate option types in shape
List.item has duplicate option types

Two options may share a runtime type — that is what the discriminator is for. They may not also share an identity, because then there is nothing left to name.

Literal counts as its base type, so Literal["a", "b"] | str collides: both compile to Str and both are identified as str. So does Annotated[int, Min(0)] | Annotated[int, Max(9)] — atoms narrow a type, they do not create one — and so does list[Annotated[int, Min(0)]] | list[Annotated[int, Max(9)]], one level down.

The core does not resolve such a pair by reading the value. Given [], list[int] | list[str] has no answer in the data, and both answers are wrong half the time; given [1, 2] the answer is only in the contents, and reading them would trade the exact error coordinates of build.md for a heuristic that is confident and sometimes wrong. So the caller supplies the answer, or there is no answer at all.

Ways out of a real collision. Mixed items that are genuinely one field become one option: list[int | str]. Alternatives that are genuinely exclusive become dataclasses and route by name with $type:

from dataclasses import dataclass

@dataclass
class Fast: budget: int

@dataclass
class Safe: budget: int

mode: Fast | Safe   # {"$type": "Fast", "budget": 1}

Inside a list, where the author sees hints rather than compiled shapes, the collision names both options and the way out:

list items: Literal['a', 'b'] and str both compile to str — merge them into one option, or give each variant a dataclass and route with $type

Variadic parameter

args: variadic parameters (*args/**kwargs) are not supported

The input contract is named keyword data. Variadics have no fixed field schema.

Positional-only parameter

x: positional-only parameters are not supported

Signature.build returns keyword arguments, so it cannot represent a positional-only call.

Missing hint

x: missing type hint

Every input field requires a schema.

Lambda

lambdas have no usable name; use a named function

Signature.name is inspectable wrapper metadata and must be meaningful.

Callable other than a plain function

expected a plain function, got <bound method Service.run of service> — bound methods, partials and callable objects are not supported: wrap the call in a plain function (def run(q: str): return service.search(q))

Bound methods, partials and callable objects hide binding state. Wrap them in a plain named function. All plain function kinds compile normally; execution policy belongs to the caller.

Dataclass InitVar or init=False

x: InitVar fields are not supported
x: init=False fields are not supported

A schema field must both enter the constructor and remain on the instance.

Field atoms inside list items

field atoms cannot apply to list items

List items have type constraints but no independent field presentation.

Un-namespaced Extra key

Extra.key must be namespaced ('package.name'), got 'color'
Extra.key must not be empty
Extra.key must be str, got int
Extra.value must be str, got int

An Extra key is the coordinate of a value the core stores and never reads, so provenance is the only thing it can enforce: the dot names the owning package, and several wrappers annotating one field stay out of each other's way. Beyond its type the value is unconstrained — an empty value is legal, and what it means belongs to the wrapper that wrote it.

Hand-built extras that are not a mapping

Int._extras must not repeat keys
Int._extras must be tuple, got dict
Int._extras: expected a (key, value) pair of str, got 'a.x'

Compilation merges extras by key, where a repeat is layering and the outer atom wins. A shape constructed directly has no layers to resolve: its pairs are a mapping, and a repeated key is a broken one. Storage is a sorted tuple, not a dict, because the shapes are frozen and hashable and their equality must not depend on the order the atoms were written in; extras rebuilds the dict on access.

datetime

unsupported type: <class 'datetime.datetime'>

date and naive time have separate shapes. A combined timestamp would need timezone and serialization policy that the core does not define.

Flag enum

EnumShape.cls: Flag enums are not supported (OR-combinable, not a closed set)

Flags combine members with bitwise OR, so their runtime value set is not the closed choice set wrappers require.

Enum without members

EnumShape.cls: enum has no members

An empty enum provides no satisfiable value.

Aware time

value: must be naive (no tzinfo): 12:00:00+00:00

Comparing aware times requires date and timezone context. The Time shape is explicitly naive.

Sub-second time

value: time precision is limited to whole seconds: 12:30:00.500000
Time.min: time precision is limited to whole seconds, got 12:30:00.500000
Time.choices: time precision is limited to whole seconds, got 12:30:00.500000

Time admits a value only when its microsecond is zero. Sub-second precision carries no meaning in this domain, so a bound, a choice or a value that supplies one is rejected — a bound or choice at compile time, a value wherever it is validated (direct check, default certification, resolve, build, and inside nested dataclasses, unions and lists). The effective range is therefore 00:00:00..23:59:59.

Exclusive bound at a temporal edge

Time: exclusive bound at 23:59:59 leaves no valid time
Time: exclusive bound at 00:00:00 leaves no valid time

An exclusive Min at the top of the range, or an exclusive Max at the bottom, excludes the only value on that side of the bound and leaves the field unsatisfiable. For Date the edges are date.max/date.min; for Time, with its whole-second precision, they are 23:59:59/00:00:00. Both reject the case at compile time, symmetrically. The analogous Float edge (sys.float_info.max) is deliberately left to run: it falls under the "no general satisfiability" doctrine of philosophy.md, where the core proves only the contradictions it can determine exactly.

Non-finite float

value: not finite: nan

NaN and infinity do not obey ordinary finite range semantics.

A numeric atom that names no float

Step.value must be finite, got inf
Float.step: expected int or float, got str
Float.min: must be finite, got inf
Float.choices: must be finite, got inf

The same rule, one step earlier: a bound, a choice and a step are all written into the document as numbers a float reader will use, so none of them has a reading outside the finite floats. nan slips past a > 0 test by being neither positive nor negative, inf passes it while naming no step at all, and both would reach the portable tree as NaN/Infinity, which no JSON reader accepts — a nan also makes a shape compare unequal to an identically written one. Float therefore asks of its step exactly what Int asks of its own: a number, and a finite one.

An integer too large to convert (10**400) fails the same finiteness check, and for the same reason: it names no float, so it is not a float bound. The integers that do name a float, including the ones above 2**53 that name a neighbour rather than themselves, are accepted and written as they stand — see contract.md.

File name that does not meet a FileHint contract

not an accepted file type: 'image.gif', expected one of ('.png', '.jpg')

That is the whole list, and the reason it is one line long is the reason the atom exists. FileHint states a file's contract — its accepted suffixes and its size bounds — and the core checks the part of that contract the value can answer by itself. The extension is spelled in the string, so the core settles it; existence and size need a filesystem, a working directory and a moment in time, so the core states them in the document and the wrapper applies them where it holds the file. An answer obtained here would only have been true in this process at this instant, and would have made a compiled schema depend on what happened to be on disk; see atoms.md.

Validation runs in one order: the value is exactly str, the ordinary Str limits apply to the text, then the pattern, then the extension, and finally Choices. The extension comes after the cheaper text rules and before Choices because a value that is not even the right kind of file name should be named as that, not as a missing choice.

The validated value is the same str that arrived. Nothing is coerced to pathlib.Path, normalized, resolved, expanded or made absolute — the core never constructs a path from it at all — so a relative name keeps its meaning for whoever eventually opens it. Defaults and Choices are certified against the extension when the schema compiles, with the field's coordinates in front:

document: not an accepted file type: 'notes.txt', expected one of ('.pdf',)
files: [1]: not an accepted file type: 'image.gif', expected one of ('.png',)
Str.choices: not an accepted file type: 'default.txt', expected one of ('.png',)

Invalid default

n: default: expected int, got str
leaf: n: default: too large: 2, maximum 1

A default that violates its own schema is reported with "default" as a path segment, the field's coordinates in front, and the violation as the leaf. One format covers both moments the same defect can surface: certification at compile time (the first line, from struct_of/signature_of) and per-serving rematerialization at runtime (the second, from build/resolve). Purity and determinism are the author's promise — per-serving validation reports observable drift; other side effects cannot be diagnosed. See defaults.md.

Unhinted self or cls

self: looks like an unbound method — pytypehint takes plain functions; wrap the call (def run(q: str): return service.search(q))

An unhinted leading receiver indicates an unbound method, not a standalone input parameter. Wrap the bound operation in a plain function.

Cyclic input data

RecursionError: maximum recursion depth exceeded

The error propagates raw, from decode as from resolve and build. Tracking visited containers on every call would charge all real inputs for a cycle that ordinary serialized data cannot contain — a portable tree parsed from a document never holds one, and a hand-built dictionary that does is a program error rather than an input error. Very deeply nested data reaches the interpreter's recursion limit the same way, and the core does not impose a shallower limit of its own.

A union option unreachable from a portable tree

Not an error, and worth knowing. resolve and build route by exact Python type, so str | date is unambiguous to them. A portable tree spells both as text, so a bare string can only be read as the str, and the date needs the wrapper of decode.md to be named:

{"when": {"$type": "date", "$value": "2026-08-08"}}

The same holds for int | float on a whole number, and for any union mixing str, date, time and enums. Where no option can take the bare spelling — date | time, two enums — the bare value fails loudly instead, and the wrapper is the only form that works.

If the alternatives are genuinely exclusive, the standard way out applies here too: give each one a dataclass and route with $type.