Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

ORBIT is a declarative workflow language and a durable runtime for coordinating work across services written in different languages. You describe what should happen in a .orbit file: which tasks run, in which order, with which retries, timeouts and compensations. The compiler checks the whole workflow against typed task contracts before anything runs, and the runtime executes it durably, recording every decision as an event. Business logic stays in ordinary programs, called workers, written in Python, TypeScript, Go, Rust, Java or C#.

This page explains the problem ORBIT addresses, how its parts fit together, and what it guarantees.

The problem

A business process such as “validate an order, score its risk, reserve stock, charge the card, notify the customer” usually spans several services, often in several languages. Coordinating them by hand means writing the same infrastructure in every service: retry loops, timeouts, deduplication, state tables to survive restarts, and cleanup code for when a later step fails. That coordination logic ends up scattered, hard to review, and hard to reason about after a crash.

ORBIT moves the coordination into one place. The workflow file states the process and its policies. The runtime owns all workflow state and makes it survive crashes. Workers only implement individual tasks.

How the pieces fit

  order.orbit ──► orbit compiler ──► orbit-ir (typed, hashed JSON)
                   type checks,              │
                   dependency graph          │ orbit deploy / orbit run
                                             ▼
                                   ┌───────────────────┐
                                   │   orbit server    │  SQLite or PostgreSQL
                                   │ (durable runtime) │──► event history
                                   └─────────┬─────────┘
                       worker protocol (HTTP/JSON or gRPC)
                 ┌──────────┬───────────┼──────────┬──────────┐
              Python   TypeScript      Go        Rust     Java, C#
              worker     worker      worker     worker    workers
PartWhat it does
LanguageDeclares schemas, task contracts and workflows in .orbit files, including the typed signals a workflow accepts and the queries it answers.
CompilerResolves imports and names, type-checks every step input and reference, builds the dependency graph, rejects cycles, warns about missing compensations and other likely mistakes, and lowers the result to the IR. It runs inside the orbit CLI (orbit check, orbit compile).
IRorbit-ir/1 and orbit-ir/2: a versioned JSON representation of a workflow, identified by a SHA-256 content hash. The runtime only accepts IR, never source.
Runtimeorbit server: stores definitions and executions, decides what runs next, dispatches tasks to workers, evaluates compute steps, delivers signals, answers queries, applies retries, timeouts and compensations, and recovers after a crash.
WorkersYour processes. Each registers the task contracts it implements, long-polls the runtime for tasks, and reports results or structured errors through an SDK.

The CLI is a single binary, orbit, that contains the compiler, the runtime server, the formatter, the language server and the client commands.

A short example

A task contract declares the typed interface that a worker implements. A workflow composes tasks into steps:

task "greeting.compose" {
    version "1.0.0"
    input { name: String }
    output { message: String }
}

task "greeting.decorate" {
    version "1.0.0"
    input { message: String, excitement: Int }
    output { message: String }
}

workflow Hello {
    version "1.0.0"

    input {
        name: String
        excitement: Int = 3
    }

    output { greeting: String }

    step compose {
        worker "greeting.compose"
        using python
        with { name: input.name }
        retry { attempts: 3, backoff: exponential, initial_delay: 500ms }
    }

    step decorate {
        worker "greeting.decorate"
        using typescript
        with { message: compose.message, excitement: input.excitement }
        timeout 10s
    }

    return { greeting: decorate.message }
}

decorate reads compose.message, so it depends on compose and runs after it. compose is served by a Python worker and retried up to three times; decorate is served by a TypeScript worker and limited to 10 seconds per attempt. If decorate passed compose.messsage (a typo) or an Int where a String is required, orbit check would reject the file with a located error before anything is deployed.

The quick start runs this workflow end to end.

Guarantees

The runtime is event-sourced: every state change is an event committed to the database before it takes effect, and the state of an execution is rebuilt from its history after a restart. Tasks are delivered at least once, never “exactly once”. Each task carries an idempotency key that stays the same across retries and redeliveries, so workers can make side effects idempotent. When the outcome of an attempt is unknown, for example because a worker or the runtime crashed mid-task, the runtime redelivers the same attempt and still accepts a late result from the original delivery. Workflow definitions are immutable: name@version is bound to a content hash, and running executions keep the definition they started with. The compiler checks every step against its contract, and the runtime validates execution inputs and worker outputs against the same types. Delivery guarantees gives the precise rules.

Current scope

Workflows can wait for typed signals, answer typed queries, compute values without a worker, and run on failure, on cancel and on timeout handlers after compensation; see Signals and queries, Compute steps and Outcome handlers. orbit test runs workflow tests in-process with mocked tasks and simulated time, orbit simulate runs a workflow locally with mocked or generated results, and orbit replay checks a recorded history against the current decider; see Testing workflows, Simulation and Replay.

One server executes workflows at a time. For availability, several servers can share one PostgreSQL database in active/standby mode: one leader runs the engine, and a standby takes over if it stops. See Running several servers. The server can cap active executions, fan-out, history length and payload sizes, and can give applications client identities limited to some workflows; see Running the server and Security.

Next steps

  • Installation: install the CLI, the runtime and an SDK.
  • Quick start: create a project and run a workflow with Python and TypeScript workers.
  • Core concepts: the vocabulary used throughout this documentation.
  • Steps: the full syntax of a step.
  • How workers work: the worker model and the SDKs.

Why ORBIT

ORBIT is for teams that want a business process to be a definition they can read, review, check and test as a whole, across services written in several languages. This page describes the problems it is built for, when another tool is a better fit, and how it compares with code-first durable execution engines such as Temporal.

The idea: the process is a typed contract

In ORBIT, a workflow is a declaration, not a program: which tasks run, in what order, under which conditions, with which retries and deadlines, how each side effect is undone, which human decisions it waits for, and what happens when it fails, is cancelled or times out. The compiler checks that declaration against the contracts of the services that do the work. That gives guarantees a process written as code does not have by default:

  • Every service call matches its contract, in every language. A field renamed in a worker’s contract breaks the build, not production.
  • Every declared side effect has an undo action. A task that declares compensated_by must be compensated wherever it is used (orbit check --strict).
  • Every human decision is typed and every wait can be required to have a deadline.
  • Every path is testable with test blocks that run in milliseconds, with time simulated.
  • A change is reviewable: orbit compat reports breaking changes and the version bump they need, and running executions keep the version they started with.

What it is built for

Business processes you have to be able to explain

Onboarding and KYC, lending and underwriting, claims, refunds, orders, procurement. Processes where an auditor, a compliance officer or a product owner asks “what happens if…?” and the answer should be in one file, not spread across services. See Typed business process contracts.

Orchestration across services in several languages

One workflow can call workers in Python, TypeScript, Go, Rust, Java and C#, and the compiler checks every call against its contract, whatever language implements it. Teams keep their services in the language they chose; the process does not belong to any one of them. See Polyglot workflows.

Human-in-the-loop work

Approvals, reviews and exception handling, with typed decisions, deadlines, escalation and a record of who decided what. See Human approvals.

Sagas

Multi-step operations that must be undone in reverse when a later step fails: reserve, charge, ship. Compensation is declared per step and checked by the compiler; outcome handlers run after it. See Orders with compensation.

AI agents that must not run away

Durable tool calls with typed results, bounded fan-out, budgets, guardrail checks and human review. See AI agents with guardrails.

Small teams that want durability without a platform team

One binary. SQLite for development and small deployments, PostgreSQL for production, several servers when you need failover or more throughput. No separate history, matching and frontend services to run.

When to use something else

  • The logic is genuinely algorithmic. Loops of unknown length, complex data structures, dynamic decisions about what to call next: these are natural in code and awkward in a declaration. A code-first engine fits better, or keep that logic in a worker and orchestrate around it.
  • You need a managed service today. ORBIT has no hosted offering; you run it yourself.
  • You need multi-region active-active execution, or Temporal-specific features such as Nexus, update handlers or worker build-ID versioning.
  • Data pipelines and batch jobs with large datasets, where tools such as Airflow, Dagster or Spark are built for scheduling and data movement.

Compared with Temporal

Both record workflow history durably, recover by replaying it, deliver work to workers at least once, and support several languages. The difference is where the process lives: in Temporal it is code in one SDK language; in ORBIT it is a typed declaration checked against contracts in every language. The FAQ has a feature-by-feature table. In short:

You wantBetter fit
The process reviewed and checked as a whole, with compile-time guaranteesORBIT
One definition orchestrating services in several languagesORBIT
Arbitrary control flow in a general-purpose languageTemporal
A large ecosystem, a managed cloud and years of production useTemporal
A single binary you can run on a laptop or a small VMORBIT

Maturity

ORBIT is young. The runtime is tested end to end in six languages, with crash recovery, TLS, failover and several active servers, and the performance page shows measured numbers and what bounds them. It does not yet have years of production use. Read the delivery guarantees before you rely on it, and report what you find.

See also

Installation

ORBIT ships as one binary, orbit, that contains the compiler, the runtime server, the formatter, the language server and the client commands. You also install a worker SDK for each language your workers use. This page covers every installation method for the CLI and runtime, the six SDKs, the VS Code extension, upgrading and uninstalling.

The current release is v0.1.0.

Choose an installation method

PlatformRecommended methodAlternatives
Linux x86_64 (glibc)Install scriptManual download, cargo install, Docker, from source
Linux arm64 (glibc)Install scriptManual download, cargo install, Docker, from source
macOS (Intel or Apple silicon)cargo install orbit-langDocker, from source
Windows x86_64Manual downloadcargo install, Docker
Containers and serversDocker imageAny of the above

The prebuilt Linux binaries link against glibc. On musl-based distributions such as Alpine, use cargo install or the Docker image.

Install script (Linux)

The install script downloads the release archive for your architecture, verifies its SHA-256 checksum against the release’s SHA256SUMS file, and installs the binary:

curl -fsSL https://github.com/AjmalX0/Orbit/releases/latest/download/install.sh | sh
orbit: downloading orbit-x86_64-unknown-linux-gnu.tar.gz (latest)
orbit: installed orbit 0.1.0 to /home/you/.local/bin/orbit

If the install directory is not on your PATH, the script prints orbit: add <dir> to your PATH. Add it in your shell profile, for example:

echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc

The script reads these environment variables:

VariableDefaultPurpose
ORBIT_VERSIONlatestRelease tag to install, for example v0.1.0.
ORBIT_INSTALL_DIR~/.local/bin, or /usr/local/bin when run as rootDirectory the binary is installed into.
ORBIT_DOWNLOAD_URLGitHub ReleasesBase URL of a mirror that serves the release assets and SHA256SUMS. Overrides ORBIT_VERSION.

Pass them to sh, not to curl:

curl -fsSL https://github.com/AjmalX0/Orbit/releases/latest/download/install.sh \
  | ORBIT_VERSION=v0.1.0 ORBIT_INSTALL_DIR=/opt/orbit/bin sh

If the checksum does not match, the script prints orbit: checksum mismatch for <asset>, exits with status 1 and installs nothing.

Manual download (Linux)

To install without piping a script into sh, download the archive and the checksum file from GitHub Releases yourself:

curl -fsSLO https://github.com/AjmalX0/Orbit/releases/latest/download/orbit-x86_64-unknown-linux-gnu.tar.gz
curl -fsSLO https://github.com/AjmalX0/Orbit/releases/latest/download/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS
tar -xzf orbit-x86_64-unknown-linux-gnu.tar.gz
install -m 0755 orbit ~/.local/bin/orbit

For arm64, use orbit-aarch64-unknown-linux-gnu.tar.gz. The archive contains the single orbit binary.

Windows

Download orbit-x86_64-pc-windows-msvc.zip from GitHub Releases, verify it, and put orbit.exe on your PATH. In PowerShell:

$base = "https://github.com/AjmalX0/Orbit/releases/latest/download"
Invoke-WebRequest "$base/orbit-x86_64-pc-windows-msvc.zip" -OutFile orbit.zip
Invoke-WebRequest "$base/SHA256SUMS" -OutFile SHA256SUMS

# Compare this hash with the orbit-x86_64-pc-windows-msvc.zip line in SHA256SUMS.
(Get-FileHash orbit.zip -Algorithm SHA256).Hash.ToLower()
Select-String "orbit-x86_64-pc-windows-msvc.zip" SHA256SUMS

$dir = "$env:LOCALAPPDATA\Programs\orbit"
Expand-Archive orbit.zip -DestinationPath $dir -Force
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
[Environment]::SetEnvironmentVariable("Path", "$userPath;$dir", "User")

Open a new terminal so that the updated PATH takes effect. The install script is a POSIX shell script and does not run in PowerShell or cmd.exe.

cargo install (any platform)

orbit-lang is the crate that provides the orbit binary. It builds on Linux, macOS and Windows with Rust 1.89 or later:

cargo install orbit-lang --locked

The build compiles the TLS stack from source, so it needs a C compiler and CMake in addition to Rust (on macOS, the Xcode command line tools and brew install cmake; on Debian and Ubuntu, apt install build-essential cmake). Cargo installs the binary into ~/.cargo/bin, which rustup adds to your PATH.

This is the recommended method on macOS: v0.1.0 has no prebuilt macOS binary.

Docker

The runtime image is published to the GitHub Container Registry:

TagContents
ghcr.io/ajmalx0/orbit:v0.1.0the v0.1.0 release
ghcr.io/ajmalx0/orbit:latestthe most recent release

The image runs orbit server as an unprivileged user, listens on 0.0.0.0:7233, and stores its SQLite database at /data/orbit.db (a volume). Because it listens on all interfaces, the server refuses to start without a token. Set ORBIT_TOKEN:

docker run -d --name orbit \
  -p 7233:7233 \
  -e ORBIT_TOKEN=change-me \
  -v orbit-data:/data \
  ghcr.io/ajmalx0/orbit:v0.1.0

Without a token, the container exits immediately with:

error: refusing to listen on 0.0.0.0:7233 without authentication; pass --token (or ORBIT_TOKEN), or --insecure for trusted networks

Use a long random token in practice; orbit token prints one. Clients and workers on the host then pass the same token:

export ORBIT_TOKEN=change-me
orbit ps

The image’s entry point is orbit, so arguments after the image name replace the default server command. For example, to print the version, or to start the server with the gRPC worker protocol (port 7234 is exposed for it) and the HTTP adapter enabled:

docker run --rm ghcr.io/ajmalx0/orbit:v0.1.0 --version

docker run -d -p 7233:7233 -p 7234:7234 -e ORBIT_TOKEN=change-me -v orbit-data:/data \
  ghcr.io/ajmalx0/orbit:v0.1.0 server --grpc-addr 0.0.0.0:7234 --enable-http-adapter

To build the image yourself from a clone, run docker build -t orbit . in the repository root. Deployment covers PostgreSQL, TLS and production settings.

From source

Build from a clone when you want unreleased changes or plan to contribute. You need Rust 1.89 or later, a C compiler and CMake:

git clone https://github.com/AjmalX0/Orbit.git
cd Orbit
cargo install --path crates/orbit-cli --locked

Alternatively, cargo build --release leaves the binary at target/release/orbit without installing it. Contributing describes the full development setup.

Verify the installation

Check the version:

orbit --version
orbit 0.1.0

Then start the runtime and query its health endpoint from a second terminal:

orbit server
curl -s http://127.0.0.1:7233/v1/health
{"status":"ok","api_version":"orbit-api/1","runtime_version":"0.2.0","ir_versions":["orbit-ir/1","orbit-ir/2"],"active_executions":0,"worker_sessions":0,"role":"single","node_id":null,"term":null,"leader":null}

By default orbit server listens on 127.0.0.1:7233 and creates orbit.db (plus orbit.db-wal and orbit.db-shm) in the current directory. Stop it with Ctrl-C.

Install a worker SDK

Workers use an SDK to register with the runtime, receive tasks and report results. Install the SDK for each language you write workers in:

LanguagePackageInstallRequirements
Pythonorbit-lang-sdk on PyPIpip install orbit-lang-sdkPython 3.9+, no dependencies
TypeScript and JavaScriptorbit-lang on npmnpm install orbit-langNode.js 18+ (22.18+ to run .ts files directly)
Gogithub.com/AjmalX0/Orbit/sdks/gogo get github.com/AjmalX0/Orbit/sdks/go@v0.1.0Go 1.21+, no dependencies
Rustorbit-worker on crates.iocargo add orbit-workerRust 1.89+
Javaio.github.ajmalx0:orbit-sdk on Maven Centralsee belowJava 17+
C# and .NETOrbitLang.Sdk on NuGetdotnet add package OrbitLang.Sdk.NET 8+

The Python package is installed as orbit-lang-sdk and imported as orbit_sdk:

from orbit_sdk import Worker

For Java with Maven:

<dependency>
  <groupId>io.github.ajmalx0</groupId>
  <artifactId>orbit-sdk</artifactId>
  <version>0.1.0</version>
</dependency>

With Gradle:

implementation("io.github.ajmalx0:orbit-sdk:0.1.0")

All SDKs read the same environment variables: ORBIT_SERVER (default http://127.0.0.1:7233), ORBIT_WORKER_TOKEN or ORBIT_TOKEN, ORBIT_CA_CERT, and ORBIT_CLIENT_CERT / ORBIT_CLIENT_KEY. Each language has its own page under Workers and SDKs.

Install the VS Code extension

The extension provides syntax highlighting and, through orbit lsp, diagnostics as you type, hover, go to definition, completion, outline and formatting. Download orbit-lang.vsix from GitHub Releases and install it:

curl -fsSLO https://github.com/AjmalX0/Orbit/releases/latest/download/orbit-lang.vsix
code --install-extension orbit-lang.vsix

The extension runs the orbit binary from your PATH. If it is installed elsewhere, set orbit.serverPath in your VS Code settings. The orbit.runtimeUrl setting (default http://127.0.0.1:7233) selects the runtime used by the extension’s Run and Deploy commands. See Editor support.

Upgrade

MethodUpgrade command
Install scriptRun the script again. It replaces the binary in ORBIT_INSTALL_DIR.
Manual downloadDownload the new archive and replace the binary.
cargo installcargo install orbit-lang --locked (Cargo replaces an older version).
Dockerdocker pull ghcr.io/ajmalx0/orbit:<new tag>, then recreate the container with the same volume.
From sourcegit pull, then cargo install --path crates/orbit-cli --locked.
SDKspip install -U orbit-lang-sdk, npm install orbit-lang@latest, go get github.com/AjmalX0/Orbit/sdks/go@latest, cargo update -p orbit-worker, or bump the version in pom.xml / the .csproj.
VS Code extensionInstall the new orbit-lang.vsix with code --install-extension.

Stop the server before replacing its binary. On restart, the runtime reloads every definition and resumes every execution that was still running. Registered workflow definitions and execution history live in the database, not in the binary, so upgrading does not change them. The runtime reports the IR versions it accepts in /v1/health (ir_versions).

Uninstall

Remove the binary that matches how you installed it:

rm ~/.local/bin/orbit          # install script as a regular user
sudo rm /usr/local/bin/orbit   # install script as root
cargo uninstall orbit-lang     # cargo install

On Windows, delete %LOCALAPPDATA%\Programs\orbit and remove it from your user PATH.

For Docker, remove the container and, if you no longer need the data, the volume:

docker rm -f orbit
docker volume rm orbit-data
docker image rm ghcr.io/ajmalx0/orbit:v0.1.0

orbit server keeps its state in the database you pointed it at, by default orbit.db, orbit.db-wal and orbit.db-shm in the directory where you started it. Delete those files to remove all definitions and history.

Remove the VS Code extension with:

code --uninstall-extension orbit-lang.orbit-lang

Next steps

Quick start

This tutorial takes you from an empty directory to a running workflow served by a Python worker and a TypeScript worker. You create a project with orbit init, run the runtime and the workers, start an execution, inspect its history, and then add a failure path to see retries and compensation at work. It takes about 15 minutes.

The outputs on this page come from real runs. Execution ids and timings differ on your machine.

Prerequisites

  • The orbit CLI, version 0.1.0 or later. See Installation.
  • Python 3.9 or later.
  • Node.js 22.18 or later. The TypeScript worker is a .ts file that Node.js runs directly, without a build step.

You use three terminals: one for the runtime, one for each worker. Run every command from the project directory unless the step says otherwise.

1. Create a project

orbit init hello-orbit
  created hello-orbit/workers/typescript/package.json
  created hello-orbit/workers/python/requirements.txt
  created hello-orbit/.gitignore
  created hello-orbit/workflows/hello.orbit
  created hello-orbit/workers/python/worker.py
  created hello-orbit/workers/typescript/worker.ts
  created hello-orbit/README.md

Next: cd hello-orbit && orbit check workflows/hello.orbit

orbit init refuses to write into a directory that exists and is not empty. The project contains:

hello-orbit/
├── .gitignore
├── README.md
├── workflows/
│   └── hello.orbit            the workflow and its task contracts
└── workers/
    ├── python/
    │   ├── requirements.txt   orbit-lang-sdk>=0.1,<0.2
    │   └── worker.py          implements greeting.compose
    └── typescript/
        ├── package.json       depends on orbit-lang ^0.1.0
        └── worker.ts          implements greeting.decorate

2. Read the workflow

Open workflows/hello.orbit:

// A two-language hello world: Python composes, TypeScript decorates.

task "greeting.compose" {
    version "1.0.0"
    input { name: String }
    output { message: String }
}

task "greeting.decorate" {
    version "1.0.0"
    input { message: String, excitement: Int }
    output { message: String }
}

workflow Hello {
    version "1.0.0"

    input {
        name: String
        excitement: Int = 3
    }

    output { greeting: String }

    step compose {
        worker "greeting.compose"
        using python
        with { name: input.name }
        retry { attempts: 3, backoff: exponential, initial_delay: 500ms }
    }

    step decorate {
        worker "greeting.decorate"
        using typescript
        with { message: compose.message, excitement: input.excitement }
        timeout 10s
    }

    return { greeting: decorate.message }
}

The file has two parts:

  • Task contracts. Each task declares a typed interface: a name, a contract version, an input and an output. Workers implement contracts; the compiler checks every step against them.
  • A workflow. Hello takes a name and an optional excitement (default 3), runs two steps and returns a greeting. decorate reads compose.message, so it depends on compose and runs after it. using python and using typescript route each step only to workers of that language.

Type-check the file:

cd hello-orbit
orbit check workflows/hello.orbit
ok Hello@1.0.0 (2 steps)

3. Install the worker dependencies

Install the Python SDK into a virtual environment, and the TypeScript SDK with npm:

python3 -m venv .venv
. .venv/bin/activate
pip install -r workers/python/requirements.txt
(cd workers/typescript && npm install)

On Windows, activate the virtual environment with .venv\Scripts\activate.

4. Start the runtime

In the first terminal, from the project directory:

orbit server
INFO orbit: starting ORBIT runtime db=orbit.db backend="sqlite" http_adapter=false auth=false ui=true
INFO orbit_runtime::server: ORBIT runtime listening addr=127.0.0.1:7233 tls=false ui=true

The runtime listens on 127.0.0.1:7233 and stores everything in orbit.db in the current directory. Leave it running. Without a token it only accepts connections from the local machine; see Security before exposing it.

5. Start the workers

Each worker is an ordinary long-running program. It registers the contracts it implements and then long-polls the runtime for tasks.

Python worker

workers/python/worker.py implements greeting.compose:

"""Python worker for the Hello workflow.

Run:  pip install -r workers/python/requirements.txt
      python workers/python/worker.py
"""
from orbit_sdk import Worker

worker = Worker("hello-python")


@worker.task("greeting.compose", version="1.0.0")
def compose(input, ctx):
    ctx.log(f"composing a greeting for {input['name']}")
    return {"message": f"Hello, {input['name']}"}


if __name__ == "__main__":
    worker.run()

In the second terminal, activate the virtual environment and start it:

. .venv/bin/activate
python workers/python/worker.py
2026-11-15 09:34:37,324 INFO orbit worker hello-python registered (1 tasks)

TypeScript worker

workers/typescript/worker.ts implements greeting.decorate:

// TypeScript worker for the Hello workflow.
//
// Run:  (cd workers/typescript && npm install) && node workers/typescript/worker.ts
//       Node.js 22.18+ runs TypeScript directly.
import { Worker } from "orbit-lang";

const worker = new Worker({ workerId: "hello-typescript" });

worker.task<{ message: string; excitement: number }, { message: string }>(
  "greeting.decorate",
  { version: "1.0.0" },
  async (input) => ({ message: input.message + "!".repeat(input.excitement) }),
);

await worker.run();

In the third terminal:

node workers/typescript/worker.ts
[orbit] worker hello-typescript registered (1 tasks)

Both workers connect to http://127.0.0.1:7233 by default. Set ORBIT_SERVER to point them at another runtime.

Confirm that the runtime sees them. You can run client commands from any terminal; this tutorial uses the second one, but a fourth works as well:

orbit workers list
WORKER                       RUNTIME      STATE    INFLIGHT  TASKS
hello-python                 python       online   0  greeting.compose@1.0.0
hello-typescript             typescript   online   0  greeting.decorate@1.0.0

6. Run the workflow

orbit run workflows/hello.orbit --input '{"name": "Ada"}'
deployed Hello@1.0.0 (sha256:505930b4d466)
started execution 99e811dd-7448-41cc-ac28-bc58d50200d2
  ✓ compose                        succeeded 122ms
  ✓ decorate                       succeeded 140ms

COMPLETED 99e811dd-7448-41cc-ac28-bc58d50200d2  Hello@1.0.0
  elapsed: 269ms
{
  "greeting": "Hello, Ada!!!"
}

orbit run did three things:

  1. Compiled and deployed the file. The runtime registered Hello@1.0.0 under the content hash sha256:505930b4d466.... Running the same file again prints up to date instead of deployed.
  2. Started an execution with the input {"name": "Ada"}. The runtime applied the default excitement: 3 and validated the input against the workflow’s input type.
  3. Followed the execution until it finished and printed the output.

orbit run exits with status 0 when the execution completes and 1 when it fails, so you can use it in scripts. Pass --no-wait to return right after the execution starts.

7. Inspect the execution

Every execution keeps its complete history. Use the execution id printed by orbit run (yours differs).

orbit status shows the outcome and one row per step:

orbit status 99e811dd-7448-41cc-ac28-bc58d50200d2
COMPLETED 99e811dd-7448-41cc-ac28-bc58d50200d2  Hello@1.0.0
  elapsed: 269ms

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  compose                      SUCCEEDED          1    122ms  worker hello-python
  decorate                     SUCCEEDED          1    140ms  worker hello-typescript

  output: {"greeting":"Hello, Ada!!!"}

orbit logs prints the event history as a timeline. These events are the authoritative record; the runtime rebuilds an execution’s state from them after a restart:

orbit logs 99e811dd-7448-41cc-ac28-bc58d50200d2
+     0ms   #1  WorkflowCreated
+     0ms   #2  WorkflowStarted
+     2ms   #3  StepScheduled             step=compose attempt=1
+     3ms   #4  StepDispatched            step=compose attempt=1 worker_id=hello-python
+   125ms   #5  StepSucceeded             step=compose attempt=1
+   127ms   #6  StepScheduled             step=decorate attempt=1
+   128ms   #7  StepDispatched            step=decorate attempt=1 worker_id=hello-typescript
+   268ms   #8  StepSucceeded             step=decorate attempt=1
+   269ms   #9  WorkflowCompleted

orbit inspect prints the full state as JSON, including each step’s input, output and idempotency key (abridged here):

orbit inspect 99e811dd-7448-41cc-ac28-bc58d50200d2
{
  "id": "99e811dd-7448-41cc-ac28-bc58d50200d2",
  "workflow": "Hello",
  "version": "1.0.0",
  "definition_hash": "sha256:505930b4d4668ff2f8cb95e04519d5292ef2fbf0630d575349aaf92c0e2b42be",
  "input": {
    "name": "Ada",
    "excitement": 3
  },
  "status": "COMPLETED",
  "units": {
    "compose": {
      "status": "SUCCEEDED",
      "attempt": 1,
      "input": {
        "name": "Ada"
      },
      "idempotency_key": "99e811dd-7448-41cc-ac28-bc58d50200d2/compose",
      "output": {
        "message": "Hello, Ada"
      },
      "redelivery": false,
      "worker_id": "hello-python",
      "tokens": "***"
    },
    "decorate": { "...": "..." }
  },
  "output": {
    "greeting": "Hello, Ada!!!"
  },
  "last_seq": 9
}

orbit ps lists recent executions:

orbit ps
EXECUTION                              WORKFLOW                 VERSION    STATUS        AGE
99e811dd-7448-41cc-ac28-bc58d50200d2   Hello                    1.0.0      COMPLETED     314ms

8. Add a failure path

Real workflows fail. In this step you add two steps and make the workers fail on purpose, so that you can see a retry, a permanent failure and a compensation.

  • publish (Python) stores the greeting and returns a post_id. Its compensate block declares how to undo it: greeting.unpublish.
  • notify (TypeScript) rejects messages longer than 40 characters with a non-retryable error.

Replace workflows/hello.orbit with the following. The version changes to 1.1.0; the next section explains why.

// A two-language hello world: Python composes, TypeScript decorates.

task "greeting.compose" {
    version "1.0.0"
    input { name: String }
    output { message: String }
}

task "greeting.decorate" {
    version "1.0.0"
    input { message: String, excitement: Int }
    output { message: String }
}

task "greeting.publish" {
    version "1.0.0"
    input { message: String }
    output { post_id: String }
}

task "greeting.unpublish" {
    version "1.0.0"
    input { post_id: String }
    output { removed: Bool }
}

task "greeting.notify" {
    version "1.0.0"
    input { post_id: String, message: String }
    output { delivered: Bool }
}

workflow Hello {
    version "1.1.0"

    input {
        name: String
        excitement: Int = 3
    }

    output { greeting: String, post_id: String }

    step compose {
        worker "greeting.compose"
        using python
        with { name: input.name }
        retry { attempts: 3, backoff: exponential, initial_delay: 500ms }
    }

    step decorate {
        worker "greeting.decorate"
        using typescript
        with { message: compose.message, excitement: input.excitement }
        timeout 10s
    }

    step publish {
        worker "greeting.publish"
        using python
        with { message: decorate.message }
        compensate {
            worker "greeting.unpublish"
            with { post_id: publish.post_id }
        }
    }

    step notify {
        worker "greeting.notify"
        using typescript
        with { post_id: publish.post_id, message: decorate.message }
    }

    return { greeting: decorate.message, post_id: publish.post_id }
}

Replace workers/python/worker.py. compose now fails its first attempt for names that start with Flaky, with a retryable error. publish uses the task’s idempotency key so that a redelivered task returns the same post instead of creating a second one:

import uuid

from orbit_sdk import TaskError, Worker

worker = Worker("hello-python")
posts = {}  # stands in for a database


@worker.task("greeting.compose", version="1.0.0")
def compose(input, ctx):
    # Simulate a flaky dependency: names starting with "Flaky" fail once.
    if input["name"].startswith("Flaky") and ctx.attempt == 1:
        raise TaskError("NAME_SERVICE_BUSY", "try again shortly", retryable=True)
    ctx.log(f"composing a greeting for {input['name']}")
    return {"message": f"Hello, {input['name']}"}


@worker.task("greeting.publish", version="1.0.0")
def publish(input, ctx):
    post_id = posts.get(ctx.idempotency_key) or f"post-{uuid.uuid4().hex[:8]}"
    posts[ctx.idempotency_key] = post_id
    ctx.log(f"published {post_id}")
    return {"post_id": post_id}


@worker.task("greeting.unpublish", version="1.0.0")
def unpublish(input, ctx):
    ctx.log(f"removed {input['post_id']}")
    return {"removed": True}


if __name__ == "__main__":
    worker.run()

Replace workers/typescript/worker.ts:

import { TaskError, Worker } from "orbit-lang";

const worker = new Worker({ workerId: "hello-typescript" });

worker.task<{ message: string; excitement: number }, { message: string }>(
  "greeting.decorate",
  { version: "1.0.0" },
  async (input) => ({ message: input.message + "!".repeat(input.excitement) }),
);

worker.task<{ post_id: string; message: string }, { delivered: boolean }>(
  "greeting.notify",
  { version: "1.0.0" },
  async (input) => {
    if (input.message.length > 40) {
      throw new TaskError("MESSAGE_TOO_LONG", `${input.message.length} characters`, { retryable: false });
    }
    return { delivered: true };
  },
);

await worker.run();

Stop both workers with Ctrl-C and start them again so that they register the new tasks. The Python worker now reports registered (3 tasks) and the TypeScript worker registered (2 tasks). Then check the workflow:

orbit check workflows/hello.orbit
ok Hello@1.1.0 (4 steps)

Why the version changed

Workflow definitions are immutable. If you had kept version "1.0.0", the runtime would reject the new content:

error: DEFINITION_CONFLICT (409): Hello@1.0.0 is already registered with a different definition (sha256:505930b4d4668ff2f8cb95e04519d5292ef2fbf0630d575349aaf92c0e2b42be); workflow definitions are immutable, bump the version

Bumping the version registers Hello@1.1.0 next to Hello@1.0.0. Executions that started on 1.0.0 keep running on 1.0.0. See Versioning.

Run the happy path

orbit run workflows/hello.orbit --input '{"name": "Grace"}'
deployed Hello@1.1.0 (sha256:c444589a24d2)
started execution c9bade94-bc35-4741-80a9-3a39780623d7
  ✓ compose                        succeeded 8ms
  ✓ decorate                       succeeded 7ms
  ✓ publish                        succeeded 10ms
  ✓ notify                         succeeded 6ms

COMPLETED c9bade94-bc35-4741-80a9-3a39780623d7  Hello@1.1.0
  elapsed: 45ms
{
  "greeting": "Hello, Grace!!!",
  "post_id": "post-71a73bf3"
}

Run the failure path

Now use a name that triggers the flaky compose, and enough excitement to make the message longer than 40 characters:

orbit run workflows/hello.orbit --input '{"name": "Flaky Grace", "excitement": 30}'
up to date Hello@1.1.0 (sha256:c444589a24d2)
started execution d599b57e-eee0-412e-b679-df8276005035
  ✓ compose                        succeeded 6ms
  ✓ decorate                       succeeded 4ms
  ✓ publish                        succeeded 4ms
  ✗ notify                         failed 48 characters
  ✓ compensate:publish             succeeded 6ms

FAILED d599b57e-eee0-412e-b679-df8276005035  Hello@1.1.0
  elapsed: 549ms
  compensation: completed
  error: MESSAGE_TOO_LONG step `notify` failed: 48 characters

The status table shows that compose needed two attempts:

orbit status d599b57e-eee0-412e-b679-df8276005035
FAILED d599b57e-eee0-412e-b679-df8276005035  Hello@1.1.0
  elapsed: 549ms
  compensation: completed
  error: MESSAGE_TOO_LONG step `notify` failed: 48 characters

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  compose                      SUCCEEDED          2      6ms  worker hello-python
  decorate                     SUCCEEDED          1      4ms  worker hello-typescript
  publish                      SUCCEEDED          1      4ms  worker hello-python
  notify                       FAILED             1      5ms  48 characters
  compensate:publish           SUCCEEDED          1      6ms  worker hello-python

The history records every decision:

orbit logs d599b57e-eee0-412e-b679-df8276005035
+     0ms   #1  WorkflowCreated
+     0ms   #2  WorkflowStarted
+     2ms   #3  StepScheduled             step=compose attempt=1
+     3ms   #4  StepDispatched            step=compose attempt=1 worker_id=hello-python
+     7ms   #5  StepAttemptFailed         step=compose attempt=1 error=NAME_SERVICE_BUSY try again shortly
+     8ms   #6  RetryScheduled            step=compose delay_ms=500
+   508ms   #7  StepScheduled             step=compose attempt=2
+   511ms   #8  StepDispatched            step=compose attempt=2 worker_id=hello-python
+   517ms   #9  StepSucceeded             step=compose attempt=2
+   518ms  #10  StepScheduled             step=decorate attempt=1
+   519ms  #11  StepDispatched            step=decorate attempt=1 worker_id=hello-typescript
+   523ms  #12  StepSucceeded             step=decorate attempt=1
+   524ms  #13  StepScheduled             step=publish attempt=1
+   526ms  #14  StepDispatched            step=publish attempt=1 worker_id=hello-python
+   530ms  #15  StepSucceeded             step=publish attempt=1
+   531ms  #16  StepScheduled             step=notify attempt=1
+   535ms  #17  StepDispatched            step=notify attempt=1 worker_id=hello-typescript
+   539ms  #18  StepAttemptFailed         step=notify attempt=1 error=MESSAGE_TOO_LONG 48 characters
+   540ms  #19  StepFailed                step=notify error=MESSAGE_TOO_LONG 48 characters
+   540ms  #20  CompensationStarted       reason=step `notify` failed: 48 characters
+   540ms  #21  StepScheduled             step=compensate:publish attempt=1
+   542ms  #22  StepDispatched            step=compensate:publish attempt=1 worker_id=hello-python
+   548ms  #23  StepSucceeded             step=compensate:publish attempt=1
+   549ms  #24  CompensationCompleted
+   549ms  #25  WorkflowFailed            error=MESSAGE_TOO_LONG step `notify` failed: 48 characters

Reading the timeline:

  • Retry (#5 to #9). The first compose attempt failed with a retryable error. The step’s policy allows three attempts with exponential backoff starting at 500 ms, so the runtime waited 500 ms and dispatched attempt 2, which succeeded.
  • Permanent failure (#18, #19). notify failed with retryable: false, so the runtime failed the step immediately, without using the remaining attempts. notify has no retry block, so it would have had only one attempt in any case.
  • Compensation (#20 to #24). Because the workflow failed after publish succeeded, the runtime ran publish’s compensation, greeting.unpublish, with the post_id that publish returned. Steps without a compensate block (compose, decorate) have nothing to undo.
  • Outcome (#25). The execution ended FAILED with compensation: completed: the failure was handled and the side effect was undone.

The Python worker’s output (timestamps removed) shows the failed attempt, the retry, and both sides of the compensation:

INFO orbit executing {"execution_id": "d599b57e-...", "step": "compose", "attempt": 1, "task": "greeting.compose", "redelivery": false}
INFO orbit task failed {"execution_id": "d599b57e-...", "step": "compose", "attempt": 1, "code": "NAME_SERVICE_BUSY"}
INFO orbit executing {"execution_id": "d599b57e-...", "step": "compose", "attempt": 2, "task": "greeting.compose", "redelivery": false}
INFO orbit composing a greeting for Flaky Grace {"execution_id": "d599b57e-...", "step": "compose", "attempt": 2}
INFO orbit executing {"execution_id": "d599b57e-...", "step": "publish", "attempt": 1, "task": "greeting.publish", "redelivery": false}
INFO orbit published post-2ce4c9b2 {"execution_id": "d599b57e-...", "step": "publish", "attempt": 1}
INFO orbit executing {"execution_id": "d599b57e-...", "step": "compensate:publish", "attempt": 1, "task": "greeting.unpublish", "redelivery": false}
INFO orbit removed post-2ce4c9b2 {"execution_id": "d599b57e-...", "step": "compensate:publish", "attempt": 1}

9. Clean up

Stop the workers and the runtime with Ctrl-C. All state is in orbit.db, orbit.db-wal and orbit.db-shm in the project directory; the project’s .gitignore already excludes them. Delete them to start from an empty runtime.

What you learned

  • A workflow file declares typed task contracts and the steps that use them. orbit check validates it before anything runs.
  • Workers in any language register contracts and pull tasks from the runtime.
  • orbit run deploys and starts an execution; orbit status, orbit logs, orbit inspect and orbit ps show what happened and why.
  • Retries are policy in the workflow, not code in the worker. A retryable: false error stops retrying at once.
  • When a workflow fails, the runtime compensates the steps that succeeded, newest first.
  • Definitions are immutable per version; changing a workflow means bumping its version.

Next steps

Core concepts

This page defines the terms used throughout the ORBIT documentation and shows how they relate. Each definition is precise about what the runtime does, so you can rely on it when you design workflows and workers.

Overview

 .orbit source                         runtime (orbit server)
 ─────────────                         ──────────────────────
 task contract ◄─── implemented by ─── worker (any language)
      ▲                                    ▲
      │ referenced by                      │ pulls tasks for
      │                                    │
 workflow ── compiled to ──► definition ── started as ──► execution
  └─ steps                    name@version                 ├─ units (one per step,
                              + content hash               │   fan-out element or
                                                           │   compensation)
                                                           ├─ history (events)
                                                           └─ child executions

A workflow is a set of steps. Each step calls a task contract, a child workflow, or a timer, waits for a signal, or computes a value. A workflow can also declare queries that report its state. Compiling a workflow produces a definition identified by name@version and a content hash. Starting a definition creates an execution. The runtime splits the execution into units, dispatches each task unit to a worker that implements the contract, and records every transition in the execution’s history.

Workflow

A workflow is a named, versioned declaration of a process: typed input, an optional typed output, a set of steps, and a return expression that maps step results to the output. Workflows contain no business logic and perform no I/O themselves. Every expression in a workflow is pure and deterministic, so every decision the runtime makes can be reproduced from history.

workflow Hello {
    version "1.0.0"
    input { name: String, excitement: Int = 3 }
    output { greeting: String }
    step compose { worker "greeting.compose" with { name: input.name } }
    return { greeting: compose.message }
}

See Workflows.

Task contract

A task contract (a task declaration) is the typed interface between a workflow and a worker: a name, a contract version, an input type and an output type.

task "greeting.compose" {
    version "1.0.0"
    input { name: String }
    output { message: String }
}

The compiler checks every step’s with block against the contract’s input, and every reference to a step’s output against the contract’s output. The runtime validates worker results against the output type again and fails the attempt with OUTPUT_CONTRACT_VIOLATION if they do not match.

Contracts are versioned independently of workflows. A worker receives a task only if it announced exactly that name@version. Names that start with orbit. are reserved for built-in tasks; there is one, orbit.http.request. See Task contracts.

Worker

A worker is a process you run, in any language with an SDK (Python, TypeScript, Go, Rust, Java, C#) or any program that speaks the worker protocol. It registers with the runtime under a worker id, announces the contracts it implements and its language runtime, long-polls for tasks, runs your handler, and reports a result or a structured error.

Workers hold no workflow state. You can run several workers for the same contracts to share load, and stop or restart them at any time: the runtime redelivers work whose outcome it never learned. A step with using python (or another language) is routed only to workers that registered with that runtime. See How workers work.

Step and unit

A step is a named node in a workflow. It does one of five things:

Step kindDeclared withExecuted by
Task stepworker "task.name"a worker that implements the contract (or the runtime, for orbit.http.request)
Child workflow stepworkflow "Name"the runtime, which starts a child execution
Sleep stepsleep 24hthe runtime, as a durable timer
Await stepawait reviewthe runtime, which waits for a signal
Compute stepcompute <expression>the runtime, which evaluates the expression (see compute step)

A step also declares its policies: dependencies (after), a condition (when), input (with), retry, timeout, queue_timeout, idempotency_key and compensate. A step depends on every step named in its after clause and on every step whose output it reads. Steps with no dependency between them run concurrently.

A unit is what the runtime actually schedules and tracks. Most steps are exactly one unit, keyed by the step name. Two cases create more:

Unit keyCreated for
reservean ordinary step
reserve[0], reserve[1], …each element of a for step (see fan-out element)
compensate:reserve_inventory, compensate:reserve[2]the compensation of a succeeded unit

Each unit has its own status, attempt counter, timeouts and idempotency key. A unit moves through PENDING → SCHEDULED → DISPATCHED → SUCCEEDED, or ends SKIPPED, FAILED or CANCELLED; it waits in RETRY_WAIT between attempts, in WAITING during a sleep or while waiting for a signal, and a fanned-out step is RUNNING while its elements execute. orbit status prints one row per unit. See Steps and Executions and history.

An attempt is one try at running a unit. retry { attempts: 3 } allows three attempts in total, including the first. A redelivery after an uncertain outcome reuses the same attempt number and does not count as a retry.

Signal

A signal is a typed message that a person or another system sends to a running execution, declared in the workflow with signal <name> { fields }. An await <name> step waits for the next signal of that name, durably, and its payload becomes the step’s output. The runtime validates every payload against the declaration before it records it, and buffers signals that arrive before a step waits for them, delivering them in order. Send one with orbit signal <id> <name> --data '{...}' or POST /v1/executions/{id}/signals/{name}. See Signals and queries.

Query

A query is a named, typed, read-only view of an execution’s state, declared with query <name> = <expression>. The runtime evaluates it on demand with orbit query <id> <name> or GET /v1/executions/{id}/queries/{name}, against the current input and step outputs; steps that have not succeeded read as null, and the compiler types every step output as optional in a query for that reason. A query records nothing and changes nothing. See Signals and queries.

Compute step

A compute step (compute <expression>) is a step that the runtime evaluates itself, with no worker: a deterministic expression over the input and earlier step outputs, whose value is recorded in the history as the step’s output. Use it for small data transformations between tasks, such as a total, a flag or a filtered list; lambdas (x => ...) with map, filter, find, any and all work on lists. See Compute steps.

Execution

An execution is one run of a definition with a specific input. It has an id (a UUID, or a client-chosen id passed with orbit run --id), a status, the state of each unit, and an output or error.

StatusMeaning
RUNNINGSteps are being scheduled and executed.
COMPENSATINGThe execution is aborting and running compensations.
COMPLETEDEvery step finished and return produced the output.
FAILEDA step failed permanently.
CANCELLEDSomeone cancelled the execution (orbit cancel).
TIMED_OUTThe workflow-level timeout elapsed.

A finished execution that aborted also records compensation: none, completed or failed. Starting an execution with an id that already exists returns the existing execution instead of creating a second one.

History and events

The history of an execution is an append-only, ordered list of events, such as StepScheduled, StepDispatched, StepAttemptFailed, RetryScheduled and CompensationStarted. History is the authoritative state. The runtime commits events to the database before acting on them, rebuilds an execution’s state by replaying its events after a restart, and derives every decision from history through a pure function, so the same history always leads to the same decisions.

orbit logs <id> prints the history as a timeline; orbit logs <id> --json prints the raw events. See Executions and history.

Definition, version and hash

Compiling a workflow produces a definition in the intermediate representation (orbit-ir/1, or orbit-ir/2 for workflows that use signals, queries, await or compute steps, lambdas, on handlers or a limits block). Registering it with the runtime (orbit deploy, or implicitly orbit run) stores it under its name@version, together with a SHA-256 definition_hash of its semantic content.

  • Source locations and formatting do not affect the hash, so reformatting a file changes nothing.
  • Registering the same content again is a no-op (up to date).
  • Registering different content under an existing name@version is rejected with DEFINITION_CONFLICT. To change a workflow, change its version.
  • Each execution is pinned to the hash it started with. Deploying a new version never changes running executions.

See Versioning.

Idempotency key

Every task carries an idempotency key: a string that identifies the unit of work and stays the same across retries and redeliveries. By default it is <execution_id>/<unit>, for example 99e811dd-7448-41cc-ac28-bc58d50200d2/compose or order-A-4/reserve[1]. You can set your own with idempotency_key:, such as "charge-" + input.order_id, to deduplicate across executions.

The runtime evaluates the key once, when the unit is scheduled. Because delivery is at least once, a worker can receive the same task more than once; workers that cause side effects should use the key to perform them only once. See Idempotency and Delivery guarantees.

Compensation

A compensation is the business action that undoes a succeeded step, declared in the step’s compensate block. When an execution fails, is cancelled or times out, the runtime:

  1. withdraws work that has not started,
  2. waits for in-flight attempts to finish (they may still succeed),
  3. runs the compensation of every succeeded unit that has one, one at a time, newest first.

Compensations are ordinary units (compensate:<unit>) with their own retries, timeouts and idempotency keys. They are explicit business logic, such as a refund or a release of reserved stock, not a database rollback. See Compensation.

Outcome handler

An outcome handler is a block of steps that runs once compensation has finished, when an execution fails (on failure), is cancelled (on cancel) or times out (on timeout), before the terminal status is recorded. Handler steps read a failure value (reason, code, message) and the outputs of the workflow steps that succeeded. Their units are on_<kind>:<step>, for example on_failure:tell_customer. A failing handler step does not change the execution’s outcome. See Outcome handlers.

Fan-out element

A for step (for item in input.items) runs its task once per list element. Each element is a separate unit, step[i], with its own retries, timeouts and idempotency key (<execution>/step[i]). At most max_parallel elements run at the same time. The step’s output is the list of element outputs in input order. The step succeeds when every element succeeds and fails when any element fails permanently; on compensation, each element that succeeded is compensated individually. See Fan-out.

Child execution

A workflow "Name" step starts another workflow as a child execution. The child has a deterministic id, <parent>.<step> (or <parent>.<step>.<i> inside a for step), so a crash or restart never starts it twice. The child is a full execution with its own history; orbit ps lists it and orbit status <parent>.<step> shows it. Its output becomes the step’s output. If the child fails, is cancelled or times out, the step fails, and cancelling the parent cancels its running children. See Child workflows.

How they relate

ConceptBelongs toIdentified by
Task contracta compilation (any .orbit file)name@version
Workflowa compilationname and version
Definitionthe runtimename@version and definition_hash
Executiona definitionexecution id
Unitan executionunit key (step, step[i], compensate:step, on_failure:step)
Signalan execution’s buffername, and the sequence number of its SignalReceived event
Querya workflowname
Attempta unitattempt number
Eventan execution’s historysequence number (#1, #2, …)
Child executiona parent unit<parent>.<step>
Workerthe runtime’s worker registryworker id

See also

Project layout

This page describes what orbit init generates, how to organize a project as it grows, how imports work, how to change workflows and contracts safely, and how to check ORBIT sources in continuous integration.

What orbit init generates

orbit init my-project

orbit init creates the directory and seven files. It refuses to write into a directory that already exists and is not empty.

my-project/
├── .gitignore
├── README.md
├── workflows/
│   └── hello.orbit
└── workers/
    ├── python/
    │   ├── requirements.txt
    │   └── worker.py
    └── typescript/
        ├── package.json
        └── worker.ts
FileContents
workflows/hello.orbitTwo task contracts, greeting.compose and greeting.decorate (both version 1.0.0), and the workflow Hello@1.0.0, which composes a greeting in Python and decorates it in TypeScript.
workers/python/worker.pyA Python worker with id hello-python that implements greeting.compose.
workers/python/requirements.txtorbit-lang-sdk>=0.1,<0.2
workers/typescript/worker.tsA TypeScript worker with id hello-typescript that implements greeting.decorate. Node.js 22.18 or later runs it directly.
workers/typescript/package.jsonA private ES module package that depends on orbit-lang ^0.1.0.
.gitignoreIgnores node_modules/, __pycache__/, the SQLite database files (*.db, *.db-wal, *.db-shm) and build/ (the default output directory of orbit compile).
README.mdThe commands to install dependencies, check the workflow, start the server and workers, and run the workflow.

The quick start walks through running the generated project.

As a project grows, keep contracts, workflows and workers apart. Contracts are shared by workflows and by the teams that write workers, so they deserve their own files:

my-project/
├── contracts/
│   ├── orders.orbit           schemas and tasks for one domain
│   └── payments.orbit
├── workflows/
│   ├── order_processing.orbit imports the contracts it uses
│   └── fulfilment.orbit
├── workers/
│   ├── python/                one directory per worker program
│   └── typescript/
└── .github/workflows/orbit.yml

Guidelines:

  • Group contracts by domain (payments.*, inventory.*), and name tasks with a dotted prefix. Per-worker credentials grant access by pattern, such as payments.*, so a consistent prefix keeps those rules short.
  • Keep one workflow per file when workflows are independent. Put a parent and the child workflows it starts in the same compilation, either in one file or through imports.
  • Keep workers next to the contracts they implement, or in the repositories of the services that own them. Workers only need the contract names and versions; they do not read .orbit files.

The examples/ directory of the ORBIT repository follows this pattern on a small scale: examples/contracts.orbit holds shared contracts, and examples/order_processing.orbit and examples/fulfilment.orbit import it.

Imports

import brings another file’s declarations into the compilation. The path is resolved relative to the importing file:

// workflows/order_processing.orbit
import "../contracts/orders.orbit"
import "../contracts/payments.orbit"

workflow OrderProcessing {
    version "1.0.0"
    // ...
}

The rules:

  • Every file is loaded once, even when several files import it, so two workflows can import the same contracts file.
  • All declarations share one namespace. Declaring the same schema, task or workflow twice is an error (E0200).
  • Declarations can appear in any order within and across files.
  • A file that cannot be read is reported as E0206.

Pass the workflow files to the CLI; imported files are found automatically:

orbit check workflows/*.orbit
ok Bye@1.0.0 (1 steps)
ok Hello@1.0.0 (1 steps)

A file that contains only contracts checks as ok no workflows (contracts only). See Files and imports.

Versioning workflow changes

Definitions are immutable per name@version, and the runtime enforces it. Deploying changed content under a version that is already registered fails:

error: DEFINITION_CONFLICT (409): Hello@1.0.0 is already registered with a different definition (sha256:505930b4d4668ff2f8cb95e04519d5292ef2fbf0630d575349aaf92c0e2b42be); workflow definitions are immutable, bump the version

Reformatting a file or editing comments does not change the definition hash, so it never causes a conflict.

Changing a workflow

  1. Edit the workflow and bump its version, for example from 1.0.0 to 1.1.0.
  2. Run orbit check.
  3. Deploy it with orbit deploy, or let orbit run deploy it.

Executions that already started keep running on the definition they started with. New executions use the version you ask for: orbit run <file> starts the version in the file, and orbit run <Name> --version <v> starts a specific registered version. orbit run <Name> without --version starts the highest registered version, compared numerically (1.10.0 is higher than 1.9.0).

Changing a task contract

Contract versions are independent of workflow versions. A worker receives a task only when it announced the same task name and contract version that the step uses.

  • Compatible change (for example, a new optional output field that no workflow reads yet): you can keep the contract version.
  • Incompatible change (renamed or retyped fields, a new required input): declare a new contract version, deploy a worker that implements it, then deploy the workflow version that uses it. Keep the worker for the old contract version running until no execution needs it.

An SDK worker registers each task name once, so serve two versions of the same contract from two worker processes with different worker ids. orbit workers list shows which name@version each worker serves.

Using ORBIT in CI

Three commands are useful in a pipeline. All of them work offline; none needs a running server.

CommandFails when
orbit check <files>a file has a compile error (exit status 1)
orbit fmt --check <files>a file is not formatted (exit status 1, and would reformat <file> for each)
orbit compile <files> -o builda file has a compile error; otherwise writes orbit-ir JSON to build/

orbit check --format json prints machine-readable diagnostics with codes, file, line and column, which you can turn into review annotations:

{
  "diagnostics": [
    {
      "severity": "error",
      "code": "E0405",
      "message": "value may be `null` but `String` is required",
      "location": { "file": "null.orbit", "line": 33, "column": 32 },
      "end_line": 33,
      "end_column": 53,
      "labels": [],
      "notes": [],
      "help": "provide a fallback with `coalesce(value, default)`, or declare the target as optional with `?`"
    }
  ]
}

A GitHub Actions job that installs the CLI with the install script and checks every source file:

name: orbit
on: [push, pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install orbit
        run: |
          curl -fsSL https://github.com/AjmalX0/Orbit/releases/latest/download/install.sh \
            | ORBIT_VERSION=v0.1.0 sh
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"
      - run: orbit check workflows/*.orbit
      - run: orbit fmt --check workflows/*.orbit contracts/*.orbit

Pin ORBIT_VERSION so that a new release does not change your pipeline unexpectedly.

To deploy from CI, compile once and deploy the IR artifacts. orbit validate checks compiled .json files, and orbit deploy accepts them as well as .orbit sources:

orbit compile workflows/*.orbit -o build
orbit validate build/*.json
ORBIT_SERVER=https://orbit.internal:7233 ORBIT_TOKEN="$ORBIT_ADMIN_TOKEN" orbit deploy build/*.json
compiled Bye@1.0.0 -> build/bye.orbit.json
compiled Hello@1.0.0 -> build/hello.orbit.json
ok build/bye.orbit.json is valid orbit-ir/1 (Bye@1.0.0, sha256:f8818e42acc5fa28a7e881b49abee1adc6febd65df6dd0d6c48b911b7ef97150)
ok build/hello.orbit.json is valid orbit-ir/1 (Hello@1.0.0, sha256:317903c71c7f843f965f75bde4e69cd36df4a05f16ee8a244236b31383902d65)
deployed Bye@1.0.0 (sha256:f8818e42acc5)
deployed Hello@1.0.0 (sha256:317903c71c7f)

Running orbit deploy again prints up to date for each definition that is already registered with the same content.

orbit deploy exits with status 1 on a DEFINITION_CONFLICT, which catches a workflow that changed without a version bump.

See also

Files and imports

This page describes how ORBIT source is organized on disk: what a source file contains, how import combines files into one compilation, how names are shared between files, and how comments and whitespace work.

Source files

ORBIT source files are UTF-8 text files with the .orbit extension. A file contains any number of top-level declarations, in any order:

DeclarationPurposePage
schema Name { ... }A named record typeSchemas
task "name" { ... }A worker contractTask contracts
workflow Name { ... }A workflow definitionWorkflows
import "path.orbit"Include another file in the compilationImports
test "name" { ... }A test of a workflow, run by orbit testTesting workflows

Declaration order does not matter. A workflow can use a schema or task that is declared further down the same file, or in a file that is imported later.

Test declarations are type-checked with the rest of the compilation, so orbit check reports their errors, but they never become part of the compiled output: orbit compile, orbit deploy and orbit run ignore them. A common convention is to keep a workflow’s tests in a separate <name>.test.orbit file that imports the workflow file.

Anything else at the top level is a syntax error, E0104:

workflow Example {
    version "1.0.0"
}

step orphan { sleep 1s }
error[E0104]: expected a declaration, found `step`
 --> example.orbit:5:1
  |
5 | step orphan { sleep 1s }
  | ^^^^
  = note: top-level declarations are `workflow`, `schema`, `task`, `import` and `test`

error: compilation failed: 1 error(s), 0 warning(s)

Imports

import takes a string literal with a path. The path is resolved relative to the directory of the file that contains the import, not relative to the current working directory.

// workflows/orders.orbit
import "../contracts/payments.orbit"
import "shared.orbit"            // workflows/shared.orbit

The compiler loads files as follows:

  • Every file is loaded once, even if several files import it. Files are identified by their canonical path, so a/../b.orbit and b.orbit are the same file.

  • Import cycles are allowed. If a.orbit imports b.orbit and b.orbit imports a.orbit, each file is still loaded once.

  • An import that cannot be read is reported as E0206 at the import declaration:

    error[E0206]: cannot import `missing.orbit`: No such file or directory (os error 2)
     --> main.orbit:1:1
      |
    1 | import "missing.orbit"
      | ^^^^^^^^^^^^^^^^^^^^^^
    

There is no module system and no selective import: an import brings every declaration of the imported file, and of the files it imports, into the compilation.

One namespace

All files in a compilation share a single namespace for each kind of declaration:

  • schema names,
  • task names (strings such as "payments.charge"),
  • workflow names.

Declaring the same schema, task or workflow twice, in the same file or in different files, is an error (E0200), with a label pointing at the first declaration. The three kinds have separate namespaces, so a schema and a workflow may share a name.

The built-in prelude is part of every compilation. It declares the task orbit.http.request (see HTTP adapter). Task names that start with orbit. are reserved for built-in tasks, and declaring one is E0200.

Compiling several files

orbit check, orbit compile and orbit deploy accept one or more files; orbit run and orbit graph take one file. The files you name, plus everything they import, form one compilation:

orbit check workflows/orders.orbit
orbit check contracts.orbit workflows/*.orbit

Workflows declared in imported files are compiled too. If main.orbit imports lib.orbit and both declare workflows, orbit check main.orbit reports both:

ok Lib@1.0.0 (0 steps)
ok Main@1.0.0 (1 steps)

A common layout keeps task contracts and shared schemas in one file that every workflow file imports. See Project layout.

Comments

ORBIT has two kinds of comments:

  • Line comments start with // and run to the end of the line.
  • Block comments start with /* and end with */. Block comments nest, so you can comment out a region that already contains a block comment.
/* Contracts for the payments service.
   /* Nested comments are allowed. */
*/
task "payments.authorize" {
    version "1.0.0"                       // the contract version
    input { order_id: String, amount: Float }
    output { authorization_id: String }
}

An unterminated block comment is E0003. Comments are kept by orbit fmt.

Whitespace, newlines and separators

Spaces, tabs and newlines are insignificant, with one exception: an infix operator must be on the same line as its left operand. This rule lets you write one field per line without commas. It is described in Expressions.

Commas and semicolons between members, fields and object entries are optional. These three declarations are equivalent:

schema Point { x: Int, y: Int }
schema Point2 { x: Int; y: Int }
schema Point3 {
    x: Int
    y: Int
}

List literals are the exception: items are separated by commas or by line breaks, and a semicolon is not accepted between list items.

Keywords are contextual

Apart from true, false and null, ORBIT has no reserved words. A word such as step, input, version or timeout acts as a keyword only where the grammar expects that keyword. Everywhere else it is an ordinary identifier, so it can be a field name or even a declaration name. See Grammar for the full list and the few names that are reserved in specific positions.

See also

Types

ORBIT is statically typed. The compiler checks every value that flows between the workflow input, task contracts, step outputs, child workflows and the workflow result before anything runs, and the runtime validates payloads again at every boundary. This page lists every type, its JSON encoding, how to write values of it, and the compatibility rules the type checker applies.

Primitive types

TypeValuesJSON encodingHow to write a value
StringUnicode textstring"text"
Intsigned 64-bit integerinteger42, -7, 1_000_000
Float64-bit floating pointnumber (integers accepted)3.14, -0.5; an Int also converts
Booltrue or falsebooleantrue, false
Bytesbinary database64 string (standard alphabet, padded)no literal; comes from input or step output
Durationa length of timenon-negative integer, in milliseconds500ms, 30s, 1.5m, 2h, 1d
Timestampa point in timeRFC 3339 stringa string literal such as "2026-01-31T09:00:00Z"
UUIDa UUIDhyphenated string, 8-4-4-4-12 hex digitsa string literal such as "123e4567-e89b-12d3-a456-426614174000"
JSONany JSON valueanyany expression

Details:

  • Int. Integer literals that do not fit in 64 bits are E0006. Arithmetic that overflows at runtime is an expression error, not a wrap-around.
  • Duration. A duration literal is a number followed by a unit: ms, s, m (minutes), h or d. Fractional values are allowed and rounded to the nearest millisecond (1.5s is 1500 ms). A bare number where a duration is required is E0101, and an unknown unit is E0005. Duration supports no operators: you cannot add or compare durations.
  • Timestamp. The accepted form is YYYY-MM-DDTHH:MM:SS[.fraction](Z|±HH:MM). The T may also be t or a space, and Z may be z. A string literal checked against Timestamp is validated at compile time (E0400).
  • UUID. 36 characters, hyphens at positions 9, 14, 19 and 24, hexadecimal digits in either case elsewhere. String literals checked against UUID are validated at compile time (E0400).
  • Bytes. A string literal is a String, not Bytes, so passing "aGVsbG8=" where Bytes is required is E0400. Pass Bytes values through from the workflow input or a step output, or declare the field as String if you build the encoding yourself.
  • String to UUID or Timestamp. Only literals are converted. A String value read from input or a step output is not accepted where UUID or Timestamp is required; declare the source field with the precise type.

Composite types

TypeValuesJSON encoding
List<T>ordered list of Tarray
Map<String, T>string keys to T valuesobject
T?a T or nullnull or the encoding of T
{ a: Int, b: String }anonymous recordobject
Namethe record declared by schema Nameobject
  • Map keys must be String, because maps are JSON objects. Map<Int, String> is E0204.
  • T?? is the same type as T?.
  • Record types, named or anonymous, list fields with optional defaults. See Schemas.
  • Type names are case-sensitive. An unknown type name is E0201, with a suggestion when a known type is close. List and Map without type arguments are E0201 too.
schema Shipment {
    id: UUID
    created_at: Timestamp
    packages: List<{ sku: String, weight_kg: Float }>
    labels: Map<String, String>
    note: String?
    raw: JSON
}

Optional types and null

T? means “a T, or null”. Three things produce optional values:

  • fields declared T?,
  • references to a step that has a when condition, because the step may be skipped (see Conditions),
  • indexing a list or map, because the element may be missing.

The literal null has its own type, written null in diagnostics. It is accepted only where an optional type (or JSON) is expected.

A value of type T? is not accepted where T is required. The compiler reports E0405 and suggests coalesce:

task "users.notify" {
    input { name: String }
    output { ok: Bool }
}

workflow Notify {
    version "1.0.0"
    input { name: String? }
    step send { worker "users.notify" with { name: input.name } }
}
error[E0405]: value may be `null` but `String` is required
 --> notify.orbit:9:52
  |
9 |     step send { worker "users.notify" with { name: input.name } }
  |                                                    ^^^^^^^^^^
  = help: provide a fallback with `coalesce(value, default)`, or declare the target as optional with `?`

error: compilation failed: 1 error(s), 0 warning(s)

Fix it with a fallback, name: coalesce(input.name, "customer"), or make the contract field optional.

The JSON type

JSON is the dynamic type. Any value is accepted where JSON is expected, and a JSON value is accepted wherever any type is expected. Member access and indexing on JSON produce JSON, and operators that involve a JSON operand are not checked at compile time. The runtime still validates payloads against contracts, so a wrong value surfaces as INPUT_CONTRACT_VIOLATION or OUTPUT_CONTRACT_VIOLATION when the step runs.

JSON counts as optional: it may hold null.

Compatibility rules

The type checker asks one question everywhere: can a value of type S be used where type D is expected? The rules are:

RuleExample
Every type is compatible with itself.String → String
JSON is compatible in both directions.Int → JSON, JSON → Int
null is compatible with any optional type.null → String?
T is compatible with T?.Int → Int?
T? is not compatible with T (E0405).Int? → Int is rejected
Int widens to Float. The reverse is rejected.Int → Float
Lists and maps are compatible when their element types are.List<Int> → List<Float>
A record is compatible with a record type when every field the target requires is present and compatible.see below

A mismatch is E0400, with a note that names the field or element at fault when the types are nested.

Record width subtyping

Records are structural: the schema name does not matter, only the fields. A record with more fields than the target is accepted. A field the target declares as optional (T?) or with a default may be missing from the source. This lets you pass a whole step output or input record to a contract that only needs part of it:

task "crm.lookup" {
    input { customer: { id: String } }
    output { tier: String }
}

workflow Lookup {
    version "1.0.0"
    input { customer: { id: String, email: String, name: String } }
    step lookup { worker "crm.lookup" with { customer: input.customer } }
}

Object literals are exact

An object literal written directly where a record type is expected must not contain fields that the record does not declare. Extra fields there are almost always typos, so the compiler reports E0404 and suggests the closest field:

task "crm.lookup" {
    input { customer_id: String }
    output { tier: String }
}

workflow Lookup {
    version "1.0.0"
    input { id: String }
    step lookup { worker "crm.lookup" with { customer_id: input.id, cutomer_tier: "gold" } }
}
error[E0404]: unknown field `cutomer_tier` for `{ customer_id: String }`
 --> lookup.orbit:9:69
  |
9 |     step lookup { worker "crm.lookup" with { customer_id: input.id, cutomer_tier: "gold" } }
  |                                                                     ^^^^^^^^^^^^
  = help: did you mean `customer_id`?

error: compilation failed: 1 error(s), 0 warning(s)

Missing required fields in an object literal are E0403, which lists every missing field with its type.

Type inference

Expressions without an expected type are inferred:

ExpressionInferred type
{ a: 1, b: "x" }{ a: Int, b: String }
{ a: null }{ a: JSON } (a null field is typed JSON)
[1, 2.5]List<Float>
[]List<JSON>
[1, "a"]E0400: list items have incompatible types
if c then "a" else nullString?
if c then 1 else 2.5Float

When two types have to be combined (list items, if branches, coalesce arguments), the compiler uses the smallest type that both fit into: equal types stay the same, null with T gives T?, Int with Float gives Float, JSON with anything gives JSON, and of two compatible records the one with fewer fields wins. Other combinations are E0400.

A workflow without an output declaration takes the inferred type of its return expression as its output type. See Workflows.

Runtime validation

Values cross process boundaries as JSON, so the runtime validates them against the declared types:

BoundaryDefaults appliedUnknown fieldsFailure
Execution input (start request)yesrejectedrequest fails with INVALID_INPUT
Child workflow inputyesallowedstep fails with CHILD_START_FAILED
Task input, after evaluating withyesallowedstep fails with INPUT_CONTRACT_VIOLATION
Worker outputyesallowedattempt fails with OUTPUT_CONTRACT_VIOLATION (not retried)
Workflow result, after evaluating returnnoallowedworkflow fails with OUTPUT_CONTRACT_VIOLATION

A worker that returns null as its output is treated as returning {}. Validation of Float accepts any JSON number. Validation of Int requires an integer, and validation of Duration requires a non-negative integer.

See also

Schemas

A schema is a named record type. You declare schemas for data that several contracts or workflows share, such as an order line or a payment request. This page covers the schema syntax, required and optional fields, default values, and the rules the compiler enforces on schema declarations.

Declaring a schema

schema LineItem {
    sku: String
    quantity: Int = 1          // default used when the field is absent
    note: String?              // optional: may be null or missing
}

A schema has a name (an identifier) and a list of fields. Each field has a name, a type, and optionally a default value after =. Fields can be separated by newlines, commas or semicolons.

Schemas can use any type, including other schemas and anonymous records:

schema Address {
    line1: String
    city: String
    country: String = "US"
}

schema Customer {
    id: String
    email: String
    shipping: Address
    billing: Address?
    preferences: { newsletter: Bool = false, language: String = "en" }
}

A schema is a type, so you use its name anywhere a type is expected: in task input and output, in workflow input and output fields, inside List<> and Map<String, >, and in other schemas.

Required, optional and defaulted fields

Every field is in one of three states:

DeclarationRequired in a value?If absent
name: Tyesthe value is invalid (E0403 at compile time, a validation error at runtime)
name: T?nothe field is absent; reading it gives null
name: T = valuenothe runtime inserts the default

A field can be both optional and defaulted (retries: Int? = 3), in which case the default is applied when the field is absent, and an explicit null is kept.

The runtime applies defaults, recursively through nested records, lists and maps, whenever it accepts a value of the type: execution input, task input after with is evaluated, and worker output. Workers therefore always see the defaulted fields, and a workflow can rely on a defaulted output field even if an older worker does not send it.

Default values

A default must be a constant expression of the field’s type. It may use literals, object and list literals, operators, if and the built-in functions, but not input, execution or step references. The compiler evaluates the default once and stores the result in the compiled IR.

schema RetryOptions {
    max_attempts: Int = 3
    window_ms: Int = 60 * 1000
    labels: Map<String, String> = { team: "payments" }
    tags: List<String> = []
    owner: String? = null
}

The compiler reports:

  • a reference to input, execution or a step: E0401, for example error[E0401]: `input` cannot be used in a default value;
  • a value of the wrong type: E0400; for example name: String = null is rejected because null is not a String (declare String? instead);
  • an expression that fails when evaluated, such as 1 / 0: E0205.

Rules the compiler enforces

CodeRule
E0200A schema name is declared only once in the compilation.
E0200A schema cannot be named after a built-in type (String, Int, Float, Bool, Bytes, Duration, Timestamp, UUID, JSON, List, Map).
E0203A field name appears only once in a record.
E0202Schemas cannot be recursive, directly or through other schemas, even through an optional field.
E0201Every type a field uses must exist.

Errors in schemas that no workflow uses are still reported.

A recursive schema is rejected with the cycle:

schema Category {
    name: String
    parent: Category?
}
error[E0202]: recursive schema `Category`
 --> category.orbit:3:13
  |
3 |     parent: Category?
  |             ^^^^^^^^
  = note: cycle: Category -> Category
  = help: schemas must be finite; break the cycle with a `JSON` or `String` id field

error: compilation failed: 1 error(s), 0 warning(s)

Model the relationship with an identifier instead, for example parent_id: String?.

Schemas are structural

Schemas are compared by their fields, not by their names. Two schemas with the same fields are interchangeable, and a record with extra fields is accepted where a schema with fewer fields is expected (see record width subtyping). The schema name appears in diagnostics and in the compiled IR, where schemas are inlined.

Anonymous records

Wherever a type is expected, you can write a record type inline instead of naming it:

task "risk.score" {
    version "1.0.0"
    input { customer_id: String, amount: Float }
    output { score: Float, reasons: List<String> }
}

Inline records follow the same rules as schemas, including defaults and optional fields. Workflow input and output blocks are inline records too.

See also

Task contracts

A task contract declares a unit of work that workers implement: its name, its version, and the types of its input and output. Workflows call tasks by name, and the compiler checks every call against the contract. Workers in any language register against the same contract. This page covers the task declaration, how the compiler uses it, and how the runtime enforces it.

Declaring a task

schema PaymentRequest {
    order_id: String
    amount: Float
    currency: String = "USD"
}

task "payments.charge" {
    version "1.0.0"
    description "Charge a customer's card"
    input PaymentRequest                    // a schema...
    output { transaction_id: String }       // ...or an inline record
}

The name is a string literal. By convention it is a dotted, lowercase service.action name, but any string is allowed except names starting with orbit., which are reserved for built-in tasks.

Members

MemberTypeDefaultMeaning
versionstring"1.0.0"The contract version. Workers receive only tasks whose name and contract version they announced. A version that is not MAJOR.MINOR.PATCH produces warning W0201.
descriptionstringnoneHuman-readable description, shown by tooling.
inputrecord type{}The type of the value the workflow passes in with.
outputrecord type{}The type of the value the worker returns.
compensated_bystringnoneThe task that undoes this one. Steps that call this task without a compensation that calls the named task get a warning (W0601, W0602). See Declaring how a task is undone.

Members can appear in any order, each may be followed by an optional : (version: "1.0.0"), and separators between them are optional.

input and output must be record types: a schema name, an inline record, or JSON. Any other type is E0310:

task "math.square" {
    input Int
    output { value: Int }
}
error[E0310]: task input must be a record type, found `Int`
 --> square.orbit:2:11
  |
2 |     input Int
  |           ^^^

error: compilation failed: 1 error(s), 0 warning(s)

Wrap scalar values in a record (input { value: Int }). A task with no input takes no input, so a step can call it without with.

Rules the compiler enforces

CodeRule
E0200A task name is declared once in the compilation, including imported files.
E0200A task name cannot start with orbit..
E0310input and output are record types or JSON.
W0201version is MAJOR.MINOR.PATCH, optionally with -pre or +build suffixes.
E0300A step’s worker "name" refers to a declared task. The diagnostic suggests the closest name.
E0316compensated_by names a declared task. The diagnostic suggests the closest name.
W0601A step that calls a task with compensated_by has a compensate block.
W0602That compensate block calls the task named by compensated_by.

Declaring how a task is undone

compensated_by records, on the contract, which task reverses its effect:

task "inventory.reserve" {
    version "1.0.0"
    input { order_id: String, quantity: Int }
    output { reservation_id: String }
    compensated_by "inventory.release"
}

task "inventory.release" {
    version "1.0.0"
    input { reservation_id: String }
    output { released: Bool }
}

workflow Reserve {
    version "1.0.0"
    input { order_id: String, quantity: Int }

    step reserve {
        worker "inventory.reserve"
        with { order_id: input.order_id, quantity: input.quantity }
        compensate {
            worker "inventory.release"
            with { reservation_id: reserve.reservation_id }
        }
    }
}

The contract author knows which tasks have effects that must be undone; the compiler then warns every workflow author who calls the task without the matching compensation. The runtime does not use compensated_by: a step is compensated only by its own compensate block. Like the rest of the contract, compensated_by is part of the definition hash of every workflow that uses the task. See Static safety checks.

How the compiler uses a contract

When a step runs a task with worker "payments.charge":

  • The step’s with expression is checked against the contract input. Object literals must provide every required field (E0403) and must not contain unknown fields (E0404). See Types.
  • A step without with is accepted only if the contract input has no required fields. Otherwise the compiler reports E0403 and lists the missing fields.
  • The contract output is the type other expressions see when they reference the step, for example charge.transaction_id: String.
task "payments.charge" {
    version "1.0.0"
    input { order_id: String, amount: Float }
    output { transaction_id: String }
}

workflow Pay {
    version "1.0.0"
    input { order_id: String, amount: Float }

    step charge {
        worker "payments.charge"
        with { order_id: input.order_id, amount: input.amount }
    }

    return { transaction_id: charge.transaction_id }
}

The compiled IR embeds every contract a workflow uses, so the runtime does not need the source files.

How the runtime enforces a contract

The runtime validates the data on both sides of every task:

  1. Input. When the step is scheduled, the runtime evaluates with, applies the contract’s input defaults, and validates the result. Unknown fields are allowed. An invalid value fails the step with the non-retryable error INPUT_CONTRACT_VIOLATION.
  2. Output. When a worker reports success, the runtime applies the output defaults and validates the result. Unknown fields are allowed, so a worker can add fields without breaking older workflows. An invalid result fails the attempt with OUTPUT_CONTRACT_VIOLATION, which is not retried. A null result counts as {}.

Each task is routed to a worker that announced the same task name and the same contract version. A step’s using <language> narrows that further to workers whose runtime matches (case-insensitively); using any matches every worker. See How workers work.

Changing a contract

A contract is part of every workflow definition that uses it, and it contributes to the workflow’s definition hash. Changing a contract, even only its output, changes the hash of every workflow that uses it, so you must bump those workflows’ versions before you register them again. Bump the contract version when the change is incompatible for workers, so that old workers stop receiving the task. See Versioning.

Built-in tasks

Built-in tasks run inside the runtime instead of on a worker. There is one, orbit.http.request, declared in the prelude that every compilation includes:

workflow Ping {
    version "1.0.0"
    step probe {
        worker "orbit.http.request"
        with { url: "https://status.example.com/health" }
    }
    return { status: probe.status }
}

See HTTP adapter.

See also

Workflows

A workflow declares what should happen, in which order, and with which guarantees: its typed input, the steps that do the work, and the result it returns. The compiler turns each workflow into an immutable, versioned definition that the runtime executes durably. This page covers the workflow declaration and each of its members.

Declaring a workflow

workflow OrderProcessing {
    version "1.0.0"
    description "Validate, charge and confirm one order"
    timeout 5m

    input {
        order_id: String
        customer_id: String
        amount: Float
        currency: String = "USD"
        items: List<LineItem>
    }

    output {
        status: String
        transaction_id: String
    }

    step validate {
        worker "orders.validate"
        with { order_id: input.order_id, amount: input.amount, items: input.items }
    }

    step charge {
        worker "payments.charge"
        after validate
        with { order_id: input.order_id, amount: input.amount, currency: input.currency }
    }

    return { status: "completed", transaction_id: charge.transaction_id }
}

The workflow name is an identifier. It is unique in the compilation (E0200) and is what you pass to orbit run and to the start API.

Members

MemberSyntaxRequiredDefaultMeaning
versionversion "1.0.0"yes (E0500)noneThe definition version. name@version is immutable once registered.
descriptiondescription "..."nononeHuman-readable description, shown by tooling.
timeouttimeout 5mnono limitDeadline for the whole execution, measured from its creation.
inputinput { fields }no{}The type of the execution input.
outputoutput { fields }noinferred from returnThe type of the execution result.
stepstep name { ... }nononeA unit of work. See Steps.
signalsignal name { fields }nononeA typed message the execution accepts; await steps receive it. See Signals and queries.
queryquery name = expressionnononeA typed, read-only view of the execution state, evaluated on demand. See Signals and queries.
returnreturn expressionwhen output has required fields (E0501){}The result of a successful execution.
continue_as_newcontinue_as_new when cond with { ... }nononeWhen every step has finished and cond holds, end the run and start a new run of the execution with this input and an empty history, instead of returning. See Continue-as-new.
on failureon failure { steps }nononeSteps that run after compensation when a step fails permanently or a resource limit is reached. See Outcome handlers.
on cancelon cancel { steps }nononeSteps that run after compensation when the execution is cancelled.
on timeouton timeout { steps }nononeSteps that run after compensation when the workflow timeout passes.
on eventon event signal { steps }nononeSteps that run every time that signal arrives, alongside the main steps; they read the payload as event. See Event handlers.
limitslimits { max_fan_out: 100 }nothe server’s limitsStricter fan-out, history and payload limits for this workflow’s executions. See Per-workflow limits.

Members can appear in any order. version, description and timeout accept an optional : after the keyword. input, output and signal name are followed directly by a record body. A workflow can declare any number of steps, signals and queries; step, signal and query names are separate namespaces, each unique within the workflow (E0302, E0317, E0318).

A workflow has at most one on block of each kind, and one on event block per signal (E0319); any other kind, such as on success, is rejected with E0105. Handler blocks contain only step declarations. Use compensation to undo work, and outcome handlers for what should happen after it, such as notifying a customer.

Version

Every workflow declares a version. A version that is not MAJOR.MINOR.PATCH (optionally with a -pre or +build suffix) produces warning W0201. A missing version is an error:

workflow Nightly {
    step pause { sleep 1h }
}
error[E0500]: workflow `Nightly` has no version
 --> nightly.orbit:1:10
  |
1 | workflow Nightly {
  |          ^^^^^^^
  = help: add `version "1.0.0"`; running executions stay pinned to the version they started with

error: compilation failed: 1 error(s), 0 warning(s)

See Versioning for how versions and definition hashes interact.

Input

input declares the fields an execution starts with, using the same field syntax as a schema. Expressions read them as input.<field>.

When an execution starts, the runtime:

  1. treats a missing or null input as {},
  2. applies the declared defaults, recursively,
  3. validates the result strictly: missing required fields, values of the wrong type and fields the workflow does not declare are rejected.

A rejected input fails the start request with INVALID_INPUT, and no execution is created:

orbit run order.orbit --input '{"order_id": "A-1", "amount": "12"}'
error: INVALID_INPUT (400): workflow input is invalid at $.amount: expected Float, found a string

Child workflows started by a workflow step get their defaults applied too, but their input is validated leniently, because the parent’s with expression was already type-checked against the child’s input.

Output and return

return maps step results to the workflow result. It is evaluated once, after every step has finished (succeeded or been skipped), and it can read input, execution and every step.

  • With output and return, the return expression is checked against output, with the same rules as a task input: object literals must be exact (E0404) and complete (E0403).
  • With output but no return, the result is {}, which is accepted only if output has no required fields. Otherwise the compiler reports E0501.
  • With return but no output, the output type is the inferred type of the return expression.
  • With neither, the result is {}.
workflow Quote {
    version "1.0.0"
    input { order_id: String, items: Int }

    step quote {
        worker "shipping.quote"
        with { order_id: input.order_id, items: input.items }
    }

    // No `output` block: the output type is inferred as
    // { carrier: String, cost: Float, expensive: Bool }.
    return { carrier: quote.carrier, cost: quote.cost, expensive: quote.cost > 50.0 }
}

The runtime validates the result against the output type before it completes the execution. A failure there, or an expression error while evaluating return, fails the workflow with OUTPUT_CONTRACT_VIOLATION or EXPRESSION_ERROR. No compensation runs in that case, because every step succeeded.

Timeout

The workflow timeout is a deadline for the whole execution, counted from the moment the execution was created. When it passes, the runtime:

  1. withdraws steps that have not started,
  2. waits for attempts that are in flight,
  3. runs the compensations of the steps that succeeded,
  4. ends the execution as TIMED_OUT.

Step timeouts, retries, sleeps and waits for signals all count against the workflow deadline. A workflow timeout also bounds every await step that has no timeout of its own (otherwise the compiler reports W0606). Compensations run after the deadline and are not limited by it. See Retries and timeouts.

Signals and queries

A workflow can declare the signals it accepts and the queries it answers:

workflow Approval {
    version "1.0.0"
    timeout 14d
    input { request_id: String }

    signal review { approved: Bool, reviewer: String }

    step decision { await review }

    query status = { decided: !is_null(decision), approved: decision.approved }

    return { approved: decision.approved }
}

orbit signal <id> review --data '{...}' sends a validated payload, which the decision step receives as its output. orbit query <id> status evaluates the query against the current state; in a query every step output is optional, because the step may not have run yet. A workflow that declares a signal or a query compiles to orbit-ir/2. See Signals and queries.

Execution lifecycle

An execution is RUNNING until it completes, or until it is aborted by a step failure, a cancellation or the workflow timeout:

RUNNING ──► COMPLETED
   │
   └──► COMPENSATING ──► FAILED | CANCELLED | TIMED_OUT

A failed, cancelled or timed-out execution records a compensation outcome: none (nothing to compensate), completed or failed. See Compensation and Executions and history.

Execution metadata

Expressions can read three fields of the running execution:

ExpressionTypeValue
execution.idStringthe execution id
execution.workflowStringthe workflow name
execution.versionStringthe workflow version

These are often used to build idempotency keys or correlation ids:

workflow Welcome {
    version "1.0.0"
    input { email: String }
    step send {
        worker "emails.send"
        with { to: input.email, template: "welcome" }
        idempotency_key: "welcome-" + execution.id
    }
}

See also

Steps

A step is one unit of work in a workflow: a task run by a worker, a child workflow, a durable timer, a wait for a signal, or a value computed by the runtime. Each step declares what it does, what it needs, and the policies that apply to it. This page is the reference for the step block: its actions, every member, where each member is allowed, and the type other expressions see when they reference the step.

Anatomy of a step

step process_payment {
    worker "payments.charge"          // the action: a task contract
    using python                      // only route to Python workers
    after reserve_inventory           // explicit ordering
    when analyze_risk.score < 0.8     // run only if true, otherwise skip
    with {                            // the task input, checked against the contract
        order_id: input.order_id
        amount: input.amount
        currency: input.currency
    }
    idempotency_key: "charge-" + input.order_id
    timeout 30s                       // per attempt
    queue_timeout 2m                  // fail if no worker picks it up in time
    retry {
        attempts: 5                   // total attempts, including the first
        backoff: exponential
        initial_delay: 500ms
        max_delay: 10s
        non_retryable: ["CARD_DECLINED"]
    }
    compensate {
        worker "payments.refund"
        with { transaction_id: process_payment.transaction_id, amount: input.amount }
    }
}

The step name is an identifier. It is unique within the workflow (E0302), and expressions use it to read the step’s output (process_payment.transaction_id). Members can appear in any order, and separators between them are optional.

Actions

Every step has exactly one action:

ActionSyntaxWhat runs
Taskworker "task.name"A task, executed by a worker that implements the contract, or by the runtime for built-in tasks.
Child workflowworkflow "Name"Another workflow, as a durable child execution. See Child workflows.
Timersleep 10sA durable timer. See Durable sleep.
Signal waitawait reviewWaits for the next review signal sent to the execution; the payload is the output. See Signals and queries.
Computecompute <expression>Evaluates an expression in the runtime; the value is the output. No worker. See Compute steps.

A step without an action is E0301. A step with two actions is E0106:

task "reports.build" {
    output { url: String }
}

workflow Report {
    version "1.0.0"
    step build {
        worker "reports.build"
        sleep 10s
    }
}
error[E0106]: a step can only have one `worker`, `workflow`, `sleep`, `await` or `compute`
 --> report.orbit:9:9
  |
9 |         sleep 10s
  |         ^^^^^
  = help: split the work into separate steps

error: compilation failed: 1 error(s), 0 warning(s)

Members

MemberSyntaxDefaultMeaningPage
workerworker "name"Run a task.Task contracts
workflowworkflow "Name"Start a child workflow.Child workflows
sleepsleep DURATIONWait durably.Durable sleep
awaitawait SIGNALWait for a signal declared in the workflow.Signals and queries
computecompute EXPREvaluate an expression in the runtime.Compute steps
withwith EXPR{}The input passed to the task or child workflow.below
usingusing LANGUAGEany workerRoute the task only to workers of this runtime.below
afterafter a, bnoneRun after these steps.Dependencies
whenwhen EXPRalways runRun only if the condition is true; otherwise skip.Conditions
forfor x in EXPRnoneRun once per list element.Fan-out
max_parallelmax_parallel NunlimitedConcurrency limit for a for step, 1 to 10000.Fan-out
timeouttimeout DURATIONno limitLimit for one attempt, from dispatch to result. On an await step: limit for the wait.Retries and timeouts
queue_timeoutqueue_timeout DURATIONno limitLimit for waiting for a worker.Retries and timeouts
retryretry { ... }one attemptRetry policy.Retries and timeouts
idempotency_keyidempotency_key EXPR<execution id>/<unit>The key workers use to deduplicate side effects.Idempotency
compensatecompensate { ... }noneThe action that undoes this step.Compensation

Syntax notes:

  • Every member except retry, compensate and for accepts an optional : after the keyword: timeout: 30s is the same as timeout 30s. retry and compensate are followed directly by {, and retry: { is a syntax error.

  • DURATION is a duration literal such as 30s. Expressions are not accepted where a duration is required.

  • after takes a comma-separated list of step names. You can repeat after; the lists are combined.

  • Every other member may appear at most once per step (E0106).

  • An unknown member is E0102, with a suggestion for close misspellings:

    task "reports.build" {
        output { url: String }
    }
    
    workflow Report {
        version "1.0.0"
        step build {
            wroker "reports.build"
        }
    }
    
    error[E0301]: step `build` does nothing
     --> report.orbit:7:10
      |
    7 |     step build {
      |          ^^^^^
      = help: add `worker "task.name"`, `workflow "Name"`, `sleep 10s`, `await <signal>` or `compute <expression>`
    
    error[E0102]: unexpected `wroker` in step
     --> report.orbit:8:9
      |
    8 |         wroker "reports.build"
      |         ^^^^^^
      = help: did you mean `worker`?
    
    error: compilation failed: 2 error(s), 0 warning(s)
    

Where members are allowed

Not every member makes sense for every kind of step. The compiler rejects misplaced members with E0307 (or E0308 for compensation):

MemberTask stepWorkflow stepSleep stepAwait stepCompute stepcompensate block
withyesyesnononoyes
usingyesnonononoyes
afteryesyesyesyesyesno
whenyesyesyesyesyesno
foryesyesnonoyesno
max_parallelwith forwith fornonowith forno
timeoutyesyesnoyesnoyes
queue_timeoutyesnonononoyes
retryyesnonononoyes
idempotency_keyyesnonononoyes
compensateyesyesno (E0308)no (E0308)no (E0308)not allowed (E0102)

A compensate block must call a task with worker; sleep, workflow, await or compute there is E0308.

Step names

Step names are identifiers. Some names are reserved because expressions use them for other purposes, and declaring a step with one of them is E0306:

input, execution, output, if, then, else

Input: with

with is an expression whose value becomes the task input (or the child workflow input). It is usually an object literal, but any expression of a compatible type works:

schema PaymentRequest {
    order_id: String
    amount: Float
    currency: String
}

task "payments.authorize" {
    input PaymentRequest
    output { authorization_id: String }
}

workflow Authorize {
    version "1.0.0"
    input { payment: PaymentRequest, note: String? }

    // Pass a whole record from the input.
    step authorize { worker "payments.authorize" with input.payment }
}

The expression is checked against the contract input (see Types). It is evaluated once, when the step is scheduled, and the result is stored in the execution history. Retries and redeliveries reuse the stored value. If evaluation fails at runtime, for example because of a division by zero, the step fails with the non-retryable error EXPRESSION_ERROR.

A step without with passes {}. That is accepted only when the contract input has no required fields; otherwise the compiler reports E0403.

Reading another step’s output in with makes this step depend on that step. See Dependencies.

Routing: using

using restricts which workers receive the task: only workers that announce the given runtime are eligible. The value is an identifier, compared case-insensitively with the runtime a worker announces. using any places no restriction.

The compiler knows these runtime names: python, typescript, javascript, go, rust, java, kotlin, csharp, ruby, php, elixir, swift, cpp, http and any. Any other name produces warning W0301, with a suggestion when the name is close to a known one:

task "risk.analyze" {
    input { customer_id: String }
    output { score: Float }
}

workflow Risk {
    version "1.0.0"
    input { customer_id: String }
    step score {
        worker "risk.analyze"
        using pyhton
        with { customer_id: input.customer_id }
    }
}
warning[W0301]: unknown worker language `pyhton`
  --> risk.orbit:11:15
   |
11 |         using pyhton
   |               ^^^^^^
   = help: did you mean `python`?

The warning does not stop compilation, but a misspelled runtime means no worker ever receives the task.

Step output types

Expressions refer to a step by name. The type they see depends on the step’s action and on when and for:

StepType of name
worker "t"the output type of task t
workflow "W"the output type of workflow W
sleep d{}
await sthe payload record type of signal s
compute ethe type of expression e
with whenthe type above, made optional (T?)
with forList<T>, one element per list item
with for and whenList<T>?

Inside the step’s own compensate block, the step’s output is never optional, and for a for step it is the output of the element being compensated. See Compensation.

Units and status

At runtime each step is one or more units. A plain step is one unit, keyed by its name. A for step has one unit per element, keyed name[0], name[1], and so on. A compensation is a unit keyed compensate:<unit>, and a step of an outcome handler is keyed on_<kind>:<step>. Unit keys appear in orbit status, orbit logs, idempotency keys and child execution ids.

A unit moves through these states:

StateMeaning
PENDINGWaiting for dependencies.
SCHEDULEDInput evaluated, queued for a worker.
DISPATCHEDA worker (or the runtime) is executing the attempt.
RETRY_WAITA retry is scheduled after a backoff delay.
WAITINGA sleep timer is running, or an await step is waiting for a signal.
RUNNINGA for step whose elements are in progress.
SUCCEEDEDFinished successfully.
SKIPPEDIts when condition was false or null.
FAILEDFailed permanently.
CANCELLEDWithdrawn because the workflow was aborted.

See Executions and history for the events behind these transitions.

See also

Dependencies

Steps in a workflow form a directed acyclic graph. The compiler builds the graph from explicit after lists and from the data each step reads, rejects cycles, and fixes a deterministic order. The runtime starts a step as soon as everything it depends on has finished, and runs independent steps concurrently. This page explains how dependencies are formed and checked.

Explicit dependencies: after

after lists steps that must finish before this step starts:

step notify {
    worker "notifications.send"
    after validate_order, reserve_inventory
    with { customer_id: input.customer_id, message: "Order received" }
}

Use after when there is an ordering requirement that the data does not express, for example “send the confirmation only after the inventory is reserved”. You can repeat after in a step; the lists are combined.

Implicit dependencies: data references

Reading a step’s output makes the reading step depend on it. You do not need to repeat that dependency in after. References count in these members:

MemberCreates a dependency
withyes
whenyes
idempotency_keyyes
for ... in (the list expression)yes
compensate { ... }no; a compensation can only read the step and its existing dependencies
workflow returnno; return runs after every step has finished

In this workflow, charge depends on risk because its when reads risk.score, and on reserve through after:

workflow Checkout {
    version "1.0.0"
    input { order_id: String, customer_id: String, amount: Float, items: List<LineItem> }

    step risk {
        worker "risk.analyze"
        with { customer_id: input.customer_id, amount: input.amount }
    }

    step reserve {
        worker "inventory.reserve"
        with { order_id: input.order_id, items: input.items }
    }

    step charge {
        worker "payments.charge"
        after reserve
        when risk.score < 0.8
        with { order_id: input.order_id, amount: input.amount, currency: "USD" }
    }

    return { reservation_id: reserve.reservation_id, transaction_id: charge.transaction_id }
}

risk and reserve have no dependency on each other, so they run at the same time. charge starts when both have finished.

When a step becomes eligible

A step is scheduled when all of these hold:

  1. every direct dependency has finished as SUCCEEDED or SKIPPED,
  2. the workflow is not being aborted (no step has failed permanently, and the execution has not been cancelled or timed out),
  3. its when condition, if any, is true.

A skipped dependency counts as finished. Its output is null, which is why references to a conditional step are optional. See Conditions.

A failed dependency never lets a step start: the failure aborts the workflow, and pending steps are withdrawn (CANCELLED). See Compensation.

Concurrency

There is no implicit sequencing by source order. Steps without a path between them in the graph run concurrently, limited only by available workers. To run steps one after another, connect them with after or a data reference.

Rules the compiler enforces

CodeRule
E0303Every name in after is a step of the same workflow.
E0305A step cannot list itself in after or read its own output.
E0304The graph has no cycles.
E0401A referenced name is a step, input, execution or a loop variable.

An unknown after target suggests the closest step name:

task "shipping.quote" {
    input { order_id: String }
    output { carrier: String }
}

task "shipping.book" {
    input { order_id: String, carrier: String }
    output { tracking: String }
}

workflow Ship {
    version "1.0.0"
    input { order_id: String }
    step quote { worker "shipping.quote" with { order_id: input.order_id } }
    step book {
        worker "shipping.book"
        after qoute
        with { order_id: input.order_id, carrier: quote.carrier }
    }
}
error[E0303]: `after` references unknown step `qoute`
  --> ship.orbit:17:15
   |
17 |         after qoute
   |               ^^^^^
   = help: did you mean `quote`?

error: compilation failed: 1 error(s), 0 warning(s)

A cycle is reported with the full path and a label at each edge:

task "a.run" {
    input { n: Int }
    output { v: Int }
}

workflow Loop {
    version "1.0.0"
    step first { worker "a.run" with { n: second.v } }
    step second { worker "a.run" with { n: first.v } }
}
error[E0304]: circular dependency between steps
 --> loop.orbit:8:10
  |
8 |     step first { worker "a.run" with { n: second.v } }
  |          ^^^^^
  |                                           ------ `first` depends on `second`
9 |     step second { worker "a.run" with { n: first.v } }
  |                                            ----- `second` depends on `first`
  = note: cycle: first -> second -> first
  = help: the step graph must be acyclic; remove one of these dependencies

error: compilation failed: 1 error(s), 0 warning(s)

Reading a step’s own output, for example to “increment” a value, is E0305. Use two steps, or a compensation, which may read its own step.

Deterministic order

The compiler sorts steps topologically and breaks ties by source order. The resulting order is stored in the compiled IR, together with each step’s sorted dependency list, and the runtime evaluates steps in that order. The order, and therefore every scheduling decision, is the same on every replay.

To see the graph, run orbit graph, which renders Mermaid or Graphviz. See Workflow graphs.

See also

Conditions

A when condition makes a step conditional: the step runs only if the condition is true, and is skipped otherwise. Because a skipped step produces no output, the type checker treats every reference to a conditional step as optional and makes you handle the missing value. This page covers the when member, skipped steps, and the patterns for working with optional results.

The when member

step manual_review {
    worker "notifications.send"
    when analyze_risk.score >= 0.8
    with { customer_id: input.customer_id, message: "Your order is under review" }
}

The condition is an expression of type Bool or Bool?. Any other type is E0408:

error[E0408]: `when` condition must be `Bool`, found `Int`

Like any data reference, a step read in when becomes a dependency (see Dependencies). when is allowed on task steps, workflow steps and sleep steps, but not inside a compensate block (E0307).

Evaluation

The runtime evaluates the condition once, when every dependency of the step has finished:

ResultEffect
trueThe step is scheduled.
falseThe step is skipped.
nullThe step is skipped.
an expression errorThe step fails with EXPRESSION_ERROR, which aborts the workflow.

A skipped step has status SKIPPED, records a StepSkipped event, and has the output null. It is never compensated, because it did nothing.

Skipped steps and optional outputs

Because the step may be skipped, every reference to it is optional. If the task’s output is { transaction_id: String }, then:

ExpressionType
process_payment{ transaction_id: String }?
process_payment.transaction_idString?

Member access propagates null, so process_payment.transaction_id is null when the step was skipped, rather than an error.

Steps that depend on a skipped step still run. The type checker makes sure they handle the missing value: using an optional value where a non-optional one is required is E0405.

task "payments.charge" {
    input { order_id: String, amount: Float }
    output { transaction_id: String }
}

task "receipts.send" {
    input { order_id: String, transaction_id: String }
    output { sent: Bool }
}

workflow Pay {
    version "1.0.0"
    input { order_id: String, amount: Float }

    step charge {
        worker "payments.charge"
        when input.amount > 0.0
        with { order_id: input.order_id, amount: input.amount }
    }

    step receipt {
        worker "receipts.send"
        with { order_id: input.order_id, transaction_id: charge.transaction_id }
    }
}
error[E0405]: value may be `null` but `String` is required
  --> pay.orbit:23:58
   |
23 |         with { order_id: input.order_id, transaction_id: charge.transaction_id }
   |                                                          ^^^^^^^^^^^^^^^^^^^^^
   = help: provide a fallback with `coalesce(value, default)`, or declare the target as optional with `?`

error: compilation failed: 1 error(s), 0 warning(s)

Handling optional results

Choose one of these patterns, depending on what the downstream step should do when the conditional step was skipped.

Provide a fallback with coalesce:

step receipt {
    worker "receipts.send"
    with { order_id: input.order_id, transaction_id: coalesce(charge.transaction_id, "none") }
}

Skip the downstream step too, with its own condition:

step receipt {
    worker "receipts.send"
    when !is_null(charge)
    with { order_id: input.order_id, transaction_id: coalesce(charge.transaction_id, "") }
}

The condition does not narrow the type, so the with expression still needs coalesce.

Branch with if ... then ... else, or compare against null:

return {
    status: if charge.transaction_id == null then "not_charged" else "charged"
    transaction_id: charge.transaction_id
}

Declare the target as optional. If a contract field or a workflow output field is T?, an optional value can be passed to it directly.

Steps that share a condition

A step that can only run when a conditional step ran reads that step’s output as non-optional. The compiler recognizes two cases in the reading step’s when, which it splits at &&:

  • a conjunct !is_null(step), or
  • every conjunct of the other step’s own when, written in any order and with any spacing (conditions are compared by structure, not by text).
task "payments.charge" {
    input { order_id: String, amount: Float }
    output { transaction_id: String }
}

task "receipts.send" {
    input { order_id: String, transaction_id: String }
    output { sent: Bool }
}

workflow Pay {
    version "1.0.0"
    input { order_id: String, amount: Float, email_receipt: Bool }

    step charge {
        worker "payments.charge"
        when input.amount > 0.0
        with { order_id: input.order_id, amount: input.amount }
    }

    // Shares `input.amount > 0.0`: `charge` has run, so no `coalesce`.
    step receipt {
        worker "receipts.send"
        when input.amount > 0.0 && input.email_receipt
        with { order_id: input.order_id, transaction_id: charge.transaction_id }
    }
}

This is sound because a condition can only read values that were final before the step it guards was decided: input, execution and the step’s dependencies, which never change afterwards. When receipt runs, the same expression that made charge run is true again, and charge succeeded (had it failed, the execution would have stopped).

The rule applies to the step’s with, compute, idempotency_key, for list and compensate block. It does not apply to the when condition itself, which still reads conditional steps as optional, or to queries and return, which can be evaluated when the step has not run. A condition joined with ||, or one that differs in any other way, does not narrow; the step’s own output stays optional either way because it has a when.

Conditions and fan-out

On a for step, when decides once whether the whole step runs, before the list is expanded. The loop variable is therefore not in scope in when:

task "inventory.reserve" {
    input { sku: String }
    output { reservation_id: String }
}

workflow Reserve {
    version "1.0.0"
    input { skus: List<String> }
    step reserve {
        for sku in input.skus
        when sku != ""
        worker "inventory.reserve"
        with { sku: sku }
    }
}
error[E0401]: loop variable `sku` is not in scope in `when`
  --> reserve.orbit:11:14
   |
11 |         when sku != ""
   |              ^^^^^^^^^
   = note: `when` decides once whether the whole `for` step runs, before it fans out
   = help: to process only some elements, filter the list in a previous step

error: compilation failed: 1 error(s), 0 warning(s)

A skipped for step has the output null, so its type is List<T>?. for has no per-element filter clause; to process only some elements, iterate over filter(list, x => ...). See Fan-out.

Conditions and compensation

Inside a step’s own compensate block, the step’s output is not optional: a compensation only runs for a step that succeeded. See Compensation.

Constant conditions

A condition that reads nothing (no input, step output, execution field or variable) has the same value in every execution. The compiler evaluates it and warns: W0603 when it is false or null (the step never runs) and W0604 when it is true (the when only makes the output optional). See Static safety checks.

See also

Retries and timeouts

Retries and timeouts bound how long a step may take and how often the runtime tries it again. A retry block sets the policy for one step, timeout and queue_timeout limit each attempt, and the workflow timeout limits the whole execution. This page lists every setting with its default, and describes exactly when the runtime retries.

Retry policy

step charge {
    worker "payments.charge"
    with { order_id: input.order_id, amount: input.amount, currency: input.currency }
    retry {
        attempts: 5
        backoff: exponential
        initial_delay: 500ms
        max_delay: 10s
        non_retryable: ["CARD_DECLINED", "INSUFFICIENT_FUNDS"]
    }
}

retry is followed directly by { (no :). Inside, each setting is name: value, separated by commas, semicolons or newlines.

SettingTypeDefaultMeaning
attemptsinteger literal, 1 to 10001Total number of attempts, including the first. 1 means no retries.
backofffixed or exponentialfixedHow the delay between attempts grows.
initial_delayduration literal1sThe delay before the first retry.
max_delayduration literal60s, or initial_delay if that is longerThe upper bound for exponential delays. Must be at least initial_delay.
non_retryablelist of string literals[]Error codes that fail the step immediately, without retrying.

A step without a retry block makes one attempt. A retry block that omits a setting uses that setting’s default, so retry { attempts: 3 } retries twice with a fixed 1 second delay.

Every value must be a literal: expressions such as attempts: input.n are rejected. The compiler reports every problem with a retry block as E0410:

ProblemMessage
attempts outside 1 to 1000`attempts` must be between 1 and 1000
non-literal attempts`attempts` must be an integer literal
unknown backoffunknown backoff strategy `linear`
non-duration delay`initial_delay` must be a duration literal such as `5s`
max_delay below initial_delay`max_delay` must be at least `initial_delay`
non-string error codeerror codes must be string literals
unknown settingunknown retry setting `backof` (with a suggestion)
repeated settingduplicate retry setting `attempts`

Backoff

The delay before attempt n (attempt 2 is the first retry) is:

BackoffDelay before attempt n
fixedinitial_delay
exponentialmin(initial_delay × 2^(n−2), max_delay)

For attempts: 6, backoff: exponential, initial_delay: 500ms, max_delay: 4s:

Attempt123456
Delay before itnone500 ms1 s2 s4 s4 s

The delay is a durable timer: a retry that is waiting survives a runtime restart, and the attempt starts when the timer fires.

When the runtime retries

After an attempt fails, the runtime schedules another attempt only if all of these hold:

  1. the error is retryable,
  2. the error code is not listed in non_retryable (codes are compared exactly, including case),
  3. attempts remain (attempt < attempts),
  4. the workflow is not being aborted.

Otherwise the step fails permanently. If the policy allowed more than one attempt and they were all used, the error message ends with (gave up after N attempts).

Whether an error is retryable is decided by whoever reports it. Workers report errors with a code and a retryable flag (see How workers work). Errors that the runtime generates have a fixed classification:

CodeRetryableCause
TIMEOUTyesThe attempt exceeded the step timeout.
HTTP_ERRORdependsNetwork failures and 5xx/429 responses are retryable; invalid requests are not. See HTTP adapter.
QUEUE_TIMEOUTnoNo worker picked the task up within queue_timeout.
INPUT_CONTRACT_VIOLATIONnoThe evaluated input does not match the contract.
OUTPUT_CONTRACT_VIOLATIONnoThe worker’s output does not match the contract.
EXPRESSION_ERRORnoEvaluating with, when, for or idempotency_key failed.
ADAPTER_DISABLEDnoA built-in task ran on a server without the adapter enabled.

Every retry reuses the input and idempotency key that were evaluated when the step was first scheduled. Redelivery after a lost worker is not a retry and does not use up an attempt; see Delivery guarantees.

Step timeout

timeout limits a single attempt, measured from the moment the task is dispatched to a worker until its result arrives:

step analyze {
    worker "risk.analyze"
    with { customer_id: input.customer_id, amount: input.amount }
    timeout 10s
    retry { attempts: 3, backoff: exponential, initial_delay: 1s }
}
PropertyValue
Defaultno limit (orbit.http.request uses 30 seconds when no timeout is set)
Applies totask steps, workflow steps and compensate blocks
On expirythe attempt fails with TIMEOUT, which is retryable
Zerorejected (E0410)

Timeouts are cooperative. The worker is told to stop, through the cancellation list it receives with heartbeat responses, but the runtime cannot force an external operation to stop. If the timed-out attempt later reports success while the step is still unresolved, the runtime accepts that result, because the work did happen.

On a workflow step, timeout limits how long the parent waits for the child. When it expires, the step fails with TIMEOUT (workflow steps cannot retry) and the child execution is cancelled.

On an await step, timeout limits how long the step waits for its signal, from the moment it starts waiting. When it expires, the step fails with SIGNAL_TIMEOUT, which is never retried. See Signals and queries.

Queue timeout

queue_timeout limits how long an attempt may wait for a worker to pick it up:

step charge {
    worker "payments.charge"
    with { order_id: input.order_id, amount: input.amount, currency: input.currency }
    queue_timeout 2m
}
PropertyValue
Defaultno limit: a task waits until a matching worker connects
Measured fromthe moment the attempt is scheduled (each retry starts a new wait)
Applies totask steps and compensate blocks; not workflow or sleep steps
On expirythe step fails with QUEUE_TIMEOUT, which is never retried
Redeliveriesnot limited by queue_timeout
Zerorejected (E0410)

Use queue_timeout to fail fast when no worker for a task (or for the using runtime) is running, instead of waiting until the workflow deadline.

Workflow timeout

The workflow-level timeout limits the whole execution, measured from its creation, across all steps, retries, sleeps and waits for signals. When it passes, the runtime stops scheduling work, waits for in-flight attempts, runs compensations, and ends the execution as TIMED_OUT. See Workflows.

Where each setting is allowed

SettingTask stepWorkflow stepSleep stepcompensate block
retryyesno (E0307)no (E0307)yes
timeoutyesyesno (E0307)yes
queue_timeoutyesno (E0307)no (E0307)yes

await steps accept timeout only, and compute steps accept none of the three (E0307): a compute step is evaluated by the runtime and cannot time out or be retried.

A child workflow is durable on its own. Put retries on the child’s steps.

Retries in the history

Each attempt appears in the execution history. A step with retry { attempts: 3, backoff: exponential, initial_delay: 100ms } that fails every time produces events like these (orbit logs <id>):

+   106ms   #9  StepScheduled             step=broken attempt=1
+   201ms  #10  StepDispatched            step=broken attempt=1 worker_id=orbit-runtime
+   203ms  #11  StepAttemptFailed         step=broken attempt=1 error=HTTP_ERROR request failed: io: Connection refused (os error 111)
+   205ms  #12  RetryScheduled            step=broken delay_ms=100
+   401ms  #13  StepScheduled             step=broken attempt=2
+   403ms  #14  StepDispatched            step=broken attempt=2 worker_id=orbit-runtime
+   406ms  #15  StepAttemptFailed         step=broken attempt=2 error=HTTP_ERROR request failed: io: Connection refused (os error 111)
+   407ms  #16  RetryScheduled            step=broken delay_ms=200
+   701ms  #17  StepScheduled             step=broken attempt=3
+   702ms  #18  StepDispatched            step=broken attempt=3 worker_id=orbit-runtime
+   704ms  #19  StepAttemptFailed         step=broken attempt=3 error=HTTP_ERROR request failed: io: Connection refused (os error 111)
+   705ms  #20  StepFailed                step=broken error=HTTP_ERROR request failed: io: Connection refused (os error 111) (gave up after 3 attempts)

See also

Idempotency

ORBIT delivers every task at least once. A worker can receive the same task more than once, for example after a network failure hid its result or after a runtime restart, so workers must make their side effects idempotent. Every task carries an idempotency key for that purpose. This page explains the default key, how to set your own with idempotency_key, and how keys behave across retries, fan-out and compensation.

The default key

If a step does not set idempotency_key, its key is:

<execution id>/<unit key>
UnitExample key
A step7f3c…/process_payment
Element 2 of a for step7f3c…/reserve[2]
A step’s compensation7f3c…/compensate:process_payment
The compensation of element 27f3c…/compensate:reserve[2]

The default key is unique per execution and unit, and stable across retries and redeliveries.

Setting a key

idempotency_key takes an expression of type String:

step process_payment {
    worker "payments.charge"
    with { order_id: input.order_id, amount: input.amount, currency: input.currency }
    idempotency_key: "charge-" + input.order_id
}

Set your own key when the deduplication should span executions. In the example above, starting the workflow twice for the same order still charges the card once, provided the payment worker (or the payment provider) honors the key.

Rules:

  • The expression must be a String. Another type is E0400, and a String? is E0405. Convert numbers with string.
  • The expression can read input, execution, other steps (which become dependencies) and, in a for step or its compensation, the loop variable.
  • It is evaluated once, when the unit is first scheduled, and stored in the history. Retries and redeliveries reuse it unchanged.
  • If it evaluates to null at runtime (possible only through a JSON value), the default key is used.
  • An evaluation error fails the step with EXPRESSION_ERROR.
  • idempotency_key is allowed on task steps and in compensate blocks. It is not allowed on workflow steps or sleep steps (E0307).

Keys in fan-out steps

In a for step the key expression is evaluated separately for each element, with the loop variable bound. Include something that identifies the element, otherwise every element gets the same key and a worker that deduplicates by key would run only one of them:

step reserve {
    for item in input.items
    worker "inventory.reserve"
    with { order_id: input.order_id, items: [item] }
    idempotency_key: input.order_id + "/" + item.sku
}

If you omit idempotency_key, the default key already includes the element index (<execution id>/reserve[0], <execution id>/reserve[1], …).

Keys in compensations

A compensation has its own key, <execution id>/compensate:<unit> by default. You can set a business key in the compensate block, and it may read the compensated step’s output:

step process_payment {
    worker "payments.charge"
    with { order_id: input.order_id, amount: input.amount, currency: input.currency }
    idempotency_key: "charge-" + input.order_id
    compensate {
        worker "payments.refund"
        with { transaction_id: process_payment.transaction_id, amount: input.amount }
        idempotency_key: "refund-" + process_payment.transaction_id
    }
}

Child workflows

Workflow steps do not take an idempotency key. The child execution has a deterministic id, <parent id>.<step> (or <parent id>.<step>.<i> for an element of a for step), so the runtime never starts the same child twice. See Child workflows.

Starting executions idempotently

The same idea applies to starting executions. If you pass a client-chosen execution id (orbit run --id, or execution_id in the start API), starting twice with the same id returns the existing execution instead of creating a new one. Combined with the default keys, which contain the execution id, this makes a retried start safe end to end. See Executions and history.

What workers should do with the key

A worker receives the key with every task. Typical uses:

  • pass it to an external API that supports idempotency keys,
  • record it with the side effect in the same database transaction, and return the recorded result when the key is seen again,
  • use it as a natural primary key for the record the task creates.

ORBIT does not promise exactly-once execution of external side effects. It promises that the key is stable, and that uncertain outcomes are redelivered with the same key rather than assumed to have failed. See Delivery guarantees.

See also

Durable sleep

A sleep step waits for a fixed amount of time. The timer is kept by the runtime in the execution history, so it survives runtime restarts and needs no worker. This page covers the sleep action, the members it accepts, and how timers behave when a workflow is aborted.

Declaring a sleep step

workflow FollowUp {
    version "1.0.0"
    input { email: String }

    step welcome {
        worker "emails.send"
        with { to: input.email, template: "welcome" }
    }

    step cool_off {
        sleep 24h
        after welcome
    }

    step follow_up {
        worker "emails.send"
        after cool_off
        with { to: input.email, template: "follow-up" }
    }
}

The duration is a duration literal: a number followed by ms, s, m, h or d, for example 500ms, 1.5h or 7d. It cannot be an expression, so the wait time is fixed per workflow version. A bare number is E0101.

Behavior

AspectBehavior
StartWhen the step becomes eligible, the runtime records TimerStarted with the wake-up time (now plus the duration). The unit is WAITING.
EndWhen the wake-up time passes, the runtime records TimerFired and the step succeeds.
Output{}. Steps that only need ordering use after.
RestartsTimers are recomputed from history. A timer that came due while the runtime was down fires right after startup.
WorkersNone. Sleep steps are handled entirely by the runtime.
Workflow deadlineSleeping counts against the workflow timeout.
AbortIf the workflow fails, is cancelled or times out while the timer is running, the step is withdrawn (CANCELLED).
PrecisionTimers fire on the runtime’s scheduling tick, so a timer may fire slightly after its wake-up time.

sleep 0s is accepted and completes on the next tick.

Allowed members

A sleep step accepts only after and when:

MemberAllowedDiagnostic
afteryes
whenyesA skipped sleep step has output null.
fornoE0307
max_parallelnoE0307
with, using, timeout, queue_timeout, retry, idempotency_keynoE0307
compensatenoE0308: a sleep has nothing to compensate
workflow Wait {
    version "1.0.0"
    step pause {
        sleep 10m
        retry { attempts: 3 }
    }
}
error[E0307]: `retry` is not allowed in a `sleep` step
 --> wait.orbit:5:9
  |
5 |         retry { attempts: 3 }
  |         ^^^^^^^^^^^^^^^^^^^^^

error: compilation failed: 1 error(s), 0 warning(s)

Waiting until a point in time

ORBIT has no “sleep until” form, and expressions cannot read the clock, so a sleep duration cannot be computed from a timestamp. To run work at a specific time, start the execution at that time from an external scheduler.

See also

Fan-out

A for step runs once per element of a list, as independent units that run in parallel up to an optional limit. Each element has its own retries, timeouts, idempotency key and compensation, and the step’s output is the list of element outputs in input order. This page covers the for member, max_parallel, typing, failure behavior and compensation of fan-out steps.

Declaring a fan-out step

workflow ReserveAll {
    version "1.0.0"
    input { order_id: String, items: List<LineItem> }

    step reserve {
        for item in input.items
        max_parallel 4
        worker "inventory.reserve"
        with { order_id: input.order_id, items: [item] }
        compensate {
            worker "inventory.release"
            with { reservation_id: reserve.reservation_id }   // this element's output
        }
    }

    return { reservations: reserve }
}

for <name> in <expression> binds a loop variable to each element. It has no optional :. The other step members (worker or workflow, with, retry, timeout, …) describe what happens for one element.

The list expression

The expression after in must be a list:

Type of the expressionLoop variable typeNotes
List<T>T
List<T>?Tnull is treated as an empty list.
JSONJSONAnything other than an array at runtime fails the step with EXPRESSION_ERROR.
anything elseE0414

The list can come from the input, a previous step (which becomes a dependency), or a literal. It is evaluated once, when the step becomes eligible, and the evaluated list is stored in the history (StepFanOut). The number and content of the elements are therefore fixed, even across restarts.

The loop variable

The loop variable is in scope in with, idempotency_key and compensate. It is not in scope in when, which decides once whether the whole step runs (E0401). See Conditions.

The variable name cannot be the name of a step in the workflow, or input or execution (E0413):

error[E0413]: loop variable `input` shadows a built-in name

Concurrency: max_parallel

max_parallel N caps how many elements are in progress at once: scheduled, running, waiting for a retry, or waiting for redelivery. When an element finishes, the next pending element (in list order) starts.

PropertyValue
Defaultno limit: all elements are scheduled at once
Rangeinteger literal from 1 to 10000 (E0410 otherwise)
Without forE0307: `max_parallel` only applies to `for` steps

Units and keys

Each element is its own unit, keyed <step>[<index>] with a zero-based index: reserve[0], reserve[1], and so on. Each unit has:

  • its own input, evaluated with the loop variable bound to that element,
  • its own attempts and retry delays, following the step’s retry policy,
  • its own timeout and queue_timeout,
  • its own idempotency key, <execution id>/reserve[<index>] by default (see Idempotency),
  • for a workflow step, its own child execution, <parent id>.reserve.<index>.

orbit status lists the element units under the step.

Output

The step succeeds when every element succeeds. Its output is the list of the element outputs, in the order of the input list, regardless of the order in which elements finished.

StepType of a reference to it
for over a task with output OList<O>
for over a child workflow with output OList<O>
for with whenList<O>?

You can index the result like any list. Indexing is optional-typed, because the index may be out of range:

return {
    count: len(reserve)
    first_reservation: reserve[0].reservation_id     // String?
}

An empty list succeeds immediately with the output [], without contacting any worker.

Failure

When an element fails permanently, after its own retries:

  1. the step fails with that element’s error,
  2. the workflow is aborted: elements that have not started are withdrawn, elements in flight are awaited,
  3. compensation runs for every element that succeeded.

There is no partial success: one failed element fails the step. To tolerate individual failures, have the worker report a result that describes the failure instead of an error.

Compensation

A compensate block on a for step runs once per element that succeeded, newest first, together with the other compensations of the workflow. Inside the block, a reference to the step (reserve.reservation_id above) is the output of the element being compensated, not the list, and the loop variable is bound to that element. Each element’s compensation is a unit keyed compensate:reserve[<index>]. See Compensation.

Combining with other members

MemberEffect on a for step
worker "t"One task per element.
workflow "W"One child execution per element.
sleepNot allowed (E0307).
awaitNot allowed (E0307): an await step receives one signal.
computeOne value per element, evaluated by the runtime. See Compute steps.
whenEvaluated once, before fan-out.
afterThe whole step waits; all elements start after the dependencies.
retry, timeout, queue_timeoutApply to each element separately.
idempotency_keyEvaluated per element.

Filtering

for has no per-element filter clause (such as for x in xs where ...). To process only some elements, iterate over a filtered list, written with the built-in filter and a lambda:

import "contracts.orbit"

workflow ReserveInStock {
    version "1.0.0"
    input { order_id: String, items: List<LineItem> }

    step reserve {
        for item in filter(input.items, i => i.quantity > 0)
        worker "inventory.reserve"
        with { order_id: input.order_id, items: [item] }
    }
}

A lambda makes the workflow compile to orbit-ir/2 (Versioning). See Built-in functions.

See also

Child workflows

A workflow step starts another workflow as a durable child execution and waits for its result. Child workflows let you reuse a workflow as a typed building block: the parent’s input to the child and the child’s result are checked at compile time, and the runtime links parent and child so that a crash never starts a child twice or loses its result. This page covers the workflow action, its typing, the runtime behavior, and its restrictions.

Declaring a child workflow step

workflow Shipping {
    version "1.0.0"
    input { order_id: String, items: Int }
    output { carrier: String, tracking: String }

    step quote { worker "shipping.quote" with { order_id: input.order_id, items: input.items } }
    step book { worker "shipping.book" with { order_id: input.order_id, carrier: quote.carrier } }

    return { carrier: quote.carrier, tracking: book.tracking }
}

workflow Fulfilment {
    version "1.0.0"
    input { order_id: String, items: List<LineItem> }

    step ship {
        workflow "Shipping"
        with { order_id: input.order_id, items: len(input.items) }
        timeout 1h
    }

    return { tracking: ship.tracking }
}

The name after workflow is a string literal naming a workflow in the same compilation: declared in the same file or in an imported file. An unknown name is E0312, with a suggestion:

error[E0312]: unknown workflow `Shiping`

Typing

The child’s declared input plays the role of a task contract’s input, and its output (declared or inferred) plays the role of the contract’s output:

  • with is checked against the child’s input. A child whose input has required fields needs a with (E0403).
  • A reference to the step has the child’s output type: ship.tracking is a String above.
  • when makes the reference optional, and for makes it a list, exactly as for task steps. See Steps.

Allowed members

MemberAllowedNotes
withyesThe child’s input.
after, whenyes
for, max_parallelyesOne child per element.
timeoutyesHow long the parent waits for the child.
compensateyesA task that undoes the child’s effect after the parent fails.
using, retry, idempotency_key, queue_timeoutnoE0307
workflow Child {
    version "1.0.0"
}

workflow Parent {
    version "1.0.0"
    step run {
        workflow "Child"
        retry { attempts: 3 }
    }
}
error[E0307]: `retry` is not allowed in a `workflow` step
 --> parent.orbit:9:9
  |
9 |         retry { attempts: 3 }
  |         ^^^^^^^^^^^^^^^^^^^^^
  = note: child workflows are durable; put retries on the child's own steps

error: compilation failed: 1 error(s), 0 warning(s)

A child execution is durable on its own and has its own steps, retries, timeouts and compensation. Configure reliability inside the child.

Cycles

Workflows cannot start each other in a cycle, directly or indirectly (E0311):

workflow Ping {
    version "1.0.0"
    step pong { workflow "Pong" }
}

workflow Pong {
    version "1.0.0"
    step ping { workflow "Ping" }
}
error[E0311]: workflows call each other in a cycle
 --> ping.orbit:3:26
  |
3 |     step pong { workflow "Pong" }
  |                          ^^^^^^
8 |     step ping { workflow "Ping" }
  |                          ------ `Pong` starts `Ping`
  = note: cycle: Ping -> Pong -> Ping
  = help: a workflow cannot (directly or indirectly) start itself as a child

error: compilation failed: 1 error(s), 0 warning(s)

The compiler orders workflows so that every child comes before the workflows that start it. orbit deploy and orbit run register definitions in that order.

Execution

When a workflow step is scheduled, the runtime evaluates with and starts the child execution itself; no worker is involved.

AspectBehavior
Child execution id<parent id>.<step>, or <parent id>.<step>.<index> for an element of a for step.
Duplicate startsImpossible: the id is deterministic, and starting an existing id returns the existing execution.
Child inputDefaults are applied; validation is lenient because the input was type-checked.
Child versionThe exact definition the parent was compiled against, identified by its definition hash.
ResultWhen the child completes, its result becomes the step’s output.
TracingThe child’s spans join the parent’s trace, under the step.

The runtime reconciles parents and children continuously, so a crash at any point neither duplicates nor loses a child.

Failure

Child outcomeParent step fails with
FAILEDthe child’s own error code, with the message child workflow `Name` failed: ...
CANCELLEDCHILD_CANCELLED
TIMED_OUTCHILD_TIMED_OUT
the step timeout passes firstTIMEOUT, and the child is cancelled
the child definition is not registeredCHILD_NOT_REGISTERED
the child cannot be startedCHILD_START_FAILED

None of these is retried, because workflow steps take no retry policy. A failed workflow step aborts the parent like any other failed step.

Cancellation

When the parent is aborted (a step failed, it was cancelled, or it timed out), the runtime cancels every running child of that parent. A cancelled child runs its own compensations before it ends.

Registering parents and children

A parent pins the definition hash of the child it was compiled against. Registering a parent fails with CHILD_NOT_REGISTERED if that exact child definition is not registered yet. orbit deploy registers children first.

Because the child’s hash is part of the parent’s definition, changing the child changes the parent’s definition hash too. After you change a child, bump the version of every workflow that starts it; otherwise registering the parent fails with DEFINITION_CONFLICT. See Versioning.

See also

Signals and queries

Signals and queries connect a running execution to the outside world. A signal is a typed message that a person or another system sends to an execution, such as a reviewer’s decision; an await step waits for it durably. A query is a typed, read-only view of an execution’s state that anyone can evaluate at any time, such as “where does this order stand?”. Both are declared in the workflow, so the compiler checks every use of them, and the runtime validates every signal payload before it is recorded. This page covers declarations, await steps, buffering and ordering, timeouts, sending and querying from the CLI, HTTP and the SDKs, typing rules, errors and history events, and ends with a complete example.

Overview

ConstructSyntaxPurpose
Signal declarationsignal review { approved: Bool, reviewer: String }A message type the workflow accepts. Its fields are the payload.
await stepstep decision { await review timeout 2d }Waits for the next review signal. The payload is the step’s output.
Query declarationquery status = { approved: decision.approved }An expression evaluated on demand against the current state.
workflow Approval {
    version "1.0.0"
    input { request_id: String }

    signal review { approved: Bool, reviewer: String, comment: String? }

    step decision {
        await review
        timeout 2d
    }

    query status = {
        decided: !is_null(decision)
        approved: decision.approved
    }

    return { approved: decision.approved, reviewer: decision.reviewer }
}

Sending a signal and evaluating a query need an execution id and the declared name:

$ orbit signal <execution-id> review --data '{"approved": true, "reviewer": "dana"}'
$ orbit query <execution-id> status

Declaring a signal

signal <name> { <fields> }

A signal declaration is a workflow member. <name> is an identifier, unique among the workflow’s signals (E0317). The body is a record, written exactly like a workflow input block: fields with types, optional fields (T?) and constant defaults. Field types can be schemas, lists and maps:

schema Reviewer {
    name: String
    team: String = "operations"
}

workflow Exceptions {
    version "1.0.0"
    timeout 30d

    signal review {
        approved: Bool
        by: Reviewer
        comment: String?
        tags: List<String> = []
    }

    step decision { await review }

    return { approved: decision.approved, team: decision.by.team }
}

Signal names and step names are separate namespaces, and so are query names. A workflow can declare any number of signals. A signal that no step awaits is warning W0605 (see Static safety checks).

How the runtime validates a payload

When a signal arrives, the runtime:

  1. applies the declared defaults to missing fields, including nested schema fields,
  2. checks the payload against the declared type and rejects it with 422 SIGNAL_PAYLOAD_INVALID if a required field is missing or a value has the wrong type; the message names the path, for example payload for signal `review` is invalid at $.approved: expected Bool, found a string,
  3. records the validated payload in the history.

Fields that the declaration does not list are accepted and kept in the recorded payload. (Workflow inputs are stricter: an execution input with an undeclared field is rejected.) An optional field that is not sent stays absent from the payload; reading it gives null.

Waiting for a signal: await

step <name> { await <signal> [timeout DURATION] [after ...] [when ...] }

await <signal> is a step action, like worker or sleep. It names a signal declared in the same workflow; an unknown name is E0315, with a suggestion for a close misspelling. When the step becomes eligible, the runtime records SignalAwaitStarted and the unit is WAITING. When a signal of that name is available, the runtime records SignalDelivered and the step succeeds with the signal’s payload as its output.

The step’s output type is the signal’s record type, so later steps read the payload fields directly:

task "refunds.issue" {
    input { order_id: String, amount: Float, note: String }
    output { refund_id: String }
}

workflow RefundRequest {
    version "1.0.0"
    input { order_id: String, amount: Float }

    signal decision { approved: Bool, note: String = "" }

    step review {
        await decision
        timeout 3d
    }

    step refund {
        worker "refunds.issue"
        when review.approved
        with { order_id: input.order_id, amount: input.amount, note: review.note }
    }

    return { approved: review.approved, refund_id: refund.refund_id }
}

Members of an await step

An await step needs no worker, so most members do not apply:

MemberAllowedMeaning
timeoutyesThe longest the step waits, from SignalAwaitStarted. See Timeouts.
afteryesStart waiting only after these steps. Signals that arrive earlier are buffered.
whenyesWait only if the condition is true; otherwise the step is SKIPPED and its output is null.
for, max_parallelno (E0307)An await step receives one signal. Use several steps to receive several signals.
with, using, retry, queue_timeout, idempotency_keyno (E0307)There is no task to call.
compensateno (E0308)Receiving a signal has nothing to undo.
workflow Approvals {
    version "1.0.0"
    input { reviewers: List<String> }
    signal review { approved: Bool }
    step decision {
        await review
        timeout 2d
        retry { attempts: 3 }
        with { reviewer: "dana" }
    }
}
error[E0307]: `retry` is not allowed in an `await` step
 --> approvals.orbit:8:9
  |
8 |         retry { attempts: 3 }
  |         ^^^^^^^^^^^^^^^^^^^^^

error[E0307]: `with` is not allowed in an `await` step
 --> approvals.orbit:9:14
  |
9 |         with { reviewer: "dana" }
  |              ^^^^^^^^^^^^^^^^^^^^

error: compilation failed: 2 error(s), 0 warning(s)

Output type

StepType of name
await sthe record type of signal s
await s with whenthe record type, optional ({ ... }?)

Reading a field of an optional record gives an optional value, so decision.approved has type Bool? when decision has a when condition. Use coalesce(decision.approved, false) where a Bool is required.

Buffering and ordering

Signals are buffered per execution. The runtime accepts a signal at any time while the execution is not terminal, whether or not a step is waiting for it:

  • A signal that arrives before its await step starts is kept in the buffer. When the step starts waiting, it takes the buffered signal right away.
  • Each await step takes the oldest signal of its name that no step has taken yet. Signals of one name are therefore delivered in the order they were received.
  • Each signal is delivered to at most one step. Two steps that wait for the same signal at the same time never receive the same signal.
  • A signal that no step takes stays in the buffer, undelivered. It is ignored when the execution finishes.

The buffer is part of the execution state, in the signals array of GET /v1/executions/{id} and orbit inspect <id> (see Executions and history).

In this workflow, both signals are sent while the first step is still sleeping:

workflow Batch {
    version "1.0.0"
    timeout 1h

    signal item { sku: String, quantity: Int = 1 }

    step pause { sleep 2s }
    step first { await item after pause }
    step second { await item after first }

    return { skus: [first.sku, second.sku], total: first.quantity + second.quantity }
}
$ orbit run batch.orbit --id batch-1 --no-wait
$ orbit signal batch-1 item --data '{"sku": "A-1"}'
signal `item` recorded for batch-1 (history #4); the next `await item` step takes it
$ orbit signal batch-1 item --data '{"sku": "B-2", "quantity": 3}'
signal `item` recorded for batch-1 (history #5); the next `await item` step takes it
$ orbit status batch-1
COMPLETED batch-1  Batch@1.0.0
  elapsed: 2.0s

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  first                        SUCCEEDED          1      0ms  
  pause                        SUCCEEDED          1     2.0s  
  second                       SUCCEEDED          1      0ms  

  output: {"skus":["A-1","B-2"],"total":4}
$ orbit logs batch-1
+     0ms   #1  WorkflowCreated           
+     0ms   #2  WorkflowStarted           
+     1ms   #3  TimerStarted              step=pause
+    13ms   #4  SignalReceived            name=item
+    24ms   #5  SignalReceived            name=item
+    2.0s   #6  TimerFired                step=pause
+    2.0s   #7  SignalAwaitStarted        step=first signal=item
+    2.0s   #8  SignalDelivered           step=first signal_seq=4
+    2.0s   #9  SignalAwaitStarted        step=second signal=item
+    2.0s  #10  SignalDelivered           step=second signal_seq=5
+    2.0s  #11  WorkflowCompleted         

first received the first signal and second the second one. The default quantity: Int = 1 was applied to the first payload.

orbit signal reports success (signal ... recorded for <id>) as soon as the runtime has recorded and buffered the signal, whether or not a step is waiting for it. The SignalDelivered event shows when a step took it.

Timeouts

timeout on an await step bounds the wait. If no signal of that name is available when the timeout passes, the step fails with the non-retryable error SIGNAL_TIMEOUT, and the execution aborts like after any other step failure: work that has not started is withdrawn and succeeded steps are compensated, newest first. See Compensation.

task "payments.charge" {
    input { order_id: String, amount: Float }
    output { transaction_id: String }
}

task "payments.refund" {
    input { transaction_id: String, amount: Float }
    output { refunded: Bool }
}

workflow ConfirmCharge {
    version "1.0.0"
    input { order_id: String, amount: Float }

    signal confirm { ok: Bool }

    step charge {
        worker "payments.charge"
        with { order_id: input.order_id, amount: input.amount }
        compensate {
            worker "payments.refund"
            with { transaction_id: charge.transaction_id, amount: input.amount }
        }
    }

    step confirmation {
        await confirm
        after charge
        timeout 2s
    }
}

With no confirm signal sent:

$ orbit logs confirm-1
+     0ms   #1  WorkflowCreated           
+     0ms   #2  WorkflowStarted           
+     1ms   #3  StepScheduled             step=charge attempt=1
+     3ms   #4  StepDispatched            step=charge attempt=1 worker_id=orders-python
+    12ms   #5  StepSucceeded             step=charge attempt=1
+    14ms   #6  SignalAwaitStarted        step=confirmation signal=confirm
+    2.0s   #7  StepFailed                step=confirmation error=SIGNAL_TIMEOUT no `confirm` signal arrived before the step's timeout
+    2.0s   #8  CompensationStarted       reason=step `confirmation` failed: no `confirm` signal arrived before the step's timeout
+    2.0s   #9  StepScheduled             step=compensate:charge attempt=1
+    2.0s  #10  StepDispatched            step=compensate:charge attempt=1 worker_id=orders-python
+    2.0s  #11  StepSucceeded             step=compensate:charge attempt=1
+    2.0s  #12  CompensationCompleted     
+    2.0s  #13  WorkflowFailed            error=SIGNAL_TIMEOUT step `confirmation` failed: no `confirm` signal arrived before the step's timeout

The deadline is recorded in SignalAwaitStarted (deadline_ms) and in the unit’s wake_at_ms, so it survives runtime restarts. A deadline that passed while the runtime was down is handled right after startup.

Without a step timeout, the step waits until a signal arrives, the workflow timeout passes, or the execution is cancelled. An await step with neither a step timeout nor a workflow timeout is warning W0606, because the execution could wait forever.

Cancellation and other aborts

An await step that is waiting when the execution is cancelled, times out, or fails elsewhere is withdrawn: the runtime records StepCancelled and the unit ends CANCELLED. Signals sent during an abort are still validated and recorded, but no step takes them. Once the execution is terminal, sending a signal fails with 409 EXECUTION_FINISHED.

$ orbit cancel order-A-103 --reason "order withdrawn"
cancellation requested; execution is CANCELLED
$ orbit logs order-A-103 | tail -5
+   312ms   #5  CancelRequested           reason=order withdrawn
+   313ms   #6  StepCancelled             step=decision
+   313ms   #7  StepCancelled             step=charge
+   313ms   #8  StepCancelled             step=notify
+   313ms   #9  WorkflowCancelled         

Sending a signal

Signals are sent by name to one execution. Sending requires the admin credential when authentication is enabled, like every other execution endpoint.

CLI

orbit signal <EXECUTION_ID> <NAME> [--data <JSON> | --data @<file>]

--data is the payload as JSON (default {}); @path reads it from a file. On success the command prints the history sequence number of the recorded SignalReceived event:

$ orbit signal order-A-100 review --data '{"approved": true, "reviewer": "dana"}'
signal `review` recorded for order-A-100 (history #5); the next `await review` step takes it
$ orbit signal order-A-100 review --data @review.json

See CLI: orbit signal.

HTTP

POST /v1/executions/{id}/signals/{name}
content-type: application/json

{"approved": true, "reviewer": "dana"}

The body is the payload. A request without a body sends {}. Response 200:

{"execution_id": "order-A-100", "signal": "review", "accepted": true, "seq": 5}

See HTTP API.

Python and TypeScript

import os

from orbit_sdk import Client

client = Client(token=os.environ["ORBIT_ADMIN_TOKEN"])
seq = client.signal("order-A-100", "review", {"approved": True, "reviewer": "dana"})
import { Client } from "orbit-lang";

const client = new Client({ token: process.env.ORBIT_ADMIN_TOKEN });
const seq = await client.signal("order-A-100", "review", { approved: true, reviewer: "dana" });

Both return the sequence number and raise (ApiError) on an error response. See Python and TypeScript.

Errors

StatusCodeWhen
404NOT_FOUNDThe execution does not exist.
404UNKNOWN_SIGNALThe execution’s workflow does not declare this signal. The message lists the declared ones: OrderApproval@1.0.0 has no signal `reveiw`; declared: review, or OrderApproval@1.0.0 declares no signals.
409EXECUTION_FINISHEDThe execution is already terminal: execution is already COMPLETED. Checked before the signal name.
422SIGNAL_PAYLOAD_INVALIDThe payload does not match the declaration. Nothing is recorded.

Signals are checked against the definition the execution is pinned to, not against the latest registered version of the workflow.

Queries

query <name> = <expression>

A query declaration is a workflow member. <name> is an identifier, unique among the workflow’s queries (E0318). The expression can read input, execution and the output of any step:

workflow Progress {
    version "1.0.0"
    input { items: List<String> }

    step count { compute len(input.items) }
    step pause { sleep 1h after count }

    query summary = {
        id: execution.id
        items: coalesce(count, 0)
        paused: !is_null(pause)
    }
}

Typing rules

A query can be evaluated at any moment, so any step may not have run yet. The compiler therefore types every step output as optional in a query: a step whose output is T is T? there (and a step that is already optional stays optional). The query’s result type is the type of the expression under that rule, and it is recorded in the compiled definition.

workflow Progress {
    version "1.0.0"
    input { items: List<String> }
    step count { compute len(input.items) }
    query remaining = count + 1
}
error[E0406]: operator `+` cannot be applied to `Int?` and `Int`
 --> progress.orbit:5:23
  |
5 |     query remaining = count + 1
  |                       ^^^^^^^^^
  |                       ----- `Int?`
  |                               - `Int`
  = help: one operand may be `null`; use `coalesce(value, default)` first

error: compilation failed: 1 error(s), 0 warning(s)

Use coalesce and is_null to handle steps that have not run. Field access on an optional record is allowed and gives null when the record is null, so decision.approved is null until decision succeeds.

Queries are not steps: they add no dependencies, cannot be read by steps or return, and do not appear in the step graph.

Evaluation

The runtime evaluates a query when it is asked, against the execution’s current state:

  • input is the execution input, with defaults applied.
  • A step that has succeeded reads as its output. A step that has not succeeded (pending, waiting, running, skipped, failed or cancelled) reads as null.
  • execution.id, execution.workflow and execution.version are available as in other expressions.

Evaluating a query is read-only. It records nothing in the history and does not change the execution. It works on running and on finished executions, so you can still read a query after the execution completes. An expression error at evaluation time, such as a division by zero, returns 422 QUERY_FAILED with the cause; nothing is recorded.

Reading a query

$ orbit query order-A-100 status
{
  "needs_approval": true,
  "approved": null,
  "reviewer": null,
  "charged": false
}
GET /v1/executions/{id}/queries/{name}
{"execution_id":"order-A-100","query":"status","value":{"needs_approval":true,"approved":true,"reviewer":"dana","charged":true}}
state = client.query("order-A-100", "status")
const state = await client.query<{ approved: boolean | null }>("order-A-100", "status");

orbit query prints the value only. The SDK methods return the value.

StatusCodeWhen
404NOT_FOUNDThe execution does not exist.
404UNKNOWN_QUERYThe workflow does not declare this query: OrderApproval@1.0.0 has no query `stat`; declared: status, or Batch@1.0.0 declares no queries.
422QUERY_FAILEDEvaluating the expression failed: evaluating query `<name>`: <cause>.

History events

EventFieldsMeaning
SignalReceivedname, payloadA signal was accepted, validated and buffered. payload has defaults applied. Its seq identifies the signal.
SignalAwaitStartedstep, signal, deadline_ms?An await step started waiting. deadline_ms is set when the step has a timeout.
SignalDeliveredstep, signal_seqThe step took the signal recorded at history position signal_seq. The step succeeded with its payload as output.

A timed-out wait is recorded as StepFailed with SIGNAL_TIMEOUT, and a withdrawn wait as StepCancelled. Queries record no events. See Executions and history.

Complete example

examples/approval.orbit charges an order. Orders of 1000 or more wait for a reviewer’s review signal; the status query reports where an order stands. The assess step is a compute step, evaluated by the runtime. It runs with the example Python and TypeScript workers.

// Human-in-the-loop approval with typed signals, queries and compute steps.
//
// Orders of 1000 or more wait for a reviewer's decision, sent as a typed
// `review` signal (`orbit signal <id> review --data '{...}'`). Anyone can ask
// where an order stands with the `status` query (`orbit query <id> status`).
//
// Workers: examples/workers/python/order_workers.py and
// examples/workers/typescript/order_workers.ts.

import "contracts.orbit"

workflow OrderApproval {
    version "1.0.0"
    description "Charge an order, with a reviewer's approval above a threshold"
    input { order_id: String, customer_id: String, amount: Float, currency: String = "USD" }
    output { status: String, transaction_id: String? }

    signal review { approved: Bool, reviewer: String, comment: String? }

    // Deterministic logic, evaluated by the runtime: no worker needed.
    step assess {
        compute {
            needs_approval: input.amount >= 1000.0
            rounded: float(int(input.amount * 100.0)) / 100.0
        }
    }

    step decision {
        await review
        when assess.needs_approval
        timeout 2d
    }

    step charge {
        worker "payments.charge"
        using python
        when !assess.needs_approval || coalesce(decision.approved, false)
        with { order_id: input.order_id, amount: assess.rounded, currency: input.currency }
        idempotency_key: "charge-" + input.order_id
        compensate {
            worker "payments.refund"
            with { transaction_id: charge.transaction_id, amount: assess.rounded }
        }
    }

    step notify {
        worker "notifications.send"
        after charge
        with {
            customer_id: input.customer_id
            message: if is_null(charge) then "Your order was not approved" else "Your order is confirmed"
        }
    }

    query status = {
        needs_approval: assess.needs_approval
        approved: decision.approved
        reviewer: decision.reviewer
        charged: !is_null(charge)
    }

    return {
        status: if is_null(charge) then "rejected" else "charged"
        transaction_id: charge.transaction_id
    }
}

decision has a when condition, so its output is optional: charge reads coalesce(decision.approved, false). When charge is skipped, notify still runs (a skipped dependency counts as done), and is_null(charge) selects the message.

Start the runtime and the example workers:

$ orbit server --db orbit.db
$ PYTHONPATH=sdks/python python3 examples/workers/python/order_workers.py
$ node examples/workers/typescript/order_workers.ts

Start an order that needs approval, and look at it while it waits:

$ orbit run examples/approval.orbit --id order-A-100 --input '{"order_id": "A-100", "customer_id": "c-7", "amount": 2500.0}' --no-wait
deployed OrderApproval@1.0.0 (sha256:939fd564b818)
started execution order-A-100
order-A-100
$ orbit status order-A-100
RUNNING order-A-100  OrderApproval@1.0.0
  elapsed: 1ms

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  assess                       SUCCEEDED          1           
  decision                     WAITING            1           
$ orbit query order-A-100 status
{
  "needs_approval": true,
  "approved": null,
  "reviewer": null,
  "charged": false
}

A payload with the wrong type is rejected and nothing is recorded:

$ orbit signal order-A-100 review --data '{"approved": "yes", "reviewer": "dana"}'
error: SIGNAL_PAYLOAD_INVALID (422): payload for signal `review` is invalid at $.approved: expected Bool, found a string

Send the decision:

$ orbit signal order-A-100 review --data '{"approved": true, "reviewer": "dana", "comment": "verified by phone"}'
signal `review` recorded for order-A-100 (history #5); the next `await review` step takes it
$ orbit status order-A-100
COMPLETED order-A-100  OrderApproval@1.0.0
  elapsed: 569ms

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  assess                       SUCCEEDED          1           
  decision                     SUCCEEDED          1    539ms  
  charge                       SUCCEEDED          1      9ms  worker orders-python
  notify                       SUCCEEDED          1      6ms  worker orders-typescript

  output: {"status":"charged","transaction_id":"tx-A-100"}
$ orbit query order-A-100 status
{
  "needs_approval": true,
  "approved": true,
  "reviewer": "dana",
  "charged": true
}

The history shows the wait, the signal and its delivery. assess is a compute step, so it succeeds without being scheduled or dispatched:

$ orbit logs order-A-100
+     0ms   #1  WorkflowCreated           
+     0ms   #2  WorkflowStarted           
+     1ms   #3  StepSucceeded             step=assess attempt=1
+     1ms   #4  SignalAwaitStarted        step=decision signal=review
+   538ms   #5  SignalReceived            name=review
+   540ms   #6  SignalDelivered           step=decision signal_seq=5
+   540ms   #7  StepScheduled             step=charge attempt=1
+   547ms   #8  StepDispatched            step=charge attempt=1 worker_id=orders-python
+   556ms   #9  StepSucceeded             step=charge attempt=1
+   557ms  #10  StepScheduled             step=notify attempt=1
+   562ms  #11  StepDispatched            step=notify attempt=1 worker_id=orders-typescript
+   568ms  #12  StepSucceeded             step=notify attempt=1
+   569ms  #13  WorkflowCompleted         
$ orbit logs order-A-100 --json | grep Signal
{"seq":4,"timestamp_ms":1794746714132,"type":"SignalAwaitStarted","step":"decision","signal":"review","deadline_ms":1794919514132}
{"seq":5,"timestamp_ms":1794746714669,"type":"SignalReceived","name":"review","payload":{"approved":true,"reviewer":"dana","comment":"verified by phone"}}
{"seq":6,"timestamp_ms":1794746714671,"type":"SignalDelivered","step":"decision","signal_seq":5}

A rejection skips the charge:

$ orbit run OrderApproval --id order-A-101 --input '{"order_id": "A-101", "customer_id": "c-8", "amount": 4000.0}' --no-wait
$ orbit signal order-A-101 review --data '{"approved": false, "reviewer": "lee", "comment": "customer could not be reached"}'
signal `review` recorded for order-A-101 (history #5); the next `await review` step takes it
$ orbit status order-A-101
COMPLETED order-A-101  OrderApproval@1.0.0
  elapsed: 328ms

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  assess                       SUCCEEDED          1           
  charge                       SKIPPED            0           
  decision                     SUCCEEDED          1    315ms  
  notify                       SUCCEEDED          1      5ms  worker orders-typescript

  output: {"status":"rejected","transaction_id":null}

A small order needs no approval: decision is skipped and the order is charged straight away:

$ orbit run OrderApproval --id order-A-102 --input '{"order_id": "A-102", "customer_id": "c-9", "amount": 120.0}'
started execution order-A-102
  ✓ assess                         succeeded 
  ↷ decision                       skipped 
  ✓ charge                         succeeded 7ms
  ✓ notify                         succeeded 9ms

COMPLETED order-A-102  OrderApproval@1.0.0
  elapsed: 22ms
{
  "status": "charged",
  "transaction_id": "tx-A-102"
}

Changing signals and queries

Signals and queries are part of the workflow definition and its hash. Adding, removing or changing one requires a new workflow version, and executions keep the signals and queries of the version they started with. Senders and readers depend on them like callers depend on the input, so orbit compat reports a removed signal or query, a signal payload change that would reject payloads that used to be valid, and a query result change that readers may not expect. See Compatibility checks.

A workflow that declares a signal or a query compiles to orbit-ir/2. See Versioning.

See also

Compute steps

A compute step evaluates an expression in the runtime and makes the value its output. It needs no worker, so it suits the small, deterministic pieces of logic that sit between tasks: deriving a flag, totalling a list, reshaping a record, picking the items that need attention. This page covers when to use a compute step instead of a worker, the syntax and members, how the runtime evaluates it, lambdas and collection functions, fan-out, conditions and errors.

When to use a compute step

Use a compute step whenUse a worker task when
The result depends only on the workflow input and earlier step outputs.The work calls a service, a database, a file system or the clock.
The logic fits in an expression: arithmetic, comparisons, conditionals, the built-in functions.The logic needs a library, a loop with state, or anything an expression cannot say.
You want the value recorded in the history and visible to later steps and queries.You want retries, timeouts, routing to a language, or a compensation.

A compute step can do nothing that an expression in a later step’s with or when could not. What it adds is a name: the intermediate result is computed once, recorded in the history, and read by any number of later steps, the return expression and queries.

Declaring a compute step

step <name> { compute <expression> }
task "payments.charge" {
    input { order_id: String, amount: Float }
    output { transaction_id: String }
}

workflow Checkout {
    version "1.0.0"
    input { order_id: String, amount: Float, coupon_percent: Int = 0 }

    step pricing {
        compute {
            discount: input.amount * float(input.coupon_percent) / 100.0
            needs_review: input.amount >= 1000.0
        }
    }

    step charge {
        worker "payments.charge"
        when !pricing.needs_review
        with { order_id: input.order_id, amount: input.amount - pricing.discount }
    }

    return { charged: !is_null(charge), discount: pricing.discount }
}

The expression can be of any type, not only a record. The compiler infers the step’s output type from it, and later steps, return and queries see that type: here pricing is { discount: Float, needs_review: Bool }, and step total { compute sum(input.prices) } would make total a Float.

The expression can read input, execution, the outputs of other steps and, in a for step, the loop variable. Reading a step’s output makes the compute step depend on it, exactly as in with. See Dependencies.

Members

MemberAllowedNotes
afteryesOrdering, as for any step.
whenyesSkipped steps have output null, and the output type becomes optional.
for, max_parallelyesOne value per element; see Fan-out. All elements are evaluated together, so max_parallel has no effect.
with, using, timeout, queue_timeout, retry, idempotency_keyno (E0307)There is no task, no worker and no attempt to bound or retry.
compensateno (E0308)A compute step has no side effect to undo.
workflow Totals {
    version "1.0.0"
    input { prices: List<Float> }
    step total {
        compute sum(input.prices)
        timeout 10s
        compensate { worker "ledger.reverse" }
    }
}
error[E0307]: `timeout` is not allowed in a `compute` step
 --> totals.orbit:6:17
  |
6 |         timeout 10s
  |                 ^^^

error[E0308]: a `compute` step has nothing to compensate
 --> totals.orbit:7:9
  |
7 |         compensate { worker "ledger.reverse" }
  |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

error: compilation failed: 2 error(s), 0 warning(s)

A step has exactly one action, so compute cannot be combined with worker, workflow, sleep or await (E0106).

How it runs

When every dependency of a compute step is done and its when condition is true, the runtime evaluates the expression during the same decision and records the value as the step’s output in a StepSucceeded event, with attempt: 1. There is no StepScheduled or StepDispatched event, no worker, no idempotency key and no retry.

AspectBehavior
DeterminismExpressions are pure: they perform no I/O and cannot read the clock or random numbers. The same input and step outputs always give the same value.
ReplayThe value is stored in the history. Recovery and replay read it from there and never evaluate the expression again.
WorkersNone. A compute step runs even when no worker is connected.
CostEvaluation happens inside the runtime’s decision loop. Keep expressions to data mapping; do heavy work in a worker.
StatusSUCCEEDED, SKIPPED, FAILED (on an expression error) or CANCELLED. A compute step never stays SCHEDULED or DISPATCHED.

In this history, assess is a compute step and charge a worker task:

$ orbit logs order-A-100
+     0ms   #1  WorkflowCreated           
+     0ms   #2  WorkflowStarted           
+     1ms   #3  StepSucceeded             step=assess attempt=1
+     1ms   #4  SignalAwaitStarted        step=decision signal=review
...
+   540ms   #7  StepScheduled             step=charge attempt=1
+   547ms   #8  StepDispatched            step=charge attempt=1 worker_id=orders-python
+   556ms   #9  StepSucceeded             step=charge attempt=1

Lambdas and collection functions

Collection functions take a list and a lambda, a one-parameter function written name => expression:

FunctionResult
map(list, x => expr)a list of expr for each element
filter(list, x => cond)the elements for which cond is true
find(list, x => cond)the first element for which cond is true, or null
any(list, x => cond)true if cond is true for at least one element
all(list, x => cond)true if cond is true for every element
sum(list)the sum of a list of numbers

A lambda is only allowed as the second argument of map, filter, find, any and all (E0415 elsewhere), and its parameter must not reuse the name of a step, a loop variable, an enclosing lambda parameter, input or execution (E0413). Lambdas and these functions work in every expression, not only in compute steps. See Built-in functions for signatures and details, and Expressions for the syntax.

schema Line {
    sku: String
    quantity: Int
    unit_price: Float
}

workflow Totals {
    version "1.0.0"
    input { lines: List<Line> }

    step totals {
        compute {
            count: len(input.lines)
            units: sum(map(input.lines, l => l.quantity))
            subtotal: sum(map(input.lines, l => float(l.quantity) * l.unit_price))
            bulk: filter(input.lines, l => l.quantity >= 10)
            first_free: find(input.lines, l => l.unit_price == 0.0)
            all_in_stock: all(input.lines, l => l.quantity > 0)
        }
    }

    step line_totals {
        for line in input.lines
        compute { sku: line.sku, total: float(line.quantity) * line.unit_price }
    }

    step per_unit {
        compute totals.subtotal / float(totals.units)
        when totals.units > 0
    }

    return {
        units: totals.units
        subtotal: totals.subtotal
        bulk_skus: map(totals.bulk, l => l.sku)
        free_sku: totals.first_free.sku
        line_totals: line_totals
        per_unit: per_unit
    }
}
$ orbit run totals.orbit --id totals-1 --input '{"lines": [{"sku": "A", "quantity": 12, "unit_price": 2.5}, {"sku": "B", "quantity": 1, "unit_price": 0.0}]}'
deployed Totals@1.0.0 (sha256:01f95045bc38)
started execution totals-1
  ✓ line_totals                    succeeded 0ms
  ✓ line_totals[0]                 succeeded 
  ✓ line_totals[1]                 succeeded 
  ✓ per_unit                       succeeded 
  ✓ totals                         succeeded 

COMPLETED totals-1  Totals@1.0.0
  elapsed: 2ms
{
  "units": 13,
  "subtotal": 30.0,
  "bulk_skus": [
    "A"
  ],
  "free_sku": "B",
  "line_totals": [
    {
      "sku": "A",
      "total": 30.0
    },
    {
      "sku": "B",
      "total": 0.0
    }
  ],
  "per_unit": 2.3076923076923075
}

first_free has type Line?, because find may find nothing, so totals.first_free.sku is a String?.

Fan-out

With for x in list, a compute step evaluates its expression once per element, with x bound to the element. As for task steps, the runtime records StepFanOut, then one unit per element (line_totals[0], line_totals[1], …), each with its own StepSucceeded, and finally the step’s output: the list of element values, in input order. The output type is List<T>, where T is the type of the expression.

+     2ms   #4  StepFanOut                step=line_totals
+     2ms   #5  StepSucceeded             step=line_totals[0] attempt=1
+     2ms   #6  StepSucceeded             step=line_totals[1] attempt=1
+     2ms   #7  StepSucceeded             step=per_unit attempt=1
+     2ms   #8  StepSucceeded             step=line_totals attempt=1

If any element fails, the step fails. See Fan-out.

Conditions

A compute step with when is skipped when the condition is false or null, and its output is null. Its output type is optional (T?, or List<T>? with for), so readers use coalesce or is_null. In the example above, per_unit is skipped for an empty order, which avoids a division by zero. See Conditions.

Errors

The type checker reports type errors in the expression as usual, mostly E0400 (type mismatch), E0405 (optional value where a value is required), E0406 (operator not defined) and E0407 (unknown function or wrong number of arguments).

Some errors can only happen at runtime: division by zero, integer overflow, a failed conversion such as int("abc"), or an operation on a JSON value of the wrong shape. Such an error fails the step (or the element) with the non-retryable code EXPRESSION_ERROR and the message evaluating `compute`: <cause>. The execution then aborts and compensates like after any other step failure.

workflow Discount {
    version "1.0.0"
    input { subtotal: Float, discount_percent: Int = 0 }

    step factor {
        compute input.subtotal * float(100 / (100 - input.discount_percent))
    }
}
$ orbit run discount.orbit --id discount-1 --input '{"subtotal": 10.0, "discount_percent": 100}'
deployed Discount@1.0.0 (sha256:910bcb4107fa)
started execution discount-1
  ✗ factor                         failed evaluating `compute`: division by zero

FAILED discount-1  Discount@1.0.0
  elapsed: 1ms
  compensation: none
  error: EXPRESSION_ERROR step `factor` failed: evaluating `compute`: division by zero

IR version

A workflow with a compute step, or with a lambda anywhere, compiles to orbit-ir/2. See Versioning.

See also

Compensation

A compensate block declares the business action that undoes a step, such as refunding a payment or releasing reserved stock. When a workflow fails, is cancelled or times out, the runtime runs the compensation of every step that succeeded, newest first. This is the Saga pattern: compensation is explicit business logic, not a database rollback. This page covers the syntax, what a compensation can read, when compensations run, and how their outcome is recorded.

Declaring a compensation

workflow Order {
    version "1.0.0"
    input { order_id: String, amount: Float, items: List<LineItem> }

    step reserve {
        worker "inventory.reserve"
        with { order_id: input.order_id, items: input.items }
        compensate {
            worker "inventory.release"
            with { reservation_id: reserve.reservation_id }
        }
    }

    step charge {
        worker "payments.charge"
        after reserve
        with { order_id: input.order_id, amount: input.amount, currency: "USD" }
        compensate {
            worker "payments.refund"
            with { transaction_id: charge.transaction_id, amount: input.amount }
            idempotency_key: "refund-" + charge.transaction_id
            retry { attempts: 10, backoff: exponential, initial_delay: 1s, max_delay: 1m }
        }
    }

    step confirm {
        worker "notifications.send"
        after charge
        with { customer_id: input.order_id, message: "Order confirmed" }
    }
}

If confirm fails permanently, the runtime refunds the charge and then releases the reservation.

Members of a compensate block

A compensate block is a small task step without its own scheduling:

MemberAllowedNotes
worker "task"requiredMissing worker: E0308.
withyesChecked against the compensation task’s input.
usingyes
timeout, queue_timeoutyes
retryyesDefaults to one attempt, like any step.
idempotency_keyyesDefaults to <execution id>/compensate:<unit>.
sleep, workflownoE0308: compensation must call a worker task.
when, after, for, max_parallelnoE0307
compensatenoCompensations cannot be nested (E0102).

Steps that can have a compensation: task steps and workflow steps. A sleep step has nothing to compensate (E0308). Declare at most one compensate block per step.

What a compensation can read

A compensation expression can read:

  • input and execution,
  • the step’s own output, which is not optional here, even if the step has a when condition, because only succeeded steps are compensated,
  • the outputs of the steps the step depends on, directly or transitively (with their usual, possibly optional, types),
  • in a for step, the loop variable, bound to the element being compensated.

In a for step, a reference to the step itself is the output of the element being compensated, not the list. See Fan-out.

Reading any other step is E0308, because that step may not have run when the compensated step did:

task "a.run" {
    input { n: Int }
    output { v: Int }
}

task "a.undo" {
    input { v: Int }
    output { ok: Bool }
}

workflow Undo {
    version "1.0.0"
    step first { worker "a.run" with { n: 1 } }
    step second {
        worker "a.run"
        with { n: 2 }
        compensate { worker "a.undo" with { v: first.v } }
    }
}
error[E0308]: step `first` cannot be referenced here
  --> undo.orbit:17:48
   |
17 |         compensate { worker "a.undo" with { v: first.v } }
   |                                                ^^^^^
   = note: a compensation for `second` can only read `second` and the steps it depends on

error: compilation failed: 1 error(s), 0 warning(s)

Add after first to second to make first an ancestor, and the reference becomes valid.

Compensation references do not add dependencies to the step graph.

When compensations run

Compensation starts when the workflow is aborted, for any of these reasons:

ReasonFinal status
A step (or an element of a for step) fails permanentlyFAILED
The execution is cancelled (orbit cancel, the cancel API, or a parent that no longer needs a child)CANCELLED
The workflow timeout passesTIMED_OUT

The runtime then proceeds in this order:

  1. Withdraw work that has not started: pending and queued units, timers, await steps waiting for a signal, and retries waiting for their delay become CANCELLED.
  2. Wait for attempts that are in flight. They may still succeed, and a step that succeeds now also needs compensating. Failed attempts are not retried while the workflow is aborting.
  3. Compensate every unit that succeeded and has a compensate block, one at a time, in reverse order of completion (the step that finished last is compensated first). Each element of a for step is a separate unit.
  4. Finish with the final status and a compensation outcome.

Steps that were skipped, failed, cancelled or never started are not compensated. Steps without a compensate block are ignored.

A workflow that fails while evaluating its return value, after all steps succeeded, ends FAILED without compensation.

Compensation outcome

Every failed, cancelled or timed-out execution records how compensation went:

OutcomeMeaning
noneNo succeeded step had a compensate block.
completedEvery compensation succeeded.
failedAt least one compensation failed permanently, after its own retries.

A compensation that fails permanently does not stop the others: the runtime keeps undoing the remaining steps, newest first, so as few effects as possible are left behind. When every compensation has run, it records one CompensationFailed event per failed compensation and ends the execution with compensation: failed. The history shows exactly which steps were undone and which were not, so an operator can finish the cleanup. Give compensations generous retry policies for that reason. (Version 0.1.0 stopped at the first failed compensation.)

Compensation in the history

For a workflow with two compensated steps, first and second, followed by a step broken that fails, orbit logs shows:

+   705ms  #20  StepFailed                step=broken error=HTTP_ERROR ...
+   705ms  #21  CompensationStarted       reason=step `broken` failed: ...
+   705ms  #22  StepScheduled             step=compensate:second attempt=1
+   806ms  #24  StepSucceeded             step=compensate:second attempt=1
+   808ms  #25  StepScheduled             step=compensate:first attempt=1
+   905ms  #27  StepSucceeded             step=compensate:first attempt=1
+   907ms  #28  CompensationCompleted
+   907ms  #29  WorkflowFailed            error=HTTP_ERROR step `broken` failed: ...

Compensation units are keyed compensate:<unit>, for example compensate:charge or compensate:reserve[2].

Designing compensations

  • Declare compensated_by on contracts whose effect must be undone. The compiler then warns about every step that calls the task without the matching compensation (W0601, W0602). See Static safety checks.

  • Make compensations idempotent. They are delivered at least once, like any task, and their key is stable.

  • Prefer business actions with clear semantics (refund, release, cancel) over attempts to restore previous state.

  • A compensation can only read data that existed when the step completed. If undoing needs an identifier, return it from the step.

  • Compensation is not available for side effects that cannot be undone, such as a sent email. Order such steps last.

See also

Outcome handlers

An outcome handler is a block of steps that runs when an execution ends badly: on failure when a step fails permanently, on cancel when the execution is cancelled, and on timeout when the workflow’s timeout passes. Handlers run after compensation has finished and before the execution records its terminal status, so they are the place to tell a customer, page an operator or record an audit entry. This page covers the syntax, what handler steps can read, the order of events, and the rules the compiler enforces.

Syntax

The complete example is examples/checkout.orbit, with a test in examples/checkout.test.orbit (Testing workflows).

import "contracts.orbit"

workflow Checkout {
    version "1.0.0"
    input { order_id: String, customer_id: String, amount: Float, items: List<LineItem> }

    step reserve {
        worker "inventory.reserve"
        with { order_id: input.order_id, items: input.items }
        compensate {
            worker "inventory.release"
            with { reservation_id: reserve.reservation_id }
        }
    }

    step charge {
        worker "payments.charge"
        after reserve
        with { order_id: input.order_id, amount: input.amount, currency: "USD" }
    }

    on failure {
        step tell_customer {
            worker "notifications.send"
            with {
                customer_id: input.customer_id
                message: "We could not complete order " + input.order_id + ": " + failure.message
            }
        }
        step summary {
            compute { code: failure.code, reserved: !is_null(reserve), charged: !is_null(charge) }
        }
    }

    on cancel {
        step confirm_cancel {
            worker "notifications.send"
            with { customer_id: input.customer_id, message: "Order " + input.order_id + " was cancelled" }
        }
    }
}

A workflow has at most one block of each kind. A block contains step declarations and nothing else.

BlockRuns whenfailure.reason
on failureA step fails permanently, or a resource limit is reached (LIMIT_EXCEEDED)failed
on cancelThe execution is cancelled (orbit cancel, the HTTP API, or a cancelled parent)cancelled
on timeoutThe workflow timeout passestimed_out

A successful execution runs no handler. When the matching block is absent, the execution finishes exactly as it would without handlers. A return expression that fails, or a result that does not match output, fails the execution directly, after every step has finished; no handler runs in that case.

What handler steps can read

NameTypeNotes
failure{ reason: String, code: String, message: String }Why the execution is ending. For a failed step, code is the step’s error code and message names the step (step `charge` failed: ...); a history limit has code LIMIT_EXCEEDED and the limit’s message; a cancellation has code CANCELLED and the cancellation reason as message; a timeout has code WORKFLOW_TIMEOUT and the message workflow timed out.
inputthe workflow inputUnchanged.
execution{ id, workflow, version }As everywhere else.
a workflow step, e.g. chargethat step’s type, made optionalA workflow step may not have run, or may have failed: inside handlers every workflow step reads as null unless it succeeded. Use is_null and coalesce.
an earlier step of the same blockits output typeOptional if that step has a when. Reading it adds a dependency, as in the main workflow.

failure exists only inside handlers; using it elsewhere is error E0401.

Order of events

When the payment above is declined, orbit logs shows compensation first, then the handler, then the terminal event:

+    59ms   #9  StepFailed                step=charge error=CARD_DECLINED charge of 9000.0 USD declined
+    59ms  #10  CompensationStarted       reason=step `charge` failed: charge of 9000.0 USD declined
+    59ms  #11  StepScheduled             step=compensate:reserve attempt=1
+    70ms  #12  StepDispatched            step=compensate:reserve attempt=1 worker_id=orders-typescript
+   150ms  #13  StepSucceeded             step=compensate:reserve attempt=1
+   151ms  #14  CompensationCompleted
+   151ms  #15  HandlersStarted           kind=failure
+   151ms  #16  StepScheduled             step=on_failure:tell_customer attempt=1
+   151ms  #17  StepSucceeded             step=on_failure:summary attempt=1
+   154ms  #18  StepDispatched            step=on_failure:tell_customer attempt=1 worker_id=orders-typescript
+   187ms  #19  StepSucceeded             step=on_failure:tell_customer attempt=1
+   187ms  #20  WorkflowFailed            error=CARD_DECLINED step `charge` failed: charge of 9000.0 USD declined
  1. In-flight steps finish, and the steps that succeeded are compensated (newest first). See Compensation.
  2. HandlersStarted records the block that runs (kind) and the compensation outcome that the terminal event will carry.
  3. Handler steps run in dependency order; steps without a dependency between them run concurrently, as in the main workflow. Their unit keys are on_<kind>:<step>, for example on_failure:tell_customer.
  4. When every handler step has finished, the terminal event (WorkflowFailed, WorkflowCancelled or WorkflowTimedOut) is recorded, with the original error and compensation outcome.

When a handler step fails

A handler step can retry like any other (retry { ... }). If it still fails, the failure is recorded on its unit and the steps that depend on it are cancelled, but the execution’s outcome does not change: the terminal event keeps the error that started the shutdown, not the handler’s. Other handler steps still run.

Rules

RuleDiagnostic
Only on failure, on cancel, on timeout and on event <signal> existE0105
One block of each kind per workflowE0319
Handler step names must differ from workflow step names and from failureE0302
after may only name steps of the same blockE0303
Handler steps run a worker task, a compute expression or a sleep; await, child workflows, for and max_parallel are not allowedE0307
Handler steps cannot declare compensateE0308
failure is read only inside handler stepsE0401
error[E0307]: `await` is not allowed in an `on failure` step
 --> bad.orbit:6:24
  |
6 |         step x { await s }
  |                        ^
  = note: handlers run while the execution is finishing; it no longer accepts signals

Workflows that declare handlers compile to orbit-ir/2; see Versioning.

See also

Event handlers

An event handler is a block of steps that runs every time a given signal arrives, alongside the workflow’s main steps: on event <signal> { ... }. Use it for messages that can come any number of times while an execution is running, such as tracking scans, progress updates or comments. Use await instead when the workflow has to stop and wait for one specific message. This page covers the syntax, what handler steps can read, how instances run, and the rules the compiler enforces.

Syntax

The complete example is examples/shipment.orbit, with a test in examples/shipment.test.orbit (Testing workflows).

import "contracts.orbit"

workflow Shipment {
    version "1.0.0"
    input { shipment_id: String, customer_id: String }

    signal location { place: String, eta_hours: Int = 0 }
    signal delivered { signed_by: String }

    step arrival { await delivered timeout 30d }

    step confirm {
        worker "notifications.send"
        with {
            customer_id: input.customer_id
            message: "Shipment " + input.shipment_id + " was delivered, signed by " + arrival.signed_by
        }
    }

    on event location {
        step text {
            compute if event.eta_hours > 0
                then "Shipment " + input.shipment_id + " is in " + event.place + ", about " + string(event.eta_hours) + "h away"
                else "Shipment " + input.shipment_id + " is in " + event.place
        }
        step tell {
            worker "notifications.send"
            with { customer_id: input.customer_id, message: text }
        }
    }

    return { signed_by: arrival.signed_by }
}

The signal must be declared with signal. A workflow has at most one on event block per signal, and a block contains step declarations and nothing else. Send the signal as usual:

orbit signal ship-1 location --data '{"place":"Rotterdam","eta_hours":30}'

What handler steps can read

NameTypeNotes
eventthe signal’s payload typeThe payload of the signal that started this instance, with defaults applied.
inputthe workflow inputUnchanged.
execution{ id, workflow, version }As everywhere else.
a workflow step, e.g. arrivalthat step’s type, made optionalMain steps may not have run yet when a signal arrives, so they read as null until they succeed. Use is_null and coalesce.
an earlier step of the same blockits output typeRefers to the step of the same instance. Optional if that step has a when. Reading it adds a dependency.

event exists only inside on event blocks; using it elsewhere is error E0401.

How instances run

Each arriving signal starts a new instance of the block. Instances run concurrently with the main steps and with each other, and each has its own copy of every step:

+     0ms   #3  SignalAwaitStarted        step=arrival signal=delivered
+     0ms   #4  SignalReceived            name=location
+     0ms   #5  SignalDelivered           step=on_event:location@4 signal_seq=4
+     0ms   #6  StepSucceeded             step=on_event:location@4:text attempt=1
+     0ms   #7  StepScheduled             step=on_event:location@4:tell attempt=1
+     0ms   #8  StepDispatched            step=on_event:location@4:tell attempt=1 worker_id=simulator
+     0ms   #9  StepSucceeded             step=on_event:location@4:tell attempt=1
+     0ms  #10  SignalReceived            name=location
+     0ms  #11  SignalDelivered           step=on_event:location@10 signal_seq=10
+     0ms  #12  StepSucceeded             step=on_event:location@10:text attempt=1
...
+     0ms  #16  SignalReceived            name=delivered
+     0ms  #17  SignalDelivered           step=arrival signal_seq=16
+     0ms  #20  StepSucceeded             step=confirm attempt=1
+     0ms  #21  WorkflowCompleted
  1. SignalReceived records the signal; SignalDelivered starts an instance. The instance key is on_event:<signal>@<seq>, where <seq> is the sequence number of the SignalReceived event, so keys are stable across restarts and replays. The instance unit’s output is the payload.
  2. The block’s steps run in dependency order with unit keys on_event:<signal>@<seq>:<step>, for example on_event:location@4:tell. They retry, time out and dispatch to workers like any other step.
  3. The execution completes only when the main steps and every started instance are finished. A signal that arrives while instances are still running starts another one.

orbit status lists instance units with the other steps, and orbit inspect shows the block under on event <signal>.

Failures and shutdown

  • A step of an instance that fails permanently fails the execution, like a failing main step: steps that have not started (in every instance and in the main workflow) are cancelled, in-flight attempts finish, succeeded steps with a compensate are compensated, and the on failure handler runs.
  • When the execution is cancelled, times out or fails, signals that arrive afterwards start no instance. A signal sent to a terminal execution is refused, as for any signal.
  • A step with a when that is false is skipped, and steps that read it see null.

Testing

orbit test sends a signal that an on event block handles as soon as the execution is idle, without waiting for an await. In expectations, write the step as on_event:<signal>:<step>. The expectation must hold for every instance of the block, and at least one instance must have started:

test "every scan notifies the customer" {
    workflow Shipment
    input { shipment_id: "S-1", customer_id: "c-1" }
    mock "notifications.send" returns { delivered: true }
    signal location { place: "Rotterdam", eta_hours: 30 }
    signal location { place: "Utrecht" }
    signal delivered { signed_by: "J. de Vries" }
    expect status COMPLETED
    expect step on_event:location:tell SUCCEEDED
}

Rules

RuleDiagnostic
The signal must be declaredE0315
One on event block per signalE0319
A signal handled by on event cannot also be awaitedE0322
Step names must differ from workflow step names, event and failureE0302
after may only name steps of the same blockE0303
Steps run a worker task, a compute expression or a sleep; await, child workflows, for and max_parallel are not allowedE0307
Steps cannot declare compensateE0308
event is read only inside on event stepsE0401
error[E0322]: signal `note` is handled by `on event` and cannot also be awaited
 --> aw.orbit:4:20
  |
4 |     step a { await note timeout 1h }
  |                    ^^^^
5 |     on event note {
  |              ---- handled here
  = note: every signal handled by `on event` starts the handler; an `await` would compete for it

Workflows with on event blocks compile to orbit-ir/2; see Versioning.

See also

Continue-as-new

An execution’s history grows with every step it runs. A workflow that loops for a long time, such as a subscription that bills every month or a poller that fetches page after page, would record an unbounded history if it ran inside one execution forever. continue_as_new ends the current run when its steps are done and starts a new run of the same execution, with a new input and an empty history. The execution keeps its id; each run is replayed, held in memory and limited on its own. This page covers the syntax and why it has this form, what happens at the switch, how runs are stored and read, and how it combines with signals, children, limits and tests.

Syntax

continue_as_new is a workflow member, next to return:

continue_as_new [when <condition>] with <input>

When every step of the run has finished (the same point at which return is evaluated), the runtime evaluates the condition. If it is true, or if there is no when, the run ends and the next one starts with <input>; otherwise the run completes with return as usual. when and with may come in either order, and the expression after with may span several lines like any object literal.

examples/subscription.orbit bills a customer once per period, with one run per period:

import "contracts.orbit"

workflow Subscription {
    version "1.0.0"
    description "Charge a customer once per period, for a number of periods"
    input {
        customer_id: String
        amount: Float
        periods: Int = 12
        period: Int = 1
        charged: Float = 0.0
    }
    output { periods: Int, charged: Float }

    // The current period, readable at any time: `orbit query <id> period`.
    query period = { period: input.period, of: input.periods, charged: input.charged }

    step charge {
        worker "payments.charge"
        with {
            order_id: input.customer_id + "-" + string(input.period)
            amount: input.amount
            currency: "USD"
        }
        idempotency_key input.customer_id + "/period-" + string(input.period)
        retry { attempts: 5, backoff: exponential, initial_delay: 1m, max_delay: 1h }
    }

    step receipt {
        worker "notifications.send"
        after charge
        with {
            customer_id: input.customer_id
            message: "Payment " + charge.transaction_id + " for period " + string(input.period)
        }
    }

    step next_period { sleep 30d after receipt }

    // Not the last period: start the next one. `with` is typed against the
    // workflow input (omitted fields take their defaults).
    continue_as_new when input.period < input.periods with {
        customer_id: input.customer_id
        amount: input.amount
        periods: input.periods
        period: input.period + 1
        charged: input.charged + input.amount
    }

    return { periods: input.period, charged: input.charged + input.amount }
}

Type checking

The input after with is checked against the workflow’s own input type, exactly like the input of a worker step against its contract: every required field must be given with the right type, fields with defaults may be left out (the defaults are applied when the run starts), and unknown fields are errors. The expressions can read input, the outputs of the run’s steps (a step that may have been skipped reads as optional) and execution. The condition must be a Bool; null counts as false.

CodeError
E0323continue_as_new without with <input>.
E0324The when condition is not a Bool.
E0400, E0403The input does not match the workflow’s input type.
E0106A second continue_as_new in the workflow.
W0607continue_as_new has no when, but the workflow has a return, which can then never be used.

Compiler diagnostics shows the messages. A workflow with continue_as_new compiles to orbit-ir/2; a workflow without it keeps its IR version and its definition hash (Versioning).

Why a declarative member

ORBIT workflows are declarations, not programs: steps and their dependencies are a graph, and the only place a workflow decides what happens after its steps is return. continue_as_new sits at that same place and has the same shape as a step’s when and with, so it reads like the rest of the language and the compiler can check it completely: the next input is typed against the workflow input, and both expressions see the same scope as return. Because it is evaluated only when every step has finished, a run never switches with work in flight: there are no running tasks, timers, children or event handlers to carry over, only the input and the buffered signals. The alternatives were a return continue <expr> form, which would make one member return two kinds of things, and a dedicated step, which could run while other steps were still in flight and would need rules for what happens to them.

What happens at the switch

When the decider finds the condition true, it records WorkflowContinuedAsNew { next_run, input } as the run’s terminal event (the run’s status is CONTINUED_AS_NEW). In the same database transaction, the runtime:

  1. appends that event to the finished run,
  2. moves the finished run’s history from execution_events to execution_run_events, keyed by execution id and run number,
  3. writes the new run’s first events: WorkflowCreated with run, WorkflowStarted, and one SignalReceived per buffered signal (below),
  4. points the execution’s row in workflow_executions at the new run.

A crash therefore leaves the execution either in the finished run (which is decided again after recovery, with the same result) or in the new run; never between them. The new run is then advanced like a newly started execution.

The Inbox workflow below takes one message signal per run and stops at stop. Three signals were sent at once, while run 1 was sleeping:

workflow Inbox {
    version "1.0.0"
    input { seen: Int = 0 }
    output { seen: Int }
    signal message { text: String }
    step take { await message timeout 1d }
    step pause { sleep 2s after take }
    continue_as_new when take.text != "stop" with { seen: input.seen + 1 }
    return { seen: input.seen + 1 }
}
$ orbit logs inbox-1 --run 1
+     0ms   #1  WorkflowCreated
+     0ms   #2  WorkflowStarted
+     1ms   #3  SignalAwaitStarted        step=take signal=message
+    10ms   #4  SignalReceived            name=message
+    11ms   #5  SignalDelivered           step=take signal_seq=4
+    11ms   #6  TimerStarted              step=pause
+    20ms   #7  SignalReceived            name=message
+    28ms   #8  SignalReceived            name=message
+    2.1s   #9  TimerFired                step=pause
+    2.1s  #10  WorkflowContinuedAsNew    next_run=2
$ orbit logs inbox-1 --run 2
+     0ms   #1  WorkflowCreated           run=2
+     0ms   #2  WorkflowStarted
+     0ms   #3  SignalReceived            name=message
+     0ms   #4  SignalReceived            name=message
+     2ms   #5  SignalAwaitStarted        step=take signal=message
+     2ms   #6  SignalDelivered           step=take signal_seq=3
+     2ms   #7  TimerStarted              step=pause
+    2.1s   #8  TimerFired                step=pause
+    2.1s   #9  WorkflowContinuedAsNew    next_run=3
$ orbit logs inbox-1 --run 2 --json | head -4
{"seq":1,"timestamp_ms":1794769170400,"type":"WorkflowCreated","workflow":"Inbox","version":"1.0.0","definition_hash":"sha256:066240aa22c4be894c16cd8294f58c93a1d712db065c53d009825221a89cb028","input":{"seen":1},"run":2}
{"seq":2,"timestamp_ms":1794769170400,"type":"WorkflowStarted"}
{"seq":3,"timestamp_ms":1794769170400,"type":"SignalReceived","name":"message","payload":{"text":"second"}}
{"seq":4,"timestamp_ms":1794769170400,"type":"SignalReceived","name":"message","payload":{"text":"stop"}}

Run 1 took first; second and stop were still buffered when it ended, so run 2 starts with both, in arrival order, and its await takes second at once. Run 3 starts with stop and completes:

$ orbit status inbox-1
COMPLETED inbox-1  Inbox@1.0.0
  run: 3 (continue-as-new)
  elapsed: 2.1s

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  take                         SUCCEEDED          1      0ms
  pause                        SUCCEEDED          1     2.1s

  output: {"seen":3}

Runs

QuestionAnswer
What does GET /v1/executions/{id} (and orbit status, orbit inspect) return?The latest run’s state. It has run (absent for run 1); input, units, signals and created_at_ms are the run’s.
How do I read an earlier run?GET /v1/executions/{id}/history?run=N, orbit logs <id> --run N, orbit replay <id> --run N, or the run selector of the web UI. Each run’s seq starts at 1.
What does wait wait for?The final run: it returns when the latest run ends with a status other than CONTINUED_AS_NEW.
Where do signals go?To the current run. Signals the finished run received but no step took are carried into the next run, in arrival order. A signal sent during the switch is recorded either before it (and carried over if nobody took it) or after it.
Queries?They evaluate against the current run, and can read its input.
Cancellation?orbit cancel cancels the current run, which then ends CANCELLED, and with it the execution.
The workflow timeout?It applies to each run, from the run’s start.
Which definition does the new run use?The same name@version (and hash) as the finished run. To move a long-running execution to a new version, end it and start a new execution of the new version.
Child workflows?A run’s workflow steps start children with ids <id>.<unit> in run 1 and <id>.run<N>.<unit> in run N, so each run’s children are new executions. A parent waiting for a child that continues as new waits for the child’s final run and gets its output.
Idempotency keys?The default key of a unit is <id>/<unit> in run 1 and <id>/run<N>/<unit> in run N. An explicit idempotency_key is used as written, so include something run-specific in it (the subscription uses the period number).
Does a new run count as a new execution?No: it does not count against --max-active-executions, and orbit_executions_started_total does not change. orbit_executions_continued_as_new_total counts the switches. GET /v1/executions lists the execution once.
Shared mode?The execution keeps its owner and ownership epoch. The finished run’s task-queue rows are deleted in the same transaction.

An execution started by a schedule keeps its schedule_id in every run, and the schedule’s overlap policy sees it as running until its final run ends.

Combining it with history limits

--max-history-events (and a workflow’s limits { max_history_events: N }) apply to each run. A workflow that continues as new before a run reaches the limit can run indefinitely under a small limit, and the limit then catches a run that grows unexpectedly. The Inbox execution above recorded 27 events across three runs on a server started with --max-history-events 12; no run recorded more than 10.

workflow Poller {
    version "1.0.0"
    input { cursor: String = "" }
    // A run that reaches 500 events fails with LIMIT_EXCEEDED; each run
    // here records about ten, then continues.
    limits { max_history_events: 500 }
    ...
    continue_as_new when page.has_more with { cursor: page.next }
}

Size a run so that it stays well below the limit: for a for step, each element adds three to five events.

Continue-as-new bounds what one run holds and replays; the archived runs stay in the database (Storage: retention).

Replay and tests

Each run replays on its own against the definition, with its carried signals as external events:

$ orbit replay inbox-1 --run 2
replayed 9 events of inbox-1 against Inbox@1.0.0 with the current decider
  5 decided, 4 external
consistent: every recorded decision is reproduced

orbit test and orbit simulate follow every run. Expectations are checked against the final run, and expect runs N checks how many runs there were (Testing workflows):

test "three periods are three runs" {
    workflow Subscription
    input { customer_id: "c-1", amount: 9.5, periods: 3 }
    mock "payments.charge" returns { transaction_id: "tx-" + input.order_id, status: "captured" }
    mock "notifications.send" returns { delivered: true }
    expect status COMPLETED
    expect runs 3
    expect output { periods: 3, charged: 28.5 }
}

Limitations

  • The switch happens only when every step has finished. There is no way to continue as new from inside a step, a handler or an on event block.
  • A failure while evaluating the condition or the input (for example a division by zero) fails the run with EXPRESSION_ERROR; an input that does not match the workflow’s input type at run time fails it with INPUT_CONTRACT_VIOLATION. Neither starts a new run.
  • A workflow whose run finishes immediately (for example, only compute steps) and always continues starts a new run on every runtime tick. Give such a loop a sleep or an await.
  • GET /v1/executions/{id} does not list the earlier runs; the latest run’s run number tells how many there are.

See also

Expressions

Expressions compute step inputs, conditions, idempotency keys, fan-out lists and workflow results from the workflow input and the outputs of earlier steps. They are pure and deterministic: they perform no I/O and cannot read the clock, so every decision the runtime makes can be replayed from the execution history. This page is the reference for literals, references, operators, precedence and the evaluation rules, including the behavior with null.

Where expressions appear

PositionExpected typeCan read
step withthe task or child workflow inputinput, execution, steps, loop variable
step whenBool or Bool?input, execution, steps
step idempotency_keyStringinput, execution, steps, loop variable
step for x in ...a listinput, execution, steps
step computeany (the type becomes the step’s output type)input, execution, steps, loop variable
compensate membersas aboveinput, execution, the step and its ancestors, loop variable
workflow returnthe workflow outputinput, execution, steps
query name = ...anyinput, execution, steps (every step output optional)
field defaults (= ...)the field typeconstants only
retry settingsliterals onlynothing

Literals

LiteralTypeExamples
stringString"text", "line\nbreak", "\u{1F680}"
integerInt0, 42, 1_000_000
floatFloat3.14, 0.5, 1_000.25
durationDuration500ms, 30s, 1.5m, 2h, 1d
booleanBooltrue, false
nullnullnull
objectrecord{ id: input.id, "content-type": "text/plain" }
listList<T>[1, 2, 3], ["a", "b"], []

Notes:

  • Strings are single-line. The escapes are \n, \t, \r, \", \\ and \u{X...} with one or more hexadecimal digits. Any other escape is E0004, and a line break inside a string is E0002.
  • Underscores in numbers are ignored (1_000 is 1000).
  • A float needs digits on both sides of the point: 0.5, not .5 or 5.. There is no exponent notation.
  • A leading - is an operator. -5 is folded into a negative literal. Because the literal 9223372036854775808 does not fit in 64 bits, write the minimum Int as -9223372036854775807 - 1.
  • A number directly followed by letters is a duration; an unknown unit is E0005 (5q).

References

An identifier at the start of an expression is a reference. It resolves, in order, to:

  1. a loop variable bound by the enclosing for step,
  2. input, the workflow input,
  3. execution, the execution metadata,
  4. a step of the workflow.

Anything else is E0401, with a suggestion when a name is close.

ExpressionTypeValue
inputthe workflow input recordthe whole input
input.order_idthe field’s typea field of the input
analyze_riskthe step’s output typethe whole output of the step
analyze_risk.scorethe field’s typea field of the step output
item.skuthe element’s field typea field of the current element
execution.idStringthe execution id
execution.workflowStringthe workflow name
execution.versionStringthe workflow version

execution must be followed by exactly one of id, workflow or version. execution alone, or another field, is E0402.

The type of a step reference depends on the step: optional if the step has a when, a list if it has a for. See Steps.

Member access

a.b reads field b of a record. An unknown field is E0402, with the closest field suggested or the available fields listed.

Member access propagates null: if a is null, a.b is null. The type of a.b is therefore optional whenever a is optional, which is how a skipped step’s fields become optional.

Member access also works on maps, as a shorthand for indexing with a string: headers.accept is headers["accept"], and its type is optional because the key may be missing.

Indexing

target[index] reads an element:

Target typeIndex typeResult typeRuntime behavior
List<T>IntT?Negative indexes count from the end (-1 is the last element). Out-of-range indexes give null.
Map<String, T>StringT?A missing key gives null.
a recorda string literalthe field typer["field"] is the same as r.field; a non-literal index is E0406.
JSONanyJSON
T?as for Tas for T, optionalA null target gives null.
other typesE0406: the type cannot be indexed

A field access after an index or a call reads a field of the result: input.items[0].sku has type String?.

Object literals

workflow Objects {
    version "1.0.0"
    input { order_id: String, amount: Float }
    return {
        id: input.order_id
        "content-type": "application/json"
        totals: { net: input.amount, gross: input.amount * 1.2 }
    }
}
  • Keys are identifiers or string literals. Use a string for keys that are not identifiers, such as "content-type", or for true, false and null.
  • A key may appear only once (E0107).
  • Entries are separated by commas, semicolons or line breaks.
  • There is no shorthand: { order_id } is a syntax error; write { order_id: input.order_id }.

How an object literal is typed depends on where it appears:

Expected typeRule
a recordEach entry is checked against its field. Unknown keys are E0404, missing required fields are E0403.
Map<String, T>Every value is checked against T.
none, or JSONThe literal’s type is inferred as an anonymous record; a null entry has type JSON.

List literals

Items are separated by commas or line breaks; a trailing comma is allowed. A semicolon between items is a syntax error. The item type is the combination of all item types (see Types), [] has type List<JSON>, and incompatible items are E0400. Where a List<T> is expected, each item is checked against T.

Operators

From lowest to highest precedence. All binary operators are left-associative.

PrecedenceOperatorsOperand typesResult
1||Bool, BoolBool
2&&Bool, BoolBool
3== !=two compatible typesBool
4< <= > >=two numbers, or two stringsBool
5+two numbers; two strings; two listsnumber, String, list
5-two numbersnumber
6* / %two numbersnumber
7 (prefix)!BoolBool
7 (prefix)-Int or Floatsame type
8 (postfix).field, [index], f(...)

1 + 2 * 3 == 7 && !false therefore groups as ((1 + (2 * 3)) == 7) && (!false), and 10 - 3 - 2 is (10 - 3) - 2. Use parentheses to group differently.

Operators that are not defined for their operand types are E0406, with labels showing each operand’s type. Optional operands are not accepted by arithmetic, comparison or logical operators; the diagnostic suggests coalesce. If either operand has type JSON, the check is skipped and the operation is checked at runtime.

Arithmetic

OperandsResult typeRuntime behavior
Int and IntInt64-bit integer arithmetic. Overflow is an error.
Int and Float, or two FloatFloat64-bit floating point.
either is JSONJSONChecked at runtime.
  • / on integers truncates toward zero: 7 / 2 is 3 and -7 / 2 is -3.
  • % takes the sign of the dividend: -7 % 3 is -1.
  • Division or remainder by zero, integer or float, is an error.
  • A float result that is not finite becomes null.
  • Duration has no arithmetic: 30s + 1s is E0406.

The runtime chooses integer or float arithmetic from the actual JSON values, not from the declared types. A Float field whose JSON value is a whole number written without a decimal point, such as 5, is an integer at runtime, and input.price / 2 then performs integer division (2). Write a float literal (input.price / 2.0) or convert explicitly (float(input.price) / 2) when you need float division.

Concatenation

+ on two strings concatenates them. + on two lists returns the items of the left list followed by the items of the right list; the result type is the combination of both list types. A string and a non-string is E0406, and the diagnostic suggests string:

workflow Label {
    version "1.0.0"
    input { order_id: String, attempt: Int }
    return { label: "order-" + input.order_id + "-" + string(input.attempt) }
}

Comparison

<, <=, > and >= compare two numbers (Int and Float mix freely) or two strings. Strings compare lexicographically by Unicode code point, so "B" < "a" is true. Other types, including optional operands and Duration, are E0406.

Equality

== and != compare values structurally: lists element by element, records and maps key by key. Numbers compare by value, so 1 == 1.0 is true.

The operands must have compatible types: comparing an Int with a String is E0406 (“values of different types are never equal”). Comparing any value with null is allowed, which is how you test an optional value:

return { charged: charge.transaction_id != null }

Logical operators

&& and || take Bool operands and short-circuit: the right operand is not evaluated when the left one decides the result. ! negates a Bool. A Bool? operand is rejected at compile time (E0406 for &&/||, E0405 for !); use coalesce(flag, false). At runtime, a non-boolean operand (possible only through JSON) is an error.

Conditional expressions

workflow Route {
    version "1.0.0"
    input { score: Float }
    return {
        queue: if input.score > 0.8 then "manual" else "auto"
    }
}

if c then a else b evaluates c, then exactly one branch.

  • The condition must be Bool or Bool? (E0408). A null condition selects the else branch.
  • else is required.
  • The result type combines both branch types: if c then 1 else 2.5 is Float, and if c then "x" else null is String?. Incompatible branches are E0400.
  • if, then and else may be on separate lines.

Function calls

name(arg, ...) calls a built-in function. There are no user-defined functions. An unknown function or a wrong number of arguments is E0407. The opening parenthesis must be on the same line as the name.

Lambdas

A lambda is a one-parameter function, written parameter => body:

workflow Prices {
    version "1.0.0"
    input { items: List<{ sku: String, price: Float, quantity: Int }> }
    return {
        totals: map(input.items, item => item.price * float(item.quantity))
        expensive: filter(input.items, item => item.price > 100.0)
        all_in_stock: all(input.items, item => item.quantity > 0)
    }
}
RuleDetail
WhereOnly as the second argument of map, filter, find, any and all. Anywhere else is E0415. See Collection functions.
ParameterOne identifier. Its type is the element type of the list. It must not reuse the name of a step, a loop variable, an enclosing lambda parameter, input or execution (E0413).
BodyAny expression, up to the end of the argument. It can read input, execution, step outputs, loop variables and enclosing lambda parameters, so lambdas can nest: map(a, x => map(b, y => x * y)).
Body typeAny type for map; Bool for filter, find, any and all.
Line breaks=> must be on the same line as the parameter. The body may start on the next line.
Precedence=> binds loosest: x => x * 2 + 1 is x => ((x * 2) + 1).
workflow Totals {
    version "1.0.0"
    input { prices: List<Float> }
    step total {
        compute { doubled: p => p * 2.0 }
    }
}
error[E0415]: a lambda can only be passed to `map`, `filter`, `any`, `all` or `find`
 --> lambda.orbit:5:28
  |
5 |         compute { doubled: p => p * 2.0 }
  |                            ^^^^^^^^^^^^
  = help: for example `map(input.items, item => item.price)`

error: compilation failed: 1 error(s), 0 warning(s)

A workflow that contains a lambda anywhere compiles to orbit-ir/2. See Versioning.

Line breaks and operators

An infix operator, and a postfix ., [ or (, must be on the same line as its left operand. A line break before one of these tokens ends the expression. This is what lets object literals and blocks use line breaks as separators:

workflow Lines {
    version "1.0.0"
    input { a: Int, b: Int }
    return {
        sum: input.a +
            input.b          // continues: `+` ends the previous line
        a: input.a
        b: -input.b          // a new entry, not `input.a - input.b`
    }
}

Starting a line with an operator does not continue the previous line:

workflow Broken {
    version "1.0.0"
    input { a: Int, b: Int }
    return {
        sum: input.a
            + input.b
    }
}
error[E0100]: expected a field name or `}`, found `+`
 --> broken.orbit:6:13
  |
6 |             + input.b
  |             ^

error[E0102]: unexpected `+` in workflow
 --> broken.orbit:6:13
  |
6 |             + input.b
  |             ^

error[E0104]: expected a declaration, found `}`
 --> broken.orbit:8:1
  |
8 | }
  | ^
  = note: top-level declarations are `workflow`, `schema`, `task`, `import` and `test`

error: compilation failed: 3 error(s), 0 warning(s)

The first error is the real one; the others follow from the parser recovering. Put the operator at the end of the line instead. Parentheses do not change this rule: inside ( ... ) an operator must also follow its left operand on the same line.

Runtime errors

Type checking prevents most errors, but some can only happen at runtime: integer overflow, division by zero, a failed conversion such as int("abc"), or an operation on a JSON value of the wrong shape. An error while evaluating:

  • a step’s with, when, for, idempotency_key or compute expression fails that step with the non-retryable code EXPRESSION_ERROR, which aborts the workflow and starts compensation,
  • the workflow return fails the workflow with EXPRESSION_ERROR, without compensation,
  • a query fails only that request, with 422 QUERY_FAILED; the execution is not affected.

The error message names the expression and the cause, for example evaluating input: division by zero.

See also

Built-in functions

ORBIT provides a fixed set of pure functions for mapping data between steps: null handling, conversions, string and collection helpers, numeric helpers, and collection functions that take a lambda. Like all expressions, they are deterministic and perform no I/O. This page lists every function with its signature, argument and result types, behavior with null, runtime errors, and examples.

Summary

FunctionSignatureResult
coalescecoalesce(a, b, ...)the first non-null argument
is_nullis_null(x)Bool
lenlen(String | List<T> | Map<String, T>)Int
stringstring(x)String
intint(Int | Float | String | Bool)Int
floatfloat(Int | Float | String | Bool)Float
containscontains(haystack, needle)Bool
lowerlower(String)String
upperupper(String)String
trimtrim(String)String
joinjoin(List<T>, String)String
keyskeys(Map<String, T>)List<String>
minmin(number, ...)number
maxmax(number, ...)number
absabs(number)number
sumsum(List<Int> | List<Float>)Int or Float
mapmap(List<T>, T => U)List<U>
filterfilter(List<T>, T => Bool)List<T>
findfind(List<T>, T => Bool)T?
anyany(List<T>, T => Bool)Bool
allall(List<T>, T => Bool)Bool

General rules:

  • Function names are case-sensitive, and there are no user-defined functions.
  • An unknown name or a wrong number of arguments is E0407. The diagnostic suggests a close name or prints the signature.
  • An argument of the wrong type is E0400 (argument 1 of `len` must be a String, List or Map, found `Int` ).
  • Arguments of type JSON are accepted everywhere and checked at runtime.
  • A runtime error, such as int("abc"), fails the step being scheduled (or the workflow, in return) with EXPRESSION_ERROR. See Expressions.

coalesce

coalesce(a: T?, b: T?, ..., z: T) -> T

Returns the first argument that is not null, or null if all are null. Takes one or more arguments.

AspectBehavior
Argument typesMust be compatible with each other (E0400 otherwise). Int and Float combine to Float.
Result typeThe combined type, non-optional if any argument is non-optional, optional otherwise.
EvaluationAll arguments are evaluated.
workflow Coalesce {
    version "1.0.0"
    input { nickname: String?, name: String?, discount: Float? }
    return {
        display: coalesce(input.nickname, input.name, "customer")   // String
        maybe: coalesce(input.nickname, input.name)                 // String?
        discount: coalesce(input.discount, 0)                       // Float
    }
}

coalesce is the standard way to turn an optional value, such as a field of a skipped step, into a required one. See Conditions.

is_null

is_null(x: T) -> Bool

Returns true if x is null. Accepts any type. is_null(x) is equivalent to x == null.

workflow IsNull {
    version "1.0.0"
    input { coupon: String? }
    return { has_coupon: !is_null(input.coupon) }
}

len

len(x: String | List<T> | Map<String, T>) -> Int
ArgumentResult
Stringthe number of Unicode characters (code points), not bytes: len("héllo") is 5
List<T>the number of items
Map<String, T>the number of keys
null0

Optional arguments are accepted, and null counts as empty. Other types are E0400.

workflow Len {
    version "1.0.0"
    input { items: List<LineItem>, note: String? }
    return { item_count: len(input.items), note_length: len(input.note) }
}

string

string(x: T) -> String

Converts any value to a string:

ArgumentResult
a Stringunchanged
null"null"
a numberits JSON form: string(42) is "42", string(1.5) is "1.5"
a Bool"true" or "false"
a list, map or recordcompact JSON: string([1, 2]) is "[1,2]", string({ a: 1 }) is "{\"a\":1}"
workflow ToString {
    version "1.0.0"
    input { order_id: String, attempt: Int }
    return { key: input.order_id + "#" + string(input.attempt) }
}

int

int(x: Int | Float | String | Bool) -> Int
ArgumentResult
Intunchanged
Floattruncated toward zero: int(2.9) is 2, int(-2.9) is -2
Stringparsed as a base-10 integer after trimming whitespace: int(" 42 ") is 42; int("1.5") and int("abc") are runtime errors
Bool1 for true, 0 for false
nullruntime error

The compiler accepts optional arguments, but a null value fails at runtime. Use int(coalesce(x, 0)) when the argument may be null.

float

float(x: Int | Float | String | Bool) -> Float
ArgumentResult
Int or Floatthe value as a float: float(5) is 5.0
Stringparsed after trimming whitespace: float("2.5") is 2.5; an invalid string is a runtime error
Bool1.0 for true, 0.0 for false
nullruntime error

Use float to force float arithmetic on a value that may arrive as a JSON integer: float(input.price) / 2. See Arithmetic.

Before ORBIT 0.2, the compiler accepted a Bool argument but the runtime rejected it with EXPRESSION_ERROR.

contains

contains(haystack: String, needle: String) -> Bool
contains(haystack: List<T>, needle: T) -> Bool
contains(haystack: Map<String, T>, key: String) -> Bool
HaystackResult
Stringtrue if needle is a substring; contains("hello", "ell") is true
List<T>true if an item equals needle, using structural equality (contains([1, 2], 2.0) is true)
Map<String, T> or a recordtrue if the key exists
nullfalse

The compiler checks only the number of arguments (2), not their types. A combination outside the table, such as a string haystack with a number needle, is a runtime error.

workflow Contains {
    version "1.0.0"
    input { tags: List<String>, email: String }
    return {
        vip: contains(input.tags, "vip")
        corporate: contains(lower(input.email), "@example.com")
    }
}

lower, upper, trim

lower(s: String) -> String
upper(s: String) -> String
trim(s: String) -> String
FunctionResult
lowerUnicode lowercase: lower("ÀB") is "àb"
upperUnicode uppercase
trimthe string without leading and trailing Unicode whitespace

With a String? argument the result is String?, and null returns null. Other argument types are E0400.

workflow Normalize {
    version "1.0.0"
    input { email: String, nickname: String? }
    return {
        email: lower(trim(input.email))          // String
        nickname: upper(input.nickname)          // String?
    }
}

join

join(items: List<T>, separator: String) -> String

Concatenates the items, with separator between them. String items are used as they are; other items are converted to their JSON form, as with string: join([1, 2], ",") is "1,2". An empty list gives "".

The compiler accepts an optional list, but a null list is a runtime error.

workflow Join {
    version "1.0.0"
    input { skus: List<String> }
    return { summary: join(input.skus, ", ") }
}

keys

keys(m: Map<String, T>) -> List<String>

Returns the keys of a map (or the field names of a record), in the order they appear in the JSON object. A null argument is a runtime error.

workflow Keys {
    version "1.0.0"
    input { labels: Map<String, String> }
    return { label_names: keys(input.labels) }
}

min, max

min(a: number, b: number, ...) -> number
max(a: number, b: number, ...) -> number

Return the smallest or largest argument. Take one or more arguments, each Int or Float (or JSON). Optional arguments are E0400; wrap them in coalesce.

AspectBehavior
Result typeInt if every argument is Int, otherwise Float.
Result valueThe winning argument itself, unchanged: max(3, 2.5) returns 3.
TiesThe first of the equal arguments wins.
workflow Clamp {
    version "1.0.0"
    input { requested: Int }
    return { batch_size: max(1, min(input.requested, 500)) }
}

abs

abs(x: Int) -> Int
abs(x: Float) -> Float

Returns the absolute value. The result type is the argument type. An optional argument is E0400. abs of the minimum Int overflows and is a runtime error.

workflow Abs {
    version "1.0.0"
    input { expected: Float, actual: Float }
    return { drift: abs(input.actual - input.expected) }
}

sum

sum(list: List<Int>) -> Int
sum(list: List<Float>) -> Float

Adds the elements of a list of numbers. The result is Int for a List<Int> and Float otherwise. The list may be optional; sum of null and of an empty list is 0. An element that is not a number is a runtime error (possible only with JSON values), and so is Int overflow. Any other argument type is E0400 (argument 1 of `sum` must be a List of Int or Float, found `List<String>` ).

At runtime, the result is an integer whenever every element is an integer JSON number, so the sum of an empty List<Float> is the JSON number 0, not 0.0. JSON consumers that distinguish the two should not rely on the representation.

workflow Invoice {
    version "1.0.0"
    input { quantities: List<Int>, prices: List<Float> }
    return { units: sum(input.quantities), total: sum(input.prices) }
}

Collection functions

map, filter, find, any and all take a list and a lambda: a one-parameter function written parameter => expression. The function calls the lambda once per element, in list order, with the parameter bound to the element. See Expressions for the lambda syntax.

Rules shared by all five:

  • They take exactly two arguments (E0407 otherwise, with the signature in a note). The first must be a list (E0400: argument 1 of `map` must be a List, found `String` ); a List<T>? is accepted, and null behaves like an empty list. The second must be a lambda (E0400: argument 2 of `any` must be a lambda).
  • The lambda body can read input, execution, step outputs, loop variables and the parameters of enclosing lambdas. Reading a step’s output adds a dependency, as anywhere else.
  • For filter, find, any and all, the body must have type Bool (E0400, or E0405 for a Bool?). At runtime a null condition counts as false.
  • The parameter must not reuse the name of a step, a loop variable, an enclosing lambda parameter, input or execution (E0413).
  • A lambda anywhere else is E0415.
  • An error inside the body, such as a division by zero for one element, fails the whole call.

map

map(list: List<T>, f: T => U) -> List<U>

Returns the list of f(x) for each element x.

workflow Emails {
    version "1.0.0"
    input { users: List<{ email: String, name: String }> }
    return { emails: map(input.users, u => lower(u.email)) }
}

filter

filter(list: List<T>, f: T => Bool) -> List<T>

Returns the elements for which f is true, in their original order.

workflow Restock {
    version "1.0.0"
    input { items: List<{ sku: String, stock: Int }> }
    return { low: map(filter(input.items, i => i.stock < 5), i => i.sku) }
}

find

find(list: List<T>, f: T => Bool) -> T?

Returns the first element for which f is true, or null when there is none. The result is always optional.

workflow Primary {
    version "1.0.0"
    input { addresses: List<{ city: String, primary: Bool }> }
    return { city: coalesce(find(input.addresses, a => a.primary).city, "unknown") }
}

any, all

any(list: List<T>, f: T => Bool) -> Bool
all(list: List<T>, f: T => Bool) -> Bool

any is true when f is true for at least one element, and all when it is true for every element. Both stop at the first element that decides the result. For an empty list, any is false and all is true.

task "mail.send" {
    input { to: List<String> }
    output { sent: Int }
}

workflow Notify {
    version "1.0.0"
    input { users: List<{ email: String, active: Bool }> }

    step send {
        worker "mail.send"
        when any(input.users, u => u.active)
        with { to: map(filter(input.users, u => u.active), u => u.email) }
    }
}

A workflow that uses a lambda anywhere compiles to orbit-ir/2. See Versioning.

See also

HTTP adapter

orbit.http.request is a built-in task that calls an HTTP endpoint from the runtime itself, without a worker. Use it for webhooks, health checks and simple API calls that need no business logic. This page covers the task’s contract, how requests are built, how responses and errors are mapped, and how to enable the adapter on the server.

Enabling the adapter

The adapter lets workflow authors make outbound requests from the runtime host, so it is disabled by default. Start the server with --enable-http-adapter:

orbit server --enable-http-adapter

On a server without the flag, every orbit.http.request attempt fails with ADAPTER_DISABLED, which is not retryable. Workflows that use the adapter still compile and register. See Running the server and Security.

Contract

The task is declared in the prelude that every compilation includes:

task "orbit.http.request" {
    version "1.0.0"
    input {
        method: String = "GET"
        url: String
        headers: Map<String, String> = {}
        body: JSON = null
    }
    output {
        status: Int
        headers: Map<String, String>
        body: JSON
    }
}
Input fieldTypeDefaultMeaning
methodString"GET"The HTTP method, case-insensitive: GET, HEAD, OPTIONS, DELETE, POST, PUT or PATCH.
urlStringrequiredThe URL. Only http:// and https:// URLs are allowed.
headersMap<String, String>{}Request headers.
bodyJSONnullThe request body. Sent only for POST, PUT and PATCH.
Output fieldTypeMeaning
statusIntThe HTTP status code.
headersMap<String, String>Response headers, with lowercase names. If a header repeats, the last value wins.
bodyJSONThe response body, parsed as JSON when possible, otherwise the body text as a string ("" for an empty body).

Example

workflow NotifyWebhook {
    version "1.0.0"
    input { order_id: String, url: String }

    step deliver {
        worker "orbit.http.request"
        with {
            method: "POST"
            url: input.url
            headers: { "content-type": "application/json", "x-order-id": input.order_id }
            body: { event: "order.completed", order_id: input.order_id }
        }
        timeout 10s
        retry { attempts: 5, backoff: exponential, initial_delay: 1s, max_delay: 30s }
    }

    return { delivered: deliver.status < 300, status: deliver.status }
}

deliver.status is an Int, deliver.headers["content-type"] is a String?, and deliver.body is JSON, so its fields can be read without type errors (deliver.body.id) and are checked at runtime.

Request body encoding

For POST, PUT and PATCH:

body valueWhat is sent
null (the default)an empty body
a stringthe string as is; set content-type yourself
any other valuethe value serialized as JSON

For GET, HEAD, OPTIONS and DELETE, body is ignored.

Status codes and errors

OutcomeResultRetryable
1xx to 4xx except 429The step succeeds with the response. Check status in the workflow.
429 and 5xxThe attempt fails with HTTP_ERROR (server responded 503). The error details carry the status and body.yes
Network or DNS failureThe attempt fails with HTTP_ERROR (request failed: ...).yes
Request slower than the timeoutThe attempt fails with HTTP_ERROR or TIMEOUT, whichever the runtime records first.yes
URL not http:// or https://HTTP_ERROR (only http:// and https:// URLs are allowed)no
Unsupported methodHTTP_ERROR (unsupported method TRACE)no
Missing url at runtimeHTTP_ERROR (missing url)no
Adapter disabledADAPTER_DISABLEDno

A 404 or 400 is a successful step: the workflow decides what it means. To treat those as failures, branch on status or check it in a later step. Retryable failures are retried only if the step has a retry policy with more than one attempt; see Retries and timeouts.

Timeouts and limits

SettingValue
Request timeoutthe step timeout, or 30 seconds when the step sets none
Response bodyread up to 8 MiB

The step timeout applies to the whole request, including connecting and reading the body. queue_timeout and using have no practical effect, because the runtime executes the request as soon as the step is scheduled.

Idempotency

The adapter does not send the step’s idempotency key. If the endpoint supports idempotency keys, pass one as a header:

workflow Charge {
    version "1.0.0"
    input { order_id: String, amount: Float }
    step charge {
        worker "orbit.http.request"
        with {
            method: "POST"
            url: "https://payments.example.com/v1/charges"
            headers: { "idempotency-key": "charge-" + input.order_id }
            body: { amount: input.amount }
        }
        retry { attempts: 3, backoff: exponential, initial_delay: 500ms }
    }
}

See also

Versioning

ORBIT versions three things independently: workflow definitions, task contracts and the compiled IR format. A registered workflow version never changes, running executions stay on the exact definition they started with, and workers only receive tasks for contract versions they implement. This page explains each kind of version, what changes a definition’s identity, and how to roll out changes safely.

Workflow versions

Every workflow declares a version (E0500 if missing):

workflow Invoice {
    version "2.1.0"
    input { customer_id: String }
    step pause { sleep 1s }
}

The version is a string. The compiler warns (W0201) when it is not MAJOR.MINOR.PATCH; pre-release and build suffixes such as 2.1.0-rc.1 or 2.1.0+build.5 are accepted.

Immutability

A workflow definition is identified by name@version. When you register a definition (orbit deploy, orbit run <file>, or POST /v1/workflows), the runtime compares its definition hash with any definition already registered under the same name and version:

SituationResult
No definition with that name@versionRegistered (HTTP 201).
Same name@version, same hashNo change (HTTP 200, up to date).
Same name@version, different hashRejected with DEFINITION_CONFLICT (HTTP 409).
error: DEFINITION_CONFLICT (409): Invoice@2.1.0 is already registered with a different definition (sha256:…); workflow definitions are immutable, bump the version

To change a workflow, bump its version and register the new definition. Both versions stay registered.

The definition hash

The definition hash is a SHA-256 hash (sha256:<hex>) of the compiled definition’s semantic content. It covers everything that affects execution: input and output types, steps, expressions, policies, and the contracts the workflow uses, including the definition hashes of child workflows. It excludes source locations, so these changes keep the hash:

  • reformatting, including orbit fmt,
  • editing comments,
  • moving top-level declarations between files or reordering them.

These changes produce a new hash, and therefore need a new version:

  • any change to the workflow’s input, output, steps or return,
  • any change to a task contract the workflow uses, including its output type or contract version,
  • any change to a child workflow it starts (because the child’s hash changes),
  • a change to a schema the workflow uses, including renaming it,
  • reordering steps that do not depend on each other, because such steps are stored in source order.

Changing a shared contract or a child workflow therefore requires bumping every workflow that uses it.

Executions are pinned

An execution records the definition hash it started with and runs that definition to the end, even after newer versions are registered. Retries, redeliveries, crash recovery and compensations all use the pinned definition.

When you start an execution without naming a version (orbit run Name, or a start request without version), the runtime picks the registered version with the highest numeric MAJOR.MINOR.PATCH. Pass --version (or version in the API) to start a specific one.

Child workflow versions

A workflow "Name" step is compiled against the child definition in the same compilation and pins that definition’s hash. The parent always starts exactly that child definition, even if a newer version of the child is registered later. Registering a parent whose child definition is not registered fails with CHILD_NOT_REGISTERED; orbit deploy registers children first. See Child workflows.

Contract versions

A task contract has its own version, "1.0.0" if omitted:

task "payments.charge" {
    version "2.0.0"
    input { order_id: String, amount_cents: Int, currency: String }
    output { transaction_id: String }
}

Workers announce the task names and contract versions they implement. The runtime gives a task only to workers that announced the same name and the same version, so a workflow compiled against payments.charge@2.0.0 never reaches a worker that only implements 1.0.0.

Use the contract version to roll out incompatible changes:

  1. Deploy workers that implement both the old and the new contract version.
  2. Change the contract, bump its version, and bump the versions of the workflows that use it. Register them.
  3. Let executions on the old workflow versions finish.
  4. Remove the old contract version from the workers.

Compatible additions, such as a new optional output field, can keep the contract version. Workers may also return fields that the contract does not declare; the runtime ignores them during validation. The workflows that use the contract still get a new definition hash, so they still need a version bump.

IR version

Every compiled definition records its IR format in ir_version. The compiler chooses the lowest version that can express the workflow:

IR versionUsed for
orbit-ir/1Every workflow that uses none of the features below.
orbit-ir/2A workflow that declares a signal or a query, has an await or compute step, uses a lambda in any expression (with, when, for, idempotency_key, compensate, return), declares an on failure, on cancel, on timeout or on event handler or a limits block, or has a continue_as_new member.

A workflow that does not use these features compiles exactly as before: it keeps orbit-ir/1 and the same definition hash, so upgrading the compiler never forces a version bump or causes a DEFINITION_CONFLICT. Using one of the features changes the definition and needs a new workflow version, like any other change. compensated_by on a contract is not an orbit-ir/2 feature, but it is part of the contract recorded in the definition, so adding it changes the hash of every workflow that calls the task.

$ orbit compile --stdout examples/order_processing.orbit | grep ir_version
  "ir_version": "orbit-ir/1",
$ orbit compile --stdout examples/approval.orbit | grep ir_version
  "ir_version": "orbit-ir/2",
$ orbit compile --stdout examples/checkout.orbit | grep ir_version
  "ir_version": "orbit-ir/2",
$ orbit compile --stdout examples/subscription.orbit | grep ir_version
  "ir_version": "orbit-ir/2",

The runtime accepts every IR version it supports and rejects any other with 400 INVALID_IR (unsupported IR version `orbit-ir/3`; this runtime supports `orbit-ir/1`, `orbit-ir/2`), instead of misreading it. It also rejects an orbit-ir/1 definition that uses an orbit-ir/2 feature. GET /v1/health lists the supported versions in ir_versions:

{"status":"ok","api_version":"orbit-api/1","runtime_version":"0.2.0","ir_versions":["orbit-ir/1","orbit-ir/2"],"active_executions":0,"worker_sessions":2}

A runtime that only lists orbit-ir/1 (ORBIT 0.1) cannot register workflows that use signals, queries, await, compute, lambdas, outcome or event handlers, or a limits block. A runtime from ORBIT 0.4.0 or earlier also rejects a workflow with continue_as_new (400 INVALID_IR, because it does not know the field, the definition hash it computes does not match). Upgrade the runtime before you deploy them; definitions already registered keep working after the upgrade.

Versioning checklist

  • Bump the workflow version for every change that is not formatting or comments.
  • Bump the contract version when old workers cannot handle the new contract.
  • After changing a contract or a child workflow, bump every workflow that uses it.
  • Use MAJOR.MINOR.PATCH versions, so that “latest” selection works as expected.
  • Treat signals and queries like the input and output: senders and readers depend on them. Run orbit compat to see which changes break them.
  • Before deploying a workflow that compiles to orbit-ir/2, check that the runtime lists it in GET /v1/health.

See also

Static safety checks

Besides type errors, the compiler looks for workflows that compile but are likely to misbehave in production: a payment that is never refunded when a later step fails, a step that can never run, a signal nobody waits for, a wait that can last forever. It reports them as warnings in the W06xx range. This page describes each check with real compiler output, the compensated_by contract member that the compensation checks rely on, how to make warnings fail a build with --strict, and what the checks do and do not guarantee.

Summary

CodeReported whenTypical fix
W0601A step calls a task whose contract declares compensated_by, and the step has no compensate block.Add the compensation.
W0602The step’s compensate block calls a different task than the contract names.Call the named task, or fix the contract.
W0603A step’s when condition is a constant that is false or null.Remove the step, or make the condition depend on data.
W0604A step’s when condition is a constant true.Remove when.
W0605A workflow declares a signal that no step awaits.Add an await step, or remove the declaration.
W0606An await step has no timeout and the workflow has no timeout.Add a step or workflow timeout.

Warnings do not stop compilation: orbit check exits 0, orbit compile writes the IR, and orbit deploy registers it. Use --strict to make them fail.

Declaring how a task is undone: compensated_by

A task contract can name the task that undoes it:

task "payments.charge" {
    version "1.0.0"
    input { order_id: String, amount: Float }
    output { transaction_id: String }
    compensated_by "payments.refund"
}

task "payments.refund" {
    version "1.0.0"
    input { transaction_id: String }
    output { refunded: Bool }
}

compensated_by takes a task name string. It must name a task declared in the compilation, including imported files; an unknown name is E0316, with a suggestion for close misspellings. With compensated_by "payments.refnud" in the file above:

error[E0316]: `compensated_by` names unknown task `payments.refnud`
 --> payments.orbit:5:20
  |
5 |     compensated_by "payments.refnud"
  |                    ^^^^^^^^^^^^^^^^^
  = help: did you mean `payments.refund`?

error: compilation failed: 1 error(s), 0 warning(s)

The declaration belongs to the contract, so every workflow that calls the task is checked against it, by whoever writes the workflow. compensated_by is checked only by the compiler; the runtime never runs a compensation that the step does not declare. It is recorded in the contract inside the compiled definition, so adding or changing it changes the definition hash of every workflow that calls the task (see Versioning). It does not by itself make a workflow orbit-ir/2.

The checks

The examples below import this file as payments.orbit:

task "payments.charge" {
    version "1.0.0"
    input { order_id: String, amount: Float }
    output { transaction_id: String }
    compensated_by "payments.refund"
}

task "payments.refund" {
    version "1.0.0"
    input { transaction_id: String }
    output { refunded: Bool }
}

task "payments.void" {
    version "1.0.0"
    input { transaction_id: String }
    output { voided: Bool }
}

W0601: missing compensation

A step calls a task whose contract declares compensated_by, but the step has no compensate block. If a later step fails, the charge would stay in place.

import "payments.orbit"

workflow Checkout {
    version "1.0.0"
    input { order_id: String, amount: Float }

    step charge {
        worker "payments.charge"
        with { order_id: input.order_id, amount: input.amount }
    }
}
warning[W0601]: step `charge` calls `payments.charge` but has no compensation
 --> checkout.orbit:8:16
  |
8 |         worker "payments.charge"
  |                ^^^^^^^^^^^^^^^^^
  = note: `payments.charge` declares `compensated_by "payments.refund"`: if a later step fails, its effect would be left in place
  = help: add `compensate { worker "payments.refund" with { ... } }`

ok Checkout@1.0.0 (1 steps)

Add the compensation:

import "payments.orbit"

workflow Checkout {
    version "1.0.0"
    input { order_id: String, amount: Float }

    step charge {
        worker "payments.charge"
        with { order_id: input.order_id, amount: input.amount }
        compensate {
            worker "payments.refund"
            with { transaction_id: charge.transaction_id }
        }
    }
}

The check applies to every step that calls the task with worker, including for steps.

W0602: compensation uses a different task

The step has a compensation, but it calls another task than the one the contract names.

import "payments.orbit"

workflow Checkout {
    version "1.0.0"
    input { order_id: String, amount: Float }

    step charge {
        worker "payments.charge"
        with { order_id: input.order_id, amount: input.amount }
        compensate {
            worker "payments.void"
            with { transaction_id: charge.transaction_id }
        }
    }
}
warning[W0602]: step `charge` compensates `payments.charge` with `payments.void`, but the contract names `payments.refund`
  --> checkout.orbit:11:20
   |
11 |             worker "payments.void"
   |                    ^^^^^^^^^^^^^^^
   = help: use `worker "payments.refund"` in the compensation, or change the contract's `compensated_by`

ok Checkout@1.0.0 (1 steps)

W0603: step never runs

A when condition that reads nothing (no input, no step output, no execution field, no loop variable) has the same value in every execution. The compiler evaluates it. If it is false or null, the step is skipped in every execution:

task "audit.record" {
    input { order_id: String }
    output { recorded: Bool }
}

workflow Flags {
    version "1.0.0"
    input { order_id: String }

    step legacy_audit {
        worker "audit.record"
        when 1 > 2
        with { order_id: input.order_id }
    }

    step audit {
        worker "audit.record"
        when true || false
        with { order_id: input.order_id }
    }
}
warning[W0603]: step `legacy_audit` never runs: its `when` condition is always false
  --> flags.orbit:12:14
   |
12 |         when 1 > 2
   |              ^^^^^
   = help: remove the step, or make the condition depend on input or step results

warning[W0604]: the `when` condition of step `audit` is always true
  --> flags.orbit:18:14
   |
18 |         when true || false
   |              ^^^^^^^^^^^^^
   = help: remove `when`; it also makes the step's output optional for no reason

ok Flags@1.0.0 (2 steps)

Constant conditions usually come from a feature flag that was switched off by editing the source, or from a condition that was simplified too far.

W0604: condition always true

The same analysis, for a constant condition that is true (see the example above). The step always runs, but its output type is optional because of the when, so every reader has to handle a null that never happens. Remove the when.

W0605: signal declared but never awaited

A workflow declares a signal that no step awaits. Senders can still send it, and the runtime accepts and buffers it, but nothing ever reads it.

workflow Review {
    version "1.0.0"

    signal review { approved: Bool }
    signal escalate { reason: String }

    step decision { await review }

    return { approved: decision.approved }
}
warning[W0605]: signal `escalate` is declared but no step awaits it
 --> review.orbit:5:12
  |
5 |     signal escalate { reason: String }
  |            ^^^^^^^^
  = help: add a step with `await escalate`, or remove the declaration

warning[W0606]: step `decision` waits for signal `review` with no timeout
 --> review.orbit:7:27
  |
7 |     step decision { await review }
  |                           ^^^^^^
  = help: add `timeout 7d` to the step (or a workflow `timeout`) so the execution cannot wait forever

ok Review@1.0.0 (1 steps)

W0606: wait without a timeout

An await step without a timeout, in a workflow without a timeout, waits until a signal arrives or someone cancels the execution. If the sender has a bug or the person never answers, the execution stays RUNNING forever. The example above reports it for decision. Either bound works:

workflow Review {
    version "1.0.0"
    timeout 30d

    signal review { approved: Bool }

    step decision {
        await review
        timeout 7d
    }

    return { approved: decision.approved }
}

A step timeout fails the step with SIGNAL_TIMEOUT, which starts compensation. A workflow timeout ends the execution TIMED_OUT. See Signals and queries.

Treating warnings as errors

orbit check --strict and orbit compile --strict fail when there is any warning, including the non-safety warnings W0201 (version is not semantic) and W0301 (unknown worker language):

$ orbit check --strict review.orbit
warning[W0605]: signal `escalate` is declared but no step awaits it
 --> review.orbit:5:12
  |
5 |     signal escalate { reason: String }
  |            ^^^^^^^^
  = help: add a step with `await escalate`, or remove the declaration

warning[W0606]: step `decision` waits for signal `review` with no timeout
 --> review.orbit:7:27
  |
7 |     step decision { await review }
  |                           ^^^^^^
  = help: add `timeout 7d` to the step (or a workflow `timeout`) so the execution cannot wait forever

error: 2 warning(s) treated as errors (--strict)
$ echo $?
1
CommandWithout --strictWith --strict and only warnings
orbit checkprints warnings and ok lines, exits 0prints warnings and error: <n> warning(s) treated as errors (--strict), no ok lines, exits 1
orbit check --format jsonprints the diagnostics, exits 0prints the diagnostics, exits 1
orbit compilewrites the IR, exits 0writes nothing, exits 1

Errors take precedence: a file with errors fails the same way with or without --strict. orbit deploy and orbit run do not take --strict; run orbit check --strict (or orbit compile --strict) before them.

In CI

Run the strict check on every change, next to the compatibility check:

- name: Check workflows
  run: orbit check --strict workflows/*.orbit

To annotate a pull request, use the JSON format and read the diagnostics array; the exit code still reflects --strict:

orbit check --strict --format json workflows/*.orbit > diagnostics.json

What the checks guarantee

The checks are deliberately simple, so that a warning is always worth reading. They are not a proof that a workflow is correct.

CheckWhat a clean result meansWhat it does not cover
W0601, W0602Every step that calls a task with compensated_by directly, with worker, declares a compensation that calls the named task.Tasks without compensated_by. Whether the compensation’s input identifies the right effect (it is type-checked like any with), whether the worker really undoes it, or whether the compensation succeeds at runtime. Child workflows that call the task are checked inside the child.
W0603, W0604No when condition is a constant.Conditions that read data but can never be true (input.amount < 0 && input.amount > 0) or are always true (input.flag || true). Constant conditions whose evaluation fails, such as 1 / 0 > 0; those fail at runtime with EXPRESSION_ERROR.
W0605Every declared signal has at least one await step.Whether that step can actually run: an await step with a condition that is never true still counts.
W0606Every await step has a step or a workflow timeout.Steps that wait for a worker. Use timeout and queue_timeout on task steps; see Retries and timeouts.

The compiler’s type checks remain the main guarantee: every step input, condition, query and return expression is checked against the declared types before anything is deployed.

Organisation policies

The checks above are fixed and apply to every workflow. Rules that depend on the organisation, such as “every payment step is compensated”, “charges of 10000 or more wait for an approval signal”, “every wait for a person has a deadline of at most 7 days” or “only these services may be called”, go in a policy file checked with orbit check --policy <file>. Violations are reported in the same format with codes in the P01xx range, can be errors or warnings per rule, and can be waived per step with a // policy-allow: <rule> <reason> comment. See Policies.

See also

Grammar

This page is the formal reference for ORBIT syntax. It reproduces the EBNF grammar accepted by the orbit-syntax parser, walks through it section by section, and describes the lexical rules: tokens, comments, contextual keywords and the line-break rule. Semantic restrictions that the compiler enforces after parsing are summarized at the end, with their diagnostic codes.

EBNF

The grammar uses ISO EBNF: { x } means zero or more, [ x ] means optional, and , separates the items of a sequence.

(* ORBIT grammar: the language accepted by orbit-syntax 0.2.0.
   Notation: ISO EBNF. { x } = zero or more, [ x ] = optional.

   Lexical notes
   - Keywords are contextual: every word lexes as IDENTIFIER and the parser
     treats it as a keyword only in keyword position. `input`, `version`,
     `status` ... remain valid field names. Only true/false/null are reserved.
   - Whitespace and newlines are insignificant, with one rule: an infix
     operator (and a postfix `[`, `.` or `(`) must be on the same line as its
     left operand. `{ a: 1  b: -2 }` therefore has two fields.
   - Separators `,` and `;` between members, fields and list items are optional.
   - Member keywords may be followed by an optional `:`
     (`timeout 5s` == `timeout: 5s`). *)

program          = { declaration } ;
declaration      = workflow_decl | schema_decl | task_decl | import_decl | test_decl ;

import_decl      = "import" , STRING ;                     (* path relative to the importing file *)

schema_decl      = "schema" , IDENTIFIER , record_body ;
record_body      = "{" , { field , [ sep ] } , "}" ;
field            = IDENTIFIER , ":" , type , [ "=" , expression ] ;   (* default must be constant *)

task_decl        = "task" , STRING , "{" , { task_member , [ sep ] } , "}" ;
task_member      = "version" , [ ":" ] , STRING
                 | "description" , [ ":" ] , STRING
                 | "input" , [ ":" ] , type
                 | "output" , [ ":" ] , type
                 | "compensated_by" , [ ":" ] , STRING ;        (* the task that undoes this one *)

workflow_decl    = "workflow" , IDENTIFIER , "{" , { workflow_member , [ sep ] } , "}" ;
workflow_member  = "version" , [ ":" ] , STRING
                 | "description" , [ ":" ] , STRING
                 | "timeout" , [ ":" ] , DURATION
                 | "input" , record_body
                 | "output" , record_body
                 | step_decl
                 | "signal" , IDENTIFIER , record_body           (* typed message the execution accepts *)
                 | "query" , IDENTIFIER , [ "=" | ":" ] , expression   (* read-only view; written with "=" *)
                 | "return" , expression
                 | "continue_as_new" , continue_as_new
                   (* at most one (E0106); `with` is required (E0323) and typed
                      against the workflow input; `when` must be Bool (E0324) *)
                 | "limits" , "{" , { IDENTIFIER , ":" , INTEGER , [ sep ] } , "}"
                   (* max_fan_out, max_history_events, max_payload_bytes; positive
                      integers (E0320, E0321); the stricter of this and the
                      server's limit applies *)
                 | "on" , "event" , IDENTIFIER , "{" , { step_decl , [ sep ] } , "}"
                   (* runs once per arriving signal of that name, alongside the
                      main steps; `event` is the payload. The signal must be
                      declared (E0315) and not awaited (E0322); one block per
                      signal (E0319). Same step rules as outcome handlers. *)
                 | "on" , ( "failure" | "cancel" | "timeout" ) , "{" , { step_decl , [ sep ] } , "}" ;
                   (* outcome handler: at most one block per kind (E0319); other
                      kinds are rejected (E0105). Handler steps take a worker,
                      compute or sleep action; no await, child workflow, for,
                      max_parallel or compensate (E0307, E0308); `after` names
                      steps of the same block (E0303). *)

continue_as_new  = [ "when" , [ ":" ] , expression ] , "with" , [ ":" ] , expression
                 | "with" , [ ":" ] , expression , "when" , [ ":" ] , expression ;

step_decl        = "step" , IDENTIFIER , "{" , { step_member , [ sep ] } , "}" ;
step_member      = action
                 | "using" , [ ":" ] , IDENTIFIER
                 | "after" , [ ":" ] , IDENTIFIER , { "," , IDENTIFIER }
                 | "with" , [ ":" ] , expression
                 | "timeout" , [ ":" ] , DURATION                (* per attempt, start-to-close *)
                 | "queue_timeout" , [ ":" ] , DURATION          (* time allowed waiting for a worker *)
                 | "retry" , "{" , { IDENTIFIER , ":" , expression , [ sep ] } , "}"
                 | "idempotency_key" , [ ":" ] , expression
                 | "when" , [ ":" ] , expression
                 | "for" , IDENTIFIER , "in" , expression        (* fan out over a list *)
                 | "max_parallel" , [ ":" ] , INTEGER            (* only with `for` *)
                 | "compensate" , "{" , { step_member , [ sep ] } , "}" ;
action           = "worker" , [ ":" ] , STRING                   (* a task contract name *)
                 | "workflow" , [ ":" ] , STRING                 (* a child workflow name *)
                 | "sleep" , [ ":" ] , DURATION
                 | "await" , [ ":" ] , IDENTIFIER                (* a signal declared in the workflow *)
                 | "compute" , [ ":" ] , expression ;            (* evaluated by the runtime *)
(* Restrictions checked by the compiler (see docs/src/reference/diagnostics.md):
   - `workflow` steps take no `using`, `retry`, `idempotency_key` or `queue_timeout` (E0307);
   - `compensate` blocks call a `worker` and take no `when`, `after`, `for` or `max_parallel`;
   - `for` is not allowed on `sleep` steps; the loop variable is in scope in `with`,
     `idempotency_key` and `compensate`, but not in `when` (evaluated once per step);
   - `await` steps take only `after`, `when` and `timeout`; `compute` steps take only
     `after`, `when`, `for` and `max_parallel`; neither takes `compensate` (E0307, E0308). *)

(* retry settings: attempts: INTEGER, backoff: fixed | exponential,
   initial_delay: DURATION, max_delay: DURATION, non_retryable: [STRING, ...] *)

type             = base_type , { "?" } ;
base_type        = "List" , "<" , type , ">"
                 | "Map" , "<" , type , "," , type , ">"       (* key must be String *)
                 | record_body                                  (* anonymous record *)
                 | IDENTIFIER ;                                 (* primitive or schema name *)
(* primitives: String Int Float Bool Bytes Duration Timestamp UUID JSON *)

expression       = or_expr ;
or_expr          = and_expr , { "||" , and_expr } ;
and_expr         = eq_expr , { "&&" , eq_expr } ;
eq_expr          = cmp_expr , { ( "==" | "!=" ) , cmp_expr } ;
cmp_expr         = add_expr , { ( "<" | "<=" | ">" | ">=" ) , add_expr } ;
add_expr         = mul_expr , { ( "+" | "-" ) , mul_expr } ;
mul_expr         = unary , { ( "*" | "/" | "%" ) , unary } ;
unary            = ( "!" | "-" ) , unary | postfix ;
postfix          = primary , { "." , IDENTIFIER | "[" , expression , "]" } ;
primary          = literal
                 | IDENTIFIER , "(" , [ argument , { "," , argument } ] , ")"   (* built-in call *)
                 | IDENTIFIER                                    (* path root: input | execution | step | loop variable *)
                 | "{" , { ( IDENTIFIER | STRING ) , ":" , expression , [ sep ] } , "}"
                 | "[" , [ expression , { [ "," ] , expression } ] , "]"   (* items: "," or newline, not ";" *)
                 | "if" , expression , "then" , expression , "else" , expression
                 | "(" , expression , ")" ;
argument         = IDENTIFIER , "=>" , expression                (* lambda: only as argument 2 of map, filter, find, any, all (E0415) *)
                 | expression ;
literal          = STRING | INTEGER | FLOAT | DURATION | "true" | "false" | "null" ;
block            = "{" , { ANY_TOKEN | block } , "}" ;
sep              = "," | ";" ;

(* Tokens *)
IDENTIFIER       = ( letter | "_" ) , { letter | digit | "_" } ;
INTEGER          = digit , { digit | "_" } ;                    (* signed 64-bit *)
FLOAT            = digit , { digit | "_" } , "." , digit , { digit | "_" } ;
DURATION         = ( INTEGER | FLOAT ) , ( "ms" | "s" | "m" | "h" | "d" ) ;
STRING           = '"' , { char | escape } , '"' ;              (* single line *)
escape           = "\n" | "\t" | "\r" | '\"' | "\\" | "\u{" , hex , { hex } , "}" ;
(* comments: // to end of line, and nestable /* ... */ *)

(* Tests, run by `orbit test` and never compiled into workflow IR.
   Values in `input`, `signal` and `expect ... output` must be constants;
   in `returns`, `input` is the mocked task's input. *)
test_decl        = "test" , STRING , "{" , { test_member , [ sep ] } , "}" ;
test_member      = "workflow" , [ ":" ] , IDENTIFIER
                 | "version" , [ ":" ] , STRING
                 | "input" , [ ":" ] , expression
                 | "mock" , STRING , mock_behaviour , { "times" , INTEGER , "then" , mock_behaviour }
                 | "signal" , IDENTIFIER , [ expression ]         (* object payload on the same line; default {} *)
                 | "expect" , expectation ;
mock_behaviour   = "returns" , expression
                 | "fails" , STRING , [ STRING ] , [ "retryable" ]   (* code, message *)
                 | "hangs" ;                                      (* never answers *)
expectation      = "status" , IDENTIFIER                          (* COMPLETED FAILED CANCELLED TIMED_OUT *)
                 | "output" , expression
                 | "error" , STRING
                 | "step" , IDENTIFIER , ( IDENTIFIER             (* a unit status, e.g. SKIPPED *)
                                         | "output" , expression
                                         | "attempts" , INTEGER )
                 | "compensated" , IDENTIFIER
                 | "runs" , INTEGER ;                               (* the final run, >= 1 (E0712, E0713) *)

The source of this grammar is specification/grammar.ebnf in the repository.

Walkthrough

Programs and declarations

A program is a sequence of declarations: workflow, schema, task, import and test, in any order. Separators (, or ;) between declarations are allowed and ignored. Anything else at the top level is E0104. See Files and imports.

Records and types

record_body is shared by schemas, inline record types and the workflow input and output blocks. Each field is name: type, optionally followed by = expression for a constant default.

A type is a base type followed by any number of ?. Base types are List<T>, Map<K, V> (where K must be String), an inline record, or an identifier naming a primitive or a schema. See Types.

Task contracts

A task declaration has a string name and up to five members: version, description, input, output and compensated_by. See Task contracts.

Workflows and steps

A workflow has an identifier name and members: version, description, timeout, input, output, any number of step, signal and query declarations, return, continue_as_new, and on handlers. continue_as_new takes with <input> and an optional when <condition>, in either order; see Continue-as-new. A signal has a name and a record body, like input. A query has a name, = and an expression; the parser also accepts : or nothing in place of =, but = is the documented form. See Signals and queries. An on member is an outcome handler: on failure, on cancel or on timeout, followed by a block of step declarations. Any other word after on is rejected with E0105. See Outcome handlers.

A step has an identifier name and members. action is one of worker, workflow, sleep, await (followed by a signal name) or compute (followed by an expression). The compensate block contains step members, with the restrictions listed under Restrictions after parsing. See Steps.

Tests

A test declaration has a string name and members: workflow (an identifier), version, input, any number of mock, signal and expect members. A mock chains behaviours with times N then. expect runs N checks the run an execution ended in. Tests are checked by the compiler but never compiled into workflow IR. See Testing workflows.

Retry blocks

retry { ... } contains name: expression pairs. The grammar accepts any identifier and expression; the compiler then requires the five known settings with literal values (E0410). See Retries and timeouts.

Expressions

The expression grammar is layered by precedence, from or_expr (lowest) to postfix and primary (highest). All binary operators are left-associative. A unary - directly before a number literal is folded into a negative literal. See Expressions.

primary covers literals, function calls (an identifier immediately followed by (), path roots (input, execution, a step name, a loop variable or a lambda parameter), object literals, list literals, if ... then ... else ..., and parenthesized expressions.

A function argument can be a lambda, IDENTIFIER => expression. The parser recognizes name => wherever a primary expression can start, so a misplaced lambda is reported by the compiler (E0415) rather than as a syntax error. The compiler accepts lambdas only as the second argument of map, filter, find, any and all. See Expressions.

Notes on the grammar

The parser differs from the EBNF in a few details:

  • List items are separated by commas or line breaks. A semicolon between list items is a syntax error (E0100), although sep in the EBNF includes ;. A trailing comma is allowed.
  • Function arguments are separated by commas only, and a trailing comma is allowed: max(1, 2,).
  • In after a, b, the commas are required. after a b parses b as an unknown member (E0102).
  • retry, compensate and for do not accept the optional : that other members accept.
  • In a lambda, => must be on the same line as the parameter.

Lexical rules

Character set

Source files are UTF-8. Identifiers and keywords are ASCII; non-ASCII characters are allowed only in string literals and comments. Any other character outside a string or comment is E0001.

Tokens

TokenFormNotes
identifier[A-Za-z_][A-Za-z0-9_]*Case-sensitive.
integer[0-9][0-9_]*Underscores are ignored. Must fit in a signed 64-bit integer (E0006).
float[0-9][0-9_]*.[0-9][0-9_]*Digits are required on both sides of .. No exponent.
durationan integer or float immediately followed by ms, s, m, h or dNormalized to milliseconds, rounded. Any other letters after a number are E0005.
string"..." on one lineEscapes: \n, \t, \r, \", \\, \u{hex}. Unknown escapes are E0004; an unterminated or multi-line string is E0002.
punctuation{ } ( ) [ ] < > , : ; . ? = ! + - * / %
operators== != <= >= && || =>Two-character operators are lexed greedily. => separates a lambda parameter from its body.
reserved literalstrue, false, nullThe only reserved words.

Comments

// starts a comment that runs to the end of the line. /* starts a block comment that ends at the matching */; block comments nest. An unterminated block comment is E0003. Comments are whitespace to the parser, except that a block comment spanning lines counts as a line break.

Whitespace and line breaks

Whitespace and line breaks are insignificant, with one rule: an infix operator, and a postfix ., [ or (, must be on the same line as its left operand. A line break before such a token ends the expression. That is why

workflow Fields {
    version "1.0.0"
    return {
        a: 1
        b: -2
    }
}

has two fields, a: 1 and b: -2, rather than one field a: 1 - 2. See Expressions.

Separators and colons

  • , and ; between members, fields and object entries are optional.
  • Member keywords that take a value may be followed by an optional :. timeout 5s and timeout: 5s are the same, and so are idempotency_key "k" and idempotency_key: "k".

Keywords

ORBIT keywords are contextual. The lexer produces an identifier for every word except true, false and null, and the parser treats a word as a keyword only in a position where that keyword is expected. Everywhere else the word is an ordinary identifier, so input, version, status, step or timeout can be field names.

PositionWords with special meaning
top levelworkflow, schema, task, import, test
task bodyversion, description, input, output, compensated_by
workflow bodyversion, description, timeout, input, output, step, signal, query, return, on
step bodyworker, workflow, sleep, await, compute, using, after, with, timeout, queue_timeout, retry, idempotency_key, when, for, in, max_parallel, compensate
typeList, Map, and the primitive type names
expressionif, then, else; input and execution as path roots
retry valuesfixed, exponential
test bodyworkflow, version, input, mock, returns, fails, retryable, hangs, times, then, signal, expect, status, output, error, step, attempts, compensated

This workflow is valid, because each word is used outside its keyword positions:

schema step {
    workflow: String
    input: Int
    timeout: Int
}

workflow workflow {
    version "1.0.0"
    input { step: step, for: Int }
    return { input: input.step.workflow, version: input.for }
}

A few names are reserved in specific positions because expressions would otherwise be ambiguous:

PositionReserved namesDiagnostic
step namesinput, execution, output, if, then, elseE0306
for loop variablesinput, execution, and the names of the workflow’s stepsE0413
lambda parametersinput, execution, the names of the workflow’s steps, loop variables and enclosing lambda parametersE0413
schema namesthe primitive type names, List, MapE0200
task namesnames starting with orbit.E0200

An object key that is not a valid identifier, or that is true, false or null, must be written as a string: { "null": 1, "content-type": "x" }. Record type fields must be identifiers, so a contract field cannot be named true, false or null.

Restrictions after parsing

The grammar accepts some programs that the compiler then rejects:

RestrictionCode
A workflow has a version.E0500
A step has exactly one of worker, workflow, sleep, await or compute.E0301, E0106
A step member appears at most once (except after).E0106
workflow steps take no using, retry, idempotency_key or queue_timeout.E0307
sleep steps take only after and when.E0307, E0308
await steps take only after, when and timeout, and name a declared signal.E0307, E0308, E0315
compute steps take only after, when, for and max_parallel.E0307, E0308
Signal and query names are unique within a workflow.E0317, E0318
compensated_by names a declared task.E0316
Lambdas appear only as the second argument of map, filter, find, any and all.E0415
max_parallel requires for.E0307
compensate calls a worker and takes no when, after, for or max_parallel.E0308, E0307
The loop variable is not in scope in when.E0401
retry settings are known names with literal values.E0410
Timeouts are greater than zero. max_parallel is 1 to 10000.E0410
Defaults are constants of the field’s type.E0401, E0400, E0205
Map keys are String.E0204
Schemas are not recursive.E0202
The step graph and the workflow call graph are acyclic.E0304, E0311
on is followed by failure, cancel or timeout.E0105
A workflow has at most one on block of each kind.E0319
Handler step names differ from workflow step names, reserved names and failure.E0302
after in a handler names a step of the same block.E0303
Handler steps take no await, child workflow, for or max_parallel, and no compensate.E0307, E0308
failure is read only inside handler steps.E0401
continue_as_new appears at most once, has with, a Bool when, and an input of the workflow’s input type.E0106, E0323, E0324, E0400, E0403
expect runs is at least 1, and more than 1 only for a workflow with continue_as_new.E0712, E0713

The full list of codes is in Compiler diagnostics.

See also

How workers work

A worker is a process you run that implements one or more task contracts. It connects to the ORBIT runtime, announces the tasks it serves, long-polls for work, runs your handler, and reports the result. Workers never own workflow state: the runtime decides what runs next, records every outcome in the execution history, and redelivers work when a worker disappears. This page describes the model that all six official SDKs share. The language pages document each SDK’s API, and the worker protocol reference describes the wire format for writing a worker in another language.

The worker lifecycle

Every SDK runs the same loop over the HTTP/JSON binding of the worker protocol:

worker                                         runtime
  │ POST /v1/workers/register  ───────────────▶ │  worker_id, runtime, contracts[]
  │ ◀─────────────── worker_session_id, heartbeat_interval_ms, session_timeout_ms
  │
  │ POST /v1/workers/poll  (long poll) ───────▶ │  worker_session_id, wait_ms
  │ ◀──────────────── 200 task assignment   or   204 nothing within wait_ms
  │   run the handler
  │ POST /v1/tasks/complete  or  /v1/tasks/fail ▶ │  token, output | error
  │ ◀──────────────────────────────── accepted, reason
  │
  │ POST /v1/workers/heartbeat  (every heartbeat_interval_ms)
  │ ◀──────────────────────────────── accepted, cancel[]
  │ POST /v1/workers/unregister  (on shutdown)
  1. Register. The worker sends its worker_id, the runtime it represents (for example python), and the list of task contracts it implements. The runtime returns a worker_session_id and the heartbeat interval. With the default --session-timeout 30, the session timeout is 30 seconds and the heartbeat interval is 5 seconds (one sixth of the timeout, at least 500 ms).
  2. Poll. Each poll loop asks for a task and waits up to wait_ms (the SDKs use 20 seconds; the runtime caps it at 60 seconds). The runtime answers as soon as a matching task is queued, or with 204 No Content when the wait ends. Workers pull work, so they need no inbound ports and work behind NAT.
  3. Execute. The SDK decodes the task input, calls your handler with the input and a context object, and waits for it to return.
  4. Complete or fail. A returned value is sent to /v1/tasks/complete. A task error or an unexpected exception is sent to /v1/tasks/fail.
  5. Heartbeat. A background loop calls /v1/workers/heartbeat at the interval the runtime asked for. Polls and heartbeats both count as contact. The heartbeat response carries the tokens of attempts the worker should stop (see Timeouts, heartbeats and cancellation).
  6. Unregister. On shutdown the SDK stops polling, lets in-flight tasks finish, and unregisters the session.

If the runtime restarts, it forgets worker sessions. The next session call returns 404 UNKNOWN_SESSION; every SDK then registers again and continues. Task tokens stay valid across sessions, so a result produced during the restart is still delivered.

Task names and contract versions

A worker announces each task as a pair of task_name and contract_version, for example payments.charge at 1.0.0. Both must match the task declaration in your .orbit files exactly:

task "payments.charge" {
    version "1.0.0"
    input  { order_id: String, amount: Float }
    output { transaction_id: String }
}

A worker receives a task only when it announced the same name and the same contract version that the running workflow uses. When a worker announces a version that no registered workflow uses, registration still succeeds, and the response includes a warning that every SDK logs:

runtime: `greeting.compose` is announced at version 2.0.0 but registered workflows use 1.0.0; this worker will not receive those tasks

Names that start with orbit. are built-in tasks, and contract names that refer to child workflows are reserved. Announcing either fails registration with 400 RESERVED_TASK. A worker must announce at least one task.

The runtime validates the output of every completed task against the contract’s output schema, after applying schema defaults. Output that does not match fails the attempt with OUTPUT_CONTRACT_VIOLATION, which is never retried. See Task contracts and Versioning.

Routing with using

A step can name the language of the worker that should run it:

step normalize { worker "text.normalize" using python with { text: input.text } }

When a step has using <language>, the runtime only hands the task to workers whose announced runtime equals that language, compared case-insensitively. using any and a step without using accept a worker of any runtime. Each SDK announces its own language by default:

SDKDefault runtime
Pythonpython
TypeScript and JavaScripttypescript
Gogo
Rustrust
Javajava
C#csharp

Every SDK lets you override the announced runtime. For example, a Node.js worker that serves steps declared using javascript must set its runtime to javascript, because the TypeScript SDK announces typescript.

When several workers can serve a task, the runtime hands the oldest queued matching task to whichever worker polls first.

Concurrency

Each SDK runs a fixed number of poll loops, set by its concurrency option (default 4 in every SDK). A loop polls, runs one task to completion, reports the result, and polls again, so the concurrency setting is the maximum number of tasks the worker runs at the same time. A busy worker stops polling, which gives natural backpressure: queued work waits for, or goes to, another worker.

How the loops run depends on the language:

SDKPoll loops run on
Pythonone thread per loop; async def handlers run in a fresh event loop per task
TypeScriptasynchronous loops on the Node.js event loop; CPU-bound handlers block every loop
Goone goroutine per loop
Rustone OS thread per loop
Javaone platform thread per loop
C#one thread-pool task per loop

To scale beyond one process, run more worker processes. Each registers its own session, and the runtime distributes tasks among them.

Delivery guarantees and idempotency

ORBIT delivers tasks at least once. A handler can run more than once for the same step:

  • Retries. A failed attempt that the retry policy allows is scheduled again with a higher attempt number.
  • Redeliveries. When the runtime cannot know the outcome of an attempt (the worker session went silent for longer than the session timeout, the worker unregistered while the task was running, or the runtime restarted and nobody reported the result), it delivers the same attempt again, with the same attempt number and redelivery: true. A redelivery does not use up a retry.

Every task carries an idempotency_key. By default it is <execution_id>/<step>; a step can set its own with idempotency_key:. The key is identical across retries and redeliveries of a step, so use it to make external side effects idempotent: pass it to a payment provider’s idempotency header, use it as a unique key in your database, or remember results by it.

@worker.task("payments.charge", version="1.0.0")
def charge(input, ctx):
    existing = db.find_charge(ctx.idempotency_key)
    if existing:
        return existing                      # a retry or redelivery
    return db.create_charge(ctx.idempotency_key, input["amount"])

The runtime reconciles results. It accepts a result for any token issued for a step that is not yet resolved, because the work really happened: a late success from an attempt that already timed out still completes the step. Failures count only for the attempt currently in flight. A result that arrives after the step is resolved is recorded as StaleResultIgnored and answered with accepted: false; the SDKs log it and move on. See Idempotency and Delivery guarantees.

Errors

A handler fails an attempt by raising or returning a task error with three parts:

FieldMeaning
codeA stable, machine-readable string, for example CARD_DECLINED.
messageA human-readable description.
retryableWhether the runtime may retry the attempt. Defaults to true in every SDK.
detailsOptional JSON value stored with the error in the execution history.

Retryable and non-retryable errors

The runtime retries a failed attempt only when all of these hold:

  1. the error is retryable,
  2. its code is not listed in the step’s retry { non_retryable: [...] },
  3. attempts remain (attempts counts the first attempt; the default is 1, which means no retries), and
  4. the workflow is not being aborted.

non_retryable matches error codes exactly and case-sensitively, so a code listed there stops retries even when the worker marked the error retryable:

step charge {
    worker "payments.charge"
    retry {
        attempts: 5
        backoff: exponential
        initial_delay: 500ms
        non_retryable: ["CARD_DECLINED"]
    }
}

Mark an error non-retryable in the worker when retrying can never help, such as invalid input or a declined card. Leave it retryable for transient problems, such as a timeout calling another service, and let the workflow’s retry policy decide. See Retries and timeouts.

Error codes the SDKs produce

The SDKs report these codes on your behalf:

CodeRetryableProduced when
UNHANDLED_EXCEPTIONyesThe handler raised an exception or returned an error that is not a task error. The details include a stack trace where the language provides one.
PANICyesA Go or Rust handler panicked.
INVALID_INPUTnoA typed handler in Rust, Java or C# could not decode the input into its input type. In Go, you return this code yourself when Decode fails.
INVALID_RESULTnoThe handler returned something that is not a JSON object.
UNKNOWN_TASKyesThe runtime delivered a task the worker did not register.
CANCELLEDyes in Java and C#, no in TypeScriptThe handler stopped because the attempt was cancelled, and let the SDK’s cancellation error propagate.

The runtime itself produces these codes, which you can list in non_retryable or see in orbit status:

CodeRetriedMeaning
TIMEOUTyesThe attempt exceeded the step’s timeout.
OUTPUT_CONTRACT_VIOLATIONneverThe worker’s output does not match the contract.
INPUT_CONTRACT_VIOLATIONneverThe step’s input does not match the contract.
EXPRESSION_ERRORneverAn expression failed to evaluate.
QUEUE_TIMEOUTneverNo worker picked the task up within queue_timeout.
ADAPTER_DISABLEDneverAn orbit.http.request step ran on a runtime without --enable-http-adapter.

Timeouts, heartbeats and cancellation

A step’s timeout limits one attempt, from dispatch to result. The runtime sends it to the worker as timeout_ms, together with deadline_ms, the absolute deadline in Unix epoch milliseconds. When the deadline passes, the runtime fails the attempt with TIMEOUT (retryable) and schedules a retry if the policy allows one.

Cancellation is cooperative. When an attempt times out, or its workflow is cancelled or fails, the runtime puts the attempt’s token in the cancel list of the next heartbeat response. The SDK then signals your handler:

SDKCancellation signalFires on deadline locally
Pythonctx.cancelled (threading.Event), ctx.is_cancelledno
TypeScriptctx.signal (AbortSignal), ctx.cancelledno
Gothe handler’s context.Contextyes
Rustctx.is_cancelled()no
Javactx.isCancelled(), ctx.cancellation(), thread interruptyes
C#ctx.CancellationTokenyes

The runtime cannot stop an external operation for you. Check the signal between units of work, pass it to I/O calls that accept one, and use remaining_ms (or the language’s equivalent) to bound calls to other services. A heartbeat cancellation only reaches the handler at the next heartbeat, up to one heartbeat interval later.

A worker whose session is silent for longer than the session timeout is considered lost, and its in-flight attempts are redelivered elsewhere. The SDKs heartbeat on a separate thread or task, so a long-running handler keeps its session alive without doing anything.

Credentials and TLS

The runtime supports three ways for a worker to authenticate. Each SDK sends credentials the same way on every request.

Shared worker token. Start the runtime with --token (the admin token) and --worker-token, and give worker hosts the worker token. A worker token can only call /v1/workers/* and /v1/tasks/*; any other endpoint returns 403 FORBIDDEN, so a compromised worker host cannot start, cancel or read executions.

Per-worker identities. A workers file (--workers-file workers.toml) gives each worker its own credential and limits. orbit token --worker payments-1 --task 'payments.*' prints a random token and the matching entry:

[[worker]]
id = "payments-1"
token_sha256 = "3cfba7de6b6d9594a537cd712545cf82b225be7d53660269f397568fc0e5a3d1"
tasks = ["payments.*"]       # exact names, `prefix.*` or `*`
runtime = "python"           # optional

A worker authenticated this way can register only under its own id, announce only tasks its patterns allow, use only its own sessions, and report results only for tokens issued to it. Anything else returns 403 FORBIDDEN. Set the SDK’s worker id to the identity’s id.

Mutual TLS. When the runtime serves HTTPS with --tls-client-ca, a worker can present a client certificate signed by that CA instead of a token. The runtime matches the certificate’s DNS SANs and subject common name against client_cert entries in the workers file.

To trust a runtime that uses a certificate from a private CA, give the SDK the CA certificate in PEM form. The CA file must hold a real CA certificate (basicConstraints=CA:TRUE, keyCertSign), not the server certificate.

See Security for how to configure the runtime side.

Environment variables

Every SDK reads its connection settings from the environment when you do not set them in code. An option set in code always wins. Empty variables count as unset.

VariablePurpose
ORBIT_SERVERRuntime URL. Default http://127.0.0.1:7233. A trailing / is removed.
ORBIT_WORKER_TOKENBearer token: the shared worker token or a per-worker token. Takes precedence over ORBIT_TOKEN.
ORBIT_TOKENBearer token used when ORBIT_WORKER_TOKEN is unset.
ORBIT_CA_CERTPath of a PEM CA certificate to trust for HTTPS.
ORBIT_CLIENT_CERTPath of a PEM client certificate for mutual TLS.
ORBIT_CLIENT_KEYPath of the PEM private key for ORBIT_CLIENT_CERT.

The TLS variables differ slightly between SDKs:

SDKORBIT_CA_CERTORBIT_CLIENT_KEY
PythonTrusted instead of the system store.Optional; without it the key is read from the certificate file.
TypeScriptUsed only together with a client certificate, and then replaces Node.js’s default CAs. Without a client certificate, set NODE_EXTRA_CA_CERTS.Optional; defaults to the certificate file.
GoAdded to the system pool.Required with a certificate.
RustTrusted instead of the system store.Required; the certificate is ignored without it.
JavaTrusted in addition to the system store.Required with a certificate; must be unencrypted PKCS#8.
C#Trusted in addition to the system store.Optional; without it the key is read from the certificate file.

The Python SDK also reads ORBIT_LOG_LEVEL (default INFO) when Worker.run() configures logging.

Graceful shutdown

Every SDK shuts down in the same order: it stops starting new polls, lets the tasks already running finish and delivers their results, then unregisters the session. Unregistering makes the runtime redeliver any task the worker still held, immediately instead of after the session timeout.

How you trigger shutdown, and what happens to running handlers, differs:

SDKTriggerRunning handlersOpen long poll
PythonSIGINT, SIGTERM, or worker.stop()run to completionwaits for it to return (up to poll_wait_ms)
TypeScriptSIGINT, SIGTERM, or worker.stop()run to completionaborted
Gocancel the context passed to Runtheir context is cancelledaborted
Rustworker.stop() from another threadrun to completionwaits for it to return
Javaworker.stop(), typically from a shutdown hookrun to completioninterrupted
C#cancel the token passed to RunAsynctheir CancellationToken is cancelledaborted

Shutting down is always safe, even with a hard kill. Delivery is at least once, so a task whose result never arrived is redelivered after the session timeout.

Result delivery through outages

If the runtime is unreachable or answers with a 5xx status when a worker reports a result, the SDK retries with exponential backoff (200 ms doubling to 5 s). After a runtime restart the result is still accepted, because the runtime reconciles results for unresolved steps. A 4xx response or accepted: false is final and is not retried.

SDKRetries a result for
Python, TypeScript, Rustas long as the worker runs; stops once shutdown begins
Go, C#up to 10 minutes, including during shutdown
Javaup to resultRetryTimeout (default 10 minutes), including during shutdown

Comparing the SDKs

PythonTypeScriptGoRustJavaC#
Packageorbit-lang-sdk (PyPI)orbit-lang (npm)github.com/AjmalX0/Orbit/sdks/goorbit-worker (crates.io)io.github.ajmalx0:orbit-sdkOrbitLang.Sdk (NuGet)
RequiresPython 3.9Node.js 18Go 1.21Rust 1.89Java 17.NET 8
Dependenciesnonenonenoneserde, serde_json, ureqJacksonnone
Handler inputdicttyped (unchecked)*orbit.Task, Decodeserde-typedJackson-typed or JsonNodeSystem.Text.Json-typed or JsonElement
Async handlersasync def or plainasync or plainblockingblockingblockingasync
Default worker idpython-<host>-<pid>typescript-<pid>go-<host>-<pid>requiredjava-<host>-<pid>csharp-<host>-<pid>
Context exposes contract_version, timeout_msnonoyescontract_version onlyyesyes
Handles signals itselfyesyesnononono
Client for starting workflowsClientClientnononono
Heartbeats while drainingnonononoyesno

During shutdown, only the Java SDK keeps heartbeating until the last task finishes. In the other SDKs, a task that keeps running for longer than the session timeout after shutdown begins can be redelivered to another worker, and the first worker’s late result is then reconciled like any other.

See also

Python

The Python SDK, orbit-lang-sdk, runs ORBIT task handlers written in Python. It uses only the standard library, supports plain and async def handlers, runs a configurable number of tasks in parallel on threads, and handles registration, long polling, heartbeats, cancellation, result delivery through runtime outages, and graceful shutdown on SIGINT and SIGTERM. It also includes a small Client for starting workflows and waiting for their results. Read How workers work first for the concepts this page relies on.

Install

pip install orbit-lang-sdk

The package requires Python 3.9 or later and has no dependencies. The distribution is named orbit-lang-sdk; the import name is orbit_sdk.

A minimal worker

This worker implements text.normalize, the Python step of examples/polyglot.orbit:

task "text.normalize" { version "1.0.0" input { text: String } output { text: String } }

step normalize { worker "text.normalize" using python with { text: input.text } }
import re

from orbit_sdk import TaskError, Worker

worker = Worker("text-python")


@worker.task("text.normalize", version="1.0.0")
def normalize(input, ctx):
    text = input["text"]
    if not text.strip():
        raise TaskError("EMPTY_TEXT", "text must not be blank", retryable=False)
    ctx.log("normalizing", chars=len(text))
    text = re.sub(r"[^\w\s']", " ", text.lower())
    return {"text": re.sub(r"\s+", " ", text).strip()}


if __name__ == "__main__":
    worker.run()

Run it against a local runtime. The workflow has four steps in four languages, so start the other three example workers from the repository root as well:

orbit server &
python3 text_worker.py &
node examples/workers/polyglot/text_worker.ts &
(cd examples/workers/polyglot/go && go run .) &
cargo run -p orbit-worker --example text_worker &

orbit run examples/polyglot.orbit \
  --input '{"text": "The quick brown fox jumps over the lazy dog. The dog sleeps."}'

With blank text the handler raises a non-retryable error, so the step fails on its first attempt, the other steps are cancelled, and orbit run reports:

FAILED 4ea9f162-...  DocumentInsights@1.0.0
  compensation: none
  error: EMPTY_TEXT step `normalize` failed: text must not be blank

Registering tasks

Register each task with the @worker.task decorator or with worker.register. The decorator returns your function unchanged, so you can still call it directly in tests.

@worker.task("payments.charge", version="1.0.0")
def charge(input, ctx):
    ...

def refund(input, ctx):
    ...

worker.register("payments.refund", refund, version="1.0.0")

The name and version must match the task declaration in your .orbit files exactly. A worker registers each task name once; registering the same name again raises ValueError. Register every task before calling run().

Handlers and results

A handler takes two arguments, the task input as a dict and a Context, and returns a dict that becomes the step’s output:

@worker.task("inventory.reserve", version="1.0.0")
def reserve(input, ctx):
    return {"reservation_id": f"res-{input['order_id']}"}
  • When the runtime sends no input, input is {}.
  • Returning None reports {}.
  • Returning anything other than a dict fails the attempt with INVALID_RESULT, which is not retried.
  • The runtime validates the output against the contract’s output schema; a mismatch fails the step with OUTPUT_CONTRACT_VIOLATION.

async def handlers are supported. The SDK runs each one to completion with asyncio.run on the poll thread that received the task:

@worker.task("text.fetch", version="1.0.0")
async def fetch(input, ctx):
    body = await download(input["url"])
    return {"bytes": len(body)}

Raising task errors

Raise TaskError to fail an attempt with a structured error:

from orbit_sdk import TaskError

@worker.task("payments.charge", version="1.0.0")
def charge(input, ctx):
    if input["amount"] > 5000:
        raise TaskError("CARD_DECLINED", "limit exceeded", retryable=False,
                        details={"limit": 5000})
    ...
ArgumentTypeDefaultMeaning
codestrrequiredError code, matched exactly by the step’s non_retryable list.
messagestr""Human-readable message.
retryableboolTrueFalse fails the step immediately, whatever its retry policy.
detailsany JSON valueNoneStored with the error in the execution history.

Any other exception is reported as a retryable UNHANDLED_EXCEPTION. The message is <ExceptionType>: <text>, and details.traceback holds the last frames of the traceback. The SDK also logs the traceback at ERROR level.

Context

The second handler argument describes the attempt being executed.

MemberTypeMeaning
tokenstrIdentifies this delivery. The SDK echoes it in the result.
execution_idstrThe workflow execution.
workflowstrWorkflow name.
workflow_versionstrWorkflow version.
step_idstrThe step, or compensate:<step> for a compensation; step[i] for a fan-out element.
task_namestrThe task name, for example text.normalize.
attemptint1-based attempt number. Redeliveries keep the number.
idempotency_keystrStable across retries and redeliveries. Use it for side effects.
redeliveryboolTrue when the outcome of an earlier delivery of this attempt is unknown.
deadline_msint or NoneAbsolute deadline of the attempt in Unix epoch milliseconds, when the step has a timeout.
cancelledthreading.EventSet when the runtime asks the attempt to stop.
is_cancelledbool (property)True once cancelled is set.
remaining_ms()int or NoneMilliseconds left before deadline_ms, never negative.
log(message, **fields)NoneLogs message at INFO with the execution, step, attempt and fields as JSON.

The context does not expose the contract version or timeout_ms.

Cancellation and timeouts

When an attempt times out, or its workflow is cancelled or fails, the runtime lists the attempt in the next heartbeat response and the SDK sets ctx.cancelled. The SDK does not interrupt your handler and does not act on deadline_ms by itself, so check the flag between units of work:

@worker.task("reports.render", version="1.0.0")
def render(input, ctx):
    pages = []
    for section in input["sections"]:
        if ctx.is_cancelled:
            raise TaskError("CANCELLED", "stopped by the runtime")
        pages.append(render_section(section, timeout=(ctx.remaining_ms() or 30000) / 1000))
    return {"pages": len(pages)}

ctx.cancelled.wait(seconds) is a cancellable sleep. Heartbeats run on a separate daemon thread at the interval the runtime requests, so a long-running handler keeps its session alive.

Concurrency

Worker(concurrency=N) starts N poll threads (default 4). Each thread runs one task at a time, so N is the maximum number of tasks this process runs at once. Synchronous handlers run on the poll thread; make them thread-safe if they share state. For CPU-bound work, run several worker processes rather than raising concurrency, because of the global interpreter lock.

Credentials and TLS

The worker reads its credentials from the environment:

VariableMeaning
ORBIT_SERVERRuntime URL. Default http://127.0.0.1:7233.
ORBIT_WORKER_TOKENBearer token. Takes precedence over ORBIT_TOKEN.
ORBIT_TOKENBearer token used when ORBIT_WORKER_TOKEN is unset.
ORBIT_CA_CERTPEM CA certificate. When set, it is the only CA trusted.
ORBIT_CLIENT_CERTPEM client certificate for mutual TLS.
ORBIT_CLIENT_KEYPEM private key. Optional when the key is in the certificate file.

The server and token arguments of Worker override the first three. The Worker constructor has no TLS arguments; it builds its Client from the environment. To configure TLS in code, replace worker.client before calling run(), and keep the timeout above the poll wait:

from orbit_sdk import Client, Worker

worker = Worker("payments-1")
worker.client = Client(
    server="https://orbit.internal:7233",
    ca_cert="ca.pem",
    client_cert="payments-1.pem",
    client_key="payments-1.key",
    timeout=worker.poll_wait_ms / 1000 + 15,
)

With a per-worker identity from a workers file, set the worker id to the identity’s id and the token to the identity’s token.

Logging

The SDK logs to the standard logging logger named orbit. Worker.run() calls logging.basicConfig with the level from ORBIT_LOG_LEVEL (default INFO); because basicConfig does nothing when logging is already configured, your own configuration takes precedence when you set it up before calling run(). At INFO the SDK logs registration, each task it executes, task failures, cancellations and ignored results. Runtime warnings, such as an unused contract version, are logged at WARNING.

Shutdown

worker.run() blocks until the process receives SIGINT or SIGTERM, or until worker.stop() is called from another thread. It then stops polling, lets running handlers finish and deliver their results, unregisters, and returns. A poll that is already waiting is not interrupted, so shutdown can take up to poll_wait_ms (20 seconds by default); lower it for faster shutdown.

run() installs signal handlers only when called from the main thread. Pass install_signal_handlers=False to keep your own. worker.start() runs the worker on a background daemon thread without signal handlers and returns the thread.

Starting workflows from Python

Client calls the runtime’s HTTP API. Starting executions requires the admin token or the token of a client identity granted start for the workflow. Pass it explicitly when the environment also holds a worker token; a client token is passed exactly like the admin token:

import os

from orbit_sdk import Client

client = Client(token=os.environ["ORBIT_ADMIN_TOKEN"])
execution_id = client.start("DocumentInsights", {"text": "Hello world"},
                            execution_id="doc-42")
result = client.wait(execution_id, timeout_ms=60000)
print(result["status"], result.get("output"))

Starting with an execution_id that already exists returns the existing execution instead of creating a duplicate.

Signals and queries

signal sends a typed signal declared with signal <name> { ... } in the workflow, and query evaluates a declared query. When authentication is enabled, both need the admin token, or a client token granted signal or query for the execution’s workflow, and wait needs read. A refused request raises ApiError with status 403 and code FORBIDDEN.

import os

from orbit_sdk import ApiError, Client

client = Client(token=os.environ["ORBIT_ADMIN_TOKEN"])

print(client.query("order-A-100", "status"))
# {'needs_approval': True, 'approved': None, 'reviewer': None, 'charged': False}

seq = client.signal("order-A-100", "review", {"approved": True, "reviewer": "dana"})

try:
    client.signal("order-A-100", "review", {"approved": "yes", "reviewer": "dana"})
except ApiError as e:
    print(e.code)  # SIGNAL_PAYLOAD_INVALID

signal returns the history sequence number of the recorded SignalReceived event. The runtime validates the payload and buffers the signal until an await step takes it. See Signals and queries.

API reference

Worker

Worker(worker_id=None, server=None, token=None, concurrency=4,
       runtime="python", poll_wait_ms=20000)
ParameterTypeDefaultMeaning
worker_idstrpython-<hostname>-<pid>Stable identity sent at registration.
serverstrORBIT_SERVER, then http://127.0.0.1:7233Runtime URL.
tokenstrORBIT_WORKER_TOKEN, then ORBIT_TOKENBearer token. Pass "" to send none.
concurrencyint4Number of poll threads; values below 1 become 1.
runtimestr"python"Announced runtime, matched against using <language>.
poll_wait_msint20000wait_ms of each long poll. The runtime caps it at 60000.
MemberMeaning
task(name, version="1.0.0")Decorator that registers fn(input, ctx) for name@version and returns fn.
register(name, fn, version="1.0.0")Registers fn for name@version. Raises ValueError for a duplicate name.
run(install_signal_handlers=True)Registers, polls and blocks until stopped. Raises RuntimeError when no task is registered, and ApiError when the runtime refuses registration (for example 401 or 403). Connection errors and 5xx responses during registration are retried.
start()Runs run(install_signal_handlers=False) on a daemon thread and returns the threading.Thread.
stop()Asks run() to finish. Returns immediately.
worker_id, runtime, concurrency, poll_wait_msThe resolved settings.
clientThe Client used for every protocol call.

TaskError

TaskError(code, message="", retryable=True, details=None)

An Exception subclass with the attributes code, message, retryable and details. See Raising task errors.

Context

See Context. The SDK creates it; you do not construct one.

Client

Client(server=None, token=None, timeout=70.0, ca_cert=None,
       client_cert=None, client_key=None)
ParameterTypeDefaultMeaning
serverstrORBIT_SERVER, then http://127.0.0.1:7233Runtime URL.
tokenstrORBIT_WORKER_TOKEN, then ORBIT_TOKENBearer token.
timeoutfloat70.0Socket timeout in seconds.
ca_certstrORBIT_CA_CERTPEM CA file to trust.
client_certstrORBIT_CLIENT_CERTPEM client certificate.
client_keystrORBIT_CLIENT_KEYPEM private key.
MethodReturnsMeaning
request(method, path, body=None)(status, body)Sends a JSON request. body is None for an empty response.
get(path)parsed bodyGET request.
post(path, body)(status, body)POST request.
start(workflow, input=None, version=None, execution_id=None)strStarts an execution and returns its id.
wait(execution_id, timeout_ms=30000)dictLong-polls until the execution finishes or the timeout elapses, and returns its state.
signal(execution_id, name, payload=None)intSends the signal name with payload (None sends {}) and returns the history sequence number of the recorded signal. Raises ApiError with UNKNOWN_SIGNAL, SIGNAL_PAYLOAD_INVALID, EXECUTION_FINISHED or NOT_FOUND.
query(execution_id, name)anyEvaluates the query name and returns its value. Raises ApiError with UNKNOWN_QUERY, QUERY_FAILED or NOT_FOUND.

ApiError

Raised by Client for an error response or a connection failure. Attributes: status (int, 0 for a connection failure), code (the runtime’s error code, CONNECTION, or HTTP_ERROR), message, and the property is_connection_error.

Testing

Handlers are plain functions, so unit-test them without a runtime. A SimpleNamespace can stand in for the context:

from types import SimpleNamespace

from text_worker import normalize

def test_normalize():
    ctx = SimpleNamespace(log=lambda *a, **k: None, is_cancelled=False)
    assert normalize({"text": "Hello,  World!"}, ctx) == {"text": "hello world"}

For an integration test, start orbit server with a temporary database, deploy your workflow, run the worker on a background thread with worker.start(), and drive executions with Client:

worker = Worker("test-worker", poll_wait_ms=1000)
...
thread = worker.start()
client = Client(token="admin-token")
execution_id = client.start("DocumentInsights", {"text": "a b a"})
assert client.wait(execution_id)["status"] == "COMPLETED"
worker.stop()
thread.join()

A short poll_wait_ms makes stop() return quickly. The SDK’s own tests in sdks/python/tests use a scripted fake runtime built on http.server, which is a useful model for testing retries and session loss.

See also

TypeScript and JavaScript

The TypeScript SDK, orbit-lang, runs ORBIT task handlers on Node.js. It has no dependencies, is built on fetch and AbortController, ships type declarations, and works from both TypeScript and JavaScript. It handles registration, long polling, heartbeats, cancellation through an AbortSignal, result delivery through runtime outages, and graceful shutdown on SIGINT and SIGTERM. It also includes a small Client for starting workflows. Read How workers work first for the concepts this page relies on.

Install

npm install orbit-lang

The package requires Node.js 18 or later and is an ES module: load it with import, not require. Node.js 22.18 and later run .ts files directly, so you can run a TypeScript worker with node worker.ts without a build step.

A minimal worker

This worker implements text.tokenize, the TypeScript step of examples/polyglot.orbit:

task "text.tokenize" { version "1.0.0" input { text: String } output { tokens: List<String> } }

step tokenize { worker "text.tokenize" using typescript with { text: normalize.text } }
import { TaskError, Worker } from "orbit-lang";

const worker = new Worker({ workerId: "text-typescript" });

worker.task<{ text: string }, { tokens: string[] }>(
  "text.tokenize",
  { version: "1.0.0" },
  async (input, ctx) => {
    if (input.text.length === 0) {
      throw new TaskError("EMPTY_TEXT", "text must not be empty", { retryable: false });
    }
    ctx.log("tokenizing", { chars: input.text.length });
    return { tokens: input.text.split(" ").filter((t) => t.length > 0) };
  },
);

await worker.run();

Run it against a local runtime. The workflow has four steps in four languages, so start the other three example workers from the repository root as well:

orbit server &
node text_worker.ts &
PYTHONPATH=sdks/python python3 examples/workers/polyglot/text_worker.py &
(cd examples/workers/polyglot/go && go run .) &
cargo run -p orbit-worker --example text_worker &

orbit run examples/polyglot.orbit \
  --input '{"text": "The quick brown fox jumps over the lazy dog. The dog sleeps."}'

Registering tasks

worker.task<Input, Output>(name, { version }, handler);

task registers handler for name@version and returns the worker, so calls can be chained. The second argument is required; when version is omitted it defaults to "1.0.0". The name and version must match the task declaration in your .orbit files exactly. Registering the same name twice throws. Register every task before calling run().

The type parameters describe the input and output for the compiler only. The SDK does not validate the input at run time; the runtime has already validated it against the contract.

Handlers and results

A handler receives the task input and a Context, and returns the output object, or a promise of it:

worker.task("inventory.reserve", { version: "1.0.0" }, async (input, ctx) => {
  return { reservation_id: `res-${input.order_id}` };
});
  • When the runtime sends no input, input is {}.
  • Returning undefined or null reports {}.
  • Returning a non-object or an array fails the attempt with INVALID_RESULT, which is not retried.
  • The runtime validates the output against the contract’s output schema; a mismatch fails the step with OUTPUT_CONTRACT_VIOLATION.

Raising task errors

Throw TaskError to fail an attempt with a structured error:

import { TaskError } from "orbit-lang";

throw new TaskError("CARD_DECLINED", "limit exceeded", {
  retryable: false,
  details: { limit: 5000 },
});
ArgumentTypeDefaultMeaning
codestringrequiredError code, matched exactly by the step’s non_retryable list.
messagestring""Human-readable message.
options.retryablebooleantruefalse fails the step immediately, whatever its retry policy.
options.detailsJsonundefinedStored with the error in the execution history.

Any other thrown value is reported as a retryable UNHANDLED_EXCEPTION. The message is <name>: <message> for an Error, and details.stack holds the first lines of the stack trace. The SDK also logs the error through logger.error.

Context

MemberTypeMeaning
tokenstringIdentifies this delivery. The SDK echoes it in the result.
executionIdstringThe workflow execution.
workflowstringWorkflow name.
workflowVersionstringWorkflow version.
stepIdstringThe step, or compensate:<step> for a compensation; step[i] for a fan-out element.
taskNamestringThe task name.
attemptnumber1-based attempt number. Redeliveries keep the number.
idempotencyKeystringStable across retries and redeliveries. Use it for side effects.
redeliverybooleantrue when the outcome of an earlier delivery of this attempt is unknown.
deadlineMsnumber | undefinedAbsolute deadline in Unix epoch milliseconds, when the step has a timeout.
signalAbortSignalAborted when the runtime asks the attempt to stop.
cancelledboolean (getter)true once signal is aborted.
remainingMs()number | undefinedMilliseconds left before deadlineMs, never negative.
log(message, fields?)voidWrites one JSON line to standard output with the execution, step, attempt and fields.

The context does not expose the contract version or timeout_ms.

Cancellation and timeouts

When an attempt times out, or its workflow is cancelled or fails, the runtime lists the attempt in the next heartbeat response and the SDK aborts ctx.signal. Its reason is a non-retryable TaskError with code CANCELLED. Pass the signal to APIs that accept one, or check it between units of work:

worker.task("reports.fetch", { version: "1.0.0" }, async (input, ctx) => {
  const res = await fetch(input.url, { signal: ctx.signal });
  return { status: res.status };
});

worker.task("reports.render", { version: "1.0.0" }, async (input, ctx) => {
  for (const section of input.sections) {
    ctx.signal.throwIfAborted();
    await renderSection(section);
  }
  return {};
});

When an aborted fetch rejects, it rejects with the signal’s reason, so the attempt is reported as CANCELLED. The SDK does not abort the signal when deadlineMs passes; only the runtime’s cancellation does. Use AbortSignal.any([ctx.signal, AbortSignal.timeout(ctx.remainingMs() ?? 30_000)]) to bound a call by the deadline as well.

Concurrency

concurrency (default 4) is the number of poll loops, and therefore the maximum number of tasks this worker runs at once. All loops share the Node.js event loop, so handlers should be asynchronous. A CPU-bound handler blocks every loop and the heartbeat; move that work to a worker_threads worker or run more processes.

Credentials and TLS

OptionEnvironmentDefaultMeaning
serverORBIT_SERVERhttp://127.0.0.1:7233Runtime URL.
tokenORBIT_WORKER_TOKEN, then ORBIT_TOKENnoneBearer token.
clientCertORBIT_CLIENT_CERTnonePEM client certificate for mutual TLS.
clientKeyORBIT_CLIENT_KEYthe clientCert filePEM private key.

To trust a runtime whose certificate comes from a private CA, start Node.js with NODE_EXTRA_CA_CERTS=/path/to/ca.pem. The global fetch cannot present client certificates, so when clientCert is set and the server URL is https:, the SDK sends requests through node:https instead. On that path only, ORBIT_CA_CERT is read and replaces Node.js’s default CA list.

const worker = new Worker({
  workerId: "payments-1",
  server: "https://orbit.internal:7233",
  clientCert: "/etc/orbit/payments-1.pem",
  clientKey: "/etc/orbit/payments-1.key",
});
ORBIT_CA_CERT=/etc/orbit/ca.pem node worker.ts

With a per-worker identity from a workers file, set workerId to the identity’s id and the token to the identity’s token.

Logging

SDK messages go to the logger option (default console), which needs info, warn and error methods; messages are prefixed with [orbit]. Each task execution is logged as a JSON line through ctx.log, which always writes to console.log. To silence the SDK in tests, pass a logger with no-op methods.

Shutdown

await worker.run() resolves after worker.stop() is called or the process receives SIGINT or SIGTERM. Shutdown aborts the open long polls, waits for running handlers to finish and deliver their results, unregisters, removes the signal listeners, and resolves. Pass { handleSignals: false } to install your own signal handling and call stop() yourself.

Starting workflows

Client calls the runtime’s HTTP API. Starting executions requires the admin token or the token of a client identity granted start for the workflow. Pass it explicitly when the environment also holds a worker token; a client token is passed exactly like the admin token:

import { Client } from "orbit-lang";

const client = new Client({ token: process.env.ORBIT_ADMIN_TOKEN });
const id = await client.start("DocumentInsights", { text: "Hello world" }, { executionId: "doc-42" });
const result = await client.wait(id, 60_000);
console.log(result.status, result.output);

Signals and queries

signal sends a typed signal declared with signal <name> { ... } in the workflow, and query evaluates a declared query. When authentication is enabled, both need the admin token, or a client token granted signal or query for the execution’s workflow, and wait needs read. A refused request throws ApiError with status 403 and code FORBIDDEN.

import { ApiError, Client } from "orbit-lang";

const client = new Client({ token: process.env.ORBIT_ADMIN_TOKEN });

type Status = { needs_approval: boolean; approved: boolean | null; reviewer: string | null; charged: boolean };
const status = await client.query<Status>("order-A-100", "status");
console.log(status.approved); // null while the review is pending

const seq = await client.signal("order-A-100", "review", { approved: true, reviewer: "dana" });

try {
  await client.signal("order-A-100", "review", { approved: "yes", reviewer: "dana" });
} catch (e) {
  if (e instanceof ApiError) console.log(e.code); // SIGNAL_PAYLOAD_INVALID
}

signal resolves to the history sequence number of the recorded SignalReceived event. The runtime validates the payload and buffers the signal until an await step takes it. See Signals and queries.

API reference

Worker

new Worker(options?: WorkerOptions)

WorkerOptions extends ClientOptions:

OptionTypeDefaultMeaning
workerIdstringtypescript-<pid>Stable identity sent at registration.
concurrencynumber4Number of poll loops; values below 1 become 1.
runtimestring"typescript"Announced runtime. Set "javascript" for steps declared using javascript.
pollWaitMsnumber20000wait_ms of each long poll. The runtime caps it at 60000.
loggerPick<Console, "info" | "warn" | "error">consoleDestination of SDK messages.
MemberMeaning
task<I, O>(name, { version? }, handler): thisRegisters handler for name@version.
run({ handleSignals? }): Promise<void>Registers, polls and resolves after shutdown. handleSignals defaults to true. Rejects when no task is registered, or with ApiError when the runtime refuses registration; connection errors and 5xx responses are retried.
stop(): voidStarts a graceful shutdown.
workerId: stringThe resolved worker id.
client: ClientThe client used for every protocol call.

TaskError

new TaskError(code: string, message?: string, options?: { retryable?: boolean; details?: Json })

Read-only properties: code, message, retryable, details. name is "TaskError".

Context

See Context. The SDK creates it; you do not construct one.

Client

new Client(options?: ClientOptions)
OptionTypeDefaultMeaning
serverstringORBIT_SERVER, then http://127.0.0.1:7233Runtime URL.
tokenstringORBIT_WORKER_TOKEN, then ORBIT_TOKENBearer token.
clientCertstringORBIT_CLIENT_CERTPath of a PEM client certificate.
clientKeystringORBIT_CLIENT_KEY, then clientCertPath of the PEM private key.
MethodReturnsMeaning
request<T>(method, path, body?, signal?)Promise<{ status: number; body: T }>Sends a JSON request. body is null for an empty response. Throws ApiError for status 400 and above.
start(workflow, input = {}, { version?, executionId? })Promise<string>Starts an execution and returns its id.
wait(executionId, timeoutMs = 30000)Promise<any>Long-polls until the execution finishes or the timeout elapses, and returns its state.
signal(executionId, name, payload = {})Promise<number>Sends the signal name and resolves to the history sequence number of the recorded signal. Rejects with ApiError (UNKNOWN_SIGNAL, SIGNAL_PAYLOAD_INVALID, EXECUTION_FINISHED, NOT_FOUND).
query<T = any>(executionId, name)Promise<T>Evaluates the query name and resolves to its value. T is not checked at runtime. Rejects with ApiError (UNKNOWN_QUERY, QUERY_FAILED, NOT_FOUND).
serverstringThe resolved runtime URL.

ApiError

Thrown by Client for an error response or a connection failure. Properties: status (0 for a connection failure), code (the runtime’s error code, CONNECTION, or HTTP_ERROR), message, and the getter isConnectionError.

Other exports

ExportMeaning
SDK_VERSION"0.1.0".
JsonType of any JSON value.
TaskAssignmentType of the task message the runtime sends (protocol ExecuteTaskRequest).
Handler<I, O>(input: I, ctx: Context) => O | Promise<O>.
ClientOptions, WorkerOptionsOption types described above.

Testing

Keep handlers as named functions so that you can call them directly. A plain object can stand in for the context:

import { test } from "node:test";
import assert from "node:assert/strict";
import { tokenize } from "./handlers.ts";

test("tokenize", async () => {
  const ctx = { signal: new AbortController().signal, log() {} } as any;
  assert.deepEqual(await tokenize({ text: "a b" }, ctx), { tokens: ["a", "b"] });
});

For an integration test, start orbit server with a temporary database, deploy your workflow, call worker.run({ handleSignals: false }) without awaiting it, drive executions with Client, then call worker.stop() and await the promise from run(). A short pollWaitMs keeps polls responsive. The SDK’s own test, sdks/typescript/test/worker.test.ts, runs the worker against a scripted fake runtime built on node:http.

See also

Go

The Go SDK, package orbit in the module github.com/AjmalX0/Orbit/sdks/go, runs ORBIT task handlers written in Go. It uses only the standard library. Handlers receive a context.Context that is cancelled when the runtime cancels the attempt, when the attempt’s deadline passes, or when the worker shuts down. The SDK handles registration, long polling, heartbeats, result delivery through runtime outages, and graceful shutdown when the context passed to Run is cancelled. Read How workers work first for the concepts this page relies on.

Install

go get github.com/AjmalX0/Orbit/sdks/go@latest

The module requires Go 1.21 or later. Import it under the name orbit:

import orbit "github.com/AjmalX0/Orbit/sdks/go"

A minimal worker

This worker implements text.frequencies, the Go step of examples/polyglot.orbit:

schema WordCount { word: String, count: Int }
task "text.frequencies" { version "1.0.0" input { tokens: List<String>, top: Int } output { top: List<WordCount>, unique: Int } }

step frequencies { worker "text.frequencies" using go with { tokens: tokenize.tokens, top: input.top } }
package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"sort"
	"syscall"

	orbit "github.com/AjmalX0/Orbit/sdks/go"
)

type input struct {
	Tokens []string `json:"tokens"`
	Top    int      `json:"top"`
}

type wordCount struct {
	Word  string `json:"word"`
	Count int    `json:"count"`
}

func frequencies(ctx context.Context, t *orbit.Task) (any, error) {
	var in input
	if err := t.Decode(&in); err != nil {
		return nil, orbit.Permanent("INVALID_INPUT", err.Error())
	}
	counts := map[string]int{}
	for _, tok := range in.Tokens {
		counts[tok]++
	}
	top := make([]wordCount, 0, len(counts))
	for word, n := range counts {
		top = append(top, wordCount{word, n})
	}
	sort.Slice(top, func(i, j int) bool {
		if top[i].Count != top[j].Count {
			return top[i].Count > top[j].Count
		}
		return top[i].Word < top[j].Word
	})
	if in.Top >= 0 && len(top) > in.Top {
		top = top[:in.Top]
	}
	return map[string]any{"top": top, "unique": len(counts)}, nil
}

func main() {
	w := orbit.NewWorker(orbit.Options{WorkerID: "text-go"})
	w.Task("text.frequencies", "1.0.0", frequencies)

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	if err := w.Run(ctx); err != nil {
		log.Fatal(err)
	}
}

Run it against a local runtime. The workflow has four steps in four languages, so start the other three example workers from the repository root as well:

orbit server &
go run . &
PYTHONPATH=sdks/python python3 examples/workers/polyglot/text_worker.py &
node examples/workers/polyglot/text_worker.ts &
cargo run -p orbit-worker --example text_worker &

orbit run examples/polyglot.orbit \
  --input '{"text": "The quick brown fox jumps over the lazy dog. The dog sleeps."}'

Registering tasks

func (w *Worker) Task(name, version string, handler Handler) *Worker

Task registers handler for name@version and returns the worker, so calls can be chained. The name and version must match the task declaration in your .orbit files exactly. Registering the same name twice panics. Register every task before calling Run; Task is not safe to call concurrently with Run.

Handlers and results

type Handler func(ctx context.Context, t *orbit.Task) (any, error)

The handler receives the attempt’s context and the task. Decode the input with t.Decode(&v), which calls json.Unmarshal on the raw input. Return the output and a nil error to complete the attempt:

  • The output is marshaled with encoding/json and must be a JSON object, so return a struct, a map[string]any, or a json.RawMessage holding an object.
  • Returning a nil output reports {}. A typed nil map marshals to null and fails.
  • Output that does not marshal to an object fails the attempt with INVALID_RESULT, which is not retried.
  • The runtime validates the output against the contract’s output schema; a mismatch fails the step with OUTPUT_CONTRACT_VIOLATION.

Returning task errors

Return a *orbit.TaskError to fail an attempt with a structured error:

if amount > 5000 {
	return nil, orbit.Permanent("CARD_DECLINED", "limit exceeded")
}
if err := callBank(ctx); err != nil {
	return nil, orbit.Retryable("BANK_UNAVAILABLE", err.Error())
}
return nil, &orbit.TaskError{
	Code:      "CARD_DECLINED",
	Message:   "limit exceeded",
	Retryable: false,
	Details:   map[string]any{"limit": 5000},
}
ConstructorRetryable
orbit.Retryable(code, message string) *TaskErroryes
orbit.Permanent(code, message string) *TaskErrorno, fails the step whatever its retry policy

The SDK finds a task error with errors.As, so a wrapped task error (fmt.Errorf("charge: %w", te)) keeps its code. Any other error is reported as a retryable UNHANDLED_EXCEPTION with err.Error() as the message. A panic in the handler is recovered and reported as a retryable PANIC.

The task

*orbit.Task carries everything the runtime sent:

FieldTypeMeaning
TokenstringIdentifies this delivery. The SDK echoes it in the result.
ExecutionIDstringThe workflow execution.
WorkflowstringWorkflow name.
WorkflowVersionstringWorkflow version.
StepIDstringThe step, or compensate:<step> for a compensation; step[i] for a fan-out element.
TaskNamestringThe task name.
ContractVersionstringThe task’s contract version.
Inputjson.RawMessageThe raw task input.
IdempotencyKeystringStable across retries and redeliveries. Use it for side effects.
Attemptint1-based attempt number. Redeliveries keep the number.
Redeliverybooltrue when the outcome of an earlier delivery of this attempt is unknown.
DeadlineMs*int64Absolute deadline in Unix epoch milliseconds, when the step has a timeout.
TimeoutMs*int64The step’s timeout in milliseconds.
MethodMeaning
Decode(v any) errorUnmarshals Input into v.

Cancellation and timeouts

The handler’s context is cancelled when any of these happens:

  • the runtime lists the attempt in a heartbeat’s cancel list, because the attempt timed out or its workflow was cancelled or failed;
  • the attempt’s DeadlineMs passes (the context carries it as its deadline);
  • the context passed to Run is cancelled.

Pass ctx to every call that accepts one:

req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
resp, err := client.Do(req)
if err != nil {
	return nil, orbit.Retryable("UPSTREAM", err.Error())
}

Returning ctx.Err() reports a retryable UNHANDLED_EXCEPTION with the message context canceled or context deadline exceeded. Heartbeats run on their own goroutine, so a long handler keeps its session alive.

Concurrency

Options.Concurrency (default 4) is the number of poll goroutines, and therefore the maximum number of tasks this worker runs at once. Handlers run on those goroutines, so make them safe for concurrent use.

Credentials and TLS

Every connection setting has an Options field and an environment variable. A non-empty field wins.

FieldEnvironmentDefaultMeaning
ServerORBIT_SERVERhttp://127.0.0.1:7233Runtime URL.
TokenORBIT_WORKER_TOKEN, then ORBIT_TOKENnoneBearer token.
CACertFileORBIT_CA_CERTnonePEM CA certificate, added to the system pool.
ClientCertFileORBIT_CLIENT_CERTnonePEM client certificate for mutual TLS.
ClientKeyFileORBIT_CLIENT_KEYnonePEM private key for the client certificate. Required with it.
w := orbit.NewWorker(orbit.Options{
	WorkerID:       "payments-1",
	Server:         "https://orbit.internal:7233",
	CACertFile:     "/etc/orbit/ca.pem",
	ClientCertFile: "/etc/orbit/payments-1.pem",
	ClientKeyFile:  "/etc/orbit/payments-1.key",
})

NewWorker does not return an error. When a CA or client certificate file cannot be read, it logs the problem and continues without it; the TLS handshake then fails and the worker keeps retrying registration. Check the log when a worker never registers. With a per-worker identity from a workers file, set WorkerID to the identity’s id and the token to the identity’s token.

Logging

The SDK writes registration, warnings from the runtime, poll and delivery failures, cancellations and ignored results to Options.Logger, a *log.Logger. The default writes to standard error with the prefix [orbit] . The SDK does not log task failures; log them in your handler if you need to.

Shutdown

Run blocks until its context is cancelled. It then aborts the open long polls and cancels the context of each running handler. It waits for the handlers to return, delivers their results with a context that outlives the cancelled one (retrying for up to 10 minutes), unregisters, and returns nil. Use signal.NotifyContext to tie shutdown to SIGINT and SIGTERM, as in the example above.

A handler that ignores its context finishes its work, and its result is delivered. A handler that returns early because its context was cancelled reports a retryable UNHANDLED_EXCEPTION, and that failure counts as an attempt. With the default retry policy of one attempt, the step then fails. Give steps served by Go workers a retry policy with more than one attempt if you restart workers while they run tasks.

API reference

NewWorker

func NewWorker(opts Options) *Worker

Options

FieldTypeDefaultMeaning
WorkerIDstringgo-<hostname>-<pid>Stable identity sent at registration.
ServerstringORBIT_SERVER, then http://127.0.0.1:7233Runtime URL.
TokenstringORBIT_WORKER_TOKEN, then ORBIT_TOKENBearer token.
CACertFilestringORBIT_CA_CERTPEM CA certificate path.
ClientCertFilestringORBIT_CLIENT_CERTPEM client certificate path.
ClientKeyFilestringORBIT_CLIENT_KEYPEM private key path.
Concurrencyint4Number of poll goroutines.
Runtimestring"go"Announced runtime, matched against using <language>.
PollWaittime.Duration20 * time.Secondwait_ms of each long poll. The runtime caps it at 60 seconds.
Logger*log.Loggerstandard error, prefix [orbit] Destination of SDK messages.

Zero values select the default.

Worker

MethodMeaning
Task(name, version string, handler Handler) *WorkerRegisters a handler. Panics on a duplicate name.
Run(ctx context.Context) errorRegisters, polls and blocks until ctx is cancelled. Returns an error when no task is registered, or an *APIError when the runtime refuses registration or ctx is cancelled before registration succeeds. Connection errors and 5xx responses during registration are retried.

TaskError

type TaskError struct {
	Code      string
	Message   string
	Retryable bool
	Details   any
}

Error() returns "<Code>: <Message>". See Returning task errors.

APIError

type APIError struct {
	Status  int    // HTTP status; 0 when the runtime could not be reached
	Code    string // the runtime's error code, CONNECTION, or HTTP_ERROR
	Message string
}

Other declarations

NameMeaning
TaskOne delivery of a task. See The task.
Handlerfunc(ctx context.Context, t *Task) (any, error).
VersionThe SDK version, "0.1.0".

Testing

Handlers are plain functions. Call them with a *orbit.Task whose Input holds the JSON you want to test:

func TestFrequencies(t *testing.T) {
	task := &orbit.Task{Input: json.RawMessage(`{"tokens":["a","b","a"],"top":1}`)}
	out, err := frequencies(context.Background(), task)
	if err != nil {
		t.Fatal(err)
	}
	got := out.(map[string]any)
	if got["unique"] != 2 {
		t.Fatalf("unique = %v", got["unique"])
	}
}

For an integration test, start orbit server with a temporary database, deploy your workflow, run w.Run(ctx) on a goroutine, start executions through the HTTP API, and cancel ctx when done. A short PollWait keeps polls responsive. The SDK’s own tests in sdks/go/orbit_test.go run the worker against a scripted fake runtime built on net/http/httptest; run them with go test -race ./....

See also

Rust

The Rust SDK, the orbit-worker crate, runs ORBIT task handlers written in Rust. Handlers are typed: the SDK deserializes each task’s input into your serde type and serializes your output. Each poll loop runs on its own OS thread, and the SDK handles registration, long polling, heartbeats, cooperative cancellation, result delivery through runtime outages, and graceful shutdown. Read How workers work first for the concepts this page relies on.

Install

cargo add orbit-worker
cargo add serde --features derive

The crate requires Rust 1.89 or later. It depends on serde, serde_json and ureq, and is blocking: it needs no async runtime.

A minimal worker

This worker implements text.fingerprint, the Rust step of examples/polyglot.orbit:

task "text.fingerprint" { version "1.0.0" input { text: String } output { hash: String, bytes: Int } }

step fingerprint { worker "text.fingerprint" using rust with { text: normalize.text } timeout 5s }
use orbit_worker::{TaskError, Worker};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct Input {
    text: String,
}

#[derive(Serialize)]
struct Output {
    hash: String,
    bytes: usize,
}

/// 64-bit FNV-1a, a stable non-cryptographic fingerprint.
fn fnv1a(bytes: &[u8]) -> u64 {
    bytes.iter().fold(0xcbf29ce484222325, |h, b| {
        (h ^ *b as u64).wrapping_mul(0x100000001b3)
    })
}

fn main() {
    let mut worker = Worker::builder("text-rust").build();
    worker.task("text.fingerprint", "1.0.0", |input: Input, ctx| {
        if input.text.is_empty() {
            return Err(TaskError::permanent("EMPTY_TEXT", "text must not be empty"));
        }
        ctx.log("fingerprinting");
        Ok(Output {
            hash: format!("{:016x}", fnv1a(input.text.as_bytes())),
            bytes: input.text.len(),
        })
    });
    worker.run().expect("worker failed");
}

Run it against a local runtime. The workflow has four steps in four languages, so start the other three example workers from the repository root as well:

orbit server &
cargo run &
PYTHONPATH=sdks/python python3 examples/workers/polyglot/text_worker.py &
node examples/workers/polyglot/text_worker.ts &
(cd examples/workers/polyglot/go && go run .) &

orbit run examples/polyglot.orbit \
  --input '{"text": "The quick brown fox jumps over the lazy dog. The dog sleeps."}'

The repository ships the same worker as an example: cargo run -p orbit-worker --example text_worker.

Registering tasks

#![allow(unused)]
fn main() {
pub fn task<I, O, F>(&mut self, name: &str, version: &str, handler: F) -> &mut Self
where
    I: DeserializeOwned,
    O: Serialize,
    F: Fn(I, &Context) -> Result<O, TaskError> + Send + Sync + 'static;
}

task registers handler for name@version and returns the worker, so calls can be chained. The name and version must match the task declaration in your .orbit files exactly. Registering the same name twice panics.

Handlers are Fn, and several poll threads can call one handler at the same time, so shared mutable state needs a Mutex or an atomic.

Handlers and results

The SDK deserializes the task input into I with serde_json. Use serde_json::Value as I to receive the raw input. Input that does not deserialize fails the attempt with INVALID_INPUT, which is not retried.

Return Ok(output) to complete the attempt:

  • O is serialized with serde_json and must be a JSON object, so return a struct, a map, or a serde_json::Value object such as json!({ ... }).
  • Ok(()) and other values that serialize to null report {}.
  • Output that is not an object, or fails to serialize, fails the attempt with INVALID_RESULT, which is not retried.
  • The runtime validates the output against the contract’s output schema; a mismatch fails the step with OUTPUT_CONTRACT_VIOLATION.

Returning task errors

Return Err(TaskError) to fail an attempt with a structured error:

#![allow(unused)]
fn main() {
use serde_json::json;

if amount > 5000 {
    return Err(TaskError::permanent("CARD_DECLINED", "limit exceeded")
        .with_details(json!({ "limit": 5000 })));
}
}
ConstructorRetryable
TaskError::retryable(code: &str, message: impl Into<String>)yes
TaskError::permanent(code: &str, message: impl Into<String>)no, fails the step whatever its retry policy

with_details(self, details: Value) -> Self attaches a JSON value that is stored with the error in the execution history. A panic in a handler is caught and reported as a retryable PANIC whose message is the panic payload; the default panic hook still prints it to standard error.

Context

The second handler argument, &Context, describes the attempt.

MethodReturnsMeaning
execution_id()&strThe workflow execution.
step_id()&strThe step, or compensate:<step> for a compensation; step[i] for a fan-out element.
attempt()u321-based attempt number. Redeliveries keep the number.
idempotency_key()&strStable across retries and redeliveries. Use it for side effects.
redelivery()booltrue when the outcome of an earlier delivery of this attempt is unknown.
is_cancelled()booltrue once the runtime asked the attempt to stop.
remaining_ms()Option<u64>Milliseconds left before the attempt’s deadline.
log(message: &str)()Writes one JSON line to standard error with the execution, step and attempt.

The public field assignment: Assignment holds the full task message:

FieldTypeMeaning
tokenStringIdentifies this delivery.
execution_idStringThe workflow execution.
workflowStringWorkflow name.
workflow_versionStringWorkflow version.
step_idStringThe step.
task_nameStringThe task name.
contract_versionStringThe task’s contract version.
inputserde_json::ValueThe raw input.
idempotency_keyStringThe idempotency key.
attemptu32The attempt number.
redeliveryboolThe redelivery flag.
deadline_msOption<u64>Absolute deadline in Unix epoch milliseconds.

Cancellation and timeouts

When an attempt times out, or its workflow is cancelled or fails, the runtime lists the attempt in the next heartbeat response and ctx.is_cancelled() starts returning true. The SDK does not interrupt the handler and does not act on the deadline by itself, so check the flag between units of work and bound blocking calls with ctx.remaining_ms():

#![allow(unused)]
fn main() {
worker.task("reports.render", "1.0.0", |input: Report, ctx| {
    let mut pages = 0;
    for section in &input.sections {
        if ctx.is_cancelled() {
            return Err(TaskError::retryable("CANCELLED", "stopped by the runtime"));
        }
        render(section, ctx.remaining_ms())?;
        pages += 1;
    }
    Ok(serde_json::json!({ "pages": pages }))
});
}

Heartbeats run on their own thread, so a long handler keeps its session alive.

Concurrency

concurrency(n) (default 4) sets the number of poll threads, and therefore the maximum number of tasks this worker runs at once. Values below 1 become 1. Handlers run on those threads.

Credentials and TLS

Worker::builder reads the environment; builder methods override it.

Builder methodEnvironmentDefaultMeaning
server(url: &str)ORBIT_SERVERhttp://127.0.0.1:7233Runtime URL.
token(token: &str)ORBIT_WORKER_TOKEN, then ORBIT_TOKENnoneBearer token.
ca_pem(pem: Vec<u8>)ORBIT_CA_CERT (a path)system rootsPEM CA certificates. When set, they are the only roots trusted.
client_identity(cert_pem: Vec<u8>, key_pem: Vec<u8>)ORBIT_CLIENT_CERT and ORBIT_CLIENT_KEY (paths)noneClient certificate chain and private key for mutual TLS.
#![allow(unused)]
fn main() {
let worker = Worker::builder("payments-1")
    .server("https://orbit.internal:7233")
    .ca_pem(std::fs::read("/etc/orbit/ca.pem")?)
    .client_identity(
        std::fs::read("/etc/orbit/payments-1.pem")?,
        std::fs::read("/etc/orbit/payments-1.key")?,
    )
    .build();
}

The environment variables for TLS are read as file paths. A file that cannot be read is ignored without an error, and the client certificate is used only when both ORBIT_CLIENT_CERT and ORBIT_CLIENT_KEY are set. Read the files yourself, as above, to surface errors. With a per-worker identity from a workers file, pass the identity’s id to Worker::builder.

Logging

The SDK writes registration, runtime warnings, poll and delivery failures, and ignored results to standard error with the prefix [orbit]. It has no logging configuration and does not use the log or tracing crates.

Shutdown

run() blocks the calling thread. stop() asks it to return: polling stops, running handlers finish and deliver their results, the worker unregisters, and run() returns Ok(()). A poll that is already waiting is not interrupted, so run() can take up to the poll wait (20 seconds by default) to return.

Worker is cheap to clone and clones share state, so stop it from another thread or a signal handler. The SDK installs no signal handlers itself. With the ctrlc crate:

#![allow(unused)]
fn main() {
let stopper = worker.clone();
ctrlc::set_handler(move || stopper.stop()).expect("cannot install signal handler");
worker.run()?;
}

The ctrlc crate’s termination feature also handles SIGTERM. A hard kill is safe as well: delivery is at least once, so unfinished tasks are redelivered.

API reference

Worker

ItemMeaning
Worker::builder(worker_id: &str) -> WorkerBuilderStarts a builder with defaults from the environment.
task(&mut self, name, version, handler) -> &mut SelfRegisters a handler. Panics on a duplicate name.
run(&self) -> Result<(), ApiError>Registers, polls and blocks until stop(). Returns Err with code NO_TASKS when no task is registered, and Err when the runtime refuses registration. Connection errors and 5xx responses are retried.
stop(&self)Asks run() to return after in-flight tasks finish.

Worker implements Clone.

WorkerBuilder

MethodDefaultMeaning
server(url: &str)ORBIT_SERVER, then http://127.0.0.1:7233Runtime URL.
token(token: &str)ORBIT_WORKER_TOKEN, then ORBIT_TOKENBearer token.
ca_pem(pem: Vec<u8>)ORBIT_CA_CERTTrusted CA certificates.
client_identity(cert_pem: Vec<u8>, key_pem: Vec<u8>)ORBIT_CLIENT_CERT, ORBIT_CLIENT_KEYMutual TLS identity.
concurrency(n: usize)4Number of poll threads.
runtime(runtime: &str)"rust"Announced runtime, matched against using <language>.
poll_wait(d: Duration)20 secondswait_ms of each long poll. The runtime caps it at 60 seconds.
build(self) -> WorkerBuilds the worker.

TaskError

#![allow(unused)]
fn main() {
pub struct TaskError {
    pub code: String,
    pub message: String,
    pub retryable: bool,
    pub details: Option<Box<serde_json::Value>>,
}
}

Implements Display ("<code>: <message>"), std::error::Error, Clone and PartialEq. See Returning task errors.

ApiError

#![allow(unused)]
fn main() {
pub struct ApiError {
    pub status: u16,     // HTTP status; 0 for a connection failure
    pub code: String,    // the runtime's error code, CONNECTION, or HTTP_ERROR
    pub message: String,
}
}

Other items

ItemMeaning
ContextSee Context.
AssignmentThe task message, Context::assignment.
VERSIONThe crate version.

Testing

Write handlers as functions so that you can call the business logic without a Context, which the SDK alone constructs:

#![allow(unused)]
fn main() {
fn fingerprint(text: &str) -> Output {
    Output { hash: format!("{:016x}", fnv1a(text.as_bytes())), bytes: text.len() }
}

#[test]
fn fingerprints_are_stable() {
    assert_eq!(fingerprint("abc").hash, fingerprint("abc").hash);
}
}

For an integration test, start a runtime, register your workflow, run the worker on a thread (let runner = worker.clone(); std::thread::spawn(move || runner.run())), start executions through the HTTP API, and call worker.stop() when done. A short poll_wait makes stop() take effect quickly. The crate’s own test, crates/orbit-worker/tests/runtime.rs, starts an in-process runtime with an in-memory store and follows this pattern.

See also

Java

The Java SDK, io.github.ajmalx0:orbit-sdk, runs ORBIT task handlers on the JVM. It is built on java.net.http and Jackson. Handlers are typed with Jackson, or work on raw JsonNode values. Each poll loop runs on its own thread, cancellation interrupts the handler’s thread, and the SDK handles registration, long polling, heartbeats, local deadlines, result delivery through runtime outages, and graceful shutdown. Read How workers work first for the concepts this page relies on.

Install

The SDK requires Java 17 or later and depends on jackson-databind.

Maven:

<dependency>
  <groupId>io.github.ajmalx0</groupId>
  <artifactId>orbit-sdk</artifactId>
  <version>0.1.0</version>
</dependency>

Gradle:

implementation("io.github.ajmalx0:orbit-sdk:0.1.0")

A minimal worker

This worker implements greeting.java from examples/java_hello.orbit:

task "greeting.java" {
    version "1.0.0"
    input { name: String, excited: Bool }
    output { message: String, letters: Int }
}
import io.github.ajmalx0.orbit.TaskException;
import io.github.ajmalx0.orbit.Worker;

public final class HelloWorker {
    record Input(String name, boolean excited) {}

    record Output(String message, int letters) {}

    public static void main(String[] args) {
        Worker worker = Worker.builder().workerId("hello-java").build();

        worker.task("greeting.java", "1.0.0", Input.class, (in, ctx) -> {
            String name = in.name() == null ? "" : in.name().strip();
            if (name.isEmpty()) {
                throw TaskException.permanent("INVALID_NAME", "name must not be blank");
            }
            int letters = (int) name.codePoints().filter(Character::isLetter).count();
            String message = "Hello, " + name + ", from Java" + (in.excited() ? "!" : ".");
            return new Output(message, letters);
        });

        Runtime.getRuntime().addShutdownHook(new Thread(worker::stop));
        worker.run();
    }
}

Run it against a local runtime. The repository’s examples/workers/java project contains this worker with a Maven build:

orbit server &
(cd examples/workers/java && mvn -q compile exec:java) &
orbit run examples/java_hello.orbit --input '{"name": "Ada", "excited": true}'
{
  "message": "Hello, Ada, from Java!",
  "letters": 3
}

Registering tasks

<I, O> Worker task(String name, String version, Class<I> inputType, TaskHandler<I, O> handler)
<I, O> Worker task(String name, String version, TypeReference<I> inputType, TaskHandler<I, O> handler)
Worker rawTask(String name, String version, RawTaskHandler handler)

Each method registers a handler for name@version and returns the worker, so calls can be chained. The name and version must match the task declaration in your .orbit files exactly. Registering the same name twice throws IllegalArgumentException, and registering after the worker started throws IllegalStateException.

Use the TypeReference overload for generic input types:

worker.task("orders.tally", "1.0.0", new TypeReference<Map<String, List<String>>>() {},
        (input, ctx) -> Map.of("count", input.size()));

Handlers and results

TaskHandler<I, O> receives the decoded input and a TaskContext:

@FunctionalInterface
public interface TaskHandler<I, O> {
    O handle(I input, TaskContext ctx) throws Exception;
}

RawTaskHandler receives the input as a Jackson JsonNode ({} when the runtime sent none) and returns any value that serializes to a JSON object, such as a Map, a record, or an ObjectNode:

worker.rawTask("inventory.release", "1.0.0", (input, ctx) ->
        Map.of("released", input.path("reservation_id").asText()));
  • The SDK decodes input with the worker’s ObjectMapper. The default mapper ignores unknown properties. Input that cannot be decoded fails the attempt with INVALID_INPUT, which is not retried.
  • Returning null reports {}.
  • A result that does not serialize to a JSON object fails the attempt with INVALID_RESULT, which is not retried.
  • The runtime validates the output against the contract’s output schema; a mismatch fails the step with OUTPUT_CONTRACT_VIOLATION.

Jackson maps record components and fields by name, so name them as in the contract (order_id), or configure the mapper with PropertyNamingStrategies.SNAKE_CASE and pass it to objectMapper(...).

Throwing task errors

Throw TaskException to fail an attempt with a structured error:

throw TaskException.permanent("CARD_DECLINED", "limit exceeded");
throw TaskException.retryable("BANK_UNAVAILABLE", "upstream timed out");
throw new TaskException("CARD_DECLINED", "limit exceeded", false, Map.of("limit", 5000));
Constructor or factoryRetryable
TaskException.retryable(String code, String message)yes
TaskException.permanent(String code, String message)no, fails the step whatever its retry policy
new TaskException(String code, String message, boolean retryable)as given
new TaskException(String code, String message, boolean retryable, Object details)as given; details is serialized with the mapper

TaskException is unchecked. Any other exception is reported as a retryable UNHANDLED_EXCEPTION whose message is the exception’s toString(), with details.exception (the class name) and details.stack_trace. The SDK also logs it at ERROR.

TaskContext

MethodReturnsMeaning
token()StringIdentifies this delivery. The SDK echoes it in the result.
executionId()StringThe workflow execution.
workflow()StringWorkflow name.
workflowVersion()StringWorkflow version.
stepId()StringThe step, or compensate:<step> for a compensation; step[i] for a fan-out element.
taskName()StringThe task name.
contractVersion()StringThe task’s contract version.
input()JsonNodeThe raw input, {} when the runtime sent none.
idempotencyKey()StringStable across retries and redeliveries. Use it for side effects.
attempt()int1-based attempt number. Redeliveries keep the number.
redelivery()booleantrue when the outcome of an earlier delivery of this attempt is unknown.
deadlineMs()OptionalLongAbsolute deadline in Unix epoch milliseconds, when the step has a timeout.
timeoutMs()OptionalLongThe step’s timeout in milliseconds.
remainingMs()OptionalLongMilliseconds left before the deadline, never negative.
isCancelled()booleantrue once the attempt was cancelled.
cancellation()CompletableFuture<Void>Completes when the attempt is cancelled.
throwIfCancelled()voidThrows a retryable TaskException with code CANCELLED if the attempt was cancelled.

Cancellation and timeouts

An attempt is cancelled when the runtime lists it in a heartbeat’s cancel list (the attempt timed out, or its workflow was cancelled or failed), or when its deadlineMs() passes. Cancellation does three things:

  • isCancelled() starts returning true;
  • the cancellation() future completes;
  • the thread running the handler is interrupted, so blocking calls such as Thread.sleep, BlockingQueue.take or HttpClient.send throw InterruptedException.

An InterruptedException that escapes the handler of a cancelled attempt is reported as a retryable CANCELLED. Use throwIfCancelled() between units of work, and cancellation() to abort a resource that does not respond to interrupts:

worker.task("reports.render", "1.0.0", Report.class, (report, ctx) -> {
    ctx.cancellation().thenRun(renderer::abort);
    for (Section s : report.sections()) {
        ctx.throwIfCancelled();
        renderer.render(s);
    }
    return Map.of("pages", report.sections().size());
});

Heartbeats run on their own thread and continue until every in-flight task has finished, including during shutdown.

Concurrency

concurrency(n) (default 4) sets the number of poll threads, named orbit-poll-<i>, and therefore the maximum number of tasks this worker runs at once. Handlers run on those threads, so make them thread-safe.

Credentials and TLS

Worker.builder() falls back to the environment for every unset option.

Builder methodEnvironmentDefaultMeaning
server(String)ORBIT_SERVERhttp://127.0.0.1:7233Runtime URL.
token(String)ORBIT_WORKER_TOKEN, then ORBIT_TOKENnoneBearer token.
caCertFile(Path)ORBIT_CA_CERTnonePEM CA certificates, trusted in addition to the system trust store.
clientCert(Path certPem, Path keyPem)ORBIT_CLIENT_CERT, ORBIT_CLIENT_KEYnoneClient certificate chain (leaf first) and private key for mutual TLS.
sslContext(SSLContext)noneYour own TLS context. Replaces the CA and client certificate options.
Worker worker = Worker.builder()
        .workerId("payments-1")
        .server("https://orbit.internal:7233")
        .caCertFile(Path.of("/etc/orbit/ca.pem"))
        .clientCert(Path.of("/etc/orbit/payments-1.pem"), Path.of("/etc/orbit/payments-1.key"))
        .build();

The private key must be an unencrypted PKCS#8 PEM file (BEGIN PRIVATE KEY, which OpenSSL 3 writes by default), RSA or EC. Convert other formats with openssl pkcs8 -topk8 -nocrypt -in old.key -out worker.key. Setting only one of the certificate and the key throws IllegalArgumentException from build(), and unreadable TLS files throw UncheckedIOException. With a per-worker identity from a workers file, set workerId to the identity’s id and the token to the identity’s token.

Logging

The SDK logs to System.getLogger("orbit"), which is java.util.logging unless you bridge it, for example with SLF4J’s jdk-platform-logging binding. Pass logger(System.Logger) to use another logger. Registration, runtime warnings, failures, cancellations and ignored results are logged at INFO and above; each execution is logged at DEBUG. Messages logged from a JVM shutdown hook can be dropped by java.util.logging’s own shutdown hook.

Shutdown

run() blocks the calling thread until stop() is called. start() runs the worker on a new thread named orbit-worker and returns it. stop() stops polling (interrupting open long polls), lets running handlers finish and deliver their results, unregisters, and blocks until all of that is done. It is safe to call from a shutdown hook and more than once. stop(Duration) waits at most the given time and returns whether the worker stopped.

Runtime.getRuntime().addShutdownHook(new Thread(worker::stop));
worker.run();

The JVM runs shutdown hooks on SIGINT and SIGTERM, so this drains the worker when the process is asked to stop.

API reference

Worker

MemberMeaning
static Builder builder()A builder; unset options fall back to the environment and defaults.
static Worker create()A worker configured entirely from the environment and defaults.
task(...), rawTask(...)Register handlers. See Registering tasks.
void run()Registers, polls and blocks until stopped. Throws IllegalStateException when no task is registered or the worker already started, and ApiException when the runtime refuses registration. Connection errors and 5xx responses are retried.
Thread start()Runs run() on a new thread.
void stop()Stops gracefully and blocks until done.
boolean stop(Duration timeout)Like stop(), waiting at most timeout. Throws InterruptedException.
String workerId()The resolved worker id.
String server()The resolved runtime URL.
ObjectMapper objectMapper()The mapper used for input and output.
VERSIONThe SDK version, "0.1.0".
DEFAULT_SERVER"http://127.0.0.1:7233".

Worker.Builder

MethodDefaultMeaning
workerId(String)java-<host>-<pid> (HOSTNAME, then the local host name)Stable identity sent at registration.
server(String)ORBIT_SERVER, then http://127.0.0.1:7233Runtime URL.
token(String)ORBIT_WORKER_TOKEN, then ORBIT_TOKENBearer token.
caCertFile(Path)ORBIT_CA_CERTPEM CA certificates.
clientCert(Path, Path)ORBIT_CLIENT_CERT, ORBIT_CLIENT_KEYMutual TLS identity.
sslContext(SSLContext)noneCustom TLS context.
concurrency(int)4Number of poll threads.
runtime(String)"java"Announced runtime, matched against using <language>.
pollWait(Duration)20 secondswait_ms of each long poll. The runtime caps it at 60 seconds.
resultRetryTimeout(Duration)10 minutesHow long to keep retrying a result through outages.
objectMapper(ObjectMapper)an ObjectMapper that ignores unknown propertiesMapper for input and output.
logger(System.Logger)System.getLogger("orbit")Destination of SDK messages.
build()Builds the worker.

TaskException

Methods: code(), errorMessage() (the message without the code prefix), retryable(), details(). getMessage() returns "<code>: <message>". See Throwing task errors.

ApiException

An unchecked exception for an error response or a connection failure. Methods: status() (0 for a connection failure), code() (the runtime’s error code, CONNECTION, or HTTP_ERROR), isConnectionError().

TaskHandler<I, O> and RawTaskHandler

Functional interfaces. See Handlers and results.

Testing

Handlers are lambdas or classes, so you can test their logic directly. The SDK constructs TaskContext, so keep the business logic in a method that does not need it:

static Output greet(Input in) { ... }

@Test
void greetsWithLetters() {
    assertEquals(3, HelloWorker.greet(new Input("Ada", true)).letters());
}

For an integration test, start orbit server with a temporary database, deploy your workflow, call worker.start(), drive executions through the HTTP API, and call worker.stop() in teardown. A short pollWait keeps polls responsive. The SDK’s own tests in sdks/java/src/test run the worker against a scripted fake runtime built on the JDK’s HttpServer, including a mutual TLS test; run them with mvn verify.

See also

C# and .NET

The C# SDK, the OrbitLang.Sdk NuGet package, runs ORBIT task handlers on .NET. It depends only on the base class library (System.Net.Http and System.Text.Json). Handlers are asynchronous and typed with System.Text.Json, or work on raw JsonElement values, and each attempt gets a CancellationToken. The SDK handles registration, long polling, heartbeats, local deadlines, result delivery through runtime outages, and graceful shutdown when the token passed to RunAsync is cancelled. Read How workers work first for the concepts this page relies on.

Install

dotnet add package OrbitLang.Sdk

The package targets .NET 8.

A minimal worker

This worker implements greeting.csharp from examples/csharp_hello.orbit:

task "greeting.csharp" {
    version "1.0.0"
    input { name: String, excitement: Int }
    output { message: String, length: Int, dotnet_version: String }
}
using System.Runtime.InteropServices;
using OrbitLang.Sdk;

using var worker = new Worker(new WorkerOptions { WorkerId = "hello-csharp" });

worker.Task<GreetInput, GreetOutput>("greeting.csharp", "1.0.0", async (input, ctx) =>
{
    if (string.IsNullOrWhiteSpace(input.Name))
        throw TaskException.Permanent("INVALID_NAME", "name must not be empty");
    if (input.Excitement is < 0 or > 10)
        throw TaskException.Permanent("INVALID_EXCITEMENT", "excitement must be between 0 and 10");
    await Task.Delay(10, ctx.CancellationToken);
    var message = $"Hello, {input.Name.Trim()}{new string('!', input.Excitement)}";
    ctx.Log($"composed greeting ({message.Length} chars)");
    return new GreetOutput(message, message.Length, Environment.Version.ToString());
});

using var stop = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Cancel(); };
using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, c => { c.Cancel = true; stop.Cancel(); });
await worker.RunAsync(stop.Token);

record GreetInput(string Name, int Excitement = 1);
record GreetOutput(string Message, int Length, string DotnetVersion);

The default JSON options use snake_case names, so DotnetVersion is written as dotnet_version, matching the contract. Run the worker against a local runtime; the repository’s examples/workers/csharp project contains the same worker:

orbit server &
dotnet run --project examples/workers/csharp &
orbit run examples/csharp_hello.orbit --input '{"name": "Ada", "excitement": 2}'
{
  "message": "Hello, Ada!!",
  "length": 12
}

Registering tasks

Worker Task<TIn, TOut>(string name, string version, Func<TIn, TaskContext, Task<TOut>> handler)
Worker Task(string name, string version, Func<JsonElement, TaskContext, Task<object?>> handler)

Each overload registers a handler for name@version and returns the worker, so calls can be chained. The name and version must match the task declaration in your .orbit files exactly. Registering the same name twice, or registering while the worker runs, throws InvalidOperationException.

Handlers and results

The typed overload deserializes the input into TIn with the worker’s JsonSerializerOptions. The default options use JsonNamingPolicy.SnakeCaseLower with case-insensitive reads, so OrderId maps to order_id. Override them with WorkerOptions.JsonOptions. Input that cannot be decoded fails the attempt with INVALID_INPUT, which is not retried.

The raw overload receives the input as a JsonElement ({} when the runtime sent none) and may return any JSON-serializable value, such as an anonymous object, a JsonObject, or a JsonElement:

worker.Task("inventory.release", "1.0.0", (input, ctx) =>
    Task.FromResult<object?>(new { released = input.GetProperty("reservation_id").GetString() }));
  • Returning null reports {}.
  • A result that does not serialize to a JSON object fails the attempt with INVALID_RESULT, which is not retried.
  • The runtime validates the output against the contract’s output schema; a mismatch fails the step with OUTPUT_CONTRACT_VIOLATION.

Throwing task errors

Throw TaskException to fail an attempt with a structured error:

throw TaskException.Permanent("CARD_DECLINED", "limit exceeded", new { limit = 5000 });
throw TaskException.Transient("BANK_UNAVAILABLE", "upstream timed out");
throw new TaskException("CARD_DECLINED", "limit exceeded", retryable: false);
Constructor or factoryRetryable
new TaskException(string code, string message, bool retryable = true, object? details = null)as given
TaskException.Transient(string code, string message, object? details = null)yes
TaskException.Permanent(string code, string message, object? details = null)no, fails the step whatever its retry policy

details is serialized with the worker’s JSON options and stored with the error in the execution history. Any other exception is reported as a retryable UNHANDLED_EXCEPTION whose message is <Type>: <message>, with details.type and details.stack. The SDK also logs it.

TaskContext

Property or methodTypeMeaning
TokenstringIdentifies this delivery. The SDK echoes it in the result.
ExecutionIdstringThe workflow execution.
WorkflowstringWorkflow name.
WorkflowVersionstringWorkflow version.
StepIdstringThe step, or compensate:<step> for a compensation; step[i] for a fan-out element.
TaskNamestringThe task name.
ContractVersionstringThe task’s contract version.
InputJsonElementThe raw input.
IdempotencyKeystringStable across retries and redeliveries. Use it for side effects.
Attemptint1-based attempt number. Redeliveries keep the number.
Redeliverybooltrue when the outcome of an earlier delivery of this attempt is unknown.
DeadlineMslong?Absolute deadline in Unix epoch milliseconds, when the step has a timeout.
TimeoutMslong?The step’s timeout in milliseconds.
CancellationTokenCancellationTokenCancelled when the attempt should stop.
RemainingTimeSpan?Time left before DeadlineMs, never negative.
Log(string message)voidLogs message prefixed with [<execution>/<step>#<attempt>].

Cancellation and timeouts

ctx.CancellationToken is cancelled when any of these happens:

  • the runtime lists the attempt in a heartbeat’s cancel list, because the attempt timed out or its workflow was cancelled or failed;
  • the attempt’s DeadlineMs passes;
  • the token passed to RunAsync is cancelled.

Pass the token to every asynchronous call that accepts one:

worker.Task<FetchInput, FetchOutput>("reports.fetch", "1.0.0", async (input, ctx) =>
{
    using var response = await http.GetAsync(input.Url, ctx.CancellationToken);
    return new FetchOutput((int)response.StatusCode);
});

An OperationCanceledException that escapes the handler after the token was cancelled is reported as a retryable CANCELLED, with a message that says whether the runtime cancelled the attempt, the deadline passed, or the worker is shutting down. Heartbeats run on their own task, so a long handler keeps its session alive.

Concurrency

WorkerOptions.Concurrency (default 4) is the number of poll loops, each started with Task.Run, and therefore the maximum number of tasks this worker runs at once. Handlers can run in parallel on thread-pool threads, so make shared state thread-safe.

Credentials and TLS

Every connection setting has a WorkerOptions property and an environment variable. A non-empty property wins.

PropertyEnvironmentDefaultMeaning
ServerORBIT_SERVERhttp://127.0.0.1:7233Runtime URL.
TokenORBIT_WORKER_TOKEN, then ORBIT_TOKENnoneBearer token.
CaCertFileORBIT_CA_CERTnonePEM CA certificates, trusted in addition to the system store.
ClientCertificatePathORBIT_CLIENT_CERTnonePEM client certificate for mutual TLS.
ClientKeyPathORBIT_CLIENT_KEYthe certificate filePEM private key: PKCS#8, PKCS#1 or EC.
using var worker = new Worker(new WorkerOptions
{
    WorkerId = "payments-1",
    Server = "https://orbit.internal:7233",
    CaCertFile = "/etc/orbit/ca.pem",
    ClientCertificatePath = "/etc/orbit/payments-1.pem",
    ClientKeyPath = "/etc/orbit/payments-1.key",
});

The SDK loads the certificate with X509Certificate2.CreateFromPemFile and re-imports it through PKCS#12, so that TLS stacks that reject ephemeral keys accept it. A key without a certificate, a certificate without a key, or a CA file without certificates throws from the constructor. With a per-worker identity from a workers file, set WorkerId to the identity’s id and the token to the identity’s token.

Logging

SDK messages go to WorkerOptions.Logger, an Action<string>. The default writes to standard error with the prefix [orbit] and a timestamp. To use Microsoft.Extensions.Logging, pass a delegate:

var options = new WorkerOptions { Logger = line => logger.LogInformation("{Line}", line) };

ctx.Log writes through the same delegate.

Shutdown

RunAsync returns after the token passed to it is cancelled. It then aborts the open long polls and cancels the CancellationToken of each running handler. It waits for the handlers to finish and delivers their results (retrying for up to 10 minutes), unregisters, and returns. The worker implements IDisposable; dispose it after RunAsync returns.

A handler that ignores its token finishes its work and its result is delivered. A handler that stops because of the token reports a retryable CANCELLED, and that failure counts as an attempt. With the default retry policy of one attempt, the step then fails. Give steps served by .NET workers a retry policy with more than one attempt if you restart workers while they run tasks.

API reference

Worker

public sealed class Worker : IDisposable
public Worker(WorkerOptions? options = null)
MemberMeaning
Task<TIn, TOut>(name, version, handler), Task(name, version, handler)Register handlers. See Registering tasks.
Task RunAsync(CancellationToken cancellationToken = default)Registers, polls and completes after shutdown. Throws InvalidOperationException when no task is registered or the worker is already running, and OrbitApiException when the runtime refuses registration. Connection errors and 5xx responses during registration are retried; cancelling the token while the runtime is unreachable returns without an error.
void Dispose()Releases the HTTP client and the client certificate.
WorkerId, Server, Runtime, Concurrency, PollWaitThe resolved settings.
ClientCertificateThe X509Certificate2 presented for mutual TLS, or null.
VersionThe SDK version, "0.1.0".

WorkerOptions

PropertyTypeDefaultMeaning
WorkerIdstring?csharp-<machine>-<pid>Stable identity sent at registration.
Serverstring?ORBIT_SERVER, then http://127.0.0.1:7233Runtime URL.
Tokenstring?ORBIT_WORKER_TOKEN, then ORBIT_TOKENBearer token.
CaCertFilestring?ORBIT_CA_CERTPEM CA certificate path.
ClientCertificatePathstring?ORBIT_CLIENT_CERTPEM client certificate path.
ClientKeyPathstring?ORBIT_CLIENT_KEYPEM private key path.
Concurrencyint4Number of poll loops; values below 1 become 1.
Runtimestring"csharp"Announced runtime, matched against using <language>.
PollWaitTimeSpan20 secondswait_ms of each long poll. The runtime caps it at 60 seconds.
JsonOptionsJsonSerializerOptions?snake_case, case-insensitiveOptions for input, output and details.
LoggerAction<string>?standard errorDestination of SDK messages.

TaskException

Properties: Code, Message, Retryable, Details. See Throwing task errors.

OrbitApiException

Thrown for an error response or a connection failure. Properties: Status (0 for a connection failure), Code (the runtime’s error code, CONNECTION, or HTTP_ERROR), IsConnectionError.

TaskContext

See TaskContext. The SDK creates it; you do not construct one.

Testing

Keep handler logic in methods you can call directly. The SDK constructs TaskContext, so test the logic without it:

static GreetOutput Greet(GreetInput input) => ...;

[Fact]
public void GreetsWithExcitement() =>
    Assert.Equal("Hello, Ada!!", Greet(new GreetInput("Ada", 2)).Message);

For an integration test, start orbit server with a temporary database, deploy your workflow, start worker.RunAsync(cts.Token) without awaiting it, drive executions through the HTTP API, then cancel cts and await the task. A short PollWait keeps polls responsive, and a Logger that collects lines lets you assert on SDK messages. The SDK’s own tests in sdks/csharp run the worker against a scripted fake runtime built on HttpListener, including mutual TLS; run them with dotnet test.

See also

Running the server

orbit server runs the ORBIT runtime: one process that stores workflow definitions and execution history, decides what happens next in every execution, hands tasks to workers and serves the HTTP API. Workers are separate processes in any language; they never own workflow state. This page covers starting the server, every option, configuration examples from local development to production, health checks and shutdown.

              orbit CLI / apps ──HTTP(S)──┐
                                          ▼
 ┌────────────────────────── orbit server ───────────────────────────┐
 │  HTTP API ─┐                                                       │
 │  gRPC  ────┼─► Engine ─► decide(state, IR, now)  (pure)            │
 │            │     │  ▲                                              │
 │            │     ▼  │ fold(events)          OTLP traces ─► collector│
 │            │   SQLite or PostgreSQL: execution_events (authoritative)│
 └────────────┴──────▲─────────────────────────────────▲─────────────┘
         long-poll / results (HTTP/JSON or gRPC, optional mTLS)
      Python · TypeScript · Go · Rust · Java · C# workers

Start a local server

With no options, the server stores everything in orbit.db in the current directory and listens on 127.0.0.1:7233 without authentication:

$ orbit server
2026-11-15T18:32:42.524122Z  INFO orbit: starting ORBIT runtime db=orbit.db backend="sqlite" http_adapter=false auth=false ui=true
2026-11-15T18:32:42.525870Z  INFO orbit_runtime::server: ORBIT runtime listening addr=127.0.0.1:7233 tls=false ui=true

In another terminal, check it and deploy a workflow:

$ curl -s http://127.0.0.1:7233/v1/health
{"status":"ok","api_version":"orbit-api/1","runtime_version":"0.2.0","ir_versions":["orbit-ir/1","orbit-ir/2"],"active_executions":0,"worker_sessions":0,"role":"single","node_id":null,"term":null,"leader":null}
$ orbit deploy examples/order_processing.orbit
deployed OrderProcessing@1.0.0 (sha256:4fdfac082bf3)

The same address serves the web UI: open http://127.0.0.1:7233/ in a browser.

An unauthenticated server is allowed only on a loopback address. To listen on another interface, set a token (see Security).

Options

Every option can also be set through the environment variable listed, except where the column is empty. A flag overrides its environment variable.

OptionEnvironmentTypeDefaultMeaning
--dbORBIT_DBpath or URLorbit.dbSQLite file path, or a postgres:// / postgresql:// URL. See Storage.
--addrORBIT_ADDRip:port127.0.0.1:7233HTTP API address. It serves the administrative API and the HTTP/JSON worker protocol. It must be an IP address, not a host name: use 0.0.0.0:7233 or [::]:7233 for all interfaces.
--grpc-addrORBIT_GRPC_ADDRip:portoffAlso serve the gRPC worker protocol on this address, for example 127.0.0.1:7234.
--tokenORBIT_TOKENstringnoneAdmin bearer token, required on every endpoint except /v1/health.
--worker-tokenORBIT_WORKER_TOKENstringnoneShared worker credential, accepted only on /v1/workers/* and /v1/tasks/*. Requires --token and must differ from it.
--workers-fileORBIT_WORKERS_FILEpathnoneWorker identities ([[worker]]) and client identities ([[client]]), in TOML. Requires --token. See Security.
--tls-certORBIT_TLS_CERTpathnonePEM certificate chain. With --tls-key, the HTTP API (and gRPC) serve TLS.
--tls-keyORBIT_TLS_KEYpathnonePEM private key (PKCS#8, PKCS#1 or SEC1).
--tls-client-caORBIT_TLS_CLIENT_CApathnoneEnables mutual TLS: client certificates signed by this CA identify workers. Requires --tls-cert and a workers file with client_cert entries.
--otlp-endpointOTEL_EXPORTER_OTLP_ENDPOINTURLoffExport OpenTelemetry traces over OTLP/HTTP (JSON) to this base URL.
--enable-http-adapterflagoffRun orbit.http.request steps inside the runtime.
--max-active-executionsORBIT_MAX_ACTIVE_EXECUTIONSintegerunlimitedMaximum running executions. Further starts get 429 RESOURCE_EXHAUSTED. See Resource limits.
--max-fan-outORBIT_MAX_FAN_OUTintegerunlimitedMaximum elements one for step may fan out to. A larger list fails the step with LIMIT_EXCEEDED.
--max-history-eventsORBIT_MAX_HISTORY_EVENTSintegerunlimitedMaximum history events per execution. Past it the execution fails with LIMIT_EXCEEDED.
--max-payload-bytesORBIT_MAX_PAYLOAD_BYTESbytesunlimitedMaximum serialized size of an execution input, a task result or a signal payload. Larger ones get 413 PAYLOAD_TOO_LARGE.
--session-timeoutseconds30A worker session silent for this long is considered lost, and its in-flight attempts are redelivered. Workers are told to heartbeat every sixth of it (at least every 500 ms).
--max-bodybytes4194304 (4 MiB)Maximum request body size. Larger requests get 413.
--log-formattext or jsontextLog format. Logs go to stderr.
--insecureflagoffAllow a non-loopback --addr without a token. Use only on a trusted network.
--clusterORBIT_CLUSTERflagoffRun as one of several servers on the same PostgreSQL database. Requires a PostgreSQL --db. See Running several servers.
--cluster-modeORBIT_CLUSTER_MODEstandby or sharedstandbyWith standby, one leader serves and the others stand by. With shared, every server is active and they share executions and worker tasks. shared requires --cluster. See Shared mode.
--node-timeoutORBIT_NODE_TIMEOUTseconds5Shared mode: a server whose heartbeat is older than this is dead, and the others take over its executions. Values below 2 are raised to 2.
--node-idORBIT_NODE_IDstringhost nameThis server’s name in the cluster. Must be unique among running servers. Requires --cluster.
--advertise-addrORBIT_ADVERTISE_ADDRURLfrom --addrURL standbys hand out for this server while it leads, and that shared mode lists in /v1/health, for example http://orbit-0.orbit:7233. Without it, a wildcard --addr advertises nothing. Requires --cluster.
--no-uiORBIT_NO_UIflagoffDo not serve the web UI at /ui/, and do not redirect / to it. The HTTP API is unaffected. The environment variable accepts true/false, 1/0, yes/no, on/off. See Web UI.

The server also reads ORBIT_LOG (log filter), ORBIT_DB_CA_CERT (CA for PostgreSQL TLS), OTEL_EXPORTER_OTLP_HEADERS and OTEL_SERVICE_NAME. See Environment variables.

Configuration the server refuses

The server checks its configuration before it opens the database and exits with status 1 if:

ConditionMessage
A non-loopback --addr without --token or --insecurerefusing to listen on 0.0.0.0:7233 without authentication; pass --token (or ORBIT_TOKEN), or --insecure for trusted networks
--worker-token without --token--worker-token requires --token: without an admin token every endpoint would be open
--worker-token equal to --token--worker-token must differ from --token
--workers-file without --token--workers-file requires --token: without an admin token administrative endpoints would be open
--tls-client-ca without client_cert entries--tls-client-ca needs --workers-file entries with `client_cert` to map certificates to workers
An invalid workers filefor example workers.toml: worker `a` needs `token_sha256` or `client_cert`
--cluster with a SQLite --db--cluster requires a PostgreSQL database (--db postgres://...): the servers share it for leader election and history
--cluster-mode shared without --cluster--cluster-mode shared requires --cluster
Shared mode while another running server uses the same --node-id (after waiting three --node-timeouts for it to stop)cannot join the shared cluster: STORAGE_ERROR: another server with node id `orbit-0` is running (it heartbeated 637 ms ago); give every server its own --node-id

An empty token (ORBIT_TOKEN=) counts as no token.

Configuration examples

Local development

orbit server

Equivalent to orbit server --db orbit.db --addr 127.0.0.1:7233. Use a separate database file per project, or delete orbit.db to start over.

Production with tokens

Generate the credentials once and keep them in a secret store:

export ORBIT_TOKEN=$(orbit token 2>/dev/null)          # admin token
export ORBIT_WORKER_TOKEN=$(orbit token 2>/dev/null)   # shared worker token
orbit server --db /var/lib/orbit/orbit.db --addr 0.0.0.0:7233 --log-format json

Give ORBIT_WORKER_TOKEN to worker hosts, and ORBIT_TOKEN only to operators and the applications that start executions. To give each worker its own credential and limit the tasks it may serve, use a workers file instead of, or in addition to, the shared worker token:

orbit server --addr 0.0.0.0:7233 --token "$ORBIT_TOKEN" --workers-file /etc/orbit/workers.toml

The same file can declare client identities ([[client]]): credentials for applications that may only start, signal, query, read or cancel executions of the workflows you list, without the admin token. The file format is described in Security and Security: client identities.

TLS

orbit server --addr 0.0.0.0:7233 --token "$ORBIT_TOKEN" \
  --tls-cert /etc/orbit/tls/server.pem --tls-key /etc/orbit/tls/server.key
INFO orbit_runtime::server: ORBIT runtime listening addr=0.0.0.0:7233 tls=true mtls=false ui=true

Clients use https:// and, for a private CA, --ca-cert or ORBIT_CA_CERT:

orbit ps --server https://orbit.internal:7233 --ca-cert /etc/orbit/tls/ca.pem

TLS 1.2 and 1.3 are supported, with HTTP/2 and HTTP/1.1 negotiated through ALPN.

Mutual TLS

orbit server --addr 0.0.0.0:7233 --token "$ORBIT_TOKEN" \
  --tls-cert /etc/orbit/tls/server.pem --tls-key /etc/orbit/tls/server.key \
  --tls-client-ca /etc/orbit/tls/workers-ca.pem \
  --workers-file /etc/orbit/workers.toml
INFO orbit_runtime::server: ORBIT runtime listening addr=0.0.0.0:7233 tls=true mtls=true ui=true

Client certificates are optional at the TLS layer: a client without a certificate, such as the admin CLI, can still connect and authenticate with a token. See Security.

PostgreSQL

orbit server --addr 0.0.0.0:7233 --token "$ORBIT_TOKEN" \
  --db 'postgres://orbit:secret@db.internal:5432/orbit?sslmode=require'

The schema is created on first start. The password is redacted in logs (postgres://orbit:***@db.internal:5432/orbit?sslmode=require). See Storage.

OpenTelemetry tracing

OTEL_SERVICE_NAME=orbit-prod \
OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=$HONEYCOMB_KEY" \
orbit server --token "$ORBIT_TOKEN" --otlp-endpoint https://api.honeycomb.io
INFO orbit_runtime::engine: exporting OpenTelemetry traces endpoint=https://api.honeycomb.io service=orbit-prod

See Observability.

HTTP adapter

orbit server --enable-http-adapter

With the adapter enabled, steps that call the built-in orbit.http.request task run inside the runtime, with no worker. Only http:// and https:// URLs are allowed. The adapter lets workflow authors make outbound requests from the runtime host, so enable it only when you trust everyone who can deploy workflows. When it is disabled, such steps fail with ADAPTER_DISABLED (not retried). See HTTP adapter.

gRPC worker protocol

orbit server --token "$ORBIT_TOKEN" --worker-token "$ORBIT_WORKER_TOKEN" \
  --addr 0.0.0.0:7233 --grpc-addr 0.0.0.0:7234
INFO orbit_runtime::server: gRPC worker protocol listening addr=0.0.0.0:7234 tls=false
INFO orbit_runtime::server: ORBIT runtime listening addr=0.0.0.0:7233 tls=false ui=true

The gRPC listener shares the engine, credentials, per-worker identities, TLS and mutual TLS settings with the HTTP API. Only the worker protocol is served over gRPC; the administrative API is HTTP only. The official SDKs use HTTP/JSON. See Worker protocol.

Resource limits

By default the server accepts as many executions, fan-out elements, history events and bytes as the host can hold. Four options cap them, so one workflow or one caller cannot exhaust the runtime. Each limit is off until you set it, and each can be set through its environment variable.

OptionLimitsWhen it is reached
--max-active-executionsExecutions that are not terminal, held in memory.A new start gets 429 RESOURCE_EXHAUSTED. Nothing is recorded.
--max-fan-outElements of one for step.The step fails with LIMIT_EXCEEDED (not retried) before any element is scheduled.
--max-history-eventsEvents in one execution’s history: in one run, for a workflow that uses continue_as_new.A LimitExceeded event is recorded and the execution fails with LIMIT_EXCEEDED.
--max-payload-bytesSerialized JSON size of an execution input (after defaults are applied), a task result, or a signal payload.The request gets 413 PAYLOAD_TOO_LARGE. Nothing is recorded.
orbit server --token "$ORBIT_TOKEN" \
  --max-active-executions 10000 --max-fan-out 1000 \
  --max-history-events 50000 --max-payload-bytes 262144

--max-payload-bytes is separate from --max-body: --max-body limits the whole HTTP request body and is enforced before the request is read, with a plain-text 413; --max-payload-bytes limits the value inside it and answers with the JSON error envelope.

Per-workflow limits

A workflow can tighten the fan-out, history and payload limits for its own executions with a limits block. The stricter of the workflow’s value and the server’s option applies; a workflow cannot raise a server limit. --max-active-executions is server-wide only.

workflow Shipment {
    version "1.0.0"
    limits { max_fan_out: 100, max_history_events: 5000, max_payload_bytes: 65536 }
    ...
}
FieldSame as
max_fan_out--max-fan-out
max_history_events--max-history-events
max_payload_bytes--max-payload-bytes, for this workflow’s input, task results and signal payloads

Each value is a positive integer literal. An unknown field is error E0320 (with a suggestion), and a zero, negative or computed value is error E0321. A workflow with a limits block compiles to orbit-ir/2, and orbit inspect lists the block. When a workflow limit is reached, the error is the same as for the server option and names both: the execution reached 5000 history events (--max-history-events or the workflow's `limits`).

Active executions

The limit applies to executions started through POST /v1/executions (and orbit run). Starting an execution_id that already exists is still answered with 200 and "created": false when the limit is reached, because it starts nothing. Child workflows are never refused and do not count toward the total, so a running parent can always start its children and the limit counts top-level executions only.

$ orbit server --token "$ORBIT_TOKEN" --max-active-executions 2
$ curl -s -H "authorization: Bearer $ORBIT_TOKEN" -H 'content-type: application/json' \
    -d '{"workflow":"Review","execution_id":"review-2","input":{"order_id":"2"}}' \
    -w ' %{http_code}\n' http://127.0.0.1:7233/v1/executions
{"error":{"code":"RESOURCE_EXHAUSTED","message":"2 executions are already running (--max-active-executions)"}} 429
$ curl -s -H "authorization: Bearer $ORBIT_TOKEN" -H 'content-type: application/json' \
    -d '{"workflow":"Review","execution_id":"review-1","input":{"order_id":"1"}}' \
    -w ' %{http_code}\n' http://127.0.0.1:7233/v1/executions
{"execution_id":"review-1","created":false} 200

Treat 429 as a signal to retry later with backoff, using the same execution_id so a retry never starts a second execution.

Fan-out

task "labels.print" {
    version "1.0.0"
    input { sku: String }
    output { label: String }
}

workflow PrintLabels {
    version "1.0.0"
    input { skus: List<String> }

    step print {
        for sku in input.skus
        worker "labels.print"
        with { sku: sku }
    }

    on failure {
        step alert {
            compute { code: failure.code, message: failure.message }
        }
    }
}

With --max-fan-out 3, an execution with four SKUs fails at the for step. The failure aborts the execution like any other step failure: compensation and on failure handlers run.

$ orbit logs labels-2
+     0ms   #1  WorkflowCreated           
+     0ms   #2  WorkflowStarted           
+     2ms   #3  StepFailed                step=print error=LIMIT_EXCEEDED `for` over 4 elements exceeds the fan-out limit of 3 (--max-fan-out or the workflow's `limits`)
+     2ms   #4  HandlersStarted           kind=failure
+     2ms   #5  StepSucceeded             step=on_failure:alert attempt=1
+     2ms   #6  WorkflowFailed            error=LIMIT_EXCEEDED step `print` failed: `for` over 4 elements exceeds the fan-out limit of 3 (--max-fan-out or the workflow's `limits`)

History events

The runtime checks the limit each time it decides what an execution does next. When the next events would take the history past the limit, it records LimitExceeded instead of scheduling more work. The execution then aborts as after a failure: steps that have not started are cancelled, in-flight attempts finish, succeeded steps are compensated, the on failure handler runs, and WorkflowFailed is recorded with LIMIT_EXCEEDED. The events of that shutdown are recorded even though they take the history past the limit, and so are results that workers report while the decision is pending, so a history can end a few events above the limit.

With --max-history-events 10, the same workflow with three SKUs:

$ orbit logs labels-3
+     0ms   #1  WorkflowCreated           
+     0ms   #2  WorkflowStarted           
+     1ms   #3  StepFanOut                step=print
+     1ms   #4  StepScheduled             step=print[0] attempt=1
+     1ms   #5  StepScheduled             step=print[1] attempt=1
+     1ms   #6  StepScheduled             step=print[2] attempt=1
+   2m14s   #7  StepDispatched            step=print[0] attempt=1 worker_id=printer
+   2m15s   #8  StepSucceeded             step=print[0] attempt=1
+   2m15s   #9  StepDispatched            step=print[1] attempt=1 worker_id=printer
+   2m15s  #10  StepSucceeded             step=print[1] attempt=1
+   2m15s  #11  StepDispatched            step=print[2] attempt=1 worker_id=printer
+   2m15s  #12  StepSucceeded             step=print[2] attempt=1
+   2m15s  #13  LimitExceeded             limit=max_history_events
+   2m15s  #14  StepCancelled             step=print
+   2m15s  #15  HandlersStarted           kind=failure
+   2m15s  #16  StepSucceeded             step=on_failure:alert attempt=1
+   2m15s  #17  WorkflowFailed            error=LIMIT_EXCEEDED the execution reached 10 history events (--max-history-events or the workflow's `limits`)

The limit protects the runtime from executions that never stop growing, such as a workflow that retries forever. Set it well above the size of your largest legitimate history: about three events per task attempt, plus one per fan-out step, signal and timer.

Payload size

The size is the length of the value serialized as compact JSON. The check covers execution input, task results reported over HTTP and gRPC, and signal payloads:

$ orbit server --token "$ORBIT_TOKEN" --max-payload-bytes 200
$ curl -s ... http://127.0.0.1:7233/v1/executions
{"error":{"code":"PAYLOAD_TOO_LARGE","message":"execution input is 414 bytes; the limit is 200 (--max-payload-bytes or the workflow's `limits`)"}} 413
$ curl -s ... http://127.0.0.1:7233/v1/executions/review-1/signals/decision
{"error":{"code":"PAYLOAD_TOO_LARGE","message":"signal payload is 277 bytes; the limit is 200 (--max-payload-bytes or the workflow's `limits`)"}} 413
$ curl -s ... http://127.0.0.1:7233/v1/tasks/complete
{"error":{"code":"PAYLOAD_TOO_LARGE","message":"task result is 312 bytes; the limit is 200 (--max-payload-bytes or the workflow's `limits`)"}} 413

A refused task result is not recorded: the attempt stays in flight, so the worker can report a smaller result or a failure with POST /v1/tasks/fail before the attempt’s timeout. Over gRPC the same refusal is a status with the orbit-error-code metadata PAYLOAD_TOO_LARGE (see HTTP API: gRPC binding). Error details sent with POST /v1/tasks/fail are not checked.

Startup and recovery

On startup, the server:

  1. checks its configuration;
  2. opens the database and creates the schema if needed;
  3. loads every workflow definition;
  4. loads every execution that is not terminal, replays its history and re-indexes the tokens of in-flight attempts;
  5. runs the decider for each recovered execution, which fires overdue timers and retries and schedules ready steps;
  6. binds its listeners and starts serving.
INFO orbit_runtime::engine: recovered active executions from history executions=1 in_flight=0

Attempts that were in flight before the restart get one session timeout for their workers to reconnect and report a result. After that, attempts nobody reported are redelivered. See Delivery guarantees.

Because recovery runs before the listener is bound, connection attempts fail until the server is ready. A runtime with many active executions takes longer to start. In cluster mode the order differs: a server binds its listener at once as a standby and recovers only when it is elected; in shared mode a server binds at once and resumes the executions it owned before it restarted, then reports ready. See Running several servers.

Health and readiness

GET /v1/health never requires authentication and returns 200 once the server is listening:

{"status":"ok","api_version":"orbit-api/1","runtime_version":"0.2.0","ir_versions":["orbit-ir/1","orbit-ir/2"],"active_executions":0,"worker_sessions":0,"role":"single","node_id":null,"term":null,"leader":null}

Use it for liveness probes, and for readiness of a single server: a single server answers only after recovery. The response also carries role (single, leader and standby in cluster mode, shared in shared mode), node_id, term and leader; in shared mode also owned_executions, tasks_claimed and nodes.

GET /v1/ready never requires authentication either. It returns 200 on a single server, on a cluster leader and on every live server in shared mode, and 503 on a standby. Use it as the readiness or load-balancer health check when you run several servers, so traffic reaches only servers that accept writes. See Running several servers.

If you use mutual TLS, a probe that presents a client certificate matching no worker gets 403; send probes without a client certificate.

For monitoring beyond liveness, scrape /v1/metrics with the admin token. See Observability.

Background work

Every 100 ms the server runs a tick that:

  • marks attempts whose worker session went silent as uncertain and redelivers them;
  • fires due timers, retries, attempt timeouts, queue timeouts and workflow timeouts;
  • runs built-in tasks (the HTTP adapter);
  • starts child workflows, delivers finished children’s results to their parents and cancels children whose parent no longer waits for them.

Worker sessions that stay silent for four session timeouts are forgotten; the worker must register again.

Shutdown

Press Ctrl+C, or send SIGINT or SIGTERM, for a graceful shutdown:

INFO orbit: shutting down

The server stops accepting connections, waits for in-flight HTTP requests to finish, stops the gRPC listener and exits with status 0. Long polls are not interrupted: a worker poll can hold shutdown for up to its wait_ms (at most 60 seconds), and a GET /v1/executions/{id}/wait for up to its timeout_ms (at most 300 seconds). With TLS, open connections get at most 10 seconds before they are closed.

Version 0.1.0 handles only SIGINT; SIGTERM ends a 0.1.0 server immediately, as SIGKILL would. Later versions handle both. Either way no committed state is lost, because every state change is committed to the database before it takes effect, and the next start recovers exactly what was committed. To get a graceful stop from every version, set the stop signal to SIGINT: docker run --stop-signal SIGINT, stop_signal: SIGINT in Compose, or KillSignal=SIGINT under systemd. See Deployment.

Single node and cluster mode

Without --cluster, one server owns a database. Do not run two such servers against the same SQLite file or PostgreSQL database: each would keep its own in-memory view of active executions and dispatch the same work. For availability, either run one server under a supervisor that restarts it (systemd, Kubernetes with one replica) and rely on recovery, or run several servers with --cluster on PostgreSQL: one is elected leader, the others take over if it fails. To spread the work over several servers, add --cluster-mode shared: every server is active, and the others take over the executions of a server that fails. See Running several servers and Storage.

See also

Executions and history

An execution is one run of a workflow definition with one input. The runtime records everything that happens to it as an append-only history of events, and derives its current state, and the state of each of its steps, from that history. This page describes the execution lifecycle, every execution and unit status, every history event with its fields, how the CLI shows them, and how cancellation and timeouts behave.

Event sourcing

Every state change is an event appended to the execution_events table. The current state of an execution is the left fold of its events: replaying the history from the first event always produces the same state. A projection table, workflow_executions, is updated in the same transaction for listing, but the history is authoritative.

Decisions come from a pure function, decide(state, definition, now), that returns the events that should happen next: schedule ready steps, fan out for steps, evaluate compute steps, fire timers, deliver buffered signals to await steps, apply retry policies, start compensation, finish the workflow. The engine runs it until it returns nothing and commits the result as one batch. Because the decider performs no I/O and expressions cannot read the clock, every decision can be explained from the history.

Starting an execution

You start an execution with orbit run, POST /v1/executions, or a parent workflow’s workflow "Name" step.

  • The definition is chosen by name and optional version; without a version, the highest registered version (by semantic version order) is used. The execution is pinned to that definition’s hash for its whole life.
  • Input defaults are applied and the input is validated strictly against the workflow’s input type. An invalid input is rejected with INVALID_INPUT and no execution is created.
  • The execution id is a random UUID unless you pass one. A client-chosen id makes starting idempotent: starting the same id again returns the existing execution (created: false) instead of creating a second one. A child workflow’s id is <parent id>.<unit>, with dots in place of brackets (order-7.ship, order-7.reserve.2); a child started by run 2 or later of its parent (continue-as-new) is <parent id>.run<N>.<unit>.
  • A schedule starts executions with the id <schedule id>:<occurrence>, for example nightly-orders:2026-11-16T01:00:00Z, and records schedule_id in the execution.
$ orbit run examples/order_processing.orbit --id order-1001 --no-wait --input-file input.json

Execution statuses

StatusTerminalMeaning
RUNNINGnoSteps are being scheduled and run. An execution that is being aborted (after a failure, a cancellation or a timeout) stays RUNNING while it withdraws queued work and waits for in-flight attempts.
COMPENSATINGnoCompensations are running, one at a time, newest first.
COMPLETEDyesEvery step finished and the return expression produced a valid output.
FAILEDyesA step failed permanently, a resource limit was reached, the return expression failed, or the output did not match the workflow’s output type.
CANCELLEDyesCancellation was requested and the abort finished.
TIMED_OUTyesThe workflow timeout passed and the abort finished.
CONTINUED_AS_NEWyes, for the runThe run ended with continue_as_new and the next run of the execution started. Only archived runs have this status (GET /v1/executions/{id}/history?run=N); the execution itself is the latest run.
                  ┌──────────────► COMPLETED
                  │
RUNNING ──────────┼──────────────► FAILED | CANCELLED | TIMED_OUT     (nothing to compensate)
                  │
                  └─► COMPENSATING ─► FAILED | CANCELLED | TIMED_OUT

A terminal execution also carries compensation:

ValueMeaning
noneNothing needed compensating.
completedEvery compensation succeeded.
failedA compensation failed permanently. The history shows exactly what was and was not undone.

Runs (continue-as-new)

An execution has one or more runs. It starts with run 1; every continue_as_new ends the current run and starts run N+1 under the same execution id, with the new input and an empty history. In one transaction the runtime appends WorkflowContinuedAsNew to the finished run, moves that run’s events from execution_events to execution_run_events (keyed by execution id and run), and writes the new run’s first events: WorkflowCreated (with run), WorkflowStarted, and one SignalReceived for every signal the finished run received but no step took, in arrival order. A crash cannot leave an execution between two runs.

  • GET /v1/executions/{id}, orbit status and orbit inspect show the latest run; its state has run (absent for run 1).
  • GET /v1/executions/{id}/history returns the latest run’s history; ?run=N (and orbit logs --run N, orbit replay --run N) an earlier run’s. Every run’s seq starts at 1.
  • wait returns when the latest run ends with a terminal status other than CONTINUED_AS_NEW.
  • Signals go to the current run. A signal sent while a run is finishing is either recorded in that run (and carried over if no step took it) or in the next one; none is lost.
  • Queries, cancellation and the workflow timeout apply to the current run. The timeout counts from the run’s start, and created_at_ms is the run’s creation time.
  • The workflow version does not change: every run uses the definition the execution started with.
  • A new run is not a new execution: it does not count against --max-active-executions and does not increment orbit_executions_started_total. --max-history-events and the workflow’s max_history_events apply to each run.
  • The default idempotency key of a unit in run N > 1 is <execution id>/run<N>/<unit>, so a worker that deduplicates by key does not mistake a later run’s step for an earlier one’s.

GET /v1/executions lists the execution once.

Units

The runtime schedules units. A unit is one of:

Unit keyWhat it is
stepA step.
step[i]Element i (from 0) of a for step.
compensate:<unit>The compensation of a unit, for example compensate:reserve_inventory or compensate:reserve[2].
on_<kind>:<step>A step of an outcome handler, for example on_failure:tell_customer or on_cancel:confirm_cancel. See Outcome handlers.

Each unit has a status, an attempt number, its evaluated input and idempotency key, its output or error, and the times it was scheduled, dispatched and finished. A unit’s input and idempotency key are evaluated once, when it is first scheduled, and reused unchanged for retries and redeliveries.

Unit statuses

StatusTerminalMeaning
PENDINGnoNot started: waiting for dependencies, or not reached. A pending unit has no status field in the API.
SCHEDULEDnoQueued for a worker. Its input is evaluated and stored.
DISPATCHEDnoA worker received it and holds a token for it. For a child workflow step: the child execution is running.
ATTEMPT_FAILEDnoTransient: an attempt failed and the decider is choosing between a retry and failure. Never observed between commits.
UNCERTAINnoTransient: the outcome of the attempt is unknown and it will be redelivered. Never observed between commits.
RETRY_WAITnoWaiting for the backoff delay before the next attempt.
WAITINGnoA sleep timer is running, or an await step is waiting for a signal.
RUNNINGnoA for step whose elements are running.
SUCCEEDEDyesFinished with an output.
SKIPPEDyesIts when condition was false (or null). Its output is null.
FAILEDyesFailed permanently: retries exhausted, a non-retryable error, or an error the runtime never retries.
CANCELLEDyesWithdrawn before it started because the execution is being aborted.

A step becomes eligible when every dependency is SUCCEEDED or SKIPPED, the execution is not being aborted, and its when condition is true. Independent steps are scheduled together and run concurrently.

PENDING ─► SCHEDULED ─► DISPATCHED ─► SUCCEEDED
   │           │             │
   │           │             ├─► ATTEMPT_FAILED ─► RETRY_WAIT ─► SCHEDULED
   │           │             │                  └► FAILED
   │           │             └─► UNCERTAIN ─► SCHEDULED (redelivery)
   │           └─► CANCELLED
   ├─► SKIPPED
   ├─► SUCCEEDED                       (compute)
   ├─► WAITING ─► SUCCEEDED | FAILED   (sleep, await)
   └─► RUNNING ─► SUCCEEDED | FAILED   (for)

History events

Every event has these fields:

FieldTypeMeaning
seqintegerPosition in the history, starting at 1, without gaps.
timestamp_msintegerCommit time, milliseconds since the Unix epoch. All events of one batch share it.
typestringEvent type, below.

Error objects (error) have the fields code (string), message (string), retryable (boolean) and optionally details (any JSON). Events that refer to a unit carry its key in step.

Workflow events

EventFieldsMeaning
WorkflowCreatedworkflow, version, definition_hash, input, parent?, schedule_id?, run?The execution (or, with run, run run of it) exists. input has defaults applied. parent ({"execution_id", "step"}) is set for child workflows, schedule_id for executions a schedule started, and run for runs after the first. Always event 1.
WorkflowStartednoneThe execution began. Always event 2.
WorkflowTimeoutReachednoneThe workflow timeout passed. The execution starts aborting.
CancelRequestedreasonCancellation was requested. The execution starts aborting, unless it is already aborting.
WorkflowCompletedoutputTerminal: COMPLETED.
LimitExceededlimit, messageA server resource limit was reached; limit is max_history_events. The execution starts aborting with the error LIMIT_EXCEEDED and message. See Resource limits.
WorkflowFailederror, compensationTerminal: FAILED. The error message names the failed step.
WorkflowCancelledcompensationTerminal: CANCELLED.
WorkflowTimedOutcompensationTerminal: TIMED_OUT.
WorkflowContinuedAsNewnext_run, inputTerminal for the run: CONTINUED_AS_NEW. Run next_run starts with input (defaults applied). The last event of an archived run.

Step events

EventFieldsMeaning
StepScheduledstep, attempt, input, idempotency_key, redeliveryThe unit is queued with its evaluated input. Recorded for the first attempt, for each retry (same input and key, next attempt number) and for each redelivery (same attempt number, redelivery: true).
StepSkippedstep, reasonThe when condition was false.
StepFanOutstep, itemsA for step evaluated its list once. One unit per element follows, step[0], step[1], and so on.
StepDispatchedstep, attempt, token, worker_id, worker_session, deadline_msA worker received the unit. deadline_ms is null without a timeout. For child workflows, worker_id is orbit-runtime and worker_session is child:<child id>.
StepSucceededstep, attempt, outputThe unit finished. For a for step, output is the list of element outputs, in order.
StepAttemptFailedstep, attempt, errorOne attempt failed: a worker reported a failure, the attempt exceeded its timeout (TIMEOUT), it was not picked up within queue_timeout (QUEUE_TIMEOUT), its output violated the contract (OUTPUT_CONTRACT_VIOLATION), or a built-in task failed.
StepAttemptUncertainstep, attempt, reasonThe outcome of a dispatched attempt is unknown: the worker stopped responding, unregistered, or the runtime restarted while a built-in task ran. The same attempt is redelivered.
RetryScheduledstep, next_attempt, delay_ms, fire_at_msThe failed attempt will be retried after delay_ms.
StepFailedstep, errorThe unit failed permanently. For a regular step, the execution starts aborting. When retries were exhausted, the message ends with (gave up after N attempts).
StepCancelledstepA unit that had not started was withdrawn because the execution is aborting.
TimerStartedstep, fire_at_msA sleep step started its durable timer.
TimerFiredstepThe timer fired. The sleep step succeeds with {}.
StaleResultIgnoredstep, token, outcomeA result arrived that no longer matters. outcome is duplicate (the unit was already resolved) or failure (a failure for an attempt that is not the one in flight). It has no effect on the execution.

Signal events

EventFieldsMeaning
SignalReceivedname, payloadA signal was sent with POST /v1/executions/{id}/signals/{name} (or orbit signal), validated against the declaration and buffered. payload has defaults applied. The event’s seq identifies the signal.
SignalAwaitStartedstep, signal, deadline_ms?An await step started waiting; the unit is WAITING. deadline_ms is set when the step has a timeout.
SignalDeliveredstep, signal_seqThe await unit took the buffered signal recorded at seq signal_seq, and succeeded with its payload as output.

An await step whose timeout passes is recorded as StepFailed with SIGNAL_TIMEOUT; one that is withdrawn during an abort as StepCancelled. A compute step is recorded as a single StepSucceeded (or StepFailed with EXPRESSION_ERROR), without StepScheduled or StepDispatched. Evaluating a query records nothing. See Signals and queries and Compute steps.

{"seq":4,"timestamp_ms":1794746714132,"type":"SignalAwaitStarted","step":"decision","signal":"review","deadline_ms":1794919514132}
{"seq":5,"timestamp_ms":1794746714669,"type":"SignalReceived","name":"review","payload":{"approved":true,"reviewer":"dana","comment":"verified by phone"}}
{"seq":6,"timestamp_ms":1794746714671,"type":"SignalDelivered","step":"decision","signal_seq":5}

The signal buffer

Every received signal is kept in the execution state, in the signals array of GET /v1/executions/{id} and orbit inspect <id>, in arrival order. A signal is buffered until an await step of the same name takes it; each step takes the oldest undelivered signal of its name, and each signal is delivered at most once. A signal that no step takes stays in the buffer without delivered_to.

FieldTypeMeaning
seqintegerSequence number of its SignalReceived event.
namestringSignal name.
payloadobjectValidated payload.
received_at_msintegerWhen it was recorded.
delivered_tostring, optionalThe unit that took it.
"signals": [
  {"seq": 5, "name": "review", "payload": {"approved": true, "reviewer": "dana", "comment": "verified by phone"},
   "received_at_ms": 1794746714669, "delivered_to": "decision"}
]

The field is absent for executions that never received a signal.

Compensation events

EventFieldsMeaning
CompensationStartedreason, stepsThe execution is COMPENSATING. steps lists the units to compensate, newest success first. reason explains the abort.
CompensationCompletednoneEvery compensation succeeded. Followed by the terminal event.
CompensationFailedstep, errorA compensation failed permanently. step is the compensated unit. Recorded once per failed compensation after the remaining compensations have run, followed by the terminal event with compensation: failed.

Compensation units use the step events above with keys such as compensate:reserve_inventory.

Handler events

EventFieldsMeaning
HandlersStartedkind, compensationCompensation is finished (or there was none) and the steps of the on <kind> handler start: kind is failure, cancel or timeout. compensation (none, completed or failed) is the outcome the terminal event will carry. Recorded only when the workflow declares a handler of that kind.

Handler steps use the step events above with keys such as on_failure:tell_customer. While they run, the execution keeps its status (RUNNING or COMPENSATING). From HandlersStarted on, the execution state (GET /v1/executions/{id}) has a handlers field, for example {"kind": "failure", "compensation": "none"}; it stays after the terminal event and is absent for executions that ran no handler. When every handler step has finished, the terminal event is recorded with the original error. A handler step that fails does not change the execution’s outcome. See Outcome handlers.

{"seq":13,"timestamp_ms":1794754619132,"type":"LimitExceeded","limit":"max_history_events","message":"the execution reached 10 history events (--max-history-events or the workflow's `limits`)"}
{"seq":15,"timestamp_ms":1794754619132,"type":"HandlersStarted","kind":"failure","compensation":"none"}
{"seq":17,"timestamp_ms":1794754619132,"type":"WorkflowFailed","error":{"code":"LIMIT_EXCEEDED","message":"the execution reached 10 history events (--max-history-events or the workflow's `limits`)","retryable":false},"compensation":"none"}

Runtime error codes

Errors that the runtime itself produces use these codes:

CodeRetryableProduced when
TIMEOUTyesAn attempt exceeded its step timeout.
QUEUE_TIMEOUTnoNo worker picked up the task within queue_timeout.
EXPRESSION_ERRORnoEvaluating with, when, for, idempotency_key, a compute step or return failed, or when did not produce a Bool.
SIGNAL_TIMEOUTnoAn await step’s timeout passed before a signal of its name arrived.
INPUT_CONTRACT_VIOLATIONnoThe evaluated step input does not match the task’s input contract.
OUTPUT_CONTRACT_VIOLATIONnoA worker’s output does not match the task’s output contract, or the workflow output does not match its output type.
ADAPTER_DISABLEDnoAn orbit.http.request step ran on a server without --enable-http-adapter.
HTTP_ERRORdependsThe HTTP adapter failed: a missing or non-HTTP URL, a connection error, or an error response.
CHILD_NOT_REGISTERED, CHILD_START_FAILED, CHILD_MISSINGnoA child workflow could not be started or found.
CHILD_CANCELLED, CHILD_TIMED_OUTnoA child workflow ended CANCELLED or TIMED_OUT. A child that ends FAILED passes its own error code up, with the child’s id in details.child_execution_id.
CANCELLEDnoA retry that was waiting when the execution started aborting, with no earlier error to report.
LIMIT_EXCEEDEDnoA for step’s list is longer than --max-fan-out (on StepFailed), or the history reached --max-history-events (on WorkflowFailed, after LimitExceeded). See Resource limits.

A worker’s own error codes are passed through unchanged. Whether a worker error is retried depends on its retryable flag and the step’s non_retryable list; see Retries and timeouts.

Example history

The execution order-1002 ran examples/order_processing.orbit. The first risk check failed with a retryable error and was retried; the worker reported the validation result twice; the payment was declined with a non-retryable error, so the reserved inventory was released:

$ orbit logs order-1002
+     0ms   #1  WorkflowCreated
+     0ms   #2  WorkflowStarted
+     1ms   #3  StepScheduled             step=validate_order attempt=1
+     1ms   #4  StepScheduled             step=analyze_risk attempt=1
+    11ms   #5  StepDispatched            step=validate_order attempt=1 worker_id=ts-1
+    14ms   #6  StepSucceeded             step=validate_order attempt=1
+    15ms   #7  StepScheduled             step=reserve_inventory attempt=1
+    17ms   #8  StaleResultIgnored        step=validate_order outcome=duplicate
+    21ms   #9  StepDispatched            step=analyze_risk attempt=1 worker_id=py-1
+    23ms  #10  StepAttemptFailed         step=analyze_risk attempt=1 error=RISK_UNAVAILABLE scoring service returned 503
+    24ms  #11  RetryScheduled            step=analyze_risk delay_ms=1000
+    33ms  #12  StepDispatched            step=reserve_inventory attempt=1 worker_id=ts-1
+    35ms  #13  StepSucceeded             step=reserve_inventory attempt=1
+    1.0s  #14  StepScheduled             step=analyze_risk attempt=2
+    1.3s  #15  StepDispatched            step=analyze_risk attempt=2 worker_id=py-1
+    1.3s  #16  StepSucceeded             step=analyze_risk attempt=2
+    1.3s  #17  StepScheduled             step=process_payment attempt=1
+    1.6s  #18  StepDispatched            step=process_payment attempt=1 worker_id=py-1
+    1.6s  #19  StepAttemptFailed         step=process_payment attempt=1 error=CARD_DECLINED card was declined
+    1.6s  #20  StepFailed                step=process_payment error=CARD_DECLINED card was declined
+    1.6s  #21  StepCancelled             step=send_notification
+    1.6s  #22  CompensationStarted       reason=step `process_payment` failed: card was declined
+    1.6s  #23  StepScheduled             step=compensate:reserve_inventory attempt=1
+    1.6s  #24  StepDispatched            step=compensate:reserve_inventory attempt=1 worker_id=ts-1
+    1.6s  #25  StepSucceeded             step=compensate:reserve_inventory attempt=1
+    1.6s  #26  CompensationCompleted
+    1.6s  #27  WorkflowFailed            error=CARD_DECLINED step `process_payment` failed: card was declined

Some of the same events as raw JSON (orbit logs order-1002 --json):

{"seq":11,"timestamp_ms":1794735367726,"type":"RetryScheduled","step":"analyze_risk","next_attempt":2,"delay_ms":1000,"fire_at_ms":1794735368726}
{"seq":17,"timestamp_ms":1794735368974,"type":"StepScheduled","step":"process_payment","attempt":1,"input":{"order_id":"1002","amount":99.5,"currency":"USD"},"idempotency_key":"charge-1002","redelivery":false}
{"seq":18,"timestamp_ms":1794735369286,"type":"StepDispatched","step":"process_payment","attempt":1,"token":"3d932a10-600d-4989-acc7-5234a3183b06","worker_id":"py-1","worker_session":"30e57815-e28e-4a2b-9c35-df96774668ae","deadline_ms":1794735399286}
{"seq":19,"timestamp_ms":1794735369289,"type":"StepAttemptFailed","step":"process_payment","attempt":1,"error":{"code":"CARD_DECLINED","message":"card was declined","retryable":false,"details":{"decline_code":"insufficient_funds"}}}
{"seq":22,"timestamp_ms":1794735369290,"type":"CompensationStarted","reason":"step `process_payment` failed: card was declined","steps":["reserve_inventory"]}
{"seq":27,"timestamp_ms":1794735369299,"type":"WorkflowFailed","error":{"code":"CARD_DECLINED","message":"step `process_payment` failed: card was declined","retryable":false,"details":{"decline_code":"insufficient_funds"}},"compensation":"completed"}

A durable timer and a workflow timeout (timeout 3s, a sleep 200ms step, and no worker for the next step):

$ orbit logs timeout-1
+     0ms   #1  WorkflowCreated
+     0ms   #2  WorkflowStarted
+     1ms   #3  TimerStarted              step=pause
+   284ms   #4  TimerFired                step=pause
+   284ms   #5  StepScheduled             step=work attempt=1
+    3.1s   #6  WorkflowTimeoutReached
+    3.1s   #7  StepCancelled             step=work
+    3.1s   #8  WorkflowTimedOut

A redelivery after a worker went silent is shown in Delivery guarantees.

Inspecting executions

CommandShows
orbit ps [--status S] [--workflow W] [--limit N]Recent executions, newest first, with status and age.
orbit status <id>The status, elapsed time, compensation outcome, error, a table of units (status, attempt, duration, error or worker) and the output.
orbit logs <id>The history as a timeline. --json prints the raw events, one per line, redacted.
orbit inspect <id>The full execution state as JSON, redacted. The same document as GET /v1/executions/{id}.
orbit workers listRegistered workers, whether they are online, and how many tasks they hold.
$ orbit status order-1001
COMPLETED order-1001  OrderProcessing@1.0.0
  elapsed: 1.6s

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  validate_order               SUCCEEDED          1      3ms  worker ts-1
  reserve_inventory            SUCCEEDED          1      3ms  worker ts-1
  analyze_risk                 SUCCEEDED          2      3ms  worker py-1
  process_payment              SUCCEEDED          1      3ms  worker py-1
  send_notification            SUCCEEDED          1      2ms  worker ts-1

  output: {"status":"completed","transaction_id":"tx-9","risk_score":0.12}

Units are listed in the order they last changed. TIME is the time from dispatch (or scheduling) to finish. Units that have not been scheduled yet are not listed. orbit run without --no-wait prints each unit transition as it happens and exits 0 only when the execution ends COMPLETED.

For automation, use the HTTP API: GET /v1/executions/{id} for state, /history for events, and /wait?timeout_ms= to block until the execution is terminal.

Cancellation

orbit cancel <id> [--reason R] or POST /v1/executions/{id}/cancel requests cancellation. The runtime records CancelRequested and aborts the execution:

  1. Units that have not started are withdrawn (StepCancelled): pending units, queued units, running sleep timers, await steps waiting for a signal, and retries waiting for their backoff. A redelivery that is queued is not withdrawn, because its first delivery may have run.
  2. Attempts in flight are awaited, because they may still succeed and then need compensating. Their workers see the tokens in the cancel list of their next heartbeat (ctx.cancelled or ctx.signal in the SDKs). Stopping is cooperative: the runtime cannot stop an external operation that is already running.
  3. A child workflow whose parent is aborting is cancelled too, with the reason parent workflow aborted.
  4. Every succeeded unit with a compensate block is compensated, one at a time, newest first. Compensations run even though the execution is aborting, with their own retry policy.
  5. The execution ends CANCELLED with compensation: none, completed or failed.

Cancellation is idempotent while the execution runs: a second request, or a request during an abort caused by a failure or timeout, records nothing and returns the current state. The original abort reason wins: a failed execution that you cancel during compensation still ends FAILED. Cancelling a terminal execution returns 409 EXECUTION_FINISHED.

$ orbit cancel order-2001 --reason "customer changed their mind"
cancellation requested; execution is CANCELLED

Timeouts

These timeouts apply, at different levels:

TimeoutDeclared onMeasuresOn expiry
timeouta stepOne attempt, from dispatch to result.The attempt fails with TIMEOUT (retryable); the step is retried if attempts remain. The worker sees the token in its heartbeat cancel list. A late success for that attempt is still accepted while the step is unresolved.
queue_timeouta stepHow long a scheduled task waits for a worker. Does not apply to redeliveries.The attempt fails with QUEUE_TIMEOUT (not retried).
timeoutan await stepThe wait for a signal, from SignalAwaitStarted.The step fails with SIGNAL_TIMEOUT (not retried), and the execution aborts.
timeoutthe workflowThe whole execution, from WorkflowCreated.WorkflowTimeoutReached, then the abort described under cancellation, ending TIMED_OUT.

The workflow timeout is checked only while the execution is RUNNING and not already aborting, so compensation is not cut short by the workflow timeout. A workflow "Name" step’s timeout bounds the child execution; when it expires, the child is cancelled.

See also

Schedules

A schedule starts executions of a workflow at times given by a cron expression or a fixed interval: a nightly report, a reconciliation every 15 minutes, a weekly cleanup. Schedules are stored in the database next to the executions, so they survive restarts and failovers, and every server of a cluster sees the same ones. This page covers creating and managing schedules, how their times are computed, the execution ids they produce, the overlap and catch-up policies, and how firing works on one server and on several.

Creating a schedule

With the CLI (orbit schedule):

$ orbit schedule create nightly-orders --workflow OrderProcessing --cron "0 2 * * *" --timezone Europe/Berlin --input '{"order_id": "N-1", "customer_id": "cust-1", "amount": 120.0, "items": [{"sku": "BOOK-1", "quantity": 1}]}' --description "Nightly reconciliation order"
active nightly-orders  cron "0 2 * * *" Europe/Berlin
  workflow:   OrderProcessing (latest version)
  input:      {"order_id":"N-1","customer_id":"cust-1","amount":120.0,"items":[{"sku":"BOOK-1","quantity":1}]}
  overlap:    skip   catchup: skip_missed
  description: Nightly reconciliation order
  fired:      0   skipped: 0
  last fire:  -
  last run:   -
  next fires:
    2026-11-16T01:00:00Z
    2026-11-17T01:00:00Z
    2026-11-18T01:00:00Z
    2026-11-19T01:00:00Z
    2026-11-20T01:00:00Z

Or with the HTTP API (POST /v1/schedules):

$ curl -s -X POST localhost:7233/v1/schedules -H 'content-type: application/json' -d '{"schedule_id": "orders-every-10s", "workflow": "OrderProcessing", "interval": "10s", "input": {"order_id": "R-1", "customer_id": "cust-1", "amount": 120.0, "items": [{"sku": "BOOK-1", "quantity": 1}]}}'
{"schedule_id":"orders-every-10s","workflow":"OrderProcessing","input":{"order_id":"R-1","customer_id":"cust-1","amount":120.0,"items":[{"sku":"BOOK-1","quantity":1}]},"interval":"10s","overlap":"skip","catchup":"skip_missed","paused":false,"next_fire_at":"2026-11-15T18:51:10Z","next_fire_times":["2026-11-15T18:51:10Z","2026-11-15T18:51:20Z","2026-11-15T18:51:30Z","2026-11-15T18:51:40Z","2026-11-15T18:51:50Z"],"last_fire_at":null,"last_execution_id":null,"fired":0,"skipped":0,"last_error":null,"created_at":"2026-11-15T18:51:00.347Z","updated_at":"2026-11-15T18:51:00.347Z"}

The workflow must be registered, and the input is validated against its input type (defaults applied) when the schedule is created or changed, and again at each start. Without version, each start uses the latest registered version at that moment, so deploying a new version changes what the schedule starts; give version to pin one. The first occurrence is the first one after the schedule was created.

A schedule has these settings:

SettingDefaultMeaning
workflow, version, inputlatest version, {}What to start.
cron or intervalrequired, exactly oneWhen to start it.
timezoneUTCThe time zone of a cron expression.
overlapskipWhat to do when the previous execution is still running.
catchupskip_missedWhat to do with occurrences missed while no server could fire them.
start_at, end_atnoneNo occurrence before start_at or after end_at.
jitternoneDelay each fire by a deterministic amount below this duration.
pausedfalseA paused schedule does not fire.
descriptionnoneFree text.

Timing

Cron expressions

A cron expression has five fields, separated by spaces:

FieldValuesNames
minute0-59
hour0-23
day of month1-31
month1-12JAN-DEC
day of week0-7 (0 and 7 are Sunday)SUN-SAT

Each field is *, a value, a range a-b, a step */n, a-b/n or a/n (from a to the end of the range, every n), or a comma-separated list of these. Names are case-insensitive. As in Vixie cron, when both the day of month and the day of week are restricted (neither starts with *), a day matches if either matches: 0 0 13 * 5 runs on the 13th and on every Friday. @hourly, @daily (or @midnight), @weekly, @monthly and @yearly (or @annually) stand for 0 * * * *, 0 0 * * *, 0 0 * * 0, 0 0 1 * * and 0 0 1 1 *. Seconds and the Quartz extensions (L, W, #, ?) are not supported.

ExpressionFires
0 2 * * *Every day at 02:00.
*/15 * * * *Every 15 minutes, on the quarter hour.
30 9 * * MON-FRIAt 09:30 on weekdays.
0 0 1 JAN,JUL *At midnight on 1 January and 1 July.
5/20 * * * *At minutes 5, 25 and 45 of every hour.
0 0 29 2 *At midnight on 29 February.

An invalid expression is rejected with 400 INVALID_SCHEDULE, for example cron `0 25 * * *`: `25` is out of range for the hour field (0-23).

Time zones

A cron expression is evaluated in UTC unless the schedule has an IANA timezone such as Europe/Berlin or America/New_York. The time-zone database is compiled into the orbit binary (it does not depend on the host’s /usr/share/zoneinfo), so every server computes the same times; upgrading ORBIT updates it. Times in the API are always shown in UTC: the nightly-orders schedule above fires at 02:00 in Berlin, which is 01:00:00Z in winter and 00:00:00Z in summer.

Around daylight-saving changes:

  • A local time that does not exist (02:30 on the night the clocks go forward) fires once, at the corresponding time after the change (03:30 local).
  • A local time that occurs twice (02:30 on the night the clocks go back) fires once, at its first occurrence.

Intervals

interval is a duration: whole numbers with the units ms, s, m, h and d, which may be combined (1h30m). The minimum is 1s. Occurrences are aligned to start_at when it is set, and to the Unix epoch otherwise: every 15m fires at :00, :15, :30 and :45 of every hour, whenever the schedule was created. Intervals are absolute and do not take a timezone.

Start, end and jitter

start_at and end_at are RFC 3339 times. There is no occurrence before start_at (an interval schedule’s first occurrence is start_at itself) or after end_at; after the last one, the schedule is listed as ended, with next_fire_at: null, and stays until you delete it.

jitter delays each fire by an amount between zero and the jitter, derived from a hash of the schedule id and the occurrence, so that many schedules on the same minute do not all start at once. Every server computes the same delay. next_fire_at includes it; next_fire_times and the execution id use the nominal time:

$ orbit schedule list
SCHEDULE                 WORKFLOW             TIMING                           STATE    NEXT FIRE              LAST EXECUTION
hourly-check             OrderProcessing      every 1h                         active   2026-11-15T19:00:12.359Z -
nightly-orders           OrderProcessing      cron "0 2 * * *" Europe/Berlin   active   2026-11-16T01:00:00Z   -

(hourly-check has --jitter 30s.)

Execution ids

Each occurrence starts an execution with the id <schedule id>:<occurrence in RFC 3339, UTC>:

$ orbit ps --workflow OrderProcessing
EXECUTION                              WORKFLOW                 VERSION    STATUS        AGE
orders-every-10s:2026-11-15T18:51:30Z  OrderProcessing          1.0.0      COMPLETED     259ms
orders-every-10s:2026-11-15T18:51:20Z  OrderProcessing          1.0.0      COMPLETED     10.3s
orders-every-10s:2026-11-15T18:51:10Z  OrderProcessing          1.0.0      COMPLETED     20.3s

Starting an execution with an id that exists is a no-op (Starting an execution), so an occurrence is never started twice, whatever happens to the servers. The execution records the schedule in schedule_id (and in its WorkflowCreated event); orbit status shows it:

$ orbit status nightly-orders:manual:2026-11-15T18:52:22.296Z
COMPLETED nightly-orders:manual:2026-11-15T18:52:22.296Z  OrderProcessing@1.0.0
  schedule: nightly-orders
  elapsed: 33ms

A manual trigger uses <schedule id>:manual:<now, with milliseconds>. Schedule ids are therefore limited to letters, digits, -, _ and ., and to 100 characters.

Overlap policy

overlap decides what happens when an occurrence is due while the execution the schedule started last is still running (RUNNING or COMPENSATING):

PolicyBehavior
skip (default)Start nothing; the occurrence counts as skipped. The next occurrence is checked again.
allowStart anyway. Executions of the schedule may run at the same time.
cancel_previousCancel the previous execution (with the reason schedule `<id>`: replaced by the next occurrence (overlap cancel_previous); its compensation and on cancel handler run as for any cancellation) and start the new one at once, without waiting for the previous one to finish.

The policy looks only at the latest execution the schedule started (last_execution_id), including a manual trigger. With allow, earlier executions may still be running too. An execution that continues as new counts as running until its final run ends.

Catch-up after downtime

Occurrences are fired by running servers. When no server could fire for a while (all servers were down, or the database was unreachable), the next server to look finds one or more occurrences in the past. catchup decides what happens to them:

PolicyBehavior
skip_missed (default)Skip them. Only an occurrence at most 60 seconds late is still started (the most recent one, if several are that recent); everything older counts as skipped.
run_onceStart one execution for the most recent missed occurrence, however late; the older ones count as skipped.

Either way, firing then continues with the first occurrence in the future, and the schedule’s skipped count and orbit_schedules_skipped_total{reason="missed"} count the skipped occurrences. A run_once start uses the id of the occurrence it stands for, for example s:2026-11-15T00:30:00Z for a 10-minute schedule whose server was down from 00:00 to 00:35.

Pausing is not downtime: a resumed schedule continues with its next occurrence after now, and starts nothing for the occurrences that passed while it was paused.

Pause, resume, trigger, change, delete

ActionCLIAPI
Pauseorbit schedule pause <id>POST /v1/schedules/{id}/pause
Resumeorbit schedule resume <id>POST /v1/schedules/{id}/resume
Start an execution noworbit schedule trigger <id>POST /v1/schedules/{id}/trigger
Change timing, input or policiesorbit schedule update <id> ...PATCH /v1/schedules/{id}
Deleteorbit schedule delete <id>DELETE /v1/schedules/{id}

A trigger starts an execution with the schedule’s workflow, version and input, also while the schedule is paused, and applies the overlap policy (with skip it starts nothing while the previous execution runs). It does not change the schedule’s timing.

$ orbit schedule trigger nightly-orders
started execution nightly-orders:manual:2026-11-15T18:52:22.296Z
$ orbit schedule trigger nightly-orders
not started: the previous execution of nightly-orders is still running (overlap: skip)

The web UI lists schedules and can create, pause, resume, trigger and delete them.

A change recomputes the next occurrence from now and keeps the counters and the last execution. Deleting a schedule does not affect the executions it started.

How firing works

Every server that can write (a single server, a cluster leader, every server in shared mode) looks for due schedules from its tick loop, about once a second, and immediately after a schedule changed on it. A schedule therefore fires up to about a second after its time (plus its jitter). For each due occurrence the server:

  1. Claims it with a compare-and-set on the schedule’s row: UPDATE orbit_schedules SET claimed_by = <this server>, claimed_until_ms = <now + 30 s> WHERE schedule_id = ... AND next_fire_ms = <the occurrence> AND paused = 0 AND <no live claim>. Of several servers that see the same occurrence, one update matches.
  2. Decides with the catch-up and overlap policies, and starts the execution (or cancels the previous one first, with cancel_previous).
  3. Advances the schedule with a second compare-and-set: next_fire_ms moves to the next occurrence, the counters and last_execution_id are updated and the claim is released, but only if the row still holds this server’s claim on this occurrence. If the schedule was changed, paused or deleted meanwhile, the change wins and the server logs it.

If the start fails (for example 429 RESOURCE_EXHAUSTED under --max-active-executions, or the workflow was not found), the schedule is not advanced: the claim is released with a retry time 5 seconds later, the error is shown in last_error, and the same occurrence is tried again. An occurrence that stays blocked for more than 60 seconds is subject to the catch-up policy like any other late occurrence.

If a server dies after claiming an occurrence and before advancing it, the claim blocks the occurrence for at most 30 seconds. A single server or a new cluster leader drops all claims when it starts, because they can only come from a process that is gone; in shared mode, the claim expires and another server takes the occurrence. In both cases the deterministic execution id makes the repeated start a no-op if the execution was already created.

The Schedules end-to-end test kills a server with SIGKILL while a 1-second schedule runs, restarts it, and checks that every occurrence between the first and the last execution was either started exactly once or counted as skipped.

On several servers, see Running several servers (active/standby: only the leader fires) and shared mode (any server fires, one per occurrence).

Access control

With client identities, managing a schedule needs the schedule action for the schedule’s workflow: creating, reading, changing, pausing, resuming, triggering and deleting it. GET /v1/schedules lists only the schedules of workflows the client may schedule. The action grants nothing on executions: reading or cancelling the executions a schedule started needs read or cancel as usual. Worker credentials cannot use /v1/schedules.

Metrics

MetricMeaning
orbit_schedules_fired_totalExecutions this server started for schedules, including triggers.
orbit_schedules_skipped_total{reason="overlap"}Starts the skip overlap policy prevented.
orbit_schedules_skipped_total{reason="missed"}Missed occurrences the catch-up policy did not start.

The schedule’s own fired and skipped counts (GET /v1/schedules/{id}) are stored in the database and cover every server; the metrics are per server and start from zero when it starts. See Observability.

Limitations

  • Schedules are managed through the API and the CLI only. There is no declarative schedule form in .orbit files, and orbit deploy does not create schedules.
  • Firing has a granularity of about one second; occurrences more often than every second are not possible (interval is at least 1s).
  • Due times are compared with the clock of the server that fires. Keep server clocks synchronized; in shared mode a server whose clock runs ahead fires early.
  • In shared mode, an occurrence claimed by a server that died waits up to 30 seconds for the claim to expire.
  • A schedule stores at most one pending occurrence. With allow, a schedule whose executions take longer than its interval accumulates running executions; skip and cancel_previous keep at most one running.

See also

Delivery guarantees

This page states what the ORBIT runtime guarantees about workflow state and task delivery, and what it does not. In short: committed history is durable and decides everything; tasks are delivered at least once, with a stable idempotency key; an attempt whose outcome is unknown is redelivered, never assumed to have failed; and a crash at any point loses no committed work. External side effects are not executed exactly once; your workers make them idempotent with the key the runtime provides.

Durability: events before effects

Every state change is an event, and the engine follows one rule: write the events, then act on them.

  • Events are appended in a database transaction, together with the update of the execution projection. Memory is updated only after the transaction commits. If the write fails, the in-memory state is unchanged and the request fails with STORAGE_ERROR.
  • A task is handed to a worker only after its StepDispatched event is committed. A result is acknowledged to the worker ({"accepted": true}) only after its StepSucceeded or StepAttemptFailed event is committed.
  • Traces are exported only after the events they describe are durable.
  • SQLite runs in WAL mode with synchronous=FULL, so a committed event survives power loss. PostgreSQL commits each batch in one transaction.

As a consequence, whatever a client or worker was told is in the history, and whatever is not in the history did not happen as far as the runtime is concerned.

At-least-once delivery

ORBIT delivers each task at least once. A task can be delivered more than once when:

  • a worker received it but went silent before reporting (process crash, network partition, a pause longer than the session timeout);
  • a worker unregistered while holding it;
  • the runtime restarted and the worker did not report back within one session timeout;
  • an attempt timed out and was retried while the first attempt was still running somewhere.

ORBIT does not promise exactly-once execution of external side effects. No orchestrator can: between an external system performing an effect and the result reaching the runtime, any failure leaves the outcome unknown.

Idempotency keys

Every task carries an idempotency_key:

  • The default is <execution_id>/<unit>, for example order-1002/validate_order or order-1002/compensate:reserve_inventory.
  • Set your own with idempotency_key: on the step or the compensate block, for example "charge-" + input.order_id. The expression is evaluated once, when the unit is first scheduled.
  • The key is identical across retries (attempt increases) and redeliveries (redelivery: true, same attempt).

Pass the key to the external system (most payment and messaging APIs accept one), or record it in your own store and skip work already done. Use a business key when the same effect must not happen twice even across executions, for example two executions started for the same order. See Idempotency.

Uncertain attempts and redelivery

A dispatched attempt becomes uncertain (StepAttemptUncertain) when:

CauseReason recorded
Its worker session has not polled or heartbeated for longer than the session timeout.worker `flaky-1` stopped responding
Its worker unregistered while holding it.worker unregistered
The runtime restarted while a built-in task (the HTTP adapter) was running it.runtime restarted while the built-in task was running

An uncertain attempt is never treated as a failure. The same attempt is scheduled again (StepScheduled with redelivery: true, the same attempt number, input and key) and delivered to the next matching worker. A redelivery does not use up a retry, queue_timeout does not apply to it, and an abort does not withdraw it, because the first delivery may have run.

Here flaky-1 received the task, then went silent; with --session-timeout 5, the attempt was redelivered to flaky-2:

$ orbit logs u-1
+     0ms   #1  WorkflowCreated
+     0ms   #2  WorkflowStarted
+     1ms   #3  StepScheduled             step=work attempt=1
+    39ms   #4  StepDispatched            step=work attempt=1 worker_id=flaky-1
+    5.1s   #5  StepAttemptUncertain      step=work attempt=1 reason=worker `flaky-1` stopped responding
+    5.1s   #6  StepScheduled             step=work attempt=1 redelivery=true
+    6.6s   #7  StepDispatched            step=work attempt=1 worker_id=flaky-2
+    6.7s   #8  StepSucceeded             step=work attempt=1
+    6.7s   #9  WorkflowCompleted

The task flaky-2 received:

{"token":"5503f137-059e-455a-92c1-e65fa3690962","execution_id":"u-1","workflow":"Plain","workflow_version":"1.0.0",
 "step_id":"work","task_name":"demo.work","contract_version":"1.0.0","input":{"n":1},
 "idempotency_key":"u-1/work","attempt":1,"redelivery":true}

Reconciliation

Results are reconciled against the history:

  • A success is accepted for any token issued for an unresolved unit. The work really happened, so it counts, even if it comes from an attempt that timed out or was considered lost. In the example above, flaky-1 came back and reported success with its original token before flaky-2 finished; the runtime accepted it (event #8), and flaky-2 found its own token in the cancel list of its next heartbeat.
  • A failure is accepted only for the attempt currently in flight. A failure from an older attempt is recorded as StaleResultIgnored with outcome: failure and answered {"accepted": false, "reason": "stale attempt"}.
  • A result for a resolved unit has no effect. It is recorded as StaleResultIgnored with outcome: duplicate and answered {"accepted": false, "reason": "duplicate result; step already resolved"}.
  • A result for a finished execution, or an unknown token, is answered {"accepted": false, "reason": "unknown token (execution finished or token invalid)"} and not recorded.
  • Output is validated. A success whose output does not match the task’s output contract fails the attempt with OUTPUT_CONTRACT_VIOLATION and is answered {"accepted": false, "reason": "output violates the task contract"}.

Workers treat accepted: false as final and do not retry it. They retry result delivery on connection errors and 5xx responses until the runtime answers; the official SDKs do this.

Crash recovery

The runtime can be killed at any moment, including with SIGKILL or a power loss. On the next start it:

  1. loads the workflow definitions;
  2. loads every execution that is not terminal and replays its history;
  3. re-indexes the tokens of in-flight attempts, so results for them are accepted;
  4. runs the decider, which fires overdue timers and retries, applies timeouts that passed during the outage and schedules ready steps;
  5. waits one session timeout for workers to reconnect and report in-flight results; the SDKs keep retrying result delivery during the outage. After that window, attempts nobody reported are redelivered.

Workers whose session was lost get 404 UNKNOWN_SESSION on their next call, register again and continue. Tokens remain valid across sessions and restarts.

Child workflows are started with a deterministic id (<parent>.<unit>) and reconciled with their parents on every tick, so a crash between starting a child and recording it neither duplicates nor loses the child.

The end-to-end test test_z_crash_recovery kills the runtime with SIGKILL while a payment is being charged, restarts it, and checks that the execution completes with exactly one recorded success for the charge.

Immutable definitions and versioning

  • A definition is identified by (name, version) and carries a SHA-256 definition_hash of its semantic content. Source locations are excluded, so reformatting a file does not change the hash.
  • Registering identical content is a no-op. Registering different content under an existing name and version is refused with 409 DEFINITION_CONFLICT; bump the version instead.
  • Every execution is pinned to the hash it started with. Deploying a new version never changes running executions, and the old version stays registered for them.
  • A parent definition pins the exact hash of each child workflow it starts; the child must be registered first (CHILD_NOT_REGISTERED otherwise). orbit deploy registers children before parents.
  • Starting an execution with a client-chosen execution_id is idempotent: a repeated start returns the existing execution (created: false) instead of creating a duplicate. The same id with a different definition is refused with 409 EXECUTION_EXISTS.

See Versioning.

Deterministic decisions

The decider is a pure function of the history, the definition and the current time. Expressions cannot read the clock or perform I/O. Inputs and idempotency keys are evaluated once and stored in the history, and for lists are evaluated once and stored in StepFanOut, so retries, redeliveries and recovery always use the same values.

What is not guaranteed

  • Exactly-once side effects. Use idempotency keys.
  • Stopping external work. Cancellation and timeouts are cooperative. The worker is told through the heartbeat cancel list, but the runtime cannot stop an operation that is already running in another system.
  • Ordering between independent steps. Steps without a dependency between them may run in any order or concurrently. Among queued tasks, a worker receives the oldest matching one, but this is not a fairness guarantee across executions.
  • Timing precision. Timers, retries and timeouts are checked every 100 ms and fire after their due time, not exactly at it. During an outage they fire when the runtime is back.
  • High availability. A single server is a single node: while it is down, no steps are scheduled and no results are accepted; workers retry. With PostgreSQL, run several servers with --cluster so a standby takes over within seconds; one leader still does all the work. With --cluster-mode shared every server works, and the others take over a failed server’s executions. See Running several servers.
  • Exactly-once compensation. Compensations are tasks with the same at-least-once delivery, and need idempotent workers too.
  • Telemetry delivery. Spans are exported on a best-effort basis; they are dropped if the collector is unreachable or the queue is full. Metrics are in-memory counters that reset when the runtime restarts.
  • Capability-based permissions beyond task patterns, and per-execution resource limits. These are not implemented.

Shared mode

With --cluster --cluster-mode shared, several servers execute workflows at once. See Shared mode for how it works.

What stays the same:

  • One writer per execution. Each running execution has one owner. Only the owner appends to its history, and every append checks the owner’s epoch in the same transaction, so a server that lost an execution to another one cannot write to it (STORAGE_ERROR starting with fenced:).
  • Events before effects. History, projection, queue rows and consumed inbox entries are written in one transaction.
  • At-least-once delivery with stable idempotency keys, uncertain attempts redelivered and never assumed failed, late results accepted for unresolved steps.
  • Commands are applied once and in order. A signal, cancellation, result or dispatch that reaches a server that does not own the execution goes to the owner’s inbox, and the owner deletes the entry in the same transaction as the events it causes. A server that dies after applying an entry has also deleted it.
  • Deterministic decisions, immutable definitions, idempotent starts.

What changes:

TopicOne serverShared mode
Task handed outAfter StepDispatched is committed.After the claim of the queue row and its inbox entry are committed. The owner records StepDispatched a moment later; a result that arrives first waits behind it in the inbox.
Result acknowledgedAfter StepSucceeded or StepAttemptFailed is committed; accepted reflects the outcome.The same on the owner. On another server: once the result is stored in the owner’s inbox, with {"accepted": true}; the owner records a contract violation or a duplicate in history as usual.
Signal seqAlways set.null when the owner has not recorded the signal within 10 seconds; the signal is still recorded once.
Extra redelivery causeA server that handed out a task died, and the result did not arrive within --node-timeout plus --session-timeout: the worker may never have received the task. The original attempt’s late result is still accepted.
Lost worker detectedOne --session-timeout after its last contact.About one second later: contact is written to the database every 250 ms and read by owners every 500 ms.
Timers, retries, timeoutsFired by the server’s clock.Fired by the owner’s clock, after a takeover by the new owner’s. Keep server clocks synchronized (NTP).
--max-active-executionsRunning executions.Running executions this server owns. Each server applies its own limit.
MetricsThe server’s.Per server. Sum them across servers.

See also

Storage

The runtime keeps workflow definitions, execution history and the worker registry in one database: a SQLite file by default, or PostgreSQL. Both backends use the same schema and give the same guarantees. This page explains how to choose and configure a backend, how TLS to PostgreSQL works, what the schema contains, how to back it up, and why one database belongs to one server.

Choosing a backend

You select the backend with --db (or ORBIT_DB). A value that starts with postgres:// or postgresql:// selects PostgreSQL; anything else is a SQLite file path.

SQLitePostgreSQL
--dba file path, default orbit.dbpostgres://user:pass@host:5432/db
Setupnone; the file is created on first startan existing database and a user that can create tables
DurabilityWAL mode, synchronous=FULLone transaction per batch of events
Connectionin-processone connection on a dedicated thread, reconnected automatically
Good fordevelopment, single hosts, moderate volumemanaged databases, existing backup and replication tooling, larger histories

Both are single-writer from ORBIT’s point of view: the runtime serializes all state changes through one engine. PostgreSQL does not make the runtime horizontally scalable; see Single node.

SQLite

orbit server --db /var/lib/orbit/orbit.db
  • The file and its tables are created on first start. The directory must exist and be writable.
  • The database runs in WAL mode, so you will see orbit.db-wal and orbit.db-shm next to orbit.db. Keep the three files together.
  • synchronous=FULL makes every commit durable before it is acknowledged, at the cost of an fsync per batch. Put the file on a local disk with reliable fsync; network file systems are not supported by SQLite’s locking.
  • A relative path is resolved against the server’s working directory. In the container image the default is /data/orbit.db.

PostgreSQL

orbit server --db 'postgres://orbit:secret@db.internal:5432/orbit?sslmode=require'
  • The URL follows the libpq connection URI format: postgres://user:password@host:port/database?param=value. Percent-encode special characters in the password.
  • The schema is created on first start with CREATE TABLE IF NOT EXISTS, so the user needs CREATE on the database’s default schema. Use a dedicated database or schema for ORBIT.
  • All statements run on one dedicated connection thread. If the connection drops, it is re-established before the next statement (reconnected to PostgreSQL in the log). A statement issued while the database is unreachable fails with STORAGE_ERROR, and the engine keeps its previous in-memory state.
  • The password is redacted in logs and error messages: postgres://orbit:***@db.internal:5432/orbit?sslmode=require.

TLS

TLS follows the URL’s sslmode parameter:

sslmodeBehavior
disableNever use TLS.
prefer (default)Use TLS when the server offers it.
requireInsist on TLS; fail if the server does not offer it.

When TLS is used, the server certificate is always verified, against the public web PKI roots plus every certificate in the PEM bundle named by ORBIT_DB_CA_CERT:

ORBIT_DB_CA_CERT=/etc/orbit/rds-ca-bundle.pem \
orbit server --db 'postgres://orbit:secret@mydb.example.rds.amazonaws.com:5432/orbit?sslmode=require'

Unlike libpq’s prefer, ORBIT never accepts a certificate it cannot verify. For a local server with a self-signed certificate, either provide its CA through ORBIT_DB_CA_CERT or use sslmode=disable. For production, use sslmode=require.

Schema

The schema is the same on both backends (BIGINT columns are INTEGER in SQLite). Schema version 1:

TableKeyContents
schema_versionOne row: the schema version (1).
workflow_definitions(name, version)definition_hash, the full IR as JSON in ir, and created_at_ms. Rows are never updated.
workflow_executionsexecution_idProjection for listing: workflow, version, definition_hash, status, created_at_ms, updated_at_ms, last_seq. Indexed by status.
execution_events(execution_id, seq)The authoritative history of each execution’s current run: event_type, step (or null), payload (the event as JSON), timestamp_ms. Append-only, except that a run’s events move to execution_run_events when it ends with continue_as_new.
execution_run_events(execution_id, run, seq)The histories of runs that ended with continue_as_new, with the same columns as execution_events plus run. Rows are never updated.
orbit_schedulesschedule_idSchedules: see Schedule table.
worker_registryworker_idLatest registration of each worker: runtime, sdk, contracts (JSON), registered_at_ms, last_seen_ms.
orbit_clusteridPostgreSQL, cluster mode only. See Cluster table.
orbit_nodes, orbit_owners, orbit_tasks, orbit_inbox, orbit_sessionsPostgreSQL, shared mode only. See Shared-mode tables.

Each batch of events and the matching workflow_executions update are written in one transaction. So is a continue-as-new: the finished run’s last events, the move of its history to execution_run_events, the next run’s first events and the projection update commit together. Worker sessions, tokens in flight and metrics are kept in memory only; sessions and tokens are rebuilt from the history on startup, and metrics start from zero. Shared mode is the exception: it keeps worker sessions and the task queue in the shared-mode tables, so that every server sees them.

The tables are an implementation detail. Read execution state through the HTTP API, which applies the same fold the engine uses. Do not write to the tables: a modified history no longer matches what the engine decided.

The tables are created with CREATE TABLE IF NOT EXISTS on every start, so a database created by an earlier version gains execution_run_events and orbit_schedules on the first start of this version, without a migration step and without changing existing rows. The schema version stays 1.

Schedule table

orbit_schedules holds one row per schedule. Every server that can write reads it about once a second for due occurrences; see Schedules: how firing works.

ColumnTypeMeaning
schedule_idTEXT, primary keyThe schedule id.
specTEXTThe specification as JSON: workflow, version, input, cron or interval, time zone, policies, start and end, jitter, description.
pausedBIGINT1 while paused.
next_fire_msBIGINTThe next occurrence (nominal time), or null when the schedule has ended. Claims and advances compare it (compare-and-set).
due_msBIGINTWhen that occurrence fires: next_fire_ms plus its jitter. Indexed.
last_fire_msBIGINTThe latest occurrence handled.
last_execution_idTEXTThe latest execution started, including triggers.
fired_count, skipped_countBIGINTCounters shown as fired and skipped.
last_errorTEXTWhy the latest start failed, if it did.
claimed_byTEXTThe engine instance that holds a claim on next_fire_ms, or null.
claimed_until_msBIGINTWhen that claim expires (30 seconds after it was taken), or when a failed start is retried.
created_at_ms, updated_at_msBIGINTCreation and last change.

Times are milliseconds since the Unix epoch, from the clock of the server that wrote them.

Cluster table

Servers started with --cluster create one more table on first start, idempotently, under an advisory lock. Single-node servers neither create nor read it, and the schema version stays 1.

ColumnTypeMeaning
idINT, primary keyAlways 1: the table has one row.
termBIGINTLeadership term, incremented by every newly elected leader. Every history append of the leader checks it in the same transaction (fencing).
leader_nodeTEXT--node-id of the latest leader.
leader_addrTEXT--advertise-addr of the latest leader, or null.
since_msBIGINTWhen the latest leader was elected.

The leader also holds a session-level advisory lock on its own election connection (application_name orbit-cluster:<node id>). See Running several servers.

Shared-mode tables

Servers started with --cluster --cluster-mode shared create five more tables on first start, idempotently, under an advisory lock; they also create the base schema under an advisory lock, because shared-mode servers usually start together. Other servers neither create nor read these tables, and the schema version stays 1. Times ending in _ms are milliseconds since the Unix epoch; heartbeat_ms and last_seen_ms use the database clock, the others the clock of the server that wrote them.

orbit_nodes: one row per server.

ColumnTypeMeaning
node_idTEXT, primary key--node-id.
incarnationTEXTRandom id of the running process; a restarted server gets a new one.
addrTEXT--advertise-addr, or null.
heartbeat_msBIGINTLast heartbeat (every second). 0 after a graceful shutdown.
started_msBIGINTWhen the process registered.

orbit_owners: one row per running execution.

ColumnTypeMeaning
execution_idTEXT, primary keyThe execution.
root_idTEXTThe top-level execution of its tree: child workflows share their root’s owner and move with it.
node_idTEXTThe owner. Indexed.
epochBIGINT1 when the execution starts, incremented by every takeover. Every history append checks it in the same transaction (fencing).
since_msBIGINTWhen the current owner got it.

The row is deleted, in the same transaction, when the execution reaches a terminal status.

orbit_tasks: the shared task queue, one row per offer of a worker task.

ColumnTypeMeaning
idBIGSERIAL, primary key
execution_id, unit_key, attempt, offerThe unit and which offer of it this is (offer is the unit’s dispatch count when it was scheduled). Unique together.
task_name, contract_version, languageTEXTWhat a worker must announce to get it (language from using, lowercase, or null).
payloadTEXTThe task assignment as JSON, without token and deadline.
enqueued_msBIGINTWhen the unit was scheduled. Workers get the oldest matching row first.
stateTEXTqueued, claimed, done (the result arrived), cancelled (the owner no longer needs it) or cancel_sent (the worker was told).
token, claimed_by, claimed_inc, claimed_session, claimed_worker, claimed_msThe claim: token handed out, server and process that handed it out, worker session and worker. Indexed by token and claimed_session.
updated_msBIGINTLast change.

orbit_inbox: commands for an execution’s owner, applied in seq order.

ColumnTypeMeaning
seqBIGSERIAL, primary keyOrder of arrival.
execution_idTEXTThe execution. Indexed with seq.
kindTEXTdispatched, complete, fail, uncertain, signal or cancel.
tokenTEXTThe task token, for task-related kinds.
payloadTEXTThe command as JSON.
created_msBIGINTWhen it was written.
done_ms, resultFor signal and cancel: when the owner applied it and its reply, read by the server that is waiting to answer the client.

The owner deletes an entry (or, for signal and cancel, marks it done) in the same transaction as the events it caused.

orbit_sessions: worker sessions, usable through any server.

ColumnTypeMeaning
session_idTEXT, primary keyThe worker_session_id returned by registration.
worker_id, runtime, contractsTEXTFrom the registration (contracts as JSON). Indexed by worker_id.
node_idTEXTThe server that registered it.
registered_msBIGINTWhen it registered.
last_seen_msBIGINTLast poll, heartbeat or result, written by the servers in batches every 250 ms.

Every server deletes, every 30 seconds: sessions silent for four session timeouts, finished queue rows and inbox entries of executions that have no owner any more (after one minute, or four session timeouts if longer), and rows of servers that have been gone for an hour and own nothing.

Retention

The runtime never deletes history. Finished executions stay queryable through orbit status, orbit logs and the API indefinitely, and the database grows with the number of events. Plan disk space accordingly; a typical step produces three to five events. There is no built-in archival or pruning in this version. Runs that ended with continue_as_new are kept in execution_run_events: continue-as-new bounds what one run replays and holds in memory, not what the database stores.

Backups

SQLite

Copy a consistent snapshot with the SQLite online backup, which is safe while the server runs:

sqlite3 /var/lib/orbit/orbit.db ".backup '/backups/orbit-$(date +%F).db'"

Alternatively, stop the server and copy orbit.db, orbit.db-wal and orbit.db-shm together. Do not copy only orbit.db while the server is running: recent commits may still be in the WAL file.

PostgreSQL

Use your usual tooling: pg_dump, continuous archiving with point-in-time recovery, or your provider’s snapshots.

pg_dump --format=custom --file=/backups/orbit.dump 'postgres://orbit:secret@db.internal:5432/orbit'

Restoring

Stop the server, restore the database, and start the server. It recovers every non-terminal execution from the restored history. Work that happened after the backup is lost from ORBIT’s point of view: executions started later do not exist, and steps that completed later run again. Idempotency keys protect external systems from duplicate effects in this case too.

Single node

Without --cluster, one database belongs to one running server. The engine keeps the active executions, the worker sessions and the in-flight tokens in memory and assumes it is the only writer. Two servers on the same database would each dispatch the same tasks and append conflicting events. Nothing in the database prevents this, so make sure your deployment cannot start a second instance: one systemd unit, one container with a restart policy, or a Kubernetes Deployment with replicas: 1 and the Recreate strategy.

For availability, rely on fast restarts and recovery, or, with PostgreSQL, run several servers in cluster mode: they elect one leader, which is the only one that writes, and fence a deposed leader so it cannot write. In shared mode every server writes, but each execution has one owner, and every append is fenced by the execution’s ownership epoch. See Running several servers. You can also use the database’s own replication and failover; servers reconnect to the new primary.

See also

Running several servers

With --cluster, you can run two or more orbit server processes against the same PostgreSQL database. One of them, the leader, runs the engine and serves every route. The others are standbys: they serve read-only routes from the database and take over within about a second if the leader stops. This page explains the architecture, how the leader is elected and fenced, what happens during a failover, how to put a load balancer in front of the servers, and the limits of the design.

This default mode, --cluster-mode standby, is for availability, not for throughput: only one server executes workflows at a time. With --cluster-mode shared, every server is active and they share the work; see Shared mode.

Architecture

        clients, CLI, workers
                 │  one address
                 ▼
        load balancer  ── health check: GET /v1/ready (200 only on the leader)
          │          │
          ▼          ▼
   ┌───────────┐  ┌───────────┐
   │ orbit-0   │  │ orbit-1   │
   │ LEADER    │  │ STANDBY   │   standby: reads from the database,
   │ engine    │  │ read-only │   writes answer 503 NOT_LEADER
   └─────┬─────┘  └─────┬─────┘
         │ store + election session    │ store + election session
         ▼                             ▼
   ┌──────────────────────────────────────────┐
   │ PostgreSQL                                │
   │  execution_events, workflow_executions …  │
   │  orbit_cluster (term, leader)             │
   │  advisory lock: held by the leader        │
   └──────────────────────────────────────────┘

Each server keeps two connections to PostgreSQL: the store connection that every single-node server has, and a separate election session that holds the leader lock.

RoleEngineRoutes served/v1/ready
leaderFull engine, recovered from history when elected. Ticks timers, dispatches tasks, accepts results.All200
standbyRead-only engine: it loads no executions into memory and cannot write.GET routes, read from the database. Every other route answers 503 NOT_LEADER.503
singleA server without --cluster.All200

Start a cluster

Point every server at the same database and give each one a node id and the address other servers should hand out for it:

orbit server --cluster --db "$ORBIT_DB" --addr 0.0.0.0:7233 --token "$ORBIT_TOKEN" \
  --node-id orbit-0 --advertise-addr http://orbit-0.orbit.internal:7233
orbit server --cluster --db "$ORBIT_DB" --addr 0.0.0.0:7233 --token "$ORBIT_TOKEN" \
  --node-id orbit-1 --advertise-addr http://orbit-1.orbit.internal:7233
OptionEnvironmentDefaultMeaning
--clusterORBIT_CLUSTERoffTake part in leader election. Requires a PostgreSQL --db.
--node-idORBIT_NODE_IDthe host name ($HOSTNAME, then /etc/hostname)This server’s name in health output, logs and the orbit_cluster table. Requires --cluster.
--advertise-addrORBIT_ADVERTISE_ADDRhttp(s)://<--addr> when --addr is not a wildcard address, otherwise noneURL that standbys return in NOT_LEADER responses while this server leads. Requires --cluster.

Every other option works as on a single server. Give all servers the same credentials, workers file, TLS settings and engine options such as --session-timeout, because whichever server is elected applies its own settings.

--cluster with a SQLite database is refused:

$ orbit server --cluster --db orbit.db
error: --cluster requires a PostgreSQL database (--db postgres://...): the servers share it for leader election and history

A server starts as a standby, binds its listener immediately and joins the election:

INFO orbit_runtime::cluster: cluster mode: standby until elected node_id=node-1 advertise_addr="http://127.0.0.1:47975"
INFO orbit_runtime::server: ORBIT runtime listening addr=127.0.0.1:47975 tls=false ui=true
INFO orbit_runtime::cluster: cluster: elected leader; recovering executions from history node_id=node-1 term=1
INFO orbit_runtime::cluster: cluster: leader is serving node_id=node-1 term=1 active_executions=0 recovery_ms=80

A server that finds a leader already in place follows it:

INFO orbit_runtime::cluster: cluster: standby; following the leader term=1 leader="node-1"

Leader election

Once per second, every standby runs SELECT pg_try_advisory_lock(...) on its election session, with a fixed key. PostgreSQL grants a session-level advisory lock to one session at a time, so at most one server holds it. The holder is the leader.

PostgreSQL releases the lock when the session ends. A leader that exits, crashes or is killed loses its connection, the server ends the session, and the next standby to try gets the lock, usually within one second.

The leader re-checks its election session every second: that the session still answers, that it still holds the lock and that the term has not moved on. It serves only while its last successful check is less than 3 seconds old (the lease). When a check fails, the leader steps down in-process: it stops serving immediately, drops its engine and its election session, installs a read-only engine and rejoins the election as a standby. It does not exit, so no supervisor is needed for it to recover:

ERROR orbit_runtime::cluster: cluster: lost leadership; stepped down to standby node_id=b reason=the election session failed: connection closed
INFO orbit_runtime::cluster: cluster: standby; following the leader term=3 leader="a"

The election session sets idle_session_timeout to 5 seconds (PostgreSQL 14 and later). If the leader’s host becomes unreachable without closing its connection, PostgreSQL ends the silent session after 5 seconds and releases the lock. The leader’s own lease of 3 seconds expires first, so the old leader has stopped serving before a new one can be elected. On older PostgreSQL versions, TCP keepalives (5 seconds idle) take that role. Recovery after election suspends the idle timeout so that a long recovery cannot release the lock.

Fencing

Timing alone does not guarantee a single writer: a leader that is paused (a long garbage-collection-like stall, a frozen VM) may resume after another server was elected. Fencing makes that harmless.

On election, the new leader increments the term:

UPDATE orbit_cluster SET term = term + 1, leader_node = $1, leader_addr = $2, since_ms = $3
WHERE id = 1 RETURNING term

It then opens its store in fenced mode. Every history append runs, in the same transaction as the events:

SELECT term FROM orbit_cluster WHERE id = 1 FOR SHARE

and aborts if the term is not the one the server was elected with. The row lock orders the two: a new leader’s term increment waits for an append in progress, and an append that starts after the increment sees the new term. A deposed leader therefore cannot append anything once a successor exists. Its requests fail with STORAGE_ERROR:

STORAGE_ERROR: fenced: this server led term 1, but the cluster is at term 2; another server is the leader

and its in-memory state is unchanged, because the engine updates memory only after an append commits. Its next leadership check notices the new term and it steps down.

The check costs one extra statement per batch of events, in cluster mode only. Single-node servers do not read the cluster table.

Failover timeline

These logs come from the end-to-end test: two servers on one database, a load balancer that routes to whichever answers 200 on /v1/ready, and Python and TypeScript workers. node-1 is killed with SIGKILL while a payment that takes 3 seconds is in flight.

# node-1 (leader, term 1), killed at about 13:55:46.19
INFO orbit_runtime::engine: execution started execution_id=ea6d4cde-f8f6-4679-adaa-9632c14dbfa6 workflow=OrderProcessing version=1.0.0

# Python worker: its long polls break, it keeps retrying
13:55:46,194 WARNING orbit poll failed: cannot reach ORBIT runtime at http://127.0.0.1:35689: Remote end closed connection without response; retrying in 0.2s

# node-2 (standby until now)
13:55:46.810072Z INFO orbit_runtime::cluster: cluster: elected leader; recovering executions from history node_id=node-2 term=2
13:55:46.859078Z INFO orbit_runtime::engine: recovered active executions from history executions=1 in_flight=1
13:55:46.860022Z INFO orbit_runtime::cluster: cluster: leader is serving node_id=node-2 term=2 active_executions=1 recovery_ms=49
13:55:46.886881Z INFO orbit_runtime::engine: worker registered worker_id=orders-python runtime=python tasks=5
13:55:46.966959Z INFO orbit_runtime::engine: worker registered worker_id=orders-typescript runtime=typescript tasks=4
Time after the killWhat happens
0 sThe leader’s connections close. Workers’ polls and result reports fail and are retried with backoff. PostgreSQL ends the leader’s sessions and releases the lock.
up to 1 sA standby’s next pg_try_advisory_lock succeeds. It increments the term, opens a fenced store and recovers active executions from history.
+ recovery timeThe new leader serves. /v1/ready answers 200, the load balancer switches to it within its check interval.
next requestWorkers’ old sessions are unknown on the new leader (404 UNKNOWN_SESSION); every SDK registers again and continues polling.
when the worker reportsA result for an attempt dispatched by the old leader is accepted: the task token is in the history the new leader recovered.
one --session-timeout after the new leader startedAttempts that nobody reported are marked uncertain and redelivered with the same idempotency key.

In the test, the payment’s worker finished after the failover and its late result was accepted; history shows one StepSucceeded for the charge. When the worker is killed together with the leader, the new leader records StepAttemptUncertain after the session timeout and dispatches the charge again (redelivery: true), which is the same outcome as a single-server crash. See Delivery guarantees.

In-flight work

Work in progress at the moment of failoverWhat happens
Events committed by the old leaderKept. The new leader recovers them.
A request the old leader had not committedLost. The client gets a connection error or STORAGE_ERROR and should retry; starting an execution with an execution_id makes the retry safe.
A task a worker is runningIts result is delivered to the new leader by the SDK’s retry loop and accepted. If the worker is gone too, the attempt is redelivered after the session timeout.
Timers, retries, deadlinesDurable in history; the new leader fires those that are due.
Built-in HTTP adapter callsThe old leader’s call may complete without being recorded; the new leader redelivers it after the session timeout. Use idempotency keys, as for any crash.
Worker sessions, metricsIn memory only. Workers register again; counters start from zero on the new leader.
GET /v1/executions/{id}/waitA wait on the old leader breaks; a wait on a standby keeps working, re-reading the database every second.

Standby behavior

A standby answers GET routes from the database: executions, history, wait, queries, workflow definitions (re-read from the database on each request), the worker list and metrics. active_executions and worker_sessions in its health output count only its own, read-only engine, so they are 0.

Every other route, including the worker protocol, answers 503 with the code NOT_LEADER, a retry-after: 1 header, and the leader’s advertised address in the body and in the x-orbit-leader header:

$ curl -si -XPOST http://127.0.0.1:7302/v1/executions -H 'content-type: application/json' -d '{"workflow":"x"}'
HTTP/1.1 503 Service Unavailable
content-type: application/json
retry-after: 1
x-orbit-leader: http://127.0.0.1:7301

{"error":{"code":"NOT_LEADER","message":"this server is a standby; the leader is `a` at http://127.0.0.1:7301","leader_node":"a","leader_addr":"http://127.0.0.1:7301"}}

On the gRPC binding, a standby fails every call with UNAVAILABLE and orbit-error-code: NOT_LEADER.

Schedules

Only the leader fires schedules. A standby’s store is read-only, so its engine never looks for due occurrences; it answers GET /v1/schedules and GET /v1/schedules/{id} from the database, and 503 NOT_LEADER to every change. Every occurrence is claimed and advanced with a compare-and-set on its orbit_schedules row, and the claim checks the cluster term in the same transaction, so a deposed leader cannot take an occurrence. When the leader fails, the new leader drops the claims left in the table (they belong to the old leader) and fires what is due, applying the schedule’s catch-up policy to occurrences that passed during the failover. An occurrence the old leader had already started is not started again: its execution id is deterministic and starting an existing id is a no-op.

The test only_the_leader_fires_schedules_and_a_new_leader_carries_on in crates/orbit-runtime/tests/cluster.rs checks this: with a 1-second interval schedule on two servers, the standby’s orbit_schedules_fired_total stays 0, the leader’s counts every start, and after the leader is stopped the new leader carries on with the same schedule.

The gate runs before authentication, so a standby answers NOT_LEADER even to a request without credentials, and /v1/ready never requires credentials.

Clients and workers

ClientBehavior on 503 NOT_LEADER
orbit CLIRepeats the request once against the leader address in the response. orbit deploy --server <standby> works.
Python, TypeScript, Go, Rust, Java and C# SDK workersTreat every 5xx like a connection error: registration, polling and result delivery are retried with backoff. After a failover they get 404 UNKNOWN_SESSION and register again.
SDK clients (Client.start, signal, …) and your applicationsGet the error. Retry, or send requests through a load balancer that routes to the leader.

Load balancer configuration

Give clients and workers one address that always reaches the leader. Health-check GET /v1/ready: it returns 200 on the leader and 503 on standbys. Keep /v1/health for liveness, since it returns 200 on every running server.

When the leader changes, connections that are still open to the old one must be closed, otherwise a keep-alive client can keep talking to a server that is now a standby (it would get 503 NOT_LEADER and retry forever on the same connection).

HAProxy

backend orbit
    mode http
    option httpchk GET /v1/ready
    http-check expect status 200
    default-server inter 1s fall 1 rise 1 on-marked-down shutdown-sessions
    server orbit-0 orbit-0.orbit.internal:7233 check
    server orbit-1 orbit-1.orbit.internal:7233 check
    timeout server 330s        # long polls and waits

nginx

Open-source nginx has no active health checks. Use passive failover on 503, which is what a standby returns for writes, and on connection errors:

upstream orbit {
    server orbit-0.orbit.internal:7233 max_fails=1 fail_timeout=2s;
    server orbit-1.orbit.internal:7233 max_fails=1 fail_timeout=2s;
}
server {
    listen 7233;
    location / {
        proxy_pass http://orbit;
        proxy_next_upstream error timeout http_503 non_idempotent;
        proxy_next_upstream_tries 2;
        proxy_read_timeout 330s;
    }
}

non_idempotent lets nginx retry a POST that a standby refused; a standby never applies a request it refuses, so the retry is safe. With nginx Plus, use health_check uri=/v1/ready match=ready; instead.

Kubernetes

Use a StatefulSet with two replicas and a readiness probe on /v1/ready. The Service then routes only to the leader, and pods that become standbys are removed from its endpoints:

readinessProbe:
  httpGet: { path: /v1/ready, port: http }
  periodSeconds: 1
  failureThreshold: 1
livenessProbe:
  httpGet: { path: /v1/health, port: http }
  periodSeconds: 10

See Deployment for the full manifest.

Health fields

/v1/health and /v1/ready include the node’s view of the cluster:

$ curl -s http://127.0.0.1:7302/v1/health
{"status":"ok","api_version":"orbit-api/1","runtime_version":"0.2.0","ir_versions":["orbit-ir/1","orbit-ir/2"],"active_executions":0,"worker_sessions":0,"role":"standby","node_id":"b","term":1,"leader":{"node_id":"a","addr":"http://127.0.0.1:7301"}}
$ curl -s http://127.0.0.1:7302/v1/ready
{"role":"standby","node_id":"b","term":1,"leader":{"node_id":"a","addr":"http://127.0.0.1:7301"},"ready":false}
FieldMeaning
roleleader, standby, shared in shared mode, or single without --cluster.
node_idThis server’s --node-id (null without --cluster).
termThe leadership term this server last saw. It increases by one at every election.
leader{"node_id", "addr"} of the current leader as recorded in orbit_cluster, or null.

To see the lock holders from PostgreSQL, look for the election sessions, which are named orbit-cluster:<node id>:

SELECT application_name, state, backend_start FROM pg_stat_activity
WHERE application_name LIKE 'orbit-cluster:%';

Limits

  • One active server. Standbys do not execute workflows, dispatch tasks or accept results. Adding servers adds availability, not throughput; size the leader as you would a single server.
  • PostgreSQL only. The database is the single point of truth and the arbiter. Its own availability (replication, managed failover) bounds the cluster’s. After a database failover, servers reconnect and elect a leader again.
  • Failover is not instant. Expect about one second after a crash, plus recovery time, plus your load balancer’s check interval; up to about 5 seconds after a network partition. Attempts nobody reports are redelivered one --session-timeout after the new leader started.
  • Same configuration everywhere. The leader applies its own options. Differences between servers (session timeout, workers file, HTTP adapter) change behavior at each failover.
  • Version skew. During a rolling upgrade, the new version may become leader before the old one is gone. Upgrade standbys first, then stop the leader; never let an older version lead a database written by a newer one.

Shared mode (several active servers)

With --cluster --cluster-mode shared, every server is active. Each one serves every route, starts and runs executions, and hands tasks to the workers that poll it. When a server dies, the others take over its executions. Use it when one server is not enough for your load, or when you want every replica to take traffic.

The sections above describe the default mode, --cluster-mode standby. Shared mode needs no election: there is no leader, no term and no NOT_LEADER answer.

Architecture

              clients, CLI, workers
                       │  one address
                       ▼
        load balancer (round robin) ── health check: GET /v1/ready (200 on every live server)
          │              │              │
          ▼              ▼              ▼
   ┌────────────┐ ┌────────────┐ ┌────────────┐
   │ orbit-0    │ │ orbit-1    │ │ orbit-2    │   every server: full engine for the
   │ engine:    │ │ engine:    │ │ engine:    │   executions it owns, claims tasks for
   │ owned exec │ │ owned exec │ │ owned exec │   the workers that poll it, reads
   └─────┬──────┘ └─────┬──────┘ └─────┬──────┘   everything else from the database
         │              │              │   store, queue, inbox, notifications
         ▼              ▼              ▼
   ┌─────────────────────────────────────────────────────────────────────┐
   │ PostgreSQL                                                          │
   │  execution_events, workflow_executions …  history, as before        │
   │  orbit_nodes      heartbeats                                        │
   │  orbit_owners     owner and epoch per running execution             │
   │  orbit_tasks      shared task queue                                 │
   │  orbit_inbox      commands for an execution's owner                 │
   │  orbit_sessions   worker sessions, usable through any server        │
   │  LISTEN/NOTIFY    orbit_tasks, orbit_inbox, orbit_done, orbit_defs  │
   └─────────────────────────────────────────────────────────────────────┘

Each server keeps seven connections to PostgreSQL: the store (history appends), four for request-path statements (claims, forwarding, sessions), one for its heartbeat, takeovers and inbox, and one that listens for notifications. Size max_connections for that, plus your other clients.

WhatWhere it livesWho acts on it
History of an executionexecution_events, as on one serverOnly its owner appends. Any server reads.
Running executionsIn the memory of their owner, rebuilt from history on takeoverThe owner ticks timers, retries, deadlines and child workflows.
Worker tasksorbit_tasksAny server claims them for the workers polling it.
Commands for an execution owned elsewhereorbit_inboxIts owner applies them in order.
Worker sessionsorbit_sessions, cached by every server that serves the workerA worker can poll, heartbeat and report through any server.
Workflow definitionsworkflow_definitions, as on one serverAny server registers; the others reload on a notification.

Start a shared cluster

Point every server at the same database, with --cluster --cluster-mode shared and a unique node id:

orbit server --cluster --cluster-mode shared --db "$ORBIT_DB" --addr 0.0.0.0:7233 --token "$ORBIT_TOKEN" \
  --node-id orbit-0 --advertise-addr http://orbit-0.orbit.internal:7233
orbit server --cluster --cluster-mode shared --db "$ORBIT_DB" --addr 0.0.0.0:7233 --token "$ORBIT_TOKEN" \
  --node-id orbit-1 --advertise-addr http://orbit-1.orbit.internal:7233
orbit server --cluster --cluster-mode shared --db "$ORBIT_DB" --addr 0.0.0.0:7233 --token "$ORBIT_TOKEN" \
  --node-id orbit-2 --advertise-addr http://orbit-2.orbit.internal:7233
OptionEnvironmentDefaultMeaning
--cluster-modeORBIT_CLUSTER_MODEstandbyshared makes every server active. Requires --cluster.
--node-timeoutORBIT_NODE_TIMEOUT5Seconds without a heartbeat after which a server is dead and the others take over its executions. At least 2.
--node-idORBIT_NODE_IDthe host nameMust be unique among running servers. A server that finds its id heartbeating from another process waits up to three node timeouts for it to expire (a quick restart), then refuses to start.
--advertise-addrORBIT_ADVERTISE_ADDRfrom --addrShown for this server in the nodes list of /v1/health.

Give all servers the same credentials, workers file, TLS settings, --session-timeout and --node-timeout. A server registers, resumes the executions it owned before a restart, and then reports ready:

INFO orbit_runtime::shared: cluster mode: shared (every server active) node_id=node-1 advertise_addr="http://127.0.0.1:36263" node_timeout_ms=3000
INFO orbit_runtime::server: ORBIT runtime listening addr=127.0.0.1:36263 tls=false ui=true
INFO orbit_runtime::shared: shared: node is serving node_id=node-1 resumed=0

Ownership and fencing

The server that starts an execution owns it, and records that in orbit_owners with epoch 1 in the same transaction as WorkflowCreated. A child workflow is owned by its parent’s owner and shares its root execution id, so a parent and its children always move together.

Only the owner appends to an execution’s history. Every append checks, in its own transaction:

SELECT node_id, epoch FROM orbit_owners WHERE execution_id = $1 FOR SHARE

and aborts unless the row still names this server at the epoch it holds. This is the same pattern as the term check of standby mode, per execution. The row lock orders an append and a takeover: a takeover waits for an append in progress, and an append that starts after a takeover sees the new epoch. A server that lost an execution, because it was paused longer than the node timeout for example, cannot write to it, and its request fails without changing anything:

STORAGE_ERROR: fenced: execution `f-1` is owned by node `node-new` at epoch 2; this node held epoch 1

The server then drops the execution from memory. When an execution reaches a terminal status, its ownership row is deleted in the same transaction.

Every server writes its heartbeat to orbit_nodes every second, with the database clock. A server whose heartbeat is older than --node-timeout is dead. Every second, each live server looks for executions owned by dead servers and takes over its share of them:

UPDATE orbit_owners SET node_id = $me, epoch = epoch + 1, since_ms = <now>
WHERE root_id = $root AND node_id = $dead RETURNING execution_id, epoch

The WHERE node_id = $dead makes the takeover single-winner: when two servers try at once, the second waits for the first, re-reads the row, and updates nothing. The share is chosen by rendezvous hashing of each root execution id over the live servers, so the executions of a dead server are spread evenly over the survivors without coordination; a server takes them in batches of 100 execution trees. It then rebuilds the executions from history, as a restarted server does: queued tasks go back on the queue, timers continue, and attempts in flight stay in flight while their workers are alive.

A server that stops gracefully (SIGTERM, Ctrl+C) sets its heartbeat to 0 on the way out, so the others take its executions over at once instead of after the node timeout:

INFO orbit_runtime::shared: shared: left the cluster; other servers take over this server's executions

A server that restarts with the same --node-id increments the epoch of the executions it still owns, which fences the previous process, and resumes them.

Task queue

When the owner schedules a worker task (StepScheduled, including retries, redeliveries, compensations and handler steps), it inserts a row into orbit_tasks in the same transaction and sends NOTIFY orbit_tasks. Built-in tasks (the HTTP adapter) and child workflows are not queued: the owner runs them itself.

A worker polls any server. That server claims the oldest row matching the worker’s contracts and using language:

WITH c AS (SELECT id FROM orbit_tasks WHERE state = 'queued' AND (task_name, contract_version) IN (...)
           AND (language IS NULL OR language = 'any' OR language = $runtime)
           ORDER BY enqueued_ms, id LIMIT 1 FOR UPDATE SKIP LOCKED)
UPDATE orbit_tasks t SET state = 'claimed', token = ... FROM c WHERE t.id = c.id

SKIP LOCKED lets servers claim different rows at the same time without waiting for each other. In the same transaction, the claiming server checks the owner. If it owns the execution itself, it records StepDispatched right after the claim; if it dies in between, the server that takes the execution over puts the task back on the queue. Otherwise it writes a dispatched entry into the owner’s inbox, so that the owner records StepDispatched with the worker id and session, and the claiming server returns the task to the worker at once. Task tokens have the form <execution id>/<uuid>, so any server can tell which execution a result belongs to.

Long polls wake up on NOTIFY orbit_tasks; every second they also look again, in case a notification was missed.

When the owner no longer needs an attempt (it timed out, the execution is being cancelled or failed, the step was redelivered), it marks the row cancelled in the same transaction. The next heartbeat of the worker’s session, on whichever server, returns that token in its cancel list, once, as on a single server.

Inbox

A command for an execution that another server owns is written to orbit_inbox and announced with NOTIFY orbit_inbox to the owner:

KindSent whenApplied by the owner as
dispatchedA server claimed a task of the execution.StepDispatched, if the unit still waits for exactly that offer; otherwise ignored, and the worker is told to cancel.
complete, failA worker reported a result through a server that does not own the execution.POST /v1/tasks/complete or fail, as on one server.
uncertainA worker unregistered while holding a task.StepAttemptUncertain and redelivery.
signal, cancelA client sent a signal or a cancellation to a server that does not own the execution.As on one server; the reply goes back to the waiting server.

The owner applies its entries in seq order, through the same engine methods a request would use, and deletes each entry in the same transaction as the events it causes. Ordering matters for one case: a worker that is fast enough reports its result before the owner has recorded the dispatch. Both entries are in the inbox, the dispatch first, so the result finds its token.

A signal or cancellation that reaches the wrong server is validated there first: the execution must exist and be running, the signal must be declared and its payload valid. Errors such as 404 UNKNOWN_SIGNAL, 409 EXECUTION_FINISHED or 422 SIGNAL_PAYLOAD_INVALID are answered at once, exactly as on one server. A valid one goes to the owner’s inbox, and the receiving server waits up to 10 seconds for the owner’s reply, so the response carries the signal’s seq and the cancelled execution’s state as usual.

Starting an execution needs no inbox: the server that receives the request owns the new execution. Reads (GET executions, history, lists, queries, wait) work on every server from the database; wait wakes on NOTIFY orbit_done.

Schedules in shared mode

Every server looks for due schedules about once a second and tries to claim each due occurrence with UPDATE orbit_schedules SET claimed_by = <server>, claimed_until_ms = <now + 30 s> WHERE schedule_id = $1 AND next_fire_ms = <the occurrence> AND <no live claim>. Exactly one server’s update matches; it applies the overlap and catch-up policies, starts the execution (and so owns it), and advances next_fire_ms with a second compare-and-set that also releases the claim. A schedule can be created, changed or triggered through any server.

If the claiming server dies before it advances the schedule, its claim expires after 30 seconds and another server takes the occurrence; the deterministic execution id makes a start that already happened a no-op. Occurrences that pass meanwhile follow the schedule’s catch-up policy.

The test each_schedule_occurrence_is_fired_by_exactly_one_node in crates/orbit-runtime/tests/shared.rs runs a 1-second schedule on three servers for five seconds and checks that the schedule’s fired count, the number of executions and the sum of the servers’ orbit_schedules_fired_total are equal, and that every occurrence between the first and the last was started or counted as skipped. The end-to-end test SharedCluster.test_d_each_schedule_occurrence_fires_once_across_nodes checks the same with three server processes behind a proxy.

Continue-as-new keeps the execution’s owner and ownership epoch; the finished run’s rows in orbit_tasks are deleted in the same transaction that starts the next run.

Worker sessions

Sessions are stored in orbit_sessions, so a session registered through one server works through all of them. A load balancer can send every request of a worker to a different server. When a server dies, its workers’ polls fail, the SDK retries through the load balancer and reaches another server with the same session: no new registration, and the tasks the worker is running stay in flight. Their results reach the new owner through the inbox.

Servers write the sessions’ last contact to the database every 250 ms, and owners read it for the sessions holding their attempts every 500 ms. A worker that goes silent is declared lost one --session-timeout after its last contact, plus about one second, and its attempts are redelivered with the same idempotency key.

Failure handling

These logs come from the setup of the end-to-end test SharedCluster: three servers with --session-timeout 3 --node-timeout 3, a round-robin proxy that sends each new connection to the next server answering 200 on /v1/ready, and the Python and TypeScript example workers. node-2 owns two orders whose charge takes 3 seconds; it is killed with SIGKILL while both charges run.

# test driver
KILL node-2 at 2026-11-15T16:33:26.037Z

# Python worker: its polls through node-2 break; it retries through the proxy
2026-11-15 16:33:26,041 WARNING orbit poll failed: cannot reach ORBIT runtime at http://127.0.0.1:44679: Remote end closed connection without response; retrying in 0.2s

# node-3, one node timeout later
2026-11-15T16:33:29.189807Z  WARN orbit_runtime::shared: shared: taking over executions of a node that stopped heartbeating from=node-2 executions=2
2026-11-15T16:33:29.192739Z  INFO orbit_runtime::engine: shared: took ownership of executions executions=2 in_flight=2 from=node-2
2026-11-15T16:33:29.244878Z  INFO orbit_runtime::engine: execution completed execution_id=sh-D91
2026-11-15T16:33:29.260564Z  INFO orbit_runtime::engine: execution completed execution_id=sh-D90

The charges finished on the worker at about 16:33:29.0, while node-2 was dead. The worker reported through the other servers, which stored the results in node-2’s inbox; node-3 applied them right after the takeover. The charge’s history shows one dispatch and one success:

seq 12  16:33:25.919  StepScheduled   process_payment
seq 13  16:33:25.986  StepDispatched  process_payment  worker_session=3ed3dc41-ffb6-4453-a1fa-cfe24fce9f6f
seq 14  16:33:29.194  StepSucceeded   process_payment

When the worker dies together with the server, the new owner redelivers the attempt one session timeout after the worker’s last contact (test test_c_kill_a_node_and_a_worker_mid_run; the Python worker was killed with the server and started again):

# node-2
2026-11-15T16:26:03.423315Z  WARN orbit_runtime::shared: shared: taking over executions of a node that stopped heartbeating from=node-3 executions=1
2026-11-15T16:26:03.424848Z  INFO orbit_runtime::engine: shared: took ownership of executions executions=1 in_flight=1 from=node-3
2026-11-15T16:26:03.676284Z  WARN orbit_runtime::engine: attempt outcome uncertain; redelivering execution_id=sh-SH-C91 step=process_payment attempt=1 reason=worker `orders-python` stopped responding

# restarted Python worker
2026-11-15 16:26:03,690 INFO orbit executing {"execution_id": "sh-SH-C91", "step": "process_payment", "attempt": 1, "task": "payments.charge", "redelivery": true}
Time after the killWhat happens
0 sThe server’s connections close. Workers’ polls and result reports through it fail, and the SDKs retry; the proxy sends the retries to the other servers. The workers’ sessions are in the database, so the other servers accept them.
until --node-timeoutThe dead server’s executions wait: nobody ticks them or records their results. Results reported meanwhile are stored in its inbox. Executions owned by the other servers continue.
--node-timeoutThe other servers see the stale heartbeat, take over the dead server’s executions (spread by rendezvous hashing), rebuild them from history and apply the waiting inbox entries.
next resultResults of attempts that were in flight are accepted by the new owner: their tokens are in the history it rebuilt.
--node-timeout + --session-timeoutAn attempt the dead server handed out is redelivered if its result has not arrived, because the worker may never have received it. A late result of the first attempt is still accepted.
one --session-timeout after a worker’s last contactAttempts of workers that died too are uncertain and redelivered (StepAttemptUncertain, redelivery: true).
FailureWhat happens
A server crashes or is killedAs above: about --node-timeout until its executions continue elsewhere. Committed events are kept; an uncommitted request is lost and the client gets a connection error; retry it (with an execution_id for starts).
A server stops gracefullyIts executions are taken over within about a second.
A server pauses (stop-the-world, frozen VM) longer than --node-timeoutIts executions are taken over. When it resumes, its appends fail with fenced:; it drops those executions from memory and continues with the ones it still owns.
A server loses the databaseIt cannot heartbeat; after --node-timeout its /v1/ready answers 503 and the others take over. When it reconnects, it compares its executions with orbit_owners and drops those it lost.
A worker diesIts attempts are redelivered after the session timeout, by their owners, as on one server.
PostgreSQL fails overAll servers reconnect to the new primary and continue.

Load balancer configuration

Every live server accepts every request, so balance over all of them and health-check GET /v1/ready: it answers 200 on a live server and 503 while a server resumes its executions at startup, cannot write its heartbeat, or shares its node id with another running server. Keep /v1/health for liveness.

Sessions are shared, so no stickiness is needed. Long polls and waits last up to 60 and 300 seconds; set the backend timeout above that.

HAProxy:

backend orbit
    mode http
    balance roundrobin
    option httpchk GET /v1/ready
    http-check expect status 200
    default-server inter 1s fall 2 rise 1
    retry-on conn-failure
    server orbit-0 orbit-0.orbit.internal:7233 check
    server orbit-1 orbit-1.orbit.internal:7233 check
    server orbit-2 orbit-2.orbit.internal:7233 check
    timeout server 330s

nginx:

upstream orbit {
    server orbit-0.orbit.internal:7233 max_fails=1 fail_timeout=2s;
    server orbit-1.orbit.internal:7233 max_fails=1 fail_timeout=2s;
    server orbit-2.orbit.internal:7233 max_fails=1 fail_timeout=2s;
}
server {
    listen 7233;
    location / {
        proxy_pass http://orbit;
        proxy_next_upstream error timeout;
        proxy_read_timeout 330s;
    }
}

Do not add non_idempotent to proxy_next_upstream in shared mode: a request that reached a server that died may have been applied.

On Kubernetes, use a Deployment or StatefulSet with several replicas, --node-id from the pod name (ORBIT_NODE_ID from metadata.name), ORBIT_CLUSTER_MODE=shared, and the readiness probe on /v1/ready from Kubernetes. The Service balances over all ready pods.

Health fields

$ curl -s http://127.0.0.1:46825/v1/health
{"status":"ok","api_version":"orbit-api/1","runtime_version":"0.3.0","ir_versions":["orbit-ir/1","orbit-ir/2"],"active_executions":0,"worker_sessions":2,"role":"shared","node_id":"node-1","term":null,"leader":null,"owned_executions":0,"tasks_claimed":18,"nodes":[{"node_id":"node-1","addr":"http://127.0.0.1:46825","live":true,"heartbeat_age_ms":2,"owned_executions":0},{"node_id":"node-3","addr":"http://127.0.0.1:47377","live":true,"heartbeat_age_ms":248,"owned_executions":0}]}
$ curl -s http://127.0.0.1:46825/v1/ready
{"role":"shared","node_id":"node-1","term":null,"leader":null,"owned_executions":0,"tasks_claimed":0,"nodes":[{"node_id":"node-1","addr":"http://127.0.0.1:46825","live":true,"heartbeat_age_ms":1,"owned_executions":0},{"node_id":"node-2","addr":"http://127.0.0.1:36959","live":true,"heartbeat_age_ms":709,"owned_executions":0},{"node_id":"node-3","addr":"http://127.0.0.1:47377","live":true,"heartbeat_age_ms":274,"owned_executions":0}],"ready":true}

The first response was taken after node-2 was killed: it is no longer listed, because it is not live and owns nothing.

FieldMeaning
roleshared.
node_idThis server’s --node-id.
term, leaderAlways null in shared mode.
active_executions, owned_executionsRunning executions this server owns.
tasks_claimedTasks this server handed to workers since it started.
nodesEvery server that is live or still owns executions, with addr, live, heartbeat_age_ms and owned_executions.

Metrics (/v1/metrics) stay per server: each counts the events it committed and the executions it owns. Sum them across servers in your monitoring.

Throughput

These numbers were measured on the machine this page was written on, so read them as the shape of the scaling, not as capacity: 4 vCPUs, PostgreSQL 16 on the same machine with default settings (synchronous_commit = on), a release build, and every server, worker and client sharing those 4 CPUs and one disk. The workload is the end-to-end setup: OrderProcessing (six worker tasks per order) with the Python and TypeScript example workers (concurrency 8 each), 600 orders started by 64 concurrent clients that each wait for their order to complete, and shared servers behind the round-robin proxy.

ServersWorker processesOrders per second
1, single server (no --cluster)1 Python, 1 TypeScript37.2, 38.2
1, single server (no --cluster)2 Python, 2 TypeScript32.6
1, shared mode1 Python, 1 TypeScript28.4, 29.9
2, shared mode1 Python, 1 TypeScript37.3
3, shared mode1 Python, 1 TypeScript42.6, 41.4
3, shared mode2 Python, 2 TypeScript42.6

Two runs of the same configuration are listed where there were two. The single-server rows were measured before the PostgreSQL commit changes in 0.4.0 (one statement per commit, fewer worker-registry writes), which make a single server faster than shown; shared mode writes through its own path and is not affected. What they show:

  • One shared server is about 25% slower than a single server: every task also writes a queue row, a claim and an ownership check.
  • Throughput grows with the number of shared servers, by about 30% for the second and 10% for the third here, where all of them compete for the same CPUs and the same PostgreSQL. Three shared servers did about 10% more than one single server.
  • More worker processes did not help: the servers and PostgreSQL were the limit, not the workers.

On separate machines the servers do not compete for CPU, and PostgreSQL’s commit rate becomes the limit: each task commits its dispatch and its result, as on one server, plus the claim.

Limits

  • PostgreSQL only, and PostgreSQL is the bottleneck. Every dispatch, result and heartbeat is a database round trip, as on one server, plus the queue and inbox writes. Adding servers adds CPU for the engines and connections for workers; it does not add database capacity.
  • Failover of an execution waits for --node-timeout. A lower value detects dead servers faster but turns long pauses into takeovers; each server must be able to write its heartbeat within it.
  • A server that dies can cause redeliveries. Attempts it handed out whose results do not arrive within --node-timeout plus --session-timeout are redelivered, even if the worker is still running them. Idempotency keys make this safe, as for any redelivery.
  • Clocks. Liveness uses the database clock, but timers, retries and timeouts use the owner’s clock. Keep server clocks synchronized.
  • Limits are per server. --max-active-executions limits the executions each server owns.
  • One owner per execution tree. An execution and its child workflows run on one server. A single very large execution does not spread.
  • Node ids must be unique. Two running processes with the same --node-id are refused or stop serving; they would otherwise take each other’s executions.
  • No mixed modes. Do not run standby and shared servers, or servers without --cluster, on the same database.
  • Version skew. During a rolling upgrade, servers of both versions run at once. Upgrade one server at a time, and do not roll back to a version without shared mode while executions are owned in orbit_owners.

See also

Security

The runtime authenticates every caller, separates administrators from workers, and can give each worker its own credential that limits which tasks it may serve, and each application its own credential that limits which workflows it may start and act on. Traffic can be encrypted with TLS, and workers can authenticate with client certificates over mutual TLS. This page describes each mechanism, what each kind of caller can do, and a checklist for production.

Threat model

The runtime owns all workflow state. Workers run your business logic on other hosts, often with access to sensitive systems, and are the most exposed part of a deployment. The design goals are:

  • a compromised worker host cannot start, cancel or read executions, or register workflows;
  • a worker with its own identity cannot impersonate another worker, serve tasks it was not assigned, or report results for tasks issued to someone else;
  • no credential is stored in plain text on the runtime host, apart from the admin and shared worker tokens you pass in;
  • nothing a worker sends can change workflow state except through validated results for tokens issued to it;
  • an application with a client identity can act only on the workflows it was granted, and cannot deploy workflows or act as a worker;
  • no single caller or workflow can exhaust the runtime, when resource limits are set.

Credentials

CredentialConfigured withWho uses it
Admin token--token / ORBIT_TOKENOperators, the orbit CLI, CI pipelines that deploy workflows, and applications without a client identity.
Shared worker token--worker-token / ORBIT_WORKER_TOKENWorker hosts, when you do not need per-worker identities.
Per-worker tokena token_sha256 entry in --workers-fileOne worker identity.
Client certificatea client_cert entry in --workers-file, with --tls-client-caOne worker identity, over mutual TLS.
Client identity tokena token_sha256 entry of a [[client]] table in --workers-fileOne application, limited to the execution API for some workflows.
Client identity certificatea client_cert entry of a [[client]] table, with --tls-client-caOne application, over mutual TLS.

Tokens are sent as Authorization: Bearer <token> (an HTTP header, or gRPC metadata). Comparisons are constant-time.

No authentication

Without --token and without --workers-file, every endpoint is open. The server allows this only on a loopback address (127.0.0.1, ::1). On any other address it refuses to start unless you pass --insecure:

$ orbit server --addr 0.0.0.0:7233
error: refusing to listen on 0.0.0.0:7233 without authentication; pass --token (or ORBIT_TOKEN), or --insecure for trusted networks

The container image listens on 0.0.0.0:7233, so it refuses to start without ORBIT_TOKEN.

Admin token

export ORBIT_TOKEN=$(orbit token 2>/dev/null)
orbit server --addr 0.0.0.0:7233 --token "$ORBIT_TOKEN"

With a token configured, every endpoint except GET /v1/health requires a credential. The admin token can call every endpoint. The CLI reads the same ORBIT_TOKEN variable, or --token.

Use a long random value. orbit token prints 64 hexadecimal characters (244 random bits) on stdout.

Shared worker token

orbit server --token "$ORBIT_TOKEN" --worker-token "$ORBIT_WORKER_TOKEN"

The shared worker token can call only the worker protocol: /v1/workers/* and /v1/tasks/*. Anything else returns 403 FORBIDDEN:

{"error":{"code":"FORBIDDEN","message":"worker credentials may only call /v1/workers/* and /v1/tasks/*"}}

It requires --token (otherwise the administrative endpoints would be open) and must differ from it. Every holder of the shared token is equivalent: it can register under any worker id and announce any task. The SDKs read ORBIT_WORKER_TOKEN before ORBIT_TOKEN.

Per-worker identities

A workers file gives each worker its own credential and limits. The same file can also declare client identities for applications.

# /etc/orbit/workers.toml

[[worker]]
id = "payments-1"
# SHA-256 of the worker's bearer token, as 64 hex characters.
token_sha256 = "cf65d58bce0a5ed7d7c7a09b53a5287eaa138e3ee3e72f22f97df7861cab7bc0"
tasks = ["payments.*", "audit.log"]
runtime = "python"

[[worker]]
id = "inventory"
# Authenticate with a client certificate (mutual TLS) instead of a token.
client_cert = "inventory.workers.internal"
tasks = ["inventory.reserve", "inventory.release"]
orbit server --token "$ORBIT_TOKEN" --workers-file /etc/orbit/workers.toml

Fields

Each [[worker]] table accepts exactly these fields. Unknown fields are rejected.

FieldTypeRequiredMeaning
idstringyesWorker id. The worker must register with this exact worker_id. Must not be empty, and must be unique in the file.
token_sha256stringone of token_sha256 or client_certHex SHA-256 of the worker’s bearer token, 64 characters (case-insensitive). The token itself is never stored.
client_certstringone of token_sha256 or client_certCertificate name for mutual TLS: matched, case-insensitively, against the certificate’s DNS subject alternative names and subject common names.
tasksarray of stringsyes, at least oneTask patterns the worker may announce: an exact name (audit.log), a prefix ending in * (payments.* matches payments.charge), or * for every task.
runtimestringnoWhen set, the worker must announce this runtime (case-insensitive), for example python.

An entry may have both token_sha256 and client_cert; either then authenticates it. The file is read once, at startup. To change it, edit it and restart the server.

The server refuses to start when the file is invalid:

$ orbit server --token t --workers-file bad.toml
error: bad.toml: worker `a` needs `token_sha256` or `client_cert`

Other messages: worker `x` is declared twice, worker `x`: `token_sha256` must be 64 hex characters (use `orbit token`), worker `x` must list at least one task pattern, a worker has an empty `id` , and TOML parse errors. A workers file requires --token.

Generating a worker token

orbit token prints a new token on stdout and the matching [[worker]] entry on stderr:

$ orbit token --worker payments-1 --task 'payments.*' --task audit.log
8a7df28ad62d4237a229d884ee8f8d659f3406f060554af19f10cd96c731b2c4

Give the token above to the worker (ORBIT_WORKER_TOKEN). Add this to the workers file:

[[worker]]
id = "payments-1"
token_sha256 = "cf65d58bce0a5ed7d7c7a09b53a5287eaa138e3ee3e72f22f97df7861cab7bc0"
tasks = ["payments.*", "audit.log"]

Give the token to the worker as ORBIT_WORKER_TOKEN, add the entry (and an optional runtime) to the workers file, and restart the server. To compute the hash of an existing token yourself:

printf %s "$TOKEN" | sha256sum | cut -d' ' -f1

What a worker identity may do

A per-worker identity (by token or by certificate) can call only /v1/workers/* and /v1/tasks/*, and is further restricted:

ActionRuleError
Registerworker_id must equal its id.this credential belongs to worker `payments-1`, not `other`
RegisterEvery announced task must match one of its tasks patterns.worker `payments-1` may not serve: inventory.reserve (allowed: payments.*, audit.log)
RegisterIf runtime is set, the announced runtime must match.worker `payments-1` must announce runtime `python`
Heartbeat, poll, unregisterThe session must be one this worker opened.session belongs to worker `payments-1`, not `inventory`
Complete, failThe token must have been issued to this worker.task token was issued to worker `payments-1`, not `inventory`

All of these return 403 FORBIDDEN.

Client identities

The admin token can do everything, including deploying workflows and reading every execution. An application that only needs to start and follow some workflows should have a client identity instead: a [[client]] entry in the same workers file, with its own credential, the workflows it may act on and the actions it may take.

# /etc/orbit/workers.toml

[[client]]
id = "warehouse-app"
token_sha256 = "4854f0bb0c1dd54b3884060e1a136550851354eccf27e8d1a5a6307b7f522df1"
workflows = ["Print*"]
# actions defaults to ["start", "signal", "query", "read"]

[[client]]
id = "ops-console"
client_cert = "ops-console.clients.internal"
workflows = ["*"]
actions = ["read", "cancel"]
orbit server --token "$ORBIT_TOKEN" --workers-file /etc/orbit/workers.toml

Client fields

Each [[client]] table accepts exactly these fields. Unknown fields are rejected.

FieldTypeRequiredMeaning
idstringyesClient name, used in error messages. Must not be empty, and must be unique in the file across workers and clients.
token_sha256stringone of token_sha256 or client_certHex SHA-256 of the client’s bearer token, 64 characters (case-insensitive).
client_certstringone of token_sha256 or client_certCertificate name for mutual TLS, matched like a worker’s client_cert.
workflowsarray of stringsyes, at least oneWorkflow name patterns: an exact name (Refund), a prefix ending in * (Invoice* matches InvoiceMonthly), or * for every workflow.
actionsarray of stringsnoGranted actions, from start, signal, query, read, cancel and schedule. Default: start, signal, query and read. cancel and schedule must be granted explicitly.

The server refuses to start when an entry is invalid:

$ orbit server --token t --workers-file bad.toml
error: bad.toml: client `app`: unknown action `deploy` (allowed: start, signal, query, read, cancel, schedule)

Other messages: client `app` needs `token_sha256` or `client_cert` , client `app`: `token_sha256` must be 64 hex characters (use `orbit token`), client `app` must list at least one workflow pattern, a client has an empty `id` , identity `x` is declared twice (a client reuses the id of a worker or of another client), and TOML parse errors such as missing field `workflows` . A workers file requires --token, even when it declares only clients.

Generating a client token

orbit token --client <id> prints a new token on stdout and the matching [[client]] entry on stderr. --workflow (repeatable, default *) sets the workflow patterns and --action (repeatable, default start, signal, query, read) the granted actions:

$ orbit token --client billing --workflow 'Order*' --action start --action read
c47e25df244d4b9c80df65eb6e3d457f43f565e91ae544599834c47de7301932

Give the token above to the application (ORBIT_TOKEN). Add this to the workers file:

[[client]]
id = "billing"
token_sha256 = "a632898a1f1166e52c3dd7c1c68ba2f0b495e566dc65e195b2b2fc37dbf94f4a"
workflows = ["Order*"]
actions = ["start", "read"]

An unknown action is rejected: error: unknown action `deploy` (allowed: start, signal, query, read, cancel, schedule). To hash a token you already have:

printf %s "$TOKEN" | sha256sum | cut -d' ' -f1

What a client identity may do

A client identity can use only the execution API, for workflows that match one of its workflows patterns:

RequestNeeds
Start an execution (POST /v1/executions, orbit run)start
Send a signal (orbit signal)signal
Evaluate a query (orbit query)query
Read an execution, its history, or wait for it (orbit status, logs, inspect, run without --no-wait); read a workflow definitionread
Cancel an execution (orbit cancel)cancel
Create, read, change, pause, resume, trigger or delete a schedule (/v1/schedules, orbit schedule)schedule, for the schedule’s workflow. Changing a schedule’s workflow needs schedule for both. GET /v1/schedules lists only the schedules of workflows the client may schedule.
List executions or workflows (orbit ps)nothing; the list shows only workflows it may read. A page of executions still holds up to limit entries it may read, taken from the 1000 most recent matching the other filters.

For a request about an existing execution, the workflow is the execution’s workflow. schedule grants nothing on executions: the executions a schedule starts are read, signalled or cancelled with the other actions. Everything else is refused with 403 FORBIDDEN: deploying workflows, GET /v1/workers, /v1/metrics, the worker protocol over HTTP (/v1/workers/*, /v1/tasks/*) and the gRPC binding.

$ orbit token --client nightly-ops --workflow 'Order*' --action schedule --action read
b8ef2f0e492f4651b8c93fa608353bf51c075ca1de3e4ab9ae33de786d88c1ee

Give the token above to the application (ORBIT_TOKEN). Add this to the workers file:

[[client]]
id = "nightly-ops"
token_sha256 = "907f6d7a6d7bdb81c376c157fc86ea81d3d78253290e76d6743846698387198a"
workflows = ["Order*"]
actions = ["schedule", "read"]
$ export ORBIT_TOKEN=$WAREHOUSE_TOKEN
$ orbit run PrintLabels --id labels-9 --no-wait --input '{"skus":["A"]}'
started execution labels-9
labels-9
$ orbit run Review --no-wait --input '{"order_id":"1"}'
error: FORBIDDEN (403): client `warehouse-app` may not start executions of `Review`
$ orbit deploy labels.orbit
error: FORBIDDEN (403): client `warehouse-app` may only use the execution API (start, signal, query, read, cancel) and schedules
$ curl -s -X POST -H "authorization: Bearer $WAREHOUSE_TOKEN" http://127.0.0.1:7233/v1/executions/labels-1/cancel
{"error":{"code":"FORBIDDEN","message":"client `warehouse-app` may not cancel executions of `PrintLabels`"}}

The SDKs’ Client classes authenticate with a client token the same way as with the admin token: pass it as the client’s token. Workers cannot use a client token; give them a worker identity.

TLS

orbit server --token "$ORBIT_TOKEN" \
  --tls-cert /etc/orbit/tls/server.pem --tls-key /etc/orbit/tls/server.key
  • The server serves HTTPS directly, with rustls: TLS 1.2 and 1.3, HTTP/2 and HTTP/1.1 via ALPN. The same certificate is used for the gRPC listener when --grpc-addr is set.
  • --tls-cert is a PEM file with the certificate chain, leaf first. --tls-key is the PEM private key (PKCS#8, PKCS#1 or SEC1).
  • Clients trust a private CA with --ca-cert or ORBIT_CA_CERT (CLI, Python, Go, Rust, Java, C#) or NODE_EXTRA_CA_CERTS (Node.js). The file must contain a CA certificate (basicConstraints=CA:TRUE, keyCertSign), not the server certificate itself.
  • The server certificate must name the host clients connect to in a subject alternative name (DNS name or IP address).

You can instead terminate TLS at a reverse proxy or load balancer in front of the runtime. Then you cannot use mutual TLS for worker identities, because the runtime does not see client certificates.

Creating a private CA for testing

openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.pem -days 365 \
  -subj "/CN=Orbit CA" \
  -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"

openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=orbit.internal"
printf "subjectAltName=DNS:orbit.internal,IP:127.0.0.1\nextendedKeyUsage=serverAuth\n" > server.ext
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
  -out server.pem -days 365 -extfile server.ext

Mutual TLS

orbit server --token "$ORBIT_TOKEN" \
  --tls-cert server.pem --tls-key server.key \
  --tls-client-ca workers-ca.pem \
  --workers-file workers.toml
  • Client certificates signed by the CA in --tls-client-ca are verified during the TLS handshake.
  • A verified certificate identifies the worker whose client_cert matches one of its DNS subject alternative names or subject common names.
  • A verified certificate that matches no [[worker]] entry identifies the [[client]] entry whose client_cert matches it, if any (worker entries are checked first).
  • A verified certificate that matches neither is refused with 403 on every endpoint, including /v1/health: the client certificate is valid but matches no worker or client in the workers file.
  • Client certificates are optional at the TLS layer. A client without a certificate can still connect and authenticate with a token, which is how the admin CLI and health probes connect.
  • When a request carries both a bearer token and a certificate, the token is checked first.
  • --tls-client-ca requires a workers file with at least one entry, and --tls-cert.

Issue a worker certificate whose subject alternative name matches its client_cert:

openssl req -newkey rsa:2048 -nodes -keyout inventory.key -out inventory.csr \
  -subj "/CN=inventory.workers.internal"
printf "subjectAltName=DNS:inventory.workers.internal\nextendedKeyUsage=clientAuth\n" > client.ext
openssl x509 -req -in inventory.csr -CA workers-ca.pem -CAkey workers-ca.key -CAcreateserial \
  -out inventory.pem -days 365 -extfile client.ext

Every SDK supports client certificates through ORBIT_CLIENT_CERT and ORBIT_CLIENT_KEY; the CLI through --client-cert and --client-key.

Permission matrix

EndpointNo auth configuredAdmin tokenShared worker tokenPer-worker identityClient identityNo or invalid credential (auth configured)
GET /v1/healthyesyesyesyesyesyes
GET /v1/metricsyesyes403403403401
POST /v1/workflowsyesyes403403403401
GET /v1/workflowsyesyes403403filtered to readable workflows401
GET /v1/workflows/{name}...yesyes403403with read401
POST /v1/executionsyesyes403403with start401
GET /v1/executionsyesyes403403filtered to readable workflows401
GET /v1/executions/{id}, history, waityesyes403403with read401
POST /v1/executions/{id}/signals/{name}yesyes403403with signal401
GET /v1/executions/{id}/queries/{name}yesyes403403with query401
POST /v1/executions/{id}/cancelyesyes403403with cancel401
POST /v1/schedules, GET, PATCH, DELETE /v1/schedules/{id}, POST .../pause, resume, triggeryesyes403403with schedule401
GET /v1/schedulesyesyes403403filtered to workflows it may schedule401
GET /v1/workersyesyes403403403401
POST /v1/workers/registeryesyesyes, any id and tasksown id, allowed tasks, required runtime403401
POST /v1/workers/heartbeat, poll, unregisteryesyesyesown sessions only403401
POST /v1/tasks/complete, failyesyesyesown tokens only403401
gRPC worker protocolyesyesyesas over HTTPPERMISSION_DENIEDUNAUTHENTICATED
GET /ui/* (web UI assets) and GET /yesyesyesyesyesyes (no credential needed; off with --no-ui)

“with start” and the like mean: when the workflow matches one of the client’s patterns and the action is granted; otherwise 403. A client certificate that is verified but matches no worker or client gets 403 everywhere.

Web UI

The server serves a web UI at /ui/ unless it is started with --no-ui (ORBIT_NO_UI=true).

  • The UI’s files (one HTML page, one script, one stylesheet, one icon) are compiled into the binary and served without authentication, so the sign-in page can load. They contain no data and no secrets. Every piece of data the UI shows comes from the HTTP API, called with the token the user enters, so the permission matrix above applies unchanged: a client identity sees only the workflows and actions it was granted, and refused requests are shown as Not permitted with the API’s message. Worker credentials cannot sign in, because they cannot read executions.
  • The token is kept in the browser tab’s sessionStorage (cleared when the tab closes, or by Sign out) and sent as Authorization: Bearer. It is never put in a URL or a cookie. The UI does not use TLS client certificates: requests are made with credentials: "omit".
  • Every UI response carries Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none', X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Resource-Policy: same-origin, a Permissions-Policy that disables camera, microphone, geolocation and payment, and Cache-Control: no-cache. The page has no inline script or style, loads nothing from other origins, and cannot be framed.
  • Values from executions (inputs, outputs, errors, signal payloads, names) are inserted into the page as text, never as markup.
  • Like the API, the UI shows values unredacted to whoever holds a token that may read them.
  • The UI uses the API’s normal endpoints, so running it adds no endpoint and no privilege. Turn it off with --no-ui if browsers should not reach the server at all.

Input validation and limits

  • Request bodies are limited to --max-body bytes (4 MiB by default); larger requests get 413.
  • With --max-payload-bytes, an execution input, a task result (over HTTP or gRPC) or a signal payload larger than the limit, as compact JSON, is refused with 413 PAYLOAD_TOO_LARGE and not recorded.
  • With --max-active-executions, a start beyond the limit gets 429 RESOURCE_EXHAUSTED. Restarting an existing execution_id and starting child workflows are never refused.
  • With --max-fan-out, a for step over a longer list fails with LIMIT_EXCEEDED; with --max-history-events, an execution whose history reaches the limit fails with LIMIT_EXCEEDED. Compensation and on failure handlers still run.
  • Execution input is validated strictly against the workflow’s input type. Step inputs are validated against task input contracts before dispatch, and worker output against task output contracts before it is accepted.
  • Execution ids are limited to 200 characters of letters, digits, -, _, . and :.
  • Workers cannot announce built-in tasks (orbit.*) or child-workflow contracts (RESERVED_TASK).
  • Results are accepted only for tokens the runtime issued; stale and duplicate results are recorded and ignored.

The limits are off by default. Set them on any server that untrusted or many callers can reach. See Running the server: resource limits.

HTTP adapter

The built-in orbit.http.request task is off by default. When enabled with --enable-http-adapter, anyone who can deploy a workflow can make the runtime host send HTTP requests, including to internal addresses. Only http:// and https:// URLs are allowed. Enable it only when you trust everyone holding the admin token, and restrict the runtime host’s outbound network access. ORBIT has no shell or code execution feature.

Redaction

  • Runtime logs never include inputs, outputs or error details.
  • orbit inspect <id>, orbit logs --json and the output line of orbit status replace the value of every key whose name contains, case-insensitively, password, passwd, secret, token, api_key, apikey, authorization, credential, private_key, card_number or cvv with "***". The protocol’s own token field is not redacted, because it is an opaque dispatch id.
  • The HTTP API returns values unredacted to admin callers. Treat history as sensitive data, and keep secrets out of workflow inputs and step outputs where you can: pass references (a vault path, a customer id) instead.
  • The PostgreSQL password in --db is redacted in logs.

Production checklist

  1. Set --token (or ORBIT_TOKEN) to a value from orbit token, stored in a secret manager.
  2. Give workers per-worker identities (--workers-file) with the narrowest tasks patterns, or at least a shared --worker-token. Never give workers the admin token.
  3. Give applications that start or follow executions client identities ([[client]]) with the narrowest workflows patterns and actions, and keep the admin token for operators and deployment pipelines.
  4. Set resource limits (--max-active-executions, --max-fan-out, --max-history-events, --max-payload-bytes) that fit your largest legitimate workload.
  5. Serve TLS with --tls-cert and --tls-key. Use mutual TLS (--tls-client-ca) for worker identities where your PKI allows it.
  6. Use sslmode=require for PostgreSQL, with ORBIT_DB_CA_CERT for a private CA.
  7. Keep the HTTP adapter disabled unless you need it.
  8. Do not pass --insecure.
  9. Restrict network access to the API port to the hosts that need it. /v1/metrics requires the admin token, so give your monitoring system a way to send it, or scrape through a proxy that adds it.
  10. Decide whether operators should use the web UI from a browser; if not, pass --no-ui.
  11. Protect the database and its backups as sensitive data: they contain every input and output.
  12. Rotate a token by changing the server’s configuration (or the workers file), restarting the server, and giving the new token to its holders. Requests with the old token get 401, so restart each worker with its new token.

To report a vulnerability, follow the security policy: use GitHub’s private vulnerability reporting, not a public issue.

See also

Observability

The runtime exposes three kinds of signals: structured logs on stderr, Prometheus metrics at /v1/metrics, and OpenTelemetry traces exported over OTLP/HTTP. Each execution’s full history is also available on demand through the CLI and the API. This page describes each signal, every metric, the span structure and attributes of traces, and how to configure them.

Logs

The server logs to stderr. Choose the format with --log-format:

text (default):

2026-11-15T09:38:33.954506Z  INFO orbit_runtime::engine: recovered active executions from history executions=1 in_flight=0
2026-11-15T09:38:33.954823Z  INFO orbit: starting ORBIT runtime db=/var/lib/orbit/orbit.db backend="sqlite" http_adapter=false auth=false ui=true
2026-11-15T09:38:33.955798Z  INFO orbit_runtime::server: ORBIT runtime listening addr=127.0.0.1:7233 tls=false ui=true
2026-11-15T09:38:34.957450Z  INFO orbit_runtime::engine: worker registered worker_id=py-1 runtime=python tasks=1
2026-11-15T09:38:34.990494Z  INFO orbit_runtime::engine: execution started execution_id=trace-1 workflow=Plain version=1.0.0
2026-11-15T09:38:35.075550Z  INFO orbit_runtime::engine: execution completed execution_id=trace-1

json, one object per line, for log pipelines:

{"timestamp":"2026-11-15T18:33:17.685910Z","level":"INFO","fields":{"message":"starting ORBIT runtime","db":"orbit.db","backend":"sqlite","http_adapter":false,"auth":false,"ui":true},"target":"orbit"}
{"timestamp":"2026-11-15T18:33:17.687448Z","level":"INFO","fields":{"message":"ORBIT runtime listening","addr":"127.0.0.1:7233","tls":false,"ui":true},"target":"orbit_runtime::server"}
{"timestamp":"2026-11-15T09:38:19.759674Z","level":"INFO","fields":{"message":"shutting down"},"target":"orbit"}

Log levels and filtering

ORBIT_LOG sets the filter, in tracing-subscriber EnvFilter syntax. The default is info. Setting ORBIT_LOG replaces the default, so include a base level:

ORBIT_LOG=info,orbit_runtime=debug orbit server   # every committed event at debug level
ORBIT_LOG=warn orbit server                       # warnings and errors only

At debug, every committed event is logged:

DEBUG orbit_runtime::engine: event execution_id=dbg-1 seq=3 event="StepScheduled" step="work"

Log messages

LevelMessageFields
INFOstarting ORBIT runtimedb (password redacted), backend, http_adapter, auth, ui
INFOORBIT runtime listeningaddr, tls, mtls (with TLS), ui
INFOgRPC worker protocol listeningaddr, tls
INFOexporting OpenTelemetry tracesendpoint, service
INFOrecovered active executions from historyexecutions, in_flight
INFOregistered workflow definitionworkflow, version, hash
INFOexecution startedexecution_id, workflow, version
INFOexecution completed, execution cancelledexecution_id
WARNexecution failedexecution_id, code, error, compensation
WARNexecution timed outexecution_id
WARNstep failedexecution_id, step, code, error
INFOretry scheduledexecution_id, step, attempt, delay_ms
WARNattempt outcome uncertain; redeliveringexecution_id, step, attempt, reason
INFOcancelling child workflow because its parent no longer needs itexecution_id
INFOworker registeredworker_id, runtime, tasks
INFOworker unregisteredworker_id
WARNreconnected to PostgreSQL
ERRORPostgreSQL reconnect failederror
WARNOTLP collector rejected spansstatus
WARNcannot export spans to the OTLP collectorerror
WARNOTLP export queue is full; dropping spans
ERRORtick failed, recording built-in task result failederror
INFOshutting down
DEBUGeventexecution_id, seq, event, step

Logs never include workflow inputs, step inputs, outputs or error details beyond the error code and message.

Prometheus metrics

GET /v1/metrics returns Prometheus text format (content-type: text/plain; version=0.0.4). It requires the admin token when authentication is enabled.

# prometheus.yml
scrape_configs:
  - job_name: orbit
    metrics_path: /v1/metrics
    scheme: https
    authorization:
      type: Bearer
      credentials_file: /etc/prometheus/orbit-token
    tls_config:
      ca_file: /etc/prometheus/orbit-ca.pem
    static_configs:
      - targets: ["orbit.internal:7233"]

Metric reference

The metrics have no labels, except reason on orbit_schedules_skipped_total. Counters are derived from committed events and from schedule fires on this server, are kept in memory, and start from zero when the server starts; Prometheus handles the reset in rate() and increase().

MetricTypeIncremented byMeaning
orbit_executions_started_totalcounterWorkflowCreatedExecutions started, including child workflows. A new run after continue_as_new is not counted.
orbit_executions_completed_totalcounterWorkflowCompletedExecutions completed.
orbit_executions_failed_totalcounterWorkflowFailedExecutions failed.
orbit_executions_cancelled_totalcounterWorkflowCancelledExecutions cancelled.
orbit_executions_timed_out_totalcounterWorkflowTimedOutExecutions timed out.
orbit_tasks_dispatched_totalcounterStepDispatchedTask attempts dispatched, including redeliveries, child workflow starts and built-in tasks.
orbit_tasks_succeeded_totalcounterStepSucceededUnits that succeeded, including compensations and completed for steps.
orbit_task_attempts_failed_totalcounterStepAttemptFailedFailed attempts, whether or not they are retried.
orbit_task_attempts_uncertain_totalcounterStepAttemptUncertainAttempts with an unknown outcome, redelivered.
orbit_retries_scheduled_totalcounterRetryScheduledRetries scheduled.
orbit_steps_skipped_totalcounterStepSkippedSteps skipped by when conditions.
orbit_stale_results_totalcounterStaleResultIgnoredDuplicate or late results ignored.
orbit_compensations_started_totalcounterCompensationStartedExecutions that started compensating.
orbit_compensations_failed_totalcounterCompensationFailedCompensations that failed permanently (one per failed compensation).
orbit_events_committed_totalcounterevery eventHistory events committed.
orbit_executions_continued_as_new_totalcounterWorkflowContinuedAsNewRuns that ended with continue_as_new.
orbit_schedules_fired_totalcountera schedule startExecutions this server started for a schedule, including triggers. A start of an id that already existed is not counted. In shared mode, the sum over the servers.
orbit_schedules_skipped_total{reason="overlap"}countera schedule occurrence or triggerStarts skipped by the skip overlap policy.
orbit_schedules_skipped_total{reason="missed"}countera schedule occurrenceOccurrences that passed while no server could fire them and that the catch-up policy did not start.
orbit_active_executionsgaugeExecutions not yet finished.
orbit_worker_sessionsgaugeWorker sessions the runtime remembers. A silent session is forgotten after four session timeouts.

Example output:

# HELP orbit_executions_started_total Executions started
# TYPE orbit_executions_started_total counter
orbit_executions_started_total 8
# HELP orbit_executions_completed_total Executions completed
# TYPE orbit_executions_completed_total counter
orbit_executions_completed_total 2
# HELP orbit_executions_failed_total Executions failed
# TYPE orbit_executions_failed_total counter
orbit_executions_failed_total 4
...
# HELP orbit_active_executions Executions not yet finished
# TYPE orbit_active_executions gauge
orbit_active_executions 0
# HELP orbit_worker_sessions Registered worker sessions
# TYPE orbit_worker_sessions gauge
orbit_worker_sessions 2

Suggested alerts

ConditionExpression
Executions failingrate(orbit_executions_failed_total[5m]) > 0
Compensation failing (needs a person)increase(orbit_compensations_failed_total[15m]) > 0
Workers going silentrate(orbit_task_attempts_uncertain_total[5m]) > 0
No workers connectedorbit_worker_sessions == 0
Runtime downup{job="orbit"} == 0

For backlog per task (tasks scheduled with no worker to take them), use queue_timeout on steps and alert on failures with code QUEUE_TIMEOUT from your logs, since the metrics have no per-task labels.

OpenTelemetry tracing

The runtime can export one trace per execution over OTLP/HTTP with JSON encoding, which the OpenTelemetry Collector, Jaeger, Grafana Tempo, Honeycomb and other OTLP backends accept.

Configuration

SettingMeaning
--otlp-endpoint URL or OTEL_EXPORTER_OTLP_ENDPOINTBase URL of the collector, for example http://localhost:4318. Spans are posted to <URL>/v1/traces. Export is off when unset.
OTEL_EXPORTER_OTLP_HEADERSExtra request headers as key=value pairs separated by commas, for example an API key.
OTEL_SERVICE_NAMEservice.name resource attribute. Default orbit-runtime.
OTEL_SERVICE_NAME=orbit-prod \
OTEL_EXPORTER_OTLP_HEADERS="x-api-key=$TRACING_KEY" \
orbit server --token "$ORBIT_TOKEN" --otlp-endpoint http://otel-collector:4318

Every request carries the resource attributes service.name and service.version (the runtime version), and the instrumentation scope orbit-runtime.

Span structure

trace  (one per top-level execution)
└── workflow OrderProcessing                     root span, execution start to end
    ├── step validate_order                      one span per attempt
    ├── step analyze_risk        (error)         attempt 1 failed
    ├── step analyze_risk                        attempt 2
    ├── step reserve_inventory
    ├── step process_payment     (error)
    ├── step compensate:reserve_inventory        compensation
    └── step ship                                child workflow step
        └── workflow Shipping                    child execution, same trace
            ├── step quote
            └── step book
  • Root span workflow <Name>: from WorkflowCreated to the terminal event. Exported when the execution finishes.
  • Unit spans step <unit key>: one per attempt of a task, from dispatch to result; one per sleep timer, from start to fire; one per compensation attempt (step compensate:<unit>). Exported when the attempt ends: succeeded, failed or uncertain. Parent: the root span.
  • Child workflows join their parent’s trace. The child’s root span is a child of the parent step’s span.
  • Attempts still running when an execution ends are not exported, because they have no end time.

All spans have kind INTERNAL (1). A span’s status is OK (1) or ERROR (2) with a message:

SpanError whenStatus message
Unitthe attempt failed<code>: <message>, for example CARD_DECLINED: card was declined
Unitthe outcome is uncertainoutcome unknown: <reason>
Rootthe execution did not complete<code>: <message> of the execution error, or the status (CANCELLED, TIMED_OUT)

Attributes

AttributeTypeOnMeaning
orbit.execution_idstringallExecution id.
orbit.workflowstringallWorkflow name.
orbit.workflow_versionstringrootWorkflow version.
orbit.statusstringrootFinal status, for example COMPLETED.
orbit.compensationstringrootnone, completed or failed, when the execution was aborted.
orbit.unitstringunitUnit key, for example reserve[2] or compensate:reserve_inventory.
orbit.attemptintegerunitAttempt number.
orbit.outcomestringunitsucceeded, failed, uncertain or timer fired.
orbit.worker_idstringunitWorker that ran the attempt, when it was dispatched.
orbit.redeliverybooleanunitPresent and true when the attempt was a redelivery.

Identifiers

Trace and span ids are derived from execution and unit identifiers with SHA-256, so a runtime restart never produces duplicate or orphaned spans:

IdDerived from
Trace id (16 bytes)orbit-trace:<top-level execution id>
Root span id (8 bytes)orbit-span:<execution id>
Unit span id (8 bytes)orbit-span:<execution id>/<unit key>#<dispatch number>

Because the trace id is a function of the execution id, you can compute it to find an execution’s trace. In Python:

import hashlib
trace_id = hashlib.sha256(b"orbit-trace:order-1002").hexdigest()[:32]

Example span

A unit span as received by a collector:

{
  "traceId": "6d61feb6ff6a03af93dec3e7836b3492",
  "spanId": "5e8f395157c28e15",
  "parentSpanId": "4c82e46ef6bc6222",
  "name": "step work",
  "kind": 1,
  "startTimeUnixNano": "1794735515047000000",
  "endTimeUnixNano": "1794735515072000000",
  "attributes": [
    {"key": "orbit.execution_id", "value": {"stringValue": "trace-1"}},
    {"key": "orbit.workflow", "value": {"stringValue": "Plain"}},
    {"key": "orbit.unit", "value": {"stringValue": "work"}},
    {"key": "orbit.attempt", "value": {"intValue": "1"}},
    {"key": "orbit.outcome", "value": {"stringValue": "succeeded"}},
    {"key": "orbit.worker_id", "value": {"stringValue": "py-1"}}
  ],
  "status": {"code": 1}
}

Export behavior

  • Spans are exported only after the events they describe are committed.
  • Export runs on a background thread and never blocks the engine. Spans queue in memory (up to 1024 batches) and are sent in requests of up to about 512 spans, with a 10-second timeout.
  • If the collector is unreachable or rejects a request, the spans of that request are dropped and a warning is logged. If the queue is full, new spans are dropped. Tracing is best-effort; the history is the complete record.
  • Timestamps have millisecond precision.
  • Worker SDKs do not create spans of their own in this version. Correlate worker logs with traces through execution_id, step_id and attempt, which every task carries.

Execution history

For any single execution, the history is the most detailed record: every event, in order, with its inputs and outputs. Use orbit status, orbit logs, orbit inspect and orbit ps, or the HTTP API. See Executions and history.

See also

Web UI

orbit server serves an operations UI at /ui/ on the same address as the HTTP API. It shows the server’s health, executions (including the earlier runs of executions that continued as new), their steps, history and graph, registered workflow definitions, schedules and connected workers. It can run queries, send signals, cancel executions, and create, pause, resume, trigger and delete schedules. This page describes each view, how sign-in and permissions work, the security headers, behaviour in a cluster, and the limitations.

The UI is three static files (HTML, CSS, JavaScript) compiled into the orbit binary. It loads nothing from the network other than the server it came from, needs no build step, and works offline. Every value it shows comes from the HTTP API, called with the token you sign in with, so it can see and do exactly what that token can through the API.

Opening it

Start a server and open its address in a browser:

orbit server
http://127.0.0.1:7233/

GET / redirects browsers (requests whose Accept header contains text/html) to /ui/. Other clients get the API’s JSON 404:

$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -H "accept: text/html" http://127.0.0.1:7391/
302 http://127.0.0.1:7391/ui/
$ curl -s http://127.0.0.1:7391/
{"error":{"code":"NOT_FOUND","message":"no such endpoint; the API is under /v1/ and the web UI under /ui/"}}

To turn the UI off, start the server with --no-ui (or ORBIT_NO_UI=true). /ui/ and the / redirect then do not exist; the API is unchanged.

Signing in

When the server has no credentials configured (orbit server on a loopback address), the UI opens directly; the top bar says No authentication configured.

Otherwise the UI asks for a token. Enter either:

  • the admin token (--token / ORBIT_TOKEN): every view and action; or
  • a client identity token: only the workflows and actions granted to that client.

The UI checks the token by reading GET /v1/executions?limit=1. A token the server does not accept is refused with Token rejected. Worker credentials cannot sign in: the API refuses them on the execution endpoints, and the UI shows that refusal.

The token is kept in the tab’s sessionStorage: it is gone when the tab is closed, and Sign out removes it. It is sent with every request as Authorization: Bearer <token> and never appears in a URL or a cookie. If the server later refuses it with 401 (for example after the token was rotated), the UI returns to the sign-in page.

The UI does not authenticate with TLS client certificates. Its requests are made with credentials: "omit", so a certificate installed in the browser is not sent; use a token.

Client identities

A client identity sees the same data the API gives it:

ViewWhat a client sees
OverviewServer health (open to everyone), counts of the executions it may read, and its recent failures. The Workers panel shows Not permitted.
ExecutionsOnly executions of workflows it may read (read action). Opening another execution shows Not permitted.
Execution detailQueries, signals and cancel each work only if the action is granted. A refused action shows Not permitted with the API’s message, for example client `billing` may not cancel executions of `OrderApproval` .
WorkflowsOnly definitions of workflows it may read.
SchedulesOnly schedules of workflows it may schedule (the schedule action); without that action the list is empty, and opening a schedule shows Not permitted, for example client `billing` may not manage schedules of `OrderProcessing` . The executions a schedule started are listed only if it may also read them.
WorkersNot permitted (GET /v1/workers is an administrative endpoint).

The top bar shows which kind of credential is signed in, for example Admin token or Client billing (restricted).

Overview

Overview of a server with two workers and one failed execution

PanelContentsSource
ServerStatus, runtime and API versions, role (single, leader, standby or shared), node id, term and leader in standby mode, active executions, worker sessions. In shared mode, also the executions owned and tasks claimed by this node, and a table of every node with its address, liveness and last heartbeat.GET /v1/health
WorkersOnline and offline workers, tasks in flight, and the tasks the online workers serve.GET /v1/workers
SchedulesActive and paused schedules, schedules whose latest start failed (last_error), and the next fire time. Each count links to the Schedules view.GET /v1/schedules
Executions by statusOne count per status, each linking to the filtered execution list. Counts are capped at 1000 per status (shown as 1000+).GET /v1/executions?status=...&limit=1000
Recent failuresThe eight most recently updated FAILED or TIMED_OUT executions, with their error code and message.the same lists, then GET /v1/executions/{id}

Executions

Execution list

The list shows the execution id, workflow and version, status, start time, last update and duration (to now, for running executions), newest first, 25 per page. Above it:

  • Status and Workflow filter on the server (?status= and ?workflow= of GET /v1/executions).
  • Search matches any part of an execution id among the newest 1000 executions that pass the filters. If nothing matches, the text is looked up as a complete id with GET /v1/executions/{id}, so an older execution can still be found by its full id.
  • Previous and Next page through the list.

An execution that continued as new is listed once, with the status of its current run. CONTINUED_AS_NEW is never the status of a listed execution (the API reports the current run), so it is not offered as a filter or counted on the overview; it appears on the earlier runs of an execution, below.

The filters and page are kept in the URL (for example #/executions?status=FAILED&workflow=OrderProcessing), so a filtered list can be bookmarked or shared.

Execution detail

Execution waiting for a review signal, after running the status query

The detail page of one execution has these sections:

SectionContents
ExecutionStatus, run number (from run 2 on, with a link to the previous run), workflow and version, definition hash, start and finish times of the current run, duration, time since the last update, number of history events. For an execution started by a schedule, a link to the schedule. For a child execution, a link to its parent and the parent’s step. While the execution winds down: the abort reason, compensation outcome and running on handler block.
ActionsOne Run button per declared query, one form per declared signal, and Cancel execution. See below.
Input and resultThe input, the output once completed, and the error if it failed, as JSON.
GraphThe workflow’s steps drawn from the definition, coloured by the status of each step in this execution. See Graph.
StepsOne row per unit: every step, every element of a for step (reserve[0]), every compensation (compensate:charge), every outcome handler step (on_failure:tell_customer), and every event handler instance and its steps (on_event:location@4, on_event:location@4:tell). Steps that have not started yet are listed as not started. Columns: action (task name, compute, await <signal>, sleep, workflow <name>), status, attempt (and the number of deliveries when an attempt was redelivered), the worker that ran it, start and finish times, time taken, and details: the last error, when a sleep or an await times out, when a retry is due, and the unit’s input, output and idempotency key (expandable).
Child executionsFor workflow steps that started, the child execution id (<parent id>.<step>, or <parent id>.<step>.<index> for for elements) with a link, its workflow and status.
Signals receivedEvery signal in arrival order: history sequence number, name, time received, the unit it was delivered to, and its payload.
HistoryEvery history event of the current run: sequence number, time since the first event, event type, the unit or signal it concerns, and the absolute time. Click an event to see its full JSON.

While the execution is running, the page reloads it and its history every 2 seconds (untick Auto-refresh to stop). Sections are redrawn only when a new event was recorded; the signal forms keep what you typed, and opened history events stay open.

Runs

An execution that continued as new shows its current run number next to its status (run 4), and a Run selector in the top right lists every run. The page itself always shows the current run, from GET /v1/executions/{id}; when the execution moves on to its next run while the page is open, the page switches to it.

Choosing an earlier run opens it at #/executions/<id>?run=<N>, so it can be bookmarked. An earlier run is read from its archived history, GET /v1/executions/{id}/history?run=N, and shown read-only:

Run 1 of an Inbox execution: it took one signal, carried two to run 2, and continued as new

SectionContents
RunRun N of M, the status (CONTINUED_AS_NEW for every run but the last), workflow, definition hash, start and end times, duration, number of events, and a link to the run it continued as.
Input and next inputThe run’s input (from WorkflowCreated) and the input of the next run (from WorkflowContinuedAsNew).
Graph, StepsThe graph and step table, with step states rebuilt from the run’s history.
Signals received, HistoryThe signals the run received (signals no step took were carried into the next run) and its full history.

There are no actions on an earlier run: queries, signals and cancellation go to the current run.

Queries

Run calls GET /v1/executions/{id}/queries/{name} and shows the result as JSON under the query’s declared result type. On a finished execution, queries are answered from its final state.

Signals

The form is generated from the signal’s declared payload type. Required fields are marked *.

Field typeControl
BoolA list: true, false, and null (optional field) or default (...) (field with a default)
Int, Float, DurationA number input
String, UUID, Timestamp, BytesA text input
anything else (records, lists, maps, JSON)A JSON text area

An empty field is left out of the payload, so the server applies the field’s default, or leaves an optional field null. The payload is validated by the server, as for any other client: an invalid payload is refused with SIGNAL_PAYLOAD_INVALID and the server’s message is shown under the form. An accepted signal shows its history sequence number, and the page refreshes.

Cancel

Cancel execution opens a confirmation with an optional reason, recorded in the CancelRequested event. Confirm cancel calls POST /v1/executions/{id}/cancel. The button is disabled once the execution has finished.

Graph

The graph is drawn from the workflow definition (GET /v1/workflows/{name}/{version}), the same on the execution page and the workflow page:

Checkout definition: two steps, a compensation, and on cancel and on failure handlers

  • Steps are placed in columns by the length of their longest dependency chain, so a step is always to the right of every step it depends on. Arrows are dependencies (after, and references to other steps’ outputs).
  • A step with a compensate block has a dashed node under it, connected by a dashed line.
  • Outcome handlers (on failure, on cancel, on timeout) and event handlers (on event <signal>) are drawn as separate labelled lanes below the main steps, each with its own dependencies.
  • The second line of each node is the action: task name, compute, await <signal>, sleep <duration>, workflow <name>, prefixed with for <variable>: for fan-out steps.
  • A workflow with continue_as_new has a node with a double border after its final steps (the steps nothing depends on), showing continue as new and its condition (when ..., or always); hovering shows the next run’s input. A dashed edge goes from it back to the first steps. On an earlier run the node is coloured continued as new (violet); on a current run that completed, it is grey (not taken).
  • On an execution page, nodes are coloured by status: green succeeded, blue running, dispatched or waiting, amber retrying or uncertain, red failed, grey skipped or cancelled, unfilled not started. A for step takes the status of its own unit, or else of its elements. An event handler step takes the status of its latest instance, and its name is followed by the number of instances (tell ×2). Hovering over a node shows its status and attempt.

Wide graphs scroll horizontally inside their panel.

Workflows

The list shows every registered definition: name, version, description, number of steps, the tasks it uses (including child workflows as workflow:<name>), the start of the definition hash, and a link to its executions.

A definition’s page shows its version, description, definition hash, IR version, workflow timeout and limits, links to its other registered versions, the input and output fields (type, default, or required), signals with their payload types, queries with their result types, the graph, a table of all steps (block, action, dependencies, whether it has a when condition, retry policy, timeout, compensation task), and the task contracts it was compiled against (name, version, input and output types, compensation, built-in or child workflow). For a workflow with continue_as_new, a Continue-as-new panel shows the condition and, field by field, the next run’s input (fields left out take their defaults).

Schedules

Three schedules: a cron schedule in Europe/Berlin that was triggered once, an interval schedule, and a paused one

The list shows every schedule the credential may manage (GET /v1/schedules): its id, workflow and version (latest when the version is not pinned), timing (cron "<expression>" <time zone> or every <interval>), state, the next fire time (including jitter), the latest occurrence handled, the latest execution it started (with a link), how many executions it started and how many occurrences it skipped, and the error of the latest failed start. The state is active, paused, or ended (past end_at, no next occurrence). The list refreshes every 5 seconds.

A schedule’s page has its settings (workflow, timing, overlap and catch-up policies, start and end, jitter, description), its state (next fire time and the next five occurrences, last fire, last execution, counts, last error), its input, and the executions it started: the executions of its workflow whose id starts with <schedule id>:, newest first. The page refreshes every 3 seconds.

A 15-second interval schedule with the executions it started

ActionAPI call
Pause, ResumePOST /v1/schedules/{id}/pause and /resume
Trigger nowPOST /v1/schedules/{id}/trigger. The new execution’s id is shown with a link. When the overlap policy skips the start, the page says so.
Delete schedule…Opens a confirmation; Confirm delete calls DELETE /v1/schedules/{id}. Executions the schedule started are not affected.

New schedule on the list opens a form with the fields of POST /v1/schedules: id, workflow, version, cron expression or interval, time zone, overlap and catch-up policies, input (JSON), description, and whether to create it paused. The form checks only that the input is JSON; everything else is validated by the server, and its error is shown under the form, for example INVALID_SCHEDULE (400). cron `0 25 * * *`: `25` is out of range for the hour field (0-23). Changing an existing schedule is not possible in the UI; use orbit schedule update or PATCH /v1/schedules/{id}.

An execution started by a schedule links back to it from its header and its Execution panel.

Workers

One row per worker id: online or offline, runtime, SDK, number of sessions, tasks in flight, the tasks it serves (hover a task for its contract version), when it registered and when it was last seen. The list refreshes every 5 seconds. Source: GET /v1/workers.

Security

The UI’s files are served without authentication, so the sign-in page can load before you have entered a token. They are the same for every server and contain no data, configuration or secrets. All data is fetched from /v1/* with your token, so serving the UI adds no new endpoint or privilege. See Security: web UI.

Every UI response carries these headers:

$ curl -sI http://127.0.0.1:7391/ui/
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-security-policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: no-referrer
cross-origin-opener-policy: same-origin
cross-origin-resource-policy: same-origin
permissions-policy: camera=(), microphone=(), geolocation=(), payment=()
cache-control: no-cache
content-length: 1317
date: Sun, 15 Nov 2026 18:35:41 GMT
  • The page has no inline script or style; the policy allows scripts and styles only from the server itself, and network requests only to the server itself.
  • frame-ancestors 'none' and X-Frame-Options: DENY stop other sites from embedding the UI.
  • Values from executions and definitions (ids, inputs, outputs, errors, payloads, step names) are inserted as text, never interpreted as markup.
  • The UI shows values unredacted, as the API does. Anyone holding a token that may read an execution can see its inputs and outputs.

Clusters

The UI is served by every server, whatever its role, because it is answered before the cluster logic.

Standby mode (Running several servers): on the leader, everything works. On a standby, the read views work, because a standby answers GET requests from the database; the Server panel shows standby, its term and the current leader. Running a query also works. Sending a signal, cancelling, or creating, pausing, resuming, triggering or deleting a schedule from a standby is refused with 503 NOT_LEADER, and the UI shows the server’s message, which names the leader and its address; open the leader’s UI to act, or reach the UI through a load balancer that sends traffic to the ready server (/v1/ready), as for the API. Worker sessions are held by the leader, so on a standby the Workers view lists the registered workers as offline, and the active execution and worker session counts in the Server panel are 0; check those on the leader.

Shared mode: every server is active. Signals and cancellations sent from any node are forwarded to the node that owns the execution. The Server panel shows shared, the node’s own executions and claimed tasks, and every node of the cluster with its liveness.

Limitations

  • The API returns at most 1000 executions per request and has no offset parameter. The list therefore pages through the newest 1000 executions that match the filters, the status counts stop at 1000, and the id search covers the newest 1000 (an older execution can be opened by its complete id).
  • The UI polls; it does not receive pushed updates. Intervals: overview 5 s, execution list 3 s, execution detail 2 s while running, schedule list 5 s, schedule page 3 s, workers 5 s.
  • The history is shown in full; executions with very many events make the detail page slower.
  • The step states of an earlier run are rebuilt in the browser from its history; the API has no state endpoint for earlier runs.
  • The UI cannot start executions, change a schedule, register workflows or change server settings. Use the CLI or the API.
  • Times are shown in the browser’s time zone.
  • The UI is served at /ui/ of the server’s own address. Serving it under a path prefix of a reverse proxy has not been tested.
  • It is tested with Chromium only (Playwright, in tests/e2e/test_e2e.py, class WebUI). It needs a current browser (it uses ES2021 JavaScript).

Performance

This page gives measured throughput and latency for the runtime, explains what bounds them, and shows how to reproduce the numbers on your own hardware. The figures are from two benchmark programs in the repository. They measure the runtime itself: the task handlers do no work, so a real deployment is usually limited by its workers instead.

What was measured

Each execution runs a workflow with three sequential task steps (each step’s input is the previous step’s output), so one execution is three dispatches and three results.

ProgramMeasures
bench_engine (crates/orbit-runtime/examples)The decider and the store only: one thread starts every execution, then plays the worker (poll, complete) until all have completed. No network.
bench_http (crates/orbit-worker/examples)The whole path: executions started over the HTTP API by 4 client threads, and tasks served by 4 Rust SDK workers with 8 handler threads each, over the HTTP worker protocol.

Machine: 4 vCPU (Intel Xeon, 2.1 GHz), 16 GB RAM, Linux, a container’s local disk. PostgreSQL 16 ran on the same machine with its default settings (synchronous_commit = on). ORBIT 0.4.0, release build.

Results

End to end (bench_http, 2000 executions)

StoreExecutions/sSteps/sLatency p50p90p99
In memory (no durability)1210362911 ms15 ms18 ms
SQLite file24473152 ms71 ms101 ms
PostgreSQL111332113 ms151 ms206 ms

Latency is from the execution’s creation to its completion, as recorded in its history (created_at_ms to finished_at_ms), with all 2000 executions started as fast as the clients could.

Engine only (bench_engine)

StoreExecutionsExecutions/sSteps/s
In memory5000446413392
In memory, 10 steps each5000134613464
SQLite file50004251276
PostgreSQL2000138413

Several servers (shared mode)

Throughput of shared mode with one, two and three servers, on the same machine, is in Running several servers: throughput.

What bounds the numbers

  • Durable commits. Every change to an execution is committed to the store before it takes effect: one transaction when a step is scheduled and dispatched, one when its result arrives. With SQLite that is a WAL write with synchronous = FULL (an fsync); with PostgreSQL it is one statement and a WAL flush. On a durable store, throughput is therefore set by how many commits per second the disk can sustain, not by the CPU. Faster disks, or a PostgreSQL server with battery-backed or fast NVMe storage, raise it directly.
  • One engine per server. Commits for all executions on one server are serialized. With PostgreSQL, the round-trip time to the database is part of every commit, so run the database close to the server.
  • Steps, not executions. Throughput in steps per second stays about the same as steps per execution grow (13392 against 13464 above); plan capacity in steps.
  • The number of running executions does not matter. Workers are handed the oldest scheduled step from an index, so the cost of a poll does not grow with the number of executions in flight: the engine benchmark runs at the same rate with 1000 or 5000 executions running at once.

Grouping the commits of several executions into one transaction would raise durable throughput further, and is planned work.

Reproducing

cargo run --release -p orbit-runtime --example bench_engine -- --executions 5000
cargo run --release -p orbit-runtime --example bench_engine -- --store /tmp/bench.db
cargo run --release -p orbit-runtime --example bench_engine -- --store postgres://orbit:orbit@127.0.0.1/orbit_bench
cargo run --release -p orbit-worker --example bench_http -- --executions 2000
cargo run --release -p orbit-worker --example bench_http -- --store /tmp/bench.db

bench_http can also drive a runtime you started yourself, for example a server with your production settings:

orbit server --token admin --worker-token worker --db /var/lib/orbit/bench.db
cargo run --release -p orbit-worker --example bench_http -- --server http://127.0.0.1:7233
OptionDefaultMeaning
--executions2000Executions to run.
--steps3Sequential task steps per execution.
--storememorymemory, a SQLite file path (deleted first), or a PostgreSQL URL (used as is; give it an empty database).
--servernonebench_http only: use this runtime instead of starting one in-process.
--token, --worker-tokenadmin, workerbench_http only: credentials for --server.
--workers, --concurrency4, 8bench_http only: SDK workers, and handler threads per worker.
--clients4bench_http only: threads starting executions.

Each run prints its parameters, throughput and latency. Against an orbit server on a SQLite file, on the machine above:

runtime      http://127.0.0.1:7391
executions   1000 (3 task steps each)
workers      4 x 8 handler threads, 4 client threads
starts       238/s
total        4.25s
throughput   236 executions/s, 707 steps/s
latency      p50 55ms, p90 72ms, p99 101ms (start to completion)

See also

Deployment

The ORBIT runtime is one self-contained binary with no runtime dependencies apart from its database. You can run it from the container image ghcr.io/ajmalx0/orbit, from a release binary under a service manager such as systemd, or from a binary you build yourself. This page covers each option, a Docker Compose setup with PostgreSQL, sizing, and upgrades. Configure security before you expose the runtime; see Security.

Container image

The image is published to the GitHub Container Registry for every release:

TagMeaning
ghcr.io/ajmalx0/orbit:v0.1.0A specific release. Use this in production.
ghcr.io/ajmalx0/orbit:latestThe most recent release.

The image is built for linux/amd64 from the repository’s Dockerfile:

PropertyValue
Basedebian:bookworm-slim with ca-certificates
Binary/usr/local/bin/orbit (release build, stripped)
Userorbit, UID 10001, home /data
Working directory and volume/data
Entrypointorbit
Default commandserver
EnvironmentORBIT_DB=/data/orbit.db, ORBIT_ADDR=0.0.0.0:7233
Exposed ports7233 (HTTP API), 7234 (gRPC, when you set ORBIT_GRPC_ADDR)

Because the image listens on 0.0.0.0, the server refuses to start without a token:

$ docker run --rm ghcr.io/ajmalx0/orbit:v0.1.0
error: refusing to listen on 0.0.0.0:7233 without authentication; pass --token (or ORBIT_TOKEN), or --insecure for trusted networks

docker run

docker volume create orbit-data
docker run -d --name orbit --restart unless-stopped \
  --stop-signal SIGINT --stop-timeout 75 \
  -p 7233:7233 \
  -e ORBIT_TOKEN="$ORBIT_TOKEN" \
  -e ORBIT_WORKER_TOKEN="$ORBIT_WORKER_TOKEN" \
  -v orbit-data:/data \
  ghcr.io/ajmalx0/orbit:v0.1.0 server --log-format json
  • --stop-signal SIGINT makes docker stop shut the server down gracefully with every version. Version 0.1.0 does not handle SIGTERM, the signal docker stop sends by default, and in a container the server is process 1, which ignores signals it has no handler for; later versions handle SIGTERM and their image sets STOPSIGNAL SIGINT. --stop-timeout 75 leaves time for open worker long polls (up to 60 seconds) to finish. Killing the server loses no committed state; the graceful path only avoids interrupting requests.
  • Arguments after the image name replace the default command, so start them with server.
  • The entrypoint is orbit, so the same image runs any CLI command: docker run --rm ghcr.io/ajmalx0/orbit:v0.1.0 --version.

Check it from the host:

curl -s http://127.0.0.1:7233/v1/health
orbit ps --server http://127.0.0.1:7233 --token "$ORBIT_TOKEN"

To use TLS, mount the certificates and the workers file read-only:

docker run -d --name orbit --restart unless-stopped \
  --stop-signal SIGINT --stop-timeout 75 \
  -p 7233:7233 \
  -e ORBIT_TOKEN="$ORBIT_TOKEN" \
  -e ORBIT_TLS_CERT=/etc/orbit/tls/server.pem \
  -e ORBIT_TLS_KEY=/etc/orbit/tls/server.key \
  -e ORBIT_WORKERS_FILE=/etc/orbit/workers.toml \
  -v orbit-data:/data \
  -v /etc/orbit:/etc/orbit:ro \
  ghcr.io/ajmalx0/orbit:v0.1.0

The files must be readable by UID 10001.

Docker Compose with PostgreSQL

# compose.yaml
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: orbit
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: orbit
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U orbit -d orbit"]
      interval: 5s
      retries: 10

  orbit:
    image: ghcr.io/ajmalx0/orbit:v0.1.0
    restart: unless-stopped
    stop_signal: SIGINT
    stop_grace_period: 75s
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      ORBIT_DB: postgres://orbit:${POSTGRES_PASSWORD}@postgres:5432/orbit?sslmode=disable
      ORBIT_TOKEN: ${ORBIT_TOKEN}
      ORBIT_WORKER_TOKEN: ${ORBIT_WORKER_TOKEN}
      ORBIT_GRPC_ADDR: 0.0.0.0:7234
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
      OTEL_SERVICE_NAME: orbit
    command: ["server", "--log-format", "json"]
    ports:
      - "7233:7233"
      - "7234:7234"

  payments-worker:
    build: ./workers/payments
    restart: unless-stopped
    environment:
      ORBIT_SERVER: http://orbit:7233
      ORBIT_WORKER_TOKEN: ${ORBIT_WORKER_TOKEN}
    depends_on: [orbit]

volumes:
  pg-data:

Put POSTGRES_PASSWORD, ORBIT_TOKEN and ORBIT_WORKER_TOKEN in an .env file next to compose.yaml, or in your secret manager. sslmode=disable is appropriate here only because PostgreSQL is on the same private Compose network; use sslmode=require for a database on another host. Remove the OTEL_* lines if you do not run a collector. The image contains no HTTP client such as curl or wget, so probe /v1/health from outside the container (a load balancer, an orchestrator probe or your monitoring system) rather than with a Compose healthcheck.

Kubernetes

Without cluster mode, run exactly one replica (for two or more, see Kubernetes with two replicas):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: orbit
spec:
  replicas: 1
  strategy:
    type: Recreate            # never two servers on one database
  selector:
    matchLabels: { app: orbit }
  template:
    metadata:
      labels: { app: orbit }
    spec:
      securityContext:
        runAsUser: 10001
        fsGroup: 10001
      containers:
        - name: orbit
          image: ghcr.io/ajmalx0/orbit:v0.1.0
          args: ["server", "--log-format", "json"]
          envFrom:
            - secretRef: { name: orbit }   # ORBIT_TOKEN, ORBIT_WORKER_TOKEN, ORBIT_DB
          ports:
            - { containerPort: 7233, name: http }
          readinessProbe:
            httpGet: { path: /v1/health, port: http }
          livenessProbe:
            httpGet: { path: /v1/health, port: http }
            periodSeconds: 10
          lifecycle:
            preStop:
              exec: { command: ["/bin/sh", "-c", "kill -INT 1"] }
          volumeMounts:
            - { name: data, mountPath: /data }
      volumes:
        - name: data
          persistentVolumeClaim: { claimName: orbit-data }

The preStop hook sends SIGINT for a graceful shutdown; without it, the pod is killed after its termination grace period, which is also safe. With PostgreSQL, set ORBIT_DB in the secret and drop the volume. With SQLite, use a ReadWriteOnce volume on block storage.

Kubernetes with two replicas

With PostgreSQL you can run two or more replicas in cluster mode: one pod is elected leader, the others stand by and take over within about a second if it fails. A readiness probe on /v1/ready keeps standbys out of the Service endpoints, so clients and workers always reach the leader through the Service address. A StatefulSet gives every pod a stable name, used as its node id, and a headless Service gives it a stable address to advertise.

apiVersion: v1
kind: Service
metadata:
  name: orbit
spec:
  selector: { app: orbit }
  ports:
    - { name: http, port: 7233, targetPort: http }
---
apiVersion: v1
kind: Service
metadata:
  name: orbit-nodes            # headless: orbit-0.orbit-nodes, orbit-1.orbit-nodes
spec:
  clusterIP: None
  publishNotReadyAddresses: true
  selector: { app: orbit }
  ports:
    - { name: http, port: 7233 }
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: orbit
spec:
  serviceName: orbit-nodes
  replicas: 2
  podManagementPolicy: Parallel
  selector:
    matchLabels: { app: orbit }
  template:
    metadata:
      labels: { app: orbit }
    spec:
      securityContext:
        runAsUser: 10001
      containers:
        - name: orbit
          image: ghcr.io/ajmalx0/orbit:v0.2.0
          args: ["server", "--cluster", "--log-format", "json"]
          env:
            - name: POD_NAME
              valueFrom: { fieldRef: { fieldPath: metadata.name } }
            - { name: ORBIT_NODE_ID, value: "$(POD_NAME)" }
            - { name: ORBIT_ADVERTISE_ADDR, value: "http://$(POD_NAME).orbit-nodes:7233" }
          envFrom:
            - secretRef: { name: orbit }   # ORBIT_TOKEN, ORBIT_WORKER_TOKEN, ORBIT_DB (postgres://...)
          ports:
            - { containerPort: 7233, name: http }
          readinessProbe:
            httpGet: { path: /v1/ready, port: http }
            periodSeconds: 1
            failureThreshold: 1
          livenessProbe:
            httpGet: { path: /v1/health, port: http }
            periodSeconds: 10
          lifecycle:
            preStop:
              exec: { command: ["/bin/sh", "-c", "kill -INT 1"] }

Point workers and applications at http://orbit:7233. A rolling update replaces one pod at a time; when the leader’s pod stops, a standby is elected and the Service switches to it after its next readiness probe. Expect workers to log a few failed polls and register again. See Running several servers for the election, fencing and failover details.

Release binaries

Every GitHub release has prebuilt binaries and a SHA256SUMS file:

AssetPlatform
orbit-x86_64-unknown-linux-gnu.tar.gzLinux x86_64 (glibc)
orbit-aarch64-unknown-linux-gnu.tar.gzLinux arm64 (glibc)
orbit-x86_64-apple-darwin.tar.gzmacOS Intel
orbit-aarch64-apple-darwin.tar.gzmacOS Apple silicon
orbit-x86_64-pc-windows-msvc.zipWindows x86_64

The install script downloads the right archive for Linux and macOS, verifies its checksum and installs it:

curl -fsSL https://github.com/AjmalX0/Orbit/releases/latest/download/install.sh | sh
# a specific version, system-wide:
curl -fsSL https://github.com/AjmalX0/Orbit/releases/latest/download/install.sh \
  | sudo env ORBIT_VERSION=v0.1.0 sh

It installs to ~/.local/bin, or /usr/local/bin as root; set ORBIT_INSTALL_DIR to change that, and ORBIT_DOWNLOAD_URL to use a mirror. To install by hand:

VERSION=v0.1.0
TARGET=x86_64-unknown-linux-gnu
curl -fsSLO https://github.com/AjmalX0/Orbit/releases/download/$VERSION/orbit-$TARGET.tar.gz
curl -fsSLO https://github.com/AjmalX0/Orbit/releases/download/$VERSION/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS
tar -xzf orbit-$TARGET.tar.gz
sudo install -m 0755 orbit /usr/local/bin/orbit

You can also build from source with cargo install orbit-lang (Rust 1.89 or later).

systemd

Create a system user and directories:

sudo useradd --system --home /var/lib/orbit --shell /usr/sbin/nologin orbit
sudo install -d -o orbit -g orbit -m 0750 /var/lib/orbit
sudo install -d -m 0750 /etc/orbit

Put the secrets in an environment file readable only by root:

# /etc/orbit/orbit.env  (mode 0600)
ORBIT_TOKEN=...
ORBIT_WORKER_TOKEN=...
ORBIT_DB=/var/lib/orbit/orbit.db
ORBIT_ADDR=0.0.0.0:7233
# /etc/systemd/system/orbit.service
[Unit]
Description=ORBIT workflow runtime
After=network-online.target
Wants=network-online.target

[Service]
User=orbit
Group=orbit
EnvironmentFile=/etc/orbit/orbit.env
ExecStart=/usr/local/bin/orbit server --log-format json
KillSignal=SIGINT
TimeoutStopSec=75
Restart=always
RestartSec=2
WorkingDirectory=/var/lib/orbit
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/orbit
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now orbit
journalctl -u orbit -f

KillSignal=SIGINT gives a graceful shutdown. TimeoutStopSec=75 leaves time for open worker long polls, which can last up to 60 seconds. With PostgreSQL, add After=postgresql.service when the database runs on the same host, and remove ReadWritePaths.

Sizing

  • CPU. The engine processes state changes one at a time, so it uses about one core under load. Two cores leave room for TLS, HTTP and the background tick.
  • Memory. Every active (non-terminal) execution is held in memory with its full state; finished executions are not. Memory grows with the number of active executions and the size of their inputs and outputs. Start with 512 MiB and watch orbit_active_executions.
  • Storage. History is never deleted. Each step attempt writes about three events, each the size of its JSON payload; inputs and outputs dominate. Keep large payloads (files, documents) out of workflows and pass references instead.
  • Disk latency. SQLite commits with fsync for every batch of events. Use local SSD storage. For PostgreSQL, latency to the database bounds throughput, so keep it close.
  • Startup time. Recovery replays the history of every active execution before the server listens. Very long-running executions with very long histories make startup slower.
  • Workers. Scale workers horizontally; the runtime does not need more instances for more workers. Each worker holds one long-poll request open most of the time.
  • Session timeout. The default 30 seconds means a crashed worker’s tasks are redelivered after about 30 seconds. Lower it for faster failover, at the cost of more heartbeat traffic (heartbeats are sent every sixth of the timeout).

Upgrades

  1. Read the changelog for the new version.
  2. Back up the database. See Storage.
  3. Stop the server gracefully (SIGINT), replace the binary or image tag, and start it. Workers lose their sessions, register again and retry result delivery; executions continue from their history.

Things that stay stable within a major version:

  • The HTTP API is versioned by path (/v1) and reports api_version orbit-api/1; new fields may appear in responses.
  • The worker protocol is orbit.worker.v1. SDKs that speak the same protocol version can connect to any runtime that serves it.
  • Compiled definitions are orbit-ir/1, or orbit-ir/2 for workflows that use signals, queries, await or compute steps, lambdas, on handlers or a limits block. /v1/health lists the IR versions a runtime accepts in ir_versions; upgrade the runtime before you deploy orbit-ir/2 definitions to it. See Versioning.
  • The database schema is versioned in the schema_version table (version 1 in this release) and created on first start.

Downgrading to an older runtime against a database written by a newer one is not supported. Workflow definitions are immutable, so upgrading the runtime never changes the behavior of a running execution’s definition; deploy new workflow versions separately from runtime upgrades.

See also

Formatter

orbit fmt formats .orbit files in a single, consistent style. It is conservative: it normalizes indentation, spacing and blank lines, but keeps your line breaks and every comment, so it never reflows code or moves a comment. You can run it on save in your editor, and check it in CI with --check.

Usage

orbit fmt workflows/*.orbit            # rewrite files in place
orbit fmt --check workflows/*.orbit    # report files that would change; write nothing
OptionMeaning
[FILES]...Files to format. At least one is required; directories are not expanded, so use your shell’s globbing.
--checkDo not write. Print would reformat <file> for each file that is not formatted, and exit with status 1 if there is one.

Files that are already formatted are not touched and produce no output. Changed files are reported on stderr:

$ orbit fmt --check messy.orbit
would reformat messy.orbit
$ orbit fmt messy.orbit
formatted messy.orbit
$ orbit fmt --check messy.orbit
$ echo $?
0
Exit codeMeaning
0Every file is formatted, or was rewritten.
1--check found an unformatted file; a file could not be read, written or formatted; or no files were given.

What it changes

Before:

// Greeting workflow
workflow Hello{
version   "1.0.0"
  input{ name:String , times:Int=-1 }



step greet{ worker "greeting.compose"   // the task
 with {name:input.name,n:input.times*2}
}
}

After orbit fmt:

// Greeting workflow
workflow Hello {
    version "1.0.0"
    input { name: String, times: Int = -1 }

    step greet { worker "greeting.compose" // the task
        with { name: input.name, n: input.times * 2 }
    }
}

The rules:

  • Indentation: four spaces per open {, ( or [. A line that continues an expression (it starts with, or follows, an infix operator, or starts with then or else) gets one extra level.
  • Spacing: one space around infix operators and =, after : and ,, and between a name and {; none inside () and [], before :, ,, . or ?, after ! or a unary minus, between a function name and (, and inside generic types (List<Map<String, Int>>). {} stays empty.
  • Blank lines: at most one in a row, none right after an opening brace or right before a closing one.
  • Trailing whitespace is removed, and the file ends with exactly one newline.
  • Line breaks are kept where you wrote them. The formatter never joins or splits lines.
  • Comments are kept, with their text unchanged. A comment at the end of a line is separated by one space; a comment on its own line is indented like the code. Multi-line block comments are kept verbatim.
  • Line endings: a file with Windows line endings (\r\n) keeps them.

Safety

Before returning, the formatter re-lexes its own output and checks that the token sequence, including the line-break information the grammar depends on, and the comments are unchanged. If the check fails, the file is left unchanged and fmt reports:

error: <file>: formatter safety check failed (<detail>); the file was left unchanged

The formatter needs a file that lexes. A file with lexical errors (an unterminated string, an unknown character) is refused:

$ orbit fmt lexbad.orbit
error: lexbad.orbit: cannot format a file with lexical errors: unterminated string literal

Syntax and type errors do not prevent formatting, because the formatter works on tokens, not on the syntax tree. Formatting never changes a workflow’s definition_hash: the hash excludes source locations.

Editors

The language server (orbit lsp) implements document formatting with the same formatter. The VS Code extension makes ORBIT the default formatter for .orbit files and turns on format-on-save. See Editor support.

CI

Fail a build when files are not formatted:

# .github/workflows/ci.yml
- name: Check formatting
  run: orbit fmt --check workflows/*.orbit

Combine it with orbit check --format json to report diagnostics as structured data.

See also

Compatibility checks

orbit compat compares two versions of your workflows and task contracts and reports every change that would break callers, result consumers, signal senders, query readers or workers, before you deploy. Run it in CI against the version on your main branch to catch a forgotten version bump or an accidental breaking change in review instead of in production.

Why versions need checking

The runtime identifies a workflow definition by name@version and never changes a registered definition:

  • Registering different content under an existing name@version is rejected with DEFINITION_CONFLICT. You find out at deploy time, after the change has been merged.
  • Running executions keep the exact definition they started with. A new version only affects executions started after it is registered, and callers that start a workflow by name get the highest registered version.
  • Workers receive a task only for the exact contract version they announce, and every workflow that uses a contract pins its version.

So a new version is a promise to the people who start the workflow, read its results and implement its tasks. orbit compat checks that promise against semantic versioning: a change that breaks callers or consumers needs a new major version. See Versioning for the runtime rules this builds on.

Usage

orbit compat [OPTIONS] <OLD> <NEW>
Argument or optionMeaning
<OLD>The old version: a .orbit file (imports are followed) or a compiled IR .json file.
<NEW>The new version, in either form. You can mix forms, for example a deployed .json file against a source file.
--format <FORMAT>human (default) prints one line per finding on stdout. json prints a single report object on stdout.

Both sides are compiled with the normal compiler, so a file with a compile error stops the check. Workflows are matched by name, and contracts by name and version. Built-in tasks such as orbit.http.request and child-workflow contracts are not compared as contracts; a changed child workflow shows up as a changed definition of its parent.

$ orbit compat v1.orbit minor.orbit
error   workflow Invoice 1.4.0 → 1.5.0: input.amount: Float → Int: incompatible type: expected `Int`, found `Float`; breaking for callers without a major version bump; hint: bump the major version (1.4.0 → 2.0.0)
error   workflow Invoice 1.4.0 → 1.5.0: input.currency: (added) → String: required field added without a default; breaking for callers without a major version bump; hint: bump the major version (1.4.0 → 2.0.0)
ok      workflow Invoice 1.4.0 → 1.5.0: output.total: Float → Int: narrowed; still accepted where the old type is expected
error: compatibility check failed: 2 error(s), 0 warning(s)

Each line has the same parts:

<severity> <workflow|contract> <name> <old version> → <new version>: <field path>: <old type> → <new type>: <message>; hint: <hint>

The field path starts at input, output, signal <name> or query <name>. [] is a list item and {} is a map value, for example input.lines[].sku. A field that only exists on one side is shown as (added) or (removed). When both versions are equal, the version is shown once as name@version.

SeverityMeaningAffects the exit code
errorThe new version would be rejected by the runtime, or breaks callers, consumers or workers without the version change that announces it.Yes
warningSomething to review: a removed workflow, a breaking contract change in a non-major version, or a version that is not MAJOR.MINOR.PATCH so the bump cannot be checked.No
okA compatible change, or an unchanged workflow or contract.No
infoAn addition, a removal that needs no action, or a breaking change that is announced by a major version bump.No

How types are compared

Every type comparison uses the compiler’s own assignability rules (the ones that check with blocks and return), so a change is compatible exactly when the language would accept the old value where the new type is expected, or the other way round for outputs:

ChangeAs an inputAs an output
Int → Floatcompatible (widening)breaking
Float → Intbreaking (narrowing)compatible
T → T?compatiblebreaking
T? → Tbreakingcompatible
String → Int, or any other unrelated typebreakingbreaking
T → JSONcompatiblebreaking
JSON → Tbreakingcompatible

Records, lists, maps and optionals are compared field by field and item by item, so each change is reported at its own path. Two rules go beyond plain assignability, because the runtime validates more strictly than the type checker:

  • Workflow inputs are validated strictly: the runtime rejects an input with a field the workflow does not declare. Removing a workflow input field is therefore breaking for callers that still send it.
  • JSON accepts any value, so narrowing from JSON to a concrete type is breaking, even though the type checker allows JSON in both directions.

Workflow rules

The checks run for every workflow name that exists in both versions.

SituationResult
Same version, same definition hashok … unchanged
Same version, different definition hasherror: the runtime rejects it with DEFINITION_CONFLICT. The hint suggests the next version: major for a breaking schema change, minor for an additive one, patch otherwise.
New version, identical definitioninfo
New version, input and output unchangedok
New version, compatible input or output changeone ok line per change
New version, breaking change, major bumpone info line per change
New version, breaking change, no major bumpone error line per change, with a hint naming the next major version
New version, breaking change, a version is not MAJOR.MINOR.PATCHwarning: the bump cannot be checked
New version lower than the old onewarning
Workflow only in the old versionwarning: running executions keep their definition, but new starts fail once it is no longer registered
Workflow only in the new versioninfo

A major bump means a higher MAJOR. For 0.x versions a higher MINOR also counts, following the semver convention that anything may change before 1.0.0. Pre-release and build suffixes are accepted and ignored for the comparison, so 1.0.0-rc.1 → 2.0.0-rc.1 is a major bump.

Changed content under the same version

Old and new definitions of Invoice@1.4.0 differ only in a sleep duration:

$ orbit compat v1.orbit same.orbit
error   workflow Invoice@1.4.0: content changed without a version change; the runtime rejects it with DEFINITION_CONFLICT; hint: bump the version (1.4.0 → 1.4.1)
error: compatibility check failed: 1 error(s), 0 warning(s)

Formatting and comments do not change the definition hash, so orbit fmt never triggers this error.

Input changes

Input changes affect callers: a caller that starts an execution with an input that was valid for the old version must still succeed.

Change to an input fieldResult
Field added as optional (T?) or with a defaultcompatible
Field added as required, without a defaultbreaking
Field removedbreaking (inputs are validated strictly)
Default added to a required fieldcompatible
Default removedbreaking
Type widened (Int → Float, T → T?, T → JSON)compatible
Type narrowed or changed (Float → Int, T? → T, String → Int)breaking

In the example above, amount: Float became amount: Int and a required currency: String was added in a minor version. The same change in 2.0.0 passes, and each breaking change is reported as info:

$ orbit compat v1.orbit major.orbit
info    workflow Invoice 1.4.0 → 2.0.0: input.amount: Float → Int: incompatible type: expected `Int`, found `Float`; breaking for callers, allowed by the major version bump
info    workflow Invoice 1.4.0 → 2.0.0: input.currency: (added) → String: required field added without a default; breaking for callers, allowed by the major version bump
ok      workflow Invoice 1.4.0 → 2.0.0: output.total: Float → Int: narrowed; still accepted where the old type is expected

A compatible minor version:

workflow Invoice {
    version "1.5.0"
    input { customer_id: String, amount: Float, note: String?, currency: String = "EUR" }
    step pause { sleep 1s }
    return { total: input.amount, currency: input.currency }
}
$ orbit compat v1.orbit compatible.orbit
ok      workflow Invoice 1.4.0 → 1.5.0: input.note: (added) → String?: optional field added
ok      workflow Invoice 1.4.0 → 1.5.0: input.currency: (added) → String: field with a default added
ok      workflow Invoice 1.4.0 → 1.5.0: output.currency: (added) → String: field added

Output changes

Output changes affect consumers of results: anything that reads a completed execution’s output.

Change to an output fieldResult
Field addedcompatible
Required field removedbreaking
Optional field removedcompatible (consumers already handle null)
Type narrowed (Float → Int, T? → T)compatible
Type made optional (T → T?), widened or changedbreaking
$ orbit compat v1.orbit output.orbit
error   workflow Invoice 1.4.0 → 1.5.0: output.total: Float → Float?: may now be null but consumers expect `Float`; breaking for consumers of results without a major version bump; hint: bump the major version (1.4.0 → 2.0.0)
error: compatibility check failed: 1 error(s), 0 warning(s)

0.x versions

Before 1.0.0, a breaking change needs a new minor version:

$ orbit compat r1.orbit r2.orbit
error   workflow Reminder 0.3.1 → 0.3.2: input.locale: (added) → String: required field added without a default; breaking for callers without a major version bump; hint: bump the minor version (0.3.1 → 0.4.0); in 0.x versions the minor version is the major version
error: compatibility check failed: 1 error(s), 0 warning(s)

Signals and queries

Signals are inputs that other systems send to running executions, and queries are outputs they read, so orbit compat compares them like the workflow input and output. Signals and queries are matched by name; paths are signal <name> and query <name>, followed by the field path.

ChangeResult
Signal removedbreaking: senders get UNKNOWN_SIGNAL
Signal payload field added as optional (T?) or with a defaultcompatible
Signal payload field added as required, without a defaultbreaking
Signal payload field removedcompatible: the runtime accepts and keeps fields a signal does not declare, so old senders keep working
Signal payload field widened or narrowedas for an input field: widening is compatible, narrowing or an unrelated type is breaking
Query removedbreaking: readers get UNKNOWN_QUERY
Query result changedas for an output: a field added or a type narrowed is compatible; a field removed, a type made optional, widened or changed is breaking
Signal or query addednot reported

A breaking change is an error without a major version bump and info with one, as for inputs and outputs. The message names who is affected: breaking for senders of the signal or breaking for readers of the query.

Old version:

workflow Approval {
    version "1.0.0"
    timeout 7d

    signal review { approved: Bool, comment: String? }
    signal escalate { reason: String }

    step decision { await review }
    step escalation { await escalate after decision when !decision.approved }

    query state = { approved: decision.approved, comment: decision.comment }
    query escalated = !is_null(escalation)

    return { approved: decision.approved }
}

A minor version that drops the escalate signal and the escalated query, adds a required reviewer field to review, and makes state.comment non-optional:

workflow Approval {
    version "1.1.0"
    timeout 7d

    signal review { approved: Bool, comment: String?, reviewer: String }

    step decision { await review }

    query state = { approved: decision.approved, comment: coalesce(decision.comment, "") }

    return { approved: decision.approved }
}
$ orbit compat a1.orbit a2.orbit
error   workflow Approval 1.0.0 → 1.1.0: signal escalate: { reason: String } → (removed): signal removed: senders get UNKNOWN_SIGNAL; breaking for senders of the signal without a major version bump; hint: bump the major version (1.0.0 → 2.0.0)
error   workflow Approval 1.0.0 → 1.1.0: signal review.reviewer: (added) → String: required field added without a default; breaking for senders of the signal without a major version bump; hint: bump the major version (1.0.0 → 2.0.0)
error   workflow Approval 1.0.0 → 1.1.0: query escalated: Bool → (removed): query removed: readers get UNKNOWN_QUERY; breaking for readers of the query without a major version bump; hint: bump the major version (1.0.0 → 2.0.0)
ok      workflow Approval 1.0.0 → 1.1.0: query state.comment: String? → String: no longer null
error: compatibility check failed: 3 error(s), 0 warning(s)

The same changes as 2.0.0 are reported as info (breaking for senders of the signal, allowed by the major version bump). A compatible minor version keeps both signals and queries, gives the new reviewer field a default, and adds fields:

workflow Approval {
    version "1.2.0"
    timeout 7d

    signal review { approved: Bool, comment: String?, reviewer: String = "unknown", priority: Int? }
    signal escalate { reason: String }

    step decision { await review }
    step escalation { await escalate after decision when !decision.approved }

    query state = { approved: decision.approved, comment: decision.comment, reviewer: decision.reviewer }
    query escalated = !is_null(escalation)

    return { approved: decision.approved }
}
$ orbit compat a1.orbit a3.orbit
ok      workflow Approval 1.0.0 → 1.2.0: signal review.reviewer: (added) → String: field with a default added
ok      workflow Approval 1.0.0 → 1.2.0: signal review.priority: (added) → Int?: optional field added
ok      workflow Approval 1.0.0 → 1.2.0: query state.reviewer: (added) → String?: field added

Query result types follow the query typing rules: every step output is optional in a query, so decision.approved has type Bool? there. In this pair, the new version (1.1.0) changes review.approved from Bool to Int, removes the optional review.comment, and returns string(decision.approved) in state.approved:

$ orbit compat b1.orbit b2.orbit
error   workflow Approval 1.0.0 → 1.1.0: signal review.approved: Bool → Int: incompatible type: expected `Int`, found `Bool`; breaking for senders of the signal without a major version bump; hint: bump the major version (1.0.0 → 2.0.0)
error   workflow Approval 1.0.0 → 1.1.0: signal review.comment: String? → (removed): field removed; values that still contain it are rejected, because inputs are validated strictly; breaking for senders of the signal without a major version bump; hint: bump the major version (1.0.0 → 2.0.0)
error   workflow Approval 1.0.0 → 1.1.0: query state.approved: Bool? → String: incompatible type: expected `Bool`, found `String`; breaking for readers of the query without a major version bump; hint: bump the major version (1.0.0 → 2.0.0)
error: compatibility check failed: 3 error(s), 0 warning(s)

The runtime itself does not reject undeclared signal payload fields (it keeps them in the payload), so a removed payload field breaks senders only in the sense that the field is no longer read. orbit compat reports it as breaking all the same, so that removing a field is a deliberate, major change.

Removed and added workflows

Renaming a workflow removes one name and adds another:

$ orbit compat v1.orbit renamed.orbit
warning workflow Invoice@1.4.0: workflow removed; running executions keep their definition, but new starts fail once it is no longer registered
info    workflow Billing@1.0.0: workflow added

A removal is a warning, not an error: the registered definition stays in the runtime, and running executions finish on it. Check that no caller still starts the old name.

Contract rules

A task contract has two sides. The workflow is the caller: it sends the input and reads the output. The worker is the implementer: it reads the input and returns the output. Because the runtime matches workers by exact name@version, the rules depend on whether the contract version changed.

Same contract version

Under one contract version, old and new workers both announce name@version, and old and new workflows both use it, while executions on old workflow versions are still running. Any worker must be able to serve any of those workflows, so a change is allowed only if it is compatible in both directions:

  • input: the old input type must be assignable to the new one, and the new to the old;
  • output: the old output type must be assignable to the new one, and the new to the old.

Contract inputs and outputs use plain record subtyping: a worker ignores input fields it does not know, and the runtime ignores output fields the contract does not declare. In practice, the only shape changes that keep a contract version are adding or removing a field that is optional (T?) or has a default. Anything else is an error:

$ orbit compat c1.orbit c2.orbit
ok      workflow Checkout 1.0.0 → 1.1.0: definition changed; input and output unchanged
error   contract payments.charge@1.0.0: input.currency: (added) → String: required field added without a default; contract shape changed without a version change, so workers announcing payments.charge@1.0.0 no longer match every workflow that uses it; hint: give the changed contract a new version (1.0.0 → 2.0.0)
ok      contract payments.charge@1.0.0: output.receipt_url: (added) → String?: field added; compatible for old and new workers under the same version
error: compatibility check failed: 1 error(s), 0 warning(s)

A contract change also changes the definition hash of every workflow that uses it, so those workflows need a new version as well. orbit compat reports that through the workflow rules.

New contract version

A new contract version never reaches old workers, so it cannot break anything at runtime. orbit compat compares it with the closest lower old version and asks one question: can a worker built for the old version also serve the new one? That holds when the new input is assignable to the old input (an old worker can read what new workflows send) and the old output is assignable to the new output (new workflows accept what an old worker returns).

Change in a new contract versionResult with a major bumpResult without a major bump
An old worker can serve the new versionokok
An old worker cannot serve the new versioninfo: deploy workers for the new version before the workflows that use itwarning, with a hint naming the next major version

This is a warning rather than an error because exact version routing already keeps old workers away from the new version. The warning keeps contract versions meaningful to the people who implement workers.

$ orbit compat c1.orbit c3.orbit
ok      workflow Checkout 1.0.0 → 1.1.0: definition changed; input and output unchanged
ok      contract payments.charge 1.0.0 → 1.1.0: input.currency: (added) → String: field added; an implementation of 1.0.0 also satisfies 1.1.0
warning contract payments.charge 1.0.0 → 1.1.0: output.settled_at: (added) → Timestamp: required field added without a default; an implementation of 1.0.0 cannot serve 1.1.0, but the version bump is not major; hint: bump the major version (1.0.0 → 2.0.0)
info    contract payments.charge@1.0.0: contract version no longer declared or used; keep workers for it until executions that use it finish

Contract versions that exist only in the old version, and contracts that exist only on one side, are reported as info.

Using it in CI

Compare each changed workflow file with its version on the main branch:

git show main:workflows/invoice.orbit > old.orbit
orbit compat old.orbit workflows/invoice.orbit

A file that imports other files must be compared together with them. Check out the old tree in a separate directory and point orbit compat at the entry file there:

git worktree add ../orbit-main main
orbit compat ../orbit-main/workflows/invoice.orbit workflows/invoice.orbit

You can also keep the IR you deployed (orbit compile -o deployed/) and compare new sources against it, so the check reflects what is actually registered:

orbit compat deployed/invoice.orbit.json workflows/invoice.orbit

As a GitHub Actions step:

- name: Check workflow compatibility
  run: |
    git fetch origin main
    git show origin/main:workflows/invoice.orbit > old.orbit
    orbit compat old.orbit workflows/invoice.orbit

JSON format

With --format json, stdout holds one object:

{
  "compatible": false,
  "errors": 2,
  "warnings": 0,
  "findings": [
    {
      "severity": "error",
      "kind": "workflow",
      "name": "Invoice",
      "old_version": "1.4.0",
      "new_version": "1.5.0",
      "path": "input.amount",
      "old_type": "Float",
      "new_type": "Int",
      "message": "incompatible type: expected `Int`, found `Float`; breaking for callers without a major version bump",
      "hint": "bump the major version (1.4.0 → 2.0.0)"
    },
    {
      "severity": "error",
      "kind": "workflow",
      "name": "Invoice",
      "old_version": "1.4.0",
      "new_version": "1.5.0",
      "path": "input.currency",
      "new_type": "String",
      "message": "required field added without a default; breaking for callers without a major version bump",
      "hint": "bump the major version (1.4.0 → 2.0.0)"
    },
    {
      "severity": "ok",
      "kind": "workflow",
      "name": "Invoice",
      "old_version": "1.4.0",
      "new_version": "1.5.0",
      "path": "output.total",
      "old_type": "Float",
      "new_type": "Int",
      "message": "narrowed; still accepted where the old type is expected"
    }
  ]
}
FieldTypeMeaning
compatiblebooltrue when there are no errors.
errors, warningsintegerNumber of findings with that severity.
findings[].severitystringerror, warning, ok or info.
findings[].kindstringworkflow or contract.
findings[].namestringWorkflow or contract name.
findings[].old_version, new_versionstringAbsent on the side where the workflow or contract does not exist.
findings[].pathstringField path, starting with input, output, signal <name> or query <name>; absent for findings about a whole workflow or contract.
findings[].old_type, new_typestringTypes at path; absent when the field does not exist on that side.
findings[].messagestringWhat changed and why it matters.
findings[].hintstringSuggested fix, when there is one.

Findings list workflows first, then contracts, each sorted by name.

Exit codes

CodeMeaning
0No errors. Warnings, ok and info findings are allowed.
1At least one error finding. In human format, stderr ends with error: compatibility check failed: <n> error(s), <m> warning(s).
2Usage error: a file cannot be read, a .json file is not valid IR, a .orbit file has compile errors (the diagnostics are printed on stderr), or an argument is missing.

See also

Testing workflows

A test declaration describes one execution of a workflow: its input, what each task returns, which signals arrive, and what you expect to happen. orbit test runs every test in-process on the real ORBIT engine, with an in-memory store and a simulated clock, so you need no server, no workers and no waiting: a test of a two-day approval timeout finishes in milliseconds. Run tests locally while you change a workflow, and in CI before you deploy.

A first test

The approval example charges an order, but orders of 1000 or more first wait for a review signal. This test sends an approval and checks the result:

import "approval.orbit"

test "large orders wait for approval" {
    workflow OrderApproval
    input { order_id: "T1", customer_id: "c-1", amount: 2500.0 }
    mock "payments.charge" returns { transaction_id: "tx-" + input.order_id, status: "captured" }
    mock "notifications.send" returns { delivered: true }
    signal review { approved: true, reviewer: "dana" }
    expect status COMPLETED
    expect output { status: "charged", transaction_id: "tx-T1" }
    expect step decision SUCCEEDED
}

Keep tests in their own file that imports the workflow, by convention <name>.test.orbit. The repository’s examples include approval.test.orbit and order_processing.test.orbit:

$ orbit test examples/*.test.orbit
running 10 tests
ok      small orders are charged without review (0ms simulated)
ok      large orders wait for approval (0ms simulated)
ok      rejected orders are not charged (0ms simulated)
ok      an order nobody reviews times out after two days (2d 0h simulated)
ok      a failed notification refunds the charge (0ms simulated)
ok      a low-risk order is charged (0ms simulated)
ok      a risky order is held for review (0ms simulated)
ok      risk scoring is retried after temporary failures (3.0s simulated)
ok      a declined card releases the reserved inventory (0ms simulated)
ok      a payment provider that never answers exhausts the retries (2m 37s simulated)

test result: ok. 10 passed, 0 failed, 0 filtered out (0.10s)

The time in parentheses is simulated time: how long the execution would have taken with real timers, retries and timeouts.

Test declarations

test "<name>" {
    workflow <Name>
    version "<version>"
    input { ... }
    mock "<task>" <behaviour> [times <n> then <behaviour> ...]
    signal <name> { ... }
    expect <expectation>
}
MemberRequiredMeaning
workflow <Name>yesThe workflow to run. It must be defined in, or imported into, the compilation (E0700).
version "<v>"noFails to compile (E0702) unless the sources define this version, so a test pinned to 1.0.0 notices a version bump.
input { ... }when the workflow has required input fieldsThe execution input. It is checked like an input sent to the runtime: required fields, field types and unknown fields are compile errors, and defaults are applied.
mock "<task>" ...for every task the execution callsWhat the simulated worker answers. See Mocks.
signal <name> { ... }no, repeatableA signal sent to the execution. See Signals.
expect ...no, repeatableWhat must be true when the execution has finished. See Expectations.

Test names are strings and must be unique across the compilation (E0709). Values in input, signal payloads and expectations are constants: they can use literals, operators and built-in functions, but not input, step outputs or execution.

Tests are part of the program: orbit check and the language server report their errors, and a file with a broken test cannot be compiled or deployed. They are never part of the compiled output, so orbit compile, orbit deploy and orbit run ignore them, and adding a test does not change any workflow’s definition hash.

Mocks

The test runner acts as the worker for every task. A mock gives the result for one task, by its contract name:

BehaviourEffect on an attempt
returns <expression>The task succeeds with this value. Inside the expression, input is the task’s input (the evaluated with block), and execution.id, execution.workflow and execution.version describe the calling execution.
fails "<CODE>"The attempt fails with this error code and the message mocked failure of `<task>` . The failure is permanent (retryable: false), so the step fails without retrying.
fails "<CODE>" "<message>"The same, with your message.
fails "<CODE>" retryableA retryable failure: the step’s retry policy applies.
hangsThe worker never answers. The attempt’s timeout fails it with TIMEOUT (retryable), and the retry policy applies.

The compiler checks every mock against the task’s contract: the task must be one the workflow calls in a step, a compensation or a child workflow (E0703), and a returns expression must have the contract’s output type, with input typed as the contract’s input (E0400, E0402, E0403). Each task can be mocked once per test (E0704).

Chain behaviours with times <n> then. Counting is per unit: each step, each element of a for step and each compensation counts its own calls, and every attempt (including a redelivery) is one call. The last behaviour answers every remaining call.

import "order_processing.orbit"

test "risk scoring is retried after temporary failures" {
    workflow OrderProcessing
    input { order_id: "A3", customer_id: "c-3", amount: 10.0, items: [] }
    mock "orders.validate" returns { valid: true }
    mock "risk.analyze" fails "TEMPORARY" retryable times 2 then returns { score: 0.2, reasons: [] }
    mock "inventory.reserve" returns { reservation_id: "r-A3" }
    mock "payments.charge" returns { transaction_id: "tx-A3", status: "captured" }
    mock "notifications.send" returns { delivered: true }
    expect status COMPLETED
    expect step analyze_risk attempts 3
}

A task the execution calls without a mock stops the test with a failure that names the task and step:

  ✗ task `notifications.send` (step `notify`) is not mocked; add `mock "notifications.send" returns { ... }` to the test

Child workflows are not mocked: they run as real child executions, and you mock the tasks they call. The built-in task orbit.http.request is mocked like any other task, so tests never make network requests.

Signals

signal <name> { payload } sends a signal declared by the workflow (E0705). The payload is checked against the signal’s declaration, with defaults applied; signal approve without a payload sends {}.

Signals are sent in the order you write them, each one as soon as the execution has a step waiting for that signal. Until then the next signal waits, and later signals wait behind it. Two consequences follow:

  • A test of an await timeout simply sends no signal: with nothing to send, the runner advances the clock to the step’s deadline.
  • A signal the execution never waits for fails the test with signal `review` was never sent: the execution finished without waiting for it. Remove the signal, or fix the condition that skipped the await step.

Expectations

Expectations are checked once the execution has finished, and every failed expectation is reported, not just the first.

ExpectationPasses when
expect status <STATUS>The execution ended COMPLETED, FAILED, CANCELLED or TIMED_OUT (E0707 for anything else).
expect output { ... }The workflow’s output contains these values.
expect error "<CODE>"The execution failed with this error code, for example CARD_DECLINED from a failed step or SIGNAL_TIMEOUT from an await timeout.
expect step <name> <STATUS>The step’s unit ended in this status: SUCCEEDED, SKIPPED, FAILED, CANCELLED, or any other unit status. PENDING means the step never started.
expect step <name> output { ... }The step’s output contains these values.
expect step <name> attempts <n>The step’s last attempt was attempt n (0 for a step that never ran).
expect step on_<kind>:<name> <STATUS>The same checks for a step of an outcome handler, for example expect step on_failure:summary SUCCEEDED.
expect compensated <name>The step’s compensation ran and succeeded. For a for step, every element’s compensation must have succeeded. The step must have a compensate block (E0708).
expect runs <n>The execution ended in run n: 1 plus one per continue_as_new. At least 1 (E0712); more than 1 only for a workflow with continue_as_new (E0713).

For a workflow with continue_as_new, every expectation except expect runs is checked against the final run: its status, output, error and steps. The mocks answer every run’s calls, and times N counts calls of a step across runs, so returns { ... } times 2 then returns { ... } can make the first two runs continue and the third stop:

import "subscription.orbit"

test "three periods are three runs" {
    workflow Subscription
    input { customer_id: "c-1", amount: 9.5, periods: 3 }
    mock "payments.charge" returns { transaction_id: "tx-" + input.order_id, status: "captured" }
    mock "notifications.send" returns { delivered: true }
    expect status COMPLETED
    expect runs 3
    expect output { periods: 3, charged: 28.5 }
    expect step charge output { transaction_id: "tx-c-1-3" }
}
$ orbit test examples/subscription.test.orbit
running 3 tests
ok      three periods are three runs (90d 0h simulated)
ok      a single period completes in the first run (30d 0h simulated)
ok      a declined charge ends the subscription in that period (30d 0h simulated)

test result: ok. 3 passed, 0 failed, 0 filtered out (0.02s)

The simulated time is measured across every run.

Unknown step names are compile errors with a suggestion (E0706). Expected outputs are type-checked against the workflow’s output (or the step’s output type), with every field optional:

  • An object matches when every field it lists matches; fields you leave out are not compared, at any depth.
  • Lists and other values must be equal. Numbers compare by value, so 1 matches 1.0.
  • A field written as null matches a missing or null field.

A test without any expect still fails if the execution cannot finish; the compiler warns that it checks nothing (W0701).

Time

Durable time is simulated. The clock starts at 2026-01-01T00:00:00Z, and whenever nothing can happen (no task to answer, no signal to send), the runner jumps the clock straight to the next moment something is due: a sleep timer, a retry’s backoff, an attempt timeout, a queue_timeout, an await timeout or the workflow timeout. Retry delays, timeouts and the order of events are exactly what the runtime would produce; only the waiting is skipped.

import "approval.orbit"

test "an order nobody reviews times out after two days" {
    workflow OrderApproval
    input { order_id: "T3", customer_id: "c-3", amount: 1000.0 }
    expect status FAILED
    expect error "SIGNAL_TIMEOUT"
    expect step decision FAILED
}

If nothing is due and the execution still has not finished, for example an await step without a timeout and no signal to send, or a hangs mock on a step without a timeout, the test fails with the reason, such as the execution cannot make progress: step `decision` waits for signal `review`, and no signal is left to send.

How tests run

Each test runs on a fresh orbit_runtime::Engine with an in-memory store. Every workflow in the compilation is registered, children first, and the runner registers one simulated worker per language that steps ask for with using, announcing every contract. The execution has the id sim; child executions get their usual derived ids, such as sim.ship. Because the engine, the decider and contract validation are the same code the server runs, a mock result that violates the task’s output contract fails the attempt with OUTPUT_CONTRACT_VIOLATION, as a real worker’s would.

Tests are deterministic: the same sources always produce the same events, in the same order, at the same simulated times.

Failure reports

A failing test prints each failed expectation and the execution’s timeline (the same view as orbit logs), followed by the timelines of child executions:

$ orbit test orders.test.orbit
running 1 test
FAILED  declined cards are reported (0ms simulated)

failures:

---- declined cards are reported (orders.test.orbit:3, OrderApproval@1.0.0)
  ✗ expected status COMPLETED, got FAILED (CARD_DECLINED: step `charge` failed: insufficient funds)
  ✗ expected output {"status":"charged"}, but the workflow produced none (it ended FAILED (CARD_DECLINED: step `charge` failed: insufficient funds))

  timeline of execution `sim`:
    +     0ms   #1  WorkflowCreated
    +     0ms   #2  WorkflowStarted
    +     0ms   #3  StepSucceeded             step=assess attempt=1
    +     0ms   #4  StepSkipped               step=decision reason=`when` condition was false
    +     0ms   #5  StepScheduled             step=charge attempt=1
    +     0ms   #6  StepDispatched            step=charge attempt=1 worker_id=simulator-python
    +     0ms   #7  StepAttemptFailed         step=charge attempt=1 error=CARD_DECLINED insufficient funds
    +     0ms   #8  StepFailed                step=charge error=CARD_DECLINED insufficient funds
    +     0ms   #9  StepCancelled             step=notify
    +     0ms  #10  WorkflowFailed            error=CARD_DECLINED step `charge` failed: insufficient funds

test result: FAILED. 0 passed, 1 failed, 0 filtered out (0.00s)

Running tests

orbit test [OPTIONS] [FILES]...
OptionMeaning
[FILES]...Files to compile. Every test in them, and in the files they import, runs.
--filter <TEXT>Run only tests whose name contains this text. The others count as filtered out.
--format <FORMAT>human (default) or json.

Exit codes: 0 when every selected test passed (including when there are none); 1 when a test failed or the files do not compile. Compile errors are printed like orbit check prints them, and no test runs.

In CI

Run orbit test next to orbit check. This repository’s CI runs:

orbit test examples/*.test.orbit

JSON format

--format json prints one object on stdout:

FieldTypeMeaning
tests[].namestringThe test name.
tests[].workflow, tests[].versionstringThe workflow under test.
tests[].file, tests[].linestring, integerWhere the test is declared.
tests[].statusstringpassed or failed.
tests[].failuresarray of stringsOne message per failed expectation, or why the test could not run.
tests[].executionobjectstatus, output, error (code, message, retryable) and simulated_ms of the execution.
tests[].historyarrayFailed tests only: the execution’s history events, in the history format.
passed, failed, filtered_outintegerCounts.

When the files do not compile, the object is {"diagnostics": [...], "tests": []}, with diagnostics in the orbit check --format json format, and the exit code is 1.

$ orbit test --format json orders.test.orbit
{
  "tests": [
    {
      "name": "declined cards are reported",
      "workflow": "OrderApproval",
      "version": "1.0.0",
      "file": "orders.test.orbit",
      "line": 3,
      "status": "failed",
      "failures": [
        "expected status COMPLETED, got FAILED (CARD_DECLINED: step `charge` failed: insufficient funds)",
        "expected output {\"status\":\"charged\"}, but the workflow produced none (it ended FAILED (CARD_DECLINED: step `charge` failed: insufficient funds))"
      ],
      "execution": {
        "status": "FAILED",
        "output": null,
        "error": {
          "code": "CARD_DECLINED",
          "message": "step `charge` failed: insufficient funds",
          "retryable": false
        },
        "simulated_ms": 0
      },
      "history": [ ... ]
    }
  ],
  "passed": 0,
  "failed": 1,
  "filtered_out": 0
}

See also

Simulation

orbit simulate runs one execution of a workflow on your machine, without a server or workers. Tasks you do not describe get a generated result that matches their contract, so you can try a new workflow, explore a branch or watch a failure and its compensations as soon as the workflow type-checks. The simulator is the one orbit test uses: the real engine and decider, an in-memory store, and a clock that jumps straight to the next timer, retry or timeout.

Use orbit simulate to explore; when a scenario is worth keeping, write it down as a test.

Usage

orbit simulate [OPTIONS] <FILE>
Argument or optionMeaning
<FILE>A .orbit file (imports are followed) or a compiled IR .json file.
--workflow <WORKFLOW>Workflow to run when the file defines several. Without it, a file with one workflow, or with a parent and its children, runs the workflow no other workflow starts.
-i, --input <JSON>The execution input. Default {}. It is validated like an input sent to the runtime, with defaults applied.
--input-file <PATH>Read the input from a JSON file.
--mocks <PATH>A JSON object mapping task names to the result each returns.
--fail <TASK=CODE>Make every attempt of a task fail permanently with this error code. Repeatable.
--signal <NAME=JSON>Send a signal once the execution waits for it, for example review='{"approved":true,"reviewer":"dana"}'. NAME=@file.json reads the payload from a file, and NAME alone sends {}. Repeatable; signals are sent in order.
--jsonPrint the result as JSON instead of the timeline.

A task named in --mocks or --fail must be one the workflow can call; anything else is an error that lists the tasks. --fail takes precedence over --mocks.

Exit codes: 0 when the execution ends COMPLETED; 1 when it ends in any other status, cannot finish, or the file does not compile.

Generated results

A task with neither a mock nor a --fail returns a sample built from its contract’s output type. The rules are deterministic, so the same command always produces the same execution:

TypeSample
String"<field>-sample", using the field’s name, for example "transaction_id-sample"
Int1
Float1.0
Booltrue
Bytes"c2FtcGxl" (base64 of sample)
Duration1000 (one second)
Timestamp"2026-01-01T00:00:00Z"
UUID"00000000-0000-4000-8000-000000000001"
JSON{}
List<T>a list with one sample T
Map<String, T>{"key": <sample T>}
T?null
recordevery field: its default if it has one, otherwise a sample of its type

Samples follow the contract, not your business logic. In the order example, the generated risk score 1.0 is above the 0.8 threshold, so the payment step is skipped; give the task a realistic result with --mocks to take the other branch. The note on stderr lists which tasks got generated results.

Child workflows run as real child executions, and their tasks follow the same rules. The built-in orbit.http.request task is simulated like any other task, so a simulation never makes network requests.

Examples

A large order with a reviewer’s approval:

$ orbit simulate examples/approval.orbit --input '{"order_id":"T1","customer_id":"c-1","amount":2500}' --signal 'review={"approved":true,"reviewer":"dana"}'
simulated OrderApproval@1.0.0 (0ms simulated, no server or workers)
note: generated sample results for: notifications.send, payments.charge

+     0ms   #1  WorkflowCreated
+     0ms   #2  WorkflowStarted
+     0ms   #3  StepSucceeded             step=assess attempt=1
+     0ms   #4  SignalAwaitStarted        step=decision signal=review
+     0ms   #5  SignalReceived            name=review
+     0ms   #6  SignalDelivered           step=decision signal_seq=5
+     0ms   #7  StepScheduled             step=charge attempt=1
+     0ms   #8  StepDispatched            step=charge attempt=1 worker_id=simulator-python
+     0ms   #9  StepSucceeded             step=charge attempt=1
+     0ms  #10  StepScheduled             step=notify attempt=1
+     0ms  #11  StepDispatched            step=notify attempt=1 worker_id=simulator-python
+     0ms  #12  StepSucceeded             step=notify attempt=1
+     0ms  #13  WorkflowCompleted

COMPLETED sim  OrderApproval@1.0.0
  elapsed: 0ms

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  assess                       SUCCEEDED          1
  charge                       SUCCEEDED          1      0ms  worker simulator-python
  decision                     SUCCEEDED          1      0ms
  notify                       SUCCEEDED          1      0ms  worker simulator-python

  output: {"status":"charged","transaction_id":"transaction_id-sample"}

A declined card in the order pipeline, with a realistic risk score from a mocks file:

{ "risk.analyze": { "score": 0.3, "reasons": [] } }
$ orbit simulate examples/order_processing.orbit --input '{"order_id":"A4","customer_id":"c-4","amount":75,"items":[{"sku":"MUG","quantity":2}]}' --mocks mocks.json --fail payments.charge=CARD_DECLINED
simulated OrderProcessing@1.0.0 (0ms simulated, no server or workers)
note: generated sample results for: inventory.release, inventory.reserve, orders.validate

+     0ms   #1  WorkflowCreated
+     0ms   #2  WorkflowStarted
+     0ms   #3  StepScheduled             step=validate_order attempt=1
+     0ms   #4  StepScheduled             step=analyze_risk attempt=1
+     0ms   #5  StepDispatched            step=analyze_risk attempt=1 worker_id=simulator-python
+     0ms   #6  StepDispatched            step=validate_order attempt=1 worker_id=simulator-typescript
+     0ms   #7  StepSucceeded             step=analyze_risk attempt=1
+     0ms   #8  StepSucceeded             step=validate_order attempt=1
+     0ms   #9  StepScheduled             step=reserve_inventory attempt=1
+     0ms  #10  StepDispatched            step=reserve_inventory attempt=1 worker_id=simulator-typescript
+     0ms  #11  StepSucceeded             step=reserve_inventory attempt=1
+     0ms  #12  StepScheduled             step=process_payment attempt=1
+     0ms  #13  StepDispatched            step=process_payment attempt=1 worker_id=simulator-python
+     0ms  #14  StepAttemptFailed         step=process_payment attempt=1 error=CARD_DECLINED simulated failure of `payments.charge` (--fail)
+     0ms  #15  StepFailed                step=process_payment error=CARD_DECLINED simulated failure of `payments.charge` (--fail)
+     0ms  #16  StepCancelled             step=send_notification
+     0ms  #17  CompensationStarted       reason=step `process_payment` failed: simulated failure of `payments.charge` (--fail)
+     0ms  #18  StepScheduled             step=compensate:reserve_inventory attempt=1
+     0ms  #19  StepDispatched            step=compensate:reserve_inventory attempt=1 worker_id=simulator-python
+     0ms  #20  StepSucceeded             step=compensate:reserve_inventory attempt=1
+     0ms  #21  CompensationCompleted
+     0ms  #22  WorkflowFailed            error=CARD_DECLINED step `process_payment` failed: simulated failure of `payments.charge` (--fail)

FAILED sim  OrderProcessing@1.0.0
  elapsed: 0ms
  compensation: completed
  error: CARD_DECLINED step `process_payment` failed: simulated failure of `payments.charge` (--fail)

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  analyze_risk                 SUCCEEDED          1      0ms  worker simulator-python
  compensate:reserve_inventory SUCCEEDED          1      0ms  worker simulator-python
  process_payment              FAILED             1      0ms  simulated failure of `payments.charge` (--fail)
  reserve_inventory            SUCCEEDED          1      0ms  worker simulator-typescript
  send_notification            CANCELLED          0
  validate_order               SUCCEEDED          1      0ms  worker simulator-typescript

Child executions are printed after the parent’s timeline, under child execution <id> (<workflow>):.

When a simulation cannot finish

The simulation stops, with a warning on stderr, when the execution cannot make progress: an await step without a timeout and no --signal left to send for it, or a step whose worker never answers. A signal the execution never waited for is reported as warning: signal `review` was not sent: the execution never waited for it. In both cases the timeline and the state show where the execution stopped, and the exit code is 1.

JSON output

--json prints one object on stdout:

FieldMeaning
workflow, versionThe workflow that ran.
status, output, errorHow the execution ended.
simulated_msSimulated time from start to finish.
sampled_tasksTasks that got generated results.
problems, unsent_signalsWhy the simulation stopped early, and signals that were never sent.
executionThe final execution state, as GET /v1/executions/{id} returns it.
historyEvery history event, as GET /v1/executions/{id}/history returns them.
childrenChild executions, each with state and history.

The history array can be saved and replayed with orbit replay --history.

See also

Replay

The runtime records every execution as an event history and rebuilds an execution’s state by folding that history (see Executions and history). The decisions in it, which step to schedule, whether to retry, when to compensate, were made by the decider of the ORBIT version that ran the execution. orbit replay re-runs a recorded history through the decider of the orbit binary you are running and reports every recorded decision it would not make again.

Use it to:

  • check that upgrading ORBIT does not change how running executions continue: replay a sample of recent histories with the new binary before you upgrade the server;
  • check that an edited workflow source still makes the decisions an execution recorded, before you deploy it under the same version;
  • inspect an execution as it was right after any event (--at), with every step’s status and output and the signal buffer.

Replay is read-only. It never changes the runtime or the history.

Usage

orbit replay [OPTIONS] [EXECUTION_ID]
Argument or optionMeaning
[EXECUTION_ID]Fetch the execution’s history (GET /v1/executions/{id}/history) and the definition it ran (GET /v1/workflows/{name}/{version}) from the runtime.
--history <PATH>Replay a history file instead: the response of GET /v1/executions/{id}/history ({"execution_id": ..., "events": [...]}), or a bare array of events.
--definition <PATH>The definition to replay against: a compiled IR .json file, or .orbit source that is compiled first. Required with --history. With an execution id, it replaces the runtime’s copy.
--workflow <NAME>The workflow to use when --definition defines several. Default: the workflow named in the history’s WorkflowCreated event.
--at <SEQ>Stop after event SEQ and print the reconstructed state.
--run <RUN>With an execution id: replay this run (continue-as-new) instead of the latest one (GET /v1/executions/{id}/history?run=N). Each run replays on its own; signals carried into a run are external events.
--jsonPrint the report as JSON.
runtime connection options--server, --token, --ca-cert, --client-cert, --client-key; see CLI.

Exit codes: 0 when every recorded decision is reproduced (up to --at, if given); 1 on a divergence, or when the history or definition cannot be loaded.

How replay decides

Replay starts from an empty state and goes through the history in order. Each event is either applied as recorded or checked against the decider:

OriginEventsHow replay treats them
ExternalWorkflowCreated, WorkflowStarted, StepDispatched, StepAttemptUncertain, SignalReceived, CancelRequested, StaleResultIgnoredApplied as recorded. They record something the decider cannot know: a start, a dispatch to a worker, a lost worker, a signal, a cancellation.
External or decidedStepSucceeded, StepAttemptFailedConsistent if the decider produces the event, or if it is a worker’s result for a task, child workflow or built-in step that is still waiting for one. A compute step’s result and a finished for step are decided, so a recorded StepSucceeded with a different value for them is a divergence.
Decidedevery other event: StepScheduled, StepSkipped, StepFanOut, TimerStarted, TimerFired, RetryScheduled, StepFailed, StepCancelled, SignalAwaitStarted, SignalDelivered, CompensationStarted, WorkflowCompleted, WorkflowFailed and the restMust be among the events decide(state, definition, time) returns at that point, where time is the event’s timestamp.

The runtime commits one batch of decisions at a time, and the decider returns the events of a batch together, so their order within a batch does not matter. Times computed from the clock (fire_at_ms of timers and retries, deadline_ms of waits) are compared with a tolerance equal to the time since the previous event, because the runtime reads its clock just before it commits.

After a divergence, replay applies the recorded event and continues, so the state keeps matching what the runtime did and later divergences are independent findings.

The report also notes when the history was recorded against a different definition than the one you replay against (the definition hash in WorkflowCreated differs). That is expected when you replay against edited source; every decision that changed is then reported as a divergence.

Examples

The examples use this workflow:

workflow Reminder {
    version "1.0.0"
    input { name: String }
    signal ack { ok: Bool }
    step greet { compute { text: "hello " + input.name } }
    step wait { sleep 1s  after greet }
    step confirm { await ack  after wait  timeout 1h }
    return { text: greet.text }
}

An execution that ran on a local server, and its history:

$ orbit logs rem-1
+     0ms   #1  WorkflowCreated
+     0ms   #2  WorkflowStarted
+     1ms   #3  StepSucceeded             step=greet attempt=1
+     1ms   #4  TimerStarted              step=wait
+    1.0s   #5  TimerFired                step=wait
+    1.0s   #6  SignalAwaitStarted        step=confirm signal=ack
+    2.0s   #7  SignalReceived            name=ack
+    2.0s   #8  SignalDelivered           step=confirm signal_seq=7
+    2.0s   #9  WorkflowCompleted
$ orbit replay rem-1
replayed 9 events of rem-1 against Reminder@1.0.0 with the current decider
  6 decided, 3 external
consistent: every recorded decision is reproduced

Time travel

--at prints the state as it was right after an event: the units with their status and attempt, every step output, the signal buffer, and what the decider would do next.

$ orbit replay rem-1 --at 6
replayed 6 events of rem-1 against Reminder@1.0.0 with the current decider
  4 decided, 2 external

state after #6 SignalAwaitStarted (+1009ms):

RUNNING rem-1  Reminder@1.0.0
  elapsed: 1.0s

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  greet                        SUCCEEDED          1
  confirm                      WAITING            1
  wait                         SUCCEEDED          1     1.0s

  step outputs:
    greet: {"text":"hello Ada"}
    wait: {}

Values whose keys look sensitive are redacted, as in orbit logs.

A divergence

Replaying the same execution against a source where sleep 1s became sleep 10s reports the one decision that changed. The timer still fires where the history says it did, because replay continues from the recorded event:

$ orbit replay rem-1 --definition reminder-v2.orbit
replayed 9 events of rem-1 against Reminder@1.0.0 with the current decider
  5 decided, 3 external, 1 divergent
  note: the history was recorded with definition sha256:a442698aa0a986027f90c9213419a8aa868f59fffa35b1d8a7807c42fd7f4410, not sha256:fcdfa74b0e068e4eb5c7381adbdf5e4e2d4ad7515c24c70c75fe1bf6a9cc5e72

divergence at #4: the runtime recorded
    {"type":"TimerStarted","step":"wait","fire_at_ms":1794753908621}
  but the current decider would record
    {"type":"TimerStarted","step":"wait","fire_at_ms":1794753917621}

Offline

Save a history and replay it later, or on another machine, against a compiled definition or the source:

curl -s "$ORBIT_SERVER/v1/executions/rem-1/history" > rem-1.json
orbit compile reminder.orbit -o build
orbit replay --history rem-1.json --definition build/reminder.orbit.json

The history array printed by orbit simulate --json and orbit test --format json (for failed tests) can be replayed the same way.

JSON report

FieldMeaning
execution_idThe replayed execution.
events, external, decidedHow many events were replayed, and how many of each origin.
divergences[]seq, the recorded event, and decider: the events the current decider returns at that point instead.
origins[]seq and origin (external, decided or divergent) of every replayed event.
definition_mismatchPresent when the history ran another definition: [recorded hash, replayed hash].
pendingWhat the decider would do next after the last replayed event.
stateThe reconstructed execution state after the last replayed event.
$ orbit replay --history rem-1.json --definition reminder.orbit --json
{
  "execution_id": "rem-1",
  "events": 9,
  "external": 3,
  "decided": 6,
  "divergences": [],
  "origins": [
    {
      "seq": 1,
      "origin": "external"
    },
    ...
  ],
  "pending": [],
  "state": { ... }
}

See also

Editor support

ORBIT ships a language server, orbit lsp, built into the orbit binary. It reports diagnostics from the real compiler as you type, and provides hover, go to definition, completion, an outline and formatting. The VS Code extension packages it with syntax highlighting and commands to run, deploy and graph workflows. Any other editor with an LSP client can use orbit lsp over stdio.

Language server features

FeatureLSP methodWhat it does
DiagnosticstextDocument/publishDiagnosticsRuns the compiler on every open, change and save, with unsaved buffers taking precedence over files on disk, imports included. Diagnostics carry the stable code (for example E0300) and source: "orbit". See Compiler diagnostics.
HovertextDocument/hoverTask contracts, step output types, field types along step.field.sub paths, schemas, child workflows, built-in functions and keywords.
Go to definitiontextDocument/definitionSteps, schemas, tasks and child workflows, across imports.
CompletiontextDocument/completionFields after input. and <step>., task names inside worker "…", keywords, types and built-in functions. Triggered by . and ".
OutlinetextDocument/documentSymbolWorkflows with their steps, schemas and tasks.
FormattingtextDocument/formattingThe same formatter as orbit fmt. See Formatter.

The server uses full document synchronization, identifies itself as orbit-lsp, and reads and writes JSON-RPC messages with Content-Length headers on stdin and stdout. It takes no command-line options and no initialization options.

Visual Studio Code

Install

The extension is attached to every GitHub release as orbit-lang.vsix. Download it from the releases page and install it:

curl -fsSLO https://github.com/AjmalX0/Orbit/releases/latest/download/orbit-lang.vsix
code --install-extension orbit-lang.vsix

You can also use Extensions: Install from VSIX… in the Command Palette.

The extension needs the orbit binary, version 0.1 or later (see Installation). If orbit is not on your PATH, set orbit.serverPath. It works with VS Code 1.82 or later, and activates when you open an .orbit file or a folder that contains one.

Features

  • Syntax highlighting for .orbit files, with bracket matching, auto-closing pairs and comment toggling.
  • Everything the language server provides, listed above.
  • Format on save, on by default for ORBIT files.
  • Commands and a run button in the editor title bar.

The extension sets these defaults for the [orbit] language, which you can override in your settings:

"[orbit]": {
  "editor.defaultFormatter": "orbit-lang.orbit-lang",
  "editor.formatOnSave": true,
  "editor.tabSize": 4
}

Commands

CommandIDWhat it does
ORBIT: Run Workfloworbit.runSaves the file, asks for the workflow input as JSON (default {}), and runs orbit run <file> --input <json> in an integrated terminal named ORBIT. Also available as the run button in the editor title bar.
ORBIT: Deploy Workflows in This Fileorbit.deployRuns orbit deploy <file> in the terminal.
ORBIT: Check Fileorbit.checkRuns orbit check <file> in the terminal.
ORBIT: Show Step Graph (Mermaid)orbit.graphRuns orbit graph <file> and opens the Mermaid source beside the editor. Use a Mermaid preview extension to render it.
ORBIT: Restart Language Serverorbit.restartServerStops and starts orbit lsp.

The terminal is created with ORBIT_SERVER set to orbit.runtimeUrl. To use a runtime with authentication, export ORBIT_TOKEN (and ORBIT_CA_CERT for a private CA) in the environment VS Code starts from, or type the command in the terminal yourself with --token.

Settings

SettingTypeDefaultMeaning
orbit.serverPathstringorbitPath to the orbit binary, used for orbit lsp and the commands. Changing it restarts the language server.
orbit.runtimeUrlstringhttp://127.0.0.1:7233Runtime used by the Run and Deploy commands, passed as ORBIT_SERVER.
orbit.trace.serveroff, messages or verboseoffLog the communication with the language server in the ORBIT Language Server output channel.

Troubleshooting

If the extension cannot start the server, it shows ORBIT: could not start `orbit lsp` (...). Install orbit or set "orbit.serverPath". Check that orbit --version works in a terminal, set orbit.serverPath to an absolute path if needed, and run ORBIT: Restart Language Server. Set orbit.trace.server to verbose to see every message.

Other editors

Configure your editor’s LSP client to start orbit lsp for files with the .orbit extension, communicating over stdio. No arguments, initialization options or workspace settings are needed. These examples assume orbit is on your PATH.

Syntax highlighting is not part of the language server. The VS Code extension contains a TextMate grammar (editors/vscode/syntaxes/orbit.tmLanguage.json) that editors with TextMate support can reuse. Elsewhere, highlighting as a C-like language gives a reasonable approximation: ORBIT uses // and /* */ comments, double-quoted strings and braces.

Neovim

With Neovim 0.11 or later and the built-in client:

-- init.lua
vim.filetype.add({ extension = { orbit = "orbit" } })

vim.lsp.config("orbit", {
  cmd = { "orbit", "lsp" },
  filetypes = { "orbit" },
  root_markers = { ".git" },
})
vim.lsp.enable("orbit")

-- Format on save.
vim.api.nvim_create_autocmd("BufWritePre", {
  pattern = "*.orbit",
  callback = function() vim.lsp.buf.format({ async = false }) end,
})

With Neovim 0.10, start the client from an autocommand instead:

vim.filetype.add({ extension = { orbit = "orbit" } })

vim.api.nvim_create_autocmd("FileType", {
  pattern = "orbit",
  callback = function(args)
    vim.lsp.start({
      name = "orbit",
      cmd = { "orbit", "lsp" },
      root_dir = vim.fs.root(args.buf, { ".git" }) or vim.fn.getcwd(),
    })
  end,
})

Helix

# ~/.config/helix/languages.toml
[language-server.orbit]
command = "orbit"
args = ["lsp"]

[[language]]
name = "orbit"
scope = "source.orbit"
file-types = ["orbit"]
roots = [".git"]
comment-token = "//"
block-comment-tokens = { start = "/*", end = "*/" }
indent = { tab-width = 4, unit = "    " }
language-servers = ["orbit"]
auto-format = true

Run hx --health orbit to check that Helix finds the server.

Other clients

For Emacs (eglot), Sublime Text (LSP package), Zed, Kate and other clients, register a server with:

SettingValue
Commandorbit lsp
Transportstdio
File pattern*.orbit
Language idorbit
Root markeryour repository root, for example .git

For example, with eglot:

(define-derived-mode orbit-mode prog-mode "ORBIT"
  (setq-local comment-start "// "))
(add-to-list 'auto-mode-alist '("\\.orbit\\'" . orbit-mode))
(with-eval-after-load 'eglot
  (add-to-list 'eglot-server-programs '(orbit-mode "orbit" "lsp")))

See also

Workflow graphs

orbit graph renders a workflow as a diagram: a Mermaid flowchart, a Graphviz (DOT) digraph, an SVG image, or a Mermaid sequence diagram of the calls the workflow makes. The diagram is drawn from the compiled definition, so it shows exactly what the runtime executes: implied dependencies, conditions, fan-out, retries, timeouts, compensations, outcome handlers and event handlers.

Usage

orbit graph workflows/order.orbit                      # Mermaid flowchart, to stdout
orbit graph workflows/order.orbit --format dot         # Graphviz DOT
orbit graph workflows/order.orbit --format svg -o order.svg
orbit graph workflows/order.orbit --sequence           # Mermaid sequence diagram
orbit graph workflows/fulfilment.orbit --workflow Shipping
Argument or optionDefaultMeaning
<FILE>requiredA .orbit file or a compiled .json IR file.
--workflow <NAME>see belowThe workflow to render, when the file defines several.
--format <FORMAT>mermaidmermaid, dot or svg.
--sequenceoffRender a Mermaid sequence diagram instead of the step graph. Only with --format mermaid.
-o, --output <PATH>stdoutWrite to a file (parent directories are created) and print wrote <PATH> on stderr.

Without --workflow, a file with one workflow renders that workflow, and a file with a parent workflow and its children renders the parent (the one workflow no other workflow in the file starts). Otherwise the command lists the workflows and asks you to choose:

$ orbit graph examples/fulfilment.orbit --workflow Nope
error: no workflow `Nope`; available: Shipping, Fulfilment

The file must compile; compilation errors are printed as with orbit check, and the command exits with status 1. --sequence with --format dot or svg is an error (status 1).

Deterministic output

The same definition always renders to the same text: steps appear in the IR’s execution order (a stable topological order), dependencies are sorted, handlers appear in the order failure, cancel, timeout, and event blocks are sorted by signal. You can commit the diagram next to the workflow and review changes to it as a diff. The first comment lines carry the workflow name, version and definition hash, so a stale diagram is visible in review.

What the graph shows

ElementMermaidGraphvizSVG
Steprectangle id["..."]rounded boxrounded box
Step with a when conditionhexagon id{{"..."}}shape=hexagonbox with an accent border
Dependency (from after or from reading another step’s output)a --> b"a" -> "b"curved arrow
Compensationstadium <step>__undo(["undo <step>..."]) with class compensation, linked by <step> -. compensate .-> <step>__undodashed box, dashed edge labelled compensatedashed box to the right of the step
Signal feeding an await stepflag signal__<name>>"signal <name>"], dotted edge to the stepshape=cds, dashed edgenot drawn (the step label names the signal)
on failure, on cancel, on timeoutsubgraph on_<kind>["on <kind>"]dashed cluster cluster_on_<kind>labelled panel below the main graph
on event <signal>subgraph on_event_<signal>["on event <signal>"]dashed clusterlabelled panel
continue_as_newsubroutine continue_as_new[["continue as new..."]] after the final steps, and continue_as_new -. next run .-> <first step>peripheries=2 box, dashed next run edge with constraint=falsebox with a double accent border below the final steps, dashed next run edge up the right side into the first steps

Node ids of handler and event steps are prefixed with the subgraph id (on_failure__tell_customer), so a handler step may share a name with a main step.

Each step’s label lists, one per line:

LineWhen presentExample
step namealwayscharge
actionalwayspayments.charge@1.0.0 · python, child workflow Shipping, sleep 1h, await signal review, compute
fan-outfor stepsfor each item (max 4 parallel)
conditionwhenwhen analyze_risk.score < 0.8
retrymore than one attemptretry 5 attempts, exponential 500ms..10s
idempotencyidempotency_keyidempotency key
timeoutstimeout, queue_timeout, await ... timeouttimeout 30s, queue 5m

Conditions are printed back as ORBIT expressions from the IR. Durations inside expressions appear as milliseconds, because the IR does not keep the unit. In Mermaid labels, <, >, " and # are written as the entities #lt;, #gt;, #quot; and #35;, which Mermaid renders as the characters.

The header comment also shows a workflow timeout and a limits block when present (%% workflow timeout 5m, %% limits max_history_events 5000).

Continue-as-new

A workflow with continue_as_new gets one more node. It follows the steps nothing depends on, because the condition is evaluated when every step has finished, and a dashed next run edge goes from it back to the steps without dependencies, where the next run starts. Its label has three lines: continue as new, the condition (when ..., or always without when), and the fields the next run’s input sets (next input { ... }, or the expression when the input is not an object literal). The node id continue_as_new is a keyword, so it never clashes with a step.

$ orbit graph examples/subscription.orbit
flowchart TD
    %% Subscription@1.0.0
    %% sha256:1d7a374edd1e400b4fb61a0d851ea74a99b4620006f6be5f06ce7a9421d6f8f1
    classDef compensation stroke-dasharray: 4 3
    classDef signal stroke-dasharray: 2 2
    charge["charge<br/><small>payments.charge@1.0.0</small><br/><small>retry 5 attempts, exponential 1m..1h</small><br/><small>idempotency key</small>"]
    receipt["receipt<br/><small>notifications.send@1.0.0</small>"]
    charge --> receipt
    next_period["next_period<br/><small>sleep 30d</small>"]
    receipt --> next_period
    continue_as_new[["continue as new<br/><small>when input.period #lt; input.periods</small><br/><small>next input { customer_id, amount, periods, period, charged }</small>"]]
    next_period --> continue_as_new
    continue_as_new -. next run .-> charge
$ orbit graph examples/subscription.orbit --format dot
digraph "Subscription" {
  // Subscription@1.0.0
  // sha256:1d7a374edd1e400b4fb61a0d851ea74a99b4620006f6be5f06ce7a9421d6f8f1
  rankdir=TB;
  node [shape=box, style=rounded, fontname="Helvetica", fontsize=11];
  edge [fontname="Helvetica", fontsize=10];
  "charge" [label="charge\npayments.charge@1.0.0\nretry 5 attempts, exponential 1m..1h\nidempotency key"];
  "receipt" [label="receipt\nnotifications.send@1.0.0"];
  "charge" -> "receipt";
  "next_period" [label="next_period\nsleep 30d"];
  "receipt" -> "next_period";
  "continue_as_new" [label="continue as new\nwhen input.period < input.periods\nnext input { customer_id, amount, periods, period, charged }", peripheries=2];
  "next_period" -> "continue_as_new";
  "continue_as_new" -> "charge" [style=dashed, constraint=false, label="next run"];
}

orbit graph examples/subscription.orbit --format svg:

Step graph of examples/subscription.orbit: charge, receipt and next_period, then continue as new with a dashed next run edge back to charge

In a sequence diagram (--sequence), the workflow sends itself a continue_as_new message after the main steps, inside opt when <condition> when there is a condition:

$ orbit graph examples/subscription.orbit --sequence
sequenceDiagram
    %% Subscription@1.0.0
    %% sha256:1d7a374edd1e400b4fb61a0d851ea74a99b4620006f6be5f06ce7a9421d6f8f1
    participant W as Subscription
    participant payments
    participant notifications
    W->>+payments: charge: payments.charge@1.0.0
    payments-->>-W: PaymentResult
    W->>+notifications: receipt: notifications.send@1.0.0
    notifications-->>-W: { delivered }
    Note over W: next_period: sleep 30d
    opt when input.period < input.periods
        W->>W: continue_as_new: next run with { customer_id, amount, periods, period, charged }
    end

The web UI draws the same node, coloured by whether a run continued.

Mermaid

A real diagram of examples/checkout.orbit:

$ orbit graph examples/checkout.orbit
flowchart TD
    %% Checkout@1.0.0
    %% sha256:8e0eda310a1bac4d68d8910cb6103264f436a98cc16535b7c5e95eac2a4a53c3
    classDef compensation stroke-dasharray: 4 3
    classDef signal stroke-dasharray: 2 2
    reserve["reserve<br/><small>inventory.reserve@1.0.0</small>"]
    reserve__undo(["undo reserve<br/><small>inventory.release@1.0.0</small>"]):::compensation
    reserve -. compensate .-> reserve__undo
    charge["charge<br/><small>payments.charge@1.0.0</small>"]
    reserve --> charge
    subgraph on_failure["on failure"]
        direction TB
        on_failure__tell_customer["tell_customer<br/><small>notifications.send@1.0.0</small>"]
        on_failure__summary["summary<br/><small>compute</small>"]
    end
    subgraph on_cancel["on cancel"]
        direction TB
        on_cancel__confirm_cancel["confirm_cancel<br/><small>notifications.send@1.0.0</small>"]
    end

This documentation is built without a Mermaid plugin, so the block above shows as source. GitHub and GitLab render mermaid code blocks in Markdown files, issues and pull requests. The same workflow rendered by orbit graph examples/checkout.orbit --format svg:

Step graph of examples/checkout.orbit, rendered by orbit graph –format svg

A workflow with conditions, retries and compensations (examples/order_processing.orbit):

flowchart TD
    %% OrderProcessing@1.0.0
    %% sha256:4fdfac082bf3f2fbbbc8ac5465d37cba01dffd1b8e4ae08e25be3e0d76486c41
    %% workflow timeout 5m
    classDef compensation stroke-dasharray: 4 3
    classDef signal stroke-dasharray: 2 2
    validate_order["validate_order<br/><small>orders.validate@1.0.0 · typescript</small><br/><small>timeout 10s</small>"]
    analyze_risk["analyze_risk<br/><small>risk.analyze@1.0.0 · python</small><br/><small>retry 3 attempts, exponential 1s..30s</small>"]
    reserve_inventory["reserve_inventory<br/><small>inventory.reserve@1.0.0 · typescript</small>"]
    validate_order --> reserve_inventory
    reserve_inventory__undo(["undo reserve_inventory<br/><small>inventory.release@1.0.0</small>"]):::compensation
    reserve_inventory -. compensate .-> reserve_inventory__undo
    process_payment{{"process_payment<br/><small>payments.charge@1.0.0 · python</small><br/><small>when analyze_risk.score #lt; 0.8</small><br/><small>retry 5 attempts, exponential 500ms..10s</small><br/><small>idempotency key</small><br/><small>timeout 30s</small>"}}
    analyze_risk --> process_payment
    reserve_inventory --> process_payment
    process_payment__undo(["undo process_payment<br/><small>payments.refund@1.0.0</small>"]):::compensation
    process_payment -. compensate .-> process_payment__undo
    send_notification["send_notification<br/><small>notifications.send@1.0.0 · typescript</small>"]
    process_payment --> send_notification

process_payment depends on analyze_risk because its condition reads analyze_risk.score, even though its after names only reserve_inventory.

A for step and a child workflow (examples/fulfilment.orbit):

$ orbit graph examples/fulfilment.orbit --workflow Fulfilment
flowchart TD
    %% Fulfilment@1.0.0
    %% sha256:ecffc60c3bd361d5112497749c532dc808cbe2db9f35af22c1ffee8f2f9d90d3
    classDef compensation stroke-dasharray: 4 3
    classDef signal stroke-dasharray: 2 2
    reserve["reserve<br/><small>inventory.reserve@1.0.0 · typescript</small><br/><small>for each item (max 4 parallel)</small>"]
    reserve__undo(["undo reserve<br/><small>inventory.release@1.0.0</small>"]):::compensation
    reserve -. compensate .-> reserve__undo
    ship["ship<br/><small>child workflow Shipping</small><br/><small>timeout 1h</small>"]

The child workflow is a single node. Render it separately with --workflow Shipping.

To produce an image from Mermaid source, use the Mermaid CLI:

orbit graph workflows/order.orbit -o order.mmd
npx -y @mermaid-js/mermaid-cli -i order.mmd -o order.svg

Graphviz

$ orbit graph examples/shipment.orbit --format dot
digraph "Shipment" {
  // Shipment@1.0.0
  // sha256:dc7d17291b8a4c9bcea44a73e12a8bbde9f20ec9d36f5447ea90d42882f4154d
  // limits max_history_events 5000
  rankdir=TB;
  node [shape=box, style=rounded, fontname="Helvetica", fontsize=11];
  edge [fontname="Helvetica", fontsize=10];
  "signal__delivered" [label="signal delivered", shape=cds, style=dashed];
  "arrival" [label="arrival\nawait signal delivered\ntimeout 30d"];
  "signal__delivered" -> "arrival" [style=dashed];
  "confirm" [label="confirm\nnotifications.send@1.0.0"];
  "arrival" -> "confirm";
  subgraph "cluster_on_event_location" {
    label="on event location";
    style=dashed;
    "on_event_location__text" [label="text\ncompute"];
    "on_event_location__tell" [label="tell\nnotifications.send@1.0.0"];
    "on_event_location__text" -> "on_event_location__tell";
  }
}

Render it with Graphviz:

orbit graph workflows/order.orbit --format dot | dot -Tsvg -o order.svg
orbit graph workflows/order.orbit --format dot | dot -Tpng -o order.png

SVG without external tools

--format svg lays the graph out itself and writes a standalone SVG document; neither Graphviz nor a browser is needed. The layout is simple and deterministic:

  • each step is placed one layer below its deepest dependency (longest path from the steps without dependencies), and steps in a layer keep the execution order;
  • a compensation is placed to the right of the step it undoes, on the same layer;
  • each outcome handler and event block is laid out the same way in its own labelled panel, below the main graph;
  • a continue_as_new node is placed one layer below the final steps; its dashed next run edges run up the right side of the graph and into the top of the first steps;
  • dependencies are curves from the bottom of a step to the top of its dependent. An edge that skips a layer can pass behind a node of the layer in between; use Graphviz for large graphs where that matters.

The SVG uses a monospace font, embeds its own stylesheet (colours follow prefers-color-scheme) and has a transparent background. orbit docs --format html embeds the same rendering in each workflow page.

Sequence diagrams

--sequence renders the calls a workflow makes as a Mermaid sequence diagram:

$ orbit graph examples/approval.orbit --sequence
sequenceDiagram
    %% OrderApproval@1.0.0
    %% sha256:939fd564b818da34796032e2c0086ce2f28118e2e9f31a6d9c1b21fdbfc57066
    participant W as OrderApproval
    participant Sender
    participant payments
    participant notifications
    Note over W: assess: compute
    opt when assess.needs_approval
        Sender->>W: decision: signal review (timeout 2d)
    end
    opt when !assess.needs_approval || coalesce(decision.approved, false)
        W->>+payments: charge: payments.charge@1.0.0
        payments-->>-W: PaymentResult
    end
    W->>+notifications: notify: notifications.send@1.0.0
    notifications-->>-W: { delivered }
    opt compensation, in reverse order, after a failure or cancellation
        W->>+payments: undo charge: payments.refund@1.0.0
        payments-->>-W: { refunded }
    end
ElementDrawn as
Worker taska call to a participant named after the task’s namespace (payments for payments.charge) and a reply with the contract’s output type (a record’s name, or its field names)
Child workflowa call to the participant child_<Name>
awaita message from the participant Sender
sleep, computea note over the workflow
whenan opt when <condition> block
fora loop for each <variable> block
Compensationsone opt compensation ... block at the end, in reverse execution order
continue_as_newa message W->>W: continue_as_new: next run with { ... } after the main steps, in an opt when <condition> block when it has one
Handlers and event blocksone opt on <kind> or opt on event <signal> block each

Steps appear in execution order. Steps that can run concurrently are drawn one after the other; the flowchart shows which steps are independent.

In VS Code

ORBIT: Show Step Graph (Mermaid) runs orbit graph on the current file and opens the Mermaid source beside the editor. See Editor support.

See also

Documentation generation

orbit docs turns .orbit files into reference documentation: one page per workflow, one page for the task contracts and an index. orbit schema exports JSON Schema for every payload that crosses the runtime’s boundary. Both read the compiled definitions, so the documentation describes what the runtime executes, and both are deterministic, so the output can be committed and reviewed.

Usage

orbit docs workflows/*.orbit --output build/docs                    # Markdown
orbit docs workflows/*.orbit --output build/docs --format html      # self-contained HTML
orbit docs workflows/*.orbit --output build/docs --json-schema      # also write schemas/
orbit schema workflows/*.orbit                                      # all schemas as one JSON object
orbit schema workflows/*.orbit --output build/schemas               # one file per schema
OptionDefaultMeaning
<FILES>...required.orbit files. Imports are followed.
-o, --output <DIR>requiredOutput directory, created if needed. Existing files with the same names are overwritten; other files are left alone.
--format <FORMAT>markdownmarkdown writes .md files, html writes .html files.
--json-schemaoffAlso write the JSON Schema files to <DIR>/schemas and add a “JSON Schema” table with links to each workflow page.

The files must compile. Compilation errors are printed as with orbit check and the command exits 1 without writing anything. Warnings are printed and do not stop generation.

Pages

FileContent
index.md / index.htmlEvery workflow (version, number of steps, description) and every task contract (version, compensated_by), with links.
<Workflow>.md / <Workflow>.htmlOne page per workflow, described below.
contracts.md / contracts.htmlEvery task contract declared in the files, and every built-in task a workflow uses: doc comment, description, version, compensated_by, source location, the workflows that use it, and input and output field tables. Each contract has the anchor task-<name> with non-alphanumeric characters replaced by - (#task-payments-charge).

A workflow page contains, in order:

SectionContent
Title and textThe workflow’s /// doc comment, then its description.
FactsVersion, definition hash, IR version, source file and line, workflow timeout, limits.
InputOne row per field: path, type, required, default, doc comment. Fields of nested records, also inside lists (items[].sku), maps (prices{}.amount) and optionals, get their own rows.
OutputPath, type and doc comment of each field, then the return expression.
SignalsFor each signal: doc comment, the steps that await it (or the on event block that handles it), and its fields.
QueriesName, result type, expression and doc comment.
StepsOne row per step in execution order: action (task and contract version with a link to the contract, using language, child workflow, sleep, await, or the compute expression), fan-out (for each ... in ..., max_parallel), direct dependencies (explicit and implied), when condition, retry policy, timeouts and idempotency key, compensation, doc comment.
Continue-as-newFor a workflow with continue_as_new: when it applies (its condition, or always), and a table of the next run’s input, field by field, including the fields left to their defaults. See below.
Outcome handlersOne steps table per on failure, on cancel, on timeout block.
Event handlersOne steps table per on event <signal> block.
DiagramMarkdown: the Mermaid flowchart from orbit graph in a mermaid code block. HTML: the SVG rendering from orbit graph --format svg, inline, with the Mermaid source in a collapsed section.
Task contracts usedEach contract with its input and output types and compensated_by, and the child workflows the workflow starts.
JSON SchemaWith --json-schema: links to the workflow’s schema files.

Expressions (conditions, step inputs, queries, return) are printed back as ORBIT source from the IR. Durations inside expressions appear as milliseconds.

HTML

Each HTML page is a single file with its stylesheet and diagram inline: no scripts, no fonts, no images or stylesheets loaded from elsewhere, so a page can be attached to a ticket or archived as it is. Pages link to each other with relative links (contracts.html#task-payments-charge, index.html). Typography uses the system font stack; colours follow the light and dark tokens of the ORBIT website and switch with prefers-color-scheme.

Continue-as-new

For examples/subscription.orbit, the section after the steps reads:

$ orbit docs examples/subscription.orbit --output reference
wrote reference/index.md
wrote reference/Subscription.md
wrote reference/contracts.md
## Continue-as-new

When every step has finished and `input.period < input.periods` is true, the run ends with status `CONTINUED_AS_NEW` and the execution continues as a new run, under the same id, with the input below and an empty history. Otherwise the run completes with `return`.

| Next run's input | Value |
|---|---|
| `customer_id` | `input.customer_id` |
| `amount` | `input.amount` |
| `periods` | `input.periods` |
| `period` | `input.period + 1` |
| `charged` | `input.charged + input.amount` |

Fields of the workflow input that the with object leaves out are listed with default <value> (or null for an optional field without a default). Without when, the text says that the execution always continues and never completes by itself. When the next input is not an object literal (continue_as_new with input), it is shown as an orbit code block. The diagram shows the continue_as_new node and its next run edge (Workflow graphs).

Doc comments

A doc comment is one or more /// line comments directly above a declaration, each on its own line:

/// Pays a refund back to the customer's card.
task "payments.payout" {
    version "1.0.0"
    input {
        /// Amount in the order currency.
        amount: Float
        order_id: String
    }
    output { payout_id: String }
}

/// Refunds an order after a support agent approves it.
///
/// Refunds of 500 or more need a second approval.
workflow Refund {
    version "1.0.0"
    input {
        /// The order to refund.
        order_id: String
        /// Amount to refund, at most the amount charged.
        amount: Float
    }
    output { payout_id: String? }

    /// Sent by the support tool when an agent decides.
    signal approval { approved: Bool, agent: String }

    /// Waits for the agent's decision.
    step decision {
        await approval
        timeout 3d
    }

    /// Pays the refund once approved.
    step payout {
        worker "payments.payout"
        when decision.approved
        with { amount: input.amount, order_id: input.order_id }
        retry { attempts: 3, backoff: exponential }
        timeout 30s
    }

    return { payout_id: payout.payout_id }
}

Doc comments are recognised on workflows, steps (also inside on blocks), signals, queries, task contracts, schemas, and fields of input, output, signal, schema and inline task records. The rules:

  • Each line starts with /// (after indentation). //// and longer runs are ordinary comments.
  • The block must be directly above the declaration: a blank line or an ordinary // comment between them ends it.
  • The declaration must start its line. In input { a: String, b: Int } the fields cannot have doc comments; put each field on its own line.
  • A line containing only /// separates paragraphs. Workflow and contract doc comments keep their paragraphs; in table cells, lines are joined with spaces.
  • The text is inserted as written. In HTML, `code` and [text](url) are rendered; other characters are escaped.

To the lexer, parser and formatter, a doc comment is an ordinary // comment. It does not change the definition hash, and orbit fmt keeps it in place.

Example

$ orbit docs refunds.orbit --output ref --json-schema
wrote ref/index.md
wrote ref/Refund.md
wrote ref/contracts.md
wrote 5 JSON Schema file(s) in ref/schemas

ref/Refund.md:

# Workflow `Refund`

Refunds an order after a support agent approves it.

Refunds of 500 or more need a second approval.

|  |  |
|---|---|
| Version | `1.0.0` |
| Definition hash | `sha256:f6b6a421b594d654d63b901a2c2230fd93a08e81425c152c3481223a890a45ae` |
| IR version | `orbit-ir/2` |
| Source | `refunds.orbit:15` |
| Workflow timeout | none |
| Limits | server defaults |

## Input

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `order_id` | `String` | yes |  | The order to refund. |
| `amount` | `Float` | yes |  | Amount to refund, at most the amount charged. |

## Output

| Field | Type | Description |
|---|---|---|
| `payout_id` | `String?` |  |

```orbit
return { payout_id: payout.payout_id }
```

## Signals

### Signal `approval`

Sent by the support tool when an agent decides.

Received by `decision`.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `approved` | `Bool` | yes |  |  |
| `agent` | `String` | yes |  |  |

## Steps

In execution order. `After` lists direct dependencies, including those implied by reading another step's output.

| Step | Action | After | When | Retry | Timeouts | Compensation | Description |
|---|---|---|---|---|---|---|---|
| `decision` | `await approval` |  |  |  | timeout 3d |  | Waits for the agent's decision. |
| `payout` | [`payments.payout`](contracts.md#task-payments-payout)@1.0.0 | `decision` | `decision.approved` | 3 attempts, exponential 1s..1m | timeout 30s |  | Pays the refund once approved. |

## Diagram

```mermaid
flowchart TD
    %% Refund@1.0.0
    %% sha256:f6b6a421b594d654d63b901a2c2230fd93a08e81425c152c3481223a890a45ae
    classDef compensation stroke-dasharray: 4 3
    classDef signal stroke-dasharray: 2 2
    signal__approval>"signal approval"]:::signal
    decision["decision<br/><small>await signal approval</small><br/><small>timeout 3d</small>"]
    signal__approval -.-> decision
    payout{{"payout<br/><small>payments.payout@1.0.0</small><br/><small>when decision.approved</small><br/><small>retry 3 attempts, exponential 1s..1m</small><br/><small>timeout 30s</small>"}}
    decision --> payout
```

## Task contracts used

| Task | Version | Input | Output | Compensated by |
|---|---|---|---|---|
| [`payments.payout`](contracts.md#task-payments-payout) | `1.0.0` | `{ amount: Float, order_id: String }` | `{ payout_id: String }` |  |

## JSON Schema

| Payload | Schema |
|---|---|
| input | [`workflow.Refund.input.schema.json`](schemas/workflow.Refund.input.schema.json) |
| output | [`workflow.Refund.output.schema.json`](schemas/workflow.Refund.output.schema.json) |
| signal approval | [`workflow.Refund.signal.approval.schema.json`](schemas/workflow.Refund.signal.approval.schema.json) |

ref/contracts.md:

# Task contracts

The typed interfaces that workers implement. Every step that calls a task is checked against its contract.

<a id="task-payments-payout"></a>

## `payments.payout`

Pays a refund back to the customer's card.

|  |  |
|---|---|
| Version | `1.0.0` |
| Source | `refunds.orbit:2` |
| Used by | [`Refund`](Refund.md) |

### Input

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `amount` | `Float` | yes |  | Amount in the order currency. |
| `order_id` | `String` | yes |  |  |

### Output

| Field | Type | Description |
|---|---|---|
| `payout_id` | `String` |  |

The Source row shows the path as given on the command line, so run orbit docs from the same directory every time if the output is committed.

JSON Schema

orbit schema (and orbit docs --json-schema) writes one JSON Schema draft 2020-12 document per payload:

NamePayload
workflow.<Workflow>.inputExecution input (POST /v1/executions, orbit run --input)
workflow.<Workflow>.outputExecution result
workflow.<Workflow>.signal.<signal>Signal payload (orbit signal --data)
workflow.<Workflow>.query.<query>Query result
task.<task>.inputWhat a worker receives for the task
task.<task>.outputWhat a worker returns

Without --output, orbit schema prints one JSON object that maps each name to its schema. With --output <DIR>, it writes <DIR>/<name>.schema.json. With --workflow <NAME>, only that workflow’s schemas and the schemas of the task contracts it uses are exported. Child-workflow contracts are covered by the child’s own workflow.<Child>.* schemas.

$ orbit schema refunds.orbit -o schemas
wrote 5 JSON Schema file(s) in schemas
$ cat schemas/workflow.Refund.signal.approval.schema.json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "urn:orbit:workflow.Refund.signal.approval@1.0.0",
  "title": "Refund@1.0.0 signal approval",
  "type": "object",
  "properties": {
    "approved": {
      "type": "boolean"
    },
    "agent": {
      "type": "string"
    }
  },
  "required": [
    "approved",
    "agent"
  ]
}

Each document has $schema, an $id of the form urn:orbit:<name>@<version> and a title. Types map as follows; the mapping follows the runtime’s own payload validation:

ORBIT typeJSON Schema
String{"type": "string"}
Int{"type": "integer"} with minimum and maximum of a signed 64-bit integer
Float{"type": "number"}
Bool{"type": "boolean"}
Bytes{"type": "string", "contentEncoding": "base64"}
Duration{"type": "integer", "minimum": 0} (milliseconds)
Timestamp{"type": "string", "format": "date-time"}
UUID{"type": "string", "format": "uuid"}
JSONtrue (any value)
List<T>{"type": "array", "items": T}
Map<String, T>{"type": "object", "additionalProperties": T}
T?{"anyOf": [T, {"type": "null"}]}
record{"type": "object", "properties": ..., "required": [...]}, with title for named schemas and default for fields with defaults

A field is in required unless it is optional or has a default. Workflow inputs are validated strictly by the runtime (unknown fields are rejected), so their records, including nested ones, have "additionalProperties": false. Signal payloads, task inputs and outputs, workflow outputs and query results allow extra fields, as the runtime does.

Differences from the runtime’s checks: draft 2020-12 treats format and contentEncoding as annotations, so most validators accept any string for Timestamp, UUID and Bytes unless format validation is enabled; the runtime always checks them.

Validating a payload with Python’s jsonschema package:

import json
from jsonschema import Draft202012Validator

schema = json.load(open("schemas/workflow.Refund.input.schema.json"))
validator = Draft202012Validator(schema)
for error in validator.iter_errors({"order_id": "A-1", "amount": "12", "note": "x"}):
    print(error.message)
'12' is not of type 'number'
Additional properties are not allowed ('note' was unexpected)

In CI

Regenerate the reference on every change and fail if it is out of date:

orbit docs workflows/*.orbit --output docs/reference --json-schema
git diff --exit-code docs/reference

See also

Process diffs

orbit diff compares two versions of a set of workflows and reports what the process does differently: steps added, removed or renamed, changed actions and contracts, dependencies, conditions, retries, timeouts, compensations, outcome and event handlers, signals, queries, input and output types, limits, and continue_as_new. It ends with the verdict of orbit compat, so one report answers both “what changed?” and “is it safe to deploy, and which version bump does it need?”. The Markdown format is meant to be pasted into a pull request.

A text diff of the source shows edits; orbit diff compares the compiled definitions, so it ignores formatting and comments, sees dependencies implied by data references, and reports a step that was only renamed as a rename.

Usage

orbit diff old/order.orbit new/order.orbit
orbit diff old/order.orbit new/order.orbit --format markdown > process-changes.md
orbit diff build/order.orbit.json workflows/order.orbit --workflow Order --exit-code
Argument or optionDefaultMeaning
<OLD>, <NEW>requiredA .orbit file (imports are followed) or a compiled IR .json file, for each side.
--workflow <NAME>allReport only this workflow, and only the compatibility findings for it and for task contracts.
--format <FORMAT>texttext, markdown or json.
--exit-codeoffExit 1 when there is any change.

Exit codes, as with git diff:

StatusWhen
0Success. With --exit-code: no changes.
1With --exit-code: there are changes.
2A file cannot be read, is not valid IR, or does not compile, or --workflow names a workflow that neither side defines.

orbit diff does not fail on an incompatible change; use orbit compat in CI for that, or read compatibility.compatible from the JSON output.

Example

The second version of examples/approval.orbit below lowers the approval threshold from 1000 to 500, shortens the reviewer’s deadline, adds a retry policy and a timeout to the charge, renames notify to tell_customer, and adds an on failure handler:

$ orbit diff approval.orbit v2/approval.orbit
workflow OrderApproval 1.0.0 → 1.1.0
  ~ workflow: version: 1.0.0 → 1.1.0
  > step: notify: notify → tell_customer
  ~ step input: assess: { needs_approval: input.amount >= 1000.0, rounded: float(int(input.amount * 100.0)) / 100.0 } → { needs_approval: input.amount >= 500.0, rounded: float(int(input.amount * 100.0)) / 100.0 }
  ~ timeout: decision: timeout 2d → timeout 1d
  ~ retry: charge: none → 3 attempts, exponential 1s..1m
  + timeout: charge: timeout 30s
  + handler: on failure: steps 1 (alert)
summary: workflows: 1 changed; 7 change(s): 2 added, 4 changed, 1 renamed; compatibility: compatible

Each line is <sigil> <area>: <subject>: <before> → <after>, with + added, - removed, ~ changed and > renamed. On a terminal the lines are coloured (disable with NO_COLOR=1).

Markdown for a pull request

$ orbit diff approval.orbit v2/approval.orbit --format markdown
## Process changes

**Summary:** workflows: 1 changed; 7 change(s): 2 added, 4 changed, 1 renamed; compatibility: compatible

### Workflow `OrderApproval 1.0.0 → 1.1.0`

| Change | Area | Subject | Before | After |
|---|---|---|---|---|
| changed | workflow | `version` | `1.0.0` | `1.1.0` |
| renamed | step | `notify` | `notify` | `tell_customer` |
| changed | step input | `assess` | `{ needs_approval: input.amount >= 1000.0, rounded: float(int(input.amount * 100.0)) / 100.0 }` | `{ needs_approval: input.amount >= 500.0, rounded: float(int(input.amount * 100.0)) / 100.0 }` |
| changed | timeout | `decision` | `timeout 2d` | `timeout 1d` |
| changed | retry | `charge` | `none` | `3 attempts, exponential 1s..1m` |
| added | timeout | `charge` |  | `timeout 30s` |
| added | handler | `on failure` |  | `steps 1 (alert)` |

### Compatibility

**compatible**

A change that needs a version bump

Adding a required input field and changing a timeout without changing the version:

$ orbit diff approval.orbit v3/approval.orbit --exit-code
workflow OrderApproval 1.0.0
  + input: input.channel: String
  ~ timeout: decision: timeout 2d → timeout 1d
compatibility
  error   workflow OrderApproval@1.0.0: content changed without a version change; the runtime rejects it with DEFINITION_CONFLICT; hint: bump the version (1.0.0 → 2.0.0)
summary: workflows: 1 changed; 2 change(s): 1 added, 1 changed; compatibility: incompatible, 1 error(s) (bump the version (1.0.0 → 2.0.0))
$ echo $?
1

The compatibility section lists the error and warning findings of orbit compat; ok and info findings are left out. The verdict is compatible, compatible, <n> warning(s) or incompatible, <n> error(s) (<hints>).

What is compared

Workflows are matched by name. A workflow only in the new version is reported as added, one only in the old version as removed.

AreaChanges reported
workflowversion, description, workflow timeout
input, outputFields added, removed or retyped, and changed defaults, by path (input.customer.email, output.items[].sku, input.prices{}.amount)
resultThe return expression
signalSignals added or removed; payload fields added, removed or retyped (signal review.comment)
queryQueries added or removed; changed expression; changed result type
stepSteps added, removed or renamed
actionWhat the step runs: task and contract version, using language, child workflow, sleep duration, awaited signal, compute
step inputThe with expression of a task or child workflow step, or the expression of a compute step
dependencyDirect dependencies added or removed (after x), explicit or implied by data references
conditionwhen added, removed or changed
fan-outfor variable, list expression and max_parallel
retryAttempts, backoff, delays and non-retryable codes
timeouttimeout, queue_timeout, and the timeout of an await step
idempotencyidempotency_key added, removed or changed
compensationCompensation added or removed, or a change of its task, contract version, input or retry policy
handleron failure, on cancel, on timeout blocks added or removed. Steps of a block present in both versions are compared like main steps; their subject is on <kind>: <step>.
eventon event <signal> blocks, the same way
limitsThe limits block
continue_as_newcontinue_as_new added or removed (subject continue_as_new, value when <condition> with <input>, or always with <input>), or a change of its condition (when ... or always) or of its next input
contractFor a child workflow, a different child definition. Declared task contracts (also when no workflow uses them, and only without --workflow): added or removed contracts, version changes, compensated_by changes, and input and output field changes (mail.send input.cc)

Expressions are compared as ORBIT source printed from the IR, so a change in formatting or comments is not a change.

Renames

Steps are matched by scope (main graph, on <kind>, on event <signal>) and name. A step that exists only in the old version and a step that exists only in the new version, in the same scope, are reported as one rename when they run the same thing and are the only unmatched pair that does: the same task (any contract version), the same child workflow, an await of the same signal, or both sleep or both compute. The renamed step is then compared field by field, and dependencies on it are compared under its new name, so other steps that depend on it show no change. Expressions that mention the old name (for example a handler that reads !is_null(charge)) do change, and are reported.

If two removed steps call the same task, neither is treated as a rename; they are reported as removed and added.

Continue-as-new

The second version of examples/subscription.orbit below continues only once the receipt was delivered, and raises the amount by 2% each period:

$ orbit diff subscription.orbit v2/subscription.orbit
workflow Subscription 1.0.0 → 1.1.0
  ~ workflow: version: 1.0.0 → 1.1.0
  ~ continue_as_new: condition: when input.period < input.periods → when input.period < input.periods && receipt.delivered
  ~ continue_as_new: input: { customer_id: input.customer_id, amount: input.amount, periods: input.periods, period: input.period + 1, charged: input.charged + input.amount } → { customer_id: input.customer_id, amount: input.amount * 1.02, periods: input.periods, period: input.period + 1, charged: input.charged + input.amount }
summary: workflows: 1 changed; 3 change(s): 3 changed; compatibility: compatible

The condition and the input are compared separately. Adding or removing continue_as_new is one change with the whole member as its value.

JSON

--format json prints one pretty-printed object with the keys summary, changes (the number of changes), workflows (each with name, old_version, new_version, status and changes), contracts and compatibility:

$ orbit diff approval.orbit v2/approval.orbit --format json | jq -c '{summary, changes, status: .workflows[0].status, rename: .workflows[0].changes[1], compatible: .compatibility.compatible}'
{"summary":"workflows: 1 changed; 7 change(s): 2 added, 4 changed, 1 renamed; compatibility: compatible","changes":7,"status":"changed","rename":{"kind":"renamed","area":"step","subject":"notify","old":"notify","new":"tell_customer"},"compatible":true}

status is added, removed, changed or unchanged; kind is added, removed, changed or renamed; old and new are strings or null. compatibility is the orbit compat --format json report.

In CI

Post the process changes as a pull request comment, for example with the GitHub CLI:

git show origin/main:workflows/order.orbit > /tmp/order.old.orbit
cp workflows/contracts.orbit /tmp/   # imports are resolved next to the file
orbit diff /tmp/order.old.orbit workflows/order.orbit --format markdown > diff.md
gh pr comment --body-file diff.md

See also

Policies

A policy is a file of organisation rules that every workflow must follow: payments are compensated, large charges wait for an approval, every wait has a deadline, only approved services are called. orbit check --policy <file> checks the compiled workflows against it and reports violations as compiler diagnostics, located at the step or workflow that breaks the rule, with codes in the P01xx range. The same flag on orbit compile and orbit deploy refuses to produce or register a definition that violates an error rule.

Policies are checked on the compiled definitions, after type checking. They add no runtime behaviour: the runtime does not read policy files.

Usage

orbit check   --policy policies/fintech.toml workflows/*.orbit
orbit compile --policy policies/fintech.toml workflows/*.orbit -o build
orbit deploy  --policy policies/fintech.toml workflows/*.orbit
orbit check   --policy policies/org.toml --policy policies/payments.toml workflows/*.orbit

--policy is repeatable; every policy is checked. It needs .orbit sources: orbit deploy --policy refuses compiled .json files, because violations and waivers are located in the source.

CommandPolicy errorsPolicy warnings only
orbit checkdiagnostics on stderr, error: policy check failed: <e> error(s), <w> warning(s), exit 1diagnostics, ok lines, exit 0
orbit check --strictas aboveexit 1, like any warning
orbit check --format jsonthe P diagnostics are in diagnostics, exit 1exit 0
orbit compilewrites nothing, exit 1writes the IR, exit 0
orbit deployregisters nothing from that file, exit 1, before contacting the serverdeploys, exit 0

Policies run only when the files compile without errors; compiler errors are reported as usual and policies are not checked. When every error is a policy error, the summary line is policy check failed: ...; otherwise it is compilation failed: ....

Example

examples/policies/fintech.toml:

# Example organisation policy for payment workflows.
#
#   orbit check --policy examples/policies/fintech.toml examples/approval.orbit
#
# approval.orbit passes (with two warnings); checkout.orbit violates the
# compensation and approval rules. See docs/src/tooling/policy.md.

[policy]
name = "fintech"
description = "Payment workflows must be reversible, approved above a threshold and bounded in time"
# Rules apply to every workflow unless a rule narrows its own scope.
workflows = ["*"]

# A charge that is not undone when a later step fails leaves money taken
# for an order that was never fulfilled.
[[rule]]
id = "payments-compensated"
type = "require_compensation"
tasks = ["payments.charge", "inventory.reserve"]
description = "every charge and reservation must be undone on failure"

# Large charges need a reviewer's decision before they run.
[[rule]]
id = "large-charge-approval"
type = "require_approval"
tasks = ["payments.charge"]
signals = ["review", "approval"]
when = "input.amount >= 10000"
description = "charges of 10000 or more need a recorded approval"

# Payment providers must not be hammered by retries.
[[rule]]
id = "payment-retries"
type = "max_attempts"
tasks = ["payments.*"]
max = 3

[[rule]]
id = "payment-timeout"
type = "require_timeout"
tasks = ["payments.*"]
max = "1m"
severity = "warning"

# A person who never answers must not keep an execution open forever.
[[rule]]
id = "approval-timeout"
type = "require_signal_timeout"
signals = ["review", "approval"]
max = "7d"

[[rule]]
id = "failure-handler"
type = "require_handler"
handlers = ["failure"]
severity = "warning"
description = "customers are told when their order fails"

[[rule]]
id = "no-legacy"
type = "forbid_tasks"
tasks = ["legacy.*"]

[[rule]]
id = "known-services"
type = "allow_tasks"
tasks = ["payments.*", "inventory.*", "notifications.*", "orders.*", "risk.*", "shipping.*"]

[[rule]]
id = "bounded-fan-out"
type = "max_fan_out"
max = 500
require_max_parallel = true

examples/approval.orbit passes, with two warnings:

$ orbit check --policy examples/policies/fintech.toml examples/approval.orbit
warning[P0106]: policy rule `failure-handler`: workflow `OrderApproval` has no `on failure` handler
  --> examples/approval.orbit:12:10
   |
12 | workflow OrderApproval {
   |          ^^^^^^^^^^^^^
   = note: policy fintech (examples/policies/fintech.toml): customers are told when their order fails
   = help: add `on failure { step ... }`, or waive it: `// policy-allow: failure-handler <reason>`

warning[P0104]: policy rule `payment-timeout`: step `charge` calls `payments.charge` without a `timeout`
  --> examples/approval.orbit:34:10
   |
34 |     step charge {
   |          ^^^^^^
   = note: required by policy fintech (examples/policies/fintech.toml)
   = help: add `timeout 1m` to the step, or waive it: `// policy-allow: payment-timeout <reason>`

ok OrderApproval@1.0.0 (4 steps)

Its charge is compensated, and it is guarded by the review approval as described under require_approval. examples/checkout.orbit charges without a compensation and without an approval:

$ orbit check --policy examples/policies/fintech.toml examples/checkout.orbit
error[P0101]: policy rule `payments-compensated`: step `charge` calls `payments.charge` but has no compensation
  --> examples/checkout.orbit:22:10
   |
22 |     step charge {
   |          ^^^^^^
   = note: policy fintech (examples/policies/fintech.toml): every charge and reservation must be undone on failure
   = help: add a `compensate { worker "..." with { ... } }` block, or waive it: `// policy-allow: payments-compensated <reason>`

error[P0105]: policy rule `large-charge-approval`: step `charge` calls `payments.charge` without depending on an approval (`await` of `review`, `approval`) when input.amount >= 10000
  --> examples/checkout.orbit:22:10
   |
22 |     step charge {
   |          ^^^^^^
   = note: policy fintech (examples/policies/fintech.toml): charges of 10000 or more need a recorded approval
   = help: add a step that awaits the approval signal and make `charge` depend on it (`after` or by reading its output), or waive it: `// policy-allow: large-charge-approval <reason>`

warning[P0104]: policy rule `payment-timeout`: step `charge` calls `payments.charge` without a `timeout`
  --> examples/checkout.orbit:22:10
   |
22 |     step charge {
   |          ^^^^^^
   = note: required by policy fintech (examples/policies/fintech.toml)
   = help: add `timeout 1m` to the step, or waive it: `// policy-allow: payment-timeout <reason>`

error: policy check failed: 2 error(s), 1 warning(s)
$ echo $?
1

With --format json, policy diagnostics have the same shape as compiler diagnostics:

$ orbit check --format json --policy examples/policies/fintech.toml examples/checkout.orbit | jq -c '.diagnostics[] | {severity, code, message, line: .location.line}'
{"severity":"error","code":"P0101","message":"policy rule `payments-compensated`: step `charge` calls `payments.charge` but has no compensation","line":22}
{"severity":"error","code":"P0105","message":"policy rule `large-charge-approval`: step `charge` calls `payments.charge` without depending on an approval (`await` of `review`, `approval`) when input.amount >= 10000","line":22}
{"severity":"warning","code":"P0104","message":"policy rule `payment-timeout`: step `charge` calls `payments.charge` without a `timeout`","line":22}

File format

A policy is a TOML file with an optional [policy] table, any number of [[rule]] tables and any number of [[exemption]] tables. Unknown keys are errors, so a misspelt key cannot silently disable a rule.

[policy]
name = "fintech"                 # shown in diagnostics; default: the file name without extension
description = "..."              # optional
workflows = ["*"]                # default scope of every rule; default ["*"]
exclude_workflows = []           # default exclusions of every rule

[[rule]]
id = "payments-compensated"      # used in diagnostics and waivers; default: the type
type = "require_compensation"    # required, see the rule reference
severity = "error"               # "error" (default) or "warning"
description = "..."              # shown as a note on each violation
workflows = ["Checkout*"]        # this rule's scope; default: [policy] workflows
exclude_workflows = ["Legacy*"]  # default: [policy] exclude_workflows
tasks = ["payments.charge"]      # rule-specific keys

Rule ids must be unique within a file; give two rules of the same type their own id. An id is one word without spaces.

Patterns

workflows, exclude_workflows, tasks, signals and compensate_with take glob patterns: * matches any run of characters, dots included, and ? matches one character. payments.* matches payments.charge and payments.eu.charge; *.charge matches payments.charge; * matches everything. Matching is case-sensitive.

Task patterns match worker task names, including built-in tasks such as orbit.http.request. They never match child workflows (workflow "Name" steps).

Errors in the policy file

A policy that cannot be loaded stops the command with exit 1 before anything is compiled or deployed, with a message naming the problem:

ProblemMessage (after error: policy <file>:)
TOML syntax, unknown key, wrong value typethe TOML parser’s message with line and column
Unknown rule typerule 3: unknown type …; known types: ...
Key that the rule type does not takerule id: tasksdoes not apply torequire_handler (it takes: handlers)
Missing required keyrule id: require_retryneedstasks = […]``
Invalid valuewrong severity, a max that is not a duration or a positive integer, min_attempts above max_attempts, an unknown handler or limit name, a malformed when
Duplicate idrule 2: duplicate id …``
Exemption for an unknown rule, or without a reasonexemption 1: unknown rule …``, exemption 1 (...): reason is required

Rules

TypeCodeChecksKeys
require_compensationP0101Main steps calling matching tasks have a compensate block.tasks, compensate_with
require_retryP0102Steps calling matching tasks retry: at least min_attempts attempts, at most max_attempts.tasks, min_attempts (default 2), max_attempts
max_attemptsP0103Steps calling matching tasks make at most max attempts.tasks (default ["*"]), max
require_timeoutP0104Steps calling matching tasks have a timeout, at most max; with queue_timeout = true, also a queue_timeout.tasks, max, queue_timeout
require_approvalP0105Steps calling matching tasks are guarded by an await of a matching signal, optionally only when a threshold is reached.tasks, signals, when, require_condition (default true)
require_handlerP0106Workflows declare the listed outcome handlers.handlers (default ["failure"])
forbid_tasksP0107No step or compensation calls a matching task.tasks
allow_tasksP0108Every step and compensation calls a matching task.tasks
max_fan_outP0109Workflows with for steps declare limits { max_fan_out } of at most max; with require_max_parallel = true, every for step sets max_parallel.max, require_max_parallel
require_limitsP0110Workflows set the listed limits.keys
require_signal_timeoutP0111await steps of matching signals have a step timeout, at most max.signals (default ["*"]), max, allow_workflow_timeout

Durations (max of require_timeout and require_signal_timeout) are strings in ORBIT syntax: "500ms", "30s", "5m", "2h", "7d".

Unless stated otherwise, step rules apply to the main steps, the steps of on failure, on cancel and on timeout, and the steps of on event blocks. Rules that look at a task (require_retry, max_attempts, require_timeout, require_approval) look at what the step itself runs, not at its compensation.

require_compensation

[[rule]]
type = "require_compensation"
tasks = ["payments.charge", "inventory.reserve"]
compensate_with = ["payments.refund", "inventory.release"]   # optional

Every main step that calls a matching task with worker has a compensate block; with compensate_with, the compensation calls a task matching one of those patterns. Handler and event steps are not checked, because the runtime only compensates main steps. This complements the compiler’s own compensated_by check (W0601): compensated_by is declared by the contract’s owner, the policy by the organisation.

require_retry and max_attempts

[[rule]]
type = "require_retry"
tasks = ["notifications.*"]
min_attempts = 3
max_attempts = 10

[[rule]]
id = "payment-retries"
type = "max_attempts"
tasks = ["payments.*"]
max = 3

The number of attempts is the step’s retry { attempts: n }, including the first attempt; a step without retry makes one attempt.

require_timeout

[[rule]]
type = "require_timeout"
tasks = ["*"]
max = "5m"
queue_timeout = true

The step must have timeout (the start-to-close limit of one attempt), not longer than max. With queue_timeout = true it must also have queue_timeout. A workflow timeout does not count.

require_approval

[[rule]]
id = "large-charge-approval"
type = "require_approval"
tasks = ["payments.charge"]
signals = ["review", "approval"]
when = "input.amount >= 10000"   # optional
require_condition = true         # default

The rule asks: can a matching step run, when the threshold is reached, without a decision having been received from a matching signal and taken into account? The check is sound (when it passes, the property below holds) but conservative (some workflows that are safe are rejected); rewrite the conditions in the supported form, or waive the rule, when that happens.

when is optional and has the form input.<field> <op> <number>, with <op> one of >=, >, <=, <, ==, != and a nested field written input.a.b. Call it P; without when, P is “always”. If the workflow’s input has no numeric field at that path, the step is reported, because the check cannot show that the threshold is never reached.

A step S that calls a matching task passes if one of these holds:

  1. S cannot run when P holds. S has a when condition that implies not P. For when = "input.amount >= 10000", when input.amount < 5000.0 && input.vip passes.
  2. An approval guards S. Some transitive dependency A of S in the same block (main steps, or the same on block) is an await of a signal matching signals, and:
    • A runs whenever P holds: A has no when, or P implies A’s when; and
    • unless require_condition = false, S’s when condition still reads A’s output after it is simplified under the assumption P. A step without when fails this part: it would run after the decision even if the reviewer rejected it.

What “implies” covers:

  • A condition is split at &&, || and ! (negations are pushed inward). The pieces that are understood are comparisons of an input field with a number literal, in either order (input.amount >= 1000.0, 1000 < input.amount). Every other piece (calls, strings, step outputs, if) is unknown: it is never assumed true or false, so it can only make the check fail.
  • Before the analysis, a reference to a field of a compute step in the same block that has no when and no for is replaced by the field’s expression. In examples/approval.orbit, assess.needs_approval becomes input.amount >= 1000.0.
  • Comparisons on the same field are compared as intervals of real numbers: input.amount >= 10000 implies input.amount >= 1000.0, and implies not (input.amount < 1000.0).
  • “Simplified under P” replaces each understood comparison that P decides by true or false and simplifies. If the result is a constant, it does not read A.

examples/approval.orbit passes the fintech rule: the wait decision has when assess.needs_approval, which is input.amount >= 1000.0, and input.amount >= 10000 implies it; the charge’s when !assess.needs_approval || coalesce(decision.approved, false) becomes input.amount < 1000.0 || coalesce(decision.approved, false), which under P simplifies to coalesce(decision.approved, false), which reads decision.

A workflow whose wait only starts at 50000:

task "payments.charge" {
    version "1.0.0"
    input { order_id: String, amount: Float }
    output { transaction_id: String }
}

workflow Payout {
    version "1.0.0"
    input { order_id: String, amount: Float }

    signal review { approved: Bool }

    step decision {
        await review
        when input.amount >= 50000.0
        timeout 2d
    }

    step charge {
        worker "payments.charge"
        after decision
        with { order_id: input.order_id, amount: input.amount }
    }
}
$ orbit check --policy approval-policy.toml payout.orbit
error[P0105]: policy rule `large-charge-approval`: step `charge` calls `payments.charge` without a guaranteed approval when input.amount >= 10000
  --> payout.orbit:19:10
   |
19 |     step charge {
   |          ^^^^^^
   = note: `decision` may be skipped when input.amount >= 10000: its condition `input.amount >= 50000.0` is not implied by the rule
   = note: the `when` condition of `charge` does not read `decision` when input.amount >= 10000, so the decision does not gate the step
   = note: required by policy approval-policy (approval-policy.toml)
   = help: make `decision` run whenever the rule's condition holds (no `when`, or one the condition implies), and make `charge` read the decision, e.g. `when coalesce(decision.approved, false)`, or waive it: `// policy-allow: large-charge-approval <reason>`

error: policy check failed: 1 error(s), 0 warning(s)

(approval-policy.toml contains only the large-charge-approval rule shown above, with signals = ["review"].) With when input.amount >= 10000.0 on decision and when input.amount < 10000.0 || coalesce(decision.approved, false) on charge instead of after decision, the check passes:

$ orbit check --policy approval-policy.toml payout2.orbit
ok Payout@1.0.0 (2 steps)

What the rule does not check: that the condition reads the right field of the decision (decision.approved rather than decision.reviewer), who sent the signal (use the runtime’s client permissions for that), or a decision taken inside a child workflow.

require_handler

[[rule]]
type = "require_handler"
handlers = ["failure", "cancel"]

The workflow declares on failure, on cancel and/or on timeout, as listed. The violation is reported at the workflow name.

forbid_tasks and allow_tasks

[[rule]]
id = "no-legacy"
type = "forbid_tasks"
tasks = ["legacy.*"]

[[rule]]
id = "known-services"
type = "allow_tasks"
tasks = ["payments.*", "inventory.*", "notifications.*"]

Both look at what every step runs and at what every compensation runs. allow_tasks also covers built-in tasks: add orbit.* to allow the HTTP adapter.

max_fan_out

[[rule]]
type = "max_fan_out"
max = 500
require_max_parallel = true

A workflow with at least one for step must declare limits { max_fan_out: n } with n <= max, so that a large list fails the step with LIMIT_EXCEEDED instead of starting thousands of tasks. Workflows without for steps are not checked. With require_max_parallel = true, each for step must also set max_parallel.

require_limits

[[rule]]
type = "require_limits"
keys = ["max_history_events", "max_payload_bytes"]

The workflow’s limits block sets each listed key (max_fan_out, max_history_events, max_payload_bytes).

require_signal_timeout

[[rule]]
type = "require_signal_timeout"
signals = ["review", "approval"]
max = "7d"
allow_workflow_timeout = false   # default

Each await of a matching signal has a step timeout, not longer than max. With allow_workflow_timeout = true, a workflow timeout (not longer than max) also satisfies the rule for steps without their own timeout. The compiler’s W0606 accepts either timeout and has no maximum.

Waivers and exemptions

A violation can be accepted in the workflow source, next to the step, or in the policy file.

Inline waivers

A line comment // policy-allow: <rule id> <reason> in the comment block directly above a step waives that rule for the step. Above the workflow line, it waives the rule for the whole workflow. Several waivers can be stacked. The reason is required. When a step also has a /// doc comment, put the waiver above the doc comment: the doc comment must be directly above the step.

    // policy-allow: payments-compensated the provider voids uncaptured authorisations after 7 days
    step charge {
        worker "payments.charge"
        after reserve
        with { order_id: input.order_id, amount: input.amount, currency: "USD" }
    }

Mistakes in waivers are diagnostics too. A copy of examples/checkout.orbit with three waivers, one valid, one without a reason, and one for a rule the step does not break:

$ orbit check --policy policies/fintech.toml checkout.orbit
warning[P0002]: waiver for policy rule `payment-timeout` suppresses nothing
  --> checkout.orbit:13:5
   |
13 |     // policy-allow: payment-timeout reservations are local
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   = help: remove the comment

error[P0001]: waiver for policy rule `large-charge-approval` has no reason
  --> checkout.orbit:24:5
   |
24 |     // policy-allow: large-charge-approval
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   = help: write `// policy-allow: large-charge-approval <why this is acceptable>`

error[P0105]: policy rule `large-charge-approval`: step `charge` calls `payments.charge` without depending on an approval (`await` of `review`, `approval`) when input.amount >= 10000
  --> checkout.orbit:25:10
   |
25 |     step charge {
   |          ^^^^^^
   = note: policy fintech (policies/fintech.toml): charges of 10000 or more need a recorded approval
   = help: add a step that awaits the approval signal and make `charge` depend on it (`after` or by reading its output), or waive it: `// policy-allow: large-charge-approval <reason>`

warning[P0104]: policy rule `payment-timeout`: step `charge` calls `payments.charge` without a `timeout`
  --> checkout.orbit:25:10
   |
25 |     step charge {
   |          ^^^^^^
   = note: required by policy fintech (policies/fintech.toml)
   = help: add `timeout 1m` to the step, or waive it: `// policy-allow: payment-timeout <reason>`

error: policy check failed: 2 error(s), 2 warning(s)
CodeSeverityReported when
P0001errorA waiver names a rule id that no --policy file defines (the note lists the ids), or has no reason. The waiver is ignored.
P0002warningA waiver suppresses no violation (the rule is not broken there, or the rule does not apply to this workflow).

With several --policy files, a waiver may name a rule of any of them, and waives every rule with that id; a waiver that names no rule of any file is reported once. Waivers are comments, so adding or removing one does not change the definition hash.

Exemptions in the policy file

[[exemption]]
rule = "large-charge-approval"
workflow = "Checkout"            # glob pattern
step = "charge"                  # optional: without it, the whole workflow
reason = "OPS-412: checkout is capped at 5000 by the storefront until the approval flow ships"

rule must be the id of a rule in the same file, and reason is required. step is a step name, or on <kind>: <step> / on event <signal>: <step> for handler and event steps. An exemption that matches no violation is reported as a warning (it does not change the exit status):

$ orbit check --policy policies/exempt.toml checkout.orbit
warning: policy policies/exempt.toml: exemption 2 (rule `payment-timeout`, workflow `Legacy*`) matched no violation
warning[P0104]: policy rule `payment-timeout`: step `charge` calls `payments.charge` without a `timeout`
  --> checkout.orbit:23:10
   |
23 |     step charge {
   |          ^^^^^^
   = note: required by policy fintech (policies/exempt.toml)
   = help: add `timeout 1m` to the step, or waive it: `// policy-allow: payment-timeout <reason>`

ok Checkout@1.0.0 (2 steps)

Here policies/exempt.toml is fintech.toml with two exemptions appended (the one above and one for payment-timeout in Legacy* workflows), and checkout.orbit keeps only the payments-compensated waiver. Inline waivers are applied before exemptions; a violation covered by both counts as used by the waiver.

Use exemptions for decisions made by the policy’s owners (with a ticket in the reason), and inline waivers for decisions that belong with the workflow and should be visible in its review.

In CI

- name: Check workflows against the organisation policy
  run: orbit check --strict --policy policies/fintech.toml workflows/*.orbit

--strict turns policy warnings into failures as well. To fail only on policy errors, leave it out.

See also

Orders with compensation

This guide builds an order-processing workflow that validates an order, scores its risk, reserves inventory, charges the customer and sends a confirmation, using Python and TypeScript workers. You run it end to end, then exercise every path: a held order, a declined card that triggers compensation, a cancellation in the middle of a payment, a transient failure that is retried, and a duplicate charge that idempotency prevents.

The workflow is the Saga pattern: each step that changes the world declares the business action that undoes it, and the runtime runs those actions in reverse order when a later step fails.

Before you begin

The guide uses files from the ORBIT repository. Clone it and run every command from the repository root:

git clone https://github.com/AjmalX0/Orbit.git
cd Orbit

You need:

  • orbit 0.1.0 or later on your PATH (Installation).
  • Python 3.9 or later and Node.js 22.18 or later.
FileContents
examples/contracts.orbitSchemas and task contracts shared by the order examples
examples/order_processing.orbitThe OrderProcessing workflow
examples/workers/python/order_workers.pyPython worker orders-python: risk.analyze, payments.charge, payments.refund (and the shipping tasks used in Fan-out and child workflows)
examples/workers/typescript/order_workers.tsTypeScript worker orders-typescript: orders.validate, inventory.reserve, inventory.release, notifications.send

The example workers load the SDKs from the repository (sdks/python and sdks/typescript/src) so they run without installing packages. In your own project, install orbit-lang-sdk and orbit-lang instead and import them by package name.

The contracts

examples/contracts.orbit declares one task per worker capability. Two of them are compensations: inventory.release undoes inventory.reserve, and payments.refund undoes payments.charge.

schema LineItem {
    sku: String
    quantity: Int
}

task "inventory.reserve" {
    version "1.0.0"
    input { order_id: String, items: List<LineItem> }
    output { reservation_id: String }
}

task "inventory.release" {
    version "1.0.0"
    input { reservation_id: String }
    output { released: Bool }
}

schema PaymentRequest {
    order_id: String
    amount: Float
    currency: String
}

schema PaymentResult {
    transaction_id: String
    status: String
}

task "payments.charge" {
    version "1.0.0"
    input PaymentRequest
    output PaymentResult
}

task "payments.refund" {
    version "1.0.0"
    input { transaction_id: String, amount: Float }
    output { refunded: Bool }
}

The file also declares orders.validate, risk.analyze and notifications.send.

The workflow

examples/order_processing.orbit imports the contracts and declares five steps:

import "contracts.orbit"

workflow OrderProcessing {
    version "1.0.0"
    description "Validate, risk-score, reserve, charge and notify for one order"
    timeout 5m

    input {
        order_id: String
        customer_id: String
        amount: Float
        currency: String = "USD"
        items: List<LineItem>
    }

    output {
        status: String
        transaction_id: String?
        risk_score: Float
    }

    step validate_order {
        worker "orders.validate"
        using typescript
        with { order_id: input.order_id, amount: input.amount, items: input.items }
        timeout 10s
    }

    step analyze_risk {
        worker "risk.analyze"
        using python
        with { customer_id: input.customer_id, amount: input.amount }
        retry {
            attempts: 3
            backoff: exponential
            initial_delay: 1s
            max_delay: 30s
        }
    }

    step reserve_inventory {
        worker "inventory.reserve"
        using typescript
        after validate_order
        with { order_id: input.order_id, items: input.items }
        compensate {
            worker "inventory.release"
            with { reservation_id: reserve_inventory.reservation_id }
        }
    }

    step process_payment {
        worker "payments.charge"
        using python
        after reserve_inventory
        when analyze_risk.score < 0.8
        with {
            order_id: input.order_id
            amount: input.amount
            currency: input.currency
        }
        idempotency_key: "charge-" + input.order_id
        timeout 30s
        retry { attempts: 5, backoff: exponential, initial_delay: 500ms, max_delay: 10s }
        compensate {
            worker "payments.refund"
            with { transaction_id: process_payment.transaction_id, amount: input.amount }
            idempotency_key: "refund-" + input.order_id
        }
    }

    step send_notification {
        worker "notifications.send"
        using typescript
        after process_payment
        with {
            customer_id: input.customer_id
            message: if process_payment.transaction_id == null
                then "Order " + input.order_id + " is held for review"
                else "Order " + input.order_id + " confirmed"
        }
    }

    return {
        status: if process_payment.transaction_id == null then "held_for_review" else "completed"
        transaction_id: process_payment.transaction_id
        risk_score: analyze_risk.score
    }
}

The step graph that the compiler derives:

validate_order (ts)          analyze_risk (py)
       │                            │
       ▼                            │ when analyze_risk.score < 0.8
reserve_inventory (ts) ──┐          │
  compensate: inventory.release     │
                         ▼          ▼
                     process_payment (py)
                       compensate: payments.refund
                              │
                              ▼
                     send_notification (ts)

orbit graph examples/order_processing.orbit prints the same graph as a Mermaid flowchart. The important decisions:

  • Parallelism comes from the data. validate_order and analyze_risk share no dependency, so they run at the same time.
  • after orders steps that share no data. reserve_inventory does not read validate_order’s output, but must not run before validation succeeds.
  • when creates an implicit dependency. process_payment reads analyze_risk.score, so it waits for analyze_risk. If the score is 0.8 or more, the payment is skipped.
  • A skipped step’s output is null. Because process_payment is conditional, process_payment.transaction_id has type String?. The workflow handles it explicitly in send_notification and in return, and the output declares transaction_id: String?. The compiler rejects code that ignores the null case; see E0405.
  • Each side effect has a compensation. If anything fails after reserve_inventory succeeded, the runtime releases the reservation. If anything fails after process_payment succeeded, it refunds the charge.
  • The charge has a business idempotency key. "charge-" + input.order_id is the same for every execution of the same order, so the payment worker can refuse to charge twice even if the order is submitted twice.

Check it:

orbit check examples/order_processing.orbit
ok OrderProcessing@1.0.0 (5 steps)

Run it

Start the runtime in one terminal:

orbit server

Start the workers in two more terminals:

PYTHONPATH=sdks/python python3 examples/workers/python/order_workers.py
node examples/workers/typescript/order_workers.ts

Run an order:

orbit run examples/order_processing.orbit --input '{
  "order_id": "A-1", "customer_id": "c-42", "amount": 120.0,
  "items": [{"sku": "BOOK-1", "quantity": 2}]
}'
deployed OrderProcessing@1.0.0 (sha256:4fdfac082bf3)
started execution ff2cbf0c-ccb6-42e1-98ac-84ec85a2196b
  ✓ analyze_risk                   succeeded 7ms
  ✓ validate_order                 succeeded 13ms
  ✓ reserve_inventory              succeeded 11ms
  ✓ process_payment                succeeded 8ms
  ✓ send_notification              succeeded 8ms

COMPLETED ff2cbf0c-ccb6-42e1-98ac-84ec85a2196b  OrderProcessing@1.0.0
  elapsed: 56ms
{
  "status": "completed",
  "transaction_id": "tx-A-1",
  "risk_score": 0.101
}

The example workers have built-in behavior so that you can trigger each path from the input:

InputBehavior
customer_id starting with riskyrisk.analyze scores 0.95, so the payment is skipped
amount above 5000payments.charge fails with CARD_DECLINED (not retryable)
amount exactly 4242payments.charge takes 3 seconds
ORBIT_FLAKY=1 set for the Python workerthe first risk.analyze attempt of each execution fails with a retryable error

Held for review: a skipped step

orbit run examples/order_processing.orbit --input '{
  "order_id": "A-3", "customer_id": "risky-7", "amount": 120.0,
  "items": [{"sku": "BOOK-1", "quantity": 1}]
}'
up to date OrderProcessing@1.0.0 (sha256:4fdfac082bf3)
started execution 1e10703f-37cc-42ac-ae1e-c4e4ce1eeb5a
  ✓ analyze_risk                   succeeded 18ms
  ✓ validate_order                 succeeded 16ms
  ✓ reserve_inventory              succeeded 6ms
  ↷ process_payment                skipped
  ✓ send_notification              succeeded 7ms

COMPLETED 1e10703f-37cc-42ac-ae1e-c4e4ce1eeb5a  OrderProcessing@1.0.0
  elapsed: 69ms
{
  "status": "held_for_review",
  "transaction_id": null,
  "risk_score": 0.95
}

A skipped step is not a failure. send_notification depends on process_payment and still runs, with the “held for review” message.

Card declined: compensation

orbit run examples/order_processing.orbit --input '{
  "order_id": "A-2", "customer_id": "c-42", "amount": 9000,
  "items": [{"sku": "BOOK-1", "quantity": 2}]
}'
up to date OrderProcessing@1.0.0 (sha256:4fdfac082bf3)
started execution 3647177c-74f4-4569-968f-824e267dce4a
  ✓ analyze_risk                   succeeded 4ms
  ✓ validate_order                 succeeded 4ms
  ✓ reserve_inventory              succeeded 4ms
  ✗ process_payment                failed charge of 9000 USD declined
  ⊘ send_notification              cancelled
  ✓ compensate:reserve_inventory   succeeded 4ms

FAILED 3647177c-74f4-4569-968f-824e267dce4a  OrderProcessing@1.0.0
  elapsed: 26ms
  compensation: completed
  error: CARD_DECLINED step `process_payment` failed: charge of 9000 USD declined

orbit run exits with status 1. The history shows each decision:

orbit logs 3647177c-74f4-4569-968f-824e267dce4a
+     0ms   #1  WorkflowCreated
+     0ms   #2  WorkflowStarted
+     1ms   #3  StepScheduled             step=validate_order attempt=1
+     1ms   #4  StepScheduled             step=analyze_risk attempt=1
+     3ms   #5  StepDispatched            step=analyze_risk attempt=1 worker_id=orders-python
+     5ms   #6  StepDispatched            step=validate_order attempt=1 worker_id=orders-typescript
+     7ms   #7  StepSucceeded             step=analyze_risk attempt=1
+     9ms   #8  StepSucceeded             step=validate_order attempt=1
+     9ms   #9  StepScheduled             step=reserve_inventory attempt=1
+    10ms  #10  StepDispatched            step=reserve_inventory attempt=1 worker_id=orders-typescript
+    14ms  #11  StepSucceeded             step=reserve_inventory attempt=1
+    15ms  #12  StepScheduled             step=process_payment attempt=1
+    15ms  #13  StepDispatched            step=process_payment attempt=1 worker_id=orders-python
+    19ms  #14  StepAttemptFailed         step=process_payment attempt=1 error=CARD_DECLINED charge of 9000 USD declined
+    20ms  #15  StepFailed                step=process_payment error=CARD_DECLINED charge of 9000 USD declined
+    20ms  #16  StepCancelled             step=send_notification
+    20ms  #17  CompensationStarted       reason=step `process_payment` failed: charge of 9000 USD declined
+    20ms  #18  StepScheduled             step=compensate:reserve_inventory attempt=1
+    22ms  #19  StepDispatched            step=compensate:reserve_inventory attempt=1 worker_id=orders-typescript
+    26ms  #20  StepSucceeded             step=compensate:reserve_inventory attempt=1
+    26ms  #21  CompensationCompleted
+    26ms  #22  WorkflowFailed            error=CARD_DECLINED step `process_payment` failed: charge of 9000 USD declined

What happened:

  1. The Python worker raised TaskError("CARD_DECLINED", ..., retryable=False). Although process_payment allows five attempts, a non-retryable error fails the step at once (#14, #15).
  2. send_notification had not started, so the runtime withdrew it (#16).
  3. The runtime compensated every succeeded step that has a compensate block. Only reserve_inventory qualifies: process_payment never succeeded, so there is nothing to refund (#17 to #21).
  4. The execution ended FAILED with compensation: completed (#22).

To make an error code permanent from the workflow side instead of the worker, list it in the step’s retry policy: retry { attempts: 5, non_retryable: ["CARD_DECLINED"] }.

Cancellation during a payment

Cancellation also triggers compensation. Use the 4242 amount, which makes the charge take three seconds, start the execution with a known id, and cancel it while the charge is in flight:

orbit run examples/order_processing.orbit --id order-A-4 --no-wait --input '{
  "order_id": "A-4", "customer_id": "c-42", "amount": 4242,
  "items": [{"sku": "BOOK-1", "quantity": 1}]
}'
orbit cancel order-A-4
up to date OrderProcessing@1.0.0 (sha256:4fdfac082bf3)
started execution order-A-4
order-A-4
cancellation requested; execution is RUNNING

A few seconds later:

orbit status order-A-4
CANCELLED order-A-4  OrderProcessing@1.0.0
  elapsed: 3.0s
  compensation: completed

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  validate_order               SUCCEEDED          1      8ms  worker orders-typescript
  analyze_risk                 SUCCEEDED          1      9ms  worker orders-python
  reserve_inventory            SUCCEEDED          1      7ms  worker orders-typescript
  send_notification            CANCELLED          0
  process_payment              SUCCEEDED          1     3.0s  worker orders-python
  compensate:process_payment   SUCCEEDED          1      4ms  worker orders-python
  compensate:reserve_inventory SUCCEEDED          1      5ms  worker orders-typescript
orbit logs order-A-4
...
+    22ms  #12  StepScheduled             step=process_payment attempt=1
+    23ms  #13  StepDispatched            step=process_payment attempt=1 worker_id=orders-python
+    1.0s  #14  CancelRequested           reason=requested via API
+    1.0s  #15  StepCancelled             step=send_notification
+    3.0s  #16  StepSucceeded             step=process_payment attempt=1
+    3.0s  #17  CompensationStarted       reason=cancelled: requested via API
+    3.0s  #18  StepScheduled             step=compensate:process_payment attempt=1
+    3.0s  #19  StepDispatched            step=compensate:process_payment attempt=1 worker_id=orders-python
+    3.0s  #20  StepSucceeded             step=compensate:process_payment attempt=1
+    3.0s  #21  StepScheduled             step=compensate:reserve_inventory attempt=1
+    3.0s  #22  StepDispatched            step=compensate:reserve_inventory attempt=1 worker_id=orders-typescript
+    3.0s  #23  StepSucceeded             step=compensate:reserve_inventory attempt=1
+    3.0s  #24  CompensationCompleted
+    3.0s  #25  WorkflowCancelled

This run shows two rules of compensation:

  • The runtime waits for in-flight work. The charge was already running when the cancellation arrived (#14). The runtime cannot know whether an external call has taken effect, so it waited for the result. The charge succeeded (#16), so it needed refunding.
  • Compensations run one at a time, newest first. The refund (#18 to #20) ran before the inventory release (#21 to #23), the reverse of the order in which the steps completed.

The refund carries the idempotency key declared in its compensate block, refund-A-4, which orbit inspect order-A-4 shows on the compensate:process_payment unit. Without one, a compensation’s key defaults to <execution>/compensate:<unit>.

Transient failures: retries

Restart the Python worker with ORBIT_FLAKY=1, so that the first risk-scoring attempt of each execution fails with a retryable error:

ORBIT_FLAKY=1 PYTHONPATH=sdks/python python3 examples/workers/python/order_workers.py
orbit run examples/order_processing.orbit --id order-A-5 --input '{
  "order_id": "A-5", "customer_id": "c-42", "amount": 120.0,
  "items": [{"sku": "BOOK-1", "quantity": 2}]
}'
up to date OrderProcessing@1.0.0 (sha256:4fdfac082bf3)
started execution order-A-5
  ↻ analyze_risk                   retry_wait upstream timed out
  ✓ validate_order                 succeeded 6ms
  ✓ reserve_inventory              succeeded 3ms
  ✓ analyze_risk                   succeeded 6ms
  ✓ process_payment                succeeded 10ms
  ✓ send_notification              succeeded 4ms

COMPLETED order-A-5  OrderProcessing@1.0.0
  elapsed: 1.1s
{
  "status": "completed",
  "transaction_id": "tx-A-5",
  "risk_score": 0.101
}

The history records the failed attempt and the backoff:

+    11ms   #9  StepAttemptFailed         step=analyze_risk attempt=1 error=RISK_SERVICE_UNAVAILABLE upstream timed out
+    12ms  #10  RetryScheduled            step=analyze_risk delay_ms=1000

analyze_risk uses exponential backoff starting at initial_delay: 1s, so the second attempt ran one second later and succeeded. Meanwhile validate_order and reserve_inventory, which do not depend on the risk score, continued. process_payment waited for both branches.

Duplicate submissions: idempotency

Run the same order twice with the same execution id:

orbit run examples/order_processing.orbit --id order-A-6 --input '{
  "order_id": "A-6", "customer_id": "c-42", "amount": 120.0,
  "items": [{"sku": "BOOK-1", "quantity": 2}]
}'

The second time, orbit run prints attached to existing execution order-A-6 and follows the first execution instead of starting another. Starting an execution with a client-chosen id is idempotent.

Now start the same order without --id. This creates a second execution, but its payment step carries the same idempotency key, charge-A-6. The payment worker remembers results by key:

@worker.task("payments.charge", version="1.0.0")
def charge(input, ctx):
    if ctx.idempotency_key in _charges:
        ctx.log("duplicate charge request; returning the original result")
        return _charges[ctx.idempotency_key]
    ...
    result = {"transaction_id": f"tx-{input['order_id']}", "status": "captured"}
    _charges[ctx.idempotency_key] = result
    return result

The worker logs:

INFO orbit duplicate charge request; returning the original result {"execution_id": "9f76c35c-...", "step": "process_payment", "attempt": 1}

The example keeps results in memory. A production worker stores them in its database, or passes the key to a payment provider that deduplicates on it, because the runtime delivers tasks at least once.

The compiler catches missing null handling

Suppose you add a receipts.send contract with input { transaction_id: String } and a step that passes it the transaction id:

step send_receipt {
    worker "receipts.send"
    with { transaction_id: process_payment.transaction_id }
}

orbit check rejects it, because process_payment can be skipped:

error[E0405]: value may be `null` but `String` is required
  --> order_processing.orbit:97:32
   |
97 |         with { transaction_id: process_payment.transaction_id }
   |                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   = help: provide a fallback with `coalesce(value, default)`, or declare the target as optional with `?`

error: compilation failed: 1 error(s), 0 warning(s)

Fix it with coalesce(process_payment.transaction_id, "none"), by making the contract’s field optional (transaction_id: String?), or by giving the receipt step the same when condition and handling the skipped case downstream.

Design checklist

  • Give every step that changes external state a compensate block, and make the compensation safe to run more than once.
  • Mark business failures that retrying cannot fix as non-retryable, in the worker (retryable=False) or in the step (non_retryable).
  • Use a business idempotency key, such as an order id, for side effects that must not repeat across executions, and use --id (or execution_id in the API) when the caller may submit the same request twice.
  • Keep compensations independent of steps that may not have run. The compiler only lets a compensation read its own step’s output and that step’s ancestors.

See also

Polyglot workflows

This guide runs one workflow whose four steps are served by workers in four languages: Python normalizes a text, TypeScript tokenizes it, Go counts word frequencies, and Rust computes a fingerprint. You see how contracts make the languages interchangeable, how using routes each step to the right worker, and what happens when a worker for one language is missing.

Before you begin

Clone the ORBIT repository and run every command from its root:

git clone https://github.com/AjmalX0/Orbit.git
cd Orbit

You need orbit 0.1.0 or later, Python 3.9+, Node.js 22.18+, Go 1.21+ and Rust 1.89+. The example workers build against the SDK sources in the repository, so no packages need to be installed.

FileContents
examples/polyglot.orbitContracts and the DocumentInsights workflow
examples/workers/polyglot/text_worker.pyPython worker text-python: text.normalize
examples/workers/polyglot/text_worker.tsTypeScript worker text-typescript: text.tokenize
examples/workers/polyglot/go/main.goGo worker text-go: text.frequencies
crates/orbit-worker/examples/text_worker.rsRust worker text-rust: text.fingerprint

The contracts and the workflow

schema WordCount {
    word: String
    count: Int
}

task "text.normalize" { version "1.0.0" input { text: String } output { text: String } }
task "text.tokenize" { version "1.0.0" input { text: String } output { tokens: List<String> } }
task "text.frequencies" { version "1.0.0" input { tokens: List<String>, top: Int } output { top: List<WordCount>, unique: Int } }
task "text.fingerprint" { version "1.0.0" input { text: String } output { hash: String, bytes: Int } }

workflow DocumentInsights {
    version "1.0.0"
    input {
        text: String
        top: Int = 3
    }
    output {
        words: Int
        unique: Int
        top_word: String?
        top: List<WordCount>
        fingerprint: String
    }

    step normalize { worker "text.normalize" using python with { text: input.text } }
    step tokenize { worker "text.tokenize" using typescript with { text: normalize.text } }
    step frequencies { worker "text.frequencies" using go with { tokens: tokenize.tokens, top: input.top } }
    step fingerprint { worker "text.fingerprint" using rust with { text: normalize.text } timeout 5s }

    return {
        words: len(tokenize.tokens)
        unique: frequencies.unique
        top_word: frequencies.top[0].word
        top: frequencies.top
        fingerprint: fingerprint.hash
    }
}

The contracts are the only thing the four languages share. Each one fixes the JSON shape of a task’s input and output, so a worker in any language can implement it, and the compiler checks every step against it. In the return block, frequencies.top[0] is an index into a list and may be missing, so top_word is declared String?.

The dependencies come from the data each step reads:

              normalize (python)
               │             │
               ▼             ▼
     tokenize (typescript)   fingerprint (rust)
               │
               ▼
     frequencies (go)

fingerprint reads only normalize.text, so it runs in parallel with tokenize and frequencies. orbit graph examples/polyglot.orbit prints the same graph as Mermaid.

orbit check examples/polyglot.orbit
ok DocumentInsights@1.0.0 (4 steps)

The workers

Each worker registers one contract by name and version. The handler receives the task input as decoded JSON and returns the output.

Python

"""Python half of examples/polyglot.orbit: text.normalize."""
import re

from orbit_sdk import Worker

worker = Worker("text-python")


@worker.task("text.normalize", version="1.0.0")
def normalize(input, ctx):
    text = re.sub(r"[^\w\s']", " ", input["text"].lower())
    return {"text": re.sub(r"\s+", " ", text).strip()}


if __name__ == "__main__":
    worker.run()

TypeScript

// TypeScript part of examples/polyglot.orbit: text.tokenize.
import { Worker } from "../../../sdks/typescript/src/index.ts";

const worker = new Worker({ workerId: "text-typescript" });
worker.task<{ text: string }, { tokens: string[] }>("text.tokenize", { version: "1.0.0" }, ({ text }) => ({
  tokens: text.split(" ").filter((t) => t.length > 0),
}));
await worker.run();

In your own project, import from the orbit-lang package instead of the repository path.

Go

w := orbit.NewWorker(orbit.Options{WorkerID: "text-go"})
w.Task("text.frequencies", "1.0.0", func(ctx context.Context, t *orbit.Task) (any, error) {
	var in input
	if err := t.Decode(&in); err != nil {
		return nil, orbit.Permanent("INVALID_INPUT", err.Error())
	}
	counts := map[string]int{}
	for _, tok := range in.Tokens {
		counts[tok]++
	}
	// ... sort by count, then word, and keep the first in.Top entries ...
	return map[string]any{"top": top, "unique": len(counts)}, nil
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := w.Run(ctx); err != nil {
	log.Fatal(err)
}

t.Decode unmarshals the input into a struct with json tags. A decoding error is returned as orbit.Permanent, so the runtime does not retry a task whose input can never be decoded. The example’s go.mod uses a replace directive to build against sdks/go in the repository; in your own module, go get github.com/AjmalX0/Orbit/sdks/go@v0.1.0.

Rust

use orbit_worker::Worker;
use serde::Deserialize;
use serde_json::json;

#[derive(Deserialize)]
struct Input {
    text: String,
}

/// 64-bit FNV-1a, a stable non-cryptographic fingerprint.
fn fnv1a(bytes: &[u8]) -> u64 {
    bytes.iter().fold(0xcbf29ce484222325, |h, b| {
        (h ^ *b as u64).wrapping_mul(0x100000001b3)
    })
}

fn main() {
    let mut worker = Worker::builder("text-rust").build();
    worker.task("text.fingerprint", "1.0.0", |input: Input, _ctx| {
        Ok(json!({ "hash": format!("{:016x}", fnv1a(input.text.as_bytes())), "bytes": input.text.len() }))
    });
    worker.run().expect("worker failed");
}

The handler’s input type implements Deserialize. Input that does not deserialize fails the attempt with a permanent INVALID_INPUT error.

Run it

Start the runtime:

orbit server

Start each worker in its own terminal:

PYTHONPATH=sdks/python python3 examples/workers/polyglot/text_worker.py
node examples/workers/polyglot/text_worker.ts
(cd examples/workers/polyglot/go && go run .)
cargo run -p orbit-worker --example text_worker

Each worker prints a line such as worker text-go registered (1 tasks). Confirm that all four are online, each with its own runtime:

orbit workers list
WORKER                       RUNTIME      STATE    INFLIGHT  TASKS
text-go                      go           online   0  text.frequencies@1.0.0
text-python                  python       online   0  text.normalize@1.0.0
text-rust                    rust         online   0  text.fingerprint@1.0.0
text-typescript              typescript   online   0  text.tokenize@1.0.0

Run the workflow:

orbit run examples/polyglot.orbit --id doc-1 --input '{"text": "The quick brown fox jumps over the lazy dog. The dog sleeps."}'
deployed DocumentInsights@1.0.0 (sha256:f99d73185b54)
started execution doc-1
  ✓ normalize                      succeeded 9ms
  ✓ fingerprint                    succeeded 13ms
  ✓ tokenize                       succeeded 15ms
  ✓ frequencies                    succeeded 8ms

COMPLETED doc-1  DocumentInsights@1.0.0
  elapsed: 44ms
{
  "words": 12,
  "unique": 9,
  "top_word": "the",
  "top": [
    {
      "word": "the",
      "count": 3
    },
    {
      "word": "dog",
      "count": 2
    },
    {
      "word": "brown",
      "count": 1
    }
  ],
  "fingerprint": "be451c7eaa8fcee3"
}

orbit status doc-1 shows which worker ran each step:

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  normalize                    SUCCEEDED          1      9ms  worker text-python
  fingerprint                  SUCCEEDED          1     13ms  worker text-rust
  tokenize                     SUCCEEDED          1     15ms  worker text-typescript
  frequencies                  SUCCEEDED          1      8ms  worker text-go

How routing works

The runtime gives a task to a worker only when all of these match:

Step declaresWorker announced
worker "text.frequencies"the task name text.frequencies
the contract’s version "1.0.0"the contract version 1.0.0
using go (optional)the runtime go

Every SDK announces its own language as the worker’s runtime (python, typescript, go, rust, java, csharp). Without using, any worker that implements the contract can take the task, whatever its language. Use using when a step depends on a language-specific implementation, or to keep a migration between implementations explicit.

When a worker is missing

Stop the Go worker and start another execution without waiting for it:

orbit run examples/polyglot.orbit --id doc-2 --no-wait --input '{"text": "To be, or not to be: that is the question."}'
orbit status doc-2
RUNNING doc-2  DocumentInsights@1.0.0
  elapsed: 30ms

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  normalize                    SUCCEEDED          1      9ms  worker text-python
  fingerprint                  SUCCEEDED          1      7ms  worker text-rust
  tokenize                     SUCCEEDED          1     11ms  worker text-typescript
  frequencies                  SCHEDULED          1

The other branches finished. frequencies is SCHEDULED: it is queued and waits for a worker that implements text.frequencies@1.0.0 with runtime go. The execution is durable, so it can wait indefinitely, across runtime restarts. Start the Go worker again and follow the execution by running the same command with the same id:

orbit run examples/polyglot.orbit --id doc-2 --input '{"text": "To be, or not to be: that is the question."}'
up to date DocumentInsights@1.0.0 (sha256:f99d73185b54)
attached to existing execution doc-2
  ✓ normalize                      succeeded 9ms
  ✓ fingerprint                    succeeded 7ms
  ✓ tokenize                       succeeded 11ms
  ✓ frequencies                    succeeded 11ms

COMPLETED doc-2  DocumentInsights@1.0.0
  elapsed: 7.4s
{
  "words": 10,
  "unique": 8,
  "top_word": "be",
  "top": [
    {
      "word": "be",
      "count": 2
    },
    {
      "word": "to",
      "count": 2
    },
    {
      "word": "is",
      "count": 1
    }
  ],
  "fingerprint": "c9e05e87c1546d07"
}

If a step must not wait indefinitely, give it a queue_timeout. When no worker picks the task up in time, the step fails with QUEUE_TIMEOUT, which is never retried:

step frequencies {
    worker "text.frequencies"
    using go
    with { tokens: tokenize.tokens, top: input.top }
    queue_timeout 2m
}

Adding Java and C# workers

The same pattern works with the Java and C# SDKs. The repository contains one-step examples for each:

WorkflowWorkerRun the worker
examples/java_hello.orbitexamples/workers/java/see examples/workers/java/README.md
examples/csharp_hello.orbitexamples/workers/csharp/dotnet run --project examples/workers/csharp
orbit run examples/csharp_hello.orbit --input '{"name": "Ada", "excitement": 2}'
orbit run examples/java_hello.orbit --input '{"name": "Ada"}'

See also

Fan-out and child workflows

This guide builds an order-fulfilment workflow that reserves every line item of an order in parallel, with a concurrency limit, and books shipping through a separate Shipping workflow that runs as a durable child execution. You run it end to end and then make shipping fail, to see each reservation compensated individually.

Before you begin

Clone the ORBIT repository and run every command from its root:

git clone https://github.com/AjmalX0/Orbit.git
cd Orbit

You need orbit 0.1.0 or later, Python 3.9+ and Node.js 22.18+. The guide reuses the order workers from Orders with compensation:

FileContents
examples/fulfilment.orbitShipping contracts, the Shipping workflow and the Fulfilment workflow
examples/contracts.orbitLineItem, inventory.reserve, inventory.release (imported)
examples/workers/typescript/order_workers.tsinventory.reserve, inventory.release
examples/workers/python/order_workers.pyshipping.quote, shipping.book

The workflows

import "contracts.orbit"

task "shipping.quote" {
    version "1.0.0"
    input { order_id: String, items: Int }
    output { carrier: String, cost: Float }
}

task "shipping.book" {
    version "1.0.0"
    input { order_id: String, carrier: String }
    output { tracking: String }
}

workflow Shipping {
    version "1.0.0"
    input { order_id: String, items: Int }
    output { carrier: String, tracking: String, cost: Float }

    step quote { worker "shipping.quote" with { order_id: input.order_id, items: input.items } }
    step book { worker "shipping.book" with { order_id: input.order_id, carrier: quote.carrier } timeout 30s }

    return { carrier: quote.carrier, tracking: book.tracking, cost: quote.cost }
}

workflow Fulfilment {
    version "1.0.0"
    input {
        order_id: String
        items: List<LineItem>
    }
    output {
        reservations: List<{ reservation_id: String }>
        tracking: String
    }

    step reserve {
        for item in input.items
        max_parallel 4
        worker "inventory.reserve"
        using typescript
        with { order_id: input.order_id + "-" + item.sku, items: [item] }
        compensate {
            worker "inventory.release"
            with { reservation_id: reserve.reservation_id }
        }
    }

    step ship {
        workflow "Shipping"
        with { order_id: input.order_id, items: len(input.items) }
        timeout 1h
    }

    return { reservations: reserve, tracking: ship.tracking }
}

The fan-out step

reserve is a for step:

  • for item in input.items runs the task once per element. item is in scope in with and in compensate, so each element reserves one SKU.
  • max_parallel 4 allows at most four elements to run at the same time.
  • Each element is its own unit, reserve[0], reserve[1], …, with its own attempts, timeouts and idempotency key (<execution>/reserve[i]).
  • The step’s output is the list of element outputs in input order. Its type is List<{ reservation_id: String }>, which is why return can use reservations: reserve directly.
  • Inside compensate, reserve.reservation_id refers to the output of the element being compensated, not to the list.

An empty items list makes the step succeed immediately with [].

The child workflow step

ship is a workflow step. It starts Shipping as a separate execution:

  • The compiler checks with against Shipping’s input and types ship’s output as Shipping’s output, so ship.tracking is a String.
  • The child execution has a deterministic id, <parent id>.ship. The runtime can therefore never start it twice, even if it crashes right after starting it.
  • timeout 1h limits how long the parent waits for the child. Child steps take no retry, using, idempotency_key or queue_timeout; retries belong on the child’s own steps.
  • Shipping must be defined in, or imported into, the same compilation. orbit deploy and orbit run register it before Fulfilment.

ship does not read reserve’s output and has no after clause, so the two steps run concurrently. Add after reserve to ship if shipping must wait for every reservation.

orbit check examples/fulfilment.orbit
ok Shipping@1.0.0 (2 steps)
ok Fulfilment@1.0.0 (2 steps)

Run it

Start the runtime and the two order workers, each in its own terminal:

orbit server
PYTHONPATH=sdks/python python3 examples/workers/python/order_workers.py
node examples/workers/typescript/order_workers.ts

Run an order with three line items:

orbit run examples/fulfilment.orbit --id ful-B-1 --input '{
  "order_id": "B-1",
  "items": [
    {"sku": "BOOK-1", "quantity": 1},
    {"sku": "PEN-2", "quantity": 3},
    {"sku": "MUG-3", "quantity": 1}
  ]
}'
deployed Shipping@1.0.0 (sha256:e9480a2e5f6b)
deployed Fulfilment@1.0.0 (sha256:ecffc60c3bd3)
started execution ful-B-1
  ✓ reserve[0]                     succeeded 16ms
  ✓ reserve[1]                     succeeded 14ms
  ✓ reserve[2]                     succeeded 19ms
  ✓ reserve                        succeeded 27ms
  ✓ ship                           succeeded 96ms

COMPLETED ful-B-1  Fulfilment@1.0.0
  elapsed: 112ms
{
  "reservations": [
    {
      "reservation_id": "res-B-1-BOOK-1"
    },
    {
      "reservation_id": "res-B-1-PEN-2"
    },
    {
      "reservation_id": "res-B-1-MUG-3"
    }
  ],
  "tracking": "UPS-B-1"
}

orbit run deployed both workflows, callee first. orbit status shows one row per element and one for the step as a whole. The ship unit is executed by the runtime itself, which appears as the worker orbit-runtime:

orbit status ful-B-1
COMPLETED ful-B-1  Fulfilment@1.0.0
  elapsed: 112ms

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  reserve[0]                   SUCCEEDED          1     16ms  worker orders-typescript
  reserve[1]                   SUCCEEDED          1     14ms  worker orders-typescript
  reserve[2]                   SUCCEEDED          1     19ms  worker orders-typescript
  reserve                      SUCCEEDED          1     27ms
  ship                         SUCCEEDED          1     96ms  worker orbit-runtime

  output: {"reservations":[{"reservation_id":"res-B-1-BOOK-1"},{"reservation_id":"res-B-1-PEN-2"},{"reservation_id":"res-B-1-MUG-3"}],"tracking":"UPS-B-1"}

The child is an execution of its own. orbit ps lists it next to its parent:

orbit ps
EXECUTION                              WORKFLOW                 VERSION    STATUS        AGE
ful-B-1.ship                           Shipping                 1.0.0      COMPLETED     121ms
ful-B-1                                Fulfilment               1.0.0      COMPLETED     135ms
orbit status ful-B-1.ship
COMPLETED ful-B-1.ship  Shipping@1.0.0
  elapsed: 33ms

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  quote                        SUCCEEDED          1     23ms  worker orders-python
  book                         SUCCEEDED          1      4ms  worker orders-python

  output: {"carrier":"ups","tracking":"UPS-B-1","cost":7.5}

A failing child: per-element compensation

The Python worker’s shipping.book fails with the non-retryable error NO_CARRIER for order ids that start with noship:

orbit run examples/fulfilment.orbit --id ful-noship-1 --input '{
  "order_id": "noship-1",
  "items": [
    {"sku": "BOOK-1", "quantity": 1},
    {"sku": "PEN-2", "quantity": 3}
  ]
}'
up to date Shipping@1.0.0 (sha256:e9480a2e5f6b)
up to date Fulfilment@1.0.0 (sha256:ecffc60c3bd3)
started execution ful-noship-1
  ✓ reserve[1]                     succeeded 5ms
  ✓ reserve[0]                     succeeded 9ms
  ✓ reserve                        succeeded 11ms
  ✗ ship                           failed child workflow `Shipping` failed: step `book` failed: no carrier serves this address
  ✓ compensate:reserve[0]          succeeded 5ms
  ✓ compensate:reserve[1]          succeeded 4ms

FAILED ful-noship-1  Fulfilment@1.0.0
  elapsed: 163ms
  compensation: completed
  error: NO_CARRIER step `ship` failed: child workflow `Shipping` failed: step `book` failed: no carrier serves this address

The child failed first. Its history ends with the failure of book:

orbit logs ful-noship-1.ship
+     0ms   #1  WorkflowCreated
+     0ms   #2  WorkflowStarted
+     2ms   #3  StepScheduled             step=quote attempt=1
+     3ms   #4  StepDispatched            step=quote attempt=1 worker_id=orders-python
+    10ms   #5  StepSucceeded             step=quote attempt=1
+    11ms   #6  StepScheduled             step=book attempt=1
+    13ms   #7  StepDispatched            step=book attempt=1 worker_id=orders-python
+    18ms   #8  StepAttemptFailed         step=book attempt=1 error=NO_CARRIER no carrier serves this address
+    18ms   #9  StepFailed                step=book error=NO_CARRIER no carrier serves this address
+    18ms  #10  WorkflowFailed            error=NO_CARRIER step `book` failed: no carrier serves this address

The parent’s history shows the fan-out, the child’s error arriving as the ship step’s error, and the compensation of each reserved element:

orbit logs ful-noship-1
+     0ms   #1  WorkflowCreated
+     0ms   #2  WorkflowStarted
+     5ms   #3  StepFanOut                step=reserve
+     5ms   #4  StepScheduled             step=ship attempt=1
+     5ms   #5  StepScheduled             step=reserve[0] attempt=1
+     5ms   #6  StepScheduled             step=reserve[1] attempt=1
+     6ms   #7  StepDispatched            step=reserve[0] attempt=1 worker_id=orders-typescript
+     9ms   #8  StepDispatched            step=reserve[1] attempt=1 worker_id=orders-typescript
+    14ms   #9  StepSucceeded             step=reserve[1] attempt=1
+    15ms  #10  StepSucceeded             step=reserve[0] attempt=1
+    16ms  #11  StepSucceeded             step=reserve attempt=1
+    51ms  #12  StepDispatched            step=ship attempt=1 worker_id=orbit-runtime
+   149ms  #13  StepAttemptFailed         step=ship attempt=1 error=NO_CARRIER child workflow `Shipping` failed: step `book` failed: no carrier serves this address
+   150ms  #14  StepFailed                step=ship error=NO_CARRIER child workflow `Shipping` failed: step `book` failed: no carrier serves this address
+   150ms  #15  CompensationStarted       reason=step `ship` failed: child workflow `Shipping` failed: step `book` failed: no carrier serves this address
+   150ms  #16  StepScheduled             step=compensate:reserve[0] attempt=1
+   151ms  #17  StepDispatched            step=compensate:reserve[0] attempt=1 worker_id=orders-typescript
+   156ms  #18  StepSucceeded             step=compensate:reserve[0] attempt=1
+   156ms  #19  StepScheduled             step=compensate:reserve[1] attempt=1
+   158ms  #20  StepDispatched            step=compensate:reserve[1] attempt=1 worker_id=orders-typescript
+   163ms  #21  StepSucceeded             step=compensate:reserve[1] attempt=1
+   163ms  #22  CompensationCompleted
+   163ms  #23  WorkflowFailed            error=NO_CARRIER step `ship` failed: child workflow `Shipping` failed: step `book` failed: no carrier serves this address

Points to note:

  • StepFanOut (#3) records the evaluated list once. Retries and recovery reuse it, so the set of elements cannot change mid-execution.
  • reserve and ship were scheduled together (#4 to #6), because ship does not depend on reserve.
  • The child’s error code propagates. The ship step fails with the child’s own code, NO_CARRIER. A child that is cancelled or times out fails the step with CHILD_CANCELLED or CHILD_TIMED_OUT. orbit inspect ful-noship-1 records the child’s id in the error details ("child_execution_id": "ful-noship-1.ship").
  • Each element is compensated individually, newest first. reserve[0] finished after reserve[1] (#10 after #9), so it was released first. Each compensation is its own unit with its own idempotency key, such as ful-noship-1/compensate:reserve[0].
  • A child compensates its own steps. Shipping declares no compensations, and its quote step had nothing to undo. Compensations declared on the child’s steps run when the child itself fails, is cancelled or times out, including when the parent cancels it. A child that already completed is not rolled back when the parent fails later. To undo a completed child’s work, give the workflow step its own compensate block that calls a worker task, such as one that cancels the booking.

Variations

Limit concurrency to one element at a time to process items sequentially:

step reserve {
    for item in input.items
    max_parallel 1
    worker "inventory.reserve"
    with { order_id: input.order_id + "-" + item.sku, items: [item] }
}

Start one child per element by combining for with workflow. The child ids are <parent>.<step>.<i>:

step ship_each {
    for item in input.items
    workflow "Shipping"
    with { order_id: input.order_id + "-" + item.sku, items: item.quantity }
}

Use when to decide whether the whole fan-out runs. The loop variable is not in scope in when, because the condition is evaluated once for the step:

step reserve {
    for item in input.items
    when len(input.items) <= 50
    worker "inventory.reserve"
    with { order_id: input.order_id + "-" + item.sku, items: [item] }
}

See also

Calling HTTP services

This guide shows how to call an HTTP endpoint from a workflow without writing a worker, using the built-in task orbit.http.request. You enable the adapter on the server, run the health-check example, post a JSON webhook with headers and retries, and learn how the adapter maps HTTP statuses to step results.

When to use the adapter

The HTTP adapter runs inside the runtime process. It suits calls that need no custom code around them: webhooks, health checks, and simple JSON APIs. Write a worker instead when the call needs request signing, pagination, response transformation, client certificates, secrets that should not pass through workflow input, or any business logic.

Enable the adapter

The adapter is off by default, because it lets workflow authors make outbound requests from the runtime host. Start the server with --enable-http-adapter:

orbit server --enable-http-adapter
INFO orbit: starting ORBIT runtime db=orbit.db backend="sqlite" http_adapter=true auth=false ui=true

If the adapter is disabled, a step that uses it fails immediately with the non-retryable code ADAPTER_DISABLED:

  ✗ probe                          failed the HTTP adapter is disabled; start the server with --enable-http-adapter

FAILED ping-off  HttpPing@1.0.0
  elapsed: 71ms
  compensation: none
  error: ADAPTER_DISABLED step `probe` failed: the HTTP adapter is disabled; start the server with --enable-http-adapter

With Docker, append the flag to the server command: docker run ... ghcr.io/ajmalx0/orbit:v0.1.0 server --enable-http-adapter.

The built-in contract

orbit.http.request is declared by the compiler’s prelude, so you use it without a task declaration:

task "orbit.http.request" {
    version "1.0.0"
    input {
        method: String = "GET"
        url: String
        headers: Map<String, String> = {}
        body: JSON = null
    }
    output {
        status: Int
        headers: Map<String, String>
        body: JSON
    }
}
Input fieldBehavior
methodGET (default), POST, PUT, PATCH, DELETE, HEAD or OPTIONS. Case does not matter. Any other method fails with a non-retryable HTTP_ERROR.
urlMust start with http:// or https://. Other schemes fail with a non-retryable HTTP_ERROR.
headersSent as request headers.
bodyFor POST, PUT and PATCH only. null sends an empty body, a string is sent as is, and any other value is sent as JSON.
Output fieldContents
statusThe HTTP status code.
headersResponse headers, with lowercase names.
bodyThe response body parsed as JSON, or the raw text as a string if it is not JSON. At most 8 MiB is read.

The step’s timeout applies to the whole request. Without one, a request may take up to 30 seconds. HTTPS certificates are verified against the public web PKI.

How statuses map to results

ResponseStep result
2xx, 3xx, 4xx other than 429The attempt succeeds. The workflow receives the status and decides what it means.
429 and 5xxThe attempt fails with a retryable HTTP_ERROR (server responded <status>), so the step’s retry policy applies. The status and body are kept in the error details.
Connection error or timeoutThe attempt fails with a retryable HTTP_ERROR (request failed: ...).

A 404 is therefore a successful step with status: 404. Test the status in return, in a when condition of a later step, or both.

Run the health-check example

examples/http_ping.orbit calls a URL and reports whether it answered 200:

// Calls an HTTP endpoint with the built-in adapter: no worker required.
// Start the runtime with `orbit server --enable-http-adapter`.

workflow HttpPing {
    version "1.0.0"
    input { url: String = "http://127.0.0.1:7233/v1/health" }

    step probe {
        worker "orbit.http.request"
        with { url: input.url, headers: { accept: "application/json" } }
        timeout 5s
        retry { attempts: 3, backoff: exponential, initial_delay: 200ms }
    }

    return {
        status: probe.status
        healthy: probe.status == 200
    }
}

The default URL is the runtime’s own health endpoint, so the example works with no other service. From a clone of the repository, with the server running:

orbit run examples/http_ping.orbit --id ping-1
deployed HttpPing@1.0.0 (sha256:0c0d025d4d99)
started execution ping-1
  ✓ probe                          succeeded 4ms

COMPLETED ping-1  HttpPing@1.0.0
  elapsed: 9ms
{
  "status": 200,
  "healthy": true
}

No worker was involved. orbit inspect ping-1 shows the request the runtime sent, with the input defaults applied, and the response it received (abridged):

"probe": {
  "status": "SUCCEEDED",
  "input": {
    "url": "http://127.0.0.1:7233/v1/health",
    "headers": { "accept": "application/json" },
    "method": "GET",
    "body": null
  },
  "output": {
    "status": 200,
    "headers": {
      "content-type": "application/json",
      "x-orbit-api-version": "orbit-api/1",
      "content-length": "140",
      "date": "Sun, 15 Nov 2026 09:37:40 GMT"
    },
    "body": {
      "status": "ok",
      "api_version": "orbit-api/1",
      "runtime_version": "0.1.0",
      "ir_versions": ["orbit-ir/1"],
      "active_executions": 1,
      "worker_sessions": 0
    }
  },
  "worker_id": "orbit-runtime"
}

A path that does not exist returns 404, which completes the workflow with "healthy": false:

orbit run examples/http_ping.orbit --id ping-404 --input '{"url": "http://127.0.0.1:7233/nope"}'
COMPLETED ping-404  HttpPing@1.0.0
  elapsed: 56ms
{
  "status": 404,
  "healthy": false
}

A URL where nothing listens fails each attempt with a connection error. The step retries twice, with 200 ms and 400 ms of backoff, then fails:

orbit run examples/http_ping.orbit --id ping-down --input '{"url": "http://127.0.0.1:7999/"}'
  ✗ probe                          failed request failed: io: Connection refused (os error 111) (gave up after 3 attempts)

FAILED ping-down  HttpPing@1.0.0
  elapsed: 888ms
  compensation: none
  error: HTTP_ERROR step `probe` failed: request failed: io: Connection refused (os error 111) (gave up after 3 attempts)

Post a webhook

The following workflow posts a JSON event with an authorization header and an idempotency header, retries server errors, and reports whether the receiver answered 201 Created:

workflow NotifyWebhook {
    version "1.1.0"
    input {
        order_id: String
        webhook_url: String
        api_key: String
    }
    output { accepted: Bool, status: Int }

    step post_event {
        worker "orbit.http.request"
        with {
            method: "POST"
            url: input.webhook_url
            headers: {
                authorization: "Bearer " + input.api_key
                "idempotency-key": "order-created-" + input.order_id
            }
            body: { event: "order.created", order_id: input.order_id }
        }
        timeout 10s
        retry { attempts: 3, backoff: exponential, initial_delay: 1s }
    }

    return {
        accepted: post_event.status == 201
        status: post_event.status
    }
}
  • Header names that are not identifiers, such as idempotency-key, are written as quoted keys.
  • The adapter does not add an idempotency header by itself. Retries and redeliveries can send the same request more than once, so pass a stable key that the receiver can deduplicate on. Build it from workflow data, as here, or from execution.id.
  • body is a record, so it is sent as JSON.

Against a receiver that answers 201:

orbit run notify.orbit --id wh-3 --input '{"order_id": "A-1", "webhook_url": "https://hooks.example.com/orders", "api_key": "k-123"}'
deployed NotifyWebhook@1.1.0 (sha256:961caf628d35)
started execution wh-3
  ✓ post_event                     succeeded 5ms

COMPLETED wh-3  NotifyWebhook@1.1.0
  elapsed: 51ms
{
  "accepted": true,
  "status": 201
}

Against a receiver that answers 503, each attempt fails with a retryable error, and the step gives up after the third:

  ↻ post_event                     retry_wait server responded 503
  ↻ post_event                     retry_wait server responded 503
  ✗ post_event                     failed server responded 503 (gave up after 3 attempts)

FAILED wh-4  NotifyWebhook@1.1.0
  elapsed: 3.3s
  compensation: none
  error: HTTP_ERROR step `post_event` failed: server responded 503 (gave up after 3 attempts)
+    70ms   #5  StepAttemptFailed         step=post_event attempt=1 error=HTTP_ERROR server responded 503
+    71ms   #6  RetryScheduled            step=post_event delay_ms=1000
+    1.2s   #9  StepAttemptFailed         step=post_event attempt=2 error=HTTP_ERROR server responded 503
+    1.2s  #10  RetryScheduled            step=post_event delay_ms=2000
+    3.3s  #13  StepAttemptFailed         step=post_event attempt=3 error=HTTP_ERROR server responded 503
+    3.3s  #14  StepFailed                step=post_event error=HTTP_ERROR server responded 503 (gave up after 3 attempts)

Secrets and the adapter

Everything a step sends is part of the execution’s history: the workflow input in WorkflowCreated, and the evaluated step input, including headers, in StepScheduled. orbit logs, orbit logs --json and orbit inspect hide values whose keys look sensitive, such as api_key and authorization:

"headers": { "authorization": "***", "idempotency-key": "order-created-A-1" }

That redaction applies to the CLI’s output only. The database and the HTTP API (GET /v1/executions/{id} and /history) hold the values in clear text, and anyone with the admin token can read them. For long-lived credentials, call the service from a worker that reads the secret from its own environment instead of passing it through workflow input.

Security checklist

  • Enable the adapter only on runtimes whose workflow authors you trust to make outbound requests from the runtime host.
  • The adapter accepts any http:// or https:// URL, including internal addresses reachable from the runtime host. Restrict the host’s egress with a firewall or proxy if workflows must not reach internal services.
  • Prefer https:// URLs. Plain http:// sends headers and bodies unencrypted.

See also

Typed business process contracts

This guide shows how to write a business process in ORBIT so that the process definition is a contract you can review, check and audit: customer onboarding with identity verification, sanctions screening, a compliance officer’s review, and an account that is closed again if anything later fails. It covers what the compiler guarantees, how to test every path, and how to read the record an execution leaves. The complete files are examples/onboarding.orbit and examples/onboarding.test.orbit.

What “contract” means here

In a process written as code, the rules live in branches, helper functions and conventions. In ORBIT they are declarations the compiler checks:

RuleWhere it is declaredWhat checks it
Each service call has a fixed input and output shapea task contractThe compiler, on every step; the runtime, on every result
A side effect has an undo actioncompensated_by on the contract, compensate on the stepW0601/W0602, errors under orbit check --strict
A human decision has a typed payload and a deadlinesignal and await ... timeoutThe compiler (payload use), the runtime (payload validation), W0606 for waits without a timeout
Business rules are visiblecompute steps and when conditionsThe type checker; the runtime evaluates them deterministically
What happens on failure, cancellation and timeouton failure, on cancel, on timeoutThe compiler; the runtime runs them after compensation
Who may start, signal or read a processclient identities in the workers fileThe server, per workflow and action
A change does not break running callersversion, orbit compatorbit compat before you deploy

The contracts

Each external service is a task contract. accounts.open declares that its effect is undone by accounts.close, so every workflow that opens an account must say how it closes it:

task "accounts.open" {
    version "1.0.0"
    input { customer_id: String, product: String, risk: String }
    output { account_id: String }
    compensated_by "accounts.close"
}

task "accounts.close" {
    version "1.0.0"
    input { account_id: String, reason: String }
    output { closed: Bool }
}

Contracts can live in a shared file that several teams import, so the worker team and the process owner agree on one definition.

The process

The risk rules are a compute step, written in the process where a reviewer can read them, and evaluated by the runtime with no worker:

step risk {
    compute {
        level: if !identity.verified || screening.hits > 0 then "high"
            else if identity.confidence < 0.9 then "medium"
            else "low"
        needs_review: !identity.verified || screening.hits > 0 || identity.confidence < 0.9
    }
}

step decision {
    await review
    when risk.needs_review
    timeout 5d
}

step open_account {
    worker "accounts.open"
    when !risk.needs_review || coalesce(decision.approved, false)
    with { customer_id: input.customer_id, product: input.product, risk: risk.level }
    idempotency_key: "open-" + input.customer_id
    compensate {
        worker "accounts.close"
        with { account_id: open_account.account_id, reason: "onboarding did not complete" }
    }
}

The officer’s decision is a typed signal. Sending one with a missing or mistyped field is refused by the server with 422 SIGNAL_PAYLOAD_INVALID and never reaches the process:

signal review { approved: Bool, officer: String, note: String? }

What the compiler refuses

Delete the compensate block from open_account and the strict check fails, naming the action that would be left in place:

warning[W0601]: step `open_account` calls `accounts.open` but has no compensation
  --> onboarding.orbit:96:16
   |
96 |         worker "accounts.open"
   |                ^^^^^^^^^^^^^^^
   = note: `accounts.open` declares `compensated_by "accounts.close"`: if a later step fails, its effect would be left in place
   = help: add `compensate { worker "accounts.close" with { ... } }`

error: 1 warning(s) treated as errors (--strict)

Run orbit check --strict in CI so this cannot be merged. Other checks that apply here: a with field of the wrong type (E0400), a missing required field (E0403), reading a value that may be null without handling it (E0405), a condition that is always false or always true (W0603, W0604), and an await without a timeout (W0606). See Static safety checks.

Organisation rules as policy

Some rules are not about one workflow but about every workflow in a domain: “accounts must be closable”, “provider calls retry and have a deadline”, “reviews are answered within a week”. Write them once as a policy file and check every workflow against it in CI. examples/policies/kyc.toml:

[policy]
name = "kyc"
workflows = ["Onboarding*"]

[[rule]]
id = "accounts-reversible"
type = "require_compensation"
tasks = ["accounts.open"]

[[rule]]
id = "provider-timeouts"
type = "require_timeout"
tasks = ["identity.*", "sanctions.*"]
max = "1m"

[[rule]]
id = "review-deadline"
type = "require_signal_timeout"
signals = ["review"]
max = "7d"

[[rule]]
id = "outcome-handlers"
type = "require_handler"
handlers = ["failure", "timeout"]

The onboarding workflow passes. Remove the timeout from the identity step and the check fails:

$ orbit check --policy examples/policies/kyc.toml examples/onboarding.orbit
error[P0104]: policy rule `provider-timeouts`: step `identity` calls `identity.verify` without a `timeout`
  --> examples/onboarding.orbit:64:10
   |
64 |     step identity {
   |          ^^^^^^^^
   = note: required by policy kyc (examples/policies/kyc.toml)
   = help: add `timeout 1m` to the step, or waive it: `// policy-allow: provider-timeouts <reason>`

error: policy check failed: 1 error(s), 0 warning(s)

orbit compile --policy and orbit deploy --policy apply the same check, so a workflow that breaks a rule cannot be deployed. A deliberate exception is written next to the step it applies to, with a reason (// policy-allow: provider-timeouts <reason>), so it is reviewed like any other change. See Policies for every rule type.

Documentation and diagrams from the definition

The process definition is also its documentation. orbit graph draws it (Mermaid, Graphviz or SVG), orbit docs writes a reference page per workflow and contract, and orbit schema exports JSON Schema for every input, output, signal and query, so other systems can validate payloads. orbit diff shows what changed between two versions of the process, in a form you can paste into a pull request. See Graphs, Documentation generation and Process diffs.

Testing every path

Each path through the process is a test block with mocked services. Tests are type-checked against the contracts, so a mock that returns the wrong shape does not compile:

test "a failure after the account is opened closes it again" {
    workflow Onboarding
    input { customer_id: "c-5", full_name: "T. Hoe", document_id: "P111", country: "BE" }
    mock "identity.verify" returns { verified: true, confidence: 0.99, reasons: [] }
    mock "sanctions.screen" returns { hits: 0, lists: [] }
    mock "accounts.open" returns { account_id: "acc-" + input.customer_id }
    mock "audit.record" fails "AUDIT_UNAVAILABLE" "the audit store rejected the write"
    mock "accounts.close" returns { closed: true }
    expect status FAILED
    expect error "AUDIT_UNAVAILABLE"
    expect compensated open_account
}
$ orbit test examples/onboarding.test.orbit
running 5 tests
ok      a verified, clean customer is opened without review (0ms simulated)
ok      a sanctions hit waits for an officer, who approves (0ms simulated)
ok      an officer rejection opens nothing and is audited (0ms simulated)
ok      a review nobody answers fails after five days and is recorded (5d 0h simulated)
ok      a failure after the account is opened closes it again (0ms simulated)

test result: ok. 5 passed, 0 failed, 0 filtered out (0.04s)

The five-day deadline is simulated: durable time is fast-forwarded, so the whole suite runs in milliseconds. See Testing workflows.

The record of an execution

Every execution’s history is the audit trail: each decision, each attempt, the officer’s signal and who sent it, and the outcome. orbit simulate shows the same timeline locally, here for a sanctions hit that an officer approves:

+     0ms   #9  StepSucceeded             step=risk attempt=1
+     0ms  #10  SignalAwaitStarted        step=decision signal=review
+     0ms  #11  SignalReceived            name=review
+     0ms  #12  SignalDelivered           step=decision signal_seq=11
+     0ms  #13  StepScheduled             step=open_account attempt=1
...
+     0ms  #22  WorkflowCompleted

  output: {"status":"opened","account_id":"acc-c-9","risk":"high"}

On a server, orbit logs <id> prints this history and GET /v1/executions/{id}/history returns it as JSON. The history is append-only and records the definition hash the execution ran, so you can show exactly which version of the process made each decision. See Executions and history.

Limiting who can act

A compliance tool should be able to read onboarding executions and send review signals, and nothing else. Give it a client identity:

[[client]]
id = "compliance-console"
token_sha256 = "..."   # from `orbit token --client compliance-console`
workflows = ["Onboarding"]
actions = ["read", "signal", "query"]

It cannot start executions, deploy workflows or touch other workflows. See Security: client identities.

Changing the process

Treat a change to the process like an API change. orbit compat compares the old and new definitions and reports breaking changes to inputs, outputs, signals and queries and the version bump they require; running executions keep the definition they started with. See Versioning.

See also

Human approvals

This guide shows how to put people into a workflow: a decision that waits for a reviewer, several levels of approval, comments that can arrive at any time, deadlines that escalate, and limits on what each person’s tool can see and do. Every decision is a typed signal, so a reviewer’s tool cannot send a decision the workflow does not understand, and every decision is recorded in the execution’s history. The complete files are examples/expense.orbit and examples/expense.test.orbit; a single-level version is examples/approval.orbit.

The building blocks

NeedORBIT construct
A typed decisionsignal review { approved: Bool, by: String }
Wait for it, with a deadlinestep manager { await manager_review timeout 3d }
Only ask when a rule says sowhen rules.needs_manager on the await step
Act on the decisiona later step with when coalesce(manager.approved, false)
Messages that can come any number of timeson event comment { ... }
What to do when nobody answersthe await fails with SIGNAL_TIMEOUT; on failure runs
Show a reviewer where things standquery status = { ... }
Limit what a reviewer’s tool can doa client identity with actions = ["read", "signal", "query"]

Two levels of approval

Claims of 100 or more need the employee’s manager; claims of 5000 or more also need finance, after the manager approved. The thresholds are a compute step, so they are part of the reviewed definition:

signal manager_review { approved: Bool, by: String }
signal finance_review { approved: Bool, by: String }

step rules {
    compute {
        needs_manager: input.amount >= 100.0
        needs_finance: input.amount >= 5000.0
    }
}

step manager {
    await manager_review
    when rules.needs_manager
    timeout 3d
}

step finance {
    await finance_review
    when rules.needs_finance && coalesce(manager.approved, false)
    timeout 5d
}

step pay {
    worker "payments.pay"
    when (!rules.needs_manager || coalesce(manager.approved, false))
        && (!rules.needs_finance || coalesce(finance.approved, false))
    with { claim_id: input.claim_id, amount: input.amount }
}

A long condition can continue on the next line when that line starts with an operator such as && or ||.

finance reads manager.approved, so it waits for the manager’s step to finish; a finance decision sent early is buffered until finance waits for it (see Buffering and ordering). A step whose when is false is skipped and reads as null afterwards, which is why the conditions use coalesce.

Comments at any time

A comment is not a decision: it can arrive any number of times, and the workflow should not wait for one. An on event block runs once per comment, alongside the main steps, with the payload as event:

signal comment { by: String, text: String }

on event comment {
    step forward {
        worker "people.notify"
        with { to: input.employee, message: event.by + " commented: " + event.text }
    }
}

The execution finishes only after every started block is done. See Event handlers.

Deadlines and escalation

When a review times out, the await step fails with SIGNAL_TIMEOUT, the execution compensates what already happened, and on failure runs. Here it tells the manager:

on failure {
    step escalate {
        worker "people.notify"
        with { to: input.manager, message: "Claim " + input.claim_id + " was not decided in time: " + failure.message }
    }
}

To remind people instead of failing, keep the long timeout and run a schedule that lists waiting executions (orbit ps --workflow ExpenseClaim --status RUNNING), reads their status query and sends reminders.

Where things stand

A query is a typed, read-only view, evaluated on demand. Steps that have not run read as null:

query status = {
    waiting_for: if coalesce(rules.needs_manager, false) && is_null(manager) then "manager"
        else if coalesce(rules.needs_finance, false) && coalesce(manager.approved, false) && is_null(finance) then "finance"
        else "nobody"
    paid: !is_null(pay)
}
orbit query claim-17 status

A query exposes only the fields you put in it. Give a reviewer’s tool the query, not the execution’s full input and history, when the input contains data the reviewer should not see.

Sending a decision

From the CLI, the HTTP API or an SDK client:

orbit signal claim-17 manager_review --data '{"approved": true, "by": "bo"}'
curl -X POST -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"approved": true, "by": "bo"}' \
  http://127.0.0.1:7233/v1/executions/claim-17/signals/manager_review

The runtime validates the payload against the signal’s declaration and applies its defaults. A payload with a missing or mistyped field is refused with 422 SIGNAL_PAYLOAD_INVALID and is not recorded; a signal the workflow does not declare is 404 UNKNOWN_SIGNAL. The web UI generates a form for each declared signal.

Who may decide

Give the approval tool a client identity limited to this workflow and to reading, querying and signalling:

[[client]]
id = "approvals-app"
token_sha256 = "..."   # from `orbit token --client approvals-app --workflow ExpenseClaim --action read --action query --action signal`
workflows = ["ExpenseClaim"]
actions = ["read", "query", "signal"]

The signal payload records who decided (by), so the history shows each decision with its author. The server authenticates the tool, not the person: the tool is responsible for putting the signed-in user into by.

Testing every path

Signals in tests are sent when the execution waits for them (or, for on event signals, as soon as it is idle), and deadlines are simulated:

$ orbit test examples/expense.test.orbit
running 6 tests
ok      a small claim is paid without review (0ms simulated)
ok      a medium claim needs the manager only (0ms simulated)
ok      a large claim needs the manager, then finance (0ms simulated)
ok      finance can refuse what the manager approved (0ms simulated)
ok      comments are forwarded while the claim waits (0ms simulated)
ok      a claim nobody reviews fails after three days and escalates (3d 0h simulated)

test result: ok. 6 passed, 0 failed, 0 filtered out (0.02s)

See also

AI agents with guardrails

This guide shows how to run an AI agent as an ORBIT workflow, so that the agent’s tool calls are durable, typed and bounded: a research agent that plans searches with a language model, runs them in parallel, drafts an answer, has the draft checked, asks a human editor when the check is not convincing, and publishes, withdrawing the article if anything later fails. The model provider is not part of the workflow: workers call whatever model you use. The complete files are examples/agent.orbit and examples/agent.test.orbit.

Why a workflow

An agent loop written as a script loses its progress when the process dies, retries tool calls by hand, and trusts whatever shape the model returns. Running it as a workflow gives you:

ProblemWhat the workflow does
The process crashes halfwayEvery step’s result is recorded; the run continues from the history, without repeating finished tool calls
A model or tool call failsretry with backoff and timeout per step; non-retryable errors stop at once
The model returns the wrong shapeEach model call is a task contract; a result that does not match it fails the step instead of flowing on
The agent does too muchlimits { max_fan_out } caps the parallel tool calls; a compute step enforces the budget before any tool runs
An answer needs a humanAn await on a typed signal, only when the guardrail check asks for it
A side effect must be undonecompensate on the step that has the effect
You need to know what happenedThe history records every call, result, decision and who approved it

Model and tool calls are typed tasks

Each call the agent can make is a task contract. The worker that implements llm.plan calls the model and returns this shape; anything else is rejected by the runtime:

task "llm.plan" {
    version "1.0.0"
    input { question: String, max_queries: Int }
    output { queries: List<String>, estimated_cost_usd: Float }
}

task "tools.search" {
    version "1.0.0"
    input { query: String }
    output { query: String, snippets: List<String>, sources: List<String> }
}

Put the model’s structured-output schema in the worker, matching the contract. The worker can validate before returning, and the runtime validates again: an OUTPUT_CONTRACT_VIOLATION failure in the history means the model or the worker broke the contract.

Bounding what the agent does

The plan comes from the model, so the workflow checks it before acting on it. limits sets ceilings no plan can exceed, and a compute step enforces the budget deterministically:

limits { max_fan_out: 10, max_history_events: 1000 }

step budget {
    compute {
        within: plan.estimated_cost_usd <= input.budget_usd && len(plan.queries) <= input.max_queries
    }
}

step search {
    for query in plan.queries
    max_parallel 4
    worker "tools.search"
    when budget.within
    with { query: query }
    retry { attempts: 3, backoff: exponential, initial_delay: 1s }
    timeout 30s
}

A plan over budget skips every tool call. The timeline shows nothing ran after the check:

+     0ms   #5  StepSucceeded             step=plan attempt=1
+     0ms   #6  StepSucceeded             step=budget attempt=1
+     0ms   #7  StepSkipped               step=search reason=`when` condition was false
+     0ms   #8  StepSkipped               step=draft reason=`when` condition was false
...
+     0ms  #14  WorkflowCompleted

A for over more than max_fan_out elements fails the step with LIMIT_EXCEEDED before any element is scheduled, whatever the budget check says. See Resource limits.

Steps guarded by when budget.within read each other without coalesce: draft, check and gate all share that condition, so the compiler knows the steps they read have run. See Steps that share a condition.

Guardrail check and human review

A separate task scores the draft. When it is not confident, or it flags something, the workflow waits for an editor, who can approve, reject or replace the text:

signal editor_review { approved: Bool, editor: String, edited_text: String? }

step check {
    worker "guardrails.check"
    when budget.within
    with { text: draft.text, citations: draft.citations }
}

step gate {
    compute {
        needs_editor: check.confidence < 0.8 || len(check.flags) > 0
    }
    when budget.within
}

step review {
    await editor_review
    when coalesce(gate.needs_editor, false)
    timeout 2d
}

The guardrail can be a model, a rules engine or both; it is a worker like any other. The threshold (0.8) is in the workflow, where it is reviewed and versioned.

Undoing a side effect

Publishing is the agent’s side effect, so it declares its undo action. If a later step fails, the article is withdrawn before on failure alerts the team:

step publish {
    worker "content.publish"
    when budget.within && (!coalesce(gate.needs_editor, true) || coalesce(review.approved, false))
    with { slug: input.slug, text: coalesce(review.edited_text, draft.text) }
    idempotency_key: "publish-" + input.slug
    compensate {
        worker "content.unpublish"
        with { slug: input.slug }
    }
}

content.publish declares compensated_by "content.unpublish", so orbit check --strict fails if someone removes the compensate block. The idempotency key means a retried or redelivered publish does not post twice, if the worker passes the key to the publishing service.

Testing the agent without a model

Tests replace every model and tool with a mock, so the paths through the agent are tested deterministically and in milliseconds:

$ orbit test examples/agent.test.orbit
running 6 tests
ok      a confident, unflagged draft is published without an editor (0ms simulated)
ok      a low-confidence draft waits for an editor, who approves an edited text (0ms simulated)
ok      an editor rejection publishes nothing (0ms simulated)
ok      a plan over budget stops before any tool is called (0ms simulated)
ok      a model error fails the run and alerts the team (0ms simulated)
ok      a failure after publishing withdraws the article (0ms simulated)

test result: ok. 6 passed, 0 failed, 0 filtered out (0.04s)

Mocks are type-checked against the contracts: a mock that returns queries: "not a list" does not compile, which is the same mistake the runtime would catch from a real model.

Writing the workers

A worker is an ordinary program in any SDK language. A Python sketch of llm.plan, with the model call left to your provider’s client:

from orbit_sdk import Worker, TaskError

worker = Worker("agent-workers", server="http://127.0.0.1:7233", token=TOKEN)

@worker.task("llm.plan", version="1.0.0")
def plan(input, ctx):
    result = call_model(               # your provider's client
        prompt=f"Plan at most {input['max_queries']} web searches for: {input['question']}",
        response_schema={"queries": [str], "estimated_cost_usd": float},
    )
    if not result.get("queries"):
        raise TaskError("EMPTY_PLAN", "the model returned no queries", retryable=True)
    return result

worker.run()

See Python SDK for the exact API, and How workers work for retries, timeouts and idempotency keys.

What this does not do

  • It does not make a model deterministic. The workflow records the model’s answers, so a replay or a crash recovery reuses them instead of asking again.
  • It does not stream tokens. A step returns when the model call finishes.
  • Agent loops of unknown length (call tools until the model says stop) need a bounded for, a chain of child workflows, or continue-as-new; there is no while.

See also

CLI

The orbit binary is the compiler driver, the runtime server and the runtime client in one executable. This page lists every command and every flag, generated from orbit <command> --help for ORBIT 0.2, with the exit codes each command returns and examples you can run against a local server. Flags are long options (--name value) unless a short form is shown.

orbit <COMMAND>

Commands:
  init      Create a new ORBIT project with an example workflow and workers
  check     Type-check workflow files without producing output
  validate  Check workflow files, and validate compiled IR (.json) files
  compile   Compile workflow files to orbit-ir JSON
  fmt       Format .orbit files in place (comments and line breaks are preserved)
  lsp       Run the language server (LSP over stdio), used by editor extensions
  graph     Render a workflow as a diagram (Mermaid, Graphviz DOT or SVG)
  docs      Generate reference documentation for workflows and task contracts
  schema    Export JSON Schema (draft 2020-12) for workflow inputs, outputs, signals, queries and task contracts
  diff      Show what changed in a process between two versions, with the compatibility verdict
  compat    Check that new workflow and contract versions are compatible with old ones
  server    Run the ORBIT runtime server
  deploy    Compile and register workflow definitions with the runtime
  run       Deploy (if given a file) and start a workflow, then follow it to completion
  status    Show an execution's status and steps
  inspect   Inspect a compiled IR file, or an execution's full state
  logs      Show an execution's event history as a timeline
  cancel    Request cancellation of an execution (runs compensations)
  signal    Send a typed signal to a running execution
  query     Evaluate a query declared in the workflow against an execution
  ps        List recent executions
  token     Generate a random bearer token and the workers-file entry for it
  workers   Worker commands
  schedule  Start workflows on a cron expression or a fixed interval
  test      Run the `test` declarations in .orbit files, without a server or workers
  simulate  Run a workflow locally with mocked or generated task results
  replay    Re-run a recorded history through the current decider and report divergences
  help      Print this message or the help of the given subcommand(s)

Global options

OptionMeaning
-h, --helpPrint help for the command. Available on every command.
-V, --versionPrint the version (orbit 0.1.0). Top level only.

Exit codes

Every command uses the same convention, except compat, which also returns 2 for files that cannot be read or compiled (see orbit compat):

CodeMeaning
0Success.
1The command ran and failed: a compilation error, an API error from the runtime, an unreachable runtime, a file that cannot be read or written, fmt --check found unformatted files, or run followed an execution that did not end COMPLETED. The message is printed on stderr, prefixed with error:.
2Invalid usage: an unknown flag, a missing argument, a flag that requires another (for example --tls-cert without --tls-key), or an unreadable --ca-cert, --client-cert or --client-key file.

API errors are printed as error: <CODE> (<HTTP status>): <message>, for example:

error: EXECUTION_FINISHED (409): execution is already CANCELLED

Output conventions

  • Results go to stdout; progress messages (deployed, started execution, formatted) and diagnostics go to stderr. You can pipe stdout safely, for example orbit run wf.orbit --no-wait prints only the execution id on stdout.
  • Colors are used only when the stream is a terminal and the NO_COLOR environment variable is not set.
  • inspect (for executions), logs --json and the output: line of status redact values whose keys look sensitive. See Security.

Runtime connection options

The commands that talk to a runtime (deploy, run, status, inspect, logs, cancel, ps, workers list) share these options:

OptionTypeDefaultEnvironmentMeaning
--server <SERVER>URLhttp://127.0.0.1:7233ORBIT_SERVERRuntime API address. Use https:// for a server with TLS.
--token <TOKEN>stringnoneORBIT_TOKENBearer token sent as Authorization: Bearer <token>. Use the admin token; worker credentials cannot call these endpoints. An empty value means no token.
--ca-cert <CA_CERT>pathnoneORBIT_CA_CERTExtra PEM CA certificate to trust, for a runtime with a private certificate. It replaces the default roots for this connection.
--client-cert <CLIENT_CERT>pathnoneORBIT_CLIENT_CERTPEM client certificate for mutual TLS. Requires --client-key.
--client-key <CLIENT_KEY>pathnoneORBIT_CLIENT_KEYPEM private key for --client-cert. Requires --client-cert.

The client waits at most 330 seconds for any single request. When --server points at a cluster standby, which answers 503 NOT_LEADER with the leader’s address, the client repeats the request once against that address. See Running several servers. A runtime that cannot be reached produces:

error: cannot reach the ORBIT runtime at http://127.0.0.1:7233: io: Connection refused (os error 111)
  hint: start it with `orbit server` or set --server / ORBIT_SERVER

orbit init

Create a new ORBIT project with an example workflow and workers.

orbit init <NAME>
ArgumentTypeMeaning
<NAME>pathDirectory to create. It must not exist, or must be empty.

Exit codes: 0 when the project was created; 1 when the directory exists and is not empty, or a file cannot be written.

$ orbit init hello
  created hello/workers/typescript/package.json
  created hello/workers/python/requirements.txt
  created hello/.gitignore
  created hello/workflows/hello.orbit
  created hello/workers/python/worker.py
  created hello/workers/typescript/worker.ts
  created hello/README.md

Next: cd hello && orbit check workflows/hello.orbit
$ orbit init hello
error: hello already exists and is not empty

orbit check

Type-check workflow files without producing output.

orbit check [OPTIONS] [FILES]...
Argument or optionTypeDefaultMeaning
[FILES]...pathsnoneOne or more .orbit files. Imports are followed.
--format <FORMAT>human or jsonhumanhuman prints diagnostics on stderr and one ok line per workflow on stdout. json prints {"diagnostics": [...]} on stdout and nothing else.
--strictflagoffTreat warnings (missing compensations, unreachable steps, unused signals, waits without a timeout, …) as errors.
--policy <FILE>pathnoneAlso check the workflows against an organisation policy (TOML). Repeatable. Violations are P01xx diagnostics, errors or warnings per rule; they are checked only when the files compile.

Exit codes: 0 when there are no errors (warnings are allowed, unless --strict is given); 1 when any file has an error, when --strict is given and there is any warning, or when no files are given. With --strict and only warnings, the human format prints the warnings, then error: <n> warning(s) treated as errors (--strict), and no ok lines.

$ orbit check examples/order_processing.orbit
ok OrderProcessing@1.0.0 (5 steps)
$ orbit check examples/contracts.orbit
ok no workflows (contracts only)

A file with an error:

$ orbit check messy.orbit
error[E0300]: unknown worker task `greeting.compose`
 --> messy.orbit:6:25
  |
6 |     step greet { worker "greeting.compose" // the task
  |                         ^^^^^^^^^^^^^^^^^^
  = help: declare its contract: `task "greeting.compose" { version "1.0.0" input { ... } output { ... } }`

error: compilation failed: 1 error(s), 0 warning(s)

The same file with --format json:

{
  "diagnostics": [
    {
      "severity": "error",
      "code": "E0300",
      "message": "unknown worker task `greeting.compose`",
      "location": { "file": "messy.orbit", "line": 6, "column": 25 },
      "end_line": 6,
      "end_column": 43,
      "labels": [],
      "notes": [],
      "help": "declare its contract: `task \"greeting.compose\" { version \"1.0.0\" input { ... } output { ... } }`"
    }
  ]
}

With --strict:

$ orbit check --strict review.orbit
warning[W0605]: signal `escalate` is declared but no step awaits it
 --> review.orbit:5:12
  |
5 |     signal escalate { reason: String }
  |            ^^^^^^^^
  = help: add a step with `await escalate`, or remove the declaration

warning[W0606]: step `decision` waits for signal `review` with no timeout
 --> review.orbit:7:27
  |
7 |     step decision { await review }
  |                           ^^^^^^
  = help: add `timeout 7d` to the step (or a workflow `timeout`) so the execution cannot wait forever

error: 2 warning(s) treated as errors (--strict)

Every code is described in Compiler diagnostics. See Static safety checks for the W06xx warnings.

orbit validate

Check workflow files, and validate compiled IR (.json) files.

orbit validate [FILES]...
ArgumentTypeMeaning
[FILES]...pathsAny mix of .orbit source files and compiled .json IR files. Source files are checked as with orbit check. IR files are parsed and validated, including their definition_hash.

Exit codes: 0 when every file is valid; 1 otherwise.

$ orbit validate build/demo.Demo.orbit.json plain.orbit
ok build/demo.Demo.orbit.json is valid orbit-ir/1 (Demo@1.0.0, sha256:d5f75dbf860e1c8471f72adb871698e87f55286496dc3547267dcfe383496418)
ok Plain@1.0.0 (1 steps)

orbit compile

Compile workflow files to orbit-ir JSON. Each workflow is compiled to orbit-ir/1, or to orbit-ir/2 when it uses signals, queries, await or compute steps, lambdas, on handlers or a limits block (see Versioning).

orbit compile [OPTIONS] [FILES]...
Argument or optionTypeDefaultMeaning
[FILES]...pathsnoneOne or more .orbit files.
--strictflagoffTreat warnings as errors: with any warning, print the diagnostics and error: <n> warning(s) treated as errors (--strict), write nothing and exit 1.
-o, --out-dir <OUT_DIR>pathbuildOutput directory, created if needed.
--stdoutflagoffPrint the IR to stdout instead of writing files.
--policy <FILE>pathnoneCheck the workflows against a policy first; with a policy error, print the diagnostics, write nothing and exit 1. Repeatable.

Each workflow is written to <out-dir>/<file stem>.orbit.json. When one file defines several workflows, each is written to <out-dir>/<file stem>.<Workflow>.orbit.json.

Exit codes: 0 on success; 1 on compilation errors, on warnings with --strict, when no files are given, or when the files contain no workflow declarations (nothing to compile: no workflow declarations found).

$ orbit compile demo.orbit -o build
compiled Demo@1.0.0 -> build/demo.Demo.orbit.json
compiled Queue@1.0.0 -> build/demo.Queue.orbit.json
$ orbit compile plain.orbit --stdout | head -5
{
  "ir_version": "orbit-ir/1",
  "name": "Plain",
  "version": "1.0.0",
  "definition_hash": "sha256:cc0640cd8d256b01c327613f0ab70930d5e9f1945f3adf8e08662381caafabd3",

orbit fmt

Format .orbit files in place. Comments and line breaks are preserved. See Formatter for the rules.

orbit fmt [OPTIONS] [FILES]...
Argument or optionTypeDefaultMeaning
[FILES]...pathsnoneFiles to format.
--checkflagoffDo not write. Print would reformat <file> for each file that is not formatted.

Exit codes: 0 when every file is formatted (or was rewritten); 1 when --check finds an unformatted file, a file has lexical errors, or no files are given.

$ orbit fmt --check messy.orbit
would reformat messy.orbit
$ orbit fmt messy.orbit
formatted messy.orbit

orbit lsp

Run the language server (LSP over stdio), used by editor extensions. It takes no options and exits when the client sends exit or closes stdin. See Editor support.

orbit lsp

Exit codes: 0 when the client ends the session; 1 on an I/O error.

orbit graph

Render a workflow as a diagram: a Mermaid flowchart, Graphviz DOT, SVG, or a Mermaid sequence diagram. See Workflow graphs.

Render a workflow as a diagram (Mermaid, Graphviz DOT or SVG)

Usage: orbit graph [OPTIONS] <FILE>

Arguments:
  <FILE>  A `.orbit` file or a compiled IR `.json` file

Options:
      --workflow <WORKFLOW>  The workflow to render when the file defines several
      --format <FORMAT>      `mermaid` (flowchart), `dot` (Graphviz) or `svg` (laid out by orbit, no external tools) [default: mermaid] [possible values: mermaid, dot, svg]
      --sequence             Render a Mermaid sequence diagram of the workflow's calls instead of the step graph
  -o, --output <PATH>        Write to this file instead of stdout
  -h, --help                 Print help
Argument or optionTypeDefaultMeaning
<FILE>pathrequiredA .orbit file or a compiled .json IR file.
--workflow <WORKFLOW>stringsee belowWorkflow to render when the file defines several. Without it, a file with one workflow renders that workflow, and a file with a parent and its children renders the one workflow no other workflow in the file starts.
--format <FORMAT>mermaid, dot or svgmermaidOutput format. svg is laid out by orbit itself.
--sequenceflagoffRender a Mermaid sequence diagram of the workflow’s calls. Only with --format mermaid.
-o, --output <PATH>pathstdoutWrite to this file (parent directories are created) and print wrote <PATH> on stderr.

Exit codes: 0 on success; 1 on compilation errors, an unknown --workflow, an ambiguous file, --sequence with --format dot or svg, or a file that cannot be written.

$ orbit graph examples/fulfilment.orbit --workflow Nope
error: no workflow `Nope`; available: Shipping, Fulfilment

orbit docs

Generate reference documentation for workflows and task contracts, as Markdown or self-contained HTML. See Documentation generation.

Generate reference documentation for workflows and task contracts

Usage: orbit docs [OPTIONS] --output <DIR> <FILES>...

Arguments:
  <FILES>...  `.orbit` files (imports are followed)

Options:
  -o, --output <DIR>     Output directory: one page per workflow, `contracts` and `index`
      --format <FORMAT>  `markdown` or `html` (one self-contained file per page) [default: markdown] [possible values: markdown, html]
      --json-schema      Also write JSON Schema files to `<DIR>/schemas` and link them from the pages
  -h, --help             Print help
Argument or optionTypeDefaultMeaning
<FILES>...pathsrequired.orbit files. Imports are followed.
-o, --output <DIR>pathrequiredOutput directory, created if needed. Writes index, one page per workflow and contracts, each .md or .html, and prints wrote <path> on stderr for each.
--format <FORMAT>markdown or htmlmarkdownPage format.
--json-schemaflagoffAlso write the JSON Schema files to <DIR>/schemas and link them from the workflow pages.

Exit codes: 0 on success; 1 on compilation errors (nothing is written) or a file that cannot be written.

$ orbit docs refunds.orbit --output ref --json-schema
wrote ref/index.md
wrote ref/Refund.md
wrote ref/contracts.md
wrote 5 JSON Schema file(s) in ref/schemas

orbit schema

Export JSON Schema (draft 2020-12) for workflow inputs, outputs, signal payloads, query results and task contract inputs and outputs. See Documentation generation.

Export JSON Schema (draft 2020-12) for workflow inputs, outputs, signals, queries and task contracts

Usage: orbit schema [OPTIONS] <FILES>...

Arguments:
  <FILES>...  `.orbit` files (imports are followed)

Options:
      --workflow <WORKFLOW>  Only this workflow (and the contracts it uses)
  -o, --output <DIR>         Write one `<name>.schema.json` file per schema into this directory instead of printing a single JSON object
  -h, --help                 Print help
Argument or optionTypeDefaultMeaning
<FILES>...pathsrequired.orbit files. Imports are followed.
--workflow <WORKFLOW>stringallOnly this workflow’s schemas and those of the task contracts it uses.
-o, --output <DIR>pathstdoutWrite <DIR>/<name>.schema.json per schema. Without it, print one JSON object mapping each name (workflow.Refund.input, task.payments.payout.output, …) to its schema.

Exit codes: 0 on success; 1 on compilation errors, an unknown --workflow, or a file that cannot be written.

$ orbit schema refunds.orbit -o schemas
wrote 5 JSON Schema file(s) in schemas

orbit diff

Show what changed in a process between two versions: steps, actions, dependencies, conditions, retries, timeouts, compensations, handlers, events, signals, queries, types and limits, with the orbit compat verdict. See Process diffs.

Show what changed in a process between two versions, with the compatibility verdict

Usage: orbit diff [OPTIONS] <OLD> <NEW>

Arguments:
  <OLD>  The old version: a .orbit file or a compiled IR .json file
  <NEW>  The new version: a .orbit file or a compiled IR .json file

Options:
      --workflow <WORKFLOW>  Only this workflow
      --format <FORMAT>      `text`, `markdown` (for a pull request) or `json` [default: text] [possible values: text, markdown, json]
      --exit-code            Exit with status 1 when there are changes (0 when there are none)
  -h, --help                 Print help
Argument or optionTypeDefaultMeaning
<OLD>pathrequiredThe old version: a .orbit file or a compiled IR .json file.
<NEW>pathrequiredThe new version: a .orbit file or a compiled IR .json file.
--workflow <WORKFLOW>stringallReport only this workflow.
--format <FORMAT>text, markdown or jsontexttext prints one line per change and a summary: line; markdown prints a section for a pull request; json prints one object.
--exit-codeflagoffExit 1 when there are changes.

Exit codes: 0 on success (with --exit-code: no changes); 1 with --exit-code when there are changes; 2 when a file cannot be read, is not valid IR or does not compile, or --workflow names a workflow neither side defines. An incompatible change does not change the exit status; use orbit compat for that.

$ orbit diff approval.orbit v3/approval.orbit --exit-code
workflow OrderApproval 1.0.0
  + input: input.channel: String
  ~ timeout: decision: timeout 2d → timeout 1d
compatibility
  error   workflow OrderApproval@1.0.0: content changed without a version change; the runtime rejects it with DEFINITION_CONFLICT; hint: bump the version (1.0.0 → 2.0.0)
summary: workflows: 1 changed; 2 change(s): 1 added, 1 changed; compatibility: incompatible, 1 error(s) (bump the version (1.0.0 → 2.0.0))
$ echo $?
1

orbit compat

Check that new workflow and contract versions are compatible with old ones. See Compatibility checks for every rule.

orbit compat [OPTIONS] <OLD> <NEW>
Argument or optionTypeDefaultMeaning
<OLD>pathrequiredThe old version: a .orbit file or a compiled IR .json file.
<NEW>pathrequiredThe new version: a .orbit file or a compiled IR .json file.
--format <FORMAT>human or jsonhumanhuman prints one line per finding on stdout. json prints {"compatible", "errors", "warnings", "findings": [...]} on stdout and nothing else.

Exit codes: 0 when there are no errors (warnings are allowed); 1 when any finding is an error; 2 when a file cannot be read, is not valid IR, or does not compile, or an argument is missing. Unlike the other commands, compat uses 2 for compile errors, so that CI can tell an incompatible change from a broken input.

$ orbit compat v1.orbit minor.orbit
error   workflow Invoice 1.4.0 → 1.5.0: input.amount: Float → Int: incompatible type: expected `Int`, found `Float`; breaking for callers without a major version bump; hint: bump the major version (1.4.0 → 2.0.0)
error   workflow Invoice 1.4.0 → 1.5.0: input.currency: (added) → String: required field added without a default; breaking for callers without a major version bump; hint: bump the major version (1.4.0 → 2.0.0)
ok      workflow Invoice 1.4.0 → 1.5.0: output.total: Float → Int: narrowed; still accepted where the old type is expected
error: compatibility check failed: 2 error(s), 0 warning(s)
$ orbit compat v1.orbit major.orbit
info    workflow Invoice 1.4.0 → 2.0.0: input.amount: Float → Int: incompatible type: expected `Int`, found `Float`; breaking for callers, allowed by the major version bump
info    workflow Invoice 1.4.0 → 2.0.0: input.currency: (added) → String: required field added without a default; breaking for callers, allowed by the major version bump
ok      workflow Invoice 1.4.0 → 2.0.0: output.total: Float → Int: narrowed; still accepted where the old type is expected

orbit test

Run the test declarations in .orbit files, without a server or workers. See Testing workflows.

Run the `test` declarations in .orbit files, without a server or workers

Usage: orbit test [OPTIONS] [FILES]...

Arguments:
  [FILES]...  Files with `test` declarations (and the workflows they import)

Options:
      --format <FORMAT>  `human` prints one line per test; `json` prints a single report object [default: human] [possible values: human, json]
      --filter <FILTER>  Only run tests whose name contains this text
  -h, --help             Print help
Argument or optionTypeDefaultMeaning
[FILES]...pathsrequiredFiles to compile. Every test in them and in the files they import runs.
--format <FORMAT>human or jsonhumanhuman prints one line per test, failure details with the execution timeline, and a summary. json prints one report object on stdout; see JSON format.
--filter <FILTER>stringnoneRun only tests whose name contains this text.

Exit codes: 0 when every selected test passed, or no test was selected; 1 when a test failed or the files do not compile.

$ orbit test examples/*.test.orbit --filter retried
running 1 test
ok      risk scoring is retried after temporary failures (3.0s simulated)

test result: ok. 1 passed, 0 failed, 9 filtered out (0.01s)

orbit simulate

Run one execution of a workflow locally, with mocked or generated task results. See Simulation.

Run a workflow locally with mocked or generated task results

Usage: orbit simulate [OPTIONS] <FILE>

Arguments:
  <FILE>  A `.orbit` file or compiled IR `.json`

Options:
      --workflow <WORKFLOW>      Workflow to run when the file defines several
  -i, --input <INPUT>            Workflow input as JSON
      --input-file <INPUT_FILE>  Read the workflow input from a JSON file
      --mocks <MOCKS>            JSON file mapping task names to the results they return
      --fail <TASK=CODE>         Make a task fail permanently: `<task>=<ERROR_CODE>` (repeatable)
      --signal <NAME=JSON>       Send a signal once the execution waits for it: `<name>='<json>'` (repeatable, in order)
      --json                     Print the final state, history and child executions as JSON
  -h, --help                     Print help
Argument or optionTypeDefaultMeaning
<FILE>pathrequiredA .orbit file or a compiled IR .json file.
--workflow <WORKFLOW>stringsee orbit graphWorkflow to run when the file defines several.
-i, --input <INPUT>JSON{}The execution input.
--input-file <INPUT_FILE>pathnoneRead the input from a JSON file.
--mocks <MOCKS>pathnoneA JSON object mapping task names to results. Tasks not listed get a generated result.
--fail <TASK=CODE>stringnoneFail every attempt of the task with this code (not retryable). Repeatable.
--signal <NAME=JSON>stringnoneSend this signal once the execution waits for it. NAME=@file.json reads the payload from a file. Repeatable, sent in order.
--jsonflagoffPrint the result, state, history and child executions as one JSON object.

Exit codes: 0 when the execution ends COMPLETED; 1 when it ends otherwise, cannot finish, or the file does not compile.

$ orbit simulate examples/approval.orbit --input '{"order_id":"T1","customer_id":"c-1","amount":10}' --fail notifications.send=MAILBOX_FULL --json | jq -c '{status, error: .error.code, sampled_tasks}'
{"status":"FAILED","error":"MAILBOX_FULL","sampled_tasks":["payments.charge","payments.refund"]}

orbit replay

Re-run a recorded history through the current decider and report every recorded decision it does not reproduce; with --at, show the execution’s state after any event. See Replay.

Re-run a recorded history through the current decider and report divergences

Usage: orbit replay [OPTIONS] [EXECUTION_ID]

Arguments:
  [EXECUTION_ID]  Execution to replay; its history and definition are fetched from the runtime

Options:
      --history <HISTORY>          Replay a history file instead (`GET /v1/executions/{id}/history` output)
      --definition <DEFINITION>    The definition to replay against: compiled IR `.json` or `.orbit` source (required with --history; overrides the runtime's copy otherwise)
      --workflow <WORKFLOW>        Workflow to use when --definition defines several (default: the one the history ran)
      --at <SEQ>                   Print the reconstructed execution state right after this event
      --run <RUN>                  Replay this run of the execution (continue-as-new); default the latest
      --json                       Print the report as JSON
      --server <SERVER>            Runtime API address [env: ORBIT_SERVER=] [default: http://127.0.0.1:7233]
      --token <TOKEN>              Bearer token for the runtime API [env: ORBIT_TOKEN]
      --ca-cert <CA_CERT>          Extra PEM CA certificate to trust (for a runtime with a private certificate) [env: ORBIT_CA_CERT=]
      --client-cert <CLIENT_CERT>  PEM client certificate for mutual TLS (with --client-key) [env: ORBIT_CLIENT_CERT=]
      --client-key <CLIENT_KEY>    PEM private key for --client-cert [env: ORBIT_CLIENT_KEY=]
  -h, --help                       Print help
Argument or optionTypeDefaultMeaning
[EXECUTION_ID]stringnoneReplay this execution: its history and the definition it ran are fetched from the runtime.
--history <HISTORY>pathnoneReplay a history file instead of an execution id.
--definition <DEFINITION>paththe runtime’s copyCompiled IR .json or .orbit source to replay against. Required with --history.
--workflow <WORKFLOW>stringfrom the historyWorkflow to use when --definition defines several.
--at <SEQ>integernoneStop after this event and print the reconstructed state.
--run <RUN>integerthe latest runWith an execution id: replay this run of the execution (continue-as-new). Every run replays on its own. Cannot be combined with --history.
--jsonflagoffPrint the report as one JSON object; see JSON report.
runtime connection optionsSee Runtime connection options. Used only with an execution id.

Exit codes: 0 when every recorded decision is reproduced; 1 on a divergence, an API or connection error, or a history or definition that cannot be loaded; 2 when both an execution id and --history are given.

$ orbit replay rem-1
replayed 9 events of rem-1 against Reminder@1.0.0 with the current decider
  6 decided, 3 external
consistent: every recorded decision is reproduced

orbit server

Run the ORBIT runtime server. Running the server explains each option in context.

orbit server [OPTIONS]
OptionTypeDefaultEnvironmentMeaning
--db <DB>path or URLorbit.dbORBIT_DBWhere definitions and execution history live: a SQLite file path, or a PostgreSQL URL such as postgres://user:pass@host:5432/orbit.
--addr <ADDR>host:port127.0.0.1:7233ORBIT_ADDRAddress of the HTTP API, including the HTTP/JSON worker protocol. Must be an IP address and port.
--otlp-endpoint <OTLP_ENDPOINT>URLnoneOTEL_EXPORTER_OTLP_ENDPOINTExport OpenTelemetry traces (OTLP/HTTP, JSON) to this collector, for example http://localhost:4318. Honors OTEL_EXPORTER_OTLP_HEADERS and OTEL_SERVICE_NAME.
--grpc-addr <GRPC_ADDR>host:portnoneORBIT_GRPC_ADDRAlso serve the gRPC binding of the worker protocol on this address, with the same credentials and TLS settings as the HTTP API.
--token <TOKEN>stringnoneORBIT_TOKENRequire this bearer token on every API call except /v1/health.
--insecureflagoffAllow binding to a non-loopback address without a token.
--worker-token <WORKER_TOKEN>stringnoneORBIT_WORKER_TOKENSecond credential that may only use the worker protocol (/v1/workers/*, /v1/tasks/*). Give it to worker hosts instead of the admin token. Requires --token and must differ from it.
--tls-cert <TLS_CERT>pathnoneORBIT_TLS_CERTPEM certificate chain. With --tls-key, serve HTTPS.
--tls-key <TLS_KEY>pathnoneORBIT_TLS_KEYPEM private key for --tls-cert.
--tls-client-ca <TLS_CLIENT_CA>pathnoneORBIT_TLS_CLIENT_CAEnable mutual TLS: verify client certificates signed by this PEM CA. Certificates identify workers declared with client_cert in --workers-file. Requires --tls-cert.
--workers-file <WORKERS_FILE>pathnoneORBIT_WORKERS_FILETOML file declaring per-worker identities ([[worker]]: id, token hash or client certificate name, allowed task patterns) and client identities ([[client]]: id, credential, allowed workflows and actions). Requires --token. See Security.
--enable-http-adapterflagoffExecute orbit.http.request steps inside the runtime.
--max-active-executions <MAX_ACTIVE_EXECUTIONS>integerunlimitedORBIT_MAX_ACTIVE_EXECUTIONSMaximum running executions; further starts get 429 RESOURCE_EXHAUSTED.
--max-fan-out <MAX_FAN_OUT>integerunlimitedORBIT_MAX_FAN_OUTMaximum elements one for step may fan out to; larger lists fail the step with LIMIT_EXCEEDED.
--max-history-events <MAX_HISTORY_EVENTS>integerunlimitedORBIT_MAX_HISTORY_EVENTSMaximum history events per execution; past it the execution fails with LIMIT_EXCEEDED.
--max-payload-bytes <MAX_PAYLOAD_BYTES>integer, bytesunlimitedORBIT_MAX_PAYLOAD_BYTESMaximum size in bytes of an execution input, task result or signal payload; larger ones get 413 PAYLOAD_TOO_LARGE.
--session-timeout <SESSION_TIMEOUT>integer, seconds30Seconds without contact after which a worker is considered lost.
--max-body <MAX_BODY>integer, bytes4194304Maximum request body size.
--log-format <LOG_FORMAT>text or jsontextLog format. Logs go to stderr.
--clusterflagoffORBIT_CLUSTERRun as one of several servers on the same PostgreSQL database. By default one leader serves and the others stand by and take over if it fails. Requires a PostgreSQL --db. See Running several servers.
--cluster-mode <CLUSTER_MODE>standby or sharedstandbyORBIT_CLUSTER_MODEWith shared, every server is active and they share executions and worker tasks. shared requires --cluster. See Shared mode.
--node-timeout <NODE_TIMEOUT>integer, seconds5ORBIT_NODE_TIMEOUTShared mode: seconds without a heartbeat after which a server is considered dead and the others take over its executions (minimum 2).
--node-id <NODE_ID>stringthe host nameORBIT_NODE_IDThis server’s name in the cluster. Must be unique among running servers. Requires --cluster.
--advertise-addr <ADVERTISE_ADDR>URLderived from --addr unless it is a wildcard addressORBIT_ADVERTISE_ADDRURL other servers give out to reach this one while it leads, and that shared mode lists in /v1/health. Requires --cluster.
--no-uiflagoff (the UI is served)ORBIT_NO_UIDo not serve the web UI at /ui/. The HTTP API is unaffected. The environment variable accepts true/false, 1/0, yes/no and on/off. See Web UI.

The cluster and UI options as orbit server --help prints them:

$ orbit server --help
...
      --cluster
          Run as one of several servers on the same PostgreSQL database. By default one leader serves and the others stand by and take over if it fails; see --cluster-mode [env: ORBIT_CLUSTER=]
      --cluster-mode <CLUSTER_MODE>
          With --cluster: `standby` (one leader serves, the others stand by) or `shared` (every server is active and they share executions and tasks) [env: ORBIT_CLUSTER_MODE=] [default: standby] [possible values: standby, shared]
      --node-timeout <NODE_TIMEOUT>
          Shared mode: seconds without a heartbeat after which a server is considered dead and the others take over its executions (minimum 2) [env: ORBIT_NODE_TIMEOUT=] [default: 5]
      --node-id <NODE_ID>
          This server's name in the cluster (default: the host name) [env: ORBIT_NODE_ID=]
      --advertise-addr <ADVERTISE_ADDR>
          URL other servers give out to reach this one while it leads, and that shared mode lists in /v1/health, e.g. http://orbit-0.orbit:7233 (default: derived from --addr when it is not a wildcard address) [env: ORBIT_ADVERTISE_ADDR=]
      --no-ui
          Do not serve the web UI at /ui/ (the HTTP API is unaffected) [env: ORBIT_NO_UI=]
  -h, --help
          Print help

The server reads ORBIT_LOG (log filter) and ORBIT_DB_CA_CERT (extra CA for PostgreSQL) from the environment. See Environment variables. The four --max-* limits are described in Running the server: resource limits.

Exit codes: 0 after a graceful shutdown (Ctrl+C or SIGINT); 1 when the configuration is refused, the database cannot be opened, or an address cannot be bound; 2 on invalid usage.

Configuration that the server refuses:

$ orbit server --addr 0.0.0.0:7233
error: refusing to listen on 0.0.0.0:7233 without authentication; pass --token (or ORBIT_TOKEN), or --insecure for trusted networks
$ orbit server --worker-token abc
error: --worker-token requires --token: without an admin token every endpoint would be open
$ orbit server --token abc --worker-token abc
error: --worker-token must differ from --token
$ orbit server --workers-file workers.toml
error: --workers-file requires --token: without an admin token administrative endpoints would be open
$ orbit server --token t --tls-cert server.pem --tls-key server.key --tls-client-ca ca.pem
error: --tls-client-ca needs --workers-file entries with `client_cert` to map certificates to workers
$ orbit server --cluster --db orbit.db
error: --cluster requires a PostgreSQL database (--db postgres://...): the servers share it for leader election and history
$ orbit server --cluster-mode shared --db postgres://orbit@db/orbit
error: --cluster-mode shared requires --cluster

orbit deploy

Compile and register workflow definitions with the runtime.

orbit deploy [OPTIONS] [FILES]...
Argument or optionTypeDefaultMeaning
[FILES]...pathsnone.orbit files or compiled .json IR files. Every workflow in every file is registered, children before parents.
--policy <FILE>pathnoneCheck each file against a policy before registering its workflows; a file with a policy error is not deployed and the command exits 1. Requires .orbit files. Repeatable.
runtime connection optionsSee Runtime connection options.

For each workflow, deploy prints deployed when the definition is new and up to date when an identical definition is already registered. The hash prefix identifies the definition.

Exit codes: 0 when every definition is registered or unchanged (including when no files are given); 1 on compilation errors, DEFINITION_CONFLICT, CHILD_NOT_REGISTERED, or any other API error.

$ orbit deploy examples/order_processing.orbit
deployed OrderProcessing@1.0.0 (sha256:4fdfac082bf3)
$ orbit deploy examples/order_processing.orbit
up to date OrderProcessing@1.0.0 (sha256:4fdfac082bf3)

Changing a definition without changing its version is refused:

$ orbit deploy order_processing.orbit
error: DEFINITION_CONFLICT (409): OrderProcessing@1.0.0 is already registered with a different definition (sha256:4fdfac082bf3f2fbbbc8ac5465d37cba01dffd1b8e4ae08e25be3e0d76486c41); workflow definitions are immutable, bump the version

orbit run

Deploy (if given a file) and start a workflow, then follow it to completion.

orbit run [OPTIONS] <TARGET>
Argument or optionTypeDefaultMeaning
<TARGET>stringrequiredA .orbit file, a compiled IR .json file, or the name of a registered workflow. A file is deployed first: every workflow in it is registered.
--workflow <WORKFLOW>stringsee graphWorkflow to run when the file defines several. Ignored when <TARGET> is a workflow name.
--version <VERSION>stringlatestVersion to start when <TARGET> is a workflow name. Without it, the highest registered version (by semantic version order) is started. Ignored for files, which start the version they define.
-i, --input <INPUT>JSON{}Workflow input as a JSON value.
--input-file <INPUT_FILE>pathnoneRead the workflow input from a JSON file. --input takes precedence when both are given.
--id <ID>stringgenerated UUIDClient-chosen execution id. Starting the same id twice is a no-op: the second call attaches to the existing execution. Letters, digits, -, _, . and :, at most 200 characters.
--no-waitflagoffReturn immediately after starting, printing the execution id on stdout.
runtime connection optionsSee Runtime connection options.

Without --no-wait, run prints each step transition on stderr until the execution finishes, then prints a summary on stderr and the workflow output, as JSON, on stdout.

Exit codes: 0 when the execution ends COMPLETED, or was started with --no-wait; 1 when it ends FAILED, CANCELLED or TIMED_OUT, or on any error.

$ orbit run examples/order_processing.orbit --no-wait \
    -i '{"order_id":"o-1","customer_id":"c-1","amount":42.5,"items":[{"sku":"A1","quantity":2}]}'
up to date OrderProcessing@1.0.0 (sha256:4fdfac082bf3)
started execution b013f1d7-09ff-45c2-ae1f-614471942a54
b013f1d7-09ff-45c2-ae1f-614471942a54

Following an execution that times out (demo.orbit declares timeout 3s and no worker serves demo.work):

$ orbit run demo.orbit --workflow Demo --id timeout-1
up to date Demo@1.0.0 (sha256:d5f75dbf860e)
up to date Queue@1.0.0 (sha256:0e3a218e9ed9)
started execution timeout-1
  ✓ pause                          succeeded 283ms
  · work                           scheduled
  ⊘ work                           cancelled

TIMED_OUT timeout-1  Demo@1.0.0
  elapsed: 3.1s
  compensation: none

Starting a registered workflow by name:

$ orbit run HttpPing --id ping-1 -i '{"url":"http://example.invalid"}'
started execution ping-1
  ✗ probe                          failed the HTTP adapter is disabled; start the server with --enable-http-adapter

FAILED ping-1  HttpPing@1.0.0
  elapsed: 90ms
  compensation: none
  error: ADAPTER_DISABLED step `probe` failed: the HTTP adapter is disabled; start the server with --enable-http-adapter

orbit status

Show an execution’s status and steps.

orbit status [OPTIONS] <EXECUTION_ID>
Argument or optionTypeMeaning
<EXECUTION_ID>stringThe execution to show.
runtime connection optionsSee Runtime connection options.

The header shows the status, id, workflow and version, the elapsed time, the compensation outcome and the error, when present. Each unit that has been touched is listed in the order it last changed, with its status, attempt number, duration and a detail column (the error message, or the worker that ran it). Units that have not been scheduled yet are not listed.

Exit codes: 0 when the execution exists; 1 otherwise (NOT_FOUND (404)).

$ orbit status order-1002
FAILED order-1002  OrderProcessing@1.0.0
  elapsed: 1.6s
  compensation: completed
  error: CARD_DECLINED step `process_payment` failed: card was declined

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  validate_order               SUCCEEDED          1      3ms  worker ts-1
  reserve_inventory            SUCCEEDED          1      2ms  worker ts-1
  analyze_risk                 SUCCEEDED          2      2ms  worker py-1
  process_payment              FAILED             1      4ms  card was declined
  send_notification            CANCELLED          0
  compensate:reserve_inventory SUCCEEDED          1      1ms  worker ts-1

orbit inspect

Inspect a compiled IR file, or an execution’s full state.

orbit inspect [OPTIONS] <TARGET>
Argument or optionTypeMeaning
<TARGET>stringWhen it names an existing file: a .json IR file, or a .orbit file (compiled first), summarized as text. Otherwise it is an execution id, and the full execution state is printed as JSON with sensitive values redacted.
runtime connection optionsUsed only for execution ids. See Runtime connection options.

Exit codes: 0 on success; 1 on compilation errors, a file that defines several unrelated workflows, or an unknown execution.

$ orbit inspect examples/order_processing.orbit
workflow OrderProcessing@1.0.0
  Validate, risk-score, reserve, charge and notify for one order
  ir:      orbit-ir/1
  hash:    sha256:4fdfac082bf3f2fbbbc8ac5465d37cba01dffd1b8e4ae08e25be3e0d76486c41
  input:   { order_id: String, customer_id: String, amount: Float, currency: String, items: List<LineItem> }
  output:  { status: String, transaction_id: String?, risk_score: Float }
  timeout: 5m00s

steps (execution order)
  validate_order         orders.validate@1.0.0 using typescript, timeout 10.0s
  analyze_risk           risk.analyze@1.0.0 using python, retry 3x exponential
  reserve_inventory      inventory.reserve@1.0.0 using typescript after validate_order
                         ↺ compensate inventory.release
  process_payment        payments.charge@1.0.0 using python, retry 5x exponential, timeout 30.0s after analyze_risk, reserve_inventory [conditional]
                         ↺ compensate payments.refund
  send_notification      notifications.send@1.0.0 using typescript after process_payment

contracts
  inventory.release@1.0.0
    in:  { reservation_id: String }
    out: { released: Bool }
  ...

For an execution id, the output is the same document as GET /v1/executions/{id}:

$ orbit inspect u-1
{
  "id": "u-1",
  "workflow": "Plain",
  "version": "1.0.0",
  "definition_hash": "sha256:cc0640cd8d256b01c327613f0ab70930d5e9f1945f3adf8e08662381caafabd3",
  "input": {},
  "status": "COMPLETED",
  ...
}

orbit logs

Show an execution’s event history as a timeline.

orbit logs [OPTIONS] <EXECUTION_ID>
Argument or optionTypeDefaultMeaning
<EXECUTION_ID>stringrequiredThe execution to show.
--jsonflagoffPrint raw JSON events, one per line, with sensitive fields redacted.
--run <RUN>integerthe latest runShow this run of the execution (continue-as-new).
runtime connection optionsSee Runtime connection options.

The timeline shows the time since the first event, the sequence number, the event type and the fields step, attempt, worker_id, reason, delay_ms, redelivery, outcome, run, next_run, schedule_id and error when the event has them. Executions and history describes every event type.

Exit codes: 0 when the execution exists; 1 otherwise.

$ orbit logs u-1
+     0ms   #1  WorkflowCreated
+     0ms   #2  WorkflowStarted
+     1ms   #3  StepScheduled             step=work attempt=1
+    39ms   #4  StepDispatched            step=work attempt=1 worker_id=flaky-1
+    5.1s   #5  StepAttemptUncertain      step=work attempt=1 reason=worker `flaky-1` stopped responding
+    5.1s   #6  StepScheduled             step=work attempt=1 redelivery=true
+    6.6s   #7  StepDispatched            step=work attempt=1 worker_id=flaky-2
+    6.7s   #8  StepSucceeded             step=work attempt=1
+    6.7s   #9  WorkflowCompleted
$ orbit logs u-1 --json | tail -2
{"seq":8,"timestamp_ms":1794735434127,"type":"StepSucceeded","step":"work","attempt":1,"output":{"ok":true}}
{"seq":9,"timestamp_ms":1794735434129,"type":"WorkflowCompleted","output":{}}

orbit cancel

Request cancellation of an execution. Work that has not started is withdrawn, in-flight attempts are awaited, and succeeded steps are compensated. See Cancellation.

orbit cancel [OPTIONS] <EXECUTION_ID>
Argument or optionTypeDefaultMeaning
<EXECUTION_ID>stringrequiredThe execution to cancel.
--reason <REASON>stringrequested via APIReason recorded in the CancelRequested event.
runtime connection optionsSee Runtime connection options.

The command prints the execution status right after the request. An execution with nothing in flight and nothing to compensate is already CANCELLED; otherwise it is still RUNNING or COMPENSATING and reaches CANCELLED later.

Exit codes: 0 when cancellation was requested (or was already requested); 1 when the execution is unknown (NOT_FOUND) or already finished (EXECUTION_FINISHED).

$ orbit cancel c-1 --reason "duplicate order"
cancellation requested; execution is CANCELLED
$ orbit cancel c-1
error: EXECUTION_FINISHED (409): execution is already CANCELLED

orbit signal

Send a typed signal to a running execution. The runtime validates the payload against the workflow’s signal declaration and buffers it until an await step takes it. See Signals and queries.

orbit signal [OPTIONS] <EXECUTION_ID> <NAME>
Argument or optionTypeDefaultMeaning
<EXECUTION_ID>stringrequiredThe execution to signal.
<NAME>stringrequiredThe signal name declared in the workflow (signal <name> { ... }).
--data <DATA>JSON or @path{}The payload as JSON. @file.json reads it from a file.
runtime connection optionsSee Runtime connection options.

On success the command prints the history sequence number of the recorded SignalReceived event on stdout. “Delivered” here means that the runtime recorded the signal; whether a step has taken it shows in the history as SignalDelivered.

Exit codes: 0 when the signal was recorded; 1 when --data is not valid JSON or the file cannot be read, or on an API error: NOT_FOUND, UNKNOWN_SIGNAL (404), EXECUTION_FINISHED (409) or SIGNAL_PAYLOAD_INVALID (422).

$ orbit signal order-A-100 reveiw --data '{}'
error: UNKNOWN_SIGNAL (404): OrderApproval@1.0.0 has no signal `reveiw`; declared: review
$ orbit signal order-A-100 review --data '{"approved": "yes", "reviewer": "dana"}'
error: SIGNAL_PAYLOAD_INVALID (422): payload for signal `review` is invalid at $.approved: expected Bool, found a string
$ orbit signal order-A-100 review --data 'x'
error: --data is not valid JSON: expected value at line 1 column 1
$ orbit signal order-A-100 review --data '{"approved": true, "reviewer": "dana", "comment": "verified by phone"}'
signal `review` recorded for order-A-100 (history #5); the next `await review` step takes it
$ orbit signal order-A-100 review --data @review.json
error: EXECUTION_FINISHED (409): execution is already COMPLETED

The execution in this example completes as soon as the signal is delivered, so the last command fails. An execution that is already terminal returns EXECUTION_FINISHED before the signal name and payload are checked.

orbit query

Evaluate a query declared in the workflow against the current state of an execution, and print its value as JSON. Works on running and finished executions. See Signals and queries.

orbit query [OPTIONS] <EXECUTION_ID> <NAME>
Argument or optionTypeDefaultMeaning
<EXECUTION_ID>stringrequiredThe execution to query.
<NAME>stringrequiredThe query name declared in the workflow (query <name> = ...).
runtime connection optionsSee Runtime connection options.

The value is printed on stdout as indented JSON, without the surrounding response object, so it can be piped to jq.

Exit codes: 0 on success; 1 on an API error: NOT_FOUND, UNKNOWN_QUERY (404) or QUERY_FAILED (422).

$ orbit query order-A-100 status
{
  "needs_approval": true,
  "approved": null,
  "reviewer": null,
  "charged": false
}
$ orbit query order-A-100 stat
error: UNKNOWN_QUERY (404): OrderApproval@1.0.0 has no query `stat`; declared: status

orbit ps

List recent executions, newest first.

orbit ps [OPTIONS]
OptionTypeDefaultMeaning
--status <STATUS>stringallOnly executions with this status (RUNNING, COMPENSATING, COMPLETED, FAILED, CANCELLED, TIMED_OUT; case-insensitive).
--workflow <WORKFLOW>stringallOnly executions of this workflow name.
--limit <LIMIT>integer20Maximum number of rows. The runtime caps it at 1000.
runtime connection optionsSee Runtime connection options.

Exit codes: 0 on success; 1 on an API or connection error.

$ orbit ps
EXECUTION                              WORKFLOW                 VERSION    STATUS        AGE
order-1002                             OrderProcessing          1.0.0      FAILED        5.4s
order-1001                             OrderProcessing          1.0.0      COMPLETED     7.4s
$ orbit ps --status running --limit 3
EXECUTION                              WORKFLOW                 VERSION    STATUS        AGE
inspect-me                             Plain                    1.0.0      RUNNING       11ms

orbit token

Generate a random bearer token and the workers-file entry for it. The token (244 random bits from the operating system’s generator, as 64 hexadecimal characters) is printed on stdout; the [[worker]] entry, which contains only the token’s SHA-256 hash, is printed on stderr. The command does not contact a runtime.

orbit token [OPTIONS]
OptionTypeDefaultMeaning
--worker <WORKER>stringmy-workerWorker id for the generated [[worker]] entry.
--task <TASKS>string, repeatable*Task pattern the worker may serve. Repeat the flag for several patterns.
--client <CLIENT>stringnonePrint a [[client]] entry with this id instead of a [[worker]] entry (client identities). Cannot be combined with --worker or --task.
--workflow <WORKFLOWS>string, repeatable*With --client: a workflow pattern the client may use.
--action <ACTIONS>string, repeatablestart, signal, query, readWith --client: a granted action (start, signal, query, read, cancel, schedule).

Exit codes: 0; 1 for an unknown --action.

$ orbit token --worker payments-1 --task 'payments.*' --task audit.log
8a7df28ad62d4237a229d884ee8f8d659f3406f060554af19f10cd96c731b2c4

Give the token above to the worker (ORBIT_WORKER_TOKEN). Add this to the workers file:

[[worker]]
id = "payments-1"
token_sha256 = "cf65d58bce0a5ed7d7c7a09b53a5287eaa138e3ee3e72f22f97df7861cab7bc0"
tasks = ["payments.*", "audit.log"]

Because the token goes to stdout and the entry to stderr, you can capture them separately:

orbit token --worker payments-1 --task 'payments.*' > payments-1.token 2>> workers.toml

orbit workers list

List registered workers.

orbit workers list [OPTIONS]
OptionTypeMeaning
runtime connection optionsSee Runtime connection options.

A worker is online while it has at least one session that contacted the runtime within the session timeout. INFLIGHT counts the tasks its live sessions hold. Workers stay listed after they disconnect.

Exit codes: 0 on success; 1 on an API or connection error.

$ orbit workers list
WORKER                       RUNTIME      STATE    INFLIGHT  TASKS
py-1                         python       offline  0  risk.analyze@1.0.0, payments.charge@1.0.0, payments.refund@1.0.0
ts-1                         typescript   offline  0  orders.validate@1.0.0, inventory.reserve@1.0.0, inventory.release@1.0.0, notifications.send@1.0.0

orbit schedule

Manage schedules: workflows started on a cron expression or a fixed interval. Every subcommand takes the runtime connection options and calls the /v1/schedules API.

Start workflows on a cron expression or a fixed interval

Usage: orbit schedule <COMMAND>

Commands:
  create   Create a schedule that starts a workflow on a cron expression or an interval
  update   Change a schedule's timing, input or policies (other settings are kept)
  list     List schedules with their next fire time
  get      Show a schedule, its state and its next fire times
  pause    Stop firing a schedule until it is resumed
  resume   Resume a paused schedule from its next occurrence (missed ones are skipped)
  trigger  Start an execution of the schedule now (the overlap policy applies)
  delete   Delete a schedule (executions it started keep running)
  help     Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help

Exit codes for every subcommand: 0 on success; 1 on an API or connection error (for example INVALID_SCHEDULE, SCHEDULE_EXISTS or SCHEDULE_NOT_FOUND) or an invalid --input; 2 for invalid arguments.

orbit schedule create

orbit schedule create -h (with --help, each --overlap and --catchup value is described):

Create a schedule that starts a workflow on a cron expression or an interval

Usage: orbit schedule create [OPTIONS] --workflow <WORKFLOW> <ID>

Arguments:
  <ID>  Schedule id: letters, digits, `-`, `_` and `.`

Options:
      --workflow <WORKFLOW>        The workflow to start
      --version <VERSION>          Workflow version (default: the latest registered version at each fire)
      --cron <CRON>                Cron expression: minute hour day-of-month month day-of-week, e.g. "0 2 * * *"
      --every <EVERY>              Fixed interval instead of cron, e.g. 15m or 1h30m (aligned to --start-at or the Unix epoch)
      --timezone <TIMEZONE>        IANA time zone for --cron, e.g. Europe/Berlin (default UTC)
  -i, --input <INPUT>              Workflow input as JSON
      --input-file <INPUT_FILE>    Read the workflow input from a JSON file
      --overlap <OVERLAP>          What to do when an occurrence is due while the previous execution still runs [default: skip] [possible values: skip, allow, cancel-previous]
      --catchup <CATCHUP>          What to do with occurrences missed while no server was running [default: skip-missed] [possible values: skip-missed, run-once]
      --start-at <START_AT>        No occurrence before this RFC 3339 time
      --end-at <END_AT>            No occurrence after this RFC 3339 time
      --jitter <JITTER>            Delay each fire by up to this long (deterministic per occurrence), e.g. 30s
      --description <DESCRIPTION>  Free text shown by `orbit schedule get`
      --paused                     Create the schedule paused
      --server <SERVER>            Runtime API address [env: ORBIT_SERVER=] [default: http://127.0.0.1:7233]
      --token <TOKEN>              Bearer token for the runtime API [env: ORBIT_TOKEN]
      --ca-cert <CA_CERT>          Extra PEM CA certificate to trust (for a runtime with a private certificate) [env: ORBIT_CA_CERT=]
      --client-cert <CLIENT_CERT>  PEM client certificate for mutual TLS (with --client-key) [env: ORBIT_CLIENT_CERT=]
      --client-key <CLIENT_KEY>    PEM private key for --client-cert [env: ORBIT_CLIENT_KEY=]
  -h, --help                       Print help (see more with '--help')

Exactly one of --cron and --every is required. The values map to the fields of POST /v1/schedules (--every is interval; cancel-previous and the other values are written with _ in the API). The command prints the new schedule as orbit schedule get does:

$ orbit schedule create nightly-orders --workflow OrderProcessing --cron "0 2 * * *" --timezone Europe/Berlin --input '{"order_id": "N-1", "customer_id": "cust-1", "amount": 120.0, "items": [{"sku": "BOOK-1", "quantity": 1}]}' --description "Nightly reconciliation order"
active nightly-orders  cron "0 2 * * *" Europe/Berlin
  workflow:   OrderProcessing (latest version)
  input:      {"order_id":"N-1","customer_id":"cust-1","amount":120.0,"items":[{"sku":"BOOK-1","quantity":1}]}
  overlap:    skip   catchup: skip_missed
  description: Nightly reconciliation order
  fired:      0   skipped: 0
  last fire:  -
  last run:   -
  next fires:
    2026-11-16T01:00:00Z
    2026-11-17T01:00:00Z
    2026-11-18T01:00:00Z
    2026-11-19T01:00:00Z
    2026-11-20T01:00:00Z

orbit schedule update

Change a schedule's timing, input or policies (other settings are kept)

Usage: orbit schedule update [OPTIONS] <ID>

Arguments:
  <ID>  The schedule id

Options:
      --cron <CRON>                New cron expression (replaces an interval)
      --every <EVERY>              New interval (replaces a cron expression)
      --timezone <TIMEZONE>        IANA time zone for the cron expression
  -i, --input <INPUT>              New workflow input as JSON
      --version <VERSION>          Pin a workflow version
      --overlap <OVERLAP>          New overlap policy [possible values: skip, allow, cancel-previous]
      --catchup <CATCHUP>          New catch-up policy [possible values: skip-missed, run-once]
      --jitter <JITTER>            New maximum fire delay, e.g. 30s
      --server <SERVER>            Runtime API address [env: ORBIT_SERVER=] [default: http://127.0.0.1:7233]
      --token <TOKEN>              Bearer token for the runtime API [env: ORBIT_TOKEN]
      --ca-cert <CA_CERT>          Extra PEM CA certificate to trust (for a runtime with a private certificate) [env: ORBIT_CA_CERT=]
      --client-cert <CLIENT_CERT>  PEM client certificate for mutual TLS (with --client-key) [env: ORBIT_CLIENT_CERT=]
      --client-key <CLIENT_KEY>    PEM private key for --client-cert [env: ORBIT_CLIENT_KEY=]
  -h, --help                       Print help (see more with '--help')

Sends PATCH /v1/schedules/{id} with the given fields. The next occurrence is recomputed from now; the counters are kept. Other fields (start and end times, description) are changed through the API.

orbit schedule list

List schedules with their next fire time

Usage: orbit schedule list [OPTIONS]

Options:
      --server <SERVER>            Runtime API address [env: ORBIT_SERVER=] [default: http://127.0.0.1:7233]
      --token <TOKEN>              Bearer token for the runtime API [env: ORBIT_TOKEN]
      --ca-cert <CA_CERT>          Extra PEM CA certificate to trust (for a runtime with a private certificate) [env: ORBIT_CA_CERT=]
      --client-cert <CLIENT_CERT>  PEM client certificate for mutual TLS (with --client-key) [env: ORBIT_CLIENT_CERT=]
      --client-key <CLIENT_KEY>    PEM private key for --client-cert [env: ORBIT_CLIENT_KEY=]
  -h, --help                       Print help

STATE is active, paused, or ended (no occurrence left before end_at). NEXT FIRE includes the jitter.

$ orbit schedule list
SCHEDULE                 WORKFLOW             TIMING                           STATE    NEXT FIRE              LAST EXECUTION
hourly-check             OrderProcessing      every 1h                         active   2026-11-15T19:00:12.359Z -
nightly-orders           OrderProcessing      cron "0 2 * * *" Europe/Berlin   active   2026-11-16T01:00:00Z   -

orbit schedule get

Show a schedule, its state and its next fire times

Usage: orbit schedule get [OPTIONS] <ID>

Arguments:
  <ID>  The schedule id

Options:
      --json                       Print the schedule as JSON
      --server <SERVER>            Runtime API address [env: ORBIT_SERVER=] [default: http://127.0.0.1:7233]
      --token <TOKEN>              Bearer token for the runtime API [env: ORBIT_TOKEN]
      --ca-cert <CA_CERT>          Extra PEM CA certificate to trust (for a runtime with a private certificate) [env: ORBIT_CA_CERT=]
      --client-cert <CLIENT_CERT>  PEM client certificate for mutual TLS (with --client-key) [env: ORBIT_CLIENT_CERT=]
      --client-key <CLIENT_KEY>    PEM private key for --client-cert [env: ORBIT_CLIENT_KEY=]
  -h, --help                       Print help

--json prints the document of GET /v1/schedules/{id}.

$ orbit schedule get nightly-orders
active nightly-orders  cron "0 2 * * *" Europe/Berlin
  workflow:   OrderProcessing (latest version)
  input:      {"order_id":"N-1","customer_id":"cust-1","amount":120.0,"items":[{"sku":"BOOK-1","quantity":1}]}
  overlap:    skip   catchup: skip_missed
  description: Nightly reconciliation order
  fired:      1   skipped: 0
  last fire:  -
  last run:   nightly-orders:manual:2026-11-15T18:52:22.296Z
  next fires:
    2026-11-16T01:00:00Z
    2026-11-17T01:00:00Z
    2026-11-18T01:00:00Z
    2026-11-19T01:00:00Z
    2026-11-20T01:00:00Z

orbit schedule pause, resume, trigger and delete

Each takes the schedule id and the runtime connection options:

Stop firing a schedule until it is resumed

Usage: orbit schedule pause [OPTIONS] <ID>

Arguments:
  <ID>  The schedule id

Options:
      --server <SERVER>            Runtime API address [env: ORBIT_SERVER=] [default: http://127.0.0.1:7233]
      --token <TOKEN>              Bearer token for the runtime API [env: ORBIT_TOKEN]
      --ca-cert <CA_CERT>          Extra PEM CA certificate to trust (for a runtime with a private certificate) [env: ORBIT_CA_CERT=]
      --client-cert <CLIENT_CERT>  PEM client certificate for mutual TLS (with --client-key) [env: ORBIT_CLIENT_CERT=]
      --client-key <CLIENT_KEY>    PEM private key for --client-cert [env: ORBIT_CLIENT_KEY=]
  -h, --help                       Print help

orbit schedule resume --help, trigger --help and delete --help are the same with their own first line: Resume a paused schedule from its next occurrence (missed ones are skipped), Start an execution of the schedule now (the overlap policy applies) and Delete a schedule (executions it started keep running).

$ orbit schedule trigger nightly-orders
started execution nightly-orders:manual:2026-11-15T18:52:22.296Z
$ orbit schedule trigger nightly-orders
not started: the previous execution of nightly-orders is still running (overlap: skip)
$ orbit schedule pause hourly-check
schedule hourly-check paused
$ orbit schedule resume hourly-check
schedule hourly-check resumed; next fire 2026-11-15T19:00:12.359Z
$ orbit schedule delete hourly-check
schedule hourly-check deleted

A trigger that the overlap policy skips still exits 0.

See also

HTTP API

The runtime serves one HTTP API on --addr (default 127.0.0.1:7233). It contains the administrative endpoints, used by the orbit CLI and your applications to register workflows and manage executions, and the HTTP/JSON binding of the worker protocol, used by the SDKs. This page lists every route with its authentication requirement, request and response bodies, status codes and error codes. The examples are real responses from orbit server with examples/order_processing.orbit deployed; long values are shortened with ....

Conventions

  • Request and response bodies are JSON (content-type: application/json). Requests with a body must send that content type.
  • Timestamps are milliseconds since the Unix epoch (*_ms fields).
  • Every response that passes authentication carries the header x-orbit-api-version: orbit-api/1.
  • The API is versioned by path (/v1). Fields may be added to responses within orbit-api/1; clients should ignore fields they do not know.
  • Outside /v1/, the same address serves the web UI at /ui/ (no credential needed for its static files). GET / redirects to /ui/ (302) when the Accept header contains text/html, and otherwise answers 404 with error code NOT_FOUND. With --no-ui neither route exists.

Authentication

When the server runs without --token and without --workers-file, every endpoint is open. This is only allowed on a loopback address unless you pass --insecure.

When a credential is configured, every endpoint except GET /v1/health and GET /v1/ready requires one of:

CallerHow it authenticatesCan call
AdminAuthorization: Bearer <--token>Every endpoint.
Shared workerAuthorization: Bearer <--worker-token>/v1/workers/* and /v1/tasks/* only. No identity restrictions.
Per-worker identityAuthorization: Bearer <token> whose SHA-256 is in a [[worker]] entry of the workers file, or a client certificate (mutual TLS) matching its client_cert/v1/workers/* and /v1/tasks/* only, restricted to its own worker id, task patterns, sessions and task tokens.
Client identityAuthorization: Bearer <token> whose SHA-256 is in a [[client]] entry of the workers file, or a client certificate matching its client_certThe execution API (/v1/executions..., and GET /v1/workflows...), for the workflows and actions the entry grants.

GET /v1/workers (the worker list) is an administrative endpoint: the /v1/workers/* prefix covers only the paths below it. A request with a valid client certificate that matches no worker or client in the workers file is refused with 403 on every path, including /v1/health. See Security for the full model.

A client identity is checked per route:

RouteNeeds the action
POST /v1/executionsstart for the workflow in the body
POST /v1/executions/{id}/signals/{name}signal
GET /v1/executions/{id}/queries/{name}query
GET /v1/executions/{id}, /history, /wait, GET /v1/workflows/{name}...read
POST /v1/executions/{id}/cancelcancel
GET /v1/executions, GET /v1/workflowsnone: the list contains only workflows the client may read
POST /v1/workflows, GET /v1/workers, /v1/workers/*, /v1/tasks/*, GET /v1/metricsrefused

For routes with an execution id, the workflow is the execution’s workflow; an id that does not exist is 404 NOT_FOUND. See Security: client identities.

$ curl -s https://orbit.internal:7233/v1/executions
{"error":{"code":"UNAUTHENTICATED","message":"missing or invalid credentials"}}
$ curl -s -H "authorization: Bearer $ORBIT_WORKER_TOKEN" https://orbit.internal:7233/v1/executions
{"error":{"code":"FORBIDDEN","message":"worker credentials may only call /v1/workers/* and /v1/tasks/*"}}
$ curl -s -H "authorization: Bearer $WAREHOUSE_TOKEN" https://orbit.internal:7233/v1/metrics
{"error":{"code":"FORBIDDEN","message":"client `warehouse-app` may only use the execution API (start, signal, query, read, cancel) and schedules"}}
$ curl -s -X POST -H "authorization: Bearer $WAREHOUSE_TOKEN" https://orbit.internal:7233/v1/executions/labels-1/cancel
{"error":{"code":"FORBIDDEN","message":"client `warehouse-app` may not cancel executions of `PrintLabels`"}}

Errors

Errors produced by ORBIT have this body:

{ "error": { "code": "WORKFLOW_NOT_FOUND", "message": "workflow Nope is not registered" } }

code is stable and meant for programs; message is for people and may change. The HTTP status and the code always agree as listed below.

CodeStatusReturned byMeaning
INVALID_REQUEST400worker register, task complete/failA required field is missing or empty: worker_id is required, a worker must announce at least one task contract, token is required, error_code is required.
INVALID_IR400POST /v1/workflowsThe body is not valid IR in a supported version (orbit-ir/1 or orbit-ir/2): an unsupported ir_version, a missing field, a failed validation, a missing definition_hash, or a definition_hash that does not match the content.
CHILD_NOT_REGISTERED400POST /v1/workflowsThe definition starts a child workflow whose exact definition is not registered. Register the child first; orbit deploy does this.
INVALID_INPUT400POST /v1/executions, POST/PATCH /v1/schedulesThe workflow input (an execution’s, or a schedule’s) does not match the workflow’s input type. The message names the path, for example $.order_id.
INVALID_SCHEDULE400POST/PATCH /v1/schedulesThe schedule is not valid: an invalid id, neither or both of cron and interval, an invalid cron expression, interval, duration or time, an unknown time zone, timezone with interval, end_at not after start_at, or an unknown field. The message says which.
INVALID_EXECUTION_ID400POST /v1/executionsexecution_id is empty, longer than 200 characters, or contains characters other than letters, digits, -, _, . and :.
RESERVED_TASK400POST /v1/workers/registerA worker announced a built-in task (orbit.*) or a child-workflow contract (workflow:*).
UNAUTHENTICATED401any authenticated endpointNo credential, or a credential that matches nothing.
FORBIDDEN403any authenticated endpointThe credential is valid but not allowed: a worker credential on an administrative endpoint, a per-worker identity acting for another worker, announcing a task outside its patterns or the wrong runtime, using another worker’s session or token, a client identity outside the execution API or its granted workflows and actions, or an unmatched client certificate.
NOT_FOUND404execution endpointsThe execution does not exist.
RUN_NOT_FOUND404GET /v1/executions/{id}/history?run=NThe execution has no run N.
SCHEDULE_NOT_FOUND404/v1/schedules/{id}The schedule does not exist.
UNKNOWN_SIGNAL404POST /v1/executions/{id}/signals/{name}The execution’s workflow does not declare this signal. The message lists the declared signals.
UNKNOWN_QUERY404GET /v1/executions/{id}/queries/{name}The execution’s workflow does not declare this query. The message lists the declared queries.
WORKFLOW_NOT_FOUND404workflow endpoints, POST /v1/executionsNo such workflow, or no such version.
UNKNOWN_SESSION404heartbeat, poll, unregisterThe worker session does not exist or has expired. Register again.
DEFINITION_CONFLICT409POST /v1/workflowsA different definition is already registered under this name and version. Definitions are immutable; bump the version.
EXECUTION_EXISTS409POST /v1/executionsAn execution with this execution_id exists for a different workflow definition.
SCHEDULE_EXISTS409POST /v1/schedulesA schedule with this schedule_id exists.
EXECUTION_FINISHED409POST /v1/executions/{id}/cancel, POST /v1/executions/{id}/signals/{name}The execution is already terminal.
PAYLOAD_TOO_LARGE413POST /v1/executions, POST /v1/executions/{id}/signals/{name}, POST /v1/tasks/completeThe execution input, signal payload or task result is larger than --max-payload-bytes, as compact JSON. Nothing is recorded. See Resource limits.
SIGNAL_PAYLOAD_INVALID422POST /v1/executions/{id}/signals/{name}The payload does not match the signal’s declaration. The message names the path, for example $.approved. Nothing is recorded.
QUERY_FAILED422GET /v1/executions/{id}/queries/{name}Evaluating the query failed at runtime, for example with a division by zero.
RESOURCE_EXHAUSTED429POST /v1/executions--max-active-executions executions are already running. Nothing is started; retry later with the same execution_id.
STORAGE_ERROR500any endpoint that reads or writes the databaseThe database operation failed. The message contains the backend error. Nothing was changed. On a cluster leader that was just deposed, or in shared mode on a server that lost an execution to another server, the message starts with fenced:.
NOT_LEADER503every route except GET routes, on a cluster standbyThis server is a standby (--cluster). The body’s error object adds leader_node and leader_addr (either may be null), the x-orbit-leader header carries the address, and retry-after: 1 is set. Send the request to the leader, or retry. See Running several servers.

Some errors are produced by the HTTP layer before a request reaches ORBIT. They have a plain-text body (content-type: text/plain), not the JSON envelope:

StatusWhenExample body
400The body is not valid JSON, or a query parameter has the wrong type.Failed to parse the request body as JSON: expected ident at line 1 column 2
404Unknown path. The body is empty.
405Known path, wrong method. The allow header lists the method.
413The body is larger than --max-body. (A value larger than --max-payload-bytes gets the JSON PAYLOAD_TOO_LARGE instead.)Failed to buffer the request body: length limit exceeded
415A body was sent without content-type: application/json.
422Valid JSON with a missing or mistyped field.Failed to deserialize the JSON body into the target type: missing field `workflow` at line 1 column 12

A client should check the status first, and parse the JSON error envelope only when the body is JSON.

Routes

MethodPathAuthPurpose
GET/v1/healthnoneLiveness, versions and cluster role.
GET/v1/readynoneReadiness: 200 only where writes are served.
GET/v1/metricsadminPrometheus metrics.
POST/v1/workflowsadminRegister a compiled definition.
GET/v1/workflowsadminList definitions.
GET/v1/workflows/{name}adminLatest version of a definition.
GET/v1/workflows/{name}/{version}adminOne version of a definition.
POST/v1/executionsadminStart an execution.
GET/v1/executionsadminList executions.
GET/v1/executions/{id}adminCurrent state of an execution.
GET/v1/executions/{id}/historyadminEvent history.
GET/v1/executions/{id}/waitadminLong-poll until terminal.
POST/v1/executions/{id}/canceladminRequest cancellation.
POST/v1/executions/{id}/signals/{name}adminSend a typed signal.
GET/v1/executions/{id}/queries/{name}adminEvaluate a declared query.
POST/v1/schedulesadminCreate a schedule.
GET/v1/schedulesadminList schedules.
GET/v1/schedules/{id}adminOne schedule, with its next fire times.
PATCH/v1/schedules/{id}adminChange a schedule.
DELETE/v1/schedules/{id}adminDelete a schedule.
POST/v1/schedules/{id}/pause, /resumeadminPause or resume a schedule.
POST/v1/schedules/{id}/triggeradminStart an execution of a schedule now.
GET/v1/workersadminRegistered workers.
POST/v1/workers/registerworkerOpen a worker session.
POST/v1/workers/heartbeatworkerKeep a session alive; receive cancellations.
POST/v1/workers/pollworkerLong-poll for a task.
POST/v1/workers/unregisterworkerClose a session.
POST/v1/tasks/completeworkerReport a successful result.
POST/v1/tasks/failworkerReport a failed attempt.

“admin” means the admin token when authentication is enabled; routes under /v1/executions and GET /v1/workflows... also accept a client identity with the matching action, and routes under /v1/schedules a client identity with the schedule action for the schedule’s workflow (see Authentication). “worker” means any credential: admin, shared worker token or a per-worker identity.

Health and metrics

GET /v1/health

Liveness and version information. It never requires authentication. A single server binds its listener only after it has opened the database and recovered active executions, so a 200 also means the runtime is ready to serve. In cluster mode every server, leader or standby, answers 200; use /v1/ready to find the leader, or in shared mode the servers that are live.

Response 200:

{
  "status": "ok",
  "api_version": "orbit-api/1",
  "runtime_version": "0.2.0",
  "ir_versions": ["orbit-ir/1", "orbit-ir/2"],
  "active_executions": 0,
  "worker_sessions": 0,
  "role": "single",
  "node_id": null,
  "term": null,
  "leader": null
}

A cluster standby:

{"status":"ok","api_version":"orbit-api/1","runtime_version":"0.2.0","ir_versions":["orbit-ir/1","orbit-ir/2"],"active_executions":0,"worker_sessions":0,"role":"standby","node_id":"b","term":1,"leader":{"node_id":"a","addr":"http://127.0.0.1:7301"}}
FieldTypeMeaning
statusstringAlways ok.
api_versionstringorbit-api/1.
runtime_versionstringVersion of the orbit binary.
ir_versionsarray of stringsIR versions this runtime accepts. ORBIT 0.1 lists only orbit-ir/1; workflows that use signals, queries, await or compute steps, lambdas, on handlers or a limits block need a runtime that lists orbit-ir/2.
active_executionsintegerExecutions not yet terminal, held in memory.
worker_sessionsintegerWorker sessions the runtime remembers, including ones that recently went silent.
rolestringsingle without --cluster; leader or standby in cluster mode.
node_idstring or nullThe server’s --node-id; null without --cluster.
terminteger or nullThe leadership term this server last saw; null without --cluster.
leaderobject or nullThe current leader as {"node_id": ..., "addr": ...}, or null.

A server in shared mode (--cluster --cluster-mode shared) adds the fields below; term and leader are null, and active_executions counts the executions this server owns:

{"status":"ok","api_version":"orbit-api/1","runtime_version":"0.3.0","ir_versions":["orbit-ir/1","orbit-ir/2"],"active_executions":0,"worker_sessions":2,"role":"shared","node_id":"node-1","term":null,"leader":null,"owned_executions":0,"tasks_claimed":18,"nodes":[{"node_id":"node-1","addr":"http://127.0.0.1:46825","live":true,"heartbeat_age_ms":2,"owned_executions":0},{"node_id":"node-3","addr":"http://127.0.0.1:47377","live":true,"heartbeat_age_ms":248,"owned_executions":0}]}
FieldTypeMeaning
rolestringshared.
owned_executionsintegerRunning executions this server owns: it ticks them and is the only one that appends to their history.
tasks_claimedintegerTasks this server handed to workers from the shared queue since it started.
nodesarrayEvery server that is live or still owns executions: node_id, addr (its --advertise-addr), live (heartbeat younger than --node-timeout), heartbeat_age_ms and owned_executions. Refreshed every second.

GET /v1/ready

Readiness for load balancers and orchestrators. It never requires authentication. It returns 200 on a single server and on a cluster leader, and 503 on a cluster standby, so a load balancer that checks it sends traffic only to the server that accepts writes.

Response 200 (single server):

{"role":"single","ready":true}

Response 503 (cluster standby):

{"role":"standby","node_id":"b","term":1,"leader":{"node_id":"a","addr":"http://127.0.0.1:7301"},"ready":false}

A leader that cannot confirm its leadership within its lease (3 seconds) answers 503 until it can. See Running several servers.

In shared mode every live server answers 200, with the shared-mode fields of /v1/health and "ready": true. A server answers 503 while it resumes the executions it owned before a restart, when it could not write its heartbeat within --node-timeout, and when another running server uses the same --node-id.

GET /v1/metrics

Prometheus text exposition (content-type: text/plain; version=0.0.4). Observability lists every metric.

# HELP orbit_executions_started_total Executions started
# TYPE orbit_executions_started_total counter
orbit_executions_started_total 8
...
# HELP orbit_active_executions Executions not yet finished
# TYPE orbit_active_executions gauge
orbit_active_executions 0

Schedules and continue-as-new add these counters (from a server that ran the examples of Schedules):

# HELP orbit_executions_continued_as_new_total Runs ended by continue_as_new
# TYPE orbit_executions_continued_as_new_total counter
orbit_executions_continued_as_new_total 2
# HELP orbit_schedules_fired_total Schedule occurrences that started an execution
# TYPE orbit_schedules_fired_total counter
orbit_schedules_fired_total 5
# HELP orbit_schedules_skipped_total Schedule occurrences that started nothing
# TYPE orbit_schedules_skipped_total counter
orbit_schedules_skipped_total{reason="overlap"} 0
orbit_schedules_skipped_total{reason="missed"} 0

Workflow definitions

POST /v1/workflows

Register a compiled workflow definition: the orbit-ir/1 or orbit-ir/2 JSON produced by orbit compile. Registering identical content again is a no-op.

Request body: the IR document. Abbreviated:

{
  "ir_version": "orbit-ir/1",
  "name": "OrderProcessing",
  "version": "1.0.0",
  "description": "Validate, risk-score, reserve, charge and notify for one order",
  "definition_hash": "sha256:4fdfac082bf3f2fbbbc8ac5465d37cba01dffd1b8e4ae08e25be3e0d76486c41",
  "timeout_ms": 300000,
  "input": { "kind": "record", "fields": [ ... ] },
  "output": { ... },
  "steps": [ ... ],
  "result": { ... },
  "contracts": { ... },
  "source_map": { ... }
}

Responses:

StatusBodyWhen
201{"name", "version", "definition_hash", "created": true}The definition is new.
200the same, with "created": falseAn identical definition is already registered.
400INVALID_IR, CHILD_NOT_REGISTERED
409DEFINITION_CONFLICT
{"name":"OrderProcessing","version":"1.0.0","definition_hash":"sha256:4fdfac082bf3f2fbbbc8ac5465d37cba01dffd1b8e4ae08e25be3e0d76486c41","created":false}
{"error":{"code":"INVALID_IR","message":"definition_hash does not match the IR content (was the file modified?)"}}

GET /v1/workflows

List every registered definition (all versions), sorted by name, then by version string.

Response 200:

{
  "workflows": [
    {
      "name": "OrderProcessing",
      "version": "1.0.0",
      "description": "Validate, risk-score, reserve, charge and notify for one order",
      "definition_hash": "sha256:4fdfac082bf3f2fbbbc8ac5465d37cba01dffd1b8e4ae08e25be3e0d76486c41",
      "steps": ["validate_order", "analyze_risk", "reserve_inventory", "process_payment", "send_notification"],
      "tasks": ["inventory.release", "inventory.reserve", "notifications.send", "orders.validate",
                "payments.charge", "payments.refund", "risk.analyze"]
    }
  ]
}

description is null when the workflow has none. tasks lists the contract keys the definition uses, including built-in tasks and child workflows (workflow:<Name>).

GET /v1/workflows/{name}

The full IR of the highest registered version of name, ordered by semantic version. Response 200: the IR document. Error 404 WORKFLOW_NOT_FOUND (workflow not found).

GET /v1/workflows/{name}/{version}

The full IR of one version. Response 200: the IR document. Error 404 WORKFLOW_NOT_FOUND.

Executions

POST /v1/executions

Start an execution.

FieldTypeRequiredMeaning
workflowstringyesWorkflow name.
versionstringnoVersion to start. Default: the highest registered version.
inputanynoWorkflow input. null or absent means {}. Defaults declared in the workflow’s input are applied, then the input is validated strictly.
execution_idstringnoClient-chosen id. Default: a random UUID. Starting an id that already exists for the same definition returns the existing execution.
{
  "workflow": "OrderProcessing",
  "version": "1.0.0",
  "execution_id": "order-1001",
  "input": {"order_id": "1001", "customer_id": "c-42", "amount": 99.5, "items": [{"sku": "BOOK-1", "quantity": 1}]}
}

Responses:

StatusBodyWhen
201{"execution_id": "order-1001", "created": true}A new execution was started.
200{"execution_id": "order-1001", "created": false}The id exists for the same definition. Nothing was started.
400INVALID_INPUT, INVALID_EXECUTION_ID
404WORKFLOW_NOT_FOUND
409EXECUTION_EXISTSThe id exists for a different definition.
413PAYLOAD_TOO_LARGEThe input, with defaults applied, is larger than --max-payload-bytes.
429RESOURCE_EXHAUSTED--max-active-executions is reached. An id that already exists still gets 200.
{"error":{"code":"INVALID_INPUT","message":"workflow input is invalid at $.order_id: expected String, found an integer"}}
{"error":{"code":"WORKFLOW_NOT_FOUND","message":"workflow OrderProcessing@9.9.9 is not registered"}}
{"error":{"code":"EXECUTION_EXISTS","message":"execution `order-1001` already exists for a different workflow"}}
{"error":{"code":"PAYLOAD_TOO_LARGE","message":"execution input is 414 bytes; the limit is 200 (--max-payload-bytes or the workflow's `limits`)"}}
{"error":{"code":"RESOURCE_EXHAUSTED","message":"2 executions are already running (--max-active-executions)"}}

GET /v1/executions

List executions, newest first.

Query parameterTypeDefaultMeaning
statusstringallFilter by status, case-insensitive (failed matches FAILED).
workflowstringallFilter by workflow name.
limitinteger50Maximum rows, clamped to 1 through 1000.

For a client identity, rows of workflows it may not read are removed after the limit is applied, so it can receive fewer rows than limit; filter by workflow to get full pages.

Response 200:

{
  "executions": [
    {
      "execution_id": "order-1002",
      "workflow": "OrderProcessing",
      "version": "1.0.0",
      "status": "FAILED",
      "created_at_ms": 1794735367702,
      "updated_at_ms": 1794735369299
    }
  ]
}

GET /v1/executions/{id}

The current state of an execution, derived from its history. Error 404 NOT_FOUND.

Response 200 (execution order-1002, which failed and was compensated; some units shortened):

{
  "id": "order-1002",
  "workflow": "OrderProcessing",
  "version": "1.0.0",
  "definition_hash": "sha256:4fdfac082bf3f2fbbbc8ac5465d37cba01dffd1b8e4ae08e25be3e0d76486c41",
  "input": {"order_id": "1002", "customer_id": "c-42", "amount": 99.5,
            "items": [{"sku": "BOOK-1", "quantity": 1}], "currency": "USD"},
  "status": "FAILED",
  "created_at_ms": 1794735367702,
  "updated_at_ms": 1794735369299,
  "finished_at_ms": 1794735369299,
  "units": {
    "process_payment": {
      "status": "FAILED",
      "attempt": 1,
      "input": {"order_id": "1002", "amount": 99.5, "currency": "USD"},
      "idempotency_key": "charge-1002",
      "error": {"code": "CARD_DECLINED", "message": "card was declined", "retryable": false,
                "details": {"decline_code": "insufficient_funds"}},
      "last_error": {"code": "CARD_DECLINED", "message": "card was declined", "retryable": false,
                     "details": {"decline_code": "insufficient_funds"}},
      "redelivery": false,
      "scheduled_at_ms": 1794735368974,
      "dispatched_at_ms": 1794735369286,
      "deadline_ms": 1794735399286,
      "finished_at_ms": 1794735369290,
      "worker_id": "py-1",
      "worker_session": "30e57815-e28e-4a2b-9c35-df96774668ae",
      "tokens": [["3d932a10-600d-4989-acc7-5234a3183b06", 1]],
      "dispatch_count": 1
    },
    "reserve_inventory": {"status": "SUCCEEDED", "attempt": 1, "output": {"reservation_id": "res-77"}, "...": "..."},
    "compensate:reserve_inventory": {"status": "SUCCEEDED", "attempt": 1, "output": {"released": true}, "...": "..."},
    "send_notification": {"status": "CANCELLED", "attempt": 0, "redelivery": false,
                          "finished_at_ms": 1794735369290, "dispatch_count": 0}
  },
  "abort": {"kind": "failed", "error": {"code": "CARD_DECLINED",
            "message": "step `process_payment` failed: card was declined", "retryable": false,
            "details": {"decline_code": "insufficient_funds"}}},
  "compensation_order": ["reserve_inventory"],
  "compensation": "completed",
  "error": {"code": "CARD_DECLINED", "message": "step `process_payment` failed: card was declined",
            "retryable": false, "details": {"decline_code": "insufficient_funds"}},
  "last_seq": 27
}

Execution fields:

FieldTypeMeaning
idstringExecution id.
runinteger, optionalThe run this state describes, when it is not run 1 (continue-as-new). The state is always the latest run’s.
parentobject, optionalFor a child workflow: {"execution_id", "step"} of the parent unit that started it.
schedule_idstring, optionalThe schedule that started the execution.
workflow, version, definition_hashstringThe definition the execution is pinned to.
inputanyInput after defaults were applied.
statusstringRUNNING, COMPENSATING, COMPLETED, FAILED, CANCELLED or TIMED_OUT. (CONTINUED_AS_NEW only in the state of an archived run, rebuilt from its history.)
created_at_ms, updated_at_msintegerFirst and latest event times of the run.
finished_at_msinteger, optionalSet when terminal.
unitsobjectUnit state by unit key (step, step[i], compensate:<unit>). Only units that have been touched appear.
abortobject, optionalWhy the execution is being wound down: {"kind": "failed", "error"}, {"kind": "cancelled", "reason"} or {"kind": "timed_out"}.
compensation_orderarray, optionalUnits being compensated, in order.
signalsarray, optionalThe signal buffer: every signal received, in arrival order. Absent when none was received. See below.
compensationstring, optionalnone, completed or failed. Set when terminal after an abort.
outputany, optionalWorkflow output, when COMPLETED.
errorobject, optional{"code", "message", "retryable", "details"?}, when FAILED.
last_seqintegerSequence number of the latest event.

Unit fields:

FieldTypeMeaning
statusstringSee Unit statuses. Absent means PENDING.
attemptintegerCurrent attempt number, starting at 1. 0 for a unit that never started.
inputany, optionalEvaluated input. For a for step: the list of items.
idempotency_keystring, optionalKey sent to workers.
outputany, optionalResult of a succeeded unit. null for a skipped one.
errorobject, optionalFinal error of a failed unit.
last_errorobject, optionalError of the latest failed attempt.
redeliverybooleanThe current attempt is a redelivery of an uncertain one.
scheduled_at_ms, dispatched_at_ms, finished_at_msinteger, optionalTransition times.
deadline_msinteger, optionalWhen the in-flight attempt times out.
wake_at_msinteger, optionalWhen a sleep timer fires, a retry becomes due, or an await step times out.
worker_id, worker_sessionstring, optionalWho holds or held the latest dispatch. Child workflows show orbit-runtime and child:<child id>; built-in tasks orbit-runtime and builtin:<instance>.
tokenstring, optionalToken of the in-flight dispatch.
tokensarray, optionalEvery [token, attempt] issued for the unit.
succeeded_seqinteger, optionalSequence number of the success, used to order compensation.
dispatch_countintegerNumber of dispatches, including redeliveries.

Signal fields (signals[]):

FieldTypeMeaning
seqintegerSequence number of the SignalReceived event. Identifies the signal.
namestringSignal name.
payloadobjectValidated payload, with defaults applied.
received_at_msintegerWhen the signal was recorded.
delivered_tostring, optionalThe await unit that took the signal. Absent while it is buffered.
"signals": [
  {"seq": 5, "name": "review", "payload": {"approved": true, "reviewer": "dana", "comment": "verified by phone"},
   "received_at_ms": 1794746714669, "delivered_to": "decision"}
]

GET /v1/executions/{id}/history

The full event history of the latest run, in commit order. Error 404 NOT_FOUND. Executions and history describes every event type.

Query parameterTypeDefaultMeaning
runintegerthe latest runThe history of this run of the execution (continue-as-new). Error 404 RUN_NOT_FOUND for a run that does not exist.

The response has run when the history is not run 1’s or when run was given.

Response 200 (first and last events of order-1002):

{
  "execution_id": "order-1002",
  "events": [
    {"seq": 1, "timestamp_ms": 1794735367702, "type": "WorkflowCreated", "workflow": "OrderProcessing",
     "version": "1.0.0", "definition_hash": "sha256:4fdf...6c41",
     "input": {"order_id": "1002", "customer_id": "c-42", "amount": 99.5,
               "items": [{"sku": "BOOK-1", "quantity": 1}], "currency": "USD"}},
    {"seq": 2, "timestamp_ms": 1794735367702, "type": "WorkflowStarted"},
    {"seq": 3, "timestamp_ms": 1794735367703, "type": "StepScheduled", "step": "validate_order", "attempt": 1,
     "input": {"order_id": "1002", "amount": 99.5, "items": [{"sku": "BOOK-1", "quantity": 1}]},
     "idempotency_key": "order-1002/validate_order", "redelivery": false},
    "...",
    {"seq": 26, "timestamp_ms": 1794735369299, "type": "CompensationCompleted"},
    {"seq": 27, "timestamp_ms": 1794735369299, "type": "WorkflowFailed",
     "error": {"code": "CARD_DECLINED", "message": "step `process_payment` failed: card was declined",
               "retryable": false, "details": {"decline_code": "insufficient_funds"}},
     "compensation": "completed"}
  ]
}

The history of an earlier run ends with WorkflowContinuedAsNew. Response 200 for GET /v1/executions/count-1/history?run=1 (the Countdown workflow of Continue-as-new):

{
    "execution_id": "count-1",
    "events": [
        {
            "seq": 1,
            "timestamp_ms": 1794768604803,
            "type": "WorkflowCreated",
            "workflow": "Countdown",
            "version": "1.0.0",
            "definition_hash": "sha256:fb3ef66116e32f38464f39b5a1ba617a8a94583b9c1a9a7ce77035d07849d709",
            "input": {
                "n": 3
            }
        },
        {
            "seq": 2,
            "timestamp_ms": 1794768604803,
            "type": "WorkflowStarted"
        },
        {
            "seq": 3,
            "timestamp_ms": 1794768604804,
            "type": "StepSucceeded",
            "step": "tick",
            "attempt": 1,
            "output": {
                "left": 2
            }
        },
        {
            "seq": 4,
            "timestamp_ms": 1794768604804,
            "type": "WorkflowContinuedAsNew",
            "next_run": 2,
            "input": {
                "n": 2
            }
        }
    ],
    "run": 1
}
$ curl -s "localhost:7233/v1/executions/count-1/history?run=7"
{"error":{"code":"RUN_NOT_FOUND","message":"execution `count-1` has runs 1 to 3; there is no run 7"}}

The history endpoint returns values unredacted. orbit logs --json redacts them.

GET /v1/executions/{id}/wait

Long-poll until the execution is terminal, or until the timeout passes, then return the same body as GET /v1/executions/{id}. Check status to tell the two apart.

Query parameterTypeDefaultMeaning
timeout_msinteger30000Maximum wait, capped at 300000.

Error 404 NOT_FOUND.

POST /v1/executions/{id}/cancel

Request cancellation. The body is optional.

FieldTypeDefaultMeaning
reasonstringrequested via APIRecorded in CancelRequested.

Response 200: the execution state right after the request. Requesting cancellation of an execution that is already being aborted (by an earlier cancel, a failure or a timeout) records nothing and returns the current state.

{"id":"order-2001","workflow":"OrderProcessing","version":"1.0.0","...":"...","status":"CANCELLED","compensation":"none","...":"..."}

Errors: 404 NOT_FOUND; 409 EXECUTION_FINISHED:

{"error":{"code":"EXECUTION_FINISHED","message":"execution is already CANCELLED"}}

POST /v1/executions/{id}/signals/{name}

Send the signal name to an execution. The body is the payload, a JSON object; a request without a body sends {}. The runtime applies the declared defaults, validates the payload against the workflow’s signal declaration, records SignalReceived and buffers the signal until an await step takes it. See Signals and queries.

curl -X POST http://127.0.0.1:7233/v1/executions/order-A-100/signals/review \
  -H 'content-type: application/json' \
  -d '{"approved": true, "reviewer": "dana"}'

Response 200:

{"execution_id": "order-A-100", "signal": "review", "accepted": true, "seq": 5}
FieldTypeMeaning
execution_idstringThe execution.
signalstringThe signal name.
acceptedbooleanAlways true in a 200 response.
seqintegerSequence number of the recorded SignalReceived event. In shared mode, a signal sent to a server that does not own the execution is passed to the owner, and the response waits for it; seq is null when the owner has not recorded the signal within 10 seconds (it is still recorded, once, when the owner gets to it).

Errors: 404 NOT_FOUND; 409 EXECUTION_FINISHED (checked first); 404 UNKNOWN_SIGNAL; 413 PAYLOAD_TOO_LARGE; 422 SIGNAL_PAYLOAD_INVALID:

{"error":{"code":"UNKNOWN_SIGNAL","message":"OrderApproval@1.0.0 has no signal `reveiw`; declared: review"}}
{"error":{"code":"PAYLOAD_TOO_LARGE","message":"signal payload is 277 bytes; the limit is 200 (--max-payload-bytes or the workflow's `limits`)"}}
{"error":{"code":"SIGNAL_PAYLOAD_INVALID","message":"payload for signal `review` is invalid at $.approved: expected Bool, found a string"}}

A body sent without content-type: application/json, or a body that is not valid JSON, is rejected by the HTTP layer with a plain-text 415 or 400 (see Errors).

GET /v1/executions/{id}/queries/{name}

Evaluate the query name against the execution’s current state. Steps that have not succeeded read as null. Nothing is recorded. Works on running and terminal executions.

Response 200:

{"execution_id":"order-A-100","query":"status","value":{"needs_approval":true,"approved":true,"reviewer":"dana","charged":true}}
FieldTypeMeaning
execution_idstringThe execution.
querystringThe query name.
valueanyThe query result, of the query’s declared type.

Errors: 404 NOT_FOUND; 404 UNKNOWN_QUERY; 422 QUERY_FAILED (evaluating query `<name>`: <cause>):

{"error":{"code":"UNKNOWN_QUERY","message":"OrderApproval@1.0.0 has no query `stat`; declared: status"}}

Schedules

A schedule starts executions of a workflow on a cron expression or a fixed interval. Schedules explains timing, the overlap and catch-up policies and how several servers share the work. Schedules are durable (stored in the database) and every change is effective at once on every server. On a cluster standby, GET routes work and changes answer 503 NOT_LEADER.

POST /v1/schedules

Create a schedule. Request fields:

FieldTypeRequiredMeaning
schedule_idstringyesLetters, digits, -, _ and ., at most 100 characters.
workflowstringyesThe workflow to start. It must be registered.
versionstringnoPin a version. Default: the latest registered version at each fire.
inputobjectnoExecution input (default {}), validated against the workflow’s input type when the schedule is created or changed, and again at each start.
cronstringone of cron or intervalFive fields, minute hour day-of-month month day-of-week, or @hourly, @daily (@midnight), @weekly, @monthly, @yearly (@annually). See Cron expressions.
intervalstringone of cron or intervalA duration such as 30s, 15m, 1h30m or 1d (units ms, s, m, h, d; at least 1s). Occurrences are aligned to start_at, or to the Unix epoch.
timezonestringnoIANA time zone for cron, such as Europe/Berlin. Default UTC. Not allowed with interval.
overlapstringnoskip (default), allow or cancel_previous.
catchupstringnoskip_missed (default) or run_once.
start_at, end_atstringnoRFC 3339 times bounding the occurrences.
jitterstringnoA duration up to 24h: each fire is delayed by a deterministic amount below it.
descriptionstringnoFree text.
pausedbooleannoCreate the schedule paused. Default false.

Unknown fields are rejected. Response 201 with the schedule (the same document as GET /v1/schedules/{id}):

$ curl -s -X POST localhost:7233/v1/schedules -H 'content-type: application/json' -d '{"schedule_id": "orders-every-10s", "workflow": "OrderProcessing", "interval": "10s", "input": {"order_id": "R-1", "customer_id": "cust-1", "amount": 120.0, "items": [{"sku": "BOOK-1", "quantity": 1}]}}'
{"schedule_id":"orders-every-10s","workflow":"OrderProcessing","input":{"order_id":"R-1","customer_id":"cust-1","amount":120.0,"items":[{"sku":"BOOK-1","quantity":1}]},"interval":"10s","overlap":"skip","catchup":"skip_missed","paused":false,"next_fire_at":"2026-11-15T18:51:10Z","next_fire_times":["2026-11-15T18:51:10Z","2026-11-15T18:51:20Z","2026-11-15T18:51:30Z","2026-11-15T18:51:40Z","2026-11-15T18:51:50Z"],"last_fire_at":null,"last_execution_id":null,"fired":0,"skipped":0,"last_error":null,"created_at":"2026-11-15T18:51:00.347Z","updated_at":"2026-11-15T18:51:00.347Z"}

Errors: 400 INVALID_SCHEDULE, 400 INVALID_INPUT, 404 WORKFLOW_NOT_FOUND, 409 SCHEDULE_EXISTS.

{"error":{"code":"INVALID_SCHEDULE","message":"cron `0 25 * * *`: `25` is out of range for the hour field (0-23)"}}
{"error":{"code":"INVALID_INPUT","message":"the schedule input is invalid for OrderProcessing@1.0.0 at $.order_id: expected String, found an integer"}}
{"error":{"code":"SCHEDULE_EXISTS","message":"schedule `nightly-orders` already exists"}}

GET /v1/schedules/{id}

Response 200, thirty seconds after the schedule above was created:

{
    "schedule_id": "orders-every-10s",
    "workflow": "OrderProcessing",
    "input": {
        "order_id": "R-1",
        "customer_id": "cust-1",
        "amount": 120.0,
        "items": [
            {
                "sku": "BOOK-1",
                "quantity": 1
            }
        ]
    },
    "interval": "10s",
    "overlap": "skip",
    "catchup": "skip_missed",
    "paused": false,
    "next_fire_at": "2026-11-15T18:51:40Z",
    "next_fire_times": [
        "2026-11-15T18:51:40Z",
        "2026-11-15T18:51:50Z",
        "2026-11-15T18:52:00Z",
        "2026-11-15T18:52:10Z",
        "2026-11-15T18:52:20Z"
    ],
    "last_fire_at": "2026-11-15T18:51:30Z",
    "last_execution_id": "orders-every-10s:2026-11-15T18:51:30Z",
    "fired": 3,
    "skipped": 0,
    "last_error": null,
    "created_at": "2026-11-15T18:51:00.347Z",
    "updated_at": "2026-11-15T18:51:30.409Z"
}

The specification fields are those of POST /v1/schedules (optional fields that are not set are absent). The state fields:

FieldTypeMeaning
pausedbooleanThe schedule does not fire.
next_fire_atstring or nullWhen the next occurrence fires: the occurrence plus its jitter. null while paused and after end_at.
next_fire_timesarray of stringsThe next five occurrences (without jitter). Empty while paused and after end_at.
last_fire_atstring or nullThe latest occurrence handled (started or skipped). Triggers do not change it.
last_execution_idstring or nullThe latest execution the schedule started, including triggers. The overlap policy looks at this execution.
firedintegerExecutions started, including triggers.
skippedintegerOccurrences that started nothing: skipped by the overlap policy or missed while no server could fire them.
last_errorstring or nullWhy the latest start failed (for example RESOURCE_EXHAUSTED); the occurrence is retried every 5 seconds. Cleared by the next successful fire and by every change.
created_at, updated_atstringRFC 3339, UTC.

Error 404 SCHEDULE_NOT_FOUND:

{"error":{"code":"SCHEDULE_NOT_FOUND","message":"schedule `orders-every-10s` not found"}}

GET /v1/schedules

Every schedule, by id: {"schedules": [ ... ]}, each in the form of GET /v1/schedules/{id}. A client identity sees only the schedules of workflows it may schedule.

{
    "schedules": [
        {
            "schedule_id": "nightly-orders",
            "workflow": "OrderProcessing",
            "input": {
                "order_id": "N-1",
                "customer_id": "cust-1",
                "amount": 120.0,
                "items": [
                    {
                        "sku": "BOOK-1",
                        "quantity": 1
                    }
                ]
            },
            "cron": "0 2 * * *",
            "timezone": "Europe/Berlin",
            "overlap": "skip",
            "catchup": "skip_missed",
            "description": "Nightly reconciliation order",
            "paused": false,
            "next_fire_at": "2026-11-16T01:00:00Z",
            "next_fire_times": [
                "2026-11-16T01:00:00Z",
                "2026-11-17T01:00:00Z",
                "2026-11-18T01:00:00Z",
                "2026-11-19T01:00:00Z",
                "2026-11-20T01:00:00Z"
            ],
            "last_fire_at": null,
            "last_execution_id": "nightly-orders:manual:2026-11-15T18:52:22.296Z",
            "fired": 1,
            "skipped": 0,
            "last_error": null,
            "created_at": "2026-11-15T18:50:54.213Z",
            "updated_at": "2026-11-15T18:52:22.296Z"
        }
    ]
}

PATCH /v1/schedules/{id}

Change a schedule. The body is a JSON object with the fields to change, in the form of POST /v1/schedules; null removes an optional field, and paused pauses or resumes. The result is validated like a new schedule, the next occurrence is recomputed from now, and fired, skipped, last_fire_at and last_execution_id are kept. schedule_id cannot change. To switch from an interval to cron, remove interval and set cron in one request. Response 200 with the schedule (shortened):

$ curl -s -X PATCH localhost:7233/v1/schedules/orders-every-10s -H 'content-type: application/json' -d '{"interval": null, "cron": "*/15 * * * *", "overlap": "cancel_previous"}'
{
    "schedule_id": "orders-every-10s",
    "cron": "*/15 * * * *",
    "overlap": "cancel_previous",
    "next_fire_at": "2026-11-15T19:00:00Z",
    "next_fire_times": [
        "2026-11-15T19:00:00Z",
        "2026-11-15T19:15:00Z",
        "2026-11-15T19:30:00Z",
        "2026-11-15T19:45:00Z",
        "2026-11-15T20:00:00Z"
    ],
    "fired": 5,
    "skipped": 0
}

Errors as for POST /v1/schedules, and 404 SCHEDULE_NOT_FOUND.

DELETE /v1/schedules/{id}

Delete a schedule. Executions it started are not affected. Response 200:

{"schedule_id":"orders-every-10s","deleted":true}

Error 404 SCHEDULE_NOT_FOUND.

POST /v1/schedules/{id}/pause and /resume

The same as PATCH with {"paused": true} or {"paused": false}; no body is needed. Resuming computes the next occurrence from now: occurrences that passed while the schedule was paused are not started. Response 200 with the schedule (shortened):

{
    "schedule_id": "orders-every-10s",
    "paused": true,
    "next_fire_at": null,
    "next_fire_times": []
}
{
    "schedule_id": "orders-every-10s",
    "paused": false,
    "next_fire_at": "2026-11-15T18:51:40Z",
    "fired": 4
}

POST /v1/schedules/{id}/trigger

Start an execution of the schedule now, with its workflow, version and input, whether it is paused or not. The execution id is <schedule_id>:manual:<now, RFC 3339 with milliseconds>. The overlap policy applies: with skip, nothing starts while the schedule’s previous execution runs. The schedule’s timing does not change. Response 200:

{
    "schedule_id": "orders-every-10s",
    "started": true,
    "execution_id": "orders-every-10s:manual:2026-11-15T18:51:38.514Z"
}

When the overlap policy skips it: {"schedule_id": "...", "started": false, "skipped": "overlap"}.

Workers

GET /v1/workers

Every worker id that has ever registered, with its latest registration.

Response 200:

{
  "workers": [
    {
      "worker_id": "ts-1",
      "runtime": "typescript",
      "sdk": "curl",
      "contracts": [
        {"task_name": "orders.validate", "contract_version": "1.0.0"},
        {"task_name": "inventory.reserve", "contract_version": "1.0.0"}
      ],
      "last_seen_ms": 1794735369613,
      "registered_at_ms": 1794735367705,
      "online": false,
      "sessions": 0,
      "inflight": 0
    }
  ]
}
FieldTypeMeaning
worker_id, runtime, sdkstringAs announced at registration; runtime is lowercased, sdk may be null.
contractsarrayAnnounced {task_name, contract_version} pairs.
last_seen_ms, registered_at_msintegerLatest contact and latest registration.
onlinebooleanAt least one session contacted the runtime within the session timeout.
sessionsintegerLive sessions.
inflightintegerTasks held by live sessions.

Worker protocol

These endpoints are the HTTP/JSON binding of orbit.worker.v1.WorkerService. The Worker protocol reference describes the lifecycle and the rules an implementation must follow; this section gives the exact bodies.

POST /v1/workers/register

Open a worker session.

FieldTypeRequiredMeaning
worker_idstringyesStable worker identity. Must not be empty.
runtimestringyesWorker language, matched case-insensitively against a step’s using <language>.
sdkstringnoFree-form SDK name and version.
contractsarrayyesAt least one {task_name, contract_version}.
{"worker_id": "py-1", "runtime": "python",
 "contracts": [{"task_name": "risk.analyze", "contract_version": "1.0.0"},
               {"task_name": "payments.charge", "contract_version": "1.0.0"}]}

Response 200:

{"accepted": true, "worker_session_id": "01081a88-bd5b-415f-9032-07d2bec51746",
 "heartbeat_interval_ms": 5000, "session_timeout_ms": 30000, "warnings": []}

heartbeat_interval_ms is one sixth of the session timeout, and at least 500. warnings lists contracts announced at a version that no registered workflow uses, which this worker will therefore never receive:

{"accepted": true, "worker_session_id": "04586a02-...", "heartbeat_interval_ms": 5000, "session_timeout_ms": 30000,
 "warnings": ["`risk.analyze` is announced at version 0.9.0 but registered workflows use 1.0.0; this worker will not receive those tasks"]}

Errors: 400 INVALID_REQUEST, 400 RESERVED_TASK, 403 FORBIDDEN (per-worker identities):

{"error":{"code":"FORBIDDEN","message":"this credential belongs to worker `payments-1`, not `other`"}}
{"error":{"code":"FORBIDDEN","message":"worker `payments-1` must announce runtime `python`"}}
{"error":{"code":"FORBIDDEN","message":"worker `payments-1` may not serve: inventory.reserve (allowed: payments.*, audit.log)"}}
{"error":{"code":"RESERVED_TASK","message":"`orbit.http.request` is a built-in task"}}

POST /v1/workers/heartbeat

Keep a session alive and learn which tasks to stop.

FieldTypeRequiredMeaning
worker_session_idstringyesSession from register.

Response 200:

{"accepted": true, "cancel": ["5503f137-059e-455a-92c1-e65fa3690962"]}

cancel lists tokens this session holds that no longer matter: the step was resolved by another attempt, the attempt timed out, or the workflow is aborting. Stop that work if you can; results for those tokens are ignored.

Errors: 404 UNKNOWN_SESSION; 403 FORBIDDEN when a per-worker identity uses another worker’s session.

POST /v1/workers/poll

Long-poll for a task. The runtime hands out the oldest queued task that matches one of the session’s contracts (same task name and contract version, and the same runtime when the step says using <language>).

FieldTypeRequiredMeaning
worker_session_idstringyesSession from register.
wait_msintegernoMaximum wait. Default 20000, capped at 60000.

Response 200: a task.

{
  "token": "3d932a10-600d-4989-acc7-5234a3183b06",
  "execution_id": "order-1002",
  "workflow": "OrderProcessing",
  "workflow_version": "1.0.0",
  "step_id": "process_payment",
  "task_name": "payments.charge",
  "contract_version": "1.0.0",
  "input": {"order_id": "1002", "amount": 99.5, "currency": "USD"},
  "idempotency_key": "charge-1002",
  "attempt": 1,
  "redelivery": false,
  "deadline_ms": 1794735399286,
  "timeout_ms": 30000
}
FieldTypeMeaning
tokenstringIdentifies this dispatch. Send it with the result.
execution_id, workflow, workflow_versionstringThe execution.
step_idstringUnit key: step, step[i], compensate:<unit> or on_<kind>:<step> (a step of an on failure, on cancel or on timeout handler).
task_name, contract_versionstringThe contract to run.
inputanyEvaluated, validated input.
idempotency_keystringStable across retries and redeliveries of this unit.
attemptintegerAttempt number, starting at 1.
redeliverybooleantrue when the previous delivery of this same attempt had an unknown outcome.
deadline_ms, timeout_msinteger, optionalPresent when the step has a timeout.

Response 204 with an empty body: no task arrived within wait_ms.

Errors: 404 UNKNOWN_SESSION; 403 FORBIDDEN.

POST /v1/workers/unregister

Close a session. Tasks it still holds become uncertain and are redelivered at once.

Request: {"worker_session_id": "..."}. Response 200: {"accepted": true}. Errors: 404 UNKNOWN_SESSION; 403 FORBIDDEN.

POST /v1/tasks/complete

Report a successful result.

FieldTypeRequiredMeaning
tokenstringyesToken from the task.
outputanynoResult. null or absent means {}. Defaults are applied and it is validated against the task’s output contract.
{"token": "41415faa-040f-4e35-9b92-b973d701ec2b", "output": {"valid": true}}

Response 200:

BodyMeaning
{"accepted": true}The step succeeded with this output.
{"accepted": false, "reason": "output violates the task contract"}The attempt failed with OUTPUT_CONTRACT_VIOLATION (not retried).
{"accepted": false, "reason": "duplicate result; step already resolved"}The step was already resolved. Recorded as StaleResultIgnored.
{"accepted": false, "reason": "unknown token (execution finished or token invalid)"}The token is unknown, or its execution has finished.

A success is accepted for any token issued for a step that is not yet resolved, including the token of an earlier attempt that timed out or was considered lost. See Delivery guarantees.

In shared mode, a result for an execution that another server owns is stored in that server’s inbox and answered with {"accepted": true} once stored; the owner then records it as above (a contract violation as OUTPUT_CONTRACT_VIOLATION, a duplicate as StaleResultIgnored). A token whose execution has finished still gets unknown token. See Shared mode.

Errors: 400 INVALID_REQUEST (token is required); 403 FORBIDDEN when a per-worker identity reports for a token issued to another worker; 413 PAYLOAD_TOO_LARGE when the output is larger than --max-payload-bytes. A refused output is not recorded and the attempt stays in flight, so the worker can still report a smaller result or a failure:

{"error":{"code":"PAYLOAD_TOO_LARGE","message":"task result is 312 bytes; the limit is 200 (--max-payload-bytes or the workflow's `limits`)"}}

POST /v1/tasks/fail

Report a failed attempt.

FieldTypeRequiredDefaultMeaning
tokenstringyesToken from the task.
error_codestringyesApplication error code, matched against the step’s non_retryable list.
error_messagestringno""Human-readable message.
retryablebooleannotruefalse fails the step without further attempts.
detailsanynononeStructured details, stored with the error.
{"token": "3d932a10-600d-4989-acc7-5234a3183b06", "error_code": "CARD_DECLINED",
 "error_message": "card was declined", "retryable": false,
 "details": {"decline_code": "insufficient_funds"}}

Response 200: {"accepted": true}, or {"accepted": false, "reason": ...} with stale attempt (the token is not the attempt currently in flight), duplicate result; step already resolved, or unknown token (execution finished or token invalid).

Errors: 400 INVALID_REQUEST (error_code is required); 403 FORBIDDEN.

gRPC binding

With --grpc-addr, the worker protocol is also served over gRPC as orbit.worker.v1.WorkerService (RegisterWorker, Heartbeat, PollTask, CompleteTask, FailTask, UnregisterWorker), defined in protocol/orbit/worker/v1/worker.proto. Credentials go in the authorization: Bearer <token> metadata entry. Errors map to gRPC status codes as follows, and carry the ORBIT code in the orbit-error-code metadata entry and as a CODE: prefix of the message:

HTTP statusgRPC status
400INVALID_ARGUMENT
401UNAUTHENTICATED
403PERMISSION_DENIED
404NOT_FOUND
409FAILED_PRECONDITION
413 (PAYLOAD_TOO_LARGE, CompleteTask) and 429RESOURCE_EXHAUSTED
500INTERNAL
503 (NOT_LEADER, cluster standby)UNAVAILABLE

PollTask returns an ExecuteTaskRequest with an empty token where HTTP returns 204. Client identities cannot use the gRPC binding: every call gets PERMISSION_DENIED with the message FORBIDDEN: client `app` may not use the worker protocol. A result larger than --max-payload-bytes gets RESOURCE_EXHAUSTED with orbit-error-code: PAYLOAD_TOO_LARGE.

See also

Worker protocol

This page specifies version 1 of the ORBIT worker protocol: the messages a worker exchanges with the runtime to register, receive tasks, report results, and stay alive. Use it to write a worker in a language without an official SDK, or to debug one. The protocol has two bindings with the same semantics: HTTP/JSON, which every runtime serves on its API port and which the official SDKs use, and gRPC, which the runtime serves when started with --grpc-addr. The canonical message definitions are in protocol/orbit/worker/v1/worker.proto. For the concepts behind the protocol, read How workers work.

Overview

worker                                         runtime
  │ POST /v1/workers/register  ───────────────▶ │
  │ ◀─────────────── worker_session_id, heartbeat_interval_ms, session_timeout_ms
  │
  │ POST /v1/workers/poll  ───────────────────▶ │  long poll, up to wait_ms
  │ ◀──────────────── 200 task assignment   or   204 No Content
  │ POST /v1/tasks/complete  or  /v1/tasks/fail ▶ │
  │ ◀──────────────────────────────── accepted, reason
  │
  │ POST /v1/workers/heartbeat  (every heartbeat_interval_ms)
  │ ◀──────────────────────────────── accepted, cancel[]
  │ POST /v1/workers/unregister  (on shutdown)

A worker is a client only. It needs no inbound port, which lets it run behind NAT and gives natural backpressure: a worker asks for a task only when it has capacity.

HTTP conventions

AspectRule
MethodEvery worker endpoint is POST.
Request bodyJSON, with content-type: application/json. A request without that header is answered 415 Unsupported Media Type.
Field namessnake_case, following the proto3 JSON mapping of worker.proto. Unknown fields are ignored.
Response headerEvery response that passes authentication carries x-orbit-api-version: orbit-api/1.
Body sizeRequests larger than the runtime’s --max-body (4 MiB by default) are answered 413 Payload Too Large.
TLSWhen the runtime runs with --tls-cert and --tls-key, use https://.

Errors

Errors produced by the runtime have this body:

{"error": {"code": "UNKNOWN_SESSION", "message": "unknown or expired worker session; register again"}}

Branch on code, not on message. A body that is not valid JSON is answered 400 and a body that lacks a required field 422; both have a plain-text body that describes the problem, not the JSON error object.

StatusCodesMeaning
400INVALID_REQUEST, RESERVED_TASKThe request is well-formed JSON but not valid.
401UNAUTHENTICATEDCredentials are required and none were valid.
403FORBIDDENThe credential may not perform this call.
404UNKNOWN_SESSIONThe session does not exist, for example after a runtime restart. Register again.
5xxvariousA runtime or storage failure. Retry with backoff.

Authentication

When the runtime runs without --token and without a workers file (local development on a loopback address), no credentials are needed. Otherwise, send a bearer token on every request:

authorization: Bearer <token>
CredentialConfigured withMay call
Admin token--token / ORBIT_TOKENEvery endpoint, including the worker endpoints.
Shared worker token--worker-token / ORBIT_WORKER_TOKEN/v1/workers/* and /v1/tasks/* only.
Per-worker tokentoken_sha256 in --workers-file/v1/workers/* and /v1/tasks/*, limited to its own identity.
Client certificateclient_cert in --workers-file, with --tls-client-caSame as a per-worker token.

A worker credential that calls any other endpoint gets 403 FORBIDDEN (“worker credentials may only call /v1/workers/* and /v1/tasks/*”). A per-worker identity is further limited, and each violation is a 403 FORBIDDEN:

RuleExample message
Register only under its own id.this credential belongs to worker `ruby-1`, not `ruby-2`
Announce its configured runtime, when one is set.worker `ruby-1` must announce runtime `ruby`
Announce only tasks matching its tasks patterns.worker `ruby-1` may not serve: payments.charge (allowed: greeting.*)
Use only sessions it opened.session belongs to worker `other`, not `ruby-1`
Report results only for tokens issued to it.task token was issued to worker `other`, not `ruby-1`

Under mutual TLS, present a client certificate signed by the CA given to --tls-client-ca. The runtime matches its DNS SANs and subject common name against client_cert entries. A valid certificate that matches no entry is refused with 403 FORBIDDEN. When a request carries both a valid token and a certificate, the token decides. See Security.

Endpoints

Register a worker

POST /v1/workers/register

Opens a session. Call it at startup and whenever another call returns 404 UNKNOWN_SESSION. Every call opens a new session; a process normally holds one. Several processes can register the same worker_id.

Request fieldTypeRequiredMeaning
worker_idstringyesStable identity, for example payments-7f9c. Must not be blank.
runtimestringyesYour language, matched case-insensitively against using <language> in steps.
contractsarrayyes, non-emptyThe tasks you serve: objects with task_name and contract_version.
sdkstringnoInformational, for example orbit-ruby/0.1.0. Shown by GET /v1/workers.
Response fieldTypeMeaning
acceptedboolAlways true on success.
worker_session_idstringSend it with every session call.
heartbeat_interval_msintegerHow often to heartbeat: one sixth of the session timeout, at least 500.
session_timeout_msintegerSilence longer than this makes the runtime consider the worker lost. Set by orbit server --session-timeout (default 30 seconds).
warningsarray of stringsProblems that do not prevent registration, such as a contract version no registered workflow uses. Log them.
StatusCodeWhen
200Registered.
400INVALID_REQUESTworker_id is blank, or contracts is empty.
400RESERVED_TASKA task name starts with orbit. (a built-in task) or names a child workflow.
403FORBIDDENA per-worker identity broke one of its rules.
422worker_id, runtime or contracts is missing.

Poll for a task

POST /v1/workers/poll

Waits for a task and returns at most one. Run one poll loop per task you want to execute in parallel.

Request fieldTypeRequiredMeaning
worker_session_idstringyesFrom registration.
wait_msintegernoHow long to wait for a task. Default 20000, maximum 60000 (larger values are capped).

The runtime answers as soon as a queued task matches the session, or with 204 No Content and an empty body when wait_ms passes first. Set your HTTP client’s read timeout above wait_ms; the SDKs use wait_ms plus 15 seconds.

A task matches when the session announced its exact task_name and contract_version, and the step either has no using, says using any, or names the session’s runtime. Among matching tasks, the oldest scheduled one is dispatched. Each dispatch issues a new token.

StatusCodeWhen
200A task assignment, described below.
204No task arrived within wait_ms. Poll again.
403FORBIDDENThe session belongs to another per-worker identity.
404UNKNOWN_SESSIONRegister again, then poll with the new session.

The task assignment

FieldTypeMeaning
tokenstringIdentifies this delivery. Echo it in the result.
execution_idstringThe workflow execution.
workflowstringWorkflow name.
workflow_versionstringWorkflow version.
step_idstringThe unit being executed: <step>, <step>[<i>] for a fan-out element, compensate:<unit> for a compensation, or on_<kind>:<step> for a step of an on failure, on cancel or on timeout handler.
task_namestringThe task to run.
contract_versionstringThe task’s contract version.
inputany JSON valueThe task input, already validated against the contract’s input schema.
idempotency_keystringStable across retries and redeliveries of the step. Defaults to <execution_id>/<step>.
attemptinteger1-based attempt number. A redelivery keeps the number.
redeliverybooltrue when the outcome of an earlier delivery of this attempt is unknown.
deadline_msinteger, optionalAbsolute deadline of this attempt in Unix epoch milliseconds. Present only when the step has a timeout.
timeout_msinteger, optionalThe step’s timeout in milliseconds. Present only when set.

Send a heartbeat

POST /v1/workers/heartbeat

Keeps the session alive and delivers cancellations. Send it every heartbeat_interval_ms, from a thread or task that is independent of your handlers.

Request fieldTypeRequiredMeaning
worker_session_idstringyesFrom registration.
Response fieldTypeMeaning
acceptedboolAlways true on success.
cancelarray of stringsTokens of attempts this worker holds that it should stop. Each token is listed once.
StatusCodeWhen
200Heartbeat recorded.
403FORBIDDENThe session belongs to another per-worker identity.
404UNKNOWN_SESSIONRegister again.

Complete a task

POST /v1/tasks/complete

Reports a successful attempt.

Request fieldTypeRequiredMeaning
tokenstringyes, non-emptyThe token from the task assignment.
outputJSON objectnoThe task output. A missing or null output counts as {}.

The runtime applies schema defaults to the output and validates it against the contract’s output schema. A mismatch fails the attempt with OUTPUT_CONTRACT_VIOLATION, which is never retried, and the response has accepted: false.

Response fieldTypeMeaning
acceptedboolWhether the result changed the step.
reasonstring, optionalWhy the result was not accepted. Present only when accepted is false.
StatusCodeWhen
200The result was processed; check accepted.
400INVALID_REQUESTtoken is empty.
403FORBIDDENThe token was issued to another per-worker identity.
422token is missing.

Fail a task

POST /v1/tasks/fail

Reports a failed attempt.

Request fieldTypeRequiredDefaultMeaning
tokenstringyesThe token from the task assignment.
error_codestringyes, non-emptyMachine-readable code, for example CARD_DECLINED.
error_messagestringno""Human-readable message.
retryableboolnotruefalse fails the step regardless of its retry policy.
detailsany JSON valuenononeStored with the error in the execution history.

The response has the same shape as for complete. The step’s retry policy then decides what happens: the runtime retries when the error is retryable, its code is not in the step’s non_retryable list (exact, case-sensitive match), and attempts remain. See Retries and timeouts.

StatusCodeWhen
200The result was processed; check accepted.
400INVALID_REQUESTerror_code is empty.
403FORBIDDENThe token was issued to another per-worker identity.
422token or error_code is missing.

Unregister

POST /v1/workers/unregister

Closes the session. Attempts the session still holds are marked uncertain and redelivered to other workers right away, instead of after the session timeout. Finish or abandon your in-flight tasks before you unregister.

Request fieldTypeRequiredMeaning
worker_session_idstringyesFrom registration.

The response is {"accepted": true}. An unknown session is answered 404 UNKNOWN_SESSION with the message “unknown worker session”.

List workers

GET /v1/workers returns every worker that has registered, with its runtime, SDK, contracts, last contact, whether it is online, and its number of live sessions and in-flight tasks. It is an administrative endpoint: worker credentials cannot call it. orbit workers list prints the same data.

Result acceptance

The response to complete and fail is final. Do not retry a result that the runtime answered, whether accepted is true or false.

acceptedreasonMeaning
trueThe result was recorded.
falseoutput violates the task contractThe output failed validation. The attempt failed with OUTPUT_CONTRACT_VIOLATION.
falsestale attemptA failure for an attempt that is no longer the one in flight. It was recorded and ignored.
falseduplicate result; step already resolvedThe step already has an outcome. The result was recorded as StaleResultIgnored.
falseunknown token (execution finished or token invalid)The execution is finished, or the token was never issued.

The runtime reconciles results. A success for any token issued for a step that is not yet resolved is accepted, because the work happened: a success that arrives after its attempt timed out, or after the attempt was redelivered to another worker, still completes the step. Failures count only for the attempt currently in flight.

Sessions, leases and liveness

There is no explicit lease to renew per task. A dispatched attempt belongs to the session that polled it, and the session stays alive as long as the runtime hears from it: every poll, heartbeat and result counts as contact.

  • If a session is silent for longer than session_timeout_ms, the runtime marks its in-flight attempts uncertain and redelivers each to another worker, with the same attempt number, the same idempotency_key, and redelivery: true. A redelivery does not use up a retry.
  • If the runtime restarts, every session is forgotten. In-flight attempts keep their tokens. The runtime waits one session timeout for workers to re-register and report results, then redelivers the attempts nobody reported.
  • A step’s timeout limits one attempt from dispatch to result. When deadline_ms passes, the runtime fails the attempt with TIMEOUT (retryable) and applies the retry policy, independently of the session.

Heartbeat from a separate thread so that a long-running handler does not let the session expire.

Cancellation

Cancellation is cooperative. When an attempt times out, or its workflow is cancelled, times out or fails, the runtime lists the attempt’s token in the cancel array of the next heartbeat response to the session that holds it. Stop the work if you can. The runtime may still record a result you send for that token. For example, while a cancelled workflow waits for its in-flight attempts, a late success is accepted and then compensated if the step has a compensate block. Do not rely on either outcome; make the handler idempotent.

Rules for implementers

  1. Register on session loss. On 404 UNKNOWN_SESSION from poll, heartbeat or unregister, register again and retry the call with the new session. Tokens stay valid across sessions.
  2. Make result delivery survive outages. Retry complete and fail on connection errors and 5xx responses, with backoff, until the runtime answers. A result from before a runtime restart is still accepted.
  3. Treat answers as final. Do not retry a result the runtime answered, and do not retry 4xx responses.
  4. Make side effects idempotent. Use idempotency_key, which is stable across retries and redeliveries.
  5. Heartbeat independently. Send heartbeats every heartbeat_interval_ms from a thread that your handlers cannot block.
  6. Honour cancellations. Stop work for tokens listed in cancel.
  7. Announce contracts exactly. Use the task_name and contract_version from the .orbit files, and log registration warnings.
  8. Return objects. Send a JSON object as output; the official SDKs fail an attempt with INVALID_RESULT (not retryable) when a handler returns anything else.
  9. Unregister on shutdown, after in-flight tasks finish.

gRPC binding

Start the runtime with --grpc-addr (or ORBIT_GRPC_ADDR), for example orbit server --grpc-addr 127.0.0.1:7234, to also serve orbit.worker.v1.WorkerService over HTTP/2. Generate a client from worker.proto with your language’s gRPC tooling. The server does not offer gRPC reflection.

service WorkerService {
  rpc RegisterWorker(RegisterWorkerRequest) returns (RegisterWorkerResponse);
  rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
  rpc PollTask(PollTaskRequest) returns (ExecuteTaskRequest);
  rpc CompleteTask(CompleteTaskRequest) returns (TaskResultAck);
  rpc FailTask(FailTaskRequest) returns (TaskResultAck);
  rpc UnregisterWorker(UnregisterWorkerRequest) returns (UnregisterWorkerResponse);
}
RPCHTTP equivalentRequest messageResponse message
RegisterWorkerPOST /v1/workers/registerRegisterWorkerRequestRegisterWorkerResponse
PollTaskPOST /v1/workers/pollPollTaskRequestExecuteTaskRequest
HeartbeatPOST /v1/workers/heartbeatHeartbeatRequestHeartbeatResponse
CompleteTaskPOST /v1/tasks/completeCompleteTaskRequestTaskResultAck
FailTaskPOST /v1/tasks/failFailTaskRequestTaskResultAck
UnregisterWorkerPOST /v1/workers/unregisterUnregisterWorkerRequestUnregisterWorkerResponse

The messages have the same fields as the JSON bodies above. input, output and details are google.protobuf.Value, and deadline_ms and timeout_ms are optional uint64.

The gRPC binding shares the engine, credentials, per-worker identities and authorization rules with HTTP. Its differences:

  • Credentials. Send authorization: Bearer <token> as call metadata. With --tls-cert, --tls-key and --tls-client-ca, the gRPC port uses the same TLS and mutual TLS settings as the HTTP port.
  • No task. When no task arrives within wait_ms, PollTask returns an ExecuteTaskRequest with an empty token. Treat an empty token as “poll again”.
  • Defaults. A wait_ms of 0 means the default of 20 seconds. Because proto3 booleans default to false, FailTask treats an unset retryable as false, unlike HTTP, where an omitted retryable is true. Always set it.
  • Numbers. google.protobuf.Value carries every number as a double. The runtime converts whole numbers within ±2^53 back to integers, so Int fields round-trip.
  • Acknowledgements. TaskResultAck.reason is an empty string when the result was accepted.
  • Errors. Errors are gRPC statuses. The status message starts with the ORBIT error code, for example status NOT_FOUND with the message UNKNOWN_SESSION: unknown or expired worker session; register again. The code is also in the orbit-error-code response metadata, except for missing credentials (UNAUTHENTICATED) and an empty token or error_code (INVALID_ARGUMENT), where only the message prefix carries it.
HTTP statusgRPC status
400INVALID_ARGUMENT
401UNAUTHENTICATED
403PERMISSION_DENIED
404NOT_FOUND
409FAILED_PRECONDITION
otherINTERNAL

A complete, tested gRPC client covering every RPC over plaintext and mutual TLS is in crates/orbit-runtime/tests/grpc.rs.

End-to-end example with curl

This transcript acts as a worker using nothing but curl. It was recorded against a local runtime; ids are shortened, and long responses are trimmed with …. The workflow routes its step to using ruby, a language without an official SDK:

task "greeting.compose" {
    version "1.0.0"
    input { name: String }
    output { message: String }
}

workflow Greet {
    version "1.0.0"
    input { name: String }
    output { message: String }

    step compose {
        worker "greeting.compose"
        using ruby
        with { name: input.name }
        timeout 30s
        retry { attempts: 3, backoff: fixed, initial_delay: 1s, non_retryable: ["INVALID_NAME"] }
    }

    return { message: compose.message }
}

Start the runtime with an admin token and a worker token, and deploy the workflow:

$ orbit server --db /tmp/orbit-demo/orbit.db --token admin-secret --worker-token worker-secret &
$ ORBIT_TOKEN=admin-secret orbit deploy greet.orbit
deployed Greet@1.0.0 (sha256:0bfbb03e7820)
$ B=http://127.0.0.1:7233
$ J='content-type: application/json'
$ W='authorization: Bearer worker-secret'
$ A='authorization: Bearer admin-secret'

Register. The worker token can only call worker endpoints:

$ curl -s $B/v1/workers/register -H "$J" -H "$W" -d '{
    "worker_id": "ruby-1", "runtime": "ruby", "sdk": "curl",
    "contracts": [{"task_name": "greeting.compose", "contract_version": "1.0.0"}]}'
{"accepted":true,"worker_session_id":"fb2bf1a7-…","heartbeat_interval_ms":5000,"session_timeout_ms":30000,"warnings":[]}
$ SESSION=fb2bf1a7-…

$ curl -si $B/v1/executions -H "$W"
HTTP/1.1 403 Forbidden
content-type: application/json

{"error":{"code":"FORBIDDEN","message":"worker credentials may only call /v1/workers/* and /v1/tasks/*"}}

Start an execution with the admin token, then poll for its task:

$ curl -s $B/v1/executions -H "$J" -H "$A" \
    -d '{"workflow": "Greet", "input": {"name": "Ada"}, "execution_id": "greet-ada"}'
{"execution_id":"greet-ada","created":true}

$ curl -s $B/v1/workers/poll -H "$J" -H "$W" -d "{\"worker_session_id\": \"$SESSION\", \"wait_ms\": 20000}"
{"token":"5e9dc58e-…","execution_id":"greet-ada","workflow":"Greet","workflow_version":"1.0.0","step_id":"compose","task_name":"greeting.compose","contract_version":"1.0.0","input":{"name":"Ada"},"idempotency_key":"greet-ada/compose","attempt":1,"redelivery":false,"deadline_ms":1794736057501,"timeout_ms":30000}

$ curl -s $B/v1/workers/heartbeat -H "$J" -H "$W" -d "{\"worker_session_id\": \"$SESSION\"}"
{"accepted":true,"cancel":[]}

Fail the first attempt with a retryable error. The policy allows three attempts, so after the one-second backoff the next poll returns attempt 2, with a new token and the same idempotency key:

$ curl -s $B/v1/tasks/fail -H "$J" -H "$W" -d '{
    "token": "5e9dc58e-…", "error_code": "GREETER_BUSY",
    "error_message": "try again later", "retryable": true, "details": {"queue_depth": 42}}'
{"accepted":true}

$ curl -s $B/v1/workers/poll -H "$J" -H "$W" -d "{\"worker_session_id\": \"$SESSION\", \"wait_ms\": 20000}"
{"token":"4d02a6e3-…","execution_id":"greet-ada","workflow":"Greet","workflow_version":"1.0.0","step_id":"compose","task_name":"greeting.compose","contract_version":"1.0.0","input":{"name":"Ada"},"idempotency_key":"greet-ada/compose","attempt":2,"redelivery":false,"deadline_ms":1794736058601,"timeout_ms":30000}

A failure reported now for the first token is stale:

$ curl -s $B/v1/tasks/fail -H "$J" -H "$W" -d '{"token": "5e9dc58e-…", "error_code": "X"}'
{"accepted":false,"reason":"stale attempt"}

Complete attempt 2 and read the result:

$ curl -s $B/v1/tasks/complete -H "$J" -H "$W" -d '{"token": "4d02a6e3-…", "output": {"message": "Hello, Ada"}}'
{"accepted":true}

$ curl -s "$B/v1/executions/greet-ada/wait?timeout_ms=5000" -H "$A"
{"id":"greet-ada","workflow":"Greet","version":"1.0.0",…,"status":"COMPLETED",…,"output":{"message":"Hello, Ada"},…}

With nothing queued, a poll returns 204 when wait_ms passes:

$ curl -si $B/v1/workers/poll -H "$J" -H "$W" -d "{\"worker_session_id\": \"$SESSION\", \"wait_ms\": 1000}"
HTTP/1.1 204 No Content
x-orbit-api-version: orbit-api/1

A non-retryable code from the step’s non_retryable list fails the step on the first attempt, even though retryable defaults to true:

$ curl -s $B/v1/executions -H "$J" -H "$A" -d '{"workflow": "Greet", "input": {"name": ""}, "execution_id": "greet-blank"}'
{"execution_id":"greet-blank","created":true}
$ # poll as above, then:
$ curl -s $B/v1/tasks/fail -H "$J" -H "$W" -d '{"token": "…", "error_code": "INVALID_NAME", "error_message": "name is blank"}'
{"accepted":true}

The execution ends FAILED after one attempt, with the error INVALID_NAME: step `compose` failed: name is blank.

Output that violates the contract is refused and fails the step:

$ curl -s $B/v1/tasks/complete -H "$J" -H "$W" -d '{"token": "…", "output": {"msg": 1}}'
{"accepted":false,"reason":"output violates the task contract"}

The execution then fails with OUTPUT_CONTRACT_VIOLATION: worker output for `greeting.compose` does not match its contract at $.message: missing required field `message` of type String.

When the workflow is cancelled while the worker holds a task, the next heartbeat lists the token:

$ curl -s -X POST $B/v1/executions/greet-cancel/cancel -H "$J" -H "$A" -d '{"reason": "customer withdrew"}'
{"id":"greet-cancel",…,"status":"RUNNING",…}
$ curl -s $B/v1/workers/heartbeat -H "$J" -H "$W" -d "{\"worker_session_id\": \"$SESSION\"}"
{"accepted":true,"cancel":["5f748944-…"]}

When a worker unregisters while it holds a task, the task is redelivered with the same attempt number and redelivery: true. A success for the original token is still accepted, because the step is not yet resolved:

$ curl -s $B/v1/workers/unregister -H "$J" -H "$W" -d "{\"worker_session_id\": \"$SESSION\"}"
{"accepted":true}
$ # register again as SESSION2, then poll:
{"token":"f26ec7a5-…","execution_id":"greet-redeliver",…,"idempotency_key":"greet-redeliver/compose","attempt":1,"redelivery":true,…}
$ curl -s $B/v1/tasks/complete -H "$J" -H "$W" -d '{"token": "d0917fff-…", "output": {"message": "Hello, Di"}}'
{"accepted":true}

Calls with an unregistered session return 404:

$ curl -si $B/v1/workers/heartbeat -H "$J" -H "$W" -d "{\"worker_session_id\": \"$SESSION\"}"
HTTP/1.1 404 Not Found
content-type: application/json
x-orbit-api-version: orbit-api/1

{"error":{"code":"UNKNOWN_SESSION","message":"unknown or expired worker session; register again"}}

A worker loop in pseudocode

session, hb = register()
spawn every hb.heartbeat_interval_ms:
    r = heartbeat(session)            # on 404: session = register()
    for token in r.cancel: signal_cancel(token)

repeat N times in parallel:
    loop until shutting down:
        task = poll(session, wait_ms=20000)   # on 404: session = register(); retry
        if no task (204): continue
        try:
            output = handler(task.input, task)
            send_with_retry(complete, token=task.token, output=output)
        except TaskError e:
            send_with_retry(fail, token=task.token, error_code=e.code,
                            error_message=e.message, retryable=e.retryable, details=e.details)
        except any other error e:
            send_with_retry(fail, token=task.token, error_code="UNHANDLED_EXCEPTION",
                            error_message=str(e), retryable=true)

on shutdown: stop polling, wait for handlers, unregister(session)

send_with_retry: retry on connection errors and 5xx with exponential backoff;
                 stop on any other answer.

See also

Compiler diagnostics

Every diagnostic the ORBIT compiler reports has a stable code. A code never changes meaning, and retired codes are not reused, so you can search for a code, link to it, or match it in CI. This page lists every code with its meaning and how to fix it. Codes starting with E are errors and stop compilation; codes starting with W are warnings, which orbit check --strict and orbit compile --strict treat as errors.

Reading a diagnostic

orbit check, orbit compile and the language server report the same diagnostics:

error[E0300]: unknown worker task `risk.analize`
 --> workflows/order.orbit:4:19
  |
4 |   step a { worker "risk.analize" }
  |                   ^^^^^^^^^^^^^^
  = help: did you mean `risk.analyze`?

The first line has the severity, the code and the message. The --> line gives the file, line and column. Labels mark the source, = note: lines add context (a cycle, for example), and = help: lines suggest a fix.

orbit check --format json prints the same information for editors and CI: code, severity, file, line, column, end position, labels, notes and help.

{
  "diagnostics": [
    {
      "severity": "error",
      "code": "E0300",
      "message": "unknown worker task `greeting.compose`",
      "location": { "file": "messy.orbit", "line": 6, "column": 25 },
      "end_line": 6,
      "end_column": 43,
      "labels": [],
      "notes": [],
      "help": "declare its contract: `task \"greeting.compose\" { version \"1.0.0\" input { ... } output { ... } }`"
    }
  ]
}

Code ranges

RangeArea
E00xxLexical: characters, strings, comments, literals.
E01xxSyntax: tokens, blocks and members.
E02xx, W02xxDeclarations, schemas, types and imports.
E03xx, W03xxSteps, dependencies, tasks and child workflows.
E04xxExpressions, types of values, retry and timeout settings.
E05xxWorkflow-level rules.
W06xxStatic safety checks: compensations, constant conditions, signals and waits.
E07xx, W07xxtest declarations: workflows, mocks, signals and expectations.
P0xxxOrganisation policies checked with --policy: waivers (P00xx) and rule violations (P01xx).

Lexical (E00xx)

E0001: unexpected character

The source contains a character that cannot start any token, for example # or $ outside a string. Message: unexpected character `#` . Remove the character, or put it inside a string literal.

E0002: unterminated string literal

A string literal has no closing ". Strings are single-line, so a line break also ends the string with this error. Close the string on the same line; use \n for a line break inside a string.

E0003: unterminated block comment

A /* comment has no matching */. Close the comment.

E0004: invalid escape sequence

A string contains an unknown escape (unknown escape sequence `\q` ) or an invalid Unicode escape (invalid unicode escape `\u{110000}` ). Use one of the supported escapes, or escape the backslash as \\.

E0005: unknown duration unit

A duration literal has a unit ORBIT does not know, for example 5sec. The valid units are ms, s, m, h and d.

E0006: integer literal out of range

An integer literal does not fit in a signed 64-bit integer. Use a smaller value, or a Float.

Syntax (E01xx)

E0100: expected token

The parser expected a specific token and found another. Message: expected <token>, found <token>. Check for a missing {, }, , or : immediately before the reported position.

E0101: duration is missing a unit

A duration was written as a bare number, for example timeout 5. Add a unit: timeout 5s. Help: add a unit such as `ms`, `s`, `m`, `h` or `d`, e.g. `30s` .

E0102: unknown member

A block contains a member that does not belong there, for example a misspelled wroker in a step. The compiler suggests the closest valid member (did you mean `worker`?).

E0103: unclosed block

A { has no matching } before the end of the file. Message: unclosed `{` in <context>. The label points at the opening brace.

E0104: expected a declaration

At the top level of a file, only import, schema, task and workflow declarations are allowed. Message: expected a declaration, found <token>.

E0105: unsupported handler

An on member names a handler kind other than failure, cancel, timeout or event. Message: `on success` handlers are not supported, with help use `on failure`, `on cancel`, `on timeout` or `on event <signal>` .

error[E0105]: `on success` handlers are not supported
 --> notify.orbit:4:5
  |
4 |     on success {
  |     ^^^^^^^^^^^^
  = help: use `on failure`, `on cancel`, `on timeout` or `on event <signal>`

To run steps after a successful execution, make them ordinary steps that depend on the others. See Outcome handlers.

E0106: duplicate member

A member appears twice in the same block (duplicate `timeout` ), or a step has more than one action (a step can only have one `worker`, `workflow`, `sleep`, `await` or `compute` ). Keep one, or split the work into separate steps.

E0107: duplicate field in object literal

An object literal sets the same field twice, for example { id: 1, id: 2 }. Message: duplicate field `id` in object.

Declarations and types (E02xx)

E0200: duplicate declaration

A workflow, schema or task is declared more than once (across all imported files), a schema redefines a built-in type such as String, or a task name starts with the reserved prefix orbit. (task names starting with `orbit.` are reserved for built-in tasks). Rename one of the declarations.

E0201: unknown type

A type name is not a built-in type and not a declared schema. Message: unknown type `Adress` . Declare the schema, import the file that declares it, or fix the spelling.

E0202: recursive schema

A schema contains itself, directly or through other schemas. The note prints the cycle (cycle: A -> B -> A). Break the cycle, for example by storing an id instead of the nested record.

E0203: duplicate field in a record type

A schema or inline record type declares the same field twice. Message: duplicate field `id` .

E0204: map key is not String

Map keys must be String. Message: map keys must be `String`, found `Int` .

E0205: invalid default value

A field default is not a constant of the field’s type, for example count: Int = "one". Message: invalid default value: <reason>.

E0206: file cannot be read

An imported file, or a file given on the command line, cannot be read. Messages: cannot import `contracts.orbit`: <reason> and cannot read `<path>`: <reason>. Import paths are relative to the importing file.

W0201: version is not semantic

A workflow or task version is not MAJOR.MINOR.PATCH. Message: version `1.0` is not semantic (MAJOR.MINOR.PATCH). The runtime orders versions semantically when you start a workflow without a version, so non-semantic versions may not sort as you expect.

Steps and dependencies (E03xx)

E0300: unknown worker task

A step names a task with worker "..." that has no task contract in scope. The compiler suggests the closest declared task (did you mean `risk.analyze`?), or tells you how to declare one. Declare the contract, or import the file that declares it.

E0301: step does nothing

A step has no action. Message: step `a` does nothing, with help add `worker "task.name"`, `workflow "Name"`, `sleep 10s`, `await <signal>` or `compute <expression>` . Add one of them.

E0302: duplicate step name

Two steps in the same workflow, or in the same handler block, have the same name. Message: step `a` is defined more than once. A handler step that has the name of a workflow step, of a reserved name or failure gets handler step `a` has the same name as a workflow step or a reserved name, with help give handler steps their own names, e.g. `alert_ops` .

E0303: unknown step in after

An after clause names a step that does not exist. Message: `after` references unknown step `validte`. Inside an on failure, on cancel or on timeout block, after may only name steps of the same block: `after` in an `on cancel` block must name a step of the same block, with the note workflow steps have all finished before a handler runs.

E0304: circular dependency

Steps depend on each other in a cycle, through after or through data references. The note prints the cycle. Remove one of the dependencies. Between the steps of a handler block, the message is circular dependency between handler steps.

E0305: step depends on itself

A step lists itself in after (step `a` cannot run after itself) or reads its own output (step `a` cannot read its own output). A step’s output can be read by later steps, its compensate block, or return.

E0306: reserved step name

A step is named input, execution, output, if, then or else. These names are reserved because expressions use them. Rename the step.

E0307: member not allowed here

A member is valid ORBIT but not in this position. Examples: retry on a sleep step, when or for inside compensate, retry, using, idempotency_key or queue_timeout on a workflow step, for on a sleep step, and max_parallel without for (help: add `for item in <list>` to fan the step out). An await step takes only after, when and timeout (`retry` is not allowed in an `await` step; for for, the note an `await` step receives one signal). A compute step takes no with, using, timeout, queue_timeout, retry or idempotency_key (`timeout` is not allowed in a `compute` step). Handler steps take no await (`await` is not allowed in an `on timeout` step, with the note handlers run while the execution is finishing; it no longer accepts signals), no child workflow (child workflows are not allowed in an `on failure` step), no for and no max_parallel. See Signals and queries, Compute steps and Outcome handlers.

E0308: invalid compensation

A compensation cannot run as written: a sleep, await or compute step has a compensate block (a `compute` step has nothing to compensate), the block has no worker (`compensate` block needs a `worker`), it calls something other than a worker task (compensation must call a worker task), or it reads a step that is not an ancestor of the compensated step. Steps of an on failure, on cancel or on timeout block cannot be compensated: handler steps cannot declare `compensate` .

E0310: task input or output is not a record

A task’s input or output must be a record type: an inline { ... } or a schema. Message: task input must be a record type, found `String` .

E0311: workflows start each other in a cycle

Child workflows form a cycle, for example A starts B and B starts A. Message: workflows call each other in a cycle; the note prints the cycle.

E0312: unknown child workflow

A workflow "Name" step names a workflow that is not declared in scope. The compiler suggests the closest name.

E0315: unknown signal

An await step names a signal the workflow does not declare. Message: unknown signal `reveiw` , with help did you mean `review`? for a close name, or declare it in the workflow: `signal approval { ... }` otherwise.

error[E0315]: unknown signal `reveiw`
 --> approvals.orbit:5:27
  |
5 |     step decision { await reveiw }
  |                           ^^^^^^
  = help: did you mean `review`?

Signals are declared per workflow; a signal of another workflow is not in scope.

E0316: unknown compensated_by task

A task contract’s compensated_by names a task that is not declared in the compilation. Message: `compensated_by` names unknown task `payments.refnud`, with help did you mean `payments.refund`?. Declare the task or import the file that declares it. See Static safety checks.

E0317: duplicate signal

A workflow declares two signals with the same name. Message: signal `review` is declared more than once; a label marks the first declaration. Merge the two payloads into one declaration, or rename one.

E0318: duplicate query

A workflow declares two queries with the same name. Message: query `state` is declared more than once; a label marks the first declaration.

E0319: duplicate handler

A workflow declares two on blocks of the same kind, or two on event blocks for the same signal. Message: `on failure` is declared more than once; a label marks the first declaration. Merge the steps into one block.

error[E0319]: `on failure` is declared more than once
 --> notify.orbit:7:8
  |
7 |     on failure {
  |        ^^^^^^^
4 |     on failure {
  |        ------- first declared here

E0320: unknown limit

A limits block names a field other than max_fan_out, max_history_events and max_payload_bytes. A close match is suggested.

error[E0320]: unknown limit `max_fanout`
 --> lim.orbit:3:14
  |
3 |     limits { max_fanout: 10, max_history_events: 0 }
  |              ^^^^^^^^^^
  = help: did you mean `max_fan_out`?

E0321: invalid limit value

A limit is zero, negative, or not an integer literal.

error[E0321]: limit `max_history_events` must be a positive integer literal
 --> lim.orbit:3:50
  |
3 |     limits { max_fanout: 10, max_history_events: 0 }
  |                                                  ^
  = help: for example `max_fan_out: 100`

E0322: signal both awaited and handled

A signal handled by an on event block is also the target of an await step. Every arriving signal of that name starts the block, so the await could never receive one. Use a separate signal for the await.

error[E0322]: signal `note` is handled by `on event` and cannot also be awaited
 --> aw.orbit:4:20
  |
4 |     step a { await note timeout 1h }
  |                    ^^^^
5 |     on event note {
  |              ---- handled here
  = note: every signal handled by `on event` starts the handler; an `await` would compete for it

E0323: continue_as_new without an input

A continue_as_new member has no with <input>. The next run needs an input, even when it is {}.

error[E0323]: `continue_as_new` needs `with <input>`
 --> diag1.orbit:5:5
  |
5 |     continue_as_new when !page.done
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  = help: give the next run's input, e.g. `continue_as_new with { cursor: page.next }`

The input itself is checked against the workflow’s input type like a step’s with against its contract: a missing required field is E0403, a value of the wrong type E0400.

error[E0403]: missing required field `limit: Int`
 --> diag4.orbit:5:42
  |
5 |     continue_as_new when !page.done with { cursor: string(page.next) }
  |                                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  = note: required by `{ cursor: Int, limit: Int }`

error[E0400]: type mismatch: expected `Int`, found `String`
 --> diag4.orbit:5:52
  |
5 |     continue_as_new when !page.done with { cursor: string(page.next) }
  |                                                    ^^^^^^^^^^^^^^^^^

E0324: continue_as_new condition is not Bool

The when condition of continue_as_new must be a Bool (or an optional Bool, where null counts as false).

error[E0324]: the `continue_as_new` condition must be `Bool`, found `Int`
 --> diag2.orbit:5:26
  |
5 |     continue_as_new when page.next with { cursor: page.next }
  |                          ^^^^^^^^^

A second continue_as_new in the same workflow is E0106 (duplicate `continue_as_new` ).

W0301: unknown worker language

A using clause names a language ORBIT does not recognize. Message: unknown worker language `pyhton` . The step still compiles, but it is dispatched only to workers that announce exactly that runtime.

Expressions (E04xx)

E0400: type mismatch

A value has the wrong type. Message: type mismatch: expected `Int`, found `String` . The same code reports string literals that are not a valid UUID or RFC 3339 timestamp where a Uuid or Timestamp is expected.

E0401: unknown reference

An expression names something that is not input, execution, a step, or a loop variable in scope. failure is in scope only in the steps of on failure, on cancel and on timeout blocks; elsewhere it is unknown reference `failure` . It is also reported when a for loop variable is used in the step’s when condition (loop variable `item` is not in scope in `when` ): when decides once, before the step fans out, whether the whole step runs.

E0402: unknown field

A member access names a field the type does not have. Message: no field `adress` on type `{ address: String, n: Int }` , with help did you mean `address`?.

E0403: missing required fields

An object literal for a record does not set every required field, or a step whose task requires input has no with (task `orders.validate` requires input but the step has no `with` ). Set the missing fields, give them defaults, or declare them optional with ?.

E0404: unknown field in object literal

An object literal sets a field the target record does not declare. Message: unknown field `colour` for `Item` .

E0405: optional value where a value is required

A value that may be null is used where a non-optional type is required. Message: value may be `null` but `String` is required. Provide a fallback with coalesce(value, default), or declare the target as optional with ?.

E0406: operator not defined for operand types

An operator is applied to types it does not support. Message: operator `*` cannot be applied to `String` and `Int` .

E0407: unknown function or wrong arity

A function call names a function that does not exist, or passes the wrong number of arguments. See Built-in functions.

E0408: condition is not Bool

A when condition or an if expression’s condition does not have type Bool. Messages: `when` condition must be `Bool`, found `String` and `if` condition must be `Bool`, found `Int`.

E0410: invalid retry, timeout or max_parallel

A retry policy, timeout or max_parallel value is out of range or malformed. Messages include: timeouts must be greater than zero, `attempts` must be between 1 and 1000 (note: attempts counts every execution, including the first), unknown backoff strategy `linear` , unknown retry setting `max_attempts` , duplicate retry setting `attempts` , error codes must be string literals, `max_delay` must be at least `initial_delay` and `max_parallel` must be between 1 and 10000.

E0413: loop variable or lambda parameter shadows a name

The variable of a for loop has the same name as a step or a built-in name such as input. Message: loop variable `input` shadows <what>. The same code reports a lambda parameter that reuses the name of a step, a loop variable, an enclosing lambda parameter, input or execution: lambda parameter `base` shadows a name already in scope. Rename the variable or parameter.

E0414: for over something that is not a list

The expression after for <var> in does not have a List type. Message: `for` needs a list, found `Int`.

E0415: lambda outside a collection function

A lambda (x => ...) appears somewhere other than the second argument of map, filter, find, any or all. Message: a lambda can only be passed to `map`, `filter`, `any`, `all` or `find` , with help for example `map(input.items, item => item.price)` . Lambdas are not values: they cannot be stored in a field, returned or passed to other functions. See Expressions.

Workflows (E05xx)

E0500: workflow has no version

Every workflow needs a version. Message: workflow `Order` has no version. Help: add `version "1.0.0"`; running executions stay pinned to the version they started with.

E0501: output without return

The workflow declares an output with required fields but has no return. Message: workflow declares an `output` but has no `return` . Add a return that builds the output.

Static safety (W06xx)

These warnings flag workflows that compile but are likely to misbehave. Static safety checks has a full example of each.

W0601: missing compensation

A step calls a task whose contract declares compensated_by, and the step has no compensate block. Message: step `charge` calls `payments.charge` but has no compensation, with the note `payments.charge` declares `compensated_by "payments.refund"`: if a later step fails, its effect would be left in place and help add `compensate { worker "payments.refund" with { ... } }` .

W0602: compensation uses a different task

The step’s compensate block calls another task than the contract’s compensated_by. Message: step `charge` compensates `payments.charge` with `payments.void`, but the contract names `payments.refund` . Use the named task, or change the contract.

W0603: step never runs

A when condition reads no input, step output, execution field or variable, and evaluates to false or null. Message: step `legacy_audit` never runs: its `when` condition is always false, with help remove the step, or make the condition depend on input or step results.

W0604: condition always true

A when condition reads nothing and evaluates to true. Message: the `when` condition of step `audit` is always true, with help remove `when`; it also makes the step's output optional for no reason.

W0605: signal declared but never awaited

No step awaits a declared signal. Message: signal `escalate` is declared but no step awaits it, with help add a step with `await escalate`, or remove the declaration. The runtime still accepts the signal, but nothing reads it.

W0606: wait without a timeout

An await step has no timeout, and the workflow has no timeout. Message: step `decision` waits for signal `review` with no timeout, with help add `timeout 7d` to the step (or a workflow `timeout`) so the execution cannot wait forever.

W0607: return is never used

A workflow has continue_as_new without when and also a return. Every run continues as new, so the execution never completes and return is never evaluated.

warning[W0607]: `return` is never used: `continue_as_new` has no `when`, so every run continues
 --> diag3.orbit:7:12
  |
7 |     return { pages: page.next }
  |            ^^^^^^^^^^^^^^^^^^^^
6 |     continue_as_new with { cursor: page.next }
  |     ------------------------------------------ always continues
  = help: add `when <condition>` to `continue_as_new`, or remove `return`

Tests (E07xx, W07xx)

These diagnostics come from test declarations. They are reported by every command that compiles the file, including orbit check, and a file with test errors cannot be deployed. Values in a test are also checked with the expression rules above: a missing required input field is E0403, a mock result of the wrong type is E0400, and reading input in a test input or expect value is E0401. Testing workflows describes the declarations.

E0700: unknown workflow in a test

The test’s workflow is not defined in, or imported into, the compilation. Message: unknown workflow `OrderAproval` , with help did you mean `OrderApproval`?, or help import the file that defines it, e.g. `import "orders.orbit"` when no name is close.

E0701: test without a workflow

Message: test `no workflow` does not name a workflow, with help add `workflow <Name>` to the test.

E0702: test version does not match

The test names a version other than the one the sources define. Message: test expects `OrderApproval` version 2.0.0, but the sources define version 1.0.0. Update the version, or remove it to test whatever version is compiled.

E0703: mocked task is not called by the workflow

A mock names a task that the workflow does not call in a step, a compensation or one of its child workflows. Message: `OrderApproval` does not call task `payments.charg`, with a suggestion, or a note listing the tasks it can call.

E0704: task mocked twice

Message: task `payments.charge` is mocked more than once, with help chain behaviours instead: `fails "X" times 1 then returns { ... }` .

E0705: unknown signal in a test

A signal names a signal the workflow does not declare. Message: `OrderApproval` declares no signal `reveiw`, with help did you mean `review`?.

E0706: unknown step in an expectation

Message: `OrderApproval` has no step `chrge`, with help did you mean `charge`?, or a note listing the steps.

E0707: unknown status

expect status accepts COMPLETED, FAILED, CANCELLED and TIMED_OUT. expect step <name> <STATUS> accepts the unit statuses PENDING, RUNNING, SCHEDULED, DISPATCHED, ATTEMPT_FAILED, UNCERTAIN, RETRY_WAIT, WAITING, SUCCEEDED, SKIPPED, FAILED and CANCELLED. Message: `COMPLETE` is not a workflow status, with help did you mean `COMPLETED`?; a lower-case status gets the help statuses are upper case: `COMPLETED` .

E0708: expected compensation of a step without one

expect compensated names a step without a compensate block. Message: step `notify` has no `compensate` block.

E0709: duplicate test name

Message: test `errors` is declared more than once, with a label at the first declaration. Test names are unique across the compilation, so --filter and reports identify a test unambiguously.

E0710: test value cannot be evaluated

A constant in input, a signal payload or an expected output type-checks but fails to evaluate, for example a division by zero. Message: cannot evaluate a test input: <reason>.

E0711: invalid count

times must be at least 1, and attempts must be a non-negative number. Message: `times 0` must be at least 1.

E0712: invalid number of runs

expect runs must be at least 1.

error[E0712]: `runs 0` is not a valid number of runs
  --> diag3.orbit:18:17
   |
18 |     expect runs 0
   |                 ^
   = note: an execution has at least one run

E0713: runs expected of a workflow without continue_as_new

expect runs N with N greater than 1 names a workflow that has no continue_as_new, so the expectation can never hold.

error[E0713]: workflow `Once` has no `continue_as_new`, so it always ends in run 1
  --> diag3.orbit:24:17
   |
24 |     expect runs 2
   |                 ^
   = help: expect `runs 1`, or add `continue_as_new` to the workflow

W0701: test checks nothing

The test has no expect. Message: test `nothing` checks nothing, with help add an expectation such as `expect status COMPLETED` . Such a test still fails if the execution cannot finish, for example because a task is not mocked.

Policy (P0xxx)

Reported only with orbit check, orbit compile or orbit deploy given --policy <file>, after the files compiled without errors. A rule’s severity decides whether its violations are errors or warnings; the code is the same either way. Every violation message starts with policy rule `<id>`: , has a note naming the policy file (with the rule’s description if it has one), and a help that ends with the waiver to write. Policies describes each rule in full.

error[P0101]: policy rule `payments-compensated`: step `charge` calls `payments.charge` but has no compensation
  --> examples/checkout.orbit:22:10
   |
22 |     step charge {
   |          ^^^^^^
   = note: policy fintech (examples/policies/fintech.toml): every charge and reservation must be undone on failure
   = help: add a `compensate { worker "..." with { ... } }` block, or waive it: `// policy-allow: payments-compensated <reason>`

Step violations point at the step name; workflow violations (P0106, P0109 for the limits block, P0110) point at the workflow name.

CodeRule typeMessage
P0001waiverswaiver names unknown policy rule `<id>` (note: the known rule ids), or waiver for policy rule `<id>` has no reason. Always an error.
P0002waiverswaiver for policy rule `<id>` suppresses nothing, help remove the comment. Always a warning.
P0101require_compensationstep `<s>` calls `<task>` but has no compensation; with compensate_with: step `<s>` compensates `<task>` with `<other>`, which is not one of <patterns>
P0102require_retrystep `<s>` calls `<task>` with <n> attempt(s); at least <min> required, or ... with up to <n> attempts; at most <max> allowed
P0103max_attemptsstep `<s>` calls `<task>` with up to <n> attempts; at most <max> allowed
P0104require_timeoutstep `<s>` calls `<task>` without a `timeout` , step `<s>` has `timeout <d>`; at most <max> allowed, or step `<s>` calls `<task>` without a `queue_timeout`
P0105require_approvalstep `<s>` calls `<task>` without depending on an approval (`await` of <signals>) when <condition>, or step `<s>` calls `<task>` without a guaranteed approval when <condition> with a note per reason, or step `<s>` calls `<task>`, but the workflow input has no numeric field `<path>` to evaluate `<condition>`
P0106require_handlerworkflow `<w>` has no `on failure` handler (every missing kind is listed)
P0107forbid_tasksstep `<s>` calls `<task>`, a forbidden task, or the compensation of step `<s>` calls `<task>`, a forbidden task
P0108allow_tasksstep `<s>` calls `<task>`, a task outside the allowed list (or the compensation of step `<s>` ...)
P0109max_fan_outworkflow `<w>` has `for` steps (<steps>) but no `limits { max_fan_out: ... }` , workflow `<w>` allows `max_fan_out: <n>`; at most <max> allowed, or `for` step `<s>` has no `max_parallel`
P0110require_limitsworkflow `<w>` does not set `<key>` in `limits`
P0111require_signal_timeoutstep `<s>` waits for signal `<signal>` without a step `timeout` , or step `<s>` waits up to <d> for `<signal>`; at most <max> allowed

Handler and event steps are named on <kind>: <step> and on event <signal>: <step> in messages.

An [[exemption]] in the policy file that matches no violation is not a diagnostic: it is printed as warning: policy <file>: exemption <n> (rule `<id>`, workflow `<pattern>`) matched no violation and does not change the exit status.

See also

Environment variables

This page lists every environment variable that ORBIT components read: the orbit CLI, the runtime server, the worker SDKs, the install script and the VS Code extension. A command-line flag always takes precedence over its environment variable, and an SDK option passed in code takes precedence over the environment. An empty value is treated as unset for credentials.

Runtime server

Read by orbit server.

VariableFlagDefaultMeaning
ORBIT_DB--dborbit.dbSQLite file path or PostgreSQL URL (postgres:// or postgresql://).
ORBIT_ADDR--addr127.0.0.1:7233HTTP API listen address. The container image sets it to 0.0.0.0:7233.
ORBIT_GRPC_ADDR--grpc-addrunsetAlso serve the gRPC worker protocol on this address.
ORBIT_TOKEN--tokenunsetAdmin bearer token. Required on every endpoint except /v1/health when set.
ORBIT_WORKER_TOKEN--worker-tokenunsetShared worker credential, limited to /v1/workers/* and /v1/tasks/*.
ORBIT_WORKERS_FILE--workers-fileunsetPath of the workers file (TOML): per-worker identities and client identities.
ORBIT_TLS_CERT--tls-certunsetPEM certificate chain for HTTPS (and TLS on gRPC).
ORBIT_TLS_KEY--tls-keyunsetPEM private key for ORBIT_TLS_CERT.
ORBIT_TLS_CLIENT_CA--tls-client-caunsetPEM CA that signs worker client certificates (mutual TLS).
OTEL_EXPORTER_OTLP_ENDPOINT--otlp-endpointunsetOTLP/HTTP collector base URL. Spans are posted to <endpoint>/v1/traces.
OTEL_EXPORTER_OTLP_HEADERSunsetExtra headers for trace export, as comma-separated key=value pairs, for example x-honeycomb-team=abc123. Read only when trace export is enabled.
OTEL_SERVICE_NAMEorbit-runtimeValue of the service.name resource attribute on exported spans.
ORBIT_LOGinfoLog filter in tracing-subscriber EnvFilter syntax, for example debug or info,orbit_runtime=debug. Setting it replaces the default info filter entirely.
ORBIT_DB_CA_CERTunsetPEM CA bundle trusted for PostgreSQL TLS, in addition to the public web PKI roots.
ORBIT_MAX_ACTIVE_EXECUTIONS--max-active-executionsunlimitedMaximum running executions; further starts get 429 RESOURCE_EXHAUSTED.
ORBIT_MAX_FAN_OUT--max-fan-outunlimitedMaximum elements of one for step; larger lists fail the step with LIMIT_EXCEEDED.
ORBIT_MAX_HISTORY_EVENTS--max-history-eventsunlimitedMaximum history events per execution; past it the execution fails with LIMIT_EXCEEDED.
ORBIT_MAX_PAYLOAD_BYTES--max-payload-bytesunlimitedMaximum size in bytes of an execution input, task result or signal payload; larger ones get 413 PAYLOAD_TOO_LARGE.
ORBIT_CLUSTER--clusterunsettrue to run as one of several servers on one PostgreSQL database.
ORBIT_CLUSTER_MODE--cluster-modestandbyshared to make every server of the cluster active.
ORBIT_NODE_TIMEOUT--node-timeout5Shared mode: seconds without a heartbeat after which a server is considered dead.
ORBIT_NODE_ID--node-idhost nameThis server’s name in the cluster.
ORBIT_ADVERTISE_ADDR--advertise-addrderived from --addrURL handed out for this server while it leads.
ORBIT_NO_UI--no-uiunset (UI served)true, 1, yes or on to stop serving the web UI at /ui/; false, 0, no or off keep it.

The flags --insecure, --enable-http-adapter, --session-timeout, --max-body and --log-format have no environment variable.

CLI client

Read by orbit deploy, run, status, inspect, logs, cancel, ps and workers list.

VariableFlagDefaultMeaning
ORBIT_SERVER--serverhttp://127.0.0.1:7233Runtime API address.
ORBIT_TOKEN--tokenunsetBearer token: the admin token, or a client identity’s token for the commands its actions allow.
ORBIT_CA_CERT--ca-certunsetExtra PEM CA certificate to trust.
ORBIT_CLIENT_CERT--client-certunsetPEM client certificate for mutual TLS.
ORBIT_CLIENT_KEY--client-keyunsetPEM private key for the client certificate.
NO_COLORunsetWhen set to any value, disables colored output in every command.

ORBIT_TOKEN is shared by the server and the client. On a host that runs both, the CLI picks up the server’s admin token, which is usually what you want.

Worker SDKs

Every SDK reads the same variables when the corresponding option is not set in code.

VariableDefaultMeaningRead by
ORBIT_SERVERhttp://127.0.0.1:7233Runtime API address.Python, TypeScript, Go, Rust, Java, C#
ORBIT_WORKER_TOKENunsetWorker credential: the shared worker token or a per-worker token. Preferred over ORBIT_TOKEN.Python, TypeScript, Go, Rust, Java, C#
ORBIT_TOKENunsetUsed as the worker credential when ORBIT_WORKER_TOKEN is not set.Python, TypeScript, Go, Rust, Java, C#
ORBIT_CA_CERTunsetExtra PEM CA certificate to trust for HTTPS.Python, Go, Rust, Java, C#; TypeScript only together with a client certificate (see below)
ORBIT_CLIENT_CERTunsetPEM client certificate for mutual TLS.Python, TypeScript, Go, Rust, Java, C#
ORBIT_CLIENT_KEYunsetPEM private key for the client certificate.Python, TypeScript, Go, Rust, Java, C#
NODE_EXTRA_CA_CERTSunsetRead by Node.js itself: extra CA certificates for every HTTPS connection. Use it to trust a private CA from a TypeScript or JavaScript worker that does not use a client certificate.Node.js
ORBIT_LOG_LEVELINFOLog level for the worker’s logging.basicConfig call.Python
HOSTNAMEsystem host nameUsed in the default worker id java-<host>-<pid>.Java

Language-specific options and defaults are on each SDK’s page, for example Python and Go.

Install script

Read by install.sh (curl -fsSL https://github.com/AjmalX0/Orbit/releases/latest/download/install.sh | sh).

VariableDefaultMeaning
ORBIT_VERSIONlatestRelease tag to install, for example v0.1.0.
ORBIT_INSTALL_DIR~/.local/bin, or /usr/local/bin as rootDirectory the binary is installed into.
ORBIT_DOWNLOAD_URLGitHub release URLBase URL of a mirror serving the release assets (orbit-<target>.tar.gz and SHA256SUMS). Overrides ORBIT_VERSION.

VS Code extension

The extension does not read environment variables from your shell for configuration. It sets ORBIT_SERVER to the value of the orbit.runtimeUrl setting in the integrated terminal it opens for the Run, Deploy and Check commands. See Editor support.

Development and tests

These variables are read only by the project’s own test suites:

VariableRead byMeaning
ORBIT_TEST_POSTGRES_URLcrates/orbit-runtime/tests/postgres.rsPostgreSQL URL for the PostgreSQL backend tests. The tests are skipped when it is unset.
ORBIT_BINeditors/vscode/testPath of the orbit binary used by the extension tests.

See also

FAQ and troubleshooting

This page lists the errors you are most likely to meet, with the exact message, the cause and the fix, followed by answers to common questions about ORBIT’s design. For every compiler error code, see Compiler diagnostics.

Troubleshooting

Cannot reach the runtime

error: cannot reach the ORBIT runtime at http://127.0.0.1:7233: io: Connection refused (os error 111)
  hint: start it with `orbit server` or set --server / ORBIT_SERVER

Workers report the same condition and keep retrying with backoff:

WARNING orbit cannot reach ORBIT runtime at http://127.0.0.1:7233: <urlopen error [Errno 111] Connection refused>; retrying in 0.2s

Causes and fixes:

  • The server is not running. Start it with orbit server and check curl http://127.0.0.1:7233/v1/health.
  • The client points at the wrong address. CLI commands and SDKs default to http://127.0.0.1:7233. Set ORBIT_SERVER (or the CLI’s --server) to the runtime’s URL, including the scheme and port.
  • The server listens on loopback only. The default --addr is 127.0.0.1:7233, which other machines and containers cannot reach. Listen on another interface with --addr 0.0.0.0:7233 and a token.
  • The container port is not published. Run the image with -p 7233:7233.

Address already in use

error: cannot listen on 127.0.0.1:7233: Address already in use (os error 98)

Another process, often an earlier orbit server, holds the port. Stop it, or start the server on another port with --addr 127.0.0.1:7300 and set ORBIT_SERVER=http://127.0.0.1:7300 for clients and workers.

The server refuses to start without a token

error: refusing to listen on 0.0.0.0:7233 without authentication; pass --token (or ORBIT_TOKEN), or --insecure for trusted networks

The server refuses to listen on a non-loopback address without authentication. This is also why the Docker image exits immediately when ORBIT_TOKEN is not set. Pass a token:

orbit server --addr 0.0.0.0:7233 --token "$(openssl rand -hex 32)"

Use --insecure only on a network where every host is trusted. See Security.

Unauthenticated or forbidden

error: UNAUTHENTICATED (401): missing or invalid credentials

The server requires a bearer token and the request carried none, or the wrong one. Pass the token that the server was started with:

ClientSetting
CLI--token or ORBIT_TOKEN
SDK workersORBIT_WORKER_TOKEN (preferred) or ORBIT_TOKEN

An SDK worker without a valid token stops at startup with the same error, for example orbit_sdk.client.ApiError: UNAUTHENTICATED (401): missing or invalid credentials in Python or ApiError: UNAUTHENTICATED (401): ... in Node.js.

error: FORBIDDEN (403): worker credentials may only call /v1/workers/* and /v1/tasks/*

You used the shared worker token (--worker-token) for an admin command such as orbit run or orbit ps. Worker credentials can only use the worker protocol. Use the admin token for the CLI. A per-worker identity from --workers-file is also refused with 403 when it registers under another worker’s id or announces a task its patterns do not allow.

error: FORBIDDEN (403): client `warehouse-app` may not start executions of `Review`

The token belongs to a client identity ([[client]] in the workers file), which may act only on the workflows and actions its entry grants, and never deploy workflows or call the worker protocol. Add the workflow or action to the entry and restart the server, or use the admin token. See Security: client identities.

error: RESOURCE_EXHAUSTED (429): 2 executions are already running (--max-active-executions)

The server runs with --max-active-executions and that many executions are running. Retry later with the same execution id, or raise the limit. See Resource limits.

A step stays SCHEDULED: no worker for the task

orbit run keeps waiting, and orbit status shows the step as SCHEDULED with no worker:

  STEP                         STATUS       ATTEMPT     TIME  DETAIL
  frequencies                  SCHEDULED          1

The task is queued, and no connected worker matches it. The runtime does not treat this as an error: the task waits, durably, until a matching worker connects. Compare the step with orbit workers list:

WORKER                       RUNTIME      STATE    INFLIGHT  TASKS
text-python                  python       online   0  text.normalize@1.0.0
text-rust                    rust         online   0  text.fingerprint@1.0.0

A worker matches only if all of the following hold:

  1. its STATE is online;
  2. it lists the step’s task name, spelled exactly the same;
  3. it lists the same contract version (see the next section);
  4. if the step says using <language>, its RUNTIME is that language.

To fail instead of waiting, add queue_timeout to the step. When it elapses, the step fails with a non-retryable QUEUE_TIMEOUT:

error: QUEUE_TIMEOUT step `compose` failed: no worker picked up the task before queue_timeout

Contract version mismatch

The workflow uses a contract version that no worker announced. For example, the workflow declares:

task "greeting.compose" {
    version "2.0.0"
    input { name: String }
    output { message: String }
}

while the worker registers:

@worker.task("greeting.compose", version="1.0.0")

orbit workers list shows greeting.compose@1.0.0, and the step waits as described above. Contract versions must match exactly. Either register the handler under the version the workflow uses, or deploy a workflow version that uses the contract the workers implement. To serve both versions during a migration, run a second worker process for the new version. See Versioning.

Worker output does not match its contract

error: OUTPUT_CONTRACT_VIOLATION step `compose` failed: worker output for `greeting.compose` does not match its contract at $.message: expected String, found an integer

The worker returned a result whose shape differs from the contract’s output type. The runtime validates every result and fails the step without retrying, because retrying the same code would produce the same result. Fix the worker, or change the contract and bump its version. The path ($.message) points at the first mismatch.

Workflow input is invalid

error: INVALID_INPUT (400): workflow input is invalid at $.name: missing required field `name` of type String

Execution input is validated strictly before the execution is created: missing required fields, wrong types and unknown fields are all rejected. Compare the JSON you passed with --input or --input-file with the workflow’s input block. Fields with a default can be omitted.

E0405: value may be null

error[E0405]: value may be `null` but `String` is required
  --> order_processing.orbit:97:32
   |
97 |         with { transaction_id: process_payment.transaction_id }
   |                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   = help: provide a fallback with `coalesce(value, default)`, or declare the target as optional with `?`

An expression of an optional type (T?) is used where a non-optional T is required. Common sources of optional values:

  • the output of a step with when, because a skipped step’s output is null;
  • a field declared with ? in a schema or contract;
  • an index such as items[0] or headers["x"].

Fixes, depending on what the value means:

  • supply a default: coalesce(process_payment.transaction_id, "none");
  • make the target optional: declare the contract field as transaction_id: String?;
  • skip the consuming step in the same case: give it the same when condition.

Definition already registered with different content

error: DEFINITION_CONFLICT (409): Hello@1.0.0 is already registered with a different definition (sha256:505930b4d4668ff2f8cb95e04519d5292ef2fbf0630d575349aaf92c0e2b42be); workflow definitions are immutable, bump the version

You changed a workflow and deployed it under a version that is already registered. Definitions are immutable per name@version, so running executions can never change underneath you. Bump the workflow’s version and deploy again. Reformatting and comment changes do not change the definition hash and never cause this error.

During local development you can also start over with an empty database: stop the server and delete orbit.db, orbit.db-wal and orbit.db-shm. This deletes all history.

TLS errors

The runtime’s certificate is not trusted:

error: cannot reach the ORBIT runtime at https://127.0.0.1:7233: io: invalid peer certificate: UnknownIssuer

The server uses a certificate from a private CA. Give clients the CA certificate, not the server certificate:

ClientSetting
CLI--ca-cert ca.pem or ORBIT_CA_CERT
Python, Go, Rust, Java, C# workersORBIT_CA_CERT
Node.js workersNODE_EXTRA_CA_CERTS

The file must be a real CA certificate (basicConstraints=CA:TRUE, keyCertSign). Passing the server’s own certificate produces the same UnknownIssuer error. The server certificate must also name the host the client connects to (for example DNS:localhost or IP:127.0.0.1 in its subject alternative names).

The client speaks HTTPS to a server that serves plain HTTP:

error: cannot reach the ORBIT runtime at https://127.0.0.1:7233: io: received corrupt message of type InvalidContentType

The client speaks plain HTTP to a server that serves HTTPS:

error: cannot reach the ORBIT runtime at http://127.0.0.1:7233: protocol: http parse fail: invalid HTTP version

In both cases, make the scheme in ORBIT_SERVER match the server: https:// when it was started with --tls-cert and --tls-key, http:// otherwise.

The HTTP adapter is disabled

error: ADAPTER_DISABLED step `probe` failed: the HTTP adapter is disabled; start the server with --enable-http-adapter

Steps that use orbit.http.request need the server flag --enable-http-adapter. See Calling HTTP services.

Frequently asked questions

Does ORBIT guarantee exactly-once execution?

No. ORBIT delivers tasks at least once and never promises exactly-once execution of external side effects. A worker can crash after charging a card but before reporting the result; no orchestrator can tell that case apart from a crash before the charge. ORBIT treats such an outcome as “possibly done”, redelivers the same attempt with the same idempotency key, and still accepts a late result from the original delivery. The runtime records exactly one result per step, but your worker must make the side effect itself idempotent, for example by storing results by ctx.idempotency_key or passing the key to an API that deduplicates on it. See Delivery guarantees.

Can I run several runtime nodes?

Yes, for availability, with PostgreSQL. Start every server with --cluster and the same --db postgres://...: one is elected leader and runs the engine, the others stand by, serve read-only routes and take over within about a second if the leader stops. Only one server executes workflows at a time, so cluster mode does not add throughput. Put a load balancer in front that checks GET /v1/ready. See Running several servers.

Without --cluster, run only one orbit server per database, with a restart policy; after a restart it recovers every unfinished execution from history. See Deployment.

How does ORBIT compare with Temporal?

Both record workflow history durably, recover by replaying it, deliver work to workers at least once, and support workers in several languages. The main differences:

ORBIT 0.5Temporal
Workflow definitionA declarative .orbit file, compiled and type-checked against task contracts before deploymentCode in an SDK language, with determinism rules enforced at replay
Business logicWorkers, one handler per task contract; small deterministic transformations as compute stepsActivities, called from workflow code
Control flowDependencies, conditions, fan-out, child workflows, sleep, waiting for signals and compensation, declared in the languageAnything the host language can express
Signals and queriesDeclared in the workflow with typed payloads and results; every await, every use of a payload field and every query expression is type-checked at compile time, and the runtime validates each payload against its declarationDefined in workflow code through SDK handlers
Static checksType checking plus warnings for missing compensations, constant conditions, unused signals and unbounded waits (orbit check --strict), and organisation policies (orbit check --policy)The host language’s type checker and tooling
Changing a workflowA new immutable name@version; running executions keep their definition; orbit compat reports breaking changesVersioning APIs and worker build IDs
Failure handlingCompensation per step, then on failure, on cancel and on timeout handler stepsCode in the workflow, such as try/catch and cancellation scopes
Recurring messageson event <signal> blocks run once per arriving signal, alongside the main stepsSignal handlers in workflow code
Recurring startsSchedules: cron expressions (with a time zone) or fixed intervals, with overlap and catch-up policies, managed with /v1/schedules and orbit scheduleSchedules
Long-running workflowscontinue_as_new, declared in the workflow and type-checked against its input; each run has its own historyContinue-As-New, called from workflow code
Testingtest declarations run by orbit test with mocked tasks and simulated time; orbit simulate and orbit replaySDK test frameworks with time skipping, and replay testing
OperationsA web UI built into the server, the CLI, Prometheus metrics and OpenTelemetry tracesThe Temporal Web UI, CLI, metrics and tracing
Process documentationorbit graph, orbit docs, orbit schema and orbit diff generate diagrams, reference pages, JSON Schema and process diffs from the definitionNot built in
DeploymentOne binary with SQLite or PostgreSQL; one server, active/standby servers, or several active servers sharing the work on PostgreSQLA multi-service cluster with several supported databases
Features not yet in ORBITUpdates, Nexus, multi-region replication, a managed cloud

ORBIT suits processes that you want to read, review and check as a whole, across services in different languages. Temporal is a mature platform that suits workflows whose logic is best expressed as general-purpose code.

Do workers need the .orbit files?

No. A worker only needs the task names and contract versions it implements, and the runtime’s address and credentials. The compiler uses the .orbit files; the runtime uses the compiled IR.

Which languages can workers use?

The SDKs cover Python, TypeScript and JavaScript, Go, Rust, Java and C#. Any other language can implement the worker protocol over HTTP/JSON or gRPC. See Worker protocol.

What happens if the runtime crashes?

Every state change is committed to the database before it takes effect. On restart, the runtime reloads every unfinished execution, replays its history, fires overdue timers and retries, and gives workers one session timeout (30 seconds by default) to report results for tasks that were in flight. Tasks that nobody reports are redelivered with the same idempotency key. The SDKs keep retrying result delivery during an outage, so results produced while the runtime was down are not lost.

Can I change a workflow while executions are running?

Yes, by deploying a new version. Running executions stay pinned to the definition hash they started with, and new executions can use the new version. You cannot change the definition of a running execution.

Where is the data stored?

In the database given by --db: a SQLite file (orbit.db in the current directory by default) or a PostgreSQL URL. It holds definitions, executions and the full event history. See Storage.

Is there a web UI?

Yes. orbit server serves one at /ui/: executions with their steps, history and graph, workflows, workers and schedules, and actions to run queries, send signals and cancel executions. Disable it with --no-ui. See Web UI. The CLI (orbit ps, status, logs, inspect, workers list), Prometheus metrics at /v1/metrics and OpenTelemetry traces are also available; see Observability.

Does ORBIT run on Windows and macOS?

Yes. Prebuilt binaries are published for Linux (x86_64, arm64) and Windows (x86_64). On macOS, install with cargo install orbit-lang. See Installation.

See also

Contributing

This page reproduces CONTRIBUTING.md from the repository root. Paths in it are relative to the root of a clone. To report a security issue, follow SECURITY.md instead of opening a public issue.

Thanks for helping. This file covers how to get a working setup, how the code is laid out, and what a good change looks like.

Setup

Any of these works:

  • VS Code + dev container (everything preinstalled): open the folder and choose Reopen in Container. .devcontainer/ installs Rust, Node, Python, Go, Java, .NET, PostgreSQL and openssl.
  • Locally: Rust 1.89+ is required. Node 22, Python 3.9+, Go 1.21+, Java 17+ and .NET 8 are needed only for the SDK you work on.
cargo build
cargo test
./scripts/check.sh

./scripts/check.sh runs everything CI runs and skips toolchains you don’t have. In VS Code, the same checks are tasks (Terminal → Run Task), and .vscode/launch.json can debug the server or the VS Code extension.

Layout

PathWhat
crates/orbit-syntaxlexer, parser, AST, diagnostics, formatter
crates/orbit-irorbit-ir/1: types, expressions, invariants
crates/orbit-compilername resolution, type checking, lowering
crates/orbit-runtimeengine, decider, storage, HTTP/gRPC servers, identities, telemetry
crates/orbit-clithe orbit binary, including the language server
crates/orbit-worker, sdks/*worker SDKs (Rust, Python, TypeScript, Go, Java, C#)
protocol/the canonical worker protocol (worker.proto)
specification/RFC-0001 and the grammar
editors/vscodethe VS Code extension
tests/e2eend-to-end tests with real processes

Guidelines

  • Semantics come first. Changes to the language or to runtime behaviour must update specification/ (grammar, RFC appendix) and docs/, and must include tests: compiler tests for new diagnostics, engine tests for new runtime behaviour, e2e tests when processes are involved.
  • Keep the decider pure. crates/orbit-runtime/src/decide.rs must not do I/O or read the clock. Every decision has to be reproducible from history.
  • Never weaken durability. Events are persisted before memory changes, and delivery is at-least-once. Don’t add shortcuts that skip either.
  • Diagnostics are an API. Error codes never change meaning. Add new codes to docs/src/reference/diagnostics.md.
  • Protocol changes go into protocol/orbit/worker/v1/worker.proto and its copy in crates/orbit-runtime/proto/. A test checks the two are identical. Keep changes backwards compatible within v1.
  • Run cargo fmt, cargo clippy --all-targets -- -D warnings and orbit fmt on any .orbit files you touch.

Pull requests

Keep pull requests focused, explain the why, and mention the tests you added. CI must be green. By contributing you agree that your contributions are licensed under the Apache License 2.0, the same license as the project.

Changelog

This page reproduces CHANGELOG.md from the repository root. Releases and their assets are listed on GitHub Releases.

All notable changes are listed here. The project follows Semantic Versioning. The language, the IR (orbit-ir/1, orbit-ir/2) and the worker protocol (orbit.worker.v1) are versioned separately, as described in RFC-0001 §17.

[0.5.0] — 2026-11-15

Language

  • Continue-as-new. continue_as_new [when <cond>] with <input> ends a run when its steps are done and starts the next run of the same execution with a new input and an empty history; the input is type-checked against the workflow’s input. Each run’s history is kept (?run=N, orbit logs --run). New diagnostics E0323, E0324, W0607, E0712, E0713.
  • Condition narrowing. A step whose when contains every conjunct of a conditional dependency’s when, or !is_null(dep), reads that dependency as non-optional, so guarded steps no longer need coalesce.
  • A line that starts with a binary operator (other than -) continues the expression, so long conditions can span lines.
  • /// doc comments on workflows, steps, tasks and fields are rendered by orbit docs (they do not change the definition hash).

Tooling

  • orbit graph: Mermaid, Graphviz DOT, SVG and sequence diagrams of a workflow, including compensations, handlers, on event blocks and signals.
  • orbit docs: reference pages for workflows and task contracts, in Markdown or self-contained HTML, with diagrams and optional JSON Schema.
  • orbit schema: JSON Schema (draft 2020-12) for every workflow input, output, signal and query, and every task input and output.
  • orbit diff: a business-process diff between two versions (steps, dependencies, conditions, retries, compensation, handlers, signals, queries, limits) in text, Markdown or JSON, with the compatibility verdict.
  • orbit check|compile|deploy --policy: organisation rules in TOML (require_compensation, require_approval above a threshold, require_retry, max_attempts, require_timeout, require_handler, allow_tasks, forbid_tasks, max_fan_out, require_limits, require_signal_timeout), with exemptions and inline waivers. Diagnostics P0001-P0111.
  • orbit test: list expectations match element by element with partial records; expect runs N for continue-as-new.
  • orbit schedule create|update|list|get|pause|resume|trigger|delete.
  • Diagnostics print each source line once, with every marker underneath.
  • The language server shows a step’s condition, contract, retry, timeouts and compensation on hover.

Runtime

  • Web UI. orbit server serves an operations UI at /ui/: overview, executions (steps, history timeline, step graph, runs), workflows, workers and schedules, with queries, signals and cancellation. Client identities see only what they may; strict Content-Security-Policy; --no-ui turns it off.
  • Schedules. Durable cron (5-field, time zones) and interval schedules with overlap (skip, allow, cancel_previous) and catch-up policies, pause, start and end times and jitter. Each occurrence starts exactly one execution, on one server, in standby or shared clusters. /v1/schedules, a new client action schedule, and metrics.
  • Shared mode: a cancel forwarded to the owning server answers with the state after the cancellation’s decisions, as a single server does.

Examples and guides

  • New examples with tests: onboarding (KYC), expense (multi-level approval), agent (AI agent with guardrails), subscription (continue-as-new); policy files kyc.toml and fintech.toml.
  • New guides: typed business process contracts, human approvals, AI agents with guardrails; pages “Why ORBIT” and “About the project”.

[0.4.0] — 2026-11-15

Language

  • Event handlers. on event <signal> { steps } runs a block once per arriving signal of that name, alongside the main steps, with the payload as event. The execution finishes when the main steps and every started block are done; a failing event step fails the execution. New diagnostic E0322 (a signal both awaited and handled); E0315 and E0319 cover unknown signals and duplicate blocks.
  • Per-workflow limits. limits { max_fan_out, max_history_events, max_payload_bytes } tightens the server’s limits for one workflow; the stricter value applies. New diagnostics E0320 and E0321.

Tooling

  • orbit test: signals handled by on event are sent as soon as the execution is idle; expect step on_event:<signal>:<step> checks every instance of the block.
  • orbit token --client <id> prints a [[client]] entry (--workflow, --action).
  • orbit inspect lists on event blocks and the limits block.
  • New benchmark programs, bench_engine and bench_http, and a Performance page with measured numbers.

Runtime

  • Several active servers. orbit server --cluster --cluster-mode shared runs every server on one PostgreSQL database as active. Each execution tree is owned by one server and fenced by an epoch; when a server stops heartbeating (--node-timeout), the others take its executions over. Tasks are claimed from a shared queue (SKIP LOCKED, LISTEN/NOTIFY), signals, cancels and results reach the owner through an inbox, and worker sessions are cluster-wide, so a round-robin load balancer works. Standby mode (--cluster-mode standby, the default) is unchanged.
  • Workers are handed work from an index of scheduled steps, so polling no longer scans every running execution: throughput stays flat as the number of executions in flight grows (about 20 times faster with 5000 running).
  • Worker last-seen times are persisted at most once a second instead of on every poll; PostgreSQL commits write the events and the execution row in one statement.
  • Child workflows no longer count toward --max-active-executions.
  • A client identity’s execution list fills its page with executions it may read.

[0.3.0] — 2026-11-15

Language

  • Outcome handlers. on failure, on cancel and on timeout blocks hold steps that run after compensation, before the execution’s terminal status, with a typed failure value (reason, code, message). A failed handler step is recorded but does not change the outcome. New diagnostic E0319.
  • Tests in the language. test "..." { ... } declarations with mocked task results (returns, fails, hangs, times N then ...), signals and expectations on status, output, error, steps (including handler steps) and compensations. Tests are type-checked by every command and never reach the IR. New diagnostics E0700–E0711 and W0701.

Tooling

  • orbit test: runs test declarations in-process against the real engine, with no server or workers; durable time is fast-forwarded. --filter, --format json.
  • orbit simulate: runs a workflow locally with mocks or generated sample results, --fail task=CODE and --signal, and prints the timeline.
  • orbit replay: re-runs a recorded history through the current runtime and reports any decision it would make differently; --at <seq> shows the state at any point. Works against a server or offline files.

Runtime

  • Several servers. orbit server --cluster runs active/standby on a shared PostgreSQL database: leader election with fencing, automatic failover (about a second after the active server dies), read-only standbys, GET /v1/ready for load balancers, 503 NOT_LEADER with the leader’s address. The CLI follows the leader automatically.
  • Resource limits. --max-active-executions (429 RESOURCE_EXHAUSTED), --max-fan-out, --max-history-events (the execution fails with LIMIT_EXCEEDED; compensation and handlers still run) and --max-payload-bytes (413 PAYLOAD_TOO_LARGE).
  • Client identities. [[client]] entries in the workers file give an application the execution API for some workflows and actions only (start, signal, query, read, cancel).

Changed

  • sum() over a Float list returns 0.0 for an empty list. Workflows that call sum on a decimal list compile to a new definition hash: give them a new version when you redeploy.
  • Durations print as 2d, 1d6h, 2h15m; orbit inspect lists signals, queries and handlers.

[0.2.0] — 2026-11-15

Language

  • Signals and queries. signal <name> { ... } declares a typed message a workflow accepts; an await <name> step waits for it durably (with timeout, failing with SIGNAL_TIMEOUT). Signals sent early are buffered and delivered in order. query <name> = <expr> declares a typed, read-only view of the execution state.
  • Compute steps. compute <expr> evaluates a deterministic expression in the runtime, with no worker; its output type is inferred. New lambdas (x => ...) and built-ins map, filter, find, any, all, sum.
  • Static safety checks. Task contracts can declare compensated_by; the compiler warns about missing or mismatched compensations (W0601, W0602), steps that never run or conditions that are always true (W0603, W0604), signals nobody awaits (W0605) and waits without a timeout (W0606). orbit check --strict / orbit compile --strict turn warnings into errors.
  • New diagnostics E0315–E0318 and E0415.

Tooling

  • orbit compat <old> <new>: reports breaking changes between two versions of workflows and contracts (inputs, outputs, signals, queries) and the version bump they require; JSON output for CI.
  • orbit signal and orbit query.
  • orbit fmt keeps Windows (CRLF) line endings. The CLI exits quietly when its output pipe closes (orbit logs --json | head).

Runtime

  • POST /v1/executions/{id}/signals/{name} and GET /v1/executions/{id}/queries/{name}; history events SignalReceived, SignalAwaitStarted, SignalDelivered.
  • orbit-ir/2 for workflows that use the new features. Workflows that do not still compile to orbit-ir/1 with unchanged definition hashes, so existing deployments are unaffected. The runtime accepts both.
  • A failed compensation no longer stops the others: every remaining compensation runs, and each failure is reported.
  • Graceful shutdown on SIGTERM as well as SIGINT; the container image sets STOPSIGNAL SIGINT.
  • float(Bool) evaluates at run time, as the compiler allows.

SDKs

  • Python and TypeScript clients: signal() and query().

Project

  • Documentation website with a landing page and complete documentation, deployed to GitHub Pages.
  • install.sh explains how to install when a release has no binary for the platform (macOS: cargo install orbit-lang).

[0.1.0] — 2026-11-15

First public version.

Language and compiler

  • Workflows, steps, task contracts, schemas, imports, defaults.
  • Static structural type checking with optional types, coalesce, and null-propagating member access.
  • Dependencies from after and from data references; cycle detection.
  • Retries (fixed or exponential, non_retryable), per-attempt and queue timeouts, workflow timeouts, idempotency keys, conditions (when), durable sleep, compensation (Saga).
  • for item in <list> fan-out with max_parallel, and child workflows (workflow "Name").
  • orbit-ir/1: deterministic, hashed and validated.
  • Located diagnostics with stable codes and suggestions.

Runtime

  • Event-sourced durable engine with a pure decider; crash recovery and reconciliation of uncertain attempts.
  • SQLite and PostgreSQL storage (TLS via sslmode).
  • HTTP API, plus the worker protocol over HTTP/JSON and gRPC.
  • Admin, shared-worker and per-worker credentials; TLS and mutual TLS.
  • OpenTelemetry trace export (OTLP/HTTP), Prometheus metrics, structured logs.
  • Built-in HTTP adapter (opt-in).

Tooling

  • orbit CLI: init check validate compile fmt graph server deploy run status inspect logs cancel ps workers token lsp.
  • Language server and VS Code extension.
  • Worker SDKs: Python, TypeScript, Go, Rust, Java and C#.

About the project

ORBIT is an open-source workflow language and durable runtime created and maintained by AjmalX0. It is developed in the open at github.com/AjmalX0/Orbit and released under the Apache License 2.0.

Getting involved

Citing ORBIT

The repository includes a CITATION.cff; GitHub’s “Cite this repository” button produces APA and BibTeX entries from it.