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.
{
"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.
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 |
qc and review are gate types — they carry the routing fields under Gates and re-routes. A review task may carry editorial_signoff. delivery is the type the disclosure gate 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 onexists()and pass the value throughinputsinstead — see the 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.
{ "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. Anagentexecutor may be doing deterministic file shuffling; asaas_apiexecutor may be running a diffusion model. Declareai_roleexplicitly. - Secrets are referenced, never inlined.
credentials_keynames 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.
{ "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.
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
{ "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.
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
{ "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:
{
"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)
{
"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) 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 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 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/producedstill 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_concurrencyis 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.
Retry
{
"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.
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.
{
"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 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
{ "billing": { "tier": "standard", "unit": "per_minute" } }The core specification imposes no billing vocabulary — tier and unit are free strings, defined by the active profile. 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.
Data flow
Tasks pass values through inputs and outputs.
{
"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.