Diagnostics reference

The structured error shape shared by the Rust crate and the TypeScript runtime.

Compilation, Contract validation, provenance validation and host-value validation all fail the same way: with a list of structured items rather than a string. One serializable type, Diagnostic, backs all four. The outer error types stay domain-specific for Rust ergonomics — CompileError.diagnostics, ContractValidationError.violations, HostValueValidationError.violations — but the items are the same algebra, and ContractViolation and HostValueViolation are type aliases for Diagnostic. The TypeScript runtime deliberately reproduces the same serialized item shape, so a consumer that already reads candid-core violations reads those too.

Three failures sit outside that shape, all of them at a decode boundary before validation begins, and each is named below: JSON that is not well formed at all (ContractJsonError::MalformedJson, which carries the serde message as a plain string), host value JSON decoding, and a rejected portable limits configuration.

The stability boundary, stated once

code, the structured path, and the {resource, limit, observed} triple are the machine interface and are stable. message text is not a stable interface and may be reworded at any time. If you are writing code that reacts to a failure, match on code, read path and read resource_limit. Never parse a message, and never assert on one in a test you intend to keep.

The fields of a diagnostic#

rustsrc/diagnostics.rs
pub struct Diagnostic {
    pub code: String,
    pub phase: Option<DiagnosticPhase>,
    pub severity: Option<Severity>,
    pub path: Option<String>,
    pub message: String,
    pub span: Option<SourceSpan>,
    pub related: Vec<RelatedLocation>,
    pub notes: Vec<String>,
    pub resource_limit: Option<ResourceLimitInfo>,
}
FieldPresent whenWhat it carries
codealways A stable snake_case identifier. The thing to match on.
messagealways Human-readable text. Not stable; display it, do not parse it.
phasecompile-domain items only "parse", "type_check", "load" or "lower".
severitycompile-domain items only "error" — the enum has exactly one variant today.
pathevery validation violation; compile items converted from one A $-rooted structured path: $.types[3], $.identities.contract, $.declarations[0].name, $.value.value.value.
spanoptional A source location, in one of exactly two forms — see below.
relatedoptional, ordered Secondary locations, in the order the underlying tool reported them after the primary one. Each is { message, span? }.
notesoptional, ordered Free-text notes, for example a parser's expected-token list.
resource_limitonly on resource_limit_exceeded { resource, limit, observed }.

Every optional field is omitted from JSON when absent, so each domain has a predictable serialized shape: a compile diagnostic is {code, phase, severity, message, span?, related?, notes?, resource_limit?}, and a validation violation built in the validation domain is {code, path, message, resource_limit?} — it can also carry span, related and notes when it arrived by conversion from a compile diagnostic, which is lossless for those fields. All the item types deserialize with unknown keys rejected.

Spans: exact, or source-scoped#

A SourceSpan carries source_name, start_byte and end_byte. One rule governs the combinations: the two offsets are set together or not at all, and a span must name a source, carry offsets, or both. Deserialization rejects a half-span (one offset without the other) and a fully empty span, which leaves three legal forms and two kinds.

json
{ "source_name": "memory:/lib.did", "start_byte": 3, "end_byte": 4 }
{ "source_name": "memory:/lib.did" }
{ "start_byte": 3, "end_byte": 4 }

The first and third are exact: offsets that are genuinely valid for the source's original text. The source name is optional there — compile_did omits it on a type-check failure, where the offsets index the one source it was handed. The second is source-scoped: it names a logical source and withholds offsets. That form exists because the native compiler backend type-checks by materializing pretty-printed copies of sources into a private temp directory, and offsets against that rewritten text would be wrong for the user's file. They are withheld rather than published as if exact. Not leaking the temp directory, the numeric materialized file name, or a rewritten offset dressed up as an original one is the design intent, and the test suite asserts it case by case — see tests/diagnostics_contract.rs and tests/browser_wasm.rs — rather than by a proof over all inputs.

start_byte and end_byte are fixed-width u64 on the wire, so the same span serializes to the same numeric text on every platform. A consumer that indexes text with them must narrow with a checked conversion.

ResourceLimitInfo#

rustsrc/diagnostics.rs
pub struct ResourceLimitInfo {
    pub resource: String,
    pub limit: u64,
    pub observed: u64,
}

Attached to every resource_limit_exceeded failure and to nothing else. limit and observed are fixed-width u64 for the same platform-neutrality reason. The resource string is not the Limits field name: the field max_input_bytes reports as input_bytes, max_value_depth as value_depth. Some resources are charged by more than one limit, and extension_bytes has no field of its own — it is charged against max_value_bytes. Match the resource string as data; do not assume a max_<resource> field exists. The Limits reference maps every one.

The triple survives every domain crossing verbatim. When a Contract validation failure converts into a compile diagnostic during lowering, or a compile diagnostic converts into a violation during provenance rederivation, the code, path, span, related locations, notes and resource metadata all travel item by item. No path in the crate reduces a resource failure to message text.

json
{
  "code": "resource_limit_exceeded",
  "path": "$.value.value.value.value",
  "message": "value depth exceeds limit 3",
  "resource_limit": { "resource": "value_depth", "limit": 3, "observed": 4 }
}

The same failure in the compile domain gains phase and severity:

json
{
  "code": "resource_limit_exceeded",
  "phase": "lower",
  "severity": "error",
  "path": "$",
  "message": "$: resource canonicalization_work exceeded limit 1; observed 2",
  "resource_limit": { "resource": "canonicalization_work", "limit": 1, "observed": 2 }
}

The stable code list#

Every code below is a literal string in the repository's own source. They are grouped by where they come from, not by how they are spelled.

Operational: budget, cancellation, deadline#

These three cover every budget failure, in every domain.

CodeTriggered by
resource_limit_exceededA limit was reached. The only code that carries a resource_limit triple.
operation_cancelledThe caller's CancellationToken was cancelled and the operation reached its next checkpoint.
operation_deadline_exceededThe configured deadline elapsed. Also what a clockless target reports for any explicit deadline, because a deadline must never silently become unbounded.

Contract document: markers, identities, declarations#

CodePathTriggered by
unsupported_contract_format$.formatformat is not "candid-core".
unsupported_format_version$.format_versionformat_version is not 1.
unsupported_semantics_profile$.semantics_profileNot "candid-1".
unsupported_canonicalization_profile$.canonicalization_profileNot "candid-core-canon-1".
invalid_contract_id_format$.identities.contractNot candid-core:contract:v1:sha256: followed by 64 lowercase hex characters.
invalid_interface_id_format$.identities.interfaceThe same test against the candid-core:interface:v1 domain.
contract_id_mismatch$.identities.contractThe declared identity does not equal what recanonicalization computes.
interface_id_mismatch$.identities.interfaceThe same, for the actor-reachable interface identity.
actorless_contract_has_interface_id$.identities.interfaceA Contract with no actor declares one anyway.
actor_contract_missing_interface_id$.identities.interfaceA Contract with an actor omits it.
invalid_producer$.producerProducer name or version is empty.
empty_declaration_name$.declarations[i].nameA declaration name is the empty string.
duplicate_declaration_name$.declarations[i].nameTwo declarations share a name.

Contract type graph#

CodeTriggered by
dangling_type_refA type reference points outside the arena.
duplicate_field_idA record or variant lists one Candid label id twice.
oneway_has_resultsA oneway function declares results.
empty_method_nameA service method name is the empty string.
duplicate_method_nameOne service lists a method name twice.
method_id_mismatchA method's id is not the Candid hash of its name.
service_method_not_functionA method's function reference does not point at a func node.
class_service_not_serviceA class's service reference does not point at a service node.
actor_service_not_service{"kind":"service"} actor whose target is not a service node.
actor_class_not_class{"kind":"class"} actor whose target is not a class node.
class_not_actor_rootA class node appears anywhere but as the top-level actor — including as the target of a named declaration.
class_not_first_class_typeA class is reached through an ordinary Candid type edge.
rootless_type_arenaA non-empty types array with no actor and no declaration to root it.
orphan_type_nodeA node unreachable from any actor or declaration root.

Envelope and serialization#

CodePathTriggered by
invalid_extension_name$.extensionsAn extension key is not a reverse-domain name followed by /v<integer>. Enforced on insert and again on decode.
contract_json_serialization_failed$Rendering a validated document back to JSON failed.

Provenance sidecar#

These fire when a SourceInfo arrives from outside and is checked against a recompilation of its own embedded sources.

CodePathTriggered by
unsupported_source_info_version$.source_info_versionNot 1.
source_contract_id_mismatch$.contract_idThe sidecar names a different Contract than the one it was presented with.
source_contract_rederivation_mismatch$.contract_idThe embedded source bundle does not recompile to the bound Contract.
source_info_rederivation_missing$Recompiling the embedded bundle produced no provenance to compare against.
source_bundle_id_mismatch$.source_bundle_idThe declared bundle identity does not survive recomputation.
non_canonical_source_bundle$sources and imports are not canonically sorted.
source_info_provenance_mismatchthe offending entryA presented provenance field disagrees with the rederived one.
invalid_source_id$.sources[i].nameA logical source id is empty or repeated.
import_source_missing$.imports[i]An import edge names a from or to source that is not in the bundle.
ambiguous_source_import$.imports[i]One source's import spelling resolves to two different targets.
source_type_ref_out_of_bounds$.<collection>[i]A provenance entry references a node outside the Contract arena.
source_origin_missing$.<collection>[i]An entry names a source that is not in the bundle.
empty_source_occurrence_path$.<collection>[i].pathAn occurrence path is the empty string.
source_field_target_mismatch$.field_labels[i]Field provenance does not target an existing aggregate field id.
source_method_target_mismatch$.methods[i]Method provenance does not target an existing service method.
source_function_target_mismatch$.function_arguments[i]Function provenance does not target a function node.
source_function_position_out_of_bounds$.function_arguments[i].positionThe argument position is past the function's arity.
source_bundle_entry_missing$.sourcesThe bundle has no unique entry source.
source_bundle_entry_ambiguous$.sourcesThe bundle has more than one possible entry source.

Compilation#

Compile items always carry phase and severity. Four codes are the generic per-phase fallback; the rest are specific.

CodePhaseTriggered by
did_parse_errorparseThe Candid parser rejected the text. Carries an exact span when the offsets index the original source; the materialized-bundle path withholds them, as the span rules above describe.
did_type_check_errortype_checkThe source parses but does not type-check.
did_load_errorloadA load-phase failure with no more specific code.
contract_lowering_errorlowerAn unstructured invariant failure while lowering the checked AST into the arena.
did_import_cycleloadImport resolution reached a source already on the stack.
did_import_requires_fileloadcompile_did was given a source containing imports. Supply the bundle through a resolver, or use compile_did_file. The import list arrives in notes.
did_invalid_source_idloadA resolver returned a non-canonical source id, or a path had no UTF-8 file name.
did_resolver_identity_mismatchloadA resolver identified one source and then loaded another.
did_materialize_errorloadThe native backend could not materialize the resolved bundle for type checking.

Resolvers#

A resolver's ResolveError reaches the caller as a load-phase compile diagnostic carrying these codes.

CodeTriggered by
did_source_not_foundThe requested source is not in the bundle.
did_source_scheme_mismatchA source id uses the wrong scheme — MemoryResolver requires memory:/, WorkspaceResolver requires workspace:/.
did_source_digest_mismatchA resolved source's declared digest does not match its bytes.
did_workspace_root_errorThe workspace root could not be opened, or filesystem resolution is unavailable on this target.
did_file_read_errorA workspace file could not be read.

Host values#

Value violations always carry path and never carry phase or severity, so they serialize as {code, path, message, resource_limit?}.

CodeTriggered by
value_contract_id_mismatchThe selector names a different Contract than the one supplied. A stored selector cannot be silently re-pointed at a changed interface.
value_type_ref_out_of_boundsThe selector's type index is outside the arena. Also what bind_type refuses with.
host_value_kind_mismatchThe value's kind does not line up with the type node at this position. There is no widening: nat and nat8 are different types.
duplicate_host_fieldA record value lists one field id twice.
record_field_set_mismatchThe value's field ids are not exactly the type's. Nothing is filled in or ignored — an opt field must still be present, spelled as an opt whose value is null.
unknown_variant_idThe variant's id is not an arm of the expected type.
empty_function_methodA func value carries an empty method name.
invalid_principalPrincipal text is not canonical. Applies to the principal, service and func kinds alike.
empty_has_no_valueAny value presented against Candid's uninhabited empty.
class_has_no_host_valueAny value presented against a class node — a service constructor is not a first-class value.
actorless_contractbind_method on a Contract with no actor.
unknown_methodbind_method with a name the actor service does not expose.
Host value JSON decoding has its own error enum

HostValue::from_json_with_limits reports a HostValueJsonError enum rather than a diagnostic collection: Malformed, Limit (the whole-document byte gate, which carries no resource name), ValueLimit (which does), Deadline and Cancelled. Everything past decoding — that is, validate_host_value — is back in the shared shape above.

Not diagnostics: the limits configuration errors#

A rejected portable limits configuration returns a LimitsConfigError, which carries code(), path() and message() and displays as {code} at {path}: {message}. It is a separate type, but the same stability rule applies to its three codes: unsupported_limits_version at $.version, unsupported_limits_profile at $.profile, and limit_override_unrepresentable at $.overrides.<field>.

Emitted only by the command-line binary#

CodeTriggered by
malformed_contract_jsoncandid-core validate was given something that is not a well-formed Contract document at all. Phase load.
contract_file_read_errorThe file could not be read, or was not valid UTF-8. Phase load.

Reading a failure in Rust#

rust
use candid_core::{Contract, Limits, RuntimeContext};

let context = RuntimeContext::new(Limits::default().with_max_input_bytes(64));
match Contract::from_slice_with_context(document, &context) {
    Ok(contract) => println!("{}", contract.contract_id()),
    Err(candid_core::ContractJsonError::MalformedJson(message)) => {
        eprintln!("not a Contract document: {message}");
    }
    Err(candid_core::ContractJsonError::InvalidContract(error)) => {
        for violation in &error.violations {
            // `code` and `path` are the stable surface; `message` is not.
            eprint!("{} at {}", violation.code, violation.path.as_deref().unwrap_or("$"));
            if let Some(info) = &violation.resource_limit {
                eprint!(" [{} limit {} observed {}]", info.resource, info.limit, info.observed);
            }
            eprintln!();
        }
    }
}
A violation list is never empty

Violations are collected up to max_diagnostics (100 by default). When more are observed than fit, the last retained slot is replaced by a resource_limit_exceeded sentinel at path $ naming the resource diagnostics, with the true count as observed. At a cap of zero that sentinel is the single item, so a caller can always rely on violations[0] existing. The flip side: with max_diagnostics = 1 and two violations you get only the sentinel, and the one real violation you had room for is gone.

The TypeScript side#

Published as a beta

The TypeScript half of this page describes the surface the repository builds, published as @candid-core/schema 0.3.0-beta.1 under the npm beta dist-tag. latest is still 0.2.0, which has the old one: its closed unions differ (no stack resource, and a contract code for opt opt), and a bad options object does not throw. Migrating from 0.2.0 lists every difference, and each is recorded in the package changelog under 0.3.0-beta.1. The Rust half is unchanged.

@candid-core/schema reproduces the same serialized item shape on purpose. Its issue interfaces carry exactly { code, path, message, resource_limit? }, its codes are stable snake_case strings, and its paths are $-rooted with .name for ASCII-identifier-shaped keys and ["…"] or [3] otherwise — the same $.declarations[3].name style candid-core emits.

tscrates/candid-core-ts/ts/validate.ts
export interface ValidationIssue {
  readonly code: ValidationCode;
  /** `$`-rooted path to the offending value, candid-core style. */
  readonly path: string;
  readonly message: string;
  readonly resource_limit?: ResourceLimitInfo;
}

export type ValidateResult =
  { readonly ok: true } | { readonly ok: false; readonly issues: readonly ValidationIssue[] };

Where the two agree#

  • The item shape, key for key, including the optional resource_limit triple.
  • $-rooted structured paths, and the same stability boundary: codes and paths are the interface, message text is not.
  • resource_limit_exceeded is spelled identically and carries the same {resource, limit, observed}, with value_depth and value_elements as shared resource names.
  • Ten codes in the runtime's Contract loader are the Rust ones verbatim: unsupported_contract_format, unsupported_format_version, unsupported_semantics_profile, unsupported_canonicalization_profile, dangling_type_ref, duplicate_field_id, empty_declaration_name, duplicate_declaration_name, invalid_extension_name, resource_limit_exceeded.
  • Both fail closed and neither repairs input.

Where they differ#

  • No phase, severity, span, related or notes. The runtime has no compiler and no source text, so an issue is the four-key form and nothing more.
  • Codes are closed TypeScript unions. ValidationCode, CodecCode and ContractIssueCode are union types, so adding a code is an API change you see at compile time. Rust codes are plain strings.
  • No exceptions for control flow. validate, encode, decode and schemaFromContract return { ok: true, … } | { ok: false, issues } and never throw on the value or document they are given — a value that fights inspection with a throwing accessor or a hostile Proxy produces a terminal unreadable_value issue instead. Data never throws. Passing the wrong schema does: resolveSchema, serviceMethods, and unwrapResult raise a TypeError eagerly, which is a programmer error rather than a failure mode. So does passing the wrong options: every entry point above throws TypeError for an unknown option key or a limit that is not a non-negative safe integer, before it reads anything — the TypeScript counterpart of the Rust policy's refusal of unknown override fields.
  • No sentinel on the issue cap. Rust replaces the last slot with a resource_limit_exceeded sentinel; the TypeScript walk's maxIssues (default 100) stops collecting, and the result is still not-ok.
  • Some resource names have no Rust counterpart — the codec's bytes, type_table_entries and numeric_bytes, the loader's name_table_entries, which bounds a caller-supplied field-name table that has no candid-core equivalent, and stack, reported by validate, encode and decode when the host JavaScript stack runs out — in code a walk calls (a getter, a rec thunk), since the walks themselves are iterative.

The three TypeScript code sets#

ModuleCodes
@candid-core/schema/validate invalid_type, not_integer, out_of_range, missing_field, unexpected_field, unknown_tag, invalid_length, uninhabited_type, unsupported_schema, unreadable_value, resource_limit_exceeded
@candid-core/schema/codec all eleven above, produced by the encode walk, plus duplicate_field_id, unrepresentable_float32, invalid_text, invalid_principal, invalid_magic, malformed_type_table, overlong_leb128, invalid_utf8, invalid_tag_byte, truncated, trailing_bytes, type_mismatch, unknown_variant_tag
@candid-core/schema/contract invalid_contract_document, unsupported_contract_format, unsupported_format_version, unsupported_semantics_profile, unsupported_canonicalization_profile, dangling_type_ref, unsupported_construct, duplicate_field_id, duplicate_field_name, empty_declaration_name, duplicate_declaration_name, invalid_name_table, invalid_extension_name, resource_limit_exceeded

There is no code for opt opt, opt null or opt reserved: both the loader and the generator accept them and box their present values as { some: v }. See Candid to TypeScript mapping.

ts
import { validate } from "@candid-core/schema/validate";
import { Account } from "./ledger.ts";

declare const incoming: unknown; // a value you were handed

const result = validate(Account, incoming);
if (!result.ok) {
  for (const issue of result.issues) {
    // Same rule as in Rust: match `code`, read `path`, display `message`.
    console.error(`${issue.code} at ${issue.path}`, issue.resource_limit ?? "");
  }
}

Next#