Authorize

#Process Models

A process is the set of steps an item goes through: a supplier is onboarded when its documents are in, it has passed screening and someone has approved it. You describe the steps once, in a process definition; a tenant activates the definition by name; people move the steps from an app page and workflows move them from outside signals. Every move is judged by the platform (is it declared, is it ready, is its evidence there, may this caller make it) and written to a log that says who moved what, when, and on what evidence.

This guide builds one process end to end against the API: a supplier onboarding process with three steps, moved by a workflow and by two app users, and read back from the API and from a rendered app page. Every request below was run, and every response is the platform's answer (trimmed).

Before you start, read Entities & Data Model, Workflows and Apps (Bridge). A process is made of their parts.

#How a process is put together

Concept What it is
Anchor The item the process runs on: an entity of the type the definition names in anchor (here, a Supplier).
Step One entry of emits. On each anchor, a step is a row of an entity type you own (here, OnboardingStep), linked to its anchor. The row holds the step's state in its attributes.
Preset The state machine a step follows: milestone or alert. A preset fixes the states (rungs) and the transitions between them; a step declares the transitions it uses.
Move One transition of one step on one anchor, made by a person or a producer. A move that passes its gates commits; a person's move that does not is refused; an automated move that does not is held.
Origin Who makes a move: manual (a person, from an app), detection, linked, poller or backfill (a workflow).
Edge A named one-hop path over a relationship rule, from the step row (or the anchor) to related rows. Edges link a step to its anchor and to its evidence.
Effect Work a move runs before it commits (before), in the mover's request, as the mover.
Activation A tenant's use of a definition, by name: which version, its config (and the credentials effects use), its job bindings, its override, and whether it is enabled.

The steps of a process never move on their own: something makes each move, and the platform decides whether it lands.

#1. Model the data

The example has three entity types. Supplier is the anchor. OnboardingStep holds the steps. SupplierDocument is the evidence a step waits on.

A step type has one rule: its identifier key is exactly the step's key attribute, untransformed. Every step row carries a unique key (here <supplier id>:documents), and the platform finds a step's row by it. The step type's schema may declare the other attributes the platform writes (state, source, state_at, occurrence); give them no required beyond the key.

POST/schema/schemasRegister the step schemaAPI docs ↗Try it

The two other schemas are supplier_attributes (name and status, both required) and supplier_document_attributes (title required, and a supplier_id).

POST/entity/entity-typesCreate the step typeAPI docs ↗Try it

Supplier and SupplierDocument are created the same way, with "config": {}. Then two relationship rules: one from the anchor type to the step type (the step's link to its supplier), and one from the step type to the evidence type.

POST/entity/relationship-rulesLink suppliers to their stepsAPI docs ↗Try it
POST/entity/relationship-rulesLink steps to their evidenceAPI docs ↗Try it

#2. Write the definition

A definition is authored in camelCase, as one JSON object, and published as the definition_json of POST /definition/processes. Publish one version: the body is checked as the runtime will run it, against your catalog (every type, edge and rule must resolve, every expression may read only what its position can see), and every refusal comes back at once.

POST/definition/processesPublish a process definitionAPI docs ↗Try it

What it says:

  • documents waits for a signed document. A person can mark it received, but only with a document linked (until); a workflow can mark it received from a documents_received signal, linking the document the signal names. When the document is missing, a person can release the workflow's held move by hand (hold.manual).
  • screening is moved only by a workflow (by: ["detection"]), after documents is done (dependsOn).
  • review follows screening. A person starts it, approves it (which runs mark_supplier_approved first), may undo, and may start a new review later (restart, a new occurrence).
  • The process applies only to suppliers whose status is not blocked (condition).

#Top-level keys

Key Required Meaning
key ✓ The process's name: letters, digits and _ (no -, no __). It must equal the name you publish under; activations and moves name the process by it.
label ✓ The process's display name.
anchor ✓ The anchor's entity type, by name.
condition A filter over the anchor (the platform filter grammar): the process applies only where it holds.
policy ✓ { name, grants }, the shape of a workflow definition's policy.
config The slots an activation fills: a JSON schema object (type: "object", properties, required). Read as $config.<slot>. A slot with "writeOnly": true holds a credential.
auth Named HTTP auth profiles an effect's http action names ("auth": "<profile>"). A profile reads {{ config.<slot> }} only.
edges Named one-hop paths (below).
fields The inputs transitions take (below).
effects Named effects that transitions run (below).
emits ✓ The steps, keyed by name (at least one).
actions Non-transition controls: { label, href?, form?, writes? }.

A definition carries words only (label, cta, rung labels, field and evidence labels). How a step is shown (order, icons, confirmations, which surface a control appears on) belongs to the app's presentation (see Show the process on an app page).

#Presets, rungs and transitions

A step's preset is its state machine. It declares only the transitions it uses; a transition it does not declare does not exist on that step.

milestone rungs: READY → IN_PROGRESS (only when initiate or restart is declared) → PENDING_APPROVAL (only when approve is declared) → DONE.

Transition From To Waits on dependsOn Default label
initiate READY IN_PROGRESS yes Start
complete READY, IN_PROGRESS PENDING_APPROVAL when approve is declared, else DONE yes Complete
approve PENDING_APPROVAL DONE yes Approve
restart DONE, latest only IN_PROGRESS on a new occurrence yes Start again
undo any rung above READY mode: "one" (default): the rung before the undone move; mode: "clear": READY no Undo

alert rungs: OPEN → ACKNOWLEDGED → RESOLVED.

Transition From To Waits on dependsOn Default label
acknowledge OPEN ACKNOWLEDGED yes Acknowledge
resolve OPEN, ACKNOWLEDGED RESOLVED no Resolve
reopen RESOLVED OPEN, on the same row yes Reopen
raise RESOLVED, latest OPEN on a new occurrence yes —

Publish refuses an approve without complete, both reopen and raise on one step, and a restart or raise whose key does not template the occurrence. A person is offered complete from READY only when the step declares no initiate; a workflow keeps every from rung the table lists.

Each rung shows a word: the presentation's, else the step's labels, else the rung's own name in words (Ready, In progress, Pending approval, Done, Open, Acknowledged, Resolved). An undo from PENDING_APPROVAL is labelled Send back.

#Steps (emits entries)

Key Required Meaning
preset ✓ milestone or alert.
type ✓ The step row's entity type, by name. Its identifier key is exactly the key role attribute, with transform: "none".
anchor ✓ The edge from the step row to its anchor. A step row the platform creates is linked through it.
key ✓ A {{ }} template for the row's identity. A step that opens occurrences templates {{ $row.attributes.occurrence }}.
fields Which attribute plays each role, only where it differs from the default: key → key, state → state, source → source, stateAt → state_at, occurrence → occurrence, occurrenceKey → occurrence_key.
labels Words per rung, e.g. { "READY": "Waiting" }.
attributes Other attributes written when the platform creates the row (never a role attribute).
dependsOn [{ key, state?, whenInapplicable?, condition? }]: the steps (<emit>, or <process>.<emit> in another process) that must be done before this step's forward transitions.
triggers The signals your workflows act on: detection: [{ signal, transition, from? }], linked: [{ edge, where, transition, from? }], poll: [{ on, transition, when?, create? }]. Publish checks them; workflows run them.
transitions ✓ The preset transitions this step declares (below).

The platform writes the role attributes on every move: the step's state, its source (manual for a person, detected for a detection move, data for a linked or poller move, a backfill's own source), when it reached the state, and its occurrence number. Never write them yourself.

#Transitions

Key Meaning
by The origins that may make it. Default ["manual"]: a person only. A person's moves are exactly the transitions whose by includes manual.
label, cta The control's word, and true for the step's call to action.
mode undo only: one or clear.
enabledWhen A filter over the step row ({ id, attributes, edges }): the transition is offered only where it holds.
inputs, bind The fields a person fills in; bind.<origin> fills inputs for an automated origin, usually from $payload.
set Attributes the move writes on the step row (smart values over $inputs, $row, $anchor, …).
links [{ edge, to }]: rows the move links through a declared edge. A detection transition must link the row its signal names ($payload.<name>).
until [{ edge, label, where?, min? }]: evidence. The move needs at least min (default 1, at most 25) rows linked through the edge, counting the move's own links.
hold { "manual": true }: a held move may also be released by a person, by hand.
before Effects run in the mover's request, as the mover, before the move commits. A list, or { "<origin>": [...], "default": [...] }.
compensate Effects run when this move is undone.
machineContinues Origins that may move a step past a person's earlier decision.

after, onSuccess, onFailure, hold.after and an await effect describe durable work that runs after a move commits. Durable effects are not supported: publish refuses each with DURABLE_EFFECTS_UNSUPPORTED, so a hold is released by data or by hand, never by time.

#Edges and conditions

An edge is { via, from?, where?, sort?, limit? }: via names a relationship rule, and the direction follows from the rule's two types. from is the root: row (the step row, the default), anchor, another edge, or { "value": <smart value> }. Conditions (condition, enabledWhen, where, item when) use the platform filter grammar and reach relationships only through edges: { "operator": "rel_exists", "path": ["edges", "documents"] }.

#Fields

A field is { label, type, required?, max?, options?, dependsOn?, accept?, default? } with type one of text, textarea, date, select, multiselect, upload. max is required on text, textarea and multiselect; options belongs to select and multiselect (a literal list, { query }, { edge } or { enum }); accept belongs to upload. A transition names the fields it takes in inputs, and a move's inputs are checked against them (INPUT_INVALID).

#Effects

An effect declares exactly one of:

  • actions — an inline list of actions, as a workflow's inline job step, with an optional output;
  • kind — a job, bound to the slot effect_<kind> by the definition's default-job-bindings or by the activation's job_bindings (see Job Bindings).

Its input is a record of smart values over the move's scopes, and the actions read it as input.*:

Scope What
$anchor The anchor row (id, attributes).
$row The step row as the move will leave it.
$inputs The move's inputs.
$payload A detection or linked move's signal.
$edges.<edge> A declared edge's rows.
$effects.<name>.output An earlier effect's output in the same move.
$config.<slot> The activation's config slot.
$itemKey A key stable across retries of the same move from the same place, for idempotent effects.

onError is abort (the default) or continue. An effect runs as whoever makes the move, under their own permissions: a move whose effect writes what the mover may not write fails. A failed effect fails the move: the step keeps its rung, shows the Failed badge, and the next press of the same control performs it again.

Credentials come from the activation only. A definition holds no secret reference (publish refuses one with SECRET_REF_IN_DEFINITION), and a job bound to an effect slot pins none (SECRET_REF_IN_EFFECT_JOB). An effect reaches a credential through an auth profile that reads a writeOnly config slot, which the activation fills with a secret reference (Activate it).

#Occurrences

restart (milestone) and raise (alert) open a new occurrence: a new step row with the next occurrence number, whose key templates {{ $row.attributes.occurrence }}. A move may name its occurrence with occurrenceKey (for example a port name), stored in the occurrenceKey role attribute. The app's step view shows the latest occurrence; the status read lists every occurrence.

#3. Publish the definition

The request in step 2 answers 201, with the stored definition under data and the lints it published with under warnings. The stored body has its defaults filled in (an edge's from: "row", an effect's onError: "abort", a dependency's whenInapplicable: "satisfied"):

{
  "data": {
    "id": "b259f319-10de-4030-a072-7e7376105348",
    "name": "supplier_onboarding",
    "version": "1.0.0",
    "status": "active",
    "label": "Supplier onboarding",
    "description": null,
    "definition_json": {
      "key": "supplier_onboarding",
      "edges": {
        "supplier": { "via": "supplier_onboarding_steps", "from": "row" },
        "documents": { "via": "onboarding_step_documents", "from": "row" }
      },
      "…": "…"
    },
    "source_type": "owned"
  },
  "warnings": []
}

A body that does not publish answers 422 with every refusal at its path. This one adds an after list, a time release and a step type that does not exist:

{
  "error": {
    "status": 422,
    "code": "VALIDATION_FAILED",
    "message": "The process definition does not publish.",
    "details": {
      "findings": [
        {
          "path": "emits.documents.transitions.complete.hold.after",
          "code": "DURABLE_EFFECTS_UNSUPPORTED",
          "message": "a time release is durable: durable effects are not supported, a hold releases by data or by hand"
        },
        {
          "path": "emits.screening.type",
          "code": "NAME_UNRESOLVED",
          "message": "ScreeningStep does not resolve"
        },
        {
          "path": "emits.review.transitions.complete.after",
          "code": "DURABLE_EFFECTS_UNSUPPORTED",
          "message": "after is durable: durable effects are not supported, only before runs"
        }
      ]
    }
  }
}

A body that does not parse answers 422 with details.issues instead. The finding codes are INVALID_BODY, KEY_NOT_NAME, NAME_UNRESOLVED, NAME_AMBIGUOUS, IDENTITY_KEY, EDGE_UNDECLARED, EDGE_ROOT, EDGE_DEPTH, FIELD_UNDECLARED, FIELD_RULE, SWITCH_COVERAGE, EFFECT_UNDECLARED, EFFECT_POSITION, CHAIN_READ, SCOPE, EXPRESSION_INVALID, IDENTITY_GATE, ROLE_WRITE, TRIGGER_FROM, SECRET_REF_IN_DEFINITION, INLINE_ASYNC_ACTION, DURABLE_EFFECTS_UNSUPPORTED, DETECTION_EVIDENCE, CONFIG_UNDECLARED, CONFIG_SECRET_READ, AUTH_PROFILE_UNDECLARED, GATE_OPERAND_TEMPLATE, GATE_CONFIG_OPERAND_UNSUPPORTED and ACTION_GATE_UNSUPPORTED. Publishing a name and version that already exist answers 409 (CONFLICT).

The other definition routes:

Route Does
GET /definition/processes The versions you can read, your own and those shared with you (?name=, ?status_in=, ?label=, …).
GET /definition/processes/{id} One version.
GET /definition/processes/{id}/definition The body alone.
PUT /definition/processes/{id}/definition Overwrite the body of a version you own, judged as a publish is.
PUT /definition/processes/{id} Change its label, description, status or body.
PUT/PATCH /definition/processes/{id}/default-job-bindings The definition's own effect_<kind> bindings.

A definition is shared with another tenant through a manifest like any other definition (see Share with Another Tenant); the other tenant activates it by name.

#4. Activate it in your tenant

A process runs in a tenant only once the tenant activates it, by name: PUT /workflows/processes/{name}/activation. One activation per name binds one version (name@version, your own definition over a shared one) and fills the config slots the definition declares.

Without the required config slot, nothing is written:

{
  "error": {
    "status": 422,
    "code": "VALIDATION_FAILED",
    "message": "The activation's config does not fit the process's config slots.",
    "details": {
      "code": "CONFIG_INVALID",
      "findings": [
        {
          "path": "approved_status",
          "code": "CONFIG_REQUIRED",
          "message": "\"approved_status\" is a required config slot"
        }
      ]
    }
  }
}
PUT/workflows/processes/{name}/activationActivate supplier_onboardingAPI docs ↗Try it
{
  "data": {
    "id": "71422330-db89-43d7-a788-8ebe90f455ed",
    "tenant_id": "645347e8-1f7a-429b-819c-bdd327b43f62",
    "name": "supplier_onboarding",
    "definition_id": "b259f319-10de-4030-a072-7e7376105348",
    "job_bindings": {},
    "overrides": {},
    "config": { "approved_status": "approved" },
    "is_enabled": true,
    "created_at": "2026-10-06T01:00:01.96672+00:00",
    "updated_at": "2026-10-06T01:00:01.96672+00:00"
  }
}

GET /workflows/processes/supplier_onboarding/activation answers the same object. The request's fields:

Field Meaning
definition Required. name@version of an active or deprecated version you can read.
config A value for each slot the definition declares, checked against the slot's schema; every required slot set. A slot whose value differs by origin is an object keyed by origin, with an entry for every origin a transition admits.
job_bindings effect_<kind> → job id or name@version, for kind effects. PATCH …/activation/job-bindings changes single slots.
overrides Your tenant's adjustments: a condition used in place of the definition's, extra fields, per step a dependsOn used in place of the step's, per transition an enabledWhen combined with the definition's and extra inputs, and extra alert steps under addEmits.
is_enabled false turns the process off in your tenant: every move is refused BINDING_DISABLED.

A field left out keeps its stored value. An activation is judged whole before anything is written (422 with details.findings or details.reasons); a request that changes nothing writes nothing; one that only turns the activation off is written without being judged. Writing an activation takes the workflow_config capability in your tenant (403 otherwise).

#Credentials for effects

An effect that calls an external API takes its credential from the activation. The definition declares a writeOnly slot and an auth profile that reads it; an effect's http action names the profile. This definition (published with POST /definition/processes as supplier_registry, version 1.0.0) registers a supplier with an external registry:

{
  "key": "supplier_registry",
  "label": "Supplier registry",
  "anchor": "Supplier",
  "policy": {
    "name": "supplier_registry",
    "grants": [
      {
        "resourceType": "entity",
        "resourceName": "OnboardingStep",
        "actions": ["create", "read", "update"]
      }
    ]
  },
  "config": {
    "type": "object",
    "properties": {
      "registry_url": { "type": "string", "format": "uri" },
      "registry_token": { "type": "string", "writeOnly": true }
    },
    "required": ["registry_url", "registry_token"]
  },
  "auth": {
    "registry": { "type": "bearer", "token": "{{ config.registry_token }}" }
  },
  "edges": { "supplier": { "via": "supplier_onboarding_steps" } },
  "effects": {
    "register_supplier": {
      "actions": [{
        "stepId": "post",
        "name": "http",
        "config": {
          "url": "{{ input.url }}/suppliers",
          "method": "POST",
          "auth": "registry",
          "body": { "supplier": "{{ input.supplier }}" }
        }
      }],
      "input": {
        "url": { "$jsonata": "$config.registry_url" },
        "supplier": { "$jsonata": "$anchor.id" }
      }
    }
  },
  "emits": {
    "registry": {
      "preset": "milestone",
      "type": "OnboardingStep",
      "anchor": "supplier",
      "key": "{{ $anchor.id }}:registry",
      "transitions": {
        "complete": { "label": "Register", "before": ["register_supplier"] }
      }
    }
  }
}

The activation fills the secret slot with a scope-explicit secret reference, never a value:

PUT/workflows/processes/{name}/activationActivate with a secret referenceAPI docs ↗Try it

A plain value in a secret slot is refused:

{
  "error": {
    "status": 422,
    "code": "VALIDATION_FAILED",
    "message": "The activation's config does not fit the process's config slots.",
    "details": {
      "code": "CONFIG_INVALID",
      "findings": [
        {
          "path": "registry_token",
          "code": "CONFIG_SECRET_REF",
          "message": "a secret slot holds one scope-explicit reference, e.g. {{ secrets.tenant.NAME }}"
        }
      ]
    }
  }
}

The reference resolves only inside the effect, for the move being made. A person's own (user) secret may sit only under the manual entry of an origin-keyed slot.

#5. Who makes a move

Who How Origin
A person, on an app page Presses a control the page drew from the process (no steps to author), or an authored command that runs performTransition, undoTransition or releaseHold. Always manual.
A producer: a workflow job Runs performTransition (one move) or performTransitions (up to 1,000), undoTransition or releaseHold. detection, linked, poller or backfill.

A person may make only the transitions whose by includes manual. A workflow names its origin on each move, with the trigger entry it fired ({ "kind": "detection" | "linked" | "poll", "index": <n> }) for detection, linked and poller, the signal it carries (detection and linked), and optionally stateAt, the time its evidence says the state was reached. A backfill move declares its own source and writes history: it skips readiness, enabledWhen, evidence and effects and is never held, but its transition must still be declared, admit backfill in by and be permitted. A workflow move that names no origin is made as detection. Every move is also checked against the mover's permissions on the step type: a first move creates the step row and a later one updates it, so a person needs create and update on the step type through the app's policy.

A move:

Field Meaning
target { "process", "emit", "anchorId" } (a step on an anchor, by name; a first move creates its row), or { "row": { "type", "id" } } (a step row).
transition The transition key.
inputs A person's inputs, by field name.
links A person's evidence: [{ "edge", "to" }], linked by the move itself, through an edge its transition waits on.
expectedUpdatedAt The step row's updated_at as you last read it; a row that changed since is refused STALE.
occurrenceKey The name of the occurrence a by-name move opens.
origin, trigger, signal, stateAt, source A workflow's move only (above).

#How a move is judged

In order: the process is activated and enabled for your tenant; the transition is declared and the origin is in its by; the process's condition holds on the anchor; the transition is legal from the step's rung; the steps it depends on are done; its enabledWhen holds; its evidence is linked; the mover may write the step. A person's move that fails a gate is refused and runs nothing. An automated move that fails readiness, enabledWhen or evidence is held: it is logged with the move's origin, signal and evidence time, and a step row that does not exist yet is created on its first rung.

A held move is released three ways:

  • by data, always: every later move that commits on the same anchor re-judges the anchor's held moves, and a held move whose gates are met commits, with the origin, signal and evidence time it was held with;
  • by hand, only when the transition declares "hold": { "manual": true }: a person's releaseHold, judged again (evidence still missing stays held);
  • any other release is refused RELEASE_NOT_DECLARED.

Writing a relationship or an attribute directly releases nothing; the next committed move on the anchor, or a hand release, does.

#6. Move it from a workflow

A workflow that moves whatever its trigger names:

POST/definition/workflowsA workflow that moves process stepsAPI docs ↗Try it

Activate it (POST /workflows/{id}/activate). The supplier Northwind Metals (cb273df2-…) has nothing linked yet, and Southgate Plastics (71edee6d-…) has status: "blocked". The screening service reports a pass for Northwind before its documents are in, and the same run carries three moves the platform refuses:

POST/workflows/wrDispatch four movesAPI docs ↗Try it

The run succeeds, and the performTransitions action's output (read from GET /workflows/runs/actions?workflow_run_id=…&job_run_id=…) answers each move in order:

{
  "outcomes": [
    {
      "index": 0,
      "outcome": "held",
      "code": null,
      "message": null,
      "details": null,
      "result": {
        "status": "held",
        "transition_id": "d2deca15-3a41-5900-bf4d-0cbcbe0f349b",
        "entity_id": "0443acf0-d998-40d9-9d1e-f8e3a3223bb7",
        "updated_at": "2026-10-06T01:00:20.717804+00:00",
        "from_state": null,
        "to_state": "DONE",
        "links": ["13c66c29-e3ae-4a55-a127-3e0f41855d16"],
        "lane_dedup_key": null,
        "dispatched": false,
        "replayed": false,
        "reason": "NOT_READY",
        "effects": {}
      }
    },
    {
      "index": 1,
      "outcome": "illegal",
      "code": "ORIGIN_NOT_ADMITTED",
      "message": "complete is not made by detection",
      "details": null,
      "result": null
    },
    {
      "index": 2,
      "outcome": "illegal",
      "code": "TRANSITION_NOT_DECLARED",
      "message": "documents declares no approve",
      "details": { "reason": "transition_not_declared" },
      "result": null
    },
    {
      "index": 3,
      "outcome": "illegal",
      "code": "NOT_APPLICABLE",
      "message": "supplier_onboarding does not apply to this anchor",
      "details": null,
      "result": null
    }
  ]
}
  • 0 is held: screening waits on documents (reason: "NOT_READY"). Its step row was created on READY and linked to the supplier (the one id in links); the report is linked when the hold is released.
  • 1 — review admits only a person.
  • 2 — documents declares no approve.
  • 3 — the process does not apply to a blocked supplier.

outcome is committed, held, conflict, refused or illegal; code is set on every outcome but committed and held. performTransitions reports each move and does not fail; performTransition (one move) fails its step with the refusal.

#7. Show the process on an app page

An app page reads the process with two sources and draws it with the generic table and actions widgets: there is no process widget. The spec below names nothing of the supplier model in its pages; the activation's config does (see Apps (Bridge) → Process pages). list shows every supplier with its three steps; detail shows one supplier's steps with their controls, its documents (each can be attached as evidence), and the history of one step (each held move can be released by hand).

POST/definition/specsThe onboarding appAPI docs ↗Try it

The activation's config names the process, the columns and the presentation:

PUT/apps/{specId}/configActivate the app with its process configurationAPI docs ↗Try it

Then, as in Apps (Bridge) steps 4–6: register the provider organisation (portal, northwind-buyers), create an integration and mint its app token. Two buyers open the app, ana and ben. ben only looks: read his auth_user_id from GET /access/external-users after his first visit and give him the read-only policy.

PUT/apps/{specId}/users/{authUserId}/policyMake ben a viewerAPI docs ↗Try it

#What the page reads

$query with process folds each row's steps onto it. With "rows": "anchor", a supplier row gets steps.<process>.<emit>.{status, actions} for each step it can read ("rows": "step" gives a step row its own {status, actions}). can folds can.<action> onto every row: whether the viewer may write that row, answered by the same read (display only: every write is judged again). A process the tenant has not activated folds nothing, and the answer carries a warning naming it:

{
  "id": "process-unresolved:vendor_audit",
  "level": "WARNING",
  "message": "Rows show no steps for a process that isn't available here: vendor_audit."
}

$job with describeProcessActions returns the same envelopes for the anchors (or step rows) you name: { unresolved, anchors: [{ anchorId, steps }] }. Here is ana's view of Northwind's review step once it is in progress (trimmed):

{
  "process": "supplier_onboarding",
  "emit": "review",
  "type": "OnboardingStep",
  "entityId": "2e189c3f-937e-4348-832d-265c7a369c06",
  "occurrence": 2,
  "label": "Supplier onboarding",
  "category": null,
  "priority": null,
  "status": {
    "state": "IN_PROGRESS",
    "label": "In progress",
    "tone": "active",
    "badges": [],
    "stateAt": "2026-10-06T01:00:41.425Z"
  },
  "actions": [
    {
      "actionId": "supplier_onboarding:review:2e189c3f-937e-4348-832d-265c7a369c06:complete",
      "transition": "complete",
      "label": "Approve",
      "icon": null,
      "variant": null,
      "cta": true,
      "surfaces": null,
      "enabled": true,
      "reason": null,
      "details": null,
      "confirm": "Approve this supplier?",
      "intent": {
        "kind": "process.perform",
        "target": {
          "row": {
            "type": "OnboardingStep",
            "id": "2e189c3f-937e-4348-832d-265c7a369c06"
          }
        },
        "transition": "complete",
        "inputs": {},
        "expectedUpdatedAt": "2026-10-06T01:00:41.476635+00:00"
      }
    }
  ]
}

ben reads the same step with "enabled": false and "reason": { "code": "PERMISSION_DENIED", "label": "Not permitted" } on each control.

  • The step envelope. label, category and priority are the process's, shared by every step of the process; tell steps apart by emit. status is the status view: the rung, its word, its tone (idle, active, waiting, done), its badge words (Held, Running, Failed, Blocked) and when the step reached the rung. A step the viewer cannot read is left out (and still counts for the steps that depend on it). An anchor the process does not apply to has no steps.
  • The descriptor. Each control is a decision made for this viewer: its words, cta, confirm, surfaces (null is everywhere), and either "enabled": true or a reason with the refusal code and its word.
  • The intent. What a press asks for, as data: process.perform, process.undo (with the step's expectedUpdatedAt), process.release (a held move whose transition declares hold.manual, with its transitionId) or navigate (a transition that takes inputs and has a presentation form page). The engine maps the intent to one action; you author no steps for it.

Presentation (priority, category, surfaces, hidden, rung labels and tone, badges, reasons, per transition label, icon, variant, form, confirm, hidden, per field help, per evidence edge supply) is keyed by process, then by step, transition and field. It changes words and placement only, never what a viewer may do.

#8. Move it from the page

Every call below is POST /apps/run/{integrationId} with the app token as Authorization: Bearer <app token>. An interaction sends the state of the previous answer and the action id of what was pressed (see Apps (Bridge) → Interactions).

POST/apps/run/{integrationId}ana opens the appAPI docs ↗Try it

The list table, before anything has moved (trimmed to its rows):

[
  {
    "id": ":p/list/items/71edee6d-583d-4507-aaa0-220c50591dc8:",
    "name": "Southgate Plastics",
    "steps.supplier_onboarding.documents.status": "",
    "steps.supplier_onboarding.screening.status": "",
    "steps.supplier_onboarding.review.status": "",
    "editable": "Yes"
  },
  {
    "id": ":p/list/items/cb273df2-d96c-4c11-b682-e7e87054680d:",
    "name": "Northwind Metals",
    "steps.supplier_onboarding.documents.status": "Waiting",
    "steps.supplier_onboarding.screening.status": "Ready · Blocked",
    "steps.supplier_onboarding.review.status": "Ready · Blocked",
    "editable": "Yes"
  }
]

The blocked supplier has no steps. ana opens Northwind (the row menu's Open, ":p/list/items/actions/0/menu/0:-cb273df2-d96c-4c11-b682-e7e87054680d"). Its checklist draws one row menu of the steps' controls, each enabled only on the rows whose control is enabled; review offers Start review but not Approve, since a person approves only from IN_PROGRESS, and screening offers nothing, since only a workflow moves it:

{
  "type": "table",
  "id": ":p/detail/checklist/table:",
  "columnDefinitions": [
    { "type": "string", "accessor": "emit", "header": "Step" },
    { "type": "string", "accessor": "status", "header": "Status" },
    {
      "type": "actions",
      "actions": [{
        "options": [
          {
            "label": "Mark received",
            "action": "custom",
            "id": ":p/detail/checklist/fromRow/0/menu/complete-Mark%20received:",
            "accessor": "__action_complete-Mark%20received"
          },
          {
            "label": "Start review",
            "action": "custom",
            "id": ":p/detail/checklist/fromRow/0/menu/initiate-Start%20review:",
            "accessor": "__action_initiate-Start%20review"
          }
        ]
      }]
    }
  ],
  "data": [
    {
      "id": ":p/detail/checklist/0:",
      "emit": "documents",
      "status": "Waiting",
      "__action_complete-Mark%20received": false,
      "__action_initiate-Start%20review": false
    },
    {
      "id": ":p/detail/checklist/1:",
      "emit": "review",
      "status": "Ready · Blocked",
      "__action_complete-Mark%20received": false,
      "__action_initiate-Start%20review": false
    },
    {
      "id": ":p/detail/checklist/2:",
      "emit": "screening",
      "status": "Ready · Blocked",
      "__action_complete-Mark%20received": false,
      "__action_initiate-Start%20review": false
    }
  ]
}

#A refused press

Mark received is disabled because no document is linked. Pressing it anyway runs nothing; the answer names the refusal:

POST/apps/run/{integrationId}ana presses Mark receivedAPI docs ↗Try it
{
  "notifications": [
    {
      "id": "press-refused-fromRow/0/menu/complete-Mark%20received",
      "level": "ERROR",
      "message": "Evidence required",
      "details": { "refusal_code": "EVIDENCE_REQUIRED" }
    }
  ]
}

Start review (row 1) answers "message": "Not ready", "refusal_code": "NOT_READY": screening is not done. An authored command whose action is refused fails its step instead; when ben presses Attach as evidence on the signed agreement (":p/detail/documents/actions/0/menu/0:-0fc6906c-fa04-4dd4-b652-a32c48b54e04"):

{
  "notifications": [
    {
      "id": "step-error-9f127f24-0b35-4e23-a20d-63b7f3f921a5",
      "level": "ERROR",
      "message": "Not permitted",
      "details": {
        "error_code": "UNPROCESSABLE_ENTITY",
        "refusal_code": "PERMISSION_DENIED"
      }
    }
  ]
}

Nothing is written and no effect runs.

#A release that is not declared

After the workflow run, Northwind's screening step reads Ready · Held, and its history lists the held move (d2deca15-…, status held, by Onboarding signals). screening declares no hold.manual, so ana's Release (":p/detail/history/actions/0/menu/0:-d2deca15-3a41-5900-bf4d-0cbcbe0f349b") is refused:

{
  "notifications": [
    {
      "id": "step-error-b679fe30-d249-488d-b9c1-49ef9c2397b8",
      "level": "ERROR",
      "message": "Cannot release",
      "details": {
        "error_code": "UNPROCESSABLE_ENTITY",
        "refusal_code": "RELEASE_NOT_DECLARED"
      }
    }
  ]
}

#Evidence, and a release by data

ana attaches the signed agreement (":p/detail/documents/actions/0/menu/0:-0fc6906c-fa04-4dd4-b652-a32c48b54e04"). The command runs performTransition on documents with "links": [{ "edge": "documents", "to": "0fc6906c-…" }]: the move links the document itself, counts it toward until, and commits. Its commit re-judges the held screening move, which commits too. The answer re-renders the page:

[
  {
    "id": ":p/detail/checklist/0:",
    "emit": "documents",
    "status": "Received",
    "__action_initiate-Start%20review": false,
    "__action_undo-Undo": true
  },
  {
    "id": ":p/detail/checklist/1:",
    "emit": "review",
    "status": "Ready",
    "__action_initiate-Start%20review": true,
    "__action_undo-Undo": false
  },
  {
    "id": ":p/detail/checklist/2:",
    "emit": "screening",
    "status": "Done",
    "__action_initiate-Start%20review": false,
    "__action_undo-Undo": false
  }
]

and the screening history reads:

[
  {
    "id": ":p/detail/history/2d1586ea-85c9-4f0d-9d00-f62c5117b98e:",
    "move": "complete",
    "origin": "detection",
    "status": "done",
    "by": "ana@northwind.example",
    "at": "2026-10-06T01:00:29.401813+00:00"
  },
  {
    "id": ":p/detail/history/d2deca15-3a41-5900-bf4d-0cbcbe0f349b:",
    "move": "complete",
    "origin": "detection",
    "status": "released",
    "by": "Onboarding signals",
    "at": "2026-10-06T01:00:21.054949+00:00"
  }
]

The release is a detection move made by the session whose commit met the gate (ana), with the held move's signal and evidence time; the held move reads released.

#Approve, undo, review again

Start review (row 1) moves review to In progress, and the bar draws its call to action:

{
  "type": "button",
  "label": "Approve",
  "variant": "primary",
  "action": "custom",
  "id": ":p/detail/bar/action/supplier_onboarding%3Areview%3A0ba333dd-277f-437f-a71a-e8d9be7d76be%3Acomplete:"
}

Pressing it runs mark_supplier_approved as ana, then commits: review reads Done, and GET /entity/entities/cb273df2-… answers "attributes": { "name": "Northwind Metals", "status": "approved" } (the activation's approved_status). The checklist then offers Undo and Review again on review.

  • Undo (":p/detail/checklist/fromRow/0/menu/undo-Undo:-1") moves it back to In progress; the undone move reads undone in the log, with the undo's id in undone_by. An undo removes the links the undone move made (never the anchor link) and runs its transition's compensate effects.
  • Approve again: Done.
  • Review again (":p/detail/checklist/fromRow/0/menu/restart-Review%20again:-1") opens occurrence 2: a new step row (2e189c3f-…) on In progress. The checklist shows the new occurrence; the first stays Done.

#A disabled activation

With the activation turned off (PUT /workflows/processes/supplier_onboarding/activation with "is_enabled": false), every move is refused. ana's Approve answers "message": "Disabled", "refusal_code": "BINDING_DISABLED", and the bar draws Disabled as text; a workflow's move answers { "outcome": "illegal", "code": "BINDING_DISABLED", "message": "supplier_onboarding is not enabled here" }, and a move naming a process the tenant never activated answers { "outcome": "illegal", "code": "PROCESS_NOT_ACTIVATED", "details": { "reason": "not_activated" } }. Turn it back on with "is_enabled": true.

#A release by hand

For a second supplier, Eastbay Textiles, the mailbox workflow reports that documents arrived but names no document:

POST/workflows/wrDocuments arrived, no document namedAPI docs ↗Try it

The move is held ("outcome": "held", "reason": "EVIDENCE_REQUIRED"), and documents reads Waiting · Held. Because the transition declares hold.manual, its control (Mark received) is enabled and its intent is process.release. Pressed with nothing linked, the release is judged again and refused ("refusal_code": "EVIDENCE_REQUIRED"). Someone links the agreement directly:

POST/entity/relationshipsLink the evidence directlyAPI docs ↗Try it

The link releases nothing by itself: documents still reads Waiting · Held. ana presses Mark received again, and the release commits: Received.

#9. Read it back

Three read actions answer from the log and the step rows. They run in a workflow job, or on an app page as a $job source.

getProcessStatus ({ processes, anchorIds }) lists each step's state, every occurrence, whether it is satisfied, what blocks it and its latest move (trimmed to review):

{
  "unresolved": [],
  "anchors": [{
    "anchor_id": "cb273df2-d96c-4c11-b682-e7e87054680d",
    "steps": [
      {
        "process": "supplier_onboarding",
        "emit": "review",
        "entity_id": "2e189c3f-937e-4348-832d-265c7a369c06",
        "occurrence": 2,
        "state": "IN_PROGRESS",
        "satisfied": false,
        "blocked_by": [],
        "updated_at": "2026-10-06T01:00:41.476635+00:00",
        "latest": {
          "transition_id": "9f5d67ab-50e3-4930-9230-a467b267ca79",
          "transition": "restart",
          "status": "done",
          "reason": null
        }
      },
      {
        "process": "supplier_onboarding",
        "emit": "review",
        "entity_id": "0ba333dd-277f-437f-a71a-e8d9be7d76be",
        "occurrence": 1,
        "state": "DONE",
        "satisfied": false,
        "blocked_by": [],
        "updated_at": "2026-10-06T01:00:40.919965+00:00",
        "latest": {
          "transition_id": "dd3f4b47-a3e6-4ef7-8da1-5c04f455395b",
          "transition": "complete",
          "status": "done",
          "reason": null
        }
      }
    ]
  }]
}

listProcessTransitions ({ target: { row: { type, id } }, limit?, includePrincipals?, linkedTo? }) is a step row's log, newest first. With includePrincipals: true each move names who made it (actor: a user, a workflow with its run, or a token). The documents step's one move, trimmed:

{
  "transitions": [{
    "id": "4257995d-1bb2-4775-9756-819039460c57",
    "process_key": "supplier_onboarding",
    "emit_key": "documents",
    "definition_version": "1.0.0",
    "transition": "complete",
    "from_state": null,
    "to_state": "DONE",
    "creates": true,
    "origin": "manual",
    "source": "manual",
    "links": [
      {
        "edge": "supplier",
        "id": "01185642-a450-45fa-a983-82571686eebf",
        "from": "cb273df2-d96c-4c11-b682-e7e87054680d",
        "to": "c4f6518b-fd06-4acf-abd2-32b3d2a9be42"
      },
      {
        "edge": "documents",
        "id": "d1739299-b865-42ae-bc5b-1cefff601f04",
        "from": "c4f6518b-fd06-4acf-abd2-32b3d2a9be42",
        "to": "0fc6906c-fa04-4dd4-b652-a32c48b54e04"
      }
    ],
    "status": "done",
    "actor": {
      "kind": "user",
      "id": "6034d906-dc27-47fa-87da-a9ab8f8638eb",
      "label": "ana@northwind.example",
      "email": "ana@northwind.example",
      "external_id": "ana"
    }
  }]
}

A move's status is done, held, released (a hold that a later move committed, named by that move's release_of), undone (with undone_by), failed (an effect failed; reason says which and why) or superseded. Each log row also keeps the move's inputs, signal, effect outputs and state_at. linkedTo: [<row id>, …] answers, for each row, the newest move that linked it: who attached a piece of evidence.

describeProcessActions is the page read above; describeProcessForm ({ target, transition, presentation?, values? }) returns a transition's fields with their defaults and options read as the viewer, and its evidence entries.

#Refusal codes

A refusal carries its code in code (an action's outcome) or in details.refusal_code (an app notification), and its word in message / the descriptor's reason.label. These were returned in this guide:

Code Word When
EVIDENCE_REQUIRED Evidence required Fewer rows linked through an until edge than it needs. An automated move is held instead.
NOT_READY Not ready A step in dependsOn is not done. An automated move is held instead.
PERMISSION_DENIED Not permitted The mover may not write the step (checked before any effect runs).
ORIGIN_NOT_ADMITTED Not allowed The origin is not in the transition's by.
TRANSITION_NOT_DECLARED Not available The step declares no such transition.
TRANSITION_NOT_ALLOWED Not allowed The transition is not legal from the step's rung (complete is not legal from DONE).
TRIGGER_FROM Not allowed from here The move's trigger entry does not exist or does not fire this transition.
NOT_APPLICABLE Not applicable The process's condition is false for the anchor.
RELEASE_NOT_DECLARED Cannot release A release by hand of a transition without hold.manual.
BINDING_DISABLED Disabled The activation is turned off.
PROCESS_NOT_ACTIVATED Not activated The tenant has no activation of that name.
EFFECT_FAILED Failed A before effect failed; the step shows Failed and the move can be made again.

The other codes a move can carry: UNDECIDABLE (a gate could not be decided, so it does not admit), GUARD_DROPPED, INPUT_INVALID, BINDING_UNRESOLVED, NOT_HELD, NOT_RETRYABLE, RACE, TRANSITION_ID_TAKEN, ROW_IN_BATCH, ROW_NOT_FOUND, STALE, STATUS_INVALID, WRONG_STEP_TYPE, OCCURRENCE_MISMATCH, IDENTITY_MISSING, IDENTITY_TAKEN, LINK_EDGE, ANCHOR_NOT_FOUND, NO_SUCH_TRANSITION, NOT_REPEATABLE, REPEATABLE_IN_PLACE, UNDO_MISMATCH, TRANSITION_NOT_SETTLED, TRANSITION_NOT_HELD, FIELD_MISMATCH, LINK_ENDPOINT_NOT_FOUND, LINK_CARDINALITY, CHECK_IMAGE and LINK_PERMISSION_DENIED. A presentation may give any code its own word (emits.<emit>.reasons.<code>).