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.
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.
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:"));
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:
| Accessor | Returns | What 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() | &str | The 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() | &ContractIdentities | Both 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() | u32 | 1. |
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.
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.
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#
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.
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(())
}cargo run --example trust_boundaryValidating, canonicalizing and rendering#
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.
// 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.
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.
ArtifactKind | Document | What 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.
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.
// 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:
-
Limitsno longer exposes public fields or exhaustive struct literals. Construction goes through a profile pluswith_*builders, and reads go through getters —limits.max_canonicalization_work(), notlimits.max_canonicalization_work. See Working with limits. -
ResourceLimitInfo.limit/.observedandSourceSpan.start_byte/.end_bytechanged from platform-widthusizeto fixed-widthu64. The serialized JSON numeric text is unchanged. SerializedLimitsdocuments changed from a bare field map to the versioned portable configuration.
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#
Every node kind, what the arena guarantees, and why fields carry ids rather than names.
Concept The trust boundaryWhy raw DTOs and validated types are different types, and where the crossing sits.
Reference JSON document formatsThe Contract, envelope, compilation and limits documents, with real excerpts.