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.
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#
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>,
}| Field | Present when | What it carries |
|---|---|---|
code | always | A stable snake_case identifier. The thing to match on. |
message | always | Human-readable text. Not stable; display it, do not parse it. |
phase | compile-domain items only | "parse", "type_check", "load" or "lower". |
severity | compile-domain items only | "error" — the enum has exactly one variant today. |
path | every validation violation; compile items converted from one | A $-rooted structured path: $.types[3],
$.identities.contract, $.declarations[0].name,
$.value.value.value. |
span | optional | A source location, in one of exactly two forms — see below. |
related | optional, ordered | Secondary locations, in the order the underlying tool reported them after the primary one. Each is { message, span? }. |
notes | optional, ordered | Free-text notes, for example a parser's expected-token list. |
resource_limit | only 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.
{ "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#
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.
{
"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:
{
"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.
| Code | Triggered by |
|---|---|
resource_limit_exceeded | A limit was reached. The only code that carries a resource_limit triple. |
operation_cancelled | The caller's CancellationToken was cancelled and the operation reached its next checkpoint. |
operation_deadline_exceeded | The 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#
| Code | Path | Triggered by |
|---|---|---|
unsupported_contract_format | $.format | format is not "candid-core". |
unsupported_format_version | $.format_version | format_version is not 1. |
unsupported_semantics_profile | $.semantics_profile | Not "candid-1". |
unsupported_canonicalization_profile | $.canonicalization_profile | Not "candid-core-canon-1". |
invalid_contract_id_format | $.identities.contract | Not candid-core:contract:v1:sha256: followed by 64 lowercase hex characters. |
invalid_interface_id_format | $.identities.interface | The same test against the candid-core:interface:v1 domain. |
contract_id_mismatch | $.identities.contract | The declared identity does not equal what recanonicalization computes. |
interface_id_mismatch | $.identities.interface | The same, for the actor-reachable interface identity. |
actorless_contract_has_interface_id | $.identities.interface | A Contract with no actor declares one anyway. |
actor_contract_missing_interface_id | $.identities.interface | A Contract with an actor omits it. |
invalid_producer | $.producer | Producer name or version is empty. |
empty_declaration_name | $.declarations[i].name | A declaration name is the empty string. |
duplicate_declaration_name | $.declarations[i].name | Two declarations share a name. |
Contract type graph#
| Code | Triggered by |
|---|---|
dangling_type_ref | A type reference points outside the arena. |
duplicate_field_id | A record or variant lists one Candid label id twice. |
oneway_has_results | A oneway function declares results. |
empty_method_name | A service method name is the empty string. |
duplicate_method_name | One service lists a method name twice. |
method_id_mismatch | A method's id is not the Candid hash of its name. |
service_method_not_function | A method's function reference does not point at a func node. |
class_service_not_service | A 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_root | A class node appears anywhere but as the top-level actor — including as the target of a named declaration. |
class_not_first_class_type | A class is reached through an ordinary Candid type edge. |
rootless_type_arena | A non-empty types array with no actor and no declaration to root it. |
orphan_type_node | A node unreachable from any actor or declaration root. |
Envelope and serialization#
| Code | Path | Triggered by |
|---|---|---|
invalid_extension_name | $.extensions | An 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.
| Code | Path | Triggered by |
|---|---|---|
unsupported_source_info_version | $.source_info_version | Not 1. |
source_contract_id_mismatch | $.contract_id | The sidecar names a different Contract than the one it was presented with. |
source_contract_rederivation_mismatch | $.contract_id | The 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_id | The declared bundle identity does not survive recomputation. |
non_canonical_source_bundle | $ | sources and imports are not canonically sorted. |
source_info_provenance_mismatch | the offending entry | A presented provenance field disagrees with the rederived one. |
invalid_source_id | $.sources[i].name | A 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].path | An 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].position | The argument position is past the function's arity. |
source_bundle_entry_missing | $.sources | The bundle has no unique entry source. |
source_bundle_entry_ambiguous | $.sources | The 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.
| Code | Phase | Triggered by |
|---|---|---|
did_parse_error | parse | The 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_error | type_check | The source parses but does not type-check. |
did_load_error | load | A load-phase failure with no more specific code. |
contract_lowering_error | lower | An unstructured invariant failure while lowering the checked AST into the arena. |
did_import_cycle | load | Import resolution reached a source already on the stack. |
did_import_requires_file | load | compile_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_id | load | A resolver returned a non-canonical source id, or a path had no UTF-8 file name. |
did_resolver_identity_mismatch | load | A resolver identified one source and then loaded another. |
did_materialize_error | load | The 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.
| Code | Triggered by |
|---|---|
did_source_not_found | The requested source is not in the bundle. |
did_source_scheme_mismatch | A source id uses the wrong scheme — MemoryResolver requires memory:/, WorkspaceResolver requires workspace:/. |
did_source_digest_mismatch | A resolved source's declared digest does not match its bytes. |
did_workspace_root_error | The workspace root could not be opened, or filesystem resolution is unavailable on this target. |
did_file_read_error | A 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?}.
| Code | Triggered by |
|---|---|
value_contract_id_mismatch | The 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_bounds | The selector's type index is outside the arena. Also what bind_type refuses with. |
host_value_kind_mismatch | The 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_field | A record value lists one field id twice. |
record_field_set_mismatch | The 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_id | The variant's id is not an arm of the expected type. |
empty_function_method | A func value carries an empty method name. |
invalid_principal | Principal text is not canonical. Applies to the principal, service and func kinds alike. |
empty_has_no_value | Any value presented against Candid's uninhabited empty. |
class_has_no_host_value | Any value presented against a class node — a service constructor is not a first-class value. |
actorless_contract | bind_method on a Contract with no actor. |
unknown_method | bind_method with a name the actor service does not expose. |
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#
| Code | Triggered by |
|---|---|
malformed_contract_json | candid-core validate was given something that is not a well-formed Contract document at all. Phase load. |
contract_file_read_error | The file could not be read, or was not valid UTF-8. Phase load. |
Reading a failure in 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!();
}
}
}
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#
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.
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_limittriple. $-rooted structured paths, and the same stability boundary: codes and paths are the interface, message text is not.-
resource_limit_exceededis spelled identically and carries the same{resource, limit, observed}, withvalue_depthandvalue_elementsas 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,relatedornotes. 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,CodecCodeandContractIssueCodeare 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,decodeandschemaFromContractreturn{ 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 terminalunreadable_valueissue instead. Data never throws. Passing the wrong schema does:resolveSchema,serviceMethods, andunwrapResultraise aTypeErroreagerly, which is a programmer error rather than a failure mode. So does passing the wrong options: every entry point above throwsTypeErrorfor 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_exceededsentinel; the TypeScript walk'smaxIssues(default 100) stops collecting, and the result is still not-ok. -
Some resource names have no Rust counterpart — the codec's
bytes,type_table_entriesandnumeric_bytes, the loader'sname_table_entries, which bounds a caller-supplied field-name table that has no candid-core equivalent, andstack, reported byvalidate,encodeanddecodewhen the host JavaScript stack runs out — in code a walk calls (a getter, arecthunk), since the walks themselves are iterative.
The three TypeScript code sets#
| Module | Codes |
|---|---|
@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.
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 ?? "");
}
}