Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions content/docs/build/automation/approvals.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
---
title: Approvals
description: Route records for human sign-off β€” without letting the automation quietly bypass row-level security.
---

# Approvals

**An approval is a [flow](/docs/build/automation/flows) with an approval node: the flow pauses until a human approves or rejects, then resumes down the matching branch.** There is no separate approval engine to learn β€” triggers, branches, and error handling all work exactly as they do in any other flow. What this page adds is the approval node itself, and the two access decisions that matter as much as the routing.

## Who does what

Three roles, deliberately separated:

| Role | Can | Needs |
|---|---|---|
| **Builder** | Author and edit the approval flow | `manage_metadata` (typically Studio users) |
| **Submitter** | Submit records that enter the flow | Normal record access β€” no automation config |
| **Approver** | Act on approval requests (approve / reject) | A capability granted by their permission set |

End users submit records and act on requests; they never edit the automation.
Keep automation configuration surfaces out of consumer apps.

## As whom does it run β€” the safety decision

A flow declares `runAs`, and for approvals this is the decision that keeps the
flow from quietly bypassing row-level security:

| `runAs` | Data operations run as | Use when |
|---|---|---|
| `'user'` (default) | The **submitter**, respecting their RLS | The flow only touches records the submitter can already see |
| `'system'` | **Elevated** β€” bypasses RLS | The flow must read/write records the submitter can't (post to a ledger, notify an approver who owns rows the submitter can't see) |

Declare elevation **explicitly** so it's visible, not accidental. The default
of `'user'` means an approval flow can't silently grant the submitter
cross-tenant or cross-owner reach β€” elevation is opt-in and auditable.

> **Tip:** A schedule-triggered escalation has no triggering user β€” it must be
> `runAs: 'system'` to act at all. That's the legitimate case for elevation;
> "`system` everywhere to make it work" is the anti-pattern.

## The approval node

The node declares **who approves** and how their decisions aggregate:

```ts
{
id: 'manager_approval',
type: 'approval',
label: 'Manager Approval',
config: {
approvers: [{ type: 'field', value: 'owner_manager_id' }],
behavior: 'unanimous',
approvalStatusField: 'approval_status',
lockRecord: true,
},
}
```

| Config | What it does |
|---|---|
| `approvers` | Who must decide β€” a named user, a position, or a user resolved from a record field (e.g. the submitter's manager) |
| `behavior` | How multiple approvers aggregate, e.g. `'unanimous'` |
| `approvalStatusField` | Optional record field the plugin mirrors the request status into |
| `lockRecord` | Lock the record against edits while the request is pending |

> **Warning β€” `position` vs `role`.** `{ type: 'position', value: 'finance_manager' }`
> routes to the holders of a position (`sys_user_position`). The `role` approver
> type is the org-membership tier (`sys_member.role`: `owner`/`admin`/`member`) β€”
> a position name authored as `type: 'role'` matches nobody and the request
> stalls. `os lint` flags this (`approval-role-not-membership-tier`).

## A complete approval flow

Route large proposals to the owner's manager, then branch on the decision:

```ts
export const opportunityApproval = defineFlow({
name: 'opportunity_approval',
label: 'Opportunity Approval',
type: 'record_change',
status: 'active',
runAs: 'user', // submitter's RLS unless a step genuinely needs more
nodes: [
{
id: 'start',
type: 'start',
config: {
triggerType: 'record-after-update',
objectName: 'opportunity',
condition: "record.amount >= 50000 && record.stage == 'proposal'",
},
},
{
id: 'manager_approval',
type: 'approval',
label: 'Manager Approval',
config: {
approvers: [{ type: 'field', value: 'owner_manager_id' }],
behavior: 'unanimous',
approvalStatusField: 'approval_status',
lockRecord: true,
},
},
{ id: 'mark_approved', type: 'update_record', label: 'Mark Approved' },
{ id: 'mark_rejected', type: 'update_record', label: 'Mark Rejected' },
{ id: 'end', type: 'end' },
],
edges: [
{ id: 'e1', source: 'start', target: 'manager_approval' },
{ id: 'approved', source: 'manager_approval', target: 'mark_approved', label: 'approve' },
{ id: 'rejected', source: 'manager_approval', target: 'mark_rejected', label: 'reject' },
{ id: 'e4', source: 'mark_approved', target: 'end' },
{ id: 'e5', source: 'mark_rejected', target: 'end' },
],
});
```

Note the labeled edges: `approve` and `reject` name the post-decision
branches. Always model the rejection path β€” a flow with only an approve branch
strands rejected records.

**Multi-step approvals:** chain multiple `approval` nodes. **Parallel
approvals:** see the aggregating-node pattern in
[Flows](/docs/build/automation/flows).

## What the approver experiences

The `@objectstack/plugin-approvals` package owns the durable approval state.
While a request is pending:

1. The plugin persists the request (`sys_approval_request`) and every decision
taken on it (`sys_approval_action`) β€” your approval history is queryable data.
2. With `lockRecord: true`, the record is locked against edits until the
decision lands.
3. If you set `approvalStatusField`, the record's own field mirrors the request
status, so views and reports can filter on it.
4. The approver approves or rejects; the plugin resumes the paused flow down
the matching edge.

Approving is itself a gated action. Model "may approve" as a capability (e.g.
`approve_invoice`) granted by the approver's permission set, and gate the
approve action's `requiredPermissions` on it β€” the gate is then enforced on
**both** the UI and the server, not just hidden from a screen.

## Best practices

| Do | Don't |
|---|---|
| Define clear entry criteria | Create too many approval steps |
| Set reasonable timeout periods | Make approvals too complex |
| Allow recall when appropriate | Forget rejection paths |
| Notify all stakeholders | Hard-code approvers |
| Track approval history | Gate "Approve" only in the UI |
| Default to `runAs: 'user'`, elevate one step at a time | Set `runAs: 'system'` everywhere "to make it work" |

## Where to go next

| Page | Why |
|---|---|
| [Flows](/docs/build/automation/flows) | The flow reference this page builds on β€” triggers, steps, error handling |
| [Workflows](/docs/build/automation/workflows) | Constrain the lifecycle the approval sits inside |
| [Actions](/docs/build/interface/actions) | Surface submit and approve as buttons in the interface |
| [CEL expressions](/docs/reference/cel) | The language behind entry conditions |
| [Email](/docs/configure/email) | Notify approvers and stakeholders |
| [Automation overview](/docs/build/automation) | Chooser: flow vs workflow vs approval |
39 changes: 39 additions & 0 deletions content/docs/build/automation/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
title: Automation
description: Pick the right tool for the job β€” flows for steps, workflows for state, approvals for human sign-off.
---

# Automation

**Automation attaches business logic to your data model declaratively β€” as metadata the runtime executes β€” instead of scattering it through application code.** Three tools cover the ground, and picking the right one first saves you from rebuilding later.

## Which one do I need?

| You need | Use | Read |
|---|---|---|
| "When X happens, do Y" β€” steps that run on a record change, a schedule, or a button click | **Flow** | [Flows](/docs/build/automation/flows) |
| "This record may only move through these states, by these events" β€” a controlled lifecycle | **Workflow** (state machine) | [Workflows](/docs/build/automation/workflows) |
| "A person must sign off before this proceeds" β€” routed human decisions | **Approval** | [Approvals](/docs/build/automation/approvals) |

Rule of thumb: model **state** with workflows, model **steps** with flows. Approvals are not a separate engine β€” an approval is a flow that pauses at an approval node until a human decides.

## How they combine

The three compose rather than compete:

- A **workflow** constrains which lifecycle transitions are valid at all β€” states, transitions, and guards, nothing else.
- A **flow** performs the side effects around those transitions: send an email, update records, call an external service, wait, branch.
- An **approval node** inside a flow blocks execution until an approver acts, then resumes down the approve or reject branch.

So "a case moves new β†’ assigned β†’ resolved" is a workflow; "notify the manager when it escalates" is a flow; "a discount over $50k needs the finance manager's sign-off" is a flow with an approval node.

> **Tip:** If a request reads "when X happens, do Y", it's a flow. If it reads "this record must never skip a state", it's a workflow. Start there and you'll rarely choose wrong.

## Where to go next

| Page | Why |
|---|---|
| [Flows](/docs/build/automation/flows) | The full flow reference β€” triggers, step types, error handling, CEL |
| [Workflows](/docs/build/automation/workflows) | States, transitions, and guards for strict lifecycles |
| [Approvals](/docs/build/automation/approvals) | Approval nodes, run-as identity, and the approver experience |
| [CEL expressions](/docs/reference/cel) | The expression language used in conditions and guards |
160 changes: 160 additions & 0 deletions content/docs/build/automation/workflows.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
---
title: Workflows
description: Model a record's lifecycle as a state machine β€” valid states, guarded transitions, and nothing a flow can do better.
---

# Workflows

**A workflow models a record's lifecycle as a finite state machine: the states the record can be in, the events that move it between them, and the guards that must hold for a move to happen.** Use one when the core requirement is "this object can only move through these states by these events."

There is no standalone Salesforce-style Workflow Rule authoring type. The old
"workflow" concept splits cleanly in two:

- **State machine metadata** for strict lifecycle transitions β€” this page.
- **[Flows](/docs/build/automation/flows)** for event-triggered or scheduled
automation, including [approval](/docs/build/automation/approvals) pauses.

## Workflows vs flows

| | Workflow (state machine) | Flow |
|---|---|---|
| Models | *State* β€” where a record is in its lifecycle | *Steps* β€” what happens when something occurs |
| Answers | "Is this transition allowed right now?" | "What do we do about it?" |
| Shape | States, transitions, guards | Nodes and edges: triggers, actions, branches |
| Side effects | None β€” it only constrains | All of them β€” email, updates, HTTP, waits |

They compose: the state machine constrains the transitions; flows perform the
side effects around them when you need notifications, record updates, or
external calls.

## Define a state machine

A support case that must move `new β†’ assigned β†’ resolved`, with an escalation
path:

```ts
import type { StateMachineConfig } from '@objectstack/spec/automation';

export const caseLifecycle: StateMachineConfig = {
id: 'case_lifecycle',
initial: 'new',
states: {
new: {
on: {
ASSIGN: { target: 'assigned' },
},
},
assigned: {
on: {
RESOLVE: { target: 'resolved', cond: 'has_resolution' },
ESCALATE: { target: 'escalated' },
},
},
escalated: {
on: {
RESOLVE: { target: 'resolved', cond: 'has_resolution' },
},
},
resolved: {
type: 'final',
},
},
};
```

Read it as a contract: a `new` case can only be assigned. An `assigned` case
can be resolved β€” but only if the `has_resolution` guard passes β€” or escalated.
A `resolved` case is final; nothing moves it again.

## Anatomy

| Key | What it declares |
|---|---|
| `id` | The state machine's identifier |
| `initial` | The state every new record starts in |
| `states` | Map of state name β†’ its outgoing transitions |
| `on` | Events this state responds to (`ASSIGN`, `RESOLVE`, …) |
| `target` | The state an event moves the record to |
| `cond` | A guard that must hold for the transition to fire |
| `type: 'final'` | Terminal state β€” no outgoing transitions |

### Guards

A guard (`cond`) makes a transition conditional: in the example above,
`RESOLVE` only reaches `resolved` when `has_resolution` holds. Guards are how
you encode "you can't close a case without a resolution" as a structural rule
instead of a validation scattered through UI code.

### Events, not field writes

Transitions fire on named **events** (`ASSIGN`, `ESCALATE`), not on arbitrary
status-field edits. That's the point: the machine defines the complete set of
legal moves, and anything not declared is impossible.

> **Tip:** Keep the machine minimal β€” states, transitions, guards. The moment
> you want "and then send an email", you've left workflow territory: put the
> side effect in a [flow](/docs/build/automation/flows) that reacts to the
> transition.

## Side effects belong in flows

State machines describe valid transitions and guards β€” nothing else. When a
transition should *do* something (notify sales, stamp a closed date, call an
external system), pair the machine with a flow triggered by the record change:

```ts
export const dealClosedWon = defineFlow({
name: 'deal_closed_won',
type: 'record_change',
nodes: [
{
id: 'start',
type: 'start',
config: {
triggerType: 'record-after-update',
objectName: 'opportunity',
condition: "record.stage == 'closed_won' && previous.stage != 'closed_won'",
},
},
{ id: 'set_closed_date', type: 'update_record', label: 'Set Closed Date' },
{ id: 'notify_sales', type: 'notify', label: 'Notify Sales' },
{ id: 'end', type: 'end' },
],
edges: [
{ id: 'e1', source: 'start', target: 'set_closed_date' },
{ id: 'e2', source: 'set_closed_date', target: 'notify_sales' },
{ id: 'e3', source: 'notify_sales', target: 'end' },
],
});
```

The machine guarantees the deal *reached* `closed_won` legally; the flow
handles what happens next. Conditions like the one above are CEL expressions β€”
see [CEL](/docs/reference/cel).

## Migrating from workflow rules

If you're coming from a platform with Workflow Rules, here's the mapping:

| Old concept | Current equivalent |
|---|---|
| Workflow Rule | Flow |
| Time trigger | Scheduled flow |
| Field update action | `update_record` node |
| Email alert | `notify` node |
| HTTP call | `http` node |
| Approval Process | Flow with one or more `approval` nodes |

The test: if the old rule was "when X happens, do Y", model it as a small
flow. If it was "this record must move through controlled states", model the
lifecycle as a state machine and use flows for the side effects.

## Where to go next

| Page | Why |
|---|---|
| [Flows](/docs/build/automation/flows) | Side effects around transitions β€” triggers, steps, error handling |
| [Approvals](/docs/build/automation/approvals) | Human sign-off as a pause inside a flow |
| [CEL expressions](/docs/reference/cel) | The language behind guards and flow conditions |
| [Data modeling](/docs/build/data) | The objects whose lifecycles you're constraining |
| [Automation overview](/docs/build/automation) | Chooser: flow vs workflow vs approval |
Loading
Loading