Skip to content

Add an approval workflow

What you're doing

Making a record require sign-off before it advances. When a user submits a checkout, its status goes to PENDING_APPROVAL; a manager approves or rejects; the platform flips the status to APPROVED or REJECTED. You write no approval code — you declare three things:

  1. a workflow definition — the stages, the approval task, who may decide
  2. an entity rule — "when status enters PENDING_APPROVAL, start that workflow"
  3. nothing else — a generic callback controller writes the decision back

The mental model

user sets status = PENDING_APPROVAL


entity rule (AFTER_UPDATE)  ──START_WORKFLOW──▶  workflow instance + a human task
        │                                              │
        │                                     manager approves / rejects
        ▼                                              │
generic workflow-callback controller  ◀────────────────┘


record.status = APPROVED  (or REJECTED)

The complete example

1. The workflow — spk-assembly/metadata/workflow/office-equipment.checkout-approval.json

Real file:

json
{
  "name": "office-equipment.checkout-approval",
  "description": "Single-stage Office Equipment Manager approval for a checkout submitted for approval.",
  "stagesJson": "[\"manager\"]",
  "tasksJson": "[{\"taskKey\":\"manager-approve\",\"stage\":\"manager\",\"taskType\":\"approval\",\"kind\":\"human\",\"payload\":{\"approvalObject\":\"office-equipment.checkout-approval\",\"amount\":0,\"dueInSeconds\":259200}}]",
  "transitionsJson": "[]",
  "approversJson": "{}",
  "slasJson": "[]",
  "escalationsJson": "[]",
  "notificationsJson": "[]",
  "metadataJson": "{}",
  "approvalPermissions": [
    { "roleCode": "OFFICE_EQUIPMENT_MANAGER", "approvalObject": "office-equipment.checkout-approval", "maxAmount": null }
  ]
}

2. The rule — spk-assembly/metadata/rules/oeq_checkout_submit_workflow.json

Real file:

json
{
  "entityType": "oeq_checkout",
  "name": "oeq_checkout_submit_workflow",
  "description": "On transition INTO PENDING_APPROVAL, start office-equipment.checkout-approval; the generic workflow callback controller flips status to APPROVED/REJECTED on the decision.",
  "triggerEvent": "AFTER_UPDATE",
  "conditions": "{\"all\":[{\"field\":\"status\",\"op\":\"eq\",\"value\":\"PENDING_APPROVAL\"},{\"field\":\"status__previous\",\"op\":\"neq\",\"value\":\"PENDING_APPROVAL\"}]}",
  "actions": "[{\"type\":\"START_WORKFLOW\",\"workflowName\":\"office-equipment.checkout-approval\",\"callbackUrl\":\"http://localhost:8080/api/v1/entities/workflow-callback\",\"config\":{\"entityType\":\"oeq_checkout\",\"idField\":\"id\",\"fields\":[{\"name\":\"status\",\"approved\":\"APPROVED\",\"rejected\":\"REJECTED\"}]}}]",
  "priority": 20,
  "active": true
}

3. The Submit button on the page

json
{
  "blockType": "core.button",
  "properties": { "labelKey": { "source": "static", "value": "office-equipment.equipment-checkout.detail.submitBtn" } },
  "events": { "clicked": { "source": "action-chain", "actions": [
    { "id": "a0", "order": 0, "type": "callApi",
      "config": { "connectionRef": "self", "path": "/api/v1/entities/oeq_checkout/records/${page.detailRecord.id}", "httpMethod": "PUT", "params": { "status": "PENDING_APPROVAL" } } },
    { "id": "a1", "order": 1, "type": "callApi",
      "config": { "connectionRef": "self", "path": "/api/v1/entities/oeq_checkout/records/${page.detailRecord.id}", "httpMethod": "GET", "params": {} }, "output": "refreshed" },
    { "id": "a2", "order": 2, "type": "setValue", "config": { "field": "page.detailRecord", "value": "${refreshed}" } },
    { "id": "a3", "order": 3, "type": "showToast", "config": { "message": "Submitted for approval." } }
  ] } }
}

Line by line

The workflow

  • name — must be <pluginId>.<something>, globally unique. The rule references it by this exact string.
  • stagesJson / tasksJson / transitionsJsoneach is a JSON-encoded string, not a nested object. stagesJson: "[\"manager\"]" is the string ["manager"]. Same rule as an entity's label.
  • stagesJson — ordered stage codes. One stage = ["manager"]. A single actor who both requests and decides = ["only"].
  • tasksJson — one approval task per stage. payload.approvalObject is the string that approvalPermissions and the platform's approval inbox key off. dueInSeconds: 259200 = 3 days.
  • transitionsJson: "[]" — no transitions needed for a single stage; reaching the last stage with no matching transition completes the instance. For a linear two-stage flow: "[{\"fromStage\":\"manager\",\"toStage\":\"hr\",\"condition\":{\"field\":\"decision\",\"op\":\"eq\",\"value\":\"approved\"}}]".
  • approvalPermissions — a real top-level array (not a *Json string). roleCode references a role by its stable code — never a display name. List every role that may decide.

The rule

  • triggerEvent: "AFTER_UPDATE" — fires after any update to an oeq_checkout row.
  • conditions — a JSON string. status__previous is the value before the update; the all clause means "status is now PENDING_APPROVAL and it wasn't before" — so the workflow starts exactly once, on the transition.
  • actions — a JSON string containing one START_WORKFLOW:
    • workflowName — matches the workflow's name.
    • callbackUrl — the generic entity workflow-callback endpoint. On this environment its base is http://localhost:8080; on yours use your ERP's base URL. (Platform teams are moving this to a relative path — check erp platform describe if START_WORKFLOW config changes.)
    • config.fields — "when the workflow approves, set status to APPROVED; when it rejects, REJECTED." This is what makes the decision land back on the row with zero code.
  • priority — lower runs first when multiple rules match. 20 leaves room for validation rules at 10.

Ground yourself first

bash
erp schema pull workflow-definition
erp schema pull entity-rule-definition
erp workflow validate spk-assembly/metadata/workflow/office-equipment.checkout-approval.json
erp examples patterns --kind workflow

erp examples patterns --kind workflow names three shapes: single-stage-self-decide, multi-stage-linear, multi-stage-conditional-branching, each pointing at a real shipped file.

How to verify it worked

After publishing, the rule is installed:

bash
erp api get "/api/v1/entity-rules?entityType=oeq_checkout"
json
[ { "id": 388, "entityType": "oeq_checkout", "name": "oeq_checkout_submit_workflow",
    "triggerEvent": "AFTER_UPDATE", "active": true,
    "actions": "[{\"type\": \"START_WORKFLOW\", ...}]" } ]

Then submit a checkout and confirm the status transitions correctly:

bash
erp api put "/api/v1/entities/oeq_checkout/records/3" --body '{"status":"PENDING_APPROVAL"}'
json
{ "id": 3, "checkout_number": "CO-200", "status": "PENDING_APPROVAL" }

The record stays PENDING_APPROVAL — correct — until a manager decides the task, at which point the callback flips it to APPROVED / REJECTED.

Confirm a real workflow instance was created and see its pending human task — engine-api re-exposes the workflow engine's read API on its own base URL, so this works even when the workflow engine runs as a separate service:

bash
erp workflow list --definition oeq_checkout_submit_workflow --record 3
json
[ { "id": 5012, "definitionName": "oeq_checkout_submit_workflow",
    "definitionVersion": 1, "status": "RUNNING", "currentStage": "manager-approval",
    "correlationId": "3" } ]

1 instance(s) for record 3 of "oeq_checkout_submit_workflow".
bash
erp workflow tasks 5012
json
[ { "id": 88, "stage": "manager-approval", "status": "PENDING",
    "approvalObject": "oeq_checkout.approve", "candidateApprovers": ["MANAGER"] } ]

A manager then decides it from the ERP's approval inbox (or, for automation, POST /api/v1/workflow-bridge/human-tasks/{id}/decide), and the callback lands the decision back on the record.

Verified in the tutorial: the rule installs, the submit transition persists without auto-resolving, and erp workflow list/tasks returns the real running instance and its pending task.

Common mistakes

SymptomCauseFix
workflow never startsrule condition missing the status__previous guardadd it, or the rule re-fires on every later update
workflow starts on every savesameas above
decision never lands back on the recordconfig.fields missing or wrong status values{ "name": "status", "approved": "APPROVED", "rejected": "REJECTED" }
install rejects the workflowstagesJson/tasksJson authored as objectsthey are JSON strings
nobody can approveapprovalPermissions references a role display nameuse roleCode (the stable code)
!= null never matches in a ruleaction-engine quirkuse is_not_null / is_null operators