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 the runtime modules error.rs/ordered_hash_map.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 three things. (1) Concrete-type names — the collection helpers (BTreeMap, OrderedHashMap, NonEmptyVec, NonEmptyMap, OrderedSet, NonEmptyOrderedSet) 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 two OrderedSet twins 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/new names DeserializeError).

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.

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 — all-fixed-value choices (foo = 0 / 1 / 2) are encoded inline wherever they are used, not via an impl on the enum.

So the tag / fixed-value encoding for these is emitted at each use site. A consequence worth knowing: a type of one of these kinds that is a root rule referenced by nothing else has no standalone (de)serialization, and can only be (de)serialized as part of a containing type.

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.

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 rest tail;
  • 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.

An alias that names a wrapped form spells its payload from the target. When the alias rule itself carries an encoding operation, the alias ident denotes the wrapped value, not the payload — so the payload read names the target:

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

tagged_creds emits pub type TaggedCreds = PairMap<u64, Credential>; with the tag riding the alias, and its values are read with Credential::deserialize inside the already-consumed tag. TaggedCreds::deserialize there would compile (the alias is transparent) and would lie: TaggedCreds is the tagged table, so naming it at the position that reads the map body claims to read a tagged value. This is the one place the rule and "one spelling per member" disagree, and the rule loses on purpose.

A tagged preserve table is the only rule shape that can still reach this, and only because a wrapper cannot hold its PairMap inner (see Type aliases and tagged rules). Every other tagged rule body — and every bytes .cbor one — mints a wrapper struct, so its ident denotes a real type whose codec is the wrapped form: cred_bytes = bytes .cbor credential is read with CredBytes::deserialize, and tagged_arr = #6.24([* uint]) with TaggedArr::deserialize, which is the truth rather than a lie.

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

Two things are deliberately not covered:

  • 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, uint, nint, text, 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.

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 emitted #[schemars(with = …)] exists only to stop schemars reading the serde with module path as a type. 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>).

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, must occur exactly *, and must follow ≥1 fixed member; other spellings (a bounded/+ occurrence, a non-final rest row, a rest row in a group-choice arm or a plain group) are graceful rejections naming the supported form. 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 unrestricted (including any).

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. A pub map named rest (rename with @name on the row — see the comment DSL), keyed by K, valued V. It is excluded from new() and defaults empty, so adding a rest row to an existing spec is both source-compatible (old new(..) calls keep working) and wire-compatible (empty rest ≡ the closed struct's bytes). Its container matches the table switch and the @duplicates policy:

modecontainer
default (non-preserve)BTreeMap<K, V>
--preserve-encodingsOrderedHashMap<K, V>
@duplicates preserve on the rowPairMap<K, V> (the byte-exact duplicate-keyed twin)

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.

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 the minted map wrapper (no new() argument, no setter — it defaults empty and is mutated through the wrapper's own surface); 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.

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, and neither a new() argument. Everything the rest-row sections above say about a container's spelling, duplicate policy and preserve/canonical replay applies to each row unchanged; 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. It changes exactly one thing about the emitted face: new takes the first typed entry — new(first_key, first_value), the NonEmptyMap::new door verbatim — instead of taking no arguments, and the wasm and WIT constructors project those two parameters likewise. Both containers are otherwise unchanged, and the catch-all still defaults empty. The bound is re-checked once after the CBOR deserialize loop (raising DeserializeFailure::RangeCheck { found: 0, min: Some(1), max: None }, the error NonEmptyMap's TryFrom door raises) and once after the JSON visitor has read every member. 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.

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 a trailing * t element[uint, tstr, * uint] — is an open array: the fixed members deserialize positionally as declared, and every trailing element is captured into a generated rest field instead of ending the decode (a plain closed struct would stop at the declared arity). This is the array analog of the open struct-map rest row — structurally simpler because arrays are positional: there are no keys, so no duplicate policy, no key dispatch, and no ordering machinery. The rest tail must be the array's last element, must occur exactly *, and must follow ≥1 fixed member; other spellings (a bounded/+ occurrence, a non-final *, a rest tail in a group-choice arm or a plain group, or a fixed-value element type like * 5) are graceful rejections naming the supported form.

The rest field. A pub Vec<T> named rest (rename with @name on the tail entry), holding the captured trailing elements in wire order. It is excluded from new() and defaults empty, so adding a rest tail to an existing spec is both source-compatible (old new(..) calls keep working) and wire-compatible (empty tail ≡ the closed struct's bytes — an array of exactly the declared arity). Unlike a map rest row, the container is a plain Vec<T> in every mode (default and preserve alike) — per-element encodings live in the owner's encoding struct, not the container.

Serialize / round-trip. The array header counts the declared members plus every tail element (N + rest.len()); declared members serialize first, then the captured elements in Vec order. Typing is enforced: [uint, tstr, * uint] errors on a non-uint trailing element (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. 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 — a tail is positional, not keyed).

JSON (--json-serde-derives). The captured tail 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. Write (to_json) skips the field when the tail is empty; read (from_json) defaults it to empty when absent — so empty tail ≡ closed-struct JSON, mirroring the CBOR invariant. An any-element tail 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 just the field's ordinary array-typed property.

wasm. The rest field is exposed as a read-only getter named after the field (rest() by default) returning the minted list wrapper (no new() argument, no setter — it defaults empty); see wasm differences.

The @ignore (tolerate-and-drop) flavor

Marking the tail entry @ignore switches from capture to tolerate-and-drop: trailing 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 prefix. This is deliberately lossy: byte round-trips do not hold for wire data that carried trailing elements (the type and its serialize fn carry a rustdoc breadcrumb saying so, worded for trailing array elements), 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 — trailing elements are tolerated on read and dropped, and there is no rest() getter.

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 bound ([* T], [2*5 T], …) keeps today's bare Vec + runtime length check — only the exactly-(1, ∞) shape changes representation.

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.

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"

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

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 bare Vec/NonEmptyVec, 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.

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:

  • From<Vec<(K, V)>> / into_inner() -> Vec<(K, V)> — free both ways. No checked door on the loose flavor (there is nothing to reject). NonEmptyPairMap instead has the single fallible TryFrom<Vec<(K, V)>> door (and a TryFrom<PairMap<K, V>> that re-uses it), returning the same 0 not at least 1 RangeCheck as the other non-empty twins for an empty input.
  • 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, 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> and NonEmptyPairMap<K>To<V>, 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. 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 the same glue, again for both markers. Its generated code names the 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. So the tool emits pub use crate::MyExt; into the wasm declaring scope's generated module too, and the contract is identical: define the wasm 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). (Types imported from a dependency crate — either marker — are unaffected by this glue; they resolve through the dependency's import path.)

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.

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 fields are pub(crate)

Newtype wrappers (bare tags, @newtype, bounded ranges) and the wasm crate's pub struct X(rust::X) wrappers back their inner value with a pub(crate) field (named inner under --preserve-encodings=true, an anonymous tuple field otherwise). External crates still cannot construct or mutate a wrapper directly — the new() bound check holds at the crate boundary, the one where it is observable — but your own hand modules (which live outside the generated subtree, per the layout above) can reach the field, e.g. to write a RawBytesEncoding impl on a bounded newtype or to access a wasm wrapper's 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/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 these 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 a 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, {* k => v}MapKToV, and their NonEmpty… 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.

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