the spec · 14
Composition
A workflow can call another workflow — as a **typed, bounded, authority-contained** step, not as a shell-out.
14 · Composition #
A workflow can call another workflow — as a typed, bounded, authority-contained step, not as a shell-out. The form is a tagged union on the verb you already know:invoke:carries exactly one oftool:orworkflow:. No fifth verb, no magicnika:workflowbuiltin, no path-shapedtarget:string — the field IS the semantics, the same command/shell law generalized.A word of discipline. Everything below is the CONTRACT. An engine may say a composition is specified the moment it parses and checks; it may only say proven once it has demonstrated, together, on real child execution: static resolution · typed I/O · effect containment · inherited budgets · cycle detection · the trace forest · nested receipts · semantic resume · and reference-model parity. Until that whole receipt exists, « proven » is not a word this contract lets an implementation use.
The form (normative) #
tasks: audit: invoke: workflow: "./audits/site-audit.nika.yaml" # a STATIC path … args: # … or registry:owner/name@version (pinned) url: "${{ inputs.target }}" returns: AuditReport # the child's typed outputs — composed, not re-declaredinvoke:is a tagged union — exactly one oftool:|workflow:, plusargs:. Both present, or neither, is a validation error (NIKA-PARSE· the same shape as two verbs on one task).workflow:is a static target: a filesystem path OR a pinnedregistry:owner/name@version. A${{ }}-templated target is refused at check — a call graph you cannot draw before the run is a call graph you cannot bound (NIKA-COMP-001).
The CallableContract (normative · G20) #
Everything invocable — a builtin, an MCP tool, a local child, a registry child, a pure decision, a structured human interaction — has ONE shape:
Callable<I, O, E, ε, ρ, δ> I inputs (typed) O outputs (typed) E error surface ε effects ρ resources δ the guarantee levelsA child's contract is DERIVED from its body, never annotated: its
I/O are the child's inputs:/outputs: (09), its
ε the inferred effect boundary (10), its ρ the
symbolic certificate (05), its E the codes its body
can raise. The parent's checker consumes the contract; the parent never
restates it. One catalog serves every surface (checker · runtime ·
agent · LSP · MCP · registry · lockfile) — the partial-view drift of
today's separate invoke/agent/LSP tables ends here.
The ten laws (normative · G22 · constitution §10.3) #
A composition is well-formed iff all ten hold:
- Static target — the callee is resolvable before the run (path or pinned registry ref · never templated).
- Typed call — the parent's
args:fit the child'sinputs:and the child'soutputs:fit the parent'sreturns:(the assignable relation of 09 · one type core, composed). - Zero implicit authority —
Authority(child) ⊆ Authority(parent) ∩ declared. A child never gains a capability the parent lacks, and never more than the call-site declares. Import-side, this is the containment law of 12. - Effect containment — the child's effect boundary is a subset of
the parent's; a child effect outside it is
NIKA-COMP-002at check,NIKA-SEC-004at run. - Resources summed — the child's symbolic certificate composes into
the parent's (
Bound(parent) ⊇ Bound(parent-own) + Bound(child)— twoBounds sum to aBound, the algebra closes). - Inherited deadlines/budgets — the child runs under
min(parent remaining, child declared)for time and cost; a child cannot outlive or outspend its caller. - Acyclic call graph — the static call graph is acyclic; a literal
self-launch or a static cycle is refused at check (
NIKA-COMP-003). The runtime depth cap (NIKA-SEC-003) is defense in depth, not the primary guard — a templated target that could recurse is bounded there, fail-closed. - Trace forest — the child keeps its OWN hash-chain; the parent's
trace records the child's
{semantic_hash, plan_id, trace_id, chain_head, outcome}(the 13 Outcome, composed) — a forest of chains, never one flattened stream. - Composed receipts — nested receipts compose by Merkle: the parent's receipt commits to the child's receipt digest, so a proof of the whole contains a proof of each part.
- Semantic cache identity — a child result is cached by the child's semantic identity (its canonical Semantic IR · W6), never by its path or the call site — two call sites that resolve to the same semantic child share the cache; the same path resolving to two different bodies does not.
Agent capability closure (normative · G25) #
An agent: task's reachable surface has TWO public fields, kept
separate: tools: and workflows:. Internally they are one tagged
CapabilitySet; a glob resolves to an EXACT set in the lockfile
(hashed and shown). An agent can never reach a path-shaped or dynamic
target — the closure is static, the same law as the top-level form.
Errors (the `NIKA-COMP` namespace · new in this chapter) #
- Code · Category · Meaning
- `NIKA-COMP-001` · `validation_error` · a `workflow:` target is not statically resolvable (templated · malformed · unpinned registry ref)
- `NIKA-COMP-002` · `security_error` · the child's effect boundary exceeds the parent ∩ declared (law 3/4)
- `NIKA-COMP-003` · `validation_error` · the static call graph is not acyclic (self-launch · cycle · law 7)
- `NIKA-COMP-004` · `validation_error` · the typed call does not compose (args ⋢ inputs, or outputs ⋢ returns · law 2)
NIKA-SEC-003 (run-recursion depth) stays the runtime backstop for the
templated-target case.
`nika:run` and the sibling-run workaround (normative note) #
Two histories closed when this chapter shipped, and
08 records both. The once-proposed `nika:run`
builtin was abandoned, never shipped · a builtin would have carried the
child as a string argument — invisible to the type system, the effect
boundary and the acyclicity check. The field IS the semantics, so the form
is the tagged union above. The sibling-run workaround — exec: nika run sub.yaml --output json + capture: stdout — remains valid as the
process floor (floor 1 of the 08 three floors) and
as the one form for what a static call graph cannot express (an unbounded
cursor chain). What the process boundary cannot carry — the typed contract,
effect containment, the trace forest — is exactly what invoke: workflow:
adds: wherever the target is static, a linter recommends the native form.
What v1 deliberately does not do #
- No dynamic dispatch. A target is static — no computed workflow names, ever (the acyclicity and the cost story both depend on it).
- No cross-workflow orchestration language. Nika composes typed calls; a pipeline-of-workflows scheduler is a different problem (08 · Temporal/Airflow class · out forever).
- No session callables yet.
CallablereservesSession(protocol-shaped) beside today'sUnary; the v1 form is unary. - No rewrite/e-graph equivalence. Semantic-equivalence rewriting of compositions is LAB (never auto · benchmark before any core dependency).
Related #
- 09 · Types — the typed call (law 2)
- 10 · Authority — effect containment (laws 3/4)
- 12 · Gateway — the import-side containment this generalizes
- 13 · Outcomes — the child Outcome the trace forest records
nika-spec@6ac29351a · 14-composition.md · sha256 56e2ff399b452fb4… · the pack upstream