Unstable beta. The version being prepared is
0.1.0-beta.1, and nothing has been published to crates.io yet. Until 1.0, any release may change the public Rust API, the serialized Contract/Compilation/envelope shapes, the canonical bytes, and every identity computed over them. Pin an exact version. See the changelog for the beta's scope and known limitations, and docs/releasing.md for the release procedure.
An early, deliberately narrow runtime foundation for turning Candid DID files into a canonical validated Contract graph. When the source compiler is enabled — it is, by default — the Rust core delegates parsing and type checking to the official candid_parser implementation; consumers never need to parse Candid source or reproduce its type rules. A consumer that only consumes Contracts can switch the compiler off and keep the model; see Cargo features.
cargo run --bin candid-core -- compile ./service.did
cargo run --bin candid-core -- validate ./contract.jsonThe compile command emits JSON containing a canonical validated contract and an optional, identity-bound source_info sidecar. The Contract exposes a full contract_id and an actor-only interface_id; source spelling/comments are identified separately by source_bundle_id. That source identity covers only the raw source files and import edges. At an external trust boundary, SourceInfo::try_from_raw recompiles that bundle and requires every presented provenance field to match the compiler-derived sidecar.
Those three are not one family. contract_id and interface_id are semantic Contract identities, so documents that mean the same thing share them on purpose; source_bundle_id is a raw-source bundle content identity, so it does identify raw source-file content — source bytes and import edges included — and formatting and comments inside a source therefore do move it. What none of the three identifies is a complete serialized Contract, ContractEnvelope, or Compilation document. A fourth does: artifact_id_with_limits hashes the exact octets of such a document and returns the ID to the caller — use it when exact octets are what must be committed to. See what each identity claims below; no unkeyed content ID authenticates itself.
See architecture and the Contract graph for the v1 model, constraints, and the explicitly deferred host-value ↔ Candid binary bridge. The byte-level algorithm for the three canonicalized identities is specified normatively in canonicalization v1, and the detached artifact identity in artifact identity v1. See release verification gates for the checks required before declaring the format stable across implementations. See performance benchmarks for reproducible comparisons with the pinned official Candid checker and for allocation measurements. See the changelog for what this beta contains and what it does not, and releasing for the exact release-candidate procedure and what is irreversible about publishing.
Every ID below is a content address over a different projection. No unkeyed content ID authenticates itself: a signature or other external mechanism is what authenticates, and signing one of these commits to exactly what its second column lists and to nothing else.
| ID | Kind of identity | Exactly what is covered | Equality means | Excluded |
|---|---|---|---|---|
interface_id |
Semantic Contract | The canonical type graph reachable from the actor, plus both profile markers | The same actor wire interface under the same profiles | Declaration names, actor-unreachable declarations, producer, extensions, SourceInfo, source text, formatting. Absent for a declaration-only Contract |
contract_id |
Semantic Contract | The complete canonical Contract payload: format markers, both profiles, every retained type node, declarations and their names, and the actor when present | The same complete semantic Contract | producer, envelope extensions, SourceInfo, source text, comments, formatting, packaging. Two files with different producers, extensions, or sidecars share it |
source_bundle_id |
Raw-source bundle content | The canonical list of raw logical sources and their import edges — comment and documentation text inside a source is source bytes, so it is covered | The same raw source bytes and import edges | Everything derived from those sources: derived declaration/method/label provenance and derived documentation fields, so it identifies the bundle, not the complete SourceInfo. A presented sidecar is validated by rederivation at construction time, not by this ID |
artifact_id |
Detached exact-octet, per declared ArtifactKind |
The exact octet sequence passed to artifact_id_with_limits |
The same kind and the same bytes | Nothing that is in the bytes; everything that is not. It makes no validity, authenticity, or provenance claim |
use candid_core::{artifact_id_with_limits, ArtifactKind, Limits};
// Detached: the ID is returned to you, never written into the document, and
// never computed implicitly by a decode. Validate the artifact separately.
let id = artifact_id_with_limits(
ArtifactKind::ContractEnvelopeJsonV1,
document_bytes,
&Limits::default(),
)?;Reformatting, whitespace, key order, a rewritten producer, an extension edit, and a SourceInfo edit all change artifact_id and leave contract_id and interface_id exactly where they were. Coverage is exactly the bytes passed to the call — whether or not those bytes have been persisted anywhere — so what travels with them depends on the kind named. ArtifactKind selects a domain and neither parses nor validates, so the following describes a valid serialized document of the declared kind: a Contract document carries the Contract alone, producer included; an envelope document carries extensions and no SourceInfo; a compilation document carries a SourceInfo sidecar and no extensions; and package or application version is covered only when it is literally in the supplied artifact bytes. Arbitrary bytes hash just as well under any kind, and the resulting ID claims nothing about their validity. There is no signer model, key format, signature algorithm, trust policy, or registry protocol here — artifact identity v1 and ADR 0007 state the boundary precisely.
The candid-core binary requires the filesystem-compiler feature, which is on by default. It accepts exactly this grammar, and nothing else:
candid-core compile <path> [--no-source-info]
candid-core validate <path>
cargo run --bin candid-core -- compile ./service.did
cargo run --bin candid-core -- compile ./service.did --no-source-info
cargo run --bin candid-core -- validate ./contract.jsonAnything outside the grammar — an unknown command, an unknown option, a missing path, an option before the path, a flag on validate, a duplicate --no-source-info, or any trailing argument — is a usage error: the process writes nothing to stdout, exits with status 64, and prints exactly this usage text on stderr:
usage: candid-core compile <path> [--no-source-info]
candid-core validate <path>
A token that begins with - is always treated as an option in the path position, never as a path; spell a dash-leading relative file with a ./ prefix (candid-core compile ./-service.did). Path arguments are taken as OS-native bytes, so a non-Unicode path is never an argument-parsing failure: validate hands the bytes to the filesystem unchanged, while compile additionally requires the entry file name to be valid UTF-8 — it becomes the source ID — and reports a did_invalid_source_id diagnostic otherwise. There are no other commands or flags — in particular no flags for custom limits; pass custom Limits through the library APIs instead.
Every non-usage outcome is one pretty-printed JSON document on stdout with an empty stderr:
| Outcome | Exit status | stdout | stderr |
|---|---|---|---|
| success | 0 | JSON with "ok": true |
empty |
| read, parse, validation, or resource-limit failure | 1 | JSON with "ok": false |
empty |
| usage error | 64 | empty | the usage text above |
compile <path> emits {"ok": true, "contract": …, "source_info": …} on success and {"ok": false, "diagnostics": […]} on failure. --no-source-info suppresses the provenance sidecar; the key stays present as "source_info": null. validate <path> emits {"ok": true, "contract": …} on success, {"ok": false, "diagnostics": […]} when the document cannot be read or is not JSON, and {"ok": false, "violations": […]} when it parses but fails validation or when the read exceeds the input byte bound (a single resource_limit_exceeded violation at path $). Codes are stable identifiers such as did_parse_error, did_file_read_error, contract_file_read_error, malformed_contract_json, and resource_limit_exceeded.
Diagnostics and violations share one item schema (see the Diagnostics section of docs/architecture.md): codes, paths, and resource metadata are the stable machine surface, message text is not. Items may additionally carry an optional structured path, a span naming the logical source ID (with byte offsets only when they are exact for the original text), and ordered related locations; these keys are omitted when the data does not exist, so pre-existing output shapes are unchanged. Locations always name logical source IDs (workspace:/…) — never the temporary files the compiler materializes for import checking.
Both commands bound their reads under Limits::default() — the versioned interactive_v1 profile — before decoding, so an oversized file fails with a resource_limit_exceeded code carrying {resource, limit, observed} metadata (fixed-width u64 values, identical on every platform) instead of allocating without bound, and the byte bound takes precedence over UTF-8 and parse errors:
compilereads every source file through the workspace resolver: each file is bounded bymax_source_bytes(1 MiB, resourcesource_bytes), and the import bundle in aggregate bymax_bundle_bytes(8 MiB, resourcebundle_bytes); an over-limit source fails in thediagnosticsshape.validatereads the contract document bounded bymax_input_bytes(4 MiB, resourceinput_bytes); an over-limit document fails in theviolationsshape.
The real-binary suite in tests/cli.rs pins this contract: the argument matrix, exit statuses, output channels, JSON shapes and codes, source-info suppression, and both byte bounds at the exact limit and one byte over.
The examples show why the Contract is a graph, how semantically equivalent DID sources share an identity, and how strict JSON validation protects the core:
cargo run --example contract_walkthrough # compiler
cargo run --example semantic_equivalence # compiler
cargo run --example trust_boundary # compiler
cargo run --example bounded_parsing # compiler
cargo run --example hermetic_bundle # compiler
cargo run --example host_value_validation # compiler + host-valueEach example declares its required-features, so cargo run --example … under a reduced feature set reports that the example was skipped rather than failing to compile.
contract_walkthrough prints a canonical recursive Contract and its provenance summary. semantic_equivalence compares interface identity with source identity. trust_boundary demonstrates rejection of injected metadata and a tampered identity. hermetic_bundle shows filesystem-free import resolution — the browser path, which is why it needs only compiler — while host_value_validation preserves a large nat and an IEEE NaN payload. bounded_parsing rejects oversized untrusted documents before decoding and shows the second limit serialization consumes.
Seven implemented foundation ADRs define the boundaries for large-ecosystem use:
- separate interface, Contract, and source-bundle identities;
- independently version schema, Candid semantics, and canonical bytes;
- make validated artifacts and provenance binding explicit;
- resolve imports through a hermetic capability boundary;
- bound all untrusted work;
- use a lossless tagged HostValue ABI; and
- give artifacts whose exact octets must be committed to a detached identity.
All seven decisions are implemented in the Rust reference runtime. Because the crate has not been released, this profile is the clean starting point rather than a compatibility layer over an earlier format.
Implemented is not the same as verified, and the two are tracked separately. ADR 0002 (independently version schema, Candid semantics, and canonical bytes) is Verified: a Python reference outside this crate reproduces all 11 canonicalization vectors, and that run is recorded in release verification gates. ADRs 0001 and 0003–0007 remain Implemented, verification pending — each has an implementation, ADR 0007 also has an independent reference and a dedicated CI job, but no run of that job is recorded yet, and wiring is not evidence. The Contract format is therefore not a stable v1, and this beta does not promote it to one.
The crate advertises Rust 1.78 as its minimum supported Rust version (MSRV). Direct dependencies are pinned to versions that are expected to build on that toolchain, and dependency updates should preserve the advertised MSRV unless the rust-version field is intentionally raised in the same change. CI runs the locked dependency graph against Rust 1.78, so an incompatible direct or transitive dependency update fails before merge.
candid-core stays one published package with one library and one binary. What it builds is split into an always-present base plus three features, all enabled by default:
| Feature | Adds | Dependencies it pulls in |
|---|---|---|
| (base) | Contract, ContractDraft, RawContract, ContractEnvelope, validation, canonicalization, the semantic Contract identities, detached artifact_id_with_limits/ArtifactKind, Limits/RuntimeContext/CancellationToken, Diagnostic |
serde, serde_json, sha2, hex |
host-value |
HostValue, HostFieldValue, validate_host_value, ContractTypeRef/ContractMethodRef, Contract::bind_type/bind_method |
ic_principal |
compiler |
compile_did and its option/context variants, compile_with_resolver, Compilation, CompileOptions, CompileError, SourceId/SourceResolver/ResolvedSource/MemoryResolver, SourceInfo/RawSourceInfo provenance |
candid, candid_parser |
filesystem-compiler (implies compiler) |
WorkspaceResolver, compile_did_file and its variants, source materialization for candid_parser::check_file, the candid-core binary |
cap-std |
Because every feature is on by default, the full surface needs no feature selection at all. The version requirement, however, has to name the prerelease explicitly: 0.1.0-beta.1 is a prerelease, and a caret requirement such as "0.1" will not resolve to it. Cargo only selects a prerelease when the requirement mentions one, so every example below pins the exact version.
# the full surface, every feature on by default
candid-core = "=0.1.0-beta.1"
# a pure Contract consumer: model, validation, canonicalization, identities
candid-core = { version = "=0.1.0-beta.1", default-features = false }
# ... plus the lossless tagged host value ABI
candid-core = { version = "=0.1.0-beta.1", default-features = false, features = ["host-value"] }
# a browser/WASM host that compiles DID source it already has — self-contained
# or a multi-file bundle with imports
candid-core = { version = "=0.1.0-beta.1", default-features = false, features = ["compiler"] }
# a native tool that reads .did files, or uses the CLI
candid-core = { version = "=0.1.0-beta.1", default-features = false, features = ["filesystem-compiler"] }cargo add candid-core@=0.1.0-beta.1 does the same thing from the command line. Pinning with = is the right choice for a prerelease for a second reason: with the API and the wire format both unstable before 1.0, an automatic upgrade to 0.1.0-beta.2 is a change a consumer should opt into deliberately.
Items outside the selected set are absent at compile time, not runtime stubs: a build error names the missing item, and turning on the feature it belongs to is the fix. tests/model_public_api.rs pins the root exports of each surface, and tests/fixtures/packaging/verify_feature_graph.py proves the dependency claims in the table above against cargo metadata — the base graph resolves to 23 packages where the default graph resolves to 126, and a compiler-only browser-WASM graph to 106.
Two caveats, both deliberate:
- Cargo unifies features across a build. If anything else in your dependency graph depends on
candid-corewith defaults, the whole surface is compiled once for every consumer in that build. Feature selection bounds what a dependency graph must contain; it cannot subtract from a graph that already asked for more. - Feature selection does not shrink the published
.cratearchive. Every published source file ships regardless of which features a consumer enables; adefault-features = falseconsumer still downloads thecompilersources it will not build. What the archive contains is bounded separately, by the positiveincludeallowlist inCargo.toml: production source, runnable examples, the publicdocs/set, and the three root documents. Test suites and their fixtures, benchmarks, the fuzz crate, and CI assets are not published, andtests/fixtures/packaging/verify_package_manifest.pyasserts that in both directions.
Producer metadata is unaffected by any of this: ProducerInfo::current reports the same name, version, candid_version, and candid_parser_version in every configuration, because it reads the pinned versions from this package's manifest at compile time rather than from a linked crate. It remains unverified provenance held outside the semantic Contract identities: neither contract_id nor interface_id covers it, so rewriting it leaves both byte-identical. A caller that needs to commit to the producer bytes it actually received commits to an artifact_id, and what else travels with them depends on the kind named — ContractJsonV1 binds the Contract document's octets, producer included and nothing more; the envelope and compilation kinds bind those octets plus extensions or the provenance sidecar respectively. See the identity ADR and ADR 0007.
compiler is the browser surface, and it now covers imported bundles as well as self-contained sources (issue #21). compile_with_resolver takes an entry ID plus a SourceResolver and compiles the whole bundle in memory: the sources are merged into one virtual program and type-checked through the official candid_parser merged-program APIs. There is no materialization, no temporary directory, no cap-std, and no ambient authority anywhere on that path.
use candid_core::{compile_with_resolver, CompileOptions, MemoryResolver, RuntimeContext};
// Sources the host already holds — fetched, bundled, typed into an editor.
let resolver = MemoryResolver::new()
.with_source("memory:/entry.did", r#"import service "api.did"; import "types.did";
service : { local: (Item) -> () };"#)?
.with_source("memory:/api.did", "service : { imported: () -> (nat) query };")?
.with_source("memory:/types.did", "type Item = record { id: nat };")?;
let compilation = compile_with_resolver(
"memory:/entry.did",
&resolver,
CompileOptions::default(),
&RuntimeContext::default(),
)?;The supported boundary is synchronous immutable source data supplied by the host. Implement SourceResolver yourself when the bundle is not a MemoryResolver — the resolver decides identity and returns bytes, and the compiler owns every limit. candid-core never fetches anything, resolves asynchronously, consults a registry, or generates bindings.
Two target facts, stated plainly:
WorkspaceResolverandcompile_did_fileare native. They still compile forwasm32-unknown-unknown(cap-stdis declared undercfg(not(target_os = "unknown"))), but there is no directory to open there, soWorkspaceResolver::newfails withdid_workspace_root_error. Use a memory or host-provided resolver.- Bare
wasm32-unknown-unknownhas no clock. Cancellation and every quantitative limit work exactly as they do natively. An explicitLimits::with_deadline_unix_mscannot be measured there, so it fails closed withoperation_deadline_exceededrather than calling an unsupportedstd::timefunction and aborting the module;Limits::deadline_exceededreports the same. No deadline configured stays unbounded, as everywhere else. The crate takes noweb-time,js-sys, orwasm-bindgenproduction dependency to paper over this.
This is a runtime claim, not a build claim: tests/browser_wasm.rs compiles a self-contained source and an imported multi-file bundle — both import kinds plus a diamond — inside headless Chrome, pins the resulting contract_id, interface_id, source_bundle_id, logical sources and import edges, and asserts that resolver, resource, cancellation, and deadline failures stay structured. CI runs it against an exactly pinned Chrome for Testing build (150.0.7871.124) driven by the ChromeDriver from that same build, not against a rolling channel. The same file's assertions run natively in every other job, which is what keeps the two in step. See release verification gates.
Each item below is tagged with the feature that provides it; untagged items are in the base.
ContractDraft→build/build_with_limits/build_with_contextis the producer path: a draft carries only types, declarations, an optional actor, and optional producer metadata — never format markers or identities — and building calculates fresh identities under the same budgets as every other entry point.RawContract→Contract::try_from_rawvalidates a decoded external artifact, verifying its presented identities against recomputation.- (
compiler)compile_didcompiles one self-contained DID source with no filesystem and no import resolution. A source that declares imports is rejected withdid_import_requires_file, which points atcompile_with_resolver. - (
compiler)compile_with_resolvercompiles an immutable logical source bundle — imports included — throughMemoryResolveror any host-supplied synchronousSourceResolver, with no filesystem. Together withcompile_didthis is the surface that runs onwasm32-unknown-unknown. - (
compiler)RawSourceInfo→SourceInfo::try_from_rawrecompiles the embedded source bundle and rejects any derived provenance that does not match exactly. It shares one backend withcompile_with_resolver, so authentication cannot drift from compilation. - (
filesystem-compiler)compile_did_filecompiles a.didfile from a sandboxedWorkspaceResolverthroughcandid_parser::check_fileover a materialized copy of the resolved bundle; thecandid-corebinary is built on that path. Limitsand constructor-basedRuntimeContextbound untrusted compilation and validation with one shared budget, monotonic deadlines, and cooperativeCancellationTokensupport. Defaults come from the versionedLimitsProfile::InteractiveV1; individual fields are overridden withwith_*builders, and the serialized form is the versioned portable configuration{"version":1,"profile":"interactive_v1","overrides":{…}}with fixed-widthu64override values (see the architecture doc for the full wire contract, including zero, overflow, and unknown-version behavior).- (
host-value)HostValueplusvalidate_host_valueprovide the lossless tagged value ABI. ContractEnvelopekeeps namespaced extensions outside the strict core.artifact_id_with_limits/artifact_id_with_contextcompute a detached, exact-octet identity for a serialized Contract, envelope, or compilation document, under theArtifactKindthe caller names. The ID is returned to the caller, never stored in the artifact, and never computed by a decode;max_input_bytesgates the slice before hashing andmax_artifact_identity_workmeters the hash on its own counter.
RawContract::new and Contract::build_raw/build_raw_with_context were
removed in the pre-1.0 API cleanup (issue #23): a producer-facing
constructor that fabricated placeholder zero identities made the intuitive
RawContract::new → Contract::try_from_raw pairing fail by construction.
ContractDraft has no identity fields at all, so the mistake is now
unrepresentable.
// Before:
let raw = RawContract::new(types, declarations, actor);
let contract = Contract::build_raw(raw, &limits)?;
// After:
let contract = ContractDraft::new(types, declarations, actor)
.build_with_limits(&limits)?; // .build() for Limits::default()
// A caller-supplied producer used to travel inside the RawContract; now:
let contract = ContractDraft::new(types, declarations, actor)
.with_producer(producer)
.build_with_limits(&limits)?;Limits no longer exposes public fields or exhaustive struct literals;
construction goes through a profile plus builders, and reads through getters:
// Before:
let limits = Limits { max_input_bytes: 512, ..Limits::default() };
let ceiling = limits.max_canonicalization_work;
// After:
let limits = Limits::default().with_max_input_bytes(512);
let ceiling = limits.max_canonicalization_work();ResourceLimitInfo.limit/.observed and SourceSpan.start_byte/.end_byte
changed from platform-width usize to fixed-width u64; the serialized JSON
numeric text is unchanged. Serialized Limits documents changed from a bare
field map to the versioned portable configuration shown above.
Untrusted bytes and already-validated values take different paths, and the crate does not let the two be confused.
Contract,ContractEnvelope,Compilation(compiler), andHostValue(host-value) do not implementDeserialize: a trait impl has no argument position for a resource policy, so it could only ever decode under limits the library chose.- Untrusted Contract, Compilation, and envelope JSON goes through
from_json_with_limits/from_json_with_contextandfrom_slice_with_limits/from_slice_with_context. These enforcemax_input_bytesbefore decoding and then share one budget with validation.Contract::from_jsonis the same path underLimits::default. The byte gate bounds peak decode allocation to a multiple of the caller's ceiling; it does not reject element by element during decode, which remains a follow-up. HostValueis the exception to that sentence:HostValue::from_json_with_limits/from_json_with_contextgate onmax_value_bytes, notmax_input_bytes, and reportHostValueJsonError::Limit, which carries noresourcename. Loweringmax_input_bytesalone does not bound HostValue decoding — lowermax_value_bytestoo.Serializeand the derivedDeserializeon the raw DTOs (RawContract,RawSourceInfo) are the trusted serde integration: they consult no limits and revalidate nothing. Decoding a raw DTO is not a bounded operation, so callers must gate byte length themselves or use a bounded parse API.- The
to_json_pretty_with_limits/to_json_pretty_with_contextserializers onContract,Compilation, andContractEnvelopevalidate before rendering and charge the rendered length againstmax_canonicalization_work. That is a second budget: raising a structural limit such asmax_string_bytesto build a value does not by itself make that value renderable. (Compilationvalidates its Contract, not its already-authenticated sidecar; rederiving provenance is construction-time work.)
HostValue is the crate's one recursive value type, and it is bounded on both sides.
- Decoding runs a constant-stack scan of the JSON text before
serde_jsonsees it, rejecting anything nested deeper thanmax_value_nestingas avalue_nestingresource limit. That check counts JSON containers, the same unit serde_json counts, so keeping the limit below serde_json's fixed 128-frame ceiling means the crate-owned check is always the one that fires — with{resource, limit, observed}metadata instead of a serde string. The ceiling itself is left in place and unmodified underneath. Raisingmax_value_nestingabove 128 therefore has no effect. - Construction is bounded by
max_value_depthandmax_value_elements:HostValue::opt,vector,record, andvarianttake a&Limitsand fail closed. This is what keeps the recursive operations on the type safe, sinceDrop,Clone,PartialEq,Debug, andSerializeall walk one stack frame per level and none of the first four can report an error.
Lexical nesting and semantic depth are deliberately separate limits, as they are for source (max_source_nesting) and types (max_type_depth): one vec level costs two JSON containers and one record level costs three, so a single limit could not report an honest observed value for both.