# Objectstack Automation

> Design ObjectStack automation — Flows (visual logic), Triggers, Approvals, state machines, and the `jobs` (`defineJob`) / `webhooks` (`defineWebhook`) stack collections. Use when the user is adding `*.flow.ts`, wiring an event-driven rule, modelling an approval chain, or building an interactive screen flow / wizard (objectstack-ui routes those here). Do not use for data lifecycle hooks at the object layer (see objectstack-data) or for kernel / plugin events (see objectstack-platform). CEL expressions in flow conditions / edge guards: load objectstack-formula alongside.

- Skill: `objectstack-ai/objectstack-automation` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add objectstack-ai/objectstack-automation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/objectstack-ai/objectstack-automation/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: Apache-2.0
- Author: objectstack-ai (https://skillmd.com/u/objectstack-ai)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/objectstack-ai/objectstack-automation

---


# Automation Design — ObjectStack Automation Protocol

## When to Use This Skill

- You are building a **visual flow** (auto-launched, screen, or scheduled).
- You need a **state machine** or **approval process** for a business object.
- You are setting up **event-driven triggers** (record create/update/delete).
- You need **scheduled automation** (daily reports, data cleanup).

> **Predicates and conditions are CEL** — every `condition` / `guard` /
> `entryCondition` / filter `value` here is an **Expression** envelope evaluated
> by `@objectstack/formula`. A slot takes a plain CEL string; the
> `P\`...\`` / `cel\`...\`` tags wrap the same string with author-time validation.
> Both parse — pick one per file (the example apps use plain strings). See
> **objectstack-formula** for the CEL contract, stdlib and legacy → CEL table.

---

## Flows — Visual Logic Orchestration

A **Flow** is a directed graph of nodes that execute sequentially or in
parallel. Flows are the primary automation building block in ObjectStack.

### Flow Types

| Type | When to Use |
|:-----|:------------|
| `autolaunched` | Runs without user interaction — triggered by events, APIs, or other flows |
| `screen` | Interactive — presents UI screens to the user (wizards, forms) |
| `schedule` | Runs on a cron/interval cadence declared on the **start node's `config.schedule`** (daily cleanup, weekly reports) — or a **per-record date sweep** via `config.timeRelative`, see *Time-relative triggers* |
| `record_change` | Fires automatically on record create/update/delete (bind via the `start` node's `triggerType`). `autolaunched` + the same `record-*` binding behaves identically — the engine reads the start node either way; `record_change` also opts into the trigger-readiness lint |
| `api` | Invoked explicitly via the API / `engine.execute()`, **or** bound as an inbound **webhook**: `POST /api/v1/automation/hooks/:flowName/:hookId` (see *Inbound webhook triggers* below) |

### Flow Node Types

Flows are built from **20 built-in node types** (the `FlowNodeAction` seed set —
plugins register more via `registerNodeExecutor`, e.g. `approval` below):

#### Control Flow

| Node | Purpose |
|:-----|:--------|
| `start` | Entry point — every flow has exactly one |
| `end` | Exit point — can have multiple (early exit, error exit) |
| `decision` | Conditional branching — routed by **edge `condition` predicates**, not node config (see the approval example below) |
| `loop` | Iterate a **nested `config.body` region** once per item of `config.collection`; `iteratorVariable` (default `item`) and optional `indexVariable` bind inside it, `maxIterations` caps it |
| `parallel` | Fan out into `config.branches[]` (≥ 2 regions) run concurrently, **joined implicitly** at block end — no split/join pair to mis-wire |
| `try_catch` | Run `config.try`; on failure run `config.catch` with the error in `errorVariable` (default `$error`); `config.retry` re-runs `try` with backoff first. **No `finally`** — the container's ordinary out-edges are the continuation |
| `map` | Sequential multi-instance — invoke a subflow once per item of a collection; each iteration may pause (batch approvals) |
| `wait` | Pause execution until a timer elapses or a named signal arrives |
| `subflow` | Invoke another flow (reusable composition) |
| `parallel_gateway` / `join_gateway` / `boundary_event` | **Not author-facing** — BPMN-interop forms the mapper lowers a `parallel` / `try_catch` container INTO (`automation/control-flow.zod.ts`). Author the container |

#### Data Operations

| Node | Purpose |
|:-----|:--------|
| `assignment` | Set variable values |
| `create_record` | Insert a new record |
| `update_record` | Modify existing records |
| `delete_record` | Remove records |
| `get_record` | Fetch records with filters — there is **no `query_record`** node (that name has no executor and throws) |

#### External Integration

| Node | Purpose |
|:-----|:--------|
| `http` | Call an external HTTP API — canonical since protocol 11.0; `http_request` survives only as a deprecation-window alias |
| `notify` | Send a notification through the messaging service (inbox channel by default) |
| `connector_action` | Invoke a pre-built integration connector |
| `script` | Call a **registered** function named by `config.function` (see *Valid-but-silently-wrong* #3) |
| `screen` | Display a UI form to the user (screen flows only) |

#### Human Decision

| Node | Purpose |
|:-----|:--------|
| `approval` | Route a record for human sign-off — **suspends** the run until a decision, then continues down the `approve` / `reject` branch (contributed by `plugin-approvals`) |

### `notify` — the most-used node type

`NotifyConfigSchema` (`automation/io-node-config.zod.ts`) is `strictObject` — an
undeclared key is a named parse error. **RAW** keys never interpolate: a
`{token}` in one is forwarded verbatim, never resolved.

```ts
{ id: 'tell_owner', type: 'notify', label: 'Notify Owner', config: {
    recipients: '{record.assignee}',   // REQUIRED — id, CSV, or string[]
    title: 'Done: {record.title}',     // inline path; XOR `template` (RAW, localizable)
    message: 'Closed by {$User.Id}',   // body; only with inline `title`
    topic: 'task',                     // RAW; default 'notify'
    severity: 'warning',               // RAW; CLOSED enum info|warning|critical
    channels: ['inbox'],               // RAW; default inbox
    sourceObject: 'task',              // click-through: a PAIR, else dropped
    sourceId: '{record.id}',           //   at execute time
    actionUrl: 'https://…/tasks/123',  // overrides the synthesized link
} }
```

### Flow Variables

Every flow defines input/output variables. `variables` is an **array** of
`{ name, type, isInput, isOutput }` entries — not a name-keyed map, and there
is no `label` property on a variable:

```typescript
variables: [
  {
    name: 'case_id',
    type: 'text',
    isInput: true,    // passed in when flow is invoked
    isOutput: false,
  },
  {
    name: 'approval_result',
    type: 'boolean',
    isInput: false,
    isOutput: true,   // returned when flow completes
  },
],
```

### Flow Example — Auto-Escalate Overdue Cases

> **Nodes connect via `edges`, not a `next` property.** The engine traverses
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is refused.
> `update_record` selects rows with **`filter`** — an ObjectQL `where` **map**
> of `field → value` / `field → { $operator: value }`, NOT the UI view-filter
> `[{ field, operator, value }]` triples — and writes with **`fields`**
> (a single call updates *every* matching row — no per-row loop needed).
> `label` is **required** on the flow and on every node, and every path through
> the graph must reach an `end` node.

<!-- os:check -->
```typescript
import { defineFlow } from '@objectstack/spec';

export const EscalateOverdueCasesFlow = defineFlow({
  name: 'escalate_overdue_cases',
  label: 'Escalate Overdue Cases',
  type: 'schedule',
  status: 'active',
  runAs: 'system',   // a scheduled run has no trigger user — elevate explicitly
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: 'Daily at 09:00',
      // The cadence lives HERE, on the start node's config — FlowSchema has NO
      // top-level `schedule` key (one there is a named parse error, not a silent
      // strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use
      // the cron`…` tagged template — its envelope is not a recognized shape.
      config: { schedule: { type: 'cron', expression: '0 9 * * *' } },
    },
    {
      id: 'escalate_overdue',
      type: 'update_record',
      label: 'Escalate Overdue Cases',
      config: {
        objectName: 'support_case',
        // which rows to update — `filter` is a `where` map, not filter triples
        filter: {
          status: { $in: ['new', 'open'] },
          due_date: { $lt: '{TODAY()}' },   // template token → today's date at run time
        },
        // what to write — `fields`, not `values`
        fields: { status: 'escalated' },
      },
    },
    {
      id: 'notify_manager',
      type: 'http',
      label: 'Notify Manager',
      config: {
        url: 'https://hooks.slack.com/services/...',
        method: 'POST',
        body: { text: 'Escalated overdue support cases.' },
        timeoutMs: 10000,   // unset = NO timeout at all — always set one
      },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start',            target: 'escalate_overdue' },
    { id: 'e2', source: 'escalate_overdue', target: 'notify_manager' },
    { id: 'e3', source: 'notify_manager',   target: 'end' },
  ],
});
```

### Failure routing & `runAs`

> **Handling a failed node: a `fault` edge.** `{ source, target, type: 'fault' }`
> routes a failed node to a handler instead of ending the run. **`type: 'fault'`
> is what routes — a `label: 'error'` alone does nothing:** the edge stays
> ordinary, and every unconditional out-edge traverses on SUCCESS, so the
> handler would run when the node succeeds and never when it fails
> (`objectstack validate` reports `flow-error-label-not-fault`).
> A handled failure does NOT consume a flow-level `errorHandling.retry`, which
> replays the flow from the start — prefer a fault edge when the failure is
> local. The handler reads `{<nodeId>.error}` (or run-wide `{$error}`). The run
> then reports success, and the failed step stays in the trace.
>
> **It is not a way past a guardrail.**

| ROUTES (runtime failure) | Does NOT route (fatal either way) |
|:--|:--|
| 404, rate-limit, rejected write, failed subflow | missing required config key (`objectName`, `url`, `flowName`, `connectorId`/`actionId`); filter token that resolved to nothing; graph past the nesting ceiling; unscoped run |

Routing a guard refusal is worse than the failure: a dropped filter condition
**widens** the query, so a routed `delete_record` empties the object while the
run reports success. `objectstack validate` names the offending template.

> **Writing a `readonly` field? Set `runAs: 'system'`.** `readonly: true`
> governs the end-user surface: under the default `runAs: 'user'`, the engine
> **strips** a `readonly` field from any non-system write — `create_record` and
> `update_record` alike — the step reports success but the value never lands,
> and the drop is named in the step's warnings. A flow that
> maintains a `readonly` field (approval stamps, conversion flags, SLA
> markers, rollups) must run `runAs: 'system'`, the trusted-writer channel.
> `os validate` / `os build` fail a `runAs:'user'` `update_record` that writes
> a `readonly` field, so the mismatch surfaces at build time, not as wrong data
> days later. (`readonlyWhen` fields are the same story, per record state —
> flagged as a warning.) Do **not** work around this by removing `readonly`;
> that loses the field's edit protection.

> **Elevate the write, not the flow.** A `screen` flow stays `runAs: 'user'`.
> When one step in it must write a `readonly` field, move that step into a
> dedicated `runAs: 'system'` flow and call it from a `subflow` node — raising
> the whole flow silently elevates every other write in it.
>
> **A `runAs: 'system'` sweep must pin its organization.** System context has no
> trigger user, so nothing narrows the query: a scan or rollup with no
> organization predicate reads and writes across every tenant. The tenant column
> is platform-injected — filter on it, never re-declare it per object.

> **A hook elevates itself with `runAs`, never with `sudo`.** An object hook
> (objectstack-data) declares its own `runAs: 'system' | 'user' | 'inherit'` —
> default `'inherit'`, the context of the write that fired it — scoping that
> hook's `ctx.api` data operations only, on the in-process `handler` and the
> sandboxed `body` alike. A `'user'` hook whose trigger resolved no user has
> nothing to scope to: its `ctx.api` data operations are refused
> (`HOOK_UNSCOPED_DATA_ACCESS`, 403) rather than run unscoped — declare
> `runAs: 'system'` when the elevation is intended. `sudo` is not a hook key.

### Filter tokens (`config.filter`)

The one slot where two `{…}` dialects meet, and the one whose failure **widens**
a query instead of narrowing it.

- **Precedence — flow variables win, placeholders pass through.** The flow
  template engine runs first. A whole-string token it resolves is a flow value;
  one it does **not** resolve that IS a recognised filter placeholder
  (`{current_user_id}`, `{current_year_start}`) passes through **verbatim** for
  the query engine to expand. So a flow variable named after a placeholder
  **shadows** it. Only `filter` gets this hand-off — in `title`, `message`,
  `fields` and `url` a bare `{current_year_start}` is a nonsense reference.
- **Static checkability splits by position.** A `{record.…}` token **inside a
  filter** naming an unknown field, or hopping a relation the start node does not
  list in `config.expand`, is an **ERROR** at `objectstack validate`: it resolves
  to nothing, the condition is DROPPED, and the node refuses to execute. The
  *same* reference **outside** a filter (message body, `http` url, write payload)
  only renders an empty string — a **warning**. A `{var}` naming a flow variable
  or node output is **not statically checkable at all**.

---

## Valid-but-silently-wrong (passes build, fails at runtime)

These are *legal* metadata that authors — AI especially — get wrong. Most are now
caught by `objectstack build` (a hard error, or an advisory warning), but write
them right the first time:

1. **Flow node VALUE interpolation uses SINGLE braces.** Value fields on a node's
   `config` (`fields`, `inputs`, notify `message`/`title`, …) interpolate
   `{token}`:
   - `{var}` / `{record.title}` — variable / record field
   - `{record.tags.0}` — **array index** (e.g. a `multiple: true` lookup, stored as an array)
   - `{$User.Id}` / `{NOW()}` / `{TODAY() + 30}` — current user / date macros
   - `{round(x)}` `{floor(x)}` `{ceil(x)}` `{abs(x)}` `{min(a,b)}` `{max(a,b)}` —
     mirror the CEL stdlib 1:1. `round` is **integer-only** (no `round(x, 2)`);
     for N decimals write `{round(x * 100) / 100}` (scale 2)
   - anything without `{…}` is a **literal**

   ❌ `body: '{{ai_reply}}'` — double-brace is the *formula / template-field* dialect, **not** flow values
   ❌ `ticket: '$source.id'` — a bare `$ref` is a literal string, not interpolated
   ✅ `body: '{ai_reply}'`, `ticket: '{source.id}'`
   ❌ `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` — any other
   name in call position **fails the node** with a named error naming the
   supported set. The build does **not** catch these (conditions are checked,
   call-position names are not) and a `fault` edge cannot route it.

2. **`create_record`'s `outputVariable` holds the created RECORD, not its id.**
   Reference a field explicitly.
   ❌ `update_record … fields: { ref: '{newRec}' }` → yields the whole record object
   ✅ `fields: { ref: '{newRec.id}' }`

3. **`script` nodes call a registered function — that is all they do.** Set
   `config.function` to a function registered via
   `defineStack({ functions: { my_fn: (ctx) => … } })`. It is **required**: an
   empty `script` node refuses at execute, and one pointing at an unregistered
   function fails loudly.

   There is no other dispatch form: use **`notify`** for delivery, a
   **`connector_action`** or `http` webhook for Slack, a function for logic.

   **A flow `function` is a PURE compute step — it does NOT read/write the
   database.** It receives `ctx.input` and **returns** a value; `config.outputVariable`
   exposes that value as a flow variable, and a later **declarative** node persists
   it. Keep data effects on the flow graph (visible, governed, build-checkable):

   ```ts
   // ❌ DON'T: expect the function to update the record itself (it has no data API)
   // ✅ DO: function returns values → outputVariable → update_record persists
   { id: 'ai', type: 'script', config: {
       function: 'helpdesk.aiTriageStub',     // returns { ai_category, ai_sentiment, … }
       inputs: { ticketId: '{record.id}' },   // inputs are interpolated
       outputVariable: 'ai',
   } },
   { id: 'apply', type: 'update_record', config: {
       objectName: 'helpdesk_ticket',
       filter: { id: '{record.id}' },
       fields: { ai_category: '{ai.ai_category}', ai_sentiment: '{ai.ai_sentiment}' },
   } },
   ```

   `defineStack({ functions: { 'helpdesk.aiTriageStub': (ctx) => ({ ai_category: 'other', … }) } })`.
   If you genuinely need data-lifecycle **side effects** (read/write other records),
   that's an L2 **hook** (objectstack-data) — hooks get `ctx.api`; flow functions don't.

   A function that writes where the platform cannot see **declares** it, so the
   run reports "cannot say" rather than `acted: 0`:

   ```ts
   defineStack({ functions: {
     'helpdesk.aiTriageStub': (ctx) => ({ ai_category: 'other' }),  // pure — the default
     'billing.sync': { handler: syncBilling, effect: 'writes' },    // declared writer
   } });
   ```

4. **Conditions are bare CEL — the stdlib is what you may call bare.** `now()`,
   `today()`, `daysFromNow(n)`, `daysAgo(n)`, `daysBetween(a, b)`, `isBlank(v)`,
   `coalesce(a, b)`, `abs/round/min/max`, `upper/lower/contains/matches`, plus CEL
   built-ins (`has`, `size`, `int`, `string`, …) — see **objectstack-formula** for that
   table: it is `CEL_STDLIB_FUNCTIONS`, the bare-callable public subset, so receiver
   methods (called on a value, never bare) are not in it.
   An UNKNOWN function (`PRIOR()`, a typo'd name) and a `{…}`-wrapped field ref
   both **fail the build**: a brace is a template, not CEL — write `record.x`,
   not `{record.x}`.

5. **`notify` reports SUCCESS when the `messaging` capability is absent.** The
   executor logs `no messaging service registered` and returns success with
   `output: { delivered: 0, failed: 0, skipped: true }` and `metrics.acted: 0` —
   a green run that delivered nothing. Declare `messaging` in `requires`.

---

## State Machines & Approvals

A record's **state machine** locks the legal transitions of its status field
so that automation — increasingly AI-generated — cannot drive a record into an
illegal state.

### State Machine — a `state_machine` validation rule (ADR-0020)

Since **ADR-0020** there is **no `workflow` metadata type** and no
`object.stateMachines` map. A record state machine is **one `state_machine`
validation rule** in the object's `validations` array: a flat `field` +
`{ from: [allowedTo] }` transition table. It is **enforced on the write path** —
an update whose `field` moves to a state not listed for the current state is
rejected with the rule's `message`. A `from` state mapped to `[]` is a declared
dead-end.

```typescript
{
  type: 'state_machine',
  name: 'case_lifecycle',
  label: 'Case Lifecycle',
  field: 'status',                 // the field that holds the state
  message: 'Invalid status transition.',
  initialStates: ['new'],          // states a record may be CREATED in
  transitions: {
    new:       ['open'],
    open:      ['escalated', 'resolved'],
    escalated: ['open', 'resolved'],
    resolved:  ['open', 'closed'],
    closed:    [],                 // final — no outgoing transitions
  },
}
```

Notes:
- **One rule per field.** Parallel lifecycles (e.g. `status` + `payment_status`)
  are N separate `state_machine` rules, one per field.
- **`initialStates`** (optional) gates INSERT: a record created with its
  state field outside this list is rejected. `transitions` only governs
  updates, so without it a record can be born mid-flow (e.g. created already
  `resolved`). Omit to keep the legacy no-check-on-insert behavior.
- **Conditional transitions / side effects are NOT part of the machine.** A
  guard is expressed as a sibling `script` / `conditional` validation rule;
  "do something when the state changes" is a **record-triggered Flow**
  (ADR-0019) — a `record_change` flow whose start-node condition gates on the
  transition, e.g. `previous.status != 'escalated' && record.status == 'escalated'`.
- **Introspection:** `GET /api/v1/meta/object/:name/state/:field?from=:state`
  returns the legal next states so UIs/agents can read the transition table
  instead of hard-coding it (`next: null` = no FSM governs the field, **or**
  `?from=` was omitted — always pass `from`).
- **An unlisted `from` state is NOT guarded.** An update whose current state is
  not a key of `transitions` is treated leniently (no lock) — list every state
  you want guarded rather than relying on an implicit "any → any".
- Predicate conditions in sibling rules evaluate against the merged record in
  the **`record.<field>`** CEL scope (bare field names do not resolve).

### Approvals (Flow Nodes)

Since **ADR-0019** there is no standalone approval-process type. An approval is
authored as an **Approval node** (`type: 'approval'`) on an ordinary flow — the
run **suspends** when it reaches the node and **resumes** down the node's
`approve` / `reject` out-edge once a decision is recorded. Multi-step review is
just successive Approval nodes wired together on the canvas, so the whole review
is one diagram a reviewer (or AI) can read end-to-end.

> There is no `approvals: [...]` stack collection — approval flows live in your
> normal `flows: [...]`. The approval *state* (`sys_approval_request` /
> `sys_approval_action`, the record lock, the status mirror, approver
> resolution) is owned by `plugin-approvals`.

```typescript
// A record-triggered flow: high-value opportunities need manager sign-off,
// and director sign-off too when the amount clears 500k.
{
  name: 'opportunity_discount_approval',
  label: 'Opportunity Discount Approval',
  type: 'record_change',
  nodes: [
    // Record-change flows bind via the START NODE's config — there is no
    // separate top-level `trigger`. `triggerType` is one of
    // `record-(before|after)-(create|update|delete)`; `condition` (bare CEL)
    // gates whether the flow launches.
    {
      id: 'start',
      type: 'start',
      label: 'On Opportunity Update',
      config: {
        objectName: 'opportunity',
        triggerType: 'record-after-update',
        condition: cel`record.amount > 100000`,
      },
    },
    {
      id: 'manager_review',
      type: 'approval',
      label: 'Sales Manager Review',
      config: {
        approvers: [{ type: 'position', value: 'sales_manager' }],
        behavior: 'first_response',            // or 'unanimous' / 'quorum' / 'per_group'
        lockRecord: true,                      // lock the record while pending
        approvalStatusField: 'approval_status', // mirror pending|approved|rejected|recalled onto the row
      },
    },
    // Decision routing lives on the OUT-EDGES, not in node config: the engine
    // evaluates each out-edge's `condition` and follows every match — and an
    // out-edge with NO condition ALWAYS runs (all such edges execute in
    // PARALLEL). Guard every branch with a condition — see e4/e5 below.
    { id: 'needs_director', type: 'decision', label: 'Needs Director?' },
    {
      id: 'director_signoff',
      type: 'approval',
      label: 'Sales Director Sign-off',
      config: {
        approvers: [{ type: 'position', value: 'sales_director' }],
        behavior: 'unanimous',
        approvalStatusField: 'approval_status',
      },
    },
    { id: 'mark_won', type: 'update_record', label: 'Mark Won',
      config: { objectName: 'opportunity', filter: { id: '{record.id}' }, fields: { stage: 'closed_won' } } },
    { id: 'approved', type: 'end', label: 'Approved' },
    { id: 'rejected', type: 'end', label: 'Rejected' },
  ],
  edges: [
    { id: 'e1', source: 'start',          target: 'manager_review',
      // entry criteria re-homes onto the edge entering the approval node:
      condition: cel`record.amount > 100000` },
    { id: 'e2', source: 'manager_review',  target: 'needs_director',   label: 'approve' },
    { id: 'e3', source: 'manager_review',  target: 'rejected',         label: 'reject'  },
    // Decision branches: mutually-exclusive edge `condition` predicates.
    // Without them BOTH branches would execute (unguarded edges run in parallel).
    { id: 'e4', source: 'needs_director',  target: 'director_signoff', label: 'true',
      condition: cel`record.amount > 500000` },
    { id: 'e5', source: 'needs_director',  target: 'mark_won',         label: 'false',
      condition: cel`record.amount <= 500000` },
    { id: 'e6', source: 'director_signoff', target: 'mark_won',        label: 'approve' },
    { id: 'e7', source: 'director_signoff', target: 'rejected',        label: 'reject'  },
    { id: 'e8', source: 'mark_won',         target: 'approved' },
  ],
}
```

### Send-back for revision (ADR-0044)

Approval centers also model **send back for revision** (退回修改) — distinct from
`reject` (terminate) and from a comment thread (which keeps the request pending).
Send-back is a **flow movement**: the request finalizes as `returned`, the run
walks a **`revise`** out-edge to an **`approval_revise`** node (the *revise
window*) where the record unlocks and the submitter reworks it, and an explicit
*resubmit* re-enters the approval node over a **declared back-edge**, opening
round N+1 with a fresh approver slate.

```
approval ──approve──▶ …
         ──reject───▶ …
         ──revise───▶ approval_revise (record unlocked, submitter edits)
                        └──resubmit──[type:'back']──▶ approval   (round N+1)
```

Three pieces author it:

1. **`revise` out-edge** — a third branch label alongside `approve` / `reject`,
   targeting an **`approval_revise`** node. It must be that node type: the window
   is a *service-owned* pause (`resumeAuthority: 'service'`), ended only by
   `POST /api/v1/approvals/requests/:id/resubmit`; a `wait` is
   `resumeAuthority: 'any'`, so a raw run-resume would walk the back-edge
   unchecked. The node takes **no config** — there is no signal to wait on.
2. **`type: 'back'` resubmit edge** — the edge from the revise window back into
   the approval node MUST be typed `'back'`. This is the *only* thing that
   legalizes the cycle: `registerFlow` validates the graph **minus `back` edges**
   as a DAG, so an **unmarked** cycle is rejected — you opt in, edge by edge. At
   run time a back-edge traverses normally (it just re-enters the node).
3. **`maxRevisions`** on the approval `config` (default `3`) — the budget of
   send-backs per run; exceeding it **auto-rejects** (resumes down the `reject`
   edge). `maxRevisions: 0` disables send-back, so never pair `0` with a `revise`
   edge.

```typescript
{
  id: 'manager_review', type: 'approval', label: 'Manager Review',
  config: { approvers: [{ type: 'position', value: 'manager' }], lockRecord: true, maxRevisions: 2 },
},
// No config and no `waitEventConfig`: the window ends on the submitter's
// explicit resubmit, not on a signal or a timer.
{ id: 'wait_revision', type: 'approval_revise', label: 'Awaiting Revision' },
// …among the approval's edges…
{ id: 'rev',  source: 'manager_review', target: 'wait_revision',  label: 'revise' },
{ id: 'back', source: 'wait_revision',  target: 'manager_review', label: 'resubmit', type: 'back' },
```

> Three mistakes the compile-time flow lint flags: a `revise` edge into anything
> but an `approval_revise` node (an **error** — `sendBack` refuses that metadata,
> so the branch cannot run; `flow-approval-revise-target-not-service-owned`), a
> `revise` edge whose window never loops back (a dead end `registerFlow` accepts
> but that leaves the submitter nowhere to resubmit), and a resubmit edge left
> **without** `type: 'back'` (an unmarked cycle `registerFlow` rejects). Resubmit
> is an explicit verb (`POST /api/v1/approvals/requests/:id/resubmit`), never a
> record-save. See the `showcase_budget_approval` flow in the showcase app in
> the framework repo for the canonical shape.

### Recording a decision

A decision is recorded through `ApprovalService.decide()` (or the REST routes
`POST /api/v1/approvals/requests/:id/approve` | `/reject`). That finalizes the
`sys_approval_request` and **resumes** the suspended run down the matching
branch — you never resume the flow by hand, and you *cannot*: the
`approval` node declares `resumeAuthority: 'service'`, so
`POST /api/v1/automation/:name/runs/:runId/resume` answers **403** for a run
parked on one (including via a `subflow` pause) and changes nothing.

A decision may also carry **structured outputs** (`{ outputs: { … } }` in the
decide body) when the node declares the keys in `decisionOutputs` — the author
declares keys, approvers only fill values. Accepted outputs resume the run as
`<nodeId>.<key>` flow variables, so a LATER node reads them as
`vars.<nodeId>.<key>` — this is how "the previous approver picks the next
step's approvers" works without writing to a record field (see Dynamic
approvers below). A decision carrying an undeclared key is rejected;
`decision` / `requestId` are reserved. A declaration marked
`required: true` must carry a non-blank value to **approve** (never to
reject) — enforced before any write, with no elevation bypass, so the run
cannot resume past the node with the key a later `expression` approver reads
still missing.

### Approver Types

| `type` | Resolves to |
|:-------|:------------|
| `user`       | A specific user id (`value` = user id) |
| `position`   | Holders of a position — `value` = the position machine name, resolved via `sys_user_position` (ADR-0090 D3) |
| `org_membership_level` | The **org-membership tier** — `value` is one of `owner`/`admin`/`delegated_admin`/`member`. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `team`       | Members of a flat `sys_team` |
| `department` | A department + all descendant departments |
| `manager`    | The submitter's manager (`sys_user.manager_id`) |
| `field`      | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user |
| `queue`      | ⛔ Declared but never resolved — the slot routes to nobody. Do not author |
| `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph |

### Dynamic approvers (`type: 'expression'`)

An `expression` approver computes WHO approves at the moment the node is
entered. Its CEL source sees exactly **three roots** — nothing else:

| Root | Meaning | Analog |
|:-----|:--------|:-------|
| `current.*` | The record's **live** state at node entry — fields written by earlier steps/approvers are visible | ServiceNow `current` |
| `trigger.*` | The **submit-time snapshot** (what flow conditions call `record`) | ServiceNow Flow Designer `trigger.record`, Power Automate `triggerBody()` |
| `vars.*` | Flow variables — node outputs (`vars.<nodeId>.<key>`), `get_record` results, `vars.previous` (the pre-update row) | BPMN process variables |

**`record` and bare field names are NOT available and fail the node loudly.**
Everywhere else on this platform `record` means "the record at event time"
(flow conditions: the trigger snapshot; hook conditions: the stored record
overlaid with the write's payload) — at an
approval node that phrase is ambiguous between two different times, so you must
say which one: `current.x` or `trigger.x`. Do not carry the `record.x` habit
over from conditions.

Result contract: a user-id string, a CSV string, or an array of ids. An **empty**
result (present-but-empty field/variable) triggers `onEmptyApprovers`. A
**missing** key (`vars.never_written`) is a loud error, never a silent empty
slate — guard genuinely-optional inputs explicitly, e.g.
`has(vars.picked) ? vars.picked : []`.

```typescript
// ① Route on a field an EARLIER approver filled in mid-flow (live value):
{ type: 'expression', value: cel`current.co_review_departments`, resolveAs: 'department' }

// ② The previous approval node's decision outputs pick this node's approvers:
{ type: 'expression', value: cel`vars.lead_review.next_reviewers` }

// ③ Dynamic co-sign (会签): expression yields department ids; resolveAs expands
//    each into its members, and with behavior: 'per_group' EACH department is
//    its own sign-off group:
{
  approvers: [{ type: 'expression', value: cel`current.picked_departments`, resolveAs: 'department' }],
  behavior: 'per_group',
  onEmptyApprovers: 'fail',
}
```

The full "previous approver picks the next step's approvers" loop, end to end
(the shipped `showcase_dynamic_approval` flow in the showcase app is this shape):

<!-- os:check -->
```typescript
import { defineFlow } from '@objectstack/spec';

export const DynamicApprovalFlow = defineFlow({
  name: 'dynamic_approval',
  label: 'Dynamic Approval',
  type: 'autolaunched',
  status: 'active',
  nodes: [
    {
      id: 'start', type: 'start', label: 'On Submit',
      config: { objectName: 'expense', triggerType: 'record-after-update', condition: "status == 'submitted'" },
    },
    {
      // Node A declares what a decision may hand to the flow. The TYPED
      // declaration renders a multi-select sys_user picker in the decision
      // dialog; the lead approves with outputs:
      //   POST …/approve { outputs: { next_reviewers: ['u2', 'u3'] } }
      // `required: true` is enforced by the runtime on APPROVE (never on
      // reject) — node B below has nobody to route to without it.
      id: 'lead_review', type: 'approval', label: 'Lead Review',
      config: {
        approvers: [{ type: 'org_membership_level', value: 'owner' }],
        decisionOutputs: [{ key: 'next_reviewers', label: 'Next Reviewers', type: 'user', multiple: true, required: true }],
      },
    },
    {
      // Node B resolves them at entry from the lead's decision outputs.
      id: 'co_sign', type: 'approval', label: 'Co-sign',
      config: {
        approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }],
        behavior: 'unanimous',
        onEmptyApprovers: 'fail',
      },
    },
    { id: 'approved', type: 'end', label: 'Approved' },
    { id: 'rejected', type: 'end', label: 'Rejected' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'lead_review' },
    { id: 'e2', source: 'lead_review', target: 'co_sign', label: 'approve' },
    { id: 'e3', source: 'lead_review', target: 'rejected', label: 'reject' },
    { id: 'e4', source: 'co_sign', target: 'approved', label: 'approve' },
    { id: 'e5', source: 'co_sign', target: 'rejected', label: 'reject' },
  ],
});
```

Time-word cheat sheet across surfaces (do not mix them up):

| Surface | Event-time record | Pre-event record | Live record |
|:--------|:------------------|:-----------------|:------------|
| Flow condition / `{…}` template | `record` (trigger snapshot) | `previous` | — (use a `get_record` node) |
| Approval `expression` approver | `trigger.*` | `vars.previous` | `current.*` |

Object-hook `ctx` is a different vocabulary — see **objectstack-data**
`references/data-hooks.md`.

### Node Config (`ApprovalNodeConfigSchema`)

| Field | Purpose |
|:------|:--------|
| `approvers` | Who may act (≥ 1 — see Approver Types above). Each approver may carry an optional **`group`** label (e.g. `{ type: 'position', value: 'auditor', group: 'finance' }`) — with `behavior: 'per_group'`, approvers sharing a label form one group; unlabelled approvers each form their own |
| `behavior` | `first_response` (first approver decides), `unanimous` (all must approve), `quorum` (`minApprovals` of N — M-of-N collective sign-off), or `per_group` (EACH approver `group` must reach `minApprovals` — one-from-each-group sign-off, 会签). In every mode a single rejection finalizes the node as `rejected`. Default `first_response` |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Omitted ⇒ ALL resolvable approvers under `quorum`, `1` per group; clamped at runtime so a misconfiguration can never deadlock |
| `lockRecord` | Lock the triggering record from edits while pending. Default `true` |
| `approvalStatusField` | Business-object field to mirror `pending`/`approved`/`rejected`/`recalled` onto (should be readonly) |
| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through). Declare it explicitly on any node with an `expression` approver (linted) |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `timeoutHours` is **calendar (wall-clock) hours** — nights, weekends and holidays count; the platform ships no business-hours calendar. `escalateTo` is a **position machine name** (expanded to its holders via `sys_user_position`, ADR-0090 D3) or a specific user id — never a membership tier. `reassign` without `escalateTo` degrades to notify (linted) |
| `maxRevisions` | ADR-0044 — max **send-backs-for-revision** per run before auto-reject. Default `3`; `0` disables send-back. Only meaningful when the node has a `revise` out-edge |

### Branching, side-effects & rejection

These are wired on the **graph**, not in node config:

- **Conditional step** — put a `decision` node before the Approval node, or a
  `condition` on the edge entering it (the old per-step `entryCriteria`).
- **On approve / on reject** — wire downstream nodes (`update_record`,
  `http`, a `notify` node, …) to the `approve` / `reject` out-edge.
- **Roll back on reject** — route the `reject` edge as a **back-edge** to an
  earlier node so the submitter can revise (the old `back_to_previous`).
- **Send back for revision (ADR-0044)** — distinct from a plain reject: a
  `revise` out-edge into an **`approval_revise`** window, closed by a
  `type: 'back'` resubmit edge. See *Send-back for revision* above.
- **Hard reject** — route the `reject` edge to an `end` node (the old
  `reject_process`).

### Approval Best Practices

1. **Gate entry on the edge** (`condition` into the Approval node) so the flow
   only pauses for records that actually need sign-off.
2. **Set `approvalStatusField`** to mirror status onto the row — views and
   formulas can then filter on it without joining `sys_approval_request`.
3. **Keep `lockRecord: true`** unless you have a strong reason to allow
   edits while pending — otherwise approvers chase a moving target.
4. **Model rejection as a visible branch** — a back-edge to revise, or an `end`
   node to terminate. The path is on the diagram, not hidden in config.
5. **Notify from downstream nodes** wired to the `approve` / `reject` edges
   rather than expecting the node to send mail itself.

---

## Triggers — Event-Driven Automation

A `record_change` flo

…(truncated)
