Workflows
The workflow system provides state machine automation for any collection. Workflows control how items move between states (e.g., draft → review → published), who can trigger each transition, and what side effects run automatically.
Concepts
| Concept | Description |
|---|---|
| Workflow Definition | JSON state machine stored in daas_wf_definition |
| Workflow Assignment | Links a workflow to a collection + optional filter rule |
| Workflow Instance | Created automatically when a matching item is created |
| Transition | Moving from one state to another via a named command |
| Action | An event emitted after a successful transition |
Workflow Definition Structure
{
"initial_state": "Draft",
"states": [
{
"name": "Draft",
"commands": [
{
"name": "Submit",
"next_state": "Pending Approval",
"policies": [],
"actions": []
}
],
"isEndState": false
},
{
"name": "Pending Approval",
"commands": [
{
"name": "Reject",
"next_state": "Draft",
"policies": [],
"actions": []
},
{
"name": "Approve",
"next_state": "Published",
"policies": ["550e8400-e29b-41d4-a716-446655440000"],
"actions": [
{
"name": "Publish",
"event_name": "xtr.item.promote",
"parameters": {}
}
]
}
],
"isEndState": false
},
{
"name": "Published",
"commands": [],
"isEndState": true
}
]
}State fields
| Field | Type | Description |
|---|---|---|
name | string | Unique state identifier |
commands | array | Available transitions from this state |
isEndState | boolean | Terminal state — no further transitions |
Command fields
| Field | Type | Description |
|---|---|---|
name | string | Unique command identifier |
next_state | string | State to transition to |
policies | string[] | Required policy UUIDs — user must hold at least one |
module_access_keys | string[] | Optional module access keys — user must hold at least one; OR-merged with policies |
actions | array | Events to emit after transition |
The workflow:approve and workflow:reject keys are seeded in the Module Access Keys registry, so Approve/Reject commands can be gated with a key grant (a policy’s Module-Level Access tab) without creating dedicated policies. See Module Access for enforcing the same keys in your own routes.
Action fields
| Field | Type | Description |
|---|---|---|
name | string | Human-readable label |
event_name | string | Event emitted — xtr.item.promote or custom |
parameters | object | Extra data passed to the event handler |
Automatic Instance Creation
When an item is created in a collection that has a Workflow Assignment, the system automatically:
- Checks
daas_wf_assignmentfor matching assignments (by collection + filter rule) - Creates a
daas_wf_instancewithcurrent_stateset toinitial_state - Updates the item’s workflow instance field and state field
Exactly one assignment must match per item. If zero match, the workflow is skipped. If multiple match, an error is logged and no instance is created.
Required collection fields
Two fields must exist in the collection: a Many-to-One link to daas_wf_instance and a Workflow State string field.
New collections — enable both switches in the collection creation wizard under System Fields → Workflow Fields:
| Switch | Field created | Details |
|---|---|---|
| Workflow Instance | workflow_instance | M2O to daas_wf_instance, nullable UUID |
| Workflow State | workflow_state | xtr-interface-workflow interface, read-only |
Existing collections — add the fields manually via Data Model → [Your Collection] → New Field:
| Field type | Interface | Purpose |
|---|---|---|
Many-to-One → daas_wf_instance | M2O | Stores the workflow instance ID |
| String | xtr-interface-workflow (Workflow State) | Stores the current state name |
Mark both as read-only in the field metadata — users should not edit them directly.
Execute a Transition
POST /api/workflow/transition{
"workflowInstanceId": "uuid",
"commandName": "Approve"
}Success (200):
{ "message": "Successfully transitioned workflow state" }Unauthorized (403):
{ "message": "You are not authorized to perform this transition" }The endpoint:
- Validates the command exists in the current state
- Checks the user holds at least one required policy UUID (directly or via role) or one of the command’s
module_access_keys(granted via a policy’s Module-Level Access tab) — the two lists are OR’d - Updates
current_stateindaas_wf_instance - Records the transition in
daas_wf_history - Updates the workflow state field in the target collection or version
- Fires all configured actions in sequence
Locking Commands to Roles
policies entries must be policy UUIDs (daas_policies.id) — not the id of the daas_access row that links a policy to a role. The authorization check resolves the caller’s held policies via the get_user_policies database function and compares that list directly against command.policies; daas_access is only the join table connecting a policy to a role or user, and its own id is never checked against anything.
// 1. Create a policy
POST /api/policies
{ "name": "Article Reviewer", "description": "Can approve/reject articles" }
// → { "data": { "id": "3f1e...-policy-id", ... } }
// 2. Link the policy to a role
POST /api/access
{ "role": "editor-role-uuid", "policy": "3f1e...-policy-id" }
// 3. Reference the POLICY id (not the daas_access row's id) in the command
{
"name": "Approve",
"next_state": "Published",
"policies": ["3f1e...-policy-id"],
"actions": []
}Using the daas_access row’s own id here instead of the policy’s id is a common mistake. It produces a workflow that looks fully configured — valid UUID, saves without error — but silently returns 403 for every user who should be authorized, since that UUID never matches anything get_user_policies returns. Always copy the id from the policy, not from the access-junction record.
If your client renders available commands conditionally (e.g. hiding a button the current user isn’t authorized to click), it needs its own way to resolve the caller’s policy UUIDs — GET /api/users/me does not include a policies field. Use GET /api/policies/me, which returns the policy records effective for the authenticated user at the current scope (send X-Resource-Uri when scoping applies). The transition endpoint enforces authorization regardless of what the client displays, so a stale or missing client-side check is a UX gap, not a security gap — but it will make an otherwise-correct role gate look “broken” during testing when it’s really just uninformed.
Scope-Aware Authorization
If the request carries an X-Resource-Uri header, the policy and module-key checks resolve at that scope: only policies granted at the asserted scope (or an ancestor, including root) authorize the transition. An invalid or unauthorized Resource URI fails the request — it does not fall back to the scope-blind check. Requests without the header keep the legacy flat resolution across all scopes.
Built-in Actions
xtr.item.promote
Promotes a content version to the main item. Only runs when the workflow instance is linked to a version (version_key is not null).
{ "name": "Promote to Main", "event_name": "xtr.item.promote", "parameters": {} }Custom actions
Register any event_name in a file-based extension or runtime extension:
export function register(sdk) {
sdk.emitter.onAction('xtr.notification.send-email', async (meta, context) => {
// action parameters are spread onto meta.payload[0] alongside workflow_instance
const { recipient, workflow_instance } = meta.payload[0];
await sendEmail(recipient, 'Workflow state changed');
});
}Workflow Assignments
Assignments connect a workflow definition to a collection with an optional filter rule:
{
"collection": "articles",
"workflow": "workflow-definition-uuid",
"filter_rule": { "category": { "_eq": "news" } }
}An empty filter_rule matches all items in the collection.
Version Workflows
Workflows can track content versions. When a version is created:
- The filter rule is tested against the parent item (not the version itself)
- The instance is linked with a
version_key - The
xtr.item.promoteaction applies the version delta to the main item
Best Practices
- One assignment per collection/filter combination — avoid overlapping filter rules
- Mark workflow fields as read-only — users should not manually edit
workflow_instanceorworkflow_state - Use empty
policiesarrays for commands that any authenticated user can trigger - Test filter rules with Supabase queries before production
- When gating a command, always use the policy’s own
id— see Locking Commands to Roles. Copying thedaas_accessrow’sidinstead is a common mistake that silently locks everyone out.
REST API
| Method | Path | Description |
|---|---|---|
GET | /api/workflows | List workflow definitions |
POST | /api/workflows | Create workflow definition |
GET | /api/workflows/:id | Get workflow definition |
PATCH | /api/workflows/:id | Update definition |
DELETE | /api/workflows/:id | Delete definition |
GET | /api/workflow-instances | List instances |
GET | /api/workflow-instances/:id | Get instance |
GET | /api/workflow-instances/:id/history | List transition history for an instance |
GET | /api/workflow-assignments | List assignments |
POST | /api/workflow-assignments | Create assignment |
GET | /api/workflow-assignments/:id | Get assignment |
PATCH | /api/workflow-assignments/:id | Update assignment |
DELETE | /api/workflow-assignments/:id | Delete assignment |
POST | /api/workflow/transition | Execute transition |