The candid-core binary

Compile a .did file to a canonical Contract document, an envelope, or validate one.

candid-core is the command-line front end to the Rust crate. It does two things: it compiles a Candid .did file into a canonical, validated Contract document — candid-core's model of an interface, a flat table of type nodes with named declarations, an optional actor, and content-addressed identities computed over it — and it re-validates such a document that arrived from somewhere else. Both outcomes are one pretty-printed JSON document on stdout, so the binary composes with jq, with a build script, or with any language that can read JSON without linking a Candid parser.

It is not a second implementation. The binary calls compile_did_file_with_options and Contract::from_json — the same functions described in Compiling Candid sources — and adds argument parsing, a JSON writer, and the field-name table it attaches when you ask for an envelope. The suite in tests/cli.rs runs the real compiled executable and pins the behaviour described below: the argument grammar, the exit statuses, which stream each response uses, the JSON shapes and their stable codes, and both byte bounds at the exact limit and one byte over.

Installing it#

bash
cargo install candid-core --version 0.1.0-beta.3 --locked
candid-core compile ./service.did --envelope > ./service.json

The version has to be named. Every published version of candid-core is a prerelease, and an unqualified request never selects one, so a bare cargo install candid-core fails with could not find candid-core in registry crates-io with version *. Spelling the exact requirement — --version '=0.1.0-beta.3' — works the same way and matches the dependency line a library consumer writes, candid-core = "=0.1.0-beta.3". --locked builds against the lockfile shipped inside the crate archive.

The binary target declares required-features = ["filesystem-compiler"], which is in the default set, so a plain install produces it. If you want the binary and nothing else, this is the feature selection the release verification uses when it installs the CLI from the packaged archive:

bash
cargo install candid-core --version 0.1.0-beta.3 --locked --no-default-features --features filesystem-compiler

Working from a clone, the same commands run through Cargo without installing anything:

bash
cargo run --bin candid-core -- compile ./service.did
cargo run --bin candid-core -- compile ./service.did --no-source-info
cargo run --bin candid-core -- compile ./service.did --envelope
cargo run --bin candid-core -- validate ./contract.json
Turn defaults off and there is no binary

A consumer who takes the crate with default-features = false to get the pure Contract model gets no candid-core executable, because filesystem-compiler is what declares it. That is the intended shape: the model has no filesystem dependency, and the binary is the part that does.

The grammar#

The binary accepts exactly this, and nothing else:

textsrc/bin/candid-core.rs
usage: candid-core compile <path> [--no-source-info | --envelope]
       candid-core validate <path>
Invocation What it does
compile <path> Type-checks the .did file, following its imports from the file's own parent directory, and prints {"contract": …, "ok": true, "source_info": …}.
compile <path> --no-source-info The same, with the provenance sidecar suppressed. The source_info key stays present and is emitted as exactly null.
compile <path> --envelope Prints the ContractEnvelope document itself — {"contract": …, "extensions": {…}}, with no ok wrapper — carrying the field-name table the TypeScript runtime needs.
validate <path> Reads a Contract JSON document, re-validates it including recomputing its identities, and prints {"contract": …, "ok": true}. Accepts no flags at all.

There are no other commands and no other flags — in particular, no flag for limits. Both commands run under Limits::default(), the frozen interactive_v1 profile; custom ceilings are a library capability, passed through the *_with_limits and *_with_context entry points.

Why the two compile flags are mutually exclusive#

--no-source-info throws the provenance sidecar away. The envelope exists to carry field names, and field names live only in that sidecar: a canonical Contract stores record and variant fields by their 32-bit Candid label hash, not by their spelling, because the hash is what the wire format carries. So --envelope asks for something --no-source-info has already discarded. Rather than silently emitting an envelope with an empty name table, the binary treats the combination as a contradiction: in either order, compile x.did --envelope --no-source-info is a usage error.

Two more grammar rules bite in practice. Flags go after the path and may appear at most once — compile --envelope ./service.did is a usage error. And any token beginning with - in the path position is treated as an option, never as a path, so a dash-leading file is spelled with a prefix: candid-core compile ./-service.did.

Exit codes and streams#

Three statuses, and they are exhaustive. In the source they are ExitCode::SUCCESS, ExitCode::FAILURE and ExitCode::from(64).

Exit Outcome stdout stderr
0 Success One pretty-printed JSON document, then a newline Empty
1 Read, parse, type-check, validation or resource-limit failure One pretty-printed JSON document with "ok": false Empty
64 Usage error Empty Exactly the two-line usage text above
Failure JSON goes to stdout, not stderr

Both the success writer and the error writer use println!. The only thing ever written to stderr is the usage text. A script that pipes stdout into a JSON parser will therefore receive the error document too, and should branch on the exit status or on the ok key — never on which stream produced output.

The usage-error suite is worth knowing about because of how it is built: every file named across its 27 rejected argument vectors is a valid, existing one, so a run that reached file processing would have succeeded. Observing exit 64 therefore proves the argument itself was rejected, not that the input was bad.

What compile prints#

Three top-level keys. Object keys come out sorted, because the binary assembles the response as a serde_json value whose maps are ordered — which is why the pinned envelope fixture further down reads alphabetically. For service : {};:

json
{
  "contract": {
    "actor": { "kind": "service", "service": 0 },
    "canonicalization_profile": "candid-core-canon-1",
    "declarations": [],
    "format": "candid-core",
    "format_version": 1,
    "identities": {
      "contract": "candid-core:contract:v1:sha256:edfb9052c7234db653686a79e02e317241b4ccfe74e989a2f5735aa2c8a70ba5",
      "interface": "candid-core:interface:v1:sha256:ea1e1478267a0f0bdffeb8f5acc0ac91c3a496f285c46c10c104fac61698e401"
    },
    "producer": {
      "candid_parser_version": "0.4.0",
      "candid_version": "0.10.30",
      "name": "candid-core",
      "version": "0.1.0-beta.3"
    },
    "semantics_profile": "candid-1",
    "types": [{ "kind": "service", "methods": [] }]
  },
  "ok": true,
  "source_info": { … }
}

Only the source_info value is elided there; every other value is what the binary prints, pinned by tests/cli.rs against tests/fixtures/conformance/empty_actor.contract.json. Whitespace has been compacted for reading — the real output puts one array element per line. Note that actor.service: 0 is an index into types, and that producer records the exact pinned engine versions the document was built with.

The elided sidecar carries ten keys: source_info_version, its own contract_id and source_bundle_id, and the arrays sources, imports, declarations, field_labels, methods, function_arguments and actors. It holds the raw text of every source in the bundle, the import edges between them, doc comments, argument names, and how each field label was spelled. It is bound to contract_id but never part of it, so adding, changing or deleting it leaves both semantic identities exactly where they were.

--no-source-info keeps the key and sets it to null — a consumer testing for key presence rather than for null gets the wrong answer. The contract value, elided here as { … }, is unchanged: provenance never enters it.

json
{
  "contract": { … },
  "ok": true,
  "source_info": null
}

What compile --envelope prints#

This mode prints a different document: the envelope itself, with no ok wrapper, so the output can be saved whole and handed to @candid-core/schema's schemaFromContract. An envelope is a Contract plus a map of namespaced extensions, and extensions live outside the canonical identities by design — attaching one never moves contract_id or interface_id.

The only extension the binary emits is the field-name table, under the literal key org.candid-core.field-names/v1. Its value is an array of [container, id, name] triples: the index of the type node containing the field, the 32-bit label id, and the spelling — sorted, deduplicated, named labels only. Numeric and positional labels carry no name and are skipped. The following is the complete, byte-pinned output for a two-line input:

candidtests/fixtures/conformance/basic.did
type Payload = record { owner: principal; amount: nat };
service : { transfer: (Payload) -> () };
jsontests/fixtures/envelope/basic.envelope.json
{
  "contract": {
    "actor": { "kind": "service", "service": 0 },
    "canonicalization_profile": "candid-core-canon-1",
    "declarations": [{ "name": "Payload", "type": 2 }],
    "format": "candid-core",
    "format_version": 1,
    "identities": {
      "contract": "candid-core:contract:v1:sha256:0b553cb65eb436a7ac5d35869d0016d043867bab0a97e844350ae72a5b4aea7d",
      "interface": "candid-core:interface:v1:sha256:99f400820c0ca2fb7c32cc3ab1df6d6c000bc614cffe0e350b6a9a576da18d48"
    },
    "producer": {
      "candid_parser_version": "0.4.0",
      "candid_version": "0.10.30",
      "name": "candid-core",
      "version": "0.1.0-beta.3"
    },
    "semantics_profile": "candid-1",
    "types": [
      { "kind": "service", "methods": [{ "function": 1, "id": 3664621355, "name": "transfer" }] },
      { "args": [2], "kind": "func", "mode": "update", "results": [] },
      { "fields": [{ "id": 947296307, "type": 3 }, { "id": 3573748184, "type": 4 }], "kind": "record" },
      { "kind": "primitive", "primitive": "principal" },
      { "kind": "primitive", "primitive": "nat" }
    ]
  },
  "extensions": {
    "org.candid-core.field-names/v1": [
      [2, 947296307, "owner"],
      [2, 3573748184, "amount"]
    ]
  }
}

Nothing is elided above except whitespace; that file is real output from this command, committed as a fixture and compared against on every run. Read the triples against the graph: type node 2 is the record, and its two field ids map back to owner and amount. The embedded contract is identical to what plain compile emits — the envelope adds side-band data without touching the canonical document.

Two details that will save you an afternoon. The extension is always present, even when it is empty: a source with no named field labels still emits "extensions": {"org.candid-core.field-names/v1": []}. And hash-colliding spellings collapse to one entry per (container, id) — cemxzwyk and amxawvks share a Candid label hash, so two structurally identical records deduplicate to one semantic node and the table carries one triple, the same spelling the TypeScript generator renders.

What validate prints#

validate reads a Contract document, parses it under the input byte bound, and revalidates it from scratch: unknown keys are rejected rather than ignored, and the identities are recomputed from the payload rather than believed. Success is two keys, the validated canonical document elided here as { … }.

json
{
  "contract": { … },
  "ok": true
}

Failure comes in two shapes, and a consumer has to handle both. How far the read got decides which one you see.

json
{
  "ok": false,
  "violations": [
    {
      "code": "invalid_contract_id_format",
      "path": "$.identities.contract",
      "message": "contract identity must use candid-core:contract:v1:sha256:<64 lowercase hex>"
    }
  ]
}

That is the shape when the document parsed but failed validation — or when the read exceeded the input byte bound, which arrives as a single resource_limit_exceeded violation at path $. When the file cannot be read, is not valid UTF-8, or is not syntactically valid JSON, the read never reaches the validator and you get diagnostics instead — the same key compile uses. Only two codes arrive by that route, both at phase load: contract_file_read_error and malformed_contract_json, whose message is the JSON parser's own text.

json
{
  "ok": false,
  "diagnostics": [
    {
      "code": "malformed_contract_json",
      "phase": "load",
      "severity": "error",
      "message": "expected value at line 1 column 1"
    }
  ]
}

Reading failures#

The two arrays mean different things. diagnostics items come from reading, parsing or type-checking source and carry code, phase, severity and message, plus span, related, notes and resource_limit where those apply. violations items come from validating an already-parsed document and carry code, path (a JSONPath such as $.identities.contract) and message, plus resource_limit when the failure is a budget — never phase or severity. In both, an absent optional key is omitted rather than emitted as null, codes and paths are the machine-stable surface, and message text is not: match on code, never on prose.

A compile failure looks like this — the whole document, span included, is pinned by tests/cli.rs for a bundle whose imported file declares no service:

json
{
  "ok": false,
  "diagnostics": [
    {
      "code": "did_type_check_error",
      "phase": "type_check",
      "severity": "error",
      "message": "Imported service file \"workspace:/types.did\" has no main service",
      "span": { "source_name": "workspace:/types.did" }
    }
  ]
}

Stable codes the CLI suite exercises include did_parse_error, did_type_check_error, did_file_read_error, did_invalid_source_id, contract_file_read_error, malformed_contract_json, invalid_contract_id_format and resource_limit_exceeded. Locations always name logical source IDs (workspace:/…) — never the temporary files the compiler materializes for import checking.

Both commands bound their reads before decoding, so an oversized file fails with structured metadata rather than allocating without bound. The byte bound takes precedence over UTF-8 and parse errors: a file that is both oversized and invalid UTF-8 reports the limit, not a decode failure.

Command Bound Resource name Reported as
compile, per source file 1 MiB (max_source_bytes) source_bytes a diagnostics item
compile, whole import bundle 8 MiB (max_bundle_bytes) bundle_bytes a diagnostics item
validate, the document 4 MiB (max_input_bytes) input_bytes a violations item at path $

A per-source refusal is an ordinary diagnostics item with the structured triple attached. Its message is elided below, because the wording is not part of the contract — the code and the triple are:

json
{
  "code": "resource_limit_exceeded",
  "phase": "load",
  "severity": "error",
  "resource_limit": { "resource": "source_bytes", "limit": 1048576, "observed": 1048577 }
}

The limit and observed values are fixed-width unsigned 64-bit numbers, identical on every platform. The test that pins this writes one file of exactly the limit, which is accepted, and one of limit + 1 that is also invalid UTF-8 and invalid Candid, which is refused with the limit code — proof that the bound fires before decoding or parsing.

Pinning an interface in CI#

Here is the job the binary is actually good at. interface_id covers the wire interface reachable from the actor and nothing else — not declaration names, not field order, not comments — so it moves exactly when a caller would notice, and stays put when a rename or a reformat would not. Check the expected value into the repository next to the .did, and fail the build when the two disagree.

bashci/check-interface.sh
#!/bin/sh
set -eu

# The interface identity this repository has reviewed and released.
expected="$(cat ci/interface.id)"

# Both outcomes print JSON on stdout; failure exits 1, so check the status
# rather than the stream.
if ! response="$(candid-core compile ./service.did --no-source-info)"; then
  printf '%s\n' "$response"       # {"ok": false, "diagnostics": [...]}
  exit 1
fi

# jq -e exits non-zero when the field is absent — a declaration-only .did has
# no actor and so no interface identity — so `set -e` fails the build here
# rather than comparing against an empty string.
actual="$(printf '%s' "$response" | jq -er '.contract.identities.interface')"

if [ "$actual" != "$expected" ]; then
  echo "the wire interface changed:"
  echo "  expected $expected"
  echo "  actual   $actual"
  echo "Bump the package version and update ci/interface.id in the same commit."
  exit 1
fi

echo "interface unchanged: $actual"

Seed ci/interface.id once with candid-core compile ./service.did --no-source-info | jq -r '.contract.identities.interface' > ci/interface.id, and thereafter the only way to change it is a commit that also changes the recorded value — which is the review moment you wanted. --no-source-info keeps the output small; the identity is the same either way, since the sidecar is not part of it.

An identity is only stable within one release line

Everything here is pre-1.0. Until 1.0, any release may change the public API, the serialized shapes, the canonical bytes, and therefore every identity computed over them. A recorded interface_id is a guard against your interface drifting, not a permanent address across candid-core upgrades — pin the compiler version alongside it, and expect to re-record when you move.

Next#

JSON document formats documents every key in the documents above. Diagnostics reference lists the structured error shape the Rust crate and the TypeScript runtime share. If your project has no Rust toolchain, the WebAssembly build of this same pipeline is described in @candid-core/cli — read the status note at the top of that page first.