Config file
A project that generates one crate is well served by command line flags. A project that generates several is not: each invocation repeats a long, mostly-identical flag list, and the overlap has structure the command line cannot express — flags every crate shares, flags a subset shares, and flags that belong to one crate alone.
cddl-codegen --config <file.toml> reads that structure from a TOML file:
cddl-codegen --config codegen.toml # generate every crate in the config
cddl-codegen --config codegen.toml wire # generate only `wire`
cddl-codegen --config codegen.toml wire core # generate a subset
cddl-codegen --config codegen.toml --print-flags # say what it would do, generate nothing
Every key in the file is a flag. There is no knob that exists only in the config, and no flag that a config cannot set — a drift gate in the test suite asserts the two sides stay in bijection — so command line flags remains the one reference for what each key means. This page covers only where a key's value comes from.
A complete config
Before the parts, one whole file. This is the shape the feature was designed against — cardano-multiplatform-lib's four generated crates, written as the config that replaces its hand-composed flag bundles. Every section below explains one piece of it, and Migrating a generation script maps each piece back to the flags it replaces.
# Every key here is a cddl-codegen flag.
[defaults] # reaches every crate
no-synthesized-rust-collection-aliases = true
json-serde-derives = true
json-schema-export = true
wasm = true
rust-wasm-feature = "used_from_wasm"
wasm-cbor-json-api-macro = "cml_core_wasm::impl_wasm_cbor_json_api"
wasm-conversions-macro = "cml_core_wasm::impl_wasm_conversions"
wasm-list-macro = "cml_core_wasm::impl_wasm_list_needs_into"
# A SUBSET's keys, not everyone's: cip25 has never had the two encoding flags.
[profiles.encoded]
preserve-encodings = true
canonical-form = true
# One static runtime for the whole workspace: written once, imported by every crate.
[runtime]
export-static-crate = "core/rust"
common-import = "cml_core"
lib-name = "cml-core" # its cargo package name — derives every crate's dependency on it
flavor-from = "chain" # which crate's flavor it is written at
[crates.chain]
input = "specs/conway" # a directory input works per-crate
output = "chain"
lib-name = "cml-chain"
profiles = ["encoded"]
json-schema-scripts = true
# `cml_core` is hand-written and this config does not generate it, so its wasm face is named by
# hand rather than derived from an edge.
extern-wasm-crate = { cml_core = "cml_core_wasm" }
[crates.cip25]
input = "specs/cip25.cddl"
output = "cip25"
lib-name = "cml-cip25"
# No `profiles`: this is the crate that omits the encoding pair.
wasm-cbor-json-api-macro = "cml_core_wasm::impl_wasm_cbor_json_api_cbor_event_serialize"
json-schema-root = ["cml_cip25::utils::CIP25MiniMetadataDetails"]
[crates.cip36]
input = "specs/cip36"
output = "cip36"
lib-name = "cml-cip36"
profiles = ["encoded"]
# `cml_chain` IS in this config, and this is still not a `deps` edge: cip36's spec carries a
# hand-written extern-deps stub for those types, so it needs the wasm face named and nothing else.
# Legal because the key names a crate rather than a file to read — see Dependencies between crates.
extern-wasm-crate = { cml_crypto = "cml_crypto_wasm", cml_chain = "cml_chain_wasm" }
[crates.multi-era]
input = "specs/multiera"
output = "multi-era"
lib-name = "cml-multi-era"
profiles = ["encoded"]
json-schema-scripts = true
deps = ["chain"] # the whole workspace edge, both directions
wasm-reexports = ["cip25", "cip36"] # the npm package ships their classes
json-schema-root = [
"cml_multi_era::utils::MultiEraBlockHeader",
"cml_multi_era::utils::MultiEraCertificate",
"cml_multi_era::utils::MultiEraProtocolParamUpdate",
]
Four things are worth reading off it before the sections that explain them:
deps = ["chain"]is the whole workspace edge. One key derives the extern import, the wrapper index, the rust and wasm manifest entries, the JSON threading, and the two reverse-edge flags that land onchainitself. See Dependencies between crates.- Generation order is not the order written. It is a topological sort over
deps, dependencies first, ties broken by crate name:chain,cip25,cip36,multi-era, followed by the convergence pass. flavor-fromis a declaration, not a preference.cip25omits the encoding pair, so no single crate's flavor is the join of all four and the carrier cannot be derived. Naming it accepts generatingcip25againstchain's runtime, and the run states that acceptance back — seeflavor-from, when you know.- One value is deliberately absent.
static-dirnames where this machine keeps cddl-codegen's own hand-written runtime, which is not a property a committed file can state. It is one of the two generation flags--configaccepts instead.
Running it is one command, and seeing what it would do is the same one with a flag:
cddl-codegen --config codegen.toml --static-dir ../cddl-codegen/static --print-flags
cddl-codegen --config codegen.toml --static-dir ../cddl-codegen/static
Editor support
A JSON Schema for this file ships in the repository at
docs/editor/cddl-codegen-config.schema.json. An editor that reads it completes the key names,
shows each key's type and the flag it stands for, and marks a misspelled or misplaced key where you
typed it rather than at the next run — the same refusals this file
lists, only earlier.
The schema is generated from struct Settings, struct CrateEntry and struct Runtime in
src/config.rs, and the editor_schema_matches_the_config_surface test fails if the committed copy
stops matching them. So it describes the version of the tool it was checked out with, not a
hand-maintained approximation of some version.
Wiring it is one line at the top of the config, which taplo and the VS Code extension built on it (Even Better TOML) both honour:
#:schema ../cddl-codegen/docs/editor/cddl-codegen-config.schema.json
The path is relative to the config file. To associate it without touching the file, taplo takes a
[[rule]] in .taplo.toml (include = ["**/codegen.toml"], [rule.schema] path = "…"), and VS
Code takes an evenBetterToml.schema.associations entry keyed by a filename pattern.
One spec, one crate
The floor: a config must not be more ceremony than the flags it replaces.
[crates.address-book]
input = "specs/address_book.cddl"
output = "gen/address-book"
preserve-encodings = true
input and output are required. lib-name defaults to the crate table key — here address-book
— which is the one place the config is less repetitive than the command line, where --lib-name
defaults to cddl-lib and so realistically always needs passing. Every other key keeps its flag's
default.
Shared values
[defaults] holds any key a crate table holds, and applies to every crate:
[defaults]
preserve-encodings = true
canonical-form = true
json-serde-derives = true
static-dir = "vendor/cddl-codegen/static"
[crates.messages]
input = "specs/messages.cddl"
output = "gen/messages"
[crates.storage]
input = "specs/storage.cddl"
output = "gen/storage"
[crates.wire]
input = "specs/wire.cddl"
output = "gen/wire"
wasm = false # internal crate, no bindings
A crate's own key wins over [defaults], which wins over the built-in default.
input, output, lib-name and profiles may not appear in [defaults] (it is a hard error,
not a silent skip): a shared value for any of them would point every crate at one spec, one
directory, or one library.
Profiles
A profile here is not a Cargo build profile: it says nothing about dev/release and nothing
about how the generated crate is compiled. It is also not selectable from the command line —
there is no --profile flag, and which profiles a crate applies is stated by that crate's own
profiles = [...] list, in the config, where the rest of its flag set is.
Real projects have grouping axes that do not nest — say every crate shares a base, the published
ones add npm packaging, and the constrained-device ones add a depth limit. One [defaults] table
cannot express two overlapping subsets without pushing the flags back down into every crate.
[defaults]
preserve-encodings = true
[profiles.published]
package-json = true
emit-tests = true
[profiles.hardened]
deserialize-depth-limit = 128
[crates.core]
input = "specs/core.cddl"
output = "gen/core"
[crates.wallet]
input = "specs/wallet.cddl"
output = "gen/wallet"
profiles = ["published"]
[crates.firmware-msgs]
input = "specs/firmware.cddl"
output = "gen/firmware-msgs"
profiles = ["published", "hardened"]
A profile is a named [defaults] table applied by reference, so the merge order is simply:
built-in default →
[defaults]→ each profile in the crate's listed order → the crate's own keys
Later wins. The order in profiles = [...] is the order they apply, not the order they are declared
in the file.
Profiles are flat: a [profiles.*] table cannot itself carry profiles. Naming a profile that
has no table, or naming one twice, is a hard error.
When a profile earns its keep
[defaults] and a profile can often express the same grouping, so the question is which one says it
better. The axis that decides it is whether a shared value can be taken back:
- A non-boolean key cannot be un-set once shared. The config has no spelling for
"absent", so a
rust-wasm-featureor adeserialize-depth-limitthat one crate must not have cannot go in[defaults]at all. A profile is the only place it can go, and that is the case a profile exists for. - Two crate subsets that genuinely overlap need two profiles, because a crate can list both while
one
[defaults]table cannot be two tables. That is the case above. - A boolean with one deviating crate needs neither.
falseis a real value, so[defaults]plus an explicitpreserve-encodings = falseon the crate that deviates is shorter than a profile plus aprofiles = [...]line on every other crate — and it states the deviation at the crate that has it, which is where a reader of that crate's table looks for it.
How each kind of key merges
Most keys are scalars and the later layer simply wins. Two kinds behave differently, because for them "replace" would be the wrong answer:
"Later wins" means a later layer can give a key a different value. It cannot take the key back to
its built-in default, because the config has no spelling for "absent" — and for the keys whose flag
takes an optional value (static-dir, export-static-crate, rust-wasm-feature,
deserialize-depth-limit, common-import-override, and the three wasm-*-macro keys) no value
means "off" either. In particular common-import-override = "" is not an escape hatch: it sets the
override to the empty string, which still suppresses the crate's own static runtime, so you get a
crate whose runtime files were never written.
Boolean keys are unaffected — false is a real value, which is how a deviating crate turns
preserve-encodings back off.
So put a value in [defaults] only when it is genuinely universal. When it is shared by most
crates, a [profiles.*] table is the answer for anything that cannot be un-set: the crates that want
it list it, and the one that does not simply omits it. For a boolean, both spellings
work and the shorter one usually wins.
Arrays concatenate, earlier layers first, author order preserved within each layer. workspace-dep
and json-schema-root are additive per-item lists, and --json-schema-root is order-significant
(extra roots emit after every spec-derived row, in flag order), so a crate adding one root must not
discard the shared list [defaults] exists to hold.
[defaults]
json-schema-root = ["cml_chain::byron::AddressContent"]
[crates.chain]
input = "specs/conway"
output = "gen/chain"
json-schema-root = ["cml_chain::byron::ByronAddress"]
# effective order: AddressContent, then ByronAddress
Concatenation never removes, and that is a deliberate divergence from the layering people arrive
with: in tsconfig.json or a kustomize overlay a later layer's list replaces the inherited one, so
there is a spelling for dropping an entry. Here there is none — a later layer can only add. Both keys
are additive by nature (one is emission order, the other is workspace membership), so replacement
would make silently discarding the shared list the easy mistake, and there is no third value a crate
could write to opt out of one entry. A crate that must not carry an entry keeps it out of the shared
layer instead.
<key>=<value> flags become sub-tables, which union per key — a later layer overrides only the
keys it names, exactly as repeated flag occurrences already accumulate. The flags spelled this way
are extern-import, component-extern-wit, extern-wasm-crate, extern-wrapper-index,
wrapper-requests, key-requests, json-schema-dep, json-gen-dep, wasm-dep, rust-dep and
component-dep:
[crates.my-extension]
input = "specs/ext.cddl"
output = "gen/ext"
[crates.my-extension.extern-import]
upstream_core = "../upstream-core/extern-interface/upstream_core"
[crates.my-extension.extern-wasm-crate]
upstream_core = "upstream_core_wasm"
Everything after a [crates.<name>.<sub-table>] header belongs to the sub-table, not to the crate,
and it goes wrong silently: a lib-name written under that header becomes an entry in the
mapping, and the crate keeps its default library name with nothing said about it.
So for a short mapping, prefer the inline table — it ends where its closing brace does, and the keys after it still belong to the crate:
[crates.my-extension]
input = "specs/ext.cddl"
output = "gen/ext"
extern-import = { upstream_core = "../upstream-core/extern-interface/upstream_core" }
extern-wasm-crate = { upstream_core = "upstream_core_wasm" }
lib-name = "ext"
The two spellings expand to exactly the same flags, so this is a layout choice and never a behavioural one. A mapping long enough to want its own header is fine too — write the crate's plain keys first and its sub-tables last.
Dependencies between crates
The sub-tables above are how you name a dependency the config does not contain. When both crates
are in one config, deps names the edge and every <name>=<path> pair is derived from it:
[defaults]
preserve-encodings = true
[crates.core]
input = "specs/core" # a directory input works per-crate
output = "gen/core"
[crates.ledger]
input = "specs/ledger.cddl"
output = "gen/ledger"
deps = ["core"]
[crates.governance]
input = "specs/governance.cddl"
output = "gen/governance"
deps = ["core"]
Every value the edge expands to is one the config already holds, which is the point: hand-maintaining
the pairs means two invocations that must agree about a third crate's output and lib-name, and
nothing checks that they do.
On the consumer (ledger), from the dependency's own entry:
| derived flag | where the value comes from |
|---|---|
--extern-import core=gen/core/extern-interface/core | the dependency's output, plus its lib-name — which is both the directory its export lands in and the crate name the generated use line carries |
--extern-wasm-crate core=core_wasm | the dependency's lib-name |
--extern-wrapper-index core=gen/core/wasm/src/generated/collections.rs | the dependency's output |
--workspace-dep core | being in this config is what "co-generated workspace member" means |
--wasm-dep core=../../core/rust and --wasm-dep core-wasm=../../core/wasm | the dependency's lib-name verbatim — the cargo package names — plus the paths from your wasm crate to its two crates. See The wasm manifest |
--rust-dep core=../../core/rust | the same package name, plus the path from your rust crate to the dependency's. See The rust manifest |
--component-extern-wit core=gen/core/component/wit | the dependency's output — only when both crates have component = true. See The component face |
--component-dep core=../../core/rust | the dependency's lib-name verbatim and the path from your component crate to its rust crate — same condition |
On the dependency (core), one pair per consumer, in consumer order:
| derived flag | where the value comes from |
|---|---|
--wrapper-requests ledger=gen/ledger/wasm/src/generated/borrowed_collections.rs | the consumer's output |
--key-requests ledger=gen/ledger/rust/src/generated/borrowed_key_types.rs | the consumer's output |
Two things vary the paths, and both are read from the other crate's own settings rather than assumed:
-
wasm = falseon the dependency derives--extern-import, plus the one--wasm-depnaming its rust package (it keeps that crate name for both of your passes, since there is no wasm crate to route the boundary through). The other three are all about a wasm face it does not have — and--workspace-depis not merely pointless without one, it is a hard error without an--extern-wasm-cratemapping. The reverse edges go with them, since the sidecars a consumer emits exist only because it has a workspace dependency to record. (Awasm = falseconsumer still gets--key-requests: that sidecar is a rust-crate concern and is written in either mode. It gets no--wasm-depat all, having no manifest for one to land in — but it does get--rust-dep, which follows the rust crate every run generates rather than the wasm face.)Worth knowing before you set it: because
--workspace-deprequires a wasm crate to exist, a dependency withwasm = falsealso loses the map-key-derive channel that rides on it. If a consumer keys a map on one of that dependency's types, the dependency never learns it must deriveEq/Ord/PartialOrdthere, and the consumer fails to build with anE0277naming the type. The remedy is to give the dependency a wasm crate, or to declare the key demand in the dependency's own spec with@used_as_key. -
package-json = truemoves a crate's cargo crates one level down, to<output>/rust/rustand<output>/rust/wasm, leaving the output root to the npm package. Every derived path into a crate follows that layout. Theextern-interface/export does not move: it is emitted in every mode, rust-only included, as a sibling of the crate directories. The dependency'scomponent/witdoes move — it lives inside the component crate, so it sits beside the export only while the nesting is off.
The component face
The two component derivations are emitted exactly when the edge carries the component bytes seam —
component = true on both crates. Without a component face on the dependency there is no
component/wit to point at, and the dependency's types stay excluded from the consumer's WIT
projection (the fallback documented in Component
differences); nothing in the consumer's guest crate then names the
dependency, so neither flag would mean anything. There is nothing for the edge to decide when both
sides do have one: import mode is the only shape in which such an edge means anything on this face.
--component-dep names the dependency's rust package, not its component package. The guest glue
holds a dependency-typed value natively and converts it across the seam, while the dependency's own
component crate is wired by the composer at the component level and never by cargo.
Encoding posture is validated on such an edge, and only on such an edge. A dependency-typed value
crossing the seam is serialized by one crate and deserialized by the other, so the crossing preserves
the value only while both agree about what CBOR they write and accept. A mismatch on
preserve-encodings, canonical-form or deserialize-depth-limit fails nothing — it silently
re-encodes on every crossing — so a config carrying one is refused before anything is written, naming
both crates and the axis. A hand-written invocation sees one crate at a time and cannot check this;
it is documented there as an integration obligation instead. On a deps edge without the seam the
dependency's types are reached by ordinary rust linkage, no bytes are produced or parsed at a
boundary, and the rule does not apply.
A hand-written sub-table entry for the same key always wins, silently — an explicit value is you overriding the sugar for a case it does not cover, not a conflict. Only the key it names is overridden; the rest of the edge still derives.
That the sub-tables are for a dependency the config does not contain is checked, not just
stated. A sub-table naming a path into another crate's output — extern-import,
component-extern-wit, extern-wrapper-index, and the reverse wrapper-requests / key-requests
— whose key is the lib-name of a crate this same config generates, with no deps edge behind it,
is refused when the config loads:
[crates.ledger].extern-wrapper-index names `core`, which is `[crates.core]` in this config. Inside
one config an edge onto a crate the config itself generates is `deps`: declare `deps = ["core"]` in
`[crates.ledger]` and drop the hand-spelled `extern-wrapper-index` entry […]
Hand-spelled, such an edge is invisible to every convergence
instrument at once — there is no sidecar to watch, no
crate to re-run, and no edge to walk — so the path is read while the other crate is still mid-run,
the run exits 0, and the next run over an unchanged tree writes different bytes. An entry backed by
a deps edge is untouched: that is the override above. So are extern-wasm-crate and
json-schema-dep, which name a crate and a rust module path rather than a file to read — nothing of
ours moves underneath them, and a crate whose spec carries a hand-written
_CDDL_CODEGEN_EXTERN_DEPS_DIR_/ stub for a type another crate here happens to generate needs
exactly that spelling (a deps edge would derive an --extern-import colliding with the stub).
deps is per-crate only. A shared edge is not a graph: it would make every crate depend on every
crate named, itself included.
Generation order, and the convergence pass
Generation order is a topological sort over deps, dependencies first, ties broken by crate name
so the order is total and reproducible. A cycle is a hard error that names it (a → b → c → a), as
are a deps entry naming an unknown crate, a crate naming itself, and a name listed twice.
That order settles a genuine conflict rather than a technicality. The two edge kinds want opposite orders:
--extern-import/--extern-wrapper-indexwant the dependency generated first — the consumer reads files the dependency writes;--wrapper-requests/--key-requestswant the consumer generated first — the dependency reads sidecars the consumer writes.
No single ordered pass satisfies both. Dependencies-first is chosen, so the reverse edges read each consumer's committed sidecar, exactly as their own documentation specifies — and a pass that rewrites a sidecar leaves the dependency that read it a pass behind.
So the run does not stop there. After the first pass it re-runs exactly the crates whose consumed sidecars that pass rewrote, in the same generation order, and says which and why:
[converge] re-running `core`: it read sidecars before this run rewrote them
(gen/ledger/wasm/src/generated/borrowed_collections.rs, …), so what it generated is a pass behind
what its consumers now ask for.
One invocation therefore leaves a workspace that builds, and the next invocation over it is byte-identical. That is not a convenience — it is what makes run twice = run once = clean run true of a config run at all. Without the pass, run 1 and run 2 of a cold tree produce different bytes by construction, which is the property failing rather than being protected.
One extra pass, not a loop to a fixpoint, because a second has nothing to do. The only cross-crate
input the pass can change is a dependency's collections.rs wrapper index; a consumer's output
depends on that index through exactly one decision (whether to defer an ownerless collection
wrapper, since a wrapper owned by one deps dependency defers unconditionally); and every wrapper
the pass adds to an index was requested by a consumer, which makes it all-one-dep-owned and so
never an ownerless name. The sidecars themselves depend only on a crate's own spec and its
dependencies' extern-interface/ exports, neither of which the pass touches — so they are already
final when it starts.
That last step is where export non-transitivity does the work, and it is what keeps the argument
true at any deps depth: a dependency's own deps never travel through its
export, so a crate's sidecars are a function of its own spec and its
direct dependencies' exports, however long the chain above it runs. In app → mid → core, the
pass re-runs mid, and mid's own sidecars cannot move — nothing core gained in the pass reaches
app through mid's export. Make exports transitive and this is the argument that has been
invalidated; a fixpoint loop would be required, and this pass would be one iteration of it.
The convergence warning below is that reasoning kept as a measurement rather than an assumption. What it measures is the sidecar channel, across every crate in the run — not only the crates the pass re-runs, so the measurement still covers a dependency sitting below a re-run middle crate. The wrapper-index channel is deliberately not watched: the pass rewrites an index by design (hosting the requested wrappers is its purpose), so an instrument watching it would fire on every successful convergence. That channel is covered by the argument above, whose bound is export non-transitivity.
The convergence warning
If a sidecar moves again across the convergence pass, the run says so and stays at exit 0:
warning: a sidecar changed during this run, so `core` generated against a stale one (it is one run
behind). … Re-run `cddl-codegen --config codegen.toml` to converge.
A full run should never print it. What it covers is what the pass cannot: a subset run, where the crate that would need re-running was not in the run to begin with.
The verdict on the committed tree
The warning above is an instruction about the run, and it is bounded by what the run touched: it compares the sidecars this run consumed, before and after. A run that does not include the dependency therefore has nothing to compare — which is precisely the dangerous case. Regenerate one crate so that it starts borrowing a new wrapper, and the dependency is left not hosting it while the run prints nothing at all.
So there is a second check, and it is a verdict about the tree. After generating, over every deps
edge touching this run — the crates you named and their declared counterparties — it reads the
consumer's committed borrowed_collections.rs and the dependency's committed collections.rs
wrapper index. Every row of that sidecar compiles to a use <dep>_wasm::collections::<Name>; line,
so a name the index does not re-export is a workspace that does not build. That one exits nonzero:
Error: "the committed workspace does not build: `core` does not host `MapU64ToCoreThing` borrowed by
`ledger`. … Run `cddl-codegen --config codegen.toml core` to host them: a dependency-alone regen
reads its consumers' committed sidecars, and is always safe."
Nonzero, and its own code. The exit status distinguishes the two failures, because they ask for different things:
| exit | what it means |
|---|---|
0 | the run did what it was asked, and nothing it can see says the workspace is inconsistent (the convergence warning, if it fires, is here — it is an instruction about the run, and re-running satisfies it) |
1 | the run failed: a config that would not expand, a spec that would not generate, a cross-crate sidecar a crate consumes that it refuses. Fix the input and run it again |
2 | the run succeeded — every crate it was asked to generate is generated — and the committed tree does not build: the verdict above. Repeating the command changes nothing; the message names the dependency to regenerate |
A CI job wants those apart: 1 is "this commit's inputs are broken", 2 is "this commit's generated
output and its committed dependency have drifted apart", and the second one names its own remedy.
The two are not two strengths of one signal. "I rewrote a sidecar something had already read" is normal, self-clearing, and expected on a first run, so it stays a warning at exit 0. "This tree does not build" is neither, and it is true whether or not the dependency was in your run — which is why it names the dependency to re-run rather than telling you to repeat what you just did.
A full run should never reach it: the convergence pass has already re-run the dependencies whose demands changed, so by the time the verdict is computed the tree it examines is the settled one. What it exists for is the run the pass cannot settle — a subset that leaves the dependency out, and a counterparty in another repository, neither of which this command can regenerate.
Both checks read generated files and neither changes one. What is generated depends on neither; the convergence check decides which crates run a second time, and the verdict's only effect is the exit code.
A hand-written codegen.sh may well run its crates in the other order — consumers before
dependencies — and accept staleness on the --extern-import side instead. Converting it to a config
flips which of your two edges is the stale one, and then settles it: the convergence
pass re-runs the dependencies whose sidecars the run
rewrote, so one command converges a brand-new workspace. You will still see one round of the
per-flag "no sidecar there yet" notice on stderr during the first pass — the sidecars a dependency
reads do not exist until its consumers have generated once — and the convergence pass is what
follows it.
Selecting a subset, and closing it over dependencies
Selecting a subset does not pull in its dependencies. The unselected dependency's committed output is trusted exactly as a dependency in another repository's is, which is the same contract the cross-crate flags already document; the selected crate still carries the full derived edge, which is what makes that committed output reachable. If it was never generated, the flags' own error names the missing path.
That default is right when the dependency is settled, and it is what makes the verdict above meaningful. But when you have just changed a consumer's spec so that it borrows something new, you already know the dependency is affected — the two commands are the verdict and then its own instruction:
cddl-codegen --config codegen.toml ledger # exit 2: `core` does not host `MapU64ToCoreThing`…
cddl-codegen --config codegen.toml core # what the verdict told you to run
--with-deps is that taken up front, in one command:
cddl-codegen --config codegen.toml --with-deps ledger # generates core, then ledger; exit 0
It closes the named selection over deps, transitively — --with-deps app on app → mid → core
runs all three — and the closed selection generates in the config's ordinary generation order, so
--with-deps a b and --with-deps b a do the same thing. --print-flags composes with it and lists
the crates the run would contain, dependency included.
Two things it deliberately does not do. It closes over dependencies only, never consumers: a
dependency is pulled in so the named crate's own inputs exist, while a consumer would be regenerated
because it might change — output you did not ask for, and the verdict is how you hear that it needs
regenerating instead. And it needs at least one crate name to close over: naming no crate already runs
every crate in the config, so --with-deps alone is an error rather than a flag that silently did
nothing.
The published JSON surface
Under json-schema-export, each crate's json-gen crate writes one document
(schemas/<lib>.schema.json), and run-json2ts.js compiles it into the .d.ts the npm package
ships. A published class whose type that document does not declare fails the npm step — so the
document has to cover everything the package ships, not everything the spec describes.
Two things bridge that gap, and only one of them is derivable:
json-schema-rootnames a hand-written Rust type no CDDL rule describes. It is emitted verbatim and cannot be derived — the config cannot tellbyron::ByronAddress(a module of this crate) fromcml_crypto::Bip32PublicKey(another crate entirely) without inventing a marker.- Threading another generated crate's rows is derived, because the list it needs is one the project already maintains for the crate to link.
[crates.multi-era]
input = "specs/multiera"
output = "gen/multi-era"
deps = ["chain"] # the spec references chain's types
wasm-reexports = ["cip25", "cip36"] # the package ships their classes; the spec never mentions them
wasm-reexports is named after the manifest entries it derives, which is the
half of its effect the name predicts; the other half is this section's — every crate it names has its
schema rows threaded into this crate's document, which is what stops a re-exported class shipping
untyped.
Why the source is the wasm dependency list, not the rust one
The crates a document must thread are the ones whose wasm classes ship in this crate's package. That is the crate's wasm-side dependency list, and it is not the same list as the rust one.
With one document per crate, everything this crate's own types reference is already present through the reference closure. What a thread adds is a dependency's unreferenced roots — an address, a hash, a transaction that a consumer of your package holds directly but nothing in your spec points at. Those are exactly the types a package re-exports without naming.
CML's tree is where this was measured. Its two crates that are published npm packages — multi-era
and the hand-written cml umbrella — each thread precisely their wasm dependencies that are
generated crates. Its two crates with no package.json (cip25, cip36) thread nothing while
depending on chain; their documents are never consumed, so the missing thread has never cost
anything. The rule holds wherever it is observable.
So the derivation is deps ∪ wasm-reexports: deps covers the wasm dependencies that exist because
the spec references them, wasm-reexports the ones that exist only because the package ships them.
Crates outside the config drop out on their own — they have no json-gen crate to call.
wasm-reexports creates no rust/extern edge and no generation-order edge. It is a packaging
fact and nothing else; deps is what declares a real dependency. The one other thing it does derive
is the manifest entry that makes the packaging fact true — see The wasm
manifest below.
wasm/Cargo.toml, where the fact already is?Because that manifest is co-owned prior output — the tool writes it through a merge, and your hand-added lines survive. Reading it back to decide which rows to emit would make generated code depend on prior output, which is the one thing the determinism contract does not bend on. Declaring the same fact in the config makes it an input.
The two derived flags
Each threaded crate expands to two flags, both read off that crate's own entry so a lib-name or
output rename propagates and nothing can drift:
| derived flag | where the value comes from |
|---|---|
--json-schema-dep=cml_chain=cml_chain_json_schema_gen | the dependency's lib-name, underscored — the rust lib path of its json-gen crate |
--json-gen-dep=cml-chain-json-schema-gen=../../../chain/wasm/json-gen | the dependency's lib-name verbatim — the cargo package name — plus the path from your json-gen crate to its |
The two spellings are opposite on purpose, and writing either the other way round is the mistake whose error names neither cause. Deriving them removes the choice.
The path is relative, and that is a determinism requirement. It is one of the two derived paths
in this file that are written into a committed file rather than read at generation time (the other
is the wasm manifest's) — it becomes a cargo path dependency in
wasm/json-gen/Cargo.toml. An absolute value would bake your checkout location
into a file your project commits, so the same config would produce different bytes in a different
clone. It is counted from the consumer's json-gen directory to the dependency's, and both endpoints
follow their own crate's package-json setting.
How the two endpoints are spelled does not reach the manifest. . and .. are resolved before
the paths are diffed, so ./gen/core and gen/sub/../core derive exactly what gen/core derives —
that resolution is lexical, since these directories need not exist yet, and so it assumes a component
a .. cancels is not a symlink. When the two endpoints do not share a frame — one output absolute
and the other relative, or a relative one climbing above the config file's own directory — the
process directory supplies the missing frame. That is not a CWD dependence in the derived value: it
reconstructs the location the relative output already denoted, so writing the same two directories
out as absolute paths, or running the same config from anywhere else, gives a byte-identical manifest
entry. The only requirement is that the process directory be readable.
An absolute output still costs you something, just not this. It bakes a machine path into the
config file, which the manifest entry no longer inherits — so it is the config, not the generated
crate, that stops being portable.
A crate with json-schema-export = false on either end of a derived edge contributes nothing,
silently: a dependency with no json-gen crate has no add_schemas to call, and a consumer with no
document has nowhere for the rows to land. That silence is what lets one config hold both kinds of
crate.
Overriding the derivation
json-schema-deps replaces the derivation for that crate entirely — deps and wasm-reexports
then contribute no threading at all, and json-schema-deps = [] threads nothing while leaving the
edges themselves intact. It is the escape hatch for a crate whose package composition and dependency
list genuinely diverge.
The raw [crates.<name>.json-schema-dep] and [crates.<name>.json-gen-dep] sub-tables remain the
only way to thread a crate that is not in this config. They union on top of whatever the
derivation or the override produced; an entry naming a key the derivation would also produce wins,
silently and per half, like every other hand-written sub-table entry.
The wasm manifest
The same two edge keys derive the [dependencies] entries the generated wasm/Cargo.toml needs, as
--wasm-dep values. Without them a config-generated workspace emits use <dep>_wasm::… into a crate
whose manifest never names <dep>_wasm, which is a call, an E0433 and a TODO rather than a
feature.
The two keys contribute different entries, because they state different facts:
depsmeans your spec references the dependency's types, and the wasm pass writes two kinds of reference to such a type —use <dep>_wasm::…at the wasm boundary, and the dependency's plain rust type as the inner storage of a wrapper this crate mints itself (a mixed-dep collection is minted here, since no single dependency can host it). So adepsedge derives both of the dependency's packages. A dependency withwasm = falsekeeps its rust crate name for both passes, so that edge derives the rust entry alone.wasm-reexportsmeans nothing generated here names the crate at all and its classes ship in your package anyway. So it derives the wasm package alone — precisely the lines a project maintains by hand under a comment like "not actual dependencies but we re-export these for the wasm builds", which is what the key is named after.
Paths are counted from the consumer's wasm directory to the dependency's, following each crate's own
package-json layout, and are always relative — the value lands in a committed manifest, so an
absolute one would make the same config produce different bytes in a different clone.
A crate with wasm = false derives nothing here: it generates no wasm crate, so there is no manifest
for an entry to land in.
The raw [crates.<name>.wasm-dep] sub-table names a crate the config does not contain, or
overrides a derived package (a vendored checkout, a registry version beside the path). A hand-written
entry wins per package, silently, like every other sub-table entry.
The rust manifest
deps alone derives the [dependencies] entry the generated rust/Cargo.toml needs, as a
--rust-dep value. An imported dependency's types are emitted into your rust source as
use <dep>::<Type>;, in every flavor — the rust crate is the one crate every run generates — so this
derivation has no wasm gate on either end and contributes exactly one entry per edge: the
dependency's rust package, the only package the rust pass can name.
wasm-reexports contributes nothing here, and that asymmetry is the key's meaning rather than an
omission: it says a dependency's wasm classes ship in your package while your spec references none of
its types, so no rust line names the crate.
Paths are counted from the consumer's rust directory to the dependency's, following each crate's own
package-json layout, and are always relative, for the reason the wasm ones are.
The raw [crates.<name>.rust-dep] sub-table names a crate the config does not contain, or
overrides a derived package. A hand-written entry wins per package, silently, like every other
sub-table entry.
Each entry is paired with a --std-forward-dep for the same package, unconditionally — including
one whose path a hand-written entry supplied. Without it, default-features = false on a crate stops
at that crate: its dependency is still built with its own defaults, so the no_std arms inside it are
unreachable. The target is a crate this config generates and every generated crate declares a std
feature, so the forward always resolves.
Each crate's dependency on the shared runtime crate is derived too, but only when you name that
crate's cargo package in [runtime].lib-name. --common-import-override cannot supply
it — an override is a Rust path prefix (crate::common is a legal value), so no package name follows
from one — which is why the key exists and why it is opt-in. Without it that dependency stays the hand
edit it has always been.
Every [crates.<name>] table needs an input, so a config cannot describe a crate that generates
only a schema document. That is the shape an umbrella npm package takes — one that re-exports
several generated crates' wasm classes without having a spec of its own. Its document must cover the
union of what it ships, and the config has no entry for it.
Until it does, that package keeps a small hand-written json-gen crate: a main.rs calling each
generated crate's add_schemas in turn through one schemars::SchemaGenerator, then the same
document assembly and reference-closure check a generated one performs. The registration order is
yours to choose there, and the reasoning is the one above — register the crate whose names you would
rather keep stable first.
The blocker is not the document. An input-less run already emits the right add_schemas; it also
emits a vestigial rust crate that the json-gen manifest then depends on, whose package name collides
with the umbrella crate you are trying to serve — a collision generation now names on stderr when
that umbrella crate is a workspace member (see --lib-name), rather than
leaving it for cargo to report at the next build.
Order, and which crate a collision blames
--json-schema-dep is order-significant: flag order is registration order, and registration order
decides which of two crates publishing one schema name keeps it. Dependency calls are emitted before
your own rows, so on a cross-crate collision it is your row that the injectivity guard blames —
the side whose owner can rename it.
A TOML sub-table is unordered: it deserializes into a map, so the raw json-schema-dep entries emit
in name order. That is deterministic but not author-controlled. Where order matters, use the
arrays — deps, wasm-reexports and json-schema-deps keep the order you wrote. Derived entries
are emitted before raw ones.
What this refuses
All of it before any crate generates:
json-schema-depsnaming a crate withjson-schema-export = false— hard error naming both crates. You asked for a call into a crate that generates no json-gen crate for it to reach; without the check, the failure is a cargo path-resolution error naming a directory that was simply never written. A derived edge onto such a crate is the silent skip above — that is the difference between "the config filtered it out for you" and "you asked for something impossible".json-schema-depson a crate withjson-schema-export = false— hard error: there is no document for the threaded rows to land in. (json-schema-deps = []is not a request and stays legal.)wasm-reexportsnaming a crate withwasm = false— hard error naming both crates. The key says your package ships that crate's wasm classes; a crate that generates no wasm crate has none, so the declaration cannot be about anything. Without the check it is silent rather than wrong — the threading derivation filters onjson-schema-export, a different axis, so such a name produced no diagnostic and no effect.wasm-reexportsorjson-schema-depsnaming an unknown crate — hard error listing the configured crates. Both derived values come from the named crate's own entry, so a crate outside this config cannot be sugar.- A crate naming itself in either — hard error: its own rows are already in its own document.
- A crate in both
depsandwasm-reexports— hard error. The edge exists once; the duplicate would derive the thread twice, which the flag itself rejects as one label under two mappings. - A name listed twice in any of the arrays — hard error, for the same reason.
wasm-reexportsorjson-schema-depsin[defaults]or a[profiles.*]table — hard error. They are edges, and a shared edge is not a graph.
What this does and does not buy
It does not make a forgotten root impossible — only the npm step can see a hand-written type
that no rule describes, and json-ts-types.js already does. It does make a forgotten thread
impossible, because the thread now follows from a list the project maintains anyway for the crate to
link.
One shared runtime crate
Many generated crates in one workspace usually want one copy of the static rust runtime
(error.rs, the serialization.rs prelude, ordered_hash_map.rs, the NonEmpty* types, …) rather
than one each. On the command line that is two flags — --export-static-crate writes the runtime,
--common-import-override points crates at it (both in
Command line flags) — and the first belongs on exactly one invocation. The
[runtime] table states it once:
[runtime]
export-static-crate = "crates/cddl-runtime" # where the shared runtime is written
common-import = "cddl_runtime" # applied to every crate
lib-name = "cddl-runtime" # its cargo package name — derives the dependency
-
common-importexpands to--common-import-override <value>on every crate. It is the lowest layer in the merge, so an explicitcommon-import-overridein[defaults], a profile, or a crate table wins for the crates it reaches — that is one crate importing a different runtime, which is legitimate rather than a conflict. -
export-static-crateis carried by exactly one crate's invocation, chosen by the derivation below, and resolves against the config file's directory like every other path key. -
A second
export-static-cratein any layer alongside[runtime].export-static-crateis a hard error. Two exports would race for one role, and letting either win would make which runtime survives depend on generation order. -
lib-nameis the runtime crate's cargo package name — the same vocabulary a[crates.<name>]table'slib-nameuses, for a crate this config does not generate. Naming it derives, for every crate,--rust-dep <lib-name>=<relative path to the export directory>and--std-forward-dep <lib-name>: the cargo dependency on the shared runtime, and the std forward that lets adefault-features = falsebuild reach the runtime'sno_stdarm instead of stopping at the generated crate.common-importcannot supply it — an override is a Rust path prefix, and no package name follows from one. Optional and opt-in: omit it and that dependency stays the hand edit it has always been.It must match the crate's actual
package.name. The runtime crate is hand-created by definition (a fresh one gets the tool's"cddl-runtime"seed on its first export), and the tool does not read that manifest to check — that would be a content read of a co-owned file for a rule one line states. A mismatch is cargo's own "no matching package named X found at path" error.lib-namewithoutexport-static-crateis a hard error: there is no directory to point at.
Which crate carries the export, and why it is derived
--export-static-crate exports the exporting crate's flavor. Choose the wrong crate and the
runtime cannot serve the others — so the choice is not a preference, it is whichever crate's flag
set the shared runtime must have. A config already knows every crate's flavor, so it derives the
carrier rather than asking you to get it right.
Five keys change a byte of what is exported, in two groups that behave differently. The compiler
errors behind each rule are spelled out under --export-static-crate in
Command line flags:
| key | rule | why |
|---|---|---|
preserve-encodings | must be identical in every crate | the preserve runtime's NonEmptyMap is backed by OrderedHashMap, so a non-preserve crate holding a {+ K => V} cannot build against it |
canonical-form | must be identical in every crate | the canonical and non-canonical preludes differ in the arity of fit_sz / to_len_sz / SerializeEmbeddedGroup, and in which crate defines Serialize |
deserialize-depth-limit | must be identical, and unset counts as a value | the limit is baked by value into the exported AnyCbor guard, so a mismatch compiles cleanly while guarding one crate's any values at another crate's limit |
json-serde-derives | the maximum wins | the serde companions are appended to the runtime types, so a runtime carrying them serves a crate that does not |
json-schema-export | the maximum wins | likewise for the schemars companions |
The carrier is the first crate whose flavor is that join. Any crate matching it writes byte-identical files, so which one is picked cannot be observed in the output — but the run says which it was:
[runtime] `chain` carries --export-static-crate: its flavor is the join of every crate's, so the
runtime it writes serves all of them.
A subset that leaves the carrier out does not refresh the runtime. The export rides the carrier's
invocation, so cddl-codegen --config c.toml cip25 uses the committed runtime as it stands — the run
says so instead, naming the carrier. That is the same "a subset trusts committed output" contract the
dependency edges follow, and it is usually what you want; the case to know about is a fresh
workspace, where the runtime has never been written and every crate in the subset is pointed at it by
--common-import-override. Generate once without a selection first.
Two configurations have no carrier, and both are hard errors rather than a silently inadequate runtime:
- The crates disagree on an identical-value key. The error names each such key and which crates hold which value.
- No single crate has the join — one crate sets
json-serde-derives, another setsjson-schema-export, and neither sets both.--export-static-crateexports the flag set of one invocation, so a runtime nobody's flags describe cannot be written at all; the error names which crate supplies each key.
flavor-from, when you know
A workspace can legitimately want one runtime for crates that do not all agree — a reduced-flavor crate whose spec happens to use none of the constructs that would break. Naming the carrier by hand accepts that:
[runtime]
export-static-crate = "crates/cddl-runtime"
common-import = "cml_core"
flavor-from = "chain"
flavor-from must name a crate in this config, and requires export-static-crate (there is
otherwise nothing to carry). It fires no per-run warning — you have declared this, and a warning
that repeats on every run trains people to ignore warnings — but the run states once what was
accepted:
[runtime] `chain` carries --export-static-crate, declared by `flavor-from`.
[runtime] Generated at a flavor the shared runtime does not match: `cip25`. They compile against it
only while their specs hold no `{+ K => V}` (whose `NonEmptyMap` is backed by `OrderedHashMap` under
--preserve-encodings) and no `any` (whose `AnyCbor` serialize arity follows --canonical-form), and a
crate whose --deserialize-depth-limit differs has its `any` values guarded at `chain`'s limit rather
than its own.
That is the substantive difference from placing the flag by hand: the same single declaration, plus a named hazard the run repeats back to you.
Paths resolve against the config file
Every path-valued key — input, output, static-dir, export-static-crate, and the right-hand
sides of extern-import, component-extern-wit, extern-wrapper-index, wrapper-requests and
key-requests — resolves
against the directory holding the config file, never the current working directory. A config
checked into a repository therefore means the same command works from anywhere, which is what
retires the trap where --static-dir static silently picks up whichever checkout you happen to be
standing in.
Absolute paths pass through untouched.
Five right-hand sides are deliberately not resolved. Two of them are not paths at all:
extern-wasm-crate names a crate, and json-schema-dep names a Rust module path that lands
verbatim in generated code.
json-gen-dep, wasm-dep and rust-dep are the other three, and they are the ones worth stating:
their right-hand sides are paths, and are still passed through verbatim. Each becomes a cargo
path dependency — in <output>/wasm/json-gen/Cargo.toml, <output>/wasm/Cargo.toml and
<output>/rust/Cargo.toml respectively — and cargo resolves such a path against the manifest holding
it, so rewriting it against the config file's directory would point it somewhere cargo never looks.
Count each from the crate whose manifest it lands in, exactly as you would writing the entry by hand.
(For a dependency this config contains you do not write any of them at all: the threading
derivation, the wasm manifest and the rust
manifest compute those relative paths for you.)
Booleans
Booleans are TOML booleans, never the --flag true string form:
preserve-encodings = true
wasm = false
One key is spelled positively where its flag is negated: --no-preserve-comments becomes
preserve-comments = false. TOML has booleans, so the config does not make you write a double
negative; omitting the key, or setting it to true, leaves
edit preservation on as it is by default.
deserialize-depth-limit is a TOML integer.
Verbosity is per crate
verbosity is an ordinary key: [defaults], a [profiles.<name>] table or one [crates.<name>]
table, merged by the same precedence as every other value.
[defaults]
verbosity = "warn"
[crates.chain]
verbosity = "debug"
The per-crate case is why the key exists. A multi-crate run's output is every crate's output in
sequence, so the level you want while debugging one crate is not the level you want for the other
five — and a run-global switch cannot say that. A [crates.<name>] value raises the level for that
crate alone; the rest stay at whatever [defaults] says. The five level names, and the
stdout/stderr split that decides where each message lands, are under
--verbosity.
A command-line --verbosity overrides the key for every crate in the run — it is one of the
two generation flags --config accepts.
One asymmetry, stated here rather than left to be discovered. A [profiles.*] or
[crates.*] value governs only that crate's own generation. The run-level lines — the [runtime]
notes, each crate's [name] generating from … into … banner, the [converge] re-run notes and the
residual convergence warning — follow [defaults] (or the command line) instead. This is the
reading that follows from the merge model rather than a special case: [defaults] is the value that
reaches every crate, and the run is what contains every crate. So [crates.chain] verbosity = "error" silences chain's own output while chain is still announced — a quiet crate that is not
even named would cost a multi-crate log its only orientation — and [defaults] verbosity = "error"
silences the announcements too.
What the config refuses
The config file is checked before any crate generates, so a mistake in the last crate's table cannot leave the first crate's output half-migrated on disk.
- An unknown key is a hard error naming it, at every level — crate table,
[defaults],[profiles.*], and the top-level tables. This is the config-file equivalent of a misspelled flag, which is already rejected; letting a typo fall back to a default would ship a crate built with the wrong flag set and no diagnostic at all. - A flag COMBINATION the generator refuses is rejected up front too, against the crate whose
merged settings produced it. These are the generator's own rules —
json-schema-scriptswithoutjson-schema-export,canonical-formwithoutpreserve-encodings, ajson-gen-deppackage named twice — and every one of them is reachable from a shared key, where the crate that trips it is usually not the crate you wrote the key in. - A value the flag itself rejects is reported against the config key that produced it, so you are pointed at the TOML line you wrote rather than at a flag you never typed.
- An unknown crate name on the command line is a hard error listing the configured crates, rather than a run that generates nothing and exits successfully.
- Two crates cannot generate into one
output, nor one inside another's. A crate's output is regenerated as a whole, so the crate running second would erase the first's generated tree while the first's seed-oncelib.rssurvived — a crate root belonging to one spec sitting over a generated tree belonging to another, with the run reporting success. It is also the copy-paste mistake this file invites most: duplicate a[crates.*]block, changeinput, forgetoutput. Sibling directories that merely share a name prefix (gen/alphaandgen/alphabet) are fine. - A cross-crate path hand-spelled at a crate this config generates is a hard error, naming the
crate, the key, and the
depsline that replaces it. Inside one config the edge isdeps; the hand-spelled sub-tables are for a dependency the config does not generate. See Dependencies between crates for which sub-tables this covers and which it deliberately does not. - A
[runtime]no single static runtime can serve is a hard error naming the keys the crates disagree on, or the crate each missing key comes from. Selecting a subset does not soften it: which crate can carry the shared runtime is a property of the config, socddl-codegen --config c.toml one-craterejects exactly what a full run rejects. [runtime].lib-namewithoutexport-static-crateis a hard error. The key derives every crate's cargo dependency on the shared runtime, whose PATH is the export directory — so with no export there is nothing to point at. Same shape as the empty-[runtime]andflavor-fromrefusals: a key that cannot mean anything is a typo, not a request.export-static-cratemay reach at most one crate, from whichever layer it comes and whether or not there is a[runtime]table. It is an ordinary shared key, so one[defaults]line is a single layer and one export site per crate — and two crates exporting into one directory is not two writes of the same bytes. At differing flavors the second export does not replace the first: the exported crate is outside the stale-file bookkeeping so the first flavor's files linger, the manifest merge accumulates both flavors' dependencies, and the comment-preservation overlay cannot classify the previous flavor's output, so it injects a freshcompile_error!block every run. Measured on a two-crate config differing only inpreserve-encodings: the exportedany_cbor.rsgrew 62 → 143 → 224 → 305compile_error!blocks over four runs of one unchanged config, exit 0 each time. Put the key on the one crate whose flavor the runtime should have, or hoist it to[runtime].export-static-crateand let the config derive the carrier.
Generation itself is not transactional across crates
What is checked up front is the config. Once the first crate starts generating, the run is a
sequence of independent regenerations, and a crate that fails to generate stops the run
where it stands — whether its own CDDL spec is what the tool refuses, or a cross-crate sidecar it
consumes (wrapper-requests / key-requests) is: the crates ordered before it
are on disk in their regenerated form, the failing
crate's own output may be partly written, and the crates after it are untouched. This is not a
rollback the tool can perform — each crate's output is a committed directory it clobbers in place.
So the failure says exactly that instead, naming the crate that failed and listing the crates the run had already finished:
[crates.ledger] failed to generate: <the spec's own error>
1 crate was already regenerated in this run before this failure: core. Generation is not
transactional across crates: ...
Generated output is committed, which is what makes the undo ordinary: git checkout on the listed
crates' output directories restores them.
--config cannot be mixed with generation flags
Passing any generation flag alongside --config is a hard error naming the flag. There is no
flags-override-config precedence on purpose: every override would have to define whether it applies
to one crate or to all of them, and the honest answer differs per flag. Crate names — positionally,
or closed over deps with
--with-deps — are the only
command-line selector; the config file is the edit loop.
Selecting a subset generates only the crates you name. Their order on the command line does not matter — the config decides the generation order.
The two exceptions: --static-dir and --verbosity
--static-dir (short -s) is accepted alongside --config and applies to every crate in the
run:
cddl-codegen --config codegen.toml --static-dir ../cddl-codegen/static
It is exempt because it is the one flag with nothing per-crate to decide. It names where this machine keeps cddl-codegen's own hand-written serialization runtime — a property of a checkout, not of any crate — so "one crate or all of them?", the question that rules every other flag out, has a single answer here. It is also the one value a committed config cannot get right by itself.
A command-line value wins over a static-dir key, silently and everywhere: the key is a
committed default and this is the per-machine override of it, so a conflict here is the intended use
rather than a mistake to report. --print-flags shows which one is in force — the overriding value
is listed with the provenance command line instead of a config key.
Unlike every key inside the config, the command-line value is not resolved against the config
file's directory: it did not come from the config file, so a relative --static-dir vendor/static
means what it means on any other command line — relative to the current directory. Prefer an
absolute path in a script that may run from anywhere.
--verbosity (short -v) is the other, and it is exempt on the same criterion: there is exactly one
answer to "which crate does it apply to", namely all of them — including the run-level lines a
[crates.*] value cannot move. What differs is only why the command line
is the right place for it. The key is the project's committed default and the flag is this
invocation's override of it, so — as with static-dir — the override winning silently is the
intended use rather than a conflict to report, and --print-flags lists the overriding value with
the provenance command line.
Seeing what a config does, without running it
--print-flags prints the flag list each crate would be generated with, and generates nothing:
cddl-codegen --config codegen.toml --print-flags # every crate, in generation order
cddl-codegen --config codegen.toml --print-flags core # just this one
# The flags each crate WOULD be generated with, and the config key each one comes from.
# This is a listing, not a command line: the left column is a config key rather than an
# argument, nothing is shell-quoted, and a copy of it stops being true at the next edit of
# the config. Nothing was generated.
[crates.core]
input --input=specs/core.cddl
output --output=gen/core
lib-name --lib-name=core-lib
preserve-encodings --preserve-encodings=true
deps (from [crates.ledger]) --wrapper-requests=ledger=gen/ledger/wasm/src/generated/borrowed_collections.rs
deps (from [crates.ledger]) --key-requests=ledger=gen/ledger/rust/src/generated/borrowed_key_types.rs
[crates.ledger]
input --input=specs/ledger.cddl
…
deps --workspace-dep=core
deps --extern-import=core=gen/core/extern-interface/core
Every line leads with the config key that produced it, which is the question worth answering:
whether a [defaults] key reached a crate, or a deps edge derived the path you expected, is
otherwise visible only by generating and reading the output tree. Every flag an edge derives is
tagged deps rather than with its own flag name, since those name no line in your file. A
reverse-edge flag lands on a dependency's block but comes from a consumer's deps — a table the
dependency does not have — so those read deps (from [crates.<consumer>]), naming the file's line
you would actually go and edit. A sub-table entry you wrote by hand keeps the key you wrote it under,
and a value overridden from the command line is tagged command line.
Each flag is one token, --name=value, rather than two. That is what lets a value begin with a -
(a directory named -out, a library named -x): everything after the first = is taken verbatim,
so a <k>=<v> sub-table value such as --extern-import=core=gen/core/… survives it unchanged.
The expansion behind the listing is the real one: every path resolution, every derivation and every validation. A config a run would refuse is refused here, with the same message.
Deliberately not shell-quoted, and deliberately not a token sequence any shell accepts. A pasted flag list is accurate on the day it is copied and silently stops being so at the next edit of the config — which is the duplication this feature exists to remove, not to mint more of. Use it to answer why is this flag here, and keep the config as the thing that is maintained.
Migrating a generation script
The shape this feature was designed against is a real one: codegen.sh in
cardano-multiplatform-lib, which generates
four crates with a shared flag bundle, a fifth reduced-flavor invocation, a workspace edge, and a
hand-threaded JSON surface. That script's config is the complete example above;
this section maps it back onto the script piece by piece, so the correspondence is greppable from
either side.
This is a mapping, not a migration. Nothing in this feature has been run against CML or any other
consumer repository — deliberately, since generation rewrites src/generated/** in place. Treat what
follows as the translation to start from, not as a result someone has already reproduced.
The flag bundles
The script keeps three arrays and composes them per invocation:
| script | becomes |
|---|---|
OVERRIDE — --common-import-override=cml_core, --no-synthesized-rust-collection-aliases=true | the first flag is [runtime] common-import; the second is a [defaults] key |
WASM_MACROS — --wasm, --rust-wasm-feature, the three macro flags | [defaults] keys |
COMMON = --preserve-encodings + --canonical-form + --json-serde-derives + --json-schema-export + OVERRIDE + WASM_MACROS | the json pair joins [defaults]; the encoding pair becomes a profile, because one crate omits exactly those two |
CIP25_WASM_MACROS — WASM_MACROS with a different --wasm-cbor-json-api-macro | one per-crate key on that crate, overriding the shared value |
A key every crate sets belongs in [defaults], which is where the first two rows land whole. The
encoding pair is the row with a choice in it, and the encoded profile keeps the boundary the script
already drew — COMMON minus what the cip25 invocation leaves out. For two booleans and a single
deviating crate, [defaults] plus an explicit preserve-encodings = false on cip25 is the other
legal spelling, and a line shorter; the profile is kept here because it carries the
script's own name for the axis. CIP25_WASM_MACROS needs neither — a bundle that differs from the
shared one in a single value is a per-crate key, since later layers win per
key.
The crate that omits the encoding flags
cip25 is generated without --preserve-encodings / --canonical-form — it never had them. In the
config that is simply the crate that does not apply the encoded
profile.
That deviation is on two of the three equality axes,
so this workspace is the flavor-from case: no single runtime matches every crate, and the
carrier cannot be derived. The script already knows this — it places --export-static-crate on the
chain invocation with a comment saying cip25's reduced flavor would export a runtime the others
cannot use. flavor-from is that comment turned into a declaration — the third line of the
[runtime] table above.
What the declaration asserts is narrower than "chain is the right crate". It asserts that you accept
generating cip25 against a runtime built at chain's flavor, and the run states the accepted gap
back to you by name — that cip25 compiles against that runtime only while its spec holds no
{+ K => V} and no any. It is a claim about cip25's spec, and one that a future spec change can
falsify; the statement is what makes that visible on the run that introduces it.
The workspace edge
multi-era consumes chain. The script spells that with three flags on the consumer
(EXTERN_WASM_MULTIERA) and one on the dependency (WRAPPER_REQUESTS_CHAIN); the config spells it
deps = ["chain"] on [crates.multi-era].
That one key derives the whole edge, both directions, where the script passes four flags. Three things it does not pass come with them, and each is worth knowing before you make the edge:
-
--extern-importis derived, and it is where the migration is. CML declares its extern dependencies by hand, in_CDDL_CODEGEN_EXTERN_DEPS_DIR_/cml_chainunder the consumer's spec directory, and passes no--extern-import.depspoints the flag at the dependency's generatedextern-interface/export instead, and the two are alternatives rather than layers: a dependency named bydeps— or by a hand-writtenextern-importentry — that the consumer also declares as a stub directory is a hard error, naming the key that declared the edge and the stub directory it collides with, during expansion and so before any crate generates. Adoptingdepstherefore means deleting that stub tree, which is a regen with a reviewable diff rather than a no-op;CML_MIGRATION.mdin this repository carries the measured steps and the diff classes for exactly this edge.What makes the deletion safe is that the import is narrowed: only the export rules your spec reaches — closed over the export's own rule bodies — enter your document, so an export rule nothing reaches is inert, including one whose name your spec defines itself. multi-era's own
block = _CDDL_CODEGEN_EXTERN_TYPE_and chain's exportedblocktherefore coexist, the first resolving to multi-era's hand-owned re-export and the second never entering the document at all. See--extern-importfor the closure and for the two cases that stay hard errors. -
--key-requestsis derived too, and the script passes none. It is the dependency-side half of the map-key-derive channel: the dependency starts reading the consumer'sborrowed_key_types.rssidecar and derivingEq/Ordwhere the consumer keys a map on one of its types. Additive, but it is a change to the dependency's generated code. -
The manifest entries (
--rust-dep,--wasm-dep, and--json-gen-depwhere the JSON surface applies) are derived, and CML hand-maintains the same entries today. They are asserted, never removed: an entry that already exists keeps the fields the derivation does not set — aversion =pin and anyfeaturessurvive — whilepathis re-asserted at the derived value. See The wasm manifest and The rust manifest.
One smaller diff to expect in the regen: the reverse edges' left-hand side is a label that lands in
the dependency's /// Generated at the request of: … attribution comments, and the derived label is
the consumer's normalized library name (cml_multi_era), where the script writes cml-multi-era.
A dependency with no export is not a deps edge at all, and that is what the raw sub-tables are
for. cip36 declares two extern crates by hand, and they are two different cases. cml_crypto is
hand-written and this config does not contain it, so there is no [crates.cml_crypto] table for
deps to name and never will be: its stub plus raw sub-table
entries — cip36's extern-wasm-crate mapping above
— is the only spelling there is.
cml_chain is the other case: the config does contain that crate, and cip36 simply keeps its own
stub for it rather than declaring the edge. That is a second migration unit, decided on its own
terms — giving cip36 deps = ["chain"] means deleting cip36's chain stub the same way multi-era's
goes, so the mapping above leaves it alone rather than folding two migrations into one.
What there is deliberately no spelling for is keeping a stub and declaring the edge. Holding on
to a stub means not writing deps, and that costs the whole edge rather than just the import: every
row of the derivation table becomes a hand-written sub-table entry on
both crates' tables, the two reverse edges included — and those are the ones whose omission is
silent, since a dependency that never reads a consumer's sidecars simply does not host the wrappers
and key derives that consumer borrows, which is a workspace that does not build for any consumer that
borrows one. deps is also what the generation
order and the committed-state
verdict are computed from, and neither of those has a per-key
spelling at all.
JSON_ROOTS_*
Each array becomes a per-crate json-schema-root list — cip25's single entry and multi-era's
three above. Array order is flag order, which is emission order, so it is
preserved rather than sorted.
Those four are fewer entries than the script's arrays hold, and the difference is not the config's
doing. A root's only observable purpose is typing a published wasm class's JSON method, and most of
the script's entries name classes that declare none; CML_CONFIG_MIGRATION.md in this repository
carries that census. Pruning them is its own reviewable diff, separate from the flag-set change.
JSON_SCHEMA_DEP_MULTIERA
The script threads three dependencies' row sets into multi-era's schema document, and separately
hand-declares each of those crates' json-gen packages in multi-era/wasm/json-gen/Cargo.toml. Both
halves are derived from the same two facts — which crates multi-era
depends on, and which crates its npm package ships, which are the deps and wasm-reexports
lines on [crates.multi-era] above.
deps already covers cml_chain; wasm-reexports declares the other two, which multi-era's package
ships without its spec referencing them. The derivation emits --json-schema-dep in
deps-then-wasm-reexports order, which is the order the script writes by hand — and it emits
--json-gen-dep alongside, which is what retires the hand-maintained [dependencies] entries in the
json-gen manifest. The relative path in those entries is computed for you.
The same two lines also retire the hand-maintained cml-cip25-wasm / cml-cip36-wasm entries in
multi-era/wasm/Cargo.toml — the ones under the "not actual multi-era dependencies but we re-export
these for the wasm builds" comment — plus cml-chain and cml-chain-wasm from the deps edge. See
The wasm manifest. The deps edge also retires the cml-chain entry in
multi-era/rust/Cargo.toml, which is the rust manifest.
The generation order flips
This is the change a migrating project feels first. The script runs multi-era before chain, and says why: multi-era rewrites its sidecar, chain reads it fresh. A config sorts topologically with dependencies first, so it runs chain, cip25, cip36, then multi-era.
That is not a better order, it is the opposite side of the same conflict. The two edge kinds want opposite orders and no single ordered pass satisfies both. Running the consumer first means the consumer reads the dependency's previous output; running the dependency first means the dependency reads the consumer's previous sidecar. What the config adds on top of the order is the convergence pass: it re-runs the dependencies whose sidecars the pass rewrote, so a full run resolves the trade rather than merely picking a side of it. The staleness that remains is the one no single command can fix — a subset run, where multi-era adds a borrow and chain was not in the run. There the committed-state verdict names the crate, fails the run, and prints the command that converges it.
CML's hand-rolled check_convergence reads the same two files against each other — multi-era's
borrowed_collections.rs against chain's collections.rs — and fails the script the same way. The
built-in verdict is that check generalised to every deps edge the config declares, so it covers the
single-crate regen the script's own comment names as the scenario it guards.
What does not move
A config replaces the flags. It does not replace the wrapper script, and a project deciding whether to migrate should count on keeping one:
- The tool pin, and fetching it.
CDDL_CODEGEN_PINNED_REV, the cached clone, thegit archiveextraction of a rev, theCARGO_TARGET_DIRtuning — all of it is about which cddl-codegen runs, and a config file is an input to a cddl-codegen that is already running. cargo fmt --alland the clippy gate. These run over the whole repository after generation, hand-written code included, and one of them is a policy decision the script documents at length (check only, never--fix, because a fix undersrc/generated/is reverted by the next regen).- The dirty-tree warning, and everything else about how the project reviews a regen — in CML's
case, regenerating in place and reading
git diff. - Opt-in passes that generate a second spec set into an existing crate. CML's
byronpass targets the same output and the samelib-nameasmulti-erafrom a different spec directory. Two crate tables cannot share a library name — that is a hard error — so this stays a flag invocation. It cannot be added to the config command either, since the only generation flags--configaccepts are--static-dirand--verbosity; it stays its owncddl-codegencall in the script, beside the--configone.
What the config removes from the script is the part that was duplicated and unchecked: the flag bundles, the cross-crate paths that two invocations had to agree about, and the ordering comment explaining which invocation had to come first.