# Changelog

## 1.2.3 - 2026-09-29

### Changed

- Documentation only: the README's Documentation list links each page on the
  documentation site, so readers on GitHub and PyPI land there. The code is the same as 1.2.2.

## 1.2.2 - 2026-09-29

### Changed

- Documentation only: the README becomes a short entrance to the documentation
  site at https://offerrall.github.io/pytypehint/, and `docs/overview.md` holds
  the introduction.
- Rename `docs/restrictions.md` to `docs/limits.md` ("Limits") and
  `docs/philosophy.md` to `docs/design.md`. Merge
  `docs/comparison.md` into Design and remove `docs/guarantees.md`, whose points
  each live on their own page.
- Add `[project.urls]` (documentation, repository, changelog) and Python
  classifiers to the package metadata.
- Fix the 1.1.0 release date in this changelog and date every entry.

The code is the same as 1.2.1.

## 1.2.1 - 2026-09-29

- Documentation only: the README title no longer carries the version, and the
  1.2.0 entry below has its release date. The code is the same as 1.2.0.

## 1.2.0 - 2026-09-20

- Add `@immutable`: deeply immutable, slotted, keyword-only dataclasses with
  automatic validation on construction and `dataclasses.replace`.
- Restrict fields to exact immutable scalars, tuples and other `@immutable`
  models. Reject mutable containers, ordinary dataclasses and enums.
- Reuse validated child instances without walking their fields again. Check new
  tuples and their constraints; track successful instances with weak references.
- Preserve normal default-factory execution; support decorated inheritance,
  recursive model definitions, copying and validated pickle reconstruction.
- Keep the implementation in `immutable.py`; existing schema operations retain
  their behavior. Add examples and the immutable model contract.

## 1.1.0 - 2026-09-20

- Add fixed tuples (`tuple[X, Y]`), variadic tuples (`tuple[X, ...]`) and empty
  tuples (`tuple[()]`), including nested types, unions and per-position atoms.
- Export the `Tuple` shape. Exact tuple validation, length constraints, indexed
  errors, fresh default contents and recursive dataclass construction use the
  same rules as the existing collection types.
- Restore portable arrays as tuples through `decode`; `resolve` and `build`
  retain exact Python types. Ambiguous unions require an explicit discriminator.
- Describe tuple schemas and defaults in `to_dict()`. The portable format remains
  `v: 1`; consumers need tuple support to read the new shape type.
- Shorten the README and move detailed guarantees and API reference into docs.

## 1.0.0 - 2026-08-10

The core now owns the portable form of its own types. Until this release a
compiled schema could only be read as Python objects, in the process that
compiled it; every consumer that needed it elsewhere — a browser, a stored row,
another language — invented its own way to write the contract down and to read
values back, and the dialects diverged in ways nothing detected. Two operations
close that boundary, and the public surface grows by no new names: both are
methods on the schema that was already there.

Owning that boundary forced a second question, and answering it is the other half
of this release: what may the core check at all? The rule it now holds to is that
the core verifies everything the schema and the value can answer between them, and
nothing that requires asking the world — not the filesystem, not the network, not
the environment, not the working directory, not the clock. One atom broke that
rule, and a contract meant to be compared byte for byte cannot afford an answer
that changes with what happens to be on disk. So the rule comes first, and the
atom follows it.

What breaks is listed first, then `decode`, then the document `to_dict` writes,
then what validation and its errors guarantee.


- Breaking: `IsPathFile` is now `FileHint`, and the core no longer touches the
  filesystem. The atom keeps its three fields — `extensions`, `min_size`,
  `max_size` — and every cross-check between them; what it loses is the
  verification. `Str._check` validates the extension, which is spelled in the
  value and settled by the text alone, and states the sizes without testing them.
  The field on the shape is `Str.file_hint` and the document key is `"file_hint"`,
  with the contents unchanged. Gone with the `stat()` call are the errors
  `file does not exist`, `not a file`, `file too small`, `file too large` and
  `cannot inspect file`; `not an accepted file type` is the one that remains.

  This was the last place the core asked the world anything, and the rule it
  broke is now stated plainly: the core verifies everything the schema can
  answer, and nothing that requires asking outside the process. A fact about a
  file is only true where and when it is read. Checking existence at compilation
  proves nothing about the moment the value is used — the file can go in between,
  and a caller who saw the schema accept it has been given a promise the core
  cannot keep. The verification is real only at the boundary that has the file in
  hand, which is the upload, the command-line argument, the request — and that
  boundary is the wrapper, which is why the sizes travel in the document instead
  of being consumed here.

  It also cost the portable contract its central property. Which option a default
  inhabits was decided by asking the options what they accept, so with a
  world-reading atom in the slot the same definition wrote one document while a
  file existed and another after it was deleted, resolved a relative path against
  whatever directory the process happened to stand in, and could fail the emitter
  outright on a schema the core had accepted. The emitter carried a workaround for
  this — it stripped the atom before routing — and the workaround did not descend
  into a nested dataclass, so the leak stayed open at depth. Both the atom's check
  and the workaround are gone. `to_dict()` is now deterministic with no asterisk
  attached, and `README.md` can say the core knows nothing about filesystems and
  mean it literally.

  What is lost, stated plainly: a default naming a file that does not exist no
  longer fails at import. If an application wants that check, it is three lines at
  startup, over the defaults it already has, in the process that knows which
  directory they are relative to — which is where the answer was ever true. There
  is no deprecated alias for `IsPathFile`: this lands before 1.0.0 and the surface
  goes out clean.
- Breaking: two options of one field may no longer share an identity across shape
  kinds. An enum class named `str` beside a `str`, one named `date` beside a
  `date`, or one named `list[str]` beside a `list[str]` now fail at compilation
  with `Field 'x': duplicate discriminator name(s): str`. Each such pair left two
  options answering to one `$type`, with one of them unreachable — the rule
  [limits.md](https://offerrall.github.io/doc/pytypehint/limits.md) already stated, now enforced where the
  implementation had only covered options sharing a runtime type. A dataclass and
  an enum of the same class name use different discriminators and remain
  admissible.

  `List` applies the same rule to its own items. It already refused two options
  of one runtime type, but not two of one identity, so a list whose elements
  answered to a single `$type` could be built directly and was caught only when a
  field was built around it. `List` is public API and its items are what its
  elements name themselves by, so the rule now runs where the two identities sit;
  the refusal names the list, `List.item: duplicate discriminator name(s): Same`.

  A refusal in one namespace no longer reads as denying an identity the other one
  publishes. A portable wrapper whose payload never reached the option it named
  arrives at validation still a dict, and in a slot with two or more dataclasses
  it was answered with the dataclass names alone — `not a choice: 'date',
  expected one of ('SA', 'SB')` — while that same `'date'` is accepted one call
  earlier when its payload parses. The refusal now carries a note saying where the
  name does belong and why the wrapper is still there.

  An option's identity is the core's to define, so the core is where it is
  checked. `pytypehintstore` had built its own guard for exactly this gap and
  refused such a schema when opening a store; that guard is now redundant, and
  the tests asserting the old division of labour describe a split that no longer
  exists. Moving the check to the layer that owns the identity is the point of
  the change, not a side effect of it.
- Breaking: `Step` requires a finite number. `nan` passed the `value <= 0` guard
  by being neither positive nor negative, and `inf` is positive without being a
  step; both reached the portable document as `NaN`/`Infinity`, which no JSON
  reader accepts, and `nan` also made a shape compare unequal to an identically
  written one, which is the property a document used as a fingerprint or a cache
  key rests on. `Step(float("nan"))` now fails with `Step.value must be finite,
  got nan`.
- Breaking: `Float` validates its `step` the way `Int` already validated its own:
  the value is a number the shape can hold, and it is finite. With `Step` itself
  refusing `nan` and `inf`, what this catches is an integer step outside the float
  range — `Float(step=Step(10**400))` fails with `Float.step: must be finite, got
  1000…` rather than compiling. A step is written into the document beside the
  bounds, and one that names no float would be read there as a bound-like number
  no float reader can use.
- Breaking: a `Float` bound written as an integer outside the float range is an
  atom error, `Float.min: must be finite, got ...`, rather than the raw
  `OverflowError` that `math.isfinite` raised converting it. `OverflowError` is
  neither `TypeError` nor `ValueError`, so it escaped every `except` the
  documented error vocabulary names. Such an integer names no float, so on a float
  shape it is not a finite one, and the question is asked in one place.
- `Struct.decode(data)` and `Signature.decode(kwargs)` take a portable tree —
  `dict`, `list`, `str`, `int`, `float`, `bool`, `None` — and return an exact
  Python one. Four things a portable tree cannot carry are restored and nothing
  else is: a `date` and a `time` spelled as text, an enum member spelled as its
  member name, and a whole `float` that arrived as `3` rather than `3.0`. See
  [decode.md](https://offerrall.github.io/doc/pytypehint/decode.md).
- **`decode` is not coercion, and the distinction is load-bearing.** `"3"` never
  becomes `3`, `"true"` never becomes `True`, `""` never becomes `None`, a tuple
  never becomes a list, and a subclass never becomes its base. A `str` field
  holding `"2026-08-08"` stays a `str`. The shape decides the reading; the text
  of a value never takes part in it. Reading `"12"` as a number is interface
  policy and stays in the wrapper that knows what interface wrote it.
- `decode` never raises a schema error. Where it cannot restore a value
  unambiguously it returns it untouched, and `resolve`/`build` report it with the
  coordinates and wording they already had. One failure, reported once.
- `decode` returns a freshly built tree and never modifies the one it is given:
  every `dict` and every `list` in the result is newly built, at any depth. The
  promise is about `dict` and `list` exactly, which is all a portable tree has;
  everything else is handed along as the same object, a `dict` or `list` subclass
  included — the `OrderedDict` a `json.loads` hook produces comes back untouched,
  because rebuilding one as its base is the coercion the rule above forbids, and
  `resolve` is right to reject it. So a container reachable only through
  something a portable tree cannot carry — behind a tuple, say — is the input's
  own object, and writing into the decoded tree there writes into the input. It
  fills no defaults, runs no recipes, drops no unknown keys, and leaves absent
  keys absent — those remain `resolve`'s work. Aliasing is not preserved.
- Union routing in `decode` follows the same rule as everywhere else: the schema
  decides, and where more than one option could read a spelling, the caller names
  the option with the `$type`/`$value` grammar the core already defines. There
  are now two wrappers spelled that way, and they are documented apart. The
  **validation wrapper** covers options sharing a Python runtime type
  (`list[str] | list[int]`); `build` reads it, so `decode` keeps it and decodes
  only its payload. The **portable wrapper** covers options sharing only a
  spelling (`str | date`, `int | float`, `date | time`, two enums); `build` does
  not know it and would report `expected str | date, got dict`, so `decode`
  consumes it and hands on the exact value. Which one a slot takes is readable
  from the schema alone: options sharing a Python type are always two or more
  lists, because any other such pair is already rejected as duplicate options.
- A wrapper whose payload did not reach the option it named is left intact rather
  than consumed, so a date that failed to parse can never settle silently as the
  `str` beside it.
- `decode` is total on portable trees: it returns a value for every input and
  raises nothing but `RecursionError`, which cyclic or very deep data reaches the
  same way it does in `resolve` and `build`. An integer that no `float` equals is
  handed back rather than restored, since it is not a float written without its
  fraction, and no `OverflowError` escapes. The criterion is exactness, not
  magnitude: an integer outside the float range does not convert at all, and one
  inside it converts to a neighbour once past `2**53`, where the floats thin out
  — `float(2**53 + 1)` answers `9007199254740992.0` instead of failing.
  Restoring that neighbour would hand `build` a number the transport never
  carried, and `build` would accept it. Sixty-four-bit ids and nanosecond
  timestamps land in exactly that band.
- The spellings `decode` accepts for `date` and `time` are fixed and disjoint,
  not delegated to `fromisoformat`. That function accepts far more than a
  canonical form and its two grammars overlap: `"20200101"` reads as a date *and*
  as a time, and `"2020"` reads as `20:20`. Accepting them would let the text of a
  value select an option. `Date` takes `YYYY-MM-DD`; `Time` takes `HH:MM` or
  `HH:MM:SS`. Both are ASCII: `\d` would otherwise admit every decimal digit
  Unicode defines, leaving the spelling canonical only as far as the parser
  behind it happened to agree.
  A sub-second or offset-bearing time is still read, so `Time` reports
  its own rule rather than having the value fall through as "not a time at all".
- Enum members travel by member name, never by member value. A name is always a
  string, always identifies one member, and is what the contract publishes; a
  value may be a tuple or an object that no portable tree can carry, and the two
  readings genuinely differ — for `RED = "BLUE"; BLUE = "RED"`, `"RED"` is one
  member by name and the other by value. An alias resolves to the member it
  aliases, and the decoded member is the singleton itself, so `is` holds.
- `Struct.to_dict()` and `Signature.to_dict()` write the compiled schema as a
  portable tree: version, kind, fields or params, options, limits, notation,
  extras, defaults, enum members and nested dataclasses. It is the contract as
  data and takes no position on presentation — no widget, no input type, no
  message strings, no HTML. See [contract.md](https://offerrall.github.io/doc/pytypehint/contract.md).
- The format carries `{"v": 1, ...}`. `v` rises only when a key already in it
  changes meaning or leaves; new keys do not raise it, so a reader must ignore the
  ones it does not know. It is the version of the format, never of the library.
- `to_dict()` is deterministic: equal definitions produce equal documents byte for
  byte, across processes and hash seeds, with no `sort_keys` needed. Every key is
  written in a fixed order, every sequence keeps the order the author wrote, and
  nothing derived from `id()` or from set iteration reaches the output. That is
  what makes a document usable as a fingerprint, a cache key, or one side of a
  diff.
- Structs and enums are always written as references into a definitions table,
  never inlined. Recursion terminates, a shared dataclass is described once, and a
  densely shared graph of twenty structs stays twenty-one definitions instead of
  two million nodes. Ids are class names, made unique within a document by first
  encounter (`Target`, `Target#2`), because two classes of one name can legally
  coexist in a schema and no name distinguishes them. The `name` is the contract —
  it is what travels as `$type`; the id only follows a `ref`.
  Determinism is bounded by the interpreter, not by the emitter: `python -OO`
  discards docstrings, so a signature's `doc` key disappears under that flag.
  An `id` is unique within its discriminator's namespace, which is what a sender
  needs, but not across the two — a dataclass and an enum of one class name are
  admissible together and both write that name, so options are indexed by
  position.
- `to_dict()` returns a fresh tree on every call and caches nothing, so the caller
  may modify the result at any depth without affecting the schema or a later call.
  No implementation reaches it: not the compiled `re.Pattern` behind a pattern,
  not the recipe behind a default, not a class object, not an enum member, not
  `MISSING`, and no `repr()` of anything. It never raises on a schema the core
  accepted.
- `to_dict()` asks the filesystem nothing, and nothing else outside the process
  either. Which option a value inhabits is decided by the core's own router, so it
  cannot drift from the option validation would select, and the router is asked
  the shapes themselves with nothing removed or held back. That is possible
  because no check in the core reads the world at all — see the `FileHint` entry
  above, which is where the one exception went. Determinism across processes and
  the totality of the encoding therefore hold with no exception beyond the
  `python -OO` caveat above. What remains is a slot whose value inhabits no option
  at all, reachable only through a schema assembled by hand and left
  half-compiled, and it reports `SchemaValueError` like every other schema error.
- A `Float` bound written as an integer keeps that integer whenever no `float`
  equals it, rather than publishing the nearest one. The emitter answers to the
  same exactness `decode` reads by: `Min(2**53 + 1)` written as
  `9007199254740992.0` states a bound the schema does not hold and invites a value
  the core then rejects as `too small`. A whole float still loses its fraction on
  the way out, which is the loss `decode` exists to undo.
- A bound of zero is published without its sign. `Min(0)`, `Min(0.0)` and
  `Min(-0.0)` are one atom — equal, and equal in hash — so the document they write
  has to be one document, and `-0.0` beside `0.0` made it two, which is exactly
  what a document compared byte for byte cannot afford. The normalization belongs
  to the numbers an atom carries and stops there: a default of `-0.0` keeps its
  sign, because a field holding it is not equal to one holding `0.0`, so there the
  sign is information the author wrote rather than an accident of spelling.
- A default is written in the same portable language `decode` reads, so a default
  taken from the document is valid input to the pipeline —
  `build(decode(written))` returns it — with no translation. Every certified
  default can be written this way: compilation already proved its type comes from
  the closed vocabulary, so the encoding is total and nothing is omitted, marked
  or failed on.
- The rule that names an option is about a **slot**, not about a field, and
  applies at every depth: a list element and a field of a nested dataclass name
  their option exactly as the outer field does. Writing the outer slot alone was
  the shape of one defect found while preparing this release —
  `list[str | date] = [date(...)]` was written as `["2026-08-08"]`, which `decode`
  is right to leave as text and `build` then filed under the `str` option without
  complaining. Silent, and reachable from ordinary annotations, which is the
  failure this rule is stated at the level of a slot to prevent.
- `resolve` and `build` run no foreign code while routing. A dict carrying a key
  that is not a `str` is reported as `expected string keys, got int` before the
  discriminated wrapper is looked for, because asking `"$value" in value` calls
  that key's `__eq__` on a hash collision with a reserved name — arbitrary code,
  which may raise anything, on a path that owes its caller a schema error and
  nothing else. `decode` already declined to ask the question; the validator
  declines it too.

  Every dict is typed before anything asks it a question, not only one whose slot
  has a wrapper to look for. The branch that routes between two dataclass options
  probes the same reserved key to read an inline `"$type"`, and a field of two
  dataclasses has nothing wrappable in it — so that branch used to run exactly
  the `__eq__` the guard was written to prevent, and a raising key escaped as
  whatever it felt like raising.
- The per-candidate notes on `matches no option` — one per option, recording why
  that option rejected the value — survive being carried out to their coordinates
  and the certification of a default. Reporting one level out rebuilds the error
  so the `path` can grow, and the notes are copied onto the rebuilt one, since
  dropping them there would drop exactly the detail the error was raised to
  carry. Every re-raise does this, not just the one that reports a violation one
  level out: a default that fails certification, a factory that could not be run
  at all, and a foreign `TypeError`/`ValueError` from a user's own
  `__post_init__` all keep the notes they arrived with.
- A schema error survives being rendered, whatever the size of the number in it.
  CPython refuses to render an `int` of more than `sys.get_int_max_str_digits()`
  digits — 4300 by default — so interpolating one into a message would raise
  `ValueError` on its own account, leaving "Exceeds the limit (4300 digits) for
  integer string conversion" as the reported cause and taking the real violation
  with it, `leaf` and `path` included. Above that limit the magnitude is named
  instead of shown: `too short: 1 chars, minimum <int of 16610 bits>`.

  This holds for every message the core writes, not only the numeric ones. A
  bound on a length is an ordinary integer written by the author and can be any
  size at all, so `Str` and `List` render theirs the same way — the length
  violations, the empty range, the choices certified against the bounds — and so
  do `Rows`, `MultipleOf` and the byte sizes of `FileHint`. The per-option notes
  on `matches no option` are built from the causes those checks raise, so they
  are safe once the causes are: a note explaining why an option declined can no
  longer be replaced by the digit limit that stopped it being spelled.
- The core still parses and emits no JSON text. `json.loads` and `json.dumps` are
  the caller's, and the boundary is trees, not bytes.
- Documentation: new [decode.md](https://offerrall.github.io/doc/pytypehint/decode.md) and [contract.md](https://offerrall.github.io/doc/pytypehint/contract.md);
  `philosophy.md` gains the distinction between decoding a representation and
  coercing a value, and states why a traversal that reports is not one of the
  interpretive helpers it refuses; `comparison.md` no longer says the core stops
  at construction; `resolve.md` states plainly that validation reaches every depth
  while filling does not; `restrictions.md` documents the discriminator-name rule
  and the union options a portable tree cannot spell bare.

## 0.0.7 - 2026-07-26

- Breaking: `IsPathFile` now guarantees that the string names a real file at the
  moment of validation, not merely that its text ends in an accepted suffix. A
  value such as `"missing.png"` previously passed on its extension alone and now
  fails with `file does not exist: 'missing.png'`. The mark validates, in this
  order: the value is exactly `str`, the ordinary `Str` limits on the text, the
  extension, `stat`, that the target is a regular file and not a directory, the
  size, and finally `Choices`. The extension is checked before the filesystem
  because it costs nothing and names the defect precisely.
- `IsPathFile` gains `min_size` and `max_size`, byte counts that are `int` or
  `None`, never negative, with `min_size` not exceeding `max_size`; `bool` is not
  accepted as an `int`. Violations report
  `IsPathFile.min_size must be int or None, got bool`,
  `IsPathFile.max_size must be >= 0, got -1` and
  `IsPathFile: min_size 100 exceeds max_size 50`. A file whose size falls outside
  the bounds fails with `file too small: 120 bytes, minimum 1024` or
  `file too large: 7000000 bytes, maximum 5242880`; an empty file is valid unless
  `min_size` is greater than zero.
- The public value remains exactly `str`. `pathlib.Path` is used only inside the
  validation, to inspect the file: nothing is coerced to `Path`, normalized,
  resolved, expanded or made absolute, so a relative path keeps its meaning
  relative to the working directory and the value stays as written.
- New failures: `file does not exist: <path>`, `not a file: <path>` for a
  directory or any non-regular target, the two size messages above, and
  `cannot inspect file <path>: <error type>: <error>` when the OS refuses the
  inspection. `FileNotFoundError` is distinguished from every other `OSError`, and
  each failure keeps its cause through `raise ... from` and its coordinate in
  `path`/`leaf` — `document: file does not exist: 'missing.pdf'`,
  `files: [1]: file too large: 7000000 bytes, maximum 5242880`.
- `Path.stat()` follows symlinks: a live link to a regular file is accepted and a
  broken one fails as non-existent. The guarantee is bounded in time — the file
  existed and met the contract when it was validated, and nothing promises it
  still does afterwards.
- Breaking: `Choices` combined with `IsPathFile` are certified against the whole
  file contract when the schema compiles, where before only their extension was
  checked. `Str.choices: file does not exist: 'default.png'` now fails
  compilation. Defaults are certified the same way, so a `signature_of` over
  `image: Annotated[str, IsPathFile()] = "default.png"` fails unless that file
  exists, is a regular file and meets the extension and size bounds.
- One internal function validates the whole contract, so `_check`, `Choices` and
  default certification cannot drift apart. `IsPathFile` remains metadata
  exclusive to `Str`: no new shape, no `PathFile` type, and no compatibility
  switch (`exists=False`, `strict=False`) — the semantics are single and explicit.

## 0.0.6 - 2026-07-24

- Breaking: `datetime.time` values with non-zero microseconds are no longer
  accepted. Time precision is limited to whole seconds, so the effective range
  is `00:00:00..23:59:59`. A value previously admissible such as
  `time(12, 30, 0, 500000)` now fails with `time precision is limited to whole
  seconds`. The rule is enforced at every entry point of the core: `Min`/`Max`
  bounds and `Choices` members at compile time, and a value wherever it is
  validated — direct check, default certification, `resolve`, `build`, and
  inside nested dataclasses, unions and lists — through the single `Time._check`.
  The failure carries its coordinate as `path`, like every other constraint.
- Following from the tighter range, `Time`'s exclusive-edge guard moves in from
  the sub-second clock edge to the whole-second one: an exclusive `Min` at
  `23:59:59` (was `time.max`) and an exclusive `Max` at `00:00:00` now report
  `exclusive bound at ... leaves no valid time`. A bound at `time.max` is instead
  rejected as sub-second precision.

## 0.0.5 - 2026-07-23

- Compilation now rejects a union of two enums that share a class name, e.g.
  two Enum classes both named `Color`, with `Field '<name>': duplicate
  discriminator name(s): Color` — the same message and recursion (into `list`
  items) that already guarded homonym dataclasses. Both options collapse to one
  `option_id()`, the public identity wrappers read to name an option, and one
  identity for two options is a defective schema. The core still routes each by
  its exact member type; the rule is about identity, not routing, so an enum and
  a dataclass of the same name never collide and stay admissible. Previously the
  core admitted the pair and only a wrapper could catch it.

## 0.0.4 - 2026-07-22

- Enum fields now accept `Extra`, the same namespaced wrapper-notation channel
  the other leaf shapes already carry. `EnumShape` gains `_extras` and a
  read-only `extras` dict; any other atom on an enum still reports `unsupported
  metadata for enum`. Dataclass (`Struct`) fields stay closed — annotate their
  fields, not the nesting.
- `Time` now rejects an exclusive bound at the clock's edge at compile time —
  `Min(time.max, exclusive=True)` and `Max(time.min, exclusive=True)` — with
  `exclusive bound at ... leaves no valid time`, symmetric with the `Date` edge.
  These bounds previously compiled while admitting no value. `Float`'s analogous
  edge is left under the "no general satisfiability" doctrine.
- `check_options_value` now attaches a PEP 678 note per candidate to a `matches
  no option` error, recording why each option rejected the value (`as <id>:
  <cause>`). The main message, `leaf` and `path` are unchanged; the notes survive
  pickle.
- Breaking (messages only): compile-time certification of an invalid default now
  reports the failure as structured data — `path` carries the field name,
  `"default"`, and any sub-path as clean coordinates, with the violation as the
  `leaf`. The rendered line reads `x: default: <leaf>`, **identical** to the
  runtime serving path (`_resolve_fields`): the same defect now reads the same
  way whether certification or serving catches it. Previously certification
  degraded the whole line into the leaf and rendered `Field 'x': default <leaf>`.
  Only the message and its structure changed; no behaviour did.

## 0.0.3 - 2026-07-20

- Unions whose options share one runtime input type now compile. `list[str] |
  list[int]` was rejected as a duplicate; both options are valid Python and
  describe different things, so the core admits them.
- Such a value selects its option through a discriminated wrapper:
  `{"$type": "list[str]", "$value": ["a", "b"]}`. `$type` is the option
  identity — `list[str]`, `list[int]`, `list[list[str]]` — and `$value` is the
  payload. The wrapper accepts no other key.
- The wrapper is required only where routing by exact runtime type is
  ambiguous. `int | str`, `list[str | int]`, `list[int] | None` and every other
  hint that already routed itself are unchanged and take no discriminator; an
  option that is alone in its runtime type does not accept one either.
- Dataclass unions keep the inline `$type` of 0.0.2 unchanged, at every depth
  and inside list items.
- Compilation still rejects options that stay indistinguishable with a
  discriminator — `list[Annotated[int, Min(0)]] | list[Annotated[int, Max(9)]]`
  share both a runtime type and an identity.
- `Shape.option_id()` returns that identity, for wrappers that need to offer the
  choice.
- No breaking change: every hint accepted by 0.0.2 compiles and behaves as
  before.

## 0.0.2 - 2026-07-17

- `Extra(value)` becomes `Extra(key, value)`, with a namespaced key
  (`"package.name"`) and any string value, empty included.
- Shapes replace `extra` with `extras`, a read-only `dict[str, str]` merged from
  every `Extra` atom on the hint. Keys layer independently: the outer atom wins.

## 0.0.1 - 2026-07-15

- Initial release.
