Profiles

Outcomes and retries

Every result case gets a class, so the caller knows whether to stop, fix the input, retry or reconcile.

jsonprofile/packs/icrc1.profile.json
{
  "at": "icrc1_transfer",
  "tag": "idempotency",
  "args": {
    "mode": "replay_same_payload",
    "stamp": "created_at_time"
  }
},
{
  "at": "icrc1_transfer",
  "tag": "outcome",
  "args": {
    "classes": {
      "Ok": "success",
      "Err.Duplicate": "already_done",
      "Err.TemporarilyUnavailable": "retry_same",
      "Err.TooOld": "retry_new",
      "Err.CreatedInFuture": "retry_new",
      "Err.BadFee": "fix_input",
      "Err.BadBurn": "fix_input",
      "Err.InsufficientFunds": "fail",
      "Err.GenericError": "fail"
    },
    "default": "fail"
  }
}

A ledger answers a transfer with one of nine result cases. Read as a type, they are one variant. Read by a caller that has to decide what happens next, they are very different: Duplicate means the money already moved, TemporarilyUnavailable means try the same thing again, TooOld means the attempt can no longer be repeated as it was. The ICRC-1 pack gives every case an outcome class, and says which argument makes a retry safe.

Six classes#

ClassWhat the caller doesICRC-1 cases
successDone.Ok
already_doneAn identical call already executed. Treat it as success and report the referenced result.Err.Duplicate, which carries duplicate_of, the block of the first transfer
retry_sameTransient. Retry with the same encoded bytes.Err.TemporarilyUnavailable
retry_newThe idempotency stamp expired or is ahead of the canister's clock. Re-stamp, show the person the new intent, and retry only after they confirm it.Err.TooOld, Err.CreatedInFuture
fix_inputThe input needs correcting; the error's fields carry the hint.Err.BadFee (with expected_fee), Err.BadBurn
failDo not retry.Err.InsufficientFunds, Err.GenericError, and anything unclassified

retry_new is the class that needs a person. Re-stamping makes a new intent, and a new intent is a new call that the person must see and confirm. A seventh word, reconcile, appears in consumers but in no pack: it is what a consumer concludes from history, covered under the consumer rules below.

Keys select variant cases#

The keys of classes are relative paths into the result tuple, and each must select a case of a variant (EE_OUTCOME_NOT_CASE): Err.BadFee is the BadFee case of the Err case of result 0. Four rules settle every reply:

  • The most specific key wins. When both Err and Err.BadFee are given, a BadFee reply takes the longer key's class.
  • Unclassified cases fall back to default, reported at info level. A case with no key and no default gets WW_OUTCOME_UNCLASSIFIED as a warning.
  • A case with no class and no default is fail. Consumers must treat it so. That covers cases the profile never saw, including the extra result cases a pack match allows.
  • Packs should set a default (WW_PACK_NO_DEFAULT otherwise), because a pack applies to canisters it was not written against.

The third rule is what makes the pack-matching relaxation of D-019 safe. A local ledger with an extra Frozen error still matches the ICRC-1 pack, and its new case is a fail, never a guess:

text
  info    W_OUTCOME_UNCLASSIFIED     [icrc1] icrc1_transfer  result case "Err.Frozen" has no class (falls back to default)

Results that are not a variant#

Not every method answers with a variant. A method that returns a record, a number or nothing has no case to select, so D-049 allows classes to be left out. default is then required and is the class of every reply. Such a method can only fail by a reject, which the consumer handles as for any update: a rejection by the replica means the call did not execute, and an unknown outcome follows the retry rules below. On a method whose result does have a variant, default alone leaves every case unclassified, as any case without a key.

idempotency: the same bytes, sent again#

A call over a network can time out after it executed. Sending it again is safe only if the canister can tell the second send from a new call. idempotency with mode: "replay_same_payload" declares that it can: a retry that carries exactly the same payload is either executed once or answered with the already_done case. Its stamp names the argument field that makes payloads unique; for ICRC-1 that is created_at_time.

  • The stamp field must carry a timestamp tag (EE_STAMP_UNTAGGED), so the tool layer knows its unit. A stamp field that is not optional gets WW_STAMP_REQUIRED, since the tool layer must then always fill it, and idempotency on a query is EE_IDEMPOTENCY_MODE.
  • The tool layer owns the stamp. The stamp position never appears in an agent's tool schema and cannot be edited in a user interface. The tool layer fills it from the canister's clock, never the host's (why).
  • It fills the stamp once per intent, encodes the arguments once, and reuses the exact encoded bytes for every retry of that intent. Re-encoding is not enough: any difference in the bytes, a fresh stamp above all, is a new call to the ledger.

One intent, one stamp#

The stamp has to follow the person's intent, not the agent's attempts. An agent that is unsure whether a transfer went through may prepare it again; if that minted a new stamp, the ledger would see a second transfer and execute it. So the spec defines an intent and ties the stamp to it.

An intent is identified by the canister, the method and the converted arguments without the stamp: base units, defaults applied, auto fields filled. Inputs that convert to the same value are the same intent ("1.5 ICP" and "1.50"), a relative time counts by its duration rather than the instant it resolves to, and the all-zero subaccount equals an absent one. The tool layer keeps every intent it stamped during the session.

  • An intent is unsettled while it was prepared but not committed, its last attempt was retry_same, it was rejected by the replica, or its outcome is unknown. Preparing the same intent again then returns the existing prepared call, with the same identifier and the same bytes, and says so; it never mints a new stamp.
  • It is settled by success, already_done, fail, fix_input or a reconciliation. Only then may the same arguments be stamped again, as a new intent the person confirms again, with a stamp later than every earlier one of the same identity.

Unknown outcomes and rejections#

An unknown outcome (a timeout, a lost connection, a reply that says the call may have executed) and a rejection are different facts, and the rules keep them apart.

  • Unknown outcome, with the tag: the consumer may send the same bytes again within the canister's deduplication window, where the canister either executes the call once or answers already_done. The tool layer's answer must tell the caller to commit the same prepared call again, and must not suggest preparing a new one.
  • Rejected by the replica: a trap or a refused call did not execute. It is not an unknown outcome, and the same bytes may be sent again. @meantjs/tools reads this narrowly: only reject codes 1, 2 and 3 count as "did not execute", and any other reject is treated as an unknown outcome (Give an agent tools).
  • Unknown outcome, without the tag: nothing can make a second send safe, so a consumer must not retry the update automatically.

Why ICRC-1 deduplication works this way#

The idempotency rules are written around the deduplication an ICRC-1 ledger already does. fact These are ICRC-1's rules, as the spec records them (verified 2026-10-08, ICRC-1 standard):

  • Deduplication applies only when created_at_time is set. A transfer without it is never recognized as a repeat, which is why the stamp is the field the pack names and why the tool layer always fills it.
  • Only within TX_WINDOW plus PERMITTED_DRIFT, which the ledger defines. A stamp older than that is refused as TooOld; one ahead of the ledger's clock by more than the drift as CreatedInFuture. fact For the DFINITY ledger the two add up to 24 hours and 2 minutes (DEDUP_WINDOW_NS in @meantjs/reconcile).
  • Different memos are never duplicates. Changing the memo makes a different transfer, which is one more reason the retry reuses the bytes rather than the person's words.
  • A duplicate returns Duplicate { duplicate_of }, the block of the transfer that did execute. The pack classes it already_done, so the caller reports that block as the result.

Three rules that depend on history#

A class map is static: it says what one reply means. Three rules depend on what happened to the same intent before, so they live in the consumers, and spec §5.3 states them for every consumer.

  1. reconcile overrides retry_new after an unknown outcome

    If an earlier attempt of the intent had an unknown outcome and a later attempt returns a retry_new case such as TooOld, the window has passed and the first attempt may have executed. The consumer treats this as reconcile: it checks the canister's history for the original before any new attempt, in ICRC-3 blocks since the stamp, matched on operation, stamp, amount, accounts, memo and caller. Balances and allowances cannot settle it. If the history cannot settle it, the consumer asks the person, and sends or re-stamps nothing until they answer. Settle an unknown outcome shows the matcher.

  2. CreatedInFuture caps the new stamp

    The error reports the ledger's time, ledger_time. The new stamp must not exceed it: re-stamping from the same clock that ran ahead would fail again.

  3. Retries stop before the window can close

    retry_same retries are limited to a few attempts with backoff, ending before the deduplication window can close; after that, rule 1 applies. @meantjs/tools stops an intent at its third retry_same.

Not on EVM contracts yet

idempotency and outcome on an EVM contract are EE_TAG_TARGET: retries by nonce and classes over reverts need receipts and nonces that a later phase adds. See EVM contracts.

Next#