Profiles
Profiles, layers and binding
What a profile is, how it binds to an interface without changing its identity, and how layers merge.
{
"format": "candid-profile",
"format_version": 0,
"kind": "canister",
"name": "tokens-example",
"origin": "author",
"language": "en",
"binds": {
"interface_id": "candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c"
},
"annotations": [
{ "at": "type:Tokens.e8s", "tag": "amount", "args": { "decimals": 8, "symbol": "TOK" } },
{ "at": "send", "tag": "effect", "args": { "kind": "transfer", "irreversible": true } },
{ "at": "send", "tag": "idempotency", "args": { "mode": "replay_same_payload", "stamp": "created_at" } },
…
]
}
That is most of a profile: a JSON document that names an interface by its
interface_id and attaches annotations to it. Each annotation is an
address (type:Tokens.e8s, send), a
tag (amount, effect) and the tag's
arguments. The interface says that e8s is a nat64; the profile says it
counts a token called TOK in units of 10-8, that send moves value and
cannot be undone, and that a retry of send is safe when it carries the same
created_at stamp.
One profile, four consumers#
A profile is written once and read by every piece of software that has to put a value into a call or explain one to a person. The spec names four consumers, and each takes a different part of the same document:
| Consumer | What it takes from the profile |
|---|---|
| Agent tools (MCP, WebMCP) | Inputs in human units, filled defaults, safe retries, classified outcomes, a confirmation step. Give an agent tools |
| ICRC-21 consent messages | An intent line and typed display fields, generated for any method. Generate consent messages |
| MCP Apps cards | The same intent and fields, rendered as an approval or inspection card |
| Web UI | Input widgets, labels and formatting. Parse and display values |
Because they read one document, they cannot disagree: the amount an agent tool reads from
"1.5 TOK" is the amount the consent message shows the person, since both read it
through the same tag. Page layout,
references across canisters and templates in several languages are outside v0; cross-canister
references are open question OQ-6.
The interface stays as it is#
A profile never changes the interface it describes. It is a separate document bound to the
interface's identity, the way the SourceInfo sidecar is, and never an input to
compiling it: the Contract, its interface_id and its contract_id are
the same whether or not any profile exists. Tags written as doc comments in
the .did itself are no exception. Doc comments live in SourceInfo,
outside the Contract, so adding them moves the source_bundle_id and neither
semantic identity (what moves which id).
This is what lets a profile be checked. Its binds.interface_id is a claim about
exactly one callable interface, and anyone holding the canister's .did can compile
it and compare one string.
Why a profile names edges, not type nodes#
The interface layer interns structurally equal types into one node of the
Contract graph. That is right for identity and wrong for
meaning. fact Compiling the ICRC-1 fragment shows the consequence (spec §2): one
opt blob node serves from_subaccount, memo and
Account.subaccount, and one nat node serves amount,
fee, Ok, duplicate_of and expected_fee.
A tag on that nat node would make the block index in Ok an amount of
tokens, and a tag on the opt blob would make the memo a subaccount. So a profile
addresses the edge that leads to a value: a method's argument or result, then a
path through fields and cases. icrc1_transfer(0).amount and
icrc1_transfer(0).fee lead to the same nat node (the fee through its
opt), and each gets its own tags.
Addresses has the grammar and the resolution rules.
The document and its profile_id#
Every profile carries format ("candid-profile"),
format_version (0), a kind, a name, an
origin, a binds object and its annotations;
language and description are optional. The kind says how
it binds:
canister: one canister's interface, bybinds.interface_id(and optionallybinds.canister_id). Itsoriginisauthor,third_partyorinferred.pack: any service that matches a Candid fragment,binds.requires. Itsoriginispack.contract: an EVM contract, by its ABI. Experimental; see EVM contracts.
A profile's own identity is its profile_id:
candid-profile:v0:sha256: and the SHA-256 of the document's RFC 8785 (JCS)
canonical JSON. Key order and whitespace do not move it; any edit to a value does. The
repository's single-profile validator (run from profile/) prints it beside the
interface it checked against:
node scripts/validate.mjs examples/tokens.profile.json --did examples/tokens.didPASS examples/tokens.profile.json (0 error, 0 warning, 0 info)
profile_id candid-profile:v0:sha256:70abc8cbce701650b6adafeabbf636a10b9c80e620bfcb6cc2d8793ee027745c
interface_id candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c [2 methods, 10 annotations]
The resolved form lists every profile it applied with its profile_id, and every tag
in it names the profile and the layer it came from, so a consumer can always say whose words it
is showing. Every field of the document and of the resolved form is on
Profile and resolved form.
Canister profiles bind to an interface_id#
A canister profile was written against one interface. When the live interface is a different one, the profile may still be right, or it may describe positions that moved. The resolver does not guess which. Given a live interface, it applies one of three rules (spec §7):
| The live interface | Status | What happens |
|---|---|---|
Has the same interface_id | applied | The profile is valid. |
| Differs, and every address still resolves and every tag still applies | rebound | WW_REBOUND. The profile is applied. |
| Differs, and anything fails | stale | EE_STALE, beside the specific errors. The value-conversion tags are dropped. |
Adding a method to the example's service moves its interface_id, and nothing the
profile names moved, so the profile is rebound:
candid-profile: tokens-added.did candid-core:interface:v1:sha256:2d98d9ca33bc945df5ef3991745c86a8b73cd778db38c3ad71b9e03b425f9d28 [3 methods]
rebound tokens-example author candid-profile:v0:sha256:70abc8cbce701650b6adafeabbf636a10b9c80e620bfcb6cc2d8793ee027745c
0 error, 1 warning, 0 info
warning W_REBOUND [tokens-example] binds.interface_id profile is bound to candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c but the given interface is candid-core:interface:v1:sha256:2d98d9ca33bc945df5ef3991745c86a8b73cd778db38c3ad71b9e03b425f9d28; checking whether every address still resolves
Changing memo : opt blob to memo : opt nat breaks the
bytes tag on send(0).memo, so the same profile is stale:
candid-profile: tokens-memo.did candid-core:interface:v1:sha256:7cb0945a805c17a9b4d768dc66eef0989b3b98409185b22e7fa465a7b3468430 [2 methods]
stale tokens-example author candid-profile:v0:sha256:70abc8cbce701650b6adafeabbf636a10b9c80e620bfcb6cc2d8793ee027745c
2 error, 1 warning, 2 info
warning W_REBOUND [tokens-example] binds.interface_id profile is bound to candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c but the given interface is candid-core:interface:v1:sha256:7cb0945a805c17a9b4d768dc66eef0989b3b98409185b22e7fa465a7b3468430; checking whether every address still resolves
error E_TAG_NOT_APPLICABLE [tokens-example] send(0).memo bytes needs blob, found opt nat
error E_STALE [tokens-example] binds.interface_id the interface changed and some annotations no longer hold; value-conversion tags must not be used (the resolved form omits this profile's value-conversion tags)What a stale profile loses#
A stale profile keeps its words and loses its numbers. The resolver leaves its six
value-conversion tags out of the resolved form (amount, timestamp,
duration, account, subaccount and bytes) and
keeps the rest: intent, effect, describe,
default and the others. The spec lists those six; the reference resolver in
@meantjs/profile 0.4.0 also leaves out a stale profile's named tags,
since a name stands in for a value. In the stale resolve above, send(0).amount.e8s
still has its describe label and no longer has its amount tag, and
send keeps its intent, effect, idempotency and outcome tags. Agent tools fall back to
raw Candid for those positions.
The asymmetry is deliberate. A label that no longer quite fits is a bad description; a
decimals: 8 applied to a position that now means something else is a wrong amount
of money. Consumers must not use a stale profile's conversion tags even when they hold them from
an earlier read.
Packs bind by subtyping#
A pack describes a standard, not a canister. Its binds.requires is a Candid service
fragment: the ICRC-1 pack's lists icrc1_symbol, icrc1_decimals,
icrc1_fee, icrc1_balance_of and icrc1_transfer with their
types. The pack applies to any canister whose service type is a
subtype of that fragment: the canister may have more methods, more optional
argument fields, fewer result cases, and the pack still fits. Every query: reference
a pack makes must be a method of its own fragment, so a pack can only read what it requires.
One relaxation is made on purpose (D-019). Under Candid subtyping a variant with more cases is a
supertype, so a ledger that added an error case to icrc1_transfer would stop
matching the ICRC-1 pack at all. For variants reached through a result, extra cases are
allowed. The resolver says which positions it relaxed. Here a local ledger has an extra
Frozen error:
candid-profile: wide-ledger.did candid-core:interface:v1:sha256:269d2a66494731bcb71f47d122c6f0d7685d9fdcd6747f031273674b66d6096b [5 methods]
applied icrc1 pack candid-profile:v0:sha256:4eaa9312d60661248ff5627dba9906aa91240abe84cb1b1da8ca20a6fba2e3e6
not_matched icrc2 pack candid-profile:v0:sha256:f9d75e8bf5ff2edb0b167988feee494eeb54c386e6f62252c4bd96ac56346567
0 error, 0 warning, 3 info
info I_PACK_MATCHED [icrc1] icrc1 the service is a subtype of the pack's binds.requires (result variants wider than the pack, D-019: icrc1_transfer->(0).Err has extra cases Frozen)
info I_PACK_NOT_MATCHED [icrc2] icrc2 pack does not apply: icrc2_allowance: method "icrc2_allowance" is missing from the canister; icrc2_approve: method "icrc2_approve" is missing from the canister; icrc2_transfer_from: method "icrc2_transfer_from" is missing from the canister
info W_OUTCOME_UNCLASSIFIED [icrc1] icrc1_transfer result case "Err.Frozen" has no class (falls back to default)
The extra case carries no annotation, and its outcome
class falls back to the pack's default, which is fail for ICRC-1. A
pack outcome without a default gets WW_PACK_NO_DEFAULT,
because a case a matching canister adds would otherwise be unclassified. Argument positions are
never relaxed: a canister that accepts less than the fragment describes does not match.
A matched pack is applied to the service's own types: its addresses resolve against the canister, not against the fragment. A position the pack names that the canister lacks, which subtyping allows (an optional field, a removed result case), is skipped with II_PACK_ANNOTATION_SKIPPED rather than reported as an error in the pack.
An EVM pack, such as the ERC-20 pack, has no subtyping. It applies to a contract that has every one of its functions with the same selector, output types and kind of call. EVM support is experimental; EVM contracts has the rules.
Layers and precedence#
A canister's effective profile is merged from up to four layers, highest first:
| Layer | Where it comes from | Trust |
|---|---|---|
author | Served by the canister as the public custom section icp:public candid:profile, or shipped by its author | The author's claim (security model) |
third_party | A profile the consumer chose to trust | As configured; attestations are planned for v1 |
pack | The standard packs, matched by subtyping | Reviewed in the repository |
inferred | Proposed by a model; active only after a person approves it, and until then shown as a suggestion | Lowest |
Merging is per tag, per position. For each position and each tag, the highest
layer that has the tag there supplies it. The same tag at the same position in a lower layer is
dropped and reported with WW_OVERRIDDEN; the position's other tags from the lower
layer are kept. Here a local ledger's author profile sets its own amount on
icrc1_transfer(0).amount, and the ICRC-1 pack also applies:
candid-profile resolve ledger.did --profile local-ledger.profile.json --packs > layered.jsoncandid-profile: ledger.did candid-core:interface:v1:sha256:7fae0dd330b20f53ae1e4e2cb1947c1c57791da0ae20299ceb499f63228d0689 [5 methods]
applied local-ledger author candid-profile:v0:sha256:6ab2dc9052627172a4cce381d36a046c320ae789cea6cfd9d448f0d8b2604ccb
applied icrc1 pack candid-profile:v0:sha256:4eaa9312d60661248ff5627dba9906aa91240abe84cb1b1da8ca20a6fba2e3e6
not_matched icrc2 pack candid-profile:v0:sha256:f9d75e8bf5ff2edb0b167988feee494eeb54c386e6f62252c4bd96ac56346567
0 error, 1 warning, 2 info
warning W_OVERRIDDEN [icrc1] icrc1_transfer(0).amount amount from profile "icrc1" (pack) is overridden by profile "local-ledger" (author) at "icrc1_transfer(0).amount" [icrc1_transfer(0).amount]The position in the resolved form takes its amount from the author and its describe from the pack, and each tag says where it came from:
{
"candid": "nat",
"tags": {
"amount": {
"args": { "decimals": 8, "symbol": "LOC" },
"origin": "author",
"profile": "local-ledger"
},
"describe": {
"args": { "label": "Amount" },
"origin": "pack",
"profile": "icrc1"
}
}
}
The layer decides before specificity. An author tag given through a
type: address beats a pack tag given through a method address, even
though the method address names the position more precisely.
Within one layer, also when the layer holds several profiles (two packs, or a
served profile and a tagged .did), the more specific address wins: a method address
beats a type: edge, which beats a whole-declaration tag. In the example profile,
type:Tokens.e8s labels every Tokens position "Amount", and
balance->(0).e8s relabels the one in balance's result "Balance".
Two different values for one tag at the same specificity are EE_CONFLICT, and the
first one given is kept; two equal values are WW_DUPLICATE.
Where an author profile comes from#
The author layer has two sources, and a consumer may read both.
-
Served by the canister. A canister can carry its profile in the public Wasm
custom section
icp:public candid:profile, next tocandid:service. A consumer reads both in one certifiedread_staterequest, so the pair comes from one version of the module, and binds the profile against that interface as above. Serve a profile from a canister -
Written in the
.did. Tags written as doc comments (/// @amount(decimals = 8, symbol = "ICP")) compile into an author profile bound to the same interface. Annotate a .did file
A consumer that reads a served profile and a tagged .did from the same certificate
applies both in the author layer, where two different values for one tag at one
position are EE_CONFLICT, as within any layer.
Next#
How an annotation names one position: the grammar, equivalent spellings and every resolution rule.
Guide Validate and resolve profilesCheck a profile, merge it with packs and other layers, and write the resolved form.
Reference Standard packsThe ICRC-1, ICRC-2, ERC-20 and EIP-2612 packs, and the Permit2 and DAI permit profiles.