v0.109.0 — the nine-key release (tag · no assets)
v0.109.0 — the nine-key release
This GitHub release has no binaries. The tag exists (f58a17396) so the language change has a name. Its own pre-upload gates refused every builder: the funnel and the trust battery still spoke the fourteen-key envelope the nine-key engine refuses at parse. The installable nine-key engine is v0.109.2.
brew · nika.sh/install.sh · nika-action · the Registry all follow 0.109.2.
0.109.0 - 2026-08-18
The nine-key release. The envelope shrinks from fourteen keys to nine and
a workflow written for 0.108.0 will not check on 0.109.0 · this is the flag-day
the 0.106.0 front page announced as possible, and it lands whole. The identity
moves onto nika: itself (nika: <id> · a kebab-case name · the workflow:
block, its id: and its description: are gone), the value authorities are
exactly three (inputs · const · secrets · config: died with the block),
types: · policy: · assert: leave the envelope, and the task body loses
its second grammars: cleanup is a real task on an unwind edge (on_finally:
is dead · graph_format: 3 carries the finally node), output: is spelled
extract:, declassify: and inert: merge into one door (lift:),
fail_workflow is gone (on_error is recover or skip), the two fan-out
knobs live INSIDE for_each:, and group: arrives (fan-in · NIKA-DAG-008).
Two P0 close at the surface users install: an expression sees only its INPUT
(the ambient env leaves the jaq function set at the three seams, with a
pinned inventory of natives that reddens if a future jaq adds one), and a
third-party receipt can no longer write the operator's clipboard (every field
rides escape_tty). The refusal a 0.108.0 file meets first now TEACHES where
each retired key's role went, instead of unknown field.
⚠️ Migration
1 · The envelope · fourteen keys become nine (LOT 2 · ADR-113 · #909 and the sweep of 2026-08-11/13)
The live envelope is nika · model · inputs · const · secrets ·
permits · run · tasks · outputs. Every other top-level key refuses,
and the refusal names the destination:
| Dead form | Write instead | The teaching |
|---|---|---|
nika: v1 + workflow: { id, description } |
nika: <id> (kebab-case) · the description as a # comment above it |
the identity IS the envelope key · prose is demoted, never dropped |
top-level description: |
a # comment above nika: |
shipped twice (bare · inside workflow:) · both dead |
config: |
an inputs: entry with required: false and a default: |
a deployment-supplied value is an input with a default · authorities are exactly three |
types: (NIKA-TYPE-002 retired) |
the verb's schema: (structured output) · a task's returns: |
a shape rides its consumer · the ten primitives stay lowercase (spec 09) |
policy: |
permits: (fs · net · exec · tools) · secrets: · the unconditional laws (spec 10 · NIKA-SEC-015 net-before-exec) |
a vocabulary is not a policy · what survived is the boundary |
assert: |
nothing in the file · nika trace verify (spec 15) |
obligations are proven on the sealed trace |
2 · The task body · one grammar per thing
| Dead form | Write instead |
|---|---|
on_finally: mini-tasks |
a task of its own · after: { <parent>: unwind } (a finally node · graph_format: 3) · every graph judge governs it because it walks wf.tasks |
output: |
extract: (same shape) |
declassify: list · inert: string |
lift: (the law is a parameter of one door · spec 10 §the authored doors) |
on_error: { fail_workflow: true } |
nothing · the default IS the failure · on_error is recover or skip |
task-level max_parallel: · fail_fast: |
inside the block · for_each: { items: …, max_parallel: N, fail_fast: false } |
depends_on: |
with: bindings (the binding IS the edge) · after: { x: success } for control (NIKA-PARSE-024 · unchanged since W2) |
graph_format: 2 pins (*.graph.json goldens) |
regenerate · nika inspect --format json · never edit a projection by hand |
3 · What nika check --fix migrates · and what it does not
The rungs are idempotent and equivalence-or-stop · r1-identity (NEW ·
nika: v1 + workflow: {id, description} · the block, the pre-W1 scalar
workflow: <id>, the one-line flow form, a bare top-level description: ·
become nika: <id> with the prose demoted to a # comment ABOVE it, never
dropped · it STOPS, never guesses, when nika: already names something else,
when the block carries a foreign key, when the id is not kebab-case, or when
there is no id at all) · w1-map (a tasks: sequence becomes the map ·
atomic or nothing) · w2-flow (depends_on + body tasks.* reads become
with: bindings and after: predicates) · d1-split (the pre-0.103 string
command:) · esplit (vars: → inputs: / const: · classify-not-rename)
· predicates (succeeded → success). One --fix runs them all in one
loop, so a 0.108.0 file whose only sins are the identity and the tasks list
heals to green in one command. Every round is a transaction: a repair whose
text no longer parses is rolled back and reported, and the file is written
once, from committed text only.
Still hand migrations in this release · the on_finally: restructuring
(cleanup becomes its own task on an unwind edge), output: → extract:,
declassify:/inert: → lift:, config: → inputs: (a classification),
and the for_each re-nesting · the refusal teaches each destination at the
point of refusal and --fix leaves the file untouched rather than write a
document its own checker would reject. Measured on the pack that ships inside
this binary: 0.108.0 passes 0/40 of these examples · 0.109.0 passes 40/40 ·
every existing file outside this repo sits on the 0.108.0 side of that line
until it is migrated.
4 · Two P0, closed where users install
- An expression sees only its input (#959). A
nika:jqexpression could
read the ambient environment (env.PATH) under an ABSENT permits block
whilecheckprinted « the body is pure compute so nothing escapes ». The
retained natives leave the jaq function set at the three seams from ONE
list (nika_cap), and a pinned inventory of jaq natives reddens if a
future jaq adds one.checknow refuses the escape at the binary users
install. - A third-party receipt wrote the operator's clipboard (#958). Three
fields of a proof receipt (assert·level·task) reached the
terminal withoutescape_ttywhile the helper existed in the same file ·
a foreign evidence pack could emit OSC52. Every receipt field rides the
escape · proven by mutation before publication.
5 · The two trains (RELEASING §0)
stable is the newest tag · what brew · the Registry · nika-action and the
starters install. next is main, at <next>.0-dev between tags · a real
semver prerelease · nika --version and every trace say which one they are.
Stable consumers move only when a tag they can install exists.
Added
-
The project file
nika.yaml(D-2026-08-11-N5). An OPTIONAL file at
the repo root, discovered upward from the CWD the way git finds.git,
carrying the four keys that are decisions of a project rather than of an
invocation:ceiling(the--max-cost-usdflag's DEFAULT — the flag
always wins),traces.keep(the retention ladder's file rung, below the
threeNIKA_TRACE_*env vars),registry.floor(a GATE, max-composed
with~/.nika/registry/policy.toml— a project raises the bar, never
lowers the operator's), andarm:(the team arming registry — parsed
and shape-validated here, executed by the cadence arc). An absent file
is today's behavior bit for bit; a present-but-broken one refuses
before any spend, with its line. The founding wizard offers a
commented starter on an explicit yes (scripted twin:nika init --project-file) — never laid silently. Noseat, noprofile, no
permits in it: the portability test (D-2026-08-10-N2) keeps the file
to defaults and gates, never meaning. -
The sandbox policy: declared
permits:now require confinement, or
the run refuses (#889 · #822's P0 fail-open). A workflow asserting a
permits:boundary used to run UNCONFINED with a loud note when the
host had no OS sandbox (Linux without bwrap · any platform without the
layer) — the contract silently degraded. The severity now derives from
the declared contract, never the machine:NIKA_SANDBOX=auto(the
default) refuses a permits-declaring workflow withexec:children
the host cannot jail (any block counts — even tools-only jails the
exec child to the empty axes; permits without exec keep running),
requirerefuses any unconfined start, andoffis the explicit
waiver — parsed ONCE at the composition root
(SandboxPolicy::judge's truth table: policy × confined × permits ·
no cell yields a silent unconfined-with-permits), and an unparsable
value refuses to start (a typo'd security knob loudly defaulting would
be the fail-open class). The refusal is the typed NIKA-1710 (the
NIKA-1708/1709 launch-refusal precedent — before the prologue, zero
events, zero spend), naming the exact per-OS fix. Every waiver is
WITNESSED: the journal's opening frame attestssandbox_policy+
sandbox_waived, so a sealed trace SHOWS the operator chose it (the
resume_unverifiedtrust-amendment precedent). ADR-080 Q4.B amended;
platform-gated best-effort stays for permit-less runs. -
The doctor's sandbox row (#891 · #822 P1).
nika doctorwas
blind to the OS sandbox: a Linux host without/usr/bin/bwrapread
green while everyexec:and external MCP spawn ran unconfined. The
row rides the ONE selection'sSandboxDecision(#888 — never a third
selector): a confined backend is Ok and names its mechanism (Linux sandbox (bubblewrap) · backend id: landlock, the host-granular
allowlist residual named as follow-on — never a full-strength claim),
and anoopWARNS with the exact per-OS fix.doctor --jsoncarries
the row on the same findings lane. -
The thinking-budget teaching at check (#651 · leg 3). A
reasoning-capable model (the vendored catalog knows) seated with
max_tokensbut nothinking:now draws thethinking-budgethint:
the reasoning share lives INSIDE that budget, and a heavy think
concludes with a paid blank answer — the typed NIKA-INFER-004 failure
at run since leg 1. The hint teaches the declaration before a token
is spent; a templated seat defers to the run's resolution, a declared
thinking:or a no-think model stays silent. -
The pause is heard — outbound pause delivery (ADR-111). When the
operator setsNIKA_NOTIFY_URL, a run that pauses on a human gate
POSTs its pause payload once — a CloudEvents 1.0.2 structured envelope
(sh.nika.run.paused, deterministic id from trace × task) with
Standard Webhooks headers (webhook-id/webhook-timestampalways;
webhook-signaturev1,-HMAC-SHA256 whenNIKA_NOTIFY_SECRETholds
awhsec_secret) — then journals the outcome (notify_delivered/
notify_failed, two additive event kinds) BEFORE the seal, so the
chain covers the delivery claim. Default OFF: no URL, no socket. The
same SSRF floor as every engine egress judges the target (the
exact-loopback carve-out included, so a local ntfy relay works
as-is), and delivery failure never changes the run's verdict — the
run exitspausedwith the same code either way. Proven red-first at
the binary plane: default-off · signed CloudEvents delivery ·
metadata-range refusal · unreachable-target non-fatality. -
Traces speak the current OTel GenAI semantic conventions
(ADR-112, Part 1).nika trace exportnow projects an infer/agent
task's access facts togen_ai.provider.nameand
gen_ai.request.model· the current semconv names (never the
deprecatedgen_ai.system) · so any OTel-native viewer or eval tool
reads the model and provider off a nika trace with zero translation.
Provider ids normalize to the semconv well-known values where one
exists and differs (mistral→mistral_ai·xai→x_ai·
gemini→gcp.gemini); every other id passes through verbatim.
gen_ai.response.modelstays OUT: the semconv defines it as the
provider-reported model that SERVED, the journal captures no
provider-reported id, and emitting the requested name there would
assert a fact never captured. The mapping lives in the one projection
module (nika_dap::otel); a
pre-stable-semconv rename is one edit, never a scatter. Additive: the
existingnika.*attributes are unchanged. (The--format dataset
SFT/eval export — ADR-112 Part 2 — is proposed and gated on an
operator go: it needs the trace to capture input prompts, a content-
policy change, not a mere projection.)
Changed
- Evidence packs redact by default — the auditor gets hashes, not
payloads.nika trace evidencenow builds a REDACTED pack unless
--fullis passed: every payload field of the copied journal (task
outputs, model answers, tool results, failure details, shown prompts)
is replaced by{"sha256", "unavailable"}— the hash of the field's
own bytes plus the reason it stays with the operator — while every
structural field (event kinds, chain links, digests, durations,
verdicts) rides verbatim. The manifest still attests the ORIGINAL
journal (journal_sha256, chain, head, seal), gains
trace.projection_sha256(the one offline check the projection
supports) and aredactionobject declaring the class, because a
pack that cannot say which class it is would wear the old one's
trust. VERIFY.md now teaches the two classes apart: a redacted pack
proves the run's INTEGRITY, not its CONTENT — those are two
different asks, and content disclosure is a separate, operator-side
gesture.--fullkeeps the historical bytes and says so, in the
summary and in the manifest.evidence_formatstays 1 (additive
fields · zero programmatic consumers). Measured on a real trace:
the redacted copy leaks zero payload bytes, each placeholder
verifies against the disclosed value withshasum -a 256, and a
zero-payload journal projects byte-identical.
Fixed
-
nika check --fixcan no longer write a document it cannot read.
Two defects, one class, measured 2026-08-18. (1)NIKA-PARSE-005
carried its human teaching (a retired key's migration · the modeline
fix · a small set's own vocabulary) in the same field as its typed
did-you-mean, and both repairers —--fixand the editor quickfix —
spliced whichever they found:--fixon a file carryingworkflow:
renamed the key to the sentence "the fields here: nika · model · …",
announced one repair applied, and left YAML that no longer parsed;
the shipped 0.108.0 did the same on a de-commented
yaml-language-server:line.SchemaError::UnknownFieldnow carries
suggestion(a bare key · what a splice applies) andteaching
(prose · never machine-applied) separately, and every repairer reads
renames through the one typed door,rename_repair(). (2) The repair
loop is transactional: each round starts from a savepoint, and a round
whose text no longer parses as YAML is rolled back — rows and notes
included — and reported as a typed refusal (✗ FIX refused — … · the file is unchanged); the byte-surgery door refuses any target with the
shape of prose. Nothing is written except committed text, atomically.
W1 also keeps a CRLF file CRLF (its rewritten task lines were LF). -
The argv exec floor is judged at check, with the run's own
predicate (#605 · NIKA-SEC-001).nika checkaudited green an
argv-formexec:command the runtime's exec floor refuses at spawn
(["bash","-c",…]— interpreter inline-eval): the static lane was an
advisory hint over a hand-mirrored eval table, and a hint cannot fail
a file the run refuses — anon_error: {skip: true}leg swallowed
the refusal as a SKIP and fleets degraded silently. The predicate now
lives innika-types::exec(the L0 leaf both sides depend on — the
host_in_allowlistprecedent), the check emits theNIKA-SEC-001
FINDING (exit 2) for any literal argv the run would refuse, and the
advisory hint retires. Honest scope, pinned by tests: the shell form
and any${{ }}-templated argv make no static claim — the runtime
re-judges the resolved argv pre-spawn.nika explain NIKA-SEC-001
teaches exactly that split, the human render gains the EXEC rung, and
a cross-crate agreement test pins check ≡ run on the same argv. -
A templated
model:resolves at run, not just at check (#824).
infer.model: "${{ config.model }}"checked green — the MODELS rung
judges the declared default through the one shared static resolver —
then the run handed the RAW template bytes to the provider, dying
NIKA-INFER-001 on a string that was never a model id. The dispatch
now rendersmodel:(infer AND agent — the same one-line seam each)
through the${{ }}render pathprompt:/system:already took, so
the resolved binding is what reaches the wire and the
spec-sanctioned parameterization idiom (03 §model-by-condition ·
08 §H20 env targeting) holds end-to-end. A declared-but-valueless
ref now fails the task loud (NIKA-1702) instead of leaking the raw
island to the provider. Proven red-first at the seam: the issue's
repro workflow lands the resolved default in the captured provider
request body, and the agent loop's mock recordsmock/echo, never
the template. -
check --fixmigrates the pre-0.103 stringcommand:(#572 · the
D1 codemod). The refusal taught the migration in prose but answered
« no machine-applicable repairs » on the exact finding whose repair IS
mechanical. The parser's refusal is now the typed
SchemaError::D1StringCommand(same wire code — NIKA-PARSE-019 — the
variant exists so the ladder can match it), and the D1 codemod joins
the ladder: a string command inside anexec:block becomesshell:
VERBATIM (the same decoded string reaches /bin/sh -c — semantics
byte-identical) or, for a bare string of provably-inert tokens
(no character a shell could reinterpret), the argv flow form the
grammar prefers. Acommand:outside an exec block (aninvoke:arg
namedcommand) is never touched; a mapping/null value STOPS with an
honest note, never a guess. The repair ladder itself descended to
nika-cli-host::fix_ladderat the 15k wall (ADR-110 · one
architectural unit, two members), and nika-migrate's D1 lives in its
ownd1.rs(the 1500-file wall, ADR-023). -
An empty
inferanswer settles FAILED, never green (#651). A
thinking model under a tightmax_tokenscan spend the whole budget
on its reasoning trace and conclude with a blank visible answer — the
run used to finish green (exit 0) overoutput: "", the only signal
a non-fatal console warn every downstream${{ tasks.X.output }}
silently ignored. The warn is promoted to the typed failure
NIKA-INFER-004(VerbInferError::EmptyAnswer· NIKA-435), raised at
the verb on the exact signal the warn keyed off (blank visible answer- token spend — a reported reasoning split OR one undifferentiated
output count) and carrying the same max_tokens/no-think teaching.
Non-transient: a declaredretry:never re-asks at the same budget
unless the author opts in viaon_codes: [NIKA-INFER-004], and the
billed round-trip rides the failure's spend. The zero-spend carve-out
is preserved (a blank answer with zero tokens is a plain empty
completion, not the footgun), and theschema:lane is untouched —
an empty reply already dies NIKA-INFER-002 at extraction, while a
schema-validated empty container stays a legitimate answer.
- token spend — a reported reasoning split OR one undifferentiated
-
The resume verifies the chain before trusting the trace (ADR-099
trust amendment).nika run --resumeserved a trace's recorded
successes as cache hits WITHOUT consulting the tamper-evidence chain
the same journal carries: a chain-broken journal resumed silently
(exit 0), propagated a forged output into a live task, and emitted a
fresh journal whose own chain verified clean — one resume laundering
a journal that FAILSnika trace verifyinto one that PASSES it.
The chain is now walked BEFORE the fold (the same walk the verify
verb runs): a broken chain refuses (exit 2 · the FILE class, one
voice with the verify verb) naming the finding and the opt-out, while
a crash's honest signatures (killed mid-flight · torn tail) still
resume — crash-resumption is the use case. The opt-out is NAMED, never
a silent default:--resume-unverifiedproceeds loudly and the NEW
run's boot manifest journalsresume_unverified: declared+
resume_unverified_finding, so a laundered trace can never claim a
clean ancestry silently. A chainless journal (a--jsonstream
capture · a pre-0.96 journal) still resumes under the compat — said
on stderr AND attested on the boot manifest (resume_unverified: unchained+ the reason), so the strip-the-chain forgery (tamper,
then delete everychainfield to turn the walker'sBrokeninto
Unchained) never converts the refusal into a SILENT proceed.
Proven red-first at the binary plane: the forgery refused by default
(and no new journal descending from it) · the opt-out attested on the
child journal · the stripped forgery attestedunchained· the
intact control journaling no claim. -
An exact
fsgrant is judged by its effective path identity on the
builtin arm too (NEP-0009). Betweennika checkandnika run, an
exact grant literal swapped for a symlink (e.g.read: ["./allowed.txt"]→./oob/secret.txt) was served by the in-process
builtins (nika:read·nika:glob·nika:grep·nika:write·
nika:edit): the boundary re-judged the path the task NAMED, and the
resolved-vs-resolved comparison followed the planted symlink on both
sides — the kernel sandbox never sees an in-process read, so the arm
suspected least was the open one. The boundary now judges the grant's
effective path identity (the longest existing ancestor canonicalized,
the FINAL component held lexical — the dispatch re-gate's own
judgment, restated on the builtin seam) and refuses the divergence as
fs.path_mismatch(NIKA-SEC-004), naming the judged prefix and the
resolved target — one verdict voice on both arms. A symlinked
ANCESTOR stays tolerated (/tmp→/private/tmp· a nix-store link),
a not-yet-existing write target stays legal (law 5), and a grant that
legitimately traverses a symlink changes verdict: declare the
effective path instead (the NEP's documented backwards-compat). Proven
red-first at the unit and binary planes: the swapped exact grant
refused with the secret never served · the inside-pointing symlink
refused as divergence · the swapped glob root refused · the honest
tree admitted before and after. -
The
--jsonstream carries the chain — the trusted-by-default class
is retired (ADR-099 §5 follow-on). Anika run --jsoncapture was
the last journal shape the resume trusted by default: the stream wrote
nochainfield, so a captured journal resumed under the
attested-but-unverifiedunchainedcompat, and tampering the capture
met no walk. The two lanes now drive one shared chain state — the
journal file is the stdout stream BYTE FOR BYTE — so a fresh capture
verifies undernika trace verify, resumes on the verified lane (no
notice · no attestation), and its forgery is refused (exit 2) like any
broken journal. The compat stays for pre-chain journals and stripped
forgeries — said, attested, never silent. Proven red-first at the
unit and binary planes: every streamed line carries the chain · the
capture verifies and resumes verified with no claim journaled · the
forged capture refused · both mutations (the insert dropped · the
head never advancing) kill their tests. -
The builtin fs boundary's decisions ride the permit witness
(NEP-0007 law 2 · the declared v1 residual, closed). Until now the
in-process arm attested a refusal only as the task's coded failure
(NIKA-SEC-004intask_failed), and its GRANTED reads and writes
were witnessed nowhere — an auditor could not reconstruct what
authority the builtin arm actually exercised. The boundary's
enforcement point now records every verdict, allow and deny alike,
into the attempt's collector (a tokio task-local scoped by the
runtime per attempt — the one channel that reaches the enforcement
point inside the shared dispatcher without breaking the kernel's
ToolExecuteseam, and the only one that follows the attempt across
.await), and the settle spine emits onepermit_checkedframe per
decision withplane: "fs"— the same payload shape as every other
plane, so the frames bind to the task that took them through the hash
chain, on the failure path too (the deny precedestask_failed).
Outside a run the slot is a no-op: telemetry never panics. Proven
red-first at the binary plane: the permitted read journals one allow
betweentask_startedandtask_completed· the dynamically-refused
read journals one deny beforetask_failedwith zero secret bytes
emitted · neutralizing the record kills both planes. The per-op NET
decisions remain the declared residual. -
The builtin fs reads open pinned — the enforce→open race closes on
the in-process arm (NEP-0009 law 6 · the builtin-side follow-on).
The dispatch guard judged the path and THEN the op opened it: between
the two, a parallel task of the same run could swap the judged file
for a symlink, and a plainopen(2)would follow it — the parallel
sibling of the sequenced pivot the re-gate killed. Every builtin READ
(read·edit's read phase ·grep·glob·decide's bundle ·
fetch's multipart parts) now runs against the judged fs: the path is
re-judged at open time (SILENT — the guard already witnessed the op's
one fs frame), then openedO_NOFOLLOW, so the KERNEL refuses a
swapped final component (ELOOP) inside the syscall itself,
atomically — no check-then-act, no window. A pre-existing
inside-pointing symlink is still served (resolved, re-judged on the
TARGET, re-opened pinned · the no-regression rule), a dangling link
keeps the file-not-found verdict, and a redirect storm refuses coded
past a hard hop bound instead of hanging. Writes need no pin: the
atomic temp+rename lane replaces a symlinked destination, never
follows it. Declared gaps, honestly on the record: the pin compiles
on the tier-1 unixes (macOS · Linux) and every other target
degenerates to enforce + plain read;O_NOFOLLOWpins the FINAL
component only, so a swapped ANCESTOR directory stays a residual of
the exec arm's--bind-fdfollow-on class; andchartkeeps the raw
fs because its save lane re-reads the write-permitted artifact for the
idempotence law. Proven red-first at the unit plane: the nofollow open
never serves a symlink target · the swapped exact grant refuses coded
with zero secret bytes · the inside link serves byte-exact · the loop
and the storm refuse coded and RETURN.
Security
nika mcp --transport httprefuses a non-loopback bind without a
bearer token (#890 · #822 P0/P1).NIKA_MCP_TOKENwas optional and
the auth gate treated its absence as OK — right for the loopback
default, a classic misconfiguration the moment--bind 0.0.0.0met a
multi-user or VPS host. The refusal is code-enforced before serving
(HttpServer::guard_bind_authjudges the RESOLVED address, so
localhostreads as the loopback it bound, never the spelling) and
names both fixes: setNIKA_MCP_TOKENto require a bearer, or bind a
loopback address. Loopback without a token stays convenient,
unchanged.