Profiles
Intent, effects and confirmation
What a call does, said once: the line a person reads, and which calls need their confirmation.
- at
- icrc1_transfer
- tag
intent
- args
- title: Send tokens
- template: Send {amount} to {to}[ from subaccount {from_subaccount}][ with memo {memo}][ paying fee {fee}]
- 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:
# 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_timeis 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):
# 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` &``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.
| kind | What it names | Needs the person's confirmation |
|---|---|---|
| read | Changes nothing the profile describes | No |
| write | A change of state | No |
| transfer | Moves value | Yes |
| approve | Lets someone else move value, as an ICRC-2 allowance does | Yes |
| admin | A change to who controls the canister or how it runs | Yes |
| destroy | Deletes something | Yes |
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:
{ "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:
- at
- icrc2_approve
- tag
effect
- args
- kind: approve
- irreversible: false
- grant: { "spender": "spender", "amount": "amount", "ends": ["expires_at"] }
| Key | The position it names |
|---|---|
spender or to, exactly one | Who 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. |
amount | An 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, optional | One 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,toandamountare 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'sPermitBatch, has none in v0. - The token is the one the
amounttag names (itstoken, 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
deadlineand DAI'sexpiryset allowances that never end, so neither is inends.
The standard packs carry these grants, and the resolved form keeps each inside its effect:
| Pack | Method or message | grant |
|---|---|---|
icrc1 | icrc1_transfer | { "to": "to", "amount": "amount" } |
icrc2 | icrc2_approve | { "spender": "spender", "amount": "amount", "ends": ["expires_at"] } |
erc20 | transfer | { "to": "to", "amount": "value" } |
erc20 | approve | { "spender": "spender", "amount": "value" } |
erc2612 | Permit and permit | { "spender": "spender", "amount": "value" } |
dai-permit | Permit | { "spender": "spender", "amount": "allowed" } |
permit2 | PermitSingle | { "spender": "spender", "amount": "details.amount", "ends": ["details.expiration", "sigDeadline"] } |
permit2 | PermitTransferFrom | { "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:
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:
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:
type Action = variant {
Transfer : record { to : principal; amount : nat };
AddMember : record { who : principal };
Pause;
};
service : {
propose : (Action) -> (nat);
}{ "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.
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:
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 tooThe 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:
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 presentCase 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#
ICRC-21 messages for any method, from a resolved form and the encoded arguments.
Concept Outcomes and retriesWhat happens after the person confirms: classes for every result, and retries that cannot pay twice.
Guide Confirm terms for a taskCheck what each request grants against terms the person confirmed once.
Concept Security modelWhy an intent is a claim, and what a consumer shows beside it.