Skip to content

v0.109.0 — the nine-key release (tag · no assets)

Choose a tag to compare

@ThibautMelen ThibautMelen released this 19 Aug 00:09
· 36 commits to main since this release

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 (succeededsuccess). 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:jq expression could
    read the ambient environment (env.PATH) under an ABSENT permits block
    while check printed « 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. check now 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 without escape_tty while 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-usd flag's DEFAULT — the flag
    always wins), traces.keep (the retention ladder's file rung, below the
    three NIKA_TRACE_* env vars), registry.floor (a GATE, max-composed
    with ~/.nika/registry/policy.toml — a project raises the bar, never
    lowers the operator's), and arm: (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. No seat, no profile, 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 with exec: children
    the host cannot jail (any block counts — even tools-only jails the
    exec child to the empty axes; permits without exec keep running),
    require refuses any unconfined start, and off is 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 attests sandbox_policy +
    sandbox_waived, so a sealed trace SHOWS the operator chose it (the
    resume_unverified trust-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 doctor was
    blind to the OS sandbox: a Linux host without /usr/bin/bwrap read
    green while every exec: and external MCP spawn ran unconfined. The
    row rides the ONE selection's SandboxDecision (#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 a noop WARNS with the exact per-OS fix. doctor --json carries
    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_tokens but no thinking: now draws the thinking-budget hint:
    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 sets NIKA_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-timestamp always;
    webhook-signature v1,-HMAC-SHA256 when NIKA_NOTIFY_SECRET holds
    a whsec_ 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 exits paused with 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 export now projects an infer/agent
    task's access facts to gen_ai.provider.name and
    gen_ai.request.model · the current semconv names (never the
    deprecated gen_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 (mistralmistral_ai · xaix_ai ·
    geminigcp.gemini); every other id passes through verbatim.
    gen_ai.response.model stays 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
    existing nika.* 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 evidence now builds a REDACTED pack unless
    --full is 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 a redaction object 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. --full keeps the historical bytes and says so, in the
    summary and in the manifest. evidence_format stays 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 with shasum -a 256, and a
    zero-payload journal projects byte-identical.

Fixed

  • nika check --fix can 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 — --fix and the editor quickfix —
    spliced whichever they found: --fix on a file carrying workflow:
    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::UnknownField now carries
    suggestion (a bare key · what a splice applies) and teaching
    (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 check audited green an
    argv-form exec: 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 — an on_error: {skip: true} leg swallowed
    the refusal as a SKIP and fleets degraded silently. The predicate now
    lives in nika-types::exec (the L0 leaf both sides depend on — the
    host_in_allowlist precedent), the check emits the NIKA-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 renders model: (infer AND agent — the same one-line seam each)
    through the ${{ }} render path prompt:/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 records mock/echo, never
    the template.

  • check --fix migrates the pre-0.103 string command: (#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 an exec: block becomes shell:
    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. A command: outside an exec block (an invoke: arg
    named command) 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_ladder at the 15k wall (ADR-110 · one
    architectural unit, two members), and nika-migrate's D1 lives in its
    own d1.rs (the 1500-file wall, ADR-023).

  • An empty infer answer settles FAILED, never green (#651). A
    thinking model under a tight max_tokens can spend the whole budget
    on its reasoning trace and conclude with a blank visible answer — the
    run used to finish green (exit 0) over output: "", 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 declared retry: never re-asks at the same budget
      unless the author opts in via on_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 the schema: lane is untouched —
      an empty reply already dies NIKA-INFER-002 at extraction, while a
      schema-validated empty container stays a legitimate answer.
  • The resume verifies the chain before trusting the trace (ADR-099
    trust amendment).
    nika run --resume served 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 FAILS nika trace verify into 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-unverified proceeds loudly and the NEW
    run's boot manifest journals resume_unverified: declared +
    resume_unverified_finding, so a laundered trace can never claim a
    clean ancestry silently. A chainless journal (a --json stream
    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 every chain field to turn the walker's Broken into
    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 attested unchained · the
    intact control journaling no claim.

  • An exact fs grant is judged by its effective path identity on the
    builtin arm too (NEP-0009).
    Between nika check and nika 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 --json stream carries the chain — the trusted-by-default class
    is retired (ADR-099 §5 follow-on).
    A nika run --json capture was
    the last journal shape the resume trusted by default: the stream wrote
    no chain field, so a captured journal resumed under the
    attested-but-unverified unchained compat, 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 under nika 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-004 in task_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
    ToolExecute seam, and the only one that follows the attempt across
    .await), and the settle spine emits one permit_checked frame per
    decision with plane: "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 precedes task_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
    between task_started and task_completed · the dynamically-refused
    read journals one deny before task_failed with 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 plain open(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 opened O_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_NOFOLLOW pins the FINAL
    component only, so a swapped ANCESTOR directory stays a residual of
    the exec arm's --bind-fd follow-on class; and chart keeps 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 http refuses a non-loopback bind without a
    bearer token (#890 · #822 P0/P1).
    NIKA_MCP_TOKEN was optional and
    the auth gate treated its absence as OK — right for the loopback
    default, a classic misconfiguration the moment --bind 0.0.0.0 met a
    multi-user or VPS host. The refusal is code-enforced before serving
    (HttpServer::guard_bind_auth judges the RESOLVED address, so
    localhost reads as the loopback it bound, never the spelling) and
    names both fixes: set NIKA_MCP_TOKEN to require a bearer, or bind a
    loopback address. Loopback without a token stays convenient,
    unchanged.