Profiles

Profiles, layers and binding

What a profile is, how it binds to an interface without changing its identity, and how layers merge.

jsonprofile/examples/tokens.profile.json
{
  "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:

ConsumerWhat 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 messagesAn intent line and typed display fields, generated for any method. Generate consent messages
MCP Apps cardsThe same intent and fields, rendered as an approval or inspection card
Web UIInput 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, by binds.interface_id (and optionally binds.canister_id). Its origin is author, third_party or inferred.
  • pack: any service that matches a Candid fragment, binds.requires. Its origin is pack.
  • 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:

bash
node scripts/validate.mjs examples/tokens.profile.json --did examples/tokens.did
text
PASS 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 interfaceStatusWhat happens
Has the same interface_idappliedThe profile is valid.
Differs, and every address still resolves and every tag still appliesreboundWW_REBOUND. The profile is applied.
Differs, and anything failsstaleEE_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:

text
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:

text
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:

text
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.

EVM packs match by selector

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:

LayerWhere it comes fromTrust
authorServed by the canister as the public custom section icp:public candid:profile, or shipped by its authorThe author's claim (security model)
third_partyA profile the consumer chose to trustAs configured; attestations are planned for v1
packThe standard packs, matched by subtypingReviewed in the repository
inferredProposed by a model; active only after a person approves it, and until then shown as a suggestionLowest

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:

bash
candid-profile resolve ledger.did --profile local-ledger.profile.json --packs > layered.json
text
candid-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:

jsonlayered.json
{
  "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 to candid:service. A consumer reads both in one certified read_state request, 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#