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#
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:
cargo install candid-core --version 0.1.0-beta.3 --locked --no-default-features --features filesystem-compilerWorking from a clone, the same commands run through Cargo without installing anything:
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
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:
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 |
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 : {};:
{
"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.
{
"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:
type Payload = record { owner: principal; amount: nat };
service : { transfer: (Payload) -> () };{
"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 { … }.
{
"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.
{
"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.
{
"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:
{
"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:
{
"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.
#!/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.
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.