> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nika.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Bindings

> Template expressions that move values between tasks. Scoped, typed, taint-tracked, evaluated at runtime.

export const STATUS = {
  head: "95962d5cd",
  branch: "main",
  version: "0.91.0",
  cratesWorkspace: 39,
  cratesAdmitted: 39,
  cratesTarget: "42",
  wipCrates: [],
  libTests: 2989,
  clippyWarnings: 0,
  adrs: 62,
  adrsAccepted: 42,
  adrsProposed: 18,
  providers: 32,
  capabilityRules: 49,
  hygieneVectors: 38,
  hygieneGreen: 28,
  hygieneYellow: 3,
  hygieneRed: 0,
  lastUpdated: "2026-06-25"
};

A **binding** is a template expression wrapped in `${{ … }}`. It resolves
at runtime against a typed scope — inputs, task outputs, environment
variables, secrets — and substitutes into the field. What's inside
`${{ }}` is [CEL](https://cel.dev) (Common Expression Language), the
same expression language GitHub Actions and Kubernetes admission
policies use. Nika does not invent a DSL.

The parser preserves both forms of every templatable field through the
AST: either a literal value or a `Templatable::Template(String)` holding
the unresolved expression. See
`crates/nika-schema/src/types/templatable.rs` for the exact types.

<Info>
  **Two expression layers, locked by the spec:** [CEL](https://cel.dev)
  inside `${{ }}` (conditions + value substitution) and **jq** inside
  `output:` bindings + the `nika:jq` builtin (extraction + transform).
  There are no template filters — anything beyond a reference or a
  boolean is a jq expression or a task. Engine-side, taint propagation
  and typed scope lookup mature across admission rounds; the syntax
  below is the frozen `nika: v1` contract.
</Info>

## Shape

```
${{ <expression> }}
```

Four examples, each a valid `Templatable::Template`:

```yaml theme={"system"}
prompt: "${{ vars.target }}"
prompt: "${{ tasks.fetch_issue.output.title }}"
prompt: "${{ with.model_family }}"
command: "echo ${{ env.HOME }}/output.txt"
```

Whitespace inside `${{ }}` is ignored. Nested `${{ }}` is rejected at
parse time.

## Scopes

Every binding resolves against one of five named scopes. The resolver
refuses unknown top-level names at validation time, so `${{ tsaks.foo }}`
is caught before the run starts.

| Scope                 | Source                                                                                    | Example                  |
| --------------------- | ----------------------------------------------------------------------------------------- | ------------------------ |
| `vars.*`              | Workflow inputs, declared in the envelope `vars:` block (typed or untyped)                | `vars.target`            |
| `with.*`              | Task-level scope, declared in a per-task `with:` block (pre-bind + alias)                 | `with.model_family`      |
| `tasks.<name>.output` | The result record of a completed task (`.output` · `.status` · `.error` · `.duration_ms`) | `tasks.greet.output`     |
| `env.*`               | Non-sensitive runtime config, declared in the envelope `env:` block                       | `env.HOME`, `env.REGION` |
| `secrets.*`           | Vault/env/file-backed secret references (masked in logs)                                  | `secrets.api_key`        |

Task names are validated at parse time (`snake_case`, unique). A
binding that references a task not present in the file fails with a
miette diagnostic pointing at the exact byte range.

## Typed resolution

Nika knows the expected type of every binding target. If `vars.count`
is declared `integer` and you drop it into a field typed as a list,
validation fails *before* the run starts.

```yaml theme={"system"}
vars:
  count:
    type: integer

tasks:
  - name: loop
    for_each: "${{ vars.count }}"      # fails — for_each needs a list
    infer:
      model: ollama/<model>     # <provider>/<name>
      prompt: "Iteration ${{ item }}"
```

Type-checking is the analyzer's job — live today in `nika check`
(every deep output reference is validated against its declared shape). The parser's
`Templatable<T>` carries `T` as a phantom type so downstream stages
can't conflate `Templatable<u32>` with `Templatable<bool>`.

## Taint tracking (target design — Phase D)

External data is tainted by default. The taint set is maintained by
the analyzer and propagates through bindings:

* `fetch` output → tainted (HTTP body, third-party controlled).
* `exec` stdout → tainted (subshell could have been influenced).
* `infer` / `agent` output → tainted (prompt injection vector).
* `invoke` output → tainted (MCP servers are third-party).
* `vars.*` from the CLI → tainted (user-provided at runtime).
* `context.*`, `env.*`, `nika.*` → **trusted** (workflow author owns
  them).

Passing tainted data to a *privileged sink* (notably `exec.command`)
without an explicit sanitizer raises a `NIKA-SEC`-family error (the
runtime taint layer · [security model](/concepts/security)):

```yaml theme={"system"}
- name: pull_user_input
  invoke:
    tool: nika:fetch
    args:
      url: https://api.example.com/comment

- name: run_it
  depends_on: [pull_user_input]
  exec:
    command: "${{ tasks.pull_user_input.output }}"   # NIKA-SEC taint violation
```

Fix — never interpolate tainted data into a command string; pass it
as data instead:

```yaml theme={"system"}
- name: run_it
  depends_on: [pull_user_input]
  exec:
    command: tee
    args: ["/tmp/comment.txt"]
    stdin: "${{ tasks.pull_user_input.output }}"   # data channel, not code channel
```

Taint is a property of **data**, not of a principal (T3:A design from
`InferResponse.trust_level`). Cross-link: ADR-014 (sealed kernel
traits), `nika-error::trust::TrustLevel`.

## The expression language — CEL, one small subset

What's inside `${{ }}` is the **v0.1 CEL subset**
([normative grammar](https://github.com/supernovae-st/nika-spec/blob/main/spec/03-dag.md)):

| Form         | Example                                                          |
| ------------ | ---------------------------------------------------------------- |
| Field access | `vars.topic` · `tasks.build.status`                              |
| Index access | `tasks.list.output[0]` · `obj['key-with-dash']`                  |
| Comparison   | `==` `!=` `<` `<=` `>` `>=`                                      |
| Boolean      | `&&` `\|\|` `!`                                                  |
| Membership   | `status in ['success', 'skipped']`                               |
| Size         | `size(items) > 0` — the one v0.1 function, the empty-check idiom |
| Literals     | `true` · `42` · `3.14` · `'str'` · `null`                        |
| Grouping     | `( … )`                                                          |

There are **no template filters** (`\| upper`, `\| truncate`, …). Anything
that transforms data is a **jq expression** — in an `output:` binding or
through the `nika:jq` builtin. Two layers, one job each:

```yaml theme={"system"}
# CEL decides · jq transforms
- id: digest
  when: ${{ size(tasks.fetch.output) > 0 }}      # CEL · condition
  invoke:
    tool: "nika:jq"
    args:
      input: "${{ tasks.fetch.output }}"          # CEL · reference
      expression: ".items | map(.title) | join(\"\\n\")"   # jq · transform
```

CEL macros and string functions are reserved for later minors — growth
is additive, never breaking.

## Multi-line

YAML block scalars compose cleanly with bindings:

```yaml theme={"system"}
prompt: |
  You are reviewing the following pull request.

  Title: ${{ tasks.fetch_pr.output.title }}
  Body:  ${{ tasks.fetch_pr.output.body }}

  Provide three concrete improvements.
```

The parser preserves indentation and line breaks — the prompt reaches
the provider exactly as you typed it, minus the leading YAML block
indentation.

## Null handling

Defaults live in declarations, not in expressions. A bare `vars:` value
IS the default; a typed var declares whether it's required:

```yaml theme={"system"}
vars:
  tone: "neutral"          # default — override from the CLI
  topic:
    type: string
    required: true         # validated before anything runs
```

If `vars.optional_field` is absent and has no `default:`, the
workflow fails to load — a miette diagnostic points at the declaration
site and the usage site.

## What bindings are **not**

* ❌ Not a scripting language. No loops, no conditionals inside `${{ }}`.
* ❌ Not arbitrary JavaScript / Python eval. No dynamic code.
* ❌ Not Jinja2 / Liquid in full — `for` and `if` blocks are **not**
  supported inside expressions.
* ✅ Scope lookup + CEL evaluation + type check + taint propagation.

Workflow-level control flow (`for_each:`, `condition:`,
`depends_on:`) lives on the **task envelope**, not inside bindings.
This is deliberate: the DAG is a static artifact, inspectable by the
analyzer and renderable in the Olympus visualizer; bindings are local
decorations.

## Related ADRs

* **ADR-010** — miette as the L4 diagnostic presentation layer. Every
  binding error points at the exact byte range in the source file.
* **ADR-014** — sealed kernel traits. The resolver runs at L3 and never
  crosses into arbitrary user-provided evaluator code.
* **ADR-021** — YAML envelope convention. `${{ }}` bindings appear
  inside `spec:` (workflow) or inside reusable skill / agent documents,
  and resolve against the same scope shape.

## Read next

<CardGroup cols={2}>
  <Card title="Events" icon="signal" href="/concepts/events">
    What gets emitted, when.
  </Card>

  <Card title="Workflows" icon="diagram-project" href="/concepts/workflows">
    The envelope that holds the `tasks:` bindings operate on.
  </Card>

  <Card title="Verbs" icon="play" href="/concepts/verbs">
    Each verb's taint classification and sanitizer expectations.
  </Card>

  <Card title="YAML reference" icon="file-code" href="/reference/yaml-syntax">
    Full binding grammar and filter catalog.
  </Card>
</CardGroup>
