Docs / Admin
Configuring approval chains and workflow states
Summary — Two related but separate configurable-routing systems live in the platform: approval chains (multi-step sign-off with per-step assignee roles) and workflow states (generic state machines with role-gated transitions). Both are admin-defined JSON structures stored on a definition record and evaluated at runtime against a current_step / current_state pointer.
Purpose — Understand how to define an approval chain, how staff approve or reject a pending request, how to define a workflow's states and transitions, and which configured fields (SLA hours, escalation target, requiresApproval, requiresForm, autoTrigger) are stored but not currently enforced by any code path.
Audience — Admins configuring approval chains or workflow definitions; staff/reviewer/lead roles acting on approval requests.
Prerequisites — None. Both modules are entity-agnostic — a chain or workflow is attached to an entityType (for example document, issue, form_submission, opportunity) and referenced by other modules by slug.
Overview
Key concepts:
- Approval chain (
approval_chainstable) — an ordered list ofApprovalStepobjects (stepIndex,name,assigneeRole,requiredDecision, optionalslaHours,escalateTo) attached to anentityType. - Approval request (
approval_requeststable) — one instance of a chain being run against a specific entity (entityId). TrackscurrentStepandstatus. - Approval decision (
approval_decisionstable) — an immutable record of one decision (approved,rejected,returned,reassigned) made at a given step. - Workflow definition (
workflow_definitionstable) — a named state machine: an array ofWorkflowStateobjects (slug,label,color,isFinal) andWorkflowTransitionobjects (from,to,label,requiredRole, plus unenforcedrequiresApproval/requiresForm/autoTrigger). - State transition (
state_transitionstable) — an immutable log row created each time an entity moves from one state to another under a given workflow.
Screenshot: Admin → Approvals list showing chain slug, display name, entity type, step count, and Active/Inactive badge.
Defining an approval chain
Path: Admin → Approvals → Create Chain (/admin/approvals/new)
The form ("Define a configurable multi-step approval workflow") collects:
| Field | Notes |
|---|---|
| Slug * | Free text, e.g. document_approval. Unique per chain. |
| Display Name * | e.g. "Document Approval". |
| Description | Free text, optional. |
| Entity Type * | Dropdown: document, issue, form_submission. |
| Approval Steps | Click Add Step to append a step; click Remove to delete one (steps are re-indexed after removal). |
Each step row has:
| Field | Notes |
|---|---|
| Step Name | e.g. "Manager Review". |
| Assignee Role * | Dropdown: staff, reviewer, lead, admin. |
| Required Decision * | Dropdown: any, all. Stored per step but the runtime decision logic (see below) does not currently branch on this value — advancing the request happens on any single approved decision at the step regardless of requiredDecision. |
| SLA Hours (optional) | Numeric input. Stored on the step and displayed on the request detail page, but see SLA and escalation fields below — nothing currently acts on it. |
Click Create Approval Chain. The action requires the admin role (requireRole("admin", { module: "approvals" }) in createApprovalChainAction).
escalateTo (a string field on ApprovalStep) is defined in the type and validated by validators.ts, but there is no UI field for it on the Create/Edit Chain form — it can only be set by posting steps JSON directly to the action.
Requesting and acting on an approval
Requesting an approval
requestApprovalAction (role: staff minimum) creates an approval_requests row with currentStep: 0 and status: 'in_review' (set in repository.createRequest — note this is the initial status even though the ApprovalStatus enum also has a pending value used elsewhere in the UI/queries).
Viewing requests
Path: Approvals → /approvals — lists all requests with a status filter (Pending, Approved, Rejected, Cancelled). Columns: Entity, Chain, Current Step, Status, Requested By, Due Date.
Viewing and deciding on a single request
Path: click a request row → /approvals/[id]
The detail page shows an Approval Timeline card (one row per chain step, highlighting the current step in amber and completed steps in green with a completed status badge) and a sidebar with Overall Status, Requested By, Due Date, and Created timestamp.
Decision buttons appear only if canDecide is true:
user.role === currentStep.assigneeRole || user.role === "admin"
and request.status === "pending". (In practice, since createRequest sets status to in_review, not pending, decision buttons will not render for a freshly created request unless something updates the request to pending — this UI/status mismatch is a real gap; treat status values as pending | in_review | approved | rejected | cancelled per the ApprovalStatus type.)
The Make Decision card has:
- A Comments textarea (optional).
- Approve button (label toggles to "Processing..." while submitting).
- Reject button (label toggles to "Processing...").
Clicking either calls decideApprovalAction(requestId, currentStep, formData), which requires the reviewer role at minimum (requireRole("reviewer", { module: "approvals" }) — note this checks role hierarchy, not that the caller is literally the step's assignee; the page-level canDecide check is what actually restricts the button in the UI).
What happens on each decision type
services.decideApproval records an ApprovalDecision row, then updates the request:
| Decision | Effect |
|---|---|
approved | If this is the last step (currentStep >= steps.length - 1), request status → approved. Otherwise currentStep advances by 1 and status is left unchanged (still in_review/pending, whatever it was). |
rejected | Request status → rejected. Terminal. |
returned | currentStep moves back one (floored at 0). Status unchanged. |
reassigned | currentStep stays the same. Status unchanged. (No reassignment target field is written anywhere — the decision is just logged.) |
Every decision and every request status change writes an audit_events row (approval_decision.created, approval_request.updated).
Notification on final approval
When a request reaches status: approved, notifyApprovalApproved fires a notification to the original requester (requestedBy) via notifyUserById. The CTA differs by entity type:
entityType === "document"→ subject"Approved — document ready for the next step", CTA label "Open document to send for signature", path/documents?selected={entityId}.entityType === "opportunity"→ CTA label "Open opportunity", path/pipeline/{entityId}.- Any other entity type: notification still sends (heading "Your approval cleared") but with no CTA button.
This is a notify-only handoff — per the platform's v1 rule that external communications require human review, the system never auto-sends the document; a human still clicks through and sends it.
Cancelling a request
cancelApprovalAction(id) requires the lead role. Sets status to cancelled and logs approval_request.cancelled.
Defining a workflow state machine
Path: Admin → Workflows → States → Create Workflow (/admin/workflows/states/new)
The form ("Define states and transitions for a configurable workflow") collects:
| Field | Notes |
|---|---|
| Display Name * | Slug auto-generates from this via slugify() until you manually edit the slug field. |
| Slug | Read-only once created (disabled={isEdit}). |
| Description | Optional. |
| Entity Type * | Dropdown: issue, document, form_submission. Locked after creation. |
| Active checkbox | Only shown when editing an existing definition. |
States
Click Add State to append a WorkflowState:
| Field | Notes |
|---|---|
| Label | e.g. "Open". Slug auto-derives from label via slugify() until manually touched. |
| Slug | Auto-generated, editable. |
| Color | Tailwind class string chosen via the color-swatch picker (e.g. bg-gray-100 text-gray-700). |
| Final State (no further transitions) checkbox | Sets isFinal. Note: this flag is not itself checked by transitionState — a state marked final can still be transitioned out of if a matching transition rule exists in the transitions array. isFinal is informational/UI-only unless transitions are also omitted. |
Transitions
Click Add Transition to append a WorkflowTransition:
| Field | Notes |
|---|---|
| From State | Dropdown of defined state slugs, plus * (any state). |
| To State * | Dropdown of defined state slugs. |
| Required Role * | Dropdown: staff, reviewer, lead, admin. |
| Label | e.g. "Mark as In Progress". |
requiresApproval, requiresForm, and autoTrigger exist on the WorkflowTransition type and are validated by workflowTransitionSchema, but the Create/Edit Workflow form has no UI fields for them, and services.transitionState never reads them — see Fields defined but not enforced below.
Click Create Workflow (or Save Changes when editing). Requires admin role.
How a transition is validated
When code calls transitionStateAction (role: staff minimum) with a workflowSlug, entityType, entityId, and toState, services.transitionState runs, in order:
- Load the workflow definition by slug. Throws
Workflow definition not found: {slug}if missing. - Throws
Workflow is not active: {slug}ifisActiveis false. - Confirms
toStateexists indef.states. ThrowsTarget state not found: {toState}if not. - Looks up the entity's current state via the latest
state_transitionsrow for thatentityType/entityId/workflowId(nullif no prior transition — a fresh entity). - Finds a transition rule where
(t.from === "*" || t.from === currentState) && t.to === toState. ThrowsNo transition available from '{currentState}' to '{toState}'if none matches. - Checks the caller's role against
transition.requiredRoleusing a role hierarchy (admin6 >lead5 >reviewer4 >staff3 >lgu2 >public1) — the caller's role level must be greater than or equal to the required level. ThrowsInsufficient permissions. Required role: {requiredRole}, user role: {userRole}if not. - Inserts a
state_transitionsrow (fromState,toState,changedBy, optionalnotes, optionalgateData) and logs astate_transition.createdaudit event.
gateData (Record<string, unknown>) is accepted and stored on the transition row, but no code path currently validates its shape or requires it — it is a free-form JSON bag callers can attach for their own bookkeeping.
SLA and escalation fields (not enforced)
ApprovalStep defines slaHours?: number and escalateTo?: string, and both are validated by approvalStepSchema in validators.ts. The Create Chain UI exposes SLA Hours (optional) as a numeric input per step, and the request detail page displays it ("SLA: {step.slaHours} hours" under each timeline step) — but:
- No code reads
slaHoursto compute a due date, send a reminder, or flag an overdue step. It is stored and displayed only. - No code reads
escalateToat all. It has no UI field on the Create/Edit Chain form and is not referenced anywhere outsidetypes.tsandvalidators.ts(confirmed by repo-wide search). - The daily
governance-remindersInngest cron (src/lib/platform/workflows/functions/governance-reminders.ts, runsTZ=Asia/Manila 0 9 * * *) does escalate overdue approvals — but it operates on the request-leveldue_atcolumn (set at request-creation time viadueAtinRequestApprovalInput), not on the per-stepslaHours/escalateTofields. It uses a hardcoded constantAPPROVAL_ESCALATE_DAYS = 3: once a request with adue_atin the past has been overdue for 3+ days, it notifies a lead/admin in addition to the original requester. This escalation target is picked generically (role IN ('lead','admin') ORDER BY (role='lead') DESC LIMIT 1) — it is not the chain step's configuredescalateTouser.
Bottom line: if you set SLA Hours on an approval step expecting the platform to auto-escalate that specific step when the clock runs out, it will not — the field is stored and shown for reference only. The only real escalation path today is the request-level dueAt field feeding the daily governance-reminders cron, which escalates to whichever lead/admin the query finds first, regardless of the chain's escalateTo configuration.
Fields defined but not enforced (transitions)
WorkflowTransition.requiresApproval (approval chain slug), requiresForm (form definition slug), and autoTrigger (Inngest event name) are typed and validated but:
- Have no input fields on
WorkflowDefinitionForm— you cannot set them through the admin UI today. - Are never read by
services.transitionState— a transition withrequiresApprovalset would still execute immediately on role check alone; no approval chain is looked up or required, and no form submission is checked. autoTriggernever fires an Inngest event from within the workflow-states module.
If your workflow needs "this transition can't happen until an approval chain clears" or "this transition auto-fires a background job," that gating must currently be built by the calling code (the module invoking transitionStateAction), not by the workflow-states module itself. Treat these three fields as reserved/planned, not active.
Roles and permissions
| Action | Required role |
|---|---|
| Create/update approval chain | admin |
| List approval chains | staff |
| Request an approval | staff |
| List/view approval requests | staff |
| Decide (approve/reject) an approval | reviewer (plus the page-level canDecide check requiring the user's role match the step's assigneeRole, or be admin) |
| Cancel an approval request | lead |
| Create/update workflow definition | admin |
| List workflow definitions | staff |
| Transition an entity's state | staff (plus the transition's own requiredRole, checked via role hierarchy) |
| View entity state / transition history | staff |
Best practices
- Set a request-level
dueAtif you want escalation. Since escalation only reacts toapproval_requests.due_at, not per-stepslaHours, populatedueAtonrequestApprovalActionfor any chain where overdue tracking matters. - Don't rely on
requiredDecision: "all"to require multiple approvers. The current decision logic advances the step on any singleapproveddecision — it does not count distinct approvers against the step's required set. - Keep
entityTypevalues consistent with what other modules pass in. The UI's entity-type dropdowns only offer a fixed set (document,issue,form_submissionfor approvals;issue,document,form_submissionfor workflow-states) — if a module calls these actions with a differententityTypestring (e.g.opportunity), it works at the data layer but won't appear as a dropdown option when building chains/workflows through the admin UI. - Use
*(any state) transitions sparingly. Afrom: "*"transition matches from every state, including terminal ones markedisFinal—isFinalis not enforced as a hard stop. - Don't configure
requiresApproval/requiresForm/autoTriggerexpecting the module to act on them. They are reserved fields; build any dependency between a transition and an approval chain or form in the calling module instead.
Warnings
slaHours and escalateTo on approval steps are not enforced. No cron, notification, or check reads them. See SLA and escalation fields.
requiresApproval, requiresForm, and autoTrigger on transitions are not enforced and have no UI. See Fields defined but not enforced.
isFinal on a workflow state does not block transitions. It is descriptive only; the actual gate is whether a transition rule exists.
Decision buttons on /approvals/[id] require status === "pending", but new requests are created with status: "in_review". Confirm what your calling module does to move a request into pending before assuming reviewers will see the Approve/Reject buttons immediately after request creation.
requiredDecision: "all" is not currently enforced as multi-approver consensus. A single approved decision advances the step regardless of this setting.
Troubleshooting
"Insufficient permissions. Required role: {role}, user role: {role}"
Symptom: transitionStateAction throws this error.
Cause: The caller's role, per the hierarchy (admin 6, lead 5, reviewer 4, staff 3, lgu 2, public 1), is below the transition's requiredRole level.
Fix: Have a user with the required role (or higher) perform the transition, or lower the transition's requiredRole in the workflow definition if that's the intended access level.
"No transition available from '{state}' to '{state}'"
Symptom: transitionStateAction throws this error when attempting a state change.
Cause: No WorkflowTransition in the definition has from matching the entity's current state (or *) and to matching the requested target state.
Fix: Add a transition rule covering this from→to pair in Admin → Workflows → States → edit the definition.
"Workflow is not active: {slug}"
Symptom: Any transition attempt on a workflow fails immediately with this message.
Cause: The workflow definition's isActive flag is false.
Fix: Edit the workflow definition and check the Active checkbox, then save.
Approve/Reject buttons don't appear on an approval request
Symptom: You open /approvals/[id] as the assigned reviewer but see no decision buttons.
Cause: canDecide requires request.status === "pending" AND (user.role === currentStep.assigneeRole OR user.role === "admin"). Requests are created with status: "in_review", not pending — if nothing in your flow moves the status to pending, the buttons never render for anyone but admins acting on a request that happens to be pending.
Fix: Confirm what status your calling module expects the request to be in before routing users to this page. As admin, you can still act if role checks otherwise pass and status is pending.
SLA Hours field doesn't trigger a reminder or escalation
Symptom: You set SLA Hours on an approval step but no notification fires when the SLA elapses.
Cause: slaHours is stored and displayed on the request detail page only — no code path reads it to compute overdue status or send a reminder.
Fix: Set a dueAt value on the approval request itself (via requestApprovalAction's dueAt field) — the daily governance-reminders cron reacts to that field, escalating to a lead/admin once a request is 3+ days overdue.
FAQ
Does setting escalateTo on a step notify that specific person when the step is overdue?
No. escalateTo is defined in the schema and validated, but no code reads it. The only automated escalation is the daily governance-reminders cron, which escalates request-level overdue approvals (based on dueAt) to whichever lead/admin its query finds first — not to a per-step escalateTo target.
Can a workflow transition require an approval chain to clear first?
Not through the workflow-states module itself. requiresApproval exists on the transition type but transitionState never checks it. Any such gating has to be implemented by the module that calls transitionStateAction.
What's the difference between an approval chain and a workflow definition? An approval chain is a linear sequence of sign-off steps with one pending/approved/rejected outcome per step. A workflow definition is a general state machine — any state can (per configured transitions) move to any other state, gated by role, without an inherent notion of sequential sign-off.
Can I reopen a request after it's rejected or cancelled?
There is no action in actions.ts to move a request out of rejected or cancelled. cancelApprovalAction only moves a request into cancelled. A new approval request would need to be created.
Does requiredDecision: "all" mean every assignee with that role must approve?
The field is stored but decideApproval's logic advances the step as soon as one approved decision is recorded, regardless of requiredDecision. Treat it as not yet enforced.
Related articles
- The investment pipeline lifecycle — a domain-specific example of stage gating with its own hardcoded rules, separate from the generic workflow-states module.
- Roles and access — role hierarchy and permission model referenced by both approvals and workflow-states role checks.
- Forms and form builder — the
form_submissionentity type andrequiresFormslug reference forms defined here. - Issue tracking and types — the
issueentity type used by both approval chains and workflow definitions.