# Tasks and executors

> The task object in full — executors, provenance edges, dependencies, failure and compensation, retry, gates and re-routes.

A task is the unit of work, of billing, and of observability. Everything else in a workflow exists to be consumed or produced by one.

```json
{
  "id": "normalize",
  "label": "Normalise loudness and encode MP3",
  "type": "work",
  "executor": { "type": "service", "ref": "transcode_from_url",
                "environment": "saas" },
  "ai_role": "none",
  "performed_by": "transcode_svc",
  "ran_on": "cloud_platform",
  "depends_on": ["edit"],
  "used": ["edited_wav"],
  "produced": ["episode_mp3"],
  "failure_mode": "CONTINUE",
  "billing": { "tier": "standard", "unit": "per_minute" },
  "retry": { "max_attempts": 2, "initial_interval_seconds": 5 }
}
```

Only `id` is required by the schema. In practice a task that omits `executor`, `performed_by`, or its provenance edges is a task that cannot be executed or audited — see the [pre-flight checklist](/for-agents#pre-flight-checklist).

## Identity and classification

| Field | Type | Notes |
|---|---|---|
| `id` | string | **Required.** Pattern `^[a-zA-Z0-9_-]+$` |
| `label` | string | Human-readable display name |
| `description` | string | Max 500 chars |
| `type` | string | Open set. Common: `work`, `qc`, `review`, `delivery`, `ingest`, `handoff` |
| `tool` | string | Tool name; should match an executor declared in an active profile |
| `metadata` | object | Freeform. See [Extensions](/specification#extensions) |

`qc` and `review` are **gate types** — they carry the routing fields under [Gates and re-routes](#gates-and-re-routes). A `review` task may carry `editorial_signoff`. `delivery` is the type the [disclosure gate](/compliance#governance-ceilings) blocks.

## Provenance edges

These four fields are the heart of the model, and they are **authoritative**: the graph's lineage comes from here and nowhere else.

| Field | Type | PROV mapping |
|---|---|---|
| `performed_by` | string (participant id) | `wasAssociatedWith` |
| `used` | string[] (asset ids) | `used` |
| `produced` | string[] (asset ids) | `generated` (inverse) |
| `ran_on` | string (infrastructure id) | `owg:ranOn` |

An asset's own `produced_by` field is a **derived convenience** for readers walking the graph from the asset side. The task's `produced[]` is the source of truth, and validation enforces that the two agree.

A task with no `used` is legitimate — it is an origin task, capturing or generating something that did not exist before.

> **Only assert an edge you can guarantee.** Listing an asset in `used[]` claims the task consumed it. If the producing task is conditional, gate on `exists()` and pass the value through `inputs` instead — see the [worked pair](/reference-syntax#a-worked-pair).

## Executors

The executor is what generalizes "call tool X" into an attribute, and it is the design choice that puts human, machine, and AI work in one accountable graph.

```json
{ "executor": { "type": "comfyui_graph", "ref": "graphs/sdxl_txt2img.json",
                "environment": "cloud" } }
```

| Field | Type | Notes |
|---|---|---|
| `type` | enum | **Required.** See below |
| `ref` | string | Tool, application, or graph reference |
| `environment` | enum | `cloud` \| `saas` \| `desktop` \| `on_prem` \| `manual` |
| `credentials_key` | string | Names a secret held by the orchestrator |
| `permissions` | string[] | e.g. `["read:assets", "write:outputs"]` |

`executor.type` is one of nine values:

| Value | Meaning |
|---|---|
| `service` | A deterministic software service |
| `mcp` | A tool invoked over the Model Context Protocol |
| `saas_api` | An external SaaS API |
| `comfyui_graph` | A whole node-graph, treated as one task with a zoomable interior |
| `comfyui_node` | A single node within such a graph |
| `local_app` | A desktop application on a workstation (Nuke, Houdini, Resolve) |
| `render_farm` | A batch job on an on-premises or cloud farm |
| `agent` | An autonomous agent |
| `human` | A person |

Two rules that matter:

- **AI involvement is never inferred from `executor.type`.** An `agent` executor may be doing deterministic file shuffling; a `saas_api` executor may be running a diffusion model. Declare [`ai_role`](/compliance#declaring-ai-involvement) explicitly.
- **Secrets are referenced, never inlined.** `credentials_key` names a secret the orchestrator holds, so the document itself stays safe to commit and to hand to a vendor.

## Dependencies

Dependencies are a list of task ids, with AND semantics — a task becomes eligible when all of its dependencies have finished.

```json
{ "id": "publish", "depends_on": ["edit", "thumbnail_review"] }
```

There is no edge object, no port-level wiring, and no cross-document dependency form. Ordering within `tasks[]` does not govern execution; `depends_on` does.

**The dependency graph must be acyclic.** Acyclicity cannot be expressed in JSON Schema, so it is a [validator rule](/validation#pass-3-graph).

Skip propagates: a task depending on a `skipped` task is itself `skipped`. That is what stops an optional branch from stalling the graph.

## Conditional execution

```json
{ "when": "$.params.add_captions == true" }
```

If the expression is false, the task is `skipped`. The expression language is small and fully specified — grammar, precedence, type rules, and the `exists()` function are in [Reference syntax](/reference-syntax#when-expressions).

Unlike everything downstream of a run, `when` is checked at **validation** time: an unresolvable reference or a type-mismatched comparison is a validation error, not a run-time surprise.

## Failure and compensation

```json
{ "failure_mode": "SKIP_DEPENDENTS" }
```

| Value | Behaviour |
|---|---|
| `HALT` | Stop the entire workflow. **The default** |
| `CONTINUE` | Mark this task failed, let other branches proceed |
| `SKIP_DEPENDENTS` | Fail this task and skip everything downstream of it |
| `COMPENSATE` | Run the named compensation task, then halt |

`HALT` is the schema default, so omitting `failure_mode` is safe — but declaring it makes intent explicit, which matters on a task where a reader would reasonably expect something gentler.

### Compensation

`COMPENSATE` requires `on_failure_compensate`, naming the task that undoes partial work:

```json
{
  "id": "upload_master",
  "type": "delivery",
  "executor": { "type": "saas_api" },
  "performed_by": "delivery_api",
  "used": ["master_prores"],
  "produced": ["delivery_receipt"],
  "failure_mode": "COMPENSATE",
  "on_failure_compensate": "rollback_partial_upload"
}
```

Two validator rules keep this honest: `COMPENSATE` without `on_failure_compensate` is an error, and `on_failure_compensate` naming a task that does not exist is an error. The compensation task is an ordinary task — it can have its own executor, participant, and cost.

## Fan-out (v0.91)

```json
{
  "id": "generate_all_idents",
  "executor": { "type": "agent" },
  "subgraph": { "format": "owg", "ref": "graphs/generate_ident.owg.json" },
  "fan_out": {
    "over": "$.params.shot_list",
    "as": "shot",
    "max_concurrency": 500,
    "tolerated_failure_percentage": 3
  }
}
```

`fan_out` runs one task — and its `subgraph`, if it has one — once per item in a list, concurrently, instead of once. It exists for the case `depends_on` and `failure_mode` were never meant to cover: not "these five named tasks," but "this same subgraph, several hundred times, over a list only known at run time."

| Field | Type | Notes |
|---|---|---|
| `over` | string | **Required.** A `$.` reference resolving to an array — `$.params.shot_list`, or `$.tasks.list_shots.outputs.items` |
| `as` | string | Name bound to the current item inside this instance, referenced as `$.fan_out.<as>`. Defaults to `item` |
| `max_concurrency` | integer ≥ 1 | Upper bound on instances running at once. This only *lowers* an engine's own ceiling — it can never raise one |
| `tolerated_failure_percentage` | number, 0–100 | The fan-out task is treated as **succeeded** if no more than this percentage of instances fail |
| `tolerated_failure_count` | integer ≥ 0 | Same idea, as an absolute count. Mutually exclusive with the percentage — declaring both is `OWG_FANOUT_TOLERANCE_CONFLICT` |

### Why this needed its own field, not just `failure_mode: CONTINUE`

`failure_mode: CONTINUE` (see [above](#failure-and-compensation)) already isolates one task's failure from its siblings — and still should be your first reach for a handful of named parallel branches, like the [worked pair](/implementation-considerations#composing-independent-branches) of a video path and an audio path. What it does not give you is a **threshold**: with five hand-authored sibling tasks, "did enough of them succeed" is a judgment call a human makes reading the run. With 500 instances of the same subgraph, nobody is reading 500 rows — the run itself has to be able to say "497 of 500 succeeded, that's a pass" without a human deciding it after the fact.

That is the one thing `fan_out` adds that composing existing fields cannot: a **declared, checkable pass/fail line** for a whole batch, evaluated by the engine, not eyeballed afterward.

### Addressing instances

Each instance is addressable in run records as `$.tasks.<id>[<index>]` rather than `$.tasks.<id>` — see [Run records](/runs#fan-out-attempts) for how attempts are recorded per instance, and how a fan-out task's own `succeeded`/`failed` status is derived from its tolerance.

### What `fan_out` does not change

- **`used`/`produced` still describe the task once**, not once per instance — they name the *kind* of thing consumed and produced, not each instance's specific asset. Per-instance lineage lives in the run's attempt records.
- **A fan-out task can still declare `failure_mode`.** It governs what happens to *this task's own dependents* if the fan-out as a whole fails (tolerance exceeded) — the same semantics as any other task, layered on top of, not instead of, the tolerance check.
- **`max_concurrency` is a ceiling an author states, not a capacity an author is promised.** An engine may run fewer instances at once than the ceiling allows — for its own rate limits, quota, or scheduling reasons — but must never exceed it. See [Implementation considerations](/implementation-considerations#fan-out-concurrency-is-a-ceiling-not-a-guarantee).

## Retry

```json
{
  "retry": {
    "max_attempts": 3,
    "initial_interval_seconds": 10,
    "backoff_coefficient": 2.0,
    "retryable_errors": ["RATE_LIMIT", "QUOTA_EXCEEDED", "TRANSIENT_ERROR"]
  }
}
```

Delay before attempt *n* is `initial_interval_seconds × backoff_coefficient ^ n`.

| Field | Range | Default |
|---|---|---|
| `max_attempts` | 1–10 | 1 |
| `initial_interval_seconds` | 0.1–3600 | 1 |
| `backoff_coefficient` | 1.0–10.0 | 1.0 |
| `retryable_errors` | string[] | empty — any error retries |

`retryable_errors` values are executor-defined; the specification does not enumerate error codes, because they belong to whatever system the executor calls. Declare them in your [profile](/profiles).

Retries create new **task attempts** under the same run. A task that failed twice and succeeded on the third try is three attributed activities, not one overwritten record — which is why `$.task.attempt` is addressable.

## Gates and re-routes

QC and review tasks route on outcome rather than merely succeeding or failing. This is the mechanism that makes a human review gate a first-class part of an automated pipeline, and it is what lets an agent be held to a standard rather than merely invoked.

```json
{
  "id": "qc_review",
  "type": "qc",
  "executor": { "type": "service", "ref": "qc_validate" },
  "performed_by": "qc_svc",
  "depends_on": ["grade"],
  "used": ["graded_master"],
  "checks": ["duration_match", "codec_compliance", "audio_sync"],
  "on_pass": "deliver",
  "on_fail": "regrade",
  "max_reroutes": 3,
  "reroute_feedback": { "to_task": "regrade", "carry": ["qc_notes"] }
}
```

| Field | Type | Purpose |
|---|---|---|
| `checks` | string[] | Named checks the gate evaluates. Profile-defined |
| `on_pass` | string | Task id to route to on pass |
| `on_fail` | string | Task id to route to on fail |
| `max_reroutes` | integer ≥ 0 | Per-gate re-route ceiling |
| `reroute_feedback` | string \| object | Where feedback goes, and what context travels with it |
| `editorial_signoff` | object | `{ editor (required), statement, timestamp }` |

`reroute_feedback` in object form takes `to_task` and `carry` — an array of output keys that travel back to the target task, so the agent being asked to try again receives the notes explaining why.

Two ceilings apply, and both are needed: `max_reroutes` bounds one gate, and [`governance.max_total_reroutes`](/specification#governance) bounds the whole run. Without the run-level ceiling, three gates each permitting three re-routes can still loop far longer than anyone intended.

## Billing

```json
{ "billing": { "tier": "standard", "unit": "per_minute" } }
```

The core specification imposes no billing vocabulary — `tier` and `unit` are free strings, defined by the active [profile](/profiles). This is deliberate: pricing models change faster than schemas should.

## Timeouts

`timeout_seconds` bounds one task, minimum 1. A whole-workflow ceiling is set at the [document level](/specification#all-top-level-fields).

## Data flow

Tasks pass values through `inputs` and `outputs`.

```json
{
  "id": "web_delivery",
  "executor": { "type": "service", "ref": "transcode_video" },
  "depends_on": ["ingest"],
  "inputs": {
    "input_key": "$.tasks.ingest.outputs.output_key",
    "output_format": "webm"
  },
  "outputs": {
    "output_key": "jobs/${workflow.id}/${task.id}/delivery.webm",
    "content_type": "video/mp4"
  },
  "used": ["normalized_mp4"],
  "produced": ["delivery_webm"]
}
```

`outputs` recognises four conventional keys — `output_key`, `content_type`, `duration_seconds`, `file_size_bytes` — and permits any others your profile declares. The last two are set by the executor at run time rather than authored.

Keep the distinction clear: **`inputs`/`outputs` carry values; `used`/`produced` assert lineage.** A task typically has both, and they describe different things — the input is a storage key, the used asset is the governed object that key belongs to.

Reference syntax, interpolation, and escaping are specified in [Reference syntax](/reference-syntax).
