Output format
- Inside of the output directly the tool always produces a
rust/directory (including Cargo.toml, etc). - Unless we pass in
--wasm=falsethe tool also generates a correspondingwasm/directory. - Under
--component=truethe tool additionally generates acomponent/directory — a wasm component model (WIT/wasip2) face beside the wasm-bindgen one:component/wit/holds the generated WIT package andcomponent/src/generated/thewit-bindgenguest glue over therust/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) andno-std-check/(a tiny throwaway crate that proves the rust crate still builds withoutstd— see Theno-std-checkshim 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.rsholds the structs,rust/src/generated/serialization.rstheir (de)serialization implementations/corresponding types, plus the runtime moduleserror.rs/ordered_hash_map.rs; a crate with any@used_as_key-tagged rule also carrieskey_demand_assertions.rs, the private compile-time record of which tag demanded which comparison/hash traits). The crate rootsrc/lib.rsis 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 (underwasm/src/generated/) for the corresponding rust-use-only structs inrust/and can be compiled for WASM builds by runningwasm-pack buildon it. The--target=nodejsoutput runs unchanged under both Node and Bun. Every wasm crate also carries awasm/src/generated/collections.rsindex: onepub use crate::…::<Wrapper>;re-export per collection wrapper class the crate defines (theFooList/MapKToVandNonEmpty…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 liketagged = #6.42(text)are NOT aliases — they auto-wrap into a tag-writing/tag-checking newtype; seecurrent_capacities.) - C-style enums — all-fixed-value choices (
foo = 0 / 1 / 2) are encoded inline wherever they are used, not via animplon 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 andT / nullbodies), abytes .cborrule body (sc_bytes = bytes .cbor stake_credential) or a@newtyperule wraps — and thenew/ getter /Fromsignatures built from it; - a
.cborpayload'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 typedStakeCredentialand the arm isStakeCredential, 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}emitspub type Nemap = OrderedHashMap<Epoch, String>); - member-level deserialize call targets —
T::deserialize,T::from_raw_bytes,T::deserialize_as_embedded_group.dc: DeltaCoinis filled byDeltaCoin::deserialize, and a member of a raw-bytes rule aliased asscript_hashbyScriptHash::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: inother = [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
NoVariantMatchederror 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 staysCenum::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-bindgenis 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--wasm—wasm = ["dep:wasm-bindgen"]when a c-style enum exists, otherwisewasm = []. 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. Thedep:form means the optional dependency introduces no implicit same-named feature. (The table also carriesdefault = ["std"]and astdkey written on every run whatever the flags, whose value is the computed forwarding list — see Cargo.toml merge;--rust-wasm-featuremay not be namedstdordefaultfor 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 istrue; 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 recordstrue, otherwise leaves the fieldfalse. - wasm exposes the bit as a getter
v() -> booland setterset_v(present: bool)on the wrapper. - JSON (serde / schemars) treats it as a plain
bool—"v": falsewhen 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>>,
}
| state | rust | CBOR | JSON |
|---|---|---|---|
| absent | None | member not written | key omitted |
| present, null | Some(None) | null written | "field0": null |
| present, value | Some(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.
null to mean absentA 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:
- Union —
dst.extend(src)(std setExtend: a duplicate is a keep-first no-op, so extending is union; nothing is discarded and noResultis threaded). - Empty-means-absent for an optional field —
Nominal::try_opt_from(vec)(empty →None; non-empty → the checked door inSome; only a duplicate surfaces asErr). Prefer this overVec::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-formthe same value serializes to RFC 8949 §4.2 deterministic encoding instead. Equality/ordering/hashing are representational (two values with equal content but different encoding —0x01vs0x1801, both the integer 1 — compare unequal), matching the@used_as_keypreserve-struct precedent, so anAnyCbormap key never silently collides. This total order by construction is whyanyis 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), ananytype-choice arm, and a newtype wrapping anany(#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-authoredfrom_jsondocument looks like. - The
AnyCborvalue codec — a total, tagged rendering, described in the subsection below. It is what a bareAnyCbor(thex = anytop-level alias, and theAnyCborwasm wrapper) uses, and the value-level escape hatch when naturalto_jsonfails.
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 value | Natural JSON | |
|---|---|---|
| uint | number | full u64 range; values > 2^53 stay numbers (an I-JSON precision caveat, not a failure) |
nint (fits i64) | number | |
| text | string | |
| bool / null | true/false / null | |
| finite float | number | |
| array | array (recurse) | |
| map | object | iff every key is text/uint/nint (stringified: text verbatim, ints decimal) and no two keys stringify identically |
| bytes | — | to_json errors (no injective image) |
| tag (any number) | — | errors |
undefined | — | errors |
| unassigned simple | — | errors |
| non-finite float (NaN/±Inf) | — | errors |
nint below i64::MIN | — | errors (serde_json's number model bottoms out at i64) |
| complex / colliding map key | — | errors (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.
| CBOR | JSON |
|---|---|
| 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
bytesnewtype under--json-serde-derives(a rule likehash = bytes ; @newtype), whose hand-written serde impls write and read that same text; - the
AnyCborvalue 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:
| Input | from_raw_hex | JSON bytes newtype | AnyCbor {"bytes": …} |
|---|---|---|---|
"a1b2" | Ok | Ok | Ok |
"A1B2" | Err — invalid character 'A' at position 0 | Err — invalid hex bytes | Err — invalid hex nibble 'A' |
"a1B2" (mixed) | Err — invalid character 'B' at position 2 (the FIRST offending digit) | Err — invalid hex bytes | Err — invalid hex nibble 'B' |
"0xa1b2" | Err — invalid character 'x' at position 1 | Err — invalid hex bytes | Err — invalid hex nibble 'x' |
"0Xa1b2" | Err — invalid character 'X' at position 1 | Err — invalid hex bytes | Err — invalid hex nibble 'X' |
"abc" | Err — odd number of digits | Err — invalid hex bytes | Err — odd-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 type —
uint, 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:
| mode | container |
|---|---|
| default (non-preserve) | BTreeMap<K, V> |
--preserve-encodings | OrderedHashMap<K, V> |
@duplicates preserve on the row | PairMap<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 complexanykey, non-finite float, bytes, …). Values render through the natural walk for ananyrange; a typed range renders throughV's own serde, so a union value is serde's externally-tagged object ({"Text": "hi"}) and abytesunion 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 inrest(loose JSON parsing for free). A bareuint/textdomain parses deterministically (decimal / verbatim); ananydomain 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 aspatternProperties("^\d+$"→ the range's schema), a text- orany-keyed one asadditionalProperties(the range's schema; permissive{}for ananyrange) — 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 preserverow therefore publishes exactly the schema its non-preserve twin publishes (the duplicate-keyto_jsonfailure 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, total | if to_json succeeds with document J, from_json(J) succeeds and to_json(from_json(J)) == J, for every K |
| T2 value fixed point, partial | from_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 data | to_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 read | a 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 containers — entries (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 it — minProperties 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_serializepair 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_serializepair is invisible to the JSON face in every position, table keys included. K_t'sSerializemust produce a JSON string (or an integer/bool serde_json renders as one). Nothing can check that at compile time: a barebyteskey (Vec<u8>→ a JSON array) or a derive over a byte array is a runtimeto_jsonerror naming the key. A hand-written hex/bech32/base64Serialize— 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_tand aV_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 point | holds 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, partial | the 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 data | the 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 read | unchanged; 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 aDeserializeErrorwhoseRangeCheck { found: 0, min: Some(1), max: None }displays as0 not at least 1.From<NonEmptyVec<T>> for Vec<T>— the infallible escape hatch. Unwrap to the loose collection, mutate freely, andtry_into()back at the edge. Plusas_slice()/AsRef<[T]>/iter()for read access without a round-trip, and infalliblepush/extend(a push can never break a min-1 bound) with checkedpop/remove(removal that would empty the container errors). Value-level mutable access —iter_mut/as_mut_slice/IndexMutonNonEmptyVec,get_mut/values_mut/iter_mutonNonEmptyMap— is unrestricted: the invariant is about the container's length, which a&mutto 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 same0 not at least 1error 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 rejectemitspub 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 looseVec; it scans for the first duplicate and, on finding one, returns aDeserializeErrorwhoseDuplicateKey(Key::Uint(i))names the zero-based indexiof the offending element — deterministic and actionable regardless of the element type (this is the set analogue of the table path'sDuplicateKey, which uses the key value).NonEmptyOrderedSet's door composes both checks: the sameRangeCheck { found: 0, min: Some(1), .. }(0 not at least 1) asNonEmptyVecfor 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 looseVec, mutate freely, andtry_into()back through the door at the edge. Plusas_slice()/AsRef<[T]>/Index/iter()/IntoIteratorfor read access without a round-trip.- Checked
pushreturnsResult. Appending an element already present is refused withDuplicateKey(Key::Uint(index))— in contrast toNonEmptyVec::push, which is infallible (growing can never break a minimum bound, but it can mint a duplicate, so the set twin'spushmust be fallible). The indexpushreports for a duplicate is the SAME index theTryFromdoor 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.NonEmptyOrderedSetadditionally gives checkedpop/remove(refused at length 1) exactly likeNonEmptyVec. - The std set contract, alongside the strict
push. Both twins also implement theHashSet/BTreeSet/IndexSetsurface, 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 plaininsertloop),contains(&T) -> bool,Extend<T>(a duplicate is a keep-first no-op, sodst.extend(src)is union), andsort()(theIndexSet::sortprecedent — 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).OrderedSetadditionally implementsFromIterator<T>(collectdedups keep-first, theIndexSet::from_itersemantics) — there is deliberately noFromIteratoronNonEmptyOrderedSet, 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 isOk(None)(the field is absent — the min-1RangeCheckdoes NOT fire on the non-empty twin), a non-empty input routes through theTryFrom<Vec<T>>door inSome, and only a duplicate surfaces asErr. This is the discriminating constructor to reach for instead ofVec::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 same0 not at least 1RangeCheckas every other min-1 door), andFrom<NonEmptyOrderedSet<T>> for OrderedSet<T>widens infallibly. - A stricter blocked-mutator set than the non-empty twins.
NonEmptyVecpermits value-level&mutaccess (iter_mut/as_mut_slice/IndexMut) because its invariant is only about length, which a&mutto an element cannot reach. The ordered-set twins expose none of those — noIndexMut, noiter_mut, noas_mut_slice, noget_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 throughinto_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 sameDuplicateKey(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:
| Need | Recipe |
|---|---|
| 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 preserveemitspub type Meta = PairMap<u64, Vec<u8>>).{ + k => v }→NonEmptyPairMap<K, V>, whose singleTryFromdoor 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).NonEmptyPairMapinstead has the single fallibleTryFrom<Vec<(K, V)>>door (and aTryFrom<PairMap<K, V>>that re-uses it), returning the same0 not at least 1RangeCheckas the other non-empty twins for an empty input.insert(key, value)APPENDS and never replaces. It always returnsNone(nothing is displaced, because nothing is overwritten) — a replacing insert would silently drop a duplicate, defeating the point. TheOption<V>return exists only so the read surface matches the loose table's.- A duplicate-honest read surface.
getreturns the FIRST match (linear scan),get_allreturns every match in entry order, anditer/keys/values/as_slicewalk in entry order. The key domain relaxes from the loose table's full key-demand bundle toOrd(a linear-scan lookup needs onlyEq;Ordis retained for the canonical sort below). - Deserialize collects positionally. The
--preserve-encodingssidecar for a preserve table is positional (parallelVecs indexed per entry, like the array_elem_encodingspath), replacing the key-value-keyedBTreeMaps 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 aResultover the staticDeserializeError),generated/serialization.rs, per-scope submodules, the copied runtime modules (error.rs,ordered_hash_map.rs), and — when any rule carries@used_as_key—key_demand_assertions.rs. (Under--common-import-overridethe runtime modules are not emitted — the override crate owns its copy, and--export-static-crateis 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-endfor added lines, and// cddl-codegen:replace-start/replaces/replace-endfor swapped code (your version plus a//-commented record of the generated code it overrides). Anything that cannot be safely re-placed becomes acompile_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-commentsto turn the whole overlay off and clobber pristine. Three carve-outs: outside acddl-codegen:block every comment here is tool-owned, so an UNMARKED own-line comment is trapped in acompile_error!rather than re-anchored on a guess (delete the block, and re-add the text with akeepmarker if it was yours); trailing (end-of-line) comments are not carried — move them to their own line (a// cddl-codegen:tag thatrustfmtitself 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.rsalready 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 theCargo.tomlchangeset carves out of the tool's otherwise strict no-prior-output-dependence). New and renamed generated types surface automatically through thepub 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/):
- Delete the generated items from
src/lib.rs— the type definitions and thepub mod serialization;/pub mod cbor_encodings;/ runtime-module decls the tool now owns undergenerated/. - Keep your hand wiring — your own
pub mod/pub use/crate attrs, extern-type modules, etc. - Add the two thin-root lines:
mod generated;andpub use generated::*;. - 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.cddlnever lingers), and it is committed so consumers read it as an input. It is outside the comment/code-preservation overlay (which is scoped to.rsunder 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/modulescope →extern-interface/<dep>/sub/module/mod.cddl), the same scope-as-directory encoding the stub channel uses, so a consumer's--extern-importrecovers 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— andDeserializetoo, 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/Deserializeand embedded-groupSerializeEmbeddedGroup/DeserializeEmbeddedGroup(eachDeserializeside 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 inextern_interface_check.rsorkey_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 ownextern 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, anduse alloc::…does not resolve without a binding in scope. - The seeded root carries
#. Fresh exports get it automatically; a crate generated before this line existed keeps compiling as plainstduntil you add it by hand — one line, once. - A default-on
stdfeature, 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, noCargo.tomlmerge, 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 hostcargo checkcannot prove anything here;thumbv7m-none-eabiis a bare-metal target with nostdat all. Install it once withrustup 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 turningstdback on for a shared dependency would make this check pass while the crate is not in factno_std-clean. (It also means the directory appearing in your output can never disturb a workspace that does not list it — and that a barecargo check -p …has no workspace to resolve the name in, hence the--manifest-pathinvocation.) - Package name
<lib-name>-no-std-check, from--lib-name, so a--configmulti-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/rustinstead of../rust. - One exception to "just run it": a crate generated with
--common-import-overridedoes 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 torust/Cargo.tomlby 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 onthread_local!, which has nocore/allocequivalent, so the crate carries acompile_error!fornot(feature = "std")builds and this check — which is adefault-features = falsebuild — 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 ano_stdbuild 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
Dereftarget andtake()'s return type name the backing crate (hashlink::LinkedHashMap<K, V, MapHashBuilder>); Entryimports move fromlinked_hash_map::Entryto the generated crate'sordered_hash_map::Entry— a thin wrapper whoseor_insert/or_insert_withdeliberately 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
hashlinkequivalent (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:
linked_hash_map::path references —uselines and qualified paths, in every file, not just the files where imports cluster.LinkedHashMap<type annotations, including public struct fields. A public field's type is published API, so retyping it toOrderedHashMapis a breaking change for your downstream consumers — worth releasing deliberately, not discovering..entry(call sites on anOrderedHashMap, resolved by TYPE, not by text. A call that readsmap.deref_mut().entry(k)looks like it reaches the backing crate's entry view, butOrderedHashMap::entryis an inherent method, and an inherent method shadowsDeref— 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.linked-hash-mapin everyCargo.tomlin 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 yourCargo.lockindefinitely.
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 depshashlink,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 contractcargo addmakes). 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. thepathon the wasm crate's path dep, and thepackageonhex, which is a RENAMED dependency: the key ishex, the package it takes isconst-hex). Everything else on the entry —optional,default-features, extra features, aversionyou added beside ourpathfor 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 itsoptionalintact 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-codegenmarker 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.0can 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_eventis asserted this way, so regenerating over an output directory written by an older release replaces its{ git = "…dcSpark/cbor_event", rev = "…" }entry withcbor_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/pathsource 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--wasmthe--rust-wasm-featurekey (defaultwasm) is set at the leaf level to["dep:wasm-bindgen"]or[](see the feature gate above), and dropping--wasmremoves exactly that key.features.stdis 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 singlefeatures.<name>leaf, your other[features]entries are untouched. Note thatpackage.editionis pinned to2024on purpose — the generated code relies onTryFrom/TryIntobeing 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.tomlback to a hand-editededition = "2018") will turn everyu64::try_from(...)in the generated crate into a hard compile error. - a spec pinning a git source asserts it implicitly (nothing else a git spec could mean), and
drops
-
Asserted, never removed. The
[dependencies]entries named by--json-gen-depinwasm/json-gen/Cargo.toml,--wasm-depinwasm/Cargo.tomland--rust-depinrust/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.versionis written only if it's absent. After the initial0.1.0(0.0.1for the json-gen crate) the tool has no opinion — bump it and it stays bumped across regenerations. -
Merged, not replaced.
features.defaultandfeatures.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.stdcarries 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'sserde/stdbeside a tombstonedserde— 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

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.