Skip to content

Policies, Results, and Observability

Preview API: This page describes FlowEngine, FlowExecutionHandle, and the portable execution policy. The similarly named FlowExecutor, FlowHandle, and FlowListener APIs are the legacy facade.

Portable definitions may request execution behavior, but the application remains the authority. FlowExecutionPolicy evaluates those requests during compilation and either accepts them, caps a numeric value with a warning, or rejects the definition with a diagnostic.

A definition can request chaining, retry, confirmation, rollback, and validity settings:

spec:
execution:
mode: SEQUENTIAL
retry:
max_attempts: 4
backoff: exponential
initial_delay: 1s
max_delay: 20s
jitter: 0.2
confirmation:
min_confirmations: 6
check_interval: 3s
timeout: 10m
rollback:
action: RECONCILE_AND_REBUILD
monitoring_horizon: UNTIL_FLOW_TERMINAL
rebuild_scope: INVALIDATED_CLOSURE
max_recovery_cycles: 3
reinclusion_window: 2m
minimum_consistent_absence_observations: 2
validity:
window: 30m
resubmit_safety_margin: 60

validity is carried, fingerprinted and policy-capped, but in this preview the engine doesn’t use it to set a transaction’s validity interval or a recovery margin. Set transaction.context.valid_from_slot and valid_to_slot, and pass resubmitSafetyMargin on FlowRecoveryRequest.

The engine does not blindly trust these values. Configure a policy at the service boundary:

FlowExecutionPolicy policy = FlowExecutionPolicy.builder()
.maxSteps(25)
.allowedNetworks(Set.of("preview"))
.allowedModes(Set.of(ChainingMode.SEQUENTIAL))
.requireValidityInterval(true)
.maxValidityWindowSlots(3_600)
.maxRetryAttempts(5)
.maxConfirmationTimeout(Duration.ofMinutes(30))
.maxRollbackRecoveryCycles(3)
.allowedRollbackActions(Set.of(
RollbackAction.FAIL,
RollbackAction.RECONCILE_AND_REBUILD,
RollbackAction.PAUSE_FOR_RECOVERY))
.allowedRollbackHorizons(Set.of(
RollbackMonitoringHorizon.UNTIL_FLOW_TERMINAL))
.strictSettings(true)
.build();

With strictSettings(false) (the default), cappable execution requests—retry attempts, confirmation timeout, rollback recovery cycles, and the duration-based validity preference—are capped and reported as warnings. With strictSettings(true), those differences are errors. Structural limits such as step count, explicit absolute-slot validity limits, disallowed modes, networks, resource prefixes, rollback actions, and monitoring horizons are always errors.

The compiler derives canonical spending identities from catalog descriptors, and a request may add more with spendingResource(...). FlowExecutionPolicy decides what happens when executions overlap:

SpendingContentionPolicyBehavior
SERIALIZEFairly wait up to maxQueueWait; defaults to a two-minute bound
REJECTFail immediately with a resource-busy result
ALLOWDo not coordinate overlapping spending identities
FlowExecutionPolicy policy = FlowExecutionPolicy.builder()
.spendingContention(SpendingContentionPolicy.SERIALIZE)
.maxQueueWait(Duration.ofSeconds(30))
.allowConcurrentSpendingOptOut(false)
.build();

The in-process coordinator acquires multiple identities in sorted order. A durable engine also holds fenced resource leases for cooperating workers that use the same store. Neither mechanism reserves an on-chain UTxO against a partitioned stale worker.

FlowExecutionRequest.allowConcurrentSpending(true) bypasses coordination only when allowConcurrentSpendingOptOut(true) permits it. Treat both ALLOW and the per-request opt-out as expert settings; concurrent builders may select and submit transactions spending the same UTxO.

RetryPolicy controls the maximum attempt count, fixed/linear/exponential backoff, delay bounds, and jitter. It does not make every failure retryable.

RetryPolicy retry = RetryPolicy.builder()
.maxAttempts(4)
.backoffStrategy(BackoffStrategy.EXPONENTIAL)
.initialDelay(Duration.ofSeconds(1))
.maxDelay(Duration.ofSeconds(20))
.retryOnTimeout(true)
.retryOnNetworkError(true)
.jitterFactor(0.2)
.build();

The safe action depends on both execution phase and SubmissionOutcome:

Known factSafe default
Typed transient network/timeout failure before signing or submissionRebuild the step, within the attempt bound
A transaction hash is known and retry is allowedRetry or reconcile the same transaction identity
Submission outcome is UNKNOWNReconcile the known hash before any retry or rebuild
Submission outcome is ACCEPTEDObserve/reconcile; acceptance is not confirmation
Submission outcome is REJECTEDFail unless a higher-level policy explicitly starts a new operation
Validation, policy, build, signing, or unclassified failureFail conservatively

Unknown submission is a safety state: a timeout after sending bytes does not prove that the network rejected them. TxFlow never authorizes a fresh transaction merely because the submission call did not return a usable answer.

The compatibility method RetryPolicy.isRetryable(Throwable) has no phase information. It admits known IOException and timeout causes according to policy, but rejects an untyped RuntimeException conservatively. Backend adapters should preserve typed transient causes.

SubmissionOutcome.ACCEPTED means only that the submission adapter reported success. Confirmation requires observing the transaction on-chain and, when configured, reaching the requested depth.

ConfirmationConfig confirmation = ConfirmationConfig.builder()
.minConfirmations(6)
.checkInterval(Duration.ofSeconds(3))
.timeout(Duration.ofMinutes(10))
.requiredAuthoritativeAbsences(2)
.build();

The production-oriented defaults are depth 10, five-second polling, a 30-minute timeout, and two consecutive authoritative absences before rollback. testnet(), devnet(), and quick() are convenience presets; verify them against the risk and block cadence of your deployment. On a Java-built TxFlow run through FlowEngine, ConfirmationConfig.devnet() and quick() are rejected because they carry legacy backend-wait fields. Use preset: devnet or preset: quick in the document.

If a portable definition has neither confirmation nor rollback, the engine uses simple on-chain polling with no depth tracking. If it has rollback but no confirmation, it uses ConfirmationConfig.defaults(): depth 10, five-second polling and a 30-minute timeout. Set confirmation explicitly.

An observation adapter controls whether absence is authoritative. A transaction that disappears from an indexer is only suspected rolled back when the backend cannot prove absence. Ambiguous observations remain pending and eventually become RECOVERY_REQUIRED; they are never treated as permission to rebuild.

DefaultChainDataSupplier doesn’t declare authoritative absence, and neither FlowEngine.builder(BackendService) nor FlowRuntime adds it. With that wiring no rollback is ever established and RollbackAction isn’t applied: the transaction times out and the execution ends RECOVERY_REQUIRED. To opt in, use the four-supplier builder:

ChainDataSupplier chain = ObservationCapabilities.withAuthoritativeAbsence(
new DefaultChainDataSupplier(backendService));

Declare this only if the index is consistent with its reported tip; otherwise a slow index looks like a rollback.

Rollback is distinct from an ordinary retry and begins only after authoritative evidence reaches the configured consecutive-absence threshold.

RollbackActionBehavior
FAILEnd as FAILED or PARTIALLY_COMPLETED, with error().category() == ROLLBACK
WAIT_FOR_REINCLUSIONKeep the same transaction hash and wait within reinclusionWindow
RECONCILE_AND_REBUILDReconcile first, then rebuild only work proven invalid
PAUSE_FOR_RECOVERYStop automated progress and return RECOVERY_REQUIRED

RollbackMonitoringHorizon.UNTIL_STEP_CONFIRMED stops monitoring after the step’s target depth. UNTIL_FLOW_TERMINAL continues checking included attempts until the whole flow is terminal.

RollbackRebuildScope.AFFECTED_STEP starts with the rolled-back step, but dependent work may still need invalidation. INVALIDATED_CLOSURE includes the producer and every transaction that consumes its invalidated outputs. TxFlow will widen a rebuild when a narrower scope cannot be safe.

The policy’s maxRecoveryCycles bounds automation. Exhaustion or an inconclusive reconciliation does not silently become success; it fails or requires explicit recovery according to the observed state and policy.

FlowExecutionHandle.await() returns a FlowExecutionResult for every normal terminal outcome. Use isSuccessful() only as a convenience for the exact COMPLETED state; otherwise branch on state().

FlowExecutionResult result = handle.await();
switch (result.state()) {
case COMPLETED -> recordSuccess(result.executionId(), result.steps());
case RECOVERY_REQUIRED -> scheduleReconciliation(
result.executionId(), result.attempts(), result.error());
case CANCELLED -> recordCancellation(result.executionId());
default -> recordFailure(result.executionId(), result.state(), result.error());
}

The result contains:

AccessorMeaning
executionId()Request correlation and durable lookup identity
definitionFingerprint()SHA-256 identity of the compiled portable plan
state()Terminal or recovery-required execution state
steps()Immutable FlowStepResult values observed by this run
attempts()Durable FlowAttemptSnapshot values; empty without a store
error()Stable FlowError, or null after successful completion
startedAt() / completedAt()Execution timestamps

FlowStepResult retains the step ID, status, known transaction hash, produced UTxOs, spent inputs, result-observation time, and the original step-level throwable. In pipelined and batch failures, a submitted but not yet confirmed step is IN_PROGRESS, not successful; a transaction that was only built is omitted. Durable FlowAttemptSnapshot adds the attempt number, exact signed payload identity, validity bounds, input identities, inclusion history, and stable error code needed for recovery.

StateMeaning
COMPLETEDEvery required step completed successfully
PARTIALLY_COMPLETEDAt least one step reached the effective confirmation policy before a later conclusive failure
FAILEDExecution ended with a failure. In PIPELINED and BATCH, earlier steps may already be submitted and can still land; check steps() for IN_PROGRESS entries with a transaction hash
ROLLED_BACKReserved; a FlowEngine run doesn’t currently produce it. Detect rollback through FlowErrorCategory.ROLLBACK
CANCELLEDCooperative cancellation reached a safe boundary
RECOVERY_REQUIREDState is uncertain; reconcile before retry or rebuild

CREATED, COMPILING, QUEUED, and RUNNING are durable lifecycle states that may appear in stored snapshots. They are not successful terminal results.

FlowError.code() is the stable, specific value for program logic. category() is a coarser dimension for metrics and routing: validation, policy, resource, build, sign, submission, network, backend unavailable, confirmation, rollback, persistence, recovery, cancellation, or internal.

FlowError error = result.error();
if (error != null) {
metrics.increment("txflow.failure", Map.of(
"code", error.code(),
"category", error.category().name()));
}

retryable() is advisory context, not permission to submit a replacement transaction. In particular, reconcile RECOVERY_REQUIRED and unknown-submission outcomes by transaction hash before creating any new attempt.

FlowExecutionHandle exposes immutable snapshots of per-execution events. Sequence numbers start at one, increase within one execution, and are not globally ordered across executions.

long cursor = 0;
for (FlowEvent event : handle.getEventsAfter(cursor)) {
publishEvent(event.executionId(), event.sequence(), event.type(), event.details());
cursor = event.sequence();
}

Persist the last successfully consumed sequence as an exclusive cursor. Calling getEventsAfter(cursor) again returns only newer handle events. With a durable store, use FlowExecutionStore.readEvents(executionId, cursor, limit) for restart-safe consumption. If it reports EVENTS_COMPACTED, reload the latest execution snapshot and establish a new baseline.

Important event groups include:

  • execution: EXECUTION_CREATED, COMPILATION_COMPLETED, EXECUTION_QUEUED, EXECUTION_STARTED, and terminal execution events;
  • transaction attempt: TRANSACTION_PREPARED, TRANSACTION_SUBMITTING, TRANSACTION_SUBMITTED, inclusion/depth/confirmation, and rollback events;
  • recovery: RECOVERY_STARTED, RECOVERY_COMPLETED, and RECOVERY_REQUIRED.

Consumers should tolerate event types added in future releases. Event details are useful diagnostic attributes, but the typed event and terminal result remain the contract.

FlowListener is configured with FlowExecutor.withListener(...); it is not a method on FlowEngine.Builder. FlowEngine internally translates execution callbacks into ordered FlowEvent values. Portable-runtime applications should consume handle or durable-store events instead of attempting to attach a FlowListener to the engine.

Likewise, FlowRegistry tracks legacy FlowHandle instances. Track portable executions by their executionId, handle, event stream, and—when configured—FlowExecutionStore.

Cancellation is a signal checked at safe execution, waiting, and retry boundaries. It does not interrupt the executor thread, close application resources, roll back the Cardano chain, or undo a transaction that has already been submitted.

CancellationResult cancellation = handle.requestCancel("application shutdown");
if (cancellation == CancellationResult.REQUESTED) {
FlowExecutionResult terminal = handle.await();
// The request was acknowledged earlier; terminal confirms where execution stopped.
}

Possible acknowledgements are REQUESTED, ALREADY_REQUESTED, and ALREADY_TERMINAL. The boolean cancel() method is shorthand for a reasonless request and returns true only for the first accepted signal. Always await or otherwise observe the terminal result after requesting cancellation.

Syntax and expected authoring problems are values, not control-flow exceptions:

FlowParseResult parsed = TxFlowCodec.standard()
.parse(source, FlowParseOptions.serverDefaults());
report(parsed.getDiagnostics());
if (parsed.hasErrors()) {
return;
}
FlowCompilationResult compiled = engine.preflight(
FlowExecutionRequest.builder(parsed.requireFlow())
.bindings(bindings)
.build());
report(compiled.getDiagnostics());
if (compiled.hasErrors()) {
return;
}

Each FlowDiagnostic has a stable code, severity, message, JSONPath-like document path, optional source line/column, and optional step ID. Log or return the code and location; avoid exposing secret binding values in diagnostic text.

Invalid Java API arguments can still throw immediately—for example, a blank execution ID, only one half of an idempotency pair, an invalid duration, or an unsupported binding value. Once start returns a handle, expected execution rejection is represented in its terminal result.

When a durable result is RECOVERY_REQUIRED, recover the persisted attempt rather than rebuilding blindly. Obtain a fresh authoritative Cardano slot—not a block height or wall-clock value—and fail closed if it is unavailable. The engine trusts currentSlot; it does not establish freshness itself. Supply a safety margin before the recorded validity upper bound:

FlowRecoveryResult recovery = engine.recover(FlowRecoveryRequest.builder()
.executionId(result.executionId())
.currentSlot(currentSlot)
.resubmitSafetyMargin(60)
.build());

stepId and attemptNumber select a particular persisted attempt. They are optional in the Java API, but operator and cross-process recovery should require both unless the target is provably unambiguous; otherwise the engine chooses the highest attempt number among all matching steps. Recovery acquires durable execution and resource leases, observes the known hash, and may resubmit only verified identical signed CBOR. Its expiry check applies only when validToSlot is present, so controlled deployments should require validity intervals through server policy.

The built-in FlowEngine persistence path stores signed CBOR inline. If a custom or pre-seeded store record uses SignedPayload.ExternalCbor, provide an application-owned SignedPayloadResolver on the recovery request:

FlowRecoveryResult recovery = engine.recover(FlowRecoveryRequest.builder()
.executionId(executionId)
.currentSlot(currentSlot)
.resubmitSafetyMargin(60)
.payloadResolver(reference -> signedPayloadStore.load(reference))
.build());

TxFlow verifies both the recorded SHA-256 digest and Cardano transaction hash after loading the bytes. A resolver retrieves bytes; it is not a trust bypass.

FlowRecoveryResult reports the reconciled AttemptState, transaction hash, whether this call resubmitted the identical payload, an optional structured error, and newly observed inclusion metadata. Treat AttemptState.RECOVERY_REQUIRED as still unresolved.

Recovery reconciles one persisted transaction attempt. It does not automatically resume every remaining step of a stopped multi-step flow; the application must interpret the recovery result and apply its workflow policy before taking another action.

See also the known issues in the latest preview.

  • Store leases and fencing prevent stale workers from mutating TxFlow’s durable state. They cannot stop a partitioned stale process from submitting already-signed bytes to Cardano. Active-active deployments needing that guarantee require external spending serialization or a UTxO reservation coordinator.
  • Cooperative cancellation cannot reverse a submitted or confirmed transaction.
  • Indexer absence is not proof of rollback unless the backend adapter declares authoritative absence. Uncertainty remains RECOVERY_REQUIRED.
  • The in-memory execution store, in-memory idempotency claims, handle event list, and legacy registry do not survive a process restart.
  • Explicit recovery reconciles a persisted attempt; it is not a general automatic continuation service for every remaining step after a process restart.
  • Resource catalogs contain public metadata. Signer keys, scripts, secure bindings, payload storage, access control, and credential rotation remain application responsibilities.
  • The engine owns no threads. Keep its caller-owned task and maintenance executors alive, bounded, observable, and shut down by the application.

Start with Executing Portable Flows for end-to-end wiring.