Approvals
Route records for human sign-off — without letting the automation quietly bypass row-level security.
An approval is a flow with an approval node: the flow pauses until a human approves or rejects, then resumes down the matching branch. There is no separate approval engine to learn — triggers, branches, and error handling all work exactly as they do in any other flow. What this page adds is the approval node itself, and the two access decisions that matter as much as the routing.
Who does what
Three roles, deliberately separated:
| Role | Can | Needs |
|---|---|---|
| Builder | Author and edit the approval flow | manage_metadata (typically Studio users) |
| Submitter | Submit records that enter the flow | Normal record access — no automation config |
| Approver | Act on approval requests (approve / reject) | A capability granted by their permission set |
End users submit records and act on requests; they never edit the automation. Keep automation configuration surfaces out of consumer apps.
As whom does it run — the safety decision
A flow declares runAs, and for approvals this is the decision that keeps the
flow from quietly bypassing row-level security:
runAs | Data operations run as | Use when |
|---|---|---|
'user' (default) | The submitter, respecting their RLS | The flow only touches records the submitter can already see |
'system' | Elevated — bypasses RLS | The flow must read/write records the submitter can't (post to a ledger, notify an approver who owns rows the submitter can't see) |
Declare elevation explicitly so it's visible, not accidental. The 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.
Tip: A schedule-triggered escalation has no triggering user — it must be
runAs: 'system'to act at all. That's the legitimate case for elevation; "systemeverywhere to make it work" is the anti-pattern.
The approval node
The node declares who approves and how their decisions aggregate:
{
id: 'manager_approval',
type: 'approval',
label: 'Manager Approval',
config: {
approvers: [{ type: 'field', value: 'owner_manager_id' }],
behavior: 'unanimous',
approvalStatusField: 'approval_status',
lockRecord: true,
},
}| Config | What it does |
|---|---|
approvers | Who must decide — see the approver types below |
behavior | How multiple approvers aggregate: 'first_response' (default), 'unanimous', 'quorum', 'per_group' |
minApprovals | Approvals required — total for quorum (M of N), per group for per_group (default 1) |
onEmptyApprovers | What happens when approver resolution yields nobody: 'admin_rescue' (default), 'fail', 'auto_approve' |
decisionOutputs | Keys a decision may carry back into the flow as structured outputs |
approvalStatusField | Optional record field the plugin mirrors the request status into |
lockRecord | Lock the record against edits while the request is pending |
Each entry in approvers names a type and a value:
type | Resolves to |
|---|---|
manager | The submitter's manager (sys_user.manager_id) — takes no value |
position | Holders of an org position |
department | Members of a business unit and all descendant units |
team | Members of a flat collaboration team |
field | The user id held in a field on the triggering record |
user | One named user id |
org_membership_level | An org-membership tier (owner / admin / delegated_admin / member) |
expression | A CEL expression resolved at node entry — see below |
Prefer the indirect bindings (manager, position, department,
team) over a literal user id: they survive the person changing team
or leaving, and a hard-coded id silently routes approvals to someone who
should no longer see them.
Warning —
positionvs the membership tier.{ type: 'position', value: 'finance_manager' }routes to the holders of a position (sys_user_position). The org-membership tier (sys_member.role:owner/admin/delegated_admin/member) is addressed astype: 'org_membership_level'since 16.0; the old spellingroleis a deprecated alias for one release — it still loads and resolves identically, with a warning, and is removed in the next major. A position name authored as the membership-tier type matches nobody and the request stalls.os lintflags both cases (approval-approver-not-membership-tier,approval-approver-type-deprecated); if the value names an org position, the fix istype: 'position'.
Quorum and per-group sign-off (16.0)
Beyond first_response and unanimous, 16.0 adds two aggregation modes.
Quorum finalizes on M-of-N approvals:
config: {
approvers: [
{ type: 'user', value: 'director_a' },
{ type: 'user', value: 'director_b' },
{ type: 'user', value: 'director_c' },
],
behavior: 'quorum',
minApprovals: 2, // any 2 of the 3 approve
}Per-group requires a sign-off from each labeled group (会签) — label
approvers with group, and the node advances only once every group has
minApprovals approvals (default 1 per group):
config: {
approvers: [
{ type: 'position', value: 'legal_counsel', group: 'legal' },
{ type: 'position', value: 'finance_manager', group: 'finance' },
],
behavior: 'per_group', // one sign-off from legal AND one from finance
}Semantics that hold across every mode:
- Approver sets are tallied against an open-time snapshot with out-of-office substitution — who must respond is fixed when the request opens, and OOO approvers are substituted rather than stalling it.
- A single rejection is still a veto: it finalizes the node as
rejectedin every mode. - Thresholds clamp at runtime to the resolvable approver count, so a
misconfigured
minApprovalscan never deadlock a request. - Approvers without a
grouplabel each form their own group, so a plain approver list still behaves sensibly underper_group.
Dynamic routing: an approver picks the next approver (17.0)
Three additions in 17.0 let a node decide its approvers from what the run has produced so far, instead of from a fixed declaration.
expression approvers resolve a CEL expression at node entry
against three explicit roots — current.* (the record's live state),
trigger.* (the submit-time snapshot), and vars.* (flow variables,
including upstream node outputs). The result becomes the approver
slate:
config: {
approvers: [
{ type: 'expression', value: 'vars.legal_review.picked_departments' },
],
resolveAs: 'department', // each value is a department id, fanned out to its members
}resolveAs ('user' by default, or 'department' / 'position' /
'team') says what kind of id the expression returned, and expands each
through the same graph lookups the static approver types use. Under
behavior: 'per_group', each returned value forms its own group — one
sign-off per returned department.
record and bare field names are deliberately not available: on
every other platform surface record means "the record at event time",
and reusing it here would silently alias one of the two times.
decisionOutputs is what feeds an expression like the one above.
The author declares which keys a decision may carry; approvers only
fill in values. Accepted outputs resume the run as
<nodeId>.<key> flow variables — never bare names, so an approver
cannot shadow an author's variable:
{
id: 'legal_review',
type: 'approval',
config: {
approvers: [{ type: 'position', value: 'legal_counsel' }],
decisionOutputs: [
{ key: 'picked_departments', type: 'department', multiple: true },
],
},
}An entry is either a bare key (rendered as a plain text input in the
decision UI) or a typed declaration — { key, type, multiple } — which
tells the decision UI to render a record picker instead:
user / department / position / team each get the matching
system-object picker, and multiple: true collects an id array. A
decision carrying undeclared keys is rejected; decision and
requestId are reserved.
onEmptyApprovers decides what happens when resolution yields no
concrete person — an empty expression result, an unstaffed position, an
empty multi-select field:
| Value | Behavior |
|---|---|
'admin_rescue' (default) | Open the request anyway and warn loudly; a privileged admin can take it over via Reassign. The only option that neither waves the record through nor kills the run |
'fail' | The node fails (fault edge / run failure). Choose when an empty slate can only mean a configuration bug |
'auto_approve' | Skip the request and continue down the approve edge with output.autoApproved = true. Opt-in, because it silently waves the record through |
The default is deliberate: approval's job is to gatekeep, so an empty slate must never silently pass.
A complete approval flow
Route large proposals to the owner's manager, then branch on the decision:
export const opportunityApproval = defineFlow({
name: 'opportunity_approval',
label: 'Opportunity Approval',
type: 'record_change',
status: 'active',
runAs: 'user', // submitter's RLS unless a step genuinely needs more
nodes: [
{
id: 'start',
type: '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' },
],
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' },
],
});Note the labeled edges: approve and reject name the post-decision
branches. Always model the rejection path — a flow with only an approve branch
strands rejected records.
Multi-step approvals: chain multiple approval nodes. Parallel
approvals: see the aggregating-node pattern in
Flows.
What the approver experiences
The @objectstack/plugin-approvals package owns the durable approval state.
While a request is pending:
- The plugin persists the request (
sys_approval_request) and every decision taken on it (sys_approval_action) — your approval history is queryable data. - With
lockRecord: true, the record is locked against edits until the decision lands. - If you set
approvalStatusField, the record's own field mirrors the request status, so views and reports can filter on it. - The approver approves or rejects; the plugin resumes the paused flow down
the matching edge. Since 16.0 a decision can carry file attachments
(persisted on
sys_approval_action.attachments, threaded through the decide/comment routes and the client SDK) — signed PDFs and evidence live with the decision. - The request exposes a server-computed
decision_progress— approvals got vs. needed forunanimous/quorum, a per-group breakdown forper_group— so the inbox and your own UIs render progress without re-implementing the tally.
Decisions are declared actions (16.0). The full decision set — approve,
reject, reassign, send-back for revision (/revise), request-info, remind,
recall, resubmit — is declared as type: 'api' actions on
sys_approval_request, with typed params, pending-only visibility gates, and
submitter-vs-approver predicates (e.g. record.submitter_id == ctx.user.id).
The approvals inbox renders those declared actions instead of
hand-written buttons, so new decision capabilities ship as metadata — no
client release required.
Approving is itself a gated action. Model "may approve" as a capability (e.g.
approve_invoice) granted by the approver's permission set, and gate the
approve action's requiredPermissions on it — the gate is then enforced on
both the UI and the server, not just hidden from a screen.
Best practices
| Do | Don't |
|---|---|
| Define clear entry criteria | Create too many approval steps |
| Set reasonable timeout periods | Make approvals too complex |
| Allow recall when appropriate | Forget rejection paths |
| Notify all stakeholders | Hard-code approvers |
| Track approval history | Gate "Approve" only in the UI |
Default to runAs: 'user', elevate one step at a time | Set runAs: 'system' everywhere "to make it work" |
Where to go next
| Page | Why |
|---|---|
| Flows | The flow reference this page builds on — triggers, steps, error handling |
| Workflows | Constrain the lifecycle the approval sits inside |
| Actions | Surface submit and approve as buttons in the interface |
| CEL expressions | The language behind entry conditions |
| Notify approvers and stakeholders | |
| Automation overview | Chooser: flow vs workflow vs approval |