Skip to main content

Output format

  • Inside of the output directly the tool always produces a rust/ directory (including Cargo.toml, etc).
  • Unless we pass in --wasm=false the tool also generates a corresponding wasm/ directory.
  • Under --component=true the tool additionally generates a component/ directory — a wasm component model (WIT/wasip2) face beside the wasm-bindgen one: component/wit/ holds the generated WIT package and component/src/generated/ the wit-bindgen guest glue over the rust/ crate. Its API shape deliberately parallels the wasm face with documented deltas — see Component (WIT) differences.
  • Two further top-level directories are emitted beside them in every mode: extern-interface/ (this crate's extern-visible type surface, for consumers — see Extern-interface export) and no-std-check/ (a tiny throwaway crate that proves the rust crate still builds without std — see The no-std-check shim crate). Both are tool-owned and delete-and-recreated on every run.
  • Every crate's generated code lives under a tool-owned src/generated/ subtree (rust/src/generated/mod.rs holds the structs, rust/src/generated/serialization.rs their (de)serialization implementations/corresponding types, plus usage-gated runtime modules such as error.rs, ordered_hash_map.rs, and bounded.rs; a crate with any @used_as_key-tagged rule also carries key_demand_assertions.rs, the private compile-time record of which tag demanded which comparison/hash traits). The crate root src/lib.rs is a thin, user-owned wrapper (mod generated; pub use generated::*;) — see Generated crate roots below.
  • The wasm/ directory is full of wasm_bindgen-annotated wrappers (under wasm/src/generated/) for the corresponding rust-use-only structs in rust/ and can be compiled for WASM builds by running wasm-pack build on it. The --target=nodejs output runs unchanged under both Node and Bun. Every wasm crate also carries a wasm/src/generated/collections.rs index: one pub use crate::…::<Wrapper>; re-export per collection wrapper class the crate defines (the FooList / MapKToV and NonEmpty… wrappers minted from [* T] / {* K => V} shapes), sorted, with no glob. Because it is compiled as part of the crate, a line naming a removed wrapper fails the crate's own build, so the index can never silently drift from the classes it inventories.

Toolchain floor. Generated crates depend on cbor_event 3.3, whose manifest declares rust-version = "1.96.1" — building a generated crate needs at least that toolchain.

Unused imports are pruned. A usage-derived post-pass reads the rendered source and drops imports a generated file's module family references nowhere, so a module carries no unused-import warning for something a sibling module uses. It removes four things. (1) Concrete-type names — the collection helpers (BTreeMap, OrderedHashMap, NonEmptyVec, BoundedVec, NonEmptyMap, BoundedMap, OrderedSet, NonEmptyOrderedSet, BoundedOrderedSet, PairMap, NonEmptyPairMap, BoundedPairMap) and, under --preserve-encodings, the encoding enums (LenEncoding, StringEncoding, TagPresenceEncoding); the wasm prelude names (JsError, JsValue, and the wasm_bindgen attribute macro, kept only where a #[wasm_bindgen] attribute appears); the --wasm-*-macro leaf names (kept only where the macro is actually invoked); and cross-scope generator-minted type idents an over-approximating reference walk imports. (The ordered-set twins and bounded compound twin ride the spec-global @duplicates reject gate — see Reject-duplicates containers — so a scope with a reject-set rule pushes them into every scope's mod.rs, and this pass keeps the scopes that never name them warning-free.) (2) use super::*; — dropped from a file whose body names nothing bound in its parent module (kept where an encoding struct is keyed by a parent-scope generated type, e.g. BTreeMap<Ikey, StringEncoding>, since it reaches Ikey through the glob). (3) use <common>::error::*; — dropped from a mod.rs whose family names no error export (kept where a bounds-carrying wrapper's TryFrom names DeserializeError). (4) use super::cbor_encodings::*; — dropped from a serialization.rs family that names no definition from its generated sibling cbor_encodings.rs (for example, a serialize-only record that reaches its sidecar only through self.encodings).

The consuming set of a file is not "every descendant" but the descendants an unbroken chain of use super::*; edges links back to it — a private import in a scope's mod.rs is nameable by that scope's serialization.rs/cbor_encodings.rs (which glob super), but NOT by a different scope's files, whose own mod.rs does not re-glob upward. A super-reachable descendant keeps a parent's copy alive only when it would genuinely resolve the name through the parent: one that carries its own direct import of the same name, that IS the file of the module the parent imports it from (the serialization.rs static prelude that defines LenEncoding), or that globs that same module (use <dep>::serialization::*; under --common-import-override) resolves the name locally and does not. A reference counts only where it could actually resolve through the namespace: an identifier appearing solely as a ::-qualified path tail (cml_chain::assets::Coin mentioning assets and Coin) resolves through its preceding path segment, so it keeps neither a named import nor a glob alive — which is why a file built entirely from fully-qualified paths (e.g. the --wrapper-requests requested_collections.rs) sheds its use super::*;. Only private use items are candidates (pub use re-exports are API surface and never touched). Trait imports (e.g. cbor_event::se::Serialize, exercised via a method call whose name never mentions Serialize) cannot be proven unused by name and are left as-is, so a scope that imports but never calls one still carries that single warning. The pass runs again after the edit-preservation overlay applies your cddl-codegen: blocks, so what justifies each import is the final shipped content, not the pristine pre-edit content — a replace block that removes an import's last user removes the import too (see Preserving edits).

Re-export-only files carry no import walls at all. A generated file whose entire body is extern re-export glue — every top-level item a use, with only crate::-anchored pub use re-exports (the mod.rs an extern-only CDDL scope produces) — has all of its private use items dropped, not just the concrete-type names and prunable globs above. Such a file's shape proves nothing can consume a private import (no local code, and no descendant module whose use super::*; could re-export one), so the wider rule is sound even for the trait imports the name-based prune must otherwise keep. The result is a re-export-only mod.rs reduced to its header comment and its pub use crate::…; lines, with zero unused_import warnings.

Unused-variable warnings are held to the same standard. An emitted binding whose body never reads it is spelled to say so — an unread loop index binds _i (e.g. a @duplicates preserve pair-map whose entries carry no per-entry encoding state to index), a constant-count match arm binds _ — so a generated crate contributes no unused_variables noise to a consumer's build. Both classes (unused imports beyond the documented trait residue above, and unused variables) are enforced by warning scans over every generated crate the tool's own test corpus compiles.

from_cbor_bytes rejects trailing bytes. The generated/static Deserialize::from_cbor_bytes (and the wasm from_cbor_bytes) decode one complete value and then require the cursor to have consumed the entire input; leftover bytes after a complete value return an error (cbor_event::Error::TrailingData) rather than being silently ignored, so a truncated/corrupt or accidentally-concatenated buffer is caught. A bytes .cbor T payload is held to the same rule, and raises the same error: .cbor says the byte string is T's encoding, so bytes left over inside it after a complete T are rejected rather than accepted-and-dropped — which under --preserve-encodings would also mean an accepted value re-encoding to different bytes. (Backwards-compatibility note: generators before 2026-08 enforced exhaustion only at the top level, so an embedded .cbor payload with trailing bytes was accepted and the leftovers silently dropped; crates re-generated since then reject such inputs. Anything written by the generated serializers — or by any encoder that writes a complete T encoding as the byte string — is unaffected; only inputs that were already silently losing bytes change verdict.)

Not every type gets its own (de)serialization in serialization.rs. Some constructs carry their CBOR framing at the point of use rather than as a standalone impl on the type:

  • Type aliases — homogeneous arrays (-> Vec), tables (-> BTreeMap), and plain aliases (foo = bar) are (de)serialized through the aliased type. (Top-level single-type tag rules like tagged = #6.42(text) are NOT aliases — they auto-wrap into a tag-writing/tag-checking newtype; see current_capacities.)
  • C-style enums — multi-value all-fixed choices (foo = 0 / 1 / 2) are encoded inline wherever they are used, not via an impl on the enum. The exception is a mandatory tag directly on the choice rule under --preserve-encodings: that rule uses the data-carrying union shape below so the enum can retain the tag head's width. A one-value named rule (answer = 42 or magic = h'CAFE', including a tagged or bytes .cbor spelling) is different: it is a nominal singleton TypeChoice with direct codecs, precisely so its standalone API has the declared wire shape.

So the tag / fixed-value encoding for these is emitted at each use site. A multi-value C-style enum root referenced by nothing else has no standalone (de)serialization, and can only be (de)serialized as part of a containing type; named singletons are the deliberate exception above.

Union (type-choice) enum variants change shape with the profile. Under the default profile a payload-carrying variant is a tuple variant (Md::Map(PairMap<Md, Md>)); under --preserve-encodings every payload-carrying variant additionally holds its per-variant encoding state, so the same variant is a struct variant (Md::Map { map, map_encoding, .. }). Code written to compile against a generated crate in either profile (a downstream consumer supporting both, or hand-written test fragments spliced into both export crates) should therefore bind variants profile-invariantly: match with braced patterns (matches!(v, Md::Map { .. })) or go through the emitted methods/serialized bytes, never tuple-destructure (Md::Map(m) compiles in one profile and is E0164 in the other). Variant names and their wire behavior are identical across profiles — only the binding shape differs. A mandatory tag directly on the choice rule adds the same Option<cbor_event::Sz> tag-width field to every preserve-profile variant. It is named tag_encoding unless an arm already owns that value/encoding name; the generator then selects the first free numeric suffix across the whole enum (tag_encoding2, …), so every variant still uses one shared spelling. Emitted constructors initialize it to None (the fit-minimal head); decoded values carry the observed width. This is part of the public enum-variant shape, so the .. pattern advice above applies to it too.

Item ordering within a file is alphabetical by name, not by kind. Top-level items are emitted sorted name-primary, kind-secondary: alphabetically by the type's bare name (module path and generics ignored), interleaving structs, enums, impls, type aliases, and modules — so a type's struct/enum and its impls sit together, and type Foo is placed alphabetically among everything rather than grouped with the other aliases. Within a single name the struct/enum definition comes first, then its impls. This canonical ordering is stable across unrelated changes to the input, which keeps diffs of generated code small. (It is independent of run-to-run determinism, which holds regardless.)

Type spelling at member positions

Wherever generated code names the type of a member, it spells that type as you declared it — keeping the outermost alias ident, with no resolution to the alias's structural target. Given

policy_id = bytes
epoch = uint
delta_coin = int
credential = [idx: uint, hash: policy_id]
stake_credential = credential
holder = [pid: policy_id, dc: delta_coin, sc: stake_credential, m: {* epoch => text}]

the emitted pid is PolicyId, never Vec<u8>; m's encoding sidecar is keyed by Epoch, never by u64; and sc is read with StakeCredential::deserialize, never Credential::deserialize.

The positions this covers:

  • data-struct fields, and the constructor / getter / setter / wasm signatures built from them;
  • a wrapper rule's inner value — the type a tag-head rule (tagged_sc = #6.9(stake_credential), including its collection and T / null bodies), a bytes .cbor rule body (sc_bytes = bytes .cbor stake_credential) or a @newtype rule wraps — and the new / getter / From signatures built from it;
  • a .cbor payload's own type where the operator is written into a member's or type-choice arm's expression (f: bytes .cbor stake_credential, bytes .cbor stake_credential / tstr): the field is typed StakeCredential and the arm is StakeCredential, since the framing belongs to the expression and the alias still names the value inside the byte string (see the ownership rule below);
  • encoding-struct fields under --preserve-encodings, including the sidecar index key of a map-typed member (that one must match the data field's key type: serialize looks the sidecar up with a key borrowed straight out of the data map, so the two are one type expressed twice);
  • the same sidecars for an open struct-map rest row or open array occurrence segment;
  • a named collection rule's own alias target (nemap = {* epoch => text} emits pub type Nemap = OrderedHashMap<Epoch, String>);
  • member-level deserialize call targetsT::deserialize, T::from_raw_bytes, T::deserialize_as_embedded_group. dc: DeltaCoin is filled by DeltaCoin::deserialize, and a member of a raw-bytes rule aliased as script_hash by ScriptHash::from_raw_bytes. This is the one covered position whose spelling is an expression rather than a declaration, and the only one where a member declared through a named rule and one declared anonymously appear side by side: in other = [rs: req_signers, more: nonempty_set<uint>] the two spell differently (ReqSigners::deserialize, NonemptySetU64::deserialize) because they are declared differently.

Container descent preserves the rule. An Array element, a Map key or value, and an Optional inner are each themselves member positions with their own declared spelling, at every depth: a rest row over {* epoch => {* policy_id => text}} spells Epoch and the nested PolicyId.

A rule that owns an encoding operation is a type, so its ident is what the member reads. When the rule itself carries an encoding operation — a tag head, bytes .cbor, the optional-tag idiom — it mints a wrapper struct rather than a transparent alias, so its ident denotes a real type whose codec is the wrapped form:

credential = [idx: uint, hash: policy_id]
tagged_creds = #6.11({* uint => credential}) ; @duplicates preserve
holder = [tc: tagged_creds]

tagged_creds emits pub struct TaggedCreds(pub(crate) PairMap<u64, Credential>) whose serialize writes write_tag(11) and whose deserialize requires it, and tc is read with TaggedCreds::deserialize — the whole tagged table, which is exactly what the ident denotes. Same for cred_bytes = bytes .cbor credential (CredBytes::deserialize) and tagged_arr = #6.24([* uint]) (TaggedArr::deserialize). Because no transparent alias can carry a wire-affecting operation, one CDDL type never has two wire forms selected by use-site, and there is no position where the ident and the value read there disagree.

What matters is which declaration owns the encoding operation, not that there is one. An operation written into the member's own type expression leaves an alias denoting exactly the value being read, so the spelling survives it: f: #6.9(stake_credential) is a field typed StakeCredential read with StakeCredential::deserialize inside the tag, and j: bytes .cbor stake_credential is a field typed StakeCredential read with StakeCredential::deserialize inside the byte string. And a container inner inside a payload is a member position of its own again, so bytes .cbor [* stake_credential] reads its elements with StakeCredential::deserialize.

Three things are deliberately not covered:

  • A member reached through an alias carrying a @custom_serialize/@custom_deserialize pair. Such an alias mints no Rust type (why), so there is no ident for a member to keep: the member spells the type the alias resolves to (inner = uint ; @custom_serialize … gives pub a: u64, and an alias of a marker gives pub p: PolicyId). The alias name still governs everything derived from the name rather than the type — enum variant idents, the structural wasm class of a collection over it (InnerList), and the encoding-sidecar shape — and no wire byte, Ord/Hash behaviour or JSON face changes with it.

  • Runtime error text and enum-variant paths. The NoVariantMatched error a c-style enum member emits names the enum struct (DeserializeError::new("Cenum", ..)) even when the member is declared through an alias, and variant construction stays Cenum::I0. The first is output your code can match on — respelling it would make this a behaviour change rather than a spelling change — and the second names the enum's own variants, which have nothing to do with how the member was declared.

  • Wasm collection wrapper names. These are keyed by structural identity — one wrapper class can serve members declared under different aliases of the same shape — so "the member's alias" is not well defined for them. They are minted from the first requester's alias idents (MapEpochToText, PolicyIdList) and are stable API surface.

Nothing about the emitted bytes, Ord/Hash behaviour, the JSON face or runtime error text depends on any of these spellings — member-level annotations carry the field name, not the type name — so a regeneration across this rule is a respelling of your generated source and nothing else.

Why this is worth stating: exactly one function spells a member's type, and it keeps the alias. Any resolved spelling you might see is not a decision to resolve for naming — it is a code path that resolved the type in order to dispatch on its structure (Map vs Array vs primitive) and then reused the dispatch-normalized value as a naming input. Since that mistake is invisible at any single emission site, the rule is written down here so a reader can predict the output and a reviewer can defend it.

Wasm feature gate for c-style enums

Under --wasm, a c-style enum is the one type kind the tool exposes to wasm directly — the wasm crate pub use-re-exports the rust enum rather than wrapping it — so it is also the only place the rust crate itself carries a #[wasm_bindgen] attribute. To keep the rust crate compilable standalone (without pulling in wasm-bindgen for non-wasm consumers), that attribute is gated behind a cargo feature:

#[cfg_attr(feature = "wasm", wasm_bindgen::prelude::wasm_bindgen)]
pub enum Language { PlutusV1, PlutusV2, PlutusV3 }

The feature name (wasm above) is set by --rust-wasm-feature. Correspondingly, in rust/Cargo.toml:

  • wasm-bindgen is an optional dependency ({ version = "0.2", optional = true }), present only when the spec has at least one c-style enum.
  • the [features] table always carries the feature under --wasmwasm = ["dep:wasm-bindgen"] when a c-style enum exists, otherwise wasm = []. It exists even in the empty case so the wasm crate can reference it unconditionally without knowing whether the spec has a c-style enum. The dep: form means the optional dependency introduces no implicit same-named feature. (The table also carries default = ["std"] and a std key written on every run whatever the flags, whose value is the computed forwarding list — see Cargo.toml merge; --rust-wasm-feature may not be named std or default for that reason.)

The generated wasm/Cargo.toml's path dependency on the rust crate enables the feature — cddl-lib = { path = "../rust", features = ["wasm"] } — so building the wasm crate compiles the #[wasm_bindgen] attribute in exactly as before; nothing about wasm builds changes.

Migration. The feature is transparent to the normal two-crate (rust + wasm) workflow. It only matters for a consumer that compiles the generated rust crate directly into its own wasm-bindgen build without going through the generated wasm crate: that build must enable the feature. A manifest that already hand-maintains wasm-bindgen = { optional = true } behind its own feature name (e.g. used_from_wasm) should pass --rust-wasm-feature=used_from_wasm so the tool gates on that same name; on regeneration a stale legacy used_from_wasm = ["wasm-bindgen"] list is rewritten to the ["dep:wasm-bindgen"] form (the dep:-less form would otherwise re-introduce the implicit feature the optional dep no longer creates).

Optional fixed-value members

A mandatory fixed-value member ([v: true, x: uint], {k: 5, x: uint}) carries zero information — the constant is implied — so it gets no struct field; it only shows up in the (de)serialization code, which writes the literal and rejects a mismatch on read.

An optional fixed-value member ([x: uint, ? v: true], {? k: 5, x: uint}) carries exactly one bit: present or absent. That bit needs somewhere to live, so the field becomes a pub <name>: bool (false = absent, true = present), defaulted to false in new() (optional fields are never constructor arguments):

pub struct A {
pub x: u64,
/// Whether the optional fixed value `true` (CDDL `? v: true`) is present; `false` means absent.
pub v: bool,
}
  • Serialize writes the constant (and, under --preserve-encodings, replays its stored width) iff the bool is true; the array length / map entry count includes the member only then.
  • Deserialize peeks the next item's CBOR type; on a match it verifies the constant exactly as a mandatory member does (a wrong value is a FixedValueMismatch) and records true, otherwise leaves the field false.
  • wasm exposes the bit as a getter v() -> bool and setter set_v(present: bool) on the wrapper.
  • JSON (serde / schemars) treats it as a plain bool"v": false when absent.

bool is used rather than Option<()> precisely because it crosses the wasm and serde/schemars boundaries cleanly (wasm-bindgen has no () ABI, and serde collapses Some(())/None). Every fixed-value kind is covered — bool, null, undefined, uint, nint, text, bytes, and float (? v: 2.5): the float presence field serializes at the literal's smallest value-preserving head (RFC 8949 § 4.1) and verifies on read with an exact raw.float()? != <lit> compare, the same spellings the mandatory fixed-float path emits (tests/corpus/optional_fixed_float.cddl, hand vectors in tests/core/tests.rs's opt_fixed_member_float). Under --preserve-encodings the member additionally carries its head-width encoding variable, so an absent member stores no width and a present one re-emits at the width it arrived on. A fixed float value is a literal, not a prelude name, so it names no value class and accepts the literal at any width — the per-name value classes are the separate rule in Floats, and the canonical narrowing is under --canonical-form.

Optional members whose type is nullable

A member that is both optional and nullable ([pre: uint, ? field0: (uint / null)] — the ? makes it optional, the / null makes its type nullable) carries three states, and the rust field is a nested Option:

pub struct NullableOptionalField {
pub pre: u64,
#[serde(with = "crate::generated::double_option")]
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub field0: Option<Option<u64>>,
}
staterustCBORJSON
absentNonemember not writtenkey omitted
present, nullSome(None)null written"field0": null
present, valueSome(Some(v))value written"field0": v

The three serde attributes are what make the JSON column carry all three states: serde's plain derive reads a JSON null into the outer None and writes the outer None as null, so without them present-null would decode as absent and absent would be indistinguishable from present-null in the text. The double_option adapter module is emitted into the generated crate under --json-serde-derives only for specs that have such a member. If T is an exact-array tree that needs the static-array adapter, generation instead emits one presence-aware recursive callback: it owns the outer Option, delegates a present value to the descriptor for the inner nullable type, and still omits an absent key. This preserves the same three-state table while keeping exact-array JSON list shapes and bounds.

Breaking for producers that wrote null to mean absent

A JSON document whose producer wrote "field0": null for an absent member now decodes as present-null, and this crate no longer writes null for an absent member. Emit an omitted key for absent and reserve null for the CDDL null.

Under --json-schema-export the member's schema is unchanged ("type": ["integer", "null"], not listed in required); the ordinary callback emits #[schemars(with = …)] only to stop schemars reading the serde with module path as a type. The composed exact-array callback instead supplies the recursive inner schema directly, preserving its null branch and every sequence bound. On the wasm read side the two Options are flattened into one, because wasm_bindgen cannot expose Option<Option<T>> — the presence bit is restored additively by a has_<field>() accessor, see Wasm differences.

Nominal tag-258 set types

A tag-258 set — the two-arm idiom (my_set = #6.258([* a]) / [* a]), the single-arm mandatory-tag form (#6.258([* a])), a generic set instantiation (set<a>), or an inline #6.258([* a]) occurrence — is a NOMINAL wrapper struct that OWNS its {tag, len, elem} encodings, not a transparent Vec/OrderedSet alias. Named rules take the rule name; generic instantiations are instantiation-derived (SetKeyHash); inline occurrences are shape-derived (SetU64, SetNonEmptyText), one per deduped shape. See Current capacities § "Duplicates policy — tag 258 defaults to reject" for the three naming regimes and the reject default. The wrapper carries the set ergonomics (Deref/DerefMut to the inner collection, borrowed + owned IntoIterator, From/TryFrom<Vec>) and always-on encodings-ignored PartialEq/Eq/PartialOrd/Ord/Hash; JSON/schemars stay transparent (the wrapper serializes as its inner collection).

Because the wrapper Deref/DerefMuts to the inner uniqueness twin, the std set contract the twin carries (insert -> bool, contains, Extend, FromIterator, sort — see Reject-duplicates containers) resolves through method-call syntax on the nominal itself: set.insert(x), set.extend(other), set.sort() all work without unwrapping. The one door that CANNOT ride Deref (it returns the inner type, not the nominal) is try_opt_from, so it is emitted inherently on every set nominal whose inner is a uniqueness twin: Nominal::try_opt_from(Vec<Elem>) -> Result<Option<Self>, DeserializeError> — empty is Ok(None), non-empty routes through the inner door and re-wraps via new.

LOAD-BEARING uniformity contract. Every generated set nominal with a fallible inner door — i.e. every @duplicates reject set (the tag-258 default) and every non-empty ([+]) set — implements TryFrom<Vec<Elem>, Error = DeserializeError>. This signature is uniform across all such instantiations by design: consumer-side generic helpers depend on it (e.g. an opt_nonempty_set<S: TryFrom<Vec<T>, Error = DeserializeError>>(elems) -> Result<Option<S>, _> works for every instantiation only because the door's error type never varies). Keep it uniform, or version it consciously. The single exception is the @duplicates preserve empty-allowed ([*]) case: its inner is a plain Vec, so it exposes an infallible From<Vec<Elem>> (and hence std's blanket TryFrom<Vec<Elem>, Error = Infallible>) rather than the fallible DeserializeError door — a duplicate-tolerant set has nothing to reject, so there is no fallible door to make uniform.

The three set operations every consumer of these types reaches for are one-liners on this surface:

  • Uniondst.extend(src) (std set Extend: a duplicate is a keep-first no-op, so extending is union; nothing is discarded and no Result is threaded).
  • Empty-means-absent for an optional field — Nominal::try_opt_from(vec) (empty → None; non-empty → the checked door in Some; only a duplicate surfaces as Err). Prefer this over Vec::try_into().ok(), which silently swallows the duplicate error alongside the empty one.
  • Dedup-normalize a duplicate-tolerant source (legacy eras, loose JSON, JS arrays) — collect through FromIterator, which dedups keep-first: vec.into_iter().collect::<OrderedSet<_>>(), and for the non-empty twin compose it with the refinement door: vec.into_iter().collect::<OrderedSet<_>>().try_into()?.

Wasm surface (flattened). The wasm #[wasm_bindgen] class for a set nominal has no Deref, so it DELEGATES the collection surface directly rather than forcing a two-layer unwrap: len(), indexed get(index) -> Elem, insert(elem) -> bool (the std-set door: false = already present), add(elem) (the CHECKED door — a duplicate is refused with an error), contains(elem) -> bool, a list-taking try_from(<Elem>List | Vec<Elem>), and the empty-means-absent try_opt_from(...) -> Option<Nominal>. A JS read is set.get(i) — not set.get().get(i) — and the wasm surface tells the same story as the rust API above. The companion collection class (<Elem>OrderedSet, still the element-crossing boundary for the nominal's new(inner)) carries the same insert/contains/add doors. A rule-name alias binding a set instantiation (required_signers = nonempty_set<key>) produces no wasm class of its own — see the JS re-key note in Current capacities § "Wasm class re-key".

Grammar selects the tag record, which lives on the nominal's own encoding struct (not the holder's — a set field no longer flattens its encodings onto the containing record): a two-arm idiom's OPTIONAL tag rides inner_tag_encoding: TagPresenceEncoding — a tri-state (Tagged(Option<Sz>) / Untagged, default Tagged(None) = tagged at fit-minimal size) deliberately NOT Option<Sz>, which would conflate "untagged" with "tagged, default size" — while the single-arm and inline MANDATORY tag rides a plain inner_tag_encoding: Option<Sz>. --canonical-form normalizes the tag's size, never its presence. A non-258 tag has no set-semantics registry entry, so a non-258 tagged idiom stays a transparent optionally-tagged alias exactly as before.

The any type (AnyCbor runtime value)

CDDL any matches any single well-formed CBOR item, so it lowers to a dedicated runtime type, AnyCbor — a structured enum over the CBOR data model (unsigned/negative integer, byte/text string, array, map, tag, and the major-type-7 specials: bool, null, undefined, unassigned simple, float). A crate whose spec uses any in any supported position carries a generated any_cbor.rs runtime module (emitted only when the finalized types actually contain any; specs without it are byte-identical to before). An any-typed member (de)serializes through AnyCbor like any other field — it contributes no encoding metadata of its own (it carries its own), so the owner's length and key machinery is unchanged.

AnyCbor comes in two flavors, matching the crate-wide --preserve-encodings split:

  • Non-preserve (default): value-level. Deserialize accepts any well-formed item; re-serialization is canonical-ish (smallest integer widths, definite lengths, floats written f64), exactly as the rest of a non-preserve crate behaves. Equality/ordering/hashing compare value (floats by bit pattern so the relations stay total and NaN is self-consistent).
  • Preserve (--preserve-encodings): representational. Every value additionally carries the encoding detail needed to re-emit the original bytes byte-for-byte — integer/tag argument widths, string chunking, and array/map length encoding. Under --canonical-form the same value serializes to RFC 8949 §4.2 deterministic encoding instead. Equality/ordering/hashing are representational (two values with equal content but different encoding — 0x01 vs 0x1801, both the integer 1 — compare unequal), matching the @used_as_key preserve-struct precedent, so an AnyCbor map key never silently collides. This total order by construction is why any is a legal table domain ({ * any => v }) under both flavors.

Both flavors deserialize without panicking on malformed input (truncated data, over-claimed lengths, reserved simple values, stray break codes all return a DeserializeError), never allocate from an untrusted claimed length, and — under --deserialize-depth-limit=N — bound their own recursion through the same depth guard the generated composite deserializers use, so hostile deeply-nested CBOR in an any position returns a graceful error rather than overflowing the stack.

Constructors and accessors

AnyCbor exposes a mode-independent constructor for each CBOR kind — new_uint(u64), new_nint(i128), new_bytes(Vec<u8>), new_text(String), new_array(Vec<AnyCbor>), new_map(Vec<(AnyCbor, AnyCbor)>), new_tag(u64, AnyCbor), new_bool(bool), new_null(), new_undefined(), new_unassigned(u8), new_float(f64). The same names and signatures exist in both flavors; under --preserve-encodings they fill default (minimal-width, definite-length) encodings, so a value built by hand serializes canonically. Read back through kind() -> AnyCborKind and the as_uint()/as_bytes()/… accessors (each returning Option<T>).

RFC 8610 expected-conversion tags over any

The RFC 8610 prelude names eb64url, eb64legacy, and eb16 are supported as the fixed tagged forms #6.21(any), #6.22(any), and #6.23(any). A use of one emits a nominal wrapper such as PreludeEb64url around AnyCbor: native Rust constructs it with PreludeEb64url::new(inner) (or From<AnyCbor>) and reads its payload with get(). Its encoder always writes, and its decoder requires, that wrapper's declared tag. Default mode treats the arbitrary payload like any other AnyCbor; preserve mode also retains the payload and tag encodings it decoded.

The tags are advice for rendering a CBOR item, not a request for this crate to choose a base64 or base16 text syntax. No base64/base16 string API is invented: callers retain the opaque AnyCbor payload, and the declared advice remains on the CBOR wire. cbor-any is intentionally different and remains unsupported because its self-describe tag marks a whole serialized CBOR stream rather than an ordinary value.

Under JSON, these wrappers use the same natural any adapter as an authored #6.21(any) wrapper: the CBOR-only tag is omitted and the contained item is rendered naturally (or fails where natural any rendering cannot represent it). The standalone AnyCbor tagged JSON value codec remains available as the escape hatch described below.

JSON representation (--json-serde-derives / --json-schema-export)

There are two JSON surfaces for any, and which one a value uses depends on where it sits:

  • Natural JSON — the primary surface, used everywhere a generated type contains an any. A member ({1: any}), a homogeneous-array element ([* any]), a {* K => any} table range (with a stringifiable key), an any type-choice arm, and a newtype wrapping an any (#6.11(any)) all render the CBOR value as the JSON value it naturally is{"count": 3}, not {"map": [[{"text":"count"},{"uint":3}]]}. This is the only rendering that composes with static typing (a {* text => any} member is a TypeScript { [k: string]: unknown }) and the only one a human-authored from_json document looks like.
  • The AnyCbor value codec — a total, tagged rendering, described in the subsection below. It is what a bare AnyCbor (the x = any top-level alias, and the AnyCbor wasm wrapper) uses, and the value-level escape hatch when natural to_json fails.

Natural rendering (RFC 8949 §6.1 injective subset, strict-fail elsewhere)

Natural rendering implements the injective subset of RFC 8949 §6.1 and fails loudly on any value §6.1 would only represent by substitution — no substitute values are ever emitted, because the output feeds a symmetric from_json and a silent substitution would be write-back corruption. Consequently to_json on a type that contains an any is fallible on data: a value in the failure set makes it return an error naming the offending node kind. When that happens, reach into the field and use the value codec (below) on the AnyCbor directly.

CBOR valueNatural JSON
uintnumberfull u64 range; values > 2^53 stay numbers (an I-JSON precision caveat, not a failure)
nint (fits i64)number
textstring
bool / nulltrue/false / null
finite floatnumber
arrayarray (recurse)
mapobjectiff every key is text/uint/nint (stringified: text verbatim, ints decimal) and no two keys stringify identically
bytesto_json errors (no injective image)
tag (any number)errors
undefinederrors
unassigned simpleerrors
non-finite float (NaN/±Inf)errors
nint below i64::MINerrors (serde_json's number model bottoms out at i64)
complex / colliding map keyerrors (a non-text/uint/nint key, or two keys with the same string form — e.g. uint 12 and text "12")

Read side (RFC 8949 §6.2). A lexically-integral JSON number reads back as uint (non-negative) or nint (negative); anything with a fraction/exponent, or beyond i64 magnitude, reads as a float (so 5 → uint, 5.0 → float). Object keys use the any-domain prefer-numeric rule: a canonical decimal spelling reads as an integer, else as text — so a text key "12" JSON-round-trips to uint 12 (the documented JSON-only ambiguity; "012"/"+7"/"5.0" stay text). CBOR is authoritative; JSON is the lossy side by charter. The round-trip law is from_natural_json(to_natural_json(x)?) value-equal modulo encodings, object-key ordering, and that numeric-key reading.

Serializer-independence. to_natural_json returns a serde_json::Value, but the #[serde(with = …)] adapter that puts it on a member does not hand that value to the serializer through serde_json::Value's own Serialize — it walks it through the json_value_ser runtime module, which renders numbers through serde's real integer/float methods. That is what keeps an any member honest under serde_json/arbitrary_precision, where serde_json::Number's own impl emits a private token struct that non-serde_json serializers (serde-wasm-bindgen, ciborium, …) ship verbatim. The table above therefore describes what every serializer sees, not just to_json. The same module is the published helper a hand-written Serialize should route through — see Wasm differences.

Depth. JSON reading (both natural and the tagged value codec) inherits serde_json's own recursion limit, so hostile deeply-nested JSON is a graceful Err, never a stack overflow; JSON writing (to_json/to_natural_json) carries the crate-wide unbounded-recursion posture — no depth guard on the write path (use --deserialize-depth-limit to bound the CBOR read side).

The AnyCbor value codec (tagged)

A bare AnyCbor derives serde as a total, tagged representation of CBOR — every value is a single-key object keyed by the snake_case kind name. It round-trips every CBOR value (nothing in the natural failure set above), which is why it is both the top-level x = any alias's surface and the escape hatch for a natural to_json that failed. Encoding detail never appears, so two preserve values differing only in encoding render identically.

CBORJSON
uint{"uint": 5} (JSON number, u64 range)
nint{"nint": -3} when it fits i64, else {"nint": "-18446744073709551616"} (decimal string; the nint domain exceeds i64). Read accepts both.
bytes{"bytes": "a1b2"} — canonical hex, read as well as written (see Hex text)
text{"text": "…"}
array{"array": [ … ]} (recursive)
map{"map": [[K, V], …]} — an array of pairs, so wire order and duplicate/non-string keys survive
tag{"tag": [11, V]}
bool{"bool": true}
null{"null": null}
undefined{"undefined": null}
unassigned{"unassigned": 250}
float{"float": 1.5}; non-finite values as strings {"float": "NaN" / "Infinity" / "-Infinity"} (serde_json cannot emit them as numbers). Read accepts both; NaN payload bits are not round-tripped.

from_json(to_json(x)) recovers x exactly for the non-preserve flavor (all finite floats); for the preserve flavor it recovers a value-equal item with encodings reset to defaults. A { * any => any } table (or any table with a non-string key) cannot serialize to JSON either way — serde_json requires string map keys, so to_json errors at runtime, matching how a { * bytes => uint } table already behaves; string/uint-keyed any ranges ({ * uint => any }) are unaffected.

The JSON Schema (--json-schema-export) mirrors this split: a generated type's any content is the permissive "any JSON value" schema (json2ts → TS unknown, a {* K => any} member → { [k: string]: unknown }), while a bare AnyCbor keeps the tagged oneOf schema over the kind objects above.

The wasm wrapper for any is documented under wasm differences.

Hex text: the canonical grammar

Three consumer-facing surfaces render bytes as hex text, and all three accept exactly the grammar they emit: bare, even-length, lowercase — no 0x/0X prefix, no uppercase digit, no separators.

  • RawBytesEncoding::to_raw_hex / from_raw_hex, the raw-bytes trait every _CDDL_CODEGEN_RAW_BYTES_TYPE_ implements;
  • the JSON representation of a bytes newtype under --json-serde-derives (a rule like hash = bytes ; @newtype), whose hand-written serde impls write and read that same text;
  • the AnyCbor value codec's {"bytes": "…"} form.

Canonical in, canonical out, and the property that buys: for every string the reader accepts, re-encoding the decoded bytes reproduces that string byte for byte. The accepted grammar and the emitted grammar are the same grammar, so no accepted spelling exists that this crate would not itself have written. A reader wider than its writer could only offer the weaker bytes-level round trip, and would leave the wire format under-specified in a direction nothing observes.

The first two surfaces read through one function, decode_canonical_hex, exported from the generated serialization.rs (or from the shared runtime crate under --common-import-override). The third carries its own copy of the grammar inside the any_cbor runtime, because a crate emitting AnyCbor does not necessarily take the hex dependency at all — same accepted grammar, separate code. What differs between the three is only how much detail a refusal can carry:

Inputfrom_raw_hexJSON bytes newtypeAnyCbor {"bytes": …}
"a1b2"OkOkOk
"A1B2"Errinvalid character 'A' at position 0Errinvalid hex bytesErrinvalid hex nibble 'A'
"a1B2" (mixed)Errinvalid character 'B' at position 2 (the FIRST offending digit)Errinvalid hex bytesErrinvalid hex nibble 'B'
"0xa1b2"Errinvalid character 'x' at position 1Errinvalid hex bytesErrinvalid hex nibble 'x'
"0Xa1b2"Errinvalid character 'X' at position 1Errinvalid hex bytesErrinvalid hex nibble 'X'
"abc"Errodd number of digitsErrinvalid hex bytesErrodd-length hex string (len 3)

The raw-bytes surface boxes the hex error into DeserializeFailure::InvalidStructure, which renders it inline (the texts above appear after that failure's own prefix), so the offending character and its 0-based index reach consumer output. The prefix needs no rule of its own — x/X are simply outside the canonical alphabet, so the same scan that rejects an uppercase digit names them. The bytes-newtype JSON surface is verdict-only by construction: serde's invalid_value carries one wording for every refusal, so a reader cannot tell which check declined the string. The AnyCbor codec's own messages reach you through serde's Error::custom, so they arrive with serde_json's at line L column C position appended; that routine checks the digit count first, so a string that is both odd-length and non-canonical reports the length where the other two name the character.

If you hold hex produced elsewhere, lowercase it at the call site (s.to_ascii_lowercase()) — see the upgrade note for what changed and when.

Open struct-maps (rest rows)

A CDDL map that mixes fixed members with a trailing * K => V entry{ 1: uint, 2: text, * uint => any } — is an open struct-map: the fixed members deserialize as declared, and every unknown map entry is captured into a generated rest field instead of being an error (a plain closed struct rejects unknown keys). This is the loose-CBOR "capture" shape for forward-compatible schemas. The rest row must be the map's last entry and follow ≥1 fixed member. Its occurrence is row-local: */0* use a loose empty-capable carrier, +/1* use NonEmptyMap (or the preserve pair-map twin), and every other window uses BoundedMap/BoundedPairMap; fixed fields never count toward that window. Restricted rows are explicit checked-carrier inputs to new(), while CBOR and JSON stage loose entries then cross one TryFrom door. A non-final rest row, a rest row in a group-choice arm or a plain group remains a graceful rejection. The key domain is a general typeuint, text and any take a fast path that reconstructs the key from the dispatch the decode loop already ran, and every other key type (bytes, nint, sized ints, bool, unions, structs, tagged and .cbor domains, externs, generic instances, aliases) is read and written by its own codec. Two key domains are rejected: one containing a float (floats have no total order, so they cannot key a map) and one admitting null (a null key and the break ending an indefinite-length map are both CBOR special values, so the row's key dispatch cannot tell them apart). The value type is otherwise unrestricted (including any).

A third shape is rejected on either slot, because it is a property of the map entry rather than of key dispatch: a plain group (kv = (a: uint, b: uint), t = { c: uint, * kv => uint } or t = { c: uint, * uint => kv }). A CBOR map entry holds exactly one item in each of its two slots and a keyless group has no single-item form — it splices its members flat, contradicting the map's own entry count — so the row has no wire. The guard reads the resolved slot type, so the bare and alias spellings refuse alike, and both slots are reported when both offend. A tagged group instead reaches the separate tag-payload boundary: #6.10(kv) is invalid because a tag wraps one TYPE, so frame the group first. The remedy the message names is the group's array framing, which gives the slot the one item it needs: t = { c: uint, * [kv] => uint } / t = { c: uint, * uint => [kv] }; a tag belongs outside that framing, on the framed reference (#6.10([kv]), not [#6.10(kv)]). The prefix-less spelling (t = { * kv => uint }) is the pure table shape and keeps its own message for the same reason.

Which path a domain takes is not a preference but a fidelity rule: the fast path can only rebuild a key the dispatch already told it everything about, so anything the dispatch does not see keeps its own codec. A domain carrying an encoding operation (* #6.24(uint) => v, * bytes .cbor uint => v) or a @custom_serialize/@custom_deserialize pair therefore reads through that codec exactly as it writes through it — the row's two halves are the same codec by construction, so what it emits is what it accepts.

Capture is typed, so it refines rather than tolerates. An unknown entry whose key or value bytes K or V refuse fails the parse; there is no untyped bucket to fall into and no capture-on-failure fallback. * uint .size 1 => uint rejects a key outside the u8 range, and a union domain rejects a key matching no arm — the same posture typed table keys and typed values take everywhere else. A declared key always peels first, so a second occurrence of one is a DuplicateKey error and never a capture. Tolerating unknown entries without storing them is the separate @ignore flavor below, which still requires each entry to deserialize.

The rest field. Ordinarily this is a pub map named rest (rename with @name on the row — see the comment DSL), keyed by K, valued V. A loose row is excluded from new() and defaults empty, so adding it is source- and wire-compatible. A restricted row instead is a checked new() parameter, so callers cannot construct an out-of-window map. Its carrier matches the table switch and the @duplicates policy:

modecontainer
loose defaultBTreeMap<K, V> (OrderedHashMap<K, V> under --preserve-encodings)
loose @duplicates preservePairMap<K, V> (the byte-exact duplicate-keyed twin)
+ / 1* default or preserveNonEmptyMap<K, V> / NonEmptyPairMap<K, V>
every other restricted window default or preserveBoundedMap<K, V, MIN, MAX> / BoundedPairMap<K, V, MIN, MAX>

When an open record's captured key domain can produce a declared or exact-zero fixed CBOR key, its carrier is private even for a loose row: rest() lends it immutably and insert_rest validates each key before changing the carrier. Statically disjoint bare primitive domains retain their public carrier; typed, encoded, and custom-codec domains are checked conservatively because their emitted wire major can differ from their Rust representation. Declared-key collisions report DuplicateKey; exact-zero keys retain ForbiddenKey. Open tables have no fixed keys, so their dynamic rows remain unaffected. An exact-zero fixed member (0*0 key: value or *0 key: value) is a different constraint: it emits no value field or schema property, and its existing complete-rest fallible constructor remains the construction door.

Serialize / round-trip. The map header counts the declared members plus every rest entry (N + rest.len()); declared members serialize first, then the captured entries. The default @duplicates reject policy refuses a duplicate captured key (value-equality — a DuplicateKey decode error); @duplicates preserve keeps duplicates via the PairMap twin.

Captured keys CBOR-value-equal to declared keys are rejected before mutation or serialization, even when their Rust representation, preserved width, or JSON spelling differs. @duplicates preserve still retains duplicates among rest entries, but never a declared/rest collision.

Preserve / canonical. Under --preserve-encodings the original wire order (declared and captured entries interleaved) is recorded and replayed byte-exact, and each captured entry's key/value header widths are preserved — via per-entry encoding sidecars for concrete domains, or self-carried by AnyCbor for any content. Under --canonical-form the declared keys and the captured rest keys are merged and ordered together length-first-then-bytewise (a rest key can sort before a declared key), and each captured key's header is minimized.

wasm. The rest field is exposed as a read-only rest() getter returning a minted map-wrapper snapshot: mutating that returned wrapper never mutates the record. The record's parent mutation door is insert_rest(key, value) -> Result<(), JsError> (or insert_<name> when the row has @name). It replaces equal keys for the ordinary carrier and appends for @duplicates preserve; a bounded carrier returns JsError before an overflow can change the record. A loose row has no new() argument and defaults empty; a restricted row takes its complete checked wrapper at the fallible constructor boundary. Exact-zero records instead take the complete wrapper at their fallible constructor boundary even for a loose row, and their wasm insertion delegates to the native forbidden-key check. There is no general setter; see wasm differences.

JSON (--json-serde-derives). Captured entries render flattened at the same JSON object level as the declared members (via serde flatten), under the natural-JSON key rules above:

  • Write (to_json) errors if a rest key stringifies to a declared member's JSON name, if two rest keys stringify identically, or if a rest key/value falls in the natural failure set (a complex any key, non-finite float, bytes, …). Values render through the natural walk for an any range; a typed range renders through V's own serde, so a union value is serde's externally-tagged object ({"Text": "hi"}) and a bytes union arm is the derive's array of byte numbers — the hex spelling is the typed-bytes field convention and a union arm never reaches it.
  • Read (from_json) binds the declared member names first; every other key lands in rest (loose JSON parsing for free). A bare uint/text domain parses deterministically (decimal / verbatim); an any domain uses the prefer-numeric key rule (a canonical decimal spelling reads back as an integer, else text); every other key domain reads through its key image, numeric first and text as a fallback.
  • The JSON Schema emits properties (the declared members) plus the open-region schema: a uint-keyed rest row renders as patternProperties ("^\d+$" → the range's schema), a text- or any-keyed one as additionalProperties (the range's schema; permissive {} for an any range) — the honest open-map shape either way (json2ts → a TS interface with an index signature). That shape follows the rest-row position (key domain × value type), not the container holding the entries, because the flattened JSON is produced the same way whichever container that is: a @duplicates preserve row therefore publishes exactly the schema its non-preserve twin publishes (the duplicate-key to_json failure above is data-dependent and has no JSON-Schema expression).

The TypeScript projection of an open region is a union. A JSON-Schema catch-all (additionalProperties/patternProperties) ranges over exactly the members the declared properties do not match; a TypeScript index signature has no such scoping — it ranges over every property, and the language requires each declared member to satisfy it. The two are therefore not the same statement, and the exact one is inexpressible. run-json2ts.js compiles the open region as [k: string]: <range> | <the declared members' types> (plus undefined when a declared member is optional), which is the least-false legal TypeScript: it loses the disjointness and nothing else. Emitting the range alone would instead be illegal TypeScript — one TS2411 per declared member whose type the range does not admit, which is every rest row over a range that is not the declared members' type.

That widening is a projection step, applied to an in-memory copy while compiling. The published schema keeps its exact additionalProperties: widening the document itself would make it over-accept, since a rest member matching a declared member's schema would validate against a document from_json rejects. Two catch-alls are left as they are, because they are already legal and already exact: one that admits every value (an any range's {}, which projects to unknown), and one a declared member's schema is structurally equal to (a * uint => uint row beside a uint member — the declared type adds nothing to the union).

Typed key domains in JSON

A rest key is a JSON object member name, so a general key type K needs a string image — not K's own JSON rendering. The image is the any domain's key convention applied to K's own CBOR bytes: text verbatim, uint/nint in decimal, and every other CBOR major type has no image at all (to_json errors naming the kind). An encoding operation on the domain (#6.24(uint), bytes .cbor uint) is not part of the image — the image is of K's rust member, which the read side rebuilds and the CBOR serializer re-wraps, so the two faces stay symmetric. Consequences worth knowing: a * bytes => V row has no JSON image for any key, so its flattened region is a pure error surface in both directions; and a nint domain images the wire value (-5), not the rust member, which holds the CBOR argument (4).

Reading a member name back prefers the numeric reading and falls back to the text reading when K refuses it; a name every reading refuses is a hard parse error, never a capture. The fallback is what makes the JSON fixed point total: a text-only K holding the key "12" would otherwise write a document our own reader rejects. The contract:

T1 JSON fixed point, totalif to_json succeeds with document J, from_json(J) succeeds and to_json(from_json(J)) == J, for every K
T2 value fixed point, partialfrom_json(to_json(x)) == x unless some key of x is a TEXT whose content is a canonical decimal spelling and K also admits the numeric reading of it — the any domain's documented ambiguity, now scoped by what K admits (CBOR stays authoritative)
T3 write is loudly fallible on datato_json errors — never substitutes — on a key with no image, on two keys imaging identically, and on a key imaging to a declared member's JSON name
T4 refinement on reada member name no reading of K admits fails the parse, mirroring the CBOR-side refinement semantics

T3's collision check is what keeps T2's carve-out bounded: a row can hold text "12" or uint 12, never both, so the rebinding loses the key's type but never merges two entries. It is also why @duplicates preserve is CBOR-only fidelity — duplicate keys image identically by definition, so a row actually carrying duplicates has no JSON image and to_json errors naming the collision.

The published schema of a typed-K region is an open object over the range (additionalProperties), on both containers. Member names are K's key image, which no K-derived schema describes: deriving the region from BTreeMap<K, V> would constrain the names by K's value schema and set additionalProperties: false, closing the very region the rest row advertises. The BTreeMap-derived shape is therefore kept only for the bare uint/text domains, where K's schema and the key image coincide. Because the helper asks nothing of K, a key type with no schemars::JsonSchema impl (an extern carrying @no_json_schema_export) still compiles — and contributes nothing to the published document.

The @ignore (tolerate-and-drop) flavor

Marking the rest row @ignore switches from capture to tolerate-and-drop: unknown entries are still typed-deserialized (key and value, so the stream advances) but then discarded. The generated type is a plain closed struct — no rest field, no new() change, and serialize emits only the declared members. This is deliberately lossy: byte round-trips do not hold for wire data that carried unknown entries (the type and its serialize fn carry a rustdoc breadcrumb saying so), which is why it is rejected under --preserve-encodings (a preserve crate's byte-exact contract cannot hold for it) along with @duplicates/@name on the same row.

@ignore is deliberately limited to loose * / 0* rows. Dropping a restricted row would re-serialize zero entries: that violates a positive minimum, while a zero-minimum restricted window would lose the bounded or exact state its checked carrier retains.

Because the type is a closed struct, its JSON and schemars surfaces are an ordinary closed struct's — the plain derive, no flatten, no additionalProperties/patternProperties open-map schema. Unknown JSON keys are tolerated on read (serde's default ignore-unknown-fields behavior, the JSON-side mirror of the CBOR looseness) and dropped, matching the CBOR contract. On wasm there is no rest() getter (nothing is stored); the class is a closed struct's, and the read-tolerance comes for free through the rust deserializer.

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

A named rule of exactly two * k => v rows and nothing else — t = { * K_t => V_t, * K_r => V_r } — is an open table: one typed table row plus one trailing typed catch-all, routed by wire major type. The CDDL-side contract (what makes K_t's major statically knowable, and the shapes that are graceful rejections) lives in the comment DSL; what follows is the emitted face. It lowers to a struct with two pub containersentries (the typed row) and rest (the catch-all), each @name-renameable, each with its own @duplicates policy, container spelling and encoding sidecars. The loose * form has no new() arguments and defaults both containers empty. Everything the rest-row sections above say about a container's spelling, duplicate policy and preserve/canonical replay applies to each row; what is new is that two rows share one map, which is where the JSON face earns its own rules.

The two regions share one order vector

Under --preserve-encodings a struct records the wire order it read in encodings.orig_deser_order, and an open table's entries come from two dynamic sequences rather than one. The slots are therefore tagged by source sequence rather than laid out in one flat index space: the typed row's i-th entry is slot 2*i, the catch-all's j-th is slot 2*j+1. Reading a slot means reading its low bit for the row and its half for the position within that row. (A flat N + i scheme cannot work here: the second sequence's base would be a runtime length that is not yet final while the loop is still pushing.) The serialize replay matches on field_index % 2, and under --canonical-form the key merge builds one order across both containers by comparing encoded key bytes — so a canonical write interleaves the two regions freely.

The NonEmpty twin

Spelling the typed row +/1* (t = { + K_t => V_t, * K_r => V_r }) adds a min-1 bound counting typed entries. Its public entries field is the actual restricted carrier — NonEmptyMap<K_t, V_t>, or NonEmptyPairMap<K_t, V_t> under @duplicates preserve — so clearing or removing its final entry is not representable. new takes the first typed entry — new(first_key, first_value) enters that carrier's new door directly — and the wasm and WIT constructors project those two parameters likewise; the catch-all still defaults empty. CBOR decode stages in the corresponding loose map and then calls the carrier's TryFrom, preserving duplicate/order behavior while raising its shared DeserializeFailure::RangeCheck { found: 0, min: Some(1), max: None }; the JSON visitor stages then builds through new for the same reason. JSON Schema publishes no expression of itminProperties would be wrong, since it counts both regions — so a schema consumer sees the unbounded open object and the bound lives in the reader alone.

Bounded rows

Every other row window (?, 0*n, n*m, *n, n*, and omitted exact-once) is a checked BoundedMap/BoundedPairMap for that row alone. A bounded typed row remains flattened on the owner's API: every bounded window takes a loose builder which converts fallibly before native new, and its finite-max insert returns the carrier's checked result. A bounded catch-all is a read-only restricted map wrapper passed whole to new. Component constructors take fallible list<tuple<K, V>> inputs and re-enter the carrier. CBOR/JSON both stage each partition separately. JSON Schema deliberately has no minProperties/maxProperties: either would count both rows (and an open struct's fixed fields), which is not the CDDL constraint.

JSON (--json-serde-derives): one flattened object holding BOTH regions, written by a hand-written Serialize/Deserialize pair rather than the derives. Two #[serde(flatten)] members cannot express this shape in either direction — on read serde hands every unmatched member to both flattened fields (nothing partitions them), and on write both fields forward into one map with no dedup (so two regions imaging one name emit it twice). The partition and the cross-region collision check are the whole point, so one emitted impl owns both regions.

Two regions, two key images

The typed row images its keys through K_t's own serde string image — the closed-table convention, i.e. what serde_json's map-key serializer makes of K_t::serialize. The catch-all keeps the rest-row image over K_r's own CBOR bytes. That the two differ is forced, not an inconsistency: a bytes-keyed typed row has no CBOR member-name image at all, so a CBOR image would make to_json fail on exactly the shape an open table exists for, while a key type whose serde writes a hex string images to a perfectly ordinary member name.

Two consequences worth knowing:

  • Two wire spellings of one rust type image identically. A key alias whose @custom_serialize pair writes hex text and one that writes bytes are the same rust type, so their JSON member names are the same bytes. A versioned wire keeps one JSON face for free — the CDDL @custom_serialize pair is invisible to the JSON face in every position, table keys included.
  • K_t's Serialize must produce a JSON string (or an integer/bool serde_json renders as one). Nothing can check that at compile time: a bare bytes key (Vec<u8> → a JSON array) or a derive over a byte array is a runtime to_json error naming the key. A hand-written hex/bech32/base64 Serialize — what a raw-bytes marker or extern key type is expected to carry — satisfies it.

Reading is typed-first

Every member name is offered to the typed row first: it binds entries iff K_t's own reading admits it (the map-key deserializer semantics that mirror the image above), otherwise the catch-all's own reading decides, and a name no reading admits is a hard error whose message names all three attempts. Two rules follow the CBOR face's refinement-not-tolerance posture rather than JSON habit:

  • once a name binds typed, its value is read as V_t and a V_t-refusing value is a hard error — it does not fall through to the catch-all (typed-first is about keys, and the row it selects owns the value too);
  • a repeated member name is a hard error, detected explicitly, because serde_json's object parser is last-wins and would otherwise silently drop the entry the CBOR face rejects as a DuplicateKey.

The T-contract, inherited — and where it bends

The rest-row contract carries over with T1 conditional:

T1 JSON fixed pointholds when the two rows' admissible member names are disjoint (or when a captured row's values also read as V_t). Otherwise a document to_json wrote can be refused by from_json: the name rebinds typed and V_t refuses the value
T2 value fixed point, partialthe rest row's carve-out, plus rebinding: a captured key whose image K_t's reading admits comes back on the TYPED row. JSON stays a fixed point where the ranges agree; what moves is the entry's row, i.e. the CBOR major it is next written under. CBOR stays authoritative
T3 write is loudly fallible on datathe rest row's failures, plus two typed keys imaging identically, plus a typed key and a captured key imaging identically — one collision set spans both regions, because they share one object
T4 refinement on readunchanged; the message names the typed reading and the catch-all's own readings

The degenerate case of T1 is not left to the reader: a K_t that admits every string takes every member name on read, so the catch-all region is unreachable through JSON and from_json refuses documents to_json itself wrote — T1 failing on every document with a captured entry, not on an edge case. Where that is decidable it is a graceful rejection: a K_t that transparently resolves to String (bare text, an alias of it, a .size-constrained one) is refused under --json-serde-derives/--json-schema-export, naming the CBOR-only nature and the remedies. Generate such a spec without the JSON flags, or key the typed row on a type whose admissible names are a proper subset (a fixed-width hex image, a decimal domain). Where it is not decidable — an opaque K_t (an extern, or a @newtype whose hand-written Serialize/Deserialize happens to read every string) — the tool cannot see the reading and the hazard stands: choosing such a key type is choosing a CBOR-only shape, and the T1 row above says what that costs.

Schema

The minted struct joins the published schema surface — a genuine addition, since a closed table is a transparent pub type alias and publishes nothing at all. It publishes one open object over both ranges:

{ "type": "object", "additionalProperties": { "anyOf": [ <V_t>, <V_r> ] } }

collapsing to the single branch when the two ranges publish the same schema. It names neither key type, for the reason the rest row's open region names none: the member names are two key images, which no K-derived schema describes. That also extends the rest row's compile exemption to both of an open table's key domains — a key type with no schemars::JsonSchema impl (an extern carrying @no_json_schema_export) still compiles, and contributes nothing to the document. V_t/V_r are not exempt: they are named. An open table has no declared properties, so the TypeScript projection is the published union verbatim — [k: string]: VtJSON | VrJSON.

@custom_json on the rule suppresses all of this and hands both faces to your own impls, exactly as it does for any other generated type.

Executed vectors: tests/open-table-json-e2e (both regions in one object, the cross-region collision, the duplicate-member detection, the rebinding carve-out, the three-attempt read failure) and tests/open-table-e2e (the CBOR face).

Open arrays (rest tails)

A CDDL array that mixes fixed members with one occurrence-bearing element — a final tail such as [uint, tstr, * uint], or a safe leading/middle segment such as [* bytes, tstr] or [uint, * bytes, tstr] — is an open array: fixed members deserialize positionally as declared and the repeated elements are captured in a generated rest field. This is the array analog of the open struct-map rest row — structurally simpler because arrays are positional: there are no keys, duplicate policy, key dispatch, or ordering machinery. The occurrence may be final, or it may be the sole leading/middle segment only when its immediate fixed suffix is mandatory and expands to exactly one CBOR item. A variable-cardinality middle window also requires either the absence of a field-local custom codec, an effective wire head on both boundary items, and no CBOR major type in common with the repeated element; or untagged generator-owned finite fixed-value domains on both sides with no CDDL value in common. A generator-proven head qualifies; a transparent custom-codec alias may instead declare the head with @custom_wire_major. An exact window (N*N, except 1*1 which is an ordinary field) instead stops after N items: [uint, 2*2 uint, uint] is therefore supported even though the segment and suffix share a major or use custom-/extern-owned heads. Count delimits that suffix only: an optional prefix still needs generator-proven, distinct heads on both sides of its peek boundary. A custom-codec or opaque-extern optional/repeated member at that boundary is serialize-only unless a mandatory outer tag or .cbor frame proves the distinct outer head. Bare * / 0* is loose; + / 1* is the compatibility min-one form; every other window (2*3, *3, 2*, *0, 0*0, …) is bounded.

The restriction is RFC 8610's greedy, non-backtracking matching rule, not an implementation convenience. A decoder for [uint, * uint, uint] cannot know whether a uint is the repeated value or the suffix; accepting it would let a constructed value serialize to bytes that do not deserialize back to that value. A normal variable safe-middle loop greedily consumes only the repeated element's majors and stops at the major-disjoint suffix; a fixed-domain loop instead tries the repeated decoder once and rewinds only when it fails, which is safe because the statically enumerated domains are disjoint. An exact loop consumes its declared count and then reads that same suffix. None speculates by parsing the suffix to choose between successful interpretations. General same-major/value- discriminator designs, multiple occurrences, inline-group occurrences, group-choice-arm occurrences, a segment inside a plain group's body, a fixed-value element type like * 5, and a plain-group element type remain graceful rejections naming a supported remedy. A declared custom head still has to be disjoint from the other effective boundary set; custom-, tagged-, float-, primitive-, and extern-owned same-major value discrimination is not inferred. The declaration is not consumed by a final tail, an exact count-delimited window, optional-prefix lookahead, or a mandatory generated outer frame that already supplies the head. The plain-group element type (kv = (a: uint, b: uint), t = [ c: uint, * kv ]) refuses because an occurrence segment collects one value per repeated array element and a plain group is not one — it has no type of its own, splicing its members flat instead — and the guard reads the resolved element type, so the bare and alias (* kv_alias) spellings refuse alike. A tag payload is a type, so * #6.10(kv) reaches the separate plain-group tag-payload refusal; frame first, then tag (* #6.10(w) where w = [kv]). The remedy the message names is the group's array framing: w = [kv], then t = [ c: uint, * w ]. A single mandatory splice (t = [ c: uint, kv ]) stays supported, but the sole-element homogeneous array ([* kv]) also refuses: repeating a group concatenates its members, whereas a homogeneous collection element is one item. Use w = [kv], then [* w]; the full splice-position matrix is Plain group placement.

The rest field. A loose * / 0* segment is pub Vec<T>, excluded from new() and defaulting empty. A + / 1* segment is pub NonEmptyVec<T>: new() takes its first repeated element, preserving that public ABI. An ordinary or duplicate-preserving exact N*N segment is pub [T; N] (including *0); new() accepts that already-valid carrier. Other variable windows are pub BoundedVec<T, MIN, MAX>. Decode stages one loose Vec<T> and exact windows make one TryFrom<Vec<T>> for [T; N] handover, mapping a short/long wire value to RangeCheck; variable windows retain the BoundedVec door. Exact @duplicates reject segments remain BoundedOrderedSet<T, N, N>. A middle segment stops greedily at its maximum: a further repeated-major item remains for the mandatory suffix to read and is rejected there, while a below-minimum middle segment still rejects through the carrier's RangeCheck. An exact middle segment takes exactly its declared count before the suffix. A definite short owner can reserve its mandatory suffix arity and reach the checked-carrier door; an indefinite same-major short owner may consume its intended suffix as a repeated value and then reject at the suffix read. Every short form rejects without seeking, rewinding, or promising one error class, and a trailing extra remains for the owner length check rather than being absorbed by the segment.

An optional fixed member is read by peeking at its reachable following member or occurrence segment. That dispatch is emitted only when both wire heads are generator-proven and CBOR-major-disjoint; a custom codec or opaque extern on either side is serialize-only unless mandatory outer tag/.cbor framing supplies the proof. @custom_wire_major does not broaden this optional-prefix rule. A final optional with no following item remains owner-length-delimited.

Serialize / round-trip. The array header counts the declared members plus every repeated element (N + rest.len()). A final tail serializes after the fixed members; a safe middle segment serializes immediately before its mandatory suffix. The captured elements themselves serialize in Vec order. Typing is enforced: [uint, * bytes, tstr] errors on a non-bytes element in the repeated segment (full looseness is spelled * any, which captures nested-container elements too).

Preserve / canonical. Under --preserve-encodings each captured element re-serializes byte-exact via a positional per-element encoding sidecar ({field}_elem_encodings: Vec<..> in the owner's encoding struct — self-healing on user mutation: a length mismatch degrades to default encodings, it never panics); an any element is self-carried and contributes no sidecar. The segment's local wire order = Vec order = re-emit order by construction, so there is no interleave record or comparator. Under --canonical-form each element re-serializes recursively-canonically in position order (no sort — the segment is positional, not keyed).

JSON (--json-serde-derives). The captured segment renders as an ordinary field — a JSON array under the field's name ("rest": [ … ]), not flattened. Array positions are already erased in JSON and serde flatten has no array analog, so nesting under the field name is the honest symmetric rendering. Only the loose segment writes by skipping the field when empty and reads by defaulting it to empty when absent — so an empty loose segment ≡ closed-struct JSON. Restricted segments are required: + / 1* publishes minItems: 1; bounded segments publish their applicable minItems and maxItems, including zero; all enter their same checked door on JSON read. An any-element segment renders natural-fallible (the same injective-subset rules as any [* any]: a non-injective node like a byte string makes to_json error rather than silently substitute). The JSON Schema is the ordinary array shape with the carrier's bounds.

wasm. The rest field is exposed as a read-only getter named after the field (rest() by default) returning the minted list wrapper. The loose form has no constructor argument; the min-one form retains its first-element constructor; each bounded form takes its matching checked bounded list wrapper. WIT despecializes that wrapper to list<T> and component glue fallsibly converts it back to its established list wrapper before native construction; component WIT remains list<T> and its guest glue restores [T; N] fallibly. The public native carrier does not depend on whether the segment is final or safe-middle; see wasm differences.

The @ignore (tolerate-and-drop) flavor

Marking the occurrence entry @ignore switches from capture to tolerate-and-drop: the repeated elements are still typed-deserialized (so the stream advances past nested containers) but then discarded. The generated type is a plain closed struct — no rest field, no new() change, and serialize emits only the declared fixed members. This is deliberately lossy: byte round-trips do not hold for wire data that carried ignored elements (the type and its serialize fn carry a position-accurate rustdoc breadcrumb), which is why it is rejected under --preserve-encodings (a preserve crate's byte-exact contract cannot hold for it) along with @duplicates/@name on the same entry. Its JSON, schemars, and wasm surfaces are an ordinary closed struct's — safe-middle or trailing elements are tolerated on read and dropped, and there is no rest() getter.

@ignore is deliberately limited to loose * / 0* final or safe-middle segments. Dropping a restricted segment would re-serialize zero elements: that violates a positive minimum, while a zero-minimum restricted window would lose the bounded or exact state its checked carrier retains.

Non-empty containers

An occurrence lower bound of exactly one — [+ T] and the equivalent [1* T] on arrays, {+ k => v} / {1* k => v} on tables — is enforced by the type, not by a bypassable check inside new(). The field type becomes NonEmptyVec<T> (or NonEmptyMap<K, V>), hand-written generics copied into the generated crate, so an empty value is simply not representable. A named rule aliases straight to it: deltas = [+ int] emits pub type Deltas = NonEmptyVec<i64>, giving the rule a real enforcement surface it never had standalone. Every other supported ordinary or @duplicates preserve homogeneous ARRAY window except bare * is likewise type-enforced: exact [2*2 T] (and [*0 T]) emits [T; N], while [2*5 T], [*5 T], [2* T], and [? T] emit BoundedVec<T, MIN, MAX> (u64::MAX is the target-independent unbounded carrier). Bare [* T] remains Vec<T>.

A bounded @duplicates reject array uses BoundedOrderedSet<T, MIN, MAX>. Its one TryFrom<Vec<T>> door preserves original order while refusing duplicate input (with the existing duplicate index) and values outside the inclusive window; checked push/pop/remove preserve both invariants. JSON/schema, CBOR, wasm, component, and requested-wrapper input all re-enter that door.

Each non-empty type provides one conversion contract:

  • TryFrom<Vec<T>> (resp. TryFrom<BTreeMap<K, V>> / TryFrom<OrderedHashMap<K, V>>) — the single checked door. It is the only way to build the value from a loose collection, and it returns a DeserializeError whose RangeCheck { found: 0, min: Some(1), max: None } displays as 0 not at least 1.
  • From<NonEmptyVec<T>> for Vec<T> — the infallible escape hatch. Unwrap to the loose collection, mutate freely, and try_into() back at the edge. Plus as_slice() / AsRef<[T]> / iter() for read access without a round-trip, and infallible push/extend (a push can never break a min-1 bound) with checked pop/remove (removal that would empty the container errors). Value-level mutable access — iter_mut/as_mut_slice/IndexMut on NonEmptyVec, get_mut/values_mut/ iter_mut on NonEmptyMap — is unrestricted: the invariant is about the container's length, which a &mut to an element or value cannot reach, so nested non-empty containers can be updated in place.
  • Deserialize routes through the same TryFrom. Wire-side and API-side enforcement cannot drift: an empty CBOR array/map is rejected during decode with the same 0 not at least 1 error the API raises, so the constraint holds identically at both doors.

BoundedVec has the same loose-to-tight TryFrom<Vec<T>> and widening From/into_inner contract. It exposes slices and mutable elements but never &mut Vec; push, extend, pop, remove, and truncate return the existing RangeCheck before crossing either bound. JSON is an ordinary array and schemars publishes minItems/maxItems. The wasm class is named <Elem>ListMinN, <Elem>ListMaxN, or <Elem>ListMinNMaxN (a named CDDL rule wins); its try_from/add doors are fallible for the same reason, and only a zero-minimum class exposes an infallible empty new() seed. Positive-minimum instantiations have no new method at all. Tables remain outside this representation and retain their documented occurrence limits. Unique-key tables now have the matching BoundedMap<K, V, MIN, MAX> carrier: { k => v } is BoundedMap<K, V, 1, 1>, { ? k => v } is BoundedMap<K, V, 0, 1>, and other finite/lower-bounded windows retain their inclusive endpoints (* stays loose and + stays NonEmptyMap). Its TryFrom<BTreeMap<_, _>>/TryFrom<Vec<(K, V)>> door, checked insert/extend/ remove, and read/value-mutation-only API have the same no-invalid-state rule; JSON schemas publish minProperties/maxProperties. A bounded @duplicates preserve table instead uses BoundedPairMap<K, V, MIN, MAX>: its checked door counts entries (including duplicate keys), and its array-of-pairs schema publishes minItems/maxItems, never object properties.

Because the invalid state is unrepresentable, a struct whose only rejectable constraint is an occurrence lower bound gets an infallible new(): every argument is already valid by construction, and serialization stays infallible (there is no empty value to fail on). Construction never checks a length — it hands over an already-checked value.

Migrating a consumer (Vec<T> field → NonEmptyVec<T> field). The break is mechanical: the fallible new(items: Vec<T>) -> Result<_, _> becomes an infallible new(items: NonEmptyVec<T>), and the length check moves to a single try_into at the point the loose collection is built.

Before (bypassable new check)After (two-type)
Foo::new(tags: Vec<Bar>) -> Result<Foo, DeserializeError>Foo::new(tags: NonEmptyVec<Bar>) -> Foo
let foo = Foo::new(tags)?; (checks length, but a later foo.tags.clear() re-breaks it)let tags: NonEmptyVec<Bar> = tags.try_into()?; let foo = Foo::new(tags);
empty vs. absent modelled as Option<Foo>empty Vec<Bar> is representable — it just isn't a NonEmptyVec yet; Option returns to meaning "the field itself is optional"

Migrating a bounded window (Vec<T> field → BoundedVec<T, MIN, MAX> field) follows the same two-type boundary. The constructor becomes infallible when it receives the already-checked bounded value; call try_into() once where a loose vector is assembled. A zero-minimum BoundedVec can be seeded with BoundedVec::new(); a positive-minimum value must enter through TryFrom<Vec<T>>.

Before (bypassable new check)After (two-type bounded window)
Foo::new(tags: Vec<Bar>) -> Result<Foo, DeserializeError>Foo::new(tags: BoundedVec<Bar, 2, 5>) -> Foo
let foo = Foo::new(tags)?;let tags: BoundedVec<Bar, 2, 5> = tags.try_into()?; let foo = Foo::new(tags);
tags.push(bar) can silently exceed the windowtags.push(bar)? refuses the first out-of-window append before mutation

Migrating an exact window (BoundedVec<T, N, N> field → [T; N]) is deliberately a native API break: constructors and setters now take [T; N] and are infallible for cardinality. When a caller has a loose vector, convert it once at the assembly boundary: let tags: [Bar; N] = tags.try_into()?; let foo = Foo::new(tags);. CBOR, wasm list wrappers, and component list<T> remain list-shaped and make the equivalent checked handover themselves. JSON does so recursively for every ordinary, @duplicates preserve, or @duplicates reject loose, nonempty, bounded, nullable, and exact collection tree that contains a wide exact array or an exact natural-any position, including aliases/newtypes, ordinary optional fields, type-choice payloads, and captured open-array segments. Each node remains a JSON list; exact layers retain minItems = maxItems, restricted sequence layers re-enter their existing NonEmptyVec, BoundedVec, NonEmptyOrderedSet, or BoundedOrderedSet TryFrom<Vec<_>> door, and duplicate-reject schemas additionally publish uniqueItems: true. The established typed direct Vec<[T; N]> case remains compatible. Open-array segments retain their named field shape; loose segments keep skip_serializing_if = "Vec::is_empty" plus default, while restricted segments are required. Map/table entries, open-struct map rest rows, and dynamic map rows still reject under either JSON flag. Optional+nullable three-state fields compose their outer presence option with the recursive descriptor for the inner nullable member, including short natural-any exact arrays. This is separate from bytes .size N, whose [u8; N] storage retains a loose Vec<u8> constructor for compatibility.

The type name, the generated doc comment (which quotes the originating CDDL), and the TryFrom signature are three redundant signals that a constraint exists. The wasm boundary mirrors this with a two-wrapper pattern — see Wasm Differences.

Reject-duplicates containers (uniqueness twins)

A @duplicates reject directive on a plain (non-tag-258) array-shaped collection rule — [* T] / [+ T] — swaps the transparent-alias target from the bare Vec/NonEmptyVec to a uniqueness twin that makes a duplicate element unrepresentable, the same enforcement-by-type move the non-empty containers make for the length bound:

  • [* T]OrderedSet<T> (signers = [* key] ; @duplicates reject emits pub type Signers = OrderedSet<Key>).
  • [+ T]NonEmptyOrderedSet<T>, whose door composes BOTH invariants (min-1 and uniqueness).
  • Every other bounded window → BoundedOrderedSet<T, MIN, MAX>, whose checked door composes uniqueness with its inclusive occurrence window. It exposes reads and checked push/pop/remove, but no normalizing insert/Extend/FromIterator: an over-maximum append is an error, never a no-op.

A tag-258 set (which defaults to reject) uses the SAME twins for its inner collection, but wraps them in a nominal struct that owns its encodings rather than aliasing to them transparently — see Nominal tag-258 set types above. The twin conversion contract below describes the inner collection either way.

The default is preserve — today's occurrence-selected Vec / NonEmptyVec / BoundedVec<T, MIN, MAX>, which accept and re-emit duplicates byte-exactly for multi-era readers; writing @duplicates preserve explicitly is an accepted, self-documenting no-op. Both twins are order-preserving and NEVER sorted: reject only narrows which inputs are accepted, it never touches the bytes of what is accepted, so a decoded set re-emits byte-exactly in its original wire order.

Each twin provides one conversion contract, parallel to the non-empty one:

  • TryFrom<Vec<T>> — the single checked door. The only way to build the value from a loose Vec; it scans for the first duplicate and, on finding one, returns a DeserializeError whose DuplicateKey(Key::Uint(i)) names the zero-based index i of the offending element — deterministic and actionable regardless of the element type (this is the set analogue of the table path's DuplicateKey, which uses the key value). NonEmptyOrderedSet's door composes both checks: the same RangeCheck { found: 0, min: Some(1), .. } (0 not at least 1) as NonEmptyVec for an empty input, plus the duplicate index for a repeat.
  • From<OrderedSet<T>> for Vec<T> / into_inner() — the infallible escape hatch. Unwrap to the loose Vec, mutate freely, and try_into() back through the door at the edge. Plus as_slice() / AsRef<[T]> / Index / iter() / IntoIterator for read access without a round-trip.
  • Checked push returns Result. Appending an element already present is refused with DuplicateKey(Key::Uint(index)) — in contrast to NonEmptyVec::push, which is infallible (growing can never break a minimum bound, but it can mint a duplicate, so the set twin's push must be fallible). The index push reports for a duplicate is the SAME index the TryFrom door reports for the identical duplicate (a would-be append position equals a bulk scan's first-repeat position), so the strict door and the bulk door never disagree. NonEmptyOrderedSet additionally gives checked pop/remove (refused at length 1) exactly like NonEmptyVec.
  • The std set contract, alongside the strict push. Both twins also implement the HashSet/BTreeSet/IndexSet surface, for callers who treat "already present" as benign rather than a bug: insert(T) -> bool (false = already present, set unchanged — so a union is a plain insert loop), contains(&T) -> bool, Extend<T> (a duplicate is a keep-first no-op, so dst.extend(src) is union), and sort() (the IndexSet::sort precedent — sorting unique elements cannot create a duplicate; it DOES change the re-emitted byte order, so the wire-order round-trip guarantee applies only to an untouched decoded set). OrderedSet additionally implements FromIterator<T> (collect dedups keep-first, the IndexSet::from_iter semantics) — there is deliberately no FromIterator on NonEmptyOrderedSet, since an empty iterator is unrepresentable there; the dedup-nonempty path composes the two: vec.into_iter().collect::<OrderedSet<_>>().try_into()?.
  • try_opt_from(Vec<T>) -> Result<Option<Self>, DeserializeError> — empty-means-absent. For an optional set field: empty input is Ok(None) (the field is absent — the min-1 RangeCheck does NOT fire on the non-empty twin), a non-empty input routes through the TryFrom<Vec<T>> door in Some, and only a duplicate surfaces as Err. This is the discriminating constructor to reach for instead of Vec::try_into().ok(), which silently swallows the duplicate failure along with the empty one.
  • Refinement doors between the twins. TryFrom<OrderedSet<T>> for NonEmptyOrderedSet<T> narrows (the only check is non-emptiness — elements are already unique — so an empty set fails with the same 0 not at least 1 RangeCheck as every other min-1 door), and From<NonEmptyOrderedSet<T>> for OrderedSet<T> widens infallibly.
  • A stricter blocked-mutator set than the non-empty twins. NonEmptyVec permits value-level &mut access (iter_mut / as_mut_slice / IndexMut) because its invariant is only about length, which a &mut to an element cannot reach. The ordered-set twins expose none of those — no IndexMut, no iter_mut, no as_mut_slice, no get_mut — because the uniqueness invariant is about element values: an in-place element edit could turn a unique set into a duplicate-bearing one past the door. Element mutation therefore always routes through into_inner() → mutate → try_into().
  • Deserialize routes through the same TryFrom. Wire-side and API-side enforcement cannot drift: a duplicate in the CBOR array is rejected during decode with the same DuplicateKey(index) error the API door raises.

Migrating a consumer (Vec<T> field → OrderedSet<T> field). Same mechanical shape as the non-empty migration — the fallible new becomes infallible and the check moves to a single try_into where the loose collection is built — but the rejectable constraint is uniqueness, not length:

Before (bypassable / no check)After (uniqueness twin)
Foo::new(signers: Vec<Key>) -> Foo (duplicates silently accepted)Foo::new(signers: OrderedSet<Key>) -> Foo
let foo = Foo::new(signers); (a later foo.signers.push(dup) cannot even be expressed)let signers: OrderedSet<Key> = signers.try_into()?; let foo = Foo::new(signers);
in-place element edit via foo.signers[i] = …let mut v = foo.signers.into_inner(); v[i] = …; foo.signers = v.try_into()?;

Three operations every consumer of the new set types needs — none of which the previous Vec field required a decision about — have a sanctioned one-liner, so no consumer should hand-roll a let _ = push(…) loop:

NeedRecipe
Union two sets (merge witnesses, accumulate)dst.extend(src); — the std set Extend; a duplicate is a keep-first no-op, so this is union (no discarded Result)
Empty-means-absent for an optional field (? 4 : nonempty_set<…>)Foo::try_opt_from(vec)? — empty → None, non-empty → the checked door in Some; only a duplicate errors. NOT vec.try_into().ok() (it swallows the duplicate error too)
Dedup-normalize a duplicate-tolerant source (pre-Conway eras, loose JSON, JS arrays — where duplicates are legal and dropping the whole field is wrong)vec.into_iter().collect::<OrderedSet<_>>(), or for the non-empty twin …collect::<OrderedSet<_>>().try_into()?FromIterator dedups keep-first (a repeated element is semantically redundant)

The wasm boundary mirrors this with the same two-wrapper pattern as the non-empty case, except the restricted wrapper's add is fallible — see Wasm Differences.

Preserve-duplicates tables (pair-map twins)

A @duplicates preserve directive on a table rule ({ * k => v } / { + k => v }) is the mirror of reject on a set. A table's default representation — a BTreeMap / OrderedHashMap keyed by key value — is structurally incapable of holding two entries with the same key, so reject (collapse duplicates) is a table's default and @duplicates reject on a table is an accepted, self-documenting no-op. preserve swaps the transparent-alias target to a pair-map twin — a Vec<(K, V)>-backed map that keeps every entry in wire order, so duplicate keys survive:

  • { * k => v }PairMap<K, V> (meta = { * uint => bytes } ; @duplicates preserve emits pub type Meta = PairMap<u64, Vec<u8>>).
  • { + k => v }NonEmptyPairMap<K, V>, whose single TryFrom door composes the min-1 check on top of the same vec-of-pairs shape.
  • Every other homogeneous occurrence window (?, exact-one, N*M, *M, or N*) → BoundedPairMap<K, V, MIN, MAX>. Its checked TryFrom<Vec<_>> / TryFrom<PairMap<_, _>> door enforces the inclusive entry count; duplicate keys still count separately.

A table rule that owns an encoding operation — a tagged body (#6.n({* k => v}), the optional-tag idiom included) or a @newtype rule — mints a wrapper struct whose stored inner is the same twin (pub struct TaggedMeta(pub(crate) PairMap<u64, Vec<u8>>)), so the policy selects the representation while the wrapper's codec owns the tag.

An anonymous inline table in type position (a union arm, a field or element type) carries the directive on its own row instead — { * k => v ; @duplicates preserve with the closing brace on the next line — and gets the same twin at that one use site (the union arm's payload becomes PairMap<K, V>). See @duplicates on an inline table's row.

The driver is a duplicate-keyed map that must round-trip byte-exactly — pre-Conway Cardano transaction_metadata, whose auxiliary-data hash is computed over the ORIGINAL bytes, so a reader that collapses or reorders duplicate keys fails hash verification. A BTreeMap cannot do this; the vec-of-pairs is the only faithful shape.

Unlike the ordered-set twins, a PairMap has no invariant — any vec of pairs is valid — so its conversion contract is unguarded:

  • Construction imports the concrete twin you need. Generated/hand-written code imports PairMap, NonEmptyPairMap, and BoundedPairMap from the shared pair_map helper; the loose flavor has free From<Vec<(K, V)>> / into_inner() -> Vec<(K, V)> conversions because there is nothing to reject. NonEmptyPairMap and BoundedPairMap instead expose the fallible TryFrom<Vec<(K, V)>> and TryFrom<PairMap<K, V>> doors, returning the same RangeCheck contract as the other non-empty/bounded twins.
  • insert(key, value) APPENDS and never replaces. It always returns None (nothing is displaced, because nothing is overwritten) — a replacing insert would silently drop a duplicate, defeating the point. The Option<V> return exists only so the read surface matches the loose table's.
  • A duplicate-honest read surface. get returns the FIRST match (linear scan), get_all returns every match in entry order, and iter/keys/values/as_slice walk in entry order. The key domain relaxes from the loose table's full key-demand bundle to Ord (a linear-scan lookup needs only Eq; Ord is retained for the canonical sort below).
  • Deserialize collects positionally. The --preserve-encodings sidecar for a preserve table is positional (parallel Vecs indexed per entry, like the array _elem_encodings path), replacing the key-value-keyed BTreeMaps that structurally cannot hold two same-key entries. Each entry's key and value encodings are replayed per-position, so a non-minimal encoding on any entry re-emits faithfully.

Canonical form (--canonical-form=true): entries stable-sort by encoded key bytes with duplicates left adjacent in first-appearance order. Duplicate-carrying data has no RFC 8949 canonical form, so this is a deterministic best-effort — never a refusal. The sort compares CANONICAL key bytes, not the preserved input encodings.

JSON (--json-serde-derives / --json-schema-export): a preserve table serializes as an array of [key, value] pairs, not a JSON object — a JSON object cannot carry duplicate keys any more than a BTreeMap can, so the array-of-pairs shape is the faithful one. The generated schemars schema is therefore an array schema, not an object/additionalProperties one. Bounded pair maps add minItems/maxItems to that pair sequence, and the {+} flavor's door refuses an empty [] on the JSON path with the same min-1 error as the wire path.

The wasm boundary mirrors the set twins' two-wrapper pattern, except the loose PairMap's insert appends (returning Option) rather than rejecting. The synthesized wasm class name carries the container flavor — PairMap<K>To<V>, NonEmptyPairMap<K>To<V>, and bounded PairMap<K>To<V>MaxN / …MinN / …MinNMaxM, against the default flavor's Map<K>To<V> / NonEmptyMap<K>To<V> — so a preserve and a non-preserve map of the identical key/value are two distinct classes. The K and V fragments are themselves recursive boundary identities, so occurrence restrictions and duplicate flavor inside nested arrays or maps also select distinct classes and matching native carriers. See Wasm Differences.

Generated crate roots (thin root, seed-once)

Every generated crate — rust/, and (when enabled) wasm/, wasm/json-gen/ and component/ — splits into two halves so your hand edits to the crate root survive regeneration:

  • Tool-owned: src/generated/** (clobbered every run). All generated code lives here — generated/mod.rs (the type definitions + module decls + generator crate attrs like #![allow(clippy::too_many_arguments, clippy::large_enum_variant, clippy::result_large_err)] as inner attrs — the latter two silence lints intrinsic to the emitted shape: CDDL choices become enums with wildly asymmetric variant sizes, and fallible APIs return a Result over the static DeserializeError), generated/serialization.rs, per-scope submodules, the copied runtime modules (error.rs, ordered_hash_map.rs), and — when any rule carries @used_as_keykey_demand_assertions.rs. (Under --common-import-override the runtime modules are not emitted — the override crate owns its copy, and --export-static-crate is the supported path for keeping that copy current.) This subtree is overwritten wholesale on every export — untagged hand edits to the code are clobbered. What DOES survive regeneration: // cddl-codegen:keep-marked own-line comments (re-anchored by symbol identity — which named item they sit in/above) and tagged code blocks// cddl-codegen:insert-start/insert-end for added lines, and // cddl-codegen:replace-start/replaces/replace-end for swapped code (your version plus a //-commented record of the generated code it overrides). Anything that cannot be safely re-placed becomes a compile_error! block (loud, never a silent drop) so you review it. See preserving edits for the full syntax, failure modes, and limits; pass --no-preserve-comments to turn the whole overlay off and clobber pristine. Three carve-outs: outside a cddl-codegen: block every comment here is tool-owned, so an UNMARKED own-line comment is trapped in a compile_error! rather than re-anchored on a guess (delete the block, and re-add the text with a keep marker if it was yours); trailing (end-of-line) comments are not carried — move them to their own line (a // cddl-codegen: tag that rustfmt itself folded into trailing position is the exception: the overlay recognizes it there, so a formatted repo needs no hand-unfolding) — and /// doc text on tool-documented items is tool-owned (it flows from your CDDL/@doc; hand edits to it are dropped, so edit the CDDL instead).

  • User-owned: the crate root src/lib.rs (seeded once, then never touched). On a first export the tool seeds a thin root — the rust crate's, verbatim:

    // Seeded by cddl-codegen on first export; never overwritten after that.
    // All regenerated code lives in the `generated` module. Add your own
    // modules/re-exports/attrs here freely (e.g. `pub mod utils;`).
    // Hand-added code should stay no_std-clean or be gated on `feature = "std"` —
    // verify with the emitted no-std-check crate (generated beside this crate):
    // cargo check --manifest-path <output-root>/no-std-check/Cargo.toml --target thumbv7m-none-eabi
    #![cfg_attr(not(feature = "std"), no_std)]
    mod generated;
    pub use generated::*;

    The wasm and json-gen crate roots are the same thin root without the last three comment lines and the cfg_attr (those two crates are std by nature).

    If src/lib.rs already exists, the tool leaves it byte-for-byte untouched — an existence check only, never a read of its contents to decide what to emit (the same bounded exception the Cargo.toml changeset carves out of the tool's otherwise strict no-prior-output-dependence). New and renamed generated types surface automatically through the pub use generated::*; glob, so the root needs no per-type maintenance.

This is what makes extern types and other hand wiring durable. Both user-supplied markers — MyExt = _CDDL_CODEGEN_EXTERN_TYPE_ and MyExt = _CDDL_CODEGEN_RAW_BYTES_TYPE_ — require you to supply the Rust definition, and both follow the contract below identically. Generated code refers to it by name inside src/generated/** (e.g. a field ext: MyExt, and MyExt::deserialize in generated/serialization.rs), but those names can't see a type you define in the crate root — a parent-module name is not visible inside mod generated. So the tool emits re-export glue into the declaring scope's generated module:

// src/generated/mod.rs (tool-owned) — resolves every in-crate `MyExt` reference back to your definition
pub use crate::MyExt;

Your job is to make crate::MyExt resolve: define the extern in a hand-written module and re-export it at the crate root, from the user-owned thin root (this is the same wiring pre-split consumers already used — it just moved to the thin lib.rs):

mod generated;
pub use generated::*;

pub mod utils; // your hand-written module
pub use utils::MyExt; // re-export it at the crate root so `crate::MyExt` resolves
// src/utils.rs — supply the definition + the (de)serialization impls the generated code calls.
// Everything the impls need is reachable at the crate root through the seeded `pub use generated::*;`.
use crate::error::DeserializeError;
use crate::serialization::Deserialize;

#[derive(Clone, Debug)]
pub struct MyExt(pub u64);

impl cbor_event::se::Serialize for MyExt {
fn serialize<'se>(
&self,
serializer: &'se mut cbor_event::se::Serializer,
) -> cbor_event::Result<&'se mut cbor_event::se::Serializer> {
serializer.write_unsigned_integer(self.0)
}
}

impl Deserialize for MyExt {
fn deserialize(
raw: &mut cbor_event::de::Deserializer,
) -> Result<Self, DeserializeError> {
Ok(Self(raw.unsigned_integer()?))
}
}

An extern declared inside a scope a gets its glue in src/generated/a/mod.rs instead (still pub use crate::MyExt; — the re-export always resolves through your crate-root re-export). If you forget to re-export the type at the crate root, the glue fails loudly with a clear unresolved-import error naming the type, rather than a confusing "cannot find type" deep in the generated code.

The wasm crate gets analogous glue, but only for wrappers its generated boundary actually names. Its generated code names an extern's hand-written #[wasm_bindgen] wrapper (e.g. pub fn ext(&self) -> MyExt) by bare ident inside wasm/src/generated/**, invisible there for the same reason. For each such reachable wrapper the tool emits pub use crate::MyExt; into the wasm declaring scope's generated module: define that wrapper in a hand-written wasm-crate module and re-export it at the wasm crate root (pub mod utils; pub use utils::MyExt; in wasm/src/lib.rs, with the wrapper supplying the From/AsRef conversions the generated boundary code calls). A marker that the wasm face never names has no wasm glue and needs no wasm root re-export. (Types imported from a dependency crate — either marker — are unaffected by this glue; they resolve through the dependency's import path.)

The Rust behavior is intentionally broader: its generated aliases and codec paths retain the native marker names they use, so Rust emits the corresponding own-spec marker/flavor glue and lists those names as required. The wasm required-name output is instead exactly its live glue set.

Because the root is user-owned, the tool never re-clobbers this wiring — the failure mode where a regeneration dropped a hand-added pub mod utils; and broke every reference to the extern type is gone by construction.

The tool tells you the exact re-export set to maintain. Since the required names are known at generation time, they are surfaced three ways so a stale hand-written root never leaves you staring at a bare unresolved-import error: (1) every run prints the per-crate required crate-root re-export list (rust and wasm) on stdout — it is run output rather than a diagnostic, so it survives every verbosity except error; (2) the generated glue in each declaring scope's mod.rs carries a short // contract comment restating this requirement; and (3) when a seed-once root already exists but is missing a required re-export (for instance after a spec change added a new extern), the run emits a warning: on stderr naming the missing idents and the exact pub use <your_module>::<Name>; fix. The warning is diagnostic-only — it reads the existing root but changes no generated bytes, and never fires on the first run that seeds the root. If your root re-exports through a non-generated glob (pub use utils::*;) the name scan can't see through, the per-name warning is suppressed rather than raised as a false positive. It is also suppressed for any name whose glue line you deliberately deleted from the generated mod.rs via a cddl-codegen:replace block: the warning is decided from this run's written output, so once the pub use crate::<Name>; glue no longer survives there the tool no longer requires that re-export and stops nagging for it.

A generic argument is the important asymmetry: for ext_set<pub_key>, Rust's ExtSetRawBytes<PubKey> alias names PubKey, while the wasm face names only its concrete ExtSetPubKey wrapper. The wasm glue and its required-name output therefore ask for ExtSetPubKey, not a dead PubKey wrapper; a direct field, alias, list, or map use of PubKey still makes the wasm walk emit its glue and require the wrapper.

Per-scope hand modules: the facade pattern

To publish hand-written code under a scope's public path (e.g. my_crate::assets::utils::… merged with the generated my_crate::assets::… namespace), do not place hand files inside src/generated/<scope>/ — that subtree is clobbered on every regeneration, and its machine-owned mod.rs would drop your pub mod utils; line. Instead declare a facade module in the user-owned thin root:

// src/lib.rs (user-owned)
mod generated;
pub use generated::*;

pub mod assets { // shadows the glob-imported generated `assets` module
pub use crate::generated::assets::*; // generated items, incl. `pub mod serialization` etc.
pub mod utils; // your hand file, at src/assets/utils.rs
pub use utils::*; // per-scope glob policy — your call, per scope
}

An explicit item shadows a glob import, so my_crate::assets resolves to your facade while pub use generated::*; keeps re-exporting every other scope untouched — this is the same item-beats-glob rule the extern glue above already relies on. Hand files live at src/<scope>/*.rs, entirely outside the machine-owned tree, so rm -rf src/generated && regen remains a valid clean rebuild and generated file paths (and any preserved comment/replace blocks in them) never move. Scopes without hand files need no facade. Impl blocks on generated types compile from any module in the crate, so a facade is only needed when hand items must appear under the scope's path.

Generated wrapper field visibility

Named integer and byte/text value windows (limited = uint .le 10, digest = bytes .size 32) are the invariant-owning exception: their native backing field is private, including to hand modules in the same crate. Construct them with Limited::try_from(value) / Digest::try_from(bytes) and read them through get(). CBOR, JSON, wasm, and component decoding all cross that same door, so no public or codec path can materialize an invalid value. Exact bytes still store [u8; N] behind the loose Vec<u8> TryFrom input. This is an intentional API break from the former public new() and pub(crate) field.

Other newtype wrappers (bare tags and unconstrained @newtype rules) and the wasm crate's pub struct X(rust::X) handles back their inner value with a pub(crate) field (named inner under --preserve-encodings=true, an anonymous tuple field otherwise). Hand modules can therefore augment those ordinary wrappers through the carrier, and wasm hand modules can reach the already-validated native value via self.0. wasm_bindgen ignores non-pub fields, so the wasm ABI is unchanged.

This applies uniformly to the wasm collection wrappers too: the plain list wrappers (FooList), the structural map wrappers (MapKToV) and named map/table rules, and the NonEmpty* list/map wrappers all back their inner Vec/BTreeMap/OrderedHashMap/NonEmptyVec/BoundedVec/NonEmptyMap with a pub(crate) tuple field, so hand augmentation reaches self.0 on any of them. The one exception is --wasm-list-macro: those list wrappers are defined by a consumer-supplied macro, so their field visibility is the macro's choice, not the generator's — give that macro a pub(crate) field if its wrappers are hand-augmented.

When any of the ordinary wrappers' pub(crate) tuple-field line would exceed rustfmt's default max_width (100 columns) — long generic map/list types with fully-qualified paths in a wasm wrapper, or an unconstrained rust-crate newtype (default profile, where the field is a tuple rather than the named inner) over a long-named type — the wrapper is emitted with a #[rustfmt::skip] attribute and a two-line citation comment, and its field is laid out in the canonical two-line tuple shape by the generator itself. This works around rust-lang/rustfmt#5703, where rustfmt breaks the line right after the field visibility, leaves trailing whitespace, and exits with an internal error (which the generator treats as fatal). The skip freezes the canonical layout rustfmt would produce absent the bug, so it is removable — as a pure-formatting no-op — once the upstream fix (PR #5708) ships and reaches consumers.

Migrating from pre-split layouts

Crates generated before the thin-root split have a monolithic src/lib.rs that carries the generated type definitions and module decls (pub mod serialization;, …) inline. Because the tool now seed-once preserves any existing src/lib.rs, it will not migrate that root for you (auto-migrating would mean reading and rewriting your file — the prior-output dependence the tool forbids). Instead the stale root breaks loudly at compile time: the generated types reappear under src/generated/** and the old pub mod serialization; decls now point at files the split relocated (an E0583 unresolved- module error), and any inline type copies collide with the regenerated ones.

The tool prints a one-time stderr warning when it sees a root without mod generated;. The one-time fix (per crate root — rust/, wasm/, wasm/json-gen/):

  1. Delete the generated items from src/lib.rs — the type definitions and the pub mod serialization; / pub mod cbor_encodings; / runtime-module decls the tool now owns under generated/.
  2. Keep your hand wiring — your own pub mod/pub use/crate attrs, extern-type modules, etc.
  3. Add the two thin-root lines: mod generated; and pub use generated::*;.
  4. Regenerate. From here on the root is yours and survives every run.

Workspace mode: borrowed and requested collection wrappers

When a set of co-generated crates form a workspace — one crate per CDDL spec, later crates importing earlier ones as extern deps — a collection wrapper over another crate's types ([* foo]FooList, [2*5 foo]FooListMin2Max5, {* k => v}MapKToV, and their restricted variants) must live in exactly one crate. Two crates that each mint the same #[wasm_bindgen] FooList export one JS class name twice and fail to link a single wasm cdylib (duplicate symbol: __wbg_foolist_free). The --workspace-dep (consumer side) and --wrapper-requests (dependency side) flags place every all-one-dep wrapper in the owning dependency, so every consumer imports one shared definition. Two generated files carry this, both under the wasm crate's tool-owned src/generated/** (clobbered every run like the rest of that subtree):

borrowed_collections.rs — the consumer's sidecar

Emitted whenever --workspace-dep is set (empty-but-present when nothing is borrowed — stable presence, stable diffs). It is the mirror of the collections.rs wrapper index — "what I borrow, from whom" against that file's "what I provide" — and its format is frozen, because the owning dependency machine-reads it:

// This file records every collection wrapper this crate borrows from workspace deps.
// It is machine-read by those deps' generation runs (--wrapper-requests) and compiled
// here, so a wrapper a dep stops providing fails THIS crate's build, naming the type.
// Rows are (dep rust-crate name, wrapper name, shape in CDDL syntax with the dep's idents).
#[allow(unused_imports)]
mod borrowed {
use cml_dep_wasm::collections::FooList;
use cml_dep_wasm::collections::MapKToV;
}
#[allow(dead_code)]
pub(crate) const BORROWED_SHAPES: &[(&str, &str, &str)] = &[
("cml_dep", "FooList", "[* foo]"),
("cml_dep", "MapKToV", "{* k => v}"),
];

The two halves serve different validators. The private mod borrowed of use lines is the compile-checked half: it names every borrowed wrapper, so a wrapper the dependency stops providing fails this crate's own build with an unresolved import naming the exact type. The BORROWED_SHAPES table is the machine half the dependency parses: (dependency rust-crate name, wrapper name, shape in CDDL syntax with the dependency's idents), sorted by (dep, name). The shape column is authoritative — the dependency derives the wrapper's definition from the shape and only cross-checks the name (reverse-parsing element types out of a name like MapAToBToC is ambiguous). The name and shape agree across the two crates only when the consumer's extern stub is representation-faithful to the dependency's spec — the stub-fidelity contract documented under --wrapper-requests; an unfaithful stub surfaces as a dependency-side hard error naming the element and the stub fix. The file is derived output, rebuilt from the consumer's spec on every regen; borrowed wrappers are never re-exported (they are not the consumer's public API). All comments — the column legend included — live in the fixed banner, never inside the const body, per the banner-only comment contract shared by every generated sidecar/check file.

Flavored shapes carry a trailing policy marker in the shape column. The structural wrapper name encodes the container for the two @duplicates twins (<Elem>OrderedSet / NonEmpty<Elem>OrderedSet for reject, PairMapKToV / NonEmptyPairMapKToV for preserve), so the shape a dependency rebuilds from must carry the policy too: rows read ("cml_dep", "FooOrderedSet", "[* foo] @duplicates reject") and ("cml_dep", "PairMapKToV", "{* k => v} @duplicates preserve"). The marker rides the column bare (no ;) because the column is round-tripped by parse and is not CDDL to begin with — the paste-able rule-line hint the not-in-index warning prints moves it into comment position, where it is CDDL. Without the marker the dependency would rebuild a loose list or a keyed table under a name that promises uniqueness or duplicate-preservation, which is the one skew this column exists to prevent.

A borrowed reject set also obliges the key-derive channel. Its hosted class wraps OrderedSet<Elem>, whose uniqueness scan needs Elem: Ord — a derive that lives in the dependency's crate. The consumer records that demand in borrowed_key_types.rs (below), so a workspace regen passes --key-requests beside --wrapper-requests; with only the latter the dependency hosts the class and its own build fails E0277 on the missing bound.

A user may hand-add a row via the overlay's // cddl-codegen:insert-start/end (or replace) blocks — honored like any preserved edit when the payload conforms to the fixed row format. A compile_error! / cddl-codegen:unpreserved-comment trap block, an unknown line, or a mangled row is not silently tolerated: the dependency's strict parser rejects the whole sidecar as a hard error naming the file, on the principle that a cross-crate request channel must never consume a drifted generated file. Regenerate the consumer crate to clear such a trap before regenerating the dependency.

borrowed_key_types.rs — the consumer's map-key-derive sidecar

Emitted into the rust crate (rust/src/generated/borrowed_key_types.rs) whenever --workspace-dep is set (same empty-but-present semantics as borrowed_collections.rs), in --wasm=true and --wasm=false alike — the sidecar is a rust-crate concern, so --workspace-dep is honored mode-independently. Where the wasm-side sidecar records borrowed wrapper classes, this one records borrowed map-key types: a consumer map keyed on a dependency's type needs Eq/Ord/PartialOrd (plus Hash under --preserve-encodings) derived on that type, and the orphan rule means only the dependency's crate can provide the derives. Same two-half structure: a compiled _borrowed_key_types_self_check fn (a dependency dropping a derive fails this crate's build, naming the type) and the machine-read BORROWED_KEY_TYPES table of (dep rust-crate name, cddl ident) rows, sorted, which the dependency re-reads via --key-requests and seeds into its key-derive computation before finalize. Strictly parsed under the same never-consume-a-drifted-file principle as BORROWED_SHAPES.

The self-check asserts on the borrowed type at the dependency's real module path — the exact path the consumer already knows it by through the extern-import / extern-deps channel and uses in its own generated use lines. When the dependency's type lives in a non-root scope, that path is scoped (cml_dep::sub::module::Foo); the dependency's thin root does not re-export scope contents, so a bare cml_dep::Foo would be a "cannot find type" error. The BORROWED_KEY_TYPES rows are independent of this: each row stays the bare (dep rust-crate name, cddl ident) regardless of scope (the first column is the dep crate name = the type's first scope component), and the dependency resolves it scope-agnostically by cddl ident — so no scope column is ever added and root-only sidecars stay byte-identical.

The one reserved CDDL name that can legally appear as a row ident is int — it names the built-in Int, which under --common-import-override this crate re-exports from the common crate rather than minting, so a map keyed on int records (<override>, "int") and the common crate's regen key-flavors its shared Int. Every other reserved ident is rejected.

// This file records every map-key type this crate borrows from workspace deps.
// …
#[allow(dead_code)]
fn _assert_key_traits<K: Eq + Ord + PartialOrd + core::hash::Hash>() {}
#[allow(dead_code)]
fn _borrowed_key_types_self_check() {
_assert_key_traits::<cml_dep::Foo>();
}
#[allow(dead_code)]
pub(crate) const BORROWED_KEY_TYPES: &[(&str, &str)] = &[("cml_dep", "foo")];

When a borrowed key carries a @used_as_key flavor (hash/ord) rather than the bare full bundle, each row gains an optional third column naming the flavor, the table type becomes &[(&str, &str, &str)], and the self-check splits into per-flavor bound carriers (_assert_key_traits_hash<K: Eq + core::hash::Hash>(), …) so a hash-only borrow is not checked against the full Ord bundle. An all-bare sidecar keeps the two-column form byte-identically — this is a pure superset. The three-column banner is a declared breaking seam: a cddl-codegen predating flavor support hard-errors ("unexpected comment"/"row must be … two string literals") when it re-reads a new consumer's flavored sidecar, rather than silently mis-deriving. Keep workspace tool versions in sync.

requested_collections.rs — the dependency's hosted wrappers

Emitted whenever --wrapper-requests is set (empty-but-present when flagged, absent with no flag — flag-off output is byte-identical to before). The dependency unions the requested shapes across all consumers' sidecars, and emits every requested wrapper it does not already produce, from its own module view (correct crate::… paths, current tool version, one writer per file). Each wrapper carries a tool-owned attribution doc listing its requesters alphabetically:

/// Generated at the request of: cml_chain, cml_multiera.
#[derive(Clone, Debug)]
#[wasm_bindgen]
pub struct FooList(Vec<cml_dep::Foo>);

Attribution is a /// doc comment — the overlay's tool-owned documentation class, which regenerates freely — so adding or dropping a requester churns the doc in place without ever tripping edit preservation. Requested wrappers are indexed in the dependency's own collections.rs (pub use crate::generated::requested_collections::<Name>;) exactly like a wrapper the dependency's own spec produces, so a consumer's use <dep_wasm>::collections::<Name>; resolves. A requested NonEmpty… wrapper additionally provisions the NonEmptyVec/NonEmptyMap runtime; a bounded list provisions BoundedVec, while a bounded reject array provisions BoundedOrderedSet; either may emit its loose try_from source wrapper as an (unattributed) transitive support type. Each hosted wrapper also carries explicit imports for the element types its body names — a cross-scope generated element from its scope module (crate::generated::<scope>::X), an extern or hand-written element through its crate-root re-export glue — so hosting works for elements that live outside the generated root rather than relying on the module's use super::*; reaching them. A companion wrapper hosted in the same file gets no import at all: a hosted map's keys()-list wrapper is normally co-requested by the same consumers (borrowing {* k => v} borrows [* k] too), minted alongside it here, and named bare — importing it from anywhere else would point at a definition that doesn't exist.

The regeneration contract

  • Holistic regen runs in reverse dependency order, one pass. Consumers first — each rewrites its own sidecar and reads nothing from any dependency's output — then dependencies, each reading its consumers' committed sidecars. A crate that is both consumer and dependency does both jobs in its single run. An unchanged workspace regenerates to a zero diff, and regenerating any single crate twice is byte-identical (attribution and shape payload included).
  • Consumer-alone regen is safe unless it adds a borrow. A spec change that introduces no new borrowed wrapper (the common iterate-on-my-own-crate loop) stays green with no dependency regen. A change that adds a borrow the dependency does not yet host fails the consumer's build with a loud unresolved import naming that wrapper — fixed by regenerating the dependency. This is a subset state (a consumer needs a wrapper the dependency lacks) and cannot exist silently.
  • Dep-alone regen is always safe. It re-reads the committed sidecars, so a converged workspace reproduces byte-identical output — the per-crate idempotency guarantee holds with no asterisk.
  • Removals are a benign superset. When the last borrowing consumer drops a shape, its regen shrinks its sidecar; the next dependency regen shrinks the union and deletes the wrapper, its index line, and its attribution doc — an ordinary code diff. Between the two regens the dependency carries an extra unused wrapper, which is harmless.

Extern-interface export

Every regeneration emits an extern-interface/<dep>/** directory — a sibling of rust/, wasm/ and no-std-check/ under the output directory — describing the crate's own extern-visible type surface so that a consumer crate can depend on it with --extern-import. <dep> is the crate's normalized lib name (underscored — the same key the --extern-wasm-crate / --extern-wrapper-index / --workspace-dep flag family already uses). This is the machine-generated form of the stub dialect a human writes under _CDDL_CODEGEN_EXTERN_DEPS_DIR_/<dep>/ — which is what that dialect is now for: declaring a dependency that has no export (a hand-written crate, one you cannot regenerate, one generated by a deliberately separate pass). A dependency is declared exactly once, so the two are alternatives per dependency rather than layers.

  • Always-clobbered, tool-owned, committed — same class as src/generated/**, but a distinct sibling tree. The whole directory is delete-and-recreated each run (a removed rule's stale .cddl never lingers), and it is committed so consumers read it as an input. It is outside the comment/code-preservation overlay (which is scoped to .rs under the generated trees) — user edits are not preserved here; the compiled self-check below is what catches them.
  • Emitted unconditionally, in every mode. Rust-only (--wasm=false) regens emit it too — there is no flag to suppress it (a suppress flag would just manufacture the stale-export state the design exists to prevent). An empty surface still emits a single header-only root file (<dep>/mod.cddl), so "was this dependency regenerated?" stays answerable.
  • Directory shape mirrors the crate's module tree (sub/module scope → extern-interface/<dep>/sub/module/mod.cddl), the same scope-as-directory encoding the stub channel uses, so a consumer's --extern-import recovers the scope from the path.
  • Complete here, consumed in slices there. The export states the whole surface once, independent of how many consumers read it; each consumer imports only the reference-closure of what its own spec needs (see --extern-import), so a rule a given consumer never reaches never enters that consumer's namespace. Growing this surface therefore cannot break an existing consumer, and a consumer may define a rule name this crate also defines as long as it does not need this crate's one.

Each file begins with a strict versioned seam header on its first line:

; _CDDL_CODEGEN_EXTERN_INTERFACE_ v1

The header is per-file (not a manifest) so a physically-copied single file still carries its seam. A consumer feeding a file through --extern-import strictly parses it against this header: a missing/unknown header, or any @-annotation outside the recognized comment-DSL set, is a hard error naming the file (physical hand-stubs under the extern-deps directory carry no header and stay lenient — see --extern-import).

Rule idents are the dependency's original CDDL idents, verbatim (the --wrapper-requests/--key-requests channels resolve by CDDL ident, so renaming would break them), sorted within each file. The dependency's final Rust name rides in a @rust_name pin so the consumer reads the name instead of re-deriving it, eliminating cross-version naming skew:

; _CDDL_CODEGEN_EXTERN_INTERFACE_ v1
plutus_data = _CDDL_CODEGEN_EXTERN_TYPE_ ; @rust_name PlutusData
coin = uint ; @rust_name Coin
fe = 0 / 1 / 2 ; @rust_name Fe
hash32 = _CDDL_CODEGEN_RAW_BYTES_TYPE_ ; @rust_name Hash32

Class-backed types (records, wrappers, type/group choices, externs, raw-bytes) export as opaque markers (_CDDL_CODEGEN_EXTERN_TYPE_ / _CDDL_CODEGEN_RAW_BYTES_TYPE_); transparent types (primitive aliases, c-style value enums, named collections whose rust surface is a pub type) export as their post-DSL truthful CDDL shape, also pinned — spelling a transparent type opaque would make the consumer manufacture calls to impls that don't exist.

Plain groups export as transparent group-body rows. A plain group is inlined at its use sites, so it is not an opaque cross-crate class — the consumer's wire format splices the group's fields into its own arrays/maps. A referenced plain group (one that materializes a struct in the dependency) exports as its truthful post-DSL group body, pinned:

stake_registration = (tag: 0, credential: credential) ; @rust_name StakeRegistration
protocol_version = (major: uint, minor: uint) ; @rust_name ProtocolVersion

The consumer re-derives the identical shape and delegates the wire code to the dependency's own class through both surfaces its generated code uses — whole-value Serialize/Deserialize (a group-choice arm that splices the group calls .serialize()) and the embedded-group surface (a spliced record member delegates through SerializeEmbeddedGroup/DeserializeEmbeddedGroup). Member spelling round-trips by construction: an array-position field carries its post-DSL Rust field name as the label (so a ; @name rename is baked in, no annotation needed), a map-position field carries its fixed member key, a T / null renders … / null (an occurrence-optional ? field is distinct), and a member .default rides in member position (on an optional member — a mandatory member's .default is inert per RFC 8610 §3.8.2 and was stripped, with a warning, before the field was built, so the export spells the plain mandatory member the IR actually holds). Plain groups that materialize a non-group shape, or that are never referenced at all, are excluded-with-record instead — see the exclusion reasons below.

; unexported: records and reference-closure. The projection runs on every regenerated spec — including leaf/test specs that will never be dependencies — so a rule it cannot export faithfully is excluded-with-record rather than aborting generation. Each excluded rule leaves a sorted comment block after the header:

; unexported: cs — @custom_serialize/@custom_deserialize does not travel through the export

The reasons are informational for humans, never parsed. The export is reference-closed: because a consumer runs a checked parse over the whole file, any rule whose body references an excluded ident is itself transitively excluded (to fixpoint), each with its own record naming the chain root. The failure then surfaces only at a consumer that actually references an excluded ident — via the undefined-reference path, whose diagnostic points back at these records. The remedies it names are on the dependency's side — regenerate it, or fix in its own spec what its export could not project — because a dependency the consumer imports is already declared, and stubbing the one missing type beside the import is the double-declaration error. A custom-serialize transparent alias is the canonical exclusion: it has no dep-crate class to delegate to, and exporting its plain definition would silently diverge the wire format. Plain groups add two more excluded-with-record cases: a plain group never referenced in the dependency's own spec materializes no shape to project, and one that materializes a non-group shape (a homogeneous array/table, a @newtype wrapper) has no embedded-group surface — both leave a record rather than vanishing.

rust/src/generated/extern_interface_check.rs

Alongside the export, every rust crate gets a compiled self-check module (always-clobbered under src/generated/**, wired in every mode like the export it guards). It is derived from the same projection as the export — they cannot drift — and asserts that every exported name is a real, correctly-typed surface in this crate:

  • opaque rows must implement Serialize — and Deserialize too, but only for the types the crate actually generates a deserializer for (the bound is weakened per type, so a legitimately deserialize-less type does not fail the check);
  • raw-bytes rows must implement RawBytesEncoding;
  • transparent rows (aliases, c-style enums, named collections) must simply exist (a use … as _; existence check);
  • group-body rows (plain groups) must implement all four surfaces the consumer delegates through — whole-value Serialize/Deserialize and embedded-group SerializeEmbeddedGroup/DeserializeEmbeddedGroup (each Deserialize side gated per type on the crate generating one).

The bound assertions ride bound-carrier fns (_assert_serialize::<T>(), …) instantiated inside a never-called _extern_interface_self_check(). Effect: a hand-edited or stale export — or a projection bug — fails the dependency's own build, naming the type, so a drifted export cannot ship silently.

The banner-only comment contract

Every machine-read or compiled sidecar/check file — borrowed_collections.rs, borrowed_key_types.rs, extern_interface_check.rs, key_demand_assertions.rs (its _demand_<rule> fns, one per @used_as_key tag) — follows one contract: all commentary, column legends included, lives in the fixed file banner, never on a row or inside a const/fn body; each row's type path (or fn name) is its own traceability. A comment on a deletable row is a trap: a spec change can delete any row, and the edit-preservation overlay turns a comment stranded on a deleted row into a compile_error! sentinel that every later regen carries forward. Its compiled companion rule: a self-check names each type at a path that actually resolves in the emitting crate — for borrowed dependency types, the dependency's real module path (see borrowed_key_types.rs above).

Migrating an older output. A crate generated by a version that still emitted per-row // <cddl> markers may already carry a // cddl-codegen:unpreserved-comment + compile_error! sentinel block in extern_interface_check.rs or key_demand_assertions.rs (stranded when you deleted a rule). Regenerating does not clear it — sentinel blocks are carried forward by design — so delete that one block by hand once; every regen afterwards stays clean.

The std feature (building without std)

The generated rust crate is no_std-capable by default. There is no mode flag and no second output variant: one set of emitted bytes serves both consumers, because the core::/alloc:: paths it uses are equally valid in a std crate.

# rust/Cargo.toml, emitted on every run whatever the flags
[features]
default = ["std"]
std = []

A no_std consumer takes the crate with default-features = false; a std consumer changes nothing and notices nothing. Three pieces make that work:

  • Emitted code is core/alloc-pathed unconditionally, and each generated module file carries its own extern crate alloc;. Per-file rather than crate-root, because the crate root is seeded once and then yours — the tool cannot deliver a new line there to a crate it has already generated, and use alloc::… does not resolve without a binding in scope.
  • The seeded root carries #![cfg_attr(not(feature = "std"), no_std)] (see the thin-root listing above). Fresh exports get it automatically; a crate generated before this line existed keeps compiling as plain std until you add it by hand — one line, once.
  • A default-on std feature, emitted unconditionally so the manifest shape is uniform.

What the feature actually gates is small, and deliberately so. Under --preserve-encodings, OrderedHashMap's hash builder is std::collections::hash_map::RandomState with the feature on and hashlink's default builder without it — an alias (MapHashBuilder) rather than a type change, so no public type name moves. Wire bytes are identical either way: iteration order is insertion order under both builders and iteration order is what the serializer writes, so the difference is HashDoS resistance on attacker-chosen keys, not output. The only other std dependence in the generated crate is --deserialize-depth-limit, whose thread_local!-based guard has no core/alloc equivalent and which therefore refuses a no_std build outright rather than gating itself away — see the carve-out below.

The std feature FORWARDS to its dependencies, and the list is computed per run. The rule is one sentence: a dependency's std feature is named by this crate's iff the tool ships that dependency with default-features = false and the dependency has a std feature to name. So a crate generated with the JSON flags and a bytes wrapper gets

[features]
default = ["std"]
std = ["serde/std", "serde_json/std", "schemars/std", "hex/std"]

while a crate with none of those gets std = []. The list VARIES because the dependency set does — the tool's dependencies are flag- and type-conditional, and a feature naming a dependency this flag set did not emit is a manifest cargo rejects. That is exactly why it is computed by the run that writes the manifest, which knows every dependency it wrote, rather than fixed in advance. hashlink and cbor_event never appear: neither declares a std feature (cbor_event is unconditionally #![no_std]), so there is nothing to forward to. An entry names the [dependencies] key, which is what cargo resolves it against — hex/std reaches the std feature of const-hex, the package that key takes (see the merge rules below).

Without the forwarding, default-features = false at your dependant would stop at this crate: its dependencies would still be built with their defaults, their std would still be on, and the no_std arms would be unreachable from any configuration. Forwarding is what makes the opt-in an actual switch.

Path dependencies forward on request--std-forward-dep <package>, which marks a --rust-dep package as std-forwarding: the entry gains default-features = false and the crate's std gains <package>/std. Under --config you never pass it: a deps edge derives it alongside the --rust-dep, and [runtime].lib-name derives it for the shared runtime crate. Only path dependencies need naming, because only they can be crates the tool has no independent knowledge of.

The merge keeps your entries and drops stranded ones. features.std is merged, not replaced: your own forwards (to a dependency you added by hand) survive verbatim and ours are appended. On top of the union there is one prune — a <pkg>/<feat> entry whose <pkg> is no longer a [dependencies] key is dropped. That is what makes a flag flip converge in a single pass: turning the JSON flags off tombstones dependencies.serde, and a leftover serde/std beside it is a manifest cargo refuses. A forward to a dependency that is present is kept whoever wrote it, and a bare (non-forwarding) entry is never pruned.

Those rules are also stated in the file, above the key. Every run that writes a features.std key asserts a short comment block directly above it, so the contract is legible to whoever is editing the manifest rather than only to a reader of this page:

[features]
default = ["std"]
# cddl-codegen: co-owned key. Entries you add here survive regeneration; forwards to
# cddl-codegen: dependencies that are no longer declared are pruned; the tool re-asserts
# cddl-codegen: its computed forwards each run. See output_format.mdx, "The `std` feature".
std = ["serde/std", "hex/std"]

Ownership of the comment lines is decided per line by the # cddl-codegen: prefix. Those lines are the tool's: they are stripped and re-written on every regeneration, so editing their text does not survive (and a block left by an older version is replaced wholesale rather than accumulating beside the new one). Every other comment or blank line above the key is yours and is preserved, in order, above the asserted block — so a note of your own explaining why a hand forward is there stays put.

Scope is the rust crate, and the exported runtime. The wasm and wasm/json-gen crates stay std by nature (wasm-bindgen, and writing schema files to disk); they consume the rust crate with default features on and are unaffected. The --export-static-crate target declares and forwards the same std feature: it receives ordered_hash_map.rs, whose cfg would otherwise silently select the no_std hasher there since an undeclared feature is always false, and its own dependency specs are asserted in alloc mode with hex/std (plus the JSON companions this flavor carries) forwarded. That crate is the one the generated crates forward INTO, so a runtime whose deps stayed std-on would absorb every consumer's default-features = false one crate short of the dependency that matters. Its manifest stays co-owned in the direction that matters: a dependency the tool no longer needs is never removed from it.

The no-std-check shim crate

Everything under src/generated/** is generated no_std-clean, and this repository's own gate is what keeps that true (below). The crate root src/lib.rs, however, is seeded once and then yours — so hand-added utils (and anything they pull in) are the one thing that can take a generated crate out of no_std, and nothing in the generator can see that happen.

So every regeneration also emits a no-std-check/ directory — a sibling of rust/, wasm/ and extern-interface/ under the output directory — holding a ~15-line crate whose only job is to be checked:

cargo check --manifest-path <output-root>/no-std-check/Cargo.toml --target thumbv7m-none-eabi

Green means the rust crate, including whatever you added to its root, builds with no std. The same command is quoted in the seeded rust/src/lib.rs header — the one file you open before adding utils.

What that lets you conclude. Generated output is held no_std-clean by a drift gate in this tool's own repository, which generates representative crates fresh and runs exactly this check against them. So a red no-std-check over an unmodified generated crate is a bug in the tool — report it — with one carve-out, which announces itself: a crate generated with --deserialize-depth-limit is std-only by design, and its check stops at

--deserialize-depth-limit output requires the `std` feature

If that is the failure you are looking at, it is the documented incompatibility rather than a defect (the shim's own header says so too, in that crate's no-std-check/src/lib.rs). Any other red over an unmodified crate is ours; otherwise it attributes to hand-written additions. In a SPLIT layout — several generated crates over a shared --export-static-crate runtime — the hand-written half includes that runtime crate's own root and any dependency it adds; the tool-written half forwards, so the feature does reach every generated crate in the chain (the repo's gate checks exactly that topology). Calibrate on one detail: that gate runs in the repo's local and full tiers, which are a run-before-shipping discipline rather than a continuous one (its CI runs the fast tier only).

Reading a red check — the one thing that voids all of the above. Cargo stops at the first crate that fails, so everything after it, the crate under test included, is never compiled at all. A red check whose failure is in a dependency is a verdict about that dependency and clears nothing behind it; none of the attribution above applies to it, because nothing about the generated crate has been checked yet. The generated crate was actually reached only if a Checking <name> line for it appears in cargo's output, and the rest becomes visible once the first failure is fixed. Both of the shim's own files carry this caveat too, next to the attribution sentence.

  • Always-clobbered and tool-owned, the same class as extern-interface/: the whole directory is delete-and-recreated each run, so hand edits inside it do not survive. It is outside every per-crate system — no seed-once file, no Cargo.toml merge, no edit-preservation overlay. Both of its files are derived from the command-line flags alone and never from the spec, which is what makes the check's verdict a statement about the crate rather than about the rules that happen to be in it.
  • Emitted unconditionally, in every mode (including --wasm=false), for the same reason the extern-interface export is: the seeded crate root tells every consumer to run it, so it has to be there.
  • A no-std target is required, not just the feature. Host targets always link std, so a host cargo check cannot prove anything here; thumbv7m-none-eabi is a bare-metal target with no std at all. Install it once with rustup target add thumbv7m-none-eabi.
  • Deliberately standalone, via an empty [workspace] table in its manifest. That keeps it out of a surrounding cargo workspace, which matters for correctness and not just tidiness: cargo unifies features across a whole dependency graph, so one other workspace member turning std back on for a shared dependency would make this check pass while the crate is not in fact no_std-clean. (It also means the directory appearing in your output can never disturb a workspace that does not list it — and that a bare cargo check -p … has no workspace to resolve the name in, hence the --manifest-path invocation.)
  • Package name <lib-name>-no-std-check, from --lib-name, so a --config multi-crate tree gets one non-colliding shim per crate.
  • It stays at the output root under --package-json, where the cargo crates move one level down; the shim absorbs that by pointing its path dependency at ../rust/rust instead of ../rust.
  • One exception to "just run it": a crate generated with --common-import-override does not emit the runtime modules at all (the override crate owns them) and does not gain a dependency on that override crate automatically, so its shim cannot go green until you have added that dependency to rust/Cargo.toml by hand. The shim is still emitted there; it just inherits the same one-time manifest edit the crate itself needs.
  • One shape that can never go green: a crate generated with --deserialize-depth-limit. Its recursion guard is built on thread_local!, which has no core/alloc equivalent, so the crate carries a compile_error! for not(feature = "std") builds and this check — which is a default-features = false build — is precisely what fires it. The shim is still emitted (a directory that silently disappears under one flag is a worse surprise than an explained failure) and its header paragraph says so, quoting the message. Drop the flag if you need a no_std build of that crate.

Upgrading a crate generated before the no_std output

Everything here is a one-time, consumer-visible change on the first regeneration after the upgrade. They are listed together because each is loud where it lands — a compile error, a compile_error! block, or a new directory — and the useful thing is knowing which of them you are looking at, not discovering it. A brand-new output tree meets only the last two.

1. Dependency specs are reshaped to alloc mode. hex, serde, serde_json and schemars are now asserted with default-features = false plus the explicit feature list the emitted code needs. Your pins, extra features and optional flags survive the merge as always. One shape does not survive: a hand-written default-features = true is a field the tool sets, so it is overwritten back to false on every regeneration. Name the features you want instead — those the merge unions in and keeps.

The sharpest instance is hand code that boxes a dependency's error type. Many crates gate their impl Error on their own std feature, and Box::new(e) into DeserializeFailure::InvalidStructure needs core::error::Error — so the moment that dependency is taken with default-features = false, the boxing stops compiling. The fix is a local newtype supplying the impl, not re-enabling the dependency's defaults. impl Error for a foreign type is blocked by the orphan rule for everyone, so this is a shape to write, not a wrapper to hunt for:

struct TheirErrorCore(their_crate::Error);

impl core::fmt::Debug for TheirErrorCore {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
core::fmt::Debug::fmt(&self.0, f)
}
}

impl core::fmt::Display for TheirErrorCore {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
core::fmt::Display::fmt(&self.0, f)
}
}

impl core::error::Error for TheirErrorCore {}

Debug and Display are hand-written and delegate verbatim rather than derived on purpose: DeserializeFailure renders a boxed error inline, so a derived Debug would insert a TheirErrorCore(..) layer into every rendered message. The wrapper stays invisible in output. tests/custom_serialization{,_preserve} in this repository are the worked exemplars, compiled on every run. The runtime itself carried one of these for its hex decode errors and no longer needs to: the hex key's package supplies a core::error::Error impl unconditionally (below), so from_raw_hex boxes the error directly.

Which dependency needs this is a property of the dependency, not of alloc mode. A crate that offers an unconditional core::error::Error impl — often behind a feature this tool then enables — needs no wrapper at all. The hex key is one: the package behind it is const-hex, taken with core-error, which impls core::error::Error whether or not std is on. serde_json's Error is the other direction, gated on its std feature. Check the dependency before writing the newtype.

"It still compiles for me" is not evidence that your hand code is migrated. Cargo unifies features across the whole dependency graph, so any other dependency anywhere in your tree that pulls serde, serde_json or schemars with default features turns their std feature back on for everyone, and the boxing keeps compiling. This is not hypothetical — it happened inside this repository while hex still named the hex package: the preserve fixture takes the cddl oracle as a test dependency, which pulls hex with default features, and its identical Box::new(FromHexError) compiled green while the core fixture's — same line, no such dependency — failed. Unification keys on the package, not on your [dependencies] key, which is why that particular masking can no longer occur for hex: the oracle's hex and this tool's const-hex are different packages and never unify. The mechanism is untouched for every dependency you and someone else name the same way. Check the crate built standalone, which is exactly what the no-std-check shim does with its empty [workspace].

2. Preserved comments attached to a rewritten use line surface as cddl-codegen:unpreserved-comment blocks. The std::core::/alloc:: rewrite moves the token anchors those comments are re-placed against, so a keep-marked comment on an import the rewrite touched cannot be re-anchored and is trapped in a compile_error! block instead of being dropped. That is the overlay working as designed; resolve each block once and regeneration stays clean afterwards.

3. "std" cannot be durably removed from features.default. That list is merged, not replaced, and the merge is one-way: delete the entry and the next regeneration adds it back. This is not a workaround for anything — the supported way to build without std is default-features = false at the depending crate, which no manifest edit in the generated crate can interfere with.

4. OrderedHashMap's backing crate changed — a semver-major break for --preserve-encodings crates. linked-hash-map has no no_std mode upstream, and OrderedHashMap is the public API type of every preserve-encodings crate, so the type could not hide behind the std feature; it is now built on hashlink. Three consumer-visible consequences:

  • the Deref target and take()'s return type name the backing crate (hashlink::LinkedHashMap<K, V, MapHashBuilder>);
  • Entry imports move from linked_hash_map::Entry to the generated crate's ordered_hash_map::Entry — a thin wrapper whose or_insert/or_insert_with deliberately leave an occupied entry's position unchanged, which is what keeps wire order identical across the swap (hashlink's own entry API refreshes it to the back);
  • Deref-exposed methods with no hashlink equivalent (get_refresh, entries) are gone, as loud compile errors.

insert is unchanged in both spelling and semantics (both crates move an existing key to the back on overwrite), and generated deserializers reject duplicate keys, so the surface this affects is hand-written mutation code.

The search that finds every affected site is four categories, in this order — learned from the first consumer's executed migration, where an import-and-annotation survey missed two site classes that only turned up as compile errors:

  1. linked_hash_map:: path referencesuse lines and qualified paths, in every file, not just the files where imports cluster.
  2. LinkedHashMap< type annotations, including public struct fields. A public field's type is published API, so retyping it to OrderedHashMap is a breaking change for your downstream consumers — worth releasing deliberately, not discovering.
  3. .entry( call sites on an OrderedHashMap, resolved by TYPE, not by text. A call that reads map.deref_mut().entry(k) looks like it reaches the backing crate's entry view, but OrderedHashMap::entry is an inherent method, and an inherent method shadows Deref — the call lands on the wrapper, whose surface is the whole surface (or_insert, or_insert_with, or_default, and_modify, key). No text search settles where an .entry( call resolves; check the receiver's type.
  4. linked-hash-map in every Cargo.toml in the repo, including hand-owned crates. The permanent tombstone (next entry below) removes it only from manifests the tool writes; a stale entry in a hand-owned manifest keeps the retired crate in your Cargo.lock indefinitely.

5. A linked-hash-map dependency entry is removed on every regeneration. The manifest changeset carries a permanent tombstone for that key so an upgraded manifest does not keep a stale entry beside hashlink. If your hand code still wants that crate, it cannot live under its own name — use a renamed dependency key, which the tombstone never sees:

lhm = { package = "linked-hash-map", version = "0.5.3" }

6. A new top-level no-std-check/ directory appears in every output root. Whether to commit it or gitignore it is your call — it is tool-owned and reproducible, like extern-interface/, which is the committed precedent. Its empty [workspace] table guarantees it cannot disturb a workspace that does not list it, but a CI step that inventories the top level of your output tree will see it.

7. The --export-static-crate target gains a std feature too, a real one: it declares the feature (which is what keeps the exported ordered_hash_map.rs on RandomState instead of silently selecting the no_std hasher) and its dependency specs are asserted in alloc mode with their std forwarded, exactly as a generated crate's are. It is the crate the generated crates forward INTO, so this is what lets a default-features = false build reach all the way down. Two things stay co-owned: the crate root, which the tool never writes (add the #![cfg_attr(not(feature = "std"), no_std)] line there yourself, as below), and the dependency LIST, from which the tool removes nothing.

7a. The std feature forwards, so default-features = false now does something. Before, it was a no-op in practice: the feature was std = [] and every dependency kept its defaults. If your tree was relying on that — a default-features = false dependant that was in fact getting a plain std build — it now gets the no_std one, which is the thing it asked for. The no-std-check shim is where you see the difference.

8. An existing crate opts into no_std with one hand-added line. The crate root is seeded once and never rewritten, so the tool cannot deliver the attribute to a tree it has already generated. Add it yourself, at the top of rust/src/lib.rs:

#![cfg_attr(not(feature = "std"), no_std)]

Until you do, the crate is plain std and still compiles — nothing forces the migration. Afterwards, no-std-check tells you whether the rest of that root came along.

9. Hex input is read canonically — uppercase is no longer accepted. This one is a WIRE-facing narrowing, and the only entry here that can change what your program accepts at runtime rather than whether it compiles. All three hex-reading surfaces — RawBytesEncoding::from_raw_hex, the JSON representation of a bytes newtype, and the AnyCbor codec's {"bytes": "…"} form — used to take uppercase and mixed-case digits and normalize them to lowercase on the way out. They now accept only the grammar they emit: bare, even-length, lowercase (full statement: Hex text).

Who breaks: any caller feeding hex it did not get from this crate — a value pasted from a block explorer, a hash rendered uppercase by another library, a JSON document produced by a non-Rust peer, an AnyCbor document hand-written or emitted by a peer implementation. Lowercase input, which is everything this crate ever wrote, is unaffected.

What you see: from_raw_hex returns a DeserializeError rendering Invalid internal structure: invalid character 'A' at position 0 — the first offending digit and its 0-based index. The JSON side returns serde's invalid hex bytes, the same wording it already gave for any other bad hex, so a bytes newtype that silently accepted uppercase before now rejects the document. AnyCbor rejects with its own invalid hex nibble 'A', plus serde_json's position.

What to do: lowercase at the call site, PubKey::from_raw_hex(&s.to_ascii_lowercase()), or normalize the field before handing the document to serde_json. There is no flag to restore the old leniency: the point of the narrowing is that the accepted grammar and the emitted grammar are the same one, which is what makes from_raw_hex(s)?.to_raw_hex() == s hold for every accepted s — and the same round trip hold on an AnyCbor {"bytes": …} document. 0x/0X-prefixed input was already rejected on every one of them and still is, with the same error.

Generated Cargo.toml (merge, not clobber)

The generated manifests — rust/Cargo.toml, and (when enabled) wasm/Cargo.toml and wasm/json-gen/Cargo.toml — are not overwritten wholesale on regeneration. Each run the tool applies a declarative changeset to whatever manifest already exists on disk (or to an empty document on a first run), so hand edits you make outside the keys the tool owns survive verbatim — comments, blank lines, formatting, key ordering, [profile.*] sections, and any dependencies you added are all preserved byte-for-byte.

Every key falls into one of three categories:

  • Tool-owned (rewritten every run). package.name, package.edition, lib.crate-type (whose value tracks the faces this run emits — see the --component flag), and the entire [dependencies] set the generator emits (e.g. cbor_event, and the flag/type-conditional deps hashlink, derivative, serde, serde_json, schemars, hex, wasm-bindgen, serde-wasm-bindgen). Non-dependency keys here are rewritten wholesale — your edits to them are overwritten by design (the same contract cargo add makes). Dependency entries are merged field-level, not clobbered: the tool owns the version floor (a compatible pin you wrote is kept verbatim — caret semantics — while an incompatible one is bumped up to the tool's requirement), the features it requires (unioned into your list, order preserved), and any field it sets (e.g. the path on the wasm crate's path dep, and the package on hex, which is a RENAMED dependency: the key is hex, the package it takes is const-hex). Everything else on the entry — optional, default-features, extra features, a version you added beside our path for publishing — is preserved, and the entry keeps its existing shape (a plain-string dep stays a plain string unless a merged field forces a table). This means, e.g., wasm-bindgen = { version = "0.2.126", optional = true } survives regeneration with its optional intact instead of collapsing to "0.2". Two things about how a merged entry reads. Its inline table is re-spaced to the canonical { a = 1, b = 2 } form — merging moves fields around, and the spacing a field carried in its old position is wrong in its new one — except in an entry whose braces hold a comment or a line break, which is left exactly as you wrote it. And a dependency the tool adds to a [dependencies] table that already existed gets a # cddl-codegen marker on its line: it can only be appended at the end of the table, which is wherever your last section comment happens to reach, so it says whose it is rather than reading as if that comment scoped it. A manifest the tool writes from scratch is wholly tool-owned and gets no markers, and an entry you wrote yourself is merged into and gets none either. One exception to field preservation: a tool spec may assert the dep's source axis outright, and then source-axis keys the spec does not itself re-specify are dropped from your entry rather than preserved. Two specs assert it, in both directions:

    • a spec pinning a git source asserts it implicitly (nothing else a git spec could mean), and drops version, rev, branch, tag, path, registry. A stale version requirement would constrain against the wrong source (^2.4.0 can never match a 3.x git checkout), and a leftover ref selector would form pairs cargo rejects outright (branch + rev).
    • a spec pinning a registry version asserts it explicitly, when the tool's changeset says so (a crates.io version has no cargo key that could carry the intent). It drops git, rev, branch, tag, path, registry, keeping the version floor rules above. This is what migrates an already-written manifest off a source the tool no longer uses: cbor_event is asserted this way, so regenerating over an output directory written by an older release replaces its { git = "…dcSpark/cbor_event", rev = "…" } entry with cbor_event = "3.3.0" — the same bytes a fresh generation writes. Fields outside the source axis (optional, your features, a version pin that already satisfies ours) survive it untouched.

    Every other version-only tool spec keeps the floor semantics above — your own git/path source survives it. A conditional dep is emitted when its flag/type condition holds and removed when it doesn't, so flipping a flag off (e.g. dropping --preserve-encodings) also drops the deps it pulled in, rather than stranding them — note the removal deletes the whole entry, including any fields you added to it. For the same reason, regenerate with the same flags every time: a regen that accidentally omits a flag (e.g. forgetting --wasm) reads as "condition off" and removes that flag's conditional deps from your manifest. Pin the full command in a script or Makefile. Three [features] entries are also tool-owned. Under --wasm the --rust-wasm-feature key (default wasm) is set at the leaf level to ["dep:wasm-bindgen"] or [] (see the feature gate above), and dropping --wasm removes exactly that key. features.std is written on every run whatever the flags, with the forwarding list that flag set implies — but it is one of the two feature keys that MERGE rather than replace (default = ["std"] is the other; both are below). Because each op addresses a single features.<name> leaf, your other [features] entries are untouched. Note that package.edition is pinned to 2024 on purpose — the generated code relies on TryFrom/TryInto being in the edition-2021+ prelude rather than importing them, so a consumer that reverts the manifest to an edition older than 2021 (e.g. git checkout -- Cargo.toml back to a hand-edited edition = "2018") will turn every u64::try_from(...) in the generated crate into a hard compile error.

  • Asserted, never removed. The [dependencies] entries named by --json-gen-dep in wasm/json-gen/Cargo.toml, --wasm-dep in wasm/Cargo.toml and --rust-dep in rust/Cargo.toml (and, outside the generated tree, the static-runtime crate's deps under --export-static-crate). These merge field-level like any other tool-owned dependency — so an entry you had already added by hand converges on one entry rather than duplicating — but dropping the flag leaves the entry behind instead of removing it. For those three flags the package name exists only inside the flag value, so a run without the flag has no name to tombstone; for the static-runtime crate the manifest is co-owned with your hand code, which may still need a dependency the current flavor does not. Remove one you no longer want by hand.

  • Seeded once. package.version is written only if it's absent. After the initial 0.1.0 (0.0.1 for the json-gen crate) the tool has no opinion — bump it and it stays bumped across regenerations.

  • Merged, not replaced. features.default and features.std — the tool's entries are added to whatever list is already there rather than overwriting it: your entries keep their order (and come first) and the tool's are appended. The consequence worth knowing before you meet it is that the merge is one-way — an entry the tool asserts cannot be durably deleted from these lists, because the next regeneration adds it back.

    features.std carries one addition the other does not need: after the union, a <pkg>/<feat> entry whose <pkg> is not a [dependencies] key is pruned. default's tool list is the constant ["std"], so a union can never strand anything; std's varies with the flags, so a flag flip would otherwise leave yesterday's serde/std beside a tombstoned serde — a manifest cargo rejects. Your forward to a dependency that is still there is kept; a bare entry (no /) names a feature of this crate and is never pruned.

  • Preserved (never touched). Any key no changeset op mentions: your own dependencies, [profile] tables, the rest of the [features] table (every entry but the three tool-owned keys above), workspace inheritance lines, etc.

The tool also writes a package.metadata.cddl-codegen.generated-with stamp recording the cddl-codegen version. It is write-only — the tool never reads it back (reading prior output would make generation depend on disk contents), so it's purely informational for debugging/support.

A fourth manifest rides the same merge machinery with a co-owned variant of this contract: the --export-static-crate target's Cargo.toml. There the tool asserts only the dependencies its exported runtime source references (in alloc mode, with a matching computed features.std), seeds (never rewrites) package identity, and never removes a dependency — see that flag's documentation for the exact contract.

If an existing manifest is not valid TOML, regeneration is a hard error naming the file — the tool never falls back to clobbering, because a parse failure is exactly the case where it can't safely tell your content apart from its own.

Known limitation: changing --lib-name between runs leaves the old-name path dep behind in wasm/Cargo.toml (the changeset is keyed by the current name). The error is loud (a duplicate/ unused dep) and the fix is to delete the stale line by hand.

package.name against the surrounding workspace. After every manifest is written, the tool reads back the names it just wrote and compares them against the members of the cargo workspace the output sits in (if any). A name a different member already holds is announced on stderr naming both manifests and the --lib-name remedy, because cargo adopts an in-workspace path dependency as a member and would otherwise report two packages named X in this workspace at your next build — long after the run that caused it exited 0 with an empty stderr. It is a warning and never a refusal: the surrounding workspace is an input the tool may read, and keeping the read diagnostic-only is what stops a directory this run does not own from deciding an emitted byte. Membership resolution is cargo's own, so this only approximates it (literal members entries and a one-level expansion of a * entry, minus exclude), which costs a missed warning at worst — every name it reports was read out of a real manifest on disk.

Example Output

note

The output format can change slightly depending on certain command line flags:

--wasm=false

--preserve-encodings=true

--json-schema-export true

That adds a third generated crate, wasm/json-gen/, whose only job is to run: its main() calls the export_schemas() it exports, and that writes one JSON Schema document for the whole crate at wasm/json-gen/schemas/<lib_name>.schema.json — a pure $defs bundle keyed by schemars::JsonSchema::schema_name(), byte-stable across regenerations of the same spec. So the schema artifact appears only once you have built and run that crate; generation alone writes the crate, not the document. export_schemas() never deletes anything in schemas/, so the directory can also hold files you own. The other public function, add_schemas(&mut schemars::SchemaGenerator), is the composition point for registering this crate's types into a generator you own. Which types get a registration row, what a row means (a declared root — $defs also holds everything a row'd type reaches), and the uniqueness the published $defs keys must satisfy are in Command line flags § --json-schema-export. The row set is derived from the spec, minus any rule carrying @no_json_schema_export, plus — when passed — the Rust paths of --json-schema-root, extra roots for published types the CDDL never describes, emitted after every spec-derived row.

export_schemas() also validates the document before writing it, so two of the ways a schema surface goes wrong surface as a panic in your run rather than as a broken .d.ts downstream: two types publishing one schema_name(), and a $ref that does not resolve inside the document. Both messages, and what to do about each, are under Command line flags § --json-schema-export.

Neither check is emitted into this crate. wasm/json-gen/src/generated/mod.rs opens with imports of them from the rust runtime crate's json_schema_gen module — use <lib>::json_schema_gen:: check_schema_ref_closure; always, and use <lib>::json_schema_gen::Registrar; when this crate has registration rows of its own — so a workspace of several generated packages shares one implementation rather than one copy per json-gen crate. A row is reg.add::<T>(); against a Registrar the body opens with let mut reg = Registrar::new(generator);, which owns the published-name ledger the injectivity guard keeps; the guard itself is the module's add_schema, which the registrar delegates to and which stays public for a row you write by hand. The module is rust/src/generated/ json_schema_gen.rs in-crate; under --common-import-override it is the common crate's, written there by --export-static-crate and declared by hand in that crate's root.

That same module carries custom_schema_impl! and custom_schema_body, which nothing emitted calls: they write the schemars::JsonSchema impl a hand-authored schema body needs, in the crate that DEFINES the type — for a generated type, a hand-owned module of the rust crate declared from its seed-once src/lib.rs, never anywhere under src/generated/**. See Comment DSL § Writing the JsonSchema impl the directive promises.

--package-json true --json-schema-export true

That layout nests the crates one level deeper (rust/rust, rust/wasm) and adds package.json plus a scripts/ directory holding the two shipped JSON-schema → TypeScript scripts, run-json2ts.js and json-ts-types.js. --json-schema-scripts true writes that same scripts/ directory without a package.json, leaving the crates in their usual rust/ and wasm/ places — for a project that hand-maintains its own npm manifests. The scripts resolve their inputs relative to their own location and work under either layout; see Command line flags § --json-schema-scripts for their arguments and their failure behaviour.

The two always-emitted sibling directories are not pictured above and do not move with the crates: extern-interface/ and no-std-check/ stay at the output root in every layout. The shim absorbs the nesting in its path dependency instead, which reaches rust/rust here rather than rust.