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