What is candid-core?

The problem it solves, in the language of a .did file you already have.

A Candid interface description — a .did file — is the contract between a canister and everything that calls it. It is also, to every tool in the chain, a string. Two strings can describe exactly the same callable interface while differing in type names, field order, method order, comments and whitespace, and nothing in the ordinary Candid toolchain gives you a stable name for the thing they both describe.

candid-core parses that string once, through the official candid_parser, and projects the checked result into a Contract: a validated data structure with one exact byte encoding and a SHA-256 identity over it. Parsing DID text stays inside the Rust boundary, behind the compiler feature; downstream of that single parse — the TypeScript schema runtime, the code generator, any other host language — nothing reads the source again, only the Contract.

A .did file is text, and text is a poor name for an interface#

Here are two interfaces, the two halves of a program that ships in the repository.

candidexamples/semantic_equivalence.rs
type Payload = record { owner: principal; amount: nat };
service : {
  z: (Payload) -> () query;
  a: (Payload) -> () query;
};
candidexamples/semantic_equivalence.rs
// Different name, documentation, field order, and method order.
type Transfer = record { amount: nat; owner: principal };
service : {
  a: (Transfer) -> () query;
  z: (Transfer) -> () query;
};

A line-based diff reports four changes: a renamed type, reordered fields, reordered methods, an added comment. None of them is visible on the wire, and a client compiled against the first file calls the second unaltered. The diff told you something changed and nothing about whether it mattered.

Compile both and two identities separate the two questions.

rustexamples/semantic_equivalence.rs
let first = compile_did(/* the first source above */)?;
let second = compile_did(/* the second source above */)?;

assert_eq!(
    first.contract().interface_id(),
    second.contract().interface_id()
);
assert_ne!(
    first.source_info().unwrap().source_bundle_id(),
    second.source_info().unwrap().source_bundle_id()
);

Run it yourself with cargo run --example semantic_equivalence. Those two assertions are the shape of every answer on this page: did the meaning change? becomes a comparison of two fixed-length strings, and did the text change? stays a separate question with its own separate name.

Every consumer re-implements Candid, and they disagree#

Anything that acts on a .did file has to do the same work first: resolve type aliases, decide when two structurally identical definitions are the same type, follow recursive definitions without expanding them forever, compute the 32-bit label hash Candid puts on the wire in place of every record field and method name, keep function modes straight (query, oneway, plain update), and handle a service class — service : (nat) -> { … } — where the actor is a constructor rather than a service.

The label hash shows how small these rules are and how easy they are to get almost right. Candid identifies a record field or a method on the wire by a number derived from the name's UTF-8 bytes:

textsrc/name_hash.rs
h = 0
for each UTF-8 byte b of the name:
    h = (h * 223 + b) mod 2^32

So ping is 1247277682. The fold is over bytes, not characters, so a two-byte character contributes twice; the arithmetic wraps by definition rather than overflowing; and distinct names can land on one number — the repository pins the colliding pair jhwlzguu and jsyrjsvk — so it is the ID, not the spelling, that the wire carries, and validation rejects an aggregate whose two fields share one (duplicate_field_id). Get any of that subtly wrong and nothing fails to compile. The bindings still build, the call still goes out, and a value lands in the wrong field or an absent option is read as present.

candid-core's rule is that exactly one component may parse DID text or apply Candid type rules: the Rust boundary, and only when the compiler feature is enabled. candid_parser is authoritative for what the source means, and the builder only projects that checked result into the graph — it reimplements no part of alias resolution, recursive-type checking, service-class constructors, or method-mode checking. TypeScript and every other host consumes already validated Contract JSON and is explicitly not allowed to grow a second hand-written parser.

One Candid rule is the deliberate exception. The name hash above is implemented in the crate itself, in src/name_hash.rs, and in the base feature set rather than behind compiler — Contract validation has to be able to check every method ID without linking a Candid source engine at all. Its tests compare it against candid_parser::candid::idl_hash so the two cannot drift apart silently. Record and variant field IDs are taken from the parser's own labels.

And a parser with no budget does whatever the input asks#

The moment the .did source comes from somewhere you do not control — a paste into a UI, an agent, a registry entry, a canister that serves its own interface — "parse it and see" becomes an availability decision. Valid structure is not the same as bounded cost.

candid-core's own first two prereleases got this wrong, which is the most honest example available. Both 0.1.0-beta.1 and 0.1.0-beta.2 walked the declaration reference graph as a tree, re-expanding a shared subtree once per incoming edge — node visits doubling per level, from a source under a kilobyte, reachable from the public compile_did entry point under default limits. Nothing was charged for the work and no refusal came: the walk returned Ok. 0.1.0-beta.3 deduplicates expansion states, charges each one against a new counter, and fails closed.

What that fix restored is the structural rule. Every parse, compile, validate and canonicalize path runs under a Limits policy — byte ceilings, node and field counts, depth bounds, work budgets and an optional deadline. The plain entry points such as compile_did apply Limits::default(); the _with_context variants take a RuntimeContext, which carries your own Limits together with a cancellation token. Graph and import algorithms use explicit work queues rather than call-stack recursion, so a deep hostile input is refused instead of overflowing the stack. Exhaustion yields a resource_limit_exceeded diagnostic carrying the exact {resource, limit, observed} triple, and no partially validated Contract is ever returned. See limits, budgets and diagnostics.

The idea: compile once, into a Contract#

A Contract is what candid-core hands you instead of the source. Four things happen, in order.

  1. Parse. candid_parser reads the source and type-checks the program. compile_did takes one self-contained source; compile_with_resolver and compile_did_file follow import edges through a resolver instead.
  2. Project. The checked result becomes a flat array called the arena: every type is one node in it, and every reference between types is an integer index — a TypeRef, a u32. Nothing refers to a type by name and nothing nests, so a record field holding 3 means "see types[3]", and recursive and mutually recursive types become ordinary cycles in the arena rather than expansions that never terminate.
  3. Validate. A fixed checklist: every reference in range and pointing at the required kind of node, field IDs unique per aggregate, each method's ID equal to Candid's hash of its name, oneway functions with no results, a class node only ever as the top-level actor, every node reachable from a root. Failures come back as a list of coded violations rather than an exception.
  4. Canonicalize and name. Structurally equivalent nodes collapse into one, survivors are renumbered by a fixed traversal, unordered collections sort by a fixed key, and a JSON writer that emits no whitespace and sorts object keys produces the bytes. SHA-256 over them, under a domain prefix, is the identity. The recipe is a written specification with a frozen name, candid-core-canon-1, so an implementation in another language reproduces the same octets — see canonical bytes.

The vocabulary is small. types is the arena. declarations is a name table of { name, type } pairs pointing into it, one per type Foo = … in the source, and not part of the type system. The actor is the optional root — {"kind":"service","service":N} or, for a service class, {"kind":"class","class":N}. A .did that declares only types has no actor at all.

Here is a whole Contract for a four-line interface, with the producer block elided and each node folded onto one line:

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": "candid-core",
  "format_version": 1,
  "identities": {
    "contract": "candid-core:contract:v1:sha256:63572a1640102e56d6c7297c49a89b2dce6bf9d2f298a408500fd9c5a6796ebe",
    "interface": "candid-core:interface:v1:sha256:7c4fb1c9212d86346ea255ad6ff1a346819b720df79076b3a6de584759534f9e"
  },
  "producer": { … },
  "semantics_profile": "candid-1",
  "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 source was four lines: a func type, a service type, a record, and service : { ping : () -> () }. Four things are worth reading out of it.

  • The actor points at types[0], whose one method points at types[1]. Re-indexing traverses from the actor first, which is why the service it uses is index 0.
  • ping keeps its text name — you need it to address the canister — beside the wire ID 1247277682 Candid actually sends. "mode": "update" is explicit although the source wrote no annotation: the Contract states defaults rather than leaving each consumer to supply them.
  • Registry is declared but the actor never reaches it, so it sits at index 6: inside contract_id, outside interface_id.
  • Three version markers travel with every document — format_version, semantics_profile, canonicalization_profile — because the JSON shape, the Candid type rules and the byte-level canonicalization change for different reasons. An unknown value in any of them fails closed.

Nothing about that document is specific to Rust. It is JSON any language reads with an ordinary parser, which is the whole point of doing the parsing once.

Aliases and duplicates collapse on the way in#

Canonicalization is not only sorting: structurally equivalent nodes are merged, so four declarations here become two nodes.

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;
tscrates/candid-core-ts/tests/goldens/recursion.ts
// Generated by candid-core-ts from a candid-core Contract. Do not edit.
import * as $ from "@candid-core/schema";

type $Even = { next: $Even | null };
const $Even: $.Schema<$Even> = $.c.rec(() => $.c.record({ next: $.c.opt($Even) }));
export { $Even as Even };

type $List = { head: bigint; tail: $List } | null;
const $List: $.Schema<$List> = $.c.rec(() => $.c.opt($.c.record({ head: $.c.nat, tail: $List })));
export { $List as List };

type $ListAlias = $List;
const $ListAlias: $.Schema<$ListAlias> = $.c.rec(() => $List);
export { $ListAlias as ListAlias };

type $Odd = $Even;
const $Odd: $.Schema<$Odd> = $.c.rec(() => $Even);
export { $Odd as Odd };

List and ListAlias resolve to the same node; Even and Odd, mutually recursive with identical structure, are one node that references itself. Both facts survive into the generated TypeScript, which writes type $Odd = $Even (exported as Odd). The recursion is a cycle, not an expansion, so it stays finite.

Four identities, four different questions#

IdentityAnswersMoves when
interface_id Can my client still call this canister? The actor-reachable type graph changes. Absent entirely when the .did declares no service.
contract_id Is this the same complete semantic Contract, declaration names included? Any part of the canonical payload changes, including a declaration name or an actor-unreachable declared type.
source_bundle_id Did the source files change? Any source byte or import edge changes. A comment edit and a reformat both move it, deliberately.
artifact_id Is this the exact document I stored or signed? Any byte of the serialized document changes — producer metadata and envelope extensions included.

The first three travel inside the documents they describe, and decoding recomputes and compares them, so a tampered identity is rejected rather than believed. The fourth is detached: returned to the caller, never written into the document it names. None of them authenticates anything on its own — they are content addresses, and signing one commits to exactly what its row covers and to nothing else. Content-addressed identities works through each in full.

What that buys you#

Who this is for, and who it is not for#

It is for you if you compare, cache, pin or gate on Candid interfaces; if you accept .did source you did not write; if you want TypeScript types checked against a runtime schema by the compiler instead of by a comment; or if you need Candid encoding and decoding in a browser with no Rust in the build.

It is not for you, at least not yet, in three specific cases.

  • You want a drop-in replacement for the agent-js runtime. It is not one. @candid-core/schema deliberately produces modern domain shapes — opt T is T | null, variants are { tag, value } unions, every vec nat8 (blob) is Uint8Array, nat / int and the 64-bit integers are bigint — not the [] | [T] options and single-key variant objects agent-js produces. Compatibility with agent-js value shapes is an explicit non-goal, so using these types against an existing agent needs a conversion at the boundary.
  • You need the Rust crate to encode and decode Candid binary. It does not. That is a named non-goal of the current Rust slice; the codec lives in the TypeScript package today.
  • You need a stable format. The Contract format is explicitly not a stable v1. Of the seven foundation decisions, one — independently versioning schema, Candid semantics and canonical bytes — is marked Verified with a recorded run of an independent reference implementation; the other six are implemented with verification pending. Guarantees and verification and design decisions say which is which.
Pre-1.0, and pinned exactly

Until 1.0, any release may change the public Rust API, the serialized Contract, envelope and compilation shapes, the canonical bytes, and therefore every identity computed over them. Every published version of the crate is a prerelease, and a caret requirement never selects a prerelease — candid-core = "0.1" resolves to nothing at all. Name the version exactly, as below.

tomlCargo.toml
[dependencies]
candid-core = "=0.1.0-beta.3"

The npm packages version independently of the crate and install normally: npm install --save-exact @candid-core/schema@beta for the runtime this site describes (a plain install selects latest, still 0.2.0), and @candid-core/cli for .did to TypeScript with no Rust toolchain at all. Status, versions and releases has the current state of each unit and what each release changed.

Next#