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. Namespaced extensions on a ContractEnvelope are 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 no actor property at all; an explicit "actor": null is a decode error, not a second spelling of absence. The same holds for identities.interface and an envelope's empty extensions map.
  • Version markers must match exactly. Unknown values fail closed rather than being ignored or upgraded.
  • There is no Deserialize impl for the validated types. Contract, ContractEnvelope, Compilation and HostValue deliberately 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 as Contract::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) -> () };.

jsontests/fixtures/conformance/basic.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: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.

KeyTypeRequiredWhat it is
formatstringyesAlways "candid-core".
format_versionintegeryesAlways 1.
semantics_profilestringyesAlways "candid-1" — which Candid type rules apply.
canonicalization_profilestringyesAlways "candid-core-canon-1" — which byte-level canonical algorithm produced the identities.
identitiesobjectyescontract always; interface only when there is an actor.
producerobjectyesFour string keys: name, version, candid_version, candid_parser_version. Untrusted metadata, and in neither semantic identity.
typesarrayyesThe arena. Every edge in the model is a zero-based index into it.
declarationsarraydefaults to []{ name, type } pairs — a name table over the arena, not part of the type algebra.
actorobjectomitted when absent{"kind":"service","service":N} or {"kind":"class","class":N}.

The eight node kinds#

json
{ "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": null is 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 with actorless_contract_has_interface_id, and an actor Contract missing one fails with actor_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:

jsontests/fixtures/conformance/actorless.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: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 }]
}
Identities are verified, not trusted

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.

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": { "…": "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".

NameAccepted?
org.candid-core.field-names/v1yes
com.example.form/v1yes
unversionedno — no namespace, no version
widgetno
com.example.form/v01no — 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.

json
"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_pairs takes in Rust, and what the generator's *.names.json golden 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.

jsontests/fixtures/artifact-identity/artifacts/compilation.json
{
  "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.

A presented sidecar is not trusted either

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:

json
{ "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.

json
{ "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:

json
{ "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 documentWhy
{"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.

Byte gate

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.

json
{ "version": 1, "profile": "interactive_v1", "overrides": {} }

That document is exactly how Limits::default() serialises. With overrides:

json
{
  "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:

json
{ "limits": { "version": 1, "profile": "interactive_v1", "overrides": {} } }

How a bad configuration is refused#

CodePathCondition
unsupported_limits_version$.versionversion is not 1.
unsupported_limits_profile$.profileThe 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.

DomainWhere it appearsWhat it covers
candid-core:contract:v1identities.contractThe whole canonical Contract, declaration names included. Not producer, not extensions, not source text.
candid-core:interface:v1identities.interfaceOnly the part of the canonical graph reachable from the actor, plus the two profile markers.
candid-core:source-bundle:v1source_info.source_bundle_idThe raw source files and their import edges — so a comment edit moves it.
candid-core:artifact:contract-json:v1returned by artifact_id_*The exact octets of a saved Contract document.
candid-core:artifact:contract-envelope-json:v1returned by artifact_id_*The exact octets of a saved envelope document, extensions included.
candid-core:artifact:compilation-json:v1returned 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-1 profile: 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.

Next#