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?
Recommended solution
An approval is a flow with an approval node. Two access decisions matter as much as the routing itself.
1. Who can configure it
Authoring flows/automations is a builder capability — it needs manage_metadata (typically Studio users). End users submit records and act on approval requests, but they do not edit the automation. Keep the automation surfaces out of consumer apps (see audience-based interfaces).
2. As whom does it run — the safety decision
A flow declares runAs (ADR-0049), and for approvals this is the decision that keeps it safe:
runAs: 'user'(default) — the flow's data operations run as the submitter, respecting their RLS. Good when the flow only touches records the submitter can already see.runAs: 'system'— elevated, bypasses RLS. Needed when the flow must read/write records the submitter can't (e.g. post to a ledger, notify an approver who owns rows the submitter can't see). Declare it explicitly so the elevation is visible, not accidental.
A 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:
behavior | Advances when… | Use for |
|---|---|---|
first_response (default) | any one approves | single reviewer / "any manager" |
unanimous | every resolved approver approves | small fixed panels |
quorum | minApprovals of N approve (M-of-N) | "2 of 3 directors" |
per_group | each group reaches minApprovals (default 1) | "legal and finance" sign-off (会签) |
// One legal AND one finance approver must sign off; either rejection vetoes.
{ type: 'approval', config: {
approvers: [
{ type: 'position', value: 'legal_counsel', group: 'legal' },
{ type: 'position', value: 'controller', group: 'finance' },
],
behavior: 'per_group', // or 'quorum' with minApprovals: 2
} }A single rejection always finalizes the node as rejected (one veto), in
every mode. minApprovals is clamped to the resolvable approver count / group
size, so it can never deadlock. A decision may also carry attachments (file
references) recorded on its audit row — e.g. a signed contract — 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:
fieldreads 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.expressioncomputes 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 callsrecord), andvars.*(flow variables, including node outputs andvars.previous).recordand 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 optionalresolveAs: 'user' (default) | 'department' | 'position' | 'team're-expands each resolved id through the same graph lookups the static types use — and withbehavior: '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:
| Value | Resolves to |
|---|---|
$root | the group organization — climbs parent_organization_id to the top |
$parent | exactly one level up (division sign-off in a three-tier group) |
a slug, e.g. acme-ssc | that 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_idroot 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,managerandteamname people without consulting an organization directory;organizationon those is refused at runtime and flagged byos 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 anexpressionthat yields no ids. Auserentry 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 aqueueentry and anexpressionvalue thatresolveAsre-expands into a group with nobody in it. Aposition:<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 abehaviorthat needs every slot (unanimous) waits on it too. - A slate left empty, or holding only literals, goes to the node's
onEmptyApproverspolicy, 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'sbehaviorreads them as one group: underfirst_response, orper_groupwith the defaultminApprovals, one of them decides;unanimousandquorumcount 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:
| Verb | Route | Who | Effect |
|---|---|---|---|
approve / reject | /approve /reject | pending approver | Records 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 | /reassign | pending approver | Hands one slot to another user (to); the request stays pending. |
revise (send back) | /revise | pending approver | Ends this round as returned, unlocks the record for rework (ADR-0044). |
request-info | /request-info | pending approver | Asks the submitter for more info; the request stays pending. comment required. |
remind | /remind | submitter | Nudges the pending approvers (publishes a notification topic). |
recall | /recall | submitter | Withdraws a pending (or abandons a returned) request → recalled. |
resubmit | /resubmit | submitter | After a send-back, re-enters the approval node and opens the next round (ADR-0044). |
comment | /comment | participant | Free-form reply on the request thread; accepts attachments. |
Send-back → resubmit (ADR-0044). An approver who wants changes rather than a
hard reject calls revise: the round finalizes returned, the record unlocks,
and the run parks 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 ispending, the caller with noactorIdnamed would take one of its slots — under their user id, the email their own account carries, or theposition:<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 aposition:<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 apendingrequest 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.
Progress and notification deep links
A pending multi-approver request also carries a server-computed
decision_progress block, so the console shows real progress rather than a
client guess:
// getRequest, for a pending multi-approver request:
"decision_progress": {
"behavior": "per_group",
"got": 1, "need": 2, // unanimous / quorum: a running M-of-N tally
"groups": [ // per_group: one entry per approver group
{ "label": "manager", "got": 1, "need": 1 },
{ "label": "finance", "got": 0, "need": 1 }
]
}It is computed from the open-time approver snapshot (OOO substitution applied), so it reflects who actually still needs to sign — the console renders it as per-group tick badges or a "2 of 3 · finance pending" count.
Approval notifications deep-link into the request: notify() rewrites the
inbox action URL to /system/approvals?request=<id>, so clicking the bell opens
that request's drawer directly instead of a generic list.
Timeouts and escalation
escalation is real, not decorative: set enabled: true and timeoutHours,
and pick an 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
| Code | HTTP | Meaning |
|---|---|---|
VALIDATION_FAILED | 400 | Malformed request |
FORBIDDEN | 403 | Actor may not take this action (not a pending approver, or not the submitter for a submitter-only verb) |
REQUEST_NOT_FOUND | 404 | No such request |
DUPLICATE_REQUEST | 409 | A pending request already exists for this record |
INVALID_STATE | 409 | The request is no longer pending |
THROTTLED | 429 | Rate limited |
Best practices
✅ DO:
- Define clear entry criteria
- Set reasonable timeout periods
- Allow recall when appropriate
- Notify all stakeholders
- Track approval history
❌ DON'T:
- Create too many approval steps
- Make approvals too complex
- Forget rejection paths
- Hard-code approvers
Why
Separating who configures from who approves from as whom it runs is the same capability/assignment/requirement decoupling as the rest of authorization (ADR-0066). The runAs default of user means an approval flow can't silently grant the submitter cross-tenant or cross-owner reach — elevation is opt-in and auditable. That is the safe-by-default posture: the common case respects RLS; the elevated case is explicit.
Runnable example
- Flows:
examples/app-showcase/src/automation/flows. - Approval schema:
packages/spec/src/automation/approval.zod.ts.
Anti-patterns
runAs: 'system'everywhere "to make it work". Default touser; 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
- Decision records: ADR-0049 (
runAs), ADR-0066 (unified authorization). - Guide: Hooks and Flows; Who can see data / automation / interface.
- Reference: Flow, Approval.