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
| Part | What it does |
|---|---|
| Language | Declares schemas, task contracts and workflows in .orbit files, including the typed signals a workflow accepts and the queries it answers. |
| Compiler | Resolves 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). |
| IR | orbit-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. |
| Runtime | orbit 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. |
| Workers | Your 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_bymust 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
testblocks that run in milliseconds, with time simulated. - A change is reviewable:
orbit compatreports 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 want | Better fit |
|---|---|
| The process reviewed and checked as a whole, with compile-time guarantees | ORBIT |
| One definition orchestrating services in several languages | ORBIT |
| Arbitrary control flow in a general-purpose language | Temporal |
| A large ecosystem, a managed cloud and years of production use | Temporal |
| A single binary you can run on a laptop or a small VM | ORBIT |
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
| Platform | Recommended method | Alternatives |
|---|---|---|
| Linux x86_64 (glibc) | Install script | Manual download, cargo install, Docker, from source |
| Linux arm64 (glibc) | Install script | Manual download, cargo install, Docker, from source |
| macOS (Intel or Apple silicon) | cargo install orbit-lang | Docker, from source |
| Windows x86_64 | Manual download | cargo install, Docker |
| Containers and servers | Docker image | Any 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:
| Variable | Default | Purpose |
|---|---|---|
ORBIT_VERSION | latest | Release tag to install, for example v0.1.0. |
ORBIT_INSTALL_DIR | ~/.local/bin, or /usr/local/bin when run as root | Directory the binary is installed into. |
ORBIT_DOWNLOAD_URL | GitHub Releases | Base 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:
| Tag | Contents |
|---|---|
ghcr.io/ajmalx0/orbit:v0.1.0 | the v0.1.0 release |
ghcr.io/ajmalx0/orbit:latest | the 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:
| Language | Package | Install | Requirements |
|---|---|---|---|
| Python | orbit-lang-sdk on PyPI | pip install orbit-lang-sdk | Python 3.9+, no dependencies |
| TypeScript and JavaScript | orbit-lang on npm | npm install orbit-lang | Node.js 18+ (22.18+ to run .ts files directly) |
| Go | github.com/AjmalX0/Orbit/sdks/go | go get github.com/AjmalX0/Orbit/sdks/go@v0.1.0 | Go 1.21+, no dependencies |
| Rust | orbit-worker on crates.io | cargo add orbit-worker | Rust 1.89+ |
| Java | io.github.ajmalx0:orbit-sdk on Maven Central | see below | Java 17+ |
| C# and .NET | OrbitLang.Sdk on NuGet | dotnet 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
| Method | Upgrade command |
|---|---|
| Install script | Run the script again. It replaces the binary in ORBIT_INSTALL_DIR. |
| Manual download | Download the new archive and replace the binary. |
cargo install | cargo install orbit-lang --locked (Cargo replaces an older version). |
| Docker | docker pull ghcr.io/ajmalx0/orbit:<new tag>, then recreate the container with the same volume. |
| From source | git pull, then cargo install --path crates/orbit-cli --locked. |
| SDKs | pip 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 extension | Install 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: create a project and run your first workflow.
- Running the server: server options and storage.
- How workers work: the worker model and the SDKs.
- CLI reference: every
orbitcommand.
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
orbitCLI, version 0.1.0 or later. See Installation. - Python 3.9 or later.
- Node.js 22.18 or later. The TypeScript worker is a
.tsfile 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
taskdeclares 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.
Hellotakes anameand an optionalexcitement(default3), runs two steps and returns a greeting.decoratereadscompose.message, so it depends oncomposeand runs after it.using pythonandusing typescriptroute 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:
- Compiled and deployed the file. The runtime registered
Hello@1.0.0under the content hashsha256:505930b4d466.... Running the same file again printsup to dateinstead ofdeployed. - Started an execution with the input
{"name": "Ada"}. The runtime applied the defaultexcitement: 3and validated the input against the workflow’sinputtype. - 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 apost_id. Itscompensateblock 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
composeattempt 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).
notifyfailed withretryable: false, so the runtime failed the step immediately, without using the remaining attempts.notifyhas noretryblock, so it would have had only one attempt in any case. - Compensation (#20 to #24). Because the workflow failed after
publishsucceeded, the runtime ranpublish’s compensation,greeting.unpublish, with thepost_idthatpublishreturned. Steps without acompensateblock (compose,decorate) have nothing to undo. - Outcome (#25). The execution ended
FAILEDwithcompensation: 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 checkvalidates it before anything runs. - Workers in any language register contracts and pull tasks from the runtime.
orbit rundeploys and starts an execution;orbit status,orbit logs,orbit inspectandorbit psshow what happened and why.- Retries are policy in the workflow, not code in the worker. A
retryable: falseerror 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: precise definitions of the terms used here.
- Project layout: how to organize a larger project and use it in CI.
- Orders with compensation: a five-step order workflow with parallel steps, a condition and compensation.
- Retries and timeouts and Compensation: the full rules.
- Python and TypeScript and JavaScript: the SDK reference.
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 kind | Declared with | Executed by |
|---|---|---|
| Task step | worker "task.name" | a worker that implements the contract (or the runtime, for orbit.http.request) |
| Child workflow step | workflow "Name" | the runtime, which starts a child execution |
| Sleep step | sleep 24h | the runtime, as a durable timer |
| Await step | await review | the runtime, which waits for a signal |
| Compute step | compute <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 key | Created for |
|---|---|
reserve | an 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.
| Status | Meaning |
|---|---|
RUNNING | Steps are being scheduled and executed. |
COMPENSATING | The execution is aborting and running compensations. |
COMPLETED | Every step finished and return produced the output. |
FAILED | A step failed permanently. |
CANCELLED | Someone cancelled the execution (orbit cancel). |
TIMED_OUT | The 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@versionis rejected withDEFINITION_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:
- withdraws work that has not started,
- waits for in-flight attempts to finish (they may still succeed),
- 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
| Concept | Belongs to | Identified by |
|---|---|---|
| Task contract | a compilation (any .orbit file) | name@version |
| Workflow | a compilation | name and version |
| Definition | the runtime | name@version and definition_hash |
| Execution | a definition | execution id |
| Unit | an execution | unit key (step, step[i], compensate:step, on_failure:step) |
| Signal | an execution’s buffer | name, and the sequence number of its SignalReceived event |
| Query | a workflow | name |
| Attempt | a unit | attempt number |
| Event | an execution’s history | sequence number (#1, #2, …) |
| Child execution | a parent unit | <parent>.<step> |
| Worker | the runtime’s worker registry | worker id |
See also
- Quick start
- Steps
- Signals and queries
- Outcome handlers
- Delivery guarantees
- Executions and history
- Worker protocol
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
| File | Contents |
|---|---|
workflows/hello.orbit | Two 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.py | A Python worker with id hello-python that implements greeting.compose. |
workers/python/requirements.txt | orbit-lang-sdk>=0.1,<0.2 |
workers/typescript/worker.ts | A TypeScript worker with id hello-typescript that implements greeting.decorate. Node.js 22.18 or later runs it directly. |
workers/typescript/package.json | A private ES module package that depends on orbit-lang ^0.1.0. |
.gitignore | Ignores node_modules/, __pycache__/, the SQLite database files (*.db, *.db-wal, *.db-shm) and build/ (the default output directory of orbit compile). |
README.md | The 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.
Recommended layout
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 aspayments.*, 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
.orbitfiles.
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
- Edit the workflow and bump its
version, for example from1.0.0to1.1.0. - Run
orbit check. - Deploy it with
orbit deploy, or letorbit rundeploy 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.
| Command | Fails 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 build | a 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:
| Declaration | Purpose | Page |
|---|---|---|
schema Name { ... } | A named record type | Schemas |
task "name" { ... } | A worker contract | Task contracts |
workflow Name { ... } | A workflow definition | Workflows |
import "path.orbit" | Include another file in the compilation | Imports |
test "name" { ... } | A test of a workflow, run by orbit test | Testing 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.orbitandb.orbitare the same file. -
Import cycles are allowed. If
a.orbitimportsb.orbitandb.orbitimportsa.orbit, each file is still loaded once. -
An import that cannot be read is reported as
E0206at theimportdeclaration: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
| Type | Values | JSON encoding | How to write a value |
|---|---|---|---|
String | Unicode text | string | "text" |
Int | signed 64-bit integer | integer | 42, -7, 1_000_000 |
Float | 64-bit floating point | number (integers accepted) | 3.14, -0.5; an Int also converts |
Bool | true or false | boolean | true, false |
Bytes | binary data | base64 string (standard alphabet, padded) | no literal; comes from input or step output |
Duration | a length of time | non-negative integer, in milliseconds | 500ms, 30s, 1.5m, 2h, 1d |
Timestamp | a point in time | RFC 3339 string | a string literal such as "2026-01-31T09:00:00Z" |
UUID | a UUID | hyphenated string, 8-4-4-4-12 hex digits | a string literal such as "123e4567-e89b-12d3-a456-426614174000" |
JSON | any JSON value | any | any 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),hord. Fractional values are allowed and rounded to the nearest millisecond (1.5sis 1500 ms). A bare number where a duration is required isE0101, and an unknown unit isE0005.Durationsupports no operators: you cannot add or compare durations. - Timestamp. The accepted form is
YYYY-MM-DDTHH:MM:SS[.fraction](Z|±HH:MM). TheTmay also betor a space, andZmay bez. A string literal checked againstTimestampis 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
UUIDare validated at compile time (E0400). - Bytes. A string literal is a
String, notBytes, so passing"aGVsbG8="whereBytesis required isE0400. PassBytesvalues through from the workflow input or a step output, or declare the field asStringif you build the encoding yourself. - String to UUID or Timestamp. Only literals are converted. A
Stringvalue read from input or a step output is not accepted whereUUIDorTimestampis required; declare the source field with the precise type.
Composite types
| Type | Values | JSON encoding |
|---|---|---|
List<T> | ordered list of T | array |
Map<String, T> | string keys to T values | object |
T? | a T or null | null or the encoding of T |
{ a: Int, b: String } | anonymous record | object |
Name | the record declared by schema Name | object |
- Map keys must be
String, because maps are JSON objects.Map<Int, String>isE0204. T??is the same type asT?.- 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.ListandMapwithout type arguments areE0201too.
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
whencondition, 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:
| Rule | Example |
|---|---|
| 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:
| Expression | Inferred 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 null | String? |
if c then 1 else 2.5 | Float |
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:
| Boundary | Defaults applied | Unknown fields | Failure |
|---|---|---|---|
| Execution input (start request) | yes | rejected | request fails with INVALID_INPUT |
| Child workflow input | yes | allowed | step fails with CHILD_START_FAILED |
Task input, after evaluating with | yes | allowed | step fails with INPUT_CONTRACT_VIOLATION |
| Worker output | yes | allowed | attempt fails with OUTPUT_CONTRACT_VIOLATION (not retried) |
Workflow result, after evaluating return | no | allowed | workflow 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:
| Declaration | Required in a value? | If absent |
|---|---|---|
name: T | yes | the value is invalid (E0403 at compile time, a validation error at runtime) |
name: T? | no | the field is absent; reading it gives null |
name: T = value | no | the 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,executionor a step:E0401, for exampleerror[E0401]: `input` cannot be used in a default value; - a value of the wrong type:
E0400; for examplename: String = nullis rejected becausenullis not aString(declareString?instead); - an expression that fails when evaluated, such as
1 / 0:E0205.
Rules the compiler enforces
| Code | Rule |
|---|---|
E0200 | A schema name is declared only once in the compilation. |
E0200 | A schema cannot be named after a built-in type (String, Int, Float, Bool, Bytes, Duration, Timestamp, UUID, JSON, List, Map). |
E0203 | A field name appears only once in a record. |
E0202 | Schemas cannot be recursive, directly or through other schemas, even through an optional field. |
E0201 | Every 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
| Member | Type | Default | Meaning |
|---|---|---|---|
version | string | "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. |
description | string | none | Human-readable description, shown by tooling. |
input | record type | {} | The type of the value the workflow passes in with. |
output | record type | {} | The type of the value the worker returns. |
compensated_by | string | none | The 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
| Code | Rule |
|---|---|
E0200 | A task name is declared once in the compilation, including imported files. |
E0200 | A task name cannot start with orbit.. |
E0310 | input and output are record types or JSON. |
W0201 | version is MAJOR.MINOR.PATCH, optionally with -pre or +build suffixes. |
E0300 | A step’s worker "name" refers to a declared task. The diagnostic suggests the closest name. |
E0316 | compensated_by names a declared task. The diagnostic suggests the closest name. |
W0601 | A step that calls a task with compensated_by has a compensate block. |
W0602 | That 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
withexpression is checked against the contractinput. Object literals must provide every required field (E0403) and must not contain unknown fields (E0404). See Types. - A step without
withis accepted only if the contract input has no required fields. Otherwise the compiler reportsE0403and lists the missing fields. - The contract
outputis the type other expressions see when they reference the step, for examplecharge.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:
- 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 errorINPUT_CONTRACT_VIOLATION. - 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. Anullresult 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
| Member | Syntax | Required | Default | Meaning |
|---|---|---|---|---|
version | version "1.0.0" | yes (E0500) | none | The definition version. name@version is immutable once registered. |
description | description "..." | no | none | Human-readable description, shown by tooling. |
timeout | timeout 5m | no | no limit | Deadline for the whole execution, measured from its creation. |
input | input { fields } | no | {} | The type of the execution input. |
output | output { fields } | no | inferred from return | The type of the execution result. |
step | step name { ... } | no | none | A unit of work. See Steps. |
signal | signal name { fields } | no | none | A typed message the execution accepts; await steps receive it. See Signals and queries. |
query | query name = expression | no | none | A typed, read-only view of the execution state, evaluated on demand. See Signals and queries. |
return | return expression | when output has required fields (E0501) | {} | The result of a successful execution. |
continue_as_new | continue_as_new when cond with { ... } | no | none | When 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 failure | on failure { steps } | no | none | Steps that run after compensation when a step fails permanently or a resource limit is reached. See Outcome handlers. |
on cancel | on cancel { steps } | no | none | Steps that run after compensation when the execution is cancelled. |
on timeout | on timeout { steps } | no | none | Steps that run after compensation when the workflow timeout passes. |
on event | on event signal { steps } | no | none | Steps that run every time that signal arrives, alongside the main steps; they read the payload as event. See Event handlers. |
limits | limits { max_fan_out: 100 } | no | the server’s limits | Stricter 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:
- treats a missing or
nullinput as{}, - applies the declared defaults, recursively,
- 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
outputandreturn, thereturnexpression is checked againstoutput, with the same rules as a task input: object literals must be exact (E0404) and complete (E0403). - With
outputbut noreturn, the result is{}, which is accepted only ifoutputhas no required fields. Otherwise the compiler reportsE0501. - With
returnbut nooutput, the output type is the inferred type of thereturnexpression. - 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:
- withdraws steps that have not started,
- waits for attempts that are in flight,
- runs the compensations of the steps that succeeded,
- 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:
| Expression | Type | Value |
|---|---|---|
execution.id | String | the execution id |
execution.workflow | String | the workflow name |
execution.version | String | the 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:
| Action | Syntax | What runs |
|---|---|---|
| Task | worker "task.name" | A task, executed by a worker that implements the contract, or by the runtime for built-in tasks. |
| Child workflow | workflow "Name" | Another workflow, as a durable child execution. See Child workflows. |
| Timer | sleep 10s | A durable timer. See Durable sleep. |
| Signal wait | await review | Waits for the next review signal sent to the execution; the payload is the output. See Signals and queries. |
| Compute | compute <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
| Member | Syntax | Default | Meaning | Page |
|---|---|---|---|---|
worker | worker "name" | Run a task. | Task contracts | |
workflow | workflow "Name" | Start a child workflow. | Child workflows | |
sleep | sleep DURATION | Wait durably. | Durable sleep | |
await | await SIGNAL | Wait for a signal declared in the workflow. | Signals and queries | |
compute | compute EXPR | Evaluate an expression in the runtime. | Compute steps | |
with | with EXPR | {} | The input passed to the task or child workflow. | below |
using | using LANGUAGE | any worker | Route the task only to workers of this runtime. | below |
after | after a, b | none | Run after these steps. | Dependencies |
when | when EXPR | always run | Run only if the condition is true; otherwise skip. | Conditions |
for | for x in EXPR | none | Run once per list element. | Fan-out |
max_parallel | max_parallel N | unlimited | Concurrency limit for a for step, 1 to 10000. | Fan-out |
timeout | timeout DURATION | no limit | Limit for one attempt, from dispatch to result. On an await step: limit for the wait. | Retries and timeouts |
queue_timeout | queue_timeout DURATION | no limit | Limit for waiting for a worker. | Retries and timeouts |
retry | retry { ... } | one attempt | Retry policy. | Retries and timeouts |
idempotency_key | idempotency_key EXPR | <execution id>/<unit> | The key workers use to deduplicate side effects. | Idempotency |
compensate | compensate { ... } | none | The action that undoes this step. | Compensation |
Syntax notes:
-
Every member except
retry,compensateandforaccepts an optional:after the keyword:timeout: 30sis the same astimeout 30s.retryandcompensateare followed directly by{, andretry: {is a syntax error. -
DURATIONis a duration literal such as30s. Expressions are not accepted where a duration is required. -
aftertakes a comma-separated list of step names. You can repeatafter; 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):
| Member | Task step | Workflow step | Sleep step | Await step | Compute step | compensate block |
|---|---|---|---|---|---|---|
with | yes | yes | no | no | no | yes |
using | yes | no | no | no | no | yes |
after | yes | yes | yes | yes | yes | no |
when | yes | yes | yes | yes | yes | no |
for | yes | yes | no | no | yes | no |
max_parallel | with for | with for | no | no | with for | no |
timeout | yes | yes | no | yes | no | yes |
queue_timeout | yes | no | no | no | no | yes |
retry | yes | no | no | no | no | yes |
idempotency_key | yes | no | no | no | no | yes |
compensate | yes | yes | no (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:
| Step | Type of name |
|---|---|
worker "t" | the output type of task t |
workflow "W" | the output type of workflow W |
sleep d | {} |
await s | the payload record type of signal s |
compute e | the type of expression e |
with when | the type above, made optional (T?) |
with for | List<T>, one element per list item |
with for and when | List<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:
| State | Meaning |
|---|---|
PENDING | Waiting for dependencies. |
SCHEDULED | Input evaluated, queued for a worker. |
DISPATCHED | A worker (or the runtime) is executing the attempt. |
RETRY_WAIT | A retry is scheduled after a backoff delay. |
WAITING | A sleep timer is running, or an await step is waiting for a signal. |
RUNNING | A for step whose elements are in progress. |
SUCCEEDED | Finished successfully. |
SKIPPED | Its when condition was false or null. |
FAILED | Failed permanently. |
CANCELLED | Withdrawn because the workflow was aborted. |
See Executions and history for the events behind these transitions.
See also
- Dependencies
- Conditions
- Retries and timeouts
- Fan-out
- Compensation
- Signals and queries
- Compute steps
- Compiler diagnostics
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:
| Member | Creates a dependency |
|---|---|
with | yes |
when | yes |
idempotency_key | yes |
for ... in (the list expression) | yes |
compensate { ... } | no; a compensation can only read the step and its existing dependencies |
workflow return | no; 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:
- every direct dependency has finished as
SUCCEEDEDorSKIPPED, - the workflow is not being aborted (no step has failed permanently, and the execution has not been cancelled or timed out),
- its
whencondition, 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
| Code | Rule |
|---|---|
E0303 | Every name in after is a step of the same workflow. |
E0305 | A step cannot list itself in after or read its own output. |
E0304 | The graph has no cycles. |
E0401 | A 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:
| Result | Effect |
|---|---|
true | The step is scheduled. |
false | The step is skipped. |
null | The step is skipped. |
| an expression error | The 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:
| Expression | Type |
|---|---|
process_payment | { transaction_id: String }? |
process_payment.transaction_id | String? |
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.
| Setting | Type | Default | Meaning |
|---|---|---|---|
attempts | integer literal, 1 to 1000 | 1 | Total number of attempts, including the first. 1 means no retries. |
backoff | fixed or exponential | fixed | How the delay between attempts grows. |
initial_delay | duration literal | 1s | The delay before the first retry. |
max_delay | duration literal | 60s, or initial_delay if that is longer | The upper bound for exponential delays. Must be at least initial_delay. |
non_retryable | list 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:
| Problem | Message |
|---|---|
attempts outside 1 to 1000 | `attempts` must be between 1 and 1000 |
non-literal attempts | `attempts` must be an integer literal |
| unknown backoff | unknown 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 code | error codes must be string literals |
| unknown setting | unknown retry setting `backof` (with a suggestion) |
| repeated setting | duplicate retry setting `attempts` |
Backoff
The delay before attempt n (attempt 2 is the first retry) is:
| Backoff | Delay before attempt n |
|---|---|
fixed | initial_delay |
exponential | min(initial_delay × 2^(n−2), max_delay) |
For attempts: 6, backoff: exponential, initial_delay: 500ms, max_delay: 4s:
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| Delay before it | none | 500 ms | 1 s | 2 s | 4 s | 4 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:
- the error is retryable,
- the error code is not listed in
non_retryable(codes are compared exactly, including case), - attempts remain (
attempt < attempts), - 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:
| Code | Retryable | Cause |
|---|---|---|
TIMEOUT | yes | The attempt exceeded the step timeout. |
HTTP_ERROR | depends | Network failures and 5xx/429 responses are retryable; invalid requests are not. See HTTP adapter. |
QUEUE_TIMEOUT | no | No worker picked the task up within queue_timeout. |
INPUT_CONTRACT_VIOLATION | no | The evaluated input does not match the contract. |
OUTPUT_CONTRACT_VIOLATION | no | The worker’s output does not match the contract. |
EXPRESSION_ERROR | no | Evaluating with, when, for or idempotency_key failed. |
ADAPTER_DISABLED | no | A 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 }
}
| Property | Value |
|---|---|
| Default | no limit (orbit.http.request uses 30 seconds when no timeout is set) |
| Applies to | task steps, workflow steps and compensate blocks |
| On expiry | the attempt fails with TIMEOUT, which is retryable |
| Zero | rejected (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
}
| Property | Value |
|---|---|
| Default | no limit: a task waits until a matching worker connects |
| Measured from | the moment the attempt is scheduled (each retry starts a new wait) |
| Applies to | task steps and compensate blocks; not workflow or sleep steps |
| On expiry | the step fails with QUEUE_TIMEOUT, which is never retried |
| Redeliveries | not limited by queue_timeout |
| Zero | rejected (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
| Setting | Task step | Workflow step | Sleep step | compensate block |
|---|---|---|---|---|
retry | yes | no (E0307) | no (E0307) | yes |
timeout | yes | yes | no (E0307) | yes |
queue_timeout | yes | no (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>
| Unit | Example key |
|---|---|
| A step | 7f3c…/process_payment |
Element 2 of a for step | 7f3c…/reserve[2] |
| A step’s compensation | 7f3c…/compensate:process_payment |
| The compensation of element 2 | 7f3c…/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 isE0400, and aString?isE0405. Convert numbers withstring. - The expression can read
input,execution, other steps (which become dependencies) and, in aforstep 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
nullat runtime (possible only through aJSONvalue), the default key is used. - An evaluation error fails the step with
EXPRESSION_ERROR. idempotency_keyis allowed on task steps and incompensateblocks. 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
| Aspect | Behavior |
|---|---|
| Start | When the step becomes eligible, the runtime records TimerStarted with the wake-up time (now plus the duration). The unit is WAITING. |
| End | When the wake-up time passes, the runtime records TimerFired and the step succeeds. |
| Output | {}. Steps that only need ordering use after. |
| Restarts | Timers are recomputed from history. A timer that came due while the runtime was down fires right after startup. |
| Workers | None. Sleep steps are handled entirely by the runtime. |
| Workflow deadline | Sleeping counts against the workflow timeout. |
| Abort | If the workflow fails, is cancelled or times out while the timer is running, the step is withdrawn (CANCELLED). |
| Precision | Timers 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:
| Member | Allowed | Diagnostic |
|---|---|---|
after | yes | |
when | yes | A skipped sleep step has output null. |
for | no | E0307 |
max_parallel | no | E0307 |
with, using, timeout, queue_timeout, retry, idempotency_key | no | E0307 |
compensate | no | E0308: 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 expression | Loop variable type | Notes |
|---|---|---|
List<T> | T | |
List<T>? | T | null is treated as an empty list. |
JSON | JSON | Anything other than an array at runtime fails the step with EXPRESSION_ERROR. |
| anything else | E0414 |
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.
| Property | Value |
|---|---|
| Default | no limit: all elements are scheduled at once |
| Range | integer literal from 1 to 10000 (E0410 otherwise) |
Without for | E0307: `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
retrypolicy, - its own
timeoutandqueue_timeout, - its own idempotency key,
<execution id>/reserve[<index>]by default (see Idempotency), - for a
workflowstep, 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.
| Step | Type of a reference to it |
|---|---|
for over a task with output O | List<O> |
for over a child workflow with output O | List<O> |
for with when | List<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:
- the step fails with that element’s error,
- the workflow is aborted: elements that have not started are withdrawn, elements in flight are awaited,
- 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
| Member | Effect on a for step |
|---|---|
worker "t" | One task per element. |
workflow "W" | One child execution per element. |
sleep | Not allowed (E0307). |
await | Not allowed (E0307): an await step receives one signal. |
compute | One value per element, evaluated by the runtime. See Compute steps. |
when | Evaluated once, before fan-out. |
after | The whole step waits; all elements start after the dependencies. |
retry, timeout, queue_timeout | Apply to each element separately. |
idempotency_key | Evaluated 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:
withis checked against the child’s input. A child whose input has required fields needs awith(E0403).- A reference to the step has the child’s output type:
ship.trackingis aStringabove. whenmakes the reference optional, andformakes it a list, exactly as for task steps. See Steps.
Allowed members
| Member | Allowed | Notes |
|---|---|---|
with | yes | The child’s input. |
after, when | yes | |
for, max_parallel | yes | One child per element. |
timeout | yes | How long the parent waits for the child. |
compensate | yes | A task that undoes the child’s effect after the parent fails. |
using, retry, idempotency_key, queue_timeout | no | E0307 |
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.
| Aspect | Behavior |
|---|---|
| Child execution id | <parent id>.<step>, or <parent id>.<step>.<index> for an element of a for step. |
| Duplicate starts | Impossible: the id is deterministic, and starting an existing id returns the existing execution. |
| Child input | Defaults are applied; validation is lenient because the input was type-checked. |
| Child version | The exact definition the parent was compiled against, identified by its definition hash. |
| Result | When the child completes, its result becomes the step’s output. |
| Tracing | The 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 outcome | Parent step fails with |
|---|---|
FAILED | the child’s own error code, with the message child workflow `Name` failed: ... |
CANCELLED | CHILD_CANCELLED |
TIMED_OUT | CHILD_TIMED_OUT |
the step timeout passes first | TIMEOUT, and the child is cancelled |
| the child definition is not registered | CHILD_NOT_REGISTERED |
| the child cannot be started | CHILD_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
| Construct | Syntax | Purpose |
|---|---|---|
| Signal declaration | signal review { approved: Bool, reviewer: String } | A message type the workflow accepts. Its fields are the payload. |
await step | step decision { await review timeout 2d } | Waits for the next review signal. The payload is the step’s output. |
| Query declaration | query 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:
- applies the declared defaults to missing fields, including nested schema fields,
- checks the payload against the declared type and rejects it with
422 SIGNAL_PAYLOAD_INVALIDif a required field is missing or a value has the wrong type; the message names the path, for examplepayload for signal `review` is invalid at $.approved: expected Bool, found a string, - 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:
| Member | Allowed | Meaning |
|---|---|---|
timeout | yes | The longest the step waits, from SignalAwaitStarted. See Timeouts. |
after | yes | Start waiting only after these steps. Signals that arrive earlier are buffered. |
when | yes | Wait only if the condition is true; otherwise the step is SKIPPED and its output is null. |
for, max_parallel | no (E0307) | An await step receives one signal. Use several steps to receive several signals. |
with, using, retry, queue_timeout, idempotency_key | no (E0307) | There is no task to call. |
compensate | no (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
| Step | Type of name |
|---|---|
await s | the record type of signal s |
await s with when | the 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
awaitstep starts is kept in the buffer. When the step starts waiting, it takes the buffered signal right away. - Each
awaitstep 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | The execution does not exist. |
| 404 | UNKNOWN_SIGNAL | The 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. |
| 409 | EXECUTION_FINISHED | The execution is already terminal: execution is already COMPLETED. Checked before the signal name. |
| 422 | SIGNAL_PAYLOAD_INVALID | The 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:
inputis 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.workflowandexecution.versionare 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.
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | The execution does not exist. |
| 404 | UNKNOWN_QUERY | The workflow does not declare this query: OrderApproval@1.0.0 has no query `stat`; declared: status, or Batch@1.0.0 declares no queries. |
| 422 | QUERY_FAILED | Evaluating the expression failed: evaluating query `<name>`: <cause>. |
History events
| Event | Fields | Meaning |
|---|---|---|
SignalReceived | name, payload | A signal was accepted, validated and buffered. payload has defaults applied. Its seq identifies the signal. |
SignalAwaitStarted | step, signal, deadline_ms? | An await step started waiting. deadline_ms is set when the step has a timeout. |
SignalDelivered | step, signal_seq | The 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 when | Use 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
| Member | Allowed | Notes |
|---|---|---|
after | yes | Ordering, as for any step. |
when | yes | Skipped steps have output null, and the output type becomes optional. |
for, max_parallel | yes | One value per element; see Fan-out. All elements are evaluated together, so max_parallel has no effect. |
with, using, timeout, queue_timeout, retry, idempotency_key | no (E0307) | There is no task, no worker and no attempt to bound or retry. |
compensate | no (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.
| Aspect | Behavior |
|---|---|
| Determinism | Expressions 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. |
| Replay | The value is stored in the history. Recovery and replay read it from there and never evaluate the expression again. |
| Workers | None. A compute step runs even when no worker is connected. |
| Cost | Evaluation happens inside the runtime’s decision loop. Keep expressions to data mapping; do heavy work in a worker. |
| Status | SUCCEEDED, 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:
| Function | Result |
|---|---|
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:
| Member | Allowed | Notes |
|---|---|---|
worker "task" | required | Missing worker: E0308. |
with | yes | Checked against the compensation task’s input. |
using | yes | |
timeout, queue_timeout | yes | |
retry | yes | Defaults to one attempt, like any step. |
idempotency_key | yes | Defaults to <execution id>/compensate:<unit>. |
sleep, workflow | no | E0308: compensation must call a worker task. |
when, after, for, max_parallel | no | E0307 |
compensate | no | Compensations 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:
inputandexecution,- the step’s own output, which is not optional here, even if the step has
a
whencondition, 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
forstep, 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:
| Reason | Final status |
|---|---|
A step (or an element of a for step) fails permanently | FAILED |
The execution is cancelled (orbit cancel, the cancel API, or a parent that no longer needs a child) | CANCELLED |
The workflow timeout passes | TIMED_OUT |
The runtime then proceeds in this order:
- Withdraw work that has not started: pending and queued units, timers,
awaitsteps waiting for a signal, and retries waiting for their delay becomeCANCELLED. - 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.
- Compensate every unit that succeeded and has a
compensateblock, one at a time, in reverse order of completion (the step that finished last is compensated first). Each element of aforstep is a separate unit. - 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:
| Outcome | Meaning |
|---|---|
none | No succeeded step had a compensate block. |
completed | Every compensation succeeded. |
failed | At 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_byon 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
- Orders with compensation guide
- Steps
- Fan-out
- Retries and timeouts
- Static safety checks
- Executions and history
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.
| Block | Runs when | failure.reason |
|---|---|---|
on failure | A step fails permanently, or a resource limit is reached (LIMIT_EXCEEDED) | failed |
on cancel | The execution is cancelled (orbit cancel, the HTTP API, or a cancelled parent) | cancelled |
on timeout | The workflow timeout passes | timed_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
| Name | Type | Notes |
|---|---|---|
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. |
input | the workflow input | Unchanged. |
execution | { id, workflow, version } | As everywhere else. |
a workflow step, e.g. charge | that step’s type, made optional | A 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 block | its output type | Optional 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
- In-flight steps finish, and the steps that succeeded are compensated (newest first). See Compensation.
HandlersStartedrecords the block that runs (kind) and the compensation outcome that the terminal event will carry.- 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 exampleon_failure:tell_customer. - When every handler step has finished, the terminal event
(
WorkflowFailed,WorkflowCancelledorWorkflowTimedOut) 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
| Rule | Diagnostic |
|---|---|
Only on failure, on cancel, on timeout and on event <signal> exist | E0105 |
| One block of each kind per workflow | E0319 |
Handler step names must differ from workflow step names and from failure | E0302 |
after may only name steps of the same block | E0303 |
Handler steps run a worker task, a compute expression or a sleep; await, child workflows, for and max_parallel are not allowed | E0307 |
Handler steps cannot declare compensate | E0308 |
failure is read only inside handler steps | E0401 |
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
- Compensation
- Retries and timeouts
- Executions and history
- Running the server: resource limits
- Compiler diagnostics
- Testing workflows
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
| Name | Type | Notes |
|---|---|---|
event | the signal’s payload type | The payload of the signal that started this instance, with defaults applied. |
input | the workflow input | Unchanged. |
execution | { id, workflow, version } | As everywhere else. |
a workflow step, e.g. arrival | that step’s type, made optional | Main 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 block | its output type | Refers 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
SignalReceivedrecords the signal;SignalDeliveredstarts an instance. The instance key ison_event:<signal>@<seq>, where<seq>is the sequence number of theSignalReceivedevent, so keys are stable across restarts and replays. The instance unit’s output is the payload.- The block’s steps run in dependency order with unit keys
on_event:<signal>@<seq>:<step>, for exampleon_event:location@4:tell. They retry, time out and dispatch to workers like any other step. - 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
compensateare compensated, and theon failurehandler 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
whenthat is false is skipped, and steps that read it seenull.
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
| Rule | Diagnostic |
|---|---|
| The signal must be declared | E0315 |
One on event block per signal | E0319 |
A signal handled by on event cannot also be awaited | E0322 |
Step names must differ from workflow step names, event and failure | E0302 |
after may only name steps of the same block | E0303 |
Steps run a worker task, a compute expression or a sleep; await, child workflows, for and max_parallel are not allowed | E0307 |
Steps cannot declare compensate | E0308 |
event is read only inside on event steps | E0401 |
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.
| Code | Error |
|---|---|
E0323 | continue_as_new without with <input>. |
E0324 | The when condition is not a Bool. |
E0400, E0403 | The input does not match the workflow’s input type. |
E0106 | A second continue_as_new in the workflow. |
W0607 | continue_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:
- appends that event to the finished run,
- moves the finished run’s history from
execution_eventstoexecution_run_events, keyed by execution id and run number, - writes the new run’s first events:
WorkflowCreatedwithrun,WorkflowStarted, and oneSignalReceivedper buffered signal (below), - points the execution’s row in
workflow_executionsat 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
| Question | Answer |
|---|---|
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 eventblock. - 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’sinputtype at run time fails it withINPUT_CONTRACT_VIOLATION. Neither starts a new run. - A workflow whose run finishes immediately (for example, only
computesteps) and always continues starts a new run on every runtime tick. Give such a loop asleepor anawait. GET /v1/executions/{id}does not list the earlier runs; the latest run’srunnumber tells how many there are.
See also
- Executions and history: runs
- Signals and queries
- Running the server: resource limits
- Schedules
- Web UI: runs
- Workflow graphs, documentation and process diffs show
continue_as_new - HTTP API: history
- Testing workflows
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
| Position | Expected type | Can read |
|---|---|---|
step with | the task or child workflow input | input, execution, steps, loop variable |
step when | Bool or Bool? | input, execution, steps |
step idempotency_key | String | input, execution, steps, loop variable |
step for x in ... | a list | input, execution, steps |
step compute | any (the type becomes the step’s output type) | input, execution, steps, loop variable |
compensate members | as above | input, execution, the step and its ancestors, loop variable |
workflow return | the workflow output | input, execution, steps |
query name = ... | any | input, execution, steps (every step output optional) |
field defaults (= ...) | the field type | constants only |
retry settings | literals only | nothing |
Literals
| Literal | Type | Examples |
|---|---|---|
| string | String | "text", "line\nbreak", "\u{1F680}" |
| integer | Int | 0, 42, 1_000_000 |
| float | Float | 3.14, 0.5, 1_000.25 |
| duration | Duration | 500ms, 30s, 1.5m, 2h, 1d |
| boolean | Bool | true, false |
| null | null | null |
| object | record | { id: input.id, "content-type": "text/plain" } |
| list | List<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 isE0004, and a line break inside a string isE0002. - Underscores in numbers are ignored (
1_000is1000). - A float needs digits on both sides of the point:
0.5, not.5or5.. There is no exponent notation. - A leading
-is an operator.-5is folded into a negative literal. Because the literal9223372036854775808does not fit in 64 bits, write the minimumIntas-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:
- a loop variable bound by the enclosing
forstep, input, the workflow input,execution, the execution metadata,- a step of the workflow.
Anything else is E0401, with a suggestion when a name is close.
| Expression | Type | Value |
|---|---|---|
input | the workflow input record | the whole input |
input.order_id | the field’s type | a field of the input |
analyze_risk | the step’s output type | the whole output of the step |
analyze_risk.score | the field’s type | a field of the step output |
item.sku | the element’s field type | a field of the current element |
execution.id | String | the execution id |
execution.workflow | String | the workflow name |
execution.version | String | the 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 type | Index type | Result type | Runtime behavior |
|---|---|---|---|
List<T> | Int | T? | Negative indexes count from the end (-1 is the last element). Out-of-range indexes give null. |
Map<String, T> | String | T? | A missing key gives null. |
| a record | a string literal | the field type | r["field"] is the same as r.field; a non-literal index is E0406. |
JSON | any | JSON | |
T? | as for T | as for T, optional | A null target gives null. |
| other types | E0406: 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 fortrue,falseandnull. - 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 type | Rule |
|---|---|
| a record | Each 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 JSON | The 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.
| Precedence | Operators | Operand types | Result |
|---|---|---|---|
| 1 | || | Bool, Bool | Bool |
| 2 | && | Bool, Bool | Bool |
| 3 | == != | two compatible types | Bool |
| 4 | < <= > >= | two numbers, or two strings | Bool |
| 5 | + | two numbers; two strings; two lists | number, String, list |
| 5 | - | two numbers | number |
| 6 | * / % | two numbers | number |
| 7 (prefix) | ! | Bool | Bool |
| 7 (prefix) | - | Int or Float | same 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
| Operands | Result type | Runtime behavior |
|---|---|---|
Int and Int | Int | 64-bit integer arithmetic. Overflow is an error. |
Int and Float, or two Float | Float | 64-bit floating point. |
either is JSON | JSON | Checked at runtime. |
/on integers truncates toward zero:7 / 2is3and-7 / 2is-3.%takes the sign of the dividend:-7 % 3is-1.- Division or remainder by zero, integer or float, is an error.
- A float result that is not finite becomes
null. Durationhas no arithmetic:30s + 1sisE0406.
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
BoolorBool?(E0408). Anullcondition selects theelsebranch. elseis required.- The result type combines both branch types:
if c then 1 else 2.5isFloat, andif c then "x" else nullisString?. Incompatible branches areE0400. if,thenandelsemay 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)
}
}
| Rule | Detail |
|---|---|
| Where | Only as the second argument of map, filter, find, any and all. Anywhere else is E0415. See Collection functions. |
| Parameter | One 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). |
| Body | Any 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 type | Any 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_keyorcomputeexpression fails that step with the non-retryable codeEXPRESSION_ERROR, which aborts the workflow and starts compensation, - the workflow
returnfails the workflow withEXPRESSION_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
| Function | Signature | Result |
|---|---|---|
coalesce | coalesce(a, b, ...) | the first non-null argument |
is_null | is_null(x) | Bool |
len | len(String | List<T> | Map<String, T>) | Int |
string | string(x) | String |
int | int(Int | Float | String | Bool) | Int |
float | float(Int | Float | String | Bool) | Float |
contains | contains(haystack, needle) | Bool |
lower | lower(String) | String |
upper | upper(String) | String |
trim | trim(String) | String |
join | join(List<T>, String) | String |
keys | keys(Map<String, T>) | List<String> |
min | min(number, ...) | number |
max | max(number, ...) | number |
abs | abs(number) | number |
sum | sum(List<Int> | List<Float>) | Int or Float |
map | map(List<T>, T => U) | List<U> |
filter | filter(List<T>, T => Bool) | List<T> |
find | find(List<T>, T => Bool) | T? |
any | any(List<T>, T => Bool) | Bool |
all | all(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
JSONare accepted everywhere and checked at runtime. - A runtime error, such as
int("abc"), fails the step being scheduled (or the workflow, inreturn) withEXPRESSION_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.
| Aspect | Behavior |
|---|---|
| Argument types | Must be compatible with each other (E0400 otherwise). Int and Float combine to Float. |
| Result type | The combined type, non-optional if any argument is non-optional, optional otherwise. |
| Evaluation | All 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
| Argument | Result |
|---|---|
String | the 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 |
null | 0 |
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:
| Argument | Result |
|---|---|
a String | unchanged |
null | "null" |
| a number | its JSON form: string(42) is "42", string(1.5) is "1.5" |
a Bool | "true" or "false" |
| a list, map or record | compact 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
| Argument | Result |
|---|---|
Int | unchanged |
Float | truncated toward zero: int(2.9) is 2, int(-2.9) is -2 |
String | parsed as a base-10 integer after trimming whitespace: int(" 42 ") is 42; int("1.5") and int("abc") are runtime errors |
Bool | 1 for true, 0 for false |
null | runtime 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
| Argument | Result |
|---|---|
Int or Float | the value as a float: float(5) is 5.0 |
String | parsed after trimming whitespace: float("2.5") is 2.5; an invalid string is a runtime error |
Bool | 1.0 for true, 0.0 for false |
null | runtime 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
| Haystack | Result |
|---|---|
String | true 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 record | true if the key exists |
null | false |
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
| Function | Result |
|---|---|
lower | Unicode lowercase: lower("ÀB") is "àb" |
upper | Unicode uppercase |
trim | the 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.
| Aspect | Behavior |
|---|---|
| Result type | Int if every argument is Int, otherwise Float. |
| Result value | The winning argument itself, unchanged: max(3, 2.5) returns 3. |
| Ties | The 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 (
E0407otherwise, with the signature in a note). The first must be a list (E0400:argument 1 of `map` must be a List, found `String`); aList<T>?is accepted, andnullbehaves 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,anyandall, the body must have typeBool(E0400, orE0405for aBool?). At runtime anullcondition counts as false. - The parameter must not reuse the name of a step, a loop variable, an
enclosing lambda parameter,
inputorexecution(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 field | Type | Default | Meaning |
|---|---|---|---|
method | String | "GET" | The HTTP method, case-insensitive: GET, HEAD, OPTIONS, DELETE, POST, PUT or PATCH. |
url | String | required | The URL. Only http:// and https:// URLs are allowed. |
headers | Map<String, String> | {} | Request headers. |
body | JSON | null | The request body. Sent only for POST, PUT and PATCH. |
| Output field | Type | Meaning |
|---|---|---|
status | Int | The HTTP status code. |
headers | Map<String, String> | Response headers, with lowercase names. If a header repeats, the last value wins. |
body | JSON | The 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 value | What is sent |
|---|---|
null (the default) | an empty body |
| a string | the string as is; set content-type yourself |
| any other value | the value serialized as JSON |
For GET, HEAD, OPTIONS and DELETE, body is ignored.
Status codes and errors
| Outcome | Result | Retryable |
|---|---|---|
1xx to 4xx except 429 | The step succeeds with the response. Check status in the workflow. | |
429 and 5xx | The attempt fails with HTTP_ERROR (server responded 503). The error details carry the status and body. | yes |
| Network or DNS failure | The attempt fails with HTTP_ERROR (request failed: ...). | yes |
| Request slower than the timeout | The 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 method | HTTP_ERROR (unsupported method TRACE) | no |
Missing url at runtime | HTTP_ERROR (missing url) | no |
| Adapter disabled | ADAPTER_DISABLED | no |
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
| Setting | Value |
|---|---|
| Request timeout | the step timeout, or 30 seconds when the step sets none |
| Response body | read 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:
| Situation | Result |
|---|---|
No definition with that name@version | Registered (HTTP 201). |
Same name@version, same hash | No change (HTTP 200, up to date). |
Same name@version, different hash | Rejected 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:
- Deploy workers that implement both the old and the new contract version.
- Change the contract, bump its version, and bump the versions of the workflows that use it. Register them.
- Let executions on the old workflow versions finish.
- 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 version | Used for |
|---|---|
orbit-ir/1 | Every workflow that uses none of the features below. |
orbit-ir/2 | A 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.PATCHversions, so that “latest” selection works as expected. - Treat signals and queries like the input and output: senders and readers
depend on them. Run
orbit compatto see which changes break them. - Before deploying a workflow that compiles to
orbit-ir/2, check that the runtime lists it inGET /v1/health.
See also
- Workflows
- Task contracts
- Child workflows
- Signals and queries
- Outcome handlers
- Compatibility checks
- Executions and history
- CLI
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
| Code | Reported when | Typical fix |
|---|---|---|
W0601 | A step calls a task whose contract declares compensated_by, and the step has no compensate block. | Add the compensation. |
W0602 | The step’s compensate block calls a different task than the contract names. | Call the named task, or fix the contract. |
W0603 | A step’s when condition is a constant that is false or null. | Remove the step, or make the condition depend on data. |
W0604 | A step’s when condition is a constant true. | Remove when. |
W0605 | A workflow declares a signal that no step awaits. | Add an await step, or remove the declaration. |
W0606 | An 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
| Command | Without --strict | With --strict and only warnings |
|---|---|---|
orbit check | prints warnings and ok lines, exits 0 | prints warnings and error: <n> warning(s) treated as errors (--strict), no ok lines, exits 1 |
orbit check --format json | prints the diagnostics, exits 0 | prints the diagnostics, exits 1 |
orbit compile | writes the IR, exits 0 | writes 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.
| Check | What a clean result means | What it does not cover |
|---|---|---|
W0601, W0602 | Every 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, W0604 | No 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. |
W0605 | Every 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. |
W0606 | Every 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
- Compensation
- Task contracts
- Signals and queries
- Conditions
- Policies
- Compiler diagnostics
- CLI: orbit check
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), althoughsepin 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 bparsesbas an unknown member (E0102). retry,compensateandfordo 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
| Token | Form | Notes |
|---|---|---|
| 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. |
| duration | an integer or float immediately followed by ms, s, m, h or d | Normalized to milliseconds, rounded. Any other letters after a number are E0005. |
| string | "..." on one line | Escapes: \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 literals | true, false, null | The 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 5sandtimeout: 5sare the same, and so areidempotency_key "k"andidempotency_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.
| Position | Words with special meaning |
|---|---|
| top level | workflow, schema, task, import, test |
| task body | version, description, input, output, compensated_by |
| workflow body | version, description, timeout, input, output, step, signal, query, return, on |
| step body | worker, workflow, sleep, await, compute, using, after, with, timeout, queue_timeout, retry, idempotency_key, when, for, in, max_parallel, compensate |
| type | List, Map, and the primitive type names |
| expression | if, then, else; input and execution as path roots |
retry values | fixed, exponential |
| test body | workflow, 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:
| Position | Reserved names | Diagnostic |
|---|---|---|
| step names | input, execution, output, if, then, else | E0306 |
for loop variables | input, execution, and the names of the workflow’s steps | E0413 |
| lambda parameters | input, execution, the names of the workflow’s steps, loop variables and enclosing lambda parameters | E0413 |
| schema names | the primitive type names, List, Map | E0200 |
| task names | names 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:
| Restriction | Code |
|---|---|
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)
- Register. The worker sends its
worker_id, the runtime it represents (for examplepython), and the list of task contracts it implements. The runtime returns aworker_session_idand 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). - 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 with204 No Contentwhen the wait ends. Workers pull work, so they need no inbound ports and work behind NAT. - Execute. The SDK decodes the task input, calls your handler with the input and a context object, and waits for it to return.
- 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. - Heartbeat. A background loop calls
/v1/workers/heartbeatat 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). - 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:
| SDK | Default runtime |
|---|---|
| Python | python |
| TypeScript and JavaScript | typescript |
| Go | go |
| Rust | rust |
| Java | java |
| 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:
| SDK | Poll loops run on |
|---|---|
| Python | one thread per loop; async def handlers run in a fresh event loop per task |
| TypeScript | asynchronous loops on the Node.js event loop; CPU-bound handlers block every loop |
| Go | one goroutine per loop |
| Rust | one OS thread per loop |
| Java | one 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
attemptnumber. - 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:
| Field | Meaning |
|---|---|
code | A stable, machine-readable string, for example CARD_DECLINED. |
message | A human-readable description. |
retryable | Whether the runtime may retry the attempt. Defaults to true in every SDK. |
details | Optional 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:
- the error is retryable,
- its code is not listed in the step’s
retry { non_retryable: [...] }, - attempts remain (
attemptscounts the first attempt; the default is 1, which means no retries), and - 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:
| Code | Retryable | Produced when |
|---|---|---|
UNHANDLED_EXCEPTION | yes | The 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. |
PANIC | yes | A Go or Rust handler panicked. |
INVALID_INPUT | no | A 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_RESULT | no | The handler returned something that is not a JSON object. |
UNKNOWN_TASK | yes | The runtime delivered a task the worker did not register. |
CANCELLED | yes in Java and C#, no in TypeScript | The 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:
| Code | Retried | Meaning |
|---|---|---|
TIMEOUT | yes | The attempt exceeded the step’s timeout. |
OUTPUT_CONTRACT_VIOLATION | never | The worker’s output does not match the contract. |
INPUT_CONTRACT_VIOLATION | never | The step’s input does not match the contract. |
EXPRESSION_ERROR | never | An expression failed to evaluate. |
QUEUE_TIMEOUT | never | No worker picked the task up within queue_timeout. |
ADAPTER_DISABLED | never | An 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:
| SDK | Cancellation signal | Fires on deadline locally |
|---|---|---|
| Python | ctx.cancelled (threading.Event), ctx.is_cancelled | no |
| TypeScript | ctx.signal (AbortSignal), ctx.cancelled | no |
| Go | the handler’s context.Context | yes |
| Rust | ctx.is_cancelled() | no |
| Java | ctx.isCancelled(), ctx.cancellation(), thread interrupt | yes |
| C# | ctx.CancellationToken | yes |
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.
| Variable | Purpose |
|---|---|
ORBIT_SERVER | Runtime URL. Default http://127.0.0.1:7233. A trailing / is removed. |
ORBIT_WORKER_TOKEN | Bearer token: the shared worker token or a per-worker token. Takes precedence over ORBIT_TOKEN. |
ORBIT_TOKEN | Bearer token used when ORBIT_WORKER_TOKEN is unset. |
ORBIT_CA_CERT | Path of a PEM CA certificate to trust for HTTPS. |
ORBIT_CLIENT_CERT | Path of a PEM client certificate for mutual TLS. |
ORBIT_CLIENT_KEY | Path of the PEM private key for ORBIT_CLIENT_CERT. |
The TLS variables differ slightly between SDKs:
| SDK | ORBIT_CA_CERT | ORBIT_CLIENT_KEY |
|---|---|---|
| Python | Trusted instead of the system store. | Optional; without it the key is read from the certificate file. |
| TypeScript | Used 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. |
| Go | Added to the system pool. | Required with a certificate. |
| Rust | Trusted instead of the system store. | Required; the certificate is ignored without it. |
| Java | Trusted 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:
| SDK | Trigger | Running handlers | Open long poll |
|---|---|---|---|
| Python | SIGINT, SIGTERM, or worker.stop() | run to completion | waits for it to return (up to poll_wait_ms) |
| TypeScript | SIGINT, SIGTERM, or worker.stop() | run to completion | aborted |
| Go | cancel the context passed to Run | their context is cancelled | aborted |
| Rust | worker.stop() from another thread | run to completion | waits for it to return |
| Java | worker.stop(), typically from a shutdown hook | run to completion | interrupted |
| C# | cancel the token passed to RunAsync | their CancellationToken is cancelled | aborted |
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.
| SDK | Retries a result for |
|---|---|
| Python, TypeScript, Rust | as long as the worker runs; stops once shutdown begins |
| Go, C# | up to 10 minutes, including during shutdown |
| Java | up to resultRetryTimeout (default 10 minutes), including during shutdown |
Comparing the SDKs
| Python | TypeScript | Go | Rust | Java | C# | |
|---|---|---|---|---|---|---|
| Package | orbit-lang-sdk (PyPI) | orbit-lang (npm) | github.com/AjmalX0/Orbit/sdks/go | orbit-worker (crates.io) | io.github.ajmalx0:orbit-sdk | OrbitLang.Sdk (NuGet) |
| Requires | Python 3.9 | Node.js 18 | Go 1.21 | Rust 1.89 | Java 17 | .NET 8 |
| Dependencies | none | none | none | serde, serde_json, ureq | Jackson | none |
| Handler input | dict | typed (unchecked) | *orbit.Task, Decode | serde-typed | Jackson-typed or JsonNode | System.Text.Json-typed or JsonElement |
| Async handlers | async def or plain | async or plain | blocking | blocking | blocking | async |
| Default worker id | python-<host>-<pid> | typescript-<pid> | go-<host>-<pid> | required | java-<host>-<pid> | csharp-<host>-<pid> |
Context exposes contract_version, timeout_ms | no | no | yes | contract_version only | yes | yes |
| Handles signals itself | yes | yes | no | no | no | no |
| Client for starting workflows | Client | Client | no | no | no | no |
| Heartbeats while draining | no | no | no | no | yes | no |
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, TypeScript and JavaScript, Go, Rust, Java, C# and .NET
- Worker protocol
- Task contracts
- Retries and timeouts
- Idempotency
- Delivery guarantees
- Security
- Polyglot workflows
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,
inputis{}. - Returning
Nonereports{}. - Returning anything other than a
dictfails the attempt withINVALID_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})
...
| Argument | Type | Default | Meaning |
|---|---|---|---|
code | str | required | Error code, matched exactly by the step’s non_retryable list. |
message | str | "" | Human-readable message. |
retryable | bool | True | False fails the step immediately, whatever its retry policy. |
details | any JSON value | None | Stored 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.
| Member | Type | Meaning |
|---|---|---|
token | str | Identifies this delivery. The SDK echoes it in the result. |
execution_id | str | The workflow execution. |
workflow | str | Workflow name. |
workflow_version | str | Workflow version. |
step_id | str | The step, or compensate:<step> for a compensation; step[i] for a fan-out element. |
task_name | str | The task name, for example text.normalize. |
attempt | int | 1-based attempt number. Redeliveries keep the number. |
idempotency_key | str | Stable across retries and redeliveries. Use it for side effects. |
redelivery | bool | True when the outcome of an earlier delivery of this attempt is unknown. |
deadline_ms | int or None | Absolute deadline of the attempt in Unix epoch milliseconds, when the step has a timeout. |
cancelled | threading.Event | Set when the runtime asks the attempt to stop. |
is_cancelled | bool (property) | True once cancelled is set. |
remaining_ms() | int or None | Milliseconds left before deadline_ms, never negative. |
log(message, **fields) | None | Logs 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:
| Variable | Meaning |
|---|---|
ORBIT_SERVER | Runtime URL. Default http://127.0.0.1:7233. |
ORBIT_WORKER_TOKEN | Bearer token. Takes precedence over ORBIT_TOKEN. |
ORBIT_TOKEN | Bearer token used when ORBIT_WORKER_TOKEN is unset. |
ORBIT_CA_CERT | PEM CA certificate. When set, it is the only CA trusted. |
ORBIT_CLIENT_CERT | PEM client certificate for mutual TLS. |
ORBIT_CLIENT_KEY | PEM 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)
| Parameter | Type | Default | Meaning |
|---|---|---|---|
worker_id | str | python-<hostname>-<pid> | Stable identity sent at registration. |
server | str | ORBIT_SERVER, then http://127.0.0.1:7233 | Runtime URL. |
token | str | ORBIT_WORKER_TOKEN, then ORBIT_TOKEN | Bearer token. Pass "" to send none. |
concurrency | int | 4 | Number of poll threads; values below 1 become 1. |
runtime | str | "python" | Announced runtime, matched against using <language>. |
poll_wait_ms | int | 20000 | wait_ms of each long poll. The runtime caps it at 60000. |
| Member | Meaning |
|---|---|
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_ms | The resolved settings. |
client | The 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)
| Parameter | Type | Default | Meaning |
|---|---|---|---|
server | str | ORBIT_SERVER, then http://127.0.0.1:7233 | Runtime URL. |
token | str | ORBIT_WORKER_TOKEN, then ORBIT_TOKEN | Bearer token. |
timeout | float | 70.0 | Socket timeout in seconds. |
ca_cert | str | ORBIT_CA_CERT | PEM CA file to trust. |
client_cert | str | ORBIT_CLIENT_CERT | PEM client certificate. |
client_key | str | ORBIT_CLIENT_KEY | PEM private key. |
| Method | Returns | Meaning |
|---|---|---|
request(method, path, body=None) | (status, body) | Sends a JSON request. body is None for an empty response. |
get(path) | parsed body | GET request. |
post(path, body) | (status, body) | POST request. |
start(workflow, input=None, version=None, execution_id=None) | str | Starts an execution and returns its id. |
wait(execution_id, timeout_ms=30000) | dict | Long-polls until the execution finishes or the timeout elapses, and returns its state. |
signal(execution_id, name, payload=None) | int | Sends 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) | any | Evaluates 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
- How workers work
- Worker protocol
- Task contracts
- Retries and timeouts
- Idempotency
- Security
- Polyglot workflows
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,
inputis{}. - Returning
undefinedornullreports{}. - 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 },
});
| Argument | Type | Default | Meaning |
|---|---|---|---|
code | string | required | Error code, matched exactly by the step’s non_retryable list. |
message | string | "" | Human-readable message. |
options.retryable | boolean | true | false fails the step immediately, whatever its retry policy. |
options.details | Json | undefined | Stored 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
| Member | Type | Meaning |
|---|---|---|
token | string | Identifies this delivery. The SDK echoes it in the result. |
executionId | string | The workflow execution. |
workflow | string | Workflow name. |
workflowVersion | string | Workflow version. |
stepId | string | The step, or compensate:<step> for a compensation; step[i] for a fan-out element. |
taskName | string | The task name. |
attempt | number | 1-based attempt number. Redeliveries keep the number. |
idempotencyKey | string | Stable across retries and redeliveries. Use it for side effects. |
redelivery | boolean | true when the outcome of an earlier delivery of this attempt is unknown. |
deadlineMs | number | undefined | Absolute deadline in Unix epoch milliseconds, when the step has a timeout. |
signal | AbortSignal | Aborted when the runtime asks the attempt to stop. |
cancelled | boolean (getter) | true once signal is aborted. |
remainingMs() | number | undefined | Milliseconds left before deadlineMs, never negative. |
log(message, fields?) | void | Writes 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
| Option | Environment | Default | Meaning |
|---|---|---|---|
server | ORBIT_SERVER | http://127.0.0.1:7233 | Runtime URL. |
token | ORBIT_WORKER_TOKEN, then ORBIT_TOKEN | none | Bearer token. |
clientCert | ORBIT_CLIENT_CERT | none | PEM client certificate for mutual TLS. |
clientKey | ORBIT_CLIENT_KEY | the clientCert file | PEM 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:
| Option | Type | Default | Meaning |
|---|---|---|---|
workerId | string | typescript-<pid> | Stable identity sent at registration. |
concurrency | number | 4 | Number of poll loops; values below 1 become 1. |
runtime | string | "typescript" | Announced runtime. Set "javascript" for steps declared using javascript. |
pollWaitMs | number | 20000 | wait_ms of each long poll. The runtime caps it at 60000. |
logger | Pick<Console, "info" | "warn" | "error"> | console | Destination of SDK messages. |
| Member | Meaning |
|---|---|
task<I, O>(name, { version? }, handler): this | Registers 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(): void | Starts a graceful shutdown. |
workerId: string | The resolved worker id. |
client: Client | The 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)
| Option | Type | Default | Meaning |
|---|---|---|---|
server | string | ORBIT_SERVER, then http://127.0.0.1:7233 | Runtime URL. |
token | string | ORBIT_WORKER_TOKEN, then ORBIT_TOKEN | Bearer token. |
clientCert | string | ORBIT_CLIENT_CERT | Path of a PEM client certificate. |
clientKey | string | ORBIT_CLIENT_KEY, then clientCert | Path of the PEM private key. |
| Method | Returns | Meaning |
|---|---|---|
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). |
server | string | The 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
| Export | Meaning |
|---|---|
SDK_VERSION | "0.1.0". |
Json | Type of any JSON value. |
TaskAssignment | Type of the task message the runtime sends (protocol ExecuteTaskRequest). |
Handler<I, O> | (input: I, ctx: Context) => O | Promise<O>. |
ClientOptions, WorkerOptions | Option 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
- How workers work
- Worker protocol
- Task contracts
- Retries and timeouts
- Idempotency
- Security
- Polyglot workflows
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/jsonand must be a JSON object, so return a struct, amap[string]any, or ajson.RawMessageholding an object. - Returning a nil output reports
{}. A typed nil map marshals tonulland 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},
}
| Constructor | Retryable |
|---|---|
orbit.Retryable(code, message string) *TaskError | yes |
orbit.Permanent(code, message string) *TaskError | no, 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:
| Field | Type | Meaning |
|---|---|---|
Token | string | Identifies this delivery. The SDK echoes it in the result. |
ExecutionID | string | The workflow execution. |
Workflow | string | Workflow name. |
WorkflowVersion | string | Workflow version. |
StepID | string | The step, or compensate:<step> for a compensation; step[i] for a fan-out element. |
TaskName | string | The task name. |
ContractVersion | string | The task’s contract version. |
Input | json.RawMessage | The raw task input. |
IdempotencyKey | string | Stable across retries and redeliveries. Use it for side effects. |
Attempt | int | 1-based attempt number. Redeliveries keep the number. |
Redelivery | bool | true when the outcome of an earlier delivery of this attempt is unknown. |
DeadlineMs | *int64 | Absolute deadline in Unix epoch milliseconds, when the step has a timeout. |
TimeoutMs | *int64 | The step’s timeout in milliseconds. |
| Method | Meaning |
|---|---|
Decode(v any) error | Unmarshals 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
cancellist, because the attempt timed out or its workflow was cancelled or failed; - the attempt’s
DeadlineMspasses (the context carries it as its deadline); - the context passed to
Runis 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.
| Field | Environment | Default | Meaning |
|---|---|---|---|
Server | ORBIT_SERVER | http://127.0.0.1:7233 | Runtime URL. |
Token | ORBIT_WORKER_TOKEN, then ORBIT_TOKEN | none | Bearer token. |
CACertFile | ORBIT_CA_CERT | none | PEM CA certificate, added to the system pool. |
ClientCertFile | ORBIT_CLIENT_CERT | none | PEM client certificate for mutual TLS. |
ClientKeyFile | ORBIT_CLIENT_KEY | none | PEM 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
| Field | Type | Default | Meaning |
|---|---|---|---|
WorkerID | string | go-<hostname>-<pid> | Stable identity sent at registration. |
Server | string | ORBIT_SERVER, then http://127.0.0.1:7233 | Runtime URL. |
Token | string | ORBIT_WORKER_TOKEN, then ORBIT_TOKEN | Bearer token. |
CACertFile | string | ORBIT_CA_CERT | PEM CA certificate path. |
ClientCertFile | string | ORBIT_CLIENT_CERT | PEM client certificate path. |
ClientKeyFile | string | ORBIT_CLIENT_KEY | PEM private key path. |
Concurrency | int | 4 | Number of poll goroutines. |
Runtime | string | "go" | Announced runtime, matched against using <language>. |
PollWait | time.Duration | 20 * time.Second | wait_ms of each long poll. The runtime caps it at 60 seconds. |
Logger | *log.Logger | standard error, prefix [orbit] | Destination of SDK messages. |
Zero values select the default.
Worker
| Method | Meaning |
|---|---|
Task(name, version string, handler Handler) *Worker | Registers a handler. Panics on a duplicate name. |
Run(ctx context.Context) error | Registers, 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
| Name | Meaning |
|---|---|
Task | One delivery of a task. See The task. |
Handler | func(ctx context.Context, t *Task) (any, error). |
Version | The 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
- How workers work
- Worker protocol
- Task contracts
- Retries and timeouts
- Idempotency
- Security
- Polyglot workflows
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:
Ois serialized withserde_jsonand must be a JSON object, so return a struct, a map, or aserde_json::Valueobject such asjson!({ ... }).Ok(())and other values that serialize tonullreport{}.- 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 }))); } }
| Constructor | Retryable |
|---|---|
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.
| Method | Returns | Meaning |
|---|---|---|
execution_id() | &str | The workflow execution. |
step_id() | &str | The step, or compensate:<step> for a compensation; step[i] for a fan-out element. |
attempt() | u32 | 1-based attempt number. Redeliveries keep the number. |
idempotency_key() | &str | Stable across retries and redeliveries. Use it for side effects. |
redelivery() | bool | true when the outcome of an earlier delivery of this attempt is unknown. |
is_cancelled() | bool | true 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:
| Field | Type | Meaning |
|---|---|---|
token | String | Identifies this delivery. |
execution_id | String | The workflow execution. |
workflow | String | Workflow name. |
workflow_version | String | Workflow version. |
step_id | String | The step. |
task_name | String | The task name. |
contract_version | String | The task’s contract version. |
input | serde_json::Value | The raw input. |
idempotency_key | String | The idempotency key. |
attempt | u32 | The attempt number. |
redelivery | bool | The redelivery flag. |
deadline_ms | Option<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 method | Environment | Default | Meaning |
|---|---|---|---|
server(url: &str) | ORBIT_SERVER | http://127.0.0.1:7233 | Runtime URL. |
token(token: &str) | ORBIT_WORKER_TOKEN, then ORBIT_TOKEN | none | Bearer token. |
ca_pem(pem: Vec<u8>) | ORBIT_CA_CERT (a path) | system roots | PEM 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) | none | Client 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
| Item | Meaning |
|---|---|
Worker::builder(worker_id: &str) -> WorkerBuilder | Starts a builder with defaults from the environment. |
task(&mut self, name, version, handler) -> &mut Self | Registers 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
| Method | Default | Meaning |
|---|---|---|
server(url: &str) | ORBIT_SERVER, then http://127.0.0.1:7233 | Runtime URL. |
token(token: &str) | ORBIT_WORKER_TOKEN, then ORBIT_TOKEN | Bearer token. |
ca_pem(pem: Vec<u8>) | ORBIT_CA_CERT | Trusted CA certificates. |
client_identity(cert_pem: Vec<u8>, key_pem: Vec<u8>) | ORBIT_CLIENT_CERT, ORBIT_CLIENT_KEY | Mutual TLS identity. |
concurrency(n: usize) | 4 | Number of poll threads. |
runtime(runtime: &str) | "rust" | Announced runtime, matched against using <language>. |
poll_wait(d: Duration) | 20 seconds | wait_ms of each long poll. The runtime caps it at 60 seconds. |
build(self) -> Worker | Builds 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
| Item | Meaning |
|---|---|
Context | See Context. |
Assignment | The task message, Context::assignment. |
VERSION | The 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
- How workers work
- Worker protocol
- Task contracts
- Retries and timeouts
- Idempotency
- Security
- Polyglot workflows
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 withINVALID_INPUT, which is not retried. - Returning
nullreports{}. - 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 factory | Retryable |
|---|---|
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
| Method | Returns | Meaning |
|---|---|---|
token() | String | Identifies this delivery. The SDK echoes it in the result. |
executionId() | String | The workflow execution. |
workflow() | String | Workflow name. |
workflowVersion() | String | Workflow version. |
stepId() | String | The step, or compensate:<step> for a compensation; step[i] for a fan-out element. |
taskName() | String | The task name. |
contractVersion() | String | The task’s contract version. |
input() | JsonNode | The raw input, {} when the runtime sent none. |
idempotencyKey() | String | Stable across retries and redeliveries. Use it for side effects. |
attempt() | int | 1-based attempt number. Redeliveries keep the number. |
redelivery() | boolean | true when the outcome of an earlier delivery of this attempt is unknown. |
deadlineMs() | OptionalLong | Absolute deadline in Unix epoch milliseconds, when the step has a timeout. |
timeoutMs() | OptionalLong | The step’s timeout in milliseconds. |
remainingMs() | OptionalLong | Milliseconds left before the deadline, never negative. |
isCancelled() | boolean | true once the attempt was cancelled. |
cancellation() | CompletableFuture<Void> | Completes when the attempt is cancelled. |
throwIfCancelled() | void | Throws 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 returningtrue;- the
cancellation()future completes; - the thread running the handler is interrupted, so blocking calls such as
Thread.sleep,BlockingQueue.takeorHttpClient.sendthrowInterruptedException.
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 method | Environment | Default | Meaning |
|---|---|---|---|
server(String) | ORBIT_SERVER | http://127.0.0.1:7233 | Runtime URL. |
token(String) | ORBIT_WORKER_TOKEN, then ORBIT_TOKEN | none | Bearer token. |
caCertFile(Path) | ORBIT_CA_CERT | none | PEM CA certificates, trusted in addition to the system trust store. |
clientCert(Path certPem, Path keyPem) | ORBIT_CLIENT_CERT, ORBIT_CLIENT_KEY | none | Client certificate chain (leaf first) and private key for mutual TLS. |
sslContext(SSLContext) | none | Your 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
| Member | Meaning |
|---|---|
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. |
VERSION | The SDK version, "0.1.0". |
DEFAULT_SERVER | "http://127.0.0.1:7233". |
Worker.Builder
| Method | Default | Meaning |
|---|---|---|
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:7233 | Runtime URL. |
token(String) | ORBIT_WORKER_TOKEN, then ORBIT_TOKEN | Bearer token. |
caCertFile(Path) | ORBIT_CA_CERT | PEM CA certificates. |
clientCert(Path, Path) | ORBIT_CLIENT_CERT, ORBIT_CLIENT_KEY | Mutual TLS identity. |
sslContext(SSLContext) | none | Custom TLS context. |
concurrency(int) | 4 | Number of poll threads. |
runtime(String) | "java" | Announced runtime, matched against using <language>. |
pollWait(Duration) | 20 seconds | wait_ms of each long poll. The runtime caps it at 60 seconds. |
resultRetryTimeout(Duration) | 10 minutes | How long to keep retrying a result through outages. |
objectMapper(ObjectMapper) | an ObjectMapper that ignores unknown properties | Mapper 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
- How workers work
- Worker protocol
- Task contracts
- Retries and timeouts
- Idempotency
- Security
- Polyglot workflows
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
nullreports{}. - 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 factory | Retryable |
|---|---|
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 method | Type | Meaning |
|---|---|---|
Token | string | Identifies this delivery. The SDK echoes it in the result. |
ExecutionId | string | The workflow execution. |
Workflow | string | Workflow name. |
WorkflowVersion | string | Workflow version. |
StepId | string | The step, or compensate:<step> for a compensation; step[i] for a fan-out element. |
TaskName | string | The task name. |
ContractVersion | string | The task’s contract version. |
Input | JsonElement | The raw input. |
IdempotencyKey | string | Stable across retries and redeliveries. Use it for side effects. |
Attempt | int | 1-based attempt number. Redeliveries keep the number. |
Redelivery | bool | true when the outcome of an earlier delivery of this attempt is unknown. |
DeadlineMs | long? | Absolute deadline in Unix epoch milliseconds, when the step has a timeout. |
TimeoutMs | long? | The step’s timeout in milliseconds. |
CancellationToken | CancellationToken | Cancelled when the attempt should stop. |
Remaining | TimeSpan? | Time left before DeadlineMs, never negative. |
Log(string message) | void | Logs 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
cancellist, because the attempt timed out or its workflow was cancelled or failed; - the attempt’s
DeadlineMspasses; - the token passed to
RunAsyncis 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.
| Property | Environment | Default | Meaning |
|---|---|---|---|
Server | ORBIT_SERVER | http://127.0.0.1:7233 | Runtime URL. |
Token | ORBIT_WORKER_TOKEN, then ORBIT_TOKEN | none | Bearer token. |
CaCertFile | ORBIT_CA_CERT | none | PEM CA certificates, trusted in addition to the system store. |
ClientCertificatePath | ORBIT_CLIENT_CERT | none | PEM client certificate for mutual TLS. |
ClientKeyPath | ORBIT_CLIENT_KEY | the certificate file | PEM 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)
| Member | Meaning |
|---|---|
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, PollWait | The resolved settings. |
ClientCertificate | The X509Certificate2 presented for mutual TLS, or null. |
Version | The SDK version, "0.1.0". |
WorkerOptions
| Property | Type | Default | Meaning |
|---|---|---|---|
WorkerId | string? | csharp-<machine>-<pid> | Stable identity sent at registration. |
Server | string? | ORBIT_SERVER, then http://127.0.0.1:7233 | Runtime URL. |
Token | string? | ORBIT_WORKER_TOKEN, then ORBIT_TOKEN | Bearer token. |
CaCertFile | string? | ORBIT_CA_CERT | PEM CA certificate path. |
ClientCertificatePath | string? | ORBIT_CLIENT_CERT | PEM client certificate path. |
ClientKeyPath | string? | ORBIT_CLIENT_KEY | PEM private key path. |
Concurrency | int | 4 | Number of poll loops; values below 1 become 1. |
Runtime | string | "csharp" | Announced runtime, matched against using <language>. |
PollWait | TimeSpan | 20 seconds | wait_ms of each long poll. The runtime caps it at 60 seconds. |
JsonOptions | JsonSerializerOptions? | snake_case, case-insensitive | Options for input, output and details. |
Logger | Action<string>? | standard error | Destination 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
- How workers work
- Worker protocol
- Task contracts
- Retries and timeouts
- Idempotency
- Security
- Polyglot workflows
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.
| Option | Environment | Type | Default | Meaning |
|---|---|---|---|---|
--db | ORBIT_DB | path or URL | orbit.db | SQLite file path, or a postgres:// / postgresql:// URL. See Storage. |
--addr | ORBIT_ADDR | ip:port | 127.0.0.1:7233 | HTTP 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-addr | ORBIT_GRPC_ADDR | ip:port | off | Also serve the gRPC worker protocol on this address, for example 127.0.0.1:7234. |
--token | ORBIT_TOKEN | string | none | Admin bearer token, required on every endpoint except /v1/health. |
--worker-token | ORBIT_WORKER_TOKEN | string | none | Shared worker credential, accepted only on /v1/workers/* and /v1/tasks/*. Requires --token and must differ from it. |
--workers-file | ORBIT_WORKERS_FILE | path | none | Worker identities ([[worker]]) and client identities ([[client]]), in TOML. Requires --token. See Security. |
--tls-cert | ORBIT_TLS_CERT | path | none | PEM certificate chain. With --tls-key, the HTTP API (and gRPC) serve TLS. |
--tls-key | ORBIT_TLS_KEY | path | none | PEM private key (PKCS#8, PKCS#1 or SEC1). |
--tls-client-ca | ORBIT_TLS_CLIENT_CA | path | none | Enables mutual TLS: client certificates signed by this CA identify workers. Requires --tls-cert and a workers file with client_cert entries. |
--otlp-endpoint | OTEL_EXPORTER_OTLP_ENDPOINT | URL | off | Export OpenTelemetry traces over OTLP/HTTP (JSON) to this base URL. |
--enable-http-adapter | flag | off | Run orbit.http.request steps inside the runtime. | |
--max-active-executions | ORBIT_MAX_ACTIVE_EXECUTIONS | integer | unlimited | Maximum running executions. Further starts get 429 RESOURCE_EXHAUSTED. See Resource limits. |
--max-fan-out | ORBIT_MAX_FAN_OUT | integer | unlimited | Maximum elements one for step may fan out to. A larger list fails the step with LIMIT_EXCEEDED. |
--max-history-events | ORBIT_MAX_HISTORY_EVENTS | integer | unlimited | Maximum history events per execution. Past it the execution fails with LIMIT_EXCEEDED. |
--max-payload-bytes | ORBIT_MAX_PAYLOAD_BYTES | bytes | unlimited | Maximum serialized size of an execution input, a task result or a signal payload. Larger ones get 413 PAYLOAD_TOO_LARGE. |
--session-timeout | seconds | 30 | A 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-body | bytes | 4194304 (4 MiB) | Maximum request body size. Larger requests get 413. | |
--log-format | text or json | text | Log format. Logs go to stderr. | |
--insecure | flag | off | Allow a non-loopback --addr without a token. Use only on a trusted network. | |
--cluster | ORBIT_CLUSTER | flag | off | Run as one of several servers on the same PostgreSQL database. Requires a PostgreSQL --db. See Running several servers. |
--cluster-mode | ORBIT_CLUSTER_MODE | standby or shared | standby | With 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-timeout | ORBIT_NODE_TIMEOUT | seconds | 5 | Shared 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-id | ORBIT_NODE_ID | string | host name | This server’s name in the cluster. Must be unique among running servers. Requires --cluster. |
--advertise-addr | ORBIT_ADVERTISE_ADDR | URL | from --addr | URL 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-ui | ORBIT_NO_UI | flag | off | Do 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:
| Condition | Message |
|---|---|
A non-loopback --addr without --token or --insecure | refusing 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 file | for 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.
| Option | Limits | When it is reached |
|---|---|---|
--max-active-executions | Executions that are not terminal, held in memory. | A new start gets 429 RESOURCE_EXHAUSTED. Nothing is recorded. |
--max-fan-out | Elements of one for step. | The step fails with LIMIT_EXCEEDED (not retried) before any element is scheduled. |
--max-history-events | Events 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-bytes | Serialized 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 }
...
}
| Field | Same 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:
- checks its configuration;
- opens the database and creates the schema if needed;
- loads every workflow definition;
- loads every execution that is not terminal, replays its history and re-indexes the tokens of in-flight attempts;
- runs the decider for each recovered execution, which fires overdue timers and retries and schedules ready steps;
- 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
- Security
- Storage
- Running several servers
- Observability
- Deployment
- CLI: orbit server
- Environment variables
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
inputtype. An invalid input is rejected withINVALID_INPUTand 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 examplenightly-orders:2026-11-16T01:00:00Z, and recordsschedule_idin the execution.
$ orbit run examples/order_processing.orbit --id order-1001 --no-wait --input-file input.json
Execution statuses
| Status | Terminal | Meaning |
|---|---|---|
RUNNING | no | Steps 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. |
COMPENSATING | no | Compensations are running, one at a time, newest first. |
COMPLETED | yes | Every step finished and the return expression produced a valid output. |
FAILED | yes | A step failed permanently, a resource limit was reached, the return expression failed, or the output did not match the workflow’s output type. |
CANCELLED | yes | Cancellation was requested and the abort finished. |
TIMED_OUT | yes | The workflow timeout passed and the abort finished. |
CONTINUED_AS_NEW | yes, for the run | The 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:
| Value | Meaning |
|---|---|
none | Nothing needed compensating. |
completed | Every compensation succeeded. |
failed | A 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 statusandorbit inspectshow the latest run; its state hasrun(absent for run 1).GET /v1/executions/{id}/historyreturns the latest run’s history;?run=N(andorbit logs --run N,orbit replay --run N) an earlier run’s. Every run’sseqstarts at 1.waitreturns when the latest run ends with a terminal status other thanCONTINUED_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
timeoutapply to the current run. Thetimeoutcounts from the run’s start, andcreated_at_msis 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-executionsand does not incrementorbit_executions_started_total.--max-history-eventsand the workflow’smax_history_eventsapply 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 key | What it is |
|---|---|
step | A 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
| Status | Terminal | Meaning |
|---|---|---|
PENDING | no | Not started: waiting for dependencies, or not reached. A pending unit has no status field in the API. |
SCHEDULED | no | Queued for a worker. Its input is evaluated and stored. |
DISPATCHED | no | A worker received it and holds a token for it. For a child workflow step: the child execution is running. |
ATTEMPT_FAILED | no | Transient: an attempt failed and the decider is choosing between a retry and failure. Never observed between commits. |
UNCERTAIN | no | Transient: the outcome of the attempt is unknown and it will be redelivered. Never observed between commits. |
RETRY_WAIT | no | Waiting for the backoff delay before the next attempt. |
WAITING | no | A sleep timer is running, or an await step is waiting for a signal. |
RUNNING | no | A for step whose elements are running. |
SUCCEEDED | yes | Finished with an output. |
SKIPPED | yes | Its when condition was false (or null). Its output is null. |
FAILED | yes | Failed permanently: retries exhausted, a non-retryable error, or an error the runtime never retries. |
CANCELLED | yes | Withdrawn 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:
| Field | Type | Meaning |
|---|---|---|
seq | integer | Position in the history, starting at 1, without gaps. |
timestamp_ms | integer | Commit time, milliseconds since the Unix epoch. All events of one batch share it. |
type | string | Event 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
| Event | Fields | Meaning |
|---|---|---|
WorkflowCreated | workflow, 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. |
WorkflowStarted | none | The execution began. Always event 2. |
WorkflowTimeoutReached | none | The workflow timeout passed. The execution starts aborting. |
CancelRequested | reason | Cancellation was requested. The execution starts aborting, unless it is already aborting. |
WorkflowCompleted | output | Terminal: COMPLETED. |
LimitExceeded | limit, message | A server resource limit was reached; limit is max_history_events. The execution starts aborting with the error LIMIT_EXCEEDED and message. See Resource limits. |
WorkflowFailed | error, compensation | Terminal: FAILED. The error message names the failed step. |
WorkflowCancelled | compensation | Terminal: CANCELLED. |
WorkflowTimedOut | compensation | Terminal: TIMED_OUT. |
WorkflowContinuedAsNew | next_run, input | Terminal for the run: CONTINUED_AS_NEW. Run next_run starts with input (defaults applied). The last event of an archived run. |
Step events
| Event | Fields | Meaning |
|---|---|---|
StepScheduled | step, attempt, input, idempotency_key, redelivery | The 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). |
StepSkipped | step, reason | The when condition was false. |
StepFanOut | step, items | A for step evaluated its list once. One unit per element follows, step[0], step[1], and so on. |
StepDispatched | step, attempt, token, worker_id, worker_session, deadline_ms | A 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>. |
StepSucceeded | step, attempt, output | The unit finished. For a for step, output is the list of element outputs, in order. |
StepAttemptFailed | step, attempt, error | One 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. |
StepAttemptUncertain | step, attempt, reason | The 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. |
RetryScheduled | step, next_attempt, delay_ms, fire_at_ms | The failed attempt will be retried after delay_ms. |
StepFailed | step, error | The unit failed permanently. For a regular step, the execution starts aborting. When retries were exhausted, the message ends with (gave up after N attempts). |
StepCancelled | step | A unit that had not started was withdrawn because the execution is aborting. |
TimerStarted | step, fire_at_ms | A sleep step started its durable timer. |
TimerFired | step | The timer fired. The sleep step succeeds with {}. |
StaleResultIgnored | step, token, outcome | A 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
| Event | Fields | Meaning |
|---|---|---|
SignalReceived | name, payload | A 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. |
SignalAwaitStarted | step, signal, deadline_ms? | An await step started waiting; the unit is WAITING. deadline_ms is set when the step has a timeout. |
SignalDelivered | step, signal_seq | The 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.
| Field | Type | Meaning |
|---|---|---|
seq | integer | Sequence number of its SignalReceived event. |
name | string | Signal name. |
payload | object | Validated payload. |
received_at_ms | integer | When it was recorded. |
delivered_to | string, optional | The 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
| Event | Fields | Meaning |
|---|---|---|
CompensationStarted | reason, steps | The execution is COMPENSATING. steps lists the units to compensate, newest success first. reason explains the abort. |
CompensationCompleted | none | Every compensation succeeded. Followed by the terminal event. |
CompensationFailed | step, error | A 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
| Event | Fields | Meaning |
|---|---|---|
HandlersStarted | kind, compensation | Compensation 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:
| Code | Retryable | Produced when |
|---|---|---|
TIMEOUT | yes | An attempt exceeded its step timeout. |
QUEUE_TIMEOUT | no | No worker picked up the task within queue_timeout. |
EXPRESSION_ERROR | no | Evaluating with, when, for, idempotency_key, a compute step or return failed, or when did not produce a Bool. |
SIGNAL_TIMEOUT | no | An await step’s timeout passed before a signal of its name arrived. |
INPUT_CONTRACT_VIOLATION | no | The evaluated step input does not match the task’s input contract. |
OUTPUT_CONTRACT_VIOLATION | no | A worker’s output does not match the task’s output contract, or the workflow output does not match its output type. |
ADAPTER_DISABLED | no | An orbit.http.request step ran on a server without --enable-http-adapter. |
HTTP_ERROR | depends | The HTTP adapter failed: a missing or non-HTTP URL, a connection error, or an error response. |
CHILD_NOT_REGISTERED, CHILD_START_FAILED, CHILD_MISSING | no | A child workflow could not be started or found. |
CHILD_CANCELLED, CHILD_TIMED_OUT | no | A 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. |
CANCELLED | no | A retry that was waiting when the execution started aborting, with no earlier error to report. |
LIMIT_EXCEEDED | no | A 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
| Command | Shows |
|---|---|
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 list | Registered 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:
- Units that have not started are withdrawn (
StepCancelled): pending units, queued units, runningsleeptimers,awaitsteps 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. - Attempts in flight are awaited, because they may still succeed and then need compensating. Their workers see the tokens in the
cancellist of their next heartbeat (ctx.cancelledorctx.signalin the SDKs). Stopping is cooperative: the runtime cannot stop an external operation that is already running. - A child workflow whose parent is aborting is cancelled too, with the reason
parent workflow aborted. - Every succeeded unit with a
compensateblock is compensated, one at a time, newest first. Compensations run even though the execution is aborting, with their own retry policy. - The execution ends
CANCELLEDwithcompensation: none,completedorfailed.
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:
| Timeout | Declared on | Measures | On expiry |
|---|---|---|---|
timeout | a step | One 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_timeout | a step | How long a scheduled task waits for a worker. Does not apply to redeliveries. | The attempt fails with QUEUE_TIMEOUT (not retried). |
timeout | an await step | The wait for a signal, from SignalAwaitStarted. | The step fails with SIGNAL_TIMEOUT (not retried), and the execution aborts. |
timeout | the workflow | The 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
- Delivery guarantees
- Compensation
- Outcome handlers
- Running the server: resource limits
- Signals and queries
- Retries and timeouts
- HTTP API
- CLI
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:
| Setting | Default | Meaning |
|---|---|---|
workflow, version, input | latest version, {} | What to start. |
cron or interval | required, exactly one | When to start it. |
timezone | UTC | The time zone of a cron expression. |
overlap | skip | What to do when the previous execution is still running. |
catchup | skip_missed | What to do with occurrences missed while no server could fire them. |
start_at, end_at | none | No occurrence before start_at or after end_at. |
jitter | none | Delay each fire by a deterministic amount below this duration. |
paused | false | A paused schedule does not fire. |
description | none | Free text. |
Timing
Cron expressions
A cron expression has five fields, separated by spaces:
| Field | Values | Names |
|---|---|---|
| minute | 0-59 | |
| hour | 0-23 | |
| day of month | 1-31 | |
| month | 1-12 | JAN-DEC |
| day of week | 0-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.
| Expression | Fires |
|---|---|
0 2 * * * | Every day at 02:00. |
*/15 * * * * | Every 15 minutes, on the quarter hour. |
30 9 * * MON-FRI | At 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):
| Policy | Behavior |
|---|---|
skip (default) | Start nothing; the occurrence counts as skipped. The next occurrence is checked again. |
allow | Start anyway. Executions of the schedule may run at the same time. |
cancel_previous | Cancel 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:
| Policy | Behavior |
|---|---|
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_once | Start 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
| Action | CLI | API |
|---|---|---|
| Pause | orbit schedule pause <id> | POST /v1/schedules/{id}/pause |
| Resume | orbit schedule resume <id> | POST /v1/schedules/{id}/resume |
| Start an execution now | orbit schedule trigger <id> | POST /v1/schedules/{id}/trigger |
| Change timing, input or policies | orbit schedule update <id> ... | PATCH /v1/schedules/{id} |
| Delete | orbit 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:
- 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. - Decides with the catch-up and overlap policies, and starts the
execution (or cancels the previous one first, with
cancel_previous). - Advances the schedule with a second compare-and-set:
next_fire_msmoves to the next occurrence, the counters andlast_execution_idare 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
| Metric | Meaning |
|---|---|
orbit_schedules_fired_total | Executions 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
scheduleform in.orbitfiles, andorbit deploydoes not create schedules. - Firing has a granularity of about one second; occurrences more often than
every second are not possible (
intervalis at least1s). - 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;skipandcancel_previouskeep at most one running.
See also
- HTTP API: schedules
- CLI: orbit schedule
- Executions and history
- Running several servers
- Storage: schedule table
- Continue-as-new
- Web UI: schedules
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
StepDispatchedevent is committed. A result is acknowledged to the worker ({"accepted": true}) only after itsStepSucceededorStepAttemptFailedevent 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 exampleorder-1002/validate_orderororder-1002/compensate:reserve_inventory. - Set your own with
idempotency_key:on the step or thecompensateblock, for example"charge-" + input.order_id. The expression is evaluated once, when the unit is first scheduled. - The key is identical across retries (
attemptincreases) and redeliveries (redelivery: true, sameattempt).
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:
| Cause | Reason 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-1came back and reported success with its original token beforeflaky-2finished; the runtime accepted it (event #8), andflaky-2found its own token in thecancellist of its next heartbeat. - A failure is accepted only for the attempt currently in flight. A failure from an older attempt is recorded as
StaleResultIgnoredwithoutcome: failureand answered{"accepted": false, "reason": "stale attempt"}. - A result for a resolved unit has no effect. It is recorded as
StaleResultIgnoredwithoutcome: duplicateand 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_VIOLATIONand 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:
- loads the workflow definitions;
- loads every execution that is not terminal and replays its history;
- re-indexes the tokens of in-flight attempts, so results for them are accepted;
- runs the decider, which fires overdue timers and retries, applies timeouts that passed during the outage and schedules ready steps;
- 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-256definition_hashof 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_REGISTEREDotherwise).orbit deployregisters children before parents. - Starting an execution with a client-chosen
execution_idis idempotent: a repeated start returns the existing execution (created: false) instead of creating a duplicate. The same id with a different definition is refused with409 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
cancellist, 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
--clusterso a standby takes over within seconds; one leader still does all the work. With--cluster-mode sharedevery 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_ERRORstarting withfenced:). - 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:
| Topic | One server | Shared mode |
|---|---|---|
| Task handed out | After 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 acknowledged | After 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 seq | Always set. | null when the owner has not recorded the signal within 10 seconds; the signal is still recorded once. |
| Extra redelivery cause | A 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 detected | One --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, timeouts | Fired 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-executions | Running executions. | Running executions this server owns. Each server applies its own limit. |
| Metrics | The 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.
| SQLite | PostgreSQL | |
|---|---|---|
--db | a file path, default orbit.db | postgres://user:pass@host:5432/db |
| Setup | none; the file is created on first start | an existing database and a user that can create tables |
| Durability | WAL mode, synchronous=FULL | one transaction per batch of events |
| Connection | in-process | one connection on a dedicated thread, reconnected automatically |
| Good for | development, single hosts, moderate volume | managed 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-walandorbit.db-shmnext toorbit.db. Keep the three files together. synchronous=FULLmakes every commit durable before it is acknowledged, at the cost of anfsyncper batch. Put the file on a local disk with reliablefsync; 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 needsCREATEon 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 PostgreSQLin the log). A statement issued while the database is unreachable fails withSTORAGE_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:
sslmode | Behavior |
|---|---|
disable | Never use TLS. |
prefer (default) | Use TLS when the server offers it. |
require | Insist 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:
| Table | Key | Contents |
|---|---|---|
schema_version | One 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_executions | execution_id | Projection 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_schedules | schedule_id | Schedules: see Schedule table. |
worker_registry | worker_id | Latest registration of each worker: runtime, sdk, contracts (JSON), registered_at_ms, last_seen_ms. |
orbit_cluster | id | PostgreSQL, cluster mode only. See Cluster table. |
orbit_nodes, orbit_owners, orbit_tasks, orbit_inbox, orbit_sessions | PostgreSQL, 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.
| Column | Type | Meaning |
|---|---|---|
schedule_id | TEXT, primary key | The schedule id. |
spec | TEXT | The specification as JSON: workflow, version, input, cron or interval, time zone, policies, start and end, jitter, description. |
paused | BIGINT | 1 while paused. |
next_fire_ms | BIGINT | The next occurrence (nominal time), or null when the schedule has ended. Claims and advances compare it (compare-and-set). |
due_ms | BIGINT | When that occurrence fires: next_fire_ms plus its jitter. Indexed. |
last_fire_ms | BIGINT | The latest occurrence handled. |
last_execution_id | TEXT | The latest execution started, including triggers. |
fired_count, skipped_count | BIGINT | Counters shown as fired and skipped. |
last_error | TEXT | Why the latest start failed, if it did. |
claimed_by | TEXT | The engine instance that holds a claim on next_fire_ms, or null. |
claimed_until_ms | BIGINT | When that claim expires (30 seconds after it was taken), or when a failed start is retried. |
created_at_ms, updated_at_ms | BIGINT | Creation 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.
| Column | Type | Meaning |
|---|---|---|
id | INT, primary key | Always 1: the table has one row. |
term | BIGINT | Leadership term, incremented by every newly elected leader. Every history append of the leader checks it in the same transaction (fencing). |
leader_node | TEXT | --node-id of the latest leader. |
leader_addr | TEXT | --advertise-addr of the latest leader, or null. |
since_ms | BIGINT | When 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.
| Column | Type | Meaning |
|---|---|---|
node_id | TEXT, primary key | --node-id. |
incarnation | TEXT | Random id of the running process; a restarted server gets a new one. |
addr | TEXT | --advertise-addr, or null. |
heartbeat_ms | BIGINT | Last heartbeat (every second). 0 after a graceful shutdown. |
started_ms | BIGINT | When the process registered. |
orbit_owners: one row per running execution.
| Column | Type | Meaning |
|---|---|---|
execution_id | TEXT, primary key | The execution. |
root_id | TEXT | The top-level execution of its tree: child workflows share their root’s owner and move with it. |
node_id | TEXT | The owner. Indexed. |
epoch | BIGINT | 1 when the execution starts, incremented by every takeover. Every history append checks it in the same transaction (fencing). |
since_ms | BIGINT | When 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.
| Column | Type | Meaning |
|---|---|---|
id | BIGSERIAL, primary key | |
execution_id, unit_key, attempt, offer | The 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, language | TEXT | What a worker must announce to get it (language from using, lowercase, or null). |
payload | TEXT | The task assignment as JSON, without token and deadline. |
enqueued_ms | BIGINT | When the unit was scheduled. Workers get the oldest matching row first. |
state | TEXT | queued, 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_ms | The claim: token handed out, server and process that handed it out, worker session and worker. Indexed by token and claimed_session. | |
updated_ms | BIGINT | Last change. |
orbit_inbox: commands for an execution’s owner, applied in seq order.
| Column | Type | Meaning |
|---|---|---|
seq | BIGSERIAL, primary key | Order of arrival. |
execution_id | TEXT | The execution. Indexed with seq. |
kind | TEXT | dispatched, complete, fail, uncertain, signal or cancel. |
token | TEXT | The task token, for task-related kinds. |
payload | TEXT | The command as JSON. |
created_ms | BIGINT | When it was written. |
done_ms, result | For 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.
| Column | Type | Meaning |
|---|---|---|
session_id | TEXT, primary key | The worker_session_id returned by registration. |
worker_id, runtime, contracts | TEXT | From the registration (contracts as JSON). Indexed by worker_id. |
node_id | TEXT | The server that registered it. |
registered_ms | BIGINT | When it registered. |
last_seen_ms | BIGINT | Last 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 the server
- Running several servers
- Delivery guarantees
- Deployment
- Security
- Environment variables
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.
| Role | Engine | Routes served | /v1/ready |
|---|---|---|---|
leader | Full engine, recovered from history when elected. Ticks timers, dispatches tasks, accepts results. | All | 200 |
standby | Read-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 |
single | A server without --cluster. | All | 200 |
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
| Option | Environment | Default | Meaning |
|---|---|---|---|
--cluster | ORBIT_CLUSTER | off | Take part in leader election. Requires a PostgreSQL --db. |
--node-id | ORBIT_NODE_ID | the host name ($HOSTNAME, then /etc/hostname) | This server’s name in health output, logs and the orbit_cluster table. Requires --cluster. |
--advertise-addr | ORBIT_ADVERTISE_ADDR | http(s)://<--addr> when --addr is not a wildcard address, otherwise none | URL 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 kill | What happens |
|---|---|
| 0 s | The 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 s | A standby’s next pg_try_advisory_lock succeeds. It increments the term, opens a fenced store and recovers active executions from history. |
| + recovery time | The new leader serves. /v1/ready answers 200, the load balancer switches to it within its check interval. |
| next request | Workers’ old sessions are unknown on the new leader (404 UNKNOWN_SESSION); every SDK registers again and continues polling. |
| when the worker reports | A 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 started | Attempts 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 failover | What happens |
|---|---|
| Events committed by the old leader | Kept. The new leader recovers them. |
| A request the old leader had not committed | Lost. 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 running | Its 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, deadlines | Durable in history; the new leader fires those that are due. |
| Built-in HTTP adapter calls | The 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, metrics | In memory only. Workers register again; counters start from zero on the new leader. |
GET /v1/executions/{id}/wait | A 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
| Client | Behavior on 503 NOT_LEADER |
|---|---|
orbit CLI | Repeats the request once against the leader address in the response. orbit deploy --server <standby> works. |
| Python, TypeScript, Go, Rust, Java and C# SDK workers | Treat 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 applications | Get 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}
| Field | Meaning |
|---|---|
role | leader, standby, shared in shared mode, or single without --cluster. |
node_id | This server’s --node-id (null without --cluster). |
term | The 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-timeoutafter 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.
| What | Where it lives | Who acts on it |
|---|---|---|
| History of an execution | execution_events, as on one server | Only its owner appends. Any server reads. |
| Running executions | In the memory of their owner, rebuilt from history on takeover | The owner ticks timers, retries, deadlines and child workflows. |
| Worker tasks | orbit_tasks | Any server claims them for the workers polling it. |
| Commands for an execution owned elsewhere | orbit_inbox | Its owner applies them in order. |
| Worker sessions | orbit_sessions, cached by every server that serves the worker | A worker can poll, heartbeat and report through any server. |
| Workflow definitions | workflow_definitions, as on one server | Any 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
| Option | Environment | Default | Meaning |
|---|---|---|---|
--cluster-mode | ORBIT_CLUSTER_MODE | standby | shared makes every server active. Requires --cluster. |
--node-timeout | ORBIT_NODE_TIMEOUT | 5 | Seconds without a heartbeat after which a server is dead and the others take over its executions. At least 2. |
--node-id | ORBIT_NODE_ID | the host name | Must 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-addr | ORBIT_ADVERTISE_ADDR | from --addr | Shown 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:
| Kind | Sent when | Applied by the owner as |
|---|---|---|
dispatched | A 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, fail | A worker reported a result through a server that does not own the execution. | POST /v1/tasks/complete or fail, as on one server. |
uncertain | A worker unregistered while holding a task. | StepAttemptUncertain and redelivery. |
signal, cancel | A 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 kill | What happens |
|---|---|
| 0 s | The 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-timeout | The 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-timeout | The 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 result | Results of attempts that were in flight are accepted by the new owner: their tokens are in the history it rebuilt. |
--node-timeout + --session-timeout | An 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 contact | Attempts of workers that died too are uncertain and redelivered (StepAttemptUncertain, redelivery: true). |
| Failure | What happens |
|---|---|
| A server crashes or is killed | As 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 gracefully | Its executions are taken over within about a second. |
A server pauses (stop-the-world, frozen VM) longer than --node-timeout | Its 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 database | It 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 dies | Its attempts are redelivered after the session timeout, by their owners, as on one server. |
| PostgreSQL fails over | All 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.
| Field | Meaning |
|---|---|
role | shared. |
node_id | This server’s --node-id. |
term, leader | Always null in shared mode. |
active_executions, owned_executions | Running executions this server owns. |
tasks_claimed | Tasks this server handed to workers since it started. |
nodes | Every 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.
| Servers | Worker processes | Orders per second |
|---|---|---|
1, single server (no --cluster) | 1 Python, 1 TypeScript | 37.2, 38.2 |
1, single server (no --cluster) | 2 Python, 2 TypeScript | 32.6 |
| 1, shared mode | 1 Python, 1 TypeScript | 28.4, 29.9 |
| 2, shared mode | 1 Python, 1 TypeScript | 37.3 |
| 3, shared mode | 1 Python, 1 TypeScript | 42.6, 41.4 |
| 3, shared mode | 2 Python, 2 TypeScript | 42.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-timeoutplus--session-timeoutare 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-executionslimits 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-idare refused or stop serving; they would otherwise take each other’s executions. - No mixed modes. Do not run
standbyandsharedservers, 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
- Running the server
- Storage
- Storage: shared-mode tables
- Deployment
- Delivery guarantees
- HTTP API: GET /v1/ready
- CLI: orbit server
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
| Credential | Configured with | Who uses it |
|---|---|---|
| Admin token | --token / ORBIT_TOKEN | Operators, the orbit CLI, CI pipelines that deploy workflows, and applications without a client identity. |
| Shared worker token | --worker-token / ORBIT_WORKER_TOKEN | Worker hosts, when you do not need per-worker identities. |
| Per-worker token | a token_sha256 entry in --workers-file | One worker identity. |
| Client certificate | a client_cert entry in --workers-file, with --tls-client-ca | One worker identity, over mutual TLS. |
| Client identity token | a token_sha256 entry of a [[client]] table in --workers-file | One application, limited to the execution API for some workflows. |
| Client identity certificate | a client_cert entry of a [[client]] table, with --tls-client-ca | One 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.
| Field | Type | Required | Meaning |
|---|---|---|---|
id | string | yes | Worker id. The worker must register with this exact worker_id. Must not be empty, and must be unique in the file. |
token_sha256 | string | one of token_sha256 or client_cert | Hex SHA-256 of the worker’s bearer token, 64 characters (case-insensitive). The token itself is never stored. |
client_cert | string | one of token_sha256 or client_cert | Certificate name for mutual TLS: matched, case-insensitively, against the certificate’s DNS subject alternative names and subject common names. |
tasks | array of strings | yes, at least one | Task patterns the worker may announce: an exact name (audit.log), a prefix ending in * (payments.* matches payments.charge), or * for every task. |
runtime | string | no | When 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:
| Action | Rule | Error |
|---|---|---|
| Register | worker_id must equal its id. | this credential belongs to worker `payments-1`, not `other` |
| Register | Every announced task must match one of its tasks patterns. | worker `payments-1` may not serve: inventory.reserve (allowed: payments.*, audit.log) |
| Register | If runtime is set, the announced runtime must match. | worker `payments-1` must announce runtime `python` |
| Heartbeat, poll, unregister | The session must be one this worker opened. | session belongs to worker `payments-1`, not `inventory` |
| Complete, fail | The 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.
| Field | Type | Required | Meaning |
|---|---|---|---|
id | string | yes | Client name, used in error messages. Must not be empty, and must be unique in the file across workers and clients. |
token_sha256 | string | one of token_sha256 or client_cert | Hex SHA-256 of the client’s bearer token, 64 characters (case-insensitive). |
client_cert | string | one of token_sha256 or client_cert | Certificate name for mutual TLS, matched like a worker’s client_cert. |
workflows | array of strings | yes, at least one | Workflow name patterns: an exact name (Refund), a prefix ending in * (Invoice* matches InvoiceMonthly), or * for every workflow. |
actions | array of strings | no | Granted 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:
| Request | Needs |
|---|---|
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 definition | read |
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-addris set. --tls-certis a PEM file with the certificate chain, leaf first.--tls-keyis the PEM private key (PKCS#8, PKCS#1 or SEC1).- Clients trust a private CA with
--ca-certorORBIT_CA_CERT(CLI, Python, Go, Rust, Java, C#) orNODE_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-caare verified during the TLS handshake. - A verified certificate identifies the worker whose
client_certmatches one of its DNS subject alternative names or subject common names. - A verified certificate that matches no
[[worker]]entry identifies the[[client]]entry whoseclient_certmatches it, if any (worker entries are checked first). - A verified certificate that matches neither is refused with
403on 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-carequires 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
| Endpoint | No auth configured | Admin token | Shared worker token | Per-worker identity | Client identity | No or invalid credential (auth configured) |
|---|---|---|---|---|---|---|
GET /v1/health | yes | yes | yes | yes | yes | yes |
GET /v1/metrics | yes | yes | 403 | 403 | 403 | 401 |
POST /v1/workflows | yes | yes | 403 | 403 | 403 | 401 |
GET /v1/workflows | yes | yes | 403 | 403 | filtered to readable workflows | 401 |
GET /v1/workflows/{name}... | yes | yes | 403 | 403 | with read | 401 |
POST /v1/executions | yes | yes | 403 | 403 | with start | 401 |
GET /v1/executions | yes | yes | 403 | 403 | filtered to readable workflows | 401 |
GET /v1/executions/{id}, history, wait | yes | yes | 403 | 403 | with read | 401 |
POST /v1/executions/{id}/signals/{name} | yes | yes | 403 | 403 | with signal | 401 |
GET /v1/executions/{id}/queries/{name} | yes | yes | 403 | 403 | with query | 401 |
POST /v1/executions/{id}/cancel | yes | yes | 403 | 403 | with cancel | 401 |
POST /v1/schedules, GET, PATCH, DELETE /v1/schedules/{id}, POST .../pause, resume, trigger | yes | yes | 403 | 403 | with schedule | 401 |
GET /v1/schedules | yes | yes | 403 | 403 | filtered to workflows it may schedule | 401 |
GET /v1/workers | yes | yes | 403 | 403 | 403 | 401 |
POST /v1/workers/register | yes | yes | yes, any id and tasks | own id, allowed tasks, required runtime | 403 | 401 |
POST /v1/workers/heartbeat, poll, unregister | yes | yes | yes | own sessions only | 403 | 401 |
POST /v1/tasks/complete, fail | yes | yes | yes | own tokens only | 403 | 401 |
| gRPC worker protocol | yes | yes | yes | as over HTTP | PERMISSION_DENIED | UNAUTHENTICATED |
GET /ui/* (web UI assets) and GET / | yes | yes | yes | yes | yes | yes (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 permittedwith 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 asAuthorization: Bearer. It is never put in a URL or a cookie. The UI does not use TLS client certificates: requests are made withcredentials: "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, aPermissions-Policythat disables camera, microphone, geolocation and payment, andCache-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-uiif browsers should not reach the server at all.
Input validation and limits
- Request bodies are limited to
--max-bodybytes (4 MiB by default); larger requests get413. - 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 with413 PAYLOAD_TOO_LARGEand not recorded. - With
--max-active-executions, a start beyond the limit gets429 RESOURCE_EXHAUSTED. Restarting an existingexecution_idand starting child workflows are never refused. - With
--max-fan-out, aforstep over a longer list fails withLIMIT_EXCEEDED; with--max-history-events, an execution whose history reaches the limit fails withLIMIT_EXCEEDED. Compensation andon failurehandlers 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 --jsonand the output line oforbit statusreplace the value of every key whose name contains, case-insensitively,password,passwd,secret,token,api_key,apikey,authorization,credential,private_key,card_numberorcvvwith"***". The protocol’s owntokenfield 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
--dbis redacted in logs.
Production checklist
- Set
--token(orORBIT_TOKEN) to a value fromorbit token, stored in a secret manager. - Give workers per-worker identities (
--workers-file) with the narrowesttaskspatterns, or at least a shared--worker-token. Never give workers the admin token. - Give applications that start or follow executions client identities (
[[client]]) with the narrowestworkflowspatterns and actions, and keep the admin token for operators and deployment pipelines. - Set resource limits (
--max-active-executions,--max-fan-out,--max-history-events,--max-payload-bytes) that fit your largest legitimate workload. - Serve TLS with
--tls-certand--tls-key. Use mutual TLS (--tls-client-ca) for worker identities where your PKI allows it. - Use
sslmode=requirefor PostgreSQL, withORBIT_DB_CA_CERTfor a private CA. - Keep the HTTP adapter disabled unless you need it.
- Do not pass
--insecure. - Restrict network access to the API port to the hosts that need it.
/v1/metricsrequires the admin token, so give your monitoring system a way to send it, or scrape through a proxy that adds it. - Decide whether operators should use the web UI from a browser; if not, pass
--no-ui. - Protect the database and its backups as sensitive data: they contain every input and output.
- 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
- Running the server
- Deployment
- HTTP API: authentication
- Running the server: resource limits
- Environment variables
- How workers work
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
| Level | Message | Fields |
|---|---|---|
| INFO | starting ORBIT runtime | db (password redacted), backend, http_adapter, auth, ui |
| INFO | ORBIT runtime listening | addr, tls, mtls (with TLS), ui |
| INFO | gRPC worker protocol listening | addr, tls |
| INFO | exporting OpenTelemetry traces | endpoint, service |
| INFO | recovered active executions from history | executions, in_flight |
| INFO | registered workflow definition | workflow, version, hash |
| INFO | execution started | execution_id, workflow, version |
| INFO | execution completed, execution cancelled | execution_id |
| WARN | execution failed | execution_id, code, error, compensation |
| WARN | execution timed out | execution_id |
| WARN | step failed | execution_id, step, code, error |
| INFO | retry scheduled | execution_id, step, attempt, delay_ms |
| WARN | attempt outcome uncertain; redelivering | execution_id, step, attempt, reason |
| INFO | cancelling child workflow because its parent no longer needs it | execution_id |
| INFO | worker registered | worker_id, runtime, tasks |
| INFO | worker unregistered | worker_id |
| WARN | reconnected to PostgreSQL | |
| ERROR | PostgreSQL reconnect failed | error |
| WARN | OTLP collector rejected spans | status |
| WARN | cannot export spans to the OTLP collector | error |
| WARN | OTLP export queue is full; dropping spans | |
| ERROR | tick failed, recording built-in task result failed | error |
| INFO | shutting down | |
| DEBUG | event | execution_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().
| Metric | Type | Incremented by | Meaning |
|---|---|---|---|
orbit_executions_started_total | counter | WorkflowCreated | Executions started, including child workflows. A new run after continue_as_new is not counted. |
orbit_executions_completed_total | counter | WorkflowCompleted | Executions completed. |
orbit_executions_failed_total | counter | WorkflowFailed | Executions failed. |
orbit_executions_cancelled_total | counter | WorkflowCancelled | Executions cancelled. |
orbit_executions_timed_out_total | counter | WorkflowTimedOut | Executions timed out. |
orbit_tasks_dispatched_total | counter | StepDispatched | Task attempts dispatched, including redeliveries, child workflow starts and built-in tasks. |
orbit_tasks_succeeded_total | counter | StepSucceeded | Units that succeeded, including compensations and completed for steps. |
orbit_task_attempts_failed_total | counter | StepAttemptFailed | Failed attempts, whether or not they are retried. |
orbit_task_attempts_uncertain_total | counter | StepAttemptUncertain | Attempts with an unknown outcome, redelivered. |
orbit_retries_scheduled_total | counter | RetryScheduled | Retries scheduled. |
orbit_steps_skipped_total | counter | StepSkipped | Steps skipped by when conditions. |
orbit_stale_results_total | counter | StaleResultIgnored | Duplicate or late results ignored. |
orbit_compensations_started_total | counter | CompensationStarted | Executions that started compensating. |
orbit_compensations_failed_total | counter | CompensationFailed | Compensations that failed permanently (one per failed compensation). |
orbit_events_committed_total | counter | every event | History events committed. |
orbit_executions_continued_as_new_total | counter | WorkflowContinuedAsNew | Runs that ended with continue_as_new. |
orbit_schedules_fired_total | counter | a schedule start | Executions 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"} | counter | a schedule occurrence or trigger | Starts skipped by the skip overlap policy. |
orbit_schedules_skipped_total{reason="missed"} | counter | a schedule occurrence | Occurrences that passed while no server could fire them and that the catch-up policy did not start. |
orbit_active_executions | gauge | Executions not yet finished. | |
orbit_worker_sessions | gauge | Worker 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
| Condition | Expression |
|---|---|
| Executions failing | rate(orbit_executions_failed_total[5m]) > 0 |
| Compensation failing (needs a person) | increase(orbit_compensations_failed_total[15m]) > 0 |
| Workers going silent | rate(orbit_task_attempts_uncertain_total[5m]) > 0 |
| No workers connected | orbit_worker_sessions == 0 |
| Runtime down | up{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
| Setting | Meaning |
|---|---|
--otlp-endpoint URL or OTEL_EXPORTER_OTLP_ENDPOINT | Base URL of the collector, for example http://localhost:4318. Spans are posted to <URL>/v1/traces. Export is off when unset. |
OTEL_EXPORTER_OTLP_HEADERS | Extra request headers as key=value pairs separated by commas, for example an API key. |
OTEL_SERVICE_NAME | service.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>: fromWorkflowCreatedto the terminal event. Exported when the execution finishes. - Unit spans
step <unit key>: one per attempt of a task, from dispatch to result; one persleeptimer, 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:
| Span | Error when | Status message |
|---|---|---|
| Unit | the attempt failed | <code>: <message>, for example CARD_DECLINED: card was declined |
| Unit | the outcome is uncertain | outcome unknown: <reason> |
| Root | the execution did not complete | <code>: <message> of the execution error, or the status (CANCELLED, TIMED_OUT) |
Attributes
| Attribute | Type | On | Meaning |
|---|---|---|---|
orbit.execution_id | string | all | Execution id. |
orbit.workflow | string | all | Workflow name. |
orbit.workflow_version | string | root | Workflow version. |
orbit.status | string | root | Final status, for example COMPLETED. |
orbit.compensation | string | root | none, completed or failed, when the execution was aborted. |
orbit.unit | string | unit | Unit key, for example reserve[2] or compensate:reserve_inventory. |
orbit.attempt | integer | unit | Attempt number. |
orbit.outcome | string | unit | succeeded, failed, uncertain or timer fired. |
orbit.worker_id | string | unit | Worker that ran the attempt, when it was dispatched. |
orbit.redelivery | boolean | unit | Present 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:
| Id | Derived 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_idandattempt, 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:
| View | What a client sees |
|---|---|
| Overview | Server health (open to everyone), counts of the executions it may read, and its recent failures. The Workers panel shows Not permitted. |
| Executions | Only executions of workflows it may read (read action). Opening another execution shows Not permitted. |
| Execution detail | Queries, 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` . |
| Workflows | Only definitions of workflows it may read. |
| Schedules | Only 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. |
| Workers | Not 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

| Panel | Contents | Source |
|---|---|---|
| Server | Status, 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 |
| Workers | Online and offline workers, tasks in flight, and the tasks the online workers serve. | GET /v1/workers |
| Schedules | Active 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 status | One 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 failures | The eight most recently updated FAILED or TIMED_OUT executions, with their error code and message. | the same lists, then GET /v1/executions/{id} |
Executions

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=ofGET /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

The detail page of one execution has these sections:
| Section | Contents |
|---|---|
| Execution | Status, 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. |
| Actions | One Run button per declared query, one form per declared signal, and Cancel execution. See below. |
| Input and result | The input, the output once completed, and the error if it failed, as JSON. |
| Graph | The workflow’s steps drawn from the definition, coloured by the status of each step in this execution. See Graph. |
| Steps | One 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 executions | For 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 received | Every signal in arrival order: history sequence number, name, time received, the unit it was delivered to, and its payload. |
| History | Every 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:

| Section | Contents |
|---|---|
| Run | Run 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 input | The run’s input (from WorkflowCreated) and the input of the next run (from WorkflowContinuedAsNew). |
| Graph, Steps | The graph and step table, with step states rebuilt from the run’s history. |
| Signals received, History | The 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 type | Control |
|---|---|
Bool | A list: true, false, and null (optional field) or default (...) (field with a default) |
Int, Float, Duration | A number input |
String, UUID, Timestamp, Bytes | A 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:

- 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
compensateblock 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 withfor <variable>:for fan-out steps. - A workflow with
continue_as_newhas a node with a double border after its final steps (the steps nothing depends on), showingcontinue as newand its condition (when ..., oralways); 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 colouredcontinued 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
forstep 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

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.

| Action | API call |
|---|---|
| Pause, Resume | POST /v1/schedules/{id}/pause and /resume |
| Trigger now | POST /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'andX-Frame-Options: DENYstop 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, classWebUI). 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.
| Program | Measures |
|---|---|
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)
| Store | Executions/s | Steps/s | Latency p50 | p90 | p99 |
|---|---|---|---|---|---|
| In memory (no durability) | 1210 | 3629 | 11 ms | 15 ms | 18 ms |
| SQLite file | 244 | 731 | 52 ms | 71 ms | 101 ms |
| PostgreSQL | 111 | 332 | 113 ms | 151 ms | 206 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)
| Store | Executions | Executions/s | Steps/s |
|---|---|---|---|
| In memory | 5000 | 4464 | 13392 |
| In memory, 10 steps each | 5000 | 1346 | 13464 |
| SQLite file | 5000 | 425 | 1276 |
| PostgreSQL | 2000 | 138 | 413 |
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
| Option | Default | Meaning |
|---|---|---|
--executions | 2000 | Executions to run. |
--steps | 3 | Sequential task steps per execution. |
--store | memory | memory, a SQLite file path (deleted first), or a PostgreSQL URL (used as is; give it an empty database). |
--server | none | bench_http only: use this runtime instead of starting one in-process. |
--token, --worker-token | admin, worker | bench_http only: credentials for --server. |
--workers, --concurrency | 4, 8 | bench_http only: SDK workers, and handler threads per worker. |
--clients | 4 | bench_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:
| Tag | Meaning |
|---|---|
ghcr.io/ajmalx0/orbit:v0.1.0 | A specific release. Use this in production. |
ghcr.io/ajmalx0/orbit:latest | The most recent release. |
The image is built for linux/amd64 from the repository’s Dockerfile:
| Property | Value |
|---|---|
| Base | debian:bookworm-slim with ca-certificates |
| Binary | /usr/local/bin/orbit (release build, stripped) |
| User | orbit, UID 10001, home /data |
| Working directory and volume | /data |
| Entrypoint | orbit |
| Default command | server |
| Environment | ORBIT_DB=/data/orbit.db, ORBIT_ADDR=0.0.0.0:7233 |
| Exposed ports | 7233 (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 SIGINTmakesdocker stopshut the server down gracefully with every version. Version 0.1.0 does not handleSIGTERM, the signaldocker stopsends by default, and in a container the server is process 1, which ignores signals it has no handler for; later versions handleSIGTERMand their image setsSTOPSIGNAL SIGINT.--stop-timeout 75leaves 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:
| Asset | Platform |
|---|---|
orbit-x86_64-unknown-linux-gnu.tar.gz | Linux x86_64 (glibc) |
orbit-aarch64-unknown-linux-gnu.tar.gz | Linux arm64 (glibc) |
orbit-x86_64-apple-darwin.tar.gz | macOS Intel |
orbit-aarch64-apple-darwin.tar.gz | macOS Apple silicon |
orbit-x86_64-pc-windows-msvc.zip | Windows 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
fsyncfor 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
- Read the changelog for the new version.
- Back up the database. See Storage.
- 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 reportsapi_versionorbit-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, ororbit-ir/2for workflows that use signals, queries,awaitorcomputesteps, lambdas,onhandlers or alimitsblock./v1/healthlists the IR versions a runtime accepts inir_versions; upgrade the runtime before you deployorbit-ir/2definitions to it. See Versioning. - The database schema is versioned in the
schema_versiontable (version1in 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
| Option | Meaning |
|---|---|
[FILES]... | Files to format. At least one is required; directories are not expanded, so use your shell’s globbing. |
--check | Do 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 code | Meaning |
|---|---|
0 | Every 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 withthenorelse) 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@versionis rejected withDEFINITION_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 option | Meaning |
|---|---|
<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.
| Severity | Meaning | Affects the exit code |
|---|---|---|
error | The new version would be rejected by the runtime, or breaks callers, consumers or workers without the version change that announces it. | Yes |
warning | Something 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 |
ok | A compatible change, or an unchanged workflow or contract. | No |
info | An 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:
| Change | As an input | As an output |
|---|---|---|
Int → Float | compatible (widening) | breaking |
Float → Int | breaking (narrowing) | compatible |
T → T? | compatible | breaking |
T? → T | breaking | compatible |
String → Int, or any other unrelated type | breaking | breaking |
T → JSON | compatible | breaking |
JSON → T | breaking | compatible |
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.
JSONaccepts any value, so narrowing fromJSONto a concrete type is breaking, even though the type checker allowsJSONin both directions.
Workflow rules
The checks run for every workflow name that exists in both versions.
| Situation | Result |
|---|---|
| Same version, same definition hash | ok … unchanged |
| Same version, different definition hash | error: 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 definition | info |
| New version, input and output unchanged | ok |
| New version, compatible input or output change | one ok line per change |
| New version, breaking change, major bump | one info line per change |
| New version, breaking change, no major bump | one error line per change, with a hint naming the next major version |
New version, breaking change, a version is not MAJOR.MINOR.PATCH | warning: the bump cannot be checked |
| New version lower than the old one | warning |
| Workflow only in the old version | warning: running executions keep their definition, but new starts fail once it is no longer registered |
| Workflow only in the new version | info |
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 field | Result |
|---|---|
Field added as optional (T?) or with a default | compatible |
| Field added as required, without a default | breaking |
| Field removed | breaking (inputs are validated strictly) |
| Default added to a required field | compatible |
| Default removed | breaking |
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 field | Result |
|---|---|
| Field added | compatible |
| Required field removed | breaking |
| Optional field removed | compatible (consumers already handle null) |
Type narrowed (Float → Int, T? → T) | compatible |
Type made optional (T → T?), widened or changed | breaking |
$ 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.
| Change | Result |
|---|---|
| Signal removed | breaking: senders get UNKNOWN_SIGNAL |
Signal payload field added as optional (T?) or with a default | compatible |
| Signal payload field added as required, without a default | breaking |
| Signal payload field removed | compatible: the runtime accepts and keeps fields a signal does not declare, so old senders keep working |
| Signal payload field widened or narrowed | as for an input field: widening is compatible, narrowing or an unrelated type is breaking |
| Query removed | breaking: readers get UNKNOWN_QUERY |
| Query result changed | as 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 added | not 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 version | Result with a major bump | Result without a major bump |
|---|---|---|
| An old worker can serve the new version | ok | ok |
| An old worker cannot serve the new version | info: deploy workers for the new version before the workflows that use it | warning, 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"
}
]
}
| Field | Type | Meaning |
|---|---|---|
compatible | bool | true when there are no errors. |
errors, warnings | integer | Number of findings with that severity. |
findings[].severity | string | error, warning, ok or info. |
findings[].kind | string | workflow or contract. |
findings[].name | string | Workflow or contract name. |
findings[].old_version, new_version | string | Absent on the side where the workflow or contract does not exist. |
findings[].path | string | Field path, starting with input, output, signal <name> or query <name>; absent for findings about a whole workflow or contract. |
findings[].old_type, new_type | string | Types at path; absent when the field does not exist on that side. |
findings[].message | string | What changed and why it matters. |
findings[].hint | string | Suggested fix, when there is one. |
Findings list workflows first, then contracts, each sorted by name.
Exit codes
| Code | Meaning |
|---|---|
0 | No errors. Warnings, ok and info findings are allowed. |
1 | At least one error finding. In human format, stderr ends with error: compatibility check failed: <n> error(s), <m> warning(s). |
2 | Usage 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>
}
| Member | Required | Meaning |
|---|---|---|
workflow <Name> | yes | The workflow to run. It must be defined in, or imported into, the compilation (E0700). |
version "<v>" | no | Fails 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 fields | The 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 calls | What the simulated worker answers. See Mocks. |
signal <name> { ... } | no, repeatable | A signal sent to the execution. See Signals. |
expect ... | no, repeatable | What 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:
| Behaviour | Effect 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>" retryable | A retryable failure: the step’s retry policy applies. |
hangs | The 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
awaittimeout 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 theawaitstep.
Expectations
Expectations are checked once the execution has finished, and every failed expectation is reported, not just the first.
| Expectation | Passes 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
1matches1.0. - A field written as
nullmatches a missing ornullfield.
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]...
| Option | Meaning |
|---|---|
[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:
| Field | Type | Meaning |
|---|---|---|
tests[].name | string | The test name. |
tests[].workflow, tests[].version | string | The workflow under test. |
tests[].file, tests[].line | string, integer | Where the test is declared. |
tests[].status | string | passed or failed. |
tests[].failures | array of strings | One message per failed expectation, or why the test could not run. |
tests[].execution | object | status, output, error (code, message, retryable) and simulated_ms of the execution. |
tests[].history | array | Failed tests only: the execution’s history events, in the history format. |
passed, failed, filtered_out | integer | Counts. |
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
- Replay
- CLI: orbit test
- Compiler diagnostics: tests
- Signals and queries
- Retries and timeouts
- Compensation
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 option | Meaning |
|---|---|
<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. |
--json | Print 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:
| Type | Sample |
|---|---|
String | "<field>-sample", using the field’s name, for example "transaction_id-sample" |
Int | 1 |
Float | 1.0 |
Bool | true |
Bytes | "c2FtcGxl" (base64 of sample) |
Duration | 1000 (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 |
| record | every 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:
| Field | Meaning |
|---|---|
workflow, version | The workflow that ran. |
status, output, error | How the execution ended. |
simulated_ms | Simulated time from start to finish. |
sampled_tasks | Tasks that got generated results. |
problems, unsent_signals | Why the simulation stopped early, and signals that were never sent. |
execution | The final execution state, as GET /v1/executions/{id} returns it. |
history | Every history event, as GET /v1/executions/{id}/history returns them. |
children | Child 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 option | Meaning |
|---|---|
[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. |
--json | Print 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:
| Origin | Events | How replay treats them |
|---|---|---|
| External | WorkflowCreated, WorkflowStarted, StepDispatched, StepAttemptUncertain, SignalReceived, CancelRequested, StaleResultIgnored | Applied 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 decided | StepSucceeded, StepAttemptFailed | Consistent 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. |
| Decided | every other event: StepScheduled, StepSkipped, StepFanOut, TimerStarted, TimerFired, RetryScheduled, StepFailed, StepCancelled, SignalAwaitStarted, SignalDelivered, CompensationStarted, WorkflowCompleted, WorkflowFailed and the rest | Must 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
| Field | Meaning |
|---|---|
execution_id | The replayed execution. |
events, external, decided | How 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_mismatch | Present when the history ran another definition: [recorded hash, replayed hash]. |
pending | What the decider would do next after the last replayed event. |
state | The 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
| Feature | LSP method | What it does |
|---|---|---|
| Diagnostics | textDocument/publishDiagnostics | Runs 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. |
| Hover | textDocument/hover | Task contracts, step output types, field types along step.field.sub paths, schemas, child workflows, built-in functions and keywords. |
| Go to definition | textDocument/definition | Steps, schemas, tasks and child workflows, across imports. |
| Completion | textDocument/completion | Fields after input. and <step>., task names inside worker "…", keywords, types and built-in functions. Triggered by . and ". |
| Outline | textDocument/documentSymbol | Workflows with their steps, schemas and tasks. |
| Formatting | textDocument/formatting | The 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
.orbitfiles, 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
| Command | ID | What it does |
|---|---|---|
| ORBIT: Run Workflow | orbit.run | Saves 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 File | orbit.deploy | Runs orbit deploy <file> in the terminal. |
| ORBIT: Check File | orbit.check | Runs orbit check <file> in the terminal. |
| ORBIT: Show Step Graph (Mermaid) | orbit.graph | Runs orbit graph <file> and opens the Mermaid source beside the editor. Use a Mermaid preview extension to render it. |
| ORBIT: Restart Language Server | orbit.restartServer | Stops 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
| Setting | Type | Default | Meaning |
|---|---|---|---|
orbit.serverPath | string | orbit | Path to the orbit binary, used for orbit lsp and the commands. Changing it restarts the language server. |
orbit.runtimeUrl | string | http://127.0.0.1:7233 | Runtime used by the Run and Deploy commands, passed as ORBIT_SERVER. |
orbit.trace.server | off, messages or verbose | off | Log 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:
| Setting | Value |
|---|---|
| Command | orbit lsp |
| Transport | stdio |
| File pattern | *.orbit |
| Language id | orbit |
| Root marker | your 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 option | Default | Meaning |
|---|---|---|
<FILE> | required | A .orbit file or a compiled .json IR file. |
--workflow <NAME> | see below | The workflow to render, when the file defines several. |
--format <FORMAT> | mermaid | mermaid, dot or svg. |
--sequence | off | Render a Mermaid sequence diagram instead of the step graph. Only with --format mermaid. |
-o, --output <PATH> | stdout | Write 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
| Element | Mermaid | Graphviz | SVG |
|---|---|---|---|
| Step | rectangle id["..."] | rounded box | rounded box |
Step with a when condition | hexagon id{{"..."}} | shape=hexagon | box with an accent border |
Dependency (from after or from reading another step’s output) | a --> b | "a" -> "b" | curved arrow |
| Compensation | stadium <step>__undo(["undo <step>..."]) with class compensation, linked by <step> -. compensate .-> <step>__undo | dashed box, dashed edge labelled compensate | dashed box to the right of the step |
Signal feeding an await step | flag signal__<name>>"signal <name>"], dotted edge to the step | shape=cds, dashed edge | not drawn (the step label names the signal) |
on failure, on cancel, on timeout | subgraph 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 cluster | labelled panel |
continue_as_new | subroutine 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=false | box 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:
| Line | When present | Example |
|---|---|---|
| step name | always | charge |
| action | always | payments.charge@1.0.0 · python, child workflow Shipping, sleep 1h, await signal review, compute |
| fan-out | for steps | for each item (max 4 parallel) |
| condition | when | when analyze_risk.score < 0.8 |
| retry | more than one attempt | retry 5 attempts, exponential 500ms..10s |
| idempotency | idempotency_key | idempotency key |
| timeouts | timeout, queue_timeout, await ... timeout | timeout 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:
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:
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_newnode is placed one layer below the final steps; its dashednext runedges 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
| Element | Drawn as |
|---|---|
| Worker task | a 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 workflow | a call to the participant child_<Name> |
await | a message from the participant Sender |
sleep, compute | a note over the workflow |
when | an opt when <condition> block |
for | a loop for each <variable> block |
| Compensations | one opt compensation ... block at the end, in reverse execution order |
continue_as_new | a 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 blocks | one 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: the same diagram inside a reference page
- Dependencies
- Fan-out
- Child workflows
- CLI: orbit graph
- Editor support
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
| Option | Default | Meaning |
|---|---|---|
<FILES>... | required | .orbit files. Imports are followed. |
-o, --output <DIR> | required | Output directory, created if needed. Existing files with the same names are overwritten; other files are left alone. |
--format <FORMAT> | markdown | markdown writes .md files, html writes .html files. |
--json-schema | off | Also 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
| File | Content |
|---|---|
index.md / index.html | Every workflow (version, number of steps, description) and every task contract (version, compensated_by), with links. |
<Workflow>.md / <Workflow>.html | One page per workflow, described below. |
contracts.md / contracts.html | Every 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:
| Section | Content |
|---|---|
| Title and text | The workflow’s /// doc comment, then its description. |
| Facts | Version, definition hash, IR version, source file and line, workflow timeout, limits. |
| Input | One 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. |
| Output | Path, type and doc comment of each field, then the return expression. |
| Signals | For each signal: doc comment, the steps that await it (or the on event block that handles it), and its fields. |
| Queries | Name, result type, expression and doc comment. |
| Steps | One 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-new | For 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 handlers | One steps table per on failure, on cancel, on timeout block. |
| Event handlers | One steps table per on event <signal> block. |
| Diagram | Markdown: 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 used | Each contract with its input and output types and compensated_by, and the child workflows the workflow starts. |
| JSON Schema | With --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:
| Name | Payload |
|---|---|
workflow.<Workflow>.input | Execution input (POST /v1/executions, orbit run --input) |
workflow.<Workflow>.output | Execution result |
workflow.<Workflow>.signal.<signal> | Signal payload (orbit signal --data) |
workflow.<Workflow>.query.<query> | Query result |
task.<task>.input | What a worker receives for the task |
task.<task>.output | What 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 type | JSON 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"} |
JSON | true (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 option | Default | Meaning |
|---|---|---|
<OLD>, <NEW> | required | A .orbit file (imports are followed) or a compiled IR .json file, for each side. |
--workflow <NAME> | all | Report only this workflow, and only the compatibility findings for it and for task contracts. |
--format <FORMAT> | text | text, markdown or json. |
--exit-code | off | Exit 1 when there is any change. |
Exit codes, as with git diff:
| Status | When |
|---|---|
0 | Success. With --exit-code: no changes. |
1 | With --exit-code: there are changes. |
2 | A 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.
| Area | Changes reported |
|---|---|
workflow | version, description, workflow timeout |
input, output | Fields added, removed or retyped, and changed defaults, by path (input.customer.email, output.items[].sku, input.prices{}.amount) |
result | The return expression |
signal | Signals added or removed; payload fields added, removed or retyped (signal review.comment) |
query | Queries added or removed; changed expression; changed result type |
step | Steps added, removed or renamed |
action | What the step runs: task and contract version, using language, child workflow, sleep duration, awaited signal, compute |
step input | The with expression of a task or child workflow step, or the expression of a compute step |
dependency | Direct dependencies added or removed (after x), explicit or implied by data references |
condition | when added, removed or changed |
fan-out | for variable, list expression and max_parallel |
retry | Attempts, backoff, delays and non-retryable codes |
timeout | timeout, queue_timeout, and the timeout of an await step |
idempotency | idempotency_key added, removed or changed |
compensation | Compensation added or removed, or a change of its task, contract version, input or retry policy |
handler | on 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>. |
event | on event <signal> blocks, the same way |
limits | The limits block |
continue_as_new | continue_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 |
contract | For 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.
| Command | Policy errors | Policy warnings only |
|---|---|---|
orbit check | diagnostics on stderr, error: policy check failed: <e> error(s), <w> warning(s), exit 1 | diagnostics, ok lines, exit 0 |
orbit check --strict | as above | exit 1, like any warning |
orbit check --format json | the P diagnostics are in diagnostics, exit 1 | exit 0 |
orbit compile | writes nothing, exit 1 | writes the IR, exit 0 |
orbit deploy | registers nothing from that file, exit 1, before contacting the server | deploys, 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:
| Problem | Message (after error: policy <file>:) |
|---|---|
| TOML syntax, unknown key, wrong value type | the TOML parser’s message with line and column |
| Unknown rule type | rule 3: unknown type …; known types: ... |
| Key that the rule type does not take | rule id: tasksdoes not apply torequire_handler (it takes: handlers) |
| Missing required key | rule id: require_retryneedstasks = […]`` |
| Invalid value | wrong 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 id | rule 2: duplicate id …`` |
| Exemption for an unknown rule, or without a reason | exemption 1: unknown rule …``, exemption 1 (...): reason is required |
Rules
| Type | Code | Checks | Keys |
|---|---|---|---|
require_compensation | P0101 | Main steps calling matching tasks have a compensate block. | tasks, compensate_with |
require_retry | P0102 | Steps calling matching tasks retry: at least min_attempts attempts, at most max_attempts. | tasks, min_attempts (default 2), max_attempts |
max_attempts | P0103 | Steps calling matching tasks make at most max attempts. | tasks (default ["*"]), max |
require_timeout | P0104 | Steps calling matching tasks have a timeout, at most max; with queue_timeout = true, also a queue_timeout. | tasks, max, queue_timeout |
require_approval | P0105 | Steps 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_handler | P0106 | Workflows declare the listed outcome handlers. | handlers (default ["failure"]) |
forbid_tasks | P0107 | No step or compensation calls a matching task. | tasks |
allow_tasks | P0108 | Every step and compensation calls a matching task. | tasks |
max_fan_out | P0109 | Workflows 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_limits | P0110 | Workflows set the listed limits. | keys |
require_signal_timeout | P0111 | await 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:
Scannot run whenPholds.Shas awhencondition that impliesnot P. Forwhen = "input.amount >= 10000",when input.amount < 5000.0 && input.vippasses.- An approval guards
S. Some transitive dependencyAofSin the same block (main steps, or the sameonblock) is anawaitof a signal matchingsignals, and:Aruns wheneverPholds:Ahas nowhen, orPimpliesA’swhen; and- unless
require_condition = false,S’swhencondition still readsA’s output after it is simplified under the assumptionP. A step withoutwhenfails 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 aninputfield 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
computestep in the same block that has nowhenand noforis replaced by the field’s expression. Inexamples/approval.orbit,assess.needs_approvalbecomesinput.amount >= 1000.0. - Comparisons on the same field are compared as intervals of real numbers:
input.amount >= 10000impliesinput.amount >= 1000.0, and impliesnot (input.amount < 1000.0). - “Simplified under
P” replaces each understood comparison thatPdecides bytrueorfalseand simplifies. If the result is a constant, it does not readA.
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)
| Code | Severity | Reported when |
|---|---|---|
P0001 | error | A waiver names a rule id that no --policy file defines (the note lists the ids), or has no reason. The waiver is ignored. |
P0002 | warning | A 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
- Static safety checks: the compiler’s built-in
W06xxchecks - Compiler diagnostics: policy
- Process diffs
- CLI: orbit check
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:
orbit0.1.0 or later on yourPATH(Installation).- Python 3.9 or later and Node.js 22.18 or later.
| File | Contents |
|---|---|
examples/contracts.orbit | Schemas and task contracts shared by the order examples |
examples/order_processing.orbit | The OrderProcessing workflow |
examples/workers/python/order_workers.py | Python 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.ts | TypeScript 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_orderandanalyze_riskshare no dependency, so they run at the same time. afterorders steps that share no data.reserve_inventorydoes not readvalidate_order’s output, but must not run before validation succeeds.whencreates an implicit dependency.process_paymentreadsanalyze_risk.score, so it waits foranalyze_risk. If the score is 0.8 or more, the payment is skipped.- A skipped step’s output is
null. Becauseprocess_paymentis conditional,process_payment.transaction_idhas typeString?. The workflow handles it explicitly insend_notificationand inreturn, and the output declarestransaction_id: String?. The compiler rejects code that ignores thenullcase; see E0405. - Each side effect has a compensation. If anything fails after
reserve_inventorysucceeded, the runtime releases the reservation. If anything fails afterprocess_paymentsucceeded, it refunds the charge. - The charge has a business idempotency key.
"charge-" + input.order_idis 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:
| Input | Behavior |
|---|---|
customer_id starting with risky | risk.analyze scores 0.95, so the payment is skipped |
amount above 5000 | payments.charge fails with CARD_DECLINED (not retryable) |
amount exactly 4242 | payments.charge takes 3 seconds |
ORBIT_FLAKY=1 set for the Python worker | the 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:
- The Python worker raised
TaskError("CARD_DECLINED", ..., retryable=False). Althoughprocess_paymentallows five attempts, a non-retryable error fails the step at once (#14, #15). send_notificationhad not started, so the runtime withdrew it (#16).- The runtime compensated every succeeded step that has a
compensateblock. Onlyreserve_inventoryqualifies:process_paymentnever succeeded, so there is nothing to refund (#17 to #21). - The execution ended
FAILEDwithcompensation: 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
compensateblock, 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(orexecution_idin 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
- Compensation
- Conditions
- Retries and timeouts
- Idempotency
- Delivery guarantees
- Fan-out and child workflows
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.
| File | Contents |
|---|---|
examples/polyglot.orbit | Contracts and the DocumentInsights workflow |
examples/workers/polyglot/text_worker.py | Python worker text-python: text.normalize |
examples/workers/polyglot/text_worker.ts | TypeScript worker text-typescript: text.tokenize |
examples/workers/polyglot/go/main.go | Go worker text-go: text.frequencies |
crates/orbit-worker/examples/text_worker.rs | Rust 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 declares | Worker 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:
| Workflow | Worker | Run the worker |
|---|---|---|
examples/java_hello.orbit | examples/workers/java/ | see examples/workers/java/README.md |
examples/csharp_hello.orbit | examples/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
- How workers work
- Python, TypeScript and JavaScript, Go, Rust, Java, C# and .NET
- Task contracts
- Worker protocol
- Fan-out and child workflows
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:
| File | Contents |
|---|---|
examples/fulfilment.orbit | Shipping contracts, the Shipping workflow and the Fulfilment workflow |
examples/contracts.orbit | LineItem, inventory.reserve, inventory.release (imported) |
examples/workers/typescript/order_workers.ts | inventory.reserve, inventory.release |
examples/workers/python/order_workers.py | shipping.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.itemsruns the task once per element.itemis in scope inwithand incompensate, so each element reserves one SKU.max_parallel 4allows 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 whyreturncan usereservations: reservedirectly. - Inside
compensate,reserve.reservation_idrefers 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
withagainstShipping’sinputand typesship’s output asShipping’soutput, soship.trackingis aString. - 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 1hlimits how long the parent waits for the child. Child steps take noretry,using,idempotency_keyorqueue_timeout; retries belong on the child’s own steps.Shippingmust be defined in, or imported into, the same compilation.orbit deployandorbit runregister it beforeFulfilment.
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.reserveandshipwere scheduled together (#4 to #6), becauseshipdoes not depend onreserve.- The child’s error code propagates. The
shipstep fails with the child’s own code,NO_CARRIER. A child that is cancelled or times out fails the step withCHILD_CANCELLEDorCHILD_TIMED_OUT.orbit inspect ful-noship-1records 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 afterreserve[1](#10 after #9), so it was released first. Each compensation is its own unit with its own idempotency key, such asful-noship-1/compensate:reserve[0]. - A child compensates its own steps.
Shippingdeclares no compensations, and itsquotestep 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 theworkflowstep its owncompensateblock 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 field | Behavior |
|---|---|
method | GET (default), POST, PUT, PATCH, DELETE, HEAD or OPTIONS. Case does not matter. Any other method fails with a non-retryable HTTP_ERROR. |
url | Must start with http:// or https://. Other schemes fail with a non-retryable HTTP_ERROR. |
headers | Sent as request headers. |
body | For 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 field | Contents |
|---|---|
status | The HTTP status code. |
headers | Response headers, with lowercase names. |
body | The 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
| Response | Step result |
|---|---|
2xx, 3xx, 4xx other than 429 | The attempt succeeds. The workflow receives the status and decides what it means. |
429 and 5xx | The 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 timeout | The 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. bodyis 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://orhttps://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. Plainhttp://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:
| Rule | Where it is declared | What checks it |
|---|---|---|
| Each service call has a fixed input and output shape | a task contract | The compiler, on every step; the runtime, on every result |
| A side effect has an undo action | compensated_by on the contract, compensate on the step | W0601/W0602, errors under orbit check --strict |
| A human decision has a typed payload and a deadline | signal and await ... timeout | The compiler (payload use), the runtime (payload validation), W0606 for waits without a timeout |
| Business rules are visible | compute steps and when conditions | The type checker; the runtime evaluates them deterministically |
| What happens on failure, cancellation and timeout | on failure, on cancel, on timeout | The compiler; the runtime runs them after compensation |
| Who may start, signal or read a process | client identities in the workers file | The server, per workflow and action |
| A change does not break running callers | version, orbit compat | orbit 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
| Need | ORBIT construct |
|---|---|
| A typed decision | signal review { approved: Bool, by: String } |
| Wait for it, with a deadline | step manager { await manager_review timeout 3d } |
| Only ask when a rule says so | when rules.needs_manager on the await step |
| Act on the decision | a later step with when coalesce(manager.approved, false) |
| Messages that can come any number of times | on event comment { ... } |
| What to do when nobody answers | the await fails with SIGNAL_TIMEOUT; on failure runs |
| Show a reviewer where things stand | query status = { ... } |
| Limit what a reviewer’s tool can do | a 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:
| Problem | What the workflow does |
|---|---|
| The process crashes halfway | Every step’s result is recorded; the run continues from the history, without repeating finished tool calls |
| A model or tool call fails | retry with backoff and timeout per step; non-retryable errors stop at once |
| The model returns the wrong shape | Each model call is a task contract; a result that does not match it fails the step instead of flowing on |
| The agent does too much | limits { max_fan_out } caps the parallel tool calls; a compute step enforces the budget before any tool runs |
| An answer needs a human | An await on a typed signal, only when the guardrail check asks for it |
| A side effect must be undone | compensate on the step that has the effect |
| You need to know what happened | The 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 nowhile.
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
| Option | Meaning |
|---|---|
-h, --help | Print help for the command. Available on every command. |
-V, --version | Print 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):
| Code | Meaning |
|---|---|
0 | Success. |
1 | The 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:. |
2 | Invalid 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 exampleorbit run wf.orbit --no-waitprints only the execution id on stdout. - Colors are used only when the stream is a terminal and the
NO_COLORenvironment variable is not set. inspect(for executions),logs --jsonand theoutput:line ofstatusredact 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:
| Option | Type | Default | Environment | Meaning |
|---|---|---|---|---|
--server <SERVER> | URL | http://127.0.0.1:7233 | ORBIT_SERVER | Runtime API address. Use https:// for a server with TLS. |
--token <TOKEN> | string | none | ORBIT_TOKEN | Bearer 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> | path | none | ORBIT_CA_CERT | Extra PEM CA certificate to trust, for a runtime with a private certificate. It replaces the default roots for this connection. |
--client-cert <CLIENT_CERT> | path | none | ORBIT_CLIENT_CERT | PEM client certificate for mutual TLS. Requires --client-key. |
--client-key <CLIENT_KEY> | path | none | ORBIT_CLIENT_KEY | PEM 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>
| Argument | Type | Meaning |
|---|---|---|
<NAME> | path | Directory 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 option | Type | Default | Meaning |
|---|---|---|---|
[FILES]... | paths | none | One or more .orbit files. Imports are followed. |
--format <FORMAT> | human or json | human | human prints diagnostics on stderr and one ok line per workflow on stdout. json prints {"diagnostics": [...]} on stdout and nothing else. |
--strict | flag | off | Treat warnings (missing compensations, unreachable steps, unused signals, waits without a timeout, …) as errors. |
--policy <FILE> | path | none | Also 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]...
| Argument | Type | Meaning |
|---|---|---|
[FILES]... | paths | Any 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 option | Type | Default | Meaning |
|---|---|---|---|
[FILES]... | paths | none | One or more .orbit files. |
--strict | flag | off | Treat 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> | path | build | Output directory, created if needed. |
--stdout | flag | off | Print the IR to stdout instead of writing files. |
--policy <FILE> | path | none | Check 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 option | Type | Default | Meaning |
|---|---|---|---|
[FILES]... | paths | none | Files to format. |
--check | flag | off | Do 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 option | Type | Default | Meaning |
|---|---|---|---|
<FILE> | path | required | A .orbit file or a compiled .json IR file. |
--workflow <WORKFLOW> | string | see below | Workflow 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 svg | mermaid | Output format. svg is laid out by orbit itself. |
--sequence | flag | off | Render a Mermaid sequence diagram of the workflow’s calls. Only with --format mermaid. |
-o, --output <PATH> | path | stdout | Write 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 option | Type | Default | Meaning |
|---|---|---|---|
<FILES>... | paths | required | .orbit files. Imports are followed. |
-o, --output <DIR> | path | required | Output 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 html | markdown | Page format. |
--json-schema | flag | off | Also 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 option | Type | Default | Meaning |
|---|---|---|---|
<FILES>... | paths | required | .orbit files. Imports are followed. |
--workflow <WORKFLOW> | string | all | Only this workflow’s schemas and those of the task contracts it uses. |
-o, --output <DIR> | path | stdout | Write <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 option | Type | Default | Meaning |
|---|---|---|---|
<OLD> | path | required | The old version: a .orbit file or a compiled IR .json file. |
<NEW> | path | required | The new version: a .orbit file or a compiled IR .json file. |
--workflow <WORKFLOW> | string | all | Report only this workflow. |
--format <FORMAT> | text, markdown or json | text | text prints one line per change and a summary: line; markdown prints a section for a pull request; json prints one object. |
--exit-code | flag | off | Exit 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 option | Type | Default | Meaning |
|---|---|---|---|
<OLD> | path | required | The old version: a .orbit file or a compiled IR .json file. |
<NEW> | path | required | The new version: a .orbit file or a compiled IR .json file. |
--format <FORMAT> | human or json | human | human 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 option | Type | Default | Meaning |
|---|---|---|---|
[FILES]... | paths | required | Files to compile. Every test in them and in the files they import runs. |
--format <FORMAT> | human or json | human | human 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> | string | none | Run 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 option | Type | Default | Meaning |
|---|---|---|---|
<FILE> | path | required | A .orbit file or a compiled IR .json file. |
--workflow <WORKFLOW> | string | see orbit graph | Workflow to run when the file defines several. |
-i, --input <INPUT> | JSON | {} | The execution input. |
--input-file <INPUT_FILE> | path | none | Read the input from a JSON file. |
--mocks <MOCKS> | path | none | A JSON object mapping task names to results. Tasks not listed get a generated result. |
--fail <TASK=CODE> | string | none | Fail every attempt of the task with this code (not retryable). Repeatable. |
--signal <NAME=JSON> | string | none | Send this signal once the execution waits for it. NAME=@file.json reads the payload from a file. Repeatable, sent in order. |
--json | flag | off | Print 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 option | Type | Default | Meaning |
|---|---|---|---|
[EXECUTION_ID] | string | none | Replay this execution: its history and the definition it ran are fetched from the runtime. |
--history <HISTORY> | path | none | Replay a history file instead of an execution id. |
--definition <DEFINITION> | path | the runtime’s copy | Compiled IR .json or .orbit source to replay against. Required with --history. |
--workflow <WORKFLOW> | string | from the history | Workflow to use when --definition defines several. |
--at <SEQ> | integer | none | Stop after this event and print the reconstructed state. |
--run <RUN> | integer | the latest run | With an execution id: replay this run of the execution (continue-as-new). Every run replays on its own. Cannot be combined with --history. |
--json | flag | off | Print the report as one JSON object; see JSON report. |
| runtime connection options | See 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]
| Option | Type | Default | Environment | Meaning |
|---|---|---|---|---|
--db <DB> | path or URL | orbit.db | ORBIT_DB | Where definitions and execution history live: a SQLite file path, or a PostgreSQL URL such as postgres://user:pass@host:5432/orbit. |
--addr <ADDR> | host:port | 127.0.0.1:7233 | ORBIT_ADDR | Address of the HTTP API, including the HTTP/JSON worker protocol. Must be an IP address and port. |
--otlp-endpoint <OTLP_ENDPOINT> | URL | none | OTEL_EXPORTER_OTLP_ENDPOINT | Export 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:port | none | ORBIT_GRPC_ADDR | Also serve the gRPC binding of the worker protocol on this address, with the same credentials and TLS settings as the HTTP API. |
--token <TOKEN> | string | none | ORBIT_TOKEN | Require this bearer token on every API call except /v1/health. |
--insecure | flag | off | Allow binding to a non-loopback address without a token. | |
--worker-token <WORKER_TOKEN> | string | none | ORBIT_WORKER_TOKEN | Second 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> | path | none | ORBIT_TLS_CERT | PEM certificate chain. With --tls-key, serve HTTPS. |
--tls-key <TLS_KEY> | path | none | ORBIT_TLS_KEY | PEM private key for --tls-cert. |
--tls-client-ca <TLS_CLIENT_CA> | path | none | ORBIT_TLS_CLIENT_CA | Enable 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> | path | none | ORBIT_WORKERS_FILE | TOML 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-adapter | flag | off | Execute orbit.http.request steps inside the runtime. | |
--max-active-executions <MAX_ACTIVE_EXECUTIONS> | integer | unlimited | ORBIT_MAX_ACTIVE_EXECUTIONS | Maximum running executions; further starts get 429 RESOURCE_EXHAUSTED. |
--max-fan-out <MAX_FAN_OUT> | integer | unlimited | ORBIT_MAX_FAN_OUT | Maximum elements one for step may fan out to; larger lists fail the step with LIMIT_EXCEEDED. |
--max-history-events <MAX_HISTORY_EVENTS> | integer | unlimited | ORBIT_MAX_HISTORY_EVENTS | Maximum history events per execution; past it the execution fails with LIMIT_EXCEEDED. |
--max-payload-bytes <MAX_PAYLOAD_BYTES> | integer, bytes | unlimited | ORBIT_MAX_PAYLOAD_BYTES | Maximum size in bytes of an execution input, task result or signal payload; larger ones get 413 PAYLOAD_TOO_LARGE. |
--session-timeout <SESSION_TIMEOUT> | integer, seconds | 30 | Seconds without contact after which a worker is considered lost. | |
--max-body <MAX_BODY> | integer, bytes | 4194304 | Maximum request body size. | |
--log-format <LOG_FORMAT> | text or json | text | Log format. Logs go to stderr. | |
--cluster | flag | off | ORBIT_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. Requires a PostgreSQL --db. See Running several servers. |
--cluster-mode <CLUSTER_MODE> | standby or shared | standby | ORBIT_CLUSTER_MODE | With shared, every server is active and they share executions and worker tasks. shared requires --cluster. See Shared mode. |
--node-timeout <NODE_TIMEOUT> | integer, seconds | 5 | ORBIT_NODE_TIMEOUT | Shared 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> | string | the host name | ORBIT_NODE_ID | This server’s name in the cluster. Must be unique among running servers. Requires --cluster. |
--advertise-addr <ADVERTISE_ADDR> | URL | derived from --addr unless it is a wildcard address | ORBIT_ADVERTISE_ADDR | URL other servers give out to reach this one while it leads, and that shared mode lists in /v1/health. Requires --cluster. |
--no-ui | flag | off (the UI is served) | ORBIT_NO_UI | Do 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 option | Type | Default | Meaning |
|---|---|---|---|
[FILES]... | paths | none | .orbit files or compiled .json IR files. Every workflow in every file is registered, children before parents. |
--policy <FILE> | path | none | Check 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 options | See 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 option | Type | Default | Meaning |
|---|---|---|---|
<TARGET> | string | required | A .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> | string | see graph | Workflow to run when the file defines several. Ignored when <TARGET> is a workflow name. |
--version <VERSION> | string | latest | Version 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> | path | none | Read the workflow input from a JSON file. --input takes precedence when both are given. |
--id <ID> | string | generated UUID | Client-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-wait | flag | off | Return immediately after starting, printing the execution id on stdout. |
| runtime connection options | See 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 option | Type | Meaning |
|---|---|---|
<EXECUTION_ID> | string | The execution to show. |
| runtime connection options | See 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 option | Type | Meaning |
|---|---|---|
<TARGET> | string | When 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 options | Used 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 option | Type | Default | Meaning |
|---|---|---|---|
<EXECUTION_ID> | string | required | The execution to show. |
--json | flag | off | Print raw JSON events, one per line, with sensitive fields redacted. |
--run <RUN> | integer | the latest run | Show this run of the execution (continue-as-new). |
| runtime connection options | See 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 option | Type | Default | Meaning |
|---|---|---|---|
<EXECUTION_ID> | string | required | The execution to cancel. |
--reason <REASON> | string | requested via API | Reason recorded in the CancelRequested event. |
| runtime connection options | See 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 option | Type | Default | Meaning |
|---|---|---|---|
<EXECUTION_ID> | string | required | The execution to signal. |
<NAME> | string | required | The 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 options | See 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 option | Type | Default | Meaning |
|---|---|---|---|
<EXECUTION_ID> | string | required | The execution to query. |
<NAME> | string | required | The query name declared in the workflow (query <name> = ...). |
| runtime connection options | See 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]
| Option | Type | Default | Meaning |
|---|---|---|---|
--status <STATUS> | string | all | Only executions with this status (RUNNING, COMPENSATING, COMPLETED, FAILED, CANCELLED, TIMED_OUT; case-insensitive). |
--workflow <WORKFLOW> | string | all | Only executions of this workflow name. |
--limit <LIMIT> | integer | 20 | Maximum number of rows. The runtime caps it at 1000. |
| runtime connection options | See 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]
| Option | Type | Default | Meaning |
|---|---|---|---|
--worker <WORKER> | string | my-worker | Worker id for the generated [[worker]] entry. |
--task <TASKS> | string, repeatable | * | Task pattern the worker may serve. Repeat the flag for several patterns. |
--client <CLIENT> | string | none | Print 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, repeatable | start, signal, query, read | With --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]
| Option | Type | Meaning |
|---|---|---|
| runtime connection options | See 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
- Policies
- Process diffs
- Documentation generation
- HTTP API
- Environment variables
- Compiler diagnostics
- Running the server
- Executions and history
- Formatter
- Compatibility checks
- Testing workflows
- Simulation
- Replay
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 (
*_msfields). - 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 withinorbit-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 theAcceptheader containstext/html, and otherwise answers404with error codeNOT_FOUND. With--no-uineither 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:
| Caller | How it authenticates | Can call |
|---|---|---|
| Admin | Authorization: Bearer <--token> | Every endpoint. |
| Shared worker | Authorization: Bearer <--worker-token> | /v1/workers/* and /v1/tasks/* only. No identity restrictions. |
| Per-worker identity | Authorization: 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 identity | Authorization: Bearer <token> whose SHA-256 is in a [[client]] entry of the workers file, or a client certificate matching its client_cert | The 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:
| Route | Needs the action |
|---|---|
POST /v1/executions | start 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}/cancel | cancel |
GET /v1/executions, GET /v1/workflows | none: the list contains only workflows the client may read |
POST /v1/workflows, GET /v1/workers, /v1/workers/*, /v1/tasks/*, GET /v1/metrics | refused |
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.
| Code | Status | Returned by | Meaning |
|---|---|---|---|
INVALID_REQUEST | 400 | worker register, task complete/fail | A 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_IR | 400 | POST /v1/workflows | The 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_REGISTERED | 400 | POST /v1/workflows | The definition starts a child workflow whose exact definition is not registered. Register the child first; orbit deploy does this. |
INVALID_INPUT | 400 | POST /v1/executions, POST/PATCH /v1/schedules | The 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_SCHEDULE | 400 | POST/PATCH /v1/schedules | The 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_ID | 400 | POST /v1/executions | execution_id is empty, longer than 200 characters, or contains characters other than letters, digits, -, _, . and :. |
RESERVED_TASK | 400 | POST /v1/workers/register | A worker announced a built-in task (orbit.*) or a child-workflow contract (workflow:*). |
UNAUTHENTICATED | 401 | any authenticated endpoint | No credential, or a credential that matches nothing. |
FORBIDDEN | 403 | any authenticated endpoint | The 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_FOUND | 404 | execution endpoints | The execution does not exist. |
RUN_NOT_FOUND | 404 | GET /v1/executions/{id}/history?run=N | The execution has no run N. |
SCHEDULE_NOT_FOUND | 404 | /v1/schedules/{id} | The schedule does not exist. |
UNKNOWN_SIGNAL | 404 | POST /v1/executions/{id}/signals/{name} | The execution’s workflow does not declare this signal. The message lists the declared signals. |
UNKNOWN_QUERY | 404 | GET /v1/executions/{id}/queries/{name} | The execution’s workflow does not declare this query. The message lists the declared queries. |
WORKFLOW_NOT_FOUND | 404 | workflow endpoints, POST /v1/executions | No such workflow, or no such version. |
UNKNOWN_SESSION | 404 | heartbeat, poll, unregister | The worker session does not exist or has expired. Register again. |
DEFINITION_CONFLICT | 409 | POST /v1/workflows | A different definition is already registered under this name and version. Definitions are immutable; bump the version. |
EXECUTION_EXISTS | 409 | POST /v1/executions | An execution with this execution_id exists for a different workflow definition. |
SCHEDULE_EXISTS | 409 | POST /v1/schedules | A schedule with this schedule_id exists. |
EXECUTION_FINISHED | 409 | POST /v1/executions/{id}/cancel, POST /v1/executions/{id}/signals/{name} | The execution is already terminal. |
PAYLOAD_TOO_LARGE | 413 | POST /v1/executions, POST /v1/executions/{id}/signals/{name}, POST /v1/tasks/complete | The execution input, signal payload or task result is larger than --max-payload-bytes, as compact JSON. Nothing is recorded. See Resource limits. |
SIGNAL_PAYLOAD_INVALID | 422 | POST /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_FAILED | 422 | GET /v1/executions/{id}/queries/{name} | Evaluating the query failed at runtime, for example with a division by zero. |
RESOURCE_EXHAUSTED | 429 | POST /v1/executions | --max-active-executions executions are already running. Nothing is started; retry later with the same execution_id. |
STORAGE_ERROR | 500 | any endpoint that reads or writes the database | The 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_LEADER | 503 | every route except GET routes, on a cluster standby | This 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:
| Status | When | Example body |
|---|---|---|
| 400 | The 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 |
| 404 | Unknown path. The body is empty. | |
| 405 | Known path, wrong method. The allow header lists the method. | |
| 413 | The 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 |
| 415 | A body was sent without content-type: application/json. | |
| 422 | Valid 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
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /v1/health | none | Liveness, versions and cluster role. |
GET | /v1/ready | none | Readiness: 200 only where writes are served. |
GET | /v1/metrics | admin | Prometheus metrics. |
POST | /v1/workflows | admin | Register a compiled definition. |
GET | /v1/workflows | admin | List definitions. |
GET | /v1/workflows/{name} | admin | Latest version of a definition. |
GET | /v1/workflows/{name}/{version} | admin | One version of a definition. |
POST | /v1/executions | admin | Start an execution. |
GET | /v1/executions | admin | List executions. |
GET | /v1/executions/{id} | admin | Current state of an execution. |
GET | /v1/executions/{id}/history | admin | Event history. |
GET | /v1/executions/{id}/wait | admin | Long-poll until terminal. |
POST | /v1/executions/{id}/cancel | admin | Request cancellation. |
POST | /v1/executions/{id}/signals/{name} | admin | Send a typed signal. |
GET | /v1/executions/{id}/queries/{name} | admin | Evaluate a declared query. |
POST | /v1/schedules | admin | Create a schedule. |
GET | /v1/schedules | admin | List schedules. |
GET | /v1/schedules/{id} | admin | One schedule, with its next fire times. |
PATCH | /v1/schedules/{id} | admin | Change a schedule. |
DELETE | /v1/schedules/{id} | admin | Delete a schedule. |
POST | /v1/schedules/{id}/pause, /resume | admin | Pause or resume a schedule. |
POST | /v1/schedules/{id}/trigger | admin | Start an execution of a schedule now. |
GET | /v1/workers | admin | Registered workers. |
POST | /v1/workers/register | worker | Open a worker session. |
POST | /v1/workers/heartbeat | worker | Keep a session alive; receive cancellations. |
POST | /v1/workers/poll | worker | Long-poll for a task. |
POST | /v1/workers/unregister | worker | Close a session. |
POST | /v1/tasks/complete | worker | Report a successful result. |
POST | /v1/tasks/fail | worker | Report 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"}}
| Field | Type | Meaning |
|---|---|---|
status | string | Always ok. |
api_version | string | orbit-api/1. |
runtime_version | string | Version of the orbit binary. |
ir_versions | array of strings | IR 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_executions | integer | Executions not yet terminal, held in memory. |
worker_sessions | integer | Worker sessions the runtime remembers, including ones that recently went silent. |
role | string | single without --cluster; leader or standby in cluster mode. |
node_id | string or null | The server’s --node-id; null without --cluster. |
term | integer or null | The leadership term this server last saw; null without --cluster. |
leader | object or null | The 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}]}
| Field | Type | Meaning |
|---|---|---|
role | string | shared. |
owned_executions | integer | Running executions this server owns: it ticks them and is the only one that appends to their history. |
tasks_claimed | integer | Tasks this server handed to workers from the shared queue since it started. |
nodes | array | Every 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:
| Status | Body | When |
|---|---|---|
201 | {"name", "version", "definition_hash", "created": true} | The definition is new. |
200 | the same, with "created": false | An identical definition is already registered. |
400 | INVALID_IR, CHILD_NOT_REGISTERED | |
409 | DEFINITION_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.
| Field | Type | Required | Meaning |
|---|---|---|---|
workflow | string | yes | Workflow name. |
version | string | no | Version to start. Default: the highest registered version. |
input | any | no | Workflow input. null or absent means {}. Defaults declared in the workflow’s input are applied, then the input is validated strictly. |
execution_id | string | no | Client-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:
| Status | Body | When |
|---|---|---|
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. |
400 | INVALID_INPUT, INVALID_EXECUTION_ID | |
404 | WORKFLOW_NOT_FOUND | |
409 | EXECUTION_EXISTS | The id exists for a different definition. |
413 | PAYLOAD_TOO_LARGE | The input, with defaults applied, is larger than --max-payload-bytes. |
429 | RESOURCE_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 parameter | Type | Default | Meaning |
|---|---|---|---|
status | string | all | Filter by status, case-insensitive (failed matches FAILED). |
workflow | string | all | Filter by workflow name. |
limit | integer | 50 | Maximum 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:
| Field | Type | Meaning |
|---|---|---|
id | string | Execution id. |
run | integer, optional | The run this state describes, when it is not run 1 (continue-as-new). The state is always the latest run’s. |
parent | object, optional | For a child workflow: {"execution_id", "step"} of the parent unit that started it. |
schedule_id | string, optional | The schedule that started the execution. |
workflow, version, definition_hash | string | The definition the execution is pinned to. |
input | any | Input after defaults were applied. |
status | string | RUNNING, 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_ms | integer | First and latest event times of the run. |
finished_at_ms | integer, optional | Set when terminal. |
units | object | Unit state by unit key (step, step[i], compensate:<unit>). Only units that have been touched appear. |
abort | object, optional | Why the execution is being wound down: {"kind": "failed", "error"}, {"kind": "cancelled", "reason"} or {"kind": "timed_out"}. |
compensation_order | array, optional | Units being compensated, in order. |
signals | array, optional | The signal buffer: every signal received, in arrival order. Absent when none was received. See below. |
compensation | string, optional | none, completed or failed. Set when terminal after an abort. |
output | any, optional | Workflow output, when COMPLETED. |
error | object, optional | {"code", "message", "retryable", "details"?}, when FAILED. |
last_seq | integer | Sequence number of the latest event. |
Unit fields:
| Field | Type | Meaning |
|---|---|---|
status | string | See Unit statuses. Absent means PENDING. |
attempt | integer | Current attempt number, starting at 1. 0 for a unit that never started. |
input | any, optional | Evaluated input. For a for step: the list of items. |
idempotency_key | string, optional | Key sent to workers. |
output | any, optional | Result of a succeeded unit. null for a skipped one. |
error | object, optional | Final error of a failed unit. |
last_error | object, optional | Error of the latest failed attempt. |
redelivery | boolean | The current attempt is a redelivery of an uncertain one. |
scheduled_at_ms, dispatched_at_ms, finished_at_ms | integer, optional | Transition times. |
deadline_ms | integer, optional | When the in-flight attempt times out. |
wake_at_ms | integer, optional | When a sleep timer fires, a retry becomes due, or an await step times out. |
worker_id, worker_session | string, optional | Who holds or held the latest dispatch. Child workflows show orbit-runtime and child:<child id>; built-in tasks orbit-runtime and builtin:<instance>. |
token | string, optional | Token of the in-flight dispatch. |
tokens | array, optional | Every [token, attempt] issued for the unit. |
succeeded_seq | integer, optional | Sequence number of the success, used to order compensation. |
dispatch_count | integer | Number of dispatches, including redeliveries. |
Signal fields (signals[]):
| Field | Type | Meaning |
|---|---|---|
seq | integer | Sequence number of the SignalReceived event. Identifies the signal. |
name | string | Signal name. |
payload | object | Validated payload, with defaults applied. |
received_at_ms | integer | When the signal was recorded. |
delivered_to | string, optional | The 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 parameter | Type | Default | Meaning |
|---|---|---|---|
run | integer | the latest run | The 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 parameter | Type | Default | Meaning |
|---|---|---|---|
timeout_ms | integer | 30000 | Maximum wait, capped at 300000. |
Error 404 NOT_FOUND.
POST /v1/executions/{id}/cancel
Request cancellation. The body is optional.
| Field | Type | Default | Meaning |
|---|---|---|---|
reason | string | requested via API | Recorded 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}
| Field | Type | Meaning |
|---|---|---|
execution_id | string | The execution. |
signal | string | The signal name. |
accepted | boolean | Always true in a 200 response. |
seq | integer | Sequence 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}}
| Field | Type | Meaning |
|---|---|---|
execution_id | string | The execution. |
query | string | The query name. |
value | any | The 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:
| Field | Type | Required | Meaning |
|---|---|---|---|
schedule_id | string | yes | Letters, digits, -, _ and ., at most 100 characters. |
workflow | string | yes | The workflow to start. It must be registered. |
version | string | no | Pin a version. Default: the latest registered version at each fire. |
input | object | no | Execution input (default {}), validated against the workflow’s input type when the schedule is created or changed, and again at each start. |
cron | string | one of cron or interval | Five fields, minute hour day-of-month month day-of-week, or @hourly, @daily (@midnight), @weekly, @monthly, @yearly (@annually). See Cron expressions. |
interval | string | one of cron or interval | A 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. |
timezone | string | no | IANA time zone for cron, such as Europe/Berlin. Default UTC. Not allowed with interval. |
overlap | string | no | skip (default), allow or cancel_previous. |
catchup | string | no | skip_missed (default) or run_once. |
start_at, end_at | string | no | RFC 3339 times bounding the occurrences. |
jitter | string | no | A duration up to 24h: each fire is delayed by a deterministic amount below it. |
description | string | no | Free text. |
paused | boolean | no | Create 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:
| Field | Type | Meaning |
|---|---|---|
paused | boolean | The schedule does not fire. |
next_fire_at | string or null | When the next occurrence fires: the occurrence plus its jitter. null while paused and after end_at. |
next_fire_times | array of strings | The next five occurrences (without jitter). Empty while paused and after end_at. |
last_fire_at | string or null | The latest occurrence handled (started or skipped). Triggers do not change it. |
last_execution_id | string or null | The latest execution the schedule started, including triggers. The overlap policy looks at this execution. |
fired | integer | Executions started, including triggers. |
skipped | integer | Occurrences that started nothing: skipped by the overlap policy or missed while no server could fire them. |
last_error | string or null | Why 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_at | string | RFC 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
}
]
}
| Field | Type | Meaning |
|---|---|---|
worker_id, runtime, sdk | string | As announced at registration; runtime is lowercased, sdk may be null. |
contracts | array | Announced {task_name, contract_version} pairs. |
last_seen_ms, registered_at_ms | integer | Latest contact and latest registration. |
online | boolean | At least one session contacted the runtime within the session timeout. |
sessions | integer | Live sessions. |
inflight | integer | Tasks 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.
| Field | Type | Required | Meaning |
|---|---|---|---|
worker_id | string | yes | Stable worker identity. Must not be empty. |
runtime | string | yes | Worker language, matched case-insensitively against a step’s using <language>. |
sdk | string | no | Free-form SDK name and version. |
contracts | array | yes | At 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.
| Field | Type | Required | Meaning |
|---|---|---|---|
worker_session_id | string | yes | Session 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>).
| Field | Type | Required | Meaning |
|---|---|---|---|
worker_session_id | string | yes | Session from register. |
wait_ms | integer | no | Maximum 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
}
| Field | Type | Meaning |
|---|---|---|
token | string | Identifies this dispatch. Send it with the result. |
execution_id, workflow, workflow_version | string | The execution. |
step_id | string | Unit 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_version | string | The contract to run. |
input | any | Evaluated, validated input. |
idempotency_key | string | Stable across retries and redeliveries of this unit. |
attempt | integer | Attempt number, starting at 1. |
redelivery | boolean | true when the previous delivery of this same attempt had an unknown outcome. |
deadline_ms, timeout_ms | integer, optional | Present 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.
| Field | Type | Required | Meaning |
|---|---|---|---|
token | string | yes | Token from the task. |
output | any | no | Result. 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:
| Body | Meaning |
|---|---|
{"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.
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
token | string | yes | Token from the task. | |
error_code | string | yes | Application error code, matched against the step’s non_retryable list. | |
error_message | string | no | "" | Human-readable message. |
retryable | boolean | no | true | false fails the step without further attempts. |
details | any | no | none | Structured 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 status | gRPC status |
|---|---|
| 400 | INVALID_ARGUMENT |
| 401 | UNAUTHENTICATED |
| 403 | PERMISSION_DENIED |
| 404 | NOT_FOUND |
| 409 | FAILED_PRECONDITION |
413 (PAYLOAD_TOO_LARGE, CompleteTask) and 429 | RESOURCE_EXHAUSTED |
| 500 | INTERNAL |
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
| Aspect | Rule |
|---|---|
| Method | Every worker endpoint is POST. |
| Request body | JSON, with content-type: application/json. A request without that header is answered 415 Unsupported Media Type. |
| Field names | snake_case, following the proto3 JSON mapping of worker.proto. Unknown fields are ignored. |
| Response header | Every response that passes authentication carries x-orbit-api-version: orbit-api/1. |
| Body size | Requests larger than the runtime’s --max-body (4 MiB by default) are answered 413 Payload Too Large. |
| TLS | When 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.
| Status | Codes | Meaning |
|---|---|---|
400 | INVALID_REQUEST, RESERVED_TASK | The request is well-formed JSON but not valid. |
401 | UNAUTHENTICATED | Credentials are required and none were valid. |
403 | FORBIDDEN | The credential may not perform this call. |
404 | UNKNOWN_SESSION | The session does not exist, for example after a runtime restart. Register again. |
5xx | various | A 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>
| Credential | Configured with | May call |
|---|---|---|
| Admin token | --token / ORBIT_TOKEN | Every endpoint, including the worker endpoints. |
| Shared worker token | --worker-token / ORBIT_WORKER_TOKEN | /v1/workers/* and /v1/tasks/* only. |
| Per-worker token | token_sha256 in --workers-file | /v1/workers/* and /v1/tasks/*, limited to its own identity. |
| Client certificate | client_cert in --workers-file, with --tls-client-ca | Same 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:
| Rule | Example 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 field | Type | Required | Meaning |
|---|---|---|---|
worker_id | string | yes | Stable identity, for example payments-7f9c. Must not be blank. |
runtime | string | yes | Your language, matched case-insensitively against using <language> in steps. |
contracts | array | yes, non-empty | The tasks you serve: objects with task_name and contract_version. |
sdk | string | no | Informational, for example orbit-ruby/0.1.0. Shown by GET /v1/workers. |
| Response field | Type | Meaning |
|---|---|---|
accepted | bool | Always true on success. |
worker_session_id | string | Send it with every session call. |
heartbeat_interval_ms | integer | How often to heartbeat: one sixth of the session timeout, at least 500. |
session_timeout_ms | integer | Silence longer than this makes the runtime consider the worker lost. Set by orbit server --session-timeout (default 30 seconds). |
warnings | array of strings | Problems that do not prevent registration, such as a contract version no registered workflow uses. Log them. |
| Status | Code | When |
|---|---|---|
200 | Registered. | |
400 | INVALID_REQUEST | worker_id is blank, or contracts is empty. |
400 | RESERVED_TASK | A task name starts with orbit. (a built-in task) or names a child workflow. |
403 | FORBIDDEN | A per-worker identity broke one of its rules. |
422 | worker_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 field | Type | Required | Meaning |
|---|---|---|---|
worker_session_id | string | yes | From registration. |
wait_ms | integer | no | How 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.
| Status | Code | When |
|---|---|---|
200 | A task assignment, described below. | |
204 | No task arrived within wait_ms. Poll again. | |
403 | FORBIDDEN | The session belongs to another per-worker identity. |
404 | UNKNOWN_SESSION | Register again, then poll with the new session. |
The task assignment
| Field | Type | Meaning |
|---|---|---|
token | string | Identifies this delivery. Echo it in the result. |
execution_id | string | The workflow execution. |
workflow | string | Workflow name. |
workflow_version | string | Workflow version. |
step_id | string | The 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_name | string | The task to run. |
contract_version | string | The task’s contract version. |
input | any JSON value | The task input, already validated against the contract’s input schema. |
idempotency_key | string | Stable across retries and redeliveries of the step. Defaults to <execution_id>/<step>. |
attempt | integer | 1-based attempt number. A redelivery keeps the number. |
redelivery | bool | true when the outcome of an earlier delivery of this attempt is unknown. |
deadline_ms | integer, optional | Absolute deadline of this attempt in Unix epoch milliseconds. Present only when the step has a timeout. |
timeout_ms | integer, optional | The 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 field | Type | Required | Meaning |
|---|---|---|---|
worker_session_id | string | yes | From registration. |
| Response field | Type | Meaning |
|---|---|---|
accepted | bool | Always true on success. |
cancel | array of strings | Tokens of attempts this worker holds that it should stop. Each token is listed once. |
| Status | Code | When |
|---|---|---|
200 | Heartbeat recorded. | |
403 | FORBIDDEN | The session belongs to another per-worker identity. |
404 | UNKNOWN_SESSION | Register again. |
Complete a task
POST /v1/tasks/complete
Reports a successful attempt.
| Request field | Type | Required | Meaning |
|---|---|---|---|
token | string | yes, non-empty | The token from the task assignment. |
output | JSON object | no | The 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 field | Type | Meaning |
|---|---|---|
accepted | bool | Whether the result changed the step. |
reason | string, optional | Why the result was not accepted. Present only when accepted is false. |
| Status | Code | When |
|---|---|---|
200 | The result was processed; check accepted. | |
400 | INVALID_REQUEST | token is empty. |
403 | FORBIDDEN | The token was issued to another per-worker identity. |
422 | token is missing. |
Fail a task
POST /v1/tasks/fail
Reports a failed attempt.
| Request field | Type | Required | Default | Meaning |
|---|---|---|---|---|
token | string | yes | The token from the task assignment. | |
error_code | string | yes, non-empty | Machine-readable code, for example CARD_DECLINED. | |
error_message | string | no | "" | Human-readable message. |
retryable | bool | no | true | false fails the step regardless of its retry policy. |
details | any JSON value | no | none | Stored 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.
| Status | Code | When |
|---|---|---|
200 | The result was processed; check accepted. | |
400 | INVALID_REQUEST | error_code is empty. |
403 | FORBIDDEN | The token was issued to another per-worker identity. |
422 | token 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 field | Type | Required | Meaning |
|---|---|---|---|
worker_session_id | string | yes | From 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.
accepted | reason | Meaning |
|---|---|---|
true | The result was recorded. | |
false | output violates the task contract | The output failed validation. The attempt failed with OUTPUT_CONTRACT_VIOLATION. |
false | stale attempt | A failure for an attempt that is no longer the one in flight. It was recorded and ignored. |
false | duplicate result; step already resolved | The step already has an outcome. The result was recorded as StaleResultIgnored. |
false | unknown 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 sameattemptnumber, the sameidempotency_key, andredelivery: 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
timeoutlimits one attempt from dispatch to result. Whendeadline_mspasses, the runtime fails the attempt withTIMEOUT(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
- Register on session loss. On
404 UNKNOWN_SESSIONfrompoll,heartbeatorunregister, register again and retry the call with the new session. Tokens stay valid across sessions. - Make result delivery survive outages. Retry
completeandfailon connection errors and 5xx responses, with backoff, until the runtime answers. A result from before a runtime restart is still accepted. - Treat answers as final. Do not retry a result the runtime answered, and do not retry 4xx responses.
- Make side effects idempotent. Use
idempotency_key, which is stable across retries and redeliveries. - Heartbeat independently. Send heartbeats every
heartbeat_interval_msfrom a thread that your handlers cannot block. - Honour cancellations. Stop work for tokens listed in
cancel. - Announce contracts exactly. Use the
task_nameandcontract_versionfrom the.orbitfiles, and log registrationwarnings. - Return objects. Send a JSON object as
output; the official SDKs fail an attempt withINVALID_RESULT(not retryable) when a handler returns anything else. - 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);
}
| RPC | HTTP equivalent | Request message | Response message |
|---|---|---|---|
RegisterWorker | POST /v1/workers/register | RegisterWorkerRequest | RegisterWorkerResponse |
PollTask | POST /v1/workers/poll | PollTaskRequest | ExecuteTaskRequest |
Heartbeat | POST /v1/workers/heartbeat | HeartbeatRequest | HeartbeatResponse |
CompleteTask | POST /v1/tasks/complete | CompleteTaskRequest | TaskResultAck |
FailTask | POST /v1/tasks/fail | FailTaskRequest | TaskResultAck |
UnregisterWorker | POST /v1/workers/unregister | UnregisterWorkerRequest | UnregisterWorkerResponse |
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-keyand--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,PollTaskreturns anExecuteTaskRequestwith an emptytoken. Treat an empty token as “poll again”. - Defaults. A
wait_msof0means the default of 20 seconds. Because proto3 booleans default tofalse,FailTasktreats an unsetretryableasfalse, unlike HTTP, where an omittedretryableistrue. Always set it. - Numbers.
google.protobuf.Valuecarries every number as a double. The runtime converts whole numbers within ±2^53 back to integers, soIntfields round-trip. - Acknowledgements.
TaskResultAck.reasonis 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_FOUNDwith the messageUNKNOWN_SESSION: unknown or expired worker session; register again. The code is also in theorbit-error-coderesponse metadata, except for missing credentials (UNAUTHENTICATED) and an emptytokenorerror_code(INVALID_ARGUMENT), where only the message prefix carries it.
| HTTP status | gRPC status |
|---|---|
400 | INVALID_ARGUMENT |
401 | UNAUTHENTICATED |
403 | PERMISSION_DENIED |
404 | NOT_FOUND |
409 | FAILED_PRECONDITION |
| other | INTERNAL |
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
- How workers work
- HTTP API
- Security
- Delivery guarantees
- Retries and timeouts
- Idempotency
- Environment variables
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
| Range | Area |
|---|---|
| E00xx | Lexical: characters, strings, comments, literals. |
| E01xx | Syntax: tokens, blocks and members. |
| E02xx, W02xx | Declarations, schemas, types and imports. |
| E03xx, W03xx | Steps, dependencies, tasks and child workflows. |
| E04xx | Expressions, types of values, retry and timeout settings. |
| E05xx | Workflow-level rules. |
| W06xx | Static safety checks: compensations, constant conditions, signals and waits. |
| E07xx, W07xx | test declarations: workflows, mocks, signals and expectations. |
| P0xxx | Organisation 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.
| Code | Rule type | Message |
|---|---|---|
P0001 | waivers | waiver names unknown policy rule `<id>` (note: the known rule ids), or waiver for policy rule `<id>` has no reason. Always an error. |
P0002 | waivers | waiver for policy rule `<id>` suppresses nothing, help remove the comment. Always a warning. |
P0101 | require_compensation | step `<s>` calls `<task>` but has no compensation; with compensate_with: step `<s>` compensates `<task>` with `<other>`, which is not one of <patterns> |
P0102 | require_retry | step `<s>` calls `<task>` with <n> attempt(s); at least <min> required, or ... with up to <n> attempts; at most <max> allowed |
P0103 | max_attempts | step `<s>` calls `<task>` with up to <n> attempts; at most <max> allowed |
P0104 | require_timeout | step `<s>` calls `<task>` without a `timeout` , step `<s>` has `timeout <d>`; at most <max> allowed, or step `<s>` calls `<task>` without a `queue_timeout` |
P0105 | require_approval | step `<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>` |
P0106 | require_handler | workflow `<w>` has no `on failure` handler (every missing kind is listed) |
P0107 | forbid_tasks | step `<s>` calls `<task>`, a forbidden task, or the compensation of step `<s>` calls `<task>`, a forbidden task |
P0108 | allow_tasks | step `<s>` calls `<task>`, a task outside the allowed list (or the compensation of step `<s>` ...) |
P0109 | max_fan_out | workflow `<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` |
P0110 | require_limits | workflow `<w>` does not set `<key>` in `limits` |
P0111 | require_signal_timeout | step `<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
- CLI: orbit check
- Static safety checks
- Policies
- Testing workflows
- Editor support
- Grammar
- Expressions
- Types
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.
| Variable | Flag | Default | Meaning |
|---|---|---|---|
ORBIT_DB | --db | orbit.db | SQLite file path or PostgreSQL URL (postgres:// or postgresql://). |
ORBIT_ADDR | --addr | 127.0.0.1:7233 | HTTP API listen address. The container image sets it to 0.0.0.0:7233. |
ORBIT_GRPC_ADDR | --grpc-addr | unset | Also serve the gRPC worker protocol on this address. |
ORBIT_TOKEN | --token | unset | Admin bearer token. Required on every endpoint except /v1/health when set. |
ORBIT_WORKER_TOKEN | --worker-token | unset | Shared worker credential, limited to /v1/workers/* and /v1/tasks/*. |
ORBIT_WORKERS_FILE | --workers-file | unset | Path of the workers file (TOML): per-worker identities and client identities. |
ORBIT_TLS_CERT | --tls-cert | unset | PEM certificate chain for HTTPS (and TLS on gRPC). |
ORBIT_TLS_KEY | --tls-key | unset | PEM private key for ORBIT_TLS_CERT. |
ORBIT_TLS_CLIENT_CA | --tls-client-ca | unset | PEM CA that signs worker client certificates (mutual TLS). |
OTEL_EXPORTER_OTLP_ENDPOINT | --otlp-endpoint | unset | OTLP/HTTP collector base URL. Spans are posted to <endpoint>/v1/traces. |
OTEL_EXPORTER_OTLP_HEADERS | unset | Extra 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_NAME | orbit-runtime | Value of the service.name resource attribute on exported spans. | |
ORBIT_LOG | info | Log 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_CERT | unset | PEM CA bundle trusted for PostgreSQL TLS, in addition to the public web PKI roots. | |
ORBIT_MAX_ACTIVE_EXECUTIONS | --max-active-executions | unlimited | Maximum running executions; further starts get 429 RESOURCE_EXHAUSTED. |
ORBIT_MAX_FAN_OUT | --max-fan-out | unlimited | Maximum elements of one for step; larger lists fail the step with LIMIT_EXCEEDED. |
ORBIT_MAX_HISTORY_EVENTS | --max-history-events | unlimited | Maximum history events per execution; past it the execution fails with LIMIT_EXCEEDED. |
ORBIT_MAX_PAYLOAD_BYTES | --max-payload-bytes | unlimited | Maximum size in bytes of an execution input, task result or signal payload; larger ones get 413 PAYLOAD_TOO_LARGE. |
ORBIT_CLUSTER | --cluster | unset | true to run as one of several servers on one PostgreSQL database. |
ORBIT_CLUSTER_MODE | --cluster-mode | standby | shared to make every server of the cluster active. |
ORBIT_NODE_TIMEOUT | --node-timeout | 5 | Shared mode: seconds without a heartbeat after which a server is considered dead. |
ORBIT_NODE_ID | --node-id | host name | This server’s name in the cluster. |
ORBIT_ADVERTISE_ADDR | --advertise-addr | derived from --addr | URL handed out for this server while it leads. |
ORBIT_NO_UI | --no-ui | unset (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.
| Variable | Flag | Default | Meaning |
|---|---|---|---|
ORBIT_SERVER | --server | http://127.0.0.1:7233 | Runtime API address. |
ORBIT_TOKEN | --token | unset | Bearer token: the admin token, or a client identity’s token for the commands its actions allow. |
ORBIT_CA_CERT | --ca-cert | unset | Extra PEM CA certificate to trust. |
ORBIT_CLIENT_CERT | --client-cert | unset | PEM client certificate for mutual TLS. |
ORBIT_CLIENT_KEY | --client-key | unset | PEM private key for the client certificate. |
NO_COLOR | unset | When 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.
| Variable | Default | Meaning | Read by |
|---|---|---|---|
ORBIT_SERVER | http://127.0.0.1:7233 | Runtime API address. | Python, TypeScript, Go, Rust, Java, C# |
ORBIT_WORKER_TOKEN | unset | Worker credential: the shared worker token or a per-worker token. Preferred over ORBIT_TOKEN. | Python, TypeScript, Go, Rust, Java, C# |
ORBIT_TOKEN | unset | Used as the worker credential when ORBIT_WORKER_TOKEN is not set. | Python, TypeScript, Go, Rust, Java, C# |
ORBIT_CA_CERT | unset | Extra PEM CA certificate to trust for HTTPS. | Python, Go, Rust, Java, C#; TypeScript only together with a client certificate (see below) |
ORBIT_CLIENT_CERT | unset | PEM client certificate for mutual TLS. | Python, TypeScript, Go, Rust, Java, C# |
ORBIT_CLIENT_KEY | unset | PEM private key for the client certificate. | Python, TypeScript, Go, Rust, Java, C# |
NODE_EXTRA_CA_CERTS | unset | Read 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_LEVEL | INFO | Log level for the worker’s logging.basicConfig call. | Python |
HOSTNAME | system host name | Used 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).
| Variable | Default | Meaning |
|---|---|---|
ORBIT_VERSION | latest | Release tag to install, for example v0.1.0. |
ORBIT_INSTALL_DIR | ~/.local/bin, or /usr/local/bin as root | Directory the binary is installed into. |
ORBIT_DOWNLOAD_URL | GitHub release URL | Base 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:
| Variable | Read by | Meaning |
|---|---|---|
ORBIT_TEST_POSTGRES_URL | crates/orbit-runtime/tests/postgres.rs | PostgreSQL URL for the PostgreSQL backend tests. The tests are skipped when it is unset. |
ORBIT_BIN | editors/vscode/test | Path 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 serverand checkcurl 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. SetORBIT_SERVER(or the CLI’s--server) to the runtime’s URL, including the scheme and port. - The server listens on loopback only. The default
--addris127.0.0.1:7233, which other machines and containers cannot reach. Listen on another interface with--addr 0.0.0.0:7233and 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:
| Client | Setting |
|---|---|
| CLI | --token or ORBIT_TOKEN |
| SDK workers | ORBIT_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:
- its
STATEisonline; - it lists the step’s task name, spelled exactly the same;
- it lists the same contract version (see the next section);
- if the step says
using <language>, itsRUNTIMEis 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 isnull; - a field declared with
?in a schema or contract; - an index such as
items[0]orheaders["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
whencondition.
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:
| Client | Setting |
|---|---|
| CLI | --ca-cert ca.pem or ORBIT_CA_CERT |
| Python, Go, Rust, Java, C# workers | ORBIT_CA_CERT |
| Node.js workers | NODE_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.5 | Temporal | |
|---|---|---|
| Workflow definition | A declarative .orbit file, compiled and type-checked against task contracts before deployment | Code in an SDK language, with determinism rules enforced at replay |
| Business logic | Workers, one handler per task contract; small deterministic transformations as compute steps | Activities, called from workflow code |
| Control flow | Dependencies, conditions, fan-out, child workflows, sleep, waiting for signals and compensation, declared in the language | Anything the host language can express |
| Signals and queries | Declared 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 declaration | Defined in workflow code through SDK handlers |
| Static checks | Type 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 workflow | A new immutable name@version; running executions keep their definition; orbit compat reports breaking changes | Versioning APIs and worker build IDs |
| Failure handling | Compensation per step, then on failure, on cancel and on timeout handler steps | Code in the workflow, such as try/catch and cancellation scopes |
| Recurring messages | on event <signal> blocks run once per arriving signal, alongside the main steps | Signal handlers in workflow code |
| Recurring starts | Schedules: cron expressions (with a time zone) or fixed intervals, with overlap and catch-up policies, managed with /v1/schedules and orbit schedule | Schedules |
| Long-running workflows | continue_as_new, declared in the workflow and type-checked against its input; each run has its own history | Continue-As-New, called from workflow code |
| Testing | test declarations run by orbit test with mocked tasks and simulated time; orbit simulate and orbit replay | SDK test frameworks with time skipping, and replay testing |
| Operations | A web UI built into the server, the CLI, Prometheus metrics and OpenTelemetry traces | The Temporal Web UI, CLI, metrics and tracing |
| Process documentation | orbit graph, orbit docs, orbit schema and orbit diff generate diagrams, reference pages, JSON Schema and process diffs from the definition | Not built in |
| Deployment | One binary with SQLite or PostgreSQL; one server, active/standby servers, or several active servers sharing the work on PostgreSQL | A multi-service cluster with several supported databases |
| Features not yet in ORBIT | Updates, 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
| Path | What |
|---|---|
crates/orbit-syntax | lexer, parser, AST, diagnostics, formatter |
crates/orbit-ir | orbit-ir/1: types, expressions, invariants |
crates/orbit-compiler | name resolution, type checking, lowering |
crates/orbit-runtime | engine, decider, storage, HTTP/gRPC servers, identities, telemetry |
crates/orbit-cli | the 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/vscode | the VS Code extension |
tests/e2e | end-to-end tests with real processes |
Guidelines
- Semantics come first. Changes to the language or to runtime behaviour
must update
specification/(grammar, RFC appendix) anddocs/, 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.rsmust 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.protoand its copy incrates/orbit-runtime/proto/. A test checks the two are identical. Keep changes backwards compatible withinv1. - Run
cargo fmt,cargo clippy --all-targets -- -D warningsandorbit fmton any.orbitfiles 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
whencontains every conjunct of a conditional dependency’swhen, or!is_null(dep), reads that dependency as non-optional, so guarded steps no longer needcoalesce. - 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 byorbit 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 eventblocks 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_approvalabove 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 Nfor 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 serverserves 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-uiturns 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 actionschedule, 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 fileskyc.tomlandfintech.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 asevent. 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 byon eventare 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 inspectlistson eventblocks and thelimitsblock.- New benchmark programs,
bench_engineandbench_http, and a Performance page with measured numbers.
Runtime
- Several active servers.
orbit server --cluster --cluster-mode sharedruns 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 cancelandon timeoutblocks hold steps that run after compensation, before the execution’s terminal status, with a typedfailurevalue (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=CODEand--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 --clusterruns 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/readyfor load balancers,503 NOT_LEADERwith the leader’s address. The CLI follows the leader automatically. - Resource limits.
--max-active-executions(429RESOURCE_EXHAUSTED),--max-fan-out,--max-history-events(the execution fails withLIMIT_EXCEEDED; compensation and handlers still run) and--max-payload-bytes(413PAYLOAD_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 aFloatlist returns0.0for an empty list. Workflows that callsumon a decimal list compile to a new definition hash: give them a new version when you redeploy.- Durations print as
2d,1d6h,2h15m;orbit inspectlists signals, queries and handlers.
[0.2.0] — 2026-11-15
Language
- Signals and queries.
signal <name> { ... }declares a typed message a workflow accepts; anawait <name>step waits for it durably (withtimeout, failing withSIGNAL_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-insmap,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 --strictturn 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 signalandorbit query.orbit fmtkeeps Windows (CRLF) line endings. The CLI exits quietly when its output pipe closes (orbit logs --json | head).
Runtime
POST /v1/executions/{id}/signals/{name}andGET /v1/executions/{id}/queries/{name}; history eventsSignalReceived,SignalAwaitStarted,SignalDelivered.orbit-ir/2for workflows that use the new features. Workflows that do not still compile toorbit-ir/1with 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
SIGTERMas well asSIGINT; the container image setsSTOPSIGNAL SIGINT. float(Bool)evaluates at run time, as the compiler allows.
SDKs
- Python and TypeScript clients:
signal()andquery().
Project
- Documentation website with a landing page and complete documentation, deployed to GitHub Pages.
install.shexplains 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
afterand from data references; cycle detection. - Retries (fixed or exponential,
non_retryable), per-attempt and queue timeouts, workflow timeouts, idempotency keys, conditions (when), durablesleep, compensation (Saga). for item in <list>fan-out withmax_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
orbitCLI: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.
Project links
| Source code | github.com/AjmalX0/Orbit |
| Website and documentation | ajmalx0.github.io/Orbit |
| Releases and binaries | GitHub Releases |
| Command-line tool | crates.io/crates/orbit-lang |
| Python SDK | pypi.org/project/orbit-lang-sdk |
| TypeScript SDK | npmjs.com/package/orbit-lang |
| Go SDK | pkg.go.dev/github.com/AjmalX0/Orbit/sdks/go |
| Rust SDK | crates.io/crates/orbit-worker |
| Java SDK | Maven Central: io.github.ajmalx0:orbit-sdk |
| C# SDK | nuget.org/packages/OrbitLang.Sdk |
| Container image | ghcr.io/ajmalx0/orbit |
Getting involved
- Questions, ideas and bug reports: issues.
- Changes: read Contributing.
- Security reports: see SECURITY.md.
Citing ORBIT
The repository includes a
CITATION.cff;
GitHub’s “Cite this repository” button produces APA and BibTeX entries from
it.