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_chains table) — an ordered list of ApprovalStep objects (stepIndex, name, assigneeRole, requiredDecision, optional slaHours, escalateTo) attached to an entityType.
  • Approval request (approval_requests table) — one instance of a chain being run against a specific entity (entityId). Tracks currentStep and status.
  • Approval decision (approval_decisions table) — an immutable record of one decision (approved, rejected, returned, reassigned) made at a given step.
  • Workflow definition (workflow_definitions table) — a named state machine: an array of WorkflowState objects (slug, label, color, isFinal) and WorkflowTransition objects (from, to, label, requiredRole, plus unenforced requiresApproval/requiresForm/autoTrigger).
  • State transition (state_transitions table) — 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:

FieldNotes
Slug *Free text, e.g. document_approval. Unique per chain.
Display Name *e.g. "Document Approval".
DescriptionFree text, optional.
Entity Type *Dropdown: document, issue, form_submission.
Approval StepsClick Add Step to append a step; click Remove to delete one (steps are re-indexed after removal).

Each step row has:

FieldNotes
Step Namee.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:

DecisionEffect
approvedIf this is the last step (currentStep >= steps.length - 1), request statusapproved. Otherwise currentStep advances by 1 and status is left unchanged (still in_review/pending, whatever it was).
rejectedRequest statusrejected. Terminal.
returnedcurrentStep moves back one (floored at 0). Status unchanged.
reassignedcurrentStep 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:

FieldNotes
Display Name *Slug auto-generates from this via slugify() until you manually edit the slug field.
SlugRead-only once created (disabled={isEdit}).
DescriptionOptional.
Entity Type *Dropdown: issue, document, form_submission. Locked after creation.
Active checkboxOnly shown when editing an existing definition.

States

Click Add State to append a WorkflowState:

FieldNotes
Labele.g. "Open". Slug auto-derives from label via slugify() until manually touched.
SlugAuto-generated, editable.
ColorTailwind class string chosen via the color-swatch picker (e.g. bg-gray-100 text-gray-700).
Final State (no further transitions) checkboxSets 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:

FieldNotes
From StateDropdown of defined state slugs, plus * (any state).
To State *Dropdown of defined state slugs.
Required Role *Dropdown: staff, reviewer, lead, admin.
Labele.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:

  1. Load the workflow definition by slug. Throws Workflow definition not found: {slug} if missing.
  2. Throws Workflow is not active: {slug} if isActive is false.
  3. Confirms toState exists in def.states. Throws Target state not found: {toState} if not.
  4. Looks up the entity's current state via the latest state_transitions row for that entityType/entityId/workflowId (null if no prior transition — a fresh entity).
  5. Finds a transition rule where (t.from === "*" || t.from === currentState) && t.to === toState. Throws No transition available from '{currentState}' to '{toState}' if none matches.
  6. Checks the caller's role against transition.requiredRole using a role hierarchy (admin 6 > lead 5 > reviewer 4 > staff 3 > lgu 2 > public 1) — the caller's role level must be greater than or equal to the required level. Throws Insufficient permissions. Required role: {requiredRole}, user role: {userRole} if not.
  7. Inserts a state_transitions row (fromState, toState, changedBy, optional notes, optional gateData) and logs a state_transition.created audit 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 slaHours to compute a due date, send a reminder, or flag an overdue step. It is stored and displayed only.
  • No code reads escalateTo at all. It has no UI field on the Create/Edit Chain form and is not referenced anywhere outside types.ts and validators.ts (confirmed by repo-wide search).
  • The daily governance-reminders Inngest cron (src/lib/platform/workflows/functions/governance-reminders.ts, runs TZ=Asia/Manila 0 9 * * *) does escalate overdue approvals — but it operates on the request-level due_at column (set at request-creation time via dueAt in RequestApprovalInput), not on the per-step slaHours/escalateTo fields. It uses a hardcoded constant APPROVAL_ESCALATE_DAYS = 3: once a request with a due_at in 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 configured escalateTo user.

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 with requiresApproval set would still execute immediately on role check alone; no approval chain is looked up or required, and no form submission is checked.
  • autoTrigger never 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

ActionRequired role
Create/update approval chainadmin
List approval chainsstaff
Request an approvalstaff
List/view approval requestsstaff
Decide (approve/reject) an approvalreviewer (plus the page-level canDecide check requiring the user's role match the step's assigneeRole, or be admin)
Cancel an approval requestlead
Create/update workflow definitionadmin
List workflow definitionsstaff
Transition an entity's statestaff (plus the transition's own requiredRole, checked via role hierarchy)
View entity state / transition historystaff

Best practices

  1. Set a request-level dueAt if you want escalation. Since escalation only reacts to approval_requests.due_at, not per-step slaHours, populate dueAt on requestApprovalAction for any chain where overdue tracking matters.
  2. Don't rely on requiredDecision: "all" to require multiple approvers. The current decision logic advances the step on any single approved decision — it does not count distinct approvers against the step's required set.
  3. Keep entityType values consistent with what other modules pass in. The UI's entity-type dropdowns only offer a fixed set (document, issue, form_submission for approvals; issue, document, form_submission for workflow-states) — if a module calls these actions with a different entityType string (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.
  4. Use * (any state) transitions sparingly. A from: "*" transition matches from every state, including terminal ones marked isFinalisFinal is not enforced as a hard stop.
  5. Don't configure requiresApproval/requiresForm/autoTrigger expecting 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_submission entity type and requiresForm slug reference forms defined here.
  • Issue tracking and types — the issue entity type used by both approval chains and workflow definitions.