AGPL-3.0-or-later · forever.

the spec · 02

The 4 verbs

Every task in a Nika workflow binds to **exactly one** of 4 verbs.

02 · The 4 verbs #

Every task in a Nika workflow binds to exactly one of 4 verbs. A verb is a distinct native execution model the engine itself implements: call a model, run a process, dispatch a tool, drive an agentic loop. That is the whole operation space.Everything callable (fetching a URL, a database query, a file write, cognitive recall) is a tool reached through invoke:. Everything about ordering (iteration, branching) is a DAG construct (for_each · when). The 4 verbs never change; tools and the stdlib grow freely.

The 4 verbs · summary #

  • Verb · What it does · Stdlib it consumes
  • `infer:` · Single LLM call · text · structured · vision · thinking · providers
  • `exec:` · Shell command with sandboxing · (none · pure effect)
  • `invoke:` · Call a builtin tool OR an MCP tool · builtins · MCP servers
  • `agent:` · Multi-turn agentic loop with tool calls · providers · builtins · MCP

A task must specify exactly one of these. Multiple verbs on a single task is a validation error.

Where did `fetch` go? Fetching a URL is calling a tool, not a distinct execution model, so it is the nika:fetch builtin, reached through invoke: (the extract modes become its mode argument). Same reason a DB query (invoke: mcp:postgres/query) or a file write (invoke: nika:write) is not its own verb. See stdlib/builtins-v0.1.md.

What `${{ tasks.<id>.output }}` holds · per verb #

Every task also exposes the full result record (.status · .error · .started_at · .ended_at · .duration_ms · 04 §task output reference). The .output shape depends on the verb (know this before you bind downstream) ·

  • Verb · `.output` is · Structured when
  • `infer:` · the model's reply · **string** · `schema:` set → **object** matching it
  • `exec:` · **stdout string** (default) · `capture: structured` → `{ stdout, stderr, exit_code }`
  • `invoke:` · the **tool's response** (tool-defined · string · object · or bytes) · per builtin / MCP tool schema · bytes are tool-determined (MCP image content · binary read) · flow opaquely · see 04 §value rendering
  • `agent:` · the loop's **final message** · string · `schema:` set → **object** matching it

`infer:` · LLM call #

A single LLM call. The result is the model's response.

Minimal #

yaml
greet:    infer:      prompt: "Say hello in French"

Full #

yaml
research:    infer:      prompt: "Research Rust async runtimes 2026 in 5 paragraphs"      system: "You are a senior software architect."      model: mistral/mistral-large         # override default · <provider>/<name>      temperature: 0.3      max_tokens: 2000      schema:                            # optional · structured output        type: object        required: [summary, paragraphs]        properties:          summary: { type: string }          paragraphs: { type: array, items: { type: string } }      thinking:                          # optional · extended thinking        enabled: true        budget_tokens: 4000      vision:                            # optional · vision input        - source: file          path: "./diagram.png"

Fields #

  • Field · Required · Type · Notes
  • `prompt` · yes · string · User prompt · may use `${{ ... }}` substitution
  • `system` · no · string · System prompt
  • `model` · no · string · Override workflow default · `<provider>/<name>` · see stdlib/providers-v0.1.md
  • `temperature` · no · number 0-2 · Sampling temperature
  • `max_tokens` · no · integer · Max output tokens · provider-dependent default
  • `schema` · no · object · raw JSON Schema · structured output validation — the **out-of-core hatch**; the typed door is task-level `returns:` ([09](./09-types.md) · both on one task = `NIKA-TYPE-003`). Numeric facts (`0|1|3`, counts, coded levels) are `type: integer` with a numeric `enum` — never `enum: ["0","1","3"]`. Models emit JSON numbers; a string-digit enum can reject the call before coerce (reference engine hint `digit-string-enum`). The model extracts facts; `nika:jq` or `nika:decide` is the law ([11](./11-decision.md) · `examples/13-extract-then-law.nika.yaml`)
  • `thinking` · no · object · Extended thinking · `{ enabled, budget_tokens }`
  • `vision` · no · array · Image inputs · each `{ source: file

Conformance #

A v0.1-compliant engine MUST ·

  • Call the configured provider with the given prompt + system + parameters
  • Return the model's response as the task output
  • Validate the response against schema if present · MAY auto-retry validation failures internally before surfacing NIKA-INFER-002 (engine-configurable · the same rule as 05 §structured output)
  • Reject any unknown field with a clear error (NIKA-PARSE-005) — the choice this line used to leave open is closed by 07 §unknown key: a key can REMOVE authority, so accept-and-warn fails open

Subscription harness access (normative) #

An engine MAY execute infer: through a user's authenticated CLI seat instead of a provider API key, but only after an infer-grade meet proves all four properties for that adapter and this task:

  1. single_turn — one request produces one terminal answer;
  2. no_implicit_tools — an accepted answer contains no tool, command, file, network, or permission event;
  3. structured_output — the adapter's level is at least the task's need (plain text, JSON, or JSON Schema);
  4. model_identity — the requested model identity is observable at command construction and is recorded without being relabelled as the model that answered.

The meet is evidence, not a product-name allowlist. A seat with no attestation, or one failing any conjunct, MUST be refused before inference with a witness that names the failed property. It MUST NOT fall back to a native provider or to the seat's agentic loop. In particular, an ACP-capable agent seat is not thereby infer-grade.

For an accepted seat run, the receipt MUST record the seat id and the requested model. It MUST leave the responding model absent unless the adapter reports that fact explicitly; request identity is never promoted into response identity. Subscription inference has no numeric token count and no numeric price in the Nika receipt or budget. Its budget lane is subscription quota, never 0 USD. An adapter may read a terminal usage object as protocol-completeness evidence, but MUST NOT project its numeric meters into the Nika receipt.

A non-executing preview that accepts an access pin MUST apply the same access admission meet as a live run and record the requested pin plus each requested model. The same invalid or unattested pin MUST refuse with the same access code. For an admitted subscription seat, the preview MUST name subscription quota and MUST NOT display a numeric USD or token meter.


`exec:` · shell command #

Run a shell command. The result is the command's stdout (default) or a structured output.

Minimal #

yaml
build:    exec:      command: ["cargo", "build", "--release"]

Full #

yaml
test:    timeout: "60s"                   # task-level (applies to any verb · Go duration · see 03-dag)    exec:      command: ["cargo", "test", "--workspace", "--lib"]      cwd: "./engine"      env:        RUST_LOG: "debug"      stdin: "${{ inputs.input_data }}"      capture: structured            # stdout | stderr | structured | combined

Fields #

  • Field · Required · Type · Notes
  • `command` · one of · array · **argv** — `["prog", "arg", …]` → direct `execve`, **NO shell** · each element substituted independently — an interpolated `${{ }}` value can never break out of its argument (see Security). Exactly one of `command` | `shell`.
  • `shell` · one of · string · **The explicit shell door** — one line run via `/bin/sh -c` · pipes · redirects · globs. The blocklist applies HERE; interpolating untrusted values here is the author's own risk, visibly. Exactly one of `command` | `shell`. *(D1 · #75: semantics never fork on a YAML type — the field name carries the meaning. The old string `command:` is rejected at parse.)*
  • `cwd` · no · string · Working directory · default = engine's cwd
  • `env` · no · object · OS environment variables for **this subprocess** · key→value map · applied over the COMPOSED environment ([01 §permits](./01-envelope.md#permits--optional--the-declared-capability-boundary) env · never inherited)
  • `stdin` · no · string · Stdin data · may use `${{ ... }}`
  • `capture` · no · enum · `stdout` (default) · `stderr` · `combined` · `structured` (= `{ stdout, stderr, exit_code }`) — the **source**
  • `decode` · no · enum · `text` (default) · `json` · `jsonl` · `bytes` — how the captured **string** becomes a value ([09 §decode](./09-types.md#decode--how-exec-bytes-become-a-value-normative)) · illegal with `capture: structured` (`NIKA-PARSE-025` — that capture already IS an object) · a non-parsing stream settles the task `failure` inside `on_error:` scope
`exec.env` is a subprocess field, never an envelope block: no top-level key of that family survives — the pre-flip envelope env: block is dead (NIKA-VALUES-002) and the top-level config: block died with the envelope nuke, so exec.env is the OS environment of this subprocess and nothing else. Nothing is auto-connected. To pass a workflow value into the process, do it explicitly: env: { API_BASE: "${{ inputs.api_base }}" }. The subprocess environment itself is COMPOSED, never inherited (NEP-0005 · 01 §permits): the runner env floor ∪ the permits.env: passthrough ∪ this map, minus the dangerous-name floor · an ambient engine variable reaches the child only through a declared permits.env: name.
timeout and retry are task-level fields (see 03-dag.md): they apply uniformly to every verb, so they are not repeated inside exec:.

Security #

A v0.1-compliant engine MUST ·

  • Honor the two exec bodies · command: (array) runs through execve with NO shell: command[0] is the program, the rest are argv passed verbatim, each element substituted independently. shell: (string) runs through /bin/sh -c — the shell-feature path (pipes · redirects · globs). Exactly one of the two; a string command: (the pre-0.103 implicit-shell form) is rejected at parse (NIKA-PARSE · validation_error).
  • Implement a shell blocklist for dangerous commands on shell: (see reference impl nika-policy for canonical list · 100+ patterns including rm -rf / · chmod 777 · curl … | sh · etc.)
  • Reject blocklist matches with a clear error
  • Honor timeout with a hard kill (Go-duration string · see 03-dag)
  • Sandbox cwd if configured (engine-specific)
The argv form is the STRUCTURAL fix for command injection: prefer it. A ${{ }}-interpolated value in the STRING form is shell-parsed: shell: "process ${{ item }}" with item == "; rm -rf /" is a classic injection, and the blocklist is a detector (best-effort), not a guarantee. The ARRAY form removes the shell entirely: command: ["process", "${{ item }}"] passes item as ONE argv element no matter what it contains; there is no shell to parse it. Rule of thumb · any command carrying a task output, a `var`, or any value not authored inline → use the array form. nika check warns (one-obvious-way) when an interpolated value lands in a string command. The string form stays first-class for genuine shell pipelines ("cat x | grep y > z"). That is what /bin/sh -c is for.

Conformance #

The engine MUST ·

  • Run the STRING command via the OS shell (/bin/sh -c on Unix · cmd /c on Windows, OR a sandboxed equivalent) · run the ARRAY command via execve with no shell
  • Capture stdout/stderr as configured
  • Return exit code in structured capture mode
  • Fail the task on non-zero exit (NIKA-EXEC-001 · process_error) in stdout / stderr / combined capture modes (unless on_error: recovers · 05), EXCEPT `capture: structured`, where a non-zero exit is data, not failure · the task succeeds and exit_code is the workflow's to branch on (when: ${{ tasks.test.output.exit_code != 0 }}) · the one-obvious-way split · default modes for « must succeed » · structured for « inspect the outcome »

`invoke:` · builtin or MCP tool call #

Call a builtin (nika: namespace) or an MCP tool (mcp: namespace). The result is the tool's response.

Tool reference grammar (canonical) #

text
<namespace>:<path>          one colon introduces the namespace · `/` separates the pathnika:fetch                  builtin · HTTP + extraction (used to be a verb)nika:write                  builtin · flat namenika:connectome/recall      builtin · grouped pathmcp:browser/navigate        MCP · server `browser` · tool `navigate`mcp:postgres/query          MCP · server `postgres` · tool `query`mcp:my-server/do_thing      MCP · server names admit kebab-case · tools admit snake_case

The mcp: form requires the slash: mcp:postgres alone (no tool) is a parse error (NIKA-PARSE · validation_error) · server segment [a-z][a-z0-9-]* · tool segment [A-Za-z0-9_-]+ (tool names are the MCP server's to define).

One rule everywhere · the colon marks the namespace boundary (exactly once), the slash separates the path within it. No ::, no mixed ./:. Globs are clean · nika:* · nika:connectome/* · mcp:browser/*.

The namespace set is CLOSED at v1 · nika: and mcp: are the only two · any other namespace (custom:thing · x:tool) is rejected at parse time (NIKA-PARSE · validation_error). A third namespace would be an additive spec minor. It never appears silently.

Builtin call #

yaml
read_config:    invoke:      tool: "nika:read"      args:        path: "./config.yaml"

See stdlib/builtins-v0.1.md for the canonical builtin list (28 builtins in v0.1).

Grouped paths are grammar-legal · v0.1 ships none. The reference grammar admits nika:<group>/<tool> (the nika:connectome/recall illustration above is the reserved FUTURE shape), but the v0.1 builtin set contains only flat names · a grouped nika: path is rejected against the closed 23 set today (NIKA-INVOKE-001). The seam exists so the Connectome tools land additively · zero workflow-shape change.

MCP call #

yaml
query_db:    invoke:      tool: "mcp:postgres/query"      args:        sql: "SELECT * FROM users WHERE id = $1"        params: ["${{ inputs.user_id }}"]

The MCP server postgres must be configured in the engine's MCP server registry.

Fields #

  • Field · Required · Type · Notes
  • `tool` · yes · string · Tool identifier · `nika:<path>` OR `mcp:<server>/<tool>`
  • `args` · no · object · Tool arguments · schema is tool-specific

Conformance #

The engine MUST ·

  • Resolve the tool identifier to its implementation (builtin or MCP server)
  • Reject unknown tools with a clear error
  • Pass args to the tool · validate against tool's schema if known
  • Return the tool's response as task output

`agent:` · multi-turn agentic loop #

Run an agentic loop · the model + a set of tools · iterating until completion or budget exhausted.

Minimal #

yaml
research:    agent:      system: "You are a research assistant."      prompt: "Research the topic · ${{ inputs.topic }}"      tools: ["nika:fetch"]             # default-deny · grant explicitly

Full #

yaml
research:    agent:      system: "You are a research assistant. Use tools to gather info."      prompt: "Research the topic · ${{ inputs.topic }} · and produce a markdown brief"      model: mistral/mistral-large      tools:                            # whitelist · default-deny (no tools if omitted)        - "nika:fetch"        - "nika:write"        - "mcp:browser/*"               # all tools from browser MCP server      skills:                           # Agent Skills · SKILL.md paths (agentskills.io shape)        - ".agents/skills/nika-authoring/SKILL.md"      max_turns: 20      max_tokens_total: 100000      temperature: 0.3      schema:                           # optional · validate the final message as structured output        type: object        required: [findings]        properties:          findings: { type: array, items: { type: string } }

Fields #

  • Field · Required · Type · Notes
  • `system` · no · string · System prompt
  • `prompt` · yes · string · Initial user message (same field name as `infer:` · consistent)
  • `model` · no · string · Override workflow default · `<provider>/<name>`
  • `tools` · no · array · Tool whitelist · glob patterns · **default-deny** · if absent the agent gets NO tools (pure conversation · least-privilege). Grant explicitly.
  • `skills` · no · array · [Agent Skill](https://agentskills.io) file paths (each an agentskills.io-shape `SKILL.md`) · **explicit static paths only** — no globs, no `${{ }}` templates (the `permits:` explicitness law) · loaded at **compose time**, **inside `permits.fs.read`** (the text reaches the model), and injected into the system context (§Agent Skills below)
  • `max_turns` · no · integer · Loop limit · default 10
  • `max_tokens_total` · no · integer · Cumulative token budget · default engine-configurable
  • `temperature` · no · number 0-2 · Sampling temperature
  • `schema` · no · object · raw JSON Schema · validates the agent's **final message** as structured output (same contract as `infer.schema:` · the out-of-core hatch — the typed door is `returns:` · [09](./09-types.md)) · `.output` becomes the matching object

Loop semantics #

The agent loops · model response → if tool calls present, execute tools → feed results back to model → repeat. The loop terminates when ·

  1. Model returns a final response with no tool calls, OR
  2. max_turns reached, OR
  3. max_tokens_total exhausted, OR
  4. A tool returns the canonical completion sentinel nika:done (the builtin tool · see stdlib/builtins-v0.1.md)

nika:done is valid only inside an `agent:` loop's tool whitelist: it is the loop-completion sentinel. Calling nika:done from a standalone invoke: task (outside any agent loop) is an error (NIKA-BUILTIN-DONE-001). The sentinel has no meaning without a loop to terminate. nika:done accepts an optional `result:` arg (any JSON value) · when present the task's .output is that value (and schema: validates IT) · when absent .output is the final assistant message (string).

The agent may also grant itself `nika:compose`, a second loop-only builtin (also valid ONLY inside an agent: whitelist · standalone is NIKA-BUILTIN-COMPOSE-001). It lets the model self-check a workflow it is drafting: pass a workflow_yaml string, get the full nika check verdict back (conformance + secret-flow + permits + the termination/cost certificate) as the tool result. It never executes the draft: verification yields an artifact + its certificate, and running it stays a separate, gated decision. The teaching shape is `examples/15-compose-self-check.nika.yaml` (nika:done first so mock/echo closes at turn one). See stdlib/builtins-v0.1.md §nika:compose. Do not confuse this loop intrinsic with parent→child composition (invoke: { workflow: } · 14 · lesson 10).

Termination outcomes (normative) ·

  • Case 1 or 4 (natural completion · nika:done) → status: success.
  • Case 2 (max_turns reached) → `status: failure` · NIKA-AGENT-001 · budget_error · the last assistant message is preserved at error.details.partial_output (a budget that runs out did NOT produce the asked-for result: failing loudly beats returning an unfinished answer · recover the partial explicitly via on_error: if it is acceptable).
  • Case 3 (max_tokens_total exhausted) → same shape · NIKA-AGENT-002.

Tool-call errors inside the loop are fed back, not fatal · a failing tool call returns its typed error to the MODEL as the tool result (the standard agentic convention: the model sees the failure and adapts) · the loop continues against its budgets. The ONE exception · a security_error (whitelist violation NIKA-SEC-002 · blocklist) fails the task immediately: security boundaries are not negotiation material for a model.

Tool whitelist · glob semantics #

The tools: whitelist uses gitignore-style globs for matching ·

yaml
tools:  - "nika:read"                  # exact match  - "nika:*"                     # any nika builtin  - "mcp:browser/*"              # any tool from the `browser` MCP server  - "mcp:postgres/query"         # exact match · postgres query only

Match rules (canonical) ·

  • * matches any sequence of characters EXCEPT the path separator / (so mcp:browser/* matches mcp:browser/navigate but not mcp:browser/tab/open)
  • ** matches any sequence including / (rare · use sparingly)
  • The namespace colon is never crossed by * (so nika:* never matches an mcp: tool)
  • Order matters · later rules override earlier rules
  • Negation via ! prefix · tools: ["mcp:browser/*", "!mcp:browser/navigate"]

A v0.1-compliant engine MUST implement these glob semantics canonically · NOT engine-specific. This is a portability invariant.

The task output is the final model response.

Agent Skills · `skills:` #

The skills: field makes the agent a consumer of the Agent Skills format — the same SKILL.md shape nika init already produces. Each entry is an explicit file path to a skill file: a markdown document opening with a YAML frontmatter block (--- fences) that carries a non-empty name and description; the markdown body after the closing fence is the skill's instructions. Other frontmatter keys (license · metadata · client-specific fields) are the skill author's surface — a consumer MUST tolerate them.

yaml
review:    agent:      system: "You are a code reviewer."      prompt: "Review the diff · ${{ tasks.diff.output }}"      skills:        - ".agents/skills/code-review/SKILL.md"        - "docs/skills/security-pass/SKILL.md"      tools: ["nika:read"]

Resolution (normative) · paths are static — no globs, no ${{ }} templates (the same explicitness law as permits:; a template or a glob in a skill path is a parse-time NIKA-AGENT-003 — it can never resolve). Relative paths resolve from the directory of the workflow file that names them, exactly like a composed workflow: child target (14) — never from the working directory (nika check sub/wf.nika.yaml run from the repo root reads sub/skills/…, whatever the operator's cwd). The files are read at compose time — before the run starts, by the composition layer, never mid-loop by the runtime.

Skill reads are INSIDE `permits.fs.read`. A skill file is not program text the way a composed child is: a child's effects are re-judged under the parent's boundary and its text goes nowhere, while a skill's text is carried into the model's context — a file read whose bytes reach a destination, the same class as nika:read. So the path as written must be admitted by the workflow's declared read boundary (the lexical permits.fs.read match every fs effect uses):

  • the workflow declares · a `skills:` entry is · code
  • no `permits:` block at all · an effect under an absent block · zero authority · `NIKA-AUTH-006`
  • a `permits:` block whose `fs.read` does not admit the path · outside the declared boundary · `NIKA-SEC-004`
  • a boundary that admits the path · the file is missing or unreadable · unresolvable at compose time · `NIKA-AGENT-003`
  • a boundary that admits the path · the file is not a valid Agent Skill · resolvable, malformed · `NIKA-AGENT-004`
  • a boundary that admits the path · a well-shaped `SKILL.md` · admitted · injected · —

The boundary is judged before the read: an ungranted path is never opened, so the refusal cannot leak whether the file exists or how its first line is shaped. (Measured on the shipped 0.108.0, 2026-08-12: under permits: {} — the declared zero — a skill path of /etc/hosts was read and the engine reported on its content while the permits and trifecta rungs stayed green. The earlier text of this paragraph, « skill reads are outside permits.fs », was the sentence that authorized it; it is retired here.)

Injection (normative) · the resolved skills join the agent's system context as ONE deterministic section, appended after the authored system: (or standing alone when system: is absent) ·

text
<authored system>## Skills### <frontmatter name><frontmatter description><markdown body · trimmed>

— one ### block per skill, in skills: source order. Same inputs, same bytes (provider-cache-friendly · reproducible transcripts).

check≡run · skills: is fully validated statically. nika check fails a workflow whose skill paths lie outside the declared read boundary (NIKA-AUTH-006 under an absent block · NIKA-SEC-004 under one that does not admit the path), do not resolve (NIKA-AGENT-003 · the file is missing/unreadable · a template or glob) or whose files are not valid Agent Skills (NIKA-AGENT-004 · no/unterminated/non-mapping frontmatter · missing/empty name or description) — see 05-errors.md. A run refuses on the same findings before any token is spent. The skill TEXT also joins the referencing task's resume identity: editing a SKILL.md re-runs the task (the same law as an edited prompt — a resume never serves output produced under a different skill).

Conformance #

The engine MUST ·

  • Honor the tools whitelist · reject tool calls not in the whitelist · default-deny when `tools:` is absent (the agent gets no tools · least-privilege)
  • Enforce max_turns and max_tokens_total budgets · terminate on exhaustion
  • Detect the nika:done completion sentinel and exit gracefully
  • Return the final model response as task output
  • Resolve skills: at compose time · judge every path against the declared permits.fs.read boundary BEFORE opening it (NIKA-AUTH-006 under an absent block · NIKA-SEC-004 outside a declared one) · inject the ## Skills section in source order exactly as specified above · fail statically (NIKA-AGENT-003/NIKA-AGENT-004) on a missing or invalid skill file — never start a run with a half-composed context

Forward-compat #

The 4 verb names are ratified by the [v1 constitution](../design/v1-constitution.md), and the count is 4, absolute — under the constitutional hierarchy of three atomic calls and one bounded controller: call a model once (infer), apply a typed callable once (invoke), run a host process once (exec), and orchestrate a bounded sequence of the atomic calls (agent). Every other capability is either an invoke-able callable (an HTTP fetch → invoke: nika:fetch · a database query → invoke: mcp:postgres/query · a file write → invoke: nika:write · cognitive recall → invoke: nika:connectome/recall) or a DAG control-flow construct (iteration → for_each · branching → when). There is no nika: v2 — ever: while the reference engine is pre-1.0 a verb change would be a break INSIDE v1 (per the pre-1.0 stability contract), and after engine 1.0.0 the verb set is frozen with the rest of the grammar, so a fifth verb is effectively never.

The closure argument · why no case forces a 5th verb #

Every candidate operation decomposes along two orthogonal axes ·

  • WHO executes: the execution model. There are exactly four · model inference (infer) · OS process (exec) · dispatch-a-call-and-await-its- result (invoke) · model-driven loop over tools (agent). Anything with a request/response shape (however exotic the backend) is by construction the invoke model.
  • WHEN it runs: ordering. Edges (with: data · after: control) · conditions (when) · iteration (for_each) · time-bounds (timeout) · recovery (retry · on_error · an unwind task). Ordering never executes anything: it is DAG-side, or host-side when it concerns starting a run at all.

The recurring « doesn't X need a verb? » cases, stress-tested ·

  • Candidate · Axis · Resolution
  • **wait-for-human** (approval gate) · WHO · a call that resolves when a human answers · a **tool** · request/response with a long latency · `invoke:` model · **already shipped** as `nika:prompt` (blocking confirm · [stdlib/builtins-v0.1.md](../stdlib/builtins-v0.1.md))
  • **sleep / delay** inside a run · WHO · a call that resolves after a duration · a **tool** · **already shipped** as `nika:wait` (relative `duration:` XOR absolute `until:`) · NOT a verb · nothing is executed, a result (elapsed) is awaited
  • **cron / schedule** (start a run at time T) · neither · it precedes the run · **host concern** · a workflow describes a run, not its triggering · explicitly out-of-scope ([08](./08-out-of-scope.md))
  • **streaming between tasks** · WHEN · changes what an *edge* delivers · a **DAG-edge semantic**, not an execution model · out-of-scope v0.1 · an additive edge attribute later · never a verb
  • **sub-workflow call** · WHO · dispatch-and-await a run · a **tool** (workflow-as-tool · see [08](./08-out-of-scope.md) §composition) · the `invoke` model again

A 5th verb would have to name an execution model that is none of call-with-result, process, inference, or loop, and every concrete candidate inspected since the 4-verb lock (D-2026-05-22-N18) has resolved to a tool (WHO · request/response) or an ordering construct (WHEN). That is the closure: the verb set is closed under decomposition into WHO × WHEN.

Field additions to each verb are additive within v1 (feature-detected · no version marker in the file at all). Field removal NEVER happens at v1.

A v0.1-compliant engine that encounters an unknown field on a verb may ·

  • Reject with a clear error (strict mode · default for tests)
  • Warn + ignore the field (lenient mode · default for production)

Engine config picks which mode.


🦋 Next · [03 · DAG shape](./03-dag.md)

nika-spec@14bf49f43 · 02-verbs.md · sha256 3744a8527e73fc55… · the pack upstream