> ## Documentation Index
> Fetch the complete documentation index at: https://velt.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Customize Behavior

> Node config, edge routing, parallel groups, triggers, SLAs, linter rules, events, errors, and the object reference.

This is the field reference. [Setup](/docs/ai/approval-engine/setup) covers the happy path, and [Patterns](/docs/ai/approval-engine/patterns) covers which option to pick. This page covers everything you reach for when you need precise control.

## Node configuration

Every node has a `nodeId`, a `type`, and a `config` block.

| Field                   | Type    | Notes                                                                                                                                                                                                                                                                                    |
| ----------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `nodeId`                | string  | Required. 1 to 64 chars. Unique within the definition.                                                                                                                                                                                                                                   |
| `type`                  | enum    | Required. `agent`, `human`, `notification`, or `webhook`.                                                                                                                                                                                                                                |
| `config`                | object  | Required. Shape depends on `type`. See below.                                                                                                                                                                                                                                            |
| `slaMs`                 | integer | Deadline for the step, up to 7 days. See [SLA and breach handling](#sla-and-breach-handling).                                                                                                                                                                                            |
| `requireNonEmptyOutput` | boolean | Sync webhook nodes only. Fails the step terminally with `webhook-node-empty-response` when the receiver returns an empty body. It has no effect in `mode: "async"`, because the step parks in `waiting` before the check runs. Accepted on the other node types, with no runtime effect. |
| `name`                  | string  | Cosmetic label, 1 to 200 chars. Echoed back, no runtime effect.                                                                                                                                                                                                                          |
| `description`           | string  | Cosmetic, up to 2000 chars. Echoed back, no runtime effect.                                                                                                                                                                                                                              |

`config` is validated strictly against the type. Unknown fields are rejected.

### Agent nodes

An agent node runs a Velt agent against a URL, then routes on the result. The step parks in `waiting` while the agent runs and resumes on its own when the agent finishes, so you never have to poke it.

| Field                | Type    | Notes                                                                                                                      |
| -------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------- |
| `agentId`            | string  | Required. A built-in or custom agent id. The reserved value `__mock__` returns a synthetic pass, for demos and tests only. |
| `url`                | string  | A fixed absolute URL, up to 2000 chars. **Wins when both `url` and `urlPath` are set.**                                    |
| `urlPath`            | string  | A dot-path into `triggerContext` that resolves the URL from the trigger payload, up to 500 chars.                          |
| `crossPageExecute`   | boolean | Let the agent crawl beyond the seed URL. Defaults to `false`.                                                              |
| `maxUrlsToProcess`   | integer | Cap on URLs crawled per run, up to 500.                                                                                    |
| `userContextMapping` | object  | `{ field: dotPath }` map that builds the agent's `userContext` from `triggerContext`.                                      |
| `promptOverride`     | string  | Up to 8000 chars.                                                                                                          |
| `inputMapping`       | object  | Extra step inputs passed to the agent.                                                                                     |
| `pollIntervalMs`     | integer | 5000 to 60000. How often the engine checks the agent's status. Defaults to 15000.                                          |
| `agentMaxRuntimeMs`  | integer | Hard ceiling, up to 24 hours. Defaults to 10 minutes.                                                                      |

**You must set `url` or `urlPath`.** Setting neither is rejected with `APPROVAL_AGENT_NODE_REQUIRES_URL_OR_URLPATH`. If `urlPath` resolves to nothing at run time and no static `url` is set, the step fails with `agent-url-unresolved`.

A `urlPath` value with no scheme is normalized. Vercel's `payload.deployment.url` of `my-app.vercel.app` becomes `https://my-app.vercel.app`. Existing `http://` and `https://` values are left alone.

```json theme={null}
{
  "nodeId": "brand-check",
  "type": "agent",
  "config": {
    "agentId": "brand-agent-v1",
    "urlPath": "documentUrl"
  },
  "slaMs": 3600000
}
```

The step output carries `agentExecutionStatus`, `agentResultsSummary`, `resolvedUrl`, `agentDurationMs`, and a `decision` of `approve` when the agent passed. Route on any of them with a [custom predicate](#custom-predicates).

<Warning>
  `blocking: true` requires a sibling `resolutionPolicy` to pass schema validation, and the pair is then rejected at run time with `agent-blocking-not-supported`, for every agent id including `__mock__`. To put a person in front of an agent's findings, place a `human` node downstream of the agent node instead.
</Warning>

### Human nodes

A human node waits for reviewers to approve or reject. Set exactly one of `reviewers[]` or the legacy `reviewerIds[]`.

| Field            | Type      | Notes                                                                                                      |
| ---------------- | --------- | ---------------------------------------------------------------------------------------------------------- |
| `reviewers`      | array     | Preferred. `[{ userId, mandatory }]`. Must include at least one `mandatory: true`. UserIds must be unique. |
| `reviewerIds`    | string\[] | Legacy. Every entry is treated as mandatory.                                                               |
| `reviewerEmails` | string\[] | Up to 50 addresses. Surfaced on the step's `output.reviewerEmails` for your notification UI.               |
| `commentBody`    | string    | Up to 8000 chars. Stored on the step's output for your reviewer UI to render.                              |

```json theme={null}
{
  "nodeId": "human-legal",
  "type": "human",
  "config": {
    "reviewers": [{ "userId": "u_legal_01", "mandatory": true }],
    "commentBody": "Please review for legal compliance."
  }
}
```

The step resolves when every mandatory reviewer approves, or when any reviewer rejects. Record decisions with [Record Reviewer Decision](/docs/api-reference/rest-apis/v2/approval-engine/steps/record-reviewer-decision).

<Note>
  A human node carries no rejection config. Its reject path is an outgoing `on: "reject"` edge, and every human node must have one, or the definition is rejected with `APPROVAL_HUMAN_NODE_REQUIRES_REJECT_PATH`. Group members are included in that rule.
</Note>

### Notification nodes

A notification node sends an email or Slack message built from the previous step's output. No extra cloud function needed.

| Field             | Type      | Notes                                                                                   |
| ----------------- | --------- | --------------------------------------------------------------------------------------- |
| `channel`         | enum      | Required. `email` or `slack`.                                                           |
| `bodyTemplate`    | string    | Required. 1 to 16000 chars, with `{{dot.path}}` interpolation.                          |
| `recipients`      | string\[] | Required for `email`. 1 to 50 addresses.                                                |
| `subjectTemplate` | string    | Email subject, up to 2000 chars.                                                        |
| `slackTarget`     | string    | Required for `slack`. A channel id such as `C0123`, or an `https` incoming-webhook URL. |
| `format`          | enum      | `text` (default), `html`, or `slack-blocks`.                                            |

```json theme={null}
{
  "nodeId": "notify-stakeholders",
  "type": "notification",
  "config": {
    "channel": "email",
    "recipients": ["lead@acme.dev", "pm@acme.dev"],
    "subjectTemplate": "Approval {{input.decision}} for {{execution.triggerContext.page.title}}",
    "bodyTemplate": "Findings: {{input.agentResultsSummary.summary}}",
    "format": "text"
  }
}
```

**Templating** is dot-path substitution only. There is no code execution. Missing tokens render as an empty string, and objects and arrays are JSON-stringified. Three roots are available:

| Root          | Resolves to                                                           |
| ------------- | --------------------------------------------------------------------- |
| `input.*`     | The previous step's output.                                           |
| `execution.*` | `executionId`, `definitionId`, `correlationId`, and `triggerContext`. |
| `step.*`      | This step's metadata.                                                 |

**Delivery notes.** Email goes through your workspace's SendGrid configuration. Delivering to at least one recipient completes the step; delivering to none fails it and retries. Slack delivery to a channel id needs a workspace bot token, and a webhook-URL target must be `https` on an allowed host. Slack config errors such as `channel_not_found` or `invalid_auth` are terminal and are not retried, while 5xx, network errors, and `rate_limited` are retried.

`format: "slack-blocks"` means the rendered body must be a JSON array of Slack Block Kit blocks. It is valid only on the Slack channel and is rejected on email with `APPROVAL_NOTIFICATION_SLACK_BLOCKS_REQUIRES_SLACK`.

### Webhook nodes

A webhook node calls your own HTTPS endpoint as a workflow step.

| Field                 | Type       | Notes                                                                             |
| --------------------- | ---------- | --------------------------------------------------------------------------------- |
| `url`                 | string     | Required. `https` only, host-allowlisted, up to 2000 chars.                       |
| `mode`                | enum       | `sync` (default) or `async`.                                                      |
| `method`              | enum       | `GET` or `POST`. Defaults to `POST`.                                              |
| `authMode`            | enum       | `hmac` (default), `token`, or `none`.                                             |
| `authTokenHeader`     | string     | Required when `authMode: "token"`. The header the engine puts your token in.      |
| `timeoutMs`           | integer    | 1000 to 60000. Defaults to 10000.                                                 |
| `expectedStatusCodes` | integer\[] | Up to 20 status codes to treat as success instead of the 2xx default.             |
| `bodyTemplate`        | enum       | `envelope` (default), `pass-through`, or `none`. `none` requires `method: "GET"`. |
| `requestHeaders`      | object     | Extra headers. `x-velt-*` and reserved header names are rejected.                 |

**`sync` mode** posts and waits for your response. A 2xx (or a listed `expectedStatusCodes` value) completes the step. A 4xx fails it terminally, because that is a configuration error on your side. A 5xx, timeout, or network error fails it with retry budget remaining.

**`async` mode** posts the payload, then parks the step in `waiting`. With the default `bodyTemplate: "envelope"`, the body carries `callback.url`, `callback.token`, and `callback.tokenHeader`. With `bodyTemplate: "pass-through"`, the same three values are nested under `_velt` as `_velt.callbackUrl`, `_velt.callbackToken`, and `_velt.callbackTokenHeader`. To complete or fail the step, POST to that callback URL with the token in the `x-velt-callback-token` header and a body of `{ "status": "completed" | "failed", "output"?: {}, "error"?: {} }`. Async mode needs a `webhookSecret` on the execution to sign the callback token.

With `authMode: "hmac"` the engine signs the outbound body with the execution's `webhookSecret` in `x-velt-signature`. With `authMode: "token"` it sends a static token, whose value is read at dispatch time from `triggerContext.webhookAuth[<nodeId>]` so the secret never lives in the definition.

On success the step output carries `httpStatus`, allowlisted `responseHeaders`, `responseJson` when the response is JSON, and `responseText` capped at 64 KB.

## Edge model

Every transition is one entry in `edges[]`: approve routing, reject routing, group fan-out and fan-in, and loop-backs.

```json theme={null}
{ "from": "human-review", "to": "rework-notice", "on": "reject" }
```

| Field  | Type                | Required                            | Notes                                                                                                          |
| ------ | ------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `from` | `EdgeEndpoint`      | yes                                 | Bare node-id string, `{ kind: "node", nodeId }`, or `{ kind: "group", groupId }`.                              |
| `to`   | `EdgeEndpoint`      | yes                                 | Same shape as `from`.                                                                                          |
| `on`   | enum                | no, defaults to `always`            | `approve`, `reject`, `always`, `exhausted`, or `custom`. `approve` and `reject` auto-compile their predicates. |
| `when` | JSON-AST string     | only with `on: "custom"`            | Up to 1000 chars. Rejected on any other role.                                                                  |
| `loop` | `{ maxIterations }` | only on an `on: "reject"` back-edge | 1 to 20. Marks the edge as a loop-back.                                                                        |

Edges round-trip exactly. What you POST is what you read back.

### Reject, loop-back, and exhausted

* **Reject route.** An outgoing `on: "reject"` edge from the rejecting node.

  ```json theme={null}
  { "from": "human-review", "to": "rework-notice", "on": "reject" }
  ```

* **Loop-back.** An `on: "reject"` edge whose `to` is an ancestor of `from`, marked with `loop`. The server derives the loop region from it.

  ```json theme={null}
  { "from": "human-review", "to": "agent-draft", "on": "reject", "loop": { "maxIterations": 3 } }
  ```

* **Exhausted route.** A sibling `on: "exhausted"` edge from the same `from`, fired when the loop hits its cap. Without one, an exhausted loop rolls the execution up to `failed`.

  ```json theme={null}
  { "from": "human-review", "to": "escalate", "on": "exhausted" }
  ```

### Custom predicates

Use `on: "custom"` with a `when` expression to gate an edge on the source step's output. `when` is a JSON-AST string. The engine parses it as JSON and evaluates it with a safe walker, never as JavaScript.

```json theme={null}
{ "from": "brand-check", "to": "legal-review", "on": "custom", "when": "{\"op\":\"eq\",\"args\":[{\"var\":\"output.passesBrandCheck\"},true]}" }
```

Common shapes:

| Goal            | `when` value                                               |
| --------------- | ---------------------------------------------------------- |
| equality        | `{"op":"eq","args":[{"var":"output.decision"},"approve"]}` |
| numeric compare | `{"op":"gt","args":[{"var":"output.score"},0.8]}`          |
| boolean AND     | `{"op":"and","args":[<a>,<b>]}`                            |
| boolean OR      | `{"op":"or","args":[<a>,<b>]}`                             |
| negation        | `{"op":"not","args":[<a>]}`                                |
| regex match     | `{"op":"regex","args":[{"var":"output.body"},"^urgent"]}`  |

Supported operators: equality, comparison, boolean and/or/not, `regex`, `includes`, `startsWith`, `endsWith`, `length`, `isEmpty`.

Path roots:

| Root                | Resolves to                                                             |
| ------------------- | ----------------------------------------------------------------------- |
| `output.*`          | The source step's terminal output.                                      |
| `step.*`            | The source step's metadata: `stepId`, `nodeId`, `status`, `retryCount`. |
| `execution.input.*` | The `triggerContext` you passed on dispatch.                            |

An `on: "always"` edge, the default when `on` is omitted, fires whenever the source step reaches a fan-out-eligible status.

### Groups as edge sources

A group can be an edge source (`from: { kind: "group", groupId }`), giving it one collective branch instead of one fan-out per member.

* **`waitAll` as an edge source.** All members must terminate, then the group takes one branch by unanimity: `approve` if every member approved, otherwise `reject`. Provide both branches and exactly one fires. A routed collective reject is not a failed run: when the reject branch's successor completes, the execution rolls up to `completed`, and the rejecting member is `completed` with `output.decision: "reject"`, never `failed`.
* **`cancelOnQuorum` and `joinOnQuorum` as edge sources.** They fire one collective approve-successor on approval quorum. A forward `on: "reject"` from either is rejected as a dead edge, because those policies only fan out on approval.
* **Edges into a group** (`to: { kind: "group", groupId }`) expand to one edge per member, for every quorum policy.

Successor step ids from a group are `group_<groupId>__to__<childNodeId>`. Group-to-group edges are rejected.

### The compiled view

Every `DefinitionView` returns a read-only `compiled` block next to your authored `edges`. `compiled.forwardEdges` is the runtime edge list with group endpoints expanded and `on` roles compiled to predicate ASTs. `compiled.loops` is the derived loop-region list.

Render `compiled` directly instead of re-implementing the compiler in your client. See [Get Definition](/docs/api-reference/rest-apis/v2/approval-engine/definitions/get-definition#the-compiled-block).

## SLA and breach handling

Set `slaMs` on any node to give the step a deadline, up to 7 days. If the step does not complete in time it becomes `breached` and emits `step.breached`.

Declare an outgoing edge that routes on the breached status: either an `on: "always"` edge, which fires on the four fan-out-eligible statuses `completed`, `skipped`, `breached`, and `failed`, or an `on: "custom"` edge whose `when` tests for `breached`. A node that has outgoing edges but none that route on a breach is rejected with `missing-breach-edge`. A terminal node with no outgoing edges is accepted; if it breaches, the execution rolls up to `failed` and emits `execution.failed`.

Agent nodes also have their own ceiling via `agentMaxRuntimeMs`, which defaults to 10 minutes.

## Parallel groups and quorum policies

A parallel group declares member nodes that run in parallel and share an approval threshold.

```json theme={null}
{
  "groupId": "parallel-review",
  "memberNodeIds": ["human-legal", "human-brand"],
  "expectedSteps": 2,
  "quorum": 2,
  "onQuorumMet": "waitAll"
}
```

| Field             | Type      | Required | Description                                                                                                                                                               |
| ----------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `groupId`         | string    | yes      | 1 to 64 chars. Stable identifier.                                                                                                                                         |
| `memberNodeIds`   | string\[] | yes      | 1 to 500 nodes. Each must be a declared node. A node belongs to at most one group.                                                                                        |
| `expectedSteps`   | integer   | yes      | 1 to 500. Members the group expects to terminate before it considers itself done. Set it equal to `memberNodeIds.length`; a higher value means the group never completes. |
| `quorum`          | integer   | yes      | 1 to `expectedSteps`. Approvals needed to fire the policy.                                                                                                                |
| `onQuorumMet`     | enum      | no       | `waitAll` (default), `cancelOnQuorum`, or `joinOnQuorum`.                                                                                                                 |
| `requiredNodeIds` | string\[] | no       | Members whose approval is required. Each must be in `memberNodeIds`, and `length` must be at most `quorum`.                                                               |

### Quorum counts approvals, not completions

A member counts toward quorum only when it ends `completed` with `output.decision === "approve"`. Rejections, failures, breaches, and cancellations advance the completion counter, never the approval counter.

Two consequences follow:

1. **Agent nodes do count toward quorum.** An agent step ends `completed` with `output.decision: "approve"` when its run passes or is skipped, so it advances the approval counter exactly like a human approval. The one exception is an agent step that fails before dispatch: it takes the failure path, whose `output` is empty and carries no `decision`. See [Anti-patterns](/docs/ai/approval-engine/patterns#assuming-an-agent-in-a-quorum-group-never-counts).
2. **A reject does not block group completion.** It just stops the approval counter from advancing. Completion and quorum are tracked separately.

### `onQuorumMet` policies

| Policy              | What happens when approval quorum is first met                                                                                    | Per-member fan-out                                                                                                 |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `waitAll` (default) | Emits `group.quorum-met` and nothing else. As an edge source, waits for all members and fires one collective branch by unanimity. | Each member's edges fire on its own completion. Two members pointing at the same node create two downstream steps. |
| `cancelOnQuorum`    | Emits `group.quorum-met` and cancels every sibling still in `waiting`, with `actorId: "system:group-quorum"`.                     | Each completing member still fans out. Cancelled siblings do not.                                                  |
| `joinOnQuorum`      | Emits `group.quorum-met`, cancels waiting siblings, and fires a single group-owned successor per shared target.                   | Suppressed for members. The group owns fan-out, so each successor runs exactly once.                               |

A `joinOnQuorum` successor's input is `{ groupOutputs, groupId, quorum, totalApproved }`.

### Requiring specific approvers

By default quorum is anonymous: any N approvals out of M members fire the policy. To require specific people, list them in `requiredNodeIds`:

```json theme={null}
{
  "groupId": "approver-group",
  "memberNodeIds": ["legal", "finance", "brand"],
  "expectedSteps": 3,
  "quorum": 2,
  "requiredNodeIds": ["legal", "finance"]
}
```

Quorum now needs both conditions: every node in `requiredNodeIds` approved, **and** the numeric `quorum` reached. Here `brand` approving alone never satisfies quorum, even though `quorum: 2` could be met numerically. Omit `requiredNodeIds` to fall back to anonymous quorum.

## Loop regions

A loop region lets a workflow re-enter an earlier node when a reviewer rejects, instead of failing outright.

You do not declare loops directly. Mark an `on: "reject"` edge with `loop`, where the `to` is an ancestor of the `from`, and the server derives the region. Add a sibling `on: "exhausted"` edge to route when the cap is reached.

```json theme={null}
{ "from": "human-legal", "to": "agent-draft", "on": "reject", "loop": { "maxIterations": 5 } }
{ "from": "human-legal", "to": "human-escalate", "on": "exhausted" }
```

The derived region is surfaced read-only as `compiled.loops[]`:

| Field           | Type             | Description                                                                                         |
| --------------- | ---------------- | --------------------------------------------------------------------------------------------------- |
| `loopId`        | string           | Server-assigned identifier.                                                                         |
| `entryNodeId`   | string           | Node re-entered on each iteration, the reject edge's `to`.                                          |
| `bodyNodeIds`   | string\[]        | Nodes inside the iteration scope, derived from the closure between `to` and `from`.                 |
| `maxIterations` | integer          | 1 to 20. Hard cap per execution.                                                                    |
| `onExhausted`   | object or `null` | Target of the sibling `on: "exhausted"` edge. `null` rolls the execution up to `failed` at the cap. |

The iteration predicate is `decision == 'reject' && rejectorMandatory == true`. Custom loop predicates are not supported: `loop` is valid only on an `on: "reject"` back-edge.

### Body-shape constraint

The derived loop body must be one of two shapes:

1. **Single-terminal sequential.** Exactly one body node has edges leaving the body. That node is the iteration terminal.
2. **Group-bounded.** The exit-bearing body nodes are exactly the `memberNodeIds` of one `joinOnQuorum` group, every member is inside the body, and the group has `quorum === expectedSteps`.

Other shapes are rejected with `loop-body-must-have-single-terminal`.

### Context threaded into the next iteration

The entry step of iteration N+1 receives:

```ts theme={null}
{
  iteration: number;            // N+1
  loopId: string;
  previousAttempts: Array<{
    iteration: number;
    authorOutput: Record<string, unknown>;  // the body's iteration-terminal output
    rejectedBy: string;
    rejectorMandatory: boolean;
    rejectionReason: string | null;
    rejectedAt: number;
  }>;
}
```

When a parallel group lives inside a loop body, each iteration gets fresh quorum state, so per-iteration quorum always starts from zero.

## Triggers

Add a `triggers[]` entry to a definition and the engine starts runs for you, with no dispatch call. Each entry carries at most one mechanism: `inboundWebhook`, `schedule`, or `appTrigger`. Combining two on one entry is rejected. A definition may declare up to 50 entries, each using a different mechanism.

| Field            | Type   | Required | Notes                                                       |
| ---------------- | ------ | -------- | ----------------------------------------------------------- |
| `triggerId`      | string | yes      | 1 to 128 chars. Stable id, also the idempotency-key prefix. |
| `eventName`      | string | no       | Up to 128 chars. Optional label.                            |
| `filters`        | object | no       | Free-form match filters.                                    |
| `inboundWebhook` | object | no       | See [Inbound webhook trigger](#inbound-webhook-trigger).    |
| `schedule`       | object | no       | See [Scheduled (cron) trigger](#scheduled-cron-trigger).    |
| `appTrigger`     | object | no       | See [App trigger](#app-trigger).                            |

<Note>
  **Scope is inherited, always.** A triggered run carries the owning definition's `scope`. An organization- or document-scoped definition fires runs with the same `organizationId` and `documentId`. Org and document ids in a webhook body never set the run's scope.
</Note>

### Inbound webhook trigger

Declaring `inboundWebhook` exposes the definition at `POST /v2/workflow/webhook-inbound/trigger` so external systems can start runs. Velt's native contract verifies an HMAC or bearer signature, and provider presets let GitHub, Vercel, or a custom source sign with their own scheme.

```json theme={null}
{
  "triggerId": "ci-build",
  "inboundWebhook": {
    "authMode": "hmac",
    "secret": "github-webhook-secret-...",
    "provider": "github",
    "allowedEvents": ["workflow_run", "push"],
    "idempotencyHeader": "x-github-delivery"
  }
}
```

| Field                 | Type      | Required    | Notes                                                                                                                                |
| --------------------- | --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `authMode`            | enum      | yes         | `hmac` verifies a body signature. `bearer` reads `Authorization: Bearer <secret>`, and is `velt`-only.                               |
| `secret`              | string    | yes         | 16 to 512 chars. The per-trigger secret, and the real authenticator.                                                                 |
| `provider`            | enum      | no          | `velt` (default), `github`, `vercel`, or `custom`.                                                                                   |
| `signatureHeader`     | string    | custom only | Header carrying the hex signature.                                                                                                   |
| `signatureAlgorithm`  | enum      | custom only | `sha1` or `sha256`.                                                                                                                  |
| `signaturePrefix`     | string    | custom only | Prefix stripped before comparing, such as `sha256=`.                                                                                 |
| `allowedEvents`       | string\[] | no          | 1 to 50 event names. Non-matching events return HTTP 200 with `event-ignored`.                                                       |
| `eventNameHeader`     | string    | custom only | Header carrying the event name for `allowedEvents`.                                                                                  |
| `idempotencyHeader`   | string    | no          | Header to read the source event id from.                                                                                             |
| `idempotencyBodyPath` | string    | no          | Dot-path in the body for the source event id, such as `event.id`.                                                                    |
| `payloadMapping`      | enum      | no          | Applies to every provider. `pass-through` (default) sends the extracted payload as `triggerContext`; `wrap` nests it under `source`. |

Presets resolve the signature header, algorithm, prefix, and event-name source for you:

| `provider`       | Signature header      | Algorithm            | Prefix            | Event name from                                      |
| ---------------- | --------------------- | -------------------- | ----------------- | ---------------------------------------------------- |
| `velt` (default) | `x-velt-signature`    | HMAC-SHA256          | `sha256=`         | body `type`                                          |
| `github`         | `x-hub-signature-256` | HMAC-SHA256          | `sha256=`         | `x-github-event` header                              |
| `vercel`         | `x-vercel-signature`  | HMAC-SHA1            | none              | `x-vercel-deployment-event` header, then body `type` |
| `custom`         | `signatureHeader`     | `signatureAlgorithm` | `signaturePrefix` | `eventNameHeader`                                    |

Any provider other than `velt` requires `authMode: "hmac"`.

**Request rules.**

* **API key.** Send `x-velt-api-key`, or use the `?apiKey=` query param for providers that cannot set custom headers. The header wins if both are present. The Velt API key is a publishable client key, and the per-trigger `secret` is what actually authenticates the call.
* **Identifiers.** Put `definitionId` and `triggerId` in the body for `velt`, or pass them as `?definitionId=` and `?triggerId=` query params.
* **Body.** A JSON object up to 1 MB. For `velt`, shape it as `{ definitionId, triggerId, payload }` and `payload` becomes the `triggerContext`. For every other provider, the **entire body** becomes the `triggerContext`.
* **Idempotency.** The source event id becomes the `idempotencyKey`, namespaced as `trig:<triggerId>:<id>` and deduplicated for 24 hours. Without one, the engine falls back to a per-request key.
* **Responses.** Success returns `{ ok: true, code: "accepted", executionId, deduplicated }`. A filtered event returns HTTP 200 with `{ ok: false, code: "event-ignored" }`.

An `allowedEvents` gate fails closed. If the event name cannot be resolved, the event is dropped.

<Note>
  This endpoint takes **raw JSON**. Unlike the other REST endpoints, you do not wrap the payload in a `data` envelope, because external providers cannot reshape their outgoing bodies. Bodies over 1 MB are rejected with HTTP 413 and code `body-too-large`. This endpoint applies no per-source rate limiting, so add your own throttling in front of it if your source can burst. URL values inside the payload are not screened: the SSRF allowlist applies only to outbound destination URLs you configure on a definition, meaning the webhook node `url`, `webhookConfig.url`, the dispatch `webhookUrl`, and a URL-valued `slackTarget`.
</Note>

### Scheduled (cron) trigger

Declaring `schedule` starts runs on a cron cadence, for example a nightly audit.

```json theme={null}
{
  "triggerId": "nightly-audit",
  "schedule": {
    "cron": "0 2 * * *",
    "timezone": "America/Los_Angeles",
    "enabled": true,
    "payloadTemplate": { "source": "nightly" }
  }
}
```

| Field             | Type    | Required | Notes                                                                                                                               |
| ----------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `cron`            | string  | yes      | Standard 5-field cron expression. Validated at write time (`APPROVAL_SCHEDULE_CRON_INVALID`).                                       |
| `timezone`        | string  | yes      | An IANA zone such as `America/Los_Angeles`. Validated at write time (`APPROVAL_SCHEDULE_TIMEZONE_INVALID`). DST is handled for you. |
| `enabled`         | boolean | yes      | Only enabled schedules fire. Setting `false` removes the schedule.                                                                  |
| `payloadTemplate` | object  | no       | A static object merged into `triggerContext` under `schedule.payload`.                                                              |

The dispatched run's `triggerContext.schedule` carries `{ triggerId, scheduledAt, payload }`. A schedule fires at most once per scheduled instant, and missed runs are not replayed.

### App trigger

Install the Velt **GitHub App** or **Vercel Integration** once, and matching events route to your workflows automatically. There is no per-repo or per-project webhook to configure, and no secret on the trigger: deliveries are authenticated app-wide against the provider's signature.

Three steps:

1. **Connect the installation once** from your Velt dashboard. This returns an `installationRef`: the GitHub `installation.id`, or the Vercel `configuration.id`.
2. **Add an `appTrigger`** to a definition, referencing that `installationRef`.
3. **Events fire automatically.** Every matching event starts a run.

```json theme={null}
{
  "triggerId": "gh-deploy-review",
  "appTrigger": {
    "provider": "github",
    "installationRef": "41234567",
    "repoFilter": ["acme/website"],
    "allowedEvents": ["push", "pull_request"]
  }
}
```

| Field             | Type      | Required | Notes                                                                                                          |
| ----------------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| `provider`        | enum      | yes      | `github` or `vercel`.                                                                                          |
| `installationRef` | string    | yes      | 1 to 256 chars. Must already be connected to your workspace, or the request fails with `FAILED_PRECONDITION`.  |
| `repoFilter`      | string\[] | no       | GitHub `org/repo` allowlist, up to 200 entries. Omit for every repo.                                           |
| `projectFilter`   | string\[] | no       | Vercel project id or name allowlist, up to 200 entries. Omit for every project.                                |
| `allowedEvents`   | string\[] | no       | 1 to 50 event names, such as `push` or `deployment.succeeded`. Omit for all events.                            |
| `payloadFilters`  | array     | no       | Up to 20 entries of `{ path, in }`. The binding fires only when every filter matches a value in its `in` list. |

Each delivery is deduplicated on the provider's delivery id, so duplicate deliveries never re-run a workflow. Events are matched by `(provider, installationRef)`, then narrowed by your filters.

<Note>
  App triggers are available on Superflow-platform workspaces. Creating a definition with an `appTrigger` from another workspace returns `FAILED_PRECONDITION` with `APPROVAL_APP_PLATFORM_NOT_SUPPORTED`.
</Note>

**Example: scan every Vercel production deploy for broken links, then email the result.**

```json theme={null}
{
  "definitionId": "vercel-broken-links-scan",
  "name": "Vercel deploy scan",
  "nodes": [
    {
      "nodeId": "scan",
      "type": "agent",
      "config": { "agentId": "broken-links", "urlPath": "payload.deployment.url", "maxUrlsToProcess": 50 }
    },
    {
      "nodeId": "notify",
      "type": "notification",
      "config": {
        "channel": "email",
        "recipients": ["web-team@acme.com"],
        "subjectTemplate": "Broken-link scan for {{execution.triggerContext.payload.deployment.url}}",
        "bodyTemplate": "Found {{input.agentResultsSummary.totalFindings}} broken link(s).",
        "format": "text"
      }
    }
  ],
  "edges": [{ "from": "scan", "to": "notify", "on": "always" }],
  "triggers": [
    {
      "triggerId": "vercel-prod-deploys",
      "appTrigger": {
        "provider": "vercel",
        "installationRef": "icfg_AbC123xyz",
        "projectFilter": ["my-marketing-site"],
        "allowedEvents": ["deployment.succeeded"]
      }
    }
  ]
}
```

`urlPath` resolves the just-deployed URL from the Vercel payload. It arrives without a scheme, so the engine normalizes it to `https://`.

## Cancelling and overriding

| Goal                                         | Endpoint                                                                                    |
| -------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Stop a whole run and all its in-flight steps | [Cancel Execution](/docs/api-reference/rest-apis/v2/approval-engine/executions/cancel-execution) |
| Stop one step, leaving its siblings running  | [Cancel Step](/docs/api-reference/rest-apis/v2/approval-engine/steps/cancel-step)                |
| Force a parked step to a decision            | [Resolve Step](/docs/api-reference/rest-apis/v2/approval-engine/steps/resolve-step)              |

Cancelling a run is a no-op on a run that already reached a terminal status. Successors of a cancelled step are never scheduled.

Resolve Step has two flavors. `force-approve`, `force-reject`, `force-complete`, and `force-fail` are admin overrides for hung steps. `reviewer-approve` and `reviewer-reject` are reviewer actions, and they additionally require `actorId` to be one of the step's declared reviewers, or the call is rejected with `PERMISSION_DENIED`.

For every approve or reject action the engine computes `decision`, `approved`, and `approvalReply` itself and writes them as authoritative. A caller-supplied `output` carrying those keys cannot override them, so you cannot record a rejection in the audit log while routing downstream edges as an approval. Other keys in `output` still pass through.

## Webhook delivery

Set a receiver in one of two places:

| Where                                      | Applies to                   | Fields                         |
| ------------------------------------------ | ---------------------------- | ------------------------------ |
| `webhookConfig` on the definition          | Every run of that definition | `{ url, secret, eventTypes? }` |
| `webhookUrl` + `webhookSecret` on dispatch | That one run                 | Overrides `webhookConfig`      |

`secret` is 16 to 512 chars. Optional `eventTypes` narrows delivery to up to 50 event types. Both forms are `https`-only and SSRF-guarded at write time and again at delivery time.

### Retry policy

| Attempt     | Delay before retry      |
| ----------- | ----------------------- |
| 1 (initial) | n/a                     |
| 2           | 2 s                     |
| 3           | 8 s                     |
| 4           | 32 s                    |
| 5           | 2 min                   |
| 6           | 8 min, then dead-letter |

After the final retry the payload goes to a dead-letter queue. Recover missed events with [Get Execution Events](/docs/api-reference/rest-apis/v2/approval-engine/executions/get-execution-events) and `sinceSeq`.

**Delivery is at-least-once.** The same `eventId` and `seq` appear on retries, so make your receiver idempotent on `(executionId, seq)`.

For signature verification and receiver setup, see [Setup, Step 4](/docs/ai/approval-engine/setup#step-4-get-the-outcome).

## Events

### Event reference

Events delivered by webhook and returned from [Get Execution Events](/docs/api-reference/rest-apis/v2/approval-engine/executions/get-execution-events):

| Event type               | When emitted                                                                               | `data` highlights                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ------------------------ | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `execution.dispatched`   | Run created and first steps scheduled.                                                     | `{ definitionId, definitionVersion, rootStepIds }`                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `execution.completed`    | All steps terminal, no unhandled failure.                                                  | null                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `execution.failed`       | A blocking step failed or breached with no recovery edge.                                  | null                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `execution.cancelled`    | Run cancelled, or fully rolled back.                                                       | `{ reason? }`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `step.awaiting-approval` | A step entered `waiting`: a human step, a running agent step, or an async webhook step.    | Human steps: `{ waitingForReviewers, mandatoryCount, resumeKey }`. Agent steps: `{ agentId, agentExecutionId, pollIntervalMs }`.                                                                                                                                                                                                                                                                                                                                                                                                 |
| `step.completed`         | Step transitioned to `completed`.                                                          | Human steps: `{ aggregatorStatus, nodeType, decision }`. Agent steps: `{ agentExecutionId, agentExecutionStatus, decision, source }`.                                                                                                                                                                                                                                                                                                                                                                                            |
| `step.failed`            | Step failed after its retry budget ran out, or failed terminally when retries cannot help. | Varies by node type. Human steps: `{ reason }` (`no-reviewers`, `no-mandatory-reviewers`, `duplicate-reviewer-user-ids`, `exception`). Webhook steps: `{ httpStatus, latencyMs, retryClass }`. Notification steps: `{ channel, retryClass }`, which is also the shape Slack config errors such as `channel_not_found` use. Config-validation failures on either type emit `{ reason }` instead, including the `webhook-node-empty-response` path. Agent resumes: `{ agentExecutionId, agentExecutionStatus, decision, source }`. |
| `step.breached`          | Step passed its SLA before completing.                                                     | `{ reason }`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `step.cancelled`         | Step cancelled directly or by a quorum side effect.                                        | `{ actorId, reason }`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `group.quorum-met`       | A group's approval threshold was first satisfied.                                          | `{ groupId, total, quorum, completedTotal, expectedSteps }`                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `loop.iteration-started` | A rejected iteration spawned the next one below the cap.                                   | `{ loopId, iteration, triggeredBy: 'rejection' }`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `loop.exhausted`         | The loop hit `maxIterations`.                                                              | `{ loopId, iteration, lastRejectedBy?, lastRejectionReason? }`                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |

A step using the reserved `__mock__` agent id completes inline and emits `{ agentId, synthetic, decision }` instead, so a receiver you tested against `__mock__` sees different `data` keys in production.

The `{ code, message }` error object is not part of the event `data`. It lives on the step's `error` field, which you read with [Get Execution](/docs/api-reference/rest-apis/v2/approval-engine/executions/get-execution) as `steps[].error`. Route on `event.type`, then fetch the step for the detail.

<Note>
  Internal events such as `step.scheduled`, `step.started`, `step.retried`, and `step.overridden` consume `seq` numbers but are not delivered externally. Your stream may have gaps in `seq`, which is expected.
</Note>

### Cancellation reasons

`step.cancelled` carries a `data.reason` string. This is an open set, so switch on `event.type` for control flow, not on `data.reason`.

| Reason             | Source | Meaning                                                                                                                                                       |
| ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `group-quorum-met` | system | The parent group met quorum under `cancelOnQuorum` or `joinOnQuorum`. Audit shows `actorId: "system:group-quorum"`.                                           |
| `loop-restart`     | system | A [loop region](#loop-regions) started the next iteration while a step from the previous one was still running. Audit shows `actorId: "system:loop-restart"`. |
| (admin-supplied)   | admin  | Free-form reason passed to [Cancel Step](/docs/api-reference/rest-apis/v2/approval-engine/steps/cancel-step).                                                      |

## Linter rules

Definitions are validated on create and update. Any violation returns `INVALID_ARGUMENT` with the code in the message.

| Code                                               | Meaning                                                                                                                                                                                                          |
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `duplicate-node-id`                                | Two nodes share the same `nodeId`.                                                                                                                                                                               |
| `dangling-edge`                                    | An edge references a node or group that is not declared.                                                                                                                                                         |
| `cycle-detected`                                   | The graph has a cycle. Only marked reject loop-backs may revisit a node.                                                                                                                                         |
| `unreachable-node`                                 | A node has no path from any root.                                                                                                                                                                                |
| `node-missing-config`                              | A node has no `config` block.                                                                                                                                                                                    |
| `missing-breach-edge`                              | A node sets `slaMs` and has outgoing edges, but none routes on a breach.                                                                                                                                         |
| `group-duplicate-id`                               | Two groups share the same `groupId`.                                                                                                                                                                             |
| `group-members-empty`                              | `memberNodeIds` is empty.                                                                                                                                                                                        |
| `group-member-missing`                             | A member references an unknown node.                                                                                                                                                                             |
| `group-expected-steps-invalid`                     | `expectedSteps` is below 1.                                                                                                                                                                                      |
| `group-quorum-invalid`                             | `quorum` is below 1 or above `expectedSteps`.                                                                                                                                                                    |
| `group-cancelonquorum-requires-quorum-lt-expected` | `cancelOnQuorum` requires `quorum < expectedSteps`.                                                                                                                                                              |
| `group-joinonquorum-members-must-share-successors` | `joinOnQuorum` requires every member to have the same set of outgoing targets.                                                                                                                                   |
| `group-required-not-in-members`                    | An entry in `requiredNodeIds` is not in `memberNodeIds`.                                                                                                                                                         |
| `group-required-exceeds-quorum`                    | `requiredNodeIds.length` is greater than `quorum`.                                                                                                                                                               |
| `group-node-in-multiple-groups`                    | A node is a member of two or more groups.                                                                                                                                                                        |
| `loop-node-in-multiple-loops`                      | Two or more reject back-edges derive loop regions with overlapping bodies. Use one group-source back-edge instead. See [Anti-patterns](/docs/ai/approval-engine/patterns#one-reject-back-edge-per-parallel-reviewer). |
| `loop-body-must-have-single-terminal`              | The derived loop body is neither single-terminal sequential nor group-bounded. See [Body-shape constraint](#body-shape-constraint).                                                                              |
| `loop-group-bounded-quorum-must-equal-expected`    | A group-bounded loop body needs `quorum === expectedSteps`, so the iteration terminal coincides with all members finishing.                                                                                      |

### Edge validation errors

| Error key                                        | Cause                                                                                   |
| ------------------------------------------------ | --------------------------------------------------------------------------------------- |
| `APPROVAL_EDGE_CUSTOM_REQUIRES_WHEN`             | `on: "custom"` without a non-empty `when`.                                              |
| `APPROVAL_EDGE_WHEN_ONLY_FOR_CUSTOM`             | `when` supplied on a non-custom edge.                                                   |
| `APPROVAL_EDGE_LOOP_REQUIRES_REJECT`             | `loop` on an edge that is not `on: "reject"`.                                           |
| `APPROVAL_EDGE_LOOP_REQUIRES_BACK_EDGE`          | A reject loop-back whose `to` is not an ancestor of `from`.                             |
| `APPROVAL_EDGE_REJECT_CYCLE_REQUIRES_LOOP`       | `on: "reject"` to an ancestor without `loop`.                                           |
| `APPROVAL_EDGE_EXHAUSTED_REQUIRES_LOOP_SIBLING`  | `on: "exhausted"` with no sibling reject loop-back from the same `from`.                |
| `APPROVAL_HUMAN_NODE_REQUIRES_REJECT_PATH`       | A human node has no outgoing `on: "reject"` edge.                                       |
| `APPROVAL_AGENT_NODE_REQUIRES_URL_OR_URLPATH`    | An agent node sets neither `url` nor `urlPath`.                                         |
| `APPROVAL_EDGE_GROUP_TO_GROUP_FORBIDDEN`         | Both endpoints are group containers.                                                    |
| `APPROVAL_GROUP_FROM_REJECT_REQUIRES_LOOP`       | Forward `on: "reject"` from a `joinOnQuorum` or `cancelOnQuorum` group.                 |
| `APPROVAL_GROUP_FROM_LOOP_REQUIRES_JOINONQUORUM` | A reject loop-back from a group that is not `joinOnQuorum`.                             |
| `APPROVAL_APP_TRIGGER_EXCLUSIVE`                 | One trigger entry declares more than one of `inboundWebhook`, `schedule`, `appTrigger`. |

## Errors

All errors follow the standard envelope:

```json theme={null}
{ "error": { "message": "...", "status": "INVALID_ARGUMENT", "details": {} } }
```

### Canonical codes

| Code                  | Meaning                                                                                                                  | Typical cause                                                                                                    |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `INVALID_ARGUMENT`    | Schema or graph validation failed, or `x-velt-auth-token` is missing.                                                    | Missing field, wrong type, out-of-range value, edge contract violation, linter rule, absent auth token.          |
| `PERMISSION_DENIED`   | The `x-velt-auth-token` is not one of the workspace's registered tokens, or the actor is not allowed to take the action. | An unregistered auth token, or a `reviewer-*` resolve action whose `actorId` is not on the step's reviewer list. |
| `NOT_FOUND`           | Target does not exist.                                                                                                   | Unknown `executionId`, `definitionId`, or `stepId`.                                                              |
| `ALREADY_EXISTS`      | Conflicting create.                                                                                                      | An active definition already uses that `definitionId`.                                                           |
| `FAILED_PRECONDITION` | Optimistic lock or state-machine violation.                                                                              | `ifVersion` mismatch, cancelling a terminal step, deleting a definition with in-flight runs.                     |
| `RESOURCE_EXHAUSTED`  | Rate limit exceeded.                                                                                                     | Per-IP or per-API-key quota.                                                                                     |
| `DEADLINE_EXCEEDED`   | Internal timeout.                                                                                                        | Retry with an idempotency key.                                                                                   |

### Schema validation messages

These are the literal `message` strings returned in the error envelope. The `APPROVAL_*` names used elsewhere on this page are internal rule identifiers, not values you will find in a response body.

| Message                                                                                                     | Trigger                                                                            |
| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `webhookUrl and webhookSecret must be provided together`                                                    | Dispatch supplied one but not the other.                                           |
| `webhookUrl must use https scheme`                                                                          | Non-HTTPS scheme.                                                                  |
| `webhookUrl host resolves to a private, loopback, or link-local address`                                    | Private IP, `localhost`, `metadata.google.internal`, or `*.internal`.              |
| `at least one of reviewerIds or reviewers must be provided`                                                 | Human node with no reviewers.                                                      |
| `cannot set both reviewerIds and reviewers, use one`                                                        | Both populated. Use `reviewers[]`.                                                 |
| `reviewer userIds must be unique`                                                                           | Duplicate `userId` in `reviewers[]`.                                               |
| `reviewers must include at least one mandatory reviewer`                                                    | Every reviewer has `mandatory: false`.                                             |
| `notification node with channel="email" requires a non-empty recipients array`                              | `channel: "email"` with no `recipients`.                                           |
| `notification node with channel="slack" requires a slackTarget (channel id or incoming-webhook URL)`        | `channel: "slack"` with no `slackTarget`.                                          |
| `webhook node with authMode="token" requires authTokenHeader to be set`                                     | `authMode: "token"` with no `authTokenHeader`.                                     |
| `inboundWebhook provider="github"/"vercel"/"custom" requires authMode="hmac"`                               | A non-`velt` provider using `authMode: "bearer"`.                                  |
| `inboundWebhook with provider="custom" and authMode="hmac" requires signatureHeader and signatureAlgorithm` | `provider: "custom"` with `hmac` but no `signatureHeader` or `signatureAlgorithm`. |
| `schedule.cron must be a valid 5-field cron expression`                                                     | `cron` is not a parseable 5-field expression.                                      |
| `schedule.timezone must be a valid IANA timezone name (e.g. America/Los_Angeles)`                           | `timezone` is not a valid IANA zone.                                               |

## Rate limiting

Rate limits apply per API key, with extra per-endpoint tiers on high-volume routes. A `RESOURCE_EXHAUSTED` error means you should back off and retry with exponential delay. Dispatch retries are safe to replay with an `idempotencyKey`.

## Object reference

```typescript theme={null}
interface ExecutionView {
  executionId: string;
  status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
  startedAt: number;             // epoch ms
  completedAt: number | null;
  cancelledAt: number | null;
  definitionId: string;
  definitionVersion: number;
  correlationId: string;
  idempotencyKey: string;
  failureReason: { code: string; message: string } | null;
  steps: StepView[];
}

interface StepView {
  stepId: string;
  nodeId: string;
  nodeType: 'agent' | 'human' | 'notification' | 'webhook';
  status: 'pending' | 'running' | 'waiting' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'breached';
  groupId: string | null;
  startedAt: number | null;
  completedAt: number | null;
  output: Record<string, unknown>;
  error: { code: string; message: string } | null;
}

interface DefinitionView {
  definitionId: string;
  name: string;
  description: string | null;
  version: number;
  scope: { level: 'apiKey' | 'organization' | 'document'; organizationId: string | null; documentId: string | null };
  nodes: NodeView[];
  edges: EdgeView[];
  groups: ParallelGroupDef[] | null;
  compiled: CompiledGraph;
  triggers: WorkflowTriggerConfig[] | null;
  tags: string[] | null;
  custom: Record<string, unknown> | null;
  createdAt: number;
  updatedAt: number;
  status: 'active' | 'tombstoned';
}

type JsonAst = Record<string, unknown>;

interface CompiledGraph {
  forwardEdges: CompiledForwardEdge[];
  loops: CompiledLoopRegion[];
}

interface CompiledForwardEdge {
  from: string;
  to: string;
  role: 'approve' | 'reject' | 'always' | 'exhausted' | 'custom';
  when: JsonAst | null;
  fromGroupId?: string;
  toGroupId?: string;
}

interface CompiledLoopRegion {
  loopId: string;
  entryNodeId: string;
  bodyNodeIds: string[];
  maxIterations: number;
  onExhausted: { routeToNodeId: string } | null;
}

interface ApprovalEventView {
  eventId: string;
  seq: number;             // monotonic per execution
  type: string;            // external event type, see Event reference
  stepId: string | null;
  timestamp: number;       // epoch ms
  correlationId: string;
  data?: Record<string, unknown>;
}
```

### Step ids

Step ids are deterministic, so retries land on the same record:

| Step                         | Id shape                             |
| ---------------------------- | ------------------------------------ |
| Root step, no incoming edges | `step_<nodeId>_<timestamp>_<rand>`   |
| Per-edge fan-out             | `<parentStepId>__to__<childNodeId>`  |
| `joinOnQuorum` fan-out       | `group_<groupId>__to__<childNodeId>` |

### Human step output

After a human step resumes, `output` carries the aggregator rollup:

```typescript theme={null}
{
  reviewers: Array<{ userId: string; mandatory: boolean }>;
  reviewerIds: string[];
  reviewerEmails: string[];
  commentBody: string | null;
  aggregatorStatus: 'resolved' | 'rejected';
  approveCount: number;
  rejectCount: number;
  totalResponses: number;
  mandatoryCount: number;
  mandatoryApproveCount: number;
  decision: 'approve' | 'reject';
  approved: boolean;
  resumedAt: number;
  resumeKey: string;
}
```

### `joinOnQuorum` successor input

```typescript theme={null}
{
  groupOutputs: Record<string /* memberNodeId */, Record<string, unknown> /* member's output */>;
  groupId: string;
  quorum: number;
  totalApproved: number;
}
```
