Validating host values

Constructing HostValue trees under limits and checking them against a Contract's type graph.

A host value is one concrete Candid value, held in a form that keeps its exact bits: the HostValue type, gated behind the host-value Cargo feature, which is in the default set. Turning it on adds exactly one crate to your dependency graph, ic_principal, for checking canonical principal text. No Candid parser is needed to build or validate values.

This page is the mechanics: how to build a value, how to point at a type inside a Contract, how to run validate_host_value and read what comes back, and how to decode a value that arrived as JSON from somewhere you do not control. For why the encoding looks the way it does, read Host values first.

toml
[dependencies]
candid-core = "=0.1.0-beta.3"

The crate is a prerelease, so the requirement has to be exact. A caret requirement never selects a prerelease. Everything below also needs the compiler feature, because the examples compile a .did source to get a Contract to validate against; both features are on by default.

Constructing a value#

Scalars#

Scalars whose representation cannot be wrong are plain constructors: HostValue::null(), boolean(bool), nat8(u8), nat16(u16), nat32(u32), int8(i8), int16(i16), int32(i32), text(impl Into<String>) and reserved(). The Rust type already constrains them.

The rest take a string and return a Result, because there is exactly one accepted spelling and the constructor is where a wrong one is caught:

rust
use candid_core::{HostValue, Limits};

// Unbounded integers, as canonical decimal text.
let big = HostValue::nat("340282366920938463463374607431768211456")?;
let negative = HostValue::int("-1")?;
assert!(HostValue::nat("01").is_err());        // leading zero
assert!(HostValue::int("-0").is_err());        // negative zero

// 64-bit integers: canonical digits *and* in range.
let max = HostValue::nat64("18446744073709551615")?;
assert!(HostValue::nat64("18446744073709551616").is_err());

// Floats as raw IEEE-754 bits: 8 lowercase hex digits, or 16.
let nan = HostValue::float64("7ff8000000000001")?;
assert!(HostValue::float32("DEADBEEF").is_err());   // uppercase
assert!(HostValue::float64("7ff800000000001").is_err()); // 15 digits

// Principals are parsed and re-rendered; only the canonical form survives.
let owner = HostValue::principal("aaaaa-aa")?;
assert!(HostValue::principal("AAAAA-AA").is_err());

let reference = HostValue::service("aaaaa-aa")?;
let callback = HostValue::func("aaaaa-aa", "go")?;

A failed constructor returns HostValueJsonError::Malformed carrying a message rooted at $, for example $: non-canonical nat.

Containers take limits#

The four container constructors are fallible and each takes the caller's limits:

rust
pub fn opt(value: Option<Self>, limits: &Limits) -> Result<Self, HostValueJsonError>
pub fn vector(values: Vec<Self>, limits: &Limits) -> Result<Self, HostValueJsonError>
pub fn record(fields: Vec<HostFieldValue>, limits: &Limits) -> Result<Self, HostValueJsonError>
pub fn variant(id: u32, value: Self, limits: &Limits) -> Result<Self, HostValueJsonError>

A fallible builder is unusual. HostValue is recursive, and Drop, Clone, PartialEq, Debug and Serialize all walk one stack frame per level. A value deep enough to exhaust the stack aborts the process, and the first four of those have no way to report a problem. Construction is the only chokepoint that covers all five, so it is where the policy is applied. Each value caches its own measured extent: how deep it nests, and how many nodes it holds. A constructor therefore checks max_value_depth (default 256) and max_value_elements (default 1 000 000) against the children's already-known extents rather than re-walking the tree.

Breaching either bound returns HostValueJsonError::ValueLimit naming value_depth or value_elements, with the limit, the observed figure, and the path $.

Field IDs#

A record field is a HostFieldValue, and it is addressed by its 32-bit Candid ID, not by a name:

rust
pub fn new(id: u32, value: HostValue) -> Self

This one is infallible on purpose: a field carries no bound of its own, and the enclosing HostValue::record call measures the combined extent. id() and value() read it back.

The crate does not export the Candid name hash. It is pub(crate), used internally to assign each service method's ID when a source is lowered and to check that a presented ID matches its name. So you get field IDs from one of two places: read them straight off the Contract's TypeNode::Record { fields }, or compute them with candid_parser::candid::idl_hash, which is what this repository's own examples and tests do. candid_parser enters this crate's graph only with the compiler feature (and, separately, as a dev-dependency for the test suites), so a consumer building only host-value would have to add it deliberately or take the IDs from the graph.

Binding a selector to a Contract#

Validation needs to know which type in which interface. That pair is a selector, and Contract mints them:

Contract::bind_type host-value
rust
pub fn bind_type(&self, type_ref: TypeRef) -> Result<ContractTypeRef, HostValueValidationError>

Turns a bare arena index into {contract_id, type_ref}, rejecting an index outside the types array up front with value_type_ref_out_of_bounds.

Contract::bind_method host-value
rust
pub fn bind_method(&self, method: impl Into<String>) -> Result<ContractMethodRef, HostValueValidationError>

Resolves a method name through the actor, either a service or a class's service, and returns {contract_id, method_name}. An actorless Contract fails with actorless_contract, an unknown name with unknown_method.

Both selectors serialize with exactly those key names and reject unknown ones, so they are safe to persist. The contract_id half is what makes storing one safe: if the interface changes, its identity changes, and a stale selector is rejected instead of silently pointing at whatever now sits at that index.

bind_method does not feed validate_host_value

validate_host_value takes a ContractTypeRef. ContractMethodRef is the persistable selector shape the planned HostValue-to-Candid-binary bridge will take; that encode/decode step is not part of this release. To check a method's arguments today, resolve the method's func node yourself and bind_type its argument type.

Validating against the graph#

There are two entry points, and only two. No validate_host_value_with_limits exists, because the plain function already takes limits.

rust
pub fn validate_host_value(
    contract: &Contract,
    selector: &ContractTypeRef,
    value: &HostValue,
    limits: &Limits,
) -> Result<(), HostValueValidationError>

pub fn validate_host_value_with_context(
    contract: &Contract,
    selector: &ContractTypeRef,
    value: &HostValue,
    context: &RuntimeContext,
) -> Result<(), HostValueValidationError>

The first builds a fresh RuntimeContext from your limits and calls the second. Use the _with_context form when you already own a budget. A RuntimeContext carries the limits, including deadline_unix_ms, plus a cancellation token, so a long walk can be interrupted and reports operation_deadline_exceeded or operation_cancelled rather than running to completion.

Reading the verdict#

Success is Ok(()). Failure is a HostValueValidationError holding violations: Vec<HostValueViolation>, where HostValueViolation is an alias of the crate-wide Diagnostic. Value violations always carry path and never carry phase or severity, so the shape you actually see is {code, path, message, resource_limit?}. Paths are rooted at $ and address the value, not the type: $.fields[3573748184], $.values[7], $.value.

CodeWhat it means
value_contract_id_mismatchThe selector names a different Contract.
value_type_ref_out_of_boundsThe selector's index is outside this Contract's arena.
host_value_kind_mismatchThe value's kind does not pair with the type node at this position.
duplicate_host_fieldThe same record field ID appears more than once in the value.
record_field_set_mismatchThe field IDs present do not match the type's, exactly. Both sorted sets are in the message.
unknown_variant_idThe variant's tag is not an arm of the expected type.
invalid_principalPrincipal text is unparseable, or parses but is not canonical.
empty_function_methodA func value carries an empty method name.
empty_has_no_valueThe type at this position is Candid's uninhabited empty, which has no value.
class_has_no_host_valueThe type at this position is a service constructor, which is not a first-class value.
resource_limit_exceededA budget ran out. Carries {resource, limit, observed}.
operation_deadline_exceededThe context's deadline elapsed mid-walk.
operation_cancelledThe caller tripped the context's cancellation token.

A complete program#

This is the example the repository ships, verbatim. It compiles a two-field record, finds its declaration, binds a selector, builds a value whose nat is larger than a u128 and whose float64 is a specific NaN payload, validates it, and prints the tagged JSON.

rustexamples/host_value_validation.rs
use candid_core::{compile_did, validate_host_value, HostFieldValue, HostValue, Limits};
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let compilation = compile_did(
        r#"
        type Measurement = record { count: nat; reading: float64 };
        service : { submit: (Measurement) -> () };
        "#,
    )?;
    let contract = compilation.contract();
    let measurement = contract
        .declarations()
        .iter()
        .find(|declaration| declaration.name == "Measurement")
        .ok_or("missing Measurement declaration")?;
    let selector = contract.bind_type(measurement.ty)?;
    let value = HostValue::record(
        vec![
            HostFieldValue::new(
                candid_parser::candid::idl_hash("count"),
                HostValue::nat("340282366920938463463374607431768211456")?,
            ),
            HostFieldValue::new(
                candid_parser::candid::idl_hash("reading"),
                // A NaN payload preserved exactly as IEEE-754 bits.
                HostValue::float64("7ff8000000000001")?,
            ),
        ],
        &Limits::default(),
    )?;

    validate_host_value(contract, &selector, &value, &Limits::default())?;
    println!(
        "selector: {} / type {}",
        selector.contract_id, selector.type_ref
    );
    println!("{}", serde_json::to_string_pretty(&value)?);
    Ok(())
}
bash
cargo run --example host_value_validation

A Declaration is a {name, ty} pair, a name table pointing into the arena, serialized with the JSON key "type". The two field IDs printed in the output are 1248019663 for count and 48364364 for reading. Nothing in the Contract stores those spellings; if you want them travelling alongside the interface, that is what the envelope's org.candid-core.field-names/v1 extension is for.

When validation fails#

The next program is built from two tests in tests/host_value_conformance.rs and shows both failure shapes. The first is a value that is perfectly well formed and still does not fit the type. nat and nat8 are different types, and nothing widens. The second is a value the type accepts but the caller's policy does not.

rust
use candid_core::{compile_did, validate_host_value, Contract, HostValue, Limits};
use std::error::Error;

fn declaration(contract: &Contract, name: &str) -> Option<u32> {
    contract
        .declarations()
        .iter()
        .find(|declaration| declaration.name == name)
        .map(|declaration| declaration.ty)
}

fn main() -> Result<(), Box<dyn Error>> {
    let compilation = compile_did("type Small = nat8; type Items = vec nat; service : {};")?;
    let contract = compilation.contract();

    // 1. A locally valid value that does not fit the type.
    let small = contract.bind_type(declaration(contract, "Small").ok_or("missing Small")?)?;
    let one = HostValue::from_json_with_limits(
        r#"{ "kind": "nat", "value": "1" }"#,
        &Limits::default(),
    )?;

    let error = validate_host_value(contract, &small, &one, &Limits::default())
        .expect_err("a nat is not a nat8");
    for violation in &error.violations {
        println!(
            "{} at {}: {}",
            violation.code,
            violation.path.as_deref().unwrap_or("$"),
            violation.message
        );
    }
    // host_value_kind_mismatch at $: expected primitive Nat8, found nat or a
    // non-canonical representation

    // 2. A value the type accepts, under a policy that does not.
    let items = contract.bind_type(declaration(contract, "Items").ok_or("missing Items")?)?;
    let hundred = HostValue::vector(
        (0..100)
            .map(|_| HostValue::nat("1"))
            .collect::<Result<Vec<_>, _>>()?,
        &Limits::default(),
    )?;

    let limits = Limits::default().with_max_value_elements(10);
    let error =
        validate_host_value(contract, &items, &hundred, &limits).expect_err("101 elements > 10");
    let violation = &error.violations[0];
    let info = violation
        .resource_limit
        .as_ref()
        .ok_or("a resource failure carries its metadata")?;
    println!(
        "{} at {}: {} observed {}, limit {}",
        violation.code,
        violation.path.as_deref().unwrap_or("$"),
        info.resource,
        info.observed,
        info.limit
    );
    // resource_limit_exceeded at $: value_elements observed 101, limit 10
    Ok(())
}

Two details in the second half matter. The observed count is 101, not 11: before recursing into a container the validator adds the whole child count to what it has already consumed and compares once, so the failure names the real size rather than the point at which counting stopped. And the reported path is $, the root, because that is where the over-large container sits.

Wide records are bounded by the work counter

Record validation scans field IDs pairwise instead of building an index, charging one unit of max_canonicalization_work per comparison across three scans, roughly 1.5n2 units for an n-field record. At the default of 10 000 000 that binds a single record at 2 581 fields; 2 582 fails closed with resource_limit_exceeded naming canonicalization_work. Neither max_fields nor max_value_elements is the binding limit there. The counter is per operation and shared with Contract canonicalization, so several wide records in one tree accumulate against it.

Decoding JSON from outside#

HostValue deliberately does not implement Deserialize: serde_json::from_str::<HostValue>(…) does not compile. A trait impl has no argument position for a resource policy, so it could only ever decode under limits the library picked. The bounded paths are:

rust
pub fn from_json_with_limits(input: &str, limits: &Limits) -> Result<Self, HostValueJsonError>
pub fn from_json_with_context(input: &str, context: &RuntimeContext) -> Result<Self, HostValueJsonError>

They check the input length, run a constant-stack scan of the raw bytes for over-nested JSON, hand the text to serde_json as a private raw DTO, and only then check canonical scalar forms level by level. A HostValue therefore cannot exist without having passed all of that.

The byte gate is max_value_bytes, not max_input_bytes

Every other bounded parse in the crate checks max_input_bytes before decoding. This one checks max_value_bytes, 16 MiB by default, and reports HostValueJsonError::Limit, which carries a limit and an observed but no resource name. Lowering max_input_bytes alone does nothing to bound HostValue decoding; lower max_value_bytes too.

HostValueJsonErrorFieldsRaised by
MalformedStringBad JSON, an unknown key, or a non-canonical scalar, including from the Rust constructors.
Limitlimit, observedThe whole-document max_value_bytes gate. No resource name.
ValueLimitresource, limit, observed, pathA named budget: value_nesting, value_depth, value_elements, value_bytes or canonicalization_work.
DeadlinepathThe context's deadline elapsed mid-decode.
CancelledpathThe caller tripped the context's cancellation token.

value_nesting and value_depth count in different units. max_value_nesting (default 64) counts { and [ in the text, before serde_json runs; max_value_depth (default 256) counts semantic container levels afterwards. One vec level costs two JSON containers and one record level costs three, so a rejection names one or the other, never both. Raising max_value_nesting above 128 has no effect: serde_json's own fixed 128-frame ceiling sits underneath and is deliberately left in place.

Serialization is ordinary. HostValue implements Serialize, so serde_json::to_string(&value) gives you the tagged document back, and decoding that text returns an equal value.

Next#

The limits page covers profiles, builders, deadlines and cancellation in full, and Limits reference lists every default. For the same values on the other side of the boundary, see Schemas at runtime, which builds TypeScript schemas from a Contract or envelope document and consumes the field-name extension described in Host values.