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:
/// ```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.
| Type | Bounded entry points | Feature |
|---|---|---|
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.
#[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>,
}
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.
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.
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.
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.
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(),
});
}
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.
#[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:
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.
// 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#
| Type | You use it when | What 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.