Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Typed business process contracts

This guide shows how to write a business process in ORBIT so that the process definition is a contract you can review, check and audit: customer onboarding with identity verification, sanctions screening, a compliance officer’s review, and an account that is closed again if anything later fails. It covers what the compiler guarantees, how to test every path, and how to read the record an execution leaves. The complete files are examples/onboarding.orbit and examples/onboarding.test.orbit.

What “contract” means here

In a process written as code, the rules live in branches, helper functions and conventions. In ORBIT they are declarations the compiler checks:

RuleWhere it is declaredWhat checks it
Each service call has a fixed input and output shapea task contractThe compiler, on every step; the runtime, on every result
A side effect has an undo actioncompensated_by on the contract, compensate on the stepW0601/W0602, errors under orbit check --strict
A human decision has a typed payload and a deadlinesignal and await ... timeoutThe compiler (payload use), the runtime (payload validation), W0606 for waits without a timeout
Business rules are visiblecompute steps and when conditionsThe type checker; the runtime evaluates them deterministically
What happens on failure, cancellation and timeouton failure, on cancel, on timeoutThe compiler; the runtime runs them after compensation
Who may start, signal or read a processclient identities in the workers fileThe server, per workflow and action
A change does not break running callersversion, orbit compatorbit compat before you deploy

The contracts

Each external service is a task contract. accounts.open declares that its effect is undone by accounts.close, so every workflow that opens an account must say how it closes it:

task "accounts.open" {
    version "1.0.0"
    input { customer_id: String, product: String, risk: String }
    output { account_id: String }
    compensated_by "accounts.close"
}

task "accounts.close" {
    version "1.0.0"
    input { account_id: String, reason: String }
    output { closed: Bool }
}

Contracts can live in a shared file that several teams import, so the worker team and the process owner agree on one definition.

The process

The risk rules are a compute step, written in the process where a reviewer can read them, and evaluated by the runtime with no worker:

step risk {
    compute {
        level: if !identity.verified || screening.hits > 0 then "high"
            else if identity.confidence < 0.9 then "medium"
            else "low"
        needs_review: !identity.verified || screening.hits > 0 || identity.confidence < 0.9
    }
}

step decision {
    await review
    when risk.needs_review
    timeout 5d
}

step open_account {
    worker "accounts.open"
    when !risk.needs_review || coalesce(decision.approved, false)
    with { customer_id: input.customer_id, product: input.product, risk: risk.level }
    idempotency_key: "open-" + input.customer_id
    compensate {
        worker "accounts.close"
        with { account_id: open_account.account_id, reason: "onboarding did not complete" }
    }
}

The officer’s decision is a typed signal. Sending one with a missing or mistyped field is refused by the server with 422 SIGNAL_PAYLOAD_INVALID and never reaches the process:

signal review { approved: Bool, officer: String, note: String? }

What the compiler refuses

Delete the compensate block from open_account and the strict check fails, naming the action that would be left in place:

warning[W0601]: step `open_account` calls `accounts.open` but has no compensation
  --> onboarding.orbit:96:16
   |
96 |         worker "accounts.open"
   |                ^^^^^^^^^^^^^^^
   = note: `accounts.open` declares `compensated_by "accounts.close"`: if a later step fails, its effect would be left in place
   = help: add `compensate { worker "accounts.close" with { ... } }`

error: 1 warning(s) treated as errors (--strict)

Run orbit check --strict in CI so this cannot be merged. Other checks that apply here: a with field of the wrong type (E0400), a missing required field (E0403), reading a value that may be null without handling it (E0405), a condition that is always false or always true (W0603, W0604), and an await without a timeout (W0606). See Static safety checks.

Organisation rules as policy

Some rules are not about one workflow but about every workflow in a domain: “accounts must be closable”, “provider calls retry and have a deadline”, “reviews are answered within a week”. Write them once as a policy file and check every workflow against it in CI. examples/policies/kyc.toml:

[policy]
name = "kyc"
workflows = ["Onboarding*"]

[[rule]]
id = "accounts-reversible"
type = "require_compensation"
tasks = ["accounts.open"]

[[rule]]
id = "provider-timeouts"
type = "require_timeout"
tasks = ["identity.*", "sanctions.*"]
max = "1m"

[[rule]]
id = "review-deadline"
type = "require_signal_timeout"
signals = ["review"]
max = "7d"

[[rule]]
id = "outcome-handlers"
type = "require_handler"
handlers = ["failure", "timeout"]

The onboarding workflow passes. Remove the timeout from the identity step and the check fails:

$ orbit check --policy examples/policies/kyc.toml examples/onboarding.orbit
error[P0104]: policy rule `provider-timeouts`: step `identity` calls `identity.verify` without a `timeout`
  --> examples/onboarding.orbit:64:10
   |
64 |     step identity {
   |          ^^^^^^^^
   = note: required by policy kyc (examples/policies/kyc.toml)
   = help: add `timeout 1m` to the step, or waive it: `// policy-allow: provider-timeouts <reason>`

error: policy check failed: 1 error(s), 0 warning(s)

orbit compile --policy and orbit deploy --policy apply the same check, so a workflow that breaks a rule cannot be deployed. A deliberate exception is written next to the step it applies to, with a reason (// policy-allow: provider-timeouts <reason>), so it is reviewed like any other change. See Policies for every rule type.

Documentation and diagrams from the definition

The process definition is also its documentation. orbit graph draws it (Mermaid, Graphviz or SVG), orbit docs writes a reference page per workflow and contract, and orbit schema exports JSON Schema for every input, output, signal and query, so other systems can validate payloads. orbit diff shows what changed between two versions of the process, in a form you can paste into a pull request. See Graphs, Documentation generation and Process diffs.

Testing every path

Each path through the process is a test block with mocked services. Tests are type-checked against the contracts, so a mock that returns the wrong shape does not compile:

test "a failure after the account is opened closes it again" {
    workflow Onboarding
    input { customer_id: "c-5", full_name: "T. Hoe", document_id: "P111", country: "BE" }
    mock "identity.verify" returns { verified: true, confidence: 0.99, reasons: [] }
    mock "sanctions.screen" returns { hits: 0, lists: [] }
    mock "accounts.open" returns { account_id: "acc-" + input.customer_id }
    mock "audit.record" fails "AUDIT_UNAVAILABLE" "the audit store rejected the write"
    mock "accounts.close" returns { closed: true }
    expect status FAILED
    expect error "AUDIT_UNAVAILABLE"
    expect compensated open_account
}
$ orbit test examples/onboarding.test.orbit
running 5 tests
ok      a verified, clean customer is opened without review (0ms simulated)
ok      a sanctions hit waits for an officer, who approves (0ms simulated)
ok      an officer rejection opens nothing and is audited (0ms simulated)
ok      a review nobody answers fails after five days and is recorded (5d 0h simulated)
ok      a failure after the account is opened closes it again (0ms simulated)

test result: ok. 5 passed, 0 failed, 0 filtered out (0.04s)

The five-day deadline is simulated: durable time is fast-forwarded, so the whole suite runs in milliseconds. See Testing workflows.

The record of an execution

Every execution’s history is the audit trail: each decision, each attempt, the officer’s signal and who sent it, and the outcome. orbit simulate shows the same timeline locally, here for a sanctions hit that an officer approves:

+     0ms   #9  StepSucceeded             step=risk attempt=1
+     0ms  #10  SignalAwaitStarted        step=decision signal=review
+     0ms  #11  SignalReceived            name=review
+     0ms  #12  SignalDelivered           step=decision signal_seq=11
+     0ms  #13  StepScheduled             step=open_account attempt=1
...
+     0ms  #22  WorkflowCompleted

  output: {"status":"opened","account_id":"acc-c-9","risk":"high"}

On a server, orbit logs <id> prints this history and GET /v1/executions/{id}/history returns it as JSON. The history is append-only and records the definition hash the execution ran, so you can show exactly which version of the process made each decision. See Executions and history.

Limiting who can act

A compliance tool should be able to read onboarding executions and send review signals, and nothing else. Give it a client identity:

[[client]]
id = "compliance-console"
token_sha256 = "..."   # from `orbit token --client compliance-console`
workflows = ["Onboarding"]
actions = ["read", "signal", "query"]

It cannot start executions, deploy workflows or touch other workflows. See Security: client identities.

Changing the process

Treat a change to the process like an API change. orbit compat compares the old and new definitions and reports breaking changes to inputs, outputs, signals and queries and the version bump they require; running executions keep the definition they started with. See Versioning.

See also