Milestone 2: codegen via daml-lf-archive (SCU-aware) + dpm component - #1
Merged
Conversation
Begin the M2 code generator on a decoder-agnostic IR so the pivotal LF-decoder choice (JVM daml-lf-archive vs a native Rust decoder) stays isolated to one module. This first slice covers the decision-independent half: - ir: intermediate representation (records, fields, the DamlType sum) - map: the Daml-LF -> Rust type mapping (Party/ContractId/Numeric/List/ Optional/TextMap/GenMap/... + references and type parameters) - emit: record -> struct generation via quote/prettyplease, with Rust keyword escaping and snake_case field names - generate_record verifies its own output parses as valid Rust (syn) Includes the M2 execution plan (planning/milestone-2-plan.md) and a runnable example. Tests + clippy green.
Extend canton-codegen's IR and emitter beyond records: - ir: DataType (Record/Variant/Enum), Variant/VariantConstructor, Enum, Template, Choice - emit: variant -> Rust enum (payload or nullary constructors), enum -> C-like enum, template -> payload struct + a typed `rt::Choice<T>` impl per choice (arg -> template -> return, NAME/CONSUMING) - fix identifier handling: Daml type/constructor names are used as-is (they are already valid Rust idents); only type *variables* are upper-camel-cased (a -> A), so names with `_` are no longer mangled - generate_data_type / generate_template, each verifying valid Rust (syn) 5 tests, demo covers template/variant/enum. Plan updated with the decoder evaluation (fujiapple daml-lf is LF-1.14/archived; Phase B -> a native LF-2.x decoder blueprinted on its design).
Close the codegen loop so generated types compile and move on/off the wire. New crate `canton-daml` (the `rt` runtime generated code depends on): - primitives: Party, ContractId<T> (typed, phantom tag), Numeric, Timestamp, Date, TextMap, GenMap - Choice<T> trait (Return / NAME / CONSUMING), matching the emitted impls - ToValue / FromValue codecs to the Ledger API `Value` (gRPC wire form) for every primitive + Option/Vec/TextMap, with typed decode errors canton-codegen: `generate_module` wraps emitted items with the module preamble (`use canton_daml as rt;` + allow-attrs for generated names like `AppInstall_Accept`), producing a ready-to-write `.rs` file. Tests: canton-daml round-trips primitives/containers/contract-ids through `Value` and checks Choice metadata + shape-mismatch errors; canton-codegen validates whole-module output is valid Rust. clippy/fmt green.
Generated records now carry their wire codec, not just their shape: - emit `impl rt::ToValue` / `impl rt::FromValue` for each (non-generic) record, keyed by the Daml field label, via the runtime `record` / `record_field` helpers; template payloads get them too - canton-daml: `record` / `record_field` builders + re-export the Ledger API `Value` as `rt::Value` - generic-record codecs are deliberately deferred (need per-parameter bounds) — the struct is still emitted Proof: canton-daml round-trips a record through `Value` using the exact pattern the generator emits (Party/Numeric/List/Optional fields, plus a missing-field error); canton-codegen asserts the codec impls are emitted and the output is valid Rust. 7 + 6 tests, clippy/fmt green.
Add an env-gated integration test (CODEGEN_COMPILE_TEST=1) that generates a module, writes it as a standalone crate depending on canton-daml, and runs `cargo test` on it — proving the generator's actual output compiles against the runtime and round-trips a value through the Ledger API `Value` end to end. Skips by default (spawns cargo, ~17s) like the M1 live tests.
Begin the native Daml-LF decoder. First layer: the DAR container. - new crate `canton-lf` with `Dar`: opens a `.dar` (a JAR-style zip of `.dalf` packages), parses `META-INF/MANIFEST.MF` (un-wrapping the 72-col continuation lines), and exposes the main package's raw Daml-LF bytes, the dependency package bytes, name, and SDK version - verified against a real Canton 3.x DAR (splice-wallet-payments-0.1.14, sdk 3.3.0 — 38 packages, 279 KB main) via an env-gated test, plus a pure manifest-unwrap unit test Next (the large remaining piece): vendor the LF 2.x protobufs, prost-decode the package bytes, and lower the AST into canton_codegen::ir.
Add the second required codec (proposal M2: "JSON and gRPC codecs"): - canton-daml newtypes get LF-JSON serde: Party/Numeric/ContractId → JSON string, Timestamp → RFC3339, Date → YYYY-MM-DD (via `time`), GenMap → array of [k,v] pairs; re-export `serde` as `rt::serde` - the generator derives `rt::serde::Serialize`/`Deserialize` on records with `#[serde(crate = "rt::serde")]` and `#[serde(rename = "<damlLabel>")]` so the JSON keys are the Daml field labels - vendored the LF 2.x archive protos for the next step (canton-lf/proto/, daml_lf.proto + daml_lf2.proto @ v3.3.0-snapshot.20250507.0) Proof: canton-daml checks the newtype JSON encodings; the end-to-end compile test now round-trips the generated type through BOTH the gRPC `Value` codec and JSON. Deferred: nested-Optional LF-JSON, generic-record codecs.
Wire prost-build over the vendored LF 2.x protos and decode real packages: - canton-lf build.rs compiles daml_lf.proto + daml_lf2.proto (vendored protoc); the generated AST is exposed as `canton_lf::pb` - decode: `.dalf` bytes -> Archive -> ArchivePayload -> daml_lf_2::Package, rejecting non-LF-2.x; `interned_str` resolves the LF 2.x interning table; `package_name`/`package_version` recover the SCU inputs from PackageMetadata - like canton-proto, this proto-hosting crate opts out of the workspace unsafe_code=forbid lint (build.rs sets PROTOC) Proven end to end: decodes the main package of a real Canton 3.x DAR (splice-wallet-payments 0.1.14 — 2 modules, 1829 interned strings, 2728 interned types) via an env-gated test. Next (the remaining large piece): lower the decoded AST — modules, data types, templates, choices, interfaces, contract keys — into canton_codegen::ir, resolving all interned indices.
Address three code-review findings: - JSON codec: nested Optionals were collapsed by plain serde (Some(None) and None both -> null, losing data). Add `rt::NestedOpt<T>` implementing the LF-JSON nested-optional list form (None -> [], Some(x) -> [x], recursively [[]] / [[x]]); the generator wraps every Optional *nested* inside another in `NestedOpt`, while the top-level Optional stays a plain `Option` (null/value). gRPC encoding is unchanged (proto Optional). Round-trip now preserves Some(None) vs None. - canton-lf: use prost-build's `protoc_executable` instead of `unsafe env::set_var`, so the crate re-enables `[lints] workspace = true` — its hand-written `dar`/`decode` logic is strictly linted again. - Document the LF/SDK/Canton version-pin policy (ADR-0009) and the native LF-decoder decision + rationale (ADR-0008). canton-daml 9 + canton-codegen 9 tests; workspace 122 no-node tests; clippy 0 across all crates.
Connect the decoder to the generator. New `canton_codegen::lower` (canton-codegen now depends on canton-lf) walks a decoded LF 2.x `Package` into the IR: - serializable records / variants / enums lower fully (template payloads are records, so they lower here too); interning is resolved (interned_str / interned_dotted_name / interned_type added to canton-lf) - field types cover the LF builtins (Unit/Bool/Int64/Numeric/Text/Party/ Timestamp/Date/ContractId/List/Optional/TextMap/GenMap), references to named types, and type variables; Forall/Struct/Syn/unknown-builtins yield a LowerError (skipped) rather than silently-wrong output - the IR reserves interface shape (view + choices) so it is fixed now, not retrofitted; templates-with-choices and interface lowering are next Proven end to end (env-gated): decode + lower + generate on a real Canton 3.x DAR (splice-wallet-payments 0.1.14) → 56 data types, 2 modules, 61 KB of syntactically valid Rust.
…iew) Address the compile-review findings (verified by compiling real generated output against canton-daml, not just syn::parse): - #1 variant & enum Value codecs + serde: enums encode as the constructor string, variants as adjacently-tagged {"tag","value"}; new runtime helpers variant_value/variant_parts/enum_value/enum_constructor/unit_value/ unexpected_constructor - #3 GenMap Value codec (pb::value::Sum::GenMap) - #4 generic records/variants get codecs too, bounded via `codec_header` (impl<A,B> ... where A: ToValue, ...) - strengthen tests/compile.rs: generate a module with enum/variant/generic/ GenMap/nested-Optional and compile + round-trip it against canton-daml — the test that actually catches compilability - second-order: lower_package returns (Module, Vec<LowerError>) (amulet: 195 lowered, 0 skipped, no silent swallowing); decode_* returns the package id (Archive hash) for the upcoming PackageMap work Still open (next milestone): #2 cross-package references and #5 name collisions — both need module qualification, so they go together.
…ions) Lower a whole DAR — main package plus its full dependency closure — into a qualified Rust module tree (crate::<package>::<module>::<Type>), so cross-package references resolve and names from different Daml modules can no longer collide. - canton-lf: decode_all() decodes every package with its id; Dar::package_bytes exposes the closure. Vendored daml_lf2.proto empties Expr so prost skips term-level bodies (codegen needs only the type skeleton) — this also avoids the decoder's recursion limit on real packages' case trees, without weakening the recursion limit on the gRPC path. - codegen: TypeRef carries a path; new Crate/PackageModule/NamedModule IR; lower_dar/lower_crate resolve SelfOrImportedPackageId (imported package by its interned id-hash); generate_crate emits the pub mod tree. - Generated code that compiles at closure scale: interface markers (phantom tag so ContractId<I> resolves), PhantomData for unused (phantom) type parameters, Box on directly-recursive types, and identifier hardening for tuple fields. Runtime gains Box<T> ToValue/FromValue. - tests/compile.rs now compiles the whole generated DAR crate against canton-daml. Verified on splice-amulet (288 types) and splice-wallet (440 types); wallet-payments and token-metadata lower with 0 unresolved refs.
Newer Daml-LF (2.dev, SDK 3.5+) references imported packages through an explicit import table rather than an interned id string: SelfOrImportedPackageId gains a package_import_id (tag 4) that indexes Package.package_imports.imported_packages (tag 9), each entry a target package-id hash. Vendor these fields from Canton's authoritative daml_lf2.proto and resolve the index -> hash -> package module, so cross-package references in 3.5.x DARs resolve instead of collapsing to self. Add a corpus compile test (CANTON_TEST_DAR_DIR) that decodes, lowers, and compiles every .dar in a directory against canton-daml, with a per-DAR temp crate so runs do not race. Verified on 18 DARs spanning two SDK versions (3.3.0 / 3.5.2) — Splice production, token-standard, and custom non-Splice packages — all compile.
A nullary variant constructor was emitted as a bare Rust unit variant, which
serde renders as {"tag":<c>} — but the LF-JSON and JSON Ledger API form is
{"tag":<c>,"value":{}} (Unit is the empty object {}), so generated code neither
produced nor accepted the API's shape. The root cause was broader: DamlType::Unit
mapped to Rust (), whose serde form is null rather than {}, so any Unit-typed
field was wrong too.
Add a runtime rt::Unit (serde {} <-> Unit, tolerant of null; gRPC = proto Unit),
map DamlType::Unit to it, and emit nullary constructors as Ctor(rt::Unit) so every
constructor is a uniform newtype variant. Covered by a canton-daml unit test and a
generated-crate test asserting the exact {"tag":"Point","value":{}} form over JSON
(both directions) and gRPC. All 18 corpus DARs still compile.
lower_crate now reads a module's templates. Each template folds in its same-named payload record (so the payload struct is still emitted exactly once), its choices (name, consuming flag, argument and return types), and its contract-key type. A new rt::Template trait carries the on-ledger template id (package:module:entity), and the emitter adds an `impl rt::Template` per template next to the typed Choice impls, so generated crates now contain real choice bindings (splice-amulet: 26 templates, 83 Choice impls). Also model LF 2.dev's curried type application: SelfOrImported already gained the import table; Type.sum now gains TApp (tag 9), which newer compilers use in place of the flattened Con/Builtin arg lists. Lowering unifies both shapes through a single apply(head, extra_args), so a choice return type expressed as `((f a) b)` resolves the same as `f [a, b]`. Without this the return record decoded as an empty type and was dropped, dangling the reference on SDK-3.5.2 DARs. All 18 corpus DARs (SDK 3.3.0 and 3.5.2) still compile with templates and choices present.
Close the typed command path (proposal Phase C). canton-daml gains: - create_command<T: Template>(payload) -> Command, building a Ledger API CreateCommand from a generated template payload; - exercise_command<T, C: Choice<T> + ToValue>(contract_id, arg) -> Command, building an ExerciseCommand from a contract id and a typed choice argument. These construct Command values only — submission stays in canton-ledger. The Template trait gains PACKAGE_NAME and a template_id() default that uses the upgrade-friendly `#<package-name>` identifier form, so the participant resolves the version vetted under Smart Contract Upgrade instead of pinning a package-id hash; codegen emits the extra const. Covered by a canton-daml unit test (create -> CreateCommand, exercise -> ExerciseCommand) and all 18 corpus DARs still compile.
A keyed template now exposes its key type through a new rt::WithKey: Template trait (type Key: ToValue), and codegen emits `impl rt::WithKey for <Template>` only when the template declares a contract key. The new rt::exercise_by_key_command<T: WithKey, C: Choice<T> + ToValue>(key, arg) builder produces a Ledger API ExerciseByKeyCommand, carrying the key as its contract_key value. Covered by codegen unit tests (keyed template emits WithKey; keyless emits none) and a canton-daml unit test. None of the 18 corpus DARs declare contract keys, so the emit path is proven by the unit tests; the corpus confirms keyless templates emit no WithKey and all 18 still compile. Closes the Phase C contract-keys line.
Interfaces now generate typed Rust, not just phantom markers. lower_crate lowers
a module's interfaces — their view type and choices — onto the interface's marker
struct. The trait model is refactored around a shared rt::Contract that carries
the on-ledger identity (package/module/entity + template_id): rt::Template is
Contract + ToValue + FromValue, and rt::Interface is Contract with an associated
view type. exercise_command now bounds on Contract, so a choice can be exercised
through a ContractId<Interface> without knowing the concrete template.
Per interface, codegen emits impl rt::Contract + impl rt::Interface { type View }
plus a typed rt::Choice per interface choice; the marker struct still comes from
the interface's data type. Verified by codegen and canton-daml unit tests
(exercise on a ContractId<Holding>) and the corpus: splice-amulet emits 9
Interface / 106 Choice / 35 Contract impls, splice-wallet 9 / 177 / 50, and all
18 DARs still compile. Closes Phase C (templates, choices, interfaces, contract
keys, JSON+gRPC codecs, SCU).
New crate canton-codegen-cli with a dpm-codegen-rust binary, invoked as
`dpm codegen-rust` (dpm's git/cargo-style subcommand convention) or standalone /
from a build script the way prost-build is. It is a thin wrapper over the ready
lower_dar + generate_crate:
dpm-codegen-rust --dar <path> --out <dir> [--name <n>]
[--runtime-path <p> | --runtime-version <v>]
and writes a self-contained crate (Cargo.toml depending on canton-daml, plus
src/lib.rs). The core lives in the crate's library (generate(Options) -> Stats)
so it is testable without spawning the binary; main.rs is hand-rolled arg parsing
(no new dependency). Verified end-to-end: generated splice-amulet-0-1-14 (26
packages / 42 modules / 306 items, 0 skipped) and compiled it against canton-daml
(env-gated tests/generate.rs). Workspace gates green with the new crate.
canton-splice-amulet: pre-built typed Rust bindings for splice-amulet-0.1.14.dar, generated by dpm-codegen-rust. The generated src/lib.rs is checked in (vendored output) — the right shape for a pre-built bindings crate: crates.io/docs.rs need the code present, consumers depend only on the canton-daml runtime (not the codegen toolchain), and the exact output stays git-auditable. Drift is guarded by an env-gated regenerate-and-diff test in canton-codegen-cli (CANTON_SPLICE_AMULET_DAR) plus a README regeneration recipe; the crate carries its own generated-code allow attributes and no workspace lints. canton-sample: the reference app tying M2 to M1. It builds a typed FeaturedAppRight from the bindings, round-trips it through the JSON and gRPC codecs, turns it into a Ledger API create command via create_command (template id in the #splice-amulet upgrade form), and — when LEDGER_ENDPOINT/LEDGER_TOKEN/ LEDGER_PARTY are set — submits it through the M1 CantonClient. It runs offline in CI; the submission is env-gated for LocalNet. Completes M2 Phases A–E; workspace gates green.
The checked-in generated bindings are formatted by the repo's rustfmt (like any other source), while the generator emits prettyplease, so the up-to-date test's byte comparison flagged formatting rather than actual drift. Compare at the AST level instead — parse both sides and re-emit through prettyplease — so the guard sees only the generated API and stays green under `cargo fmt`, while still catching real changes from the DAR. Adds syn + prettyplease as dev-deps and notes the `cargo fmt` step in the regeneration recipe.
Retarget the sample to the cn-quickstart licensing app, which an app-provider party can actually drive, so its online path is a real end-to-end run rather than a demo that needs the DSO. New pre-built bindings crate canton-quickstart-licensing (generated from quickstart-licensing-0.0.1.dar) is what the sample uses; the drift guard now covers both bindings crates (amulet + licensing). canton-sample now builds a typed AppInstallRequest from the bindings, round-trips it through the JSON and gRPC codecs, turns it into a create command via create_command (template id #quickstart-licensing:Licensing.AppInstall:AppInstallRequest), and — with LEDGER_ENDPOINT/LEDGER_TOKEN/LEDGER_PARTY set — submits it through the M1 CantonClient and reads back the committed transaction. Verified live against a running participant: the typed command commits and the transaction is returned. Workspace gates green (fmt, clippy all-features, feature-powerset, doc, MSRV 1.88, deny, tests); the canton-ledger live suite (23 tests) also passes against the node.
…rates Sample (verification formula): canton-sample now runs the whole M2 loop — codegen → submit → observe transaction → query ACS and confirm the created contract — over BOTH transports (gRPC and JSON), not just a gRPC submit. Its comments no longer promise more than the code does. Code nits: - commands.rs into_record now fails loudly (unreachable!) instead of silently returning an empty Record if a payload is somehow not a record. - dpm-codegen-rust notes that the default --runtime-version only resolves once canton-daml is published (tracked in the CHANGELOG M2 release checklist). Deliverables: - docs/daml-lf-type-mapping.md — the documented Daml-LF → Rust mapping (the table that previously lived only in map.rs rustdoc). - canton-splice-wallet and canton-splice-wallet-payments — two more pre-built bindings crates via the same recipe; the drift guard now covers all four. - README/CHANGELOG updated: M1 published + M2 code-complete, a full M2 changelog section, and the M2 release checklist (publish=false, --runtime-version, crate-version convention). Verified live: the sample's typed create commits and is read back from the ACS on gRPC and JSON. All workspace gates green (fmt, clippy all-features, feature-powerset, doc, MSRV 1.88, deny, tests, four drift guards).
… findings) Int64 on the JSON path (P1): the JSON Ledger API encodes Int64 as a *string* (encodeInt64AsString, to survive JS's 53-bit precision) and accepts a string or a number on input. The old mapping Int64 -> i64 with plain serde only parsed the number form, so reading any contract with an Int64 field (e.g. Splice `Round`) from the JSON API failed. Add a runtime rt::Int64 newtype that serialises as a string and deserialises from either form, and map DamlType::Int64 to it; the gRPC form is unchanged. Numeric (P2): rt::Numeric now also accepts a JSON number on input (it already emitted a string); high-precision values should still use the string form. Both are covered by canton-daml conformance tests and the generated-crate round trip. Regenerated all four bindings crates (Int64 fields are now rt::Int64); the drift guards and the 18-DAR corpus compile. Also add .gitattributes marking the checked-in generated src/lib.rs as linguist-generated (kept out of GitHub's language stats and collapsed in diffs), and note the mapping change in docs/daml-lf-type-mapping.md and the changelog.
The JSON verification lane previously only asserted the ACS snapshot was non-empty, while the gRPC lane found its specific created contract. Equalize it: extract the created contract id from the JSON transaction and confirm it by reading the committed transaction back from the bounded update range `(offset - 1, offset]`. That is reliable on a busy party where the JSON ACS — a bounded read — would not include a fresh contract; the ACS snapshot count is still shown. Verified live: both lanes report "our create present: true".
Capture what the codegen does for Smart Contract Upgrade (version in the module name, references resolved by package id, version-independent #package-name template id — all verified) and a ready runbook to demonstrate "a version bump regenerates compatible code" by building a bumped DAR and diffing the bindings. The live two-version run needs a second-version DAR and the Daml toolchain, so it is the reviewer/author demo; the mechanics themselves are already proven.
…chive reader The decoder's bytes-to-AST step is already machine-generated from the official daml-lf-archive protobuf schema (vendored verbatim); this holds the hand-written interpretation above it (LF version dispatch, interned-table resolution, package-reference resolution) to the authoritative JVM implementation. Both sides render the DAR's type-signature surface — the part codegen consumes: package id/name/version, serializable data types, templates with keys/implements/choices, interfaces with views — to one canonical JSON document: - crates/canton-lf/tests/oracle.rs: renders from our decoded AST, runs the JVM twin, canonicalizes both documents, and asserts equality with a first-diff pointer on mismatch. Env-gated on CANTON_LF_ORACLE_DAR + scala-cli on PATH, skips cleanly otherwise (same pattern as the other DAR-dependent suites). - tools/lf-oracle/LfOracle.scala: reads the same DAR through com.daml:daml-lf-archive-reader (the decoder Canton itself uses; stable 3.4.11 from Maven Central) and prints the same document. Dev-only tooling; the SDK and codegen still need no JVM. The JVM reader re-computes and verifies each archive's SHA-256 while reading, so agreement also covers package-id integrity. Verified across the Splice surface (splice-amulet, splice-wallet, splice-wallet-payments, token-standard API DARs, quickstart-licensing): 245 package decodings, zero disagreements. Documented in docs/daml-lf-type-mapping.md.
1. Recursion breaking was Rust-unsound. The old pass boxed only direct
top-level self-references and wrongly assumed `Optional` provides
indirection — it does not (`Option<T>` stores T inline), so `data Tree =
Node { left : Optional Tree }`, mutual recursion (incl. cross-module), and
recursion through a generic instantiation (`Wrap T`) all generated
infinitely-sized types (E0072): the user's crate did not compile. Replaced
with a crate-wide containment-cycle breaker: build the inline-containment
graph (Optional and generic-argument positions are inline; List/TextMap/
GenMap/Box/ContractId stop containment), then box every reference
occurrence that closes a cycle. Conservative and always safe — Box is
transparent to both codecs. The flat lower_package path, which never boxed
at all, now runs the same pass. Five always-on IR-level regression tests.
2. Record decode broke on two legitimate Canton wire shapes: non-verbose
output omits field labels entirely, and record normalization (Smart
Contract Upgrade) drops trailing empty-Optional fields. record_field
matched by label only and errored on absence, so both shapes failed to
decode. Generated FromValue now locates fields by label or declaration
index (labels win when present) via required_field, and Optional-typed
fields decode absence as None via optional_field/AbsentField. Regression
tests cover verbose, non-verbose, normalized, and missing-required shapes.
The four committed binding crates are regenerated with the new decoder
(their types are unchanged: no Splice type needed new boxing). Verified live
against Canton 3.5.7 LocalNet: typed create + read-back on gRPC and JSON.
Robustness (user-supplied DARs must never panic or clobber): - canton-lf: cap per-entry decompression at 256 MiB (zip-bomb guard, checked against both the declared and the actual size); distinguish "no manifest" / "manifest carries no entries" / "manifest is not UTF-8"; reject archives with zero .dalf packages; "not a DAR" error for non-zip input; decode errors name the offending .dalf (DecodeError::InPackage). Hostile-input tests included. - canton-codegen-cli: refuse to overwrite files this tool did not generate (marker check; --force overrides) so a wrong --out cannot clobber a user's crate; error early on an empty DAR and on an invalid crate name; generated crate version comes from the DAR's package version; --runtime-path is absolutized and TOML-escaped (Windows paths were invalid TOML); errors name the offending path; --flag=value accepted; --force/--version flags; usage hint on missing args; note when the runtime dependency is by version; "and N more" past the skipped-warnings cap. - canton-codegen: names that are not valid Rust identifiers (LF permits `$`) are a LowerError, not an emitter panic; two labels collapsing onto one Rust field name fail the type with both labels named; lowering errors carry their module context; two packages with identical name+version disambiguate by id prefix instead of emitting colliding modules; generated code spells std types fully qualified (::std::string::String, ::core::option::Option, …) so same-named Daml types cannot shadow them. Runtime semantics: - Numeric now compares/hashes/orders by canonical decimal value — the ledger echoes "1.5" as "1.5000000000" (scale 10) and the two must be equal — with Numeric::parse for early validation; non-decimal content falls back to string comparison and never equals a valid decimal. - The typed READ path: Template::from_created_event decodes a CreatedEvent (transaction / ACS) into the payload struct, checking template identity (module/entity, not package id — SCU may report any vetted version), plus from_record for bare Records. - ContractId: retag() for interface exercise, and Hash/Ord (manual impls, no bound on the phantom tag); Timestamp/Date gain Hash; pre-epoch RFC 3339 timestamps floor sub-microsecond digits instead of rounding forward. Docs and the sample: - README: the M2 crates join the crates table and a "typed bindings from your DAR" quickstart shows generate -> create_command -> from_created_event -> exercise_command; stale "work in progress / Phase A" front-page docs on canton-codegen/canton-daml replaced with the shipped architecture. - canton-sample now runs the complete typed loop live: create, decode the committed event back into the payload (from_created_event), confirm in the ACS, then exercise AppInstallRequest_Reject — verified against Canton 3.5.7 LocalNet on gRPC + JSON. The four binding crates are regenerated (fully-qualified std spellings). Gates: fmt, clippy -D warnings (0), rustdoc -D warnings, full workspace tests, LF conformance oracle 36/36 — all green.
`parse_spelled_duration` guarded NaN and negatives and then handed the number to `Duration::from_secs_f64`, which panics on anything a `Duration` cannot hold. The number is not ours: it is the `retryInfo` field of a JSON error body, so `"1e300 seconds"` — or a plausible-looking `"1e15 days"` — aborted the caller inside `Error::retry_delay()`. That is the worst place for it. Classification runs while an error is already being handled, and `run_with_retry` calls it on every retriable failure, so a participant having a bad day could take the application down with it. A server error must come back as an error. `try_from_secs_f64` refuses the out-of-range value and returns `None`, which is what the caller already handles: no server recommendation, use the local backoff schedule. It rejects NaN and negatives too, so the guard it replaces is not lost. Found by Equilibrium's M1 review; their reproduction is covered by the cases added to the parser's own test.
`updates_with` dropped `OffsetCheckpoint` frames before anything else saw
them, and the resumable stream was built on top of it. So the resume point
only ever advanced when a transaction arrived. On a quiet stream — which is
most streams, most of the time — a reconnect went back to wherever the caller
started, re-reading everything since, and after the participant has pruned
that far back the restart fails outright against an offset it will no longer
serve. The participant had been naming a safe offset the whole time.
The fix is not to change what a subscriber receives: a checkpoint is not an
update anyone asked for, and `updates_with` still filters them. It is that
the resumable path now reads the unfiltered stream, takes the position from
every frame including checkpoints, and filters for itself.
Second failure in the same loop: after the last reconnect it replaced the
participant's error with `UnexpectedResponse("failed to resume after N
reconnects")`. That threw away the gRPC status, the structured details, the
correlation id and the retriable classification — everything an application
branches on — at the one moment it needs them. It now yields the failure that
caused the reconnect, and logs the exhaustion instead of encoding it in the
error.
Both from Equilibrium's M1 review. Their two scenarios are the two tests
added here: a first stream carrying nothing but a checkpoint at offset 10
must reconnect at 10, and an unavailable participant must come back as
`Unavailable` with its own message.
…equest `OidcConfig::auth0` and `OidcConfig::okta` set a token URL and nothing else, which is not what either provider's normal client-credentials request looks like. Auth0 needs an `audience`. Without one it answers the request by issuing an opaque token for its own userinfo endpoint — a token a participant cannot verify — so the preset could not produce a working request at all. The audience names the caller's own API and cannot be derived from the domain, so the preset now asks for it: `auth0(domain, audience, client_id, secret)`. Okta reads the credentials from an `Authorization: Basic` header for a confidential client. The SDK always put them in the form body, which Okta rejects as `invalid_client` — the same error a wrong secret produces, which is a bad hour for whoever is debugging it. The preset now selects Basic, and `ClientAuth` makes the choice available to a custom endpoint that wants the other form. The presets' tests asserted their token URLs, which is exactly the part that was already right. The new tests read the request off a socket: Keycloak's credentials in the body with no Basic header, Auth0's url-encoded audience, and Okta's `Authorization: Basic Yzpz` with nothing in the body. From Equilibrium's M1 review. `auth0`'s new parameter is a breaking change, which this release already is.
Every typed gRPC read of the Active Contract Set matched `ActiveContract` and dropped the rest with `_ => None`. The rest is not noise: a reassignment that is half-done at the snapshot offset arrives as `IncompleteUnassigned` — the contract has left one synchronizer and not yet arrived — or as `IncompleteAssigned`. The Ledger API sends them precisely so an application can bootstrap across synchronizers without a hole, and a caller had no method that could see them. On a single-synchronizer ledger nothing is lost, which is why every test passed. `AcsEntry` is the lossless read: `acs_page`, `acs_entries` and `acs_entries_resumable`, each with its `_with` sibling. The active-only methods keep their names, their signatures and their meaning — they are now that same read with `into_active` applied, so the two cannot drift apart. The enum is `#[non_exhaustive]`, because the entry kinds are the Ledger API's to extend. `acs_entries_resumable_with` also stops replacing a spent reconnect budget with an error of its own, the same defect the update stream had one commit ago and for the same reason. From Equilibrium's M1 review, which noted they could not write the test because the return type could not express the answer. It can now: one page carrying one entry of each kind comes back as three entries, and one active contract through the convenience method.
Submitting is not a request whose failure means it did not happen. A dropped response, a timeout, a retry the participant de-duplicated: the command may have committed perfectly well and the caller still gets an error. The way back to the outcome is the completion stream, keyed by the command's identity — and the SDK generated that identity *inside* the call that failed and returned it only on success. On the path where it matters, it was gone. `CantonClient::submission` fixes the identity first and hands back a `Submission` that carries it. Submitting is then a method on the handle, and `recover` is the one it has that the client alone cannot: it knows what to look for. The three client methods keep working and are now thin wrappers over the same object, so there is one code path rather than two. The recovery itself was matching on `command_id` alone. Canton identifies a command by its **change ID** — user, acting parties, command id — and two applications on one participant may each use `daily-run`. `ChangeId::matches` compares all three, with acting parties as a set. Two asymmetries are deliberate and documented: a user id left to the bearer token is not compared, because the participant resolved it and the client genuinely does not know it; and a completion carrying no acting parties is not rejected, because some rejections arrive that way and missing a rejection is the worse failure. `await_completion` now takes a `&ChangeId` rather than a command id and a party list — the correct call is the only one that type-checks. From Equilibrium's M1 review. The in-process test is their scenario: a completion stream where somebody else's command shares our command id, and the recovery must return ours.
Issue #554 describes one client over two transports and then lists what it must do. Four of those were gRPC-only: submit without waiting, submit and wait for the completion, recover a submission whose result is uncertain, and look up a contract's events by id. All four exist in Canton 3.5.7's JSON API, so this was the SDK stopping short, not the protocol. `JsonClient::submit` (`/v2/commands/async/submit`), `JsonClient::submit_and_wait` (`/v2/commands/submit-and-wait`) and `JsonClient::events_by_contract_id` (`/v2/events/events-by-contract-id`) close three of them. `JsonClient::submission` closes the fourth: the same handle the gRPC lane got, carrying the change ID before the send, with `recover` reading the completion back over the WebSocket and matching on all three components through the same `ChangeId` the other transport uses. Live against Canton 3.5.7 — which corrected the shape of two request bodies. Those two endpoints take the command set *itself*, where `submit-and-wait-for-transaction` takes a request object wrapping it; the participant answered the wrapped form with a 400 naming the fields it could not find. Reading the OpenAPI would have said so; running it is what proved it. Also here, from the same review's non-blocking list: the WebSocket streams take their reconnect budget and backoff from the client's `RetryConfig` instead of a hardcoded five-at-250ms, so configuring retries once governs both lanes.
…e's edge Four gaps, all in the same direction: the instrumentation covered the moment a call was made and not what happened afterwards. **A stream's outcome is not known when it opens.** `instrument` wraps the future that opens a subscription, so a stream that opened cleanly and failed an hour later was recorded as a success and never corrected — the error counter said nothing about the failure mode a long-lived client actually meets. `instrument_stream` lives with the stream instead: each item polled inside the span, errors counted as they arrive, and the end logged with how many items it delivered. Every streaming method on both transports uses it. **A WebSocket makes one request.** The upgrade was the only place trace context could go, and it was the one place nothing was injected — so the whole streaming JSON lane sat outside the caller's trace while every unary call on either transport joined it. The handshake now carries `traceparent`. **A log line could not be joined to its trace.** The structured events carry `trace_id` now. An application could have got there with its own subscriber plumbing; the SDK is the thing that already knows. **Metrics had no supported path out.** The counters go through the `metrics` facade, which does nothing until something installs a recorder, and the repository offered no OpenTelemetry one. `otel::otlp_metrics` is that path: it builds the OTLP pipeline and installs a recorder bridging the two models — instruments cached by name, a `metrics` key's labels becoming attributes, and the two mismatches handled explicitly (a cumulative `absolute` becomes a delta, a relative gauge move becomes an absolute record). From Equilibrium's M1 review, whose two reproductions are now tests: an in-process stream that fails after its first item must move `canton_client_errors_total`, and an upgrade request opened inside an active OpenTelemetry span must carry a well-formed `traceparent`.
Each of these cost a round trip and came back as a server-side error that reads like the ledger's fault, when the request was knowably wrong before it left: a submission with no commands or no acting party, both minimum-ledger- time forms set at once, a negative offset, a range that ends before it begins, and a subscription filtered to nobody — which looks alive and can never yield. `read_as` now reaches the transaction filter of a submission's response. The Ledger API's default covers both party sets, and a command submitted with `read_as` is one whose result the caller expects to see through those parties too; filtering to `act_as` alone returned a transaction quietly missing events. The idempotent reads — events-by-contract-id, the ACS page, the updates page — take the configured retry policy, which until now applied only to `version`, the health check, `ledger_end` and the submissions. A lookup that fails transiently is worth another attempt; that was the whole point of configuring retries. From the non-blocking half of Equilibrium's M1 review.
Two places answered a question with less than the truth and no way to tell. `list_known_parties` walked pages until the participant stopped advancing the token, then warned and returned what it had. "Which parties exist" is a question whose short answer looks exactly like its right answer, and the caller was given no way to distinguish them. It fails now. `collect_entries` dropped any topology row missing its context or its mapping — both `Required` in the proto — so a response this client could not fully read came back as a shorter list of participants, namespaces or vetted packages. Same failure shape, same fix: the read fails rather than shrinking. Also documents what party management here covers — allocation and discovery — and why updates and the identity-provider options are not wrapped in M1, which the review asked for as the alternative to adding them.
The semver-checks job has been sitting commented out since before the first release, waiting for a baseline to compare against. There has been one since 0.1.0, so it runs — scoped to the crates that are actually published, because a crate with no release has nothing to be checked against. Verified locally against the 0.1.4 baseline: the five published crates report no required update, which is what a 0.1 → 0.2 bump is for. ADR-0005 claimed that requiring the same version between our own crates makes mixed installs "fail to resolve rather than misbehave". Cargo reads `version = "0.2.0"` as a caret requirement, so `canton-ledger 0.2.0` beside `canton-core 0.2.1` resolves and builds perfectly well; only crossing a minor fails. The ADR now says that, and says why pinning `=x.y.z` between our own crates would be the wrong fix: it would forbid the resolution rather than the misbehaviour, and make a one-crate security patch un-installable without re-releasing the family. Both from the non-blocking half of Equilibrium's M1 review.
…ed quietly Forty-five third-party `.proto` files sat in the tree with a release number in an ADR and nothing else: no source, no checksum, no refresh procedure. Vendoring makes a schema editable, and a schema is the one thing that must not be — an edit compiles, passes every test, and quietly makes this client speak a wire format the participant does not. `proto/PROVENANCE.md` records what each subtree tracks and what it is pinned to. `proto/SHA256SUMS` records a hash per file and a test verifies it on every run; the guarantee is narrow and stated as such — it cannot prove the files came from upstream, it proves nobody has touched them since. The file set is compared too, so a schema cannot be added or dropped without the record moving with it. `tools/vendor-protos.sh --rehash` is the procedure after a deliberate re-vendor. The same script can diff the tree against the copies inside a running Canton container, which is how the provenance note's third section was produced rather than asserted: against the 3.5.7 image the LocalNet runs, 33 of 45 files are byte-identical, 10 differ only by additions on our side (fields the image's copies lack, such as `Completion.transaction_hash`), and 2 are not in that tree at all. No field present there is missing here — a superset that is wire-compatible in both directions. From Equilibrium's M1 review, which asked for the source, checksum and refresh process to be recorded.
The suites are gated on environment variables and step aside when they are missing — which cargo reports as a pass. So "28 live tests" was true of a run that never opened a socket, and there was no way for a reader, or for me, to tell the two apart afterwards. `CANTON_TEST_REQUIRE_LIVE=1` makes a skip a failure. Every gate in both live suites goes through one `skip!` macro that checks it, and the skip line now says `SKIP (no live environment)` rather than being a bare `eprintln!` in the noise. Without the variable nothing changes: `cargo test` on a machine with no node stays green, which is the property the gating exists for. Verified both ways: with the variable set and no environment, the suite fails; with it set against the LocalNet, canton-ledger reports 33 passed and canton-admin 5 — and now that means 38 tests reached a participant. The two live tests added for the JSON recovery and the resumable WebSocket ACS are gated on the `ws` feature, which they need and had been missing.
Both transports read the created contract back, but from the transaction the submit call had just returned — which proves the call returned something, not that the ledger holds it. Each lane now makes a separate request over the update range `(offset - 1, offset]` and matches the exact update id. Matching on the contract id would not do: another transaction mentioning the same contract would satisfy it. Verified live on both lanes against Canton 3.5.7. From the non-blocking half of Equilibrium's M1 review, which noted the single `submit_and_wait_for_transaction` call was still a reasonable reading of the issue — it is, and this is the stronger one.
The proposal's supported matrix is Tier-1 Rust on x86_64 and aarch64 plus `x86_64-unknown-linux-musl` for static container binaries. CI ran on ubuntu-latest, macos-latest and windows-latest — which covers x86_64 Linux and Windows and, since macos-latest became Apple silicon, aarch64 macOS. Nothing built for aarch64 Linux or for musl, so the claim rested on nothing for the two targets whose audience — indexers and validators in containers — is exactly who the SDK is for. A `cross-targets` job checks both. `check` rather than `test`, because running them needs those machines; that a claimed target compiles is the part CI can hold. The TLS stack compiles C for the target, so the job installs the cross toolchains that `cargo check` still needs. Verified locally for `aarch64-unknown-linux-gnu`: the whole workspace, all features, clean. The musl leg could not be verified on this machine — it is arm64, where `musl-gcc` is an aarch64 compiler and rejects the `-m64` ring passes for an x86_64 target, which is a property of the host and not of the code. Also lands the CHANGELOG entry covering the whole review.
The crate table still described the JSON lane as "command submission and bounded reads" and the ACS read as though active contracts were all a snapshot holds. It also never showed the thing hardest to get right from the outside: that a submission's identity has to exist before the call that might lose it. That is now the example next to the quickstart, on both transports. Credits Equilibrium for the review, and links the changelog rather than restating it here.
Everything else in this crate asserts what the SDK emits — a span opened, a counter moved. Whether any of it reaches a collector was the half nobody had checked, and it is the half an application depends on. So the test is the collector: it serves the two OTLP services on a local port, points `otlp_metrics` and `otlp_tracer_provider` at them, makes one successful call and one failing one, and asserts what arrives — the `canton.rpc` span, and both counters carrying the `method`, `transport` and `retriable` attributes a dashboard is built from. That last part is what the metrics bridge could plausibly get wrong: `metrics` keeps labels on the key, OpenTelemetry takes attributes at record time, and a bridge that dropped them would still export a number. `otlp_tracer_provider` is new, and not only for the test. Spans export in batches, so a process that exits without flushing loses the last batch — which tends to hold the spans around whatever made it exit — and the tracer alone does not expose its provider. `otlp_tracer` is now that call plus `.tracer()`, and both set the `service.name` resource, which only the metrics side did. The reviewers suggested exactly this test.
`cargo deny check advisories` fails on RUSTSEC-2026-0258: h2 accepts and queues empty DATA frames without limit, so a stream nothing drains grows without bound, or panics when the length overflows. Low severity, and it reaches us only through hyper — but it reaches every crate here that speaks HTTP/2, which is all of them. `cargo update -p h2` takes 0.4.15 → 0.4.17, a lockfile-only change. All four `cargo deny` checks pass and the workspace's 333 tests are unchanged. This is not from the review; it is what running the gates before a release is for.
The facade re-exports the client crates and a handful of `canton-core` types, and `canton::telemetry` was not among them. So the metric names, the transport labels, and the OTLP setup functions this branch just added — the ones the documentation tells an application to call — did not exist under `canton::` at all. Depending on `canton-core` directly was the only way to reach them, which is exactly what a facade exists to avoid. The `otel` feature had the same shape of bug waiting: it forwarded to `canton-ledger/otel`, which happens to enable `canton-core/otel`, so the re-export would have compiled — until someone changed canton-ledger's feature list and the facade's OTLP module vanished from the API with nothing failing. It names `canton-core/otel` itself now. M3 already fixed the re-export, found the same way — through a suite that uses the facade rather than the crates behind it. The tests here are that check, brought back to where the surface is defined: they name the client types, the two recovery handles, `ChangeId`, `AcsEntry`, `ClientAuth`, the metric constants, and the three OTLP entry points. Also adds RELEASING.md: the publish order derived from the manifests rather than from memory (`tools/publish-order.sh` regenerates it), and the checklist that has to pass before a version that cannot be taken back.
`ws_updates_resumable` and `ws_active_contracts_resumable` open their subscriptions directly, so they were outside both instrumentation paths: not the unary `instrument` that counts an RPC, and not the `instrument_stream` added for a stream's life. They are the two streams most likely to be running when something goes wrong — a resumable tail is what an application leaves open for days — and a dashboard showed nothing for either. Each connection inside the reconnect loop is now instrumented for its own life, so a subscription that dies mid-snapshot is counted rather than being absorbed by the loop that quietly replaces it. Found by re-checking my own work against the finding it was meant to close, rather than against the commit that claimed to close it.
Two rounds ago the update stream stopped replacing a spent reconnect budget
with an error of its own, because the participant's status, details and
retriable classification are what an application branches on. I fixed that in
`updates_resumable_with`, then in the resumable ACS page reader, and did not
look at the two WebSocket streams at all. They were still yielding
`UnexpectedResponse("ws … failed to resume after N reconnects")` over the top
of whatever actually went wrong.
Both now carry the cause out of the inner loop. `ws_updates_resumable` needs
an `Option` where the others do not: a clean WS close is also a reason to
reconnect there, and it carries no failure — so if that is all that ever
happened, the error says that instead of blaming the participant.
The four resumable paths in the SDK now behave the same way, and `grep` for
the phrase that used to paper over them finds nothing.
**Coverage this was hiding behind.** The gap was not that the code was
unreviewed; it was that nothing tested it. Filling that found the bug:
- `tests/json_http.rs` is new. The JSON command surface — async submit,
submit-and-wait, events-by-contract-id, both submission handles — existed
only in the env-gated live suite, so CI, where no participant is reachable,
never ran a line of it. That is the review's own complaint about live tests
applied to the largest thing this branch added. The assertions are about the
request that goes out, because the request shape is the part that is easy to
get wrong and impossible to notice: these two endpoints take the command set
*as* the body, where submit-and-wait-for-transaction wraps it.
- `ws_active_contracts_resumable` had no in-process test — only its update
sibling did. It has one now, and a second that drives a stream to give up:
that one fails against the previous commit and passes against this one,
which is the only reason to believe it.
- `read_as` reaching the transaction filter, and the three local validations
that refuse a submission before it is sent, had no tests anywhere.
- A participant that repeats its page token had none either.
`ChangeId::matches_json` reads the same three fields from a JSON completion that `matches` reads from a gRPC one, and exists so the two transports cannot come to disagree about what identifies a command. Nothing verified that. It was written, reviewed and shipped on the strength of looking correct next to its sibling. The tests now include one that runs both matchers over the same five cases and asserts they answer identically — which is the actual property, rather than two lists of expectations that can drift apart one edit at a time. Two more paths that no test named: - `current_trace_id`, the id that goes into every structured event. It is now checked to be 32 hex digits, to be absent rather than all-zero when no tracer is installed, and to be the same id the `traceparent` header carries — a log line pointing at a different request's trace is worse than one pointing at none. - `from_env_for`'s error message, where the variable name is built from the role. Uppercasing and folding dashes to underscores is small enough to get wrong and is the first thing a user meets when their shell has no network exported. Found by listing every function this branch adds and asking which are never named in a test, rather than by reading the diff again.
Every Rust block in the README is `rust,ignore`, so none of them is checked by anything. Three of the four are backed by a compiled file under `examples/` — version-and-health, submit-and-read, localnet — which is what has kept them honest. The recovery handle, added this round and the most likely of the four to be copied by someone who has just lost a submission, had no such backing. `examples/recover_a_submission.rs` is that file, and it does more than compile: it walks the review's own scenario. Submit; submit the identical change ID again, which is exactly what the SDK sends when a response is lost and the request is retried; watch the participant reject it as `DUPLICATE_COMMAND`; then recover the original command's outcome from the completion stream. Run live against LocalNet just now: committed: update_id=12203b2c…dd0c14e second submission rejected as expected: DUPLICATE_COMMAND(10,5b4a9830) recovered the original outcome: update_id=12203b2c…dd0c14e offset=53060 Same update id. That is the finding — "the command has already succeeded, the application receives an error, and without the ID it cannot look up the result" — demonstrated as closed rather than asserted to be.
Three verification passes checked my work against my reading of the review. This one ran Equilibrium's actual regression branch — seven public tests they wrote to state the behaviour each finding requires. Six passed. One did not, and it was right. `LEDGER-01: generated command ID survives an ambiguous submission` submits against a node that accepts the command, loses the response, and then refuses the SDK's retry as a duplicate. It asserts `submit` returns the command id. Mine returned the duplicate rejection. That is the finding's own title — "a command may succeed while the SDK reports an error" — and adding the recovery handle did not close it. The handle makes the outcome *recoverable*; it does not stop the SDK from reporting failure over a command that succeeded. When the SDK retries with the same change ID and the participant answers `ALREADY_EXISTS`, the participant is telling us our previous attempt landed. That is success, and `submit` now says so — on both transports, since the JSON lane had the same problem and no reason to differ. Only on a retry. A duplicate on the first attempt means the caller reused a change id from an earlier submission, which is a real rejection they must see; both cases have a test. The waiting variants cannot answer this way — their result is a transaction and a de-duplicated retry does not carry one. Their documentation said "here is a caveat"; it now says to take a `Submission` before submitting and recover through it, and points at the example that does. The other failure was mine to explain, not to fix: `OBS-01` counts a process-global metric, and cargo runs the suite's tests in parallel in one binary, so two other tests in that suite incremented the same labelled counter between its two reads. It passes alone and single-threaded. The stream instrumentation counts exactly one error per failure. Their branch: equilibriumco/canton-rust-sdk @ review/issue-554-regressions.
…ntouched `the_vendored_protos_match_their_recorded_checksums` failed on the Windows runner and nowhere else. Nothing was modified: git on Windows defaults to `core.autocrlf=true`, so it checked the vendored schemas out with CRLF, and a checksum over the bytes on disk is exactly the kind of test that notices. The message even said what it looked like — "has been modified since it was vendored" — about files that were not. `.gitattributes` now marks the vendored tree `-text`, so the working copy is byte-for-byte what was downloaded on every platform, which is what a provenance record is for. The fixture DAR is marked binary for the same reason. The test carries the note, since it is the reason the attribute exists and a future tidy-up of `.gitattributes` would otherwise turn this red again on one platform only. Caught by the first CI run of this branch — the Windows job had never executed here before.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Milestone 2 of the Canton dev-fund grant (#407): typed Rust codegen from DARs,
the
dpm codegen-rustcomponent, and the first prebuiltcanton-splice-*crates — plus every finding from Equilibrium's independent review of M1.
Deliverables
canton-codegen— typed Rust from a DAR. Decodesdaml-lf-archivenatively(vendored official protos, no JVM for any consumer), SCU/PackageMap-aware,
covering templates, choices, interfaces and contract keys, with both codecs on
every generated type: serde for the JSON lane and
ToValue/FromValueforgRPC. Held to the official JVM reader by a conformance oracle in CI, so
"native decoder" does not mean "our interpretation of the format".
dpm codegen-rust. Ships as thedpm-codegen-rustbinary, whichdpmresolves as a subcommand.
Documented type mapping, reference crates, sample app.
docs/daml-lf-type-mapping.mdis thehuman-readable specification of the mapping the generator implements.
canton-splice-amulet,canton-splice-walletandcanton-splice-wallet-paymentsare the first prebuilt bindings;canton-sampleis the reference app.Verification
The proposal asks for codegen → submit → observe transaction → query ACS on
both transports, and for an SCU version bump to regenerate compatible code.
The sample runs the full loop on gRPC and JSON against a Canton 3.5.7
participant, reading the committed transaction back independently and matching
its update id rather than trusting the submit call's own answer.
The SCU property is demonstrated in
docs/scu-regeneration.md: bumping a package'sversion and regenerating changes 8 lines, all of them
PACKAGE_ID. Modulepaths, type names, field names and the version-independent template ids are
identical, and one unchanged consumer program compiles and runs against both
sets of bindings. What that does not cover is stated there too.
The M1 review
Equilibrium reviewed M1 independently and published a
regression branch stating the behaviour each finding requires. All nine
blocking findings are fixed here, along with the non-blocking list. Their suite
runs against this branch: seven public tests pass, and so do the three sent
privately.
One of their tests found something reading the review had not. After the
recovery handle was added,
submitstill reported failure when its own retrywas de-duplicated — over a command that had committed. That is the finding's
own title, and it was still open. A retry refused
ALREADY_EXISTSmeans theearlier attempt landed, so it is success now, on both transports; a duplicate
on the first attempt stays an error.
Headlines from the rest: a lossless ACS read (
AcsEntry) that no longer dropsincomplete reassignments; resumable streams that resume from the participant's
own checkpoint and report the failure that stopped them instead of one of ours;
the JSON lane reaching parity on command submission, recovery and event
lookup; Auth0 and Okta presets that produce their providers' actual requests;
stream-lifetime telemetry, WebSocket trace propagation and a supported OTLP
metrics path; and two credential leaks through
Debugclosed.CHANGELOG.mdcarries the full list, including the two breaking signaturechanges for anyone on 0.1.x and how to migrate.
Test plan
clippy --all-targets --all-features -D warnings, and the same across thefeature powerset
-D warnings,cargo deny, packaging,semver-checksCANTON_TEST_REQUIRE_LIVE=1so a missing environment fails instead ofskipping quietly — which is what made the M1 live claims unverifiable
Release
Publishes as 0.2.0 in the order derived by
./tools/publish-order.sh; thechecklist is in
RELEASING.md.