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 run that resolves no triggering user has nothing to scope to, so under the default runAs: 'user' its data operations are refused — declare system to make the elevation explicit and intended. os lint rejects the shapes it can prove at authoring time (flow-runas-unscoped, covering schedule / time-relative / api triggers), and the engine warns at run setup before refusing.

This is not a schedule-only concern, and for approvals it is the common case: the approvals service mirrors a decision back onto the target record with a system write, which carries no user. Any record-change flow bound to that object then runs with no triggering user — so a flow left at the default runAs: 'user' is refused. Nothing can flag that at authoring time (whether a given write carries a user is only knowable at run time), so declare runAs: 'system' on record-change flows that react to approval outcomes.

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:

behaviorAdvances when…Use for
first_response (default)any one approvessingle reviewer / "any manager"
unanimousevery resolved approver approvessmall fixed panels
quorumminApprovals of N approve (M-of-N)"2 of 3 directors"
per_groupeach 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 — and, when the node declares decisionOutputs, structured outputs the flow receives as <nodeId>.<key> variables (see Dynamic approvers below). Weighted voting and approval-matrix governance are enterprise, not here.

Dynamic approvers (#3447)

Two approver kinds bind to live data at the moment the node is entered — not the submit-time snapshot — so a step can route on data produced after submit:

  • field reads the record's live field value at node entry. An earlier step (or an earlier step's approver) can write the routing field mid-flow and the next approval node sees it; a multi-select user field fans out into one approver slot per user.
  • expression computes the slate with a CEL expression over exactly three roots: current.* (the record's live state at node entry), trigger.* (the submit-time snapshot — what a flow condition calls record), and vars.* (flow variables, including node outputs and vars.previous). record and bare field names are deliberately unavailable — at an approval node "the record" is ambiguous between two times, so the expression must say which one; referencing anything else fails the node loudly at entry rather than resolving a silently-empty slate. The optional resolveAs: 'user' (default) | 'department' | 'position' | 'team' re-expands each resolved id through the same graph lookups the static types use — and with behavior: 'per_group', each intermediate value (e.g. each returned department) forms its own sign-off group.

Approving across organizations (ADR-0105 D9)

Requires the group tenancy posture. Under single or isolated the runtime refuses an approver that declares organization, rather than ignoring it.

A group-shaped deployment routinely needs a plant's document signed off by someone at the group: the purchase order lives in the plant organization, but the CFO holds her cfo position in the group organization. By default an approver is resolved against the request's own organization, so cfo would match nobody there.

Declare which organization's directory resolves that approver:

config: {
  approvers: [
    { type: 'position', value: 'plant_manager', group: 'plant' },
    { type: 'position', value: 'cfo', organization: '$root', group: 'finance' },
  ],
  behavior: 'per_group',   // one plant sign-off AND one group sign-off
}

organization takes a symbol or an organization slug:

ValueResolves to
$rootthe group organization — climbs parent_organization_id to the top
$parentexactly one level up (division sign-off in a three-tier group)
a slug, e.g. acme-sscthat organization — for a sibling, such as a shared-services centre approving payables for every plant

Prefer the symbols. Flow metadata is portable across environments while organization ids are minted per deployment, so $root says "the group" without naming anything deployment-specific. An organization id is rejected: pass the slug.

Three things this deliberately does not do:

  • It does not move the request. Only the approver lookup changes — the request, its audit trail, and its inbox rows stay in the plant's organization.
  • It is not a route to any tenant. The target must share a parent_organization_id root with the request's organization, so approval routes within one group. The rule reads only the organization tree, never the submitter, so a flow routes identically for everyone.
  • It does not apply to every approver kind. user, field, manager and team name people without consulting an organization directory; organization on those is refused at runtime and flagged by os lint (approval-approver-cross-org-unsupported).

A cross-organization approver must also be able to read the request. The request stays in the plant's organization, so a group-side approver reaches it only if she also holds a membership there — the usual group shape is that group staff are members of every plant while holding their positions at the group. Approvers without that membership are dropped from the slate with a warning naming them, and the node's onEmptyApprovers policy takes over — better an empty slate you can see than a task the tenancy wall hides.

A result may legitimately be empty (a present-but-empty field or variable); the node-level onEmptyApprovers policy decides what that means — admin_rescue (default: the request opens, a privileged admin takes over via Reassign), fail (the node fails: an empty slate is a config bug), or auto_approve (skip the request and continue down approve with output.autoApproved = true; opt-in, since it waves the record through). A missing key (vars.never_written) is a loud error instead — guard genuinely-optional inputs with has(vars.x) ? vars.x : [].

The "previous approver picks the next step's approvers" loop needs no record field at all: declare decisionOutputs: ['next_reviewers'] on node A, have the approver decide with { outputs: { next_reviewers: ['u2', 'u3'] } }, and let node B's approver be { type: 'expression', value: 'vars.<nodeA-id>.next_reviewers' }. The author declares output keys, approvers only fill values; undeclared keys reject the decision, and decision / requestId are reserved.

A declaration can also be typed{ key: 'next_reviewers', type: 'user', multiple: true } — which renders a multi-select user picker in the decision dialog instead of free text (department / position / team render the matching system-object picker; picker values are record ids, multiple collects an id array). The type shapes only the input widget; the whitelist still works by key, so bare strings and typed declarations mix freely.

Add required: true when the next step cannot run without it — the very case above, where node B has nobody to route to if the lead skipped the field. Unlike type / multiple, this one is enforced by the runtime: an approve carrying no value (or a blank one — '', []) for a required key is rejected before any write, so the run can never resume past the node with the key missing. A reject never requires it: the run leaves down the reject edge, where nothing reads the outputs. Enforcement has no elevation bypass — a one-click email action link and an auto_approve SLA escalation both fail rather than advance into a node that would resolve nobody, leaving the request pending and visibly overdue. Without the flag your only backstop is onEmptyApprovers — the next node opens, resolves nobody, and stalls for an admin rescue long after the approver who could have filled it in has gone.

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.

The lock applies to everyone except the run that opened the request. A flow may still write its own target record while its own approval is pending, so it can never deadlock against itself. The exemption is keyed on run identity rather than elevation, so a runAs:'user' run stays row-level-security scoped while it writes — it does not become a system write.

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, org_membership_level, and expression (described in the callout above and in Dynamic approvers); org_membership_level is the one that silently resolves to nobody when it's mistaken for a business hierarchy. queue still parses so stored flows keep loading, but it is not implemented by the runtime and is no longer offered for authoring (#3508) — a queue entry resolves to nobody. Route to a team, department, or position instead. field, manager, and expression resolve against the record's live state at node entry (#3447). position, department, org_membership_level and expression may additionally name which organization's directory resolves them — see Approving across organizations. 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

The approver's surface is the Approvals Inbox — in the stock console, the Approvals entry in the account menu. It lists what is waiting on the current user (status = pending, pending_approvers containing them) and it is the only surface that carries the decision actions: approve, reject, return, reassign, request info, delegate, remind, override.

Why the raw sys_approval_request table is not that surface. Every decision action on the object is gated on record.viewer.can_act || record.viewer.can_override, and the viewer block is computed and attached only by the approvals REST path (/api/v1/approvals/*) — never by the generic data API a plain object view reads. Browse the table directly and you get a correct, completely inert list: the rows are there, the buttons are not. The table stays available under Setup → Approvals → Requests for admins and diagnostics, which is what it is good for.

Programmatically the same queue is:

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.

approverId is a filter, not authorization. What you may see is decided separately: a request is visible to its participants — the submitter, a current approver, and anyone who has already acted on it (a past approver whose slot has moved on, a commenter). So omitting approverId returns your requests, not every request in the tenant. Admins with override authority (admin_full_access, or organization_admin within their org) see all of them — that is what the "all requests" view is for.

The same rule governs a decision's files: an attachment is exactly as readable as the decision it hangs off, never more (sys_approval_action delegates that question back to this service via fileAccessDelegate).

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.

Mount the approvals inbox in your app

Approvals are cross-cutting: people approve expenses inside the expenses app, not by leaving for a platform app. Add one navigation entry to any app you ship and that app gets the full inbox:

// my-app.app.ts
navigation: [
  // …
  {
    id: 'nav_approvals',
    type: 'component',
    label: 'Approvals',
    componentRef: 'approvals:inbox',
    icon: 'check-circle',
    // Hide the entry where the approvals plugin is not installed. Enforced
    // server-side: a gated-off entry never reaches the browser.
    requiresService: 'approvals',
  },
],

componentRef names a component-registry key, not a URL. The console owns the route it resolves to, so approvals:inbox keeps working if that route ever changes — never hand-write a console path into app metadata.

The inbox is per-user, not per-app: it shows the signed-in user everything waiting on them across every object, because "what is waiting on me" is a property of the person and not of the app they happen to have open. Filtering it down to one app's objects is not an option today.

The platform's own Account app mounts it exactly this way, and Setup → Approvals → Approvals Inbox mounts the same component for admins above the raw sys_approval_request / sys_approval_action / sys_approval_delegation tables.

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, and since #3801 you cannot: POST /api/v1/automation/{flow}/runs/{runId}/resume answers 403 for a run parked on an approval node (including via a subflow pause) and changes nothing — the request stays pending and the run stays parked, so the real decision still lands. The approver slate, the sys_approval_action row and the status mirror all live on this path; it is the only one that produces a consistent outcome.

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 — the APPROVAL_STATUSES constant in @objectstack/spec/contracts, which is also what sys_approval_request.status offers and what ApprovalStatus is derived from. If you add a status there, this line is a fourth copy that nothing updates for you.

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:

VerbRouteWhoEffect
approve / reject/approve /rejectpending approverRecords the decision (finalizes per behavior). Accepts comment, attachments, and — when the node declares decisionOutputs — structured outputs handed to the flow as <nodeId>.<key> variables.
reassign/reassignpending approverHands one slot to another user (to); the request stays pending.
revise (send back)/revisepending approverEnds this round as returned, unlocks the record for rework (ADR-0044).
request-info/request-infopending approverAsks the submitter for more info; the request stays pending. comment required.
remind/remindsubmitterNudges the pending approvers (publishes a notification topic).
recall/recallsubmitterWithdraws a pending (or abandons a returned) request → recalled.
resubmit/resubmitsubmitterAfter a send-back, re-enters the approval node and opens the next round (ADR-0044).
comment/commentparticipantFree-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 in the revise window — an approval_revise node on the flow's revise edge. 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, and that edge must target an approval_revise node, for send-back to be available.

The window is deliberately not an ordinary wait: resubmit is what authorizes (submitter-only), orders (latest round) and records (an audit row) the continuation, and refuses when another request is already pending on the record — so the pause it parks on declares resumeAuthority: 'service' and the generic POST /api/v1/automation/:name/runs/:runId/resume route answers 403 for it. ADR-0044 D3 originally prescribed a wait here; its 2026-07-28 amendment reversed that (#3823). A flow still carrying the old shape publishes with an error (flow-approval-revise-target-not-service-owned) and its revise verb is refused at runtime until the node's type is changed — approve/reject are unaffected.

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:<name> 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.

Note the rule is "the actor is an admin", not "the slate is unstaffed" — so an admin can also act on a request whose slate is properly staffed, bypassing the people on it. That is why the decision records which door it came through: sys_approval_action.via_override is true when the actor was admitted only by this privileged path, holding no slot themselves. An admin who is also a designated approver is approving normally and records false — the flag is about the branch that authorized the call, not about who holds admin rights. A row written before the column existed carries no value at all, which reads as not recorded rather than as not an override. Without it, an override and an ordinary approval were byte-for-byte identical, and the only trace that a slate had been bypassed was the designated approver's later 409 INVALID_STATE — if they happened to try.

A dead run releases its own lock. If the flow run that opened an approval reaches a terminal state without a decision — it failed, was cancelled, timed out, or the process hosting it crashed — nothing is left to decide the request, so a periodic sweep finalizes it as recalled and releases the record. The audit row records the actor system:dead-run and names the run and its status, so it reads distinctly from a submitter's own recall.

The sweep only ever acts on a run it can positively confirm is terminal: a paused run (the normal state of a live approval), an unknown run, or an unreachable automation engine all count as alive and are left untouched. It frees orphaned records; it never cancels a live approval.

That sweep scans pending requests, which leaves one shape outside it: a request already decidedapproved, rejected or returned — whose run has since vanished. The decision landed and the flow never moved, and flipping the request out of pending is precisely what removed it from the sweep's view. A second, read-only inspection rides the same clock for those: it reports a terminal request only when the suspension store says no live pause exists and no terminal run record exists either, skipping (never condemning) any row whose store could not be read. It deliberately does not rewrite them — the decision really happened, and rolling it back automatically would put the audit trail at odds with the facts — so it names the stuck requests, their step, and the stale mirrored status for an operator to act on.

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

CodeHTTPMeaning
VALIDATION_FAILED400Malformed request
FORBIDDEN403Actor may not take this action (not a pending approver, or not the submitter for a submitter-only verb)
REQUEST_NOT_FOUND404No such request
DUPLICATE_REQUEST409A pending request already exists for this record
INVALID_STATE409The request is no longer pending
THROTTLED429Rate 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

On this page