ObjectOS
構築自動化

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:

RoleCanNeeds
BuilderAuthor and edit the approval flowmanage_metadata (typically Studio users)
SubmitterSubmit records that enter the flowNormal record access — no automation config
ApproverAct 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:

runAsData operations run asUse when
'user' (default)The submitter, respecting their RLSThe flow only touches records the submitter can already see
'system'Elevated — bypasses RLSThe 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; "system everywhere 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,
  },
}
ConfigWhat it does
approversWho must decide — see the approver types below
behaviorHow multiple approvers aggregate: 'first_response' (default), 'unanimous', 'quorum', 'per_group'
minApprovalsApprovals required — total for quorum (M of N), per group for per_group (default 1)
onEmptyApproversWhat happens when approver resolution yields nobody: 'admin_rescue' (default), 'fail', 'auto_approve'
decisionOutputsKeys a decision may carry back into the flow as structured outputs
approvalStatusFieldOptional record field the plugin mirrors the request status into
lockRecordLock the record against edits while the request is pending

Each entry in approvers names a type and a value:

typeResolves to
managerThe submitter's manager (sys_user.manager_id) — takes no value
positionHolders of an org position
departmentMembers of a business unit and all descendant units
teamMembers of a flat collaboration team
fieldThe user id held in a field on the triggering record
userOne named user id
org_membership_levelAn org-membership tier (owner / admin / delegated_admin / member)
expressionA 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 — position vs 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 as type: 'org_membership_level' since 16.0; the old spelling role is 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 lint flags both cases (approval-approver-not-membership-tier, approval-approver-type-deprecated); if the value names an org position, the fix is type: '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 rejected in every mode.
  • Thresholds clamp at runtime to the resolvable approver count, so a misconfigured minApprovals can never deadlock a request.
  • Approvers without a group label each form their own group, so a plain approver list still behaves sensibly under per_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:

ValueBehavior
'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:

  1. The plugin persists the request (sys_approval_request) and every decision taken on it (sys_approval_action) — your approval history is queryable data.
  2. With lockRecord: true, the record is locked against edits until the decision lands.
  3. If you set approvalStatusField, the record's own field mirrors the request status, so views and reports can filter on it.
  4. 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.
  5. The request exposes a server-computed decision_progress — approvals got vs. needed for unanimous/quorum, a per-group breakdown for per_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

DoDon't
Define clear entry criteriaCreate too many approval steps
Set reasonable timeout periodsMake approvals too complex
Allow recall when appropriateForget rejection paths
Notify all stakeholdersHard-code approvers
Track approval historyGate "Approve" only in the UI
Default to runAs: 'user', elevate one step at a timeSet runAs: 'system' everywhere "to make it work"

Where to go next

PageWhy
FlowsThe flow reference this page builds on — triggers, steps, error handling
WorkflowsConstrain the lifecycle the approval sits inside
ActionsSurface submit and approve as buttons in the interface
CEL expressionsThe language behind entry conditions
EmailNotify approvers and stakeholders
Automation overviewChooser: flow vs workflow vs approval

On this page