Skip to main content

Current capacities

Types

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

  • Fixed values - null, nil, undefined, true, false, integers, floats, text literals, and parseable byte-string literals (h'CAFE', 'raw'). A named fixed-value rule is a nominal singleton with its own CBOR codec (answer = 42, enabled = true, label = "ok"); it can consequently be a record member, homogeneous-array element, table value, or standalone root. A two-arm T / null whose non-null arm is fixed uses Option<ThatSingleton>; bare null / null normalizes to the one null singleton rather than exposing two equivalent Rust states, while an encoded-null arm (#6.7(null) or bytes .cbor null) remains distinct from bare null. undefined is its own unit-valued fixed singleton (not an alias of null): it writes 0xf7, has no inner preserve sidecar, and undefined / null is Option<FixedUndefined>. Byte literals include empty strings, use FixedValueMismatch/Key::Bytes for a wrong decoded value, and participate in fixed choices, tags, and .cbor wrappers. The pinned parser accepts uppercase hex and raw UTF-8 only: lowercase h'cafe' and b64'…' remain parser refusals.

  • Optional fixed-value members - foo = [x: uint, ? v: true], {? k: h'CAFE', x: uint} — the one-bit present/absent state is stored as a bool presence field (see Output format); covers bool, null, uint, nint, text, bytes, float, and undefined ([x: uint, ? u: undefined, 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. When the row's possible CBOR key values can equal a declared or exact-zero fixed key, that carrier is private: rest() is immutable and checked insert_<row> rejects collisions atomically (DuplicateKey; exact-zero remains ForbiddenKey). A faithful bare uint row beside only text fixed keys (or bare text beside only uint fixed keys) is statically disjoint and retains the ordinary public carrier; custom/tagged/encoded/general domains compare their actual wire value. Open tables have no fixed keys and are unaffected. Every rest-row occurrence is supported and belongs to that row alone: loose */0*, NonEmptyMap/NonEmptyPairMap for +/1*, or a checked BoundedMap/BoundedPairMap for every other window. Restricted rows are checked constructor arguments; CBOR/JSON stage loose data then enter one TryFrom door, while wasm/component re-enter the same carrier. Wasm reads a detached rest snapshot and mutates the parent through insert_<row> (insert_rest by default), with exact-zero/bounded failures surfaced at that checked door; component remains read-only. The key domain is general (uint/text/any plus bytes, nint, sized ints, bool, unions, structs, tagged/.cbor domains, externs, generic instances and aliases; float-containing and null-admitting keys reject). @name/@duplicates apply; @ignore remains loose-only: dropping a restricted row either re-serializes zero against a positive minimum or loses a zero-minimum window's bounded/exact state. See Open struct-maps.

  • Open arrays (an array mixing fixed members with occurrence-bearing segments) - a final tail such as foo = [uint, tstr, * uint], and a sole safe leading/middle segment such as foo = [uint, * bytes, tstr] or foo = [* fixed_choice, 2] where fixed_choice = 0 / 1, are supported. Multiple segments are also supported when every segment is a finite exact N*N window other than 1*1 and has a unique explicit @name: each becomes its own flat [T; N] field and count-owned wire boundary, so adjacent/same-major segments do not require peeking or suffix speculation. Loose */0* captures rest: Vec<T> and defaults empty; +/1* captures rest: NonEmptyVec<T> and keeps its first-element new() ABI; ordinary exact windows such as 2*2 or *0 capture [T; N], while variable windows such as 2*3, *3, or 2* use BoundedVec<T, MIN, MAX>. A sole variable middle segment always needs an immediate mandatory fixed suffix that expands to exactly one CBOR item. A variable-cardinality window additionally needs either a field-codec-free, effective, CBOR-major-disjoint boundary (generator-proven heads or a transparent custom alias's declared @custom_wire_major) or untagged generator-owned finite fixed-value domains on both sides with no CDDL value in common. The finite-domain path retries the repeated decoder on the real cursor and restores it for the suffix on failure. Exact 2*2 stops by count and needs neither proof. Optional-prefix lookahead remains generator-proven-only: a custom codec or opaque extern there is serialize-only unless a mandatory outer tag/.cbor frame proves its distinct head. Variable multiple occurrences, general/residue same-major discrimination, direct opaque extern heads, and group/group-choice/plain-group occurrences remain graceful rejections. Restricted CBOR/JSON/component boundaries stage a loose list: exact windows make one fallible static-array conversion, while variable windows re-enter their checked carrier. JSON keeps its named list field shape and recursively adapts a segment whose carrier tree contains a wide exact array or exact natural-any leaf; loose remains omitted/defaulted, restricted JSON is required with its actual minItems/maxItems, and every restricted carrier enters its existing checked door. Wasm keeps the matching list wrapper, and component restores the native carrier from list<T>. @duplicates reject exact windows remain BoundedOrderedSet, not arrays. @name names each multi-segment field; preserve/canonical round-trip each element byte-exact in source position. The loose flavor alone keeps skip-if-empty/default-on-read JSON and may use @ignore; dropping a restricted segment is rejected because it would either re-serialize zero against a positive minimum or lose a zero-minimum bounded/exact state. See Open arrays.

  • Open tables - foo = { * K_t => V_t, * K_r => V_r } carries a typed entries row and a catch-all rest row, partitioned by key wire major. Each has its own loose, NonEmpty (+/1*), or Bounded occurrence carrier; neither row contributes to the other's count. Typed + keeps new(first_key, first_value) and flattened accessors, while bounded typed rows re-enter a checked carrier from a fallible wasm builder. Restricted catch-alls cross as checked wrappers; component constructors take fallible lists. CBOR/JSON stage per-row and Schema intentionally omits object-wide property counts. See Open tables.

  • 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). Synthesized instance identities retain their established spelling for unconstrained arguments, while occurrence bounds and other argument-local configuration are included recursively: foo<([* uint])>, foo<([*5 uint])>, and foo<([* [*5 uint]])> therefore mint distinct nominals with their own Vec/BoundedVec carriers instead of sharing whichever instance happened to register last. Generic defs whose body is a collectionxs<a> = [* a], [+ a], [2*3 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/BoundedVec/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).

Known generic-name collision: a scoped generic parameter can currently be confused with an unrelated outer plain-group rule when their CDDL names normalize to the same Rust identifier. For example, A = (x: uint, y: tstr) beside xs<a> = [* a] falsely reports that parameter a as the group A. Rename either symbol to avoid the collision. The scoped-symbol provenance grid needed to remove this limitation is tracked in the matrix roadmap.

  • Length bounds - foo = bytes .size (0..32). An exact byte window (bytes .size N, including zero) is stored as [u8; N]; non-exact byte windows remain Vec<u8>. Constructors deliberately accept a Vec<u8> and return RangeCheck on a wrong length before atomically converting it to the static carrier. JSON follows the same invariant, including exact schema cardinality. That byte-string identity and loose constructor ABI are separate from ordinary/ preserve exact homogeneous CDDL array occurrences, which use native [T; N]; exact @duplicates reject arrays retain their uniqueness-enforcing BoundedOrderedSet<T, N, N> carrier.

  • Type-enforced homogeneous collections - bare [* T] maps to Vec<T> and [+ T] / 1* T to NonEmptyVec<T>; an ordinary or duplicate-preserving exact N*N T (including zero) maps to [T; N], while every other supported finite/lower-bounded window maps to BoundedVec<T, MIN, MAX>. Exact @duplicates reject deliberately remains BoundedOrderedSet<T, N, N>; [T; N] cannot enforce uniqueness. CBOR and component list boundaries stage a Vec<T> and cross one checked array conversion; wasm remains its established list wrapper/class shape. JSON recursively adapts every ordinary/preserve and duplicate-reject loose, nonempty, bounded, nullable, and exact collection tree containing a wide exact array or an exact natural-any position, including aliases, optional fields, type-choice payloads, optional+nullable three-state fields, and captured open-array segments. Every node remains a JSON list and retains its own authored bounds; duplicate-reject schemas add uniqueItems: true, loose segments keep omission/defaulting, and restricted nodes re-enter their native checked constructor door. Map/table keys or values, open-struct map rest rows, and dynamic map rows still reject when they need this adapter. Exact bytes are independently [u8; N] and retain their loose constructor door. A unique-key table maps { * K => V } to a loose map, { + K => V } to NonEmptyMap<K, V>, and every other window (including omitted exact-once) to BoundedMap<K, V, MIN, MAX>. A preserve table uses PairMap / NonEmptyPairMap for those loose forms and BoundedPairMap<K, V, MIN, MAX> for every other window. See Output format.

    The same distinction applies to generic collection instances: an ordinary/preserve exact N*N instance remains a transparent [T; N] alias (with its list-shaped wasm class), rather than a BoundedVec alias; exact reject keeps BoundedOrderedSet<T, N, N>.

  • 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 payload that carries no value of its own — a fixed value (bytes .cbor 42), or the same behind any number of mandatory tags (bytes .cbor #6.1(42), bytes .cbor #6.1(#6.2(42))) — works in every position. A rule-body fixed payload is a nominal singleton whose codec owns the complete .cbor/tag chain and preserve metadata; a member or arm fixed payload remains an unstored check, but still verifies the tags and constant and requires the byte string to be exactly consumed. A non-fixed .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. A payload may itself be a .cbor payload, to any depth, in either spelling: the INLINE one (bytes .cbor (bytes .cbor uint), both depths in one type expression) and the NAMED one (inner = bytes .cbor uint, referenced as bytes .cbor inner — a non-fixed .cbor rule body is a wrapper struct with its own serialize fn, so this needs no @newtype and emits the same nested wire shape). Each .cbor level owns its own staging buffer, reader and — under --preserve-encodings — its own byte-string encoding member, so the levels round-trip independently: an outer byte string encoded non-minimally keeps its own head width rather than inheriting the inner one. Every level is required to be exactly consumed, so bytes left over inside any of them are rejected at the level they were found in. The nesting composes with the rest of this bullet: a tag between two levels, a value-less payload under the nesting (bytes .cbor (bytes .cbor 42)), 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.

  • RFC 8610 expected-conversion names - eb64url, eb64legacy, and eb16 generate nominal wrappers around AnyCbor with fixed tags 21, 22, and 23. Their codecs require and retain the declared tag around one arbitrary CBOR item (including preserve-mode payload/tag encodings), and the Rust, wasm, JSON, WIT, and component faces reuse the existing tagged-any representation. The tags are rendering advice: no base64/base16 text conversion API is invented. cbor-any remains a separate unsupported stream marker; see Output format.

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

  • default values - ? key : uint .default 0. The value may be an integer, a float, a text string, a byte string (? payload: bytes .default h'CAFE'), or true/false (the bool constants are spelled as typenames in CDDL, and are lowered from that spelling). 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, and bare int — which is bignum-capable and resolves to the Int struct rather than to a primitive) or of the wrong kind (tstr .default 1, uint .default null) is rejected gracefully at generation, naming the head. A signed default goes on nint or on an integer range, which does collapse onto a signed primitive (si = -128..127, then ? n : si .default -2i8). An operand that is not a literal at all (uint .default some_rule) is likewise rejected gracefully, naming the operand. Per RFC 8610 §3.8.2 a default substitutes for an absent value, so it is meaningful only on an optional occurrence: on a mandatory member (key : uint .default 0 — including when the control arrives through an alias rule, d = uint .default 0 referenced as key : d) it is inert. The tool warns on stderr and emits that member as an ordinary mandatory field, so it keeps its constructor argument on every face; the same alias stays fully defaulted at its optional use sites, which is why this is a per-use-site decision rather than a rejection of the alias. Both representations apply it: a defaulted member of an array-representation record (arr = [ a: uint, ? b: uint .default 7 ], final position) is stored plain and filled in on decode exactly as a map member is. Under --preserve-encodings a member that was explicitly written on the wire with its default value re-emits it, rather than collapsing to the absent form — the round trip is byte-exact either way.

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 occurrence marker on a single-entry arm (t = [ x: uint // ? kv ], // * kv, // + kv, // 2*3 kv) is rejected gracefully at parse in the array representation: a one-entry arm becomes one enum variant holding exactly one value, so honoring the marker has nowhere to live, and dropping it would make the generated decoder reject counts the spec admits (the empty encoding under ?/*, every 2-or-more encoding). The remedy the message names is a TYPE choice over one named rule per count — xarr = [x: uint], kvarr = [kv], empty = [], then t = xarr / kvarr / empty. A repeating alternative first gives the group a one-item array form (kvitem = [kv]), then repeats that type (kvs = [* kvitem]). In the map representation only the zero-permitting markers (?, *, 0*n, *n) are rejected; a lower-bound-≥1 marker (+, 2*3, 2*) is honored by collapse — map keys are unique, so a second repetition of a fixed-key alternative would duplicate its keys, leaving count 1 as the only encoding, and the arm generates byte-identically to its unmarked twin (that identity is the pinned contract, the same collapse boundary the inline-group splice uses). The pedantic 1*1 is honored in both representations, and a multi-entry arm is unaffected throughout — it mints a record whose field walk reads occurrence markers normally. A field-level directive on a single-entry arm's own entry (// f: bytes ; @custom_serialize …) is likewise a hard error naming the slot that works — see comment DSL for the arm's two comment slots.

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.

A group-choice pairing whose fixed values share a CBOR major type (t = [ v: 1.5 // flag: true ] — a float and a bool are both major 7) likewise forces the brute-force dispatch, and works under every profile: under --preserve-encodings the arm's success return constructs the variant together with its encoding sidecars (len_encoding, plus the value's own where the kind has one), so a stored non-minimal or indefinite outer length re-encodes byte-exactly instead of being defaulted away (pinned e2e — generate, cargo check, round-trip — by group_choice_same_major_fixed_arm_constructs_preserve_struct_variant, whose vectors carry both length forms). The disjoint-major pairing (t = [ v: 1.5 // label: tstr ]) dispatches on wire type and works under every profile through the type-match path.

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 (bytes, 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.

For a fixed keyed struct-map field, every positive-upper zero-permitting occurrence (*, 0*n, or *n where n > 0) has the same representation as ?: the generated field is Option<T>, so an absent entry is accepted and omitted on serialization. A fixed key has only one entry under the record's unique-key contract, so lower-bound-at-least-one occurrences remain a mandatory field. The exact-zero forms 0*0 and *0 instead forbid their key. They emit no value field; decoding reports DeserializeFailure::ForbiddenKey, and an open record keeps its rest map private behind a checked complete-map constructor so the forbidden value cannot be inserted through the capture.

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 arrow with no occurrence marker — { tstr => uint } — has RFC 8610's exact-once window and becomes BoundedMap<K, V, 1, 1>, never a loose map. { ? tstr => uint }, { 2*3 tstr => uint }, { *3 tstr => uint }, { 2* tstr => uint }, and { 0*3 tstr => uint } likewise become their corresponding inclusive BoundedMap window (u64::MAX represents an absent upper bound). + / 1* retains NonEmptyMap<K, V> and * remains loose. The checked TryFrom door is shared by API, CBOR, JSON, wasm, and component construction. With @duplicates preserve, those bounded windows use BoundedPairMap<K, V, MIN, MAX> instead: duplicate keys count separately, retain entry order, and cross every door through its checked conversion rather than a unique-key map.

Named integer and byte/text value windows are nominal checked values too: small = uint .le 10 and digest = bytes .size 32 generate wrappers with private carriers, a public TryFrom door, and a read-only get(). Every codec and boundary face re-enters that door; exact byte windows retain their [u8; N] storage. A window written directly on a record member remains owned by that record's fallible constructor because the member has no separately named type to carry the invariant.

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.

Plain group placement

A plain group rule (kv = (a: uint, b: uint)) is spliced where it is referenced: its members are written flat into the surrounding container, so the group has no one-item form of its own. That fact decides every placement below — a slot that holds exactly one CBOR item refuses the splice, each refusal naming its remedy (usually the named-array framing w = [kv], which gives the slot the one item it needs), while an exactly-once container placement whose length scales with the group's arity accepts it.

A CBOR tag's payload is a TYPE, not a group-entry modifier. Therefore #6.1(kv) is rejected gracefully wherever it is written: a tag has to wrap exactly one data item, while kv only splices its members. Frame the group first, then tag that type (w = [kv], #6.1(w), or #6.1([kv])). The placement-specific refusals below consequently describe bare and transparent-alias group references; a tagged spelling reaches this semantic type/group boundary before it reaches the enclosing placement's guard.

A group rule's BODY must carry exactly one group choice. A body with two or more (pg = (a: uint // f: bytes)) is rejected gracefully at parse, on the definition alone — no reference to the rule is needed to reach it — because a spliced body has to name one sequence of members: alternatives would mint a choice of bodies at every reference, with arms a decoder can tell apart, which is a named-choice design rather than a splice. The remedies the message names are to write the alternatives where the choice is actually made, as the referencing container's own group choices (h = [ x: uint // a: uint // f: bytes ]), or to give each alternative its own single-choice group rule and reference those as separate arms (pga = (a: uint), pgf = (f: bytes), h = [ x: uint // pga // pgf ]). The same pair of remedies is what an inline group choice in entry position (h = [ (a: uint // f: bytes) ]) is pointed at.

A bare plain group in either table domain (coords = (uint, uint), { * uint => coords } or { * coords => uint }) is rejected gracefully at parse, in the named-rule and inline ([{ * uint => coords }]) spellings alike: a CBOR map entry holds exactly one item in each slot, and a keyless group has no single-item form, so it could only be spliced in with its members written flat — which contradicts the map's own entry count and produces bytes other CBOR implementations do not read back as the spec says. A repeated homogeneous array ([* coords]) is also rejected: repeating a group concatenates its member sequence, whereas a homogeneous collection element has to be one item, and lowering it as a Vec of the group would silently write nested arrays instead. The remedy is to wrap the group in an array, which gives each slot or repeated element one item with real nested-array semantics: { * uint => [coords] }, or arr = [coords] followed by { * uint => arr } or [* arr].

The keyed struct-map member spelling of the same shape (kv = (a: uint, b: uint), t = { c: kv }) is rejected gracefully at parse for the same reason — the key claims one map entry and that entry's value slot holds exactly one item, so the splice overruns the header — and the guard is on the member's resolved type, so the bare member, ? on it, an alias to the group, and a map group-choice arm carrying it ({ n: uint // c: kv }) refuse alike. Here the remedy is specifically a named array rule (w = [kv], then c: w): the inline spelling c: [kv] collapses the group to an array representation while the member position demands a map one, and is refused separately as a conflicting representation on the group itself.

A keyless plain-group entry in a map group-choice arm ({ n: uint // kv }) stays supported — the referenced struct owns its own keys and writes a conformant two-entry map — as does the array-representation placement of the same group (t = [ c: uint, kv ]).

An alias to the group is honored like the direct reference in every supported placement — the array-record field (kv_alias = kv, t = [ c: uint, kv_alias ]), an exactly-once group-choice arm (including the keyless map arm above), and alias chains of any depth — generating byte-identical wire to the direct spelling (one deliberate API nuance: a group-choice arm through an alias keeps a single-argument constructor taking the aliased type, where a direct arm expands the group's fields — same variant wire, compile-visible ergonomics); the refused placements refuse the alias spelling with the same message as the direct one.

The ?-optional flavor of the array-record field (t = [ c: uint, ? kv ]) is rejected gracefully at parse in the bare and alias spellings: a splice writes no marker of its own, so the array's length is the only evidence the group is present, which the embedded decoder cannot read. The remedy the message names is the same named-array framing as the map cases — w = [kv], then ? w — which makes the optional item exactly one array element the decoder can test for; dropping the ? (the mandatory splice) is supported as it stands.

The *-rest-tail flavor of the same placement (t = [ c: uint, * kv ], a final-position open-array tail — see Open arrays) is rejected gracefully at parse in the bare and alias spellings, for a different reason: a rest tail collects one value per remaining array element and a plain group is not one, having no type of its own to collect. Its remedy is the same framing (w = [kv], then * w), with a tag belonging on the framed reference (* #6.10(w)). The sole-element homogeneous array [* kv] is refused too, because a repeated group would need to concatenate its members rather than serialize one nested item; use arr = [kv], then [* arr].

The map twin of that tail — a plain group on an open struct-map rest row's key or value slot (t = { c: uint, * kv => uint }, t = { c: uint, * uint => kv }; see Open struct-maps) — is rejected gracefully at parse on either slot, in the bare and alias spellings, for the table domain's reason rather than the tail's: a map entry holds exactly one item in each slot. Both slots are reported when both offend. The remedy is the array framing on the offending slot (* [kv] => uint, * uint => [kv]), with a tag belonging outside it (#6.10([kv])); the prefix-less spelling { * kv => uint } is the pure table shape above and keeps that message.

A plain group as a TYPE-choice arm (u = kv / null, t = [ c: uint, x: kv / tstr ]) is rejected gracefully at parse wherever the arm sits — rule position and member position, array and map representation, collapsing (/ null) and non-collapsing, at any arm count — in the bare and alias spellings. Here the refusal is the semantics rather than a limitation: a type choice denotes exactly one data item, because telling the arms apart on the wire is the whole of what a choice decoder does, and a splice has no one-item form, so there is nothing an arm could hold. The remedy is the same named-array framing (w = [kv], then w / null or w / tstr), with a tag belonging on the framed reference (#6.10(w)).

Splicing the group with no choice around it stays supported in all its usual placements: the mandatory array member (t = [ c: uint, kv ]), the keyless group-choice arm (t = [ x: uint // kv ]) and a plain alias to the group (u = kv).

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.

For a fixed literal arm with no explicit @name, the generated variant keeps its established spellable legacy name: 5 is I5, true is True, "x" is X, and h'CAFE' is BytesCAFE. Only when that legacy spelling cannot be emitted as a Rust identifier does the fixed value take its canonical, kind-prefixed fallback identity: -1 is Nint1, 1.5 is Float3FF8000000000000 (its exact IEEE-754 bits), and "self" is Text73656C66 (its exact UTF-8 bytes). The same rule covers empty, punctuation-only, and digit-led text. These names apply to ordinary and anonymous type choices and to a bare fixed member of a group choice; an explicit @name still selects the authored variant name and reserves it before derived names settle. Any field that is T / null is transformed as a special case into Option<T> rather than creating a TOrNull enum. A fixed T is first nominalized with its full mandatory-tag or bytes .cbor wire chain. Only two bare null arms (null / null) normalize to one singleton state; #6.7(null) / null and bytes .cbor null / null keep both wire arms as Option<Singleton>. An optional member of such a type (? f: (T / null)) therefore nests two Options and carries three states — absent, present-null, present-value — which CBOR and JSON both keep distinct, including when T needs the static exact-array adapter; see Optional members whose type is nullable.

A mandatory CBOR tag directly over an anonymous type or group choice — t = #6.10(int / tstr), #6.11([a: uint // b: tstr]), the map spelling, or the all-fixed #6.13(1 / 2 / 3) — is supported under --preserve-encodings=true. The tag is owned by the enum rule: each physical Rust variant carries the same generated-code-owned Option<cbor_event::Sz> tag-width field because Rust enums have no enum-level instance fields. Decoding validates and reads that tag once before dispatch (including backtracking), then re-emits its exact head width; a fresh value uses the fit-minimal width and canonical serialization re-minimizes a decoded width while retaining the mandatory tag. Arm-specific encoding sidecars remain independent; the field's deterministic collision handling and public Rust variant shape are documented in Output format.

A special case for this is when all types are fixed values e.g. foo = 0 / 1 / "hello", in which case we normally 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. A mandatory tag directly on that all-fixed rule is the exception under --preserve-encodings: it uses the data-carrying enum above so the rule-owned tag width has a home on every variant. Outside that profile the c-style lowering remains.

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.

Incremental type-choice extension

A type choice may be written incrementally, one statement per arm — the RFC 8610 socket/plug idiom:

$currency /= "usd"
$currency /= "eur"
$currency /= uint ; a numeric code

The statements are combined into a single type-choice rule before anything else looks at the spec, so a = int plus a /= tstr generates byte-identically to a = int / tstr — same enum, same variant names, same wasm and JSON surfaces, under every flag profile. There is no separate "incremental" code path to behave differently.

Two rules govern the combining:

  • Arm order is statement order, top to bottom, whichever operator each statement carries. The base statement is not promoted to the front, so writing the extension first (a /= tstr then a = int) means a = tstr / int. Arm order is the order on the page, and it matters: a choice decodes to its first matching arm.
  • The first statement owns the rule. With multi-file input, where each file gets its own module scope, a /= statement in another file contributes arms only — the emitted type lands in the scope of the file holding the rule's first statement.

The base statement may use = or /= (a lone /= rule is just a definition), and an extension statement may carry several arms of its own (a /= tstr / bytes). Comment-DSL directives follow the ordinary type-choice convention, applied to the combined arm list — see Incremental /= chains.

Some repeated definitions cannot be combined, and are rejected gracefully at parse rather than silently keeping one of them. Each names the rule and an actionable remedy:

SpellingWhyRemedy
//= group-choice extension (g = (1: int) + g //= (2: tstr))the arms would have to merge into a plain group rule carrying several group choices, which is not a supported shapegive each arm its own named group and choose at the use site: t = [ g_a // g_b ]
one name defined as both a type and a group (a /= tstr + a = (1: int))a type-choice arm and a group-choice arm are not the same thing, so there is no single rule to merge intofold the type statements into one type choice, or name the groups and choose at the use site
generics involved (a<t> = [t] + a /= tstr)the merged body would be a type choice under a parameter list, and a generic definition's body must be a shape that registers a structgive each arm its own named rule and choose at the use site (a_a<t> = [t], then x = a_a<int> / tstr)

All three are rejected in either statement order — writing the extension before the base does not change the verdict.

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/BoundedVec, native [T; N] for an ordinary/preserve exact N*N homogeneous window, or the OrderedSet/NonEmptyOrderedSet/BoundedOrderedSet uniqueness carrier — depends on the occurrence window and 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), variable bounded windows use BoundedVec, and ordinary/preserve exact windows use [T; N]; each enforces its window through a single TryFrom door. Exact reject sets deliberately remain BoundedOrderedSet<T, N, N>. 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 collection shape. 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 bounds-bearing <Elem>BoundedOrderedSet… class for bounded reject, or the loose/non-empty <Elem>OrderedSet twin otherwise), 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 so JS reads are single-layer. Loose/non-empty reject sets expose len/get(index)/insert/add/contains/try_from/try_opt_from; bounded reject sets keep len/get(index)/add/contains/try_from but omit the normalizing insert/try_opt_from doors that could hide an overflow. (For a non-258 collapsed tag, or a plain non-set generic collection, instances stay transparent Vec/NonEmptyVec/BoundedVec/[T; N]/map aliases as applicable.)

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], or another bounded window, 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>, or BoundedOrderedSet<a, MIN, MAX>. Its single TryFrom door refuses duplicates identically on the wire (DeserializeFailure::DuplicateKey(Key::Uint(i))) and through the API; the bounded carrier also enforces its inclusive occurrence window at that door. The contract holds across the Rust, JSON, wasm, and component 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 homogeneous array) keeps the preserve default (Vec<a> / NonEmptyVec<a> / BoundedVec<a, MIN, MAX> for variable windows, or [a; N] for exact N*N, 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 the occurrence-selected Vec / NonEmptyVec / BoundedVec<T, MIN, MAX> carrier, or [T; N] for an exact window — 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]) / bounded homogeneous occurrence 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 ordered-set carrier, 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>, or BoundedOrderedSet<a, MIN, MAX> carrier selected by the window). 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> for {* k => v}, NonEmptyPairMap<K, V> for {+ k => v}, or BoundedPairMap<K, V, MIN, MAX> for every other homogeneous occurrence window — the only shapes 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 (restoring the occurrence-selected Vec / NonEmptyVec / BoundedVec<T, MIN, MAX> carrier, or [T; N] for an exact window, and today's 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). Newly introduced runtime modules such as ordered_set.rs and bounded.rs need matching pub mod ordered_set; / pub mod bounded; lines added by hand. A consumer using --export-static-crate receives files into a hand-owned crate root the tool never edits, so without that line a 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, bounded, 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 … 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). One exception mints no Rust type at all: an alias carrying a @custom_serialize/@custom_deserialize pair — a pub type there would hand the CDDL name a standalone codec (the aliased type's built-in one) contradicting the custom wire every embed site writes, so members spell the type the alias resolves to and the name carries only wire facts (see the absence caution).

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 a nominal tag-writing/tag-checking owner. A fixed inner is a one-variant singleton TypeChoice (tagged_answer = #6.42(42)); every other inner is the tag 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 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 redundant on an ordinary tag wrapper, but rejected on an already-nominal fixed singleton rather than silently creating an inert layer. Tag rules whose body is a group (rational = #6.30([...])) already generate a struct that writes the tag and are unaffected.

A tagged table carrying @duplicates preserve (t = #6.n({* k => v}) ; @duplicates preserve) wraps like any other tagged collection: the wrapper holds the PairMap/NonEmptyPairMap vec-of-pairs twin the policy selects, its codec writes and requires the tag in both directions, and its wasm class's new/getter boundary names the PairMap<K>To<V> structural class (minted beside the default-flavored Map<K>To<V>). {* k => v} ; @newtype @duplicates preserve nominalizes through the same path.

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.

An anonymous choice tag rule follows the same one-wire-form contract under --preserve-encodings: t = #6.10(int / tstr), the array/map group-choice spellings and an all-fixed choice all retain the mandatory tag's decoded head width. As described under type choices above, the enum rule owns one tag-width fact repeated physically on every variant; naming the choice first and tagging that name remains equivalent, but is no longer required for preserve support.

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.

The loose value is deliberately not a back door for type-level directives or control constraints. @newtype on a bare any alias is unsupported: AnyCbor is already the nominal runtime value and another wrapper would not narrow its accepted CBOR domain. Control operators on bare any are also unsupported because the operator must constrain a known value class; name a supported typed rule and apply the operator there. These are permanent product boundaries, not missing coverage cells.

any generates under every output-surface flag profile — the Rust surface, the wasm AnyCbor wrapper, and the JSON/schema surfaces under --json-serde-derives / --json-schema-export. A hand invocation importing a shared runtime with --common-import-override must also name that runtime's exported flavor record; this checks that both sides baked the same deserialize-depth limit before generation. See --common-import-flavor. 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. The same is true of a plain alias hop: hop_alias = hop_arr / hop_arr = [* hop_alias] is repaired exactly like a spelling whose collection rule sorts first.

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 neither source order nor the alias/collection rules' sort order changes the emitted API.
  • 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). A directory holding a single .cddl file is not modular input: module scopes are only split when more than one file is found, so a lone file's rules land at the crate root exactly as if the file itself had been passed to --input.
  • 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 one shape earns 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 at 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. RFC 8610 occurrence matching is greedy and non-backtracking: for [? uint, uint], a one-item [uint] is invalid because the optional consumes it and the mandatory field is then missing. The decoder must not reserve the suffix and reinterpret that wire form as an absent optional. Remedy: make the types distinct, drop the optional, or restructure (an optional-last field never needs the peek disambiguated against a follower).

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
CDDL_CODEGEN_RAW_BYTES_TYPE generic base — refused at parse timerb_gen<T> = _CDDL_CODEGEN_RAW_BYTES_TYPE_; bar = [x: rb_gen<uint>]rejected gracefully at parse
Generic group definitionset<a> = (* a)rejected gracefully at parse
cbor-anyx = cbor-anyrejected 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

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: a fixed-field heterogeneous type2.map or type2.array accepts a scoped ; @name when it is the group entry's whole member type (up to tag wrappers), while every other listed position needs an explicit rule; for the key and occurrence rows the remedy depends on the spelling.

ConstructUnsupported roleExample
grpent.groupnameoccurrence-target (2 shapes)a = [* pair]; pair = (int, tstr)
grpent.inline_groupgroup-choice-armt = [ (uint, tstr) // bytes ]
grpent.inline_groupoccurrence-target (5 shapes)a = [* (int, tstr)]
memberkey.type1group-choice-armt = { uint => tstr // b: tstr }
memberkey.type1map-key (6 shapes)m = { true => uint, 1: uint }
memberkey.type1occurrence-target (2 shapes)m = { 1: uint, * float => any }
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))
type2.valueoccurrence-target (4 shapes)a = [? 5]

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":

  • Supported in rule and member positions, but not as a map key. A byte-string literal works as a named singleton, an array element, and a map value — [v: h'0102', x: uint], [h'0102', x: uint], {k: h'0102', j: uint}, and [v: 'text', x: uint] all constrain the decoded bytes. Fixed literal map keys remain limited to uint and text, so a byte literal in a key position is still rejected gracefully. This is a representation boundary, distinct from the parser's lowercase-hex/base64 spelling limitations above.

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.