Appearance
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:
- a workflow definition — the stages, the approval task, who may decide
- an entity rule — "when status enters
PENDING_APPROVAL, start that workflow" - 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
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
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/transitionsJson— each is a JSON-encoded string, not a nested object.stagesJson: "[\"manager\"]"is the string["manager"]. Same rule as an entity'slabel.stagesJson— ordered stage codes. One stage =["manager"]. A single actor who both requests and decides =["only"].tasksJson— one approval task per stage.payload.approvalObjectis the string thatapprovalPermissionsand 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*Jsonstring).roleCodereferences a role by its stablecode— never a display name. List every role that may decide.
The rule
triggerEvent: "AFTER_UPDATE"— fires after any update to anoeq_checkoutrow.conditions— a JSON string.status__previousis the value before the update; theallclause means "status is nowPENDING_APPROVALand it wasn't before" — so the workflow starts exactly once, on the transition.actions— a JSON string containing oneSTART_WORKFLOW:workflowName— matches the workflow'sname.callbackUrl— the generic entity workflow-callback endpoint. On this environment its base ishttp://localhost:8080; on yours use your ERP's base URL. (Platform teams are moving this to a relative path — checkerp platform describeifSTART_WORKFLOWconfig changes.)config.fields— "when the workflow approves, setstatustoAPPROVED; 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.20leaves room for validation rules at10.
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 workflowerp 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 3json
[ { "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 5012json
[ { "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/tasksreturns the real running instance and its pending task.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
| workflow never starts | rule condition missing the status__previous guard | add it, or the rule re-fires on every later update |
| workflow starts on every save | same | as above |
| decision never lands back on the record | config.fields missing or wrong status values | { "name": "status", "approved": "APPROVED", "rejected": "REJECTED" } |
| install rejects the workflow | stagesJson/tasksJson authored as objects | they are JSON strings |
| nobody can approve | approvalPermissions references a role display name | use roleCode (the stable code) |
!= null never matches in a rule | action-engine quirk | use is_not_null / is_null operators |
What to read next
- Add business rules and expressions
- Recipe: entity with an approval workflow + reminder job
- Tutorial chapter 6: the checkout approval workflow