${{ … }}. It resolves
at runtime against a typed scope (the four value authorities, task-scope
bindings, task records) and substitutes into the field. What’s inside
${{ }} is CEL (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.
Two expression layers, locked by the spec: CEL
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.Shape
Templatable::Template:
${{ }} is ignored. Nested ${{ }} is rejected at
parse time.
The six namespaces
Every binding resolves against one of six named namespaces. The resolver refuses unknown top-level names at validation time, so${{ tsaks.foo }}
is caught before the run starts — and a value-namespace read outside the
closed family (${{ params.X }} and friends) refuses NIKA-VALUES-003.
The first four are the value authorities — the closed family every
workflow value is declared under, one role each, no alias.
with and
tasks are the runtime namespaces: legal in ${{ }}, never value
authorities.
${{ vars.X }} and ${{ env.X }} are dead reads (NIKA-VALUES-001 /
NIKA-VALUES-002). Each old use classifies into the authority its role
commands — a typed parameter into inputs:, a fixed value into const:,
non-sensitive runtime configuration into config:, a governed store
reference into secrets:. Classification, never a bulk rename: the
question is what the value plays, not what it was spelled.
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.
The reference boundary: where tasks.* may appear
The tasks namespace is boundary-only. A ${{ tasks.X.* }} reference
is legal in exactly five places: with: values (the binding IS the edge) ·
after: keys (the entry names the producer) · on_error.recover: (a
fallback reads a settled record) · on_finally: blocks (the parent
task only) · workflow outputs: (the run’s exports read the settled
world).
Everywhere else — verb fields (prompt: · command: · args: · …),
when:, for_each: — a tasks.* reference is refused at parse time
(NIKA-VAR-021) with a machine-applicable fix: hoist it into with:
and read the binding.
with · the
four value authorities inputs · config · const · secrets · the loop
locals): every cross-task dependency is visible at the boundary, named, and
typed by its edge role.
Typed resolution
Nika knows the expected type of every binding target. Ifinputs.count
is declared integer and you drop it into a field typed as a list,
validation fails before the run starts.
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:fetchoutput → tainted (HTTP body, third-party controlled).execstdout → tainted (subshell could have been influenced).infer/agentoutput → tainted (prompt injection vector).invokeoutput → tainted (MCP servers are third-party).inputs.*→ tainted (caller-supplied at launch).config.*→ tainted (deployment-supplied, from outside the file).- literals,
const.*,secrets.*→ trusted (the file owns them).
exec.command)
without an explicit sanitizer raises a NIKA-SEC-family error (the
runtime taint layer · security model):
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):
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:
Multi-line
YAML block scalars compose cleanly with bindings (the pull-request fields crossed the boundary inwith:; the prompt reads the bindings):
Null handling
Defaults live in declarations, not in expressions. Which declaration depends on who owns the value:inputs.optional_field is required and the caller supplies nothing,
the run is refused before it starts: 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:
forandifblocks are not supported inside expressions. - ✅ Scope lookup + CEL evaluation + type check + taint propagation.
for_each:, when:, the with: /
after: edges) lives on the task envelope, not inside bindings.
This is deliberate: the DAG is a static artifact, inspectable by the
analyzer and renderable by any graph surface — nika inspect <file> --format mermaid|dot|json; 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 insidespec:(workflow) or inside reusable skill / agent documents, and resolve against the same scope shape.
Read next
Events
What gets emitted, when.
Workflows
The envelope that holds the
tasks: bindings operate on.Verbs
Each verb’s taint classification and sanitizer expectations.
YAML reference
Full binding grammar and filter catalog.