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:

rustsrc/model/type_graph.rs
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.

rustsrc/model/type_graph.rs
#[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,
    },
}
KindJSON shapeOutgoing 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.

No blob, no tuple, no Result

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.

candidcrates/candid-core-ts/tests/fixtures/quoting.did
type Weird = record { "has space" : nat; "naïve" : text; "quote\"mark" : bool };
jsoncrates/candid-core-ts/tests/goldens/quoting.contract.json
{
  "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.

  1. The arena is flat. The record is types[0]; its three members are the numbers 1, 2 and 3.
  2. The field names are gone. 1495684992 is has space, 1451868782 is naïve and 734960755 is quote"mark. The next section explains why.
  3. The fields are in ID order, not source order. The source wrote has space first; its label ID is the largest, so it is listed last. This is part of canonicalisation.
  4. There is no actor key and no interface identity. This .did declares a type and nothing callable.
  5. producer is in the document but in neither semantic identity. The golden fixtures normalise it to 0.0.0-golden so that a version bump does not churn the checked-in files; two Contracts that differ only in producer share the same contract_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:

rustsrc/model/type_graph.rs
#[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. SourceInfo records, for every source occurrence of a label, whether it was written as a name, as an explicit number, or positionally — SourceLabel is { "kind": "named", "name": … }, { "kind": "numeric" } or { "kind": "positional" } — along with the original spelling. Sources, imports and provenance covers the sidecar.
  • The envelope extension. A ContractEnvelope wraps a Contract with a namespaced extension map that sits outside the identities. The one extension this repository emits is org.candid-core.field-names/v1, an array of [container, id, name] triples. For the fixture above it is exactly this:
jsoncrates/candid-core-ts/tests/goldens/quoting.names.json
[
  [
    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:

rustsrc/model/type_graph.rs
#[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#

rustsrc/model/type_graph.rs
#[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.

rustsrc/model/type_graph.rs
#[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:

candidtests/fixtures/conformance/class.did
service : (nat) -> { get: () -> (nat) query };
jsontests/fixtures/conformance/class.contract.json
{
  "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.

Absent, empty and null are three different things

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#

rustsrc/model/type_graph.rs
#[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:

candidcrates/candid-core-ts/tests/fixtures/deferred.did
type Callback = func (nat) -> (text) query;
type Registry = service { register : (text) -> (nat) };
type Kept = record { value : nat };
service : { ping : () -> () }
jsoncrates/candid-core-ts/tests/goldens/deferred.contract.json
{
  "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:

candidcrates/candid-core-ts/tests/fixtures/recursion.did
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:

jsoncrates/candid-core-ts/tests/goldens/recursion.contract.json
  "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:

rusttests/ecosystem_examples.rs
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.

This is beta, and the shape can still move

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.