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
natandintare unbounded, andnat64/int64reach 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 throughJSON.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 nullandnullare different values. Candid'snulltype has one value. The typeopt nullhas two: the absent one, and the present one whose payload isnull. Collapse those into a bare JSONnulland you can no longer tell an absent option from an option holdingnull, 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
principalis its own primitive, distinct fromtext.serviceandfuncreferences 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, thenh = h * 223 + bfor each UTF-8 byte, modulo 232.owneris 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 type | kind | Payload | Example |
|---|---|---|---|
null | null | none | {"kind":"null"} |
bool | bool | value, a JSON boolean | {"kind":"bool","value":true} |
nat | nat | value, a decimal string | {"kind":"nat","value":"340282366920938463463374607431768211456"} |
int | int | value, a decimal string | {"kind":"int","value":"-1"} |
nat8 nat16 nat32 | same names | value, a JSON number | {"kind":"nat32","value":4294967295} |
nat64 | nat64 | value, a decimal string | {"kind":"nat64","value":"18446744073709551615"} |
int8 int16 int32 | same names | value, a JSON number | {"kind":"int16","value":-32768} |
int64 | int64 | value, a decimal string | {"kind":"int64","value":"-9223372036854775808"} |
float32 | float32 | bits, exactly 8 lowercase hex digits | {"kind":"float32","bits":"deadbeef"} |
float64 | float64 | bits, exactly 16 lowercase hex digits | {"kind":"float64","bits":"7ff8000000000001"} |
text | text | value, a JSON string | {"kind":"text","value":"hello"} |
reserved | reserved | none | {"kind":"reserved"} |
principal | principal | value, 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#
{ "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:
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.
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:
{
"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.
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#
Building trees under limits, binding a selector, and reading the verdict, with runnable code.
Concept The Contract graphThe arena of type nodes that validation walks, and what a TypeRef actually is.
The Contract, envelope, compilation, host value and limits documents side by side.