﻿# workflows — state machine definitions

A workflow definition is a named finite state machine attached to an entity model. Workflows are stored per model reference `(entityName, modelVersion)`. …

<em>cyoda-go version <a href="https://github.com/Cyoda/cyoda-go/releases/tag/v0.8.4">0.8.4</a></em>

# workflows

## NAME

workflows — workflow state machine definitions: states, transitions, processors, and criteria.

## SYNOPSIS

```
POST  /api/model/{entityName}/{modelVersion}/workflow/import
GET   /api/model/{entityName}/{modelVersion}/workflow/export
```

Context path prefix is `CYODA_CONTEXT_PATH` (default `/api`). All endpoints require `Authorization: Bearer <token>` except when `CYODA_IAM_MODE=mock`.

## DESCRIPTION

A workflow definition is a named finite state machine attached to an entity model. Workflows are stored per model reference `(entityName, modelVersion)`. A model may have multiple workflow definitions; the engine selects the matching one per entity using the workflow-level `criterion` field evaluated at entity creation time. When no `criterion` matches, the engine uses the default built-in workflow.

The engine executes automatically after every entity write. It sets the initial state, evaluates automated transitions (cascade), and invokes processors on each transition. Manual transitions are triggered by the client via `PUT /entity/{format}/{entityId}/{transition}`.

The engine enforces a per-state visit limit of 10 by default (configurable via `WithMaxStateVisits`) and an absolute cascade depth limit of 100 to prevent infinite loops. Static cycle detection runs at import time.

## WORKFLOW SCHEMA

**WorkflowDefinition** (element of the `workflows` array in import):

```json
{
  "version": "1.4",
  "name": "prize-lifecycle",
  "desc": "State machine for Nobel Prize entities",
  "initialState": "NEW",
  "active": true,
  "annotations": { "roles": ["reviewer"], "label": "Prize lifecycle" },
  "criterion": null,
  "states": {
    "NEW": {
      "transitions": [
        {
          "name": "APPROVE",
          "next": "APPROVED",
          "manual": true,
          "annotations": { "ui": { "color": "green" } },
          "disabled": false,
          "criterion": null,
          "processors": [
            {
              "type": "externalized",
              "name": "notify-approval",
              "executionMode": "SYNC",
              "annotations": { "displayName": "Send approval email" },
              "config": {
                "attachEntity": true,
                "calculationNodesTags": "approval-service",
                "responseTimeoutMs": 30000,
                "retryPolicy": "",
                "context": ""
              }
            }
          ]
        },
        {
          "name": "AUTO_VALIDATE",
          "next": "VALIDATED",
          "manual": false,
          "disabled": false,
          "criterion": {
            "type": "simple",
            "jsonPath": "$.year",
            "operatorType": "EQUALS",
            "value": "2024"
          },
          "criterionAnnotations": { "displayName": "Year is 2024" },
          "processors": []
        }
      ]
    },
    "APPROVED": {
      "transitions": []
    },
    "VALIDATED": {
      "transitions": []
    }
  }
}
```

**WorkflowDefinition fields:**

- `version` — semver `MAJOR.MINOR` string identifying the workflow-import contract this definition was authored against. Validated strictly on import; stamped to the current contract version on export. See `cyoda help workflows schema-version` for the bump rules and current/supported list.
- `name` — string — unique within the model; the primary key for MERGE mode
- `desc` — string — optional human-readable description. Surfaced in the import audit log line (`workflow import applied` at `INFO`, or `workflow import resulted in zero workflows` at `WARN`) as part of the per-workflow `{name, desc}` digest, and round-tripped via export when non-empty. Use it to record change intent that operators reading logs can correlate without consulting the workflow JSON
- `initialState` — string — state assigned when the entity is first created; must exist in `states`
- `active` — boolean — when `false`, the engine skips this workflow during selection
- `criterion` — `Condition` JSON or `null` — evaluated against the entity at creation to select this workflow; `null` matches all entities
- `states` — object — map of state name → `StateDefinition`
- `annotations` — object or absent — optional client-owned metadata, stored and round-tripped (compacted) but never interpreted by the engine. Must be a JSON object; capped at 64 KB per field. Use for client concerns such as permitted roles, display labels, or UI hints. Two well-known optional keys, `displayName` and `description` (strings), are documented for renderer use (workflow visualisers, condition builders); the engine ignores them and the types are advisory, not enforced. All five workflow element types — workflow, state, transition, processor, and criterion via `criterionAnnotations` — share this same bag shape
- `criterionAnnotations` — object or absent — optional client-owned metadata attached to this workflow's `criterion` as a whole (see `annotations` above); a sibling field rather than embedded in the criterion, so the criterion blob keeps round-tripping byte-verbatim

**StateDefinition:**

- `transitions` — array of `TransitionDefinition` — may be empty
- `annotations` — object or absent — optional client-owned metadata (see WorkflowDefinition `annotations`); object-only, 64 KB cap, engine-opaque

## TRANSITIONS

**TransitionDefinition fields:**

- `name` — string — transition name; used by the client in `PUT /entity/{format}/{entityId}/{name}` and in engine cascade
- `next` — string — target state; must exist in `states`
- `manual` — boolean — `true` means the transition requires an explicit client request; `false` means the engine evaluates it automatically in cascade
- `disabled` — boolean — when `true`, the engine skips this transition entirely
- `criterion` — `Condition` JSON or `null` — evaluated before executing the transition; `null` means always matches; the same Condition DSL as search (see `search` topic)
- `criterionAnnotations` — object or absent — optional client-owned metadata attached to this transition's `criterion` as a whole (see WorkflowDefinition `annotations`); sibling field, engine-opaque
- `processors` — array of `ProcessorDefinition` — invoked sequentially on this transition
- `annotations` — object or absent — optional client-owned metadata (see WorkflowDefinition `annotations`); object-only, 64 KB cap, engine-opaque

## PROCESSORS

A processor may return modified entity data. That data is governed by the model exactly as a client's write is: it must be storable, and it must satisfy the schema. Introducing a field the model does not declare requires the model's `changeLevel` to permit it; otherwise the transition fails with `WORKFLOW_FAILED` and rolls back, leaving neither the entity nor any schema change behind.

**ProcessorDefinition fields:**

- `type` — string — execution-location axis; see below for valid values
- `name` — string — logical processor name
- `executionMode` — string — execution mode; see valid values below
- `config` — `ProcessorConfig`
- `annotations` — object or absent — optional client-owned metadata (see WorkflowDefinition `annotations`); object-only, 64 KB cap, engine-opaque. Excluded from the gRPC `EntityProcessorCalculationRequest` sent to compute members — never delivered to external processor implementations

**Processor `type` (execution-location axis):**

- `"externalized"` (default when omitted) — dispatched via gRPC to a calculation node selected by `Config.calculationNodesTags`. This is the only execution location implemented today; all the `executionMode` semantics below apply to externalized processors.

The engine reserves the value `"internalized"` for an in-process execution location not yet implemented. Any transition that fires a processor with `type: "internalized"` is rejected at dispatch with `WORKFLOW_FAILED` (400) and the operator-visible error `processor X failed: execution type "internalized" is not yet implemented`. The reserved value is intentionally absent from the OpenAPI enum until the subtype lands; workflow authors who include it in import payloads will not be rejected at import, but their entities cannot transit past the affected step.

Any value other than `"internalized"` (including the empty string, the canonical `"externalized"`, and unknown values such as legacy `"scheduled"` or `"EXTERNAL"`) falls through to the `executionMode` dispatch path. This permissiveness will narrow in a future release; do not rely on it.

**Valid `executionMode` values (exhaustive):**

- `"SYNC"` — the engine dispatches the processor and blocks until a response is received; the entity write transaction remains open during the wait; processor failure (including timeout and `success=false` in the response) returns `errors.WORKFLOW_FAILED` (`400`) and the entity remains in the source state
- `"ASYNC_SAME_TX"` — same dispatch mechanics as `SYNC` (blocks inline, transaction stays open); failure semantics are identical to `SYNC`
- `"ASYNC_NEW_TX"` — dispatched within a savepoint; on failure the savepoint is rolled back and the error is logged as a warning; the pipeline continues to the next processor and the transition completes; returned entity modifications are discarded
- `"COMMIT_BEFORE_DISPATCH"` — the engine splits the cascade into two transactions around this processor. `TX_pre` flushes the pre-callout state of the transition and **commits before the processor is dispatched**, releasing the storage connection for the duration of the external compute. The processor runs outside any transaction (entity already durable in the pre-callout state). When the processor returns, the engine opens `TX_post` on the same node, reapplies the result via `CompareAndSave` (CAS expects the txID stamped at `TX_pre`'s commit), runs any subsequent SYNC processors and cascade transitions, then commits. CAS conflict at the boundary surfaces as `409 retryable`; entity remains durable in the pre-callout state, no engine-side retry, no automatic compensation. Failure of the dispatched processor (`success=false`, timeout, member crash) returns `errors.WORKFLOW_FAILED` (`400`) and the entity remains in the pre-callout state. Designed to relieve connection-pool pressure for slow processors and supersedes `ASYNC_NEW_TX` as the recommended mode for slow external work.

**`COMMIT_BEFORE_DISPATCH` configuration flag:**

- `startNewTxOnDispatch` — boolean — sibling field on the same processor object; default `false`; valid only when `executionMode == "COMMIT_BEFORE_DISPATCH"`. Validator rejects `true` for any other mode. When `true`, the engine opens a fresh transaction context (`TX_post`) for the dispatched processor's CRUD callbacks; the processor may use the supplied transaction token to read or write entities other than the cascade-anchor. When `false`, no transaction context is supplied to the dispatched call.

**`COMMIT_BEFORE_DISPATCH` workflow-author requirements:**

- **Idempotency.** A `COMMIT_BEFORE_DISPATCH` processor must be **idempotent or have an external mechanism for detecting prior completion** (e.g., a write-once external resource ID). Replays can fire from two distinct places: (a) CAS conflict during continuation — the caller's retry of the same API call restarts the cascade and re-dispatches the processor; (b) engine crash between segments — the entity is durable in the pre-callout state, the in-flight orchestration is gone, the caller retries, the cascade re-fires from the beginning, the processor is re-dispatched. The engine cannot deduplicate replays; idempotency is the workflow author's responsibility.
- **Visibility of segment-boundary states.** States on a segment boundary (the pre-callout state of a `COMMIT_BEFORE_DISPATCH` processor) are **publicly observable** to readers between segments. A concurrent transaction's `Get`/`GetPage`/`Iterate`/`Search`/`Count` will see the entity in the pre-callout state, and a second cascade may decide to fire criteria-driven transitions based on that observed state. Workflow authors using `COMMIT_BEFORE_DISPATCH` must treat segment-boundary states as committed states — design state-machine criteria, transition guards, and external monitoring accordingly. If invisibility of an intermediate state is required, model it as a workflow-level `DRAFT` parent state with sub-stages in payload, or do not expose the entity until a designated terminal state.
- **Attribution handover with `startNewTxOnDispatch=false`.** With no transaction context supplied, the dispatched processor's callback writes are ordinary independent requests — the platform tracks no causal chain for them. Each is attributed to whatever identity it presents (its own service credentials, or an OBO user token it forwards). The dispatch's AuthContext (`authtype`/`authid`/`authclaims`) carries the causal principal so the application can self-attribute if it wants user-level attribution; the platform supplies no separate carrier for this mode.
- **Best-practice: a processor must not save the entity it is processing for.**
  Processors with TX-callback access (SYNC, ASYNC_SAME_TX, COMMIT_BEFORE_DISPATCH
  with startNewTxOnDispatch=true) can write the cascade-anchor entity via the
  supplied transaction token, but if they do AND also return mutations for the
  same entity in their result, the engine's apply-result will overwrite the
  processor's intra-TX writes (last-writer-wins inside the transaction buffer).
  Pick one path: let the engine apply the result, OR have the processor write
  the entity itself and return no mutations for it.

Import-time validation rejects any `executionMode` value not in the list above (and not empty) with `400 VALIDATION_FAILED`. The empty string continues to default to `SYNC` at engine fire.

**ProcessorConfig fields:**

- `attachEntity` — boolean, optional, default `true` — when `true`, the full entity payload is sent to the processor; set `false` to omit it
- `calculationNodesTags` — string — comma-separated tags for routing to registered calculation nodes; the engine selects a node that declares all required tags; returns `errors.NO_COMPUTE_MEMBER_FOR_TAG` if no node matches
- `responseTimeoutMs` — int64 — timeout in milliseconds for `SYNC` processor response; `0` means use node default
- `retryPolicy` — string — selects the server-resolved retry strategy.
  Valid values: `NONE` (single attempt, no retry), `FIXED` (up to N
  additional attempts with fixed delay between tries, where N and delay
  are server-configured). When omitted, defaults to `FIXED` at engine
  fire. Import-time validation rejects any other value with `400
  VALIDATION_FAILED`. **cyoda-go status:** captured but not consumed —
  the dispatcher is single-shot regardless of policy; the full retry
  loop ships in a later release. Cloud honours both policies.
- `context` — string — pass-through string forwarded **verbatim** as the `parameters` JSON node of the outgoing `EntityProcessorCalculationRequest` (and `EntityCriteriaCalculationRequest` when used on a `function`-typed criterion's `config`). Marshalling shape is **pass-as-string**: the value is encoded as a JSON string, not parsed as JSON. The receiver gets a JSON-quoted string in `parameters`. Empty `context` causes `parameters` to be omitted entirely. Use to distinguish multiple workflow roles served by a single externalized processor or criterion implementation without registering a separate name per role.
- `asyncResult` — boolean (pointer; nil-default) — declared in the
  OpenAPI for Cloud parity; the runtime does **not** implement
  async-result semantics on this backend. Imports that set
  `asyncResult: true` are rejected with `400 VALIDATION_FAILED`. The
  explicit `asyncResult: false` and absent cases are accepted and
  round-tripped.
- `crossoverToAsyncMs` — int64 (pointer; nil-default) — crossover
  delay (ms) for the async-result semantic; declared in the OpenAPI
  for Cloud parity; the runtime does **not** implement it. Imports
  that set any non-nil value are rejected with `400 VALIDATION_FAILED`,
  including the orphan case where `asyncResult` is absent or false.

## SCHEDULED TRANSITIONS

A transition may carry an optional `schedule` object, marking it as
**scheduled**: rather than being fired by an API call or by automated
cascade, it fires on its own at a computed **scheduled time**. How that
time is determined is chosen per transition, in one of two mutually
exclusive ways:

- **`delayMs`** — a static delay, the same for every entity.
- **`function`** — a per-entity Function callout that computes the
  firing time (and optionally an expiry) from the entity itself.

Exactly one of `delayMs` / `function` is required whenever `schedule` is
present, and both are mutually exclusive with `manual: true`. The two
modes differ *only* in how the scheduled time (and expiry) are
determined; everything under **Engine behaviour** below applies to a
scheduled transition either way.

**Static timing — `delayMs`.** The transition fires at
`scheduledTime = stateEntryTime + delayMs`:

```json
{
  "name": "AutoClose",
  "next": "Closed",
  "manual": false,
  "schedule": {
    "delayMs": 86400000,
    "timeoutMs": 600000
  }
}
```

- `delayMs` (integer) — delay between source-state entry and the
  scheduled time, in milliseconds. Must be `> 0`.
- `timeoutMs` (integer, optional) — late-tolerance window past the
  scheduled time (see **Lateness / expiry** below). Absent means no
  timeout; explicit `0` is strictest (drop on any lateness). Independent
  of `delayMs` — the two measure different quantities.

**Per-entity timing — `function`.** The transition computes its firing
time (and optional expiry) per entity via a Function callout — the same
dispatch mechanism as an externalized processor or a `function`-type
criterion, but returning a typed `Schedule` result instead of an entity
payload or a boolean:

```json
{
  "name": "Escalate",
  "next": "Escalated",
  "manual": false,
  "schedule": {
    "function": {
      "name": "compute-escalation-time",
      "resultKind": "Schedule",
      "calculationNodesTags": "escalation-service",
      "attachEntity": true
    }
  }
}
```

- `name` (string, required) — registered function name.
- `resultKind` (string, required) — must be `"Schedule"`.
- `calculationNodesTags` (string, required) — comma-separated tags
  selecting the dispatch target, same as a processor or criterion.
- `attachEntity` (boolean, optional, default `true`) — whether the
  entity payload is attached to the request.
- `context` (string, optional) — pass-through string forwarded verbatim
  as the request's `parameters`; omitted when empty.
- `responseTimeoutMs` (integer, optional) — response timeout for this
  callout.

The function responds with `resultKind: "Schedule"` and a `result`
object giving the fire time and, optionally, an expiry:

```json
{ "fireAfterMs": 3600000, "expireAfterMs": 600000 }
```

- **Fire time** (required) — exactly one of `fireAt` (absolute,
  epoch-ms) or `fireAfterMs` (relative to arm time). A past `fireAt` (or
  non-positive `fireAfterMs`) is not an error — the transition is due
  immediately.
- **Expiry** (optional) — at most one of `expireAt` (absolute) or
  `expireAfterMs` (relative to the *resolved fire time*, not arm time).
  Both absent means no expiry (equivalent to an absent `timeoutMs`). A
  resolved expiry *after* the fire time becomes the `timeoutMs`
  late-tolerance window — the gap between the two. A resolved expiry *at
  or before* the fire time is **born expired**: the transition is not
  armed, any existing scheduling for it is cancelled
  (`SCHEDULED_TRANSITION_EXPIRE`), and the triggering write still
  succeeds.

*Fail-closed.* The callout runs synchronously inside the entity-write
transaction (see **Arming** below), so a callout failure fails that
write. If the compute node is unreachable, disconnected, or times out,
the write fails as a retryable `503` with the same dispatch error codes
as a processor or criterion (`NO_COMPUTE_MEMBER_FOR_TAG`,
`DISPATCH_TIMEOUT`, `COMPUTE_MEMBER_DISCONNECTED`) — no state change
commits against an unschedulable transition. A structurally valid
response with the wrong `resultKind`, or a malformed `Schedule` value,
fails with `500 SCHEDULE_FUNCTION_INVALID_RESULT` (see that error topic).

**Import-time validation.**

- Exactly one of `schedule.delayMs` / `schedule.function` must be
  present (`VALIDATION_FAILED`).
- `schedule` and `manual: true` are mutually exclusive
  (`VALIDATION_FAILED`).
- Static mode: `delayMs <= 0` is rejected (`VALIDATION_FAILED`);
  `timeoutMs` need only be `>= 0`.
- Function mode: `name` and `calculationNodesTags` must be non-empty,
  and `resultKind` must be `"Schedule"` (`VALIDATION_FAILED`).

**Engine behaviour (applies to both timing modes).** A scheduled
transition is driven by a background scheduler, independently of cascade
evaluation and of any other API call touching the entity.

- **Arming.** On every write that leaves the entity in the transition's
  source state — the initial entry AND every subsequent settled write
  (an ordinary in-place data update or a self-loop) — the transition is
  (re-)armed with a freshly computed scheduled time. Static mode sets it
  to `now + delayMs`; function mode **invokes the callout**
  synchronously, inside that write's transaction, and each call fully
  replaces the previous scheduling decision.
- **Settled-interval reset.** Because arming happens on *every* settled
  write, an entity written more often than its scheduled interval never
  reaches the fire. Authors relying on "escalate N after entry"
  semantics must account for this: routine touch-writes on a busy entity
  postpone the fire indefinitely (and, in function mode, make a callout
  on each such write).
- **Firing.** When the scheduled time is due, the engine re-evaluates
  the transition's criterion exactly **once**. A `true` (or absent)
  criterion fires the transition normally (processors run, state
  advances, `TRANSITION_MAKE` is recorded). A `false` criterion
  **declines** the transition — the entity stays in its current state
  and the timer is **not** retried (`TRANSITION_NOT_MATCH_CRITERION`).
  See "One-shot vs. polling" below for how to model a retry.
- **Lateness / expiry (`timeoutMs`).** `timeoutMs` — set directly on a
  static schedule, or derived from a function schedule's expiry — bounds
  how late the scheduler may pick up a due timer before giving up: if it
  is picked up more than `timeoutMs` past the scheduled time, it is
  **dropped** without evaluating the criterion (`Expired`) — the
  transition never fires and the entity stays put. No `timeoutMs` (no
  expiry) means no upper bound: the timer fires whenever it is
  eventually picked up.
- **Explicitly firing a scheduled transition by name still returns**
  HTTP 400 `TRANSITION_NOT_FOUND`, with the message `transition "X"
  in state "Y" is scheduled and fires automatically; it is not
  manually fireable`. Same code returned when a transition is
  `disabled: true` — same semantic: "the transition exists but is not
  currently dispatchable from the caller's POV." The entity remains
  in the source state. To allow early firing, give the state an
  ordinary manual transition alongside the scheduled one.
- **Audit trail.** Arming, firing, expiry, and cancellation each emit a
  dedicated event: `SCHEDULED_TRANSITION_ARM`, `SCHEDULED_TRANSITION_FIRE`
  (alongside the ordinary `TRANSITION_MAKE`), `SCHEDULED_TRANSITION_EXPIRE`,
  `SCHEDULED_TRANSITION_CANCEL`. A loopback that re-arms the same state
  emits only `ARM`, not `CANCEL`. `CANCEL` has two causes: the entity left
  the source state before the timer fired, or the task came due and the
  workflow now selected for the entity does not declare it as a scheduled
  transition of that state (see *Workflow-level selection*). A task that is
  both obsolete and past its `timeoutMs` grace band records `EXPIRE`, not
  `CANCEL` — expiry is decided from the stored task and the clock alone,
  before the workflow is consulted.

**One-shot vs. polling.** The criterion is evaluated once per fire —
there is no built-in retry-until-true. Three shapes cover the common
cases:

- **Unconditional scheduled cycle** (`S1 →scheduled→ S2 →scheduled→
  S1`, no criteria) — an intentional recurring heartbeat: it fires
  every `delayMs`, forever, for every entity in the cycle. Requires
  `allowCycles: true` at import (below).
- **Conditional scheduled transition** (a criterion on the scheduled
  transition) — a one-shot deadline gate: "at the deadline, fire iff
  the condition holds, else abandon." A `false` criterion is a
  deliberate Decline, not a retry.
- **Poll-until-condition** — model it as an *unconditional* scheduled
  tick into a state whose **ordinary** (non-scheduled) transitions
  carry the condition and exit when it holds. The retry loop lives in
  normal workflow structure, not in the timer.

**Importing cyclic scheduled workflows.** A canonical scheduled-
transition use case is a polling pattern such as `S1 →scheduled→ S2
→scheduled→ S1`. The import-time cycle detector rejects unguarded
automated cycles by default — including this one, because a delayed
cycle is still a cycle. To import such a workflow, set the request-
level field `allowCycles: true` on the import body:

```json
{
  "importMode": "REPLACE",
  "allowCycles": true,
  "workflows": [ /* ... */ ]
}
```

`allowCycles: true` bypasses only the cycle-detection check. Schedule
shape rules (the `delayMs`/`function` XOR, `manual`+`schedule`
exclusion) and all other validators remain unconditional. The runtime
cascade-depth and per-state visit caps still catch actual runaway at
fire time. Use only for workflows whose cyclicity is intentional.

## CRITERIA

Criteria on workflows and transitions use the same `Condition` DSL as search — five condition types are supported: `simple`, `lifecycle`, `group`, `array`, `function`. All but `function` are evaluated in-memory against the entity's JSON payload and lifecycle metadata; a `function` criterion is dispatched to a compute member and must be the whole criterion — one nested inside a `group` fails the evaluation. See `cyoda help search` for the per-type JSON shapes.

`simple` and `array` criteria address entity data fields by `jsonPath`, under the same grammar a search condition obeys — the `$.` leader is required, and a path outside it is rejected at **import** with `400 VALIDATION_FAILED` rather than at every later evaluation. Write `$.amount`, not `amount`. **Well-formed** array subscripts — the wildcard `[*]` or a non-negative index `[0]` — are valid (`$.tags[*].name`, `$.arr[0]`): criteria are evaluated in memory, which resolves them. A path ending in `[*]` addresses every element, so the criterion fires when **some** element satisfies it (`$.arr[*] GREATER_THAN 50`), and never on an empty array. Any other bracket spelling (`$.a[-1]`, `$.a[0:2]`, `$.a[?(@.x)]`, `$.a[`, `$.a[0]b`) is malformed and is rejected at import with the same `400 VALIDATION_FAILED` — previously it imported cleanly and the criterion then silently never fired, because no evaluator resolves those spellings. See `cyoda help search` for the grammar.

**Import checks the path's grammar, not whether the model declares it.** A model may legitimately be declared after the workflow that references it, so a criterion naming a field the model does not yet declare imports cleanly. The model check happens instead when the criterion is **evaluated**: a query never executes against a field the model does not declare, so if the field is still undeclared at evaluation time, the save that triggered the evaluation is **aborted and rolled back** with `400 WORKFLOW_FAILED` — no entity write, no state transition, no partial effect. This applies to all 26 operators, not only the ones that need a declared type. A field that no entity has ever written must be declared explicitly through `POST /model/import/...`; the model does not grow from a criterion alone. See `errors.WORKFLOW_FAILED` and `docs/cloud-parity/unevaluable-criterion-fails-save.md`.

`group` criteria combine conditions with `AND`, `OR`, or `NOT`. `NOT` takes **exactly one** child condition (`NOT(A AND B)` is written by nesting, not as a two-entry list); zero entries or two-or-more is rejected at import with `400 VALIDATION_FAILED`. Over a wildcard path `NOT` is a universal quantifier where the leaf beneath it is existential — `NOT($.tags[*] EQUALS "red")` fires when no element equals `"red"`, a different question from `$.tags[*] NOT_EQUAL "red"` (some element differs) — and `NOT` over an empty list, an explicit `null`, or an absent field fires, because the inner leaf is false. See `cyoda help search` for the full contract.

`lifecycle` criteria match entity metadata fields: `state`, `creationDate`, `lastUpdateTime`, `transitionForLatestSave` (alias `previousTransition`), `transactionId`, `id`. `creationDate` and `lastUpdateTime` are temporal — compared chronologically, exactly as in search.

A `null` criterion on a workflow means the workflow matches any entity. A `null` criterion on a transition means the transition always fires (automated) or is always available (manual). When multiple automated transitions are eligible, the engine selects the first one by declaration order whose criterion matches. A `null` criterion matches unconditionally, so a `null`-criterion automated transition must be the last automated transition in declaration order; any automated transitions declared after a `null`-criterion transition are unreachable.

### Workflow-level selection

When a model has more than one imported workflow definition, the engine picks the workflow per entity at execution time using these rules — applied in order on **every** door onto the engine, with no caching across calls: entity creation, a named transition, a loopback re-evaluation, a scheduled transition firing, and `GET /entity/{entityId}/transitions`:

1. Iterate workflows in their stored declaration order. (Storage preserves the order from the most recent import; MERGE inserts new workflows at the tail.)
2. Skip any workflow whose `active` flag is `false`. Inactive workflows are invisible to selection, regardless of their criterion.
3. For each active workflow, evaluate `criterion` against the entity payload and lifecycle metadata. A `null` (absent) criterion matches unconditionally — the workflow is selected immediately.
4. The first active workflow whose criterion matches is selected. Subsequent workflows in the array are not consulted.
5. If no active workflow matches — which includes the case where every active workflow has a criterion and none of them passes — the engine falls back to the embedded **default workflow**. The substitution surfaces on two channels: a body warning via `AddWarning` and an operator-visible `slog.Warn` line (`reason=no_criterion_matched`).

Place a `null`-criterion (or otherwise unconditional) workflow last in the import array if you want it to act as a catch-all. Any active workflows declared after it are unreachable for the same reason an unguarded automated transition shadows successors at the transition level.

Workflow-level selection is independent of transition-level selection: once a workflow is chosen, the engine then applies the transition-evaluation rules above against that workflow's `states` map.

Because selection is re-evaluated per call, editing an entity's payload can re-bind it to a different definition. If its current state is not declared in the newly selected workflow, the engine does **not** fall through to another definition that happens to declare it: a named transition is rejected with `400 WORKFLOW_FAILED`, and a loopback settles as a no-op.

A pending scheduled task the newly selected workflow no longer declares as a scheduled transition of that state is **not** cancelled by the write that caused the re-bind: cancellation on re-arm only removes tasks for a state the entity has left, and this one names the state the entity is still in. The task is discarded when it next comes due — the fire door re-resolves the workflow, finds no such scheduled transition, deletes the row and records `SCHEDULED_TRANSITION_CANCEL`. Nothing wrong fires in the meantime, but a timer retired this way is reported at its scheduled time, not at the write, and is attributed to the system principal.

Two consequences worth designing for:

- **Select on fields the caller cannot rewrite.** The criterion is evaluated against the payload of the request being served, so a criterion over a client-writable field lets one request choose which definition's guards apply to itself. Where definitions differ in what they permit, select on immutable fields or on lifecycle metadata.
- **Select on something that stays true for the entity's whole lifetime.** A criterion over a field that changes mid-flow can strand an entity in a state its new definition does not declare, and can silently retire a scheduled transition that was acting as a time-based control.

Selection is audited: each skipped workflow records a `WORKFLOW_SKIP` event (with the criterion's rejection reason) and the chosen one a `WORKFLOW_FOUND` event, under the transaction driving the call. `GET /entity/{entityId}/transitions` is a pure read and records nothing.

## IMPORT REQUEST

**POST /api/model/{entityName}/{modelVersion}/workflow/import**

- `entityName` (path): string
- `modelVersion` (path): int32

Request body (`application/json`):

```json
{
  "importMode": "MERGE",
  "workflows": [
    { ...WorkflowDefinition... }
  ]
}
```

- `importMode` — `"MERGE"` (default): incoming workflows overwrite existing ones by name; existing workflows not in the import are preserved. `"REPLACE"`: all existing workflows are discarded; only the incoming set is stored. `"ACTIVATE"`: incoming workflows replace same-named existing ones; existing workflows not in the import set are kept but flipped `active=false`. `REPLACE` / `ACTIVATE` reject an empty `workflows` array (or a missing `workflows` key) with `400 VALIDATION_FAILED` — once a model has imported workflows it always carries ≥1; the built-in default workflow is only used when no workflow has ever been imported. `MERGE` with an empty `workflows` array is allowed as a no-op. The field defaults to `MERGE` when omitted or empty. Parsing is **case-insensitive**: `"merge"`, `"Merge"`, and `"MERGE"` are equivalent. Any value outside the documented enum (after case-folding) is rejected with `400 BAD_REQUEST`.
- `workflows` — array of `WorkflowDefinition`. The `active` flag on each incoming workflow is preserved as supplied; the server never overrides it. If the field is absent (or explicitly `null`), it defaults to `true`. Controlling which workflows are active is entirely up to the importer.

Static validation runs on the incoming request before saving. Any of the following returns `400 VALIDATION_FAILED` with the offending workflow / state / transition named in `detail`:

- Definite infinite loops — cycles reachable only via unguarded automated transitions.
- Empty workflow `name`, or two workflows in the same request sharing a `name`.
- Empty `initialState`, or `initialState` not declared in `states`.
- Empty state-map key (i.e. `"states": { "": { … } }`).
- Empty or duplicate transition `name` within a single state.
- Empty processor `name`.
- Workflow / state / transition / processor names longer than 256 characters.
- Transition `next` not declared in `states`.
- Unknown `executionMode` value on any processor (allowed: `SYNC`, `ASYNC_SAME_TX`, `ASYNC_NEW_TX`, `COMMIT_BEFORE_DISPATCH`, or empty).
- Unknown `retryPolicy` value on any processor (allowed: `NONE`, `FIXED`, or empty).
- `startNewTxOnDispatch=true` on a processor whose `executionMode` is not `COMMIT_BEFORE_DISPATCH`.
- Empty `workflows` array (or a missing `workflows` key) when `importMode` is `REPLACE` or `ACTIVATE`. `MERGE` with an empty array is a legitimate no-op.
- A criterion `jsonPath` (on a `simple` or `array` clause, at any nesting depth) that is not JSON Path — see CRITERIA below.
- A criterion `LIKE` or `MATCHES_PATTERN` value that is not a valid pattern.
- A criterion `lifecycle` clause naming an unknown metadata field, or comparing a temporal field (`creationDate`, `lastUpdateTime`) against a non-timestamp operand.
- A criterion `group` clause whose `operator` is not `AND`, `OR`, or `NOT`, or whose `operator` is `NOT` with `conditions` other than exactly one entry.

The new structural rules (state graph, name uniqueness, `executionMode` enum, `retryPolicy` enum) run on the incoming request only — existing stored workflows are not retroactively re-checked against them. The cycle-detection and `startNewTxOnDispatch` coherence checks continue to run against the merged result, so a legacy stored cycle or incoherent flag still surfaces at any subsequent import.

Response: `200 OK`, `application/json`:

```json
{"success": true}
```

### Audit log on success

Every successful import emits a single structured `log/slog` line so operators can correlate workflow-config changes in their log pipeline.

- **Normal path** — `level=INFO`, `msg="workflow import applied"`. Fields: `pkg=workflow`, `tenant`, `entityName`, `modelVersion`, `importMode`, `workflowCount` (size of THIS call's incoming payload), `storedWorkflowCount` (model's post-merge total), `workflows` (array of `{name, desc}` reflecting the incoming payload — the audit subject is what was applied, not the resulting model state).
- **Zero-result canary** — `level=WARN`, `msg="workflow import resulted in zero workflows"`, same field shape. After `REPLACE` / `ACTIVATE` empty became a `400 VALIDATION_FAILED` (see above), the only reachable path is a `MERGE` with an empty `workflows` array against a model that has no prior workflows. The model will then silently fall back to the embedded default on the next entity execution; this canary surfaces that outcome before it shows up in entity-execution logs.

The `desc` field on each workflow is surfaced in the audit log digest, truncated to 200 characters with a `...` suffix when longer — set a meaningful description to record change intent that log readers can correlate without consulting the workflow JSON.

## EXPORT RESPONSE

**GET /api/model/{entityName}/{modelVersion}/workflow/export**

Response: `200 OK`, `application/json`:

```json
{
  "entityName": "nobel-prize",
  "modelVersion": 1,
  "workflows": [
    { ...WorkflowDefinition... }
  ]
}
```

Returns `404 WORKFLOW_NOT_FOUND` when no workflows have been imported for the model.

**Export field omission:** The export response omits optional fields that were not explicitly set or are default values. Specifically, `TransitionDefinition` objects in the export may omit `disabled` (when `false`) and `processors` (when empty). States with no transitions are serialised as `{}` rather than `{"transitions":[]}`. The `desc` field on `WorkflowDefinition` is omitted when empty. `annotations` (on the workflow, any state, any transition, or any processor) and `criterionAnnotations` (on the workflow or any transition) are omitted when absent, and re-serialised in compacted form when present.

## ENGINE EXECUTION

The workflow engine runs synchronously within the entity write transaction. The execution sequence for a CREATE:

1. Load workflow definitions for the model.
2. Evaluate each workflow's `criterion` against the entity; select the first match. If none match (or if no workflows have been imported for the model), use the built-in default workflow. The substitution emits both a `slog.Warn` line (fields: `pkg=workflow`, `tenant`, `entityName`, `modelVersion`, `entityId`, `reason=no_workflows_imported`|`no_criterion_matched`) and an `AddWarning` entry surfaced in the response body, so operators can detect models silently running on the default.
3. Set `entity.Meta.State = workflow.initialState`.
4. If a named transition was requested (by the client), execute it: evaluate `criterion`, invoke processors, set `entity.Meta.State = transition.next`.
5. Cascade: repeatedly scan the current state's transitions; for each automated (`manual=false`) non-disabled transition, evaluate `criterion`; if it matches, invoke processors and advance the state. Stop when no automated transition matches or the state has no automated transitions.
6. The engine records `StateMachineEvent` entries to the audit log under the entity's `transactionId`.

Per-state visit limit (default 10) and total cascade depth limit (100) are enforced to prevent infinite loops.

## ERRORS

- `errors.TRANSITION_NOT_FOUND` — `400` — named transition does not exist in the current state's workflow
- `errors.WORKFLOW_NOT_FOUND` — `404` — no workflows found for the model (export endpoint)
- `errors.WORKFLOW_FAILED` — workflow engine encountered an unrecoverable error during execution
- `errors.NO_COMPUTE_MEMBER_FOR_TAG` — no registered calculation node matches the required `calculationNodesTags`
- `errors.COMPUTE_MEMBER_DISCONNECTED` — a calculation node disconnected during processor dispatch
- `errors.WORKFLOW_SCHEMA_VERSION_UNSUPPORTED` — `400` — workflow declares a schema version this server does not accept
- `errors.VALIDATION_FAILED` — `400` — workflow import validation failed; see IMPORT REQUEST above for the enumerated rules

## EXAMPLES

**Import a workflow:**

```
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "importMode": "MERGE",
    "workflows": [
      {
        "version": "1.4",
        "name": "prize-lifecycle",
        "initialState": "NEW",
        "active": true,
        "states": {
          "NEW": {
            "transitions": [
              {
                "name": "APPROVE",
                "next": "APPROVED",
                "manual": true,
                "processors": []
              }
            ]
          },
          "APPROVED": {
            "transitions": []
          }
        }
      }
    ]
  }' \
  "http://localhost:8080/api/model/nobel-prize/1/workflow/import"
```

**Export workflows:**

```
curl -s -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8080/api/model/nobel-prize/1/workflow/export"
```

**Trigger a manual transition:**

```
curl -s -X PUT \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"category":"physics","year":"2024"}' \
  "http://localhost:8080/api/entity/JSON/74807f00-ed0d-11ee-a357-ae468cd3ed16/APPROVE"
```

**Replace all workflows:**

```
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "importMode": "REPLACE",
    "workflows": [
      {
        "version": "1.4",
        "name": "simple-wf",
        "initialState": "OPEN",
        "active": true,
        "states": {
          "OPEN": { "transitions": [] }
        }
      }
    ]
  }' \
  "http://localhost:8080/api/model/nobel-prize/1/workflow/import"
```

## SEE ALSO

- models
- crud
- grpc
- search
- errors.TRANSITION_NOT_FOUND
- errors.WORKFLOW_NOT_FOUND
- errors.WORKFLOW_FAILED
- errors.NO_COMPUTE_MEMBER_FOR_TAG
- errors.COMPUTE_MEMBER_DISCONNECTED
- errors.WORKFLOW_SCHEMA_VERSION_UNSUPPORTED
- errors.VALIDATION_FAILED
- errors.MODEL_NOT_FOUND

## Subtopics

- [`cyoda help workflows schema-version`](/help/workflows/schema-version/) — workflows schema-version — semver `MAJOR.MINOR` contract identifying the workflow-import DTO shape that a workflow definition was authored against.

## See also

- [`cyoda help models`](/help/models/) — A model is a named, versioned schema registered per tenant. Every entity in the system is an instance of exactly one model. Models are identified by `(entityName, modelVersion)`. The model ID is a deterministic UUID v5 derived from that key: `UUID.newSHA1(NameSpaceURL, "{entityName}.{modelVersion}")`.
- [`cyoda help crud`](/help/crud/) — Entities are instances of models. Each entity has a UUID, a model reference (`entityName`, `modelVersion`), and a lifecycle state managed by the workflow engine. Creating an entity requires the referenced model to be in `LOCKED` state. All write operations run within a Cyoda transaction and return a `transactionId` alongside the affected entity IDs.
- [`cyoda help grpc`](/help/grpc/) — cyoda-go exposes one gRPC service: `CloudEventsService` (package `org.cyoda.cloud.api.grpc`). All gRPC methods use the CloudEvents Protobuf envelope (`io.cloudevents.v1.CloudEvent`) as both request and response types. The event type string in the CloudEvent envelope selects the operation; the JSON payload in `text_data` (or `binary_data`) carries the operation-specific body.
- [`cyoda help search`](/help/search/) — Search operates against a specific entity model `(entityName, modelVersion)`. Two modes are supported:
- [`cyoda help workflows schema-version`](/help/workflows/schema-version/) — workflows schema-version — semver `MAJOR.MINOR` contract identifying the workflow-import DTO shape that a workflow definition was authored against.
- [`cyoda help errors TRANSITION_NOT_FOUND`](/help/errors/transition_not_found/) — Entity workflow state machines define explicit transitions between states. This error fires when a transition is triggered that does not exist in the model's workflow definition for the entity's current state. Also occurs when the transition name is misspelled, when the transition is disabled, when it is a scheduled transition (those fire on their timer and are never manually fireable), or when the entity is in a terminal state that allows no further transitions.
- [`cyoda help errors WORKFLOW_NOT_FOUND`](/help/errors/workflow_not_found/) — Entity models reference a workflow by name to govern state transitions. This error is returned when the named workflow cannot be found in the tenant's workflow registry, during entity type registration or when a model references a workflow that was deleted.
- [`cyoda help errors WORKFLOW_FAILED`](/help/errors/workflow_failed/) — During an entity create or transition operation the associated workflow processors (pre-processors, post-processors) or guard conditions ran but one of them signalled failure. The failure message from the processor is included in the error detail.
- [`cyoda help errors NO_COMPUTE_MEMBER_FOR_TAG`](/help/errors/no_compute_member_for_tag/) — Workflow processors are dispatched to nodes that advertise matching compute tags. When no node with the required tag is alive in the cluster within the configured wait timeout (`CYODA_DISPATCH_WAIT_TIMEOUT`), the operation is rejected with this error.
- [`cyoda help errors COMPUTE_MEMBER_DISCONNECTED`](/help/errors/compute_member_disconnected/) — The compute member responsible for executing a processor or workflow step disconnected before completing the operation. The task may or may not have been executed.
- [`cyoda help errors WORKFLOW_SCHEMA_VERSION_UNSUPPORTED`](/help/errors/workflow_schema_version_unsupported/) — Every workflow definition must declare a schema version in `MAJOR.MINOR` format (for example `"version": "1.1"`). This error is returned when the import request contains a version string that does not match any supported schema version.
- [`cyoda help errors VALIDATION_FAILED`](/help/errors/validation_failed/) — Unlike `BAD_REQUEST` (which covers parse failures, bad parameters, and unstorable bytes), this error is returned when the payload parsed and then failed against the registered model. On an entity write that means:
- [`cyoda help errors MODEL_NOT_FOUND`](/help/errors/model_not_found/) — The entity type or model name specified in the request does not exist in the tenant's model registry. Occurs on write paths (creating entities with an unknown type, importing data that references a missing model, performing model lifecycle transitions on a model ID that does not exist) and on read paths (list, stats, grouped-stats, and search operations that reference an unregistered model).
- [`cyoda help errors SCHEDULE_FUNCTION_INVALID_RESULT`](/help/errors/schedule_function_invalid_result/) — A `TransitionSchedule.function` callout must return `resultKind: "Schedule"` with a value shaped `{fireAt|fireAfterMs, expireAt?|expireAfterMs?}` — exactly one of `fireAt`/`fireAfterMs`, and at most one of `expireAt`/`expireAfterMs`. This error is raised when the compute node returns a different `resultKind`, or a `Schedule` value that is malformed: missing or duplicate fire/expiry fields, an unknown field, or a non-numeric value.

## Raw formats

- [`/help/workflows.json`](/help/workflows.json) — full descriptor (matches `GET /help/{topic}` envelope)
- [`/help/workflows.md`](/help/workflows.md) — body only