Building and validating Contracts

Producing a Contract without a parser, and validating one that arrived from somewhere else.

A Contract is the validated, canonically ordered type graph this crate is built around: an arena of type nodes, a table of named declarations, an optional actor, and content-addressed identities computed over its canonical bytes. Compiling .did source is one way to get one. This page covers the other two — authoring a Contract from a graph you built yourself, and accepting a Contract document that arrived from somewhere else.

Both live in the base surface. Nothing here needs the compiler feature, so a default-features = false build can author, validate, canonicalize, serialize and content-address Contracts with no Candid engine in its dependency graph at all.

ContractDraft — the producer path#

A draft carries the four things an authoring tool supplies and nothing it must not: the type graph, named declarations, an optional actor, and optional producer metadata. It has no format markers and no identity fields at all, so a draft cannot carry a fake, stale or placeholder identity. Building stamps the format constants and calculates fresh identities.

rustsrc/model/contract.rs
pub struct ContractDraft {
    pub types: Vec<TypeNode>,
    pub declarations: Vec<Declaration>,
    pub actor: Option<Actor>,
    pub producer: Option<ProducerInfo>,
}

impl ContractDraft {
    pub fn new(types: Vec<TypeNode>, declarations: Vec<Declaration>, actor: Option<Actor>) -> Self;
    pub fn with_producer(self, producer: ProducerInfo) -> Self;

    pub fn build(self) -> Result<Contract, ContractValidationError>;
    pub fn build_with_limits(self, limits: &Limits) -> Result<Contract, ContractValidationError>;
    pub fn build_with_context(self, context: &RuntimeContext) -> Result<Contract, ContractValidationError>;
}

build() uses Limits::default(). build_with_limits takes your own policy; build_with_context additionally shares the context's deadline and cancellation token, which is what you want when the build is one step inside a larger bounded operation. All three validate the structure and canonicalize before computing identities, so an invalid draft never yields a Contract.

rust
use candid_core::{
    Actor, ContractDraft, Declaration, Field, Limits, MethodMode, PrimitiveType,
    ServiceMethod, TypeNode,
};

// A record { id: nat64; label: text } and a service with one query method.
// Every id below is the Candid name hash of the label or method name --
// fold(\s b -> s * 223 + b) 0 over the UTF-8 bytes, modulo 2^32. Validation
// recomputes a method's hash from its name, so a wrong id is rejected.
let types = vec![
    TypeNode::Primitive { primitive: PrimitiveType::Nat64 },   // 0
    TypeNode::Primitive { primitive: PrimitiveType::Text },    // 1
    TypeNode::Record {
        fields: vec![
            Field { id: 23_515, ty: 0 },
            Field { id: 1_873_743_348, ty: 1 },
        ],
    },                                                          // 2
    TypeNode::Func { args: vec![], results: vec![2], mode: MethodMode::Query }, // 3
    TypeNode::Service {
        methods: vec![ServiceMethod { name: "read".into(), id: 1_269_254_998, function: 3 }],
    },                                                          // 4
];

let contract = ContractDraft::new(
    types,
    vec![Declaration { name: "Item".into(), ty: 2 }],
    Some(Actor::Service { service: 4 }),
)
.build_with_limits(&Limits::default())?;

assert!(contract.contract_id().starts_with("candid-core:contract:v1:sha256:"));
Indices are not the ones you wrote

Building canonicalizes, which deterministically re-indexes the arena. The TypeRef values in the resulting Contract are not necessarily the ones you supplied, and the field and method orders are the canonical ones. Read the result through its accessors rather than assuming your own indices survived. The canonical bytes page explains what the ordering is and why it exists.

with_producer overrides the ProducerInfo::current default applied at build time. Producer metadata is untrusted and sits outside both semantic identities — two Contracts differing only in producer share the same contract_id and interface_id, even though they are byte-different on the wire. Its bytes are still bounded, by max_producer_bytes.

Reading a Contract#

Every field is private. A Contract value is always valid, and that is only enforceable if nothing can reach in and change one. The full accessor list:

AccessorReturnsWhat it is
types()&[TypeNode]The arena. Every edge in the graph is a TypeRef, a zero-based index into this slice.
declarations()&[Declaration]Named roots — one per type Foo = …. A name table over the graph, not part of the type algebra.
actor()Option<&Actor>The root, when the source declared one: Service or Class.
contract_id()&strThe whole-Contract semantic identity, prefixed candid-core:contract:v1:sha256:.
interface_id()Option<&str>The actor-reachable wire semantics only. None for an actorless Contract.
identities()&ContractIdentitiesBoth of the above as one struct: { contract, interface }.
producer()&ProducerInfo{ name, version, candid_version, candid_parser_version }. Unverified provenance.
format()&str"candid-core".
format_version()u321.
semantics_profile()&str"candid-1" — which Candid semantics the graph was projected under.
canonicalization_profile()&str"candid-core-canon-1" — which byte-level canonicalization produced the identities.

The three profile-ish markers are versioned independently on purpose: the schema, the Candid semantics and the canonical bytes can each move without dragging the others. See Content-addressed identities.

Accepting a document from somewhere else#

Contract deliberately does not implement Deserialize. A trait impl has no argument position for a resource policy, so it could only ever decode under limits the library picked. The serde entry point is RawContract, an unvalidated data-transfer object with exactly the wire shape, and the crossing from one to the other is explicit.

rustsrc/model/contract.rs
pub fn try_from_raw(raw: RawContract) -> Result<Self, ContractValidationError>;
pub fn try_from_raw_with_limits(raw: RawContract, limits: &Limits) -> Result<Self, ContractValidationError>;
pub fn try_from_raw_with_context(raw: RawContract, context: &RuntimeContext) -> Result<Self, ContractValidationError>;

These check every structural rule and verify the identities the document supplied against recomputation. A mismatch is an error, not a silent repair: a document whose identities.contract does not hash out is rejected. From<&Contract> for RawContract projects a validated Contract back onto the wire shape in the other direction.

Decoding a RawContract is not a trust boundary

RawContract derives Deserialize with no size bound at all. serde_json::from_str::<RawContract> on untrusted bytes will happily allocate whatever the document describes. Gate the byte length yourself, or — better — use the bounded parse entry points below, which do it for you before serde_json is ever invoked.

The bounded parse APIs#

rustsrc/model/contract.rs
Contract::from_json(input: &str)                                  // Limits::default()
Contract::from_json_with_limits(input: &str, limits: &Limits)
Contract::from_json_with_context(input: &str, context: &RuntimeContext)
Contract::from_slice_with_limits(input: &[u8], limits: &Limits)
Contract::from_slice_with_context(input: &[u8], context: &RuntimeContext)
// -> Result<Contract, ContractJsonError>

Each of these enforces max_input_bytes against the input length before the document is decoded, reporting resource input_bytes if it is too large — so an oversized document is rejected without being materialized. The gate, the decode and the validation then share one budget, rather than each starting from a fresh allowance.

ContractJsonError separates the two failure kinds: MalformedJson(String) for "this is not JSON, or not the right shape", and InvalidContract(ContractValidationError) for "this is a well-formed document that breaks a Contract rule". The same pair of bounded families exists on Compilation and on ContractEnvelope.

rustexamples/trust_boundary.rs
use candid_core::{compile_did, Contract};
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let compilation = compile_did("service : { ping: () -> (nat) query };")?;
    let canonical_json = compilation.contract().to_json_pretty()?;
    let accepted = Contract::from_json(&canonical_json)?;
    println!("validated {} type nodes", accepted.types().len());

    // An unknown key in the strict core is rejected, not ignored.
    let mut injected: serde_json::Value = serde_json::from_str(&canonical_json)?;
    injected["widget"] = serde_json::json!("date-picker");
    let rejected = Contract::from_json(&serde_json::to_string(&injected)?).unwrap_err();
    println!("unknown core metadata rejected: {rejected}");

    // A supplied identity that does not recompute is rejected, not repaired.
    let mut tampered: serde_json::Value = serde_json::from_str(&canonical_json)?;
    tampered["identities"]["contract"] = serde_json::json!(
        "candid-core:contract:v1:sha256:0000000000000000000000000000000000000000000000000000000000000000"
    );
    let rejected = Contract::from_json(&serde_json::to_string(&tampered)?).unwrap_err();
    println!("tampered semantic identity rejected: {rejected}");
    Ok(())
}
bash
cargo run --example trust_boundary

Validating, canonicalizing and rendering#

rustsrc/model/contract.rs
contract.validate()                     // and _with_limits / _with_context
contract.canonicalize()                 // and _with_limits / _with_context
contract.to_json_pretty()               // and _with_limits / _with_context

validate checks graph structure and verifies the content identities. canonicalize returns a deterministically re-indexed copy with freshly calculated identities, and is idempotent — canonicalizing an already-canonical Contract returns an equal value.

Rendering consumes a second budget#

This is the one behaviour most likely to surprise you. to_json_pretty_with_limits is not a dump of the in-memory value: it revalidates and recanonicalizes first, and then charges the rendered byte length against max_canonicalization_work. So a caller who raised only the structural limit that gated construction — max_string_bytes, say, or max_input_bytes — may still fail to render the result, and the failure names canonicalization_work rather than the limit they raised.

rustexamples/bounded_parsing.rs
// Serialization consumes a second budget. Whichever structural limit gated
// construction, rendering additionally charges the emitted byte length
// against `max_canonicalization_work`, so a caller who raised only
// `max_input_bytes` to parse a document may still fail to render it.
let render = raised.clone().with_max_canonicalization_work(16);
let starved_render = contract.to_json_pretty_with_limits(&render).unwrap_err();
let metadata = starved_render
    .violations
    .iter()
    .find_map(|violation| violation.resource_limit.as_ref())
    .ok_or("expected resource metadata")?;
println!(
    "render starved: resource={} limit={} observed={}",
    metadata.resource, metadata.limit, metadata.observed
);

Compilation::to_json_pretty_with_context differs in one respect: it validates without recanonicalizing, because a Compilation's Contract is already canonical and its sidecar's type references are indexed against exactly that arena. Rendering a recanonicalized copy could desynchronize the two.

For a completely unbounded render of a value you already trust, Serialize is implemented on Contract directly. It consults no limits and revalidates nothing. That path is for values you produced, not values you received.

Detached artifact identities#

contract_id and interface_id hash meaning, so they are unchanged by rewriting producer, reformatting the document, adding an envelope extension, or replacing the provenance sidecar. That is exactly what makes them useful as compatibility keys and exactly what makes them the wrong thing to commit to when the octets themselves are what matter.

rustsrc/artifact_id.rs
pub fn artifact_id_with_limits(
    kind: ArtifactKind,
    bytes: &[u8],
    limits: &Limits,
) -> Result<String, ContractValidationError>

pub fn artifact_id_with_context(
    kind: ArtifactKind,
    bytes: &[u8],
    context: &RuntimeContext,
) -> Result<String, ContractValidationError>

These hash the exact octet sequence you hand them, under a domain chosen by kind, and return the digest. Nothing is stored, nothing is serialized back into the artifact, and no decode ever computes one. Two artifact IDs are equal if and only if the kind and the byte sequence were equal, under the SHA-256 collision assumption. That is the whole claim: it establishes neither semantic equality, nor structural validity — the bytes are never parsed — nor authenticity. It is a content address, not a credential.

ArtifactKindDocumentWhat those bytes carry
ContractJsonV1 A bare Contract document The Contract alone, producer included. No extensions, no sidecar.
ContractEnvelopeJsonV1 A ContractEnvelope document A Contract plus its namespaced extension map. No sidecar.
CompilationJsonV1 A Compilation document A Contract plus its optional SourceInfo sidecar. No extensions.

The enum is #[non_exhaustive], so naming a further kind later is not a breaking change, and the existing domains are frozen. Selecting a kind is all it does — no variant parses or validates, so arbitrary bytes hash as readily under any kind and the resulting ID makes no claim they are a document of that kind.

rustsrc/artifact_id.rs
use candid_core::{artifact_id_with_limits, ArtifactKind, Limits};

let document = br#"{"contract":{}}"#;
let id = artifact_id_with_limits(
    ArtifactKind::ContractEnvelopeJsonV1,
    document,
    &Limits::default(),
)?;
assert!(id.starts_with("candid-core:artifact:contract-envelope-json:v1:sha256:"));

// The same bytes under any other kind are a different identity.
for other in [ArtifactKind::ContractJsonV1, ArtifactKind::CompilationJsonV1] {
    let rehashed = artifact_id_with_limits(other, document, &Limits::default())?;
    assert_ne!(id, rehashed);
}

// One byte of whitespace is a different artifact.
let reformatted = artifact_id_with_limits(
    ArtifactKind::ContractEnvelopeJsonV1,
    br#"{"contract": {}}"#,
    &Limits::default(),
)?;
assert_ne!(id, reformatted);

Two counters are involved and no others: max_input_bytes gates the slice before any hashing, reported as resource input_bytes; then max_artifact_identity_work is charged one unit per byte plus the fixed domain framing, reported as artifact_identity_work. The _with_context form checks cancellation and the deadline before the first byte and again at every chunk boundary, and fails closed rather than returning a partial digest.

Migrating from the pre-1.0 producer APIs#

RawContract::new and Contract::build_raw/build_raw_with_context were removed in the pre-1.0 API cleanup. A producer-facing constructor that fabricated placeholder zero identities made the intuitive RawContract::new → Contract::try_from_raw pairing fail by construction: try_from_raw verifies supplied identities, and the placeholder ones never verified. ContractDraft has no identity fields at all, so the mistake is now unrepresentable.

rustREADME.md
// 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)?;

Two smaller changes landed in the same cleanup:

  • Limits no longer exposes public fields or exhaustive struct literals. Construction goes through a profile plus with_* builders, and reads go through getters — limits.max_canonicalization_work(), not limits.max_canonicalization_work. See Working with limits.
  • 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.
The Contract format is not a stable v1

Any release before 1.0 may change the public API, the serialized shapes, the canonical bytes, and therefore every identity computed over them. Of the seven foundation architecture decisions, only the versioning-and-canonical-bytes one is marked Verified; the rest are implemented with verification pending. Treat identities as stable within a version, not across versions. See Guarantees and verification.

Next#