| title | Approval workflow |
|---|---|
| description | Route a record for sign-off — who can configure the automation, and the run-identity decision that keeps an approval flow from quietly bypassing row-level security. |
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.
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).
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 schedule-triggered escalation has no triggering user — so it must be system to act at all.
The node declares who approves (a named user, a position, the submitter's manager, …) and what happens on approve / reject / escalate. The authoritative shape is ApprovalNodeConfig / ApproverType; a flow strings the trigger, the approval node, and the post-decision branches together.
// Illustrative — see the Approval reference for the exact node schema.
defineFlow({
name: 'invoice_approval',
type: 'record_change',
runAs: 'user', // submitter's RLS unless a step needs more
nodes: [
{ id: 'start', type: 'start', label: 'Start',
config: { objectName: 'invoice', triggerType: 'record-after-create' } },
{ id: 'approval', type: 'approval', label: 'Approval',
config: { approvers: [{ type: 'position', value: 'finance_manager' }] } },
{ id: 'mark_approved', type: 'update_record', label: 'Mark Approved' },
{ id: 'mark_rejected', type: 'update_record', label: 'Mark Rejected' },
],
edges: [
{ id: 'e1', source: 'start', target: 'approval' },
{ id: 'e2', source: 'approval', target: 'mark_approved', label: 'approve' },
{ id: 'e3', source: 'approval', target: 'mark_rejected', label: 'reject' },
],
});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).
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.
What actually happens between "the flow hits the approval node" and "the record says approved":
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.
Only approvers is required on the node; everything else has a default
(behavior: 'first_response', lockRecord: true, maxRevisions: 3). An
approver entry that resolves to nobody is not an error: the request opens with
an empty pending_approvers and nothing can move it, so the run parks forever.
The approver-type trap that causes this is covered under
3. The approval node above.
Requests land in Setup → Approvals → Requests, whose my_pending view
filters to status = pending and pending_approvers containing the current
user. Programmatically:
curl -b cookies.txt \
"https://your-app.example.com/api/v1/approvals/requests?status=pending&approverId=usr_123"approverId accepts a user id, an email, or a <type>:<value> approver literal
(position:finance_manager — the form an entry falls back to when it resolves
to no users) — and takes several values (comma-separated or repeated) to cover
a person's identities in one call.
Other filters: status, object, recordId, submitterId, q, limit,
offset.
curl -b cookies.txt -X POST \
https://your-app.example.com/api/v1/approvals/requests/req_456/approve \
-H "Content-Type: application/json" \
-d '{ "comment": "Within budget." }'
# POST .../reject for the other direction; body: { actorId?, comment? }actorId defaults to the caller. The actor must be in pending_approvers
or the call returns 403 (FORBIDDEN: actor '…' is not a pending approver); a
request that isn't pending returns 409 (INVALID_STATE). Always go through
these endpoints — never resume the flow run directly.
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.
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.
| Code | HTTP | Meaning |
|---|---|---|
VALIDATION_FAILED |
400 | Malformed request |
FORBIDDEN |
403 | Actor isn't a pending approver |
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 |
✅ 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
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.
- Flows:
examples/app-showcase/src/automation/flows. - Approval schema:
packages/spec/src/automation/approval.zod.ts.
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.
- Decision records: ADR-0049 (
runAs), ADR-0066 (unified authorization). - Guide: Hooks and Flows; Who can see data / automation / interface.
- Reference: Flow, Approval.