Browse docs

Start here

examplesGetting started with Flowdocumentation

Design

FoundationsLanguage architecturePhilosophy

Language specification

Program checkingConcurrencyDataEffectsResults, Tool problems, and faultsGrammarHistoryModules and importsLanguage specificationEvaluationStandard libraryToolsTypes

Runtime

Runtime architectureThe host boundaryDiagnosticsRunning a programThe history format

Guides

Writing programs that reach checkpointsImplementing Tools with a toolkitLoops that never returnRecursive delegationSharing types between Tool modulesHarnesses over tool registries

Results, Tool problems, and faults

This specification owns how failure appears in a Flow program: Result<T, E> as an ordinary value, ? and x ? f, the four cases of ToolProblem<E>, faults and the four places they come from, and how a run ends as the language sees it.

It does not own the syntax of ?, match or guard (grammar.md), enum declarations and constructor resolution (types.md), pattern meaning and return (semantics.md), the discard rules and exhaustiveness (checking.md), @tool declarations and reply decoding (tools.md), task faults and stopping (concurrency.md), the facts a failure leaves in history and replay verdicts (history.md), the result and option helpers (stdlib.md), or run states, exit codes and limits (execution.md).

Examples are illustrative. A flow block is a fragment of source in the decided syntax; the Tool modules and types it mentions are assumed.

Two kinds of failure

Flow separates failures a program handles from failures that end it.

A failure the program handles is a value. A function names it in its result type, usually as the Err side of a Result or as a constructor of its own outcome enum. A Tool call names it through ToolProblem<E>. The program takes it apart with match, guard let and ?, like any other value.

A fault is not a value. It ends the run. No expression observes, catches or recovers from one, and there is no exception, unwinding or catch.

Nothing converts one kind into the other. A program can't raise a fault, and a fault never becomes an Err.

Serves foundations: honest uncertainty.

Result

Result<T, E> is an ordinary std enum with two constructors, Ok(T) and Err(E). It is exported by the prelude (modules.md), and std marks it @lang(result) so that ? and Tool calls can find it (stdlib.md). E is any type; it is usually an enum the program declares.

An Err is a value and nothing more. It doesn't leave the function that holds it, doesn't stop a list.map over many calls, and doesn't propagate unless the program writes ?. There is no attempt, no automatic propagation, no universal error type and no implicit conversion between error types.

type ReadView { Read(Text), Missing, Failed(Text) }

flow describe(reply: Result<Text, ToolProblem<files.ReadError>>) -> ReadView = match reply {
    Ok(body) => Read(body),
    Err(Failed(NotFound)) => Missing,
    Err(problem) => ReadView.Failed("${problem}"),
}
Open in playground →

Reaching the success value takes a match that covers every case, or one of the forms below, so using a result means facing its error. Dropping a result unread takes let _ =. checking.md owns both rules; neither knows anything about Result.

Option<T> is the type for an absent value, not for a failure. It has no ?; a program unwraps it with match or guard let:

guard let Some(owner) = dict.get(owners, plan.owner) else { return UnknownOwner(plan.owner) }
Open in playground →

std's result and option modules offer a small set of helpers, such as map, map_err, and_then, unwrap_or and ok. None of them faults, and there is no unwrap (stdlib.md).

? and x ? f

Postfix ? returns early from a Result, visibly at each place it is written.

  • x?: when x is Ok(v), the expression is v. When x is Err(e), the enclosing function returns Err(e) unchanged. The error type of x must be exactly the error type of the function's result; there is no subtyping and no conversion.
  • x ? f: when x is Ok(v), the expression is v and f is not called. When x is Err(e), the enclosing function returns Err(f(e)). f is a name or an anonymous function; a constructor is a function, so x ? Planning wraps the error in a constructor.

The enclosing function is the nearest one, anonymous functions included, the same target return has (semantics.md). That function's result type must be Result<_, E>. ? anywhere else is an error, including in a function that returns its own outcome enum: such a function calls a helper that returns Result and matches on it once.

? works only on the type std marks @lang(result). Every conversion between error types is a function written at the call; nothing is converted by a trait or by type.

use "github.com/acme/tools@v1/planner"
use "github.com/acme/tools@v1/files"

type StepError {
    Planning(ToolProblem<planner.PlanError>),
    Reading(ToolProblem<files.ReadError>),
}

type Outcome { Done(Text), GaveUp(StepError) }

flow step(goal: Text) -> Result<Text, StepError> !tool = {
    let plan = planner.next(goal) ? Planning
    let body = files.read(plan.path) ? Reading
    Ok(body)
}

pub flow main(goal: Text) -> Outcome !tool = match step(goal) {
    Ok(body) => Done(body),
    Err(problem) => GaveUp(problem),
}
Open in playground →

grammar.md owns the precedence of ? and the forms f may take.

Tool problems

A call to a Tool declared as returning Result<T, E> has the type Result<T, ToolProblem<E>> (tools.md). ToolProblem<E> is an ordinary std enum, marked @lang(tool_problem) and exported by the prelude:

pub type ToolProblem<E> {
    Failed(E),          // the Tool reported its declared error
    BadReply(Text),     // a reply arrived but didn't match the declared type
    NotRun(Text),       // the call definitely didn't happen
    Unknown(Text),      // the call may or may not have happened
}
Open in playground →

A Tool call's outcomes mean exactly this:

Case Claim Who makes it
Ok(t) the Tool replied with a value of the declared success type the Tool, checked by the runtime
Failed(e) the Tool replied with a value of its declared error type the Tool, checked by the runtime
BadReply(reason) a reply arrived and did not decode into the declared type the runtime
NotRun(reason) the call did not reach the outside world the host
Unknown(reason) the call may have reached the outside world, and no admitted reply says what happened the host

The split tells a program whether repeating the call is safe. NotRun is a claim that nothing happened, so a retry cannot repeat an effect. Unknown makes no such claim: the call may have taken effect, and repeating it may repeat the effect. BadReply says a reply arrived, so the call ran.

The Text in BadReply, NotRun and Unknown is the reason as the runtime or host gave it. The language does not classify it, gives it no structure, and infers nothing from it. A Tool whose declared error type is Never can't produce Failed; its call fails only in the other three ways.

ToolProblem<E> is data whenever E is: it has equality, ordering and text, and it can be stored, returned and sent to another Tool like any other value (types.md). A program's own enum may have constructors named Failed or Unknown; where a bare name is ambiguous it is qualified by its type, as in ToolProblem.Unknown.

Every case is the record of an outcome, not a finding about the world. Ok and Failed are the Tool's claims, admitted because they have the declared type; admission makes them interpretable, not true. tools.md owns how a reply is decoded and which values a reply may build, and history.md owns the Reply fact each case is recorded as.

Serves foundations: honest uncertainty, typed, composable crossings.

No automatic retry

Flow never repeats a Tool call on its own. Replay and resume never re-dispatch a recorded call, and they never turn Unknown into success or failure. Attempts a host makes inside one call are not calls (history.md).

A retry the program writes is a new call, with its own identity and its own outcome. Recovery from Unknown is usually not a retry but a question the program asks with another call:

use "github.com/acme/tools@v1/payments"

type Settlement { Confirmed(Text), Declined(payments.ChargeError), NotCharged, Unsettled }

flow settle(invoice: Invoice, key: Text) -> Settlement !tool = match payments.charge(invoice, key) {
    Ok(receipt) => Confirmed(receipt.id),
    Err(Failed(error)) => Declined(error),
    Err(NotRun(_)) => NotCharged,
    Err(_) => reconcile(key),
}

flow reconcile(key: Text) -> Settlement !tool = match payments.lookup(key) {
    Ok(Some(receipt)) => Confirmed(receipt.id),
    Ok(None) | Err(_) => Unsettled,
}
Open in playground →

The language does not assume a Tool supports lookup, idempotency keys or compensation. Retry schedules, backoff and reconciliation are library code and application decisions over these values.

Serves foundations: effects cannot always be repeated, honest uncertainty.

Faults

A fault is a bug or an exhausted limit. A program cannot raise one: there is no fail, panic, assert or unwrap, and an impossible state is an error variant like any other failure. Faults come from exactly four places:

  1. Division by zero: int.div, % or Decimal / with a zero divisor, as data.md defines them. int.checked_div returns an Option instead.
  2. Waiting on a stopped task, with task.await or task.first (concurrency.md).
  3. task.first([]), a race over no tasks (concurrency.md).
  4. Exceeding a host limit with the program's own values, such as the size of an Int or Text or the depth of non-tail calls. The specification sets minimums every implementation supports; exceeding the limit an implementation actually has is a fault (execution.md).

A fault ends the run as faulted, with a message and the source location of the operation that faulted. Nothing catches it. A fault inside a task reaches whoever awaits or stops that task, and if nobody does, it faults the call that owns the task at once; concurrency.md owns that rule.

Other failures are not faults:

  • A reply that doesn't match its declared type is BadReply, a value, and so is a reply larger than the host's size limit: the limit was exceeded by something from outside, and the program can handle it.
  • A Tool the host can't reach, or a host token the host no longer recognizes, is NotRun, a value.
  • A program that doesn't check, a missing binding, or input that doesn't decode refuses the run before it starts; nothing has faulted, because nothing ran.
  • A replay that disagrees with its history reports a verdict (history.md); it is not a fault of the program.

A fault rolls nothing back. Calls already made stay made, and their outcomes stay in history. Flow does not claim that a Tool received or honored a cancellation, and the host owns cleanup of processes, connections and anything else outside the run.

How a run ends

As the language sees it, a run that started ends in one of two ways, or does not end yet:

  • Completed(value): the entry function returned. The value is the entry's result, whatever it is. An entry that returns Err(e) has completed with Err(e); the run did not fault.
  • Faulted(message, location): a fault ended it.
  • Paused: a Tool call is waiting for its reply. A pause is an open call and nothing else; resuming supplies the reply (history.md).

Halting a run is the host's act, not a language outcome (execution.md). Stopped is only what task.stop returns, never a name for a halted run. A nested run's fault is a value to the outer run (execution.md).

execution.md owns how a host reports these states, including refusal before a run starts and the CLI's exit codes. history.md owns the End fact and the replay verdicts.

Serves foundations: semantic execution history.