ObjectStackObjectStack

Approval chains — multi-step sign-off rules

Route a record for sign-off with an approval flow — who may configure it, and the run-identity choice that keeps it from bypassing row-level security.

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. Not every kind is configured the same way: the submitter's manager resolves from a column a platform admin sets on the user (Setup → Users → ⋯ → Set Manager), not on the node, so read the manager callout below before you reach for it. 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. It resolves exactly as org_membership_level does, so a position name authored there finds no member and leaves the org_membership_level:<value> slot, which only an admin override can decide. The fix is one line: { type: 'position', value: '<the position>' }.

manager is set on the user by an admin, not configured here. { type: 'manager' } resolves at runtime from sys_user.manager_id on the record's owner. That column is not a profile field: it is readonly on the user form and the data API's managed-update whitelist is {name, image, locale}, so it is never populated by editing the user in the Console.

It has a dedicated admin operation instead. In the Console a platform admin sets it from Setup → Users → ⋯ → Set Manager (the user's row menu, or the user record's header), which posts the link to the endpoint below; a script can post it directly:

POST /api/v1/auth/admin/set-user-manager
Content-Type: application/json

{ "userId": "usr_report", "managerId": "usr_boss" }

"managerId": null clears the link. The endpoint refuses a link that would make a user their own manager, close a cycle in the reporting chain, run past the chain depth cap, or point at a manager who is provably outside every organization the user belongs to — each with the reason in the response. It is platform-admin gated, like every other /api/v1/auth/admin/* route.

For many users at once, the admin bulk import (POST /api/v1/auth/admin/import-users) reads a manager_id column holding the manager's email address (or phone number, where phone sign-in is enabled) — someone in the same file or an existing user. It links each row once every row in the file exists, through the same refusals as the endpoint above, and reports each outcome on the row's manager result: a link it cannot make never fails the user it imported.

The column is also written by a seed, or by any other system-context write, which bypasses the managed-update whitelist. SCIM provisioning and directory sync can populate it too, but only through a provisioning path your own deployment supplies: this platform declares the SCIM manager attribute without projecting it onto the column. Where an identity carries source: 'idp_provisioned' the admin operation refuses, so that directory stays the one surface that authors its manager.

Setting the column is still something you have to do. Where manager_id is unset the rung expands to nobody, and under the default onEmptyApprovers: 'admin_rescue' the request opens on the organization's administrators instead of on the submitter's manager (When a slate names no one). So set it for everyone who submits this request, or take one of the two escapes that need no write to the column at all: add a second approver entry that cannot resolve empty — e.g. { type: 'org_membership_level', value: 'owner' } — or declare onEmptyApprovers: 'fallback' on the node with a fallbackApprovers list, so an empty manager rung opens the request on those people instead of on the administrators. Neither makes manager resolve; they route the request to the person who should decide it, which is a different and independently useful thing. os lint reports the shape at authoring time (approval-approvers-may-resolve-empty, info) and carries the same remedy text, so the two cannot drift apart — and it keeps reporting it even with a fallback declared, because a static check cannot read the column and cannot know the fallback resolves either.

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 on the organization's administrators — When a slate names no one), fail (the node fails: an empty slate is a config bug), auto_approve (skip the request and continue down approve with output.autoApproved = true; opt-in, since it waves the record through), or fallback (open the request on the people named in fallbackApprovers). A missing key (vars.never_written) is a loud error instead — guard genuinely-optional inputs with has(vars.x) ? vars.x : [].

fallback is how a node names who decides an empty slate, instead of leaving it to the organization's administrators:

{
  approvers: [{ type: 'manager' }],
  onEmptyApprovers: 'fallback',
  fallbackApprovers: [{ type: 'org_membership_level', value: 'owner' }],
}

fallbackApprovers takes the same entries as approvers — same types, same group / resolveAs / organization keys — and is resolved by the same expansion, OOO delegation included. The pairing is enforced in both directions: fallback without a fallbackApprovers list is refused, and a fallbackApprovers list under any other policy is refused too, because nothing would ever read it. Both arms fire at flow execution, where the approval node parses its config on entry — not at authoring time, and os lint has no pairing arm for this one. The refusal is loud, names both keys, and lands before the node opens any approval request.

Prefer a fallback target that cannot itself resolve empty (a literal user, or the owner membership tier). If it does resolve empty the node degrades to admin_rescue — the request opens on the organization's administrators and the run is never killed — and the log says both that the fallback fired and that it found nobody.

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 / team render the matching system-object picker and commit record ids; position renders a picker over the position registry and commits position machine names; multiple collects an array of them). 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 lands on the organization's administrators 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); manager additionally depends on sys_user.manager_id, which a platform admin sets from Setup → Users → ⋯ → Set Manager — see the manager callout above. position, department, org_membership_level and expression may additionally name which organization's directory resolves them — see Approving across organizations.

What an entry that resolves to nobody leaves on the slate depends on its kind:

  • A person entry (manager, field) adds no slot. Nor does an expression that yields no ids. A user entry never resolves to nobody: its value is the slot.
  • A group entry (position, team, department, org_membership_level) keeps its <type>:<value> literal as a slot, and so do a queue entry and an expression value that resolveAs re-expands into a group with nobody in it. A position:<name> slot is decided by whoever is staffed into that position later. No person ever holds any other literal, so only an administrator can decide it (the admin-override callout under Acting on requests in the console). Beside entries that do resolve, a literal is a slot like any other, so a behavior that needs every slot (unanimous) waits on it too.
  • A slate left empty, or holding only literals, goes to the node's onEmptyApprovers policy, whose four values are listed under Approving across organizations. Under the default, admin_rescue, the request opens on the organization's administrators instead.

When a slate names no one

Under the default onEmptyApprovers: 'admin_rescue', a slate that names no person opens the request on the organization's administrators: the people who hold the organization-admin capability (organization_admin or organization_admin_no_bypass, ADR-0095 D3) in the request's organization. An organization's owners and admins receive that capability automatically. It is the same authority that lets a tenant admin override a request, resolved to the people who hold it.

They are ordinary approvers:

  • the request is in each administrator's queue and counted in their bell, under their own approverId, as for any approver;
  • each is told once, by approval.requested, and out-of-office delegation applies to them as to any named approver;
  • a decision by one of them is an ordinary approval or rejection, recorded with via_override: false. The node's behavior reads them as one group: under first_response, or per_group with the default minApprovals, one of them decides; unanimous and quorum count them as they count any slate.

A platform administrator is not added for being one: that standing crosses the tenant wall, and the slate is the organization's own. One who also holds the organization's capability, such as its owner, is on the slate for that.

The request says why. Its timeline carries a note, for example Routed to this organization's administrators because the approver slate named no one: position 'sales_manager' has no holder., and its node-config snapshot keeps the abandoned slate and the same reason under __resolvedFrom (admin_rescue:abandoned, admin_rescue:reason). The abandoned slate is replaced, not kept beside them: a holder staffed into the position after the request opened does not receive it, and an administrator can reassign it to them.

To name the person who should decide instead, declare onEmptyApprovers: 'fallback' with fallbackApprovers. Only an organization with no administrator, or a request with no organization, still opens on the slate that names no one: the server logs a warning, no one is told, and only the admin override decides it.

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 — their user id, the email their own account carries, or the position:<name> literal of a position they hold) 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. That inertness is now a considered false rather than an evaluation fault — the predicates guard the block with has(record.viewer) && has(record.viewer.can_act) before reading it, so a row without the block resolves cleanly instead of aborting at key resolution. Same empty toolbar, arrived at deliberately. 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 a group 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. A position literal matches under position:<name>, the one spelling the service admits a holder of that position under as their own identity. The deprecated pre-rename prefix is no alias of it (ADR-0090 D3): it matches only a slot stored under that exact spelling, and no holder acts under it. Other filters: status, object, recordId, submitterId, q, limit, and 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 (counted by every identity the decision routes let the caller take a slot under: their user id, the email their own account carries, and the position:<name> address of a position they hold), and anyone who has already acted on it (a past approver whose slot has moved on, a commenter). An action is recorded under the slot it took, so "already acted" is counted by those same identities: a decision taken on a position:<name> slot keeps the request visible to whoever holds that position. 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 step tells its approvers. When a request opens, each concrete approver on its slate gets one approval.requested notification ("Approval requested", linked to that request in the Approvals Inbox): a named user, every holder of a staffed position or member of a team, the fallbackApprovers when the node's onEmptyApprovers: 'fallback' opened on them, and the organization's administrators when the default admin_rescue opened on them. It goes through the installed messaging service, the same way as the reminder, reassignment, escalation, and return notifications, and the same delivery preferences apply. Without a messaging service, no approval notification is delivered at all.

Two cases tell nobody by design. A slot no one holds (position:<name> with no holders) names no person, so it is told nothing; a step whose whole slate names no one is decided by its onEmptyApprovers policy, and only in an organization with no administrator does the default leave no one to tell. An out-of-office delegate gets one message, approval.ooo_substituted, not a second one for the opening.

If a flow has a notify node beside its approval node only to tell the approvers that a request is waiting, remove that node: those approvers are now told twice. Keep notify nodes that address someone else, such as the submitter or a watcher, or that report the decision.

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, who takes the first slot in pending_approvers keyed by one of their identities — their user id, the email their own account carries, or the position:<name> address of a position they hold (the literal left by a position nobody held when the request opened, once someone is staffed into it). A named actorId must be one of those identities, and a named position:<name> takes that position's slot; the deprecated pre-rename prefix is not one of them (ADR-0090 D3) and returns 403 (FORBIDDEN: cannot act as '…'). No such slot and no admin override returns 403 (FORBIDDEN: actor '…' is not a pending approver); a request that isn't pending returns 409 (INVALID_STATE). The decision records two facts on its sys_approval_action row: actor_id is the user who decided, and acted_as is the slot the decision took, in that slot's stored spelling — the multi-approver tally counts approvals by matching acted_as against the slate. A reassign row records the slot it moved the same way: reassign_from and reassign_to hold slot addresses (a user id, an email or a position address), not users. 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 attachments — attachments: 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.

The resumed half is not covered by the flow's errorHandling.retry. A durable pause ends the retry-governed segment, so strategy: 'retry' on the flow protects the work before the approval and gives whatever fails after the decision exactly one attempt. Wrap the post-decision work in a try_catch node with its own retry (or draw a fault edge to a handler) — see A durable pause ends the retry-governed segment. It is also the shape you want regardless: a flow-level retry re-runs the flow from the start, which would open a second approval request.

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, approval_comment. approval_comment is the thread reply: a pending approver sees it, and so does the submitter while the request is pending; it has no admin-override arm. 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: while the request is pending, the caller with no actorId named would take one of its slots — under their user id, the email their own account carries, or the position:<name> address of a position they hold. It is computed by the same function the decision routes authorize with, so it reflects position/team/manager resolution and a position:<name> slot whose position was staffed after the request opened.
  • 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. A slate that names no one opens on the organization's administrators under the default admin_rescue (When a slate names no one), so the override is break-glass for what that cannot reach: a request whose named approvers have all left after it opened, and an organization with no administrator, where the request opens on its <type>:<name> literals. A position:<name> slot is decided by whoever holds that position once someone is staffed into it; until then — and for a team or department literal, always — 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.

The same override is the only way to decide a slot no position holder addresses since ADR-0090 D3 retired the pre-rename word: a request a 15.x-era release stored under the pre-rename prefix, and a request opened from a flow that still authors the deprecated approver type (above) with a position name as its value (its slot is org_membership_level:<name>). Approve or reject it as an admin, or reassign it to the position's holder, who then decides it normally; then change the flow to { type: 'position', value: '<the position>' }.

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 no actor — a sweep is not a user, so actor_id is empty, never a placeholder value (ADR-0118 D1) — and names the run and its status, so it reads distinctly from a submitter's own recall, which records the submitter.

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 decided — approved, 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 action — notify (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 of kind escalate naming the configured action; it, the auto decision that follows it, and the escalation's notifications record no actor, because no person acted (ADR-0118 D1). timeoutHours counts calendar (wall-clock) hours — nights, weekends and holidays included, because the platform ships no business-hours calendar — so a request opened at 17:00 on a Friday with timeoutHours: 4 escalates at 21:00 that same Friday.

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