the spec · 04
Variables
Nika uses **one substitution syntax everywhere** · `${{ ...
04 · Variables #
Nika uses one substitution syntax everywhere · ${{ ... }} ·
matching GitHub Actions. Inside strings · inside object values ·
inside array elements · inside conditions. One syntax · one mental
model.The one syntax · `${{ ... }}` #
# Inside a stringprompt: "Summarize · ${{ inputs.topic }}"# Inside a value positionwith: data: ${{ tasks.research.output }}# Inside a condition (local namespaces · [03 §when](./03-dag.md))when: ${{ with.coverage > 80 }}# Inside an arraytools: - ${{ const.tool_a }} - ${{ const.tool_b }}If you have used GitHub Actions, this is the same. If you have not, the rule is simple · anywhere you want a value resolved at task dispatch time, wrap it in `${{ }}`.
What's inside `${{ }}` is [CEL](https://cel.dev) (Common Expression
Language · the validated, non-Turing-complete standard used by Kubernetes,
Envoy, and gRPC). A bare reference like ${{ inputs.topic }} is a CEL identifier
path that evaluates to its value; a condition like ${{ with.coverage > 80 }}
is a CEL boolean. One expression language, everywhere: Nika does not invent a
DSL. See 03-dag.md for the v0.1 CEL subset.
The 5 namespaces #
${{ inputs.X }} typed workflow input (declared in envelope `inputs:` · supplied by the caller at launch, or by its own `default:`)${{ const.X }} named constant (declared in envelope `const:` · a fixed value baked into the workflow)${{ secrets.X }} masked secret reference (vault-backed · never in logs)${{ with.X }} task-level scope (declared per-task `with:` block · the bindings ARE the data edges)${{ tasks.X.output }} task record reference (or .status · .error · .duration_ms · the CLOSED projection set · BOUNDARY surfaces only — see below)Five namespaces. That's it.
The first three — inputs · const · secrets — are the value
authorities, the closed family every workflow value is declared under
(LAW-SURFACE-0201 · one authority, one spelling, no alias). with and
tasks are the runtime namespaces (task-scope bindings and settled task
records) — legal in ${{ }}, never value authorities.
⚰️ `config` was the fourth, and it is dead (2026-08-12). Measured: zero usage in real work; itsdefault:was its only possible source (nothing outside the file could supply it —--varreachesinputs:only); and under the taint lattice${{ config.p }}and${{ inputs.p }}produced the sameNIKA-AUTH-008by the same taint path — indiscernible. The cell it occupied (nobody supplies it × treated as untrusted) was a constant the checker was being asked to treat as hostile. A deployment knob is now aninputs:entry withrequired: falseand adefault:. A${{ config.X }}read refusesNIKA-VALUES-003, which teaches the three-authority family.
Dead forms (refused with a classification teaching · the E-split). The pre-flipvars:andenv:envelope fields are dead: avars:block refuses withNIKA-VALUES-001, anenv:block withNIKA-VALUES-002, and a${{ vars.X }}/${{ env.X }}read with the same codes. Each old use classifies into the authority its role commands — a typed parameter is aninputs:declaration, a fixed value aconst:entry, non-sensitive runtime configuration aninputs:declaration withrequired: falseand adefault:, a governed store reference asecrets:entry (classify-not-rename · never a bulk rename). A value-namespace read outside the three-authority family (${{ params.X }}and friends) refuses withNIKA-VALUES-003.
The reference boundary · where `tasks.*` may appear #
Since W2 « the flow », the tasks namespace is boundary-only. A
${{ tasks.X.* }} reference is legal in exactly five places ·
- surface · why it is a boundary · graph effect
- `with:` values · the binding imports the data — **the binding IS the edge** · one typed edge per reference ([03 §with](./03-dag.md))
- `with:` values, plural · `${{ group.<name> }}` · the fan-in fold imports a whole declared set — **this is the ONLY door it has**, tighter than `tasks.*` · one `fan-in` edge per declared member ([03 §group](./03-dag.md#group--optional--fan-in-membership--the-plural-of-a-data-edge))
- `after:` keys · the entry names the producer · one control edge per entry
- `on_error.recover:` · a fallback reads a settled record · a recovery edge (parking · `NIKA-DAG-004`)
- an `unwind` task body · cleanup reads the **producer it unwinds** — the ONLY legal target there (a sibling may still be running · the read would race) · none (the E_f attachment never enters `G_p`)
- workflow `outputs:` · the run's exports read the settled world · none (everything is terminal at read time)
Everywhere else — verb fields (`prompt:` · `command:` · `args:` · …),
`when:`, `for_each:` — a `tasks.*` reference is refused at parse time
(NIKA-VAR-021 · validation_error) with a machine-applicable fix:
hoist it into `with:` and read the binding ·
# ❌ NIKA-VAR-021 — the body reads the global namespacesummarize: infer: prompt: "Summarize · ${{ tasks.fetch.output }}"# ✅ the fix `nika check --fix` appliessummarize: with: article: ${{ tasks.fetch.output }} infer: prompt: "Summarize · ${{ with.article }}"The task body is a pure function of its declared inputs (with · the
three value authorities inputs · const · secrets · the loop
locals): every cross-task dependency is
visible at the boundary, named, and typed by its edge role. Nothing else
reads another task.
Bare `tasks.X` is not a value (normative · D2 · #75). The task result is a record; its observable projections are the CLOSED set.output(the value) ·.status(terminal enum) ·.error·.duration_ms— additions are a spec minor. An UNPROJECTED${{ tasks.X }}in any value position is avalidation_error(NIKA-VAR· « the envelope is not a value — pick.output»): before 0.103 it silently denoted the whole envelope, the source of the golden-drift trap engine#524 had to teach around. No aliases — one meaning per spelling.
`group.<name>` is not a namespace either. It is the plural reader of `tasks` — same runtime family, one door instead of five (awith:value · everywhere else isNIKA-VAR-021). It denotes a set of task records, so it belongs besidetasksrather than beside the three value authorities, and the count below is unchanged. See 03 §group for the record shape and thefan-inpass-set.
Loop-locals are not a namespace. Inside afor_eachtask body, two extra identifiers are in scope:${{ item }}(the current element) and${{ index }}(its 0-based position). They are loop-scoped locals, alive only within that task's body, not global namespaces. So the count stays « 5 namespaces » + the for-each locals where a loop is present.
Shadowing is structurally impossible. Every namespace is reached
through its explicit prefix (inputs. · const. · secrets. ·
with. · tasks.): an inputs.item and the loop-local item never collide
(one is inputs.item · the other is bare item), a task may be named
item (tasks.item.output is unambiguous), and with.X never hides an
inputs.X. The only bare identifiers in the language are the two
loop-locals, and for_each does not nest within a task, so there is no
scope chain · no resolution-order subtleties · nothing to shadow. This
is by construction, not by rule.
`${{ inputs.X }}` · typed workflow inputs #
Declared once in the envelope · immutable across the workflow run · every
entry is a typed declaration whose type: speaks the full TypeExpr of
09-types.md (the flat 6-enum is dead · bool is the one
boolean spelling) — validation + schema generation for callable workflows
(see 01-envelope.md) ·
nika: research-pipelineinputs: topic: type: string required: true # the caller MUST supply it description: "Subject to research" paragraphs: type: integer default: 5 # MUST conform to type: (checked · NIKA-DEFAULT-001)tasks: research: infer: prompt: "Research · ${{ inputs.topic }} · in ${{ inputs.paragraphs }} paragraphs"A required: true input has no default: the caller must supply it at
launch. To supply or override an input ·
nika run flow.nika.yaml --var topic="CEL subsets in 2026" (repeatable ·
engine CLI concern). A --var value overrides the declared default and
satisfies a required: true input · an undeclared key is refused before the
run. See 01-envelope.md
for the launch contract.
Every bound input carries its ORIGIN (normative) · a value is not only what it is, it is where it came from, and the run records which ·
- Origin · The channel that supplied the value
- `cli-operator` · `--var name=value`, typed by a human at a terminal
- `ci-context` · `--var name=value` arriving through a pipeline — the caller is not a human
- `env` · `--var name=@env:VAR` · the **declared** environment channel, read through its explicit spelling
- `file` · the workflow's own declared `default:` filled the input
The origin is journaled at boot — it rides the prologue manifest
(17 §the prologue) — and from there rises into the run's
evidence pack, so a proof answers who supplied this and not merely
what was supplied. (Measured 2026-08-13 · `nika 0.108.0`: `--var
qui=…` at a terminal journals `inputs: {"qui":"cli-operator"}` and the
pack reads `inputs.origins` with `source: "journal"`. The run RECEIPT —
`receipt_format: 1`, whose folded shape is [15 §the one receipt](./15-proof.md)
— does not carry origins; the claim belongs to the journal and the pack
it feeds.) The distinction that carries the
weight is env: an environment value enters through a spelling the
author wrote, never through an ambient guess — the same law the
`permits.env` passthrough enforces for child
processes, applied to the launch surface. A pipeline and a person are
likewise not the same caller, and a receipt that conflated them would
lose the fact an auditor is looking for.
`${{ const.X }}` · named constants #
const: retries: 3 # bare literal output_dir: "./output" window: # typed constant · value MUST conform to type: type: integer value: 30tasks: note: infer: prompt: "Keep the ${{ const.retries }} retries in ${{ const.output_dir }}"const holds the fixed values baked into the workflow: either a bare
literal (any YAML value) or a typed constant { type, value } whose
value: MUST conform to its type: (checked · NIKA-DEFAULT-001). An
object carrying BOTH type and value keys is the typed form; an object
missing either key is a bare literal object constant (the discriminator ·
so a literal like config: { type: "custom" } is never misread). Constants
are immutable across the run and are never caller-supplied: a value the
caller must be able to override is an inputs: declaration, not a constant.
`${{ with.X }}` · task-level scope #
Declared per-task · resolves at task dispatch time · the task's import surface ·
`with:` is the data boundary, not sugar. A${{ tasks.X.* }}reference lives ONLY inwith:(and the other boundary surfaces above): each such binding creates one typed edge, and the body consumes the binding by its local name.with:is where a task's inputs are visible at a glance — and where the graph gets its data edges (03 §with).
summarize: with: content: ${{ tasks.research.output }} # value edge · research → summarize style: "concise" # literal · no edge infer: prompt: "Summarize in ${{ with.style }} style · ${{ with.content }}"A binding whose evaluation errors settles the task failure — on_error:
is NOT consulted (the boundary feeds the verb; the armor covers the verb ·
03 §gate algebra).
`${{ tasks.X.output }}` · task record reference #
Reference an upstream task's output (or status · error · duration_ms) — at the boundary ·
deploy: after: { test: success } # strict gate · no data from test with: coverage: ${{ tasks.test.output.coverage }} # value edge · read the number artifact: ${{ tasks.build.output.artifact_path }} # value edge · build → deploy when: ${{ with.coverage > 80 }} # local business condition exec: command: ["./deploy.sh", "${{ with.artifact }}"]tasks.X is the task result record: a CEL object, NOT the bare output
value. Always write .output for the value · the record's fields are ·
${{ tasks.X.output }} the verb's output (string · object · or bytes · per verb · see 02)${{ tasks.X.status }} success | failure | skipped | cancelled (closed enum · v1)${{ tasks.X.error }} typed error record · present iff status == failure (see 05)${{ tasks.X.started_at }} RFC 3339 start timestamp${{ tasks.X.ended_at }} RFC 3339 end timestamp${{ tasks.X.duration_ms }} execution time · integer milliseconds${{ tasks.X.<name> }} a named extract: binding (jq · see below)Defined-null reads (normative · the branch-join unlock) #
Reading a field of a task that reached a terminal state never errors: absent values are `null`, deterministically ·
tasks.X.output of a skipped task → null (incl. empty-collection for_each)tasks.X.output of a cancelled task → nulltasks.X.error when status != failure → null (EXCEPT on_error.skip · error stays · see 05)tasks.X.<name> bindings of a skipped/cancelled task → nullnull is a CEL literal (with.x != null is in the v0.1 subset) and
a JSON value (jq's select(. != null) filters it). This makes the
diamond-join canonical · two exclusive when: branches + a join that
takes whichever ran ·
pick: with: # value edges pass on skipped · the skipped one is null prod: ${{ tasks.build_prod.output }} dev: ${{ tasks.build_dev.output }} invoke: tool: nika:jq args: input: [ "${{ with.prod }}", "${{ with.dev }}" ] expression: "[ .[] | select(. != null) ] | first"One obvious way · no bare alias. ${{ tasks.X }} is the whole result
object · the output is ALWAYS ${{ tasks.X.output }}: there is no tasks.X
== output shortcut (it would make tasks.X both a scalar and a record · which
CEL cannot type). This matches every workflow engine · GitHub Actions
steps.X.outputs · Argo node context · Temporal result-vs-state · the task
result is a record, never a scalar masquerading as the namespace.
Static binding validation against a declared `schema:` (normative) #
When the producing task declares a structured-output `schema:` (an
infer: or agent: task · 02), the shape of
tasks.X.output is KNOWN at parse time, so a reference path INTO that
output (${{ tasks.X.output.entities }}) is statically checkable. The
authoring contract ·
- An engine SHOULD validate
tasks.X.output.<path>references against the declared schema at parse time (the misspelled-key class is caught before any model is called). - An engine MUST reject invalid paths ·
NIKA-VAR-003·variable_error. A path step is invalid when ·1. a **member step** lands on a schema level that **declares `properties:`** and does NOT list the key — declaring properties CLOSES the level for binding (operator lock 2026-07-30 · strict by default, one voice with the `returns:` walk below). The level opens back up ONLY explicitly: `additionalProperties: true` (or a schema object for extras) makes undeclared siblings legal again. (`additionalProperties: false` on a level with no `properties:` also closes it — the declared empty object.) 2. a **member step** lands on a level whose `type` excludes `object`; 3. an **index step** lands on a level whose `type` excludes `array`. - The static walk covers the v0.1 subset `properties` · `items` ·
`type` · `additionalProperties` only. Any other construct at a level
(
$ref·oneOf/anyOf/allOf·patternProperties· a missingtype· …) makes that level open: the walk stops and the engine MUST NOT reject anything beneath it. - A level that declares no `properties:` at all (a bare
type: object) is open: nothing is declared, so nothing can be contradicted — paths beneath it are never statically rejected. - Tasks with NO declared schema (every
exec:/invoke:task · aninfer:withoutschema:) are dynamic: paths into their output are never statically rejected (a wrong path surfaces at run time asNIKA-VAR-001).
The balance is deliberate (2026-07-30 · supersedes the earlier
"only additionalProperties: false closes" reading): where the author
declared NOTHING, the check stays sound — open levels and dynamic
producers are never refused. Where the author DECLARED the shape, the
declaration is a contract — reading an undeclared sibling of declared
keys is the misspelled-key class and refuses loudly, with a one-line
fix either way (declare the key, or open the level with
additionalProperties: true). Note the runtime nuance this owns: JSON
Schema itself treats an absent additionalProperties as permissive at
run time, and that runtime semantic is unchanged — the static BINDING
law is stricter than the runtime validator on purpose (catching typos
before any model is called is the point of declaring a schema).
`returns:` sharpens the walk (normative · [09-types.md](./09-types.md)).
When the producer declares a returns: type instead of a raw schema:,
the walk runs on the type with full precision: the v1 type grammar
has no open construct, so every level is walkable — a member outside a
closed object is NIKA-VAR-003 (the same code · one voice), and only
additional: true or an Unknown producer opens a level. The raw
schema: hatch keeps the weaker subset walk above.
`${{ secrets.X }}` · masked secret reference #
secrets: api_key: source: vault key: prod/anthropic/api-key egress: [{ to: "nika:fetch", host: "api.anthropic.com" }]headers: Authorization: "Bearer ${{ secrets.api_key }}"A secret is always a reference to a store (the local nika-vault by
default), declared in the envelope secrets: block, never an inline
literal. The engine masks every resolved secrets.X value in logs,
traces, and journal events (it renders as ••••••). This inputs / secrets
split is the modern secure-workflow default: non-sensitive values in inputs,
masked references in secrets.
The masking boundary (normative). Masking covers the engine's OWN observability surface: logs · traces · journal · thenika:inspectoutput. It does NOT follow a secret value that the AUTHOR routes into a subprocess or tool that then re-emits it: asecrets.Xput intoexec.env(or anika:fetchheader) which the command echoes to stdout is captured verbatim intotasks.X.outputand flows downstream like any other data: the engine cannot know that captured string IS the secret. The contract: the engine masks what IT prints; the author owns what they pipe a secret INTO. Thenika checkpre-flight flags every unsanctionedsecrets.Xflow into an effect (exec·invoke— and the provider-egress sinksinfer/agent, where a secret in a prompt leaves the run to a third party), so the leak is caught statically before the run, not after. A legitimate flow is sanctioned where the secret is declared — theegress:list on the secret (the example above · full grammar in 01-envelope §egress): declassification is the owner's act, co-located with the data, never a property of the sink.
Extraction bindings · `extract:` #
Use extract: to define named bindings extracted from a task's raw response via a jq expression (the one data language). These bindings appear in the task's typed output and are referenced as ${{ tasks.X.<name> }} ·
api_call: invoke: tool: "nika:fetch" args: url: "https://api.example.com/v1/users" # returns JSON · extract: jq extracts extract: user_count: ".data.users | length" first_user: ".data.users[0]" user_emails: "[.data.users[].email]" # [...] collects the stream into an arrayDownstream ·
notify: with: user_count: ${{ tasks.api_call.user_count }} # value edges · the bindings are the edges emails: ${{ tasks.api_call.user_emails }} infer: prompt: | We have ${{ with.user_count }} users. Emails · ${{ with.emails }}Raw output vs named bindings · dual-accessible #
When a task has an extract: block defining named bindings · downstream
access is dual-accessible ·
api_call: invoke: tool: nika:fetch args: { url: "..." } extract: body: .body http_status: .status # NOT `status:` — that name is reserved (the task's own .status)Downstream (each form imported through a consumer's with: · the
reference boundary) ·
# Raw output (whole structure · pre-binding extraction)${{ tasks.api_call.output }} # full raw JSON · including all fields the verb returned# Named bindings (defined in extract: block above) · value-role fields${{ tasks.api_call.body }} # jq .body · the response body${{ tasks.api_call.http_status }} # jq .status · the HTTP status field${{ tasks.api_call.status }} # RESERVED · the task's own status (success|failure|…) · NOT a binding · terminal-observation roleRules ·
tasks.X.outputALWAYS returns the raw output (unmodified value returned by the verb · before any binding extraction)tasks.X.<name>for any<name>declared inextract:block returns the extracted jq result<name>collisions with reserved wordsoutput·status·error·started_at·ended_at·duration_msare forbidden at parse time (NIKA-PARSE·validation_error: the rule is structural · schema-checkable viapropertyNames·NIKA-VAR-NNNstays reserved for reference resolution and binding evaluation errors)- If no
extract:block · onlytasks.X.outputis accessible (named bindings are an opt-in convenience)
Path grammar · jq (the one data language) #
Output binding uses a jq expression: the SAME jq as the nika:jq builtin.
Nika has ONE data extraction-and-transform language (jq), not two: the former
RFC 9535 JSONPath was dropped because jq is a superset (any JSONPath query + more)
and a workflow language must not force the author (or an LLM) to choose between
two extraction syntaxes (SOTA « one obvious way · ≤2 expression layers »). The two
expression layers are CEL (inside ${{ }} · conditions + value substitution)
and jq (inside extract: bindings + nika:jq · extraction + transform).
Reference engines use jaq (Rust jq) so paths behave identically everywhere.
v0.1 jq conformance subset (every engine MUST support) ·
.<name> object member.<name>[<index>] array index.<name>[] iterate all elements (jq `.[]` · was JSONPath `[*]`).a.b.c deep path. | map(...) | select(...) jq pipeline for reshaping / filteringThe subset above is the portability floor every engine MUST support. Full jq
(the jaq Rust impl · « full stdlib ») MAY be used minus what the next
section refuses (D-2026-08-11-N26 · ambient reads and the wall clock): it is
the single data extraction-and-transform language (extract: bindings + the
nika:jq builtin). ⚠️ Full-minus-a-prose-list is a floor, not a ceiling. The
ceiling is a named, versioned grammar — jq-subset/0.1, the sibling of
`cel-subset/0.1` — which does not exist yet
(D-2026-08-11-N31 creates it). Until it does, this sentence is the boundary,
and a boundary written in prose is weaker than one written in EBNF.
Formal grammar · jq v0.1 subset (normative · grammar version `jq-subset/0.1`) #
Prose + examples are not re-implementable; this EBNF is. A conformant engine
parses exactly this grammar in an extract: binding and in nika:jq (it is a
strict subset of jq: any full jq parser accepts every expression below) ·
program = pipeline ;pipeline = alt , { "|" , alt } ;alt = cmp , { "//" , cmp } ; (* alternative · the // operator *)cmp = sum , [ cmpop , sum ] ; (* at most ONE comparison · non-associative *)cmpop = "==" | "!=" | "<" | "<=" | ">" | ">=" ;sum = term , { ( "+" | "-" ) , term } ;term = unary , { ( "*" | "/" ) , unary } ;unary = [ "-" ] , postfix ;postfix = primary , { suffix } ;suffix = "." , IDENT | "[" , [ pipeline ] , "]" (* index · [] iterates · NO slice in 0.1 *) | "?" ; (* optional · swallows the type error *)reduce = "reduce" , postfix , "as" , "$" , IDENT , "(" , pipeline , ";" , pipeline , ")" ; (* bounded fold · the stream IS the bound *)format = "@" , ( "base64" | "base64d" | "text" | "json" | "csv" | "tsv" | "uri" ) ;primary = "." (* identity *) | "." , IDENT | call | literal | object | array | reduce | format | "$" , IDENT | "(" , pipeline , ")" ;call = FUNC , [ "(" , pipeline , { ";" , pipeline } , ")" ] ;object = "{" , [ pair , { "," , pair } ] , "}" ;pair = ( IDENT | STRING ) , [ ":" , pipeline ] ;array = "[" , [ pipeline ] , "]" ;literal = NUMBER | STRING | "true" | "false" | "null" ;FUNC is the closed set ·
length · keys · values · has · type · not · empty · errormap · map_values · select · add · join · split · flatten · rangesort · sort_by · group_by · unique · unique_by · reversefirst · last · min · max · min_by · max_by · any · allto_entries · from_entries · with_entries · del · pathstostring · tonumber · tojson · fromjson · getpath · setpath · leaf_pathsascii_downcase · ascii_upcase · ltrimstr · rtrimstr · startswith · endswithfloor · ceil · fabsThe set is deliberately small, and small is the safe direction: a minor
version may only ADD (same law as cel-subset/0.1), so a name omitted here
costs one amendment while a name admitted early can never be withdrawn.
⚠️ The first cut of this grammar refused four of THIS SPEC'S OWN canonical recipes (2026-08-11 · corrected the same day). It had been verified against the 41 programs the corpus uses and never against the recipes stdlib §what jq subsumes recommends —reduce, the@base64formats, and thegetpath/setpath/leaf_pathsfamily, which the cut-builtin table publishes as the supported way to do the work those builtins used to do. A corpus of USE and a corpus of RECOMMENDATION are two different subjects, and a grammar that refuses its own documentation would have made the spec self-contradictory the day it shipped.reduceis admitted with its stream as the bound — it folds over a finite stream and cannot recurse, so it terminates by construction and does not reopen what the table below refuses. Both corpora now pass: 40/40 programs, 9/9 recipes. Derived from the corpus, not invented: over 41 programs in the shipped and internal corpora, the functions actually used aremap · sort · last · fromjson · join · length, plus paths, the pipe, object/array construction and arithmetic. Everything above is that set plus its obvious companions.
Current in v0.115. The engine embeds a full jaq and the bounded syntax subset below is not yet enforced. A workflow whose only task is ·…passescheckwith rc=0. So dosub·gsubandscan: the regex ceiling remains owed byjq-subset/0.1.The expression effect boundary is independently enforced.env·debug·stderr·haltandhalt_errorare withheld by one typed policy at all three jq seams.nowis intentionally accepted, but its upstream host native is removed and the spelling is rebound to the exactWorkflowStartedtimestamp.localtimeandstrflocaltimeuse the same value with a deterministic UTC projection. Pure conversions such asgmtime·mktimeandstrftimeremain ordinary available functions: they transform their argument and observe neither host clock nor timezone.Saying so here is not a footnote: a spec that asserts a refusal the engine does not make teaches a boundary that is not there. Two rows of this table are security-adjacent (effectful upstream symbols and the regex family's data-dependent blowup), so a reader must be able to tell the enforced effect policy from the still-owed bounded grammar.The table stands as the specification of the gate, unchanged. What changes is its status: it describes whatjq-subset/0.1MUST refuse when the gate ships, not what v0.115 refuses today. Until then the honest sentence is « the grammar is written, the gate is owed ».
What the grammar refuses BY CONSTRUCTION, and why ·
- Absent · Why
- `recurse` · `..` · `while` · `until` · `repeat` · non-termination · a binding must be decidable at check
- `def` · user-defined recursion re-introduces the same
- `env` · `$ENV` · `input` · `inputs` · `input_filename` · ambient reads · the law above
- `debug` · `stderr` · writes outside the value
- `halt` · `halt_error` · process control · a data expression does not end a run
- `test` · `match` · `sub` · `gsub` · `splits` · `scan` · regex · data-dependent blowup · deferred to a minor that pairs them with a bounded engine, not banned on principle
- slices `.[a:b]` · omitted for 0.1 only · a minor may add
Clock names are deliberately absent from that refusal table. now ·
localtime · strflocaltime are accepted and rebound to the run-start
value/UTC projection; gmtime · mktime · strftime are pure conversions
over supplied data. None reads the host clock or timezone.
⚠️ A grammar bounds the SHAPE, never the MAGNITUDE. [range(1e9)]
parses clean and still materialises a billion elements. That is the runtime
step budget's job (D-2026-08-11-N32), and the two ceilings are not
interchangeable: the static one buys termination, the runtime one buys
size. Neither alone is enough.
An expression sees only its input (normative · D-2026-08-11-N26) #
The world of a data expression is the value it is given, and nothing else. Not the process, not an ambient clock, not the disk, not the environment. The immutable run-start value is an explicit engine input. The control expressions of 03 §CEL obey the same law: their world is the bindings they are given.
This is what makes the surrounding closure real. A data expression cannot name
a host, a path, a program or a tool — those are literal in permits:. It
cannot decide whether a task runs — that is CEL, and CEL is non-Turing. It
carries no effect of its own. What remains is a transformation from a value
to a value, and that is the whole of its power. The language is not closed
because it forbids expression; it is closed because an expression cannot
reach anything.
Two consequences, both normative:
- Ambient reads are refused. A program that reads the process environment
(
env,$ENV), the input filename, or the standard input reaches outside its value. ⚠️ Measured 2026-08-11 on a shipped engine: `env.NAME` returned the ambient value under an absent `permits:` block and again under an explicit empty `permits.env`, while the static check reported the body as pure compute from which nothing escapes. The reach is bounded — such a program is a literal in the reviewed file and cannot be interpolated from a model's output (NIKA-VAR-005) — but a computation that reads the ambient environment is not pure, and the certificate said it was. ✅ ENFORCED since 2026-08-15. Re-measured that day on the three authority shapes:env.NAMEis refused atnika checkwithNIKA-VAR-005under an absentpermits:block, under an explicit emptypermits.env, AND under a grantedpermits.env— a grant does not turn it back on, becausepermits.envpasses an environment to a process the workflow SPAWNS and an in-process expression is not that process. The mechanism is the subtraction itself:envis withheld from the function set every jq seam hands the compiler, so the refusal belongs to the compiler rather than to a scanner, and the three seams (thenika:jqbuiltin ·extract:bindings · the static compile-check) read ONE list.$ENVwas already refused by jaq's own compiler and still is. ✅ The remaining effect classes are enforced in v0.115. Host diagnostic and process-control symbols are withheld by that same policy; clock forms follow the rebinding rule in the next bullet rather than subtraction. - The clock is an input, never an ambient (D-2026-08-11-N27).
nowand its relatives resolve to the exact run's start instant stamped onWorkflowStarted, so that a replay yields the same value forever. Reading the wall clock would make the same file pass on a fast machine and fail on a slow one — the property a replay exists to deny. A long task therefore sees the START instant, not its own; that is the price of determinism and it is the correct one. The runtime mints this instant once at the execution boundary; composition does not sample it independently.
Why the layer is not simply removed. The need to reshape a value does not
disappear with it: an author denied this layer reaches for exec: and a real
subprocess, which is strictly worse. The bounded expression layer is what
prevents the escape to the shell. Subtracting without replacing moves the
hole; it does not close it.
Binding rules (single-value · pure-jq) #
- A binding resolves to exactly ONE value. A jq program emits a stream:
.users[]yields N separate values, NOT an array. A binding whose program emits zero or multiple values is an evaluation-time error (NIKA-VAR-002· the emission count is data-dependent · undecidable at parse). The reference linter additionally WARNS at check time (one-obvious-way/009) on the statically-visible smell (a binding jq ending in a trailing iterator[]with no collecting[ … ]wrapper). A jq program that itself errors at runtime isNIKA-VAR-004. Collect a stream with[ … ]([.users[].email]→ array) · take one with an index (.users[0]) orfirst(…). One obvious way · no silent first-match, no implicit array-wrap.
`extract:` or `nika:jq` · the choice, decided (normative for linters · 2026-08-11) #
- Rule · Instead of · Write
- `one-obvious-way/013` · `invoke: {tool: nika:jq}` reshaping **one** producer's output · an `extract:` binding on that producer
They are the same jq over the same value, and until now nothing said which to reach for — the only genuine unguarded overlap left in the language. The rule follows from what each one IS, not from taste ·
- An
extract:binding is boundary work: it costs no task, no wave and no node in the graph, it runs per iteration inside afor_each, and it carries a normative cardinality law (exactly one value ·NIKA-VAR-002). nika:jqis a task: it has an id, a place in the DAG, its own gate and its own timeout — and it can read several producers throughwith:, which a binding structurally cannot, since a binding sees only the output it hangs from.
⇒ One producer, reshaped: a binding. Two or more, joined: the builtin. The builtin stays because the many-input case is real and inexpressible otherwise; the binding wins the single-input case because a whole task node to rename a field is a node the graph did not need.
- An `extract:` jq expression is pure jq over the task's raw output: it does
NOT contain
${{ }}(the two expression layers never nest in one string · CEL reads the namespaces · jq reads the task output). To parametrize an extraction by a workflow value, shape the verb's input with${{ }}· the jq then runs over the result. (Exposing the read namespaces as jq variables,.items[] | select(.id == $inputs.target), is a v0.2 candidate · jq-native · additive · NOT in the v0.1 subset.)
Resolution order #
When a task is admitted · the engine resolves ${{ ... }} references in this order ·
- Boundary first · the
with:bindings materialize (theirtasks.X.fieldreferences read the settled records — this is where the data edges deliver) - `when:` evaluates over the local namespaces (
inputs·const·with· loop locals) - Body · verb-field expressions resolve (
inputs.X·const.X·secrets.X·with.X· loop locals — nevertasks.*) - Single-pass · a substitution result is NOT re-evaluated (no nested substitution)
If a reference is unresolved · the engine raises a NIKA-VAR-001 (undefined
variable) error — at the boundary (steps 1-2) it settles the task failure
with on_error: NOT consulted; in the body (step 3) it is task-stage work,
recoverable by on_error: (03 §task states).
Value rendering · object → string #
When a ${{ }} reference resolves to an object or array and is substituted
into a string position (e.g. inside a prompt: or command:), it renders
as compact JSON · deterministic (object keys sorted · no insignificant
whitespace). Scalars render as their natural string (numbers · booleans ·
null → null). There are no template pipe-filters (${{ x | json }} is
NOT a thing · per the §locked substitution surface). To control the rendering,
extract a string with jq in extract: (@json for JSON text · tostring /
@text for scalar coercion) and reference that binding. One obvious way ·
implicit compact-JSON by default · explicit jq when you need a specific shape.
A bytes output (tool-determined · e.g. MCP image content · a binary
nika:read) is opaque · it flows tool→tool by reference (a with:
binding of ${{ tasks.fetch_img.output }} → another tool's content: arg ·
or a file path for infer.vision). Bytes cannot be jq-extracted (jq is JSON-only)
nor substituted into a string position: that is an error (NIKA-VAR-007) ·
the engine never silently UTF-8-coerces a blob (it would corrupt the data).
For nika:fetch and exec (no binary value channel · every fetch mode is
text/JSON · raw included), binary is file-mediated · write to a path,
then read or reference the path. There is no output_format field · the
value carries its own type.
Escaping #
To embed a literal ${{ in a string · use \${{ (backslash escape). The engine MUST honor this.
infer: prompt: "The syntax \\${{ inputs.x }} is how you reference variables."(Note · YAML escaping of backslash · \\ in double-quoted strings · \ in single-quoted or block scalars.)
Backslash runs (normative) · the escape counts the CONTIGUOUS backslash
run immediately before ${{ · an odd run escapes the opener (the island
is literal text; the escaping backslash is consumed) · an even run
(including zero) leaves the island live. Within that run, each remaining
backslash PAIR renders as one literal backslash; backslashes anywhere else
are ordinary characters (there is no general backslash processing). So
\${{ x }} renders the literal ${{ x }} · \\${{ x }} renders one \
followed by the RESOLVED island · \\\${{ x }} renders one \ + the
literal ${{ x }}.
An unclosed `${{` (an unescaped opener with no closing }}) is rejected at
parse time · NIKA-VAR-008 · validation_error: the substitution surface belongs
to this section, even though the YAML itself parses fine.
Why one syntax everywhere #
An earlier draft proposed two syntaxes ($task_id in value positions · {{var}} inside strings). That was a confusion source · v0.1 unifies on a single ${{ }} syntax for everything.
Reasons ·
- One mental model · same syntax everywhere · low cognitive load (Rams principle 4 understandable)
- GitHub Actions familiarity · 30M+ developers already know
${{ ... }} - Composable in any position · strings · object values · array elements · conditions
- Unique enough · escape rarely needed
- Future-proof · GitHub Actions has extended this syntax for 8+ years without breaking change
Forward-compat #
The ${{ ... }} substitution surface and the 5 namespaces are locked at v1. Template pipe-filters (`${{ inputs.x | json }}` · `| upper`) are NOT a growth path (they would duplicate builtins + push CEL toward a string-DSL). Data transforms live in the nika:jq builtin; the ${{ }} surface grows only with CEL-native features: the conditional ?:, the has() presence macro, and the contains/startsWith/endsWith string tests ship in cel-subset/0.1 (03 §grammar); all/exists and matches() regex stay reserved for a later additive minor. jq is the single extraction-and-transform language (extract: + nika:jq).
Out of scope for v0.1 (deferred · see `08-out-of-scope.md`) ·
- Expression language (no arithmetic in templates)
- User-defined functions in templates
- Multi-pass substitution
🦋 Next · [05 · Errors](./05-errors.md)
nika-spec@14bf49f43 · 04-variables.md · sha256 1317f903f342a3e5… · the pack upstream