Profiles

Intent, effects and confirmation

What a call does, said once: the line a person reads, and which calls need their confirmation.

Profile annotation
at
icrc1_transfer
tag
intent
args
  • title: Send tokens
  • template: Send {amount} to {to}[ from subaccount {from_subaccount}][ with memo {memo}][ paying fee {fee}]
Profile annotation
at
icrc1_transfer
tag
effect
args
  • kind: transfer
  • irreversible: true
  • grant: { "to": "to", "amount": "amount" }

Two method tags from the ICRC-1 pack. The first says what an icrc1_transfer call does, in a line a person can read; the second says what kind of change it makes, which decides whether the person must confirm it. From the arguments of one call, the consent generator writes the message below, and a signer shows it with the effect:

Nothing in that message was written for this call. The title and the sentence come from the pack, the amount from the amount tag and the ledger's decimals, the labels from describe, and the badges from the effect tag, whose grant names who is paid and how much, for a wallet that checks requests against terms the person confirmed. The same intent is the title of an agent tool's preview and of an MCP Apps card, so a person reads the same words wherever the call is shown.

intent: the line a person reads#

intent has a title of at most 60 characters and a template. In an ICRC-21 fields message, the intent line is the title, a colon and a space, and the rendered template; in the generic (Markdown) message, the title is the heading and the template the paragraph under it. This is the generic message for the call above, exactly as @meantjs/consent writes it:

textprofile/packages/consent/test/golden/transfer-tka-basic.json
# Send tokens

Send `0.29 TKA` to `k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae`

- **Amount:** `0.29 TKA`
- **Recipient:** `k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae`

A method with no intent still gets a message: its title is the method's describe.label, or Call <method>, followed by one row per leaf of the decoded arguments. An intent is what turns that list of values into a sentence.

Templates: placeholders and optional segments#

The template rules are those of spec §6. A placeholder {relpath} names a position of the call's arguments by a relative path, and is rendered with the formatter of the tag at that position: amount as 1.5 ICP, account in its textual encoding, timestamp as a date. A position with no formatting tag renders as Candid text.

  • Optional segments. [ … ] is dropped when any placeholder inside it is absent (null). The pack's template has three, which is why the message above says nothing about subaccounts, memos or fees. With every optional present, the same template gives: Send 1.5 TKA to ig6pt-…-ig6-cabqh3q.7 from subaccount #1 with memo lunch paying fee 0.0001 TKA. Segments do not nest in v0.
  • Optional placeholders outside a segment get WW_TEMPLATE_OPTIONAL, because an absent value would leave a hole in the sentence; the consent generator writes (none) there.
  • Escapes. {{, }}, [[ and ]] write a literal brace or bracket.
  • Not every argument appears. created_at_time is in no placeholder: it is the idempotency stamp, filled by the tool layer, and the message for a stamped call is the same as for an unstamped one.

A placeholder that names no position is EE_TEMPLATE_PATH; a template that does not parse is EE_TEMPLATE. On an EVM contract, where an ABI value is never absent, a template has no [ ] segments (EVM contracts).

Values are escaped for where they land#

A template is the author's text; the values in it are the caller's. A memo is whatever bytes the caller chose, and in a Markdown consent message a memo of [Approve now](https://evil.example/drain) must not become a link. Rendered values are escaped for the target format. The generator puts every value from the call into a Markdown code span, with a fence longer than any backtick run inside it, so the memo below is shown as the characters it is (the spender's account is shortened here):

textprofile/packages/consent/test/golden/approve-tka-hostile-memo.json
# Approve spending

Set `ig6pt-…-ig6-7fqknwy.1`'s allowance to `1 TKA`, replacing any existing allowance with memo ``[Approve now](https://evil.example/drain) **URGENT** <b>x</b> `y` &amp;``

Templates make no claims about state#

A template describes what the call does with its arguments, and nothing that depends on the canister's state. "Send 1.5 ICP to Alice" is allowed; "your balance after this is 3 ICP" is not. ICRC-21 requires a consent message to describe every possible outcome, because time passes between fetching the message and submitting the call, and the balance can change in between. The consent generator has no network access and never states anything about ledger state; see Generate consent messages.

effect: what kind of change a call makes#

effect has a kind and an irreversible flag, plus an optional replicated flag for one special case below and, on a transfer or an approve, an optional grant.

kindWhat it namesNeeds the person's confirmation
readChanges nothing the profile describesNo
writeA change of stateNo
transferMoves valueYes
approveLets someone else move value, as an ICRC-2 allowance doesYes
adminA change to who controls the canister or how it runsYes
destroyDeletes somethingYes

irreversible is separate from the kind: an ICRC-1 transfer is a transfer that cannot be undone, and an ICRC-2 approval is an approve that a later approval replaces (irreversible: false).

Defaults, and an effect that fits the method#

When a method has no effect tag, the default is taken from its Candid mode: a query is a read, and an update is a write with irreversible: true. The default for an update is the conservative one, since nothing says the call can be undone.

An effect must fit the method it sits on. A query or composite query declared as anything but read is EE_EFFECT_MODE: a query cannot change state, so the profile is wrong. A read on an update or a oneway method is WW_EFFECT_MODE, since the method could change state and the profile says it does not; the resolver's message points at the form below.

A read-only update#

Some methods change nothing and are still called as updates, so that their answer is certified by the subnet. fact The reply to an update call comes with a certificate of the subnet's state, while a query's reply comes from a single replica and is signed only by that node (IC interface specification, HTTPS interface). D-038 gives these methods their own form:

json
{ "kind": "read", "irreversible": false, "replicated": true }

It is valid only on an update method, with exactly that kind and flag. Consumers must send such a method as an update call, never through the query endpoint, or the certification it declares is lost; @meantjs/tools gives it a read tool, sent once as an update, with no preview. For everything else it is a read, and for retries it is still an update. replicated: true on a query, a composite query or a oneway method, or with another kind or irreversible: true, is EE_EFFECT_MODE. Like any effect, it is the author's claim: the resolver cannot check that the method changes nothing.

grant: what a transfer or approve lets someone take#

An effect of kind transfer or approve may carry grant: who may take tokens because of the call or the signature, which token, how many and until when, so that a wallet can compare a request with terms the person confirmed. Its values are relative paths into the method's arguments, written as a template's placeholders are (spec §5.3, D-056). From the ICRC-2 pack:

Profile annotation
at
icrc2_approve
tag
effect
args
  • kind: approve
  • irreversible: false
  • grant: { "spender": "spender", "amount": "amount", "ends": ["expires_at"] }
KeyThe position it names
spender or to, exactly oneWho may take the tokens. spender for an allowance, or for a signature someone else submits; to for a transfer that pays them directly. An approve's grant names a spender. The position is an EVM address or carries an account tag.
amountAn unsigned position with an amount tag, where a value at or above the tag's threshold means unlimited; or a bool, where true is unlimited and false grants nothing (DAI's permit).
ends, optionalOne to eight positions with a timestamp tag. The grant ends at the latest of them; one at or above its tag's threshold, or an optional one left out, means it never ends. With no ends, an approve never ends and a transfer ends when it is made.
  • spender, to and amount are in every call: none of them is optional, and no position of a grant is inside a case of a variant.
  • No position steps into []. A grant covers one token, so a request that grants several, such as Permit2's PermitBatch, has none in v0.
  • The token is the one the amount tag names (its token, on EVM), else the contract or canister the call is for.
  • A deadline that bounds only when a signature may be used is not an end of what it grants. EIP-2612's deadline and DAI's expiry set allowances that never end, so neither is in ends.

The standard packs carry these grants, and the resolved form keeps each inside its effect:

PackMethod or messagegrant
icrc1icrc1_transfer{ "to": "to", "amount": "amount" }
icrc2icrc2_approve{ "spender": "spender", "amount": "amount", "ends": ["expires_at"] }
erc20transfer{ "to": "to", "amount": "value" }
erc20approve{ "spender": "spender", "amount": "value" }
erc2612Permit and permit{ "spender": "spender", "amount": "value" }
dai-permitPermit{ "spender": "spender", "amount": "allowed" }
permit2PermitSingle{ "spender": "spender", "amount": "details.amount", "ends": ["details.expiration", "sigDeadline"] }
permit2PermitTransferFrom{ "spender": "spender", "amount": "permitted.amount", "ends": ["deadline"] }

ERC-20 transferFrom and ICRC-2 icrc2_transfer_from, which spend an allowance someone else gave, and Permit2's two batch messages have none. A grant on another kind, on a case effect, or whose positions break these rules is EE_GRANT. The example canister's send of Write a profile, given a grant whose to is a bare principal:

text
candid-profile: tokens.did  candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c  [2 methods]
  applied     tokens-example   author       candid-profile:v0:sha256:040a934bbd2916cb523c0dab7a11e2d1370890a0925521307cd5ee49dd849204
  1 error, 0 warning, 0 info
  error   E_GRANT                    [tokens-example] send  grant to "to" is principal; it must be an address or carry an account tag

A pack applied to a service where its grant does not hold keeps its effect and loses the grant, with II_PACK_ANNOTATION_SKIPPED. A ledger whose ApproveArgs has no expires_at still matches the ICRC-2 pack, and candid-profile resolve --verbose says:

text
  info    I_PACK_ANNOTATION_SKIPPED  [icrc2] icrc2_approve  effect grant dropped, the effect kept: E_GRANT: grant ends "expires_at": no field "expires_at" in record

Its icrc2_approve is still an approve the person confirms; with no grant, no terms can cover it. A grant is the author's claim, like any effect: the resolver checks the positions it names, not what the method does with them.

Intents and effects on a variant case#

A governance canister often has one method, propose, whose argument is a variant of very different actions. One intent and one effect for the whole method would either say too little about a transfer or too much about a pause. D-053 lets intent and effect sit on a case:

candiddao.did
type Action = variant {
  Transfer : record { to : principal; amount : nat };
  AddMember : record { who : principal };
  Pause;
};
service : {
  propose : (Action) -> (nat);
}
jsondao.profile.json
{ "at": "propose", "tag": "intent", "args": { "title": "Propose an action", "template": "Propose an action" } },
{ "at": "propose", "tag": "effect", "args": { "kind": "transfer", "irreversible": true } },
{ "at": "propose(0).Transfer", "tag": "intent", "args": { "title": "Propose a transfer", "template": "Send {Transfer.amount} to {Transfer.to}" } },
{ "at": "propose(0).Transfer", "tag": "effect", "args": { "kind": "transfer", "irreversible": true } },
{ "at": "propose(0).AddMember", "tag": "intent", "args": { "title": "Add a member", "template": "Add {AddMember.who} as a member" } },
{ "at": "propose(0).Pause", "tag": "effect", "args": { "kind": "admin", "irreversible": false } }

The cases a call takes are the tagged cases on the path its argument value takes, and the deepest of them is the call's case for that tag. A call whose argument is Transfer reads "Propose a transfer: Send … to …"; one whose argument is Pause has the admin effect. A call's effect is its case's, else the method's, else the default. The other method tags (idempotency, outcome, list, names) stay on the method.

Where a case tag may sit#

A case address is an argument address whose last step selects a case of a variant. It may not step into a vector element, since one call holds many elements, and it may not be a result address or a type: address, which can reach several methods. Any of those is EE_TAG_TARGET. For each of the two tags, the tagged cases of one method must not overlap: two case addresses overlap when one value can take both, which happens when they sit in different arguments or their paths part at a record field. Cases of one variant never overlap, since a value takes one case.

text
  error   E_CASE_OVERLAP             [local-dao] manage(0).other.P  intent on "manage(0).command.Disburse" and on "manage(0).other.P": one call can take both cases (their paths part at a record field)

The method's effect bounds its cases'#

A consumer that does not read case tags uses the method's. So when any case effect is transfer, approve, admin or destroy, the method's effect must be one of those four, and when any case is irreversible, the method must be too. Without the method's effect line above, the default write would let a consumer that reads only method tags send a transfer unconfirmed:

text
  error   E_CASE_EFFECT              [local-dao] propose(0).Transfer  case effect transfer needs a person's confirmation, but the method's effect is write, irreversible (the default); declare the method's effect as transfer, approve, admin or destroy, so that a consumer that reads only method tags confirms it too

The result is that reading case effects can only lift a confirmation from a call whose case is weaker, never add a missing one. That lifting is the author's claim, like any effect.

Case templates name values from the method#

A case intent's placeholders are relative paths from the method's argument tuple, like a method's, so propose(0).Transfer writes {Transfer.amount}, not {amount}, and a case inside a record can name the record's other fields beside its own. A placeholder that selects another case of a variant the case address passes through can never be present:

text
  error   E_TEMPLATE_PATH            [local-dao] propose(0).Transfer  {AddMember.who}: selects case "AddMember" of a variant where this intent's case takes "Transfer", so it is never present

Case tags have no doc-comment form in v0: write them in a profile.

Which calls wait for the person#

A consumer must get the person's explicit confirmation before it executes a call whose effect is transfer, approve, admin or destroy, unless a separately configured policy, such as a spending budget, pre-authorizes it. Policies are outside v0 except one, confirmed terms, so every other such call waits. open Spending policies and budgets are OQ-8 on Open questions.

The person confirms; the model never does. An agent prepares a call and shows its preview, and the send waits for a person's answer. @meantjs/tools goes further than the rule: it awaits the person's confirmation before every send of a prepared call, re-sends included, and has no auto-approve default (Give an agent tools). Because a profile can call a drain "claim reward", a consumer should also show the decoded raw arguments beside the intent for these four kinds; the security model says why.

Confirmed terms#

Terms are what the person allows one task: the token, who may take it, the most it may be (or unlimited) and the latest end (or never). Terms the person confirmed for one task, before the agent read the request, pre-authorize that task's request: one whose grant fits them may be signed or sent without a second confirmation. One that does not fit is refused with its reasons, and the agent asks for new terms; a request without a grant needs its confirmation. The terms are shown exactly and confirmed on the wallet's own screen, never through a tool the agent can call (security model). The steps, with @meantjs/consent/terms: Confirm terms for a task.

Next#