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

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

[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: (DXN.md §4.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 (DXN.md §4.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 (DXN.md §4.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; DXN.md §4.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; DXN.md §4.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; DXN.md §4.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; DXN.md §4.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; DXN.md §4.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; DXN.md §4.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; DXN.md §4.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 (DXN.md §4.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 (DXN.md §4.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 (DXN.md §4.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 %{...}} (DXN.md §4.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 (DXN.md §4.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 (DXN.md §4.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.

  • DXN.md §4.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 DXN.md §4.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 DXN.md §4.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 (DXN.md §4.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:

    # 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.

  • Dextrin.Schema.Validator.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 Dextrin.Schema.FileResolver.for_paths/2's 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.Errors 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.