The Contract graph
The data model everything else is built on: a validated, arena-based type graph with declarations and an optional actor.
A Contract is what candid-core hands you instead of a .did file.
It is the same interface, re-expressed as data: a flat array of type nodes, a table of
declared names pointing into that array, an optional actor describing what the canister
exposes, and a set of SHA-256 identities computed over the whole thing. Every consumer —
the TypeScript generator, the schema runtime, a form renderer, your own tool — reads that
one structure rather than parsing Candid again.
Two design choices shape everything on this page. Types live in an arena: one flat array, with every edge stored as an integer index, so a recursive type is a cycle in a graph rather than an infinitely nested tree. And the graph carries wire semantics only: a record field keeps its numeric Candid label and drops its name, because the name is provenance, not meaning. Both choices have consequences you will meet the first time you walk a Contract, so they are worth understanding before you write the traversal.
The arena: one flat array, integer edges#
A Contract stores every type node in a single array called types. A reference
to a type is not a name and not a pointer — it is the array index of the node. That index
type has a name of its own:
pub type TypeRef = u32;
So a record whose field holds the number 3 means "see types[3]".
Nothing nests. Nothing is looked up by name at traversal time. And because a node can point
back at a node that already points at it, type List = opt record { head : nat; tail :
List } is two nodes referring to each other rather than an expansion that never
terminates.
Reachability is enforced: validation requires every node to be reachable from the actor or
from a declaration root, and rejects an unreachable one with
orphan_type_node. A reference outside the array is
dangling_type_ref. You never have to defend against either while walking a
Contract value, because a Contract that failed those checks cannot
exist — see the trust boundary for how that is enforced.
The eight node kinds#
TypeNode has exactly eight variants. There is no ninth, and there is no escape
hatch. In JSON each node is an object with a "kind" discriminator, and unknown
keys are rejected.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum TypeNode {
Primitive {
primitive: PrimitiveType,
},
Opt {
inner: TypeRef,
},
Vec {
inner: TypeRef,
},
Record {
fields: Vec<Field>,
},
Variant {
fields: Vec<Field>,
},
Func {
args: Vec<TypeRef>,
results: Vec<TypeRef>,
mode: MethodMode,
},
Service {
methods: Vec<ServiceMethod>,
},
Class {
init: Vec<TypeRef>,
service: TypeRef,
},
}| Kind | JSON shape | Outgoing edges |
|---|---|---|
primitive | { "kind": "primitive", "primitive": "nat8" } | none |
opt | { "kind": "opt", "inner": N } | inner |
vec | { "kind": "vec", "inner": N } | inner |
record | { "kind": "record", "fields": [ … ] } | each field's type |
variant | { "kind": "variant", "fields": [ … ] } | each field's type |
func | { "kind": "func", "args": [ … ], "results": [ … ], "mode": "query" } | every entry of args and results |
service | { "kind": "service", "methods": [ … ] } | each method's function |
class | { "kind": "class", "init": [ … ], "service": N } | every entry of init, plus service |
PrimitiveType is the eighteen Candid primitives, serialized in snake case:
null, bool, nat, int, nat8,
nat16, nat32, nat64, int8,
int16, int32, int64, float32,
float64, text, reserved, empty and
principal.
None of the three is a distinct wire type, so none of them is a node kind.
blob and tuple syntax are Candid spellings that lower to other kinds: a
blob arrives as a vec whose inner is
nat8, and a tuple arrives as a record with positional labels 0, 1, 2, ….
Result is not Candid syntax at all — it is a naming convention for an
ordinary variant, and that is what arrives. Recognising them is a view a consumer
builds on top of the graph. The graph itself never encodes the distinction.
A one-line .did and the document it becomes#
This fixture is checked into the repository, and so is the document beside it. A test compiles the first and compares the result to the second byte for byte.
type Weird = record { "has space" : nat; "naïve" : text; "quote\"mark" : bool };{
"canonicalization_profile": "candid-core-canon-1",
"declarations": [
{
"name": "Weird",
"type": 0
}
],
"format": "candid-core",
"format_version": 1,
"identities": {
"contract": "candid-core:contract:v1:sha256:f178391a8ed8a150dcca42417e2d3e15d452d9db62c4995a2dc880f02ddf1b10"
},
"producer": {
"candid_parser_version": "0.0.0-golden",
"candid_version": "0.0.0-golden",
"name": "candid-core",
"version": "0.0.0-golden"
},
"semantics_profile": "candid-1",
"types": [
{
"fields": [
{
"id": 734960755,
"type": 1
},
{
"id": 1451868782,
"type": 2
},
{
"id": 1495684992,
"type": 3
}
],
"kind": "record"
},
{
"kind": "primitive",
"primitive": "bool"
},
{
"kind": "primitive",
"primitive": "text"
},
{
"kind": "primitive",
"primitive": "nat"
}
]
}Five things in that document are worth naming.
-
The arena is flat. The record is
types[0]; its three members are the numbers 1, 2 and 3. -
The field names are gone.
1495684992ishas space,1451868782isnaïveand734960755isquote"mark. The next section explains why. -
The fields are in ID order, not source order. The source wrote
has spacefirst; its label ID is the largest, so it is listed last. This is part of canonicalisation. -
There is no
actorkey and nointerfaceidentity. This.diddeclares a type and nothing callable. -
produceris in the document but in neither semantic identity. The golden fixtures normalise it to0.0.0-goldenso that a version bump does not churn the checked-in files; two Contracts that differ only inproducershare the samecontract_id. Identities covers what each hash does and does not cover.
Fields carry a label ID and no name#
A record or variant member is a Field, and a Field is two numbers:
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Field {
/// The authoritative Candid label ID: numeric label or `idl_hash(name)`.
pub id: u32,
#[serde(rename = "type")]
pub ty: TypeRef,
}
That is not an omission. Candid identifies a record or variant member on the wire by a
32-bit number, never by its spelling. A source that writes an explicit numeric label —
record { 0 : nat } — uses that number directly; a source that writes a name uses
the Candid hash of the name's UTF-8 bytes, folding h := h * 223 + byte modulo
2^32 from a starting value of zero. The crate implements that fold in eight lines
in the base feature set, so validation can check labels without linking a Candid source
engine at all.
The consequence is that a name is derived from the wire ID's history, not recoverable
from it. Two sources can write record { nat; text } and record { 0 : nat;
1 : text } and produce byte-identical Contracts, because they mean the same thing to a
caller. Keeping the names in the semantic core would make those two documents differ, and
would put a formatting decision inside a content identity.
Names live in two places instead, both outside the canonical Contract:
-
The provenance sidecar.
SourceInforecords, for every source occurrence of a label, whether it was written as a name, as an explicit number, or positionally —SourceLabelis{ "kind": "named", "name": … },{ "kind": "numeric" }or{ "kind": "positional" }— along with the original spelling. Sources, imports and provenance covers the sidecar. -
The envelope extension. A
ContractEnvelopewraps a Contract with a namespaced extension map that sits outside the identities. The one extension this repository emits isorg.candid-core.field-names/v1, an array of[container, id, name]triples. For the fixture above it is exactly this:
[
[
0,
734960755,
"quote\"mark"
],
[
0,
1451868782,
"naïve"
],
[
0,
1495684992,
"has space"
]
]Service methods are the exception#
A method on a service node keeps its text name:
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServiceMethod {
/// Method text is required to invoke a service. `id` is retained as the
/// authoritative Candid hash for reflection and validation.
pub name: String,
pub id: u32,
#[serde(rename = "function")]
pub function: TypeRef,
}
The reason is practical: you cannot call a canister method by hash. The name is what goes on
the call, so dropping it would make the Contract unable to describe an invocation. The hash
is kept alongside it, and validation checks that id equals the Candid hash of
name — a mismatch is method_id_mismatch. Within one service, names
must be non-empty and unique; two different names may legitimately produce the same
32-bit number, which is another reason the name is kept: it is the name, not the hash, that
tells two methods apart.
Declarations are a name table over the arena#
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Declaration {
pub name: String,
#[serde(rename = "type")]
pub ty: TypeRef,
}
One entry per type Foo = … in the source. A declaration is a label attached to a
position in the arena; it is not part of the type algebra. Nothing in the graph refers to a
declaration, and a type's identity is its shape and its edges, never its name.
Two consequences bite code that assumes otherwise. Distinct names can point at the same node,
because an alias produces a declaration entry but never a node of its own. And the number of
declarations has no relationship to the number of nodes — canonicalisation collapses
structurally equivalent nodes into one, so four declarations can share two nodes. Names must
be non-empty and unique (empty_declaration_name,
duplicate_declaration_name), but that is the only constraint between them.
The actor, and its two forms#
The actor is the Contract's root: what the canister itself exposes. It has exactly two shapes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Actor {
Service { service: TypeRef },
Class { class: TypeRef },
}
A plain service : { … } gives the first form. A service class — a
canister whose installation takes constructor arguments, written service : (nat) -> {
… } — gives the second, and its class node keeps the constructor argument
types alongside the service it produces. A class node is valid only as
the actor root; one appearing anywhere else fails with class_not_actor_root.
Here is the whole conformance vector for a service class:
service : (nat) -> { get: () -> (nat) query };{
"format": "candid-core",
"format_version": 1,
"semantics_profile": "candid-1",
"canonicalization_profile": "candid-core-canon-1",
"identities": {
"contract": "candid-core:contract:v1:sha256:a91547c764a74b424776b2af1ea68cc68e3e47e879b0c498c20786a69da3c86b",
"interface": "candid-core:interface:v1:sha256:f7aeff5c83e4293731a993d4b4db82d32e3d5368800903956dcbb378b11ec6cf"
},
"producer": { "name": "candid-core", "version": "0.1.0-beta.3", "candid_version": "0.10.30", "candid_parser_version": "0.4.0" },
"types": [
{ "kind": "class", "init": [1], "service": 2 },
{ "kind": "primitive", "primitive": "nat" },
{ "kind": "service", "methods": [{ "name": "get", "id": 5144726, "function": 3 }] },
{ "kind": "func", "args": [], "results": [1], "mode": "query" }
],
"declarations": [],
"actor": { "kind": "class", "class": 0 }
}The actor's target is always node 0 when an actor is present: canonical renumbering walks the actor first, then the declaration roots.
A .did that declares only types produces a document with no
actor property, and the identity payload hashes it that way. An explicit
"actor": null is a decode error, not a second spelling of absence. And "no
actor" is not the same as "empty actor": service : {} produces a
service node with an empty methods array, and an
actor that points at it.
interface_id is present exactly when the actor is. An actorless Contract
carrying one fails with actorless_contract_has_interface_id; an actor Contract
missing one fails with actor_contract_missing_interface_id. In Rust,
Contract::interface_id() returns Option<&str> — do not
write consumer code that assumes it is always there.
MethodMode: "update" is stated, never implied#
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MethodMode {
/// The absence of a Candid annotation, made explicit in the Contract.
Update,
Query,
CompositeQuery,
Oneway,
}
In Candid source, a method with no annotation is an update call. In a Contract that is not
represented as a missing field or a null — the func node carries
"mode": "update" outright. The four serialized values are update,
query, composite_query and oneway, and validation
requires a oneway function to have no results
(oneway_has_results).
This fixture shows the default made explicit. The source annotates nothing on
ping; the Contract states the mode:
type Callback = func (nat) -> (text) query;
type Registry = service { register : (text) -> (nat) };
type Kept = record { value : nat };
service : { ping : () -> () }{
"actor": {
"kind": "service",
"service": 0
},
"canonicalization_profile": "candid-core-canon-1",
"declarations": [
{
"name": "Callback",
"type": 4
},
{
"name": "Kept",
"type": 2
},
{
"name": "Registry",
"type": 6
}
],
… "format", "format_version", "identities", "producer" and
"semantics_profile" elided; the "types" array below is complete …
"types": [
{
"kind": "service",
"methods": [
{
"function": 1,
"id": 1247277682,
"name": "ping"
}
]
},
{
"args": [],
"kind": "func",
"mode": "update",
"results": []
},
{
"fields": [
{
"id": 834174833,
"type": 3
}
],
"kind": "record"
},
{
"kind": "primitive",
"primitive": "nat"
},
{
"args": [
3
],
"kind": "func",
"mode": "query",
"results": [
5
]
},
{
"kind": "primitive",
"primitive": "text"
},
{
"kind": "service",
"methods": [
{
"function": 7,
"id": 3500123747,
"name": "register"
}
]
},
{
"args": [
5
],
"kind": "func",
"mode": "update",
"results": [
3
]
}
]
}
The actor points at types[0], a service whose single method ping
points at types[1], the func node with
"mode": "update". Everything the actor reaches is numbered first, which is why
the Registry service — declared but not reachable from the actor — sits at
index 6. Callback, the func … query declaration, is at index 4 and
is a first-class type: a func node is a value type in Candid, not only a method
signature.
Recursion stays a small cycle#
This is where the arena earns its keep. Four declarations, two of them mutually recursive and one a plain alias:
type List = opt record { head : nat; tail : List };
type Even = record { next : opt Odd };
type Odd = record { next : opt Even };
type ListAlias = List;
The whole graph is five nodes. The head keys of the document are elided; the
declarations and types arrays are complete and verbatim:
"declarations": [
{
"name": "Even",
"type": 3
},
{
"name": "List",
"type": 0
},
{
"name": "ListAlias",
"type": 0
},
{
"name": "Odd",
"type": 3
}
],
… "canonicalization_profile", "format", "format_version",
"identities", "producer" and "semantics_profile" elided …
"types": [
{
"inner": 1,
"kind": "opt"
},
{
"fields": [
{
"id": 1158359328,
"type": 2
},
{
"id": 1291237008,
"type": 0
}
],
"kind": "record"
},
{
"kind": "primitive",
"primitive": "nat"
},
{
"fields": [
{
"id": 1224901875,
"type": 4
}
],
"kind": "record"
},
{
"inner": 3,
"kind": "opt"
}
]
Follow the cycle literally: types[0] is opt with
inner: 1; types[1] is the record whose second field
(1291237008 is the hash of tail) has type: 0. That is
the recursion, closed in two nodes.
Two more things happened here. List and ListAlias both resolve to
node 0, because an alias never becomes a node. And Even and Odd
are structurally indistinguishable, so canonicalisation collapsed them into the single node
3, which references itself through node 4. Four declarations, two nodes between them.
How to walk it#
The idiomatic traversal is an explicit worklist plus a visited set — never recursion over a type tree, because the graph has cycles and a hostile graph could be deep. This test compiles a recursive interface and asserts the result stays finite:
fn children(node: &TypeNode) -> Vec<TypeRef> {
match node {
TypeNode::Primitive { .. } => Vec::new(),
TypeNode::Opt { inner } | TypeNode::Vec { inner } => vec![*inner],
TypeNode::Record { fields } | TypeNode::Variant { fields } => {
fields.iter().map(|field| field.ty).collect()
}
TypeNode::Func { args, results, .. } => args.iter().chain(results).copied().collect(),
TypeNode::Service { methods } => methods.iter().map(|method| method.function).collect(),
TypeNode::Class { init, service } => init
.iter()
.copied()
.chain(std::iter::once(*service))
.collect(),
}
}
let compilation = compile(
r#"
type List = opt record { head: nat; tail: List };
service : { get: () -> (List) query };
"#,
);
let contract = compilation.contract();
let root = match contract.actor().as_ref().expect("service actor") {
Actor::Service { service } => *service,
Actor::Class { .. } => panic!("expected service actor"),
};
let mut seen = BTreeSet::new();
let mut work = vec![root];
while let Some(reference) = work.pop() {
if seen.insert(reference) {
work.extend(children(&contract.types()[reference as usize]));
}
}
assert_eq!(seen.len(), contract.types().len());
assert!(
seen.len() < 10,
"recursive syntax should remain a finite graph"
);
The children helper enumerates the outgoing edges of all eight kinds and is
worth copying as a starting point. The seen.len() == contract.types().len()
assertion is the reachability property from the top of this page, checked from the outside.
The crate is 0.1.0-beta.3 and the Contract format is not a stable v1. Any
pre-1.0 release may change the public API, the serialized shapes, the canonical bytes and
therefore every identity computed over them. What is settled is written down: the format
markers candid-core / 1, the semantics profile
candid-1, and the canonicalisation profile
candid-core-canon-1, all of which fail closed on an unrecognised value. See
status and releases.
Next#
Canonical bytes explains how the arena above gets its ordering and its byte-exact form; content-addressed identities explains what each of the hashes in these documents actually commits to. If you are about to accept one of these documents from somewhere you do not control, read the trust boundary first, and JSON document formats for the full key-by-key reference.