Skip to main content

Current capacities

Types

  • Primitives - bytes, bstr, tstr, text, uint, nint

  • Fixed values - null, nil, true, false

  • Optional fixed-value members - foo = [x: uint, ? v: true], {? k: 5, x: uint} — the one-bit present/absent state is stored as a bool presence field (see Output format); covers bool, null, uint, nint, text, and float ([x: uint, ? f: 2.5, label: text]) fixed values in both array and map representations

  • int (signed integer, uint / nint) - maps to a generated Int enum covering the full CBOR integer range, in any position: as a member, an array element, a table value, or a bare top-level alias (x = int; x = bytes .cbor int mints a wrapper struct over Int rather than an alias — see the auto-wrapping rule bodies below)

  • Floats - the CDDL name is a set of VALUES, not of encodings. RFC 8610 § 2.2.3 is explicit that the #7.x notation "is about a set of values at the data model level … it does not mandate that these values also do have to be serialized as half-precision floats: CDDL does not provide any language means to restrict the choice of serialization variants", and § 3.3 defines float32 as "a number representable as a single-precision float". The six prelude names therefore partition the float values by their shortest lossless form, and the generated codec implements exactly that partition in both directions and in every profile:

    CDDL namecontains the values whose shortest lossless form isRust typewrites
    float160xf9f320xf9
    float320xfaf320xfa
    float640xfbf640xfb
    float16-320xf9 or 0xfaf32that width
    float32-640xfa or 0xfbf64that width
    floatany float valuef64that width

    The classes are disjoint: 1.5 is a float16 and not a float32, at any head. That is the only reading under which float16-32 and float32-64 are not redundant spellings of float32/float64.

    Decoding accepts every float head0xf9, 0xfa and 0xfb alike — and judges the decoded value. An 0xfb-headed 1.5 is a perfectly good float16; an 0xfa-headed 1.5 is not a float32. Head-strictness would be wrong in both directions here: RFC 8949 § 4.1 preferred serialization lets any conforming encoder pick a different width for the same value, so a head-strict reader would reject its own class's canonical bytes. A value outside the class is a decode error with the usual location annotation.

    Writing is always the smallest head that preserves the value (RFC 8949 § 4.1 preferred serialization), uniformly, the same rule the integer writes follow. For a member of a constrained class that head is its declared width — membership means "the value's shortest form is that width" — so no separate width-choice rule is needed. Under --preserve-encodings the head width is ordinary encoding data: a recorded head replays byte-exactly whenever it still represents the value (so the 0xfb-headed 1.5 above round-trips as 0xfb), and only a value with no record takes its shortest form. --canonical-form ignores recorded widths and writes the shortest form.

    Because every value a class contains is exact in its Rust carrier, decoding never loses precision and a NaN payload survives byte-exactly at every width (both conversions are done in software: an as cast's NaN-payload behaviour is not merely platform-dependent — LLVM const-folds the conversion to a canonical quiet NaN, so as can differ between the const-evaluated and runtime paths of one binary). Note that a NaN's class follows the same rule as any other value: the canonical quiet NaN's shortest form is 0xf9 0x7e00, so it is a float16 value and not a float64 one.

    On the write side the partition is visible as a membership check: serializing a value the member's class does not contain — 1.1 in a float16, or 1.5 in a float32fails rather than picking some head that would fit. Writing it wider would emit bytes this crate's own decoder rejects for that member; writing it narrower would round the value. The error is returned through Serialize, but note that the convenience to_cbor_bytes() door unwraps it, so that door panics on such a value; call cbor_event::se::Serialize::serialize directly (or keep the value inside its class) where the input is untrusted. This is the pre-existing design of that door, not specific to floats

  • Array values - [uint]

  • Table types as members - foo = ( x: { * a => b } )

  • Inline groups at root level - foo = ( a: uint, b: uint)

  • Array groups - foo = [uint, tstr, 0, bytes]

  • Map groups (both struct-type and table-type) - foo = { a: uint, b: tstr } or bar = { * uint => tstr }

  • Open struct-maps (a struct-map mixing fixed members with a trailing * K => V rest row) - foo = { 1: uint, * uint => any }: unknown map entries are captured into a generated rest field instead of being an error (the loose-CBOR forward-compatible shape), across Rust, JSON, and wasm. The key domain is a general type (uint/text/any plus bytes, nint, sized ints, bool, unions, structs, tagged/.cbor domains, externs, generic instances and aliases; float-containing and null-admitting key domains are rejected). A rest key is a JSON object member name, so a typed key domain images through its own CBOR bytes on the JSON side (text verbatim, uint/nint decimal, everything else a loud to_json error) — see Typed key domains in JSON. The rest field defaults empty and is excluded from new(), so adding a rest row is source- and wire-compatible. @name/@duplicates apply to the row; preserve/canonical round-trip byte-exact. The @ignore directive on the row selects the tolerate-and-drop flavor instead: unknown entries are typed-deserialized and discarded (no rest field, serialize emits declared members only — a deliberately lossy view type, rejected under --preserve-encodings). See Open struct-maps.

  • Open arrays (an array mixing fixed members with a trailing * t rest tail) - foo = [uint, tstr, * uint]: trailing elements after the declared members are captured into a generated rest Vec instead of ending the decode (the array analog of the open struct-map rest row). The tail must be final, occur exactly *, and follow ≥1 fixed member; the rest field defaults empty and is excluded from new(), so adding a rest tail is source- and wire-compatible (empty tail ≡ the closed array). @name renames the tail field; preserve/canonical round-trip each element byte-exact in position order (no keys, so no duplicate policy or ordering machinery). JSON renders the tail as an ordinary array-typed field (skip-if-empty / default-on-read). The @ignore directive on the tail selects the tolerate-and-drop flavor: trailing elements are typed-deserialized and discarded (no rest field, serialize emits the declared prefix only — a deliberately lossy view type, rejected under --preserve-encodings). See Open arrays.

  • Embedding groups in other groups - foo = (0, bstr) bar = [uint, foo, foo]

  • Group choices - foo = [ 0, uint // 1, tstr, uint // tstr }

  • Tagged major types - rational = #6.30([ numerator : uint, denominator : uint])

  • Optional fields - foo = { ? 0 : bytes }

  • Type aliases - foo = bar

  • Type choices - foo = uint / tstr

  • Serialization for all supported types.

  • Deserialization for almost all supported types (see the Limitations section).

  • CDDL Generics - foo<T> = [T], bar = foo<uint>, in every instantiation position: rule RHS, keyed member (x: foo<uint>), homogeneous array element (bars = [* foo<uint>]), and bare member position (a = [foo<uint>, tstr]). In each case the anonymous instance is registered and emitted (generics on plain groups, e.g. set<a> = (* a), remain unsupported). Generic defs whose body is a collectionxs<a> = [* a], [+ a], {* k => a}, tagged or not, including the tag-set idiom described below — are also supported: a plain (non-258) collection instance becomes a transparent Vec/NonEmptyVec/map alias whose use-site fields serialize identically to the non-generic equivalent, while a 258 set def mints one nominal wrapper struct per instantiation (set<uint>SetU64; see the tag-set section below). A generic definition's body has to be a shape that registers a struct for the instance's arguments to substitute into, so four bodies are rejected gracefully at parse, each naming the supported shapes: another named type (bar<V> = foo<V, uint>), a T / null optional collapse (foo<T> = T / null — spell the / null at each use site), group choices (g<T> = [ (a: T) // (b: uint) ] — give each arm its own named group), and a type choice that is not the tag-set idiom (xs<a> = #6.258([+ a]) / [* a], whose arms differ by more than the tag). A _CDDL_CODEGEN_RAW_BYTES_TYPE_ rule is likewise rejected if given generic parameters — a raw-bytes type is its own bytes and has no element type for a parameter to name (a _CDDL_CODEGEN_EXTERN_TYPE_ rule may be generic, since it names an arbitrary hand-written type).

  • Length bounds - foo = bytes .size (0..32)

  • Non-empty containers - [+ T] maps to NonEmptyVec<T> and {+ k => v} (or the 1* spelling) to NonEmptyMap<K, V>, enforcing the "at least one" lower bound through a single TryFrom door instead of a bypassable constructor check (see Output format). Other count-permitting map markers (?, n*m, …) are rejected gracefully.

  • Integer width via .size - u = uint .size 1 maps to u8, uint .size 8 to u64, etc. .size on a signed int is rejected gracefully: per the spec semantics clarified by the RFC author (cbor-wg/cddl#32), int = uint / nint and a control distributes over the choice with .size undefined (never-matching) on nint, so int .size N means exactly the uint .size N window 0...(256**N) — a signed i{8N} reading would mis-enforce it in both directions, and the aligned unsigned reading is spelled uint .size N directly. If you mean an N-byte signed integer, use the explicit range (e.g. -9223372036854775808..9223372036854775807 maps to i64), which is fully supported.

  • Value ranges - integer (foo = -10..3, bar = int .le 10) and float (f = 0.5..10.5, g = float64 .lt 10.5) windows are both enforced (float checks are NaN-safe; see the range note below for the boundaries)

  • cbor in bytes - foo_bytes = bytes .cbor foo. The payload may be inline (bytes .cbor uint) or a named rule, and a named target is almost any rule — including one that generates a transparent alias (foo = uint) rather than a struct. It works in every position a type does: a rule body, a record field, and a type-choice arm (bytes .cbor foo / tstr). The payload is decoded from a reader over the byte string's own contents, so a nested value's framing is independent of the enclosing item — including a payload whose own collection elements or map values are themselves .cbor payloads (bytes .cbor [* bytes .cbor uint], bytes .cbor {* uint => bytes .cbor uint}), and a payload that is a choice of literal values (ce = 3 / 1 / 4, bytes .cbor ce — the c-style enum lowering, whose encodings live in the owner's sidecar under --preserve-encodings). A .cbor rule BODY generates a wrapper struct rather than a transparent alias (see Type aliases and tagged rules); the operator in a member's or arm's own type expression does not. One payload type is not supported and is rejected gracefully at generation: a second .cbor control in the SAME op chain, which is the INLINE spelling bytes .cbor (bytes .cbor uint) — both depths in one type expression. The generated serializer names one payload staging buffer per owning value, so both depths of one chain would share it; the rejection names the composition and the two working spellings for that nesting, which are a NAMED payload (inner = bytes .cbor uint, referenced as bytes .cbor inner — the rule body's wrapper struct has its own serialize fn and its own buffer, so this needs no @newtype and emits the same nested wire shape) and the collection form above. The HEAD is restricted too: RFC 8610 §3.8.4 allows .cbor only on a byte string, so a non-bytes head (uint .cbor uint, in rule or member position) is rejected gracefully at generation, naming the head.

  • Support for the CDDL standard prelude (using raw CDDL from the RFC) - biguint, etc

  • default values - ? key : uint .default 0. The default is written into the Rust primitive backing the head, so the head must be a primitive that can hold a value of the default's kind; a head with no such primitive (tdate .default 1) or of the wrong kind (tstr .default 1) is rejected gracefully at generation, naming the head.

We generate getters for all fields, and setters for optional fields. Mandatory fields are set via the generated constructor. All wasm-facing functions are set to take references for non-primitives and clone when needed. Returns are also cloned. This helps make usage from wasm more memory safe.

Identifiers and fields are also changed to rust style. ie foo_bar = { Field-Name: text } gets converted into struct FooBar { field_name: String }

Group choices

Group choices are handled as an enum with each choice being a variant. This enum is then wrapped around a wasm-exposed struct as wasm_bindgen does not support rust enums with members/values. Group choices that have only a single non-fixed-value field use just that field as the enum variant, otherwise we create a GroupN for the Nth variant enum with the fields of that group choice. Any fixed values are resolved purely in serialization code, so 0, "hello", uint puts the uint in the enum variant directly instead of creating a new struct.

An arm whose content is entirely fixed values (t = { a: 0 // b: tstr }, t = [ a: 0 // b: tstr ], t = [ 0 // tstr ]) is the limit case of that rule: with no non-fixed field left to lift, the variant is field-less (T::A), and the constant — plus the member key, in the map representation — is read and verified on deserialize with nothing stored. This holds in every profile. Under --preserve-encodings the variant is not field-less, because it still owns its encoding sidecars (a_encoding, a_key_encoding, len_encoding), exactly as any other arm does. The true/null kinds are the sidecar-less limit of that rule: a fixed bool or null has no encoding variation, so it contributes no a_encoding — under --preserve-encodings the arm verifies the constant without binding it and keeps only the key/len sidecars its representation has, and a bare type-choice arm (t = true / tstr) stays genuinely field-less in every profile (pinned by tests/corpus/group_choice_fixed_special.cddl). The neighbouring spelling with bool AND null literal arms beside a data arm (t = true / null / tstr) works in every profile too: the arms share the CBOR Special major type, so no type-match dispatch can separate them and the enum decodes through a brute-force try-each-arm path instead — a distinct emission site, pinned by execution round-trips under --preserve-encodings (fixed_special_type_choice_brute in tests/preserve-encodings/input.cddl). Pinned by group_choice_fixed_value_arm_emits_fieldless_variant, with tests/robustness/group_choice_fixed_arm_array.cddl and tests/robustness/group_choice_fixed_arm_bare.cddl holding the two array spellings' generation outcome.

For a map-representation group choice whose arm collapses to a single field (foo = { a: uint // b: tstr }), the enum variant stores only the value; the fixed member key lives in the IR and is written on serialize and verified on deserialize, so the output is a well-formed map(1) { key: value } that any CBOR implementation reads and spec-valid input is accepted. Arms whose member-key types differ ({ 1: uint // b: tstr }) dispatch on the key's CBOR type; arms sharing a key type (all-text or all-uint keys) fall through to the ordinary try-each-variant path, each attempt verifying its key value. A multi-field map arm decodes through its GroupN record, keying on the first member key. Only fixed-value member keys are supported in a collapsed map arm: a non-fixed key (k => v), a keyless entry, or a non-uint/non-text key is rejected at generation time with a clear error rather than silently emitting malformed CBOR. The same limit applies to ordinary struct-map records: fixed member keys support uint and text only, and other fixed key kinds (nint, float) are rejected at generation time with a clear error — for nint it points at the table { * k => v } alternative, which keeps generating; a float key is rejected in both forms (a float-family table key domain — { * float64 => uint }, { * number => uint }, { * time => uint }, or a composite key carrying a float — is also rejected at generation, since floats have no total order and cannot key a map), so the float message advises an integer/text key instead. A literal-key arrow spelling k => v is equivalent to the colon spelling k: v (the same wire entry per RFC 8610) — single- and multi-entry alike — and generates identically. A non-literal (type-domain) arrow entry with no occurrence indicator{ tstr => uint } — is rejected at generation: per RFC 8610 an entry with no occurrence marker occurs exactly once, and treating it as a 0..N table would silently widen that occurrence (the generated decoder would wrongly accept e.g. an empty map); spell the table explicitly as { * tstr => uint }. A + (or the equivalent 1*) marker on such an entry — { + tstr => uint } — is honored as a non-empty table: the field type becomes NonEmptyMap<K, V> (see Non-empty containers), whose single TryFrom door rejects an empty map with the same 0 not at least 1 error at both the API and the wire, so the + lower bound cannot be bypassed. The other count-permitting markers — { ? tstr => uint }, { 2*3 tstr => uint }, { *3 tstr => uint }, { 2* tstr => uint }, { 0*3 tstr => uint } — are rejected gracefully at generation with a message naming the marker and advising * (or + when the intent is ≥1); honoring a real bounded map cardinality is a candidate feature, and silently widening these markers to an unbounded * table (which made the generated decoder wrongly accept out-of-window maps) was the bug removed by that rejection. A bareword key that is a Rust keyword ({ if: uint }) would emit an invalid field identifier, so it is rejected with a clear error; rename it with a ; @name <other> comment directive (the CBOR wire key stays the bareword text). The same rejection covers a reserved generated-local name in any record — see Reserved field names.

Reserved field names

A field whose emitted identifier is one of the fixed locals the generated serialize/deserialize bodies bind would shadow that local, and the crate would generate at exit 0 and fail to compile two build steps from the CDDL line that caused it — so it is rejected at parse time instead, with the field, the rule and the remedy in the message. The reserved set and where each member applies:

Field nameReserved inThe local it shadows
rawevery recordthe deserializer parameter (fn deserialize(raw: &mut Deserializer))
lenevery recordthe array/map length read
len_encodingevery recordthe container's own length-encoding companion
readmap-representation recordsthe deserialize loop counter
orig_deser_ordermap-representation recordsthe preserve-encodings member-order record
text_keymap-representation recordsthe unknown-key path's key binding
tagrecords inside a #6.n(…) tagthe tag read (let tag = raw.tag()?)

Each name is refused uniformly across profiles — several of them only break under --preserve-encodings, and a spec author does not choose their consumer's flags — but only in the shapes whose emitted body binds the local, so the common tag: 0 group-choice discriminant in an untagged array keeps generating. The check runs on the resolved field name (after ; @name and snake_casing), so ; @name raw is refused and raw: uint ; @name payload is accepted. Two fields in one record that stand in an <f> / <f>_encoding relation (also <f> / <f>_key and <f> / <f>_key_encoding in a map record) are rejected for the same reason: --preserve-encodings mints a per-field encoding companion whose name is exactly the other field's. In every case the remedy is ; @name <other> on one of the entries, and the CBOR wire key is unchanged.

Type choices

Type choices are handled via enums as well with the name defaulting to AOrBOrC for A / B / C when inlined as a field/etc, and will take on the type identifier if provided ie foo = A / B / C would be Foo. Any field that is T / null is transformed as a special case into Option<T> rather than creating a TOrNull enum. An optional member of such a type (? f: (T / null)) therefore nests two Options and carries three states — absent, present-null, present-value — which the CBOR and JSON surfaces both keep distinct; see Optional members whose type is nullable.

A special case for this is when all types are fixed values e.g. foo = 0 / 1 / "hello", in which case we generate a special c-style enum in the rust. This will have wasm_bindgen tags so it can be directly used in the wasm crate. Encoding variables (for --preserve-encodings=true) are stored where the enum is used like with other primitives.

Identical arms collapse into one variant. A choice's decoder returns the first arm that accepts the bytes, so two arms that build the same type are one arm on the wire: c = tstr / tstr (and text / tstr, the same type under two prelude spellings, and 1 / 1) can only ever decode as the first, and a second variant for it would be constructible but unreachable. cddl-codegen drops the duplicate arm, naming the rule and the arm it collapsed into. If you want the extra variant anyway — a distinct Rust/wasm constructor over the same wire form — give it its own ; @name <Name>: an explicitly named arm is kept (and announced, since nothing on the wire will decode to it). This is exact-representation matching, not overlap analysis: arms that genuinely overlap without being identical ([ ga: -10...10 / tstr // tstr ], or a bytes .cbor uint arm beside a plain bytes arm) stay as written, and first match decides at runtime. With --emit-tests=true the emitted round-trip asserts exactly that property — the decoded variant is never later than the minted one, its value matches when the decoder lands on the minted arm, and the re-encode is byte-identical either way.

Transparent tag-set idiom (#6.N([* a]) / [* a])

258 set rules are NOMINAL — named AND generic-instance. A tag-258 set rule (the two-arm idiom my_set = #6.258([* a]) / [* a] and the single-arm mandatory-tag form my_set = #6.258([* a]), plus their [+]/bounded and @duplicates preserve flavors) is emitted as a nominal wrapper struct that OWNS its {tag, len, elem} encodings — pub struct MySet { inner: OrderedSet<a>, encodings: … } (default-profile tuple form MySet(OrderedSet<a>)), grammar deciding the tag record (the two-arm optional tag rides a TagPresenceEncoding; the single-arm mandatory tag an Option<Sz>). It exposes the set ergonomics — Deref/DerefMut to the inner collection, borrowed + owned IntoIterator, From/TryFrom Vec conversions — and always-on encodings-ignored PartialEq/Eq/PartialOrd/Ord/Hash (map-key/== usable, matching the inner twin). JSON stays transparent (serializes as the bare inner array). Wire bytes are unchanged from the transparent-alias era — only the source-level API differs. A generic set def mints one nominal per instantiation: set<a0> = #6.258([* a0]) / [* a0] with set<key_hash> at N field sites mints exactly one SetKeyHash (name = <def>_<args>); a named binding of an instance (named_set = set<key_hash>) becomes a transparent alias to the instantiation nominal (pub type NamedSet = SetKeyHash;). The author's spelling is the identity — set<uint> (→ SetU64) is a different nominal from the inline [* uint] shape, and two identically-bodied defs (set<a0> vs myset<a0>) mint distinct nominals. Only inline (non-generic) occurrences remain transparent (described below).

A two-arm type choice whose arms are the same collection differing only by one tagmy_set = #6.258([* a]) / [* a], the Cardano ledger's set/nonempty_set/nonempty_oset shape — is not two types: both arms denote the same logical value, and which arm was written is an encoding detail. cddl-codegen recognizes this structurally (any tag number, not just the IANA finite-set tag 258) and collapses it into one transparent collection — the same registration a bare #6.N([* a]) array rule generates (its alias target — Vec/NonEmptyVec or the OrderedSet/NonEmptyOrderedSet uniqueness twin — depends on the effective duplicates policy; see Duplicates policy below) — instead of a MySetArrOrArr enum that would leak the encoding into the type. The [+] flavor collapses to NonEmptyVec (distinct from the [*]/Vec flavor) and enforces its "at least one" bound through the single TryFrom door. Recognition is unconditional and structural (no comment directive): the arm distinction carries no type-level information, so the collapse is the correct default and the enum was the accident. The collapse fires for named rules and for CDDL generics (set<a> = #6.258([* a]) / [* a]). For a 258 set the collapsed body nominalizes: a named non-generic rule mints its own wrapper (above), and a generic def mints one nominal per instantiation (set<uint>SetU64, set<key_hash>SetKeyHash) — the generic and non-generic paths converge by BOTH nominalizing, so a field holder = [items: set<uint>] delegates to the instantiation nominal and both wire arms roundtrip byte-exact under --preserve-encodings. On the wasm boundary each instantiation surfaces its own #[wasm_bindgen] class (SetU64, SetKeyHash), distinct from the inline [* uint] / [* key_hash] shape (which stays the structural KeyHashList-style wrapper). A named binding of an instance (named_set = set<key_hash>) is a transparent pub type NamedSet = SetKeyHash; on both the rust and wasm sides — one class, a passthrough alias. The nominal's new(inner) still rides the structural collection wrapper (KeyHashList for a preserve Vec inner, the <Elem>OrderedSet twin for reject), so a downstream --wrapper-requests consumer importing that structural name is still satisfied by the dep's own spec; the wasm class also flattens the collection surface (len/get(index)/insert/add/contains/try_from/try_opt_from) so JS reads are single-layer. (For a non-258 collapsed tag, or a plain non-set generic collection, instances stay transparent Vec/NonEmptyVec/map aliases as before.)

Under --preserve-encodings=true the tag becomes a tri-state encoding variable (TagPresenceEncoding, absent | present-with-size-hint), so a value read tagged re-serializes tagged and a value read untagged re-serializes untagged — either wire arm roundtrips byte-exact. A newly constructed value defaults to tagged with the fit-minimal tag size (for tag 258 that is size 2), matching current-era ledger emission. Canonical-form policy (--canonical-form=true): canonicalization normalizes the tag's size but never its presence — the arm the author/data used is preserved, because other implementations validate structurally and canonicality governs encoding minimality only. Without --preserve-encodings there is no encoding var: serialize always writes the tag (the default) and deserialize accepts either arm.

Duplicates policy — tag 258 defaults to reject. The alias target of a collapsed set depends on its effective @duplicates policy, and that default is tag-keyed through a well-known-tag registry. Tag 258 is the IANA finite-set tag, so a #6.258 that directly wraps a homogeneous occurrence collection ([* a] / [+ a], single-arm or the two-arm idiom) carries set semantics — uniqueness — and defaults to @duplicates reject: the transparent alias targets an order-preserving, duplicate-free OrderedSet<a> / NonEmptyOrderedSet<a> whose single TryFrom door refuses a duplicate identically on the wire (DeserializeFailure::DuplicateKey(Key::Uint(i))) and through the API, across the Rust, JSON, and wasm boundaries (an element with no bare-Vec form — bytes, bool — enters the wasm door through its loose list wrapper, &BytesList / &BoolList; see Wasm differences). The structural collapse itself stays tag-agnostic — any tag number collapses; only the duplicates default is registry-driven, and only tag 258 has an entry, so every other collapsed set (a non-258 tagged idiom, a plain untagged [* a] / [+ a]) keeps the preserve default (Vec<a> / NonEmptyVec<a>, duplicates accepted and re-emitted byte-exactly in wire order). The registry's shape guard is "directly wraps a homogeneous occurrence collection", so it deliberately excludes shapes where uniqueness is meaningless: a record-shaped #6.258([uint, text]) (a Record), a tagged primitive #6.258(text) (a Wrapper), and a tagged map #6.258({* k => v}) (a map is not a set) all get nothing from the registry.

Per-rule you can always override the default: ; @duplicates reject self-documents the 258 default (a no-op), and ; @duplicates preserve is the opt-out back to plain Vec/NonEmptyVec — today's wire behavior verbatim — for a rule that must accept and re-emit historical duplicate-bearing data (see comment DSL).

Inline positions nominalize into shape-derived set types. The registry is not limited to named rules: an inline #6.258([* a]) / #6.258([+ a]) written directly at a member, optional member, array-element, map key/value, or generic-argument position also carries set semantics. Rather than inlining a transparent OrderedSet<a>, it mints a shape-derived nominal wrapperSet<Elem> (SetU64, SetText, SetNonEmptyText for [+], SetSetU64 for a nested set…) — one per deduped inline shape in the spec, owning its own {tag, len, elem} encodings exactly like a named or generic set nominal, and defaulting to @duplicates reject (its inner is the OrderedSet<a> / NonEmptyOrderedSet<a> uniqueness twin). The name derives deterministically: the Set prefix is the tag-258 registry entry, the element spelling is the same for_variant() scheme the generic instantiations (SetKeyHash) and wasm structural names use. The same shape guard applies inline — an inline record-shaped #6.258([uint, text]), tagged primitive #6.258(text), or tagged map #6.258({* k => v}) gets nothing. Because an inline occurrence has no comment slot for @duplicates, the opt-out is to hoist it to a named rule that carries the directive (input_set = #6.258([* input]) ; @duplicates preserve, then reference input_set); a generation-time notice names the minted nominal and prints this recipe. One boundary remains: the two-arm collapse (#6.258([* a]) / [* a] → one collection) is named-rule-only — an inline two-arm choice is not collapsed (it stays a two-variant enum, per Near misses below), so only its tagged arm nominalizes (to Set<Elem>) while the untagged arm stays a plain Vec. Opting an inline occurrence out is therefore always the hoist, never an inline directive. Nesting is fully handled: an inline #6.258 array nested inside another set — including inside a named two-arm idiom rule (foo = #6.258([* #6.258([* uint])]) / …) — nominalizes at every level (the outer wraps OrderedSet<SetU64>).

Breaking change (decode-time)

A tag-258 set previously defaulted to preserve; it now defaults to reject. Generated code still compiles, but a decoder built from a no-directive 258 set now fails with DuplicateKey on loose historical bytes that carried duplicate elements. The migration is one line of CDDL you control — add ; @duplicates preserve to the rule; for an inline #6.258 occurrence with no rule-level comment slot, hoist it to a named rule to carry the directive (input_set = #6.258([* input]) / [* input] ; @duplicates preserve, then reference input_set).

Preserve-mode tables (@duplicates preserve). The duplicates policy has a mirror on tables. A CBOR map's default representation (BTreeMap/OrderedHashMap, keyed by key value) is structurally incapable of holding two entries with the same key, so reject — collapse duplicate keys — is a table's default and ; @duplicates reject on a table is an accepted no-op. Opting a table into ; @duplicates preserve swaps its transparent alias to the Vec<(K, V)>-backed PairMap<K, V> (or NonEmptyPairMap<K, V> for {+ k => v}) — the only shape faithful to both entry order and duplicate keys, so a duplicate-keyed map decodes and re-emits byte-exactly in wire order. An anonymous inline table in type position (a union arm, a field or element type) takes the directive on its own * k => v row instead of at rule position, and gets the same twin at that one use site (see Output format). The driver is pre-Conway Cardano transaction_metadata: its auxiliary-data hash is computed over the ORIGINAL bytes, so a reader that collapses or reorders duplicate keys fails hash verification. This holds across the Rust, JSON, and wasm boundaries; the JSON representation of a preserve table is an array of [key, value] pairs (not a JSON object — a JSON object cannot carry duplicate keys either), which diverges from the loose table's object shape. Under --canonical-form=true entries stable-sort by encoded key bytes with duplicates left adjacent in first-appearance order — a deterministic best-effort, since duplicate-carrying data has no RFC 8949 canonical form (never a refusal).

Near misses keep today's enum (no error, no collapse): mismatched occurrence bounds (#6.258([+ a]) / [* a]), different element types (#6.258([+ uint]) / [+ text]), both arms tagged (#6.258([+ a]) / #6.259([+ a])), a non-collection inner, or three or more arms. Recognition is limited to named-rule type choices; inline/anonymous choices are out of scope. Note: any pre-existing spec with the recognized shape changes its generated API from an enum to a transparent alias — the intended correction, but a breaking change for that type's consumers.

Migrating a consumer across the set changes

The set redesign — tag-258 defaulting to @duplicates reject, and set rules/instances/inline occurrences becoming nominal wrapper types — is a breaking change for an existing consumer in four independent ways (a fifth applies only to --export-static-crate consumers). Each is engineered to fail loudly (a compile error or a decode error, never silent wrong behavior), so migration is walking the errors, not auditing for silent drift.

1. Decode-time break (reject default). A no-directive tag-258 set now defaults to @duplicates reject, so a decoder built from it fails with DeserializeFailure::DuplicateKey on loose historical bytes that carried duplicate elements — the code still compiles; the break surfaces only at decode. This is deliberate: the tag is the author's declared uniqueness intent, and the opt-out is one line of CDDL the consumer controls. To keep accepting duplicate-bearing data, add ; @duplicates preserve to the rule (today's Vec/NonEmptyVec wire behavior verbatim); for an inline #6.258 occurrence with no rule-level comment slot, hoist it to a named rule that carries the directive (input_set = #6.258([* input]) / [* input] ; @duplicates preserve, then reference input_set). See comment DSL.

2. API break (nominal wrapper types). A set rule/instance/inline occurrence is now a nominal wrapper struct (MySet, SetKeyHash, SetU64) instead of a transparent Vec/OrderedSet alias, so a call site treating the field as a bare collection no longer type-checks. The wrapper is built to make the fix mechanical — it Deref/DerefMuts to its inner collection, implements borrowed and owned IntoIterator, From<Wrapper> for Vec<T> (infallible unwrap), and a duplicate/emptiness-checking TryFrom<Vec<T>> (the construction door). Recipes:

  • build from a vector: let set: MySet = vec.try_into()?;;
  • unwrap for an FFI/legacy sink: let vec: Vec<T> = set.into(); (or Vec::from(set));
  • read through Deref: holder.field.len(), for x in &holder.field { … }, indexed reads via OrderedSet::get(i), or &*holder.field where a borrow of the inner collection is wanted.

Comparisons and hashing are unchanged: the wrapper derives PartialEq/Eq/PartialOrd/Ord/Hash (encodings ignored, matching the inner twin), so it stays usable as a BTreeMap/HashSet key. See Output format for the full surface.

3. Comment-preservation degradation. A set field's encodings moved off the holder struct into the nominal's own {tag, len, elem} encoding struct, and its serialize/deserialize moved out of the holder body into the nominal's impl. A cddl-codegen:replace/insert block a consumer anchored inside a relocated holder serialize body therefore no longer finds its recorded original at that anchor and degrades to a cddl-codegen:unpreserved-comment compile_error! block (the drift / "no longer exists" channel) — en masse across a set-heavy crate on the first regen after upgrading. Nothing is lost: each block carries its original text forward verbatim in the error message until deleted. Re-place each block by hand against the nominal's new serialize impl (where the code now lives), then delete the marker. See Preserving edits.

4. Wasm class re-key. A generic set instance previously converged its wasm wrapper onto the structural list/OrderedSet class name; it now surfaces one nominal class per instantiation (SetKeyHash, SetU64), and an inline #6.258([* T]) occurrence likewise surfaces its shape-derived nominal class. The structural boundary class (KeyHashList, U64OrderedSet) is not eliminated — it is the nominal's own new(inner) element-crossing boundary and stays own-produced, so a --wrapper-requests consumer importing the structural name is still satisfied. Downstream JS / --wrapper-requests code that imported a generic set instance under its structural name should move to the nominal class. The nominal wasm class is now FLATTENED: it delegates the collection surface (len(), get(index), insert(elem) -> bool, add(elem), contains(elem), try_from(list), try_opt_from(list)) directly, so a JS read is set.get(i) — the old two-layer set.get().get(i) unwrap is gone (call sites re-key). A named binding of an instance (required_signers = nonempty_set<key>) is a pub type alias on both sides, and wasm-bindgen exports no type aliases — so the rule name has no JS class of its own: JS call sites re-key from the rule name (RequiredSigners) to the nominal class name (NonemptySetKey). TypeScript keeps compiling because the tool emits a typescript_custom_section export type RequiredSigners = NonemptySetKey; alongside the alias (a TS type alias only — JS value positions such as new/static methods must use the nominal class). See Wasm differences.

5. New static runtime file (--export-static-crate only). Nominal reject-sets introduced a new runtime module, ordered_set.rs. A consumer using --export-static-crate receives the runtime as files written into a hand-owned crate root the tool never edits, so a newly-shipped runtime file needs a matching pub mod ordered_set; line added by hand — otherwise the file sits dead in-tree. The failure signature is distinctive and does not look like a missing module at first glance, and which error code you see depends on how generated code reaches the module: a type-bearing runtime module (ordered_set, pair_map, non_empty*) is imported, so a generated use <crate>::ordered_set::{…} fails with E0432 unresolved import; a function-bearing helper module (open_struct_rest_json, any_cbor) is referenced by inline path instead, so a generated <crate>::open_struct_rest_json::serialize_flattened_rest(…) fails with E0433 failed to resolve — same missing pub mod, different code to grep for. Either unresolved name then cascades into a swarm of spurious E0119 "conflicting implementations … in crate <core>" errors en masse inside generated code (the unresolved error type unifies against std's blanket impl<T, U> TryFrom<U> for T), pointing you at a dozen phantom problems in machine-owned files before the one-line real cause. The tool now prints a loud warning: NEW static file ordered_set.rs … on stderr at export time naming exactly the pub mod line to add — at the default verbosity and above, so a run pinned to --verbosity error is the one case where this swarm arrives with no warning ahead of it; if you hit the E0119 swarm, add the missing pub mod <module>; for each newly-exported static file to your crate root. The same applies to json_schema_gen.rs, which joins the exported set under --json-schema-export (it hosts the helpers every wasm/json-gen crate imports, one copy per common crate), to json_value_ser.rs, which joins it under --json-serde-derives (the honest serde_json::Value walk the any adapters and hand-written Serialize impls both route through — see Wasm differences), and to open_struct_rest_json.rs — the E0433 example above — which joins it under either json flag, carrying the flatten mechanics for open struct-map rest rows under --json-serde-derives and the rest-row schema helper under --json-schema-export. --export-static-crate also carries the recipe for putting the json-schema module behind a cargo feature by hand.

Type aliases and tagged rules

A rule that is a plain reference to another type (foo = bar) or a wire-transparent wrapper (basic = (uint), where parentheses have no CBOR effect) generates a transparent pub type Foo = ... alias. Use ; @newtype to opt into a pub struct wrapper instead (see comment DSL).

A top-level range rule whose window does not collapse exactly onto a Rust primitive width — literal-headed (c = -10..-3) or typename-headed (bounded = int .le 10) — generates a newtype wrapper whose constructor and deserializer enforce the window (a transparent alias has nothing to hang the check on). An exact collapse (u8ish = 0..255) stays a pub type alias. Float-typed range windows are enforced the same way: a float range (c = 0.5..10.5, exclusive 0.5...10.5, or a control form like float64 .lt 10.5) wraps into a newtype whose constructor and deserializer enforce the window with a NaN-safe check (NaN is always rejected), and a tagged float range writes/requires its tag. Boundaries: .ne over a float, a decimal bound on an integer-primitive head (uint .le 10.5), and a range bound that is not a numeric literal (uint..10, 0..uint — a range lowers a (min, max) pair of values, so a named type as a bound has no value to lower) are rejected at generation time with a clear error (there is no principled single-value float exclusion, and silently flooring a decimal onto an int head would mis-enforce). Under --preserve-encodings a float window is enforced on the same value the default profile checks — an f32-carried member's window is checked on the f32 its read produced, and every value such a member's class contains is f32-exact — so accept/reject does not vary by profile, and no window verdict depends on a rounding step. A window and a float class compose: float64 .lt 10.5 admits the values that are inside the window and are float64 values, so 1.5 (a float16 value) is refused however the window reads.

A top-level tag rule is the exception: it always generates the tag-writing/tag-checking newtype wrapper (pub struct Tagged(pub(crate) String) whose serialize writes write_tag(42) and whose deserialize rejects a wrong tag with TagMismatch). This holds for every inner except the one deliberately-transparent combination in the paragraph that follows — a primitive or named type (tagged = #6.42(text), uri = #6.32(tstr)), a bytes .cbor T wrapper (foo_bytes = #6.20(bytes .cbor foo)), a .default-carrying inner (#6.42(uint .default 5) — the default has no meaning on an always-present standalone value and is dropped), a ranged inner (#6.42(uint .le 255) or the literal-headed #6.5(3..10), whether or not the range collapses exactly onto a rust primitive), a collection (tagged_arr = #6.24([* uint]), tagged_table = #6.11({* tstr => uint}), and the optional-tag idiom #6.n([* t]) / [* t] in both its array and map flavors), and a T / null body (topt = #6.10(uint / null), whose wrapper holds an Option<T> inner). A transparent pub type alias cannot carry a custom serialize impl, so an alias would silently drop the tag from that type's own standalone to/from_cbor_bytes API while every embed site of the rule still wrote and required it — one CDDL type with two wire forms, a CBOR conformance bug. @newtype is therefore redundant (though harmless) on a tag rule, and on a tagged T / null rule it used to be silently ignored; both spellings now produce the identical wrapper. Tag rules whose body is a group (rational = #6.30([...])) already generate a struct that writes the tag and are unaffected.

One combination is deliberately left transparent: a tagged table carrying @duplicates preserve (t = #6.n({* k => v}) ; @duplicates preserve). Its inner is the PairMap vec-of-pairs twin, which a wrapper cannot hold — the wasm boundary for a PairMap-inner wrapper class is unwired — and refusing the shape would drop support that exists today. It keeps a transparent alias, so that rule's standalone to/from_cbor_bytes still drop the tag; reach it through a holder, or drop @duplicates preserve, until the wasm wrapper lands. This is a ledgered residual, tracked in cddl-matrix/ROADMAP.md under "A tagged PRESERVE table's standalone codec drops the tag".

A top-level bytes .cbor T rule is the same exception, for the same reason: foo_bytes = bytes .cbor foo always generates the byte-string-framing newtype wrapper (pub struct FooBytes(pub(crate) Foo) whose serialize writes the payload into a nested serializer and write_byteses it, and whose deserialize reads raw.bytes() and decodes the payload from it). A transparent pub type FooBytes = Foo; alias would have left FooBytes::to_cbor_bytes as Foo's — writing the bare payload where the spec says a byte string, and accepting the bare form while rejecting the spec's own bytes — while every embed site of foo_bytes wrapped correctly: one CDDL type with two incompatible wire forms selected by use-site. @newtype is therefore redundant (though harmless) here too; its only remaining effect is renaming the getter. The wrapper writes exactly what the member position always wrote, so no wire bytes moved — what changed is the API: members and arms declared through such a rule are now typed by the wrapper (pub f: FooBytes, constructed with FooBytes::new(inner) / inner.into()) instead of by the payload type, and the wasm face gains a wrapper class where it had a bare pub type.

One tag-rule body is not supported under --preserve-encodings and is rejected gracefully there: an anonymous choicet = #6.10(int / tstr), the group-choice spellings (#6.10([ a: uint // b: tstr ]), the map twin), and the all-fixed one (#6.10(0 / 1 / 2), which under this profile is denied the C-style enum lowering). Such a rule mints an enum that carries the tag, while the encoding metadata --preserve-encodings records is per-variant, so the enum has nowhere to store how the tag was written. Name the choice and tag the NAME instead — inner = int / tstr with t = #6.10(inner) — which mints a tagged wrapper over the enum and round-trips byte-exact; a tagged member of a named enum ([f: #6.42(inner)]) works the same way. Tags over structs, arrays and maps are unaffected, and without --preserve-encodings the anonymous form generates.

The any type (loose CBOR)

CDDL any (the prelude type matching any single well-formed CBOR item) is supported in every position: a struct-map member value ({ 1: any }), an array-record member ([any, uint]), a homogeneous array element ([* any]), a table domain and/or range ({ * any => any }, { * uint => any }, { * any => uint }), a top-level alias (x = any), a tagged rule (t = #6.11(any)), and a type-choice arm (see below). It lowers to the AnyCbor runtime value — see The any type for the runtime type, its value-vs-representational flavors, and self-carried encodings. Because AnyCbor has a total order by construction, any is a legal map-key/table domain in both preserve and non-preserve modes.

any generates under every flag combination — the Rust surface, the wasm AnyCbor wrapper, and the JSON/schema surfaces under --json-serde-derives / --json-schema-export. JSON is the deliberately lossy side: any renders as a JSON representation of CBOR (single-key tagged objects, encodings dropped, non-finite floats and NaN payload bits not round-tripped) rather than natural JSON — see the output-format JSON table. One runtime limitation follows the crate-wide non-string-key-table posture: a table keyed by any ({ * any => any }) serializes each key as a JSON object, so to_json errors at runtime exactly as a { * bytes => uint } table already does — string/uint-keyed any ranges ({ * uint => any }, the demanded metadata-table shape) are unaffected.

Type-choice arms. A bare any arm (x = a / b / any) is a catch-all — it matches every CBOR item — so it is allowed in last position only; a non-last bare any arm makes every later arm unreachable and is rejected gracefully at generation (`any` arm makes later arms unreachable — move it last). The trailing any arm forces the choice deserializer onto its backtracking strategy (try each arm in source order, rewinding on failure) rather than CBOR-type dispatch, so a typed arm that matches on type but fails on content falls through to the any arm: x = uint .le 5 / any decoding wire 0x06 (the integer 6, outside the .le 5 bound) round-trips through the Any arm. A tagged any arm (x = #6.11(any) / tstr) is not a catch-all — its wire type is Tag, distinct from the other arms' — so it dispatches like any tagged arm and is allowed in any position. A container of any as an arm (x = [* any] / tstr) is likewise an ordinary supported arm ([* any]'s wire type is Array). Note the distinct construct # (Type2::Any, a bare # matching any item at the grammar level) stays unsupported and is a separate Limitations row.

Open tables (a typed row plus a catch-all)

A named rule of exactly two * k => v rows — t = { * K_t => V_t, * K_r => V_r } — is supported under every flag combination: one typed table row plus one trailing typed catch-all, routed by CBOR wire major type peeked before any deserializer runs. The emitted face, the JSON face and the dispatch contract are in Output format; the CDDL-side rules (what makes K_t's major statically knowable, the @custom_wire_major declaration a custom-codec key needs, and every graceful rejection) are in the comment DSL. The {+ …} spelling adds a min-1 bound counting typed entries.

The one limitation worth stating here is a JSON one, and it is conditional on K_t. Both rows share one JSON object and a member name binds the typed row first, so the JSON round trip is a fixed point only when the two rows' admissible member names are disjoint (or when a captured value also reads as V_t). A K_t admitting every member name therefore leaves the catch-all unreachable through JSON, and from_json refuses documents to_json wrote. Where that is decidable — a K_t transparently resolving to String — the tool refuses to generate a JSON face at all. Where it is not — an opaque K_t, i.e. an extern or a @newtype whose hand-written serde reads every string — it stands as a hazard the spec author owns: such a key type makes the rule a CBOR-only shape. The CBOR face has no such conditionality; there the two rows partition by major and always round-trip.

Recursive types

Recursive types are supported when the emitted Rust for the cycle satisfies two conditions: the cycle passes through at least one nominal type (a generated struct or enum — not a pub type alias), and it crosses heap indirection (a collection: a [* …] array becomes a Vec, a table becomes a map type). tree = [value: uint, children: [* tree]] satisfies both — a Tree struct whose recursive occurrence sits inside Vec<Tree> — and generates and round-trips normally. Which rule of a cycle the generator reaches first does not change the result: h = [mdmap] and h = [md], over the same md = mdmap / int and mdmap = { * text => md }, produce equivalent code.

Whether the CDDL itself can terminate is neither necessary nor sufficient — both classes below are spelled by terminable specs. A cycle that misses one of the two conditions is one the emitted Rust could not compile, so the generator does not hand you such a crate. What it does instead depends on whether a repair is available:

  • Alias-only cycles — no nominal type on the cycle — would fail with rustc's E0391 ("cycle detected when expanding type alias") even where indirection is present, because alias expansion is structural: x = [* x] would emit pub type X = Vec<X> and mdmap = { * text => mdmap } would emit pub type Mdmap = BTreeMap<String, Mdmap>. When the cycle contains at least one named collection rule, the generator repairs it automatically by applying @newtype to every collection-backed rule of the cycle — emitting a wrapper struct, which is a nominal node — and announces the change on stderr at the default verbosity and above. The result is byte-identical to writing ; @newtype on those rules yourself, which is the way to make the wrapper explicit and silence the notice. Every collection-backed member of the cycle is nominalized, rather than some minimal subset, so the emitted API does not depend on the order the rules appear in.
  • Cycles with no repair in reach are refused: the generator exits non-zero, writes nothing, and names the cycle and the members that close it. Two shapes fall here. A nominal cycle without heap indirection would fail with rustc's E0072 ("recursive type has infinite size"): md = mdrec / int with mdrec = { a: md }, or foo = [foo]. An optional member does not help — a = { ? next: a } fails the same way, because an Option stores its payload inline. The generator emits no Box in any type position and there is no directive that asks for one, so the remedy is to restructure the spec until the cycle crosses a collection on a struct/enum node (as tree does). An alias cycle with no named collection in it (x = y with y = x) has nothing to nominalize, and the remedy is to give some rule of the cycle a body of its own.

The refusal is a property of the cycle rather than of the traversal that found it, so permuting the rules in your spec changes neither the message nor which rules are auto-repaired. The Recursive type: … stderr notice is a cycle detector, not a defect detector — it fires for supported recursion too (tree prints it and compiles), and it is not the boundary.

The generated deserializer is recursive-descent, so by default it has no depth bound: maliciously deep CBOR recurses until the thread's stack overflows and the process aborts (SIGABRT) — this is a process abort, not a returnable error, so it cannot be caught with catch_unwind. There is deliberately no default limit, because any fixed cap would reject spec-valid documents that happen to nest deeper.

Consumers that deserialize untrusted input (e.g. on-chain data) should generate with --deserialize-depth-limit=N, which makes the generated deserializers return a graceful DeserializeError once nesting exceeds N instead of overflowing the stack. See the flag's documentation for the tradeoff (it also rejects legitimately deep documents past N).

Depth is the only hostile-input class that needs an opt-in flag. The other classes are handled by the runtime (the cbor_event the generated Cargo.toml depends on, 3.3.0 or later): truncated input, over-claiming length headers, reserved simple values (the 0xfc0xfe heads, rejected as not well-formed per RFC 8949 §3.3), and stray break codes all return a DeserializeError — no panic, and no allocation sized from an untrusted claimed length (an 8-byte header claiming gigabytes errors before any allocation).

Multi-file specs, cross-crate dependencies & CDDL modules

  • Directory input: a directory of .cddl files generates one crate with a Rust module per file (subdirectories nest; see Output format). Rule names must be unique across the whole directory (duplicates are rejected at parse).
  • Cross-crate dependencies: a spec can reference types generated (or hand-written) in another cddl-codegen crate — the dependency's regen emits a machine-generated extern-interface/ description of its surface, consumed via --extern-import (see Integration with other cddl-codegen libraries).
  • CDDL module directives (;# import / ;# include, draft-ietf-cbor-cddl-modules) are recognized and refused with a precise error rather than misread as comments: silently ignoring a directive would yield a misleading undefined-reference error, or silently-incomplete output for include-composed rules. Resolve modules with cddlc before feeding cddl-codegen — but note as-namespaced output (dotted rule names like cose.label) is also rejected, since dots cannot map to Rust identifiers; only un-prefixed expansion is consumable today. Other ;# -prefixed comment lines warn; ;##### banner comments stay plain comments.

no_std support (which flags are std-gated)

The generated rust crate builds without std: it declares a default-on std cargo feature and a no_std consumer depends on it with default-features = false. There is no mode flag — one set of emitted bytes serves both. The contract, what the feature gates, and the one-time changes an existing crate meets on upgrade are in Output format § The std feature and § Upgrading a crate generated before the no_std output.

The feature forwards: it names the std feature of every dependency the tool ships with default-features = false that has one, so the list is computed per run from that run's dependency set (std = ["serde/std", "serde_json/std", "schemars/std", "hex/std"] at the widest, std = [] with nothing to forward to). Path dependencies opt in with --std-forward-dep, which a --config deps edge and [runtime].lib-name derive for you. The --export-static-crate runtime crate does the same, which is what lets a default-features = false build reach through a split layout rather than stopping at the first crate.

Two flags interact with it, and only one is a limitation:

  • --deserialize-depth-limit makes the crate std-only. The guard's counter is a thread_local!, which has no core/alloc equivalent. Rather than leaking std silently, such a crate carries a compile_error! for not(feature = "std") builds reading --deserialize-depth-limit output requires the `std` feature, and the no-std-check shim it emits is red by design. The std feature is default-on, so an ordinary consumer is unaffected; the two are simply not combinable today. A depth guard that works without std is not built because nobody has needed the combination — the loud refusal is what makes that demand visible rather than latent.
  • --emit-tests output works under no_std. The emitted #[cfg(test)] module restores std for itself (tests run on a host, where std exists to be linked), so cargo test --no-default-features --lib passes on a generated crate. --lib is required: the crate's cdylib crate-type is linked on a host target and a #![no_std] cdylib wants an allocator and a panic handler.

The wasm and json-gen crates stay std by nature (wasm-bindgen, and writing schema files to disk) and consume the rust crate with default features on. They are out of scope rather than incomplete: a no_std target reached through one of those surfaces would mean the layering is wrong for that consumer, not merely unfinished.

Types generated without a decoder (deserialize refusals)

A few accepted shapes generate at exit 0 with their encode half only: the struct/enum, its constructor, getters and Serialize/to_cbor_bytes are all emitted and compile, but the generator declines the Deserialize impl, so the type has no from_cbor_bytes. Every refusal is loud — generation prints Not generating <Type>::deserialize() - reasons: on stderr with one line per cause — and two shapes earn one:

  • An array-record ? optional field whose CBOR major types overlap what can follow it — a later declared field reachable through any run of optionals, or the open rest tail when it is reachable: foo = [? f0: uint, f1: uint]. The decoder decides presence by peeking the next element's major type, so a type-disjoint non-final optional (foo = [? f0: uint, f1: tstr]) decodes fine; on overlap the peek cannot decide and the generator refuses the decoder rather than emitting one that guesses. Remedy: make the types distinct, drop the optional, or restructure (an optional-last field never needs the peek disambiguated against a follower).
  • A map-representation record with a plain-group field (foo = { a: uint, bar } with bar = (c: uint, d: tstr)): map entries can arrive in any order, which does not fit the embedded-group decode shape. Remedy: inline the group's fields or make bar a struct of its own.

The refusal propagates to every container of such a type: an enum with it in any arm (both type-choice and group-choice flavors, transitively), a wrapper over it, and a collection or table over it each lose their own Deserialize too — announced with the same stderr notice naming the member that caused it. The alternative would be emitted code calling a decode function that was never generated. Consistently across the faces: --emit-tests skips both the round-trip and reject mints for every such type (announced per type on stderr), and the wasm wrapper exposes no from_cbor_bytes for it. The serialize-only surface compiles and works everywhere.

Limitations

This section is generated from cddl-matrix/matrix.json by cddl-matrix/query_q1_gaps.ts (regenerate with cd cddl-matrix && bun run query_q1_gaps.ts --write). It lists the constructs in cddl-codegen's target CDDL profile (RFC 8610 + the IANA control-op registry) that the generator does not yet support — its actionable gaps. "Supported" means the generated crate's emitted round-trip tests pass, not merely that it generates and compiles. Constructs that post-date the target profile are listed separately at the end (they are not gaps).

Unsupported constructs

ConstructCDDL exampleBehavior
Incremental group-choice extension (//=)tcpopts = (1: int); tcpopts //= (2: tstr)rejected gracefully at parse
Incremental type-choice extension (/=)a = int; a /= tstrrejected gracefully at parse
Generic group definitionset<a> = (* a)rejected gracefully at parse
cbor-anyx = cbor-anyrejected gracefully at parse
eb16x = eb16rejected gracefully at parse
eb64legacyx = eb64legacyrejected gracefully at parse
eb64urlx = eb64urlrejected gracefully at parse
falsex = falserejected gracefully at parse
nilx = nilrejected gracefully at parse
nullx = nullrejected gracefully at parse
truex = truerejected gracefully at parse
undefinedx = undefinedrejected gracefully at parse
Any (#)foo = #rejected gracefully at parse
Choice from named group (&)color = &colors; colors = (red: 0, green: 1)rejected gracefully at parse
Choice from inline group (&)color = &(red: 0, green: 1, blue: 2)rejected gracefully at parse
Major-type sigil (#N, #N.n)foo = #1.2rejected gracefully at parse
Major-type 7 / simple sigil (#7, #7.n)foo = #7.20rejected gracefully at parse
Unwrap (~)bar = [uint]; foo = ~barrejected gracefully at parse
Literal value as a typeanswer = 42rejected gracefully at parse
Byte-string literal valuemagic = h'cafe'rejected gracefully at parse
Numeric literal valueversion = 5rejected gracefully at parse
Text literal valuemarker = "v1"rejected gracefully at parse

Control operators

Supported: .cbor, .default, .eq, .ge, .gt, .le, .lt, .ne, .size.

Unsupported (in-profile):

.abnf, .abnfb, .and, .b32, .b45, .b64c, .b64c-sloppy, .b64u, .b64u-sloppy, .base10, .bits, .cat, .cborseq, .det, .feature, .h32, .hex, .hexlc, .hexuc, .join, .json, .oid, .plus, .printf, .regexp, .sdnv, .sdnvseq, .within.

Contextual gaps (supported top-level, unsupported when nested)

These constructs work as their own rule but are unsupported in the listed nesting role — one row per (construct, role). A role annotated with a shape count is reached by that many distinct unsupported spellings; the Example column shows one of them. For the inline anonymous composites the remedy is to name the composite: type2.map takes a rule of its own, while type2.array also accepts a ; @name on the group entry it is the whole type of; for the key and occurrence rows the remedy depends on the spelling.

ConstructUnsupported roleExample
grpent.groupnameoccurrence-targetm = { * grp }; grp = (k: int)
grpent.inline_groupgroup-choice-armt = [ (uint, tstr) // bytes ]
grpent.inline_groupoccurrence-target (5 shapes)a = [* (int, tstr)]
grpent.memberoccurrence-targeta = [uint, + bytes]
memberkey.barewordoccurrence-target (2 shapes)multi = { a: uint, 0*1 b: uint }
memberkey.type1group-choice-armt = { uint => tstr // b: tstr }
memberkey.type1map-key (7 shapes)m = { true => uint, 1: uint }
memberkey.type1occurrence-target (6 shapes)m = { 2*3 tstr => uint }
memberkey.valuegroup-choice-arm (2 shapes)flt = { 1.5: uint // b: tstr }
memberkey.valuemap-key (4 shapes)flt = { 1.5: uint, 1: uint }
prelude.anychoice-membera = any / tstr
type2.arrayarray-elementa = [[int]]
type2.arraycbor-payloadb = bytes .cbor ([uint, uint])
type2.arraychoice-membert = [int] / [tstr]
type2.arraymap-keym = { [int] => tstr }
type2.arraymap-valuem = { k: [int], j: uint }
type2.arrayoccurrence-targeta = [* [int]]
type2.maparray-elementa = [{x: int, y: uint}]
type2.mapcbor-payloadb = bytes .cbor ({a: int, c: uint})
type2.mapchoice-membert = {a: int, c: uint} / {b: tstr, d: uint}
type2.mapgeneric-argfoo<a> = [a]; bar = foo<{x: int, y: uint}>
type2.mapgroup-choice-armt = [ {a: int, b: uint} // tstr ]
type2.mapmap-valuem = { outer: { a: int, c: uint } }
type2.mapoccurrence-targeta = [* {x: int, y: uint}]
type2.tagtag-contentt = #6.24(#6.25(uint))

Out of profile (not gaps)

The following construct post-dates cddl-codegen's target profile, so it is not counted as a support gap: Tagged data item, type-valued tag number (#6.<T>) (t = #6.<n>(int); n = uint, RFC9682).

Reading the Behavior column: it is scoped to the top-level spelling

Every row of "Unsupported constructs" above is probed as a rule body (x = <construct>), and its Behavior is that probe's outcome. Member position is a different question with a different answer, in both directions, so read a row as "unsupported as a rule body" rather than "unusable":

  • Better as a member. The fixed prelude constants true, false, null / nil are listed above as rejected, yet they are fully supported as fixed-value members — [x: uint, ? v: true], {? k: false, x: uint} — which is what "Optional fixed-value members" at the top of this page describes. A constant carries no information, so there is nothing to store as a rule body; as a member it is read and verified on deserialize.
  • Unsupported in both, with different wording. A byte-string literal is listed above as rejected gracefully, and that holds in member position too — [v: h'0102', x: uint], the unkeyed [h'0102', x: uint], {k: h'0102', j: uint} and the UTF-8 spelling [v: 'text', x: uint] all exit 1 naming the construct, exactly as x = h'0102' does. Only the message differs, because the member-side one can name the role. Widening the member to bytes generates but no longer constrains the value, so it is a different spec; carrying the constraint would need a fixed-bytes member representation, tracked in cddl-matrix/ROADMAP.md under "A byte-string literal has no fixed-MEMBER representation".

undefined is the one constant with no such split in the other direction either: it is refused gracefully in every position, member and rule body alike, for the same reason — it has no value a member could carry and no type a rule could name.

The "Contextual gaps" table covers only the constructs that are supported as a rule body and unsupported in some nesting role. A construct unsupported in both places appears once, above, and its per-role behaviour lives in the containment cells of cddl-matrix/matrix.json.