> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ilyama.golain.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Workflows, states and transitions

> Author a ticket workflow spec — labels, transitions, permits, CEL guards, validation rules, actions — then validate, version and activate it.

A **workflow** defines the states a ticket can be in and the transitions between them. You write it as a spec (JSON or YAML), check it with the validate endpoint, save it as a **version**, and **activate** that version so new tickets use it.

This page is the reference for the spec. For forms, see [Forms](/ticketing/forms). For `trigger_intake`, `due_policy` and transition actions in depth, see [Automation](/ticketing/automation). For every route, see [API reference](/ticketing/api-reference).

```bash theme={null}
export API=https://api.ilyama.golain.io/core/api/v1
export TOKEN=…          # OIDC access token (same as Console)
export ORG_ID=…         # Organization UUID
export PROJECT_ID=…
```

| Header | Value |
| - | - |
| `Authorization` | `Bearer $TOKEN` |
| `ORG-ID` | `$ORG_ID` |

| Operation | Permission |
| - | - |
| List, get, state labels | Project read access |
| `POST …/ticket-workflows/validate` | Project membership |
| Create, patch, delete, activate | Project permission **manage workflows** |

## Spec structure

| Field | Required | Description |
| - | - | - |
| `name` | Yes | Workflow name. It identifies the workflow definition in the Project — creating a spec with an existing `name` adds a new version to that definition. |
| `kind` | No | Workflow capability. `work_order` marks a CMMS work-order workflow. See [Work-order workflows](#work-order-workflows). |
| `initial` | Yes | Label key every new ticket starts in. |
| `labels` | Yes | States: `import` (Project catalog keys) and `extra` (labels declared in this spec). |
| `forms` | No | `import` (catalog forms `{ name, version }`) and `extra` (inline form specs). See [Forms](/ticketing/forms). |
| `validation` | No | `on_create` and `before_transition` rules. |
| `transitions` | Yes | Allowed edges with `permit`, `guards`, `on_enter` and `on_exit`. |
| `trigger_intake` | No | Events that open tickets automatically. See [Automation](/ticketing/automation). |
| `due_policy` | No | `lead_time` and `on_breach.to` timers driven by the ticket's `due_at`. See [Automation](/ticketing/automation). |

A spec missing `name` or `initial` cannot be parsed at all — the validate endpoint returns `400`, and create or patch fails. Every other problem shows up as a validation error (see [Graph validation](#graph-validation)). Run the spec through the validate endpoint before you save it.

## Labels

Each label is a state a ticket can be in. Declare labels in `labels.extra`:

| Field | Required | Description |
| - | - | - |
| `key` | Yes | Label key. Transitions, `initial` and `to_label` on the transition API use this key. |
| `class` | Yes | One of `open`, `in_progress`, `terminal_success`, `terminal_failed`, `cancelled` (case-insensitive). |
| `color` | No | Display color, for example `#10B981`. |
| `terminal` | No | `true` marks an end state. |

The label's `class` becomes the ticket's `system_class` when the ticket enters that label. Use `system_class` to filter and report across workflows that use different label keys.

| `class` | Meaning | Ticket `closed_at` set? |
| - | - | - |
| `open` | Not started | No |
| `in_progress` | Being worked | No |
| `terminal_success` | Finished successfully | Yes |
| `terminal_failed` | Finished unsuccessfully | Yes |
| `cancelled` | Abandoned | Yes |

> [!WARNING]
>
> Set `terminal: true` on every end-state label, even when its class is already `terminal_success`, `terminal_failed` or `cancelled`. The class alone sets `closed_at`, but only `terminal: true` publishes the **Ticket Closed** event (and its notifications) and stops the ticket's due-date timers.

### Imported labels

`labels.import` lists keys from the Project's shared state-label catalog. Each imported key must already exist in that catalog, or creating the workflow fails. Imported labels take their class and color from the catalog and are never terminal, so they do not count toward the terminal-state rules below. There is no API to manage the catalog today — declare your labels in `labels.extra`.

## Initial state

`initial` names the label a new ticket starts in. It must be a declared label key. If the workflow has `trigger_intake` entries, the initial label must be in `labels.extra` with `class: open`.

## Transitions

Each entry in `transitions` is one allowed edge:

| Field | Required | Description |
| - | - | - |
| `from` | Yes | Source label key. |
| `to` | Yes | Target label key. |
| `permit` | Yes | Who may fire the transition. At least one entry. |
| `guards` | No | List of `{ cel }` boolean expressions. All must return `true`. |
| `on_exit` | No | Actions queued when the ticket leaves `from`. |
| `on_enter` | No | Actions queued when the ticket enters `to`. |

Each `from` → `to` pair can appear only once. To move a ticket, call `POST …/tickets/{ticket_id}/transition` with `{"to_label": "<key>"}`. A target with no edge from the ticket's current label returns `400 no such transition`.

### Permits

A transition succeeds when **any** entry in `permit` matches the caller.

| Permit | Matches when |
| - | - |
| `system` | The platform itself moves the ticket — for example a `due_policy` breach. A user request never matches. |
| `ticket.assignee` | The caller is the ticket's assigned user, **or** the caller's user id is a `user` tag on the ticket. |
| `ticket.tagged_user` | Same check as `ticket.assignee`: assigned user or any `user` tag. |
| `ticket.assignee_group_member` | The ticket is assigned to a group and the caller is a member of that group. |
| `ticket.creator` | The caller created the ticket. |
| `project.<permission>` | The caller has `<permission>` on the ticket's Project, for example `project.member`, `project.admin` or `project.can_manage_assets`. |
| `ticket.can_read`, `ticket.can_manage` | The caller has that permission on the ticket itself. |
| `ticket.<group_name>` | The caller is a member of the Organization user group whose name matches `<group_name>` (case-insensitive), for example `ticket.maintenance_team`. No matching group means no match. |
| `device.<permission>` | The caller has `<permission>` on **any** device tagged on the ticket (`device` tags), for example `device.can_invoke_rpc`. |
| `device_group.<permission>` | The caller has `<permission>` on any fleet tagged on the ticket (`fleet` tags). |
| `dashboard.<permission>`, `collection.<permission>` | The caller has `<permission>` on an `object` tagged on the ticket. |
| `datapoint.<permission>`, `data_def.<permission>` | The caller has `<permission>` on a `datapoint` tagged on the ticket. |
| `user_context.<permission>` | The caller has `<permission>` on a `user_context` tagged on the ticket. |

If no entry matches, the transition returns `403 transition permit denied`.

> [!WARNING]
>
> Validation only checks that each permit is a reserved value or has the shape `<entity>.<permission>`. A misspelled entity or permission passes validation and then never matches. Test each edge with a real user before you rely on it.

An empty `permit` list fails validation with `at least one permit clause is required`. Use `[system]` for edges that only automation should take.

### What happens on a transition

The platform checks a transition in this order and stops at the first failure. Everything runs in one transaction, so a failure leaves the ticket unchanged.

| Step | Check | Failure |
| - | - | - |
| 1 | A `from` → `to` edge exists for the current label | `400 no such transition` |
| 2 | Terminal target: no open sub-tickets (see [Sub-tickets](#sub-tickets-and-terminal-states)) | `409` |
| 3 | `before_transition.require_form`: validates `form_payload` and writes its bindings | `400` |
| 4 | `permit` | `403 transition permit denied` |
| 5 | `guards` | `400 guard failed` |
| 6 | `before_transition.require_tag_kinds` | `400 missing required tag kind "…" before transition` |
| 7 | State change, transition history row, actions queued | — |

Because the form is applied in step 3, guards and tag checks see the metadata and tags the form just wrote.

## Guards with CEL

Guards are [CEL](https://cel.dev) expressions that must evaluate to `bool`. Validation compiles every guard and rejects non-boolean expressions. At transition time each guard has a 50 ms evaluation budget.

| Variable | Type | Contents |
| - | - | - |
| `ticket.id` | string | Ticket UUID |
| `ticket.metadata` | map | Ticket metadata, including values written by forms and HTTP action bindings |
| `ticket.assigned_to` | string or `null` | Assigned user id; falls back to the first `user` tag when no user is assigned |
| `ticket.assigned_group_id` | string or `null` | Assigned group id |
| `ticket.tagged_user_ids` | list of strings | Values of `user` tags |
| `ticket.tags` | map of lists | Tag kind → list of values, for example `ticket.tags.device` |
| `actor.id` | string | User id of the caller |
| `project.id` | string | Project UUID |
| `now` | timestamp | Evaluation time |

`event`, `queue` and `http` are declared but empty when a guard runs. CEL string extensions are available (`lowerAscii()`, `split()`, `replace()` and so on).

Reading a key that does not exist is an evaluation error, which returns `400 guard expression could not be evaluated`. Guard optional keys with `has()`. A guard that returns `false` returns `400 guard failed`.

Examples:

```yaml theme={null}
guards:
  # Someone must own the ticket before work starts
  - cel: "ticket.assigned_to != null || ticket.assigned_group_id != null"

  # At least one device is tagged on the ticket
  - cel: "has(ticket.tags.device) && size(ticket.tags.device) > 0"

  # An inspection result recorded in metadata must have passed
  - cel: "has(ticket.metadata.inspection) && ticket.metadata.inspection.passed == true"
```

## Validation rules

`validation` adds requirements on top of permits and guards.

### On create

| Field | Description |
| - | - |
| `on_create.require_tag_kinds` | Tag kinds the ticket must have after creation (from `initial_tags` or form bindings). Missing kind: `400 on_create requires tag kind "…"`. |
| `on_create.require_form` | `{ name, version? }`. The create request must include a valid `form_payload`. Missing payload: `400 on_create requires form payload`. |
| `on_create.optional_form` | `{ name, version? }`. Applied when the request includes `form_payload`, skipped when it does not. Use this when automation also creates tickets. |

`require_form` and `optional_form` cannot both be set.

### Before a transition

Each `before_transition` entry applies to one declared `from` → `to` edge:

| Field | Description |
| - | - |
| `from`, `to` | The edge. It must match a declared transition, or validation fails. |
| `require_tag_kinds` | Tag kinds the ticket must have before it moves. |
| `require_form` | `{ name, version? }`. The transition request must include a valid `form_payload`. Missing payload: `400 transition <from> -> <to> requires form payload`. |

Omit `version` to use the latest version of a catalog form. Inline forms in `forms.extra` take precedence over catalog forms of the same name. See [Forms](/ticketing/forms).

## Transition actions

`on_exit` and `on_enter` list actions to run when a transition happens. Two kinds are supported:

| `kind` | Does | Key fields |
| - | - | - |
| `ticket.http` | Calls an HTTP endpoint and can copy response fields into ticket metadata | `url`, `method`, `headers`, `body`, `bindings` |
| `ticket.rpc` | Calls a Device RPC method on a device | `device_id` (CEL), `service_name`, `method_name`, `fields` (CEL), `ttl_seconds` (60–86400) |

Actions are queued in the same transaction as the state change and run **asynchronously after it commits** — the transition response does not wait for them, and an action failure does not undo the transition. For each transition, `on_exit` actions run before `on_enter` actions.

`ticket.http` URLs must use `http` or `https` and must resolve to a public address when you validate the workflow. Private and link-local addresses are rejected. See [Automation](/ticketing/automation) for retries, bindings and RPC details.

## Work-order workflows

Set `kind: work_order` to mark a workflow as a CMMS work-order workflow. CMMS features look for workflows of this kind — for example, an [alarm](/alarms/overview) that creates work orders without a named workflow uses the Project's active `work_order` workflow. `name` is only a label. A workflow with no `kind` whose `name` is `work_order` is also treated as a work-order workflow.

The `cmms.work_order` [workflow template](/ticketing/automation) installs a ready-made work-order workflow you can edit.

## Sub-tickets and terminal states

A parent ticket links a child with a tag of kind `subticket` whose value is the child ticket's UUID. While any linked child has `system_class` `open` or `in_progress`, the parent cannot move to a terminal label (`terminal: true`, or class `terminal_success`, `terminal_failed` or `cancelled`). The transition returns:

```
409 cannot transition to terminal: 2 open subticket(s) remain
```

> [!NOTE]
>
> The Console links sub-tickets by setting `metadata.parent_ticket_id` on the child. This check does not read that field. Only `subticket` tags on the parent block terminal transitions.

## Graph validation

Validation runs these checks on every spec. Errors block activation; warnings do not.

| Check | Error message (example) |
| - | - |
| `initial` is a declared label | `initial state "nope" not found in labels` |
| Each `labels.extra` entry has a `key` and a valid `class` | `unknown class "done"` |
| Transition `from` / `to` are declared labels | `unknown from "missing_state"` |
| Each transition has at least one permit | `at least one permit clause is required` |
| Permits are well formed | `malformed permit "admin"` |
| No duplicate `from` → `to` edges | `duplicate transition from "new" to "in_progress"` |
| Every label is reachable from `initial` | `G1: label "review" is not reachable from initial "new"` |
| Every non-terminal `labels.extra` label has an outgoing transition | `G2: non-terminal label "in_progress" has no outgoing transitions` |
| Some path from `initial` reaches a terminal label | `G4: no path from initial reaches a terminal label` |
| Guards compile and return `bool` | `cel: guard expression must evaluate to bool, got int` |
| Actions have a supported `kind` and required fields | `unsupported action kind "email" (use ticket.http or ticket.rpc)` |
| `before_transition` matches a declared edge | `no declared transition from "new" to "resolved"; this before_transition rule (e.g. require_form) would never fire` |
| `require_form` and `optional_form` not both set | `require_form and optional_form are mutually exclusive; …` |
| `due_policy.lead_time` is a positive duration | `lead_time must be > 0, got "0s"` |
| `due_policy.on_breach.to` is a declared label targeted by a transition that permits `system` | `transition new -> overdue does not permit system; on_breach would fail at runtime with a permit denial` |
| `trigger_intake` events, CEL and durations | See [Automation](/ticketing/automation) |

A label counts as terminal when it has `terminal: true` or class `terminal_success`, `terminal_failed` or `cancelled`.

### Validate a spec

`POST /projects/{project_id}/ticket-workflows/validate` checks a spec without saving it. Send JSON with the spec under `workflow`, or a YAML document with a top-level `workflow:` key and `Content-Type: application/yaml`.

```bash JSON theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  --data-binary @maintenance-request.json \
  "$API/projects/$PROJECT_ID/ticket-workflows/validate"
```

```bash YAML theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/yaml" \
  --data-binary @maintenance-request.yaml \
  "$API/projects/$PROJECT_ID/ticket-workflows/validate"
```

The response always returns `200` when the spec parses. Check `valid`:

```json theme={null}
{
  "ok": 1,
  "data": {
    "valid": false,
    "errors": [
      { "path": "initial", "message": "initial state \"nope\" not found in labels" },
      { "path": "transitions[1].permit", "message": "at least one permit clause is required" },
      { "path": "transitions", "message": "G4: no path from initial reaches a terminal label" }
    ]
  }
}
```

| Field | Description |
| - | - |
| `valid` | `true` when there are no errors |
| `errors` | `{ path, message }` list; `path` points into the spec, for example `transitions[0].guards[0].cel` |
| `warnings` | Same shape; present only when there are warnings |

A body that cannot be parsed, or a spec without `name` or `initial`, returns `400`.

## Full example

A `maintenance-request` workflow: tickets need a tagged device, someone must be assigned before work starts, resolving requires a resolution form, and resolving posts a message to a webhook.

```mermaid theme={null}
stateDiagram-v2
  [*] --> new
  new --> in_progress: assignee or admin
  new --> cancelled: creator or admin
  in_progress --> resolved: assignee or admin + form
  in_progress --> cancelled: admin
  resolved --> [*]
  cancelled --> [*]
```

Both files below are complete request bodies for validate, create and patch.

```json maintenance-request.json theme={null}
{
  "workflow": {
    "name": "maintenance-request",
    "initial": "new",
    "labels": {
      "extra": [
        { "key": "new", "class": "open", "color": "#9CA3AF" },
        { "key": "in_progress", "class": "in_progress", "color": "#8B5CF6" },
        { "key": "resolved", "class": "terminal_success", "color": "#10B981", "terminal": true },
        { "key": "cancelled", "class": "cancelled", "color": "#6B7280", "terminal": true }
      ]
    },
    "forms": {
      "extra": [
        {
          "name": "maintenance-resolution",
          "schema": {
            "type": "object",
            "required": ["resolution_notes", "downtime_minutes"],
            "properties": {
              "resolution_notes": { "type": "string", "minLength": 1 },
              "downtime_minutes": { "type": "integer", "minimum": 0 }
            }
          },
          "bindings": [
            {
              "field": "resolution_notes",
              "writes_to": { "kind": "metadata", "metadata_path": "resolution.notes" }
            },
            {
              "field": "downtime_minutes",
              "writes_to": { "kind": "metadata", "metadata_path": "resolution.downtime_minutes" }
            }
          ]
        }
      ]
    },
    "validation": {
      "on_create": {
        "require_tag_kinds": ["device"]
      },
      "before_transition": [
        {
          "from": "in_progress",
          "to": "resolved",
          "require_form": { "name": "maintenance-resolution" }
        }
      ]
    },
    "transitions": [
      {
        "from": "new",
        "to": "in_progress",
        "permit": ["ticket.assignee", "ticket.assignee_group_member", "project.admin"],
        "guards": [
          { "cel": "ticket.assigned_to != null || ticket.assigned_group_id != null" }
        ]
      },
      {
        "from": "new",
        "to": "cancelled",
        "permit": ["ticket.creator", "project.admin"]
      },
      {
        "from": "in_progress",
        "to": "resolved",
        "permit": ["ticket.assignee", "ticket.assignee_group_member", "project.admin"],
        "guards": [
          { "cel": "has(ticket.tags.device) && size(ticket.tags.device) > 0" }
        ],
        "on_enter": [
          {
            "kind": "ticket.http",
            "url": "https://hooks.slack.com/services/T000/B000/XXXX",
            "method": "POST",
            "headers": { "Content-Type": "application/json" },
            "body": { "text": "A maintenance request was resolved" }
          }
        ]
      },
      {
        "from": "in_progress",
        "to": "cancelled",
        "permit": ["project.admin"]
      }
    ]
  }
}
```

```yaml maintenance-request.yaml theme={null}
workflow:
  name: maintenance-request
  initial: new
  labels:
    extra:
      - { key: new,         class: open,             color: "#9CA3AF" }
      - { key: in_progress, class: in_progress,      color: "#8B5CF6" }
      - { key: resolved,    class: terminal_success, color: "#10B981", terminal: true }
      - { key: cancelled,   class: cancelled,        color: "#6B7280", terminal: true }
  forms:
    extra:
      - name: maintenance-resolution
        schema:
          type: object
          required: [resolution_notes, downtime_minutes]
          properties:
            resolution_notes: { type: string, minLength: 1 }
            downtime_minutes: { type: integer, minimum: 0 }
        bindings:
          - field: resolution_notes
            writes_to: { kind: metadata, metadata_path: resolution.notes }
          - field: downtime_minutes
            writes_to: { kind: metadata, metadata_path: resolution.downtime_minutes }
  validation:
    on_create:
      require_tag_kinds: [device]
    before_transition:
      - from: in_progress
        to: resolved
        require_form: { name: maintenance-resolution }
  transitions:
    - from: new
      to: in_progress
      permit: [ticket.assignee, ticket.assignee_group_member, project.admin]
      guards:
        - cel: "ticket.assigned_to != null || ticket.assigned_group_id != null"
    - from: new
      to: cancelled
      permit: [ticket.creator, project.admin]
    - from: in_progress
      to: resolved
      permit: [ticket.assignee, ticket.assignee_group_member, project.admin]
      guards:
        - cel: "has(ticket.tags.device) && size(ticket.tags.device) > 0"
      on_enter:
        - kind: ticket.http
          url: https://hooks.slack.com/services/T000/B000/XXXX
          method: POST
          headers:
            Content-Type: application/json
          body:
            text: A maintenance request was resolved
    - from: in_progress
      to: cancelled
      permit: [project.admin]
```

Replace the webhook URL with your own. To resolve a ticket on this workflow, send the form answers with the transition:

```bash theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "to_label": "resolved",
    "reason": "Replaced pump seal",
    "form_payload": { "resolution_notes": "Replaced pump seal", "downtime_minutes": 45 }
  }' \
  "$API/projects/$PROJECT_ID/tickets/$TICKET_ID/transition"
```

## Versioning and activation

A workflow **definition** (`workflow_def_id`) holds a list of immutable **versions**. Routes that take `{workflow_id}` expect a **version** id.

**1. Create a version**

`POST /projects/{project_id}/ticket-workflows` with the spec. The response `data.item` includes `id` (version id), `workflow_def_id`, `version`, `is_active`, `spec` and `validation_report`. A new version is never active.

**2. Check the validation report**

The version is saved even when it has errors. `validation_report.ok` is `false` and `validation_report.errors` lists them as `"<path>: <message>"` strings.

**3. Activate the version**

`POST /projects/{project_id}/ticket-workflows/{workflow_id}/activate`. This deactivates any other version of the same workflow. A version with validation errors returns `400 workflow validation failed`.

**4. Create tickets**

Pass `workflow_def_id` to `POST …/tickets`. New tickets always use the definition's active version.

```bash theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  --data-binary @maintenance-request.json \
  "$API/projects/$PROJECT_ID/ticket-workflows"

curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  "$API/projects/$PROJECT_ID/ticket-workflows/$WORKFLOW_VERSION_ID/activate"
```

| Action | Route | Behavior |
| - | - | - |
| Edit | `PATCH …/ticket-workflows/{workflow_id}` | Body `{"workflow": {...}}`. Appends a new version to the same definition; it does not activate it. `name` must match the existing workflow, or `400 workflow yaml name must match existing workflow`. |
| Activate | `POST …/ticket-workflows/{workflow_id}/activate` | Makes this version the one new tickets use. |
| Delete | `DELETE …/ticket-workflows/{workflow_id}` | Deletes one version. Returns `409` if the version is active, is used by any ticket, or has queued actions. |
| List | `GET …/ticket-workflows` | Paginated with `limit` (default 10, max 200) and `page` or `offset`. Filter with `is_active=true`. |
| Get | `GET …/ticket-workflows/{workflow_id}` | One version in `data.item`. |

Existing tickets stay on the version they were created with. Their transitions, permits, guards and actions keep following that version after you activate a newer one.

## State labels endpoint

`GET /projects/{project_id}/ticket-workflows/{workflow_id}/state-labels` lists the labels of one workflow version, with the ids tickets reference in `state_label_id`.

```bash theme={null}
curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  "$API/projects/$PROJECT_ID/ticket-workflows/$WORKFLOW_VERSION_ID/state-labels"
```

```json theme={null}
{
  "ok": 1,
  "data": {
    "items": [
      {
        "id": "4f1c2a7e-3b9d-4c55-9a0e-6d2b8f1e7c30",
        "org_id": "…",
        "project_id": "…",
        "workflow_version_id": "…",
        "project_state_label_id": null,
        "label": "new",
        "system_class": "open",
        "color": "#9CA3AF",
        "is_initial": true,
        "is_terminal": false,
        "created_at": "2026-10-05T09:12:44.120Z",
        "updated_at": "2026-10-05T09:12:44.120Z"
      }
    ]
  }
}
```

| Field | Description |
| - | - |
| `id` | State label id; matches a ticket's `state_label_id` |
| `label` | Label key |
| `system_class` | Class from the spec |
| `color` | Color, or `null` |
| `is_initial` | `true` for the `initial` label |
| `is_terminal` | `true` when the label has `terminal: true` |
| `project_state_label_id` | Catalog label id for imported labels, otherwise `null` |

An unknown version id returns `404 workflow not found`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.