JSON document formats
The Contract, envelope, compilation, host value and limits documents, with real excerpts.
Five JSON documents travel between candid-core and everything else: the Contract (the canonical interface graph), the ContractEnvelope (a Contract plus namespaced side-band metadata), the Compilation (a Contract plus its provenance sidecar), the host value encoding (one Candid value, losslessly tagged), and the portable limits configuration. This page gives the shape of each, with real excerpts from the repository's own fixtures.
Everything here is plain JSON that any language can read with a stock parser. The byte-exact encoding rules that make an identity reproducible — key ordering, number spelling, escape set — are a separate specification; this page points at it rather than restating it.
Rules every document shares#
-
Unknown keys are rejected, everywhere in the semantic core. Every model type
carries
#[serde(deny_unknown_fields)]. You cannot attach a UI hint, a default or a workflow flag to a Contract document — an extra top-level key is a decode error. Namespacedextensionson aContractEnvelopeare the sanctioned place for that data, and they sit outside the canonical identities by design. -
Absence is an omitted key, not
null. An actorless Contract has noactorproperty at all; an explicit"actor": nullis a decode error, not a second spelling of absence. The same holds foridentities.interfaceand an envelope's emptyextensionsmap. - Version markers must match exactly. Unknown values fail closed rather than being ignored or upgraded.
-
There is no
Deserializeimpl for the validated types.Contract,ContractEnvelope,CompilationandHostValuedeliberately do not implement it: a trait impl has no argument position for a resource policy, so it could only ever decode under limits the library picked. Untrusted bytes go through a bounded entry point such asContract::from_json_with_context. See The trust boundary.
The Contract document#
Here is a complete Contract, unabbreviated, for a two-line .did file. The source is
type Payload = record { owner: principal; amount: nat }; followed by
service : { transfer: (Payload) -> () };.
{
"format": "candid-core",
"format_version": 1,
"semantics_profile": "candid-1",
"canonicalization_profile": "candid-core-canon-1",
"identities": {
"contract": "candid-core:contract:v1:sha256:0b553cb65eb436a7ac5d35869d0016d043867bab0a97e844350ae72a5b4aea7d",
"interface": "candid-core:interface:v1:sha256:99f400820c0ca2fb7c32cc3ab1df6d6c000bc614cffe0e350b6a9a576da18d48"
},
"producer": {
"name": "candid-core",
"version": "0.1.0-beta.3",
"candid_version": "0.10.30",
"candid_parser_version": "0.4.0"
},
"types": [
{ "kind": "service", "methods": [
{ "name": "transfer", "id": 3664621355, "function": 1 }
] },
{ "kind": "func", "args": [2], "results": [], "mode": "update" },
{ "kind": "record", "fields": [
{ "id": 947296307, "type": 3 },
{ "id": 3573748184, "type": 4 }
] },
{ "kind": "primitive", "primitive": "principal" },
{ "kind": "primitive", "primitive": "nat" }
],
"declarations": [
{ "name": "Payload", "type": 2 }
],
"actor": { "kind": "service", "service": 0 }
}
Four things to read off it. types is flat — the record is types[2] and its
members are the numbers 3 and 4, not nested objects. The field names are gone:
947296307 and 3573748184 are the Candid label ids for owner and amount.
The method keeps its text name as well as its id, because you cannot invoke a canister method by
hash. And the func node says "mode": "update" even though the source wrote
no annotation — the Contract makes the default explicit.
| Key | Type | Required | What it is |
|---|---|---|---|
format | string | yes | Always "candid-core". |
format_version | integer | yes | Always 1. |
semantics_profile | string | yes | Always "candid-1" — which Candid type rules apply. |
canonicalization_profile | string | yes | Always "candid-core-canon-1" — which byte-level canonical algorithm produced the identities. |
identities | object | yes | contract always; interface only when there is an actor. |
producer | object | yes | Four string keys: name, version, candid_version, candid_parser_version. Untrusted metadata, and in neither semantic identity. |
types | array | yes | The arena. Every edge in the model is a zero-based index into it. |
declarations | array | defaults to [] | { name, type } pairs — a name table over the arena, not part of the type algebra. |
actor | object | omitted when absent | {"kind":"service","service":N} or {"kind":"class","class":N}. |
The eight node kinds#
{ "kind": "primitive", "primitive": "nat8" }
{ "kind": "opt", "inner": 3 }
{ "kind": "vec", "inner": 3 }
{ "kind": "record", "fields": [ { "id": 23515, "type": 2 } ] }
{ "kind": "variant", "fields": [ { "id": 24860, "type": 2 } ] }
{ "kind": "func", "args": [2], "results": [3], "mode": "query" }
{ "kind": "service", "methods": [ { "name": "ping", "id": 1247277682, "function": 1 } ] }
{ "kind": "class", "init": [1], "service": 2 }
primitive takes one of the eighteen snake_case names. mode is
"update", "query", "composite_query" or
"oneway". Record and variant fields carry id and type and no
name; service methods carry name, id and function. There is
no blob, tuple or result kind —
Candid to TypeScript mapping shows how those are recognised as
derived views.
Omitted, never emitted as null#
-
actor— an absent actor is an absent key. Decoding"actor": nullis an error, and the identity payload hashes the document that way too. -
identities.interface— absent exactly when the actor is. Validation enforces both directions: an actorless Contract carrying one fails withactorless_contract_has_interface_id, and an actor Contract missing one fails withactor_contract_missing_interface_id. Do not write consumer code that assumes it is always there.
A declaration-only Contract, in full, looks like this — note both omissions:
{
"format": "candid-core",
"format_version": 1,
"semantics_profile": "candid-1",
"canonicalization_profile": "candid-core-canon-1",
"identities": { "contract": "candid-core:contract:v1:sha256:d43274872cdb6c503456065d12c26b512ba9e3eac5b0a9533c8f9716293c6e18" },
"producer": { "name": "candid-core", "version": "0.1.0-beta.3", "candid_version": "0.10.30", "candid_parser_version": "0.4.0" },
"types": [
{ "kind": "record", "fields": [{ "id": 834174833, "type": 1 }, { "id": 1225398258, "type": 2 }] },
{ "kind": "primitive", "primitive": "nat" },
{ "kind": "primitive", "primitive": "text" }
],
"declarations": [{ "name": "LibraryValue", "type": 0 }]
}
Decoding a Contract recomputes both identities from the canonical form and compares. A document
whose identities.contract does not match is rejected with
contract_id_mismatch at path $.identities.contract — never silently
repaired. If you are authoring a Contract you have no trustworthy identity yet, so use
ContractDraft, which has no identity fields to fill in wrongly.
The ContractEnvelope document#
Exactly two keys: contract, holding a whole Contract document, and
extensions, a map of namespaced metadata. The map is omitted entirely when empty.
Unknown top-level keys are rejected, the same as in the Contract itself.
{
"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": { "…": "elided here; present in the file" },
"semantics_profile": "candid-1",
"types": [ "…" ]
},
"extensions": {
"org.candid-core.field-names/v1": [
[2, 947296307, "owner"],
[2, 3573748184, "amount"]
]
}
}
This is what candid-core compile <path> --envelope prints: the envelope document
itself, not an ok-wrapped response, so the output can be saved and handed whole to the
TypeScript runtime's schemaFromContract. The producer block and the
types array are elided above only for length; the fixture carries both in full.
Extension names fail closed#
A name is a reverse-domain namespace — dot-separated segments of lowercase letters, digits and
hyphens — followed by /v and an integer with no leading zero. Anything else is refused
with an invalid_extension_name violation at path $.extensions, both on
insert and again on decode, so two ecosystems cannot collide on a bare key like
"widget".
| Name | Accepted? |
|---|---|
org.candid-core.field-names/v1 | yes |
com.example.form/v1 | yes |
unversioned | no — no namespace, no version |
widget | no |
com.example.form/v01 | no — leading zero |
Values are arbitrary JSON, bounded in aggregate — names plus serialized values — by
max_value_bytes under the resource name extension_bytes. Adding, editing
or removing an extension never moves contract_id or interface_id: both are
computed over the Contract alone. Only the artifact identity over the envelope's exact bytes covers
extensions, because they are in those bytes.
The field-names extension#
The one extension this repository emits. Its value is an array of
[container, id, name] triples: the zero-based index of the containing record or variant
node, the Candid label id, and the spelling as written in the source.
"org.candid-core.field-names/v1": [
[2, 947296307, "owner"],
[2, 3573748184, "amount"]
]- Named labels only. A numeric label (
5 : nat) or a positional one (tuple syntax) carries no name and is skipped, so those fields keep the_N_rendering. - Sorted and deduplicated: one name per
(container, id). -
The TypeScript loader validates the table the way it validates a caller-supplied one: the name
must be the Candid preimage of its id, and
_N_-shaped names are refused — erased to a schema key they are indistinguishable from the numeric-id rendering, and the codec derives wire ids from keys. A lying table fails closed. -
The same triples are what
TsNames::from_pairstakes in Rust, and what the generator's*.names.jsongolden files record.
The Compilation document#
What compile_did and friends produce: contract, plus an optional
source_info provenance sidecar holding everything the Contract deliberately drops —
raw source text, import edges, doc comments, argument names and the original label spellings. The
sidecar is versioned independently of the Contract format — source_info_version must be
exactly 1, or decoding fails with unsupported_source_info_version — and is
bound to a Contract by contract_id; it never affects an identity.
{
"contract": { "…": "a whole Contract document" },
"source_info": {
"source_info_version": 1,
"contract_id": "candid-core:contract:v1:sha256:5b4d7090e72cefb298b0d5e2941dc2ed1f6ac44957163d75dd406ac5e30c930d",
"source_bundle_id": "candid-core:source-bundle:v1:sha256:52cb9ba7ed7105a36de5a5f2665ef82261080406133498ed4aa8b3cac6f9bcca",
"sources": [
{ "name": "memory:/root.did", "source": "import \"types.did\";\n/// Ledger service.\nservice : { read: (id: nat) -> (Item) query };\n" },
{ "name": "memory:/types.did", "source": "/// An item.\ntype Item = record { id: nat; label: text };\n" }
],
"imports": [
{ "from": "memory:/root.did", "import": "types.did", "to": "memory:/types.did", "kind": "type" }
],
"declarations": [
{ "source": "memory:/types.did", "name": "Item", "type": 3, "docs": ["/ An item."] }
],
"field_labels": [
{
"origin": { "kind": "declaration", "source": "memory:/types.did", "name": "Item" },
"path": "type:Item.fields[0]",
"container": 3,
"id": 23515,
"label": { "kind": "named", "name": "id" }
}
],
"methods": [
{
"origin": { "kind": "actor", "source": "memory:/root.did" },
"path": "actor.methods[0]",
"service": 0,
"name": "read"
}
],
"function_arguments": [
{
"origin": { "kind": "actor", "source": "memory:/root.did" },
"path": "actor.methods[0].function.args[0]",
"function": 1,
"direction": "argument",
"position": 0,
"name": "id"
}
],
"actors": [
{ "source": "memory:/root.did", "docs": ["/ Ledger service."] }
]
}
}
The contract block and the second field_labels entry are elided; the
fixture holds both in full. label.kind is "named", "numeric"
or "positional" — a numeric label and a positional one lower to the same wire id, and
only the sidecar can tell them apart. source_info is omitted from the document when a
compilation carries none, and the six collections after sources —
imports, declarations, field_labels, methods,
function_arguments, actors — each default to an empty array when the key
is absent.
Accepting a Compilation document from outside means recompiling its embedded sources and requiring
every derived provenance field to match. The bundle must be canonically sorted
(non_canonical_source_bundle), its source_bundle_id must survive
recomputation (source_bundle_id_mismatch), and rederived provenance must agree
(source_info_provenance_mismatch). See
Sources, imports and provenance.
The CLI wrapper is a different shape#
candid-core compile <path> prints an ok-wrapped response rather than
the Compilation document, and that wrapper always carries the source_info key — exactly
null when --no-source-info suppressed it:
{ "ok": true, "contract": { "…": "…" }, "source_info": null }
Failures print {"ok": false, "diagnostics": [...]} or, for Contract validation,
{"ok": false, "violations": [...]}, with exit status 1; a usage error exits 64.
The candid-core binary has the full grammar.
The host value encoding#
A host value is one Candid value encoded so that nothing is lost on a trip through JSON.
Every value states its own Candid type in a kind field, arbitrary-size and 64-bit
integers travel as decimal strings, floats travel as raw IEEE-754 bits in hex so NaN payloads and
negative zero survive, and record fields are addressed by their 32-bit Candid id rather than a name.
There are 23 kinds.
{ "kind": "null" }
{ "kind": "bool", "value": true }
{ "kind": "nat", "value": "340282366920938463463374607431768211456" }
{ "kind": "int", "value": "-1" }
{ "kind": "nat8", "value": 255 }
{ "kind": "nat16", "value": 65535 }
{ "kind": "nat32", "value": 4294967295 }
{ "kind": "nat64", "value": "18446744073709551615" }
{ "kind": "int8", "value": -128 }
{ "kind": "int16", "value": -32768 }
{ "kind": "int32", "value": -2147483648 }
{ "kind": "int64", "value": "-9223372036854775808" }
{ "kind": "float32", "bits": "deadbeef" }
{ "kind": "float64", "bits": "7ff8000000000001" }
{ "kind": "text", "value": "hello" }
{ "kind": "reserved" }
{ "kind": "principal", "value": "aaaaa-aa" }
{ "kind": "service", "principal": "aaaaa-aa" }
{ "kind": "func", "principal": "aaaaa-aa", "method": "go" }The four containers:
{ "kind": "opt", "value": null }
{ "kind": "opt", "value": { "kind": "nat", "value": "1" } }
{ "kind": "vec", "values": [ { "kind": "nat8", "value": 1 } ] }
{ "kind": "record", "fields": [ { "id": 947296307, "value": { "kind": "principal", "value": "aaaaa-aa" } } ] }
{ "kind": "variant", "id": 24860, "value": { "kind": "null" } }
Candid's empty has no encoding at all — it is uninhabited, so any value presented
against it fails with empty_has_no_value. A class is not a first-class
value either and fails with class_has_no_host_value.
One spelling per value#
Each scalar kind has exactly one accepted form, and everything else is rejected rather than normalised — so two documents that mean the same value look the same. All of these fail to decode, and the equivalent Rust constructors fail the same way:
| Rejected document | Why |
|---|---|
{"kind":"nat","value":"01"} | Leading zero. |
{"kind":"int","value":"-0"} | Negative zero. |
{"kind":"nat64","value":"18446744073709551616"} | Canonical digits, but outside the type's range. |
{"kind":"float32","bits":"DEADBEEF"} | Uppercase hex. |
{"kind":"float64","bits":"7ff800000000001"} | 15 hex digits, not 16. |
{"kind":"principal","value":"AAAAA-AA"} | Parses, but does not render back byte-identically. |
{"kind":"service","principal":"aaaaa-aa","extra":true} | Unknown key. The same applies inside a record's fields entries. |
float32 bits are exactly 8 lowercase hex characters and float64 exactly 16,
from the alphabet 0–9 and a–f. A principal must
render back byte-identically from its parsed bytes, so merely parseable is not enough. Unknown keys
are rejected at every level, containers included.
Host value decoding is bounded by max_value_bytes (16 MiB by default), not
max_input_bytes like every other bounded parse in the crate. Lowering
max_input_bytes alone does nothing here.
Host values covers what the encoding is for and how validation against a Contract's type graph works.
The portable limits configuration#
Limits never serialises as a bare field map. It serialises as a schema version, the name
of the frozen baseline profile, and only the fields you actually changed — each as a fixed-width
u64. Because the profile carries the defaults by name, the same document configures
identical policy on a 32-bit and a 64-bit host.
{ "version": 1, "profile": "interactive_v1", "overrides": {} }That document is exactly how Limits::default() serialises. With overrides:
{
"version": 1,
"profile": "interactive_v1",
"overrides": {
"max_input_bytes": 512,
"max_diagnostics": 0,
"deadline_unix_ms": 2000000000000
}
}
A missing overrides object means no overrides. An override explicitly set back to its
profile value normalises away, so omission and an equal explicit value are the same policy forever.
deadline_unix_ms is an ordinary override key. Every value in
LimitsProfile::InteractiveV1 is listed on the
Limits reference.
A RuntimeContext serialises to exactly one key — the cancellation token is host-local
bookkeeping and is never written, and a document that carries a "cancellation" key is
rejected:
{ "limits": { "version": 1, "profile": "interactive_v1", "overrides": {} } }How a bad configuration is refused#
| Code | Path | Condition |
|---|---|---|
unsupported_limits_version | $.version | version is not 1. |
unsupported_limits_profile | $.profile | The profile name is not "interactive_v1". |
limit_override_unrepresentable | $.overrides.<field> | The value does not fit this host's usize. Rejected, never truncated or wrapped. |
Decode to LimitsConfig and convert with TryFrom when you want
code(), path() and message() programmatically; going straight
to Limits through serde wraps the same rendering in a serde error string. Unknown
top-level keys, unknown override keys and an explicit null override are all decode
errors — absence is the only way to say "use the profile value". A document carrying a limit key a
build predates is rejected by that build on purpose: silently ignoring it would apply a policy the
document did not ask for.
Identity strings#
Every identity is a single string of the form <domain>:sha256:<64 lowercase hex>. The domain says what kind of thing was hashed, so a digest computed for one purpose can never be mistaken for another's.
| Domain | Where it appears | What it covers |
|---|---|---|
candid-core:contract:v1 | identities.contract | The whole canonical Contract, declaration names included. Not producer, not extensions, not source text. |
candid-core:interface:v1 | identities.interface | Only the part of the canonical graph reachable from the actor, plus the two profile markers. |
candid-core:source-bundle:v1 | source_info.source_bundle_id | The raw source files and their import edges — so a comment edit moves it. |
candid-core:artifact:contract-json:v1 | returned by artifact_id_* | The exact octets of a saved Contract document. |
candid-core:artifact:contract-envelope-json:v1 | returned by artifact_id_* | The exact octets of a saved envelope document, extensions included. |
candid-core:artifact:compilation-json:v1 | returned by artifact_id_* | The exact octets of a saved Compilation document. |
Artifact identities are detached: they are returned to the caller and never written into the document they name, they exist even for bytes that would fail validation, and reformatting changes them. Content-addressed identities explains what moves each one.
Byte-level detail lives in the specification#
This page describes the documents; it does not describe the octets. Two files in the repository do that, and they are the authority if anything here reads ambiguously:
-
docs/canonicalization-v1.md
— the
candid-core-canon-1profile: minimisation, node renumbering, sort orders, the node signature encoding, and the exact identity preimages. - docs/artifact-identity-v1.md — the detached exact-octet identity: the frozen domain strings, the domain separator, and the chunking rules.
Each specification has its own standard-library Python reference implementation
(tests/fixtures/conformance/verify_vectors.py and
tests/fixtures/artifact-identity/verify_artifact_ids.py) that recomputes every vector
in its manifest without Rust, and each runs in CI as a separate job. See
Canonical bytes and
Guarantees and verification.