ObjectStackObjectStack

Approval workflow

Route a record for sign-off — who can configure the automation, and the run-identity decision that keeps an approval flow from quietly bypassing row-level security.

Approval workflow

Scenario

A record (an invoice, a discount, a leave request) must be routed for approval before it proceeds. Who is allowed to build that automation, and how do I make sure it runs safely?

An approval is a flow with an approval node. Two access decisions matter as much as the routing itself.

1. Who can configure it

Authoring flows/automations is a builder capability — it needs manage_metadata (typically Studio users). End users submit records and act on approval requests, but they do not edit the automation. Keep the automation surfaces out of consumer apps (see audience-based interfaces).

2. As whom does it run — the safety decision

A flow declares runAs (ADR-0049), and for approvals this is the decision that keeps it safe:

  • runAs: 'user' (default) — the flow's data operations run as the submitter, respecting their RLS. Good when the flow only touches records the submitter can already see.
  • runAs: 'system'elevated, bypasses RLS. Needed when the flow must read/write records the submitter can't (e.g. post to a ledger, notify an approver who owns rows the submitter can't see). Declare it explicitly so the elevation is visible, not accidental.

A schedule-triggered escalation has no triggering user — so it must be system to act at all.

3. The approval node

The node declares who approves (a named user, a position, the submitter's manager, …) and what happens on approve / reject / escalate. The authoritative shape is ApprovalNodeConfig / ApproverType; a flow strings the trigger, the approval node, and the post-decision branches together.

// Illustrative — see the Approval reference for the exact node schema.
defineFlow({
  name: 'invoice_approval',
  type: 'record_change',
  runAs: 'user',                       // submitter's RLS unless a step needs more
  nodes: [
    { id: 'start', type: 'start', label: 'Start',
      config: { objectName: 'invoice', triggerType: 'record-after-create' } },
    { id: 'approval', type: 'approval', label: 'Approval',
      config: { approvers: [{ type: 'position', value: 'finance_manager' }] } },
    { id: 'mark_approved', type: 'update_record', label: 'Mark Approved' },
    { id: 'mark_rejected', type: 'update_record', label: 'Mark Rejected' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'approval' },
    { id: 'e2', source: 'approval', target: 'mark_approved', label: 'approve' },
    { id: 'e3', source: 'approval', target: 'mark_rejected', label: 'reject' },
  ],
});

position vs role. { type: 'position', value: 'finance_manager' } routes to the holders of a position (sys_user_position, ADR-0090 D3). The role approver type is the better-auth org-membership tier (sys_member.role: owner/admin/member) — a position name authored as type: 'role' matches nobody and the request stalls; os lint flags this (approval-role-not-membership-tier).

Approving is itself a gated action — model "may approve" as a capability (approve_invoice) the approver's permission set grants, and gate the approve action's requiredPermissions on it so the gate is enforced on both the UI and the server (ADR-0066 D4).

Approval nodes in practice

Approvals are authored as Flow nodes with type: 'approval'. The @objectstack/plugin-approvals package owns the durable approval state (sys_approval_request and sys_approval_action), record locking, approver resolution, and resume-on-decision behavior.

export const opportunityApproval: Flow = {
  name: 'opportunity_approval',
  label: 'Opportunity Approval',
  type: 'record_change',
  status: 'active',
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: 'Start',
      config: {
        triggerType: 'record-after-update',
        objectName: 'opportunity',
        condition: "record.amount >= 50000 && record.stage == 'proposal'",
      },
    },
    {
      id: 'manager_approval',
      type: 'approval',
      label: 'Manager Approval',
      config: {
        approvers: [{ type: 'field', value: 'owner_manager_id' }],
        behavior: 'unanimous',
        approvalStatusField: 'approval_status',
        lockRecord: true,
      },
    },
    { id: 'mark_approved', type: 'update_record', label: 'Mark Approved' },
    { id: 'mark_rejected', type: 'update_record', label: 'Mark Rejected' },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'manager_approval' },
    { id: 'approved', source: 'manager_approval', target: 'mark_approved', label: 'approve' },
    { id: 'rejected', source: 'manager_approval', target: 'mark_rejected', label: 'reject' },
    { id: 'e4', source: 'mark_approved', target: 'end' },
    { id: 'e5', source: 'mark_rejected', target: 'end' },
  ],
};

For multi-step approvals, chain multiple approval nodes. For parallel approvals, see the aggregating-node pattern in Flow Metadata.

Best practices

DO:

  • Define clear entry criteria
  • Set reasonable timeout periods
  • Allow recall when appropriate
  • Notify all stakeholders
  • Track approval history

DON'T:

  • Create too many approval steps
  • Make approvals too complex
  • Forget rejection paths
  • Hard-code approvers

Why

Separating who configures from who approves from as whom it runs is the same capability/assignment/requirement decoupling as the rest of authorization (ADR-0066). The runAs default of user means an approval flow can't silently grant the submitter cross-tenant or cross-owner reach — elevation is opt-in and auditable. That is the safe-by-default posture: the common case respects RLS; the elevated case is explicit.

Runnable example

Anti-patterns

  • runAs: 'system' everywhere "to make it work". Default to user; elevate a single step only when it genuinely needs to bypass RLS.
  • Exposing automation config to approvers/submitters. Configuring the flow is a manage_metadata (builder) concern; acting on a request is not.
  • Gating "Approve" only in the UI. Make approve_* a capability and gate the action so the server enforces it too.

See also

On this page