> ## Documentation Index
> Fetch the complete documentation index at: https://velt-v6-0-0-beta-1.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Customize Behavior

> Node configuration, edge expressions, parallel groups, SLAs, linter rules, events, errors, and the full object reference.

This is the deep-dive reference. Setup covers the happy path. This page covers everything you reach for when you need precise control over node behavior, edge routing, quorum policies, SLA breaches, and event consumption.

## Node configuration

Every node has a `nodeId`, a `type` (`agent`, `human`, or `webhook`), a `config` block, and an optional `slaMs` deadline. `webhook` nodes validate in a definition but their runtime handler isn't enabled in v1, so this section covers the two runnable types. (The deferred `webhook` node is distinct from the [inbound webhook handler](/ai/approval-engine/overview#inbound-webhook-handler) and from [per-execution webhook delivery](/ai/approval-engine/setup#step-4-get-the-outcome), both of which are live in v1.)

Every node also accepts optional cosmetic `name` (1–200 chars) and `description` (≤ 2000 chars) fields, echoed verbatim in `NodeView`. They're labels for visual graph editors and don't affect runtime behavior.

### Agent nodes

| Field                   | Type    | Notes                                                                                                                                                 |
| ----------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agentId`               | string  | Required.                                                                                                                                             |
| `promptOverride`        | string  | `≤ 8000` chars.                                                                                                                                       |
| `inputMapping`          | object  | Pass step inputs to the agent.                                                                                                                        |
| `blocking`              | boolean | Default `false`. When `true`, the step parks in `waiting` until external resolutions arrive via `/steps/recordAgentResolution`.                       |
| `resolutionPolicy`      | object  | Required when `blocking: true`. `{ kind: "allResolved" \| "minResolved", minCount?: integer }`. `minCount` is required when `kind === "minResolved"`. |
| `agentMaxRuntimeMs`     | integer | `≤ 86400000`.                                                                                                                                         |
| `requireNonEmptyOutput` | boolean | Fail the step if the agent returns an empty output.                                                                                                   |

```json theme={null} theme={null}
{
  "nodeId": "brand-check",
  "type": "agent",
  "config": {
    "agentId": "brand-agent-v1",
    "blocking": false,
    "requireNonEmptyOutput": true
  },
  "slaMs": 3600000
}
```

### Human nodes

Exactly one of `reviewers[]` (preferred) or `reviewerIds[]` (legacy) must be provided.

| Field            | Type      | Notes                                                                                                                                                                                                                                                                                                                                                |
| ---------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reviewers`      | array     | Preferred: `[{ userId, mandatory }]`. Must include at least one `mandatory: true`. UserIds must be unique.                                                                                                                                                                                                                                           |
| `reviewerIds`    | string\[] | Legacy. Accepted for back-compat only.                                                                                                                                                                                                                                                                                                               |
| `reviewerEmails` | string\[] | Optional. 0–50 reviewer email addresses. Surfaced in the human step's `output.reviewerEmails` for downstream notification UIs.                                                                                                                                                                                                                       |
| `commentBody`    | string    | `≤ 8000` chars. Stored on the human step's output for use by your reviewer-facing UI. The engine does not auto-create a Velt annotation per human step in v1 — your application is responsible for surfacing this string to reviewers (and, if you use the legacy comment-resolution flow, for creating the comment thread the reviewer replies to). |

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

### Rejection paths are edges

A `human` node carries no rejection config. Its reject path is an outgoing `on:"reject"` edge — see [the edge model](#edge-model) below. Every `human` node must have one, or the definition is rejected with `APPROVAL_HUMAN_NODE_REQUIRES_REJECT_PATH`.

## Edge model

All workflow transitions — approve routing, reject routing, group fan-out/in, and loop-backs — are expressed as a single unified `edges[]` array. Each edge is `{ from, to, on?, when?, loop? }`.

```json theme={null} 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 (default `"always"`)            | `approve` / `reject` / `always` / `exhausted` / `custom`. `approve` and `reject` auto-compile their predicates. |
| `when` | JSON-AST string     | only when `on:"custom"`            | Custom predicate over the source step's output. Invalid on any other `on` role.                                 |
| `loop` | `{ maxIterations }` | only on an `on:"reject"` back-edge | `maxIterations` 1–20. Marks the reject edge as a loop-back; the server derives the loop region.                 |

### Reject, loop-back, and exhausted

* **Reject path:** an outgoing `on:"reject"` edge from the rejecting node.

  ```json theme={null} 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 (entry node, body, cap) from the marked edge.

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

* **Exhausted handling:** a sibling `on:"exhausted"` edge from the same `from`, fired when the loop's `maxIterations` cap is reached. Without it, an exhausted loop rolls the execution up to `failed`.

  ```json theme={null} 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. No untrusted code ever runs. `when` is invalid on any non-custom edge.

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

**Supported operators:** equality, comparison, boolean, regex, `includes`, `startsWith`, `endsWith`, `length`, `isEmpty`.

**Path roots:**

| Root                | Resolves to                                  |
| ------------------- | -------------------------------------------- |
| `output.*`          | The source step's `output` object.           |
| `step.*`            | The source step's metadata (status, timing). |
| `execution.input.*` | The `triggerContext` you passed on dispatch. |

An `on:"always"` edge (the default when `on` is omitted) always fires.

### Groups as edge sources

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

* **`waitAll` edge-source:** all members must terminate, then the group takes one collective branch by **unanimity** — `approve` if every member approved, else `reject`. Provide both an `on:"approve"` and an `on:"reject"` branch; exactly one fires. Successor step ID: `group_<groupId>__to__<childNodeId>`. A routed collective reject is *not* a failed run — when the reject branch's successor completes, the execution rolls up to `completed`, and a member whose rejection is handled by the group edge is surfaced as `completed` with `output.decision="reject"`, never `failed`.
* **`cancelOnQuorum` edge-source:** fires one collective approve-successor on approval-**quorum** (same as `joinOnQuorum`). A forward `on:"reject"` from a `joinOnQuorum` or `cancelOnQuorum` group is rejected as a dead edge — those policies fan out only on approval-quorum.
* **Edge-to-group fan-out** (`to: { kind: "group", groupId }`) expands to one compiled edge per group member for all quorum policies.

### The compiled view

Every `DefinitionView` returns a read-only `compiled` block alongside the authored `edges` (which echo `sourceEdges` byte-for-byte): `compiled.forwardEdges` is the runtime forward-edge list (group endpoints expanded, `on` roles compiled to predicate ASTs) and `compiled.loops` is the server-derived loop region list. See [Get Definition](/api-reference/rest-apis/v2/approval-engine/definitions/get-definition#the-compiled-block) for the field schema.

## SLA and breach handling

Set `slaMs` on any node to give the step a deadline. If the step doesn't complete within the window, it transitions to `breached` and emits a `step.breached` event.

To handle breaches, declare an outgoing edge that routes on the breached status. Otherwise the engine emits the `missing-breach-edge` linter rule and rejects the definition. Silent dead-ends are a bug.

## Parallel groups and quorum policies

A parallel group declares a set of member nodes that conceptually run in parallel and share an approval threshold.

```json theme={null} 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 declared as a top-level node. A node may belong to at most one group.                                                                                                                                                 |
| `expectedSteps`   | integer   | yes      | 1 to 500. Total members the group expects to terminate (any status) before it considers itself "fully done." Should equal `memberNodeIds.length` in practice — values higher than the member count cause the group to never roll up to "complete." |
| `quorum`          | integer   | yes      | 1 to `expectedSteps`. The number of approvals required to fire the policy's side effect.                                                                                                                                                           |
| `onQuorumMet`     | enum      | no       | `waitAll` (default), `cancelOnQuorum`, or `joinOnQuorum`.                                                                                                                                                                                          |
| `requiredNodeIds` | string\[] | no       | Specific members whose approval is required for quorum to be met. Every entry must also be in `memberNodeIds`. `length` must be `<= quorum`.                                                                                                       |

### Quorum is approval count, not completion count

A member counts as an approval when its terminal status is `completed` AND its `output.decision === 'approve'`. Rejections, failures, breaches, and cancellations contribute to the completion counter only, never the approval counter.

Two consequences:

1. Non-blocking agent members never satisfy quorum. They have no decision concept. Only place human or blocking agent nodes inside groups whose policy is `cancelOnQuorum` or `joinOnQuorum`.
2. A `reject` does not block the group from rolling up to "complete". It just keeps `approvedShards` from advancing. Group-completion (`expectedSteps` met) and group-quorum (approval threshold) are tracked separately.

### `onQuorumMet` policies

| Policy              | Side effect on first-time approval-quorum-met                                                                                                                                                                                                                                                    | Per-member fan-out                                                                                                                                       |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `waitAll` (default) | Without group-source edges, emits `group.quorum-met` event only and is informational. When used as an edge source, waits for all members and fires one collective approve/reject branch by unanimity.                                                                                            | Each member's outgoing edges fire on its own completion. If two members both fan out to the same downstream node, you get two downstream step instances. |
| `cancelOnQuorum`    | Emits `group.quorum-met` AND cancels every sibling member step still in `waiting` (system-actor cancellation, audit reason `group-quorum-met`).                                                                                                                                                  | Each completing member still fans out per-edge. Cancelled siblings do not fan out.                                                                       |
| `joinOnQuorum`      | Emits `group.quorum-met`, cancels waiting siblings, AND fires a single group-owned downstream fan-out: one new step per shared outgoing-edge target with deterministic stepId `group_<groupId>__to__<childNodeId>`. The successor's input is `{ groupOutputs, groupId, quorum, totalApproved }`. | Suppressed for group members. The group container owns fan-out, so downstream successors run exactly once.                                               |

### Specific-must-approve quorum

By default, quorum is anonymous: any N approvals out of M members trigger the policy. To express "these specific members must approve", declare them in `requiredNodeIds`:

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

Quorum-met now requires both:

1. Every `nodeId` in `requiredNodeIds` is among the approvers, AND
2. Total approval count reaches the numeric `quorum`.

In the example, `brand` alone approving doesn't satisfy quorum even if `quorum: 2` is reached numerically. `legal` AND `finance` must both also approve. If `requiredNodeIds` is omitted or empty, behavior collapses 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 don't declare loops directly — you mark an `on:"reject"` edge with `loop` (its `to` must be an ancestor of `from`), and the server derives the loop region (entry node, body, cap) on the [compiled graph](#the-compiled-view). Add a sibling `on:"exhausted"` edge to route when the cap is reached.

```json theme={null} 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 stable identifier for the derived loop.                                                |
| `entryNodeId`   | string                      | Node re-entered first on each iteration (the reject edge's `to`).                                      |
| `bodyNodeIds`   | string\[]                   | Nodes inside the loop's iteration scope, derived from the topological closure between `to` and `from`. |
| `maxIterations` | integer                     | 1–20. Hard cap per execution, taken from the reject edge's `loop.maxIterations`.                       |
| `onExhausted`   | `{ routeToNodeId } \| null` | Target of the sibling `on:"exhausted"` edge. `null` rolls the execution up to `failed` at the cap.     |

The default iteration predicate is `decision == 'reject' && rejectorMandatory == true`. The unified contract does not support custom loop predicates on loop-back edges: `loop` is valid only on an `on:"reject"` back-edge.

### Body-shape constraint

The derived loop body must be one of:

1. **Single-terminal sequential.** Exactly one body node has outgoing edges that leave the body — that node is the iteration-terminal.
2. **Group-bounded.** The set of exit-bearing body nodes equals the `memberNodeIds` of one parallel group with `onQuorumMet: 'joinOnQuorum'`, every member lies inside the body, and the group has `quorum === expectedSteps`.

The linter rejects other shapes with `loop-body-must-have-single-terminal`.

### `previousAttempts` payload threaded into iteration N+1

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;
  }>;
}
```

### Iteration-scoped parallel groups

When a parallel group lives inside a loop body, each iteration gets fresh quorum state — the container path becomes `parallelGroups/<groupId>_iter_<N>` internally. You don't address this directly, but it explains why per-iteration quorum starts from zero.

### Loop events

Two new event types fire alongside the standard `step.*` and `execution.*` events. Both are returned from [Get Execution Events](/api-reference/rest-apis/v2/approval-engine/executions/get-execution-events).

| Event type               | When emitted                                                                                                                                   | `data`                                                         |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `loop.iteration-started` | Iteration N+1 spawns after a body iteration terminated rejected and the cap wasn't hit.                                                        | `{ loopId, iteration, triggeredBy: 'rejection' }`              |
| `loop.exhausted`         | Cap reached. Either the `onExhausted.routeToNodeId` step is spawned next, or the execution rolls up to `failed` if no route node was declared. | `{ loopId, iteration, lastRejectedBy?, lastRejectionReason? }` |

## Linter rules

Definitions are validated at create and update time. Authored-edge provenance errors come from `compileGraph`; graph-shape errors come from the linter. Any violation is rejected with `INVALID_ARGUMENT` and an explicit code in the error message.

| Code                                               | Meaning                                                                                                                                                                                                                                                             |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `duplicate-node-id`                                | Two nodes share the same `nodeId`.                                                                                                                                                                                                                                  |
| `dangling-edge`                                    | Edge references a `from` or `to` that isn't declared.                                                                                                                                                                                                               |
| `cycle-detected`                                   | The graph contains a cycle. v1 is DAG-only.                                                                                                                                                                                                                         |
| `unreachable-node`                                 | A node has no path from any root.                                                                                                                                                                                                                                   |
| `node-missing-config`                              | A node has no config block.                                                                                                                                                                                                                                         |
| `missing-breach-edge`                              | A node has `slaMs` set but no outgoing edge that routes on `status == 'breached'`. Breaches would silently dead-end.                                                                                                                                                |
| `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 < 1`.                                                                                                                                                                                                                                                |
| `group-quorum-invalid`                             | `quorum < 1` or `quorum > expectedSteps`.                                                                                                                                                                                                                           |
| `group-cancelonquorum-requires-quorum-lt-expected` | `cancelOnQuorum` requires `quorum < expectedSteps`.                                                                                                                                                                                                                 |
| `group-joinonquorum-members-must-share-successors` | `joinOnQuorum` requires every member to have an identical set of outgoing-edge target nodes.                                                                                                                                                                        |
| `group-required-not-in-members`                    | An entry in `requiredNodeIds` is not in `memberNodeIds`.                                                                                                                                                                                                            |
| `group-required-exceeds-quorum`                    | `requiredNodeIds.length > quorum`.                                                                                                                                                                                                                                  |
| `group-node-in-multiple-groups`                    | A node appears as a member of two or more groups.                                                                                                                                                                                                                   |
| `loop-body-must-have-single-terminal`              | Derived loop body shape is neither single-terminal sequential nor group-bounded — see [Loop regions › Body-shape constraint](#body-shape-constraint).                                                                                                               |
| `loop-group-bounded-quorum-must-equal-expected`    | Derived body is group-bounded but the bounding `joinOnQuorum` group has `quorum < expectedSteps`. Force `quorum === expectedSteps` so iteration-terminal coincides with all-members-done; otherwise a late rejection races against an already-fired join successor. |

### Edge model validation errors

The unified `edges[]` contract is validated at create and update time. Violations are rejected with `INVALID_ARGUMENT` and one of these keys in the error message:

| 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 a non-reject edge.                                                                                                       |
| `APPROVAL_EDGE_LOOP_TARGET_NOT_ANCESTOR`         | `reject`+`loop` whose `to` is not an ancestor of `from`.                                                                           |
| `APPROVAL_EDGE_REJECT_CYCLE_REQUIRES_LOOP`       | `on:"reject"` to an ancestor without `loop` (unmarked cycle).                                                                      |
| `APPROVAL_EDGE_EXHAUSTED_REQUIRES_LOOP_SIBLING`  | `on:"exhausted"` with no sibling `reject`+`loop` from the same `from`.                                                             |
| `APPROVAL_HUMAN_NODE_REQUIRES_REJECT_PATH`       | Human node with no outgoing `on:"reject"` edge.                                                                                    |
| `APPROVAL_EDGE_GROUP_TO_GROUP_FORBIDDEN`         | Both endpoints are group containers.                                                                                               |
| `APPROVAL_GROUP_FROM_REJECT_REQUIRES_LOOP`       | Forward `on:"reject"` from a `joinOnQuorum` / `cancelOnQuorum` group (dead edge — those policies fan out only on approval-quorum). |
| `APPROVAL_GROUP_FROM_LOOP_REQUIRES_JOINONQUORUM` | `reject`+`loop` back-edge from a non-`joinOnQuorum` group.                                                                         |

## Events

For receiver setup (signature verification, security rules, delivery basics), see [Setup, Configure your webhook receiver](/ai/approval-engine/setup#step-3-configure-your-webhook-receiver).

### Event reference

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

| Event type (external)    | Internal name               | When emitted                                                                                                                                                | `data` highlights                                                                                                                  |
| ------------------------ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `execution.dispatched`   | same                        | Execution created. First step(s) scheduled.                                                                                                                 | `{ definitionId, definitionVersion, rootStepIds }`                                                                                 |
| `execution.completed`    | same                        | All steps terminal, no unhandled failures.                                                                                                                  | null                                                                                                                               |
| `execution.failed`       | same                        | Any blocking step ended in `failed` or `breached` without a recovery edge.                                                                                  | `{ failureReason }`                                                                                                                |
| `execution.cancelled`    | same                        | `/executions/cancel` or full-execution rollback.                                                                                                            | `{ reason? }`                                                                                                                      |
| `step.awaiting-approval` | `step.waiting`              | A human or blocking-agent step entered `waiting`.                                                                                                           | `{ waitingForReviewers, mandatoryCount, resumeKey }`                                                                               |
| `step.completed`         | same                        | Step transitioned to `completed`.                                                                                                                           | For human or blocking-agent: `{ aggregatorStatus, nodeType, decision, aggregatorBacked }`. For non-blocking agents: `{ agentId }`. |
| `step.failed`            | same                        | Step transitioned to `failed` (retry budget exhausted).                                                                                                     | `{ error: { code, message } }`                                                                                                     |
| `step.breached`          | same                        | Step exceeded its configured SLA before completing.                                                                                                         | `{ reason }`                                                                                                                       |
| `step.cancelled`         | same                        | Step cancelled via `/steps/cancel` or by quorum-met side effect.                                                                                            | `{ actorId, reason }`                                                                                                              |
| `group.quorum-met`       | `parallel-group.quorum-met` | A parallel group's approval threshold was first satisfied.                                                                                                  | `{ groupId, total, quorum, completedTotal, expectedSteps }`                                                                        |
| `loop.iteration-started` | same                        | Iteration N+1 spawns after a body iteration terminated rejected and the cap wasn't hit.                                                                     | `{ loopId, iteration, triggeredBy: 'rejection' }`                                                                                  |
| `loop.exhausted`         | same                        | The loop's `maxIterations` cap was reached. If `onExhausted.routeToNodeId` is set, that step is spawned next; otherwise the execution rolls up to `failed`. | `{ loopId, iteration, lastRejectedBy?, lastRejectionReason? }`                                                                     |

<Note>
  Internal-only events (`step.scheduled`, `step.started`, `step.retried`, `step.resumed`, `step.response-recorded`, `step.overridden`, `parallel-group.completed`, `idempotency.suppressed`) fill `seq` gaps but are filtered from external delivery. Your stream may have non-contiguous `seq` values.
</Note>

### Cancellation reasons

`step.cancelled` events carry a `data.reason` string. This is an open string set. Consumers should switch on `event.type` for control flow, not on `data.reason`.

| Reason             | Source | Meaning                                                                                                                                                                                           |
| ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `group-quorum-met` | system | Cancelled by the engine when the parent group's approval quorum was met under `cancelOnQuorum` or `joinOnQuorum`. Audit shows `actorId: "system:group-quorum"`.                                   |
| `loop-restart`     | system | Cancelled by the engine when a [loop region](#loop-regions) is starting iteration N+1 and an in-flight body step from iteration N is still running. Audit shows `actorId: "system:loop-restart"`. |
| (admin-supplied)   | admin  | Free-form reason passed to `/steps/cancel`.                                                                                                                                                       |

### Webhook 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 5 failed retries, the payload is written to a dead-letter queue. Recover missed events via [Get Execution Events](/api-reference/rest-apis/v2/approval-engine/executions/get-execution-events) with `sinceSeq`.

**At-least-once delivery.** The same `eventId` and `seq` appear on retries. Make your receiver idempotent on `(executionId, seq)`.

## Errors

All errors follow the standard envelope:

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

### Canonical codes

| Code                  | Meaning                                        | Typical cause                                                                                                |
| --------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `INVALID_ARGUMENT`    | Schema, `compileGraph`, or linter failure.     | Missing field, wrong type, value out of range, edge contract violation, or linter rule violation.            |
| `UNAUTHENTICATED`     | Missing or invalid `x-velt-auth-token`.        |                                                                                                              |
| `PERMISSION_DENIED`   | Auth token valid but lacks the required scope. | `/steps/resolve` with `reviewer-approve` / `reviewer-reject` and `actorId` not in the step's reviewer list.  |
| `NOT_FOUND`           | Target doc does not exist.                     | Unknown `executionId`, `definitionId`, or `stepId`.                                                          |
| `ALREADY_EXISTS`      | Conflicting create.                            | Creating a definition with a `definitionId` already in use.                                                  |
| `FAILED_PRECONDITION` | Optimistic lock or state-machine violation.    | `ifVersion` mismatch on update. Cancelling a terminal step. Deleting a definition with in-flight executions. |
| `RESOURCE_EXHAUSTED`  | Rate limit exceeded.                           | Per-IP or per-API-key quota.                                                                                 |
| `DEADLINE_EXCEEDED`   | Internal timeout.                              | Retry with idempotency.                                                                                      |

### Schema-level validation errors

| `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`                                      | Literal 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. Pick the modern `reviewers[]` form.                       |
| `reviewer userIds must be unique`                                                                             | Duplicate userId in `reviewers[]`.                                        |
| `reviewers must include at least one mandatory reviewer (allMandatoryApproved would otherwise never resolve)` | Every `reviewer.mandatory === false`.                                     |
| `resolutionPolicy required when blocking === true`                                                            | Blocking agent node without a policy.                                     |
| `minCount required when kind === "minResolved"`                                                               | `resolutionPolicy.kind = "minResolved"` with no `minCount`.               |

## Rate limiting

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

## Object reference

```typescript theme={null} 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';
  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>;
}
```

For **human steps**, `output` (after resume) includes the aggregator rollup:

```typescript theme={null} 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;
}
```

For **`joinOnQuorum` group successor steps**, `input` includes:

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