Kodexa ActivityPlan authoring
An ActivityPlan is an org-scoped directed graph of typed steps; starting one creates a project-scoped Activity
plus a runtime step row per step. Plans live in kdxa_activity_plans, resolve as activity-plan://acme-corp/invoice-intake
(unversioned), sync from an activity-plans/ directory, and must be bound to a project to run.
Resource shape
slug: invoice-intake # required, unique in the org
orgSlug: acme-corp # `kdx apply -f` needs it (or --org-slug); ${org} also resolves
name: Invoice Intake
type: activity-plan # routes the file — `kdx apply -f` refuses one without it
inputOptions: # the ONLY server-enforced input contract
- { name: vendorId, label: Vendor, type: string, required: true }
- { name: priority, label: Priority, type: selection, required: false }
inputsSchema: { type: object, required: [vendorId], properties: { vendorId: { type: string } } }
defaultTitleTemplate: "Invoice — {{ filename .documentPath }}" # Go text/template; the start
defaultDescriptionTemplate: "{{ .documentCount }} document(s)" # returns 400 if it fails to render
steps: [...]
A required inputOptions entry missing (or empty) in inputs fails the start: missing required inputs: <names>.
inputsSchema only renders the Studio start form — never validated server-side — so declare every input in both.
The step envelope is FLAT and keyed by type
steps:
- slug: extract # required; unique in plan; ^[a-z0-9][a-z0-9_-]*$
type: EXECUTION # required — the discriminator is `type`, never `kind`
moduleRef: "kodexa/fast-pdf-model" # kind fields sit at the TOP LEVEL
conditionExpr: "inputs.priority = 'high'" # JSONata; false ⇒ step NOT_TAKEN
setDocumentStatus: extracted # any type; stamps a project document status on completion
bypass: false # any type; true ⇒ step settles SKIPPED with no work
kind: is not an alias — a step without type fails the start: step missing required slug or type.
- There is no
config: {} wrapper: it is silently flattened into the step on save (so a later pull
looks like a rewrite), and the save-time validator runs before that flattening, over the flat keys —
so wherever that validator is enabled it reports your wrapped fields as missing.
bypass: true settles the step SKIPPED, never COMPLETED — and SKIPPED is pass-through for every
dep form, action-qualified ones included, so bypassing a router releases its branches, never strands them.
Step types
type |
Runs |
Key fields |
EXECUTION |
a module over the documents |
moduleRef, options, perDocument (default true), maxParallel (5), joinPolicy |
CREATE_TASK |
a Task from an org TaskTemplate; waits for it |
taskTemplateRef, taskStatusSlug, taskData |
SCRIPT |
JavaScript in a sandboxed VM (300 s budget) |
scriptBody, scriptActions, scriptSidecars, perDocument |
LLM |
a prompt via the AI gateway |
promptBody or promptTemplateRef, promptActions, outputMapping, perDocument |
BRIDGE_CALL |
an HTTP call to a ServiceBridge endpoint |
serviceBridgeRef, endpointName, request* maps XOR requestScript |
AGENT |
dispatches an agent runtime |
agentRuntimeRef (orgSlug/runtimeSlug, must be READY), prompt, moduleRefs |
APPROVAL |
nothing — see below |
— |
Only these seven are safe. TASK, BRIDGE and AI_PLANNER sit outside the accepted type set and
materialize with none of their type-specific fields (AI_PLANNER settles SKIPPED); AI_PROMPT is
accepted but loses its prompt fields at start; APPROVAL is not implemented — the orchestrator settles
it SKIPPED at once, so it never reaches an approver and can never emit an action (use CREATE_TASK
with a review template); and AGENT ignores assistantRef/agentInputs, then fails the step with
planned agent <id> has no agent_runtime_id.
Reference formats differ per field
| Field |
Form |
If you get it wrong |
moduleRef (EXECUTION) |
orgSlug/moduleSlug, or module://orgSlug/moduleSlug |
module not found |
scriptSidecars[] |
orgSlug/moduleSlug required, no :version |
must be 'orgSlug/moduleSlug' |
agentRuntimeRef |
orgSlug/runtimeSlug required |
expected 'orgSlug/runtimeSlug' |
serviceBridgeRef, enrichment[].serviceBridgeRef |
bare slug; an orgSlug/ prefix is stripped |
— |
taskTemplateRef, taskStatusSlug, setDocumentStatus |
bare slug, resolved in the plan's org |
silently unresolved — an unknown taskStatusSlug leaves the task with no status at all (there is no fallback) |
promptTemplateRef |
bare slug — an orgSlug/ prefix never resolves |
prompt "acme-corp/x" not found |
${orgSlug} is expanded when the plan is saved, inside steps, metadata, inputOptions, inputsSchema,
documentFamilyGroups and the two templates — so moduleRef: "${orgSlug}/helpers" works while
promptTemplateRef: "${orgSlug}/…" does not. ${org}/ is a different placeholder, expanded by kdx at push.
Dependency and action edges
dependsOn entry |
Ready when the upstream is |
"slug" |
COMPLETED or SKIPPED |
"slug?" |
also NOT_TAKEN (await). Requires a conditionExpr unless it is a valid ANY_BRANCH join, else the start is rejected (await-no-condition) |
"slug:action" |
COMPLETED with that action token — or SKIPPED |
"slug?:action" |
await, plus warned action-qualifier-on-await-ignored; the qualifier only means anything on an ANY_BRANCH branch dep. Qualify a plain dep or gate with conditionExpr instead |
Only SCRIPT, LLM, BRIDGE_CALL and CREATE_TASK can emit actions — an action edge off an EXECUTION,
AGENT or APPROVAL step is an error at start (action-edge-upstream-cannot-emit). Actions are declared
{name, slug} (uuid is the legacy spelling of slug; both, differing, is rejected). The live source is
scriptActions / promptActions / bridgeActions — and for CREATE_TASK, the referenced TaskTemplate's own
actions plus the org's DONE-typed task-status slugs; the step's inline actions: array is a mirror only, and an
edge matching only it fails the start.
Every SCRIPT step must declare scriptActions and return one of them. The runtime needs a returned object
with a non-empty action matching one by slug (exact) or name (case-insensitive); return {}, or returning an
action with nothing declared, fails the step. There is no action() function and no ctx/context global — the
globals are inputs, families, task, org, documents, tasks, knowledge, loadDocument, serviceBridge, llm, log.
Worked SCRIPT steps: references/examples.md.
Per-document processing, routing and document status
perDocument: true fans a step out over the activity's document families. Valid only on EXECUTION
(default true), LLM, SCRIPT, BRIDGE_CALL (default false); anywhere else is an error
(per-document-unsupported). maxParallel is EXECUTION-only (default 5) — on LLM/SCRIPT/BRIDGE_CALL it
is an error, because those per-document paths run sequentially.
Routing is auto-detected, never a flag: it turns on when a perDocument step declaring actions is the target
of an action-qualified dep. Branch with dependsOn: ["classify:receipt"] / ["classify:invoice"], then re-join on
a perDocument step with joinPolicy: ANY_BRANCH whose branch deps are await-only (["receipt-extract?", "invoice-extract?"]) — plain branch deps AND-combine per document and converge nothing. joinPolicy is
ALL_SETTLED (default), ALL_COMPLETED, ANY_COMPLETED or ANY_BRANCH; a document whose action matches no
edge is NOT_TAKEN on every branch and counted unrouted.
setDocumentStatus: <slug> works on any step type, resolving against the project's document statuses
(kdxa_project_document_status, not task statuses): a perDocument step stamps the families that COMPLETED
there, any other step stamps every family in the activity. Best-effort — an unknown slug is logged, never fatal.
Under routing, conditionExpr is evaluated per document and gains a document object (.status slug-or-null,
.statusLabel, .locked, .labels, .path), so a root step can gate with
"$not(document.status in ['reviewed', 'completed'])"; elsewhere document.* is inert.
Template languages — four, in one file
| Where |
Language |
Right |
Wrong |
defaultTitleTemplate, defaultDescriptionTemplate |
Go text/template |
{{ .inputs.vendorId }} |
${inputs.vendorId} |
conditionExpr, BRIDGE_CALL request* values, treatAsError, promptVariables, outputMapping |
JSONata |
"inputs.vendorId", literal "'active'" |
"$.context.vendorId", bare "active" |
LLM promptBody |
FString |
Classify {docType} |
{{ docType }} |
taskData.title/description/properties, EXECUTION options |
fixed ${…} placeholders only |
"${activity.title}" |
"{{ .inputs.vendorId }}" — stays literal |
enrichment[].inputMapping values |
plain dot-paths |
"inputs.vendorId" |
"$.inputs.vendorId" — resolves to null |
JSONata roots: conditionExpr and BRIDGE_CALL maps see {orgId, projectId, inputs, documentFamilyId, extractedData, steps}, where upstream output is steps.<slug>.<key>; LLM promptVariables see
{context: {orgId, projectId, inputs}, enrichment, steps}, so an enrichment result is at enrichment.<outputKey>,
never context.<outputKey>. Backtick hyphenated slugs: steps.`a-b`.action. steps holds COMPLETED steps
only, and in a BRIDGE_CALL request* map a path that matches nothing fails the step (eval: no results found) —
guard the full path: "$exists(steps.review.completedActionUuid) ? steps.review.completedActionUuid : ''".
Validate, bind, start
POST /api/activity-plans/validate with {steps, organizationId} returns {valid, issues:[{stepSlug, code, severity, message}]}, always 200 — the same checks the start enforces, but all of them; every
severity: "error" means start returns 400. The separate save-time rule set is off unless a deployment
enables it, so a clean save proves nothing.
- The plan must be bound to the project — a
kdxa_project_resources binding of type activity-plan —
else POST /api/activities returns 400:
activity-plan "<slug>" is not bound to project <id>; create a project-resource binding first.
- Start body:
projectId + activityPlanRef required, plus optional title, inputs, triggerKind
(default MANUAL), documentFamilyIds, documentFamilyFilter; success is 201. Also launchable from a
Trigger, an intake script returning { activityPlan: 'invoice-intake', … }, or a SCRIPT nextActivity.
What the run runs over is chosen at start, not by the plan — and selecting nothing is silent.
documentFamilyIds / documentFamilyFilter resolve once at start, then intersect with the document
stores bound to the project; a family in an unbound store is dropped there with no error either side.
An empty result is not a failure: every perDocument step fans out over zero documents, settles
COMPLETED, and the activity finishes green — so "the plan ran and nothing came out" is more often an
empty document set than a broken step. Assert the document count before debugging a step. Only a
documentFamilyGroups entry with required: true fails loudly instead, with a 400 naming the group.
Declared but inert
Persisted, round-tripped, present in existing YAML — and read by nothing.
| Field |
Reality |
waitForCompletion (CREATE_TASK) |
never read; the step always waits for its task to reach a DONE status |
disableCache (BRIDGE_CALL) |
plumbed to the request then ignored; there is no caching layer |
outputMapping (BRIDGE_CALL) |
accepted in YAML but never carried onto the runtime step, so bridgeActions never resolve and action edges off a BRIDGE_CALL never fire. Branch instead with a conditionExpr on the downstream steps, reading the always-present steps.<slug>._statusCode |
inputsSchema |
drives the Studio start form only |
approverRole, approvalCriteria, all of APPROVAL |
the step settles SKIPPED before anyone can act |
CREATE_TASK inline actions: |
folded into taskData.actions, never rendered, never routable |
badges[] without promote: true |
stays on the step; only promoted badges reach the activity |
joinPolicy on CREATE_TASK / AGENT / APPROVAL |
dropped; behaves as ALL_SETTLED (warned join-policy-inert) |
documentSummarizationPrompt, documentValidationPrompt |
browser upload flow only; API and intake starts bypass both, and an unreachable model fails open |
maxParallel on CREATE_TASK / AGENT / APPROVAL |
never persisted and never flagged — only EXECUTION reads it (on LLM/SCRIPT/BRIDGE_CALL it is a start-time error) |
Common mistakes
| Mistake |
What happens |
kind: SCRIPT |
start rejected: step missing required slug or type |
config: { moduleRef: … } |
flattened away on save; where the save-time validator runs it reports them missing |
action('high') in scriptBody |
ReferenceError — return { action: 'high' } instead |
return {} from a SCRIPT |
step fails: the return needs a non-empty action matching a scriptActions entry |
ctx. / context. in scriptBody |
no such global; use inputs, families, documents, task, org |
treatAsError: '$.status >= "400"' |
always false, so a 500 counts as success; use $._statusCode >= 300 |
unguarded "steps.x.y" / "inputs.optional" in a request* map |
step fails eval: no results found when the path is absent; wrap in $exists(…) ? … : … |
joinPolicy: all_complete |
stored verbatim, then reduced as ALL_SETTLED with a warning |
promptTemplateRef: "${orgSlug}/x" |
prompt not found at run time; use the bare slug |
maxParallel on LLM/SCRIPT/BRIDGE_CALL, perDocument on CREATE_TASK/AGENT/APPROVAL |
error at start |
await dep slug? with no conditionExpr |
error at start (await-no-condition) |
See references/step-types.md (per-type fields, runtime contexts, limits, plan-level keys),
references/validation.md (every issue code) and references/examples.md (complete plans). Related
skills: task-template, task-status, trigger, service-bridge, prompt-template, module.
1---2name: activity-plan3description: Use when writing or editing Kodexa ActivityPlan YAML — the org-scoped graph of steps (EXECUTION, CREATE_TASK, SCRIPT, LLM, BRIDGE_CALL, AGENT) that runs as a project Activity. Covers the flat step envelope keyed by `type`, dependsOn and action edges, per-document fan-out and routing (perDocument, ANY_BRANCH, await `?` deps), setDocumentStatus, inputOptions vs inputsSchema, per-field reference formats, and the several template languages that coexist in one plan.4---56# Kodexa ActivityPlan authoring78An **ActivityPlan** is an org-scoped directed graph of typed steps; starting one creates a project-scoped **Activity**9plus a runtime step row per step. Plans live in `kdxa_activity_plans`, resolve as `activity-plan://acme-corp/invoice-intake`10(unversioned), sync from an `activity-plans/` directory, and must be **bound to a project** to run.1112## Resource shape1314```yaml15slug: invoice-intake # required, unique in the org16orgSlug: acme-corp # `kdx apply -f` needs it (or --org-slug); ${org} also resolves17name: Invoice Intake18type: activity-plan # routes the file — `kdx apply -f` refuses one without it19inputOptions: # the ONLY server-enforced input contract20 - { name: vendorId, label: Vendor, type: string, required: true }21 - { name: priority, label: Priority, type: selection, required: false }22inputsSchema: { type: object, required: [vendorId], properties: { vendorId: { type: string } } }23defaultTitleTemplate: "Invoice — {{ filename .documentPath }}" # Go text/template; the start24defaultDescriptionTemplate: "{{ .documentCount }} document(s)" # returns 400 if it fails to render25steps: [...]26```2728A required `inputOptions` entry missing (or empty) in `inputs` fails the start: `missing required inputs: <names>`.29`inputsSchema` only renders the Studio start form — never validated server-side — so declare every input in both.3031## The step envelope is FLAT and keyed by `type`3233```yaml34steps:35 - slug: extract # required; unique in plan; ^[a-z0-9][a-z0-9_-]*$36 type: EXECUTION # required — the discriminator is `type`, never `kind`37 moduleRef: "kodexa/fast-pdf-model" # kind fields sit at the TOP LEVEL38 conditionExpr: "inputs.priority = 'high'" # JSONata; false ⇒ step NOT_TAKEN39 setDocumentStatus: extracted # any type; stamps a project document status on completion40 bypass: false # any type; true ⇒ step settles SKIPPED with no work41```4243- `kind:` is **not** an alias — a step without `type` fails the start: `step missing required slug or type`.44- There is **no `config: {}` wrapper**: it is silently flattened into the step on save (so a later pull45 looks like a rewrite), and the save-time validator runs *before* that flattening, over the flat keys —46 so wherever that validator is enabled it reports your wrapped fields as missing.47- `bypass: true` settles the step **SKIPPED**, never COMPLETED — and SKIPPED is pass-through for **every**48 dep form, action-qualified ones included, so bypassing a router releases its branches, never strands them.4950## Step types5152| `type` | Runs | Key fields |53|---|---|---|54| `EXECUTION` | a module over the documents | `moduleRef`, `options`, `perDocument` (**default true**), `maxParallel` (5), `joinPolicy` |55| `CREATE_TASK` | a Task from an org TaskTemplate; waits for it | `taskTemplateRef`, `taskStatusSlug`, `taskData` |56| `SCRIPT` | JavaScript in a sandboxed VM (300 s budget) | `scriptBody`, `scriptActions`, `scriptSidecars`, `perDocument` |57| `LLM` | a prompt via the AI gateway | `promptBody` **or** `promptTemplateRef`, `promptActions`, `outputMapping`, `perDocument` |58| `BRIDGE_CALL` | an HTTP call to a ServiceBridge endpoint | `serviceBridgeRef`, `endpointName`, `request*` maps XOR `requestScript` |59| `AGENT` | dispatches an agent runtime | `agentRuntimeRef` (`orgSlug/runtimeSlug`, must be READY), `prompt`, `moduleRefs` |60| `APPROVAL` | **nothing — see below** | — |6162Only these seven are safe. `TASK`, `BRIDGE` and `AI_PLANNER` sit outside the accepted type set and63materialize with none of their type-specific fields (`AI_PLANNER` settles SKIPPED); `AI_PROMPT` is64accepted but loses its prompt fields at start; **APPROVAL is not implemented** — the orchestrator settles65it `SKIPPED` at once, so it never reaches an approver and can never emit an action (use `CREATE_TASK`66with a review template); and **AGENT** ignores `assistantRef`/`agentInputs`, then fails the step with67`planned agent <id> has no agent_runtime_id`.6869## Reference formats differ per field7071| Field | Form | If you get it wrong |72|---|---|---|73| `moduleRef` (EXECUTION) | `orgSlug/moduleSlug`, or `module://orgSlug/moduleSlug` | module not found |74| `scriptSidecars[]` | `orgSlug/moduleSlug` **required**, no `:version` | `must be 'orgSlug/moduleSlug'` |75| `agentRuntimeRef` | `orgSlug/runtimeSlug` **required** | `expected 'orgSlug/runtimeSlug'` |76| `serviceBridgeRef`, `enrichment[].serviceBridgeRef` | bare slug; an `orgSlug/` prefix is stripped | — |77| `taskTemplateRef`, `taskStatusSlug`, `setDocumentStatus` | bare slug, resolved in the plan's org | silently unresolved — an unknown `taskStatusSlug` leaves the task with **no status at all** (there is no fallback) |78| `promptTemplateRef` | **bare slug — an `orgSlug/` prefix never resolves** | `prompt "acme-corp/x" not found` |7980`${orgSlug}` is expanded when the plan is **saved**, inside `steps`, `metadata`, `inputOptions`, `inputsSchema`,81`documentFamilyGroups` and the two templates — so `moduleRef: "${orgSlug}/helpers"` works while82`promptTemplateRef: "${orgSlug}/…"` does not. `${org}/` is a different placeholder, expanded by `kdx` at push.8384## Dependency and action edges8586| `dependsOn` entry | Ready when the upstream is |87|---|---|88| `"slug"` | COMPLETED or SKIPPED |89| `"slug?"` | also NOT_TAKEN (await). **Requires a `conditionExpr`** unless it is a valid `ANY_BRANCH` join, else the start is rejected (`await-no-condition`) |90| `"slug:action"` | COMPLETED with that action token — or SKIPPED |91| `"slug?:action"` | await, plus warned `action-qualifier-on-await-ignored`; the qualifier only means anything on an `ANY_BRANCH` branch dep. Qualify a plain dep or gate with `conditionExpr` instead |9293Only `SCRIPT`, `LLM`, `BRIDGE_CALL` and `CREATE_TASK` can emit actions — an action edge off an `EXECUTION`,94`AGENT` or `APPROVAL` step is an error at start (`action-edge-upstream-cannot-emit`). Actions are declared95`{name, slug}` (`uuid` is the legacy spelling of `slug`; both, differing, is rejected). The **live** source is96`scriptActions` / `promptActions` / `bridgeActions` — and for `CREATE_TASK`, the **referenced TaskTemplate's own97actions plus the org's DONE-typed task-status slugs**; the step's inline `actions:` array is a mirror only, and an98edge matching only it fails the start.99100**Every SCRIPT step must declare `scriptActions` and return one of them.** The runtime needs a returned object101with a non-empty `action` matching one by `slug` (exact) or `name` (case-insensitive); `return {}`, or returning an102action with nothing declared, fails the step. There is no `action()` function and no `ctx`/`context` global — the103globals are `inputs`, `families`, `task`, `org`, `documents`, `tasks`, `knowledge`, `loadDocument`, `serviceBridge`, `llm`, `log`.104Worked SCRIPT steps: `references/examples.md`.105106## Per-document processing, routing and document status107108`perDocument: true` fans a step out over the activity's document families. Valid **only** on `EXECUTION`109(default `true`), `LLM`, `SCRIPT`, `BRIDGE_CALL` (default `false`); anywhere else is an error110(`per-document-unsupported`). `maxParallel` is **EXECUTION-only** (default 5) — on LLM/SCRIPT/BRIDGE_CALL it111is an error, because those per-document paths run sequentially.112113**Routing is auto-detected, never a flag**: it turns on when a `perDocument` step declaring actions is the target114of an action-qualified dep. Branch with `dependsOn: ["classify:receipt"]` / `["classify:invoice"]`, then re-join on115a `perDocument` step with `joinPolicy: ANY_BRANCH` whose branch deps are await-only (`["receipt-extract?",116"invoice-extract?"]`) — plain branch deps AND-combine per document and converge nothing. `joinPolicy` is117`ALL_SETTLED` (default), `ALL_COMPLETED`, `ANY_COMPLETED` or `ANY_BRANCH`; a document whose action matches no118edge is NOT_TAKEN on every branch and counted unrouted.119120`setDocumentStatus: <slug>` works on **any** step type, resolving against the **project's** document statuses121(`kdxa_project_document_status`, not task statuses): a `perDocument` step stamps the families that COMPLETED122there, any other step stamps every family in the activity. Best-effort — an unknown slug is logged, never fatal.123Under routing, `conditionExpr` is evaluated per document and gains a `document` object (`.status` slug-or-null,124`.statusLabel`, `.locked`, `.labels`, `.path`), so a root step can gate with125`"$not(document.status in ['reviewed', 'completed'])"`; elsewhere `document.*` is inert.126127## Template languages — four, in one file128129| Where | Language | Right | Wrong |130|---|---|---|---|131| `defaultTitleTemplate`, `defaultDescriptionTemplate` | Go text/template | `{{ .inputs.vendorId }}` | `${inputs.vendorId}` |132| `conditionExpr`, BRIDGE_CALL `request*` values, `treatAsError`, `promptVariables`, `outputMapping` | JSONata | `"inputs.vendorId"`, literal `"'active'"` | `"$.context.vendorId"`, bare `"active"` |133| LLM `promptBody` | FString | `Classify {docType}` | `{{ docType }}` |134| `taskData.title/description/properties`, EXECUTION `options` | fixed `${…}` placeholders only | `"${activity.title}"` | `"{{ .inputs.vendorId }}"` — stays literal |135| `enrichment[].inputMapping` values | plain dot-paths | `"inputs.vendorId"` | `"$.inputs.vendorId"` — resolves to null |136137JSONata roots: `conditionExpr` and BRIDGE_CALL maps see `{orgId, projectId, inputs, documentFamilyId,138extractedData, steps}`, where upstream output is `steps.<slug>.<key>`; LLM `promptVariables` see139`{context: {orgId, projectId, inputs}, enrichment, steps}`, so an enrichment result is at `enrichment.<outputKey>`,140never `context.<outputKey>`. Backtick hyphenated slugs: ``steps.`a-b`.action``. **`steps` holds COMPLETED steps141only, and in a BRIDGE_CALL `request*` map a path that matches nothing fails the step** (`eval: no results found`) —142guard the **full** path: `"$exists(steps.review.completedActionUuid) ? steps.review.completedActionUuid : ''"`.143144## Validate, bind, start145146- `POST /api/activity-plans/validate` with `{steps, organizationId}` returns `{valid, issues:[{stepSlug,147 code, severity, message}]}`, always 200 — the **same** checks the start enforces, but all of them; every148 `severity: "error"` means start returns 400. The separate save-time rule set is off unless a deployment149 enables it, so a clean save proves nothing.150- The plan must be bound to the project — a `kdxa_project_resources` binding of type `activity-plan` —151 else `POST /api/activities` returns **400**:152 `activity-plan "<slug>" is not bound to project <id>; create a project-resource binding first`.153- Start body: `projectId` + `activityPlanRef` required, plus optional `title`, `inputs`, `triggerKind`154 (default `MANUAL`), `documentFamilyIds`, `documentFamilyFilter`; success is 201. Also launchable from a155 Trigger, an intake script returning `{ activityPlan: 'invoice-intake', … }`, or a SCRIPT `nextActivity`.156157**What the run runs over is chosen at start, not by the plan — and selecting nothing is silent.**158`documentFamilyIds` / `documentFamilyFilter` resolve once at start, then intersect with the **document159stores bound to the project**; a family in an unbound store is dropped there with no error either side.160An empty result is not a failure: every `perDocument` step fans out over zero documents, settles161COMPLETED, and the activity finishes green — so "the plan ran and nothing came out" is more often an162empty document set than a broken step. **Assert the document count before debugging a step.** Only a163`documentFamilyGroups` entry with `required: true` fails loudly instead, with a 400 naming the group.164165## Declared but inert166167Persisted, round-tripped, present in existing YAML — and read by nothing.168169| Field | Reality |170|---|---|171| `waitForCompletion` (CREATE_TASK) | never read; the step always waits for its task to reach a DONE status |172| `disableCache` (BRIDGE_CALL) | plumbed to the request then ignored; there is no caching layer |173| `outputMapping` (BRIDGE_CALL) | accepted in YAML but never carried onto the runtime step, so `bridgeActions` never resolve and action edges off a BRIDGE_CALL never fire. Branch instead with a `conditionExpr` on the downstream steps, reading the always-present `steps.<slug>._statusCode` |174| `inputsSchema` | drives the Studio start form only |175| `approverRole`, `approvalCriteria`, all of APPROVAL | the step settles SKIPPED before anyone can act |176| CREATE_TASK inline `actions:` | folded into `taskData.actions`, never rendered, never routable |177| `badges[]` without `promote: true` | stays on the step; only promoted badges reach the activity |178| `joinPolicy` on CREATE_TASK / AGENT / APPROVAL | dropped; behaves as ALL_SETTLED (warned `join-policy-inert`) |179| `documentSummarizationPrompt`, `documentValidationPrompt` | browser upload flow only; API and intake starts bypass both, and an unreachable model fails open |180| `maxParallel` on CREATE_TASK / AGENT / APPROVAL | never persisted and never flagged — only EXECUTION reads it (on LLM/SCRIPT/BRIDGE_CALL it is a start-time error) |181182## Common mistakes183184| Mistake | What happens |185|---|---|186| `kind: SCRIPT` | start rejected: `step missing required slug or type` |187| `config: { moduleRef: … }` | flattened away on save; where the save-time validator runs it reports them missing |188| `action('high')` in `scriptBody` | `ReferenceError` — `return { action: 'high' }` instead |189| `return {}` from a SCRIPT | step fails: the return needs a non-empty `action` matching a `scriptActions` entry |190| `ctx.` / `context.` in `scriptBody` | no such global; use `inputs`, `families`, `documents`, `task`, `org` |191| `treatAsError: '$.status >= "400"'` | always false, so a 500 counts as success; use `$._statusCode >= 300` |192| unguarded `"steps.x.y"` / `"inputs.optional"` in a `request*` map | step fails `eval: no results found` when the path is absent; wrap in `$exists(…) ? … : …` |193| `joinPolicy: all_complete` | stored verbatim, then reduced as `ALL_SETTLED` with a warning |194| `promptTemplateRef: "${orgSlug}/x"` | prompt not found at run time; use the bare slug |195| `maxParallel` on LLM/SCRIPT/BRIDGE_CALL, `perDocument` on CREATE_TASK/AGENT/APPROVAL | error at start |196| await dep `slug?` with no `conditionExpr` | error at start (`await-no-condition`) |197198See `references/step-types.md` (per-type fields, runtime contexts, limits, plan-level keys),199`references/validation.md` (every issue code) and `references/examples.md` (complete plans). Related200skills: `task-template`, `task-status`, `trigger`, `service-bridge`, `prompt-template`, `module`.