workflows — state machine definitions
cyoda-go version 0.8.3
workflows
Section titled “workflows”workflows — workflow state machine definitions: states, transitions, processors, and criteria.
SYNOPSIS
Section titled “SYNOPSIS”POST /api/model/{entityName}/{modelVersion}/workflow/importGET /api/model/{entityName}/{modelVersion}/workflow/exportContext path prefix is CYODA_CONTEXT_PATH (default /api). All endpoints require Authorization: Bearer <token> except when CYODA_IAM_MODE=mock.
DESCRIPTION
Section titled “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
Section titled “WORKFLOW SCHEMA”WorkflowDefinition (element of the workflows array in import):
{ "version": "1.3", "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— semverMAJOR.MINORstring identifying the workflow-import contract this definition was authored against. Validated strictly on import; stamped to the current contract version on export. Seecyoda help workflows schema-versionfor the bump rules and current/supported list.name— string — unique within the model; the primary key for MERGE modedesc— string — optional human-readable description. Surfaced in the import audit log line (workflow import appliedatINFO, orworkflow import resulted in zero workflowsatWARN) 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 JSONinitialState— string — state assigned when the entity is first created; must exist instatesactive— boolean — whenfalse, the engine skips this workflow during selectioncriterion—ConditionJSON ornull— evaluated against the entity at creation to select this workflow;nullmatches all entitiesstates— object — map of state name →StateDefinitionannotations— 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,displayNameanddescription(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 viacriterionAnnotations— share this same bag shapecriterionAnnotations— object or absent — optional client-owned metadata attached to this workflow’scriterionas a whole (seeannotationsabove); a sibling field rather than embedded in the criterion, so the criterion blob keeps round-tripping byte-verbatim
StateDefinition:
transitions— array ofTransitionDefinition— may be emptyannotations— object or absent — optional client-owned metadata (see WorkflowDefinitionannotations); object-only, 64 KB cap, engine-opaque
TRANSITIONS
Section titled “TRANSITIONS”TransitionDefinition fields:
name— string — transition name; used by the client inPUT /entity/{format}/{entityId}/{name}and in engine cascadenext— string — target state; must exist instatesmanual— boolean —truemeans the transition requires an explicit client request;falsemeans the engine evaluates it automatically in cascadedisabled— boolean — whentrue, the engine skips this transition entirelycriterion—ConditionJSON ornull— evaluated before executing the transition;nullmeans always matches; the same Condition DSL as search (seesearchtopic)criterionAnnotations— object or absent — optional client-owned metadata attached to this transition’scriterionas a whole (see WorkflowDefinitionannotations); sibling field, engine-opaqueprocessors— array ofProcessorDefinition— invoked sequentially on this transitionannotations— object or absent — optional client-owned metadata (see WorkflowDefinitionannotations); object-only, 64 KB cap, engine-opaque
PROCESSORS
Section titled “PROCESSORS”ProcessorDefinition fields:
type— string — execution-location axis; see below for valid valuesname— string — logical processor nameexecutionMode— string — execution mode; see valid values belowconfig—ProcessorConfigannotations— object or absent — optional client-owned metadata (see WorkflowDefinitionannotations); object-only, 64 KB cap, engine-opaque. Excluded from the gRPCEntityProcessorCalculationRequestsent 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 byConfig.calculationNodesTags. This is the only execution location implemented today; all theexecutionModesemantics 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 andsuccess=falsein the response) returnserrors.WORKFLOW_FAILED(400) and the entity remains in the source state"ASYNC_SAME_TX"— same dispatch mechanics asSYNC(blocks inline, transaction stays open); failure semantics are identical toSYNC"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_preflushes 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 opensTX_poston the same node, reapplies the result viaCompareAndSave(CAS expects the txID stamped atTX_pre’s commit), runs any subsequent SYNC processors and cascade transitions, then commits. CAS conflict at the boundary surfaces as409 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) returnserrors.WORKFLOW_FAILED(400) and the entity remains in the pre-callout state. Designed to relieve connection-pool pressure for slow processors and supersedesASYNC_NEW_TXas the recommended mode for slow external work.
COMMIT_BEFORE_DISPATCH configuration flag:
startNewTxOnDispatch— boolean — sibling field on the same processor object; defaultfalse; valid only whenexecutionMode == "COMMIT_BEFORE_DISPATCH". Validator rejectstruefor any other mode. Whentrue, 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. Whenfalse, no transaction context is supplied to the dispatched call.
COMMIT_BEFORE_DISPATCH workflow-author requirements:
- Idempotency. A
COMMIT_BEFORE_DISPATCHprocessor 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_DISPATCHprocessor) are publicly observable to readers between segments. A concurrent transaction’sGet/GetAll/Search/Countwill 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 usingCOMMIT_BEFORE_DISPATCHmust 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-levelDRAFTparent 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, defaulttrue— whentrue, the full entity payload is sent to the processor; setfalseto omit itcalculationNodesTags— string — comma-separated tags for routing to registered calculation nodes; the engine selects a node that declares all required tags; returnserrors.NO_COMPUTE_MEMBER_FOR_TAGif no node matchesresponseTimeoutMs— int64 — timeout in milliseconds forSYNCprocessor response;0means use node defaultretryPolicy— 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 toFIXEDat engine fire. Import-time validation rejects any other value with400 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 theparametersJSON node of the outgoingEntityProcessorCalculationRequest(andEntityCriteriaCalculationRequestwhen used on afunction-typed criterion’sconfig). 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 inparameters. Emptycontextcausesparametersto 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 setasyncResult: trueare rejected with400 VALIDATION_FAILED. The explicitasyncResult: falseand 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 with400 VALIDATION_FAILED, including the orphan case whereasyncResultis absent or false.
SCHEDULED TRANSITIONS
Section titled “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:
{ "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; explicit0is strictest (drop on any lateness). Independent ofdelayMs— 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:
{ "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, defaulttrue) — whether the entity payload is attached to the request.context(string, optional) — pass-through string forwarded verbatim as the request’sparameters; 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:
{ "fireAfterMs": 3600000, "expireAfterMs": 600000 }- Fire time (required) — exactly one of
fireAt(absolute, epoch-ms) orfireAfterMs(relative to arm time). A pastfireAt(or non-positivefireAfterMs) is not an error — the transition is due immediately. - Expiry (optional) — at most one of
expireAt(absolute) orexpireAfterMs(relative to the resolved fire time, not arm time). Both absent means no expiry (equivalent to an absenttimeoutMs). A resolved expiry after the fire time becomes thetimeoutMslate-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.functionmust be present (VALIDATION_FAILED). scheduleandmanual: trueare mutually exclusive (VALIDATION_FAILED).- Static mode:
delayMs <= 0is rejected (VALIDATION_FAILED);timeoutMsneed only be>= 0. - Function mode:
nameandcalculationNodesTagsmust be non-empty, andresultKindmust 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_MAKEis recorded). Afalsecriterion 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 thantimeoutMspast the scheduled time, it is dropped without evaluating the criterion (Expired) — the transition never fires and the entity stays put. NotimeoutMs(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 messagetransition "X" in state "Y" is scheduled and fires automatically; it is not manually fireable. Same code returned when a transition isdisabled: 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 (the
entity leaving the source state before the timer fires) each emit a
dedicated event:
SCHEDULED_TRANSITION_ARM,SCHEDULED_TRANSITION_FIRE(alongside the ordinaryTRANSITION_MAKE),SCHEDULED_TRANSITION_EXPIRE,SCHEDULED_TRANSITION_CANCEL. A loopback that re-arms the same state emits onlyARM, notCANCEL.
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 everydelayMs, forever, for every entity in the cycle. RequiresallowCycles: trueat 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
falsecriterion 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:
{ "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
Section titled “CRITERIA”Criteria on workflows and transitions use the same Condition DSL as search. All four condition types are supported: simple, lifecycle, group, array. Criteria are evaluated in-memory against the entity’s JSON payload and lifecycle metadata.
simple criteria match entity data fields via JSONPath. lifecycle criteria match state, creationDate, or previousTransition from entity metadata.
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
Section titled “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 Execute / ManualTransition / Loopback (no caching across calls):
- Iterate workflows in their stored declaration order. (Storage preserves the order from the most recent import; MERGE inserts new workflows at the tail.)
- Skip any workflow whose
activeflag isfalse. Inactive workflows are invisible to selection, regardless of their criterion. - For each active workflow, evaluate
criterionagainst the entity payload and lifecycle metadata. Anull(absent) criterion matches unconditionally — the workflow is selected immediately. - The first active workflow whose criterion matches is selected. Subsequent workflows in the array are not consulted.
- 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
AddWarningand an operator-visibleslog.Warnline (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.
IMPORT REQUEST
Section titled “IMPORT REQUEST”POST /api/model/{entityName}/{modelVersion}/workflow/import
entityName(path): stringmodelVersion(path): int32
Request body (application/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 flippedactive=false.REPLACE/ACTIVATEreject an emptyworkflowsarray (or a missingworkflowskey) with400 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.MERGEwith an emptyworkflowsarray is allowed as a no-op. The field defaults toMERGEwhen omitted or empty. Parsing is case-insensitive:"merge","Merge", and"MERGE"are equivalent. Any value outside the documented enum (after case-folding) is rejected with400 BAD_REQUEST.workflows— array ofWorkflowDefinition. Theactiveflag on each incoming workflow is preserved as supplied; the server never overrides it. If the field is absent (or explicitlynull), it defaults totrue. 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 aname. - Empty
initialState, orinitialStatenot declared instates. - Empty state-map key (i.e.
"states": { "": { … } }). - Empty or duplicate transition
namewithin a single state. - Empty processor
name. - Workflow / state / transition / processor names longer than 256 characters.
- Transition
nextnot declared instates. - Unknown
executionModevalue on any processor (allowed:SYNC,ASYNC_SAME_TX,ASYNC_NEW_TX,COMMIT_BEFORE_DISPATCH, or empty). - Unknown
retryPolicyvalue on any processor (allowed:NONE,FIXED, or empty). startNewTxOnDispatch=trueon a processor whoseexecutionModeis notCOMMIT_BEFORE_DISPATCH.- Empty
workflowsarray (or a missingworkflowskey) whenimportModeisREPLACEorACTIVATE.MERGEwith an empty array is a legitimate no-op.
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:
{"success": true}Audit log on success
Section titled “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. AfterREPLACE/ACTIVATEempty became a400 VALIDATION_FAILED(see above), the only reachable path is aMERGEwith an emptyworkflowsarray 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
Section titled “EXPORT RESPONSE”GET /api/model/{entityName}/{modelVersion}/workflow/export
Response: 200 OK, application/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
Section titled “ENGINE EXECUTION”The workflow engine runs synchronously within the entity write transaction. The execution sequence for a CREATE:
- Load workflow definitions for the model.
- Evaluate each workflow’s
criterionagainst 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 aslog.Warnline (fields:pkg=workflow,tenant,entityName,modelVersion,entityId,reason=no_workflows_imported|no_criterion_matched) and anAddWarningentry surfaced in the response body, so operators can detect models silently running on the default. - Set
entity.Meta.State = workflow.initialState. - If a named transition was requested (by the client), execute it: evaluate
criterion, invoke processors, setentity.Meta.State = transition.next. - Cascade: repeatedly scan the current state’s transitions; for each automated (
manual=false) non-disabled transition, evaluatecriterion; if it matches, invoke processors and advance the state. Stop when no automated transition matches or the state has no automated transitions. - The engine records
StateMachineEvententries to the audit log under the entity’stransactionId.
Per-state visit limit (default 10) and total cascade depth limit (100) are enforced to prevent infinite loops.
ERRORS
Section titled “ERRORS”errors.TRANSITION_NOT_FOUND—404— named transition does not exist in the current state’s workflowerrors.WORKFLOW_NOT_FOUND—404— no workflows found for the model (export endpoint)errors.WORKFLOW_FAILED— workflow engine encountered an unrecoverable error during executionerrors.NO_COMPUTE_MEMBER_FOR_TAG— no registered calculation node matches the requiredcalculationNodesTagserrors.COMPUTE_MEMBER_DISCONNECTED— a calculation node disconnected during processor dispatcherrors.WORKFLOW_SCHEMA_VERSION_UNSUPPORTED—400— workflow declares a schema version this server does not accepterrors.VALIDATION_FAILED—400— workflow import validation failed; see IMPORT REQUEST above for the enumerated rules
EXAMPLES
Section titled “EXAMPLES”Import a workflow:
curl -s -X POST \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "importMode": "MERGE", "workflows": [ { "version": "1.3", "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.3", "name": "simple-wf", "initialState": "OPEN", "active": true, "states": { "OPEN": { "transitions": [] } } } ] }' \ "http://localhost:8080/api/model/nobel-prize/1/workflow/import"SEE ALSO
Section titled “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
Section titled “Subtopics”cyoda help workflows schema-version— workflows schema-version — semverMAJOR.MINORcontract identifying the workflow-import DTO shape that a workflow definition was authored against.
See also
Section titled “See also”cyoda 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— 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 inLOCKEDstate. All write operations run within a Cyoda transaction and return atransactionIdalongside the affected entity IDs.cyoda help grpc— cyoda-go exposes one gRPC service:CloudEventsService(packageorg.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 intext_data(orbinary_data) carries the operation-specific body.cyoda help search— Search operates against a specific entity model(entityName, modelVersion). Two modes are supported:cyoda help workflows schema-version— workflows schema-version — semverMAJOR.MINORcontract identifying the workflow-import DTO shape that a workflow definition was authored against.cyoda 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 or when the entity is in a terminal state that allows no further transitions.cyoda 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— 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— 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— 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— Every workflow definition must declare a schema version inMAJOR.MINORformat (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— UnlikeBAD_REQUEST(which covers parse failures), this error is returned when the payload is parseable but violates the registered model schema — for example, a required field is missing, a value is out of the allowed range, or a workflow guard condition is not satisfied. The error detail includes the specific validation failure.cyoda 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— ATransitionSchedule.functioncallout must returnresultKind: "Schedule"with a value shaped{fireAt|fireAfterMs, expireAt?|expireAfterMs?}— exactly one offireAt/fireAfterMs, and at most one ofexpireAt/expireAfterMs. This error is raised when the compute node returns a differentresultKind, or aSchedulevalue that is malformed: missing or duplicate fire/expiry fields, an unknown field, or a non-numeric value.
Raw formats
Section titled “Raw formats”/help/workflows.json— full descriptor (matchesGET /help/{topic}envelope)/help/workflows.md— body only