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