S
supero.docs
Documentation/Foundations/Workflow Orchestration

Workflow Orchestration

A record changes, a workflow runs, and services fire in a defined order — with the definition stored as an ordinary record and every step written to a log you can query.

A CRUD event firing a workflow definition whose steps call services and write records, with a per-step log
A CRUD event fires a binding, the binding runs a definition, and each step leaves a log row behind it.

What It Is For

Some of what an application does is not a request. A class booking is created, and three things must follow: a confirmation goes to the member, the record is marked processed, an audit trail is written. A severe adverse event is logged in a clinical trial, and staff have to hear about it within the minute. Putting that in a create handler works until one step fails and nobody can say which, or when.
A workflow is where that sequence belongs: a named, versioned definition of an ordered set of steps, an error policy for each, and a record of every run. Crucially it is not code you deploy — a workflow is a CRUD record of type wf:workflow_definition, created, listed, updated and deleted through the same API as every other record.
Reach for one when the trigger is a data event rather than a user action, when the sequence crosses more than one service, or when you will later be asked to prove a step ran for a given record. Not for validating a field — that belongs in the schema.

The Shape

A definition carries the workflow_id that bindings and callers name it by, a display_name, a version, an enabled flag, a status, a default on_error policy, an input_schema, and an ordered steps array.
status is Draft, Active, Disabled or Archived, so a workflow can be parked without deleting it or breaking the bindings that name it. on_error is stop, continue, retry or compensate, set on the workflow and overridable per step; max_retries, retry_delay_seconds and timeout_seconds complete a retry policy.
There are six step types:
  • •service_call — invoke an operation on an imported service. Carries service, operation and an input_map.
  • •crud_operation — read or write a record. Carries operation, object_type, and data and/or record_uuid.
  • •condition — the branching step type.
  • •parallel — the fan-out step type.
  • •transform — the value-shaping step type.
  • •loop — repeat a body of sub-steps until a condition holds or a ceiling is reached. Carries steps, until and max_iterations.
service_call and crud_operation are the pair the working applications are built from, and the two shown below. Values interpolate from the run context: {{input.field}} reads a declared input, {{context.timestamp}} reads the execution context. This definition is the real one from a clinical-trial application.
json
{
  "name": "adverse_event_alert",
  "parent_type": "tenant",
  "workflow_id": "adverse_event_alert",
  "display_name": "Adverse Event Alert",
  "description": "Notify staff when a severe adverse event is logged",
  "version": "1.0.0",
  "enabled": true,
  "status": "Active",
  "on_error": "continue",

  "input_schema": {
    "event_uuid":  { "type": "string", "required": true },
    "event_name":  { "type": "string", "required": true },
    "severity":    { "type": "string", "required": true }
  },

  "steps": [
    {
      "id": "send_in_app_notification",
      "type": "service_call",
      "service": "notification",
      "operation": "send_notification",
      "input_map": {
        "title": "Severe Adverse Event: {{input.event_name}}",
        "body": "A new adverse event with severity {{input.severity}} has been reported."
      },
      "on_error": "continue"
    },
    {
      "id": "mark_processed",
      "type": "crud_operation",
      "operation": "update",
      "object_type": "trialhub:adverse_event",
      "record_uuid": "{{input.event_uuid}}",
      "data": {
        "workflow_status": "processed",
        "processed_at": "{{context.timestamp}}"
      }
    }
  ]
}
You create it as a record: POST to the domain's CRUD surface with type wf:workflow_definition. There is no registry to publish to and no deployment step between writing a definition and running it.

How It Works

A workflow reaches execution by one of three routes, named by trigger_source: api when something calls it, crud_event when a data change fires it, scheduled when a clock does.
The crud_event route is the interesting one. An event binding attaches a workflow to a CRUD event with the syntax @<verb>:<namespace>:<schema> — verb, namespace and schema name, colon-separated after an @. The binding's input_map maps fields on the record that fired onto the workflow's declared inputs: the key is the input name, the value is the field on the record.
json
// event_bindings.json — shipped with the application bundle

[
  {
    "event": "@create:ironworks:class_booking",
    "workflow_id": "notify_on_booking",
    "input_map": {
      "booking_uuid": "uuid",
      "start_time":   "start_time"
    }
  },
  {
    "event": "@create:ironworks:order",
    "workflow_id": "notify_on_order",
    "input_map": {
      "order_uuid": "uuid",
      "order_name": "name"
    }
  }
]
Read the first entry as: when a class_booking is created in the ironworks namespace, run notify_on_booking, passing the record's uuid as booking_uuid and its start_time as start_time. The workflow never learns a CRUD event caused it — it sees only its declared inputs.
  1. 1

    Define

    Create the wf:workflow_definition record, with status Active and enabled true when you want it reachable.

  2. 2

    Bind, or do not

    Add an entry to event_bindings.json to fire it on a CRUD event. A workflow with no binding is still usable — it just has to be called.

  3. 3

    Fire it directly when you need to

    POST /api/v1/services/execute with service_id "workflows", operation "execute_workflow", and an input carrying the workflow_id. This works under an API key alone — which is what makes it reachable from a scheduled job or an external caller.

  4. 4

    Observe the run

    workflow_instance captures the run and workflow_step_log captures each step. Both are ordinary CRUD types in the wf namespace, queried with list-bulk like anything else.

json
POST /api/v1/services/execute

{
  "service_id": "workflows",
  "operation": "execute_workflow",
  "domain": "acme",
  "project_uuid": "9f3c1b6a-2e5d-4f81-b0a7-3c5e9d1f2a44",
  "input": {
    "workflow_id": "adverse_event_alert",
    "trigger_record_uuid": "3d7c9e11-5a24-4b8f-9e60-1c2a7b4d8e33"
  }
}

💡 The step log is the evidence

A workflow_step_log row exists only because a step executed and wrote its outcome back through CRUD. When a workflow "did not run", query wf:workflow_step_log before touching the definition: no rows means it never started, failed rows mean it started and something downstream broke.

What You Get

  • •A definition that is data. Version, list, update, disable or archive it through the ordinary CRUD API, with no deployment and no restart.
  • •Per-step evidence. workflow_instance records the run and workflow_step_log records each step, so "did it fire?" is a query, not a log-scraping exercise.
  • •Error policy at two levels: a workflow-wide on_error overridden per step, choosing stop, continue, retry or compensate, with max_retries, retry_delay_seconds and timeout_seconds.
  • •Steps that cross the service boundary. A service_call reaches any imported integration; a crud_operation writes back through platform CRUD under the same authentication.
  • •Value interpolation from the run context — {{input.event_name}}, {{context.timestamp}} — with no code between the binding and the step.
  • •Event binding as configuration: a JSON entry, not a trigger you write and register.
  • •Execution under an API key. The whole chain — execute, service_call into a plugin, crud_operation back into the platform — works with an X-API-Key and no user session.
  • •Three trigger sources for one definition: api, crud_event and scheduled.

What You Decide

⚠️ A workflow is an executor, not a guarantee

The engine runs the steps you wrote, in the order you wrote them, and logs what happened. It does not take locks, enforce invariants across concurrent runs, or know that two steps were meant to be atomic. Anywhere your design depends on "this cannot happen twice" or "these must both succeed or neither", that property is yours to build — the workflow is where you build it, not something it hands you.
Three cases come up often enough to name, each a place where teams assume the platform is holding something it is not.
  • •Renewal. There is no automatic recurring billing and no dunning loop. If a subscription renews, you define renewal as a workflow — scheduled trigger, billing-date check, service_call to a payment integration — and you own the failure path when a charge declines. See Subscriptions & Memberships.
  • •Capacity and contention. Capacity is a field to check against, not a lock the platform takes, and double-booking prevention is not guaranteed. A condition step can read a counter and refuse, but two bookings arriving together can both read the same value and both pass.
  • •Idempotency. on_error: retry re-runs a step. If that step charges a card or sends an email, making it safe to run twice is your responsibility — the engine does not deduplicate.
Beyond those: what a partially completed run means is a design decision, not a default. on_error: continue lets later steps proceed after a failure, which is right for a notification and wrong for a payment.