Declarative approval workflows
An app declares approval workflows in workflows/*.workflow.ts using defineWorkflow from @ddcore/sdk. The framework enforces transitions on the server, refuses the insert, delete and direct-write paths that would bypass a workflow, locks the row (FOR UPDATE) during a transition, records administrative audit events (tab_audit_event) and document timeline comments (tab_comment), and provides Desk integration with state indicators and action buttons.
Defining a workflow
import { defineWorkflow } from "@ddcore/sdk";
export default defineWorkflow({
name: "Order Approval",
doctype: "Order",
stateField: "workflow_state", // Defaults to "workflow_state", configurable
initialState: "Draft",
states: [
{ state: "Draft", docstatus: 0, allowEdit: "Sales User" },
{ state: "Pending Approval", docstatus: 0, allowEdit: "Sales Manager" },
{ state: "Approved", docstatus: 1, allowEdit: "Sales Manager", updateFields: { status: "Approved" } },
{ state: "Rejected", docstatus: 2, updateFields: { status: "Rejected" } },
],
transitions: [
{
state: "Draft",
action: "Submit for Approval",
nextState: "Pending Approval",
allowed: ["Sales User", "Sales Manager"],
},
{
state: "Pending Approval",
action: "Approve",
nextState: "Approved",
allowed: "Sales Manager",
allowSelfApproval: false,
condition: (doc) => doc.total > 0,
},
{
state: "Pending Approval",
action: "Reject",
nextState: "Rejected",
allowed: "Sales Manager",
},
],
});Top-level properties
name: Unique name for the workflow definition.doctype: Target DocType governed by the workflow. Each DocType may have at most one active workflow.stateField: The field on the DocType storing the active state name. Defaults to"workflow_state"if omitted.initialState: The initial state assigned to newly inserted documents. Must exist instates.states: Non-empty list of state definitions (WorkflowStateDef).transitions: Non-empty list of transition definitions (WorkflowTransitionDef).
State declarations (states)
Each state defines:
state: String identifier of the state (e.g."Draft","Pending Approval","Approved").docstatus: Optional document lifecycle status bound to the state; defaults to0when omitted.0: Draft / In-progress1: Submitted (invokesbeforeSubmitandonSubmithooks when a transition changes docstatus to 1)2: Cancelled (invokesbeforeCancelandonCancelhooks when a transition changes docstatus to 2) A state withdocstatus: 1or2requires the DocType to besubmittable; the load-time check below rejects the workflow otherwise.
allowEdit: Optional role name. When specified, only users with this role (orAdministrator) can modify document fields while in this state. If omitted, normal DocType role permissions apply.updateFields: Optional map of field values to set atomically when the document enters this state (e.g.{ status: "Approved" }).- State and action names are translation keys: the Desk shows both through
__(), andddcore i18n extractcollects them into the app's translation catalogue.
Transition declarations (transitions)
Each transition defines:
state: Origin state name. Must exist instates.action: Name of the user action triggering the transition (e.g."Submit for Approval","Approve","Reject").nextState: Destination state name. Must exist instates.allowed: Role name or array of role names authorized to trigger this transition. Checked against the authenticated caller's roles.Administratoralways passes.allowSelfApproval: Optional boolean (default:true). Iffalse, the user who created the document (owner) cannot trigger this transition, even if they possess the authorized role. Running withignorePermissions(a background job, a patch) skips the role check but not this one — a non-Administratorowner still cannot self-approve whileignorePermissionsis raised.Administratoris exempt from self-approval through its own check (anyAdministratoraction is allowed, whether or notignorePermissionsis set), not becauseignorePermissionshappens to be set.condition: Optional synchronous JavaScript function(doc) => booleanrunning on goja.docis a plain JSON object (the document's field values), not aDocumentinstance — it has no methods. Evaluated before applying the transition; if it returnsfalse, the transition is rejected. Never useasync/awaitor Promises. A condition that throws hides the action fromGET /api/workflow/actions's list without failing the request; applying that action returns the thrown error to the caller without writing aDeniedaudit entry (unlike a wrong role, a self-approval violation, or a condition that returnsfalse, which do write one).
Workflows are linear state machines: ApplyWorkflowTransition matches the first transition whose state and action fit the current state and stops there, without checking its condition. Declaring two transitions from the same state with the same action name is a modeling error — the second is unreachable — so keep action unique per state.
Server-side enforcement and bypass prevention
A workflow's guards run in Insert, Delete, DBSet and SaveDoc. The insert, delete and direct state/docstatus mutation guards (1-3 below) are not lifted by ignorePermissions, whether passed as an option (doc.insert({ ignorePermissions: true }), ddcore.deleteDoc(doctype, name, { ignorePermissions: true })) or already raised on the context, which every enqueued job and scheduled method runs with. They yield only to an in-progress workflow transition, exactly like the state field and docstatus checks SaveDoc already enforces for a plain save: a workflow document cannot be submitted or cancelled "not even with ignorePermissions" (see below), and the same now holds for inserting, deleting and db.setValue-ing one. A patch that genuinely needs to repair workflow data (a bad migration, a one-off correction) writes SQL directly through ctx.sql in a patches/*.ts file, bypassing the document API rather than asking it for an exemption. The state editability guard (4) and row locking (5) are unrelated to that escape and keep their own rules, described below; the load-time checks (6) always run regardless.
Insert guard: Inserting a document whose DocType has an active workflow initializes
doc[stateField]toinitialStatewhen it is empty or omitted. An explicit state other thaninitialState, or adocstatusother than the initial state's, is rejected with aValidationError. This closes the path wherePOST /api/resource/<DocType> {docstatus: 1}(ordoc.insert()on a document built withdocstatus: 1) would create a submitted document still sitting in the initial state.Delete guard: Deleting a document governed by a workflow is refused with a
PermissionErrorunless its current state hasdocstatus: 0and either the state isinitialStateor the user's roles satisfy that state'sallowEdit(the same checkHasPermission(..., "write")applies; a state with noallowEditrestriction allows the delete, same as it allows a plain field edit). A document withdocstatus: 1still hits the existing "cancel before deleting" rule, and one withdocstatus: 2follows the delete permission the DocType already grants — this guard only narrows drafts that have leftinitialState. Without it, a document's owner could delete it while it awaited approval, discarding the pending decision.Administratoris exempt outright, as it is from the DocType's own delete permission.Direct state field / docstatus mutation guard:
DBSetandSaveDocboth refuse a write todoc[stateField]ordocstatusoutside a workflow transition (inWorkflowTransition), with aValidationError. Client code, form scripts andddcore.db.setValuecannot alter either field directly; onlydoc.applyWorkflow(action)/POST /api/workflow/applycan.State editability guard (
allowEdit): When a document is in a state withallowEdit: "Role", a user lacking that role (other thanAdministrator, or a context running withc.IgnorePermissions()) fails the DocType'swritepermission check for that document, so a plain field save is rejected with aPermissionError. Unlike guards 1-3, this one is a permission check like any other, so it follows the DocType's usualignorePermissionsrules.Atomic row locking (
FOR UPDATE): Transitions execute under a PostgreSQL row lock (SELECT ... FOR UPDATE). Concurrent approval requests serialize: the first caller transitions the state, and the subsequent caller reads the updated state, finds no transition matching the old state and action, and fails with aValidationErrorwithout duplicating side effects.Load-time validation: Engine reload (
Engine.Load) validates every workflow against the current DocType registry and refuses to load definitions that fail:- the workflow's
doctypemust exist; stateFieldmust be a field of that DocType;- every state's
docstatusmust be0,1or2; - a state with
docstatus: 1or2requires the DocType to besubmittable; - a transition cannot go from a
docstatus: 1state to adocstatus: 0state, and cannot leave adocstatus: 2state —SaveDocalways rejects those transitions, so a workflow declaring one could never execute it.
A hot reload that fails validation keeps the previously loaded definitions; check the server log for the rejection reason.
- the workflow's
Server-side transitions
A document governed by a workflow cannot be submitted or cancelled with doc.submit() / doc.cancel(), not even with ignorePermissions. App code — tests, fixtures, scheduled jobs, patches — applies the action instead:
const order = ddcore.getDoc<Order>("Order", "ORD-0001");
order.applyWorkflow("Approve"); // reloads `order` with the new state and docstatusdoc.applyWorkflow(action) runs the same transition as POST /api/workflow/apply: the caller's roles, allowSelfApproval and condition are checked, the row is locked, the transition runs through the full save lifecycle (validate, beforeSave, onUpdate or onUpdateAfterSubmit, beforeSubmit/onSubmit or beforeCancel/onCancel when docstatus changes, webhooks, notifications), and the audit event and timeline comment are written. The document must already be saved. Administrator passes every role and self-approval check, which is what a test running as Administrator relies on.
A transition requires read access to the document and a role listed in the matched transition's allowed — it does not require the DocType's submit, cancel or write permission (the save lifecycle skips that check while inWorkflowTransition is set).
Amending
Amending a cancelled document clears the state field on the copy, so the amendment starts over at initialState when it is inserted.
Audit trail and timeline comments
Every workflow transition writes an audit event and a timeline comment:
- Administrative Audit Ledger (
tab_audit_event):- Successful transitions record
action: "workflow.transition"andoutcome: "Allowed"on the caller's transaction withdetail: { "from_state": ..., "to_state": ..., "action": ... }. - Refused transition attempts (unauthorized role, self-approval violation, or failing condition) write
action: "workflow.transition"andoutcome: "Denied"directly to the connection pool viac.AuditDenied.
- Successful transitions record
- Document Timeline (
tab_comment):- A timeline comment with
comment_type: "Workflow"is posted to the document, built from the translation key"{0} applied action '{1}' ({2} → {3})"with the acting user's id and the translated action, from-state and to-state names. Translation uses the acting user's own language (c.Lang) at the moment the transition runs, so the comment is stored already translated into the approver's language, not the reader's — a reader in a different language sees the approver's wording, not their own. - The comment insert runs inside a savepoint; a failure there is rolled back and logged (
workflow timeline comment failed), and the transition still commits — a broken comment never undoes an approval. - Visible in the Desk form view timeline.
- A timeline comment with
HTTP API
Apply transition
POST /api/workflow/apply
Executes a workflow action on a document. Requires read permission on the document and the authorized role for the transition.
Request:
{
"doctype": "Order",
"name": "ORD-0001",
"action": "Approve"
}Response:
{
"data": {
"name": "ORD-0001",
"workflow_state": "Approved",
"docstatus": 1,
"status": "Approved",
"_workflow": {
"state": "Approved",
"allowEdit": true,
"actions": []
}
}
}List available actions
GET /api/workflow/actions?doctype=Order&name=ORD-0001
Returns the current state, whether the authenticated caller may edit fields in it, and the workflow actions available to them, taking into account caller roles, self-approval rules, and condition functions.
Response:
{
"data": {
"state": "Pending Approval",
"allowEdit": false,
"actions": [
{ "action": "Approve", "nextState": "Approved" },
{ "action": "Reject", "nextState": "Rejected" }
]
}
}On a DocType with no active workflow, the endpoint still requires read access to the document and returns {"state": "", "allowEdit": true, "actions": []}.
Integrated Document Resource
GET /api/resource/{doctype}/{name}
When fetching, creating, updating, or applying a transition on a document whose DocType has an active workflow, the response automatically includes a _workflow envelope containing the current state, whether the caller may edit fields in it (allowEdit), and the actions available to them:
{
"data": {
"name": "ORD-0001",
"workflow_state": "Pending Approval",
"docstatus": 0,
"_workflow": {
"state": "Pending Approval",
"allowEdit": false,
"actions": [
{ "action": "Approve", "nextState": "Approved" },
{ "action": "Reject", "nextState": "Rejected" }
]
}
}
}This avoids extra HTTP round-trips when loading forms in the Desk.
Desk integration
The ddcore Desk automatically provides workflow UI on governed DocTypes:
Workflow State Badge: The form view header displays an indicator badge showing the current workflow state (e.g.
Draft,Pending Approval,Approved), styled using standard status colors.Workflow Action Buttons:
- If available actions exist (and the document is saved), buttons render in the header.
- If up to 2 actions are available, they render as standalone action buttons (the first styled as primary).
- If more than 2 actions are available, they render inside an Actions dropdown menu.
- Clicking an action prompts for user confirmation (the action name is translated) before calling
/api/workflow/apply. - Buttons are disabled while the form is saving or has unsaved edits — applying an action reloads the document, which would otherwise drop those edits silently.
- Manual "Submit" and "Cancel" buttons are hidden when a workflow controls the DocType.
Field and Save Locking: If the current user does not hold the role specified by the active state's
allowEdit, form controls switch to read-only and the Save button is suppressed.