Host values

A lossless, self-describing JSON encoding for Candid values — and validation directed by the Contract graph.

A Contract tells you what shape a canister's arguments must have. A host value is the other half: one concrete argument, written down in a form that a host program can hold, store, send over a network and hand back without changing it. In this project a host value is a HostValue, a closed algebra of 23 kinds with a portable JSON encoding, gated behind the host-value Cargo feature, which is on by default.

Two separate questions live on this page, and the crate keeps them separate. Is this a well-formed value at all? is answered at the JSON boundary, without any Contract. Does this value fit the type at this position in this interface? is answered by validate_host_value, which walks the Contract's type graph. A value can pass the first and fail the second. A nat is a perfectly good value and still not a nat8.

Why plain JSON cannot carry a Candid value#

The obvious design is to encode a Candid value as the JSON that "looks like" it: a nat as a number, a record as an object keyed by field name, an absent opt as a missing key. Every one of those choices loses information, and the loss is silent.

  • Integers overflow the JSON number. Candid's nat and int are unbounded, and nat64/int64 reach 18 446 744 073 709 551 615 and -9 223 372 036 854 775 808. A JSON number is a double in most parsers, so it represents integers exactly only up to 253. Anything above that is rounded on the way through JSON.parse, and nothing tells you it happened.
  • Floats lose their bits. JSON has no spelling for NaN or for an infinity at all, and a NaN's payload bits are gone long before that. A signed zero does have a JSON spelling, but it survives a round trip only if nothing in the chain normalises the sign away.
  • opt null and null are different values. Candid's null type has one value. The type opt null has two: the absent one, and the present one whose payload is null. Collapse those into a bare JSON null and you can no longer tell an absent option from an option holding null, or either from a missing field.
  • A variant needs its tag. Which arm was selected is part of the value, not something to guess from the payload's shape. Two arms can carry the same type.
  • A principal is not a string. In the type system principal is its own primitive, distinct from text. service and func references are not primitives at all — each is its own kind of type node — and neither is a string either.
  • Record fields are numbers on the wire. Candid addresses a record field or variant arm by a 32-bit ID: a numeric label is used as written, and a named label is hashed with h = 0, then h = h * 223 + b for each UTF-8 byte, modulo 232. owner is 947296307. The Contract stores the ID, because that is what the wire carries.

Every tool that invents its own JSON shape bakes one of these coercions into everything built on top of it. So HostValue takes the other route. Each value states its own Candid type in a kind field and keeps its exact bits. The encoding is verbose on purpose. A friendlier, lossy view can always be layered on top, but not the other way round.

The tagged encoding, kind by kind#

Scalars#

Candid typekindPayloadExample
nullnullnone{"kind":"null"}
boolboolvalue, a JSON boolean{"kind":"bool","value":true}
natnatvalue, a decimal string{"kind":"nat","value":"340282366920938463463374607431768211456"}
intintvalue, a decimal string{"kind":"int","value":"-1"}
nat8 nat16 nat32same namesvalue, a JSON number{"kind":"nat32","value":4294967295}
nat64nat64value, a decimal string{"kind":"nat64","value":"18446744073709551615"}
int8 int16 int32same namesvalue, a JSON number{"kind":"int16","value":-32768}
int64int64value, a decimal string{"kind":"int64","value":"-9223372036854775808"}
float32float32bits, exactly 8 lowercase hex digits{"kind":"float32","bits":"deadbeef"}
float64float64bits, exactly 16 lowercase hex digits{"kind":"float64","bits":"7ff8000000000001"}
texttextvalue, a JSON string{"kind":"text","value":"hello"}
reservedreservednone{"kind":"reserved"}
principalprincipalvalue, canonical textual form{"kind":"principal","value":"aaaaa-aa"}

The split between JSON numbers and decimal strings is not a matter of taste. nat8, nat16, nat32 and their signed counterparts fit a double exactly, so they travel as numbers; nat64 and int64 do not, so they travel as strings, as do the two unbounded kinds. Floats travel as their raw IEEE-754 bits, which is what lets a NaN payload, an infinity and a signed zero survive the trip. {"kind":"float64","bits":"7ff8000000000001"} is a specific NaN, not "a NaN".

Containers#

json
{ "kind": "opt", "value": null }
{ "kind": "opt", "value": { "kind": "null" } }
{ "kind": "vec", "values": [ { "kind": "nat8", "value": 1 }, { "kind": "nat8", "value": 2 } ] }
{ "kind": "record", "fields": [ { "id": 947296307, "value": { "kind": "principal", "value": "aaaaa-aa" } },
                                { "id": 3573748184, "value": { "kind": "nat", "value": "42" } } ] }
{ "kind": "variant", "id": 24860, "value": { "kind": "text", "value": "ok" } }
{ "kind": "service", "principal": "aaaaa-aa" }
{ "kind": "func", "principal": "aaaaa-aa", "method": "go" }

The first two lines are the point of the whole design: an absent opt and an opt holding null are two different documents. A record's fields is an array of {id, value} pairs, never an object keyed by name, because the ID is the authoritative thing and the spelling is not. 24860 is the hash of ok, and 947296307 and 3573748184 are owner and amount.

What has no encoding#

Candid's empty type is uninhabited, so there is no HostValue for it; presenting any value against an empty position fails with empty_has_no_value. A service constructor (service : (nat) -> { … }, a class node in the graph) is not a first-class value either, and fails with class_has_no_host_value.

Three familiar Candid spellings are also absent, deliberately. blob is vec nat8, a tuple is a record with the numeric labels 0, 1, 2…, and the conventional Result is an ordinary variant. They are views layered on top, so a nicer view can never change what goes on the wire underneath.

One spelling per value#

For each scalar kind exactly one encoding is accepted, and everything else is rejected rather than repaired. A nat may not carry a leading zero; an int may not be -0; float bits must be exactly 8 or 16 characters drawn from 0-9 and a-f; a nat64 whose digits are canonical but whose value does not fit 64 bits is still rejected; and a principal is parsed and then re-rendered, so "AAAAA-AA" and a de-hyphenated spelling both fail even though they parse. An unknown JSON key anywhere is a failure too. Nothing is normalised on your behalf.

Record field order is the one thing left free. The decoder keeps the order it was given rather than sorting, so two documents listing the same fields in different orders both decode, both validate against the same type, and still compare unequal as HostValues. If you need one byte-exact form for a value, impose the field order yourself before encoding.

Validation directed by the Contract#

Once a value is well formed, checking it against an interface is a separate step:

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

The selector is a {contract_id, type_ref} pair minted by Contract::bind_type. type_ref is an index into the Contract's flat types array; contract_id records which Contract that index belongs to. Validation refuses immediately if the selector names a different Contract (value_contract_id_mismatch) or an index outside the arena (value_type_ref_out_of_bounds). That is what makes a stored selector safe to keep: when the interface changes, its contract_id changes, and the stale selector is caught instead of silently pointing at whatever now sits at that index.

From there the walk pairs a type node with a value node at every step. A vec type recurses into its inner for each element; a record recurses per field ID; a variant looks its tag up in the type's field table. Any pair that does not line up is a host_value_kind_mismatch. Because the walk reads only the Contract, a host can validate values without linking a Candid parser at all. The host-value feature adds exactly one dependency, ic_principal, for canonical principal text.

The validator does nothing helpful behind your back. A record's field set must match exactly: same count, same IDs, or record_field_set_mismatch listing both sorted sets. A record with an opt field still needs that field present, spelled as an opt whose value is null. There is no UI defaulting, no string-to-number coercion and no tuple guessing anywhere in the core validator.

Not a wire encoder yet

Encoding a validated host value into a Candid binary message is not part of this release. validate_host_value checks a value against a type; the binary bridge that would take a {contract_id, method_name} selector and reuse this validator is recorded as the next slice. The crate is 0.1.0-beta.3, and the Contract format is not a stable v1, so the serialized shapes on this page may still move.

ContractEnvelope: where the field names live#

The canonical Contract stores only field IDs, and it rejects unknown JSON keys, so there is nowhere inside it to put a form hint or a table of label spellings. ContractEnvelope is the sanctioned place for that data: a validated Contract plus a map of namespaced extensions, serialized as {"contract": …, "extensions": {…}} with extensions omitted when empty. It belongs to the base feature set, so it is available even with default-features = false.

An extension name fails closed. It must be a reverse-domain namespace (dot-separated segments of lowercase letters, digits and hyphens), then /v and an integer with no leading zero. "widget", "unversioned" and "com.example.form/v01" are all refused with invalid_extension_name, on insert and again on decode. Two ecosystems therefore cannot collide on a bare key.

This repository emits exactly one extension, org.candid-core.field-names/v1, from candid-core compile <path> --envelope. Its value is an array of [container, id, name] triples for labels that had a name in the source, sorted and deduplicated:

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"
    },
    "semantics_profile": "candid-1",
    "types": [
      { "kind": "service", "methods": [{ "function": 1, "id": 3664621355, "name": "transfer" }] },
      { "args": [2], "kind": "func", "mode": "update", "results": [] },
      { "fields": [{ "id": 947296307, "type": 3 }, { "id": 3573748184, "type": 4 }], "kind": "record" },
      { "kind": "primitive", "primitive": "principal" },
      { "kind": "primitive", "primitive": "nat" }
    ]
  },
  "extensions": {
    "org.candid-core.field-names/v1": [
      [2, 947296307, "owner"],
      [2, 3573748184, "amount"]
    ]
  }
}

Node 2 is the record, so the two triples say that its fields are spelled owner and amount. The producer block is elided above for length; it is present in the fixture. The TypeScript runtime exports the same key as FIELD_NAMES_EXTENSION and its schemaFromContract accepts this whole document, so one file carries both the interface and its names. See Schemas at runtime.

Extensions do not move the semantic identities

contract_id and interface_id are computed over the Contract alone, so adding, editing or removing an extension leaves both unchanged. Only an artifact identity over the envelope's exact bytes covers extension data. If you need a commitment that includes your extension, that is the identity to use. See Content-addressed identities.

Bounded on both sides#

HostValue is the crate's one recursive value type, and every operation on it walks one stack frame per level: decoding, cloning, comparing, formatting, dropping, serializing. A value deep enough to exhaust the stack aborts the process rather than returning an error, and Drop, Clone, PartialEq and Debug have nowhere to report a failure. So the crate applies the bound where a failure can still be reported, at the two entry points.

Decoding goes through HostValue::from_json_with_limits. There is no Deserialize impl. A trait impl has no argument slot for a resource policy, so it could only ever decode under limits the library chose. The function checks the input length against max_value_bytes, then runs a constant-stack, string-aware scan of the raw bytes counting { and [ against max_value_nesting (default 64), and only then hands the text to serde_json. serde_json's own fixed 128-frame ceiling is left in place underneath as a second line of defence, which is why raising max_value_nesting above 128 has no effect.

Construction is bounded by max_value_depth (default 256) and max_value_elements (default 1 000 000). HostValue::opt, vector, record and variant each take a &Limits and return a Result. A fallible builder is unusual. Refusing to build the value is the only chokepoint that covers all five recursive operations at once.

The two nesting limits count in different units and never agree. One vec level costs two JSON containers and one record level costs three, so a rejection names either value_nesting or value_depth, never both. The full list of value limits and their defaults is on the Limits reference.

Next#