The trust boundary

Raw DTOs and validated types are different types on purpose. Where the boundary sits and why.

A Contract is candid-core's validated model of a Candid interface: an arena of type nodes, a table of declared names, an optional actor, and the content identities computed over them. The library takes a position that shapes its whole API surface — a value of type Contract is always valid, and the only way to get one is through a constructor that actually did the checking. That is enforced by keeping "decoded but unchecked" and "validated" as different Rust types with different names, rather than as two states of one type separated by a convention.

There are two directions across that boundary and they are not symmetrical. When you are producing a Contract you have no trustworthy identity yet, so the producer type has no identity fields to fill in wrongly. When you are receiving one, the document already claims identities, so the receiving path recomputes them and compares. Mixing the two up is the mistake this design makes unrepresentable.

Why Contract does not implement Deserialize#

Four public types deliberately do not implement serde's Deserialize: Contract, ContractEnvelope, Compilation and HostValue. Nor does SourceInfo, the provenance sidecar. The reason is stated in one line in the source, and it is a reason about the trait rather than about taste:

A trait impl has no argument position for a resource policy, so it could only ever decode under limits the library chose.

Deserialize::deserialize takes a deserializer and nothing else. There is nowhere to pass a byte ceiling, a node count, a depth bound, a deadline or a cancellation token. An implementation would therefore have to pick limits on your behalf and apply them to every caller, or apply none — and applying none is how a four-line JSON document turns into an unbounded allocation. Rather than ship a decode path that cannot be governed, the crate refuses to have one. For Contract, ContractEnvelope and Compilation, a compile_fail doctest pins the refusal, so a future derive cannot reintroduce it quietly:

rustsrc/model/contract.rs
/// ```compile_fail
/// // A validated Contract cannot be produced by serde alone.
/// let _: candid_core::Contract = serde_json::from_str("{}").unwrap();
/// ```

What you use instead is a bounded entry point. For the three document types — Contract, ContractEnvelope and Compilation — that entry point enforces max_input_bytes before handing the bytes to serde_json, then shares one budget between the decode and the validation that follows. HostValue gates on a different limit, which is its own section below, and SourceInfo::try_from_raw starts from a DTO you decoded yourself, so it has no byte gate of its own.

TypeBounded entry pointsFeature
Contract from_json, from_json_with_limits, from_json_with_context, from_slice_with_limits, from_slice_with_context base
ContractEnvelope from_json_with_limits, from_json_with_context, from_slice_with_limits, from_slice_with_context base
Compilation from_json_with_limits, from_json_with_context, from_slice_with_limits, from_slice_with_context compiler
HostValue from_json_with_limits, from_json_with_context host-value
SourceInfo try_from_raw, try_from_raw_with_context (from an already-decoded DTO) compiler

Contract::from_json is the only one that takes no policy argument, and it is not an exception to the rule: it applies Limits::default(), which is the versioned interactive_v1 profile. See limits, budgets and diagnostics for what that profile allows and how to choose a different one.

Raw DTOs are the trusted serde integration#

RawContract and RawSourceInfo do derive Deserialize, and that is on purpose. They are the plain data-transfer objects for a document you already decoded from somewhere — same fields, same JSON, no validity claim in the name and none in the type.

rustsrc/model/contract.rs
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RawContract {
    pub format: String,
    pub format_version: u32,
    pub semantics_profile: String,
    pub canonicalization_profile: String,
    pub identities: ContractIdentities,
    pub producer: ProducerInfo,
    pub types: Vec<TypeNode>,
    #[serde(default)]
    pub declarations: Vec<Declaration>,
    // serde attributes elided: an absent `actor` key means "actorless", and an
    // explicit `"actor": null` is a decode error rather than a second spelling.
    pub actor: Option<Actor>,
}
Decoding a raw DTO is not a bounded operation

serde_json::from_str::<RawContract>(…) consults no limits, revalidates nothing, and carries no allocation bound. It is the right tool when the bytes are already yours — a file you wrote, a value you round-tripped in memory. If the bytes came from somewhere you do not control, either gate the length yourself before calling it, or skip it entirely and use a bounded entry point from the table above.

The same applies to ContractDraft, which also derives Deserialize: it cannot carry a fabricated identity, but decoding one is still unbounded.

The mirror image is Serialize. It is implemented on Contract, ContractEnvelope, Compilation, HostValue and SourceInfo — writing out a value you already hold is not a trust boundary, because the value is already validated. That path consults no limits and revalidates nothing, which is exactly what you want for a trusted value. The bounded counterpart, Contract::to_json_pretty_with_limits, revalidates and charges the rendered byte length against max_canonicalization_work, so a Contract built under raised structural limits can still fail to render under default ones.

Validating a document that arrived from somewhere else#

Contract::try_from_raw is the promotion step. It takes a decoded RawContract and returns a Contract only after running the full structural checklist, canonicalising, and — this is the part that matters — recomputing the document's own identities and comparing them with the ones it presented.

Contract::try_from_raw base
rust
pub fn try_from_raw(raw: RawContract) -> Result<Self, ContractValidationError>

Validates and canonicalises under Limits::default(). try_from_raw_with_limits and try_from_raw_with_context take an explicit resource policy. A presented contract_id that does not match recomputation fails with contract_id_mismatch; a presented interface_id that does not match fails with interface_id_mismatch. Neither is silently repaired.

Two other rules do a lot of work here. Every struct and every tagged enum in the model carries #[serde(deny_unknown_fields)], so an unrecognised key anywhere in a Contract document is a decode error — you cannot smuggle UI hints, defaults or workflow metadata into the semantic core. And the format markers fail closed: an unrecognised format_version, semantics_profile or canonicalization_profile is rejected rather than assumed compatible (unsupported_format_version, unsupported_semantics_profile, unsupported_canonicalization_profile).

A worked example#

This is a runnable program in the repository — cargo run --example trust_boundary. It compiles a tiny interface, serialises it, accepts it back, and then shows the two ways a tampered version is refused.

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());

    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}");

    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(())
}

The extra "widget" key fails because of deny_unknown_fields. The hand-edited identities.contract fails because decoding recomputes the hash and compares. In a real service you would replace from_json with from_slice_with_context and pass a RuntimeContext carrying your own limits, a deadline and a cancellation token — everything else about the shape is the same.

Errors come back as ContractJsonError, which separates the two failure modes cleanly: MalformedJson(String) for "this is not JSON, or not this shape", and InvalidContract(ContractValidationError) for "this decoded but breaks a Contract rule". The second carries a list of violations, each with a stable code, a JSON-pointer-like path, and — when a limit fired — a structured { resource, limit, observed } triple. Codes and paths are the machine-matchable surface; message text is not. See the diagnostics reference.

A content identity is not a signature

Recomputation proves the document is internally consistent — that the hash matches the content. It proves nothing about who sent it. contract_id and interface_id are unkeyed content addresses, and no unkeyed content ID authenticates itself. If you need authenticity, sign something, and read content-addressed identities to pick which identity your signature should commit to.

Host values: the same idea, a different limit#

HostValue is the lossless tagged encoding for Candid values rather than types (see host values). It sits on the same side of the boundary — no Deserialize, bounded entry points only — but it is gated by a different limit, and that catches people out.

rustsrc/value.rs
pub fn from_json_with_context(
    input: &str,
    context: &crate::RuntimeContext,
) -> Result<Self, HostValueJsonError> {
    let limits = &context.limits;
    if input.len() > limits.max_value_bytes {
        return Err(HostValueJsonError::Limit {
            limit: limits.max_value_bytes,
            observed: input.len(),
        });
    }
Lowering max_input_bytes does not bound host value decoding

The byte gate here reads max_value_bytes (16 MiB by default), not max_input_bytes (4 MiB). If you tightened max_input_bytes for Contract documents and assumed values were covered, they are not — lower max_value_bytes too.

There is a second, smaller asymmetry. The failure that gate produces is HostValueJsonError::Limit { limit, observed }, which carries no resource name. The other limit failures in the same enum use a different variant, HostValueJsonError::ValueLimit { resource, limit, observed, path }, which does. So if you are matching on the error to report which budget fired, the top-level byte gate is the one case where the enum variant itself is the only label you get.

The producer side: ContractDraft#

When you are authoring a Contract — building one from a schema, a database, an editor, or anything that is not .did text — you do not have an identity yet, because the identity is a hash of the thing you are still assembling. ContractDraft is shaped around that fact: it has four fields and none of them is an identity.

rustsrc/model/contract.rs
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ContractDraft {
    pub types: Vec<TypeNode>,
    #[serde(default)]
    pub declarations: Vec<Declaration>,
    // serde attributes elided on the two optional fields; an explicit JSON
    // `null` is rejected for both, exactly as `RawContract` rejects it.
    pub actor: Option<Actor>,
    pub producer: Option<ProducerInfo>,
}

No format marker, no version, no profile, no identities. build() stamps the four format constants and calculates the hashes, under the same validation and canonicalisation budgets as every other entry point. A draft cannot carry a fake, stale or placeholder identity, because there is no field in which to put one. This is the doctest on the type, so it is compiled and run by cargo test:

rustsrc/model/contract.rs
use candid_core::{ContractDraft, PrimitiveType, TypeNode};

let contract = ContractDraft::new(
    vec![TypeNode::Primitive { primitive: PrimitiveType::Nat }],
    vec![candid_core::Declaration { name: "Amount".to_string(), ty: 0 }],
    None,
)
.build()?;
assert!(contract.contract_id().starts_with("candid-core:contract:v1:sha256:"));
assert_eq!(contract.producer(), &candid_core::ProducerInfo::current());

build_with_limits and build_with_context take a policy; with_producer overrides the default ProducerInfo::current(). That producer metadata is caller-supplied and unverified by design, and it is excluded from both semantic identities — two Contracts differing only in producer share the same contract_id.

What was removed, and why it is worth knowing#

Earlier pre-1.0 releases had a different producer path: RawContract::new, paired with Contract::build_raw / build_raw_with_context. Both were removed in the pre-1.0 API cleanup. RawContract::new fabricated placeholder zero identities, which made the pairing a reader would naturally reach for — RawContract::new then Contract::try_from_raw — fail by construction, because try_from_raw verifies presented identities and those placeholders never matched. The fix was not a better error message; it was deleting the type that could hold the wrong value.

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)?;

The three positions, side by side#

TypeYou use it whenWhat it does with identities
ContractDraft Authoring a Contract from something other than .did text Has none. build() computes them.
RawContract Holding a document you decoded from elsewhere, before checking it Carries whatever the document claimed, unchecked.
Contract Everywhere else — the only validated form Verified against recomputation on the way in.

Fields on Contract are private and read through accessors — types(), declarations(), actor(), contract_id(), interface_id(), producer(). That is what makes "a Contract value is always valid" enforceable rather than aspirational: there is no way to mutate one into an invalid state after construction.

One honest note to close on. The boundary described here is implemented and covered by tests, including the compile_fail doctests on the three Contract-document types. That is not the same as the design decision behind it being fully verified: ADR 0003 is recorded as implemented, verification pending, and of the seven foundation decisions only ADR 0002 is marked Verified. Guarantees and verification is explicit about which gate proves what, and design decisions links the ADRs themselves.

Next#

Limits, budgets and diagnostics is the other half of accepting untrusted input: the byte ceilings, work counters, deadlines and cancellation that the bounded entry points above actually enforce. Building and validating Contracts walks the same APIs from the Rust side with more code, and content-addressed identities explains exactly what try_from_raw is comparing.