Skip to content

Latest commit

 

History

History
438 lines (353 loc) · 20.7 KB

File metadata and controls

438 lines (353 loc) · 20.7 KB
title Approval workflow
description 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?

Recommended solution

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 under the default runAs: 'user' its data operations run unscoped (elevated, RLS-bypassing) anyway — declare system to make that elevation explicit and intended rather than an implicit fail-open (the engine warns, and os lint flags the bare shape as flow-schedule-runas-unscoped).

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 `org_membership_level`.** `{ type: 'position', value: 'finance_manager' }` routes to the holders of a position (`sys_user_position`, ADR-0090 D3). `org_membership_level` is a different thing — the better-auth **org-membership tier**, whose only values are `owner`/`admin`/`member`. A position name authored there matches nobody and the request stalls; `os lint` flags it (`approval-approver-not-membership-tier`).

Authored type: 'role' on 15.x? That is the deprecated spelling of org_membership_level (ADR-0090 D3): it still resolves, warns at runtime, and is removed in the next major.

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.

Combining multiple approvers (#3266)

When a node has several approvers, behavior decides what "approved" means — one node, no parallel branches needed:

behavior Advances when… Use for
first_response (default) any one approves single reviewer / "any manager"
unanimous every resolved approver approves small fixed panels
quorum minApprovals of N approve (M-of-N) "2 of 3 directors"
per_group each group reaches minApprovals (default 1) "legal and finance" sign-off (会签)
// One legal AND one finance approver must sign off; either rejection vetoes.
{ type: 'approval', config: {
    approvers: [
      { type: 'position', value: 'legal_counsel', group: 'legal' },
      { type: 'position', value: 'controller',    group: 'finance' },
    ],
    behavior: 'per_group',   // or 'quorum' with minApprovals: 2
} }

A single rejection always finalizes the node as rejected (one veto), in every mode. minApprovals is clamped to the resolvable approver count / group size, so it can never deadlock. A decision may also carry attachments (file references) recorded on its audit row — e.g. a signed contract. Weighted voting and approval-matrix governance are enterprise, not here.

The full lifecycle — submit to field change

What actually happens between "the flow hits the approval node" and "the record says approved":

The request opens, the run pauses

The node writes a sys_approval_request row: status: 'pending', pending_approvers (the resolved approver list), and — if you declared approvalStatusField — it mirrors 'pending' onto your record's field. The record is locked against edits while pending (lockRecord, default true), and the flow run parks until a decision arrives.

Only approvers is required on the node; everything else has a default (behavior: 'first_response', lockRecord: true, maxRevisions: 3).

Approver entries resolve by kind — position, user, field, manager, team, department, queue, and org_membership_level, described in the callout above, which is the one that silently resolves to nobody when it's mistaken for a business hierarchy. An entry that resolves to nobody is not an error: the request opens with an empty pending_approvers and nothing can move it, so the run parks forever.

The approver finds it in their queue

Requests land in Setup → Approvals → Requests, whose my_pending view filters to status = pending and pending_approvers containing the current user. Programmatically:

curl -b cookies.txt \
  "https://your-app.example.com/api/v1/approvals/requests?status=pending&approverId=usr_123"

approverId accepts a user id, an email, or a <type>:<value> approver literal (position:finance_manager — the form an entry falls back to when it resolves to no users) — and takes several values (comma-separated or repeated) to cover a person's identities in one call. Other filters: status, object, recordId, submitterId, q, limit, offset.

**Opening a request notifies nobody.** There is no built-in "you have an approval waiting" message today — an approver only discovers it by looking at the queue. If people need to be told, do one of: add a `notify` node next to the approval node in the flow (the practical answer), or install a messaging service and drive `remind()`. Reminder, escalation, return, and reassignment *do* publish notification topics — the initial request doesn't.

The decision

curl -b cookies.txt -X POST \
  https://your-app.example.com/api/v1/approvals/requests/req_456/approve \
  -H "Content-Type: application/json" \
  -d '{ "comment": "Within budget." }'
# POST .../reject for the other direction; body: { actorId?, comment? }

actorId defaults to the caller. The actor must be in pending_approvers or the call returns 403 (FORBIDDEN: actor '…' is not a pending approver); a request that isn't pending returns 409 (INVALID_STATE). Always go through these endpoints — never resume the flow run directly.

A decision may carry file attachmentsattachments: string[] of sys_file ids — recorded on its audit row (e.g. a signed contract on the approve):

POST .../approve   { "comment": "Signed.", "attachments": ["file_abc", "file_def"] }
**With `behavior: 'unanimous'`, an approve is not the end.** Every approver but the last only trims `pending_approvers`; the request stays `pending` (`finalized: false`) and the flow stays parked. Only the final approval finalizes it.

The record changes and the flow resumes

On finalization the request row gets status: 'approved' (or 'rejected'), pending_approvers: null, and completed_at; your approvalStatusField is mirrored to the same value, and the record unlocks. The parked run resumes down the approve or reject branch.

Statuses in full: pending, approved, rejected, recalled, returned.

Beyond approve/reject — the full decision surface

approve / reject move the flow; the rest are thread interactions and continuity levers on the same request. All are POST /api/v1/approvals/requests/:id/<verb>; the service enforces who may call each:

Verb Route Who Effect
approve / reject /approve /reject pending approver Records the decision (finalizes per behavior). Accepts comment, attachments.
reassign /reassign pending approver Hands one slot to another user (to); the request stays pending.
revise (send back) /revise pending approver Ends this round as returned, unlocks the record for rework (ADR-0044).
request-info /request-info pending approver Asks the submitter for more info; the request stays pending. comment required.
remind /remind submitter Nudges the pending approvers (publishes a notification topic).
recall /recall submitter Withdraws a pending (or abandons a returned) request → recalled.
resubmit /resubmit submitter After a send-back, re-enters the approval node and opens the next round (ADR-0044).
comment /comment participant Free-form reply on the request thread; accepts attachments.

Send-back → resubmit (ADR-0044). An approver who wants changes rather than a hard reject calls revise: the round finalizes returned, the record unlocks, and the run parks at a wait point. The submitter reworks the record and resubmits (a fresh round opens for all approvers) or recalls (abandons it). Past the node's maxRevisions budget (default 3) a send-back auto-rejects instead. The flow's approval node must declare a revise edge for send-back to be available.

Acting on requests in the console

You rarely call these routes by hand. The decision verbs above ship as server-declared actions on sys_approval_request (actions[] in its object metadata) — approval_approve, approval_reject, approval_reassign, approval_send_back, approval_request_info, approval_remind, approval_recall, approval_resubmit. The console's generic action runtime renders and executes them wherever the object is surfaced — the Approvals inbox included — so a decision (comment, a file-upload for attachments, the reassign user-picker) is collected by the generic action dialog with no per-action UI code. New decision capabilities ship as backend metadata, not hand-written buttons.

Each action's visibility is gated by a server-computed per-viewer block the service attaches to every request it returns:

// getRequest / listRequests responses carry, per the calling user:
"viewer": { "can_act": true, "is_submitter": false, "can_override": false }
  • can_act — the caller is a current pending approver (their id is in the resolved pending_approvers while the request is pending). This is the same check the decision routes authorize with, so it already reflects position/team/manager resolution.
  • is_submitter — the caller submitted the request.
  • can_override — the caller is a platform or tenant admin who may act on a pending request despite holding no slot (see the admin-override callout below).

Approver actions gate on record.viewer.can_act, submitter levers (remind/recall/resubmit) on record.viewer.is_submitter, and Approve/Reject/ Reassign additionally OR in record.viewer.can_override. So a submitter viewing their own pending request never sees Approve/Reject/Reassign (buttons the server would 403 anyway), a position-addressed approver is never wrongly hidden, and an admin can rescue a stuck request. The service stays the sole authority — the predicate only trims the UI.

**Admin override — recovering a stuck request.** An approval routed to a `position` / `team` / `department` with **no holders** resolves to only an unresolvable `position:` literal in `pending_approvers`: no concrete user can act, and (with `lockRecord`) the record stays locked. A **platform admin** (`admin_full_access`) or **tenant admin** (`organization_admin`, org-scoped) may act on any `pending` request — **approve, reject, reassign** it to a real approver, or **recall** it — releasing the lock. An admin decision is authoritative: it finalizes the node even under `unanimous`/`quorum`/`per_group`, and is audited under the admin's own id. Prefer a guaranteed-staffed fallback approver so the set is never empty in the first place.

Progress and notification deep links

A pending multi-approver request also carries a server-computed decision_progress block, so the console shows real progress rather than a client guess:

// getRequest, for a pending multi-approver request:
"decision_progress": {
  "behavior": "per_group",
  "got": 1, "need": 2,                 // unanimous / quorum: a running M-of-N tally
  "groups": [                          // per_group: one entry per approver group
    { "label": "manager", "got": 1, "need": 1 },
    { "label": "finance", "got": 0, "need": 1 }
  ]
}

It is computed from the open-time approver snapshot (OOO substitution applied), so it reflects who actually still needs to sign — the console renders it as per-group tick badges or a "2 of 3 · finance pending" count.

Approval notifications deep-link into the request: notify() rewrites the inbox action URL to /system/approvals?request=<id>, so clicking the bell opens that request's drawer directly instead of a generic list.

Timeouts and escalation

escalation is real, not decorative: set enabled: true and timeoutHours, and pick an actionnotify (default), reassign, auto_approve, or auto_reject. Auto decisions run through the normal decide path, so the flow resumes exactly as if a human had clicked. Every escalation writes an audit row.

**Escalation needs the job service.** The plugin sweeps pending requests on an internal timer (~5 min). Without a `job` service registered, SLA timers are **display-only** and no escalation ever fires.

Out-of-office delegation

An approval routes to a specific person, and people take leave. Declare an out-of-office delegation so an approver's individually-routed slots reroute to a backup while they're away — the approval never freezes:

curl -b cookies.txt -X POST \
  https://your-app.example.com/api/v1/data/sys_approval_delegation \
  -H "Content-Type: application/json" \
  -d '{
    "delegator_id": "usr_alice",
    "delegate_id":  "usr_bob",
    "valid_from":   "2026-05-26T00:00:00Z",
    "valid_until":  "2026-05-30T00:00:00Z",
    "reason":       "Annual leave"
  }'

When a request opens, approvers that resolve to a specific individual (type: user, type: field, type: manager) are checked against active delegations and rerouted to the delegate. The window is half-open [valid_from, valid_until) (UTC), evaluated at resolution time — no background job — so a lapsed delegation simply stops applying. Chains (A → B → C) are followed and cycle-safe. The delegate acts under their own identity; the reroute is recorded as an ooo_substitute audit action and both the delegate and the skipped approver are notified.

**Group-routed approvers are not rerouted.** `type: position` / `team` / `department` / `org_membership_level` keep their whole membership, so there is nothing to skip; a position-holder's leave is handled by position delegation (ADR-0091), not here. Out-of-office delegation is self-service — create your own row via the data API (Setup → Approvals → *Delegations (OOO)*). For a single in-flight request, `reassign` hands one slot to another user directly.

Error codes

Code HTTP Meaning
VALIDATION_FAILED 400 Malformed request
FORBIDDEN 403 Actor may not take this action (not a pending approver, or not the submitter for a submitter-only verb)
REQUEST_NOT_FOUND 404 No such request
DUPLICATE_REQUEST 409 A pending request already exists for this record
INVALID_STATE 409 The request is no longer pending
THROTTLED 429 Rate limited

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