Packages

Under heavy development: not for production use before 1.0.0. DXN (Data eXchange Notation) for Elixir -- .dxn text, .dxnb binary, and .dxns schema documents, built on the Ichor grammar compiler.

Current section

Files

Jump to
dextrin CHANGELOG.md
Raw

CHANGELOG.md

# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.3.2] - 2026-10-23

A duration's zero fields are left out again, a `key/not-allowed`
error names its key in DXN, and the README says how fast Dextrin is.
Nothing breaks.

### Fixed

- **A `key/not-allowed` error names the key in DXN**, as Node and PHP
  do: `@datetime "2026-10-22T10:00:00Z" cannot be a map key`, no longer
  `~U[2026-10-22 10:00:00.000000Z] cannot be a map key`.
- **A duration's zero fields are left out again** when it is written, as
  DXN.md §1.5 says, and one that is zero throughout is `PT0S`:
  `@duration "P1DT0H"` is written `@duration "P1D"`, `@duration "P0D"`
  `@duration "PT0S"`. `.dxnb` sets no bit for a zero field either, so the
  two agree, and `P0D` and `PT0S` are one value to `unique:`. Reading
  still keeps what was written.

### Documentation

- **The README says how fast it is**: decoding and checking timed for 90 minutes in each implementation, on the same data sets; and the size table's one changed cell (`.dxnb`, the config file gzipped, 210 bytes).
- The README counts DXN's 31 types (it said 30), and DXN.md §4 names the old section right: "DXNS.md §2" is the old "DXN.md §4.2".
- **The docs build without a warning**: a stray code fence in
  `guides/EXAMPLES.md` left the rest of the page unfenced, and three
  moduledocs and two changelog entries named functions that no longer
  exist (`Dextrin.Schema.Reader.read/5` builds a decoded value now).

## [0.3.1] - 2026-10-21

DXNS guides of their own, decoding about four times faster on
ichor_runtime 0.6.1, and a schema-validation benchmark. Nothing breaks.

### Documentation

- **DXNS has guides of its own** (Jan, 2026-10-21), in `guides/dxns/`: a
  tutorial, the reference (`DXNS.md`, DXN.md's old §4, its sections
  renumbered 1 to 8 -- "DXNS.md §2" is the old "DXN.md §4.2"), examples
  and a cheatsheet. Every schema and every good and bad value in the
  tutorial, the examples and the cheatsheet is checked by the test suite.

### Added

- **`bench/schema_validation.exs`**: for ten minutes, times decoding
  stage by stage and the schema check, data set by data set, against a
  schema set more complex than any real one, `bench/schemas/universe.dxns`
  (Jan, 2026-10-21). `bench/generate_universes.exs` writes the same data
  sets for the Node and PHP benchmarks.

### Documentation

- **DXN.md's reference tables are Markdown tables**, no longer TOON
  (Jan, 2026-10-21); the `canonical` table said 32 rows and had 33.

### Changed

- **ichor_runtime 0.6.1** (ichor 0.7.1 for `mix ichor.gen`), the parser
  regenerated with it: lexing a 5 KB document takes 2.0 ms instead of
  12.4, decoding it 4.2 ms instead of 14.2 (Ichor's character classes
  were a linear scan; found by the schema-validation benchmark).

## [0.3.0] - 2026-10-21

One way to write a value, nanoseconds, `@objectid` and `@ip`, the
smallest `.dxnb`, and DXNS as a superset of the schema languages DXN is
compared with. Breaking: sigils are gone, `.dxnb` gains tags a 0.2
reader does not know, and a schema that misused a refine key no longer
compiles.

### Migrating from 0.2.x

- **Text: sigils are gone.** Rewrite `~D[2026-10-20]` as
  `@date "2026-10-20"`, `~T[14:30:00]` as `@time "14:30:00"`,
  `~U[2026-10-20 14:30:00Z]` as `@datetime "2026-10-20T14:30:00Z"`, a
  naive date-time as `@local-datetime "2026-10-20T14:30:00"`, and
  `~r/^a/i` as `@regex(i) "^a"`. A sigil is now `syntax`.
- **Binary: deploy readers first.** 0.3 writes times, datetimes, local
  date-times and durations in nanoseconds under tags 217-220,
  `@objectid` and `@ip` under 221, 52 and 54, and a float in half or
  single precision when that holds it exactly. A 0.2 reader refuses all
  of these; a 0.3 reader still reads everything 0.2 wrote.
- **`Dextrin.Duration`'s seconds are `nanoseconds`**, one field for the
  seconds and their fraction; a time or datetime finer than a
  microsecond decodes to a `Dextrin.Nanos`.
- **`:timestamp` is a datetime in UTC**, `{:refine :datetime %{offsets:
  ["Z"]}}`; a schema naming it keeps working.
- **Schemas are checked harder.** An unknown refine key, a refine
  argument of the wrong kind, `min-contains` without `contains`, and an
  unknown `format:` are compile errors; a constraint on a value it does
  not apply to refuses the value instead of raising; `{:enum 1}` no
  longer takes `1.0`.
- **A trusted decode keeps six keywords** -- `:nan`,
  `:positive_infinity`, `:negative_infinity`, `:nil`, `:true`, `:false`
  -- as `Dextrin.Keyword`, since their atoms are already values.
- **`@uuid` reads only 8-4-4-4-12 hex digits.**
- **ichor_runtime 0.6** is the runtime dependency.

### Changed

- **ichor_runtime 0.6** (ichor 0.7 for `mix ichor.gen`), the parser
  regenerated with it: a syntax error names the line and token where the
  parse broke and what was expected there, not the document's start;
  `mix dextrin.check` reports that line.
- **DXN.md says a pattern PCRE does not compile is `syntax`**, as every
  implementation with PCRE already refused it, and names Node's limit:
  without PCRE, it reads any pattern.
- **Every refine argument is checked when the schema compiles**: a
  count that is no non-negative integer, a flag that is no boolean,
  `multiple-of: 0`, `bits: 5`, a `pattern` that is no regex and the like
  are compile errors (`schema/compile`); some raised at check time
  before. A compile error writes the value it refuses in DXN, not as
  Elixir's `inspect` shows it.
- **A refine constraint refuses a value it does not apply to**: a length
  on an integer, a range on a string, `bits` on a negative integer.
  Before, several such checks raised; `finite: false` and
  `unique: false` took any value at all. A constraint key that is no
  name is a compile error, no longer a crash.
- **An unknown refine key is a compile error** (`schema/compile`), as
  are `min-contains:` and `max-contains:` without `contains:`. A misspelt
  key (`min-lenght:`) refused every value before, silently.
- **`.dxnb` writes the smallest form of a float, a UTC datetime and a
  regex without flags** (Jan, 2026-10-20; DXN.md §2.2): a float in the
  shortest of CBOR's half, single and double precision that holds it
  exactly (NaN and the infinities in half), a datetime in UTC as its bare
  nanoseconds, a regex without flags as its bare pattern. A reader reads
  every form, half-precision floats included, which it refused before.
- **One way to write a value: every sigil is gone** (Jan, 2026-10-20;
  DXN.md §1.2). Breaking. Dates and times are ISO 8601 strings under a
  tag -- `@date "2026-10-20"`, `@time "14:30:00.5"`, `@datetime
  "2026-10-20T14:30:00Z"`, and the new `@local-datetime
  "2026-10-20T14:30:00"` -- and a regex is `@regex "pattern"`, its flags
  in a header, `@regex(im) "..."`, or inline, `@regex "(?im)..."`.
  `~D[...]`, `~T[...]`, `~U[...]` and `~r/.../` are refused.
- **`timestamp` and `datetime` are one type**, `datetime`: an instant
  with its offset, `Z` for UTC. Breaking. `:timestamp` in a schema is
  sugar for `{:refine :datetime %{offsets: ["Z"]}}`, so a schema naming it
  keeps its meaning.
- **Times, datetimes, local date-times and durations carry nanoseconds**
  (DXN.md §2.2). A value finer than a microsecond decodes to
  `Dextrin.Nanos`; `Dextrin.Duration`'s seventh field is `nanoseconds`
  (was `microseconds`). In `.dxnb` the nanosecond forms have new tags
  (218 time, 219 datetime, 220 duration, 217 local date-time); 0.2's
  microsecond tags (1, 210, 211, 212) are still read, as what they
  meant, and never written, so a tag never changes its meaning.
- **Refine keys `finite:` and `offsets:`** (DXNS.md §2), and **NaN lies
  outside every range**: `min`, `max` and both exclusive bounds refuse it.
- **The README's size table is measured again**: the pretty layout and
  the datetime spelling both changed it.
- **A named type sees the named types beside it** (DXNS.md §5): a
  reference to a sibling in the same document resolves, in any order,
  where it used to fall back to a struct reference; a named type that
  reaches itself is refused (`schema/compile`).
- **HexDocs shows the README's development warning as a warning box**
  (Jan, 2026-10-20): ExDoc renders no GitHub alert, so `mix docs` gives
  it the README with `> [!WARNING]` turned into ExDoc's own admonition,
  written to `doc_src/`; the README itself stays as GitHub shows it, and
  "View Source" still points at it.
- **The package description opens with the development warning**
  (Jan, 2026-10-20), "Under heavy development: not for production use
  before 1.0.0.", so the registry's search results show it too.
- **An array is written `@array[1 2]`**, its bracket against the tag
  (Jan, 2026-10-20): the preferred spelling, which the compact text
  form now writes as the pretty layout and the canonical form already
  did. `@array [1 2]` still reads, as the same array.
- **The README opens with a warning** (Jan, 2026-10-20): the library
  is under heavy development and not for production use before `1.0.0`;
  things can and will break, and the specification can and will change,
  between any two releases until then.
- **`{:one-of ...}` means exactly one variant matches** (Jan,
  2026-10-20; JSON Schema's `oneOf`), and **`{:any-of ...}`** is the new
  "one or more" (`anyOf`) -- what `one-of` meant before. Breaking: a
  schema whose `one-of` variants overlap now refuses a value two of them
  match; write `any-of` to keep the old meaning. `{:nilable t}` is sugar
  for `{:any-of :nil t}`.
- **DXN.md says `.dxnb` writes `-0.0M` as `0.0M`** (Jan, 2026-10-20):
  CBOR's decimal (tag 4) has no negative zero, so it is the one value
  the binary form does not keep; text keeps the sign (§1.5, §2.2).
- **The pretty layout keeps short collections on one line** (Jan,
  2026-10-20): the top-level collection still breaks, one entry per
  line, however short; a nested one is written on one line, its entries
  single-space separated, when that line -- indent and key included --
  fits the width (`width:`, 80). `encode/2` with `pretty: true`,
  `Dextrin.Document.format/2` and `mix dextrin.format` all lay out this
  way. A nested collection holding a comment, or a blank line between
  its entries, stays over lines.
- **Decoding is untrusted by default** (Jan, 2026-10-19, ASCO's
  proposal): `decode/2`, `decode_binary/2` and `Dextrin.Document` decode
  a keyword as `Dextrin.Keyword.t()`, so no input can exhaust the atom
  table; `trusted: true` (or `Dextrin.Registry.put_trusted/2`) is the
  opt-in for a source you control, as Jason's `keys: :atoms`. Breaking
  for callers that relied on atoms from the default. A `Dextrin.Document`
  path's atom names a keyword key either way, and `update/3` compares the
  caller's atoms with the document's keywords by name, answering edits in
  the caller's atoms where they exist (`String.to_existing_atom/1` --
  never a new atom).
- **A field typed with a namespaced reference takes a struct of that
  name** (DXNS.md §7): a field typed `geo/Address` accepts
  `%geo/Address{...}`, and refuses `%Address{...}`. It was the other way
  round, because a schema `FileResolver` found as `geo/Address` kept the
  name its file gave it, `Address`. Breaking for documents that wrote the
  bare name; write the name the reference uses.
- **DXN.md stays format version 1.0** (Jan, 2026-10-20): the version
  moves past 1.0 only once the libraries reach 1.0.0, so this release's
  changes to `.dxns` documents (the body lists the fields, `%schema(N)`
  carries a version) and `bits` keep `1.0`. A reader accepts any `1.x`
  header; the `.dxnb` version byte is `1`.
- **DXN.md gains `bits`**, a run of bits that is not whole bytes:
  `@bits {"oA==" 3}` in text, private tag 216 (`T_BITS`) around
  `[bytes, count]` in `.dxnb`, `:bits` in a schema. Bits and bytes are
  one value space; a whole number of bytes is bytes. The private tag
  range is 200-216 (§2.5 still said 200-214).
- DXN.md §5 lists the four errors a document's edits are refused with:
  `document/not-found`, `document/exists`, `document/ambiguous` and
  `document/not-a-collection`, which every implementation raised and the
  conformance suite expects.
- **A 0.1 schema says it is one**: `fields: @ordered %{...}`,
  `closed: true` and `forbidden: [...]` are refused (`schema/compile`)
  naming Dextrin 0.1's syntax and what a schema is written as now,
  rather than as a field of no known type.

### Added

- **The README compares DXNS with the other schema languages**, says
  why a DXNS type is never closed, and how to change a schema across
  services that deploy one at a time.
- **Sugar** (Jan, 2026-10-20; DXNS.md §1; the superset's group 9):
  `:int32`, `:int64`, `:uint32`, `:uint64` and `:float32`, `:number`,
  `{:const v}`, and `{:if I T E}` (JSON Schema's if/then/else).
- **Annotations, `{:doc T %{...}}`** (Jan, 2026-10-20; DXNS.md §1b;
  the superset's group 8): a `title`, `description`, `examples`,
  `read-only`, `write-only`, `deprecated` and free `meta` on any type,
  around a whole schema, named type or generic too, and `values:` for an
  enum's single values. They change nothing a type takes;
  `Dextrin.Schema.annotations/2` reads them and `Dextrin.Schema.Compiled`
  holds a schema's. `mix dextrin.docs` shows them: a title, the
  description, examples, meta and an enum's values beside the
  definition, deprecated, read-only and write-only as chips; the example
  tree carries a few.
- **`{:tagged name T}`** (Jan, 2026-10-20; DXNS.md §1; the superset's
  group 7): a custom tag of that name whose value is a `T` -- or an
  application's value its registered tag encoder writes as one.
- **Encoded content** (Jan, 2026-10-20; DXNS.md §2): `encoding:` (RFC
  4648's base64, base64url, base32, base16), `media-type:` and
  `content: T` on strings -- the string, decoded, is a DXN document of
  type T, `.dxnb` under `application/dxnb` -- and `encodes: T` and
  `encodes-seq: T` on bytes, CDDL's `.cbor` and `.cborseq`.
  `Dextrin.Binary.Decoder.decode_sequence/2` reads documents back to
  back.
- **`format:`** (Jan, 2026-10-20; DXNS.md §2a): a string in one of a
  closed set of 22 formats -- `date`, `time`, `datetime`,
  `local-datetime`, `duration`, `uuid`, `ipv4`, `ipv6`, `hostname`,
  `idn-hostname`, `email`, `idn-email`, `uri`, `uri-reference`, `iri`,
  `iri-reference`, `uri-template`, `json-pointer`,
  `relative-json-pointer`, `semver`, `mac`, `host-port` --
  `Dextrin.Schema.Format`. A format DXN has a tag for is read by that
  tag's reader; every other one by a grammar the spec gives.
- **Refine keys on scalars** (Jan, 2026-10-20; DXNS.md §2; the
  superset's group 5): `min-length`/`max-length` on keywords, symbols,
  bytes (in bytes) and bits (in bits, under `:bits`); `min-bytes`/
  `max-bytes` (UTF-8); `pattern` on keywords, symbols and bytes;
  `min-precision`/`max-precision` and `min-exponent`/`max-exponent` on
  decimals; `float32: true`; `bits: [0 1 4]` (RFC 8610's `.bits`);
  `min`/`max`/`exclusive-*` on dates, times, datetimes, local date-times
  and durations; `before-now`, `after-now`, `within: @duration "PT1H"`;
  `resolution: :millisecond`.
- **A list's items in order, and lists' refine keys** (Jan, 2026-10-20;
  DXNS.md §1a, §4.2; the superset's group 4). `{:list-items :string
  {:repeat :integer 0 nil}}` is a list item by item: a type for one item,
  `{:repeat p min max}` for several (`max` `nil` for no bound), and
  `{:repeat {:group a b} min max}` for several together; a repeat takes
  as many items as it can and gives them back one at a time.
  `{:tuple-of-all T}` is a tuple of any length. The refine keys
  `contains:`, `min-contains:`, `max-contains:`, `unique:` and
  `unique-by:` count and compare a collection's elements, two values the
  same when their canonical forms are; `min-count:`/`max-count:` count an
  array's too.
- **`@objectid` and `@ip` are built in** (Jan, 2026-10-20; DXN.md §1.3),
  like `@uuid`: readable in text, their raw bytes in `.dxnb`.
  `@objectid "507f1f77bcf86cd799439011"` is BSON's 12-byte ObjectId
  (`Dextrin.ObjectId`, tag 221), and may be a map key. `@ip "192.0.2.1"`,
  `"10.0.0.0/8"`, `"2001:db8::1"` and `"2001:db8::/32"` are an address or
  a prefix (`Dextrin.Ip`), written as RFC 9164 says, CBOR tags 52 and 54;
  DXNS adds `:objectid`, `:ip` and the refine keys `version:` and
  `prefix:`. **`.dxnb` uses the smallest representation that keeps a
  value**, now said in DXN.md §2.2.
- **`mix dextrin.check` and `Dextrin.Schema.Tree`** (Jan, 2026-10-20): a
  tree of `.dxns` files read as one -- every entry named from its path
  (DXNS.md §6, `Dextrin.Schema.Layout`), the files compiled in the order
  their references need -- with every problem reported as `file:line`:
  syntax, compiling, a versioned file holding another version, one
  version in two files, files needing each other, and a name resolving
  to nothing unless its namespace is `--extern`. `Dextrin.Schema.compile/3`
  takes the full names a tree gives a file's entries.
- **Generic named types** (DXNS.md §5, Jan, 2026-10-20):
  `Page: {:generic [t] %shape{items: {:list-of t}}}` declares a named
  type with parameters, and `{:of Page Order}` uses it. A generic may use
  itself, guarded; a `%schema` cannot be generic, since a struct has one
  name on the wire.
- **Recursive named types** (DXNS.md §5, Jan, 2026-10-20): a named type
  or shape may refer to itself, directly or through others, when the
  cycle passes a field or a collection's elements -- `Comment:
  %shape{replies?: {:list-of Comment}}`, a JSON-like value type. A cycle
  with no such guard, `A: {:nilable A}`, is a compile error.
- **`%shape{...}` and `{:case field %{...}}`** (DXNS.md §4a, Jan,
  2026-10-20; design/shapes-and-case.md): a shape is a named, unversioned
  plain-map type with a `%schema` body, which fills its missing fields'
  defaults when it decodes; a case chooses a field's type by its sibling
  field's value, anywhere in the field's type. **`Dextrin.Schema.check/3`**
  answers whether a value matches a schema, shape or named type without
  changing it, and **`conform/3`** answers it with the defaults filled.
- **The conformance adapter answers `media-type`, `identify` and
  `identify-binary`** (`DXN.md` §6): the form a media type names, and
  whether a body is worth decoding as text, a schema or binary, so the
  suite checks `Dextrin.Format` across implementations.
- **`mix dextrin.docs` and `Dextrin.Schema.Docs`** (Jan, 2026-10-20):
  HTML documentation for a directory of `.dxns` schemas: a sidebar tree
  of every namespace and definition at the left of every page; the index
  and every namespace's page in one design, a section per namespace
  below it listing its own namespaces and definitions, then its own
  definitions in full; the pages a directory tree, one `index.html` per
  namespace; every reference a link, every version shown, a field's
  position in the binary form; light or dark, the reader's choice kept
  in localStorage. Names come from the file paths (DXNS.md §6); a
  definition's documentation is the comment block directly above it and
  the comment on its line, in a small Markdown subset. `example/` holds
  a schema set using every kind of definition, and its generated `doc/`.
- **New type forms** (DXNS.md §1): `{:not t}`, `:never` (JSON
  Schema's `false`), and element types for the collections that had
  none: `{:array-of t}`, `{:ordered-map-of k v}`, `{:sorted-set-of t}`.
- **`Dextrin.Format`** (DXN.md §6, ASCO's proposal): `media_type/1`,
  `extension/1`, `form/1` -- the three media types, and any `+dxn`
  suffix as text -- and `identifies?/2`, a cheap prefix test of whether
  a body is worth decoding as a form: binary by the envelope's magic,
  text and schemas by a first significant `%` after whitespace, comments
  and a header. Lists and scalars stay unrecognised on purpose: they
  overlap with JSON (Jan, 2026-10-20).

- `Dextrin.Schema.compile_all/2`: several `.dxns` documents compiled into
  one registry, in order, over a base registry; the first that does not
  compile is the refusal, naming which (ASCO's proposal, part 3).
- **Dotted names** (DXN.md §1.1, Jan 2026-10-19): a name is segments
  joined by `.`, with at most one `/` between a namespace and a name --
  `%Foo.Bar/Baz{...}`, `acme.billing/invoice`, `:a.b`, `a.b: 1` -- for
  structs, symbols, keywords and map keys alike. A name with two `/`, or
  an empty segment, is refused. The parser is regenerated.
- DXNS.md §6 states the file convention a reader that resolves names to
  files follows -- `Foo.Bar/Baz(N)` in `Foo/Bar/Baz-vN.dxns`,
  `Foo/Bar/Baz/vN.dxns` or `Foo.Bar.Baz-vN.dxns` (`-v1` optional), or the
  entry `Baz` in `Foo.Bar.dxns` -- with a version found twice refused.
- **`Dextrin.Schema.FileResolver` follows DXNS.md §6**: the four
  layouts, the first search path that has the name deciding, a file's
  version its schema's, one version found once, and every version found
  loaded, so an older struct reaches the materializer. The old
  `Namespace.dxns` convention is one of the layouts and keeps working.
- A resolver may answer every version it finds (`{:ok, [compiled]}`) or
  why they cannot be used (`{:error, error}`), which a decode refuses
  with; looking up an exact version consults it too.

- **`bits`**, as DXN.md defines it: a bitstring that is not whole
  bytes -- bare, or in a `Dextrin.Bytes` -- is written `@bits {"oA==" 3}`
  and `T_BITS` (216), and reads back; `Dextrin.Bits` holds the rules.
  `:bits` in a schema takes bits and bytes, `:bytes` only whole bytes;
  bits are no map key.
- Takes `ichor_runtime` 0.5 and `ichor` 0.6 (was 0.4 and 0.5), for the
  action errors below; the DXN parser is regenerated with `ichor` 0.6.

### Fixed

- **A generic checked by its own name refuses the value**
  (`schema/type-mismatch`), where `Dextrin.Schema.check/3` raised: a
  generic takes no value until `{:of Name ...}` gives its arguments.

- **An enum's literal is the value itself**: `{:enum 1}` took `1.0`,
  which Elixir's `==` calls equal and DXN does not (§1.5).
- **`bits` is a built-in tag** for `{:tagged}`, which took it as custom.
- **The docs show an enum value whose annotations are no map**, where
  `mix dextrin.docs` raised.

- **A trusted decode keeps `:nan`, `:positive_infinity`,
  `:negative_infinity`, `:nil`, `:true` and `:false` keywords**
  (`Dextrin.Keyword.trusted/1`): their atoms are already NaN, the
  infinities, nil and the booleans, so a trusted round trip turned the
  keyword into that value.
- **NaN and the infinities are numbers in a schema**: `min: -Infinity`,
  `max: Infinity`, `{:enum NaN}` compiled them as keywords.
- **A final newline is no part of a format, a date or a time**: the
  grammars' `$` matched before one (PCRE's `D` flag now), and
  `@time`, `@datetime`, `@local-datetime` and `@duration` read one;
  `host-port` raised on `"a:80\n"`.
- **A hostname's 253 are codepoints**, not graphemes; an unknown type's
  compile error writes it in DXN.

- **A string's length counts codepoints**, as DXNS.md §2 says, not
  graphemes: `"e\u0301"` is two long, as in every other implementation.
- **A `.dxnb` document read inside another keeps the outer one's state**:
  the shared-item and stringref tables are put back after it.

- **`@uuid` reads only 8-4-4-4-12 hex digits.** It took 32 bare hex
  digits, or hyphens anywhere, which the other implementations refuse.

- **`@ip` writes every address so it reads back, and refuses a sign**:
  Erlang's `:inet.ntoa` wrote a link-local address with a non-zero second
  group (`fe80:1::1`) as `fe80::1%01`, which no reader takes, and its
  parser took `+1.2.3.4`. Found by node-dextrin's port.
- **A field naming another file's named type resolves** (DXNS.md §6):
  `Dextrin.Schema.FileResolver` compiled each file on its own, so
  `discount: acme.common/Percentage` named a struct that never came and
  refused every decimal. A file is now compiled with its full names, over
  the named types its search path defines.
- **An error found while parsing text says where it is**: a map key
  that is not allowed, a value that is not one, a schema violation now
  carry the line and column of what they are about, and
  `Dextrin.Error.format/1` shows them. 0.2.0 said they did; they had
  neither, because Ichor placed no error an action returned.
- `Dextrin.Schema.compile/2` crashed (`Protocol.UndefinedError`) on a
  document whose top level is a struct or a tuple -- maps to `is_map/1`
  -- instead of refusing it; it refuses every document that is not a
  plain map (`schema/compile`). Found by ASCO.
- Encoding a bitstring that is not whole bytes crashed the text and
  binary writers; it is `bits` now.
- A schema a resolver found under a namespaced name (`geo/Point` in
  `geo.dxns`) kept the name it had in its file, so reading an older
  version of it looked for `Point` and missed.
- `Dextrin.Document.format/2` wrote the header without its space
  (`@dxn"1.0"`); it keeps one space between the tag and the version,
  however they were spaced.
- `Dextrin.Document.format/2` dropped a document's final newline; it now
  ends with one exactly when the source did. `mix dextrin.format` still
  writes a file with one final newline.

## [0.2.0] - 2026-10-19

Versioned schemas, extra fields, the conformance suite's adapter and a
document that keeps its comments. Breaking: a 0.1.x `.dxns` file has
to be rewritten, and decoding a checked struct answers a
`Dextrin.Struct`.

### Migrating from 0.1.x

- **`.dxns` files.** The body lists the fields directly, in the order
  `fields: @ordered %{...}` gave them, and a schema without a version
  is version 1:

  ```text
  # 0.1.x
  User: %schema{closed: true, fields: @ordered %{name: :string, email?: :string}}
  # 0.2.0
  User: %schema{name: :string, email?: :string}
  ```

  An unconverted file is refused when it is compiled (`schema/compile`:
  `field "fields": unrecognized type expression`), never read as
  something else.
- **`closed:`, `forbidden:` and `refine-fn:` are gone.** A struct may
  carry fields its schema does not declare. Where `closed: true` refused
  them, decode with `strict: true` (or `Dextrin.Registry.put_strict/2`),
  which refuses every undeclared field as `schema/undeclared-field`. A
  `forbidden:` list or a refine predicate becomes a check in the
  schema's materializer (`Dextrin.Registry.put_struct_materializer/3`),
  which may refuse.
- **A checked struct decodes to a `Dextrin.Struct`**, not a string-keyed
  map. A materializer that answers the map you had keeps the old shape:
  `fn _name, _version, fields -> {:ok, fields} end`.
- **`.dxnb` written by 0.1.x still reads**, and so does text: its structs
  carry no version, and a struct without a version is read as the latest
  version loaded. Proven against bytes 0.1.3 wrote, in
  `test/binary/v0_1_3_test.exs`.
- **Rewrite stored 0.1.x data before you load a version 2.** Once a
  version 2 is loaded, an unversioned struct is read as version 2, and a
  `.dxnb` struct with version 1's fields no longer matches it
  (`schema/field-count`). Decoded and encoded again with 0.2.0, the same
  value is written as `User(1)`, and from then on it is read as version 1
  and handed to the materializer to upgrade.

### Added

- `Dextrin.Document` -- a `.dxn` document with everything it holds, its
  comments, blank lines and commas as well as its value: `parse/2` (with
  `Dextrin.decode/2`'s options and errors), `to_value/1` (what `decode/2`
  gives) and `render/1` (the source, byte for byte, when nothing was
  edited). Built on Ichor's lossless parse of the same grammar.
- `Dextrin.Document.replace/3`, `delete/2` and `insert/4` -- explicit
  edits by path (a map's key, a struct's field name, an index). Every byte
  an edit does not touch renders as it was; a replaced value keeps the
  comments around it; a deleted entry takes the comment blocks above it
  and its trailing block, and nobody else's; an inserted entry goes above
  the comments of the entry it lands before, laid out like its
  neighbours.
- `Dextrin.Document.format/2` -- a document laid out the way the pretty
  printer lays out its value, with every comment kept beside the entry
  it belongs to. Layout only: every token is written as it was, and a
  comment-free document comes out exactly as `Dextrin.encode(value,
  pretty: true)` writes its value.
- **`mix dextrin.format` keeps comments** (`--mode pretty`, the default):
  it formats through `Dextrin.Document.format/2`, where it reprinted the
  value and lost every comment. `--mode condense` still drops them: a
  comment ends its line, and condensed output is one line.
- `Dextrin.Document.update/3` -- the document holding a new value, by the
  fewest explicit edits, answered as a changeset. Equal values are left
  as written; lists compare with Myers' diff. A deleted item followed by
  an inserted one is ambiguous, and `on_ambiguity:` decides: `:safe`
  (the default) refuses only where the deleted item owns a comment,
  `:guess` reads it as a replace, `:error` refuses every one.
- `Dextrin.Registry.from_options/1` and `Dextrin.Error.from_parse/1`, what
  `decode/2` and `Dextrin.Document.parse/2` share.
- `Dextrin.Canonical.print/1` -- the canonical form of a value
  (`DXN.md` §1.5): one `.dxn` rendering fixed down to the byte, with
  maps and sets in canonical order, floats by one exact algorithm and
  one fixed set of string escapes, so values from different
  implementations compare as text.
- `Dextrin.Error`'s `category` -- the error's kind as `DXN.md` §5 names
  it (`"syntax"`, `"binary"`, `"encode/unsupported"`, ...), so errors
  compare across implementations without comparing messages.
  `Dextrin.Error.action/2` takes it; schema errors have none yet.
- `mix dextrin.conformance REQUEST ANSWER` and `Dextrin.Conformance` --
  this implementation's adapter for the Dextrin Conformance Suite,
  answering a JSON batch of cases with canonical forms, base64 bytes or
  error categories.
  It also answers `format` and `edit` cases, a `Dextrin.Document` laid
  out or edited, as `text`.
- The tutorial's §8, "Evolving a schema": a worked example of versions,
  reading across them, the materializer, the positional risk and
  `mix dextrin.compat`. The cheatsheet gains versions and extra fields.
- `Dextrin.Schema.Compat.check/2` and `mix dextrin.compat OLD NEW` --
  the version discipline check (`DXN.md` §4.8), for CI against the
  previous release's schema files: a version whose content changed
  without a new version, or a version that went down, fails; what the
  latest version's materializer has to upgrade is listed either way.

### Changed

- Licensed under the Apache License 2.0 (`Apache-2.0`); it was MIT.
- The Node.js port is published as `@joetjen/dextrin`; the links to it
  say so. The unscoped npm `dextrin` is deprecated.
- Requires `ichor_runtime ~> 0.4` (was `~> 0.2`); `ichor ~> 0.5` builds
  the DXN parser, now generated with `--lossless` for the comment-keeping
  document to come. Decoding runs the same code as before.
- An error of Dextrin's own found while parsing text -- a map key that is
  not allowed, a schema violation -- travels through Ichor in
  `Ichor.Error`'s `data` and comes back as itself, now with Ichor's
  line and column; `Dextrin.Error.format/1` shows them.
- **An absent optional field is never written as `nil`** (`DXN.md`
  §4.3): `.dxnb` fills every position, so `encode_binary` writes an
  absent field's default where it has one and otherwise refuses
  (`schema/missing-field`). It wrote `nil`, which the same schema then
  refused to read back. `nil` in an optional field stays an error unless
  the type is `{:nilable t}`.
- **Every schema violation carries its `DXN.md` §5 category**:
  `schema/field-count`, `schema/missing-field`, `schema/type-mismatch`
  and `schema/shadowed-field`, decoding text or binary and encoding
  alike. They came back as `syntax` from text, `binary` from binary,
  and with no category from an encode. `Dextrin.Schema.validate/3`,
  `validate_encode/3` and `validate_encode_tree/2` answer a
  `Dextrin.Error` instead of a string.

- `DXN.md` gains §1.5 (canonical form), §1.6 (map keys: only `string`,
  `keyword`, `integer`, `uuid`, `date`, `bytes`, `symbol` and `char`)
  and §5 (error categories).
- **Schemas are versioned** (`DXN.md` §4.8). A schema is written
  `%schema(N){field: type, ...}`: the body lists the fields directly, in
  the order `.dxnb` encodes them, and the header holds only the version
  (version 1 without one). `fields:` and `@ordered` are gone. Each
  `.dxns` document describes one version; the registry keeps schemas by
  name *and* version (`Dextrin.Registry.fetch_struct_schema/3`, with `/2`
  answering the latest). Loading a version already loaded is a
  `schema/duplicate-version` error; `Dextrin.Registry.replace_struct_schema/3`
  hot-swaps a loaded version and refuses one that is not
  (`schema/not-loaded`).
- **A struct literal may carry its version**: `%User(3){...}`,
  `%User(3)[...]`, kept in `Dextrin.Struct`'s new `version` field and
  written back by the printer, the formatter and the canonical form.
- **Every struct written against a schema carries the latest loaded
  version of it**, in text (`%User(3){...}`, and `%User(1){...}` when
  version 1 is the only one) and in `.dxnb`, where the type name becomes
  `"User(3)"`. A struct with no schema keeps its own version, or none.
  `Dextrin.Registry.as_written/2` decides; `Dextrin.Struct.type_name/2`
  and `parse_type_name/1` spell it. A `.dxnb` type name whose version is
  not a positive integer is a `binary` error. `DXN.md` §4.8 documents
  how to deploy a schema change.
- **A struct is read according to the version it was written with**
  (`DXN.md` §4.8, `Dextrin.Schema.Reader`), in text and binary alike. The
  latest version, or none, is read exactly. A newer version is read
  against the latest: text by name, binary by position, dropping items
  beyond the reader's fields and leaving missing optional fields absent.
  An older version that is loaded is read with its own schema and handed
  to the materializer, and refused as `schema/no-materializer` without
  one; an older version that is not loaded is refused as
  `schema/unknown-version`.
- **One materializer per schema, for every version** (`DXN.md` §4.8).
  `Dextrin.Registry.put_struct_materializer/3` takes a function of
  `(schema_name, version, fields)`, `fields` keyed by string; it is
  called for every decoded struct of its schema, upgrades older
  versions, and its result is checked against the latest schema — a
  refusal or a rejected result is `schema/materializer`.
  `Dextrin.Schema.Provider`'s optional callback becomes
  `dxn_materialize/3` accordingly.
- **Without a materializer, a checked struct decodes to a
  `Dextrin.Struct`** at its schema's latest version, fields in the
  schema's order, instead of a plain string-keyed map — so it encodes
  back to the text it came from. An optional field that was absent stays
  absent instead of becoming `nil`.
- The validator's `materialize/4` is replaced by `resolve/3`;
  building the value is `Dextrin.Schema.Reader`'s job.
- **A struct may carry extra fields** (`DXN.md` §4.7), kept in
  `Dextrin.Struct`'s new `extra` list and written back with it: in text
  as entries — after a positional struct's items,
  `%User(3)["a" "a@x" nick: "b"]` (the grammar's positional rule gains
  trailing entries) — and in `.dxnb` as one trailing map under the new
  private tag 215. A keyed struct's undeclared names, and any entry keyed
  by something other than a name, are extras. An extra never shadows a
  declared field (`schema/shadowed-field`); the materializer receives
  extras with the declared fields; `strict: true` refuses them
  (`schema/undeclared-field`, `Dextrin.Registry.put_strict/2`). A
  registered application struct's runtime keys beyond its `defstruct`
  are written as its extras (`Dextrin.Struct.runtime_extra/1`).
- A keyed struct entry whose key is not a name — `%Point{1 => 2}` — is an
  extra field instead of an error.
- **A struct is matched only by a struct** (`DXN.md` §4.7). A field
  typed `User` accepts a struct named `User`, or the application struct
  registered for `User`; a plain map, any other value, and an application
  struct registered for no schema or another are type errors. Until now
  every non-struct value, and every unregistered application struct, was
  accepted. `DXN.md` §4.7 states the rule and its test: a host value
  always means the same DXN type, schema or not.
- **Removed: `closed:`, `forbidden:` and `refine-fn:`**, and with them
  `Dextrin.Schema.compile/3`'s and the file resolver's `for_paths`
  `predicates` argument. A struct may carry fields its schema does not
  declare; `strict: true` refuses the undeclared ones, and the
  schema's materializer checks what a predicate checked.
- Schema compile errors are `Dextrin.Error`s with the `schema/compile`
  category, not bare strings.
- **A map key must be one of the eight types of `DXN.md` §1.6**
  (`string`, `keyword`, `integer`, `uuid`, `date`, `bytes`, `symbol`,
  `char`). Any other key -- a float, `nil`, a boolean, a time, a
  collection, ... -- is refused as `key/not-allowed` when decoding text
  or binary and when encoding, in maps and ordered maps alike.
  `Dextrin.MapKey` holds the rule.
- `DXN.md` §1.5 states that nothing is normalised: a value is written
  as it was given, so `1.50M` keeps its scale and `-0.0M` its sign, in
  the canonical form as in every encoder.

### Fixed

- A symbol whose name is not an identifier is refused by the text
  writers (`encode/unsupported`): `encode/2`, the pretty printer and the
  canonical form wrote its name verbatim, which read back as something
  else, or as nothing (`""`). `.dxnb` keeps any name.

- A keyed struct written to `.dxnb` went on the wire in the order of
  its keys rather than its schema's field order, so `%Point{y: 2, x: 1}`
  came back with its fields swapped.

## [0.1.3] - 2026-08-18

### Fixed

- `decimal` bumped `~> 2.1` -> `~> 3.0` -- `mix hex.audit` flagged the resolved `2.4.1` against a real, published MEDIUM-severity advisory (EEF-CVE-2026-32686, "unbounded exponent in decimal enables unauthenticated DoS") while adding a downstream consumer. This package parses untrusted `.dxn`/`.dxnb` input directly into `Decimal.new/1,3` (`lib/dextrin/text/actions.ex`, `lib/dextrin/binary/decoder.ex`) -- exactly the attack surface the advisory describes, not an incidental transitive path. Every `Decimal.*` call this package makes (`new/1`, `new/3`, `to_float/1`, `to_string/2`) is unchanged, stable core API across 2.x and 3.x -- confirmed by the full existing test suite passing unmodified against `3.1.1`.

## [0.1.2] - 2026-08-14

### Added

- `Dextrin`'s own moduledoc now names its two sibling ports
  (`node-dextrin` on npm, `php-dextrin` on Packagist) directly,
  alongside the existing README "Other language implementations"
  section and `mix.exs` package links.

### Fixed

- `Dextrin.Text.Formatter` (`encode/2`'s `pretty: true` path) rendered
  a map entry's *key* incorrectly in two cases, both traced to
  `render_entry/4` conflating "render this as a value" with "render
  this as a keyword key":
  - A string-typed key that happened to look like a bare identifier
    (e.g. `"id"`) rendered as keyword-shorthand (`id: 1`) instead of
    the quoted arrow form (`"id" => 1`) — silently changing the key's
    type from `string` to `keyword` on the next decode. This is also
    why a schema-materialized struct (whose field map is always
    string-keyed) round-tripped incorrectly once pretty-printed.
  - A map keyed by exactly the atom `:nan`, `:positive_infinity`, or
    `:negative_infinity` — the same atoms a *float* `NaN`/`Infinity`/
    `-Infinity` decodes to — rendered the key as the float sigil
    (`Infinity => ...`) instead of the keyword it actually was
    (`positive_infinity: ...`), because the key fell through to the
    same code path that (correctly, in *value* position) special-cases
    those three atoms.

  `Dextrin.Text.Printer` (the compact, non-pretty path) was not
  affected — only the multi-line formatter had this bug. Found via a
  cross-language round-trip check against `node-dextrin`/`php-dextrin`.

## [0.1.1] - 2026-08-03

### Changed

- Bumped `ichor_runtime` to `~> 0.2` (and dev-only `ichor` to `~> 0.3`, its
  matching release) for the fix to `Ichor.Actions`' `eval_all` sometimes
  evaluating sibling captures out of source order. Regenerated
  `lib/dextrin/text/grammar/native.ex` via `mix ichor.gen` and updated
  the private `@ordered`-map re-entry helper in `Dextrin.Text.Actions`
  for `ichor_runtime`'s breaking change to raw capture data: an ordered
  `[{name, value}]` list instead of a plain map. No user-visible
  behavior change.

## [0.1.0] - 2026-07-30

### Added

- **`.dxn` text**: full grammar support for all 30 types DXN.md §1.3
  defines, via a hand-authored Aether grammar (`priv/grammar/dxn.aether`)
  compiled at build time through Ichor's `Grammar.Native` backend.
- **`.dxnb` binary**: a hand-rolled CBOR codec (`Dextrin.Binary.Encoder`/
  `Decoder`) covering the full type mapping, the private tag block
  (200-214), the duration bitmask and regex flags byte, and both
  value-sharing extensions (string-only, tag 256/25; general arbitrary-
  value sharing, tags 28/29 — decode-only required, encode opt-in via
  `share: true`, gated by a size-aware threshold rather than a fixed rule).
- **`.dxns` schema documents**: `Dextrin.Schema.compile/3` compiles a
  decoded `.dxns` document (itself plain `.dxn` data, no new grammar)
  into a `Dextrin.Registry` — struct schemas with required/optional
  (`?`-suffixed keys)/closed/forbidden fields and `refine` constraints,
  plus reusable named types composed from the fixed 13-form type_expr
  vocabulary. Enforcement is automatic, fail-fast, and symmetric:
  `Dextrin.decode/2`/`decode_binary/2` check every registered struct
  name unconditionally on the way in; `Dextrin.encode/2`/
  `encode_binary/2` do the same automatic whole-tree check on the way
  out (`validate: false` opts out), plus an opt-in `schema:` check for
  a nameless top-level value.
- **`Dextrin.Registry`**: the shared extension point for both `struct`
  (schema-driven, `put_struct_materializer/3`/`put_struct_module/3`)
  and `custom-tag` (`put_tag/3`/`put_tag_encoder/4`), plus a lazy
  `put_resolver/2` hook for on-demand schema loading.
- **`Dextrin.Schema.Std`**: a small standard library of common named
  types (`PositiveInteger`, `NonEmptyString`, `Percentage`, ...),
  opt-in via `Std.registry/1`.
- **`Dextrin.Schema.FileResolver`**: one reasonable, swappable
  convention resolving `Namespace/Name` references to
  `<path>/Namespace.dxns` files on disk.
- **Value types**: `Dextrin.Symbol`, `Dextrin.Keyword` (never Elixir
  atoms — decoding untrusted data can't exhaust the atom table),
  `Dextrin.Tuple`, `Dextrin.Array`, `Dextrin.OrderedMap`,
  `Dextrin.SortedSet`, `Dextrin.Struct`, `Dextrin.Duration`,
  `Dextrin.Rational`, `Dextrin.Uuid`, `Dextrin.Uri`, `Dextrin.Bytes`,
  `Dextrin.Char`, `Dextrin.CustomTag` — small wrapper structs only
  where Elixir has nothing native that fits without losing information.
- **`Dextrin.Text.Formatter`**: multi-line, indented `.dxn` rendering
  (`pretty/2`), built on top of `Dextrin.Text.Printer`'s single-line
  default.
- **`mix dextrin.*` tasks**: `validate`, `encode`, `decode`, `format`,
  `gen.schema` (scaffold a `.dxns` file from an existing Elixir
  struct's field list), and `gen.unicode` (regenerate the grammar's
  Unicode `XID_Start`/`XID_Continue` identifier ranges from the latest
  Unicode Character Database — a deliberate, reviewed action, never
  run at build time).
- Conformance fixtures covering `DXN.md` §3's full worked example,
  per-type round-trip tests, cross-format equivalence tests, grammar
  hazard regression tests, and a fuzz/malformed-input pass over the
  binary decoder.

- `Dextrin.encode/2` gained `pretty:` and `indent:` opts — `pretty:
  true` produces multi-line, indented output (`indent:` sets spaces
  per level, default 2) instead of the single-line/compact default.
  Either way, schema validation runs the same; formatting and
  validation are independent concerns.
- `Dextrin.Schema.Provider`, a behaviour letting a struct's own library
  ship a DXN schema for it without that library ever depending on
  `dextrin` — the schema, the target struct module, and an optional
  materializer are declared on a small, separately-compiled companion
  module (meant to be guarded behind an `optional: true` dependency),
  and `Dextrin.Schema.register_provider/2` compiles and wires it into
  a `Dextrin.Registry` in one call from the consuming application.
- Property-based tests (`stream_data`/`ExUnitProperties`, already a
  declared dependency but previously unused): a shared
  `Dextrin.Generators` module, round-trip properties covering
  `encode/2`/`decode/2`, `encode_binary/2`/`decode_binary/2`,
  `share: true`, and `pretty: true` against randomly generated values,
  a `Dextrin.Binary.FuzzTest` asserting `decode_binary/2` never raises
  or hangs on arbitrary or bit-flipped input, and a per-type property
  suite (`Dextrin.RoundTripByTypeTest`) covering every DXN type
  individually rather than mixed into one nested-structure generator.
  These found several of the bugs listed below on their very first run.
- `Dextrin.encode/2`/`encode_binary/2` accept a bare Elixir atom
  anywhere a `keyword` value is expected — as a map key (`%{x: 1}`,
  matching the shorthand's own colon syntax) or standalone
  (`Dextrin.encode(:ok)`) — exactly as if it were
  `Dextrin.Keyword.new(Atom.to_string(atom))`. `DXN.md` §1.3's own type
  table documents `keyword`'s Elixir counterpart as "Elixir atom".
  `nil`/`true`/`false` are unaffected — those stay their own literals.
- `Dextrin.decode/2`/`decode_binary/2` gained a `trusted:` opt
  (default `true`) mirroring the above on the way in: `keyword` decodes
  as a real Elixir atom by default, the same natural type `encode/2`
  now accepts. Pass `trusted: false` — per call, or once on a reused
  registry via the new `Dextrin.Registry.put_trusted/2` — for any
  source you don't fully control; only then does `keyword` fall back
  to `Dextrin.Keyword.t()`, so a `String.to_atom/1` call is never
  reachable from attacker-controlled text. `symbol` is unaffected
  either way — it stays `Dextrin.Symbol.t()` regardless of `trusted`,
  mirroring `encode/2`'s own choice to treat a bare atom as a stand-in
  for `keyword` only, never `symbol`.

### Changed

- **Breaking:** `Dextrin.Registry.new/0`'s `trusted` field — and so
  `decode/2`/`decode_binary/2`'s effective default — is `true`, not
  `false`. Any code relying on the previous behavior (`keyword`
  decoding as `Dextrin.Keyword.t()` with no opt given at all) needs
  `trusted: false` added explicitly. This is deliberate: `keyword`'s
  wrapper only ever existed to keep untrusted input from reaching
  `String.to_atom/1` unbounded; assuming a trusted source (your own
  config, your own application's data) by default matches how the
  type is documented (`DXN.md` §1.3) and how most callers actually use
  this library, at the cost of needing an explicit opt-out for
  genuinely untrusted/network input.

- **Breaking:** `Dextrin.Text.Printer`'s (and so `Dextrin.encode/2`'s
  default, non-`pretty:` output) is now maximally compact: no space
  after a map/struct entry's `:`, no space around `=>`, and entries/
  positional-struct fields are comma-joined with no trailing space
  (`%{x:1,y:2}`, `%Point{x:1,y:2}`, `%{1=>"one"}`) rather than the
  previous `%{x: 1, y: 2}`-style spacing. DXN's own grammar treats
  whitespace and commas as insignificant everywhere, so this loses no
  information and every value still round-trips identically through
  `decode/2`; only the default *rendering* got smaller. List/tuple/
  set/array element separators and `@tag value`'s space are unchanged
  (the former have nothing to gain — comma and space are both a
  single byte — and the latter's space can't be dropped in general for
  a custom tag whose value could start with an identifier character).
- **Breaking:** `Dextrin.Text.Formatter.pretty/2` now returns
  `{:ok, String.t()} | {:error, Dextrin.Error.t()}`, matching
  `Dextrin.Text.Printer.print/2`'s contract, instead of a bare
  `String.t()` that raised `ArgumentError` on an unencodable value.
  Also gained the same `indent:` opt `Dextrin.encode/2`'s new
  `pretty:`/`indent:` opts use internally (default 2 spaces per
  nesting level, previously a fixed, non-configurable 2).
- Adopted ichor's new `mix ichor.gen`/`ichor_runtime` split: the `.dxn`
  lexer/parser is now generated ahead of time into
  `lib/dextrin/text/grammar/native.ex` (checked in, regenerated via
  `mix ichor.gen` whenever `priv/grammar/dxn.aether` changes) instead
  of being produced by `use Ichor` at `dextrin`'s own compile time.
  `Dextrin.Text.Grammar` is now a thin, hand-documented wrapper around
  the generated `Grammar.Native`. This lets `mix.exs` depend on the
  small `ichor_runtime` package (the only thing the generated code
  actually calls) as an ordinary runtime dependency, while `ichor`
  proper (the Aether front-end, format importers, `Grammar.Analysis`,
  both codegen backends) moves to `only: :dev, runtime: false` — the
  bulk of Ichor no longer ships in a `dextrin` release. Both are now
  published to Hex separately (`ichor ~> 0.2.1`, `ichor_runtime ~>
  0.1.0`) and referenced as ordinary Hex dependencies — no `git:`/
  `sparse:`/`override:` needed, since `ichor`'s own `mix.exs` now
  depends on `ichor_runtime` the same way. Migrating surfaced a genuine
  gap in that split, fixed upstream: the raw-capture-node re-evaluation
  entry point needed by `Dextrin.Text.Actions` at actual decode time
  (for `@ordered %{...}`) had stayed on the top-level, dev-only `Ichor`
  module instead of moving to `ichor_runtime`; it's now
  `Ichor.Actions.evaluate_node/3`.
- Added `credo`, `dialyxir`, `sobelow`, and `excoveralls` as dev/test
  tooling, plus a `mix precommit` alias (`format`, `compile
  --warnings-as-errors`, `credo --strict`, `sobelow`, `test`,
  `dialyzer`) run before every commit. Fixed everything it surfaced:
  canonical module layout (`moduledoc`/`use`/`alias`/...) across the
  mix tasks and text pipeline modules; `Dextrin.decode/2`'s spec
  under-declared its own return type (the underlying grammar engine
  can report a list of errors, not just one) — widened to match, which
  also resolved three "dead code" warnings in the mix tasks' own error
  handling; removed a dead `Duration.microsecond || {0, 0}` fallback
  (that field is never `nil`).
- Every hand-rolled "walk a collection, thread an accumulator, halt on
  the first non-`{:ok, _}` step result" reduce across
  `Dextrin.Schema.Compiler`, `Dextrin.Schema.Validator`,
  `Dextrin.Binary.Encoder`, `Dextrin.Text.Printer`, and
  `Dextrin.Text.Actions` now uses `Ichor.Toolkit.Result.reduce_ok/3`/
  `map_ok/3` instead of a hand-rolled `Enum.reduce_while/3`.
- Documentation reworked throughout: every module's docs are now
  self-contained (no references to an external design document), and
  the normative `DXN.md` format specification moved under
  `guides/dxn/DXN.md`, alongside a full set of tutorials, examples,
  and cheatsheets for both this library and the DXN format itself.
  `DXN.md` itself gained a new normative §4, "Schema documents
  (`.dxns`)" — the `type_expr`/`refine`-constraint vocabulary,
  `%schema{}`/`%field{}` shape, named types, cross-file references, and
  enforcement semantics, written implementation-independent (this
  library's own choices stay documented separately, in
  `Dextrin.Schema`'s own module docs) — closing a gap where `.dxns`
  was documented everywhere *except* the one place meant to be
  the authoritative, cross-implementation reference for it.

### Fixed

- Struct literal parsing (`%Point{x: 1, y: 2}`) and `.dxns` schema
  compilation both depended, internally, on `keyword`/map-key text
  always decoding as `Dextrin.Keyword`/`Dextrin.Symbol` — exposed as a
  real regression the moment `trusted: true` became the default, not
  merely a test-assertion mismatch: struct literals failed to parse at
  all, and any schema whose own `.dxns` source was decoded trusted
  failed to compile. Fixed at the boundaries that actually needed it:
  `Dextrin.Text.Actions`'s struct-field-name resolution now accepts a
  bare atom the same way it already accepted `Dextrin.Keyword`
  /`Dextrin.Symbol`; `Dextrin.Schema.Compiler.compile/3` normalizes a
  decoded `.dxns` document's atoms back to `Dextrin.Keyword` once, up
  front (mirroring `Dextrin.Schema.Validated.strip/1`'s exact
  recursive-walk shape), so its own `.dxns`-vocabulary pattern
  matching needs no changes at every site; a schema's `enum` literals
  and a `:keyword`-typed field's actual data can now be either shape
  independently, compared correctly either way instead of only ever
  matching the untrusted shape. `default: nil`/`true`/`false` field
  values are deliberately *not* touched by that normalization (only
  `.dxns` syntax positions are) — and `x: :nil` as a schema field's
  *type* (the `"nil"` primitive) has its own dedicated fix, since
  `String.to_atom("nil")` and the bare literal `nil` are, once
  decoded, the exact same unrecoverable Elixir value.
- `Dextrin.Binary.Decoder`'s handling of the `time` tag (210) had no
  fallback clause — a malformed payload that wasn't an integer crashed
  with `FunctionClauseError` instead of a clean `{:error, _}`, the one
  tag missed in the earlier decoder-hardening pass. Found by the
  property-based fuzz test firing again after the `trusted:` default
  flip added more generated shapes to exercise it with.

- `Dextrin.Text.Printer`/`Dextrin.Text.Formatter` no longer produce
  unparseable text for a map entry or struct field whose
  `Dextrin.Keyword`/field name isn't a bare `identifier` (empty,
  containing spaces, ...) — `map_entry`'s colon-shorthand (`DXN.md`
  §1.2) only exists for an `identifier`; both printers now fall back
  to the arrow form (`:"name" => value`) for anything else, instead of
  emitting invalid syntax like `%{"":0}` or a bare `: 0` that failed to
  parse back at all. `Dextrin.Text.Printer.bare_identifier?/1` is now
  public so both printers share one rule.
- `Dextrin.Binary.Decoder` no longer crashes (raises) on a malformed
  `.dxnb` document that places a `tuple`/`array`/`set`/`sorted-set`
  tag around a non-array payload, a bignum/`uuid` tag around a
  non-bytes payload, a `uri`/`symbol`/`keyword` tag around non-text, a
  `char` tag around a non-codepoint or out-of-range value, a `date`
  tag around a non-integer, or a `timestamp`/`datetime` epoch outside
  the representable range — every one of these now returns a clean
  `{:error, %Dextrin.Error{}}` instead of an unhandled
  `FunctionClauseError`/`ArgumentError`/`CaseClauseError`. Found by the
  new property-based fuzz test, not a hand-picked case.
- `Dextrin.Text.Printer.print/2` no longer crashes on a value with no
  DXN representation at all (a PID, a port, a reference, a function, a
  raw Elixir tuple that isn't `Dextrin.Tuple`, ...) — a clean
  `{:error, %Dextrin.Error{}}` now, matching `encode/2`'s own typespec,
  instead of an unhandled `FunctionClauseError`.
- `@ordered %{}` (an *empty* ordered map) failed to parse at all
  (`"@ordered requires a map literal argument"`), even though `%{}` on
  its own decodes fine — the action handler required its raw capture
  tree to have a `:map_entry` key present, which a zero-entry map
  simply doesn't have (the same reason the ordinary, non-`@ordered`
  `map_lit` handler already defaults it to `[]`). Found by the new
  per-type property suite.
- `Dextrin.Binary.Decoder` now accepts `DXN.md` §2.4's CBOR string
  -reference sharing extension (tags 256/25) — previously an
  otherwise-well-formed `.dxnb` document using it (to compactly
  represent repeated `symbol`/`keyword` text or repeated struct type
  names) failed to decode at all (`"unrecognized CBOR tag 25"`), which
  violated §2.4's "a conforming decoder MUST accept it" requirement.
  `dextrin`'s own encoder still never produces tags 256/25 (that
  remains out of scope, same as before) — this is decode-side
  acceptance only, same stance as §2.5's tag 28/29 support.
- `Dextrin.encode/2`/`encode_binary/2` can now encode a registered
  application struct (`Dextrin.Registry.put_struct_module/3`) directly
  — previously, only a hand-built `Dextrin.Struct` could actually be
  serialized; a real struct registered via `put_struct_module/3` was
  recognized by automatic encode-time *validation* but had no path to
  actually being written out, forcing callers to reconstruct a
  `Dextrin.Struct` from the real struct's own fields by hand before
  encoding. `Dextrin.Text.Printer`/`Dextrin.Binary.Encoder` now rebuild
  the equivalent `Dextrin.Struct` automatically, in the compiled
  schema's own canonical field order.