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/ias@regex(i) "^a". A sigil is nowsyntax. - Binary: deploy readers first. 0.3 writes times, datetimes, local
date-times and durations in nanoseconds under tags 217-220,
@objectidand@ipunder 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 arenanoseconds, one field for the seconds and their fraction; a time or datetime finer than a microsecond decodes to aDextrin.Nanos.:timestampis 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-containswithoutcontains, and an unknownformat:are compile errors; a constraint on a value it does not apply to refuses the value instead of raising;{:enum 1}no longer takes1.0. - A trusted decode keeps six keywords --
:nan,:positive_infinity,:negative_infinity,:nil,:true,:false-- asDextrin.Keyword, since their atoms are already values. @uuidreads 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.checkreports 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, apatternthat 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'sinspectshows it. - A refine constraint refuses a value it does not apply to: a length
on an integer, a range on a string,
bitson a negative integer. Before, several such checks raised;finite: falseandunique: falsetook 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 aremin-contains:andmax-contains:withoutcontains:. A misspelt key (min-lenght:) refused every value before, silently. .dxnbwrites 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. timestampanddatetimeare one type,datetime: an instant with its offset,Zfor UTC. Breaking.:timestampin 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 isnanoseconds(wasmicroseconds). In.dxnbthe 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:andoffsets:(DXN.md §4.2), and NaN lies outside every range:min,maxand 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 docsgives it the README with> [!WARNING]turned into ExDoc's own admonition, written todoc_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'soneOf), and{:any-of ...}is the new "one or more" (anyOf) -- whatone-ofmeant before. Breaking: a schema whoseone-ofvariants overlap now refuses a value two of them match; writeany-ofto keep the old meaning.{:nilable t}is sugar for{:any-of :nil t}.- DXN.md says
.dxnbwrites-0.0Mas0.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/2withpretty: true,Dextrin.Document.format/2andmix dextrin.formatall 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/2andDextrin.Documentdecode a keyword asDextrin.Keyword.t(), so no input can exhaust the atom table;trusted: true(orDextrin.Registry.put_trusted/2) is the opt-in for a source you control, as Jason'skeys: :atoms. Breaking for callers that relied on atoms from the default. ADextrin.Documentpath's atom names a keyword key either way, andupdate/3compares 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/Addressaccepts%geo/Address{...}, and refuses%Address{...}. It was the other way round, because a schemaFileResolverfound asgeo/Addresskept 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
.dxnsdocuments (the body lists the fields,%schema(N)carries a version) andbitskeep1.0. A reader accepts any1.xheader; the.dxnbversion byte is1. - 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,:bitsin 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/ambiguousanddocument/not-a-collection, which every implementation raised and the conformance suite expects. - A 0.1 schema says it is one:
fields: @ordered %{...},closed: trueandforbidden: [...]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,:uint64and: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): atitle,description,examples,read-only,write-only,deprecatedand freemetaon any type, around a whole schema, named type or generic too, andvalues:for an enum's single values. They change nothing a type takes;Dextrin.Schema.annotations/2reads them andDextrin.Schema.Compiledholds a schema's.mix dextrin.docsshows 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 aT-- 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:andcontent: Ton strings -- the string, decoded, is a DXN document of type T,.dxnbunderapplication/dxnb-- andencodes: Tandencodes-seq: Ton bytes, CDDL's.cborand.cborseq.Dextrin.Binary.Decoder.decode_sequence/2reads 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-lengthon keywords, symbols, bytes (in bytes) and bits (in bits, under:bits);min-bytes/max-bytes(UTF-8);patternon keywords, symbols and bytes;min-precision/max-precisionandmin-exponent/max-exponenton 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 (maxnilfor 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 keyscontains:,min-contains:,max-contains:,unique:andunique-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.@objectidand@ipare 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,:ipand the refine keysversion:andprefix:..dxnbuses the smallest representation that keeps a value, now said in DXN.md §2.2.mix dextrin.checkandDextrin.Schema.Tree(Jan, 2026-10-20): a tree of.dxnsfiles 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 asfile: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/3takes 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%schemacannot 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%schemabody, 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/3answers whether a value matches a schema, shape or named type without changing it, andconform/3answers it with the defaults filled.The conformance adapter answers
media-type,identifyandidentify-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 checksDextrin.Formatacross implementations.mix dextrin.docsandDextrin.Schema.Docs(Jan, 2026-10-20): HTML documentation for a directory of.dxnsschemas: 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, oneindex.htmlper 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 generateddoc/.New type forms (DXN.md §4.1):
{:not t},:never(JSON Schema'sfalse), 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+dxnsuffix as text -- andidentifies?/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.dxnsdocuments 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)inFoo/Bar/Baz-vN.dxns,Foo/Bar/Baz/vN.dxnsorFoo.Bar.Baz-vN.dxns(-v1optional), or the entryBazinFoo.Bar.dxns-- with a version found twice refused.Dextrin.Schema.FileResolverfollows 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 oldNamespace.dxnsconvention 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 aDextrin.Bytes-- is written@bits {"oA==" 3}andT_BITS(216), and reads back;Dextrin.Bitsholds the rules.:bitsin a schema takes bits and bytes,:bytesonly whole bytes; bits are no map key.Takes
ichor_runtime0.5 andichor0.6 (was 0.4 and 0.5), for the action errors below; the DXN parser is regenerated withichor0.6.
Fixed
A generic checked by its own name refuses the value (
schema/type-mismatch), whereDextrin.Schema.check/3raised: a generic takes no value until{:of Name ...}gives its arguments.An enum's literal is the value itself:
{:enum 1}took1.0, which Elixir's==calls equal and DXN does not (§1.5).bitsis 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.docsraised.A trusted decode keeps
:nan,:positive_infinity,:negative_infinity,:nil,:trueand:falsekeywords (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'sDflag now), and@time,@datetime,@local-datetimeand@durationread one;host-portraised 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
.dxnbdocument read inside another keeps the outer one's state: the shared-item and stringref tables are put back after it.@uuidreads only 8-4-4-4-12 hex digits. It took 32 bare hex digits, or hyphens anywhere, which the other implementations refuse.@ipwrites every address so it reads back, and refuses a sign: Erlang's:inet.ntoawrote a link-local address with a non-zero second group (fe80:1::1) asfe80::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.FileResolvercompiled each file on its own, sodiscount: acme.common/Percentagenamed 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/1shows them. 0.2.0 said they did; they had neither, because Ichor placed no error an action returned.Dextrin.Schema.compile/2crashed (Protocol.UndefinedError) on a document whose top level is a struct or a tuple -- maps tois_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
bitsnow.A schema a resolver found under a namespaced name (
geo/Pointingeo.dxns) kept the name it had in its file, so reading an older version of it looked forPointand missed.Dextrin.Document.format/2wrote 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/2dropped a document's final newline; it now ends with one exactly when the source did.mix dextrin.formatstill 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
.dxnsfiles. The body lists the fields directly, in the orderfields: @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:andrefine-fn:are gone. A struct may carry fields its schema does not declare. Whereclosed: truerefused them, decode withstrict: true(orDextrin.Registry.put_strict/2), which refuses every undeclared field asschema/undeclared-field. Aforbidden: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..dxnbwritten 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, intest/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
.dxnbstruct 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 asUser(1), and from then on it is read as version 1 and handed to the materializer to upgrade.
Added
Dextrin.Document-- a.dxndocument with everything it holds, its comments, blank lines and commas as well as its value:parse/2(withDextrin.decode/2's options and errors),to_value/1(whatdecode/2gives) andrender/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/2andinsert/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 asDextrin.encode(value, pretty: true)writes its value.mix dextrin.formatkeeps comments (--mode pretty, the default): it formats throughDextrin.Document.format/2, where it reprinted the value and lost every comment.--mode condensestill 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, andon_ambiguity:decides::safe(the default) refuses only where the deleted item owns a comment,:guessreads it as a replace,:errorrefuses every one.Dextrin.Registry.from_options/1andDextrin.Error.from_parse/1, whatdecode/2andDextrin.Document.parse/2share.Dextrin.Canonical.print/1-- the canonical form of a value (DXN.md§1.5): one.dxnrendering 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'scategory-- the error's kind asDXN.md§5 names it ("syntax","binary","encode/unsupported", ...), so errors compare across implementations without comparing messages.Dextrin.Error.action/2takes it; schema errors have none yet.mix dextrin.conformance REQUEST ANSWERandDextrin.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 answersformatandeditcases, aDextrin.Documentlaid out or edited, astext.- 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/2andmix 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 npmdextrinis deprecated.Requires
ichor_runtime ~> 0.4(was~> 0.2);ichor ~> 0.5builds the DXN parser, now generated with--losslessfor 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'sdataand comes back as itself, now with Ichor's line and column;Dextrin.Error.format/1shows them.An absent optional field is never written as
nil(DXN.md§4.3):.dxnbfills every position, soencode_binarywrites an absent field's default where it has one and otherwise refuses (schema/missing-field). It wrotenil, which the same schema then refused to read back.nilin 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-mismatchandschema/shadowed-field, decoding text or binary and encoding alike. They came back assyntaxfrom text,binaryfrom binary, and with no category from an encode.Dextrin.Schema.validate/3,validate_encode/3andvalidate_encode_tree/2answer aDextrin.Errorinstead of a string.DXN.mdgains §1.5 (canonical form), §1.6 (map keys: onlystring,keyword,integer,uuid,date,bytes,symbolandchar) 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.dxnbencodes them, and the header holds only the version (version 1 without one).fields:and@orderedare gone. Each.dxnsdocument describes one version; the registry keeps schemas by name and version (Dextrin.Registry.fetch_struct_schema/3, with/2answering the latest). Loading a version already loaded is aschema/duplicate-versionerror;Dextrin.Registry.replace_struct_schema/3hot-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 inDextrin.Struct's newversionfield 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/2decides;Dextrin.Struct.type_name/2andparse_type_name/1spell it. A.dxnbtype name whose version is not a positive integer is abinaryerror.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 asschema/no-materializerwithout one; an older version that is not loaded is refused asschema/unknown-version.One materializer per schema, for every version (
DXN.md§4.8).Dextrin.Registry.put_struct_materializer/3takes a function of(schema_name, version, fields),fieldskeyed 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 isschema/materializer.Dextrin.Schema.Provider's optional callback becomesdxn_materialize/3accordingly.Without a materializer, a checked struct decodes to a
Dextrin.Structat 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 becomingnil.Dextrin.Schema.Validator.materialize/4is replaced byresolve/3; building the value isDextrin.Schema.Reader's job.A struct may carry extra fields (
DXN.md§4.7), kept inDextrin.Struct's newextralist 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.dxnbas 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: truerefuses them (schema/undeclared-field,Dextrin.Registry.put_strict/2). A registered application struct's runtime keys beyond itsdefstructare 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 typedUseraccepts a struct namedUser, or the application struct registered forUser; 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:andrefine-fn:, and with themDextrin.Schema.compile/3's andDextrin.Schema.FileResolver.for_paths/2'spredicatesargument. A struct may carry fields its schema does not declare;strict: truerefuses the undeclared ones, and the schema's materializer checks what a predicate checked.Schema compile errors are
Dextrin.Errors with theschema/compilecategory, 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 askey/not-allowedwhen decoding text or binary and when encoding, in maps and ordered maps alike.Dextrin.MapKeyholds the rule.DXN.md§1.5 states that nothing is normalised: a value is written as it was given, so1.50Mkeeps its scale and-0.0Mits 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 ("")..dxnbkeeps any name.A keyed struct written to
.dxnbwent 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
decimalbumped~> 2.1->~> 3.0--mix hex.auditflagged the resolved2.4.1against 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/.dxnbinput directly intoDecimal.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. EveryDecimal.*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 against3.1.1.
[0.1.2] - 2026-08-14
Added
Dextrin's own moduledoc now names its two sibling ports (node-dextrinon npm,php-dextrinon Packagist) directly, alongside the existing README "Other language implementations" section andmix.exspackage links.
Fixed
Dextrin.Text.Formatter(encode/2'spretty: truepath) rendered a map entry's key incorrectly in two cases, both traced torender_entry/4conflating "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 fromstringtokeywordon 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 floatNaN/Infinity/-Infinitydecodes 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 againstnode-dextrin/php-dextrin.- A string-typed key that happened to look like a bare identifier
(e.g.
[0.1.1] - 2026-08-03
Changed
- Bumped
ichor_runtimeto~> 0.2(and dev-onlyichorto~> 0.3, its matching release) for the fix toIchor.Actions'eval_allsometimes evaluating sibling captures out of source order. Regeneratedlib/dextrin/text/grammar/native.exviamix ichor.genand updated the private@ordered-map re-entry helper inDextrin.Text.Actionsforichor_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
.dxntext: 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'sGrammar.Nativebackend..dxnbbinary: 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 viashare: true, gated by a size-aware threshold rather than a fixed rule)..dxnsschema documents:Dextrin.Schema.compile/3compiles a decoded.dxnsdocument (itself plain.dxndata, no new grammar) into aDextrin.Registry— struct schemas with required/optional (?-suffixed keys)/closed/forbidden fields andrefineconstraints, 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/2check every registered struct name unconditionally on the way in;Dextrin.encode/2/encode_binary/2do the same automatic whole-tree check on the way out (validate: falseopts out), plus an opt-inschema:check for a nameless top-level value.Dextrin.Registry: the shared extension point for bothstruct(schema-driven,put_struct_materializer/3/put_struct_module/3) andcustom-tag(put_tag/3/put_tag_encoder/4), plus a lazyput_resolver/2hook for on-demand schema loading.Dextrin.Schema.Std: a small standard library of common named types (PositiveInteger,NonEmptyString,Percentage, ...), opt-in viaStd.registry/1.Dextrin.Schema.FileResolver: one reasonable, swappable convention resolvingNamespace/Namereferences to<path>/Namespace.dxnsfiles 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.dxnrendering (pretty/2), built on top ofDextrin.Text.Printer's single-line default.mix dextrin.*tasks:validate,encode,decode,format,gen.schema(scaffold a.dxnsfile from an existing Elixir struct's field list), andgen.unicode(regenerate the grammar's UnicodeXID_Start/XID_Continueidentifier 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/2gainedpretty:andindent:opts —pretty: trueproduces 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 ondextrin— the schema, the target struct module, and an optional materializer are declared on a small, separately-compiled companion module (meant to be guarded behind anoptional: truedependency), andDextrin.Schema.register_provider/2compiles and wires it into aDextrin.Registryin one call from the consuming application.Property-based tests (
stream_data/ExUnitProperties, already a declared dependency but previously unused): a sharedDextrin.Generatorsmodule, round-trip properties coveringencode/2/decode/2,encode_binary/2/decode_binary/2,share: true, andpretty: trueagainst randomly generated values, aDextrin.Binary.FuzzTestassertingdecode_binary/2never 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/2accept a bare Elixir atom anywhere akeywordvalue is expected — as a map key (%{x: 1}, matching the shorthand's own colon syntax) or standalone (Dextrin.encode(:ok)) — exactly as if it wereDextrin.Keyword.new(Atom.to_string(atom)).DXN.md§1.3's own type table documentskeyword's Elixir counterpart as "Elixir atom".nil/true/falseare unaffected — those stay their own literals.Dextrin.decode/2/decode_binary/2gained atrusted:opt (defaulttrue) mirroring the above on the way in:keyworddecodes as a real Elixir atom by default, the same natural typeencode/2now accepts. Passtrusted: false— per call, or once on a reused registry via the newDextrin.Registry.put_trusted/2— for any source you don't fully control; only then doeskeywordfall back toDextrin.Keyword.t(), so aString.to_atom/1call is never reachable from attacker-controlled text.symbolis unaffected either way — it staysDextrin.Symbol.t()regardless oftrusted, mirroringencode/2's own choice to treat a bare atom as a stand-in forkeywordonly, neversymbol.
Changed
Breaking:
Dextrin.Registry.new/0'strustedfield — and sodecode/2/decode_binary/2's effective default — istrue, notfalse. Any code relying on the previous behavior (keyworddecoding asDextrin.Keyword.t()with no opt given at all) needstrusted: falseadded explicitly. This is deliberate:keyword's wrapper only ever existed to keep untrusted input from reachingString.to_atom/1unbounded; 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 soDextrin.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 throughdecode/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/2now returns{:ok, String.t()} | {:error, Dextrin.Error.t()}, matchingDextrin.Text.Printer.print/2's contract, instead of a bareString.t()that raisedArgumentErroron an unencodable value. Also gained the sameindent:optDextrin.encode/2's newpretty:/indent:opts use internally (default 2 spaces per nesting level, previously a fixed, non-configurable 2).Adopted ichor's new
mix ichor.gen/ichor_runtimesplit: the.dxnlexer/parser is now generated ahead of time intolib/dextrin/text/grammar/native.ex(checked in, regenerated viamix ichor.genwheneverpriv/grammar/dxn.aetherchanges) instead of being produced byuse Ichoratdextrin's own compile time.Dextrin.Text.Grammaris now a thin, hand-documented wrapper around the generatedGrammar.Native. This letsmix.exsdepend on the smallichor_runtimepackage (the only thing the generated code actually calls) as an ordinary runtime dependency, whileichorproper (the Aether front-end, format importers,Grammar.Analysis, both codegen backends) moves toonly: :dev, runtime: false— the bulk of Ichor no longer ships in adextrinrelease. Both are now published to Hex separately (ichor ~> 0.2.1,ichor_runtime ~> 0.1.0) and referenced as ordinary Hex dependencies — nogit:/sparse:/override:needed, sinceichor's ownmix.exsnow depends onichor_runtimethe same way. Migrating surfaced a genuine gap in that split, fixed upstream: the raw-capture-node re-evaluation entry point needed byDextrin.Text.Actionsat actual decode time (for@ordered %{...}) had stayed on the top-level, dev-onlyIchormodule instead of moving toichor_runtime; it's nowIchor.Actions.evaluate_node/3.Added
credo,dialyxir,sobelow, andexcoverallsas dev/test tooling, plus amix precommitalias (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 deadDuration.microsecond || {0, 0}fallback (that field is nevernil).Every hand-rolled "walk a collection, thread an accumulator, halt on the first non-
{:ok, _}step result" reduce acrossDextrin.Schema.Compiler,Dextrin.Schema.Validator,Dextrin.Binary.Encoder,Dextrin.Text.Printer, andDextrin.Text.Actionsnow usesIchor.Toolkit.Result.reduce_ok/3/map_ok/3instead of a hand-rolledEnum.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.mdformat specification moved underguides/dxn/DXN.md, alongside a full set of tutorials, examples, and cheatsheets for both this library and the DXN format itself.DXN.mditself gained a new normative §4, "Schema documents (.dxns)" — thetype_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, inDextrin.Schema's own module docs) — closing a gap where.dxnswas 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.dxnsschema compilation both depended, internally, onkeyword/map-key text always decoding asDextrin.Keyword/Dextrin.Symbol— exposed as a real regression the momenttrusted: truebecame the default, not merely a test-assertion mismatch: struct literals failed to parse at all, and any schema whose own.dxnssource 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 acceptedDextrin.Keyword/Dextrin.Symbol;Dextrin.Schema.Compiler.compile/3normalizes a decoded.dxnsdocument's atoms back toDextrin.Keywordonce, up front (mirroringDextrin.Schema.Validated.strip/1's exact recursive-walk shape), so its own.dxns-vocabulary pattern matching needs no changes at every site; a schema'senumliterals 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/falsefield values are deliberately not touched by that normalization (only.dxnssyntax positions are) — andx: :nilas a schema field's type (the"nil"primitive) has its own dedicated fix, sinceString.to_atom("nil")and the bare literalnilare, once decoded, the exact same unrecoverable Elixir value.Dextrin.Binary.Decoder's handling of thetimetag (210) had no fallback clause — a malformed payload that wasn't an integer crashed withFunctionClauseErrorinstead of a clean{:error, _}, the one tag missed in the earlier decoder-hardening pass. Found by the property-based fuzz test firing again after thetrusted:default flip added more generated shapes to exercise it with.Dextrin.Text.Printer/Dextrin.Text.Formatterno longer produce unparseable text for a map entry or struct field whoseDextrin.Keyword/field name isn't a bareidentifier(empty, containing spaces, ...) —map_entry's colon-shorthand (DXN.md§1.2) only exists for anidentifier; both printers now fall back to the arrow form (:"name" => value) for anything else, instead of emitting invalid syntax like%{"":0}or a bare: 0that failed to parse back at all.Dextrin.Text.Printer.bare_identifier?/1is now public so both printers share one rule.Dextrin.Binary.Decoderno longer crashes (raises) on a malformed.dxnbdocument that places atuple/array/set/sorted-settag around a non-array payload, a bignum/uuidtag around a non-bytes payload, auri/symbol/keywordtag around non-text, achartag around a non-codepoint or out-of-range value, adatetag around a non-integer, or atimestamp/datetimeepoch outside the representable range — every one of these now returns a clean{:error, %Dextrin.Error{}}instead of an unhandledFunctionClauseError/ArgumentError/CaseClauseError. Found by the new property-based fuzz test, not a hand-picked case.Dextrin.Text.Printer.print/2no 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'tDextrin.Tuple, ...) — a clean{:error, %Dextrin.Error{}}now, matchingencode/2's own typespec, instead of an unhandledFunctionClauseError.@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_entrykey present, which a zero-entry map simply doesn't have (the same reason the ordinary, non-@orderedmap_lithandler already defaults it to[]). Found by the new per-type property suite.Dextrin.Binary.Decodernow acceptsDXN.md§2.4's CBOR string -reference sharing extension (tags 256/25) — previously an otherwise-well-formed.dxnbdocument using it (to compactly represent repeatedsymbol/keywordtext 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/2can now encode a registered application struct (Dextrin.Registry.put_struct_module/3) directly — previously, only a hand-builtDextrin.Structcould actually be serialized; a real struct registered viaput_struct_module/3was recognized by automatic encode-time validation but had no path to actually being written out, forcing callers to reconstruct aDextrin.Structfrom the real struct's own fields by hand before encoding.Dextrin.Text.Printer/Dextrin.Binary.Encodernow rebuild the equivalentDextrin.Structautomatically, in the compiled schema's own canonical field order.