The operation lifecycle

One durable record carries an operation from intent to evidence — persisted, non-blocking, and advanced only by reconciliation, which is why there is no Resume button.

import type { OperationRecord } from '@flarekit-dev/core'

Everything @flarekit-dev/core does to a mint, a redeem, a swap, a bridge send, a delegation or a stake happens to one type: OperationRecord. A widget renders it, a hook holds it, an agent reads it, and a durable store keeps it. There is no second representation of "what is happening", so there is no place for two answers to diverge.

The record#

import type { OperationRecord } from '@flarekit-dev/core'

interface OperationRecord<TIntent = unknown, TQuote = unknown, TPlan = unknown> {
  readonly schemaVersion: number
  readonly id: string
  readonly capability: string
  readonly network: number
  readonly idempotencyKey?: string
  readonly intent: TIntent
  readonly quote?: TQuote
  readonly quoteHistory: readonly TQuote[]
  readonly plan?: TPlan
  readonly state: OperationState
  readonly steps: readonly OperationStep[]
  readonly evidence: readonly EvidenceItem[]
  readonly attempts: readonly OperationAttempt[]
  readonly recovery?: readonly RecoveryAction[]
  readonly awaiting?: AwaitingDescriptor
  readonly error?: SerializedError
  readonly createdAt: number
  readonly updatedAt: number
}

Each capability names the three generics and gets its own alias — for the direct mint, DirectMintOperation is OperationRecord<DirectMintIntent, DirectMintQuote, never>. The lifecycle code below is generic over all of them.

Intent#

An operation begins with what the caller wants, before anything has been priced. createOperation records it and nothing else:

import { createOperation } from '@flarekit-dev/core'

const record = createOperation({
  capability: 'fassets.directMint',
  network: 114,
  intent,
  now: Date.now(),
})

The id is generated independently of any transaction hash, because one operation correlates several — an XRPL payment, an FDC request, a Flare transaction. The intent is frozen. A new state is draft.

Quote#

A quote is terms, and terms are approved rather than assumed. When a re-quote lands, applyTransition does not overwrite the old one: it pushes the previous quote onto quoteHistory and sets the new one. What was shown to the person who approved it survives.

Capability code refuses to build an operation on terms that cannot be signed. createDirectMintOperation throws QUOTE_NOT_PROCEEDABLE when quote.canProceed is false, and buildPaymentForQuote throws it again — below the protocol minimum there is no argument list that produces a signable payment at all.

Plan#

plan holds the concrete calls a capability will make, produced by pure builder functions rather than by the record itself: buildSwapPlan, buildDepositPlan, buildBridgePlan, buildDelegationPlan, buildRewardsClaimPlan, buildGaslessPlan, planStake. A plan is unsigned until exact approval, and core never signs it. See Adapters.

Execution#

Execution moves the record along the transition table and stamps the spine. steps is the operation's spine — one row per real stage, each naming the actor that owns it:

import { DIRECT_MINT_STEPS, advanceSteps } from '@flarekit-dev/core'

// ['pay-xrpl', 'xrpl-finality', 'fdc-attest', 'execute-mint', 'credit-fasset']

const steps = advanceSteps(record.steps, { done: 2, current: 'active' }, now)

advanceSteps never regresses a step. Protocol readings arrive duplicated, out of order and backfilled, and a later, poorer reading must not walk the spine backwards past evidence already held.

Evidence#

evidence is the set of identifiers this operation correlates — the XRPL transaction, the ledger, the payment reference, the FDC request, round and proof, the Flare transaction and block, the executor and recipient addresses, the reservation id, the agent vault. mergeEvidence is idempotent by (kind, value), keeps the earliest observedAt, and accepts a link a later sighting supplies without revising when the identifier was first seen.

A refused transition still absorbs the evidence its event carried, so a duplicate, late or backfilled protocol event can never cost an identifier.

Recovery#

Two fields carry a long wait honestly. awaiting is an AwaitingDescriptor — the actor, the reason, when the wait began, an expected range, and availableAt where the protocol states an exact end. It is absent when the wait has no knowable end, which is a real answer rather than a missing one.

recovery is the list of RecoveryActions that are safe right now. An empty list is also a real answer: it means nothing is safe yet, and the surface says so rather than offering a button that could pay twice. The load-bearing field on an action is movesNewValue — false means the action completes the operation from evidence that already exists.

import { availableActions, isAvailable } from '@flarekit-dev/core'

const offerable = availableActions(record.recovery, Date.now())

escalate is the only path a long wait takes, and it is structurally unable to invent a failure: the sole state it can move to is action_required, and only when a safe action exists. Otherwise it updates awaiting and stays put.

Every attempt is appended, never replaced. appendAttempt records what the attempt reused, whether it moved new value, and its outcome, so the original plan and the full history survive a recovery.

Submitting never blocks#

Nothing in this file awaits a chain. createOperation, applyTransition, advanceSteps, escalate and appendAttempt are synchronous pure functions over a record. Submitting writes a record and returns; the operation's progress is then a property of the chain, not of a promise the UI is holding open. Close the tab and the payment still lands.

Why there is no Resume button#

There is no resume() in this package. reconcile is safe to call at any time from any state, and it is the only thing that advances an operation, so opening the app and receiving a protocol event are the same code path.

// The only way an operation moves forward, whichever kit you hold.
const advanced = await kit.reconcile(record)

Reconciliation derives the target state from what the chain currently shows rather than from what happened last, which makes it idempotent: calling it twice with the same reading changes nothing. A Resume button would be a second, worse way to do what opening the app already does. See Storage and reconcile.

What it will not do#

The lifecycle will not report an outcome it has not observed. There is no code path from a timeout to failed, no path from submitted to a success claim, and no branch on which a direct mint offers to pay again — planRecovery returns waits and one action that reuses the existing payment and proof, and that is the whole matrix. It will not persist a secret either: the store refuses one at the boundary rather than trusting the caller.