Changelog
All notable changes to the Canton Rust SDK are documented here.
The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning.
Generated protobuf types (the canton-proto crate and the proto re-exports)
are exempt from SemVer — see the stability policy in canton-proto’s docs.
[Unreleased]
Documentation
- A documentation site, built from
book/with mdBook and published to GitHub Pages: guides for getting started, local development, reading the ledger, codegen, the token standard, interactive submission, PQS, and the admin API and telemetry. The compatibility matrix, the ADRs, the changelog and the project files are copied in at build time, so the site and the repository cannot drift apart, and CI fails on a link inside it that points at nothing. MAINTENANCE.md: what is maintained, in what order (security fixes, then critical bugs, then new Canton releases), how releases are made, and what is not promised.SECURITY.md: a report is acknowledged within five business days while the Canton Dev Fund grant runs (was three), and on a best-effort basis after it.- Corrected while writing the guides:
- turning off the facade’s default features drops the in-memory key type,
not
ring, which the TLS stack uses; - codegen runs ahead of time, not from a build script;
JsonClient::with_max_decoding_message_sizealso caps HTTP response bodies;- PQS
Predicate::eqcompares the stored text, andcomparewithOp::Eqdoes no numeric cast (only the ordered operators do), so numeric equality across scales needsOp::GeplusOp::Le; - the compatibility matrix’s live-test counts and its LocalNet settlement row were stale.
- turning off the facade’s default features drops the in-memory key type,
not
[0.3.2] — 2026-10-23
The review findings filed against 0.3.1 by the C#/.NET SDK’s maintainer
(#7–#12), fixed. Two behaviour changes are listed first because they can turn
a call that succeeded into one that is refused. Both refuse what should not
have been accepted, and the public API only grows, so they ship as a patch:
cargo update brings them, and the fix for 0.3.1’s broken fresh build, to
every project on 0.3.
Changed — behaviour
- A created event must name its package to decode (#7).
Template::from_created_eventandfrom_json_created_eventnow compare the event’s package name with the template’s, as well as its module and entity; the package id is still not compared, so an upgraded version decodes. An event from another package that defines the sameModule:Entitywith a payload of the same shape used to decode as this template; it is now refused. An event with no template id, or with a package id and no package name, is refused rather than decoded on trust. Events a participant returns carry both; a hand-built event in a test may needpackage_nameset. - A prepared transaction hashed under a scheme the SDK cannot check is
refused unless the caller opts in (#10). Under V2 and V3 the hash is
recomputed and held to the transaction; any other scheme (V4) used to be
signed as the participant returned it with only a warning, and now needs
Prepare::trust_participant_hash().
Added
- Hashing scheme V3 (#10):
canton_ledger::hashing::hash_prepared_transaction_v3andhash_prepared_transaction(prepared, version), byte for byte as Canton 3.5.17 builds V3 (no encoding-version bytes; keys on create, fetch and exercise nodes; the by-key flag; lookup-by-key nodes; the metadata’s maximum record time), checked against Canton’s own V3 vectors. Interactive submission recomputes the hash under V3 as it does under V2, so a transaction that uses contract keys is no longer signed on the participant’s word. Live: an external party creates a keyed contract and exercises it by key under V3. - Point reads and pruning points on both transports (#12):
update_by_offset,update_by_id(and_withforms taking anUpdatesRequest’s format) andlatest_pruned_offsetsonCantonClientandJsonClient. “No such update” isOk(None).Error::pruned_earliest_offsetnames the earliest offset the participant can still serve when a read starts below its pruning point; a resumable stream yields that refusal rather than moving forward on its own. - Updates as SDK types (#11):
canton_ledger::update, the update envelope (Update,Transaction, created, archived and exercised events,Reassignment,TopologyTransaction,OffsetCheckpoint) built from either transport, with payloads decoded by the generated type (CreatedEvent::decode) and the wire form still reachable.CantonClient::updates_typed_with,updates_resumable_typed_withandJsonClient::updates_typed_withreturn them. At 1.0 these take the plain names and the proto-typed reads move to*_raw(ADR-0010 §4). - A keyed Daml-LF 2.3 DAR under CI (#8):
testdata/contract-keys, pinned by commit and checksum. The LF oracle runs on it, the codegen compile test builds bindings from it and an exercise-by-key command from them, and thecommands__contract_keysconformance row generates from it. v2_allocateruns the receiver’s side of a leg (CANTON_TOKEN_SIDE=receiver), so a V2 settlement between three different parties runs from the examples.- The live suites run nightly and on demand against a disposable LocalNet with OIDC (#9, contributed by the C#/.NET SDK’s maintainer).
Fixed
canton-lf0.3.x does not build on a fresh install. zip 2.6, inside the2range the crate accepts, added a type parameter toZipFile, whichcanton-lfnamed; the workspace lockfile held zip at 2.4, so CI stayed green whilecargo add canton-lf,cargo add canton-codegenandcargo install canton-codegen-cli(without--locked) failed to compile. The entry is now read throughstd::io::Read, which builds on every 2.x, the lockfile moves to zip 2.6.1, and a new CI job builds the workspace with every dependency at its newest compatible version.- The lockfile, which
canton-codegen-cliinstalls with under--locked, moves past the RustSec advisories published since 0.3.1: rustls 0.23.46, rustls-webpki 0.103.16, webpki-roots 1.0.11, tokio 1.54.1, ctutils 0.4.5. Three advisories against zip (RUSTSEC-2026-0367, -0368, -0369) are fixed only in zip 9 and concern its extraction API, whichcanton-lfdoes not call: it reads each DAR entry into memory under a size cap. Moving to zip 9 changes the error typecanton_lf::DarError::Zipcarries, so it waits for 0.4;deny.tomlrecords the three with that reason. - The
PreparedandPrepare::with_hashing_scheme_versiondocs still said the SDK did not recompute the hash and that the scheme defaulted to the participant’s; both were stale since 0.3.1 (#10).
[0.3.1] — 2026-09-26
Fixes from an internal security review of 0.3.0, run ahead of the independent audit. No new features; every change closes a way for a hostile participant, registry, token endpoint or DAR to make the SDK do something its caller did not ask for. Additive to the public API; behaviour changes are listed.
Fixed — signing and onboarding
canton-ledger: the hash of a prepared transaction is recomputed, not taken from the participant. Under hashing scheme V2 (the default, and now requested explicitly) the newcanton_ledger::hashingmodule rebuilds the hash from the returned transaction and metadata, byte for byte as Canton does (checked against Canton’s own test vectors), andprepare_submissionrefuses a response whose hash is of something else. A response under an unknown scheme, a scheme other than the one requested, or with a hash that is not 32 bytes is refused too. A caller that asks for another scheme explicitly signs the participant’s hash as returned, with a warning.canton-admin: the external-party onboarding set is read before it is signed for. The multi-hash is recomputed over the returned topology transactions, each transaction is decoded, and only the key’s own namespace delegation, a key mapping to that key, and hosting with confirmation or observation rights are accepted; a participant that slips in a second host with submission rights, a delegation to another key, or any other mapping is refused.allocate_external_partyrefuses a participant that allocated a party other than the one signed for.
Fixed — transport and retries
canton-auth,canton-ledger,canton-token: HTTP redirects are no longer followed. The token client refuses a 3xx from the token endpoint instead of replaying the client secret at the redirect target; the JSON Ledger API client and the registry client answer a 3xx as the status it is, unretried, so a bearer token or a command body is never re-sent elsewhere.- HTTP response bodies are read only up to a bound on all three lanes: the
JSON client up to its decoding limit (
with_max_decoding_message_size), the token client up to 64 KiB, the registry client up to 64 MiB; error bodies are kept up to 64 KiB. The bound holds without aContent-Length. canton-core: a server-recommended retry delay is capped atMAX_SERVER_DELAY(60 s, logged when capped), a spelledretryInfois clamped after parsing, and the jitter arithmetic can no longer panic on a hostile value. ARetryInfodetail on its own no longer makes a permanent gRPC code or a permanent HTTP status retriable.canton-ledger: a retried JSON submission counts as de-duplicated only onDUPLICATE_COMMANDorALREADY_EXISTS, not on any HTTP 409 (which isABORTEDas often as not on Canton’s JSON lane) or on a body that merely mentions the word. Two backpressure refusals no longer come back asOk.canton-ledger,canton-admin: a party listing that cycles page tokens or never ends is reported as incomplete (MAX_LIST_PAGES) instead of followed until memory runs out.
Fixed — codegen
canton-codegen: a Daml name cannot inject into generated docs. Lowering refuses a choice, field or package name carrying a control character or*/, and the emitter keeps every doc attribute on one line, so a DAR cannot plant a Markdown fence (and with it a doctest thatcargo testwould run) in the generated crate.
Added — Daml-LF 2.3
canton-lfdecodes LF 2.3, the version Canton 3.5.x SDKs emit and a DevNet participant already vets (Digital Asset’s utility apps,daml-stdlib3.5.1). The vendored archive schema is now Canton 3.5.17’s, trimmed as before (expression bodies stay opaque), and the conformance oracle runs against the 3.5.17 JVM reader (daml-lf-archive_2.13). Bindings generated from a 290-package DevNet DAR at LF 2.3 compile; the committed bindings are unchanged. In the generated typespb::daml_lf_devis nowpb::daml_lfand aTypereferencing an interned type spells itInternedType, following the schema.canton_lf::pbis now#[doc(hidden)]: generated schema types, exempt from SemVer ascanton-proto’s are (ADR-0002), reachable but not part of the documented surface; CI’s semver guard skipscanton-lffor this one release and takes it back with 0.3.1 as the baseline.fetch_packagesskips a package in an LF version the build does not read and continues, instead of stopping at it.
Changed
Prepareasks forHASHING_SCHEME_VERSION_V2explicitly rather than leaving the choice to the participant.canton-proto’ssmoke_versionexample speaks TLS to anhttps://participant, so it runs against a DevNet validator as well as a LocalNet.- Two live-suite assumptions that held only on a LocalNet are gone: the
participant id is checked for its shape rather than a
participant::prefix, and the PQS suite expects a ledger effective time only from a store that read the ledger from the beginning (SCRIBE_PIPELINE_LEDGER_STARTis the hint). tools/pqs/wait-ready.shwaits for Scribe’s ingestion to settle, not only for its schema to exist.docs/security/audit-scope.mdnames 0.3.1 as the commit under review and states the interactive-submission guarantee as the code now provides it.
[0.3.0] — 2026-09-23
Changed — one crate per Daml package (breaking)
- The eight token-standard and featured-app packages are now crates of their
own:
canton-splice-api-token-metadata-v1,-holding-v1,-allocation-v1,-allocation-instruction-v1,-allocation-request-v1,-burn-mint-v1,-transfer-instruction-v1andcanton-splice-api-featured-app-v1.canton-splice-amulet,-wallet,-wallet-paymentsandcanton-quickstart-licensingreference them instead of carrying copies. - Why it is breaking: those packages previously existed once inside each
crate that depended on them, and Rust treats the copies as unrelated types.
A
ContractId<Holding>read throughcanton-splice-amuletdid not typecheck against theHoldingofcanton-splice-wallet, so no program could use both crates together. They are now one type. - Migration: a path that went through the containing crate now goes through
the package’s own crate —
canton_splice_amulet::splice_api_token_holding_v1::…becomescanton_splice_api_token_holding_v1::splice_api_token_holding_v1::…, and the new crate is added toCargo.toml. Nothing changes on the ledger: the package ids, template ids and wire encodings are identical. splice-utilis unchanged — it ships no DAR of its own, so it stays insidecanton-splice-amulet, andcanton-splice-walletreaches it from there.canton-daml-stdlib(new) owns the Daml standard library —daml-stdlib,daml-prim,ghc-stdliband their per-module packages — and every bindings crate references it. The same defect applied to those:RelTimeappears in public field types (HoldingView’s lock expiry,AnyValue::AV_RelTime), and each crate declared its own. The standard library ships no DAR, so this crate is generated from a selection of packages out of the DAR committed to this repository — which means its drift guard needs no external checkout.- Every crate shrank by the standard library it no longer carries:
canton-splice-api-token-metadata-v1134 KB → 12 KB,canton-splice-wallet310 KB → 189 KB,canton-splice-amulet556 KB → 435 KB.
Fixed — the pre-submission review, September 2026
Five agents went over the branch the way M1 and M2 were reviewed — a gap audit against the proposal, three adversarial code reviews (token, signing, PQS + conformance + release hygiene), and a test runner over every live suite, both feature profiles, the CI gates and a semver diff — and this is what they turned up. Every finding was re-verified against the code before it was acted on; the ones that did not survive that check are not here.
canton-pqsdid not require TLS fromconnect_tls. The connection string went to the driver unchanged, and tokio-postgres’s default issslmode=prefer— so a store with TLS off, or a string carryingsslmode=disable, gave a plaintext session from a method namedconnect_tls, with no error. The mode isRequirenow, and a test proves a store that answers the TLS request with “no” is refused.canton-pqscalled every error without a SQLSTATE retriable — a typo in the connection string, a wrong password, a parameter the driver cannot bind, a column it cannot decode. An application looping onis_retriable()spun on its own mistake. The driver’s permanent kinds are classifiedInvalidRequest; and the client, which held one connection and could only ever say “retriable” once the store had closed it, now reconnects.canton-pqshad no deadlines. Connect and query are bounded (30s by default;connect_with_timeout,with_timeout) and surface asError::Timeout.Predicate::eq("count", 5)matched nothing. LF-JSON stores every number as a string; a jsonb number never equals one. Numbers bind in their string form now. And alimitwithout an order was a random sample — pages could overlap — so a limit orders by ledger offset, then contract id.- The registry’s 409 was permanent. Every token-standard document defines it as “a contract in the reply is mid-reassignment”: transient, gone in seconds. Reported as a connection-class error now, which retries.
Instrumentwas a transcription of metadata-v1 1.0.0. The 1.2.0 document addspaused,pauseInfoandaccountInputFieldsToShow; a wallet on this crate could not say why a paused instrument refused. Added. The seven OpenAPI documents are vendored undertestdata/openapi/at the Splice release the LocalNet runs, with provenance, and a test pins every path the crate spells against them.canton_token::holdings. A transfer or allocation naming no input holdings is refused by Splice’s registry, and one naming a locked holding fails at the interpreter — both after the registry round-trip succeeded, which is how the V2 examples failed on a machine that had just run the allocation example. The module reads the sender’sHoldingviews from the ACS and says which are spendable; the examples use it whenCANTON_TOKEN_HOLDINGSis unset, and all three commit first try.execute_submissiondid not recognise its own retry. A retried attempt the participant de-duplicated came back asALREADY_EXISTSand was reported as failure — the casesubmit_commandsalready handles. Handled the same way;Prepared::change_id/Executable::change_idhand out whatawait_completionneeds;execute_submission_and_waitreturns the whole response, completion offset included, rather than the update id alone.- Three live suites skipped silently.
canton-token,canton-pqsandinteractive_liveneither said so nor looked atCANTON_TEST_REQUIRE_LIVE; a run with no registry, no store and no participant was green. They carry the same guard as the ledger and admin suites. - A conformance row claimed party management over JSON with a test that
built a gRPC client. There is no
/v2/partiesin the JSON client; the row is a declared gap with the reason. (packages__json_package_mgmtwent the other way — see reading packages.) - docs.rs metadata was missing on every M3 crate, so
connect_tlsand the in-memory key would not have been documented, and nothing after publication can fix that. Every hand-written crate carries the block now, withdoc(cfg)on the gated items. Generated crates forbidunsafe_codein the file itself — the workspace lint never reached them. - Docs that had fallen behind: RELEASING.md’s thirteen-crate order (there
are thirty-one; derived from the manifests now), the facade’s feature list
(
ed25519andpqs-tlswere undocumented),canton-token’s CIP-56-only title for a crate that implements both standards, and this file, which the merge from 0.2.3 had left with every M3 entry under a published version. - Coverage and mutation passes over the M3 crates, the two phases the
September runner did not reach. Every
canton-tokenworkflow function — V1 allocation, the instruction choices, all ofv2::*— now has an in-process drive against the registry stub that asserts the request and that the registry’s context lands inside the exercise argument (a mutation run showed a workflow could drop the context and no test would notice); the holdings read is driven against an in-processStateService; the JSON transport’s bounded reads, wrapped submission and party endpoints are covered by the scripted server; the interactive-submission accessors, the registry’s transport verdict (refactored to be testable on the strings it reads), the PQS row accessors and path spellings, and the signer’sDebugand fingerprint are pinned by unit tests. The proposal’s “submit → observe → query on both transports” runs in CI as one flow per transport against an in-process participant (submit_observe_query_over_grpc/_over_json), with the contract id threaded through all three steps; the live suites run the same flow against a real node. rustls0.23.45 in the lockfile for RUSTSEC-2026-0285 (TLS 1.3 handshake messages accepted across encryption-level boundaries), which cargo-deny caught on the milestone PR. The manifests already allowed the fixed version; a consumer of 0.2.x gets it withcargo update -p rustls.- The live suites run on any LocalNet, not only cn-quickstart. The
interactive-submission suite runs against an unauthenticated participant
(Canton Builder Tool, Splice LocalNet’s default) with nothing beyond the
endpoint, where before it skipped for want of an admin client; the PQS suite
reads
Amulet— which any Splice LocalNet holds once its party has tapped — instead of the reference app’sAppInstallRequest, andtools/pqs/compose.yamlruns a Scribe store against any participant on the host in one command. The README’s testing section says where a LocalNet’s registry is (scan.localhost:4000) and how a single party runs every token example,settle_batchincluded. - The live runs are on record. The three token-standard examples and the
JSON-only package read were run again on 2026-09-07 and their output is
committed verbatim, with the environment and commands, under
docs/verification/; the compatibility matrix’s offsets now point at that run. On 2026-09-22 the same examples ran against a validator on the Canton Network DevNet (Canton 3.5.17, Splice 0.8.1, the public SV Scan as the registry): both transfers settled asdirect, the allocation was created and then withdrawn by the sender —v2_withdraw_allocationis the new example that does so — a second allocation was settled by its executor through Amulet’s settlement factory (v2_settle, the other new example, which finds the allocations naming the caller as executor and settles each settlement as one batch), and every update id was read back from the node. The factory first refused a one-sided leg: a V2 leg needs both sides authorised, sov2_allocatenow authorises both when the sender is also the receiver. The token examples take aCANTON_TOKENbearer token as well as OIDC credentials, and an OIDCCANTON_TEST_AUDIENCE. canton-ledger’s tests did not build on their own: the dev-dependency oncanton-signerinheriteddefault-features = falseand the tests import the in-memory key. Green only through the facade’s feature unification.
Changed — Error::Http grew a url field (breaking)
canton-core reads Error::Http::body as the response — a category, a retry
delay, a correlation id — and the registry client had been prefixing the URL
onto it, which broke every one of those reads on that lane. It was doing so
for a real reason: a 404 from a mistyped base URL and a 404 from an unknown
contract read the same otherwise. Both were right, so the variant carries
both: body is the response and nothing else, url: Option<String> is where
the request went, and Display reads http 404 at <url>: <body>.
The variant is #[non_exhaustive] from here. Construct it with
Error::http(status, body) or Error::http_at(status, body, url); match it
with ... That is the migration for code on 0.2.x that built or matched the
variant by hand.
Added — interactive submission with a pluggable signer
canton-signer(new) —Signer, an object-safe async trait for signing a prepared transaction’s hash, so an HSM or KMS fits behind it.Ed25519Keyis the in-memory implementation, onring, behind a defaulted25519feature an HSM implementer can turn off.- A key is not yet an identity: Canton addresses a key by a fingerprint it
computes itself, so
Ed25519Keycannot sign for the ledger untilinto_signer(fingerprint)gives it one. The ordering the types enforce is the real one. canton-ledger—prepare_submission,execute_submission,execute_submission_and_wait,execute_submission_and_wait_for_transaction, with the flow as a type per stage:Prepare→Prepared→Executable. Nothing unsigned can be executed, because there is no such value. Signing refuses a party that is not acting, andunsigned_partiesanswers “who is still missing” without a round trip.canton-ledger—connected_synchronizers, so “which synchronizer?” is a question the participant answers rather than configuration.canton-admin—generate_external_party_topologyandallocate_external_party: onboarding a party whose key the participant does not hold, which is two calls with a signature over the onboarding multi-hash in between.- Verified live on a Canton 3.5.7 participant: an external party is
onboarded by signing its own topology, a command is prepared, signed off the
participant and committed, and a signature from the wrong key is refused —
Received 0 valid signatures from distinct keys (1 invalid). That last one is the control: it is what shows the signature is carrying the authorization.
Added — token standard (CIP-56)
- The
cantonfacade re-exports the three new crates ascanton::signer,canton::tokenandcanton::pqs. Without that,cargo add canton— which the README describes as getting “everything below as one version-locked set” — delivered none of this milestone. canton-token(new) — the workflow over the generated token-standard types, which it does not re-declare:RegistryClientfor the off-ledger API, choice contexts with their disclosures,TransferFactory_Transfer, and the allocate path with execute / withdraw / cancel.- Every path and payload comes from the standard’s OpenAPI documents. Two are vendored in cn-quickstart; the other two were taken from the pinned upstream commit those copies name.
- The standard specifies
choiceArgumentsas the choice “encoded using the Daml JSON API, withextraArgs.contextandextraArgs.metaset to the empty object”, and returns the context the same way — which is whatcanton-damlimplements. So a generated choice serializes straight onto the wire and the reply deserializes straight into the generatedChoiceContext. TokenCommandkeeps a command and its disclosures together and converts into either an ordinary or an interactive submission, so a token transfer can be signed by a party whose key the participant does not hold.- Each choice on an allocation fetches its own context: the standard says a context may be specific to the choice, so sharing one is a bug that works until a registry starts distinguishing them.
- The V2 workflow lives in
canton_token::v2, keeping the same function names one module down:token::transferandtoken::v2::transferare different standards, and the path is what says which you meant.
Added — conformance to the Ledger Client Standard
canton-conformance(new, not published) — one test per capability of the standard, named for the row it answers, so a reviewer can read the two side by side.conformance/capabilities.tomlis the machine-readable checklist, derived from the capability matrix DA published.- A completeness guard asserts the two agree in both directions: no capability claimed without a test, and no test claiming a capability the registry does not list. Checked against the suite’s source rather than a run of it, so an ignored test does not count as coverage.
- The suite exercises the SDK through the
cantonfacade, which is how it found thatcanton::telemetrywas not re-exported — the metric names and transport labels an application builds a dashboard from were unreachable fromcargo add canton. They are exported now. - One row is honest about its limits rather than quietly weaker: contract keys are generated and exercisable by key, but no template in this corpus declares a key, so the test asserts the mechanism is present and says so.
docs/compatibility-matrix.md— toolchain, platform, Canton release, Daml-LF minors, token-standard version, and a table of what CI checks against what needs a live node.- A
conformanceCI job runs the suite and asserts the count matches the registry exactly, with nothing ignored.>=across every binary in the package left slack equal to the number of guard tests, and an#[ignore]d capability could hide in it — the name-based guard reads the suite’s source, so it counts an ignored test as coverage.
Fixed — a full M3 review, and what it turned up
Two independent reviews of the whole milestone: one against the proposal, one hunting bugs and checking whether the problem classes found in M1 and M2 had recurred. Every finding below was verified against the code before being acted on, and two of the reviews’ own claims did not survive that check.
Retriability, which M1 got wrong in two places and M3 got wrong in two more.
canton-pqsclassified every Postgres SQLSTATE asInvalidRequest, which is not retriable. A failover (57P01), a serialization failure (40001), a deadlock,53300 too_many_connections— all reported to the caller as “PQS rejected the query”, pointing whoever read it at a predicate that was never wrong, and making an application give up on a five-second restart. Now classified by SQLSTATE class:08,40,53,55and57are transient, everything else is the caller’s.classifyhad no test at all; it has three.canton-token’s registry client turned every transport failure into a retriableError::Connectioncarrying only reqwest’s outer sentence. A certificate the client cannot verify was retried forever, and the word “certificate” — which lives in the source chain — never reached the operator. The chain is now walked, and a certificate failure, a malformed URL and a redirect loop are reported as non-retriable.
Two integrity checks that did not check.
canton-admin::get_packagecompared the participant’s ownhashfield against the id that was asked for, and skipped the comparison entirely when that field was empty — while its doc claimed “a package id is the hash of its payload, so that check is what makes asking by id pin the content”. It now hashes the payload.- The committed V2 payload corpus is documented as needing no checksum file
“because each file name ends with the id it hashes to”. Nothing hashed them.
The shared generation table now does, and rejects a name that does not carry a
64-hex id —
rsplit('-').next()returned the whole filename when there was no-, sofoo.lfpayloadyielded the package idfooand the error written for that shape was unreachable.
A guard that was absent from the configuration it was written for. The
Ed25519 signature-length check in canton-signer sat inside
#[cfg(feature = "ed25519")], and the crate’s docs tell HSM/KMS callers to
build with default-features = false. The length of an Ed25519 signature is a
property of the algorithm, not of the in-memory implementation, so the check is
now unconditional — and CI runs the tests with default features off, which the
feature-powerset job could not (it passes --no-dev-deps, so tests are never
compiled under a feature combination).
A query that silently returned less than it should. Predicate::not_eq
rendered as <>, which yields SQL NULL against a missing JSON path and drops
the row. Since a query matches on the package name so it survives a Smart
Contract Upgrade, a result set normally mixes payloads of different versions
with different field sets — so adding a field in v2 would have quietly dropped
every v1 contract from a not_eq on it. Now IS DISTINCT FROM. The ordered
comparisons keep NULL semantics, which is right, and now say so.
instrument() reported a misconfigured base URL as an answer. Any 404
became Ok(None) — “this registry does not issue it” — so a base URL one path
component off made a wallet’s polling call return a steady, quiet None while
every other call on the same client failed loudly. A 404 whose body is not JSON
did not come from a registry handler and is now an error that says so.
The conformance registry claimed less than the SDK does, and nothing could
tell. conformance/capabilities.toml listed 39 rows; the Ledger Client
Standard has 49 in scope. The two existing guards compare the registry to the
suite, so a row missing from both was invisible to them — and ten were,
including Explicit disclosure, an M3 row and a named sub-item of the
token-standard deliverable. Meanwhile the CI job derived its threshold from that
same short file, so the gate grew easier as the registry shrank.
- The ten rows are added, each with a test: explicit disclosure, interface subscriptions, node health, gRPC package management, vetting, the three topology reads, and user self-inspect.
- One of them is not added as a claim. “JSON package mgmt” is an M1 row this
SDK does not implement —
JsonClientcovers version, ledger end, commands, ACS and updates, and has no package endpoint — so it is recorded as a[[gap]]with a reason. A capability nobody can name a test for is not one this SDK has, and writing a test that named it anyway is precisely the fake coverage the mechanism exists to prevent. - A third guard asserts capabilities + gaps account for every in-scope row, so dropping one now fails rather than passing quietly. Removing “Explicit disclosure” was used to check the guard actually fires.
- The CI counter requires an exact match with zero ignored, and counts
[[capability]]blocks rather thanidlines — gaps carry anidtoo.
Reachability through the facade. canton-pqs’s tls feature was not
forwarded, so PqsClient::connect_tls did not exist for a cargo add canton
user; reaching a PQS behind TLS meant a second, separately-versioned dependency
— the version skew the facade exists to prevent. Forwarded as pqs-tls, and the
conformance suite now names the method, which is the file that states the rule.
The registry stub did not check the HTTP verb. It recorded path and body
only, and answered 200 to any request line, so a factory issued as a GET
passed every assertion — reqwest sends the body either way. The method is
recorded and asserted.
Documentation that claimed more than the code did. The compatibility matrix
said bindings drift covers “all nineteen” generated crates (CI covers eighteen;
canton-quickstart-licensing builds its DAR from source and is guarded
locally), and opened by saying every row is exercised in CI while its own table
lists four that are not. The capability registry cited canton-kernel and
canton-daml-runtime, neither of which exists — they are canton-core and
canton-daml. The facade’s landing-page table omitted all three M3 crates and
still described the token crate as CIP-56 only. Three intra-doc links were
broken, two of them predating this work.
Reviewer claims that did not survive checking, recorded because a review is evidence, not a verdict: the count of missing capability rows was first reported against a 49-row total I could not reproduce until I found that three sections of the map use a different column order; and “all ten are implemented” was wrong for JSON package management, which is why that one became a gap rather than a test.
Verified — both token standards, against a live registry
The registry half of canton-token had been exercised only by
tests/inprocess.rs, because the environment was recorded as having no
registry. That was wrong: a LocalNet’s registry is the scan, which the
super-validator runs, and cn-quickstart runs one under SV_PROFILE=on on port
5012 — it simply does not publish that port to the host.
Against it, with the Amulet instrument declaring both standards
(splice-api-token-transfer-instruction-v1 and -v2):
- A V1 transfer settles end to end — factory resolved against the registry, submitted with the four contracts it named for disclosure, committed at offset 39643.
- A V2
Account-based transfer against Amulet on a LocalNet, exercised as a V2 implementation — committed at offset 39646. The proposal’s verification clause names the V2 reference token; that network is retired (see the compatibility matrix), so this is the closest available target, and the first time the/v2/paths, theAccountmodel and theactorsfield have met a real registry rather than a transcription of its specification. - V2 event parsing on a committed transaction —
events::holdings_changesread back one holdings change: one holding spent, two produced, two transfer legs. The interface is matched on its qualified name rather than its package id, and this is the first evidence that a real registry’s event satisfies that match. tests/live.rs(new, env-gated onCANTON_TOKEN_REGISTRY_URL) covers the registry read path: the admin party parses, instruments decode with the optional fields genuinely absent, a not-issued instrument isNonerather than an error, page tokens round-trip, and the declared API versions are readable. As with the other live suites, a set variable that cannot be reached fails rather than skips.
What the first real submission found. Both examples passed an empty
inputHoldingCids, with a comment that a registry may select holdings itself.
Splice’s reference registry does not: the transfer reached the Daml interpreter
and failed with At least one holding must be provided. An end-to-end example
that cannot complete against the reference implementation is not end to end, so
both examples now take CANTON_TOKEN_HOLDINGS and say why it is not optional in
practice. The V2 example also reads its own committed transaction back through
holdings_changes, which is where a reader would look for it.
Fixed — a three-milestone review, and the fixes that had siblings
Three reviews: M1+M2 against their proposal text, M3 exhaustively, and a sweep for fixes applied in one place while the same pattern survived elsewhere. The third was the one worth running.
The cause chain, now in one place. The previous round fixed how the registry
client reports a transport failure. Four other sites had the same two defects —
a message that dropped everything under the outer error, and a verdict of
retriable Error::Connection for conditions no amount of waiting fixes. Two of
them, canton-ledger’s JSON client and canton-auth’s token fetch, sit inside
run_with_retry, which the registry client did not: an IdP or participant behind
a certificate the client cannot verify ran the full retry schedule and then
reported error sending request for url (…), with the word certificate one
level down in a chain nobody read. canton_core::chain is now the single walker
— three crates had each grown a private copy before anyone noticed the fourth had
none — and each site classifies for itself, because what is permanent differs:
a bad token endpoint is Auth, a bad JSON body is UnexpectedResponse.
PqsClient::connect had the bug the same file’s classify had just lost.
Fixed by the SQLSTATE class rule already written twelve lines below it. Verified
against the running store: a wrong password is now
invalid request: … the store refused the connection (28P01): … FATAL: password authentication failed, non-retriable, instead of a retriable db error.
The token deliverable could not be called through the facade. Every entry
point takes a generated type — transfer takes a Transfer — and the facade
depends on no canton-splice-* crate, so a cargo add canton user could reach
the functions and not name their arguments. canton_token::types re-exports the
modules (types::v1::transfer_instruction, types::v2::holding, types::metadata
…), whole modules rather than a hand-listed set that goes stale on the next
record the standard adds. This is the pqs-tls defect one level up, and larger:
that one hid a feature, this one hid the milestone.
External-party onboarding now checks the one relation it can. A party id is
<hint>::<namespace>, and for an external party the namespace is the
fingerprint of the key being registered — so a response assembled for a
different key is now refused before anything signs it. The multi-hash still
cannot be recomputed (that needs Canton’s hash-purpose scheme, which this
workspace does not implement); this is the free check that was missing beside it.
Half-applied fixes from the previous round, completed:
- The HTTP-verb assertion reached the six POST endpoints and none of the GET ones — in the file whose own new comment names both.
CANTON_TOKEN_HOLDINGSwas added to both examples with a doc that still carried the pre-fix reassurance, contradicting the call-site comment eight lines below it. The helper now warns when the variable is unset rather than defaulting quietly into a failure that arrives from the Daml interpreter.pqs-tlsreached the manifest and the module table but not the facade’s own canonical feature list, which reads as complete.get_package’s doc still described the check the hash fix removed — that the SDK trusts the server’shashfield.
Documentation that claimed more than the code did. The published matrix
called a run “against the V2 reference token” six lines after naming the
instrument as Amulet; it now says what it is — a V2 implementation exercised as
one — and points at what the clause still needs. docs/daml-lf-type-mapping.md,
the M2 deliverable document, said references are “always”
crate::<package>::<module>::<Type>; every one of the nineteen generated crates
in this repository disproves it, and the cross-crate form is now documented with
the emitted code beside it. ADR-0005 claimed the bindings crates encode the DAR
version in their version metadata (they do not). The CHANGELOG said thirteen
generated crates in the same entry where it said nineteen. Two CI comments
described a four-guard shape that no longer exists, and canton-quickstart-licensing
pointed at the wrong crate for its own drift guard.
The CI step gating the conformance count did not check that it ran.
cargo test exits 0 on “0 passed”, so deleting every completeness guard would
have turned green the step that everything downstream depends on. Its sibling
steps already assert their own markers.
Added — the V2 token standard (CIP-0112)
- Six new bindings crates:
canton-splice-api-token-holding-v2(which carries theAccountmodel),-transfer-instruction-v2,-transfer-events-v2(theEventLoga V2 transfer is parsed from),-allocation-v2(withSettlementFactory,Allocation_SettleandFinalizedAllocation— executor settlement),-allocation-instruction-v2and-allocation-request-v2. - V2 reuses
splice-api-token-metadata-v1: there is nometadata-v2. - Where the packages came from. V2 ships as no DAR anyone publishes —
cn-quickstart carries only the V1 set, the Splice repository holds Daml
sources rather than built artefacts, and there are no release assets. It is
live on the network, so it was taken from a participant through the Ledger
API’s
GetPackageand committed undertestdata/token-standard-v2. A package id is the SHA-256 of its own bytes and each file name ends with that id, so the corpus is pinned by construction and needs no checksum file. - The V2 crates reference
canton-daml-stdliband the metadata crate rather than copying them, exactly as the V1 crates do — theghc-stdlibanddaml-stdlibpackages they depend on carry the same ids the V1 corpus uses, which is why one stdlib crate serves both. - The drift guard covers all nineteen generated crates, and the seven generated from committed packages are guarded in CI with no external checkout.
canton_token::v2— the workflow over those types: transfer with accept/reject/withdraw, allocate,settle_batch, and the allocation and allocation-instruction choices. Four differences from V1 a caller meets at once, each of which the module documents: a transfer moves between accounts rather than parties (and both the owner and the provider of an account are optional); every choice names its actors, which V1 left implicit in the submitting party; a V2 allocation names the settlement it belongs to when it is created; and settlement moved to a batch on the settlement factory, which is what lets both legs of a delivery-versus-payment settle together. Asking for a V1execute-transfercontext is refused before it reaches the network, naming the settlement factory as its replacement — a registry would answer 404 with nothing that says why.- The V2 paths were taken from the OpenAPI documents rather than derived from
V1: the collections are singular (
transfer-instruction,allocation-instruction,allocation) except the choice contexts on an allocation, which are under the pluralallocations. A test pins each one, because a client that regularises the odd one out gets a 404 from a registry that is working correctly. canton_token::v2::events—holdings_changesreads what actually moved off a transaction. A V2 registry records it by exercisingEventLog_HoldingsChangeon theEventLoginterface, and that is the only complete answer: the creates and archives show holdings appearing and disappearing without saying which transfer they belonged to. Events are matched on the interface’s qualified name, not its package id — pinning the id would make a client stop seeing events the day a network upgraded the standard, and that failure reads as “nothing moved” rather than as an error. The choice name alone is not enough either: it is not reserved. An argument that does not decode is reported rather than skipped, for the same reason.examples/v2_transfer.rsis the V1 example’s counterpart, written to be read beside it, andexamples/v2_allocate.rsis the allocation flow — the half that needs three parties rather than two. Run against the live registry with app-provider as sender, app-user as receiver and the super-validator as executor: allocated at offset 40176, six events. It stops before settling on purpose; that is the executor’s move from their own participant, and one process holding both sides would prove nothing about a pattern whose whole point is that the principals differ.- The allocation example is also the first thing written entirely against
canton_token::types— it namesSettlementInfo,AllocationSpecification,TransferLegSideandAccountwith no direct dependency on anycanton-splice-*crate, which is what the re-export was for.
Added — reading packages from a participant
-
The same reads over JSON.
JsonClient::list_packages(GET /v2/packages) andJsonClient::package_status(GET /v2/packages/{package-id}/status), returning the gRPC path’sPackageStatusso a caller switches transports without re-learning the vocabulary. Asked for in issue #2 by a JSON-only deployment; the conformance registry’s one declared gap (packages__json_package_mgmt) becomes a capability with it. DAR upload (POST /v2/dars) is deliberately not included — an operator write with its own authorization story, tracked separately. -
Party management over JSON.
JsonClient::list_known_parties_page/list_known_parties(GET /v2/parties, paged),get_parties(GET /v2/parties/{party}),allocate_party/allocate_party_with(POST /v2/parties, the latter with synchronizer, user, identity provider and annotations throughAllocateParty),update_party_details(PATCH /v2/parties/{party}with a field mask) andparticipant_id. All answer in the gRPC path’sadmin::PartyDetails, so a caller switches transports without re-learning the type. Allocation and update are not retried, for the reasoncanton-admingives. With this the conformance registry’s last declared gap (parties__json_party_mgmt) is a capability: 51 rows, 51 tests, no gaps (two rows the plan had filed under post-v1 turned out delivered by the signing work — external-party creation and listing connected synchronizers — and are claimed). -
canton-admin—get_packagedownloads a package’sArchivePayloadbytes and checks the hash the participant returns against the id that was asked for. Withlist_packages, that is enough to generate bindings from what a network has actually vetted rather than from a file that has to be found — and for a package that exists only on a network, there may be no file. -
canton-lf—decode_payloadreads that shape. A DAR entry is a wholeArchive(payload, hash and hash function together) anddecode_packagereads that; the Ledger API returns the three as separate fields. Mistaking one for the other fails with a protobuf error about ahashfield that says nothing about the cause. -
canton-codegen—lower_packages_selectinggenerates from decoded packages rather than only from a DAR.
Added — the Participant Query Store
canton-pqs(new) — a typed read client for PQS/Scribe. A query names its template by type: the qname isPACKAGE_NAME:MODULE_NAME:ENTITY_NAME, read off the generatedContract, and it is the package name, so a query survives an upgrade instead of pinning one build.- PQS stores payloads in the Daml JSON encoding, which
canton-damlimplements, so a contract read from Postgres deserializes into the same generated type a transaction stream yields. Two ways in, one set of types. - Predicates compile to parameterized statements. Every value is a parameter and
so is every JSON field path — bound as
text[]and applied with#>— so the statement text depends on a query’s shape and never on its data. - Ordered comparisons on numbers are numeric: LF-JSON carries
Int64andNumericas strings, so comparing lexically would sort"9"after"10". tlsfeature forconnect_tls; off by default, since PQS is usually inside a trust boundary.- Verified live against a running Scribe 3.5.4 store: 969 active contracts read as typed payloads, payload and party-column predicates filtering in the database, containment, lookup by id, and a pinned-offset read.
Added — codegen
Selectionandlower_dar_selecting: generate a crate from part of a DAR. A reference that leaves the selection is reported as a skipped type rather than emitted, since the path would name a module the crate does not have — it would compile where it is generated and fail in the consumer.Selection::and_prefixedandExternalPackages::with_prefixedtake package name prefixes, for a family that arrives as many packages. The standard library is some thirty of them, and which ones a DAR carries depends on what its Daml source touched, so an exact list is right for one DAR and wrong for the next. A prefix matches at a-boundary:daml-primdoes not also match adaml-primary.
Publish order — this replaces 0.2.0’s, which no longer works.
canton-ledgerandcanton-adminnow depend oncanton-signer, so it has to go out before them rather than not at all:
canton-proto→canton-core→canton-auth→canton-lf→canton-daml→canton-signer→canton-ledger→canton-admin→canton-codegen→canton-codegen-cli→canton-pqs→canton-daml-stdlib(every binding references it) →canton-splice-api-token-metadata-v1,canton-splice-api-featured-app-v1→-holding-v1→-allocation-v1→-allocation-instruction-v1,-allocation-request-v1,-burn-mint-v1,-transfer-instruction-v1→canton-splice-api-token-holding-v2→-transfer-instruction-v2,-transfer-events-v2,-allocation-v2→-allocation-instruction-v2,-allocation-request-v2→canton-splice-amulet→canton-splice-wallet-payments→canton-splice-wallet→canton-token→canton(last: the facade re-exports everything above).
canton-sampleandcanton-quickstart-licensingstay unpublished.
Changed — CI
- The packaging check derives its crate list from
cargo metadatarather than a hand-written one, so a crate added to the workspace cannot be left out of it — the previous list silently stopped covering the eight crates above. - The bindings drift guard checks all nineteen generated crates (it checked
three), reading the crate/DAR/external-package table from the same file the
regeneration example uses so the two cannot disagree.
canton-daml-stdlibis generated from the DAR committed here, so that one is guarded in CI with no checkout at all.
[0.2.3] — 2026-08-25
Fixed
-
A gRPC stream or submit did not retry a connection that died in flight. tonic surfaces a dropped connection mid-call as a
Statuswith codeUnknown(message “transport error” / “h2 protocol error”), not as atransport::Error— that variant is only for connection establishment. SoError::is_retriable()treated the most ordinary failure there is as terminal:updates_resumablegave up on the first blip andrun_with_retrynever fired. Data integrity was never at risk (no loss, no duplicate — the failure surfaced as an error), but the resilience did not engage. A category-lessUnknown/Internalstatus whose source chain is populated is now retriable; a bare serverUnknown(decoded from trailers, no source) stays terminal. Found by killing a real TCP connection under load through a chaos proxy — in-process mocks had emittedStatus::unavailable, which is why it never showed. The WebSocket resumable lane was already correct (it treats a socket close as a reconnect trigger, not via this predicate). -
Template::from_json_created_eventrejected theevents-by-contract-idresponse shape. That endpoint wraps the created event as{"created": {"createdEvent": …}}; the helper peeled only the inner key and errored with “carries no createArgument”. It now unwraps both layers, so all four JSON shapes the API uses decode. -
A test broke
cargo testwithout--all-features.futures_are_spawnableassertedJsonSubmission::recoverisSend, but that method isws-gated while the assertion was not — so the default-feature build failed to compile. Invisible because CI ran tests only with--all-features; a secondcargo test --workspacejob (default features) now guards the whole class. Found by running the suite under every feature combination. -
tokio-stream’s declared lower bound was0.1, but thenetfeature it uses landed in0.1.1— a minimal-versions resolve does not build against0.1.0. Corrected to0.1.1. Found bycargo +nightly build -Z direct-minimal-versions.
[0.2.2] — 2026-08-24
Added
- Conversion and hashing polish from a Rust API Guidelines audit (16
observations, none blocking; the audit’s two breaking items are decided
rather than fixed — ADR-0010
defers the
*ParseErrorrenames and the removal ofParty’s unvalidatedFrom<&str>/From<String>to 1.0, the latter blocked from gaining a checkedTryFromtoday by std’s blanket impl).ChangeIdderivesHash— it is the key the docs tell applications to track submissions by, and it could not be aHashMapkey.Store,TransactionShapeandClientAuthfollow.canton-ledgerandcanton-adminre-exportAuth,ErrorInfoandResourceInfo(and admin gainsErrorCategory): these appear in the signatures ofError::error_info/resource_infoandConfig::auth, so a user of one client crate could not previously name the types their own functions return without adding acanton-coredependency.ErrorCategoryimplementsTryFrom<i32>andFrom<ErrorCategory> for i32alongside the inherent methods.#[must_use]on the constructors that lacked it; docs.rs feature badges on all seven remainingws-gated methods (two of nine had them).
- A compile-time proof that every public future is
Send(tests/futures_are_spawnable.rs) — an async API that cannot betokio::spawned is unusable in a server, and the property breaks silently. - Hermetic coverage for the paths only live tests exercised, found by
measuring coverage rather than reading it: the three
TopologyClientlist reads (36% file coverage, all three public methods untested without a node — the same shape of gap the M1 review named about live tests), both recovery handles’recover, andSubmission::submit_and_wait. Hand-written code measures 87.7% line coverage;cargo udepsreports no unused dependencies.
Fixed
- The in-process duplicate-submission mocks answer synchronously on the
fire-and-forget lane; a live Canton 3.5.7 participant does not — it accepts
the RPC and reports the rejection on the completion stream. Established by
adversarial probes run with the published 0.2.1 crates against a real node
(which also confirmed the JSON duplicate answer is exactly the
HTTP 409 +
DUPLICATE_COMMANDtheis_duplicate_submissionpredicate was written against, and that OIDC refresh survives a real token expiry). The mocks stay as pins of the synchronous case; their comments now say where reality differs.
[0.2.1] — 2026-08-24
Fixed
canton-ledgerdocumented as failed on docs.rs. Two#[cfg_attr(docsrs, doc(cfg(feature = "ws")))]attributes went in without the crate declaring#![cfg_attr(docsrs, feature(doc_cfg))], which that attribute requires.cargo docnever setsdocsrs, so the attributes compiled out and every gate stayed green; docs.rs does set it, on nightly, and the published crate’s documentation page said the build failed. The crates themselves installed and built correctly throughout — this only ever affected the rendered documentation, and it cannot be fixed for a version already published, hence this patch.- A
docsrsCI job now builds the workspace the way docs.rs will, so the class of error that produced this cannot reach a release again. - The MSRV job never checked the MSRV.
rust-toolchain.tomlpins the channel for anything run inside the checkout and wins over the toolchain the CI action installs — rustup says so in its own log, then uses stable. So a job that asked for 1.88 ran on stable, andrust-version = "1.88"went unverified every time it was claimed. It happens to be true (1.88 checks clean), but nothing was establishing that. Both jobs that want a specific toolchain now setRUSTUP_TOOLCHAIN, which is the override that beats the file. - The
cantonfacade declaredall-featuresfor docs.rs without the matchingrustdoc-args, the same inconsistency one step behind. cargo-semver-checkscovered 5 crates of 11. The M2 crates had no published baseline to compare against until 0.2.0, and the job’s own comment said they should join afterwards — this is afterwards.canton-protostays out by ADR-0002 andcanton-codegen-clihas no library API.
Changed
RELEASING.mdcarries the post-publish verification the pre-publish gate cannot do:cargo packageruns--no-verifybecause verifying needs the siblings to already be on crates.io, so the packaged artefact is first built by whoever installs it. The runbook now says to be that person first.- The README compatibility table listed 0.2 as unreleased.
[0.2.0] — 2026-08-24
All canton-* crates release in lockstep, so the M1 crates move to 0.2.0 with
the rest. Everything the 0.1.x line gained after the M1 submission — the read
request builders, the full Commands surface, Canton-native error
classification (see 0.1.2 below) and the documentation fixes of 0.1.3/0.1.4 —
is included.
Breaking, for code on 0.1.x
Two signatures change, both because the old one could not express a correct call. Everything else from 0.1.x compiles unchanged.
-
OidcConfig::auth0(domain, client_id, secret)→auth0(domain, audience, client_id, secret). Auth0 answers a client-credentials request without anaudienceby issuing a token for its own userinfo endpoint, which a participant rejects — so the old preset could not produce a working request. The audience identifies your Auth0 API and cannot be derived from the domain.- OidcConfig::auth0("my.eu.auth0.com", "client-id", "secret") + OidcConfig::auth0("my.eu.auth0.com", "https://ledger.example", "client-id", "secret") -
CantonClient::await_completion(command_id, parties, offset, timeout)→await_completion(&ChangeId, offset, timeout). Canton identifies a command by (user, acting parties, command id), and matching on the command id alone can return another application’s completion. The parties move inside the change ID.- client.await_completion(&command_id, vec![party.clone()], offset, timeout) + client.await_completion(&ChangeId::new("", vec![party.clone()], &command_id), offset, timeout)Better still, take the identity from the submission rather than rebuilding it:
let submission = client.submission(submit);thensubmission.recover(offset, timeout).
Added — type-safe codegen from DARs
canton-lf— native Rust Daml-LF reader: DAR container (zip +MANIFEST.MF) and an LF 2.x protobuf decoder (vendoreddaml_lf2.proto), including the LF 2.dev additions — the explicit package-import table and curried type application (TApp) — used by SDK 3.5 DARs. Decodes a DAR’s whole dependency closure (decode_all).canton-daml— the runtime generated code depends on (asrt): the Daml primitive types (Party,ContractId<T>,Numeric,Timestamp,Date,TextMap,GenMap,NestedOpt,Unit), theToValue/FromValuegRPC codecs and serde JSON conventions (LF-JSON:Int64andNumericas strings — accepting a number on input — the nested-optional list form,Unitas{}, adjacently-tagged variants, …), theContract/Template/Interface/WithKey/Choicetraits, and thecreate_command/exercise_command/exercise_by_key_commandbuilders.canton-codegen— DAR → typed Rust: a decoder-agnostic IR, the documented Daml-LF → Rust type mapping (seedocs/daml-lf-type-mapping.md), and emission of records, variants, enums, templates (payload + typed choices + on-ledger id), interfaces (marker + view + choices), and contract keys, with JSON and gRPC codecs. Output is a fully-qualifiedpub modtree (crate::<package>::<module>::<Type>) so cross-package references resolve and names never collide; template ids use the SCU-friendly#<package-name>form. The package segment is the package name — a version bump does not rename the paths a consumer imports — with the version appended only to separate two packages that would otherwise share a module name. Each template’s docs carry its on-ledger id and its choices with their consuming flag.canton-codegen-cli— thedpm-codegen-rustbinary (adpm codegen-rustcomponent / build-script tool) that writes a self-contained bindings crate from a DAR.- Pre-built bindings crates (checked-in generated output, drift-guarded):
canton-splice-amulet,canton-splice-wallet,canton-splice-wallet-payments, andcanton-quickstart-licensing. canton-sample— reference app: builds a typedAppInstallRequestfrom the bindings, round-trips both codecs, and runs the full verification loop (submit → observe transaction → query ACS) over gRPC and JSON.- Verified on a live cn-quickstart participant: the whole-DAR closure compiles for all corpus DARs, and the sample’s typed create commits and is read back from the ACS on both transports.
Added — a local network needs no configuration in your program
canton_core::localnetreads a Splice LocalNet out of the environment — the variables canton-devkit’slocalnet envexports. Aftereval "$(canton-devkit localnet env demo)",Config::from_env()is a working gRPC configuration andJsonClient::from_env()its JSON counterpart, with the participant’s token attached;Config::from_env_for("app-user")reaches the other participant, andlocalnet::party(alias)the on-ledger id an application needs foract_as.CANTON_ENDPOINT/CANTON_TOKENoverride the lot for an environment that is not a LocalNet. Runnable as thelocalnetexample.Configaccepts a scheme-lesshost:port, which is what a gRPC client dials and therefore what tooling hands out —CANTON_GRPC_LEDGER_API_URLis exactly this shape. Previously the missing scheme surfaced at the first RPC as an unexplained transport error; the scheme now defaults to the one TLS configuration implies.
Security
-
canton-lf(zip bomb): a DAR is now bounded in total, not only per entry. Every.dalfis read into memory, so an archive of many entries each legal on its own — a thousand at 256 MiB — passed every check and still asked for hundreds of gigabytes; zeros DEFLATE at roughly 1000:1, so the file carrying that request arrives small enough to go unnoticed. Each read is now capped by whichever of the per-entry ceiling and the remaining archive budget (2 GiB) is smaller, and the error says which limit was reached. The ceiling is ~50× the entire available corpus (41 MiB across 18 DARs), so it cannot fire on a real DAR. Found by reading canton-devkit’s DAR reader, which has had the aggregate cap all along. -
canton-core: the mutual-TLS private key no longer reaches logs.TlsConfigderivedDebugand holdsclient_identity_pem, soformat!("{config:?}")— or onetracingfield capturing aConfig— printed the key byte by byte;Configleaked it too, along with any credentials in the endpoint URL. Both now have hand-writtenDebugthat reports presence and length. Five error paths acrosscanton-core,canton-authandcanton-ledgerthat quoted a URL verbatim now run it throughcanton_core::redact_url. Two more types held the same secret behind aDebugand were missed the first time:JsonClientderived one and printed itsbase_url— userinfo included — andOidcConfigredacted itsclient_secretfield while printing atoken_urlthat, for a provider taking client credentials as basic auth, is the secret.TokenResponseno longer derivesDebugat all; it is one bearer token, and nothing should print it. -
canton-daml: a type mismatch no longer copies the offending value into the error message.mismatch()formatted it with prost’sDebug, so decoding a payload as the wrong type put the whole record — parties, amounts, free text — into a string that travels into the application’s traces and metrics. It now names the kind and stops. -
canton-core(bearer token inDebug):AuthInterceptorderivedDebugover the token it injects into every gRPC request. The interceptor lives inside every client that holds a channel, so one{:?}on client state — a tracing field, a panic message, an error context — printed a live credential, andSECURITY.md’s “Debug output is redacted” was untrue as written. It now reports presence only. Reported privately by Equilibrium during their M1 review, alongside the mutual-TLS key above. -
canton-core(panic on a hostile retry hint):Error::retry_delay()handed a server-supplied number toDuration::from_secs_f64, which panics on anything aDurationcannot hold — so a JSON error body carrying"retryInfo": "1e300 seconds"aborted the caller inside error classification, which the retry loop calls on every retriable failure. The conversion is fallible now and an out-of-range hint reads as no recommendation.
Fixed — from Equilibrium’s independent M1 review
An engineering review of the released 0.1.4 client by Equilibrium,
carried out on the Development Fund milestone issue. Every finding is closed
here; two of them (the Debug leak and the retry-hint panic) are in
Security above.
-
Ambiguous submissions are recoverable. A submission whose response is lost may still have committed, and the change ID is the only way back to the outcome — but the SDK generated the command id inside the call that failed and returned it only on success.
CantonClient::submissionandJsonClient::submissionfix the identity first and hand back aSubmissioncarrying itsChangeId, withrecoverreading the completion back. The existing client methods are thin wrappers over the same object. -
Recovery matches the whole change ID.
await_completioncomparedcommand_idalone; Canton identifies a command by (user, acting parties, command id), and two applications on one participant may each usedaily-run. It now takes a&ChangeId. A user id left to the bearer token is not compared (the participant resolved it and the client cannot know it), and a completion carrying no acting parties is not rejected on that ground. -
The resumable update stream honours checkpoints.
updates_withdroppedOffsetCheckpointframes before the resumable wrapper could see them, so the resume point only advanced when a transaction arrived — on a quiet stream a reconnect went back to where the caller started, which after pruning fails outright. The resumable path now reads the unfiltered stream and filters for itself. Subscribers see no change. -
A spent reconnect budget reports the participant’s failure, not
UnexpectedResponse("failed to resume after N reconnects"), which threw away the status, the details, the correlation id and the retriable classification at the moment they were needed. Same fix in the resumable ACS read. -
The JSON lane gained the four operations it was missing:
submit(/v2/commands/async/submit),submit_and_wait,events_by_contract_id, and recovery throughJsonClient::submission. All four exist in Canton 3.5.7’s JSON API; this was the SDK stopping short. -
JsonClient::ws_active_contracts_resumableresubscribes from the laststreamContinuationTokenrather than restarting the snapshot, which the gRPC lane has done since M1. -
The typed ACS read is lossless. Every gRPC ACS method matched
ActiveContractand dropped the rest, so a reassignment in flight at the snapshot offset —IncompleteUnassigned/IncompleteAssigned— vanished from a multi-synchronizer application’s view.AcsEntryand theacs_page/acs_entries/acs_entries_resumablefamily are the lossless read; the active-only methods keep their names and are now that read withinto_activeapplied. -
The Auth0 and Okta presets produce their providers’ normal requests. Auth0 needs an
audience(without one it issues a token for its own userinfo endpoint, which no participant accepts) —auth0now takes it, which is a breaking signature change. Okta reads the credentials from anAuthorization: Basicheader and rejects them in the body asinvalid_client; the preset selects that, andClientAuthexposes the choice for custom endpoints. -
Telemetry covers a stream’s life, not its opening.
instrument_streamcounts errors that arrive after a subscription opens — previously a stream that failed an hour in had been recorded as a success and never revisited. The WebSocket upgrade carriestraceparent(the only request a WS stream makes), structured events carrytrace_id, andotel::otlp_metricsis a supported OTLP path for the counters, recorder and all. -
A retry the participant de-duplicates is a success, not a failure. This is the other half of the finding, and the half that was still open after the recovery handle was added. When the SDK retries a submission whose response was lost, the participant refuses the second attempt as
DUPLICATE_COMMAND— because the first one was accepted.submitwas reporting that rejection to the caller, which says the command did not happen at the exact moment it provably did. It now reports success, over both transports. A duplicate on the first attempt is untouched: nothing of ours is at the participant, so the caller reused a change id and needs to hear about it.The waiting variants cannot do this — their result is a transaction, and a de-duplicated retry does not carry one — so they surface the rejection and their documentation now says to recover through the handle rather than describing a caveat and leaving it there.
-
examples/recover_a_submission.rswalks the finding’s own scenario end to end: submit, submit the same change ID again (what a retry after a lost response looks like to the participant), watch it be rejected asDUPLICATE_COMMAND, and recover the original outcome. Verified live — the recoveredupdate_idis the one the first submission committed. The README’s other examples are backed by compiled example files; this one was not, and the newest API is the worst one to leave uncompiled. -
The facade reaches what the documentation promises.
canton::telemetrywas not re-exported, socargo add cantoncould not see the metric names, the transport labels, or — now — the OTLP setup those metrics are meant to be exported through. Theotelfeature also reachedcanton-coreonly by way ofcanton-ledger, which was correct by accident. Both fixed, with tests that name the paths a reader of the README would try.
From the same review’s non-blocking list:
- Requests the participant would certainly refuse are refused locally: a submission with no commands or no acting party, both minimum-ledger-time forms at once, a negative offset, an inverted range, a subscription filtered to nobody.
read_asreaches the transaction filter of a submission’s response, matching the Ledger API’s own default; filtering toact_asalone 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 had applied only to
version, the health check,ledger_endand submissions. - Jitter never brings a retry back before a server-recommended delay; a
RetryInfois a minimum, and coming back early spends an attempt on a guaranteed rejection. - The WebSocket streams take their reconnect budget and backoff from the
client’s
RetryConfiginstead of a hardcoded five-at-250ms. list_known_partiesfails on a repeated page token instead of returning a prefix as if it were the whole list, and a topology response missing a required field fails the read rather than shrinking it.- The vendored
.protofiles carry a provenance record and per-file SHA-256s, verified by a test, withtools/vendor-protos.shfor the refresh. CANTON_TEST_REQUIRE_LIVE=1turns a skipped live test into a failure, so a live run’s result is a claim about a participant rather than about an empty environment.- CI checks
aarch64-unknown-linux-gnuandx86_64-unknown-linux-musl, and thecargo-semver-checksjob is enabled now that a baseline exists. - ADR-0005 no longer claims mixed installs “fail to resolve”; with caret requirements mixed patch versions resolve, which is intended.
canton-admindocuments that party management here is allocation and discovery, and why updates are out of scope for M1.- The reference app reads its committed transaction back independently and matches the exact update id, on both transports.
Fixed — from an external review of the M1 client
canton-core(message size): the gRPC decode limit is raised off tonic’s 4 MiB default to 128 MiB, configurable viaConfig::with_max_decoding_message_size. One ACS page arrives as a single message and the participant permits page sizes up to 10 000, so the default was reachable in ordinary use and surfaced as a client-sideOUT_OF_RANGEthat reads like a server fault. Applied at all 24 service-client constructions acrosscanton-ledgerandcanton-adminthrough one macro per crate, so a newly added RPC cannot pick the default back up. The WebSocket lane had the same problem one layer down and no way to fix it:tungstenite’s defaults cap a message at 64 MiB and a single frame at 16 MiB.JsonClient::with_max_decoding_message_sizenow sets both, and defaults them to the same 128 MiB, so one transport cannot quietly be stricter than the other. (The HTTP lane needs nothing —reqwestputs no limit on a response body.)canton-core(errors):Error::resource_info()exposes thegoogle.rpc.ResourceInfoCanton attaches to failures where “which one?” is the first question —CONTRACT_NOT_FOUNDnames the contract id. AVecrather than anOption, because the JSON transport carries a list.canton-ledger(unbounded waits): the JSON lane had no timeout at all.reqwestapplies none unless asked, so a participant that accepted the connection and then went quiet held the caller’s task open for the life of the process — while the gRPC channel beside it had bounded the same call at 30s since M1.JsonClient::with_timeoutsets it, defaulting to that same 30s, applied per request so it holds whatever order the builders were called in. The WebSocket handshake is bounded by the same value; the stream that follows is a live tail and deliberately is not.canton-core(transport parity):Error::error_info()andError::code()now answer on the JSON transport too. Both returnedNonethere while returning the real thing over gRPC — so an application that classified errors by error id, exactly aserror_info’s documentation tells it to, silently fell back to string-matching the display text the moment it was pointed at the JSON lane. The body spells both (code/contextandgrpcCodeValue); a redacted error’s literal"NA"is reported as no error id rather than as one. A live test now asserts that both lanes describe the same failure identically, so this cannot drift apart again unnoticed.canton-daml(party ids):Party::parse/"…".parse::<Party>()validate a party id a caller supplies — refusing empty, over-long, and characters Canton does not use — whileParty::newstill takes a wire value as-is, the same asymmetryNumericalready had.FromStrwasInfallible, so"".parse::<Party>()succeeded and the failure surfaced only at the participant, as aPermissionDeniednaming nothing. An empty party id is the shapestd::env::varreturns forPARTY=, which is how it happens in practice. Idea taken from zenith-network/canton-rs, which validates its identifier types.canton-codegen(submit vs read): the two encoders a generated template carries are now pinned to agree. The emitter writes the payload’s field list twice — inToValueand inTemplate::to_record— and they are different paths at runtime:create_commandsubmitsto_record, while a contract read back arrives throughfrom_value. A template whose two lists drifted would write one shape to the ledger and expect another, and nothing compared them: every round-trip test goesto_value→from_value, which is the half the submit path does not use.canton-daml(decode errors in containers): a failure inside aList,TextMaporGenMapnow names the element. Generated records attach the field name to every decode, but containers dropped everything below it, so one bad entry in a list of five hundred holdings reported that the list was bad and left the reader to find which. The paths compose:holders.2. A list reports the index, aTextMapthe key (which locates an entry better than a position), aGenMapthe position and which half of the entry — its keys are arbitrary values, so there is no name to point at.canton-daml(dead API removed before it froze):record_fieldandrecord_valueare gone from the generated-code surface. The emitter never emitted either, nothing in the workspace called them, and the crate is about to publish — after which they would have to keep working forever.canton-codegen(drift guard, published crates): a CI job fetches the three DARs the published bindings were generated from — cn-quickstart tracks them in git — at a pinned commit, verifies their SHA-256, and runs the guards against them, asserting each one ran rather than skipped. Nothing before this checked on any push that the emitter still produces what is on crates.io. (canton-quickstart-licensingstays local-only: its DAR is built from source rather than committed, so there is nothing to pin.)canton-daml(fixture drift): the test fixture that claims to be written “exactly as the generator emits” is now checked against the real emitter instead of asserting it in a comment. It had drifted three times — the.at(label)on each field decode, the serde derives, and the per-field renames — and each drift quietly removed a path from coverage while every test stayed green.canton-codegen(phantom type parameters): a generated codec bounds only the type parameters it actually encodes. Daml permits a phantom parameter — declared but used in no field — and the emitter requiredT: ToValuefor every declared one, so instantiating a phantom parameter with an interface marker produced Rust that does not compile. Markers carry no codec by design: they exist only as the tag of aContractId. Valid Daml therefore generated invalid Rust, with the error landing in code the reader did not write. Four types across the published bindings carried the spurious bound and have been regenerated without it.canton-codegen(IR semver): every public IR struct is#[non_exhaustive], with a constructor for each. The IR is documented as something a caller lowers and then post-processes, its fields are public, and it gained forty fields during Milestone 2 alone — so once the crate is on crates.io, one more field would be a breaking change for anyone who wrote a struct literal, and adding#[non_exhaustive]afterwards is itself breaking. Fields stay public, so reading and mutating a lowered IR is unchanged; only construction goes throughRecord::new,Template::newand the rest.canton-codegen(a test that had stopped compiling): the end-to-end test that generates a crate, builds it and round-trips both codecs was gated onCODEGEN_COMPILE_TEST, which nothing set — so it skipped on every push and went stale when the runtime madeNumericandGenMap’s fields private. It now builds its values through the public API, the way a consumer must, and CI sets the variable and asserts the test ran.canton-codegen(manifest injection): the DAR’s package version is validated before it reaches the generatedCargo.toml. It was interpolated raw, so a version of the form0.1.0"+ newline +[dependencies.evil]+git = "…closed the string and opened a table — an arbitrary git dependency in a manifest the caller then compiles, which is code execution from an archive. The archive-integrity guards do not stop it: the package id is the hash of whatever payload its author chose, so an authored DAR passes them all. The crate name beside it was already validated and the runtime path beside it already escaped; the version was the third field in the same manifest and the one that was missed.canton-codegen(drift guard): the guard that keeps the committed bindings honest now runs in CI. The four existing ones need a DAR from a Splice or cn-quickstart checkout, so on a machine without one they skip — and a skipped test reads exactly like a passing one, which meant the property had never been enforced anywhere but a developer’s laptop, for three crates that are published. A fifth guard regenerates from the DAR committed to this repository and AST-compares against a committed fixture, and the CI job asserts it ran rather than skipped, the way the conformance-oracle job already did.canton-daml(typed read on JSON):Template::from_json_created_eventis the JSON counterpart offrom_created_event, which was gRPC-only. Both transports carry the same contract for the same bindings, so an application may write over one and read over the other — but on the JSON lane a caller had to reach intoevent["CreatedEvent"]["createArgument"]themselves, and nothing then checked that the event was that template at all. A party’s stream carries every template it sees, and where two payloads share a field shape, decoding one as the other succeeds and is wrong. Accepts the event wrapped (CreatedEvent/createdEvent) or bare, and compares module and entity but not the package id, matching the gRPC path under Smart Contract Upgrade.canton-codegen(shared packages): a package already published as its own crate can be referenced instead of re-generated —Options::with_external_package(name_or_id, crate), orlower_dar_withfor the library path. A DAR’s dependency closure is shared, sosplice-api-token-holding-v1sits under amulet, wallet and wallet-payments alike; generating it into each gave each crate its ownHolding, and Rust treats those as unrelated types. A program depending on two of the published binding crates failed to compile with “expectedHolding, found a differentHolding” — for the same interface, in the same package. Packages are keyed by name as well as id, and the name is the one to prefer: an id is the hash of one build, while the name survives a version bump, which is the point of addressing packages by name under Smart Contract Upgrade.canton-codegen(hostile DAR): a type that resolves to itself is refused instead of overflowing the stack. Interned types are a flat table of indices, sointerned_types[0] = Interned(0)— or two entries pointing at each other — is not malformed to prost, which bounds nested messages and has no view of the table. Following it recursed until the stack ended, and a stack overflow aborts the process rather than failing the DAR: the one input in this reader that still killed the caller after the zip-bomb, entry-size, archive-size, package-id and LF-version guards. Bounded at 256 levels, where the deepest type in the 648-package corpus resolves in 15.canton-lf(archive integrity): a package’s payload is hashed and checked against the id it declares before it is parsed, and ahash_functionother than SHA-256 is refused rather than assumed. That id is embedded in generated bindings asPACKAGE_IDand is what cross-package references resolve through, so a DAR whose id does not match its bytes previously produced bindings naming a package its contents do not answer to.canton-lf(LF version): an archive declaring a Daml-LF 2.x minor this build was not compiled against is refused instead of decoded. prost drops fields from a schema it does not know, so a newer minor yielded a package quietly missing template fields. Accepts2.1and2.2, which is what the available corpus contains (648 packages across 18 DARs). The major version is decided first: an LF 1 archive — every DAR a Daml 2.x SDK built — was being reported as an unsupported LF 2 minor, telling the reader to upgrade an SDK that will never read it.canton-codegen(decode errors): generatedFromValuebodies attach the field name to a failure, so a mismatch inside a nested record reportsmeta.valuesinstead of a bare “expected Text”.ValueError::atexisted and was documented as being used by generated code; it was not.
Fixed — pre-release hardening (M2 quality audit)
- Recursion is now Rust-sound.
Optionalself-recursion (data Tree = Node { left : Optional Tree }), mutual recursion (including cross-module), and recursion through a generic instantiation previously generated infinitely-sized structs (E0072); a crate-wide containment-cycle breaker now boxes every cycle-closing reference occurrence. - Record decode matches Canton’s wire shapes. Generated
FromValuelocates fields by label or declaration index (non-verbose output omits labels) and decodes an absent trailingOptionalfield asNone(record normalization under Smart Contract Upgrade drops them). Numericcompares numerically, not textually — the ledger echoes"1.5"as"1.5000000000"and the two are now equal (Eq/Ord/Hashon the canonical decimal;Numeric::parsevalidates early).- Typed read path:
Template::from_created_eventdecodes aCreatedEventinto the payload struct (with a template-identity check);ContractId::retagre-tags an id for interface exercise. - Codegen robustness on arbitrary DARs: per-entry decompression cap
(zip-bomb guard), typed errors naming the offending file/package,
$-named GHC-internal types skip instead of panicking the emitter, snake-case field collisions and duplicate package modules are detected, generated code spells std types fully qualified so same-named Daml types cannot shadow them. - CLI safety: refuses to overwrite files it did not generate (
--forceto override); validates the crate name; the generated crate’s version is the DAR package’s version;--runtime-pathis absolutized and TOML-escaped. - Always-on fixture tests:
testdata/splice-api-token-holding-v1-1.0.0.darruns the full decode → lower → emit pipeline (plus determinism and CLI contract checks) in everycargo test, no external setup.
Changed
canton-coregainsError::Payload— the one addition to the surface published in 0.1.x. It carries acanton-damlcodec failure as itssource, so a typed decode and a transport failure land in the sameResultand an application can stay oncanton::Errorend to end.Erroris#[non_exhaustive], so the new variant does not break amatch.canton-damlsupplies the bridge (impl From<ValueError> for canton_core::Error), andValueErroris structured rather than a string: it carries the field path it failed at and the message separately, so the path survives into the error chain instead of being formatted away.- The
cantonfacade re-exports the codegen runtime as [canton::daml], socargo add cantongets a version-locked runtime for generated bindings. The generator stays out of the facade on purpose: it is a build-time tool (cargo install canton-codegen-cli, or depend oncanton-codegenfrom a build script). - The codegen pipeline (
generate,Options,Runtime,Stats) lives incanton-codegen, not in the-clicrate, so a build script can call it without pulling in a CLI.canton-codegen-cliis binary-only. - Codegen errors are typed:
GenerateErrorandCodegenErrorreplaceBox<dyn Error>andsyn::Error, andSkippedType(withmodule()/reason()) replaces a bare string. Nosyn/proc-macro2type appears in a public signature. Optionsuses the same consuming-builder style as M1’sConfig, andOptions/Stats/Runtimeare#[non_exhaustive].- Generated crates get a publishable
Cargo.toml(description, no forced[workspace]stanza) and their default name drops the DAR’s version suffix, so a DAR bump no longer renames the crate a caller depends on. - Every published crate now ships the Apache-2.0 licence text, and integration tests whose fixtures live outside the package are no longer packaged.
Publish order:
canton-proto→canton-core→canton-auth→canton-lf→canton-daml→canton-ledger→canton-admin→canton-codegen→canton-codegen-cli→canton→ thecanton-splice-*bindings.canton-sampleandcanton-quickstart-licensingstay unpublished (reference material).
[0.1.4] - 2026-08-05
Documentation and developer-experience fixes from the Canton Foundation review
of the M1 submission. No library code changes; 0.1.3 and 0.1.4 are the same
SDK.
Fixed
- Examples:
version_and_healthandsubmit_and_readread the sameCANTON_TEST_*variables as the live tests (the unprefixedCANTON_*spellings still work and take precedence). They previously required a second, undocumented set of six names, so following the README’s own setup and then running the example it links to failed onset CANTON_TOKEN_URL. - Live tests: the ACS token-paging test no longer caps the walk at 500 pages. At page size 1 that bounded the ledger rather than the loop, so on a participant with a few hundred contracts a healthy walk reported “paging did not terminate”. It now fails when the page token stops advancing, which is the condition it was meant to catch.
Documentation
- README: the live-test variables are documented under their real names
(every one is
CANTON_TEST_-prefixed) in a table that says what each gates, with a copy-pasteable export block.CANTON_TEST_JSON_ENDPOINT(the JSON and WebSocket tests) andCANTON_TEST_SYNC_IDwere previously undocumented, and six names were listed without their prefix. - README: node setup lists the
Canton Builder Tool
and Splice LocalNet
alongside
cn-quickstart, with what each one actually provides: Splice LocalNet is unauthenticated, so the OIDC-gated tests skip there as well as the command-submission ones; the Builder Tool’scanton builder deployuploads a DAR, which is how to getCANTON_TEST_LICENSING_PKGwithout cn-quickstart.
[0.1.3] - 2026-08-04
Fixed
canton-ledger:CompletionsRequest’s JSON body builder is now gated on thewsfeature, which is the only thing that reads it — the JSON Ledger API has no POST endpoint for completions. Without the gate the method was dead code in any build withoutws, which failed the workspace’s own-D warningson the feature powerset. Consumers of 0.1.2 are unaffected: Cargo caps lints in registry dependencies, so adefault-features = falsebuild of the published crate compiles either way.
[0.1.2] - 2026-08-04
Additive (semver-compatible) API growth from external M1 review feedback: request builders for the read streams, and Canton-native error classification.
Added
-
canton-ledger(request builders):UpdatesRequest,ActiveContractsRequest, andCompletionsRequest(modulerequest, re-exported at the root) open up the request surface the plain methods hard-code — on every read path and both transports: gRPC (updates_with,updates_resumable_with,updates_page_with,active_contracts_with,active_contracts_page_with,active_contracts_resumable_with,completions_with), JSON POST (JsonClient::updates_with,active_contracts_with), and WebSocket (ws_updates_with,ws_active_contracts_with,ws_completions_with):- bounded streams (
until(end_inclusive)) for catch-up/sync reads that terminate at an offset instead of staying live; - template and interface filters (
for_templates/for_interfaces, acceptingpackage:Module:Entityids with#package-namereferences) instead of the wildcard filter; - transaction shape selection (
TransactionShape::AcsDelta/LedgerEffects), created-event blobs, topology events, dropping reassignments, and non-verbose records; UpdatesRequest::descending— bounded newest-first reads on the stream and page paths, both transports (rejected up front on the resumable stream, whose position tracking assumes ascending order);for_any_party(updates and ACS) — thefilters_for_any_party/filtersForAnyPartyfull-ledger ingestion form, for tokens with wildcard read authorization;CompletionsRequest::with_user_idto scope the completion stream to a submitting user id (pairs withSubmit::with_user_id) — required for tokens that do not carry a user id.
Defaults match the plain methods exactly (
updates_with(UpdatesRequest:: new(p, o))≡updates(p, o)), whose signatures are unchanged — the plain methods now delegate to the builders, so there is a single body/request producer per query on each transport. - bounded streams (
-
canton-ledger(submit):Submit::with_transaction_shapeselects the shape of the transaction returned bysubmit_and_wait_for_transaction(ledger effects — the default, as before — or ACS delta), removing the last hard-codedTransactionShape. -
canton-ledger(submit, fullCommandssurface): every remainingCommandsfield is now settable on both transports —Submit/JsonCommandsgainwith_submission_id,add_disclosed_contract(the explicit-disclosure counterpart to the created-event blobs the read builders expose),with_package_id_selection_preference(the SCU upgrade pin), andwith_min_ledger_time_abs/with_min_ledger_time_rel;Submitadditionally gainswith_prefetch_contract_keysandwith_taps_max_passes, andJsonCommandsgainswith_deduplication_period(raw JSON, mirroringSubmit::with_deduplication_duration/_offset). -
canton-ledger(JSON retries):JsonClient::with_retry(RetryConfig)— the same opt-in retry pipeline as the gRPC client (category-first classification of the participant’s error body, exponential backoff, server-recommended delays honoured; de-duplication-safe for submits since the command id stays fixed across attempts). -
canton-core(error classification): errors now expose the precise retry semantics the Ledger API encodes, beyond the transport status code — on both transports:ErrorCategory— Canton’s error categories (fromErrorInfo.metadata["category"]on gRPC statuses, or theerrorCategoryfield of JSON API error bodies) with the retryability the error-code docs define; surfaced asError::category().Error::retry_delay()— the server-recommended pause, from thegoogle.rpc.RetryInfodetail (gRPC) or theretryInfofield (JSON).Error::correlation_id()— from thegoogle.rpc.RequestInfodetail (gRPC) orcorrelationId/traceId(JSON), for quoting to the participant operator.Error::is_retriableis now category-first for bothError::StatusandError::Http: the category’s verdict wins over the gRPC/HTTP status code (e.g. a category-12 “seek after ledger end” on HTTP 400 is retryable; a category-10 “resource exists” onABORTEDis not), a bareRetryInfocounts as retryable, and the previous code-based classification remains as the fallback for non-Canton errors. Behaviour change under opt-in retry: HTTP errors that Canton marks retryable now retry where they previously failed fast — including the 413JSON_API_MAXIMUM_LIST_ELEMENTS_NUMBER_EXCEEDED(category 2 with a 1-secondretryInfo), even though shrinking the requestlimitis the actual remedy. The SDK follows the server’s verdict rather than special-casing codes; callers that want to fail fast on it can match onError::Http { status: 413, .. }or handle it before retrying.retry::run_with_retryhonours the server’sretry_delaywhen it exceeds the local backoff.
Fixed
canton-ledger(WebSocket error frames): aJsCantonErrorframe on a WS stream is now surfaced asError::Http(with the gRPC code’s canonical HTTP status and the full error body), instead ofError::CommandRejected. The frame is the same error object the JSON API returns in HTTP error bodies, so it now carries itscategory()/retry_delay()/correlation_id()— and, behaviour change,ws_updates_resumablenow correctly reconnects on transient participant errors (categories 1/2/…), which the never-retriableCommandRejectedmapping previously turned into a dead stream.
[0.1.1] - 2026-07-22
Hardening patch over 0.1.0, from an adversarial pre-release review. Each fix has
a regression test. Semver-compatible, so ^0.1 resolves to it.
Fixed
canton-core(TLS):Config::with_tlsno longer connects in plaintext on anhttp://endpoint.tonicselects the TLS handshake from the URI scheme, not from the presence of atls_config, so TLS was silently ignored (no encryption, no server-certificate verification, no client certificate for mutual TLS). The scheme is now normalised tohttps://when TLS is configured, case-insensitively.canton-ledger(JSON and WebSocket TLS): the same normalisation onJsonClient::with_tls. Anhttp://base URL previously sent plaintext HTTP with the configured certificates unused and opened aws://socket; the base URL is now upgraded tohttps://so both the HTTP and WebSocket lanes use TLS.canton-ledger(JSON updates): the JSON update stream now requestsincludeReassignments, matching the gRPC transport. Behaviour change: a JSON update stream now surfaces the assigned/unassigned reassignment events it silently dropped before.canton-auth: the cached-token TTL is clamped to 30 days, so a token endpoint reporting an absurdexpires_incan no longer overflow the cache deadline and panic.canton-core(retry):Error::is_retriablenow treats the whole5xxrange as retriable (plus408and429), instead of a hand-picked subset that missed codes such as501,507,509,511, and520.
[0.1.0] - 2026-07-20
Added — Milestone 1: core Ledger API client, auth & PoC
-
canton— the SDK entry point: a thin facade (re-exports only, no logic) over the crate family —canton::ledger/canton::auth/canton::admin, the sharedConfig/Error/TLS/retry types at the root, and thews/otelfeatures forwarded (ADR-0007). -
canton-ledger— async Ledger API v2 client over gRPC:- Command submission: fire-and-forget
submit,submit_and_wait(update id), andsubmit_and_wait_for_transaction; change-ID de-duplication; completion stream +await_completionrecovery; theSubmitbuilder (act_as/multi-party,user_id,read_as,workflow_id,synchronizer_id, de-duplication period). - Reads & streams: ACS snapshot + paging (+ resumable paged ACS),
updates stream + paging + reverse-order (+ resumable from the last
offset), event query by contract id, node health
(
grpc.health.v1). - JSON transport: command submission, bounded ACS/update reads, and —
behind the
wsfeature — WebSocket streaming (updates, ACS, completions, resumable updates), TLS-aware. - TLS/mTLS on gRPC, HTTP, and WebSocket from one shared
TlsConfig.
- Command submission: fire-and-forget
-
canton-core— shared kernel: typedErrorwith retriable/non-retriable classification and structuredgoogle.rpc.ErrorInfodetails;Config(endpoint, auth, TLS, timeout); opt-in retry with per-attempt timeouts and jittered exponential backoff; telemetry (tracingspans +metricscounters per method/transport, optional OTLP export + W3C trace-context propagation behindotel). -
canton-auth— OIDC client-credentialsTokenProviderwith caching, refresh, bounded fetch, credential-rejection surfacing (Error::Auth), and Keycloak/Auth0/Okta presets. -
canton-admin— party allocation/management, user self-inspect, packages read, and topology read (party→participant mappings, namespace delegations, vetted packages) over the Canton admin API. -
canton-proto— generated types/stubs from vendored protos pinned to Canton 3.5.7 (Ledger API v2, Canton admin-api topology read, gRPC health). -
Runnable examples (
version_and_health,submit_and_read), CI (fmt/clippy/tests on Linux+macOS+Windows/docs/deny/MSRV/feature matrix).