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 usage-gated runtime modules such aserror.rs,ordered_hash_map.rs, andbounded.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 four things. (1) Concrete-type names — the collection helpers (BTreeMap, OrderedHashMap, NonEmptyVec, BoundedVec, NonEmptyMap, BoundedMap, OrderedSet, NonEmptyOrderedSet, BoundedOrderedSet, PairMap, NonEmptyPairMap, BoundedPairMap) and, under --preserve-encodings, the encoding enums (LenEncoding, StringEncoding, TagPresenceEncoding); the wasm prelude names (JsError, JsValue, and the wasm_bindgen attribute macro, kept only where a #[wasm_bindgen] attribute appears); the --wasm-*-macro leaf names (kept only where the macro is actually invoked); and cross-scope generator-minted type idents an over-approximating reference walk imports. (The ordered-set twins and bounded compound twin ride the spec-global @duplicates reject gate — see Reject-duplicates containers — so a scope with a reject-set rule pushes them into every scope's mod.rs, and this pass keeps the scopes that never name them warning-free.) (2) use super::*; — dropped from a file whose body names nothing bound in its parent module (kept where an encoding struct is keyed by a parent-scope generated type, e.g. BTreeMap<Ikey, StringEncoding>, since it reaches Ikey through the glob). (3) use <common>::error::*; — dropped from a mod.rs whose family names no error export (kept where a bounds-carrying wrapper's TryFrom names DeserializeError). (4) use super::cbor_encodings::*; — dropped from a serialization.rs family that names no definition from its generated sibling cbor_encodings.rs (for example, a serialize-only record that reaches its sidecar only through self.encodings).
The consuming set of a file is not "every descendant" but the descendants an unbroken chain of use super::*; edges links back to it — a private import in a scope's mod.rs is nameable by that scope's serialization.rs/cbor_encodings.rs (which glob super), but NOT by a different scope's files, whose own mod.rs does not re-glob upward. A super-reachable descendant keeps a parent's copy alive only when it would genuinely resolve the name through the parent: one that carries its own direct import of the same name, that IS the file of the module the parent imports it from (the serialization.rs static prelude that defines LenEncoding), or that globs that same module (use <dep>::serialization::*; under --common-import-override) resolves the name locally and does not. A reference counts only where it could actually resolve through the namespace: an identifier appearing solely as a ::-qualified path tail (cml_chain::assets::Coin mentioning assets and Coin) resolves through its preceding path segment, so it keeps neither a named import nor a glob alive — which is why a file built entirely from fully-qualified paths (e.g. the --wrapper-requests requested_collections.rs) sheds its use super::*;. Only private use items are candidates (pub use re-exports are API surface and never touched). Trait imports (e.g. cbor_event::se::Serialize, exercised via a method call whose name never mentions Serialize) cannot be proven unused by name and are left as-is, so a scope that imports but never calls one still carries that single warning. The pass runs again after the edit-preservation overlay applies your cddl-codegen: blocks, so what justifies each import is the final shipped content, not the pristine pre-edit content — a replace block that removes an import's last user removes the import too (see Preserving edits).
Re-export-only files carry no import walls at all. A generated file whose entire body is extern re-export glue — every top-level item a use, with only crate::-anchored pub use re-exports (the mod.rs an extern-only CDDL scope produces) — has all of its private use items dropped, not just the concrete-type names and prunable globs above. Such a file's shape proves nothing can consume a private import (no local code, and no descendant module whose use super::*; could re-export one), so the wider rule is sound even for the trait imports the name-based prune must otherwise keep. The result is a re-export-only mod.rs reduced to its header comment and its pub use crate::…; lines, with zero unused_import warnings.
Unused-variable warnings are held to the same standard. An emitted binding whose body never reads it is spelled to say so — an unread loop index binds _i (e.g. a @duplicates preserve pair-map whose entries carry no per-entry encoding state to index), a constant-count match arm binds _ — so a generated crate contributes no unused_variables noise to a consumer's build. Both classes (unused imports beyond the documented trait residue above, and unused variables) are enforced by warning scans over every generated crate the tool's own test corpus compiles.
from_cbor_bytes rejects trailing bytes. The generated/static Deserialize::from_cbor_bytes (and the wasm from_cbor_bytes) decode one complete value and then require the cursor to have consumed the entire input; leftover bytes after a complete value return an error (cbor_event::Error::TrailingData) rather than being silently ignored, so a truncated/corrupt or accidentally-concatenated buffer is caught. A bytes .cbor T payload is held to the same rule, and raises the same error: .cbor says the byte string is T's encoding, so bytes left over inside it after a complete T are rejected rather than accepted-and-dropped — which under --preserve-encodings would also mean an accepted value re-encoding to different bytes. (Backwards-compatibility note: generators before 2026-08 enforced exhaustion only at the top level, so an embedded .cbor payload with trailing bytes was accepted and the leftovers silently dropped; crates re-generated since then reject such inputs. Anything written by the generated serializers — or by any encoder that writes a complete T encoding as the byte string — is unaffected; only inputs that were already silently losing bytes change verdict.)
Not every type gets its own (de)serialization in serialization.rs. Some constructs carry their CBOR framing at the point of use rather than as a standalone impl on the type:
- Type aliases — homogeneous arrays (
-> Vec), tables (-> BTreeMap), and plain aliases (foo = bar) are (de)serialized through the aliased type. (Top-level single-type tag rules liketagged = #6.42(text)are NOT aliases — they auto-wrap into a tag-writing/tag-checking newtype; seecurrent_capacities.) - C-style enums — multi-value all-fixed choices (
foo = 0 / 1 / 2) are encoded inline wherever they are used, not via animplon the enum. The exception is a mandatory tag directly on the choice rule under--preserve-encodings: that rule uses the data-carrying union shape below so the enum can retain the tag head's width. A one-value named rule (answer = 42ormagic = h'CAFE', including a tagged orbytes .cborspelling) is different: it is a nominal singleton TypeChoice with direct codecs, precisely so its standalone API has the declared wire shape.
So the tag / fixed-value encoding for these is emitted at each use site. A multi-value C-style enum root referenced by nothing else has no standalone (de)serialization, and can only be (de)serialized as part of a containing type; named singletons are the deliberate exception above.
Union (type-choice) enum variants change shape with the profile. Under the default profile a
payload-carrying variant is a tuple variant (Md::Map(PairMap<Md, Md>)); under
--preserve-encodings every payload-carrying variant additionally holds its per-variant encoding
state, so the same variant is a struct variant (Md::Map { map, map_encoding, .. }). Code
written to compile against a generated crate in either profile (a downstream consumer supporting
both, or hand-written test fragments spliced into both export crates) should therefore bind
variants profile-invariantly: match with braced patterns (matches!(v, Md::Map { .. })) or go
through the emitted methods/serialized bytes, never tuple-destructure
(Md::Map(m) compiles in one profile and is E0164 in the other). Variant names and their wire
behavior are identical across profiles — only the binding shape differs. A mandatory tag directly
on the choice rule adds the same Option<cbor_event::Sz> tag-width field to every preserve-profile
variant. It is named tag_encoding unless an arm already owns that value/encoding name; the
generator then selects the first free numeric suffix across the whole enum (tag_encoding2, …), so
every variant still uses one shared spelling. Emitted constructors initialize it to None (the
fit-minimal head); decoded values carry the observed width. This is part of the public enum-variant
shape, so the .. pattern advice above applies to it too.
Item ordering within a file is alphabetical by name, not by kind. Top-level items are emitted sorted name-primary, kind-secondary: alphabetically by the type's bare name (module path and generics ignored), interleaving structs, enums, impls, type aliases, and modules — so a type's struct/enum and its impls sit together, and type Foo is placed alphabetically among everything rather than grouped with the other aliases. Within a single name the struct/enum definition comes first, then its impls. This canonical ordering is stable across unrelated changes to the input, which keeps diffs of generated code small. (It is independent of run-to-run determinism, which holds regardless.)
Type spelling at member positions
Wherever generated code names the type of a member, it spells that type as you declared it — keeping the outermost alias ident, with no resolution to the alias's structural target. Given
policy_id = bytes
epoch = uint
delta_coin = int
credential = [idx: uint, hash: policy_id]
stake_credential = credential
holder = [pid: policy_id, dc: delta_coin, sc: stake_credential, m: {* epoch => text}]
the emitted pid is PolicyId, never Vec<u8>; m's encoding sidecar is keyed by Epoch, never by u64; and sc is read with StakeCredential::deserialize, never Credential::deserialize.
The positions this covers:
- data-struct fields, and the constructor / getter / setter / wasm signatures built from them;
- a wrapper rule's inner value — the type a tag-head rule (
tagged_sc = #6.9(stake_credential), including its collection 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 occurrence segment;
- 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.
A rule that owns an encoding operation is a type, so its ident is what the member reads. When the rule itself carries an encoding operation — a tag head, bytes .cbor, the optional-tag idiom — it mints a wrapper struct rather than a transparent alias, so its ident denotes a real type whose codec is the wrapped form:
credential = [idx: uint, hash: policy_id]
tagged_creds = #6.11({* uint => credential}) ; @duplicates preserve
holder = [tc: tagged_creds]
tagged_creds emits pub struct TaggedCreds(pub(crate) PairMap<u64, Credential>) whose serialize writes write_tag(11) and whose deserialize requires it, and tc is read with TaggedCreds::deserialize — the whole tagged table, which is exactly what the ident denotes. Same for cred_bytes = bytes .cbor credential (CredBytes::deserialize) and tagged_arr = #6.24([* uint]) (TaggedArr::deserialize). Because no transparent alias can carry a wire-affecting operation, one CDDL type never has two wire forms selected by use-site, and there is no position where the ident and the value read there disagree.
What matters is which declaration owns the encoding operation, not that there is one. An operation written into the member's own type expression leaves an alias denoting exactly the value being read, so the spelling survives it: f: #6.9(stake_credential) is a field typed StakeCredential read with StakeCredential::deserialize inside the tag, and j: bytes .cbor stake_credential is a field typed StakeCredential read with StakeCredential::deserialize inside the byte string. And a container inner inside a payload is a member position of its own again, so bytes .cbor [* stake_credential] reads its elements with StakeCredential::deserialize.
Three things are deliberately not covered:
-
A member reached through an alias carrying a
@custom_serialize/@custom_deserializepair. Such an alias mints no Rust type (why), so there is no ident for a member to keep: the member spells the type the alias resolves to (inner = uint ; @custom_serialize …givespub a: u64, and an alias of a marker givespub p: PolicyId). The alias name still governs everything derived from the name rather than the type — enum variant idents, the structural wasm class of a collection over it (InnerList), and the encoding-sidecar shape — and no wire byte,Ord/Hashbehaviour or JSON face changes with it. -
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, undefined, uint, nint, text, bytes, and float (? v: 2.5): the float
presence field serializes at the literal's smallest value-preserving head (RFC 8949 § 4.1) and
verifies on read with an exact raw.float()? != <lit> compare, the same spellings the mandatory
fixed-float path emits (tests/corpus/optional_fixed_float.cddl, hand vectors in
tests/core/tests.rs's opt_fixed_member_float). Under --preserve-encodings the member
additionally carries its head-width encoding variable, so an absent member stores no width and a
present one re-emits at the width it arrived on. A fixed float value is a literal, not a prelude
name, so it names no value class and accepts the literal at any width — the per-name value classes
are the separate rule in Floats, and the canonical narrowing is under
--canonical-form.
Optional members whose type is nullable
A member that is both optional and nullable ([pre: uint, ? field0: (uint / null)] — the ?
makes it optional, the / null makes its type nullable) carries three states, and the rust field
is a nested Option:
pub struct NullableOptionalField {
pub pre: u64,
#[serde(with = "crate::generated::double_option")]
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub field0: Option<Option<u64>>,
}
| 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. If T is an exact-array tree
that needs the static-array adapter, generation instead emits one presence-aware recursive callback:
it owns the outer Option, delegates a present value to the descriptor for the inner nullable type,
and still omits an absent key. This preserves the same three-state table while keeping exact-array
JSON list shapes and bounds.
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 ordinary callback emits #[schemars(with = …)] only to stop schemars
reading the serde with module path as a type. The composed exact-array callback instead supplies
the recursive inner schema directly, preserving its null branch and every sequence bound.
On the wasm read side the two Options are flattened into one, because wasm_bindgen cannot
expose Option<Option<T>> — the presence bit is restored additively by a has_<field>() accessor, see
Wasm differences.
Nominal tag-258 set types
A tag-258 set — the two-arm idiom (my_set = #6.258([* a]) / [* a]), the single-arm mandatory-tag
form (#6.258([* a])), a generic set instantiation (set<a>), or an inline #6.258([* a])
occurrence — is a NOMINAL wrapper struct that OWNS its {tag, len, elem} encodings, not a
transparent Vec/OrderedSet alias. Named rules take the rule name; generic instantiations are
instantiation-derived (SetKeyHash); inline occurrences are shape-derived (SetU64,
SetNonEmptyText), one per deduped shape. See
Current capacities § "Duplicates policy — tag 258 defaults to reject" for the
three naming regimes and the reject default. The wrapper carries the set ergonomics
(Deref/DerefMut to the inner collection, borrowed + owned IntoIterator, From/TryFrom<Vec>)
and always-on encodings-ignored PartialEq/Eq/PartialOrd/Ord/Hash; JSON/schemars stay transparent
(the wrapper serializes as its inner collection).
Because the wrapper Deref/DerefMuts to the inner uniqueness twin, the std set contract the twin
carries (insert -> bool, contains, Extend, FromIterator, sort — see
Reject-duplicates containers) resolves through
method-call syntax on the nominal itself: set.insert(x), set.extend(other), set.sort() all work
without unwrapping. The one door that CANNOT ride Deref (it returns the inner type, not the nominal)
is try_opt_from, so it is emitted inherently on every set nominal whose inner is a uniqueness
twin: Nominal::try_opt_from(Vec<Elem>) -> Result<Option<Self>, DeserializeError> — empty is
Ok(None), non-empty routes through the inner door and re-wraps via new.
LOAD-BEARING uniformity contract. Every generated set nominal with a fallible inner door — i.e.
every @duplicates reject set (the tag-258 default) and every non-empty ([+]) set — implements
TryFrom<Vec<Elem>, Error = DeserializeError>. This signature is uniform across all such
instantiations by design: consumer-side generic helpers depend on it (e.g. an
opt_nonempty_set<S: TryFrom<Vec<T>, Error = DeserializeError>>(elems) -> Result<Option<S>, _>
works for every instantiation only because the door's error type never varies). Keep it uniform, or
version it consciously. The single exception is the @duplicates preserve empty-allowed ([*]) case:
its inner is a plain Vec, so it exposes an infallible From<Vec<Elem>> (and hence std's blanket
TryFrom<Vec<Elem>, Error = Infallible>) rather than the fallible DeserializeError door — a
duplicate-tolerant set has nothing to reject, so there is no fallible door to make uniform.
The three set operations every consumer of these types reaches for are one-liners on this surface:
- 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>).
RFC 8610 expected-conversion tags over any
The RFC 8610 prelude names eb64url, eb64legacy, and eb16 are supported as the fixed tagged
forms #6.21(any), #6.22(any), and #6.23(any). A use of one emits a nominal wrapper such as
PreludeEb64url around AnyCbor: native Rust constructs it with PreludeEb64url::new(inner) (or
From<AnyCbor>) and reads its payload with get(). Its encoder always writes, and its decoder
requires, that wrapper's declared tag. Default mode treats the arbitrary payload like any other
AnyCbor; preserve mode also retains the payload and tag encodings it decoded.
The tags are advice for rendering a CBOR item, not a request for this crate to choose a base64 or
base16 text syntax. No base64/base16 string API is invented: callers retain the opaque AnyCbor
payload, and the declared advice remains on the CBOR wire. cbor-any is intentionally different and
remains unsupported because its self-describe tag marks a whole serialized CBOR stream rather than an
ordinary value.
Under JSON, these wrappers use the same natural any adapter as an authored #6.21(any) wrapper:
the CBOR-only tag is omitted and the contained item is rendered naturally (or fails where natural
any rendering cannot represent it). The standalone AnyCbor tagged JSON value codec remains
available as the escape hatch described below.
JSON representation (--json-serde-derives / --json-schema-export)
There are two JSON surfaces for any, and which one a value uses depends on where it sits:
- Natural JSON — the primary surface, used everywhere a generated type contains an
any. A member ({1: any}), a homogeneous-array element ([* any]), a{* K => any}table range (with a stringifiable key), 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 and follow ≥1 fixed member. Its occurrence is row-local:
*/0* use a loose empty-capable carrier, +/1* use NonEmptyMap (or the preserve pair-map
twin), and every other window uses BoundedMap/BoundedPairMap; fixed fields never count toward
that window. Restricted rows are explicit checked-carrier inputs to new(), while CBOR and JSON
stage loose entries then cross one TryFrom door. A non-final rest row, a rest row in a group-choice
arm or a plain group remains a graceful rejection. The key domain is a general 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 otherwise
unrestricted (including any).
A third shape is rejected on either slot, because it is a property of the map entry rather than of
key dispatch: a plain group (kv = (a: uint, b: uint), t = { c: uint, * kv => uint } or
t = { c: uint, * uint => kv }). A CBOR map entry holds exactly one item in each of its two slots and
a keyless group has no single-item form — it splices its members flat, contradicting the map's own
entry count — so the row has no wire. The guard reads the resolved slot type, so the bare and alias
spellings refuse alike, and both slots are reported when both offend. A tagged group instead reaches the
separate tag-payload boundary: #6.10(kv) is invalid because a tag wraps one TYPE, so frame the group
first. The remedy the message names is the group's array framing, which gives the slot the one item it
needs: t = { c: uint, * [kv] => uint } / t = { c: uint, * uint => [kv] }; a tag belongs outside
that framing, on the framed reference (#6.10([kv]), not [#6.10(kv)]). The prefix-less spelling
(t = { * kv => uint }) is the pure table shape and keeps its own message for the same reason.
Which path a domain takes is not a preference but a fidelity rule: the fast path can only rebuild
a key the dispatch already told it everything about, so anything the dispatch does not see keeps its
own codec. A domain carrying an encoding operation (* #6.24(uint) => v, * bytes .cbor uint => v) or a @custom_serialize/@custom_deserialize
pair therefore reads through that codec exactly as it writes through it — the row's two halves are the
same codec by construction, so what it emits is what it accepts.
Capture is typed, so it refines rather than tolerates. An unknown entry whose key or value bytes
K or V refuse fails the parse; there is no untyped bucket to fall into and no
capture-on-failure fallback. * uint .size 1 => uint rejects a key outside the u8 range, and a
union domain rejects a key matching no arm — the same posture typed table keys and typed values take
everywhere else. A declared key always peels first, so a second occurrence of one is a
DuplicateKey error and never a capture. Tolerating unknown entries without storing them is the
separate @ignore flavor below, which still requires each entry to
deserialize.
The rest field. Ordinarily this is a pub map named rest (rename with @name on the row — see
the comment DSL), keyed by K, valued V.
A loose row is excluded from new() and defaults empty, so adding it is source- and
wire-compatible. A restricted row instead is a checked new() parameter, so callers cannot construct
an out-of-window map. Its carrier matches the table switch and the @duplicates policy:
| mode | container |
|---|---|
| loose default | BTreeMap<K, V> (OrderedHashMap<K, V> under --preserve-encodings) |
loose @duplicates preserve | PairMap<K, V> (the byte-exact duplicate-keyed twin) |
+ / 1* default or preserve | NonEmptyMap<K, V> / NonEmptyPairMap<K, V> |
| every other restricted window default or preserve | BoundedMap<K, V, MIN, MAX> / BoundedPairMap<K, V, MIN, MAX> |
When an open record's captured key domain can produce a declared or exact-zero fixed CBOR key, its
carrier is private even for a loose row: rest() lends it immutably and insert_rest validates each
key before changing the carrier. Statically disjoint bare primitive domains retain their public
carrier; typed, encoded, and custom-codec domains are checked conservatively because their emitted
wire major can differ from their Rust representation. Declared-key collisions report DuplicateKey;
exact-zero keys retain ForbiddenKey. Open tables have no fixed keys, so their dynamic rows remain
unaffected. An exact-zero fixed member
(0*0 key: value or *0 key: value) is a different constraint: it emits no value field or schema
property, and its existing complete-rest fallible constructor remains the construction door.
Serialize / round-trip. The map header counts the declared members plus every rest entry
(N + rest.len()); declared members serialize first, then the captured entries. The default
@duplicates reject policy refuses a duplicate captured key (value-equality — a DuplicateKey decode
error); @duplicates preserve keeps duplicates via the PairMap twin.
Captured keys CBOR-value-equal to declared keys are rejected before mutation or serialization, even
when their Rust representation, preserved width, or JSON spelling differs. @duplicates preserve
still retains duplicates among rest entries, but never a declared/rest collision.
Preserve / canonical. Under --preserve-encodings the original wire order (declared and
captured entries interleaved) is recorded and replayed byte-exact, and each captured entry's key/value
header widths are preserved — via per-entry encoding sidecars for concrete domains, or self-carried by
AnyCbor for any content. Under --canonical-form the declared keys and the captured rest keys are
merged and ordered together length-first-then-bytewise (a rest key can sort before a declared key),
and each captured key's header is minimized.
wasm. The rest field is exposed as a read-only rest() getter returning a minted map-wrapper
snapshot: mutating that returned wrapper never mutates the record. The record's parent mutation door
is insert_rest(key, value) -> Result<(), JsError> (or insert_<name> when the row has @name).
It replaces equal keys for the ordinary carrier and appends for @duplicates preserve; a bounded
carrier returns JsError before an overflow can change the record. A loose row has no new()
argument and defaults empty; a restricted row takes its complete checked wrapper at the fallible
constructor boundary. Exact-zero records instead take the complete wrapper at their fallible
constructor boundary even for a loose row, and their wasm insertion delegates to the native
forbidden-key check. There is no general setter; see wasm
differences.
JSON (--json-serde-derives). Captured entries render flattened at the same JSON
object level as the declared members (via serde flatten), under the natural-JSON key rules above:
- Write (
to_json) errors if a rest key stringifies to a declared member's JSON name, if two rest keys stringify identically, or if a rest key/value falls in the natural failure set (a 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.
@ignore is deliberately limited to loose * / 0* rows. Dropping a restricted row would
re-serialize zero entries: that violates a positive minimum, while a zero-minimum restricted window
would lose the bounded or exact state its checked carrier retains.
Because the type is a closed struct, its JSON and schemars surfaces are an ordinary closed
struct's — the plain derive, no flatten, no additionalProperties/patternProperties open-map
schema. Unknown JSON keys are tolerated on read (serde's default ignore-unknown-fields behavior, the
JSON-side mirror of the CBOR looseness) and dropped, matching the CBOR contract. On wasm there is
no rest() getter (nothing is stored); the class is a closed struct's, and the read-tolerance comes
for free through the rust deserializer.
Open tables (a typed row plus a catch-all)
A named rule of exactly two * k => v rows and nothing else — t = { * K_t => V_t, * K_r => V_r } —
is an open table: one typed table row plus one trailing typed catch-all, routed by wire major
type. The CDDL-side contract (what makes K_t's major statically knowable, and the shapes that are
graceful rejections) lives in the comment DSL; what follows is the emitted
face. It lowers to a struct with two pub containers — entries (the typed row) and rest (the
catch-all), each @name-renameable, each with its own @duplicates policy, container spelling and
encoding sidecars. The loose * form has no new() arguments and defaults both containers empty.
Everything the rest-row sections above say about a container's spelling, duplicate policy and
preserve/canonical replay applies to each row; what is new is that two rows share one map, which
is where the JSON face earns its own rules.
The two regions share one order vector
Under --preserve-encodings a struct records the wire order it read in encodings.orig_deser_order,
and an open table's entries come from two dynamic sequences rather than one. The slots are
therefore tagged by source sequence rather than laid out in one flat index space: the typed row's
i-th entry is slot 2*i, the catch-all's j-th is slot 2*j+1. Reading a slot means reading its
low bit for the row and its half for the position within that row. (A flat N + i scheme cannot work
here: the second sequence's base would be a runtime length that is not yet final while the loop is
still pushing.) The serialize replay matches on field_index % 2, and under --canonical-form the
key merge builds one order across both containers by comparing encoded key bytes — so a canonical
write interleaves the two regions freely.
The NonEmpty twin
Spelling the typed row +/1* (t = { + K_t => V_t, * K_r => V_r }) adds a min-1 bound counting
typed entries. Its public entries field is the actual restricted carrier — NonEmptyMap<K_t, V_t>, or NonEmptyPairMap<K_t, V_t> under @duplicates preserve — so clearing or removing its final
entry is not representable. new takes the first typed entry — new(first_key, first_value) enters
that carrier's new door directly — and the wasm and WIT constructors project those two parameters
likewise; the catch-all still defaults empty. CBOR decode stages in the corresponding loose map and
then calls the carrier's TryFrom, preserving duplicate/order behavior while raising its shared
DeserializeFailure::RangeCheck { found: 0, min: Some(1), max: None }; the JSON visitor stages then
builds through new for the same reason. JSON Schema publishes no expression of 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.
Bounded rows
Every other row window (?, 0*n, n*m, *n, n*, and omitted exact-once) is a checked
BoundedMap/BoundedPairMap for that row alone. A bounded typed row remains flattened on the
owner's API: every bounded window takes a loose builder which converts fallibly before native new,
and its finite-max insert returns the carrier's checked result. A bounded catch-all is a read-only
restricted map wrapper passed whole to new. Component constructors take fallible list<tuple<K, V>>
inputs and re-enter the carrier. CBOR/JSON both stage each partition separately. JSON Schema
deliberately has no minProperties/maxProperties: either would count both rows (and an open
struct's fixed fields), which is not the CDDL constraint.
JSON (--json-serde-derives): one flattened object holding BOTH regions, written by a
hand-written Serialize/Deserialize pair rather than the derives. Two #[serde(flatten)]
members cannot express this shape in either direction — on read serde hands every unmatched member to
both flattened fields (nothing partitions them), and on write both fields forward into one map with
no dedup (so two regions imaging one name emit it twice). The partition and the cross-region collision
check are the whole point, so one emitted impl owns both regions.
Two regions, two key images
The typed row images its keys through K_t's own serde string image — the closed-table
convention, i.e. what serde_json's map-key serializer makes of K_t::serialize. The catch-all keeps
the rest-row image over K_r's own CBOR bytes. That the two differ
is forced, not an inconsistency: a bytes-keyed typed row has no CBOR member-name image at all, so a
CBOR image would make to_json fail on exactly the shape an open table exists for, while a key type
whose serde writes a hex string images to a perfectly ordinary member name.
Two consequences worth knowing:
- Two wire spellings of one rust type image identically. A key alias whose
@custom_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 one occurrence-bearing element — a final tail such as
[uint, tstr, * uint], or a safe leading/middle segment such as [* bytes, tstr] or
[uint, * bytes, tstr] — is an open array: fixed members deserialize positionally as declared and
the repeated elements are captured in a generated rest field. This is the array analog of the
open struct-map rest row — structurally simpler because arrays are
positional: there are no keys, duplicate policy, key dispatch, or ordering machinery. The occurrence
may be final, or it may be the sole leading/middle segment only when its immediate fixed suffix is
mandatory and expands to exactly one CBOR item. A variable-cardinality middle window also requires
either the absence of a field-local custom codec, an effective wire head on both boundary items, and
no CBOR major type in common with the repeated element; or untagged generator-owned finite fixed-value
domains on both sides with no CDDL value in common. A generator-proven head qualifies; a transparent
custom-codec alias may instead declare the head with
@custom_wire_major.
An exact window (N*N, except 1*1 which is an ordinary field)
instead stops after N items: [uint, 2*2 uint, uint] is therefore supported even though the segment
and suffix share a major or use custom-/extern-owned heads. Count delimits that suffix only: an
optional prefix still needs generator-proven, distinct heads on both sides of its peek boundary. A
custom-codec or opaque-extern optional/repeated member at that boundary is serialize-only unless a
mandatory outer tag or .cbor frame proves the distinct outer head. Bare * / 0* is loose; + / 1* is the
compatibility min-one form; every other window (2*3, *3, 2*, *0, 0*0, …) is bounded.
The restriction is RFC 8610's greedy, non-backtracking matching rule, not an implementation
convenience. A decoder for [uint, * uint, uint] cannot know whether a uint is the repeated value
or the suffix; accepting it would let a constructed value serialize to bytes that do not deserialize
back to that value. A normal variable safe-middle loop greedily consumes only the repeated element's
majors and stops at the major-disjoint suffix; a fixed-domain loop instead tries the repeated decoder
once and rewinds only when it fails, which is safe because the statically enumerated domains are
disjoint. An exact loop consumes its declared count and then reads that same suffix. None speculates
by parsing the suffix to choose between successful interpretations. General same-major/value-
discriminator designs, multiple occurrences, inline-group occurrences,
group-choice-arm occurrences, a segment inside a plain group's body, a fixed-value element type like
* 5, and a plain-group element type remain graceful rejections naming a supported remedy. A
declared custom head still has to be disjoint from the other effective boundary set; custom-, tagged-,
float-, primitive-, and extern-owned same-major value discrimination is not inferred. The declaration is not consumed by a final tail, an exact
count-delimited window, optional-prefix lookahead, or a mandatory generated outer frame that already
supplies the head. The
plain-group element type (kv = (a: uint, b: uint), t = [ c: uint, * kv ]) refuses because an
occurrence segment collects one value per repeated array element and a plain group is not one — it has
no type of its own, splicing its members flat instead — and the guard reads the resolved element
type, so the bare and alias (* kv_alias) spellings refuse alike. A tag payload is a type, so
* #6.10(kv) reaches the separate plain-group tag-payload refusal; frame first, then tag (* #6.10(w)
where w = [kv]). The remedy the message names is the group's array framing: w = [kv], then
t = [ c: uint, * w ]. A single mandatory splice (t = [ c: uint, kv ]) stays supported, but
the sole-element homogeneous array ([* kv]) also refuses: repeating a group concatenates its members,
whereas a homogeneous collection element is one item. Use w = [kv], then [* w]; the full
splice-position matrix is Plain group placement.
The rest field. A loose * / 0* segment is pub Vec<T>, excluded from new() and defaulting
empty. A + / 1* segment is pub NonEmptyVec<T>: new() takes its first repeated element,
preserving that public ABI. An ordinary or duplicate-preserving exact N*N segment is pub [T; N]
(including *0); new() accepts that already-valid carrier. Other variable windows are
pub BoundedVec<T, MIN, MAX>. Decode stages one loose Vec<T> and exact windows make one
TryFrom<Vec<T>> for [T; N] handover, mapping a short/long wire value to RangeCheck; variable
windows retain the BoundedVec door. Exact @duplicates reject segments remain
BoundedOrderedSet<T, N, N>. A middle segment stops
greedily at its maximum: a further repeated-major item remains for the mandatory suffix to read and
is rejected there, while a below-minimum middle segment still rejects through the carrier's
RangeCheck. An exact middle segment takes exactly its declared count before the suffix. A definite
short owner can reserve its mandatory suffix arity and reach the checked-carrier door; an indefinite
same-major short owner may consume its intended suffix as a repeated value and then reject at the
suffix read. Every short form rejects without seeking, rewinding, or promising one error class, and a
trailing extra remains for the owner length check rather than being absorbed by the segment.
An optional fixed member is read by peeking at its reachable following member or occurrence segment.
That dispatch is emitted only when both wire heads are generator-proven and CBOR-major-disjoint; a
custom codec or opaque extern on either side is serialize-only unless mandatory outer tag/.cbor
framing supplies the proof. @custom_wire_major does not broaden this optional-prefix rule. A final
optional with no following item remains owner-length-delimited.
Serialize / round-trip. The array header counts the declared members plus every repeated
element (N + rest.len()). A final tail serializes after the fixed members; a safe middle segment
serializes immediately before its mandatory suffix. The captured elements themselves serialize in
Vec order. Typing is enforced: [uint, * bytes, tstr] errors on a non-bytes element in the
repeated segment (full looseness is spelled * any, which captures nested-container elements too).
Preserve / canonical. Under --preserve-encodings each captured element re-serializes byte-exact
via a positional per-element encoding sidecar ({field}_elem_encodings: Vec<..> in the owner's
encoding struct — self-healing on user mutation: a length mismatch degrades to default encodings, it
never panics); an any element is self-carried and contributes no sidecar. The segment's local wire
order = Vec order = re-emit order by construction, so there is no interleave record or
comparator. Under --canonical-form each element re-serializes recursively-canonically in position
order (no sort — the segment is positional, not keyed).
JSON (--json-serde-derives). The captured segment renders as an ordinary field — a JSON
array under the field's name ("rest": [ … ]), not flattened. Array positions are already erased
in JSON and serde flatten has no array analog, so nesting under the field name is the honest
symmetric rendering. Only the loose segment writes by skipping the field when empty and reads
by defaulting it to empty when absent — so an empty loose segment ≡ closed-struct JSON. Restricted
segments are required: + / 1* publishes minItems: 1; bounded segments publish their applicable
minItems and maxItems, including zero; all enter their same checked door on JSON read. An
any-element segment renders natural-fallible (the same
injective-subset rules as any
[* any]: a non-injective node like a byte string makes to_json error rather than silently
substitute). The JSON Schema is the ordinary array shape with the carrier's bounds.
wasm. The rest field is exposed as a read-only getter named after the field (rest() by
default) returning the minted list wrapper. The loose form has no constructor argument; the min-one
form retains its first-element constructor; each bounded form takes its matching checked bounded list
wrapper. WIT despecializes that wrapper to list<T> and component glue fallsibly converts it back to
its established list wrapper before native construction; component WIT remains list<T> and its
guest glue restores [T; N] fallibly. The public native carrier does not depend on whether the
segment is final or safe-middle; see wasm differences.
The @ignore (tolerate-and-drop) flavor
Marking the occurrence entry @ignore switches from capture to
tolerate-and-drop: the repeated elements are still typed-deserialized (so the stream advances past
nested containers) but then discarded. The generated type is a plain closed struct — no
rest field, no new() change, and serialize emits only the declared fixed members. This is
deliberately lossy: byte round-trips do not hold for wire data that carried ignored elements (the
type and its serialize fn carry a position-accurate rustdoc breadcrumb), which is why it is rejected under
--preserve-encodings (a preserve crate's byte-exact contract cannot hold for it) along with
@duplicates/@name on the same entry. Its JSON, schemars, and wasm surfaces are an ordinary
closed struct's — safe-middle or trailing elements are tolerated on read and dropped, and there is
no rest() getter.
@ignore is deliberately limited to loose * / 0* final or safe-middle segments. Dropping a
restricted segment would
re-serialize zero elements: that violates a positive minimum, while a zero-minimum restricted window
would lose the bounded or exact state its checked carrier retains.
Non-empty containers
An occurrence lower bound of exactly one — [+ T] and the equivalent [1* T] on arrays, {+ k => v}
/ {1* k => v} on tables — is enforced by the type, not by a bypassable check inside new(). The
field type becomes NonEmptyVec<T> (or NonEmptyMap<K, V>), hand-written generics copied into the
generated crate, so an empty value is simply not representable. A named rule aliases straight to it:
deltas = [+ int] emits pub type Deltas = NonEmptyVec<i64>, giving the rule a real enforcement
surface it never had standalone. Every other supported ordinary or @duplicates preserve
homogeneous ARRAY window except bare * is likewise type-enforced: exact [2*2 T] (and [*0 T])
emits [T; N], while [2*5 T], [*5 T], [2* T], and [? T] emit
BoundedVec<T, MIN, MAX> (u64::MAX is the target-independent unbounded carrier). Bare [* T] remains Vec<T>.
A bounded @duplicates reject array uses BoundedOrderedSet<T, MIN, MAX>. Its one
TryFrom<Vec<T>> door preserves original order while refusing duplicate input (with the existing
duplicate index) and values outside the inclusive window; checked push/pop/remove preserve both
invariants. JSON/schema, CBOR, wasm, component, and requested-wrapper input all re-enter that door.
Each non-empty type provides one conversion contract:
TryFrom<Vec<T>>(resp.TryFrom<BTreeMap<K, V>>/TryFrom<OrderedHashMap<K, V>>) — the single checked door. It is the only way to build the value from a loose collection, and it returns 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.
BoundedVec has the same loose-to-tight TryFrom<Vec<T>> and widening From/into_inner contract.
It exposes slices and mutable elements but never &mut Vec; push, extend, pop, remove, and
truncate return the existing RangeCheck before crossing either bound. JSON is an ordinary array
and schemars publishes minItems/maxItems. The wasm class is named <Elem>ListMinN,
<Elem>ListMaxN, or <Elem>ListMinNMaxN (a named CDDL rule wins); its try_from/add doors are
fallible for the same reason, and only a zero-minimum class exposes an infallible empty new() seed.
Positive-minimum instantiations have no new method at all. Tables remain outside this representation and retain their documented
occurrence limits. Unique-key tables now have the matching BoundedMap<K, V, MIN, MAX> carrier:
{ k => v } is BoundedMap<K, V, 1, 1>, { ? k => v } is BoundedMap<K, V, 0, 1>, and other
finite/lower-bounded windows retain their inclusive endpoints (* stays loose and + stays
NonEmptyMap). Its TryFrom<BTreeMap<_, _>>/TryFrom<Vec<(K, V)>> door, checked insert/extend/
remove, and read/value-mutation-only API have the same no-invalid-state rule; JSON schemas publish
minProperties/maxProperties. A bounded @duplicates preserve table instead uses
BoundedPairMap<K, V, MIN, MAX>: its checked door counts entries (including duplicate keys), and
its array-of-pairs schema publishes minItems/maxItems, never object properties.
Because the invalid state is unrepresentable, a struct whose only rejectable constraint is an
occurrence lower bound gets an infallible new(): every argument is already valid by
construction, and serialization stays infallible (there is no empty value to fail on). Construction
never checks a length — it hands over an already-checked value.
Migrating a consumer (Vec<T> field → NonEmptyVec<T> field). The break is mechanical: the
fallible new(items: Vec<T>) -> Result<_, _> becomes an infallible new(items: NonEmptyVec<T>), and
the length check moves to a single try_into at the point the loose collection is built.
Before (bypassable new check) | After (two-type) |
|---|---|
Foo::new(tags: Vec<Bar>) -> Result<Foo, DeserializeError> | Foo::new(tags: NonEmptyVec<Bar>) -> Foo |
let foo = Foo::new(tags)?; (checks length, but a later foo.tags.clear() re-breaks it) | let tags: NonEmptyVec<Bar> = tags.try_into()?; let foo = Foo::new(tags); |
empty vs. absent modelled as Option<Foo> | empty Vec<Bar> is representable — it just isn't a NonEmptyVec yet; Option returns to meaning "the field itself is optional" |
Migrating a bounded window (Vec<T> field → BoundedVec<T, MIN, MAX> field) follows the same
two-type boundary. The constructor becomes infallible when it receives the already-checked bounded
value; call try_into() once where a loose vector is assembled. A zero-minimum BoundedVec can be
seeded with BoundedVec::new(); a positive-minimum value must enter through TryFrom<Vec<T>>.
Before (bypassable new check) | After (two-type bounded window) |
|---|---|
Foo::new(tags: Vec<Bar>) -> Result<Foo, DeserializeError> | Foo::new(tags: BoundedVec<Bar, 2, 5>) -> Foo |
let foo = Foo::new(tags)?; | let tags: BoundedVec<Bar, 2, 5> = tags.try_into()?; let foo = Foo::new(tags); |
tags.push(bar) can silently exceed the window | tags.push(bar)? refuses the first out-of-window append before mutation |
Migrating an exact window (BoundedVec<T, N, N> field → [T; N]) is deliberately a native API
break: constructors and setters now take [T; N] and are infallible for cardinality. When a caller
has a loose vector, convert it once at the assembly boundary:
let tags: [Bar; N] = tags.try_into()?; let foo = Foo::new(tags);. CBOR, wasm list wrappers, and
component list<T> remain list-shaped and make the equivalent checked handover themselves. JSON
does so recursively for every ordinary, @duplicates preserve, or @duplicates reject loose,
nonempty, bounded, nullable, and exact collection tree that contains a wide exact array or an exact natural-any position,
including aliases/newtypes, ordinary optional fields, type-choice payloads, and captured open-array
segments. Each node remains a JSON list;
exact layers retain minItems = maxItems, restricted sequence layers re-enter their existing
NonEmptyVec, BoundedVec, NonEmptyOrderedSet, or BoundedOrderedSet TryFrom<Vec<_>> door,
and duplicate-reject schemas additionally publish uniqueItems: true.
The established typed direct Vec<[T; N]> case remains compatible. Open-array segments retain their
named field shape; loose segments keep skip_serializing_if = "Vec::is_empty" plus default, while
restricted segments are required. Map/table entries, open-struct map rest rows, and dynamic map rows
still reject under either JSON flag. Optional+nullable three-state fields compose their outer presence
option with the recursive descriptor for the inner nullable member, including short natural-any
exact arrays. This is separate from bytes .size N, whose [u8; N] storage retains a
loose Vec<u8> constructor for compatibility.
The type name, the generated doc comment (which quotes the originating CDDL), and the TryFrom
signature are three redundant signals that a constraint exists. The wasm boundary mirrors this with a
two-wrapper pattern — see Wasm Differences.
Reject-duplicates containers (uniqueness twins)
A @duplicates reject directive on a plain (non-tag-258) array-shaped collection
rule — [* T] / [+ T] — swaps the transparent-alias target from the bare Vec/NonEmptyVec to a
uniqueness twin that makes a duplicate element unrepresentable, the same enforcement-by-type move
the non-empty containers make for the length bound:
[* T]→OrderedSet<T>(signers = [* key] ; @duplicates rejectemitspub type Signers = OrderedSet<Key>).[+ T]→NonEmptyOrderedSet<T>, whose door composes BOTH invariants (min-1 and uniqueness).- Every other bounded window →
BoundedOrderedSet<T, MIN, MAX>, whose checked door composes uniqueness with its inclusive occurrence window. It exposes reads and checkedpush/pop/remove, but no normalizinginsert/Extend/FromIterator: an over-maximum append is an error, never a no-op.
A tag-258 set (which defaults to reject) uses the SAME twins for its inner collection, but wraps
them in a nominal struct that owns its encodings rather than aliasing to them transparently — see
Nominal tag-258 set types above. The twin conversion contract below
describes the inner collection either way.
The default is preserve — today's occurrence-selected Vec / NonEmptyVec /
BoundedVec<T, MIN, MAX>, which accept and re-emit duplicates byte-exactly for multi-era readers;
writing @duplicates preserve explicitly is an accepted, self-documenting no-op. Both twins are
order-preserving and NEVER sorted: reject only narrows
which inputs are accepted, it never touches the bytes of what is accepted, so a decoded set
re-emits byte-exactly in its original wire order.
Each twin provides one conversion contract, parallel to the non-empty one:
TryFrom<Vec<T>>— the single checked door. The only way to build the value from a 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.- Every other homogeneous occurrence window (
?, exact-one,N*M,*M, orN*) →BoundedPairMap<K, V, MIN, MAX>. Its checkedTryFrom<Vec<_>>/TryFrom<PairMap<_, _>>door enforces the inclusive entry count; duplicate keys still count separately.
A table rule that owns an encoding operation — a tagged body
(#6.n({* k => v}), the optional-tag idiom included) or a @newtype rule — mints a wrapper
struct whose stored inner is the same twin (pub struct TaggedMeta(pub(crate) PairMap<u64, Vec<u8>>)), so the policy selects the representation while the wrapper's codec owns the tag.
An anonymous inline table in type position (a union arm, a field or element type) carries the
directive on its own row instead — { * k => v ; @duplicates preserve with the closing brace on the
next line — and gets the same twin at that one use site (the union arm's payload becomes
PairMap<K, V>). See @duplicates on an inline table's row.
The driver is a duplicate-keyed map that must round-trip byte-exactly — pre-Conway Cardano
transaction_metadata, whose auxiliary-data hash is computed over the ORIGINAL bytes, so a reader that
collapses or reorders duplicate keys fails hash verification. A BTreeMap cannot do this; the
vec-of-pairs is the only faithful shape.
Unlike the ordered-set twins, a PairMap has no invariant — any vec of pairs is valid — so its
conversion contract is unguarded:
- Construction imports the concrete twin you need. Generated/hand-written code imports
PairMap,NonEmptyPairMap, andBoundedPairMapfrom the sharedpair_maphelper; the loose flavor has freeFrom<Vec<(K, V)>>/into_inner() -> Vec<(K, V)>conversions because there is nothing to reject.NonEmptyPairMapandBoundedPairMapinstead expose the fallibleTryFrom<Vec<(K, V)>>andTryFrom<PairMap<K, V>>doors, returning the sameRangeCheckcontract as the other non-empty/bounded twins. 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. Bounded pair maps add
minItems/maxItems to that pair sequence, and the {+} flavor's door refuses an empty [] on the
JSON path with the same min-1 error as the wire path.
The wasm boundary mirrors the set twins' two-wrapper pattern, except the loose PairMap's insert
appends (returning Option) rather than rejecting. The synthesized wasm class name carries the
container flavor — PairMap<K>To<V>, NonEmptyPairMap<K>To<V>, and bounded
PairMap<K>To<V>MaxN / …MinN / …MinNMaxM, against the default flavor's
Map<K>To<V> / NonEmptyMap<K>To<V> — so a preserve and a non-preserve map of the identical
key/value are two distinct classes. The K and V fragments are themselves recursive boundary
identities, so occurrence restrictions and duplicate flavor inside nested arrays or maps also select
distinct classes and matching native carriers. See
Wasm Differences.
Generated crate roots (thin root, seed-once)
Every generated crate — rust/, and (when enabled) wasm/, wasm/json-gen/ and component/ — splits into two
halves so your hand edits to the crate root survive regeneration:
-
Tool-owned:
src/generated/**(clobbered every run). All generated code lives here —generated/mod.rs(the type definitions + module decls + generator crate attrs like#![allow(clippy::too_many_arguments, clippy::large_enum_variant, clippy::result_large_err)]as inner attrs — the latter two silence lints intrinsic to the emitted shape: CDDL choices become enums with wildly asymmetric variant sizes, and fallible APIs return 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 analogous glue, but only for wrappers its generated boundary actually names.
Its generated code names an extern's hand-written #[wasm_bindgen] wrapper (e.g. pub fn ext(&self) -> MyExt) by bare ident inside wasm/src/generated/**, invisible there for the same reason. For each such
reachable wrapper the tool emits pub use crate::MyExt; into the wasm declaring scope's generated module:
define that wrapper in a hand-written wasm-crate module and re-export it at the wasm crate root (pub mod utils; pub use utils::MyExt; in wasm/src/lib.rs, with the wrapper supplying the From/AsRef
conversions the generated boundary code calls). A marker that the wasm face never names has no wasm glue
and needs no wasm root re-export. (Types imported from a dependency crate — either marker — are
unaffected by this glue; they resolve through the dependency's import path.)
The Rust behavior is intentionally broader: its generated aliases and codec paths retain the native marker names they use, so Rust emits the corresponding own-spec marker/flavor glue and lists those names as required. The wasm required-name output is instead exactly its live glue set.
Because the root is user-owned, the tool never re-clobbers this wiring — the failure mode where a
regeneration dropped a hand-added pub mod utils; and broke every reference to the extern type is
gone by construction.
The tool tells you the exact re-export set to maintain. Since the required names are known at
generation time, they are surfaced three ways so a stale hand-written root never leaves you staring
at a bare unresolved-import error: (1) every run prints the per-crate required crate-root re-export
list (rust and wasm) on stdout — it is run output rather than a diagnostic, so it survives every
verbosity except error; (2) the generated glue in each declaring scope's
mod.rs carries a short // contract comment restating this requirement; and (3) when a
seed-once root already exists but is missing a required re-export (for instance after a spec change
added a new extern), the
run emits a warning: on stderr naming the missing idents and the exact pub use <your_module>::<Name>;
fix. The warning is diagnostic-only — it reads the existing root but changes no generated bytes, and
never fires on the first run that seeds the root. If your root re-exports through a non-generated
glob (pub use utils::*;) the name scan can't see through, the per-name warning is suppressed rather
than raised as a false positive. It is also suppressed for any name whose glue line you deliberately
deleted from the generated mod.rs via a cddl-codegen:replace block: the
warning is decided from this run's written output, so once the pub use crate::<Name>; glue no
longer survives there the tool no longer requires that re-export and stops nagging for it.
A generic argument is the important asymmetry: for ext_set<pub_key>, Rust's
ExtSetRawBytes<PubKey> alias names PubKey, while the wasm face names only its concrete
ExtSetPubKey wrapper. The wasm glue and its required-name output therefore ask for ExtSetPubKey,
not a dead PubKey wrapper; a direct field, alias, list, or map use of PubKey still makes the wasm
walk emit its glue and require the wrapper.
Per-scope hand modules: the facade pattern
To publish hand-written code under a scope's public path (e.g. my_crate::assets::utils::…
merged with the generated my_crate::assets::… namespace), do not place hand files inside
src/generated/<scope>/ — that subtree is clobbered on every regeneration, and its machine-owned
mod.rs would drop your pub mod utils; line. Instead declare a facade module in the
user-owned thin root:
// src/lib.rs (user-owned)
mod generated;
pub use generated::*;
pub mod assets { // shadows the glob-imported generated `assets` module
pub use crate::generated::assets::*; // generated items, incl. `pub mod serialization` etc.
pub mod utils; // your hand file, at src/assets/utils.rs
pub use utils::*; // per-scope glob policy — your call, per scope
}
An explicit item shadows a glob import, so my_crate::assets resolves to your facade while
pub use generated::*; keeps re-exporting every other scope untouched — this is the same
item-beats-glob rule the extern glue above already relies on. Hand files live at
src/<scope>/*.rs, entirely outside the machine-owned tree, so rm -rf src/generated && regen
remains a valid clean rebuild and generated file paths (and any
preserved comment/replace blocks in them) never move. Scopes
without hand files need no facade. Impl blocks on generated types compile from any module in the
crate, so a facade is only needed when hand items must appear under the scope's path.
Generated wrapper field visibility
Named integer and byte/text value windows (limited = uint .le 10, digest = bytes .size 32)
are the invariant-owning exception: their native backing field is private, including to hand
modules in the same crate. Construct them with Limited::try_from(value) /
Digest::try_from(bytes) and read them through get(). CBOR, JSON, wasm, and component decoding
all cross that same door, so no public or codec path can materialize an invalid value. Exact bytes
still store [u8; N] behind the loose Vec<u8> TryFrom input. This is an intentional API break
from the former public new() and pub(crate) field.
Other newtype wrappers (bare tags and unconstrained @newtype rules) and the wasm
crate's pub struct X(rust::X) handles back their inner value with a pub(crate) field (named
inner under --preserve-encodings=true, an anonymous tuple field otherwise). Hand modules can
therefore augment those ordinary wrappers through the carrier, and wasm hand modules can reach the
already-validated native value via self.0. wasm_bindgen ignores non-pub fields, so the wasm ABI
is unchanged.
This applies uniformly to the wasm collection wrappers too: the plain list wrappers (FooList),
the structural map wrappers (MapKToV) and named map/table rules, and the NonEmpty* list/map
wrappers all back their inner Vec/BTreeMap/OrderedHashMap/NonEmptyVec/BoundedVec/NonEmptyMap with a
pub(crate) tuple field, so hand augmentation reaches self.0 on any of them. The one exception is
--wasm-list-macro: those list wrappers are defined by a consumer-supplied macro, so their field
visibility is the macro's choice, not the generator's — give that macro a pub(crate) field if its
wrappers are hand-augmented.
When any of the ordinary wrappers' pub(crate) tuple-field line would exceed rustfmt's default
max_width (100 columns) — long generic map/list types with fully-qualified paths in a wasm
wrapper, or an unconstrained rust-crate newtype (default profile, where the field is a tuple rather
than the named inner) over a long-named type — the wrapper is emitted with a
#[rustfmt::skip] attribute and a two-line citation comment, and its field is laid out in the
canonical two-line tuple shape by the generator itself. This works around
rust-lang/rustfmt#5703, where rustfmt breaks the
line right after the field visibility, leaves trailing whitespace, and exits with an internal error
(which the generator treats as fatal). The skip freezes the canonical layout rustfmt would produce
absent the bug, so it is removable — as a pure-formatting no-op — once the upstream fix (PR #5708)
ships and reaches consumers.
Migrating from pre-split layouts
Crates generated before the thin-root split have a monolithic src/lib.rs that carries the
generated type definitions and module decls (pub mod serialization;, …) inline. Because the tool now
seed-once preserves any existing src/lib.rs, it will not migrate that root for you (auto-migrating
would mean reading and rewriting your file — the prior-output dependence the tool forbids). Instead the
stale root breaks loudly at compile time: the generated types reappear under src/generated/** and
the old pub mod serialization; decls now point at files the split relocated (an E0583 unresolved-
module error), and any inline type copies collide with the regenerated ones.
The tool prints a one-time stderr warning when it sees a root without mod generated;. The one-time fix
(per crate root — rust/, wasm/, wasm/json-gen/):
- 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, [2*5 foo] → FooListMin2Max5, {* k => v} → MapKToV, and their restricted variants) must live in
exactly one crate. Two crates that each mint the same #[wasm_bindgen] FooList export one JS
class name twice and fail to link a single wasm cdylib (duplicate symbol: __wbg_foolist_free). The
--workspace-dep (consumer side) and --wrapper-requests (dependency side) flags place every
all-one-dep wrapper in the owning dependency, so every consumer imports one shared definition. Two
generated files carry this, both under the wasm crate's tool-owned src/generated/** (clobbered
every run like the rest of that subtree):
borrowed_collections.rs — the consumer's sidecar
Emitted whenever --workspace-dep is set (empty-but-present when nothing is borrowed — stable
presence, stable diffs). It is the mirror of the collections.rs wrapper index — "what I borrow, from whom" against
that file's "what I provide" — and its format is frozen, because the owning dependency machine-reads
it:
// This file records every collection wrapper this crate borrows from workspace deps.
// It is machine-read by those deps' generation runs (--wrapper-requests) and compiled
// here, so a wrapper a dep stops providing fails THIS crate's build, naming the type.
// Rows are (dep rust-crate name, wrapper name, shape in CDDL syntax with the dep's idents).
#[allow(unused_imports)]
mod borrowed {
use cml_dep_wasm::collections::FooList;
use cml_dep_wasm::collections::MapKToV;
}
#[allow(dead_code)]
pub(crate) const BORROWED_SHAPES: &[(&str, &str, &str)] = &[
("cml_dep", "FooList", "[* foo]"),
("cml_dep", "MapKToV", "{* k => v}"),
];
The two halves serve different validators. The private mod borrowed of use lines is the
compile-checked half: it names every borrowed wrapper, so a wrapper the dependency stops providing
fails this crate's own build with an unresolved import naming the exact type. The
BORROWED_SHAPES table is the machine half the dependency parses: (dependency rust-crate name, wrapper name, shape in CDDL syntax with the dependency's idents), sorted by (dep, name). The
shape column is authoritative — the dependency derives the wrapper's definition from the shape and
only cross-checks the name (reverse-parsing element types out of a name like MapAToBToC is
ambiguous). The name and shape agree across the two crates only when the consumer's extern stub is
representation-faithful to the dependency's spec — the stub-fidelity contract documented under
--wrapper-requests; an unfaithful stub surfaces as a
dependency-side hard error naming the element and the stub fix. The file is derived output, rebuilt from the consumer's spec on every regen; borrowed
wrappers are never re-exported (they are not the consumer's public API). All comments — the column
legend included — live in the fixed banner, never inside the const body, per
the banner-only comment contract shared by every generated
sidecar/check file.
Flavored shapes carry a trailing policy marker in the shape column. The structural wrapper name
encodes the container for the two @duplicates twins
(<Elem>OrderedSet / NonEmpty<Elem>OrderedSet for reject, PairMapKToV /
NonEmptyPairMapKToV for preserve), so the shape a dependency rebuilds from must carry the policy
too: rows read ("cml_dep", "FooOrderedSet", "[* foo] @duplicates reject") and
("cml_dep", "PairMapKToV", "{* k => v} @duplicates preserve"). The marker rides the column bare
(no ;) because the column is round-tripped by parse and is not CDDL to begin with — the paste-able
rule-line hint the not-in-index warning prints moves it into comment position, where it is CDDL.
Without the marker the dependency would rebuild a loose list or a keyed table under a name that
promises uniqueness or duplicate-preservation, which is the one skew this column exists to prevent.
A borrowed reject set also obliges the key-derive channel. Its hosted class wraps
OrderedSet<Elem>, whose uniqueness scan needs Elem: Ord — a derive that lives in the
dependency's crate. The consumer records that demand in borrowed_key_types.rs (below), so a
workspace regen passes --key-requests beside --wrapper-requests; with
only the latter the dependency hosts the class and its own build fails E0277 on the missing bound.
A user may hand-add a row via the overlay's // cddl-codegen:insert-start/end (or replace) blocks —
honored like any preserved edit when the payload conforms to the fixed row format. A
compile_error! / cddl-codegen:unpreserved-comment trap block, an unknown line, or a mangled row is
not silently tolerated: the dependency's strict parser rejects the whole sidecar as a hard error
naming the file, on the principle that a cross-crate request channel must never consume a drifted
generated file. Regenerate the consumer crate to clear such a trap before regenerating the dependency.
borrowed_key_types.rs — the consumer's map-key-derive sidecar
Emitted into the rust crate (rust/src/generated/borrowed_key_types.rs) whenever
--workspace-dep is set (same empty-but-present semantics as borrowed_collections.rs), in
--wasm=true and --wasm=false alike — the sidecar is a rust-crate concern, so --workspace-dep is
honored mode-independently. Where the
wasm-side sidecar records borrowed wrapper classes, this one records borrowed map-key types:
a consumer map keyed on a dependency's type needs Eq/Ord/PartialOrd (plus Hash under
--preserve-encodings) derived on that type, and the orphan rule means only the dependency's crate
can provide the derives. Same two-half structure: a compiled
_borrowed_key_types_self_check fn (a dependency dropping a derive fails this crate's build,
naming the type) and the machine-read BORROWED_KEY_TYPES table of
(dep rust-crate name, cddl ident) rows, sorted, which the dependency re-reads via
--key-requests and seeds into its key-derive computation before finalize.
Strictly parsed under the same never-consume-a-drifted-file principle as BORROWED_SHAPES.
The self-check asserts on the borrowed type at the dependency's real module path — the exact path
the consumer already knows it by through the extern-import / extern-deps channel and uses in its own
generated use lines. When the dependency's type lives in a non-root scope, that path is scoped
(cml_dep::sub::module::Foo); the dependency's thin root does not re-export scope contents, so a
bare cml_dep::Foo would be a "cannot find type" error. The BORROWED_KEY_TYPES rows are
independent of this: each row stays the bare (dep rust-crate name, cddl ident) regardless of scope
(the first column is the dep crate name = the type's first scope component), and the dependency
resolves it scope-agnostically by cddl ident — so no scope column is ever added and root-only sidecars
stay byte-identical.
The one reserved CDDL name that can legally appear as a row ident is int — it names the built-in
Int, which under --common-import-override this crate re-exports from the
common crate rather than minting, so a map keyed on int records (<override>, "int") and the common
crate's regen key-flavors its shared Int. Every other reserved ident is rejected.
// This file records every map-key type this crate borrows from workspace deps.
// …
#[allow(dead_code)]
fn _assert_key_traits<K: Eq + Ord + PartialOrd + core::hash::Hash>() {}
#[allow(dead_code)]
fn _borrowed_key_types_self_check() {
_assert_key_traits::<cml_dep::Foo>();
}
#[allow(dead_code)]
pub(crate) const BORROWED_KEY_TYPES: &[(&str, &str)] = &[("cml_dep", "foo")];
When a borrowed key carries a @used_as_key flavor (hash/ord) rather than the
bare full bundle, each row gains an optional third column naming the flavor, the table type becomes
&[(&str, &str, &str)], and the self-check splits into per-flavor bound carriers
(_assert_key_traits_hash<K: Eq + core::hash::Hash>(), …) so a hash-only borrow is not checked against
the full Ord bundle. An all-bare sidecar keeps the two-column form byte-identically — this is a
pure superset. The three-column banner is a declared breaking seam: a cddl-codegen predating flavor
support hard-errors ("unexpected comment"/"row must be … two string literals") when it re-reads a new
consumer's flavored sidecar, rather than silently mis-deriving. Keep workspace tool versions in sync.
requested_collections.rs — the dependency's hosted wrappers
Emitted whenever --wrapper-requests is set (empty-but-present when flagged, absent with no flag —
flag-off output is byte-identical to before). The dependency unions the requested shapes across all
consumers' sidecars, and emits every requested wrapper it does not already produce, from its own
module view (correct crate::… paths, current tool version, one writer per file). Each wrapper carries
a tool-owned attribution doc listing its requesters alphabetically:
/// Generated at the request of: cml_chain, cml_multiera.
#[derive(Clone, Debug)]
#[wasm_bindgen]
pub struct FooList(Vec<cml_dep::Foo>);
Attribution is a /// doc comment — the overlay's tool-owned documentation class, which regenerates
freely — so adding or dropping a requester churns the doc in place without ever tripping edit
preservation. Requested wrappers are indexed in the dependency's own collections.rs
(pub use crate::generated::requested_collections::<Name>;) exactly like a wrapper the dependency's
own spec produces, so a consumer's use <dep_wasm>::collections::<Name>; resolves. A requested
NonEmpty… wrapper additionally provisions the NonEmptyVec/NonEmptyMap runtime; a bounded list
provisions BoundedVec, while a bounded reject array provisions BoundedOrderedSet; either may emit
its loose try_from source wrapper as an (unattributed)
transitive support type. Each hosted wrapper
also carries explicit imports for the element types its body names — a cross-scope generated
element from its scope module (crate::generated::<scope>::X), an extern or hand-written element
through its crate-root re-export glue — so hosting works for elements that live outside the
generated root rather than relying on the module's use super::*; reaching them. A companion
wrapper hosted in the same file gets no import at all: a hosted map's keys()-list wrapper is
normally co-requested by the same consumers (borrowing {* k => v} borrows [* k] too), minted
alongside it here, and named bare — importing it from anywhere else would point at a definition
that doesn't exist.
The regeneration contract
- Holistic regen runs in reverse dependency order, one pass. Consumers first — each rewrites its own sidecar and reads nothing from any dependency's output — then dependencies, each reading its consumers' committed sidecars. A crate that is both consumer and dependency does both jobs in its single run. An unchanged workspace regenerates to a zero diff, and regenerating any single crate twice is byte-identical (attribution and shape payload included).
- Consumer-alone regen is safe unless it adds a borrow. A spec change that introduces no new borrowed wrapper (the common iterate-on-my-own-crate loop) stays green with no dependency regen. A change that adds a borrow the dependency does not yet host fails the consumer's build with a loud unresolved import naming that wrapper — fixed by regenerating the dependency. This is a subset state (a consumer needs a wrapper the dependency lacks) and cannot exist silently.
- Dep-alone regen is always safe. It re-reads the committed sidecars, so a converged workspace reproduces byte-identical output — the per-crate idempotency guarantee holds with no asterisk.
- Removals are a benign superset. When the last borrowing consumer drops a shape, its regen shrinks its sidecar; the next dependency regen shrinks the union and deletes the wrapper, its index line, and its attribution doc — an ordinary code diff. Between the two regens the dependency carries an extra unused wrapper, which is harmless.
Extern-interface export
Every regeneration emits an extern-interface/<dep>/** directory — a sibling of rust/, wasm/ and
no-std-check/
under the output directory — describing the crate's own extern-visible type surface so that a
consumer crate can depend on it with --extern-import. <dep> is the
crate's normalized lib name (underscored — the same key the --extern-wasm-crate /
--extern-wrapper-index / --workspace-dep flag family already uses). This is the machine-generated
form of the stub dialect a human writes under _CDDL_CODEGEN_EXTERN_DEPS_DIR_/<dep>/ — which is what
that dialect is now for: declaring a dependency that has no export (a hand-written crate, one
you cannot regenerate, one generated by a deliberately separate pass). A dependency is declared
exactly once, so the two are alternatives per dependency rather than layers.
- Always-clobbered, tool-owned, committed — same class as
src/generated/**, but a distinct sibling tree. The whole directory is delete-and-recreated each run (a removed rule's stale.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 (on an optional member — a mandatory member's
.default is inert per RFC 8610 §3.8.2 and was stripped, with a warning, before the field was
built, so the export spells the plain mandatory member the IR actually holds). Plain groups that materialize a non-group shape, or
that are never referenced at all, are excluded-with-record instead — see the exclusion reasons below.
; unexported: records and reference-closure. The projection runs on every regenerated spec —
including leaf/test specs that will never be dependencies — so a rule it cannot export faithfully is
excluded-with-record rather than aborting generation. Each excluded rule leaves a sorted comment
block after the header:
; unexported: cs — @custom_serialize/@custom_deserialize does not travel through the export
The reasons are informational for humans, never parsed. The export is reference-closed: because a
consumer runs a checked parse over the whole file, any rule whose body references an excluded ident is
itself transitively excluded (to fixpoint), each with its own record naming the chain root. The
failure then surfaces only at a consumer that actually references an excluded ident — via the
undefined-reference path, whose diagnostic points back at these records. The remedies it names are on
the dependency's side — regenerate it, or fix in its own spec what its export could not project —
because a dependency the consumer imports is already declared, and stubbing the one missing type
beside the import is the double-declaration error. A custom-serialize transparent alias is the canonical exclusion: it has no
dep-crate class to delegate to, and exporting its plain definition would silently diverge the wire
format. Plain groups add two more excluded-with-record cases: a plain group never referenced in
the dependency's own spec materializes no shape to project, and one that materializes a non-group
shape (a homogeneous array/table, a @newtype wrapper) has no embedded-group surface — both leave a
record rather than vanishing.
rust/src/generated/extern_interface_check.rs
Alongside the export, every rust crate gets a compiled self-check module (always-clobbered under
src/generated/**, wired in every mode like the export it guards). It is derived from the same
projection as the export — they cannot drift — and asserts that every exported name is a real,
correctly-typed surface in this crate:
- opaque rows must implement
Serialize— 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(whose value tracks the faces this run emits — see the--componentflag), 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.