@supernovae-st/nika 0.71.0 → 0.120.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,47 +1,1010 @@
1
- # @supernovae-st/nika
1
+ <p align="center">
2
+ <a href="https://nika.sh">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://nika.sh/brand/nika-logo-dark.svg">
5
+ <img src="https://nika.sh/brand/nika-logo-light.svg" alt="Nika" width="220">
6
+ </picture>
7
+ </a>
8
+ </p>
2
9
 
3
- Thin npm wrapper for the [Nika CLI](https://github.com/supernovae-st/nika) -- a semantic YAML workflow engine for AI tasks.
10
+ <h1 align="center">@supernovae-st/nika</h1>
4
11
 
5
- This package downloads the pre-built Nika binary for your platform during `npm install`.
12
+ <p align="center">
13
+ <strong>The TypeScript door to Nika: audit a workflow, run it, watch it, prove it.</strong><br>
14
+ Locally through the released engine, or against an authenticated <code>nika serve</code>.
15
+ </p>
16
+
17
+ <p align="center">
18
+ <a href="https://www.npmjs.com/package/@supernovae-st/nika"><img src="https://img.shields.io/npm/v/@supernovae-st/nika?label=npm" alt="npm version"></a>
19
+ <a href="https://github.com/supernovae-st/nika-client/actions/workflows/ci.yml"><img src="https://github.com/supernovae-st/nika-client/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI status"></a>
20
+ <a href="https://github.com/supernovae-st/nika/releases/latest"><img src="https://img.shields.io/github/v/release/supernovae-st/nika?label=engine" alt="Engine release"></a>
21
+ <a href="https://docs.nika.sh"><img src="https://img.shields.io/badge/docs-docs.nika.sh-8b8cf8.svg" alt="Documentation"></a>
22
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="Apache-2.0"></a>
23
+ </p>
24
+
25
+ <p align="center">
26
+ <a href="https://scorecard.dev/viewer/?uri=github.com/supernovae-st/nika-client"><img src="https://api.scorecard.dev/projects/github.com/supernovae-st/nika-client/badge" alt="OpenSSF Scorecard"></a>
27
+ <a href="https://www.npmjs.com/package/@supernovae-st/nika"><img src="https://img.shields.io/badge/npm-provenance-2ea44f.svg" alt="Published with provenance through GitHub Actions trusted publishing"></a>
28
+ <a href="https://www.npmjs.com/package/@supernovae-st/nika"><img src="https://img.shields.io/npm/dm/@supernovae-st/nika?label=downloads" alt="npm downloads"></a>
29
+ <a href="https://archive.softwareheritage.org/browse/origin/?origin_url=https://github.com/supernovae-st/nika-client"><img src="https://archive.softwareheritage.org/badge/origin/https://github.com/supernovae-st/nika-client/" alt="Archived by Software Heritage"></a>
30
+ </p>
31
+
32
+ ## Thirty seconds, no API key
33
+
34
+ One package installs the client, the `nika` command and the engine payload for
35
+ your platform (macOS and Linux, arm64 and x64):
36
+
37
+ ```sh
38
+ npm install @supernovae-st/nika@0.118.7
39
+ ./node_modules/.bin/nika --version
40
+ ```
41
+
42
+ ```
43
+ nika 0.118.7 (f3a31a6ee)
44
+ ```
45
+
46
+ Write `hello.nika`. The `mock/echo` model rehearses with no key and no
47
+ network:
48
+
49
+ ```yaml
50
+ nika: hello
51
+ model: mock/echo
52
+ permits: {}
53
+ tasks:
54
+ greeting:
55
+ infer:
56
+ prompt: "Say hello from the Nika SDK."
57
+ max_tokens: 32
58
+ outputs:
59
+ greeting: ${{ tasks.greeting.output }}
60
+ ```
61
+
62
+ Audit it before anything runs:
63
+
64
+ ```sh
65
+ ./node_modules/.bin/nika check hello.nika
66
+ ```
67
+
68
+ ```
69
+ ✔ ORDER no exec: sits downstream of a net-effecting task · unauthored content never reaches a shell
70
+ ✔ PERMITS literal + const: args fit the boundary · computed paths + symlinks are the RUN's verdict
71
+ ✔ TRIFECTA no lethal trifecta over the declared permits: without a human gate
72
+ ✔ JOURNEY internal · 0 sources · 0 destinations · 1 model endpoint · no secret reaches an external destination
73
+ ✔ audited · 1 task · 1 wave · permits {} · est out ≤$0.0000 · 0 hints · risk low
74
+ layers · valid ✔ · access ready ✔ · capacity fit ✔ · run ready ✔
75
+ ```
76
+
77
+ Now drive the same engine from TypeScript:
78
+
79
+ ```ts
80
+ import { Nika } from '@supernovae-st/nika';
81
+
82
+ const nika = new Nika({
83
+ cwd: process.cwd(),
84
+ // bin: '/absolute/path/to/nika', // or set NIKA_BIN
85
+ });
86
+
87
+ const report = await nika.check('hello.nika', {
88
+ nativeStrict: true,
89
+ });
90
+ if (!report.clean) throw new Error('workflow did not pass nika check');
91
+
92
+ const run = await nika.run('hello.nika', { maxCostUsd: 0 });
93
+ for await (const event of run.events()) {
94
+ // The same lifecycle words on both transports; the engine's own frame,
95
+ // in its protocol vocabulary, stays on event.raw.
96
+ console.log([event.kind, event.task, event.status].filter(Boolean).join(' '));
97
+ }
98
+
99
+ const result = await run.result();
100
+ console.log(result.status, result.outputs, result.receipt);
101
+ ```
102
+
103
+ Expected output: `run.started`, `task.scheduled greeting`,
104
+ `task.started greeting`, `task.completed greeting`, `engine.event`, then
105
+ `run.settled succeeded`, then the terminal `succeeded` line with the outputs
106
+ and the receipt. The `engine.event` is the native journal's own
107
+ `workflow_completed` frame: the SDK gives it no lifecycle name and no state,
108
+ drops nothing, and shows it on `event.raw.kind`.
109
+
110
+ That is six frames for one task; the same shape with 90 tasks was measured at
111
+ 273 (`3N + 3`). A session retains the most recent 4096 frames by default, so
112
+ `run.events()` opened after `run.result()` replays them all, and is refused,
113
+ never shortened, past that bound; see
114
+ [Observing a run after the fact](#observing-a-run-after-the-fact).
115
+
116
+ <p align="center">
117
+ <img src="https://raw.githubusercontent.com/supernovae-st/nika-client/main/media/local-driver.gif" alt="The typed driver over the released binary: check the workflow, gate on the report, run it to the end under a cost ceiling, count the events" width="960">
118
+ </p>
119
+
120
+ *Recorded by `scripts/media/render.sh` against this package and the released
121
+ engine; every line on screen is the SDK's own output. The recorded driver
122
+ predates the Run-owned lifecycle: it still calls `nika.events(run)`, a
123
+ deprecated wrapper that keeps working through the
124
+ [compatibility window](#migrating-to-the-run-owned-lifecycle), and the
125
+ `run.done` alias, which is not deprecated.*
126
+
127
+ Native checks and explicit local snapshot checks preserve the engine's
128
+ `findings[]` and `exitCode`. A check by served name returns the resident's
129
+ compact acknowledgement with `clean: true`, or its typed workflow refusal
130
+ with `clean: false`; it does not invent local findings or an exit code.
131
+
132
+ `run()` returns after stable admission, and the `NikaRun` it returns owns its
133
+ lifecycle: `run.events()`, `run.result()`, `run.status()` and `run.cancel()`.
134
+ `run.result()` is the sole terminal result (`run.done` is its compatibility
135
+ alias, the same promise).
136
+ A workflow the engine refuses before admission never yields a run: `run()`
137
+ itself rejects with a `NikaOperationError` that names the engine's code and,
138
+ for a red check, carries its `findings[]` (see [Errors](#errors)). The engine
139
+ checks on every run, so no `check()` is needed first to be protected or taught.
140
+
141
+ ### Workflow inputs
142
+
143
+ `inputs` binds the workflow's declared `inputs:` by name, and means the same
144
+ thing on both transports:
145
+
146
+ ```ts
147
+ const run = await nika.run('support-triage.nika', {
148
+ inputs: { ticketId: '42' },
149
+ // idempotencyKey: `triage-${ticket.id}`, // HTTP only
150
+ });
151
+ ```
152
+
153
+ Values are strict JSON and stay literal. `'42'` stays a string and `42` a
154
+ number; `'@env:HOME'` and `'${{ tasks.x.output }}'` are text, never read from
155
+ the environment or evaluated; nothing is coerced to the declared type. The
156
+ engine owns the verdict and refuses before any run exists: an undeclared key
157
+ (`unknown_input`), a value that does not fit its declared type
158
+ (`input_type_mismatch`), a required input left out (`NIKA-1708`). Each rejects
159
+ `run()` as a `NikaOperationError` with that code, native (`status: 3`) or HTTP
160
+ (`status: 422`). Supplied values are recorded with `api-caller` provenance;
161
+ declared defaults keep `file`.
162
+
163
+ The SDK refuses, before it spawns or sends anything, a value JSON would
164
+ silently lose: `undefined`, a function, a symbol, a bigint, `NaN` or
165
+ `Infinity`, a cycle, a class instance (a `Date`, a `Map`), an object whose
166
+ prototype only claims to be plain, an array hole, an accessor. That is a
167
+ `NikaConfigurationError` naming the path (`inputs.ticket.tags[1] is
168
+ undefined`), never the value. No caller code runs while the map is judged: a
169
+ getter is never invoked, and a `Proxy` is refused before it is read, so none
170
+ of its traps run. The serialized map is
171
+ bounded at 1 MiB on both transports. Do not put a secret in `inputs`.
172
+
173
+ The engine must advertise the channel, and the SDK checks before admission:
174
+
175
+ - Native: `inputsLiteral` in `nika --sdk-identity`. The map rides the engine's
176
+ stdin (`nika run --inputs-json -`), so a value never appears in a process
177
+ listing.
178
+ - HTTP: `jobInputs` in `GET /health`, for a workflow run by its served name.
179
+ An execution snapshot froze its inputs and takes no overlay, so an HTTP run
180
+ of a local path (`./flow.nika`) refuses `inputs`, an empty map included.
181
+
182
+ An engine without the capability rejects with `NikaCompatibilityError`
183
+ (`capability: 'inputsLiteral'` or `'jobInputs'`) and nothing runs. The SDK
184
+ never falls back to `--var`, and a resident that merely answers 202 has
185
+ negotiated nothing: one from before the envelope accepts the field and ignores
186
+ its values. The engine payload pinned by this package version advertises
187
+ neither capability yet, so `inputs` is refused on it until the pin moves.
188
+
189
+ `vars` is deprecated. It remains the native `--var KEY=VALUE` operator channel,
190
+ unchanged: the engine reads `@env:NAME` from its environment and coerces text
191
+ to the declared type, so it cannot carry literal values and has no HTTP form.
192
+ `inputs` and `vars` together reject `run()`; they are never merged.
193
+ An admitted workflow failure is result data with `status: "failed"` and, when
194
+ the engine named the failing task, `error: { code, message, task }`; transport,
195
+ protocol, configuration, and compatibility failures throw typed SDK errors.
196
+ A `try { await run.result() } catch {}` alone therefore never catches a failed
197
+ workflow: a CI job or an application must read `result.status` and treat
198
+ anything but `succeeded` as its own failure, or a red run passes silently.
199
+
200
+ On the development branch, `isNikaRunSucceeded` provides that TypeScript
201
+ narrowing. It is **unreleased** and is not exported by the published
202
+ `0.118.7` package; that package uses `result.status === 'succeeded'` directly.
203
+ For CI built from this branch:
204
+
205
+ ```ts
206
+ import { Nika, isNikaRunSucceeded } from '@supernovae-st/nika';
207
+
208
+ const run = await new Nika().run<{ answer: number }>('flow.nika');
209
+ const result = await run.result();
210
+ if (isNikaRunSucceeded(result)) {
211
+ console.log(result.outputs?.answer); // outputs stay typed and optional
212
+ } else {
213
+ console.error(result.status, result.error?.code, result.error?.message);
214
+ process.exitCode = 1;
215
+ }
216
+ ```
217
+
218
+ A paused result is a human gate, not a successful completion and not a
219
+ failure. It arrives as a `run.waiting` event, never as `run.settled`, and
220
+ `result.status` keeps the engine's word, `paused`. Applications can render
221
+ that state separately; a CI job awaiting completion must not pass it as
222
+ success. The guard reads the engine's status and never turns absent outputs
223
+ into a fabricated output map.
224
+
225
+ ## Why this door
226
+
227
+ - **Audited before it runs.** `check()` returns the engine's verdict on the
228
+ order of effects, the permits, the lethal trifecta, the journey of every
229
+ secret and the cost floor. A red check never becomes a run.
230
+ - **Sovereign by default.** The same file runs on local models (Ollama,
231
+ llama.cpp, vLLM), on Mistral, Hugging Face, OpenAI, xAI, Anthropic and the
232
+ rest of the engine's catalog; `mock/echo` rehearses with no key and no
233
+ network.
234
+ - **Traced after.** Every native run leaves a hash-chained journal and hands
235
+ back a receipt; `traceVerify()` asks the engine to verify it. The SDK never
236
+ re-implements the proof.
237
+ - **One vocabulary, two transports.** `check`, `run`, `events`, `cancel`,
238
+ `traceVerify` and `schedule` read the same against a local process and an
239
+ authenticated `nika serve`; only the constructor changes. Run events carry
240
+ the same lifecycle words on both (`run.started`, `run.waiting`,
241
+ `run.settled`), with the engine's own frame kept on `event.raw`.
242
+
243
+ ## One vocabulary
244
+
245
+ `Nika` exposes `check`, `run`, `attachRun`, `traceVerify`, `listWorkflows`,
246
+ `workflow`, `schedule`, and `scheduleStatus`. The `NikaRun` it returns owns the
247
+ run's lifecycle:
248
+
249
+ ```
250
+ NikaRun
251
+ ├── id
252
+ ├── events() one lifecycle vocabulary · event.raw keeps the protocol frame
253
+ ├── result() the one settlement · admitted failure is data, never a throw
254
+ ├── status() durable over HTTP · a typed refusal on a native process
255
+ └── cancel() idempotent
256
+ ```
257
+
258
+ `nika.events(run)`, `nika.cancel(run)` and `nika.status(run)` remain as
259
+ deprecated wrappers for one release train, counted from the first published
260
+ train that carries this API; see
261
+ [Migrating to the Run-owned lifecycle](#migrating-to-the-run-owned-lifecycle).
262
+ The handle owns observation, settlement, `status` and cancellation and nothing
263
+ else: checking, proof, catalogs and authoring stay on `Nika`.
264
+
265
+ The engine remains authoritative for parsing, admission, execution, receipts,
266
+ traces, permits, scheduling, and cost. The SDK transports those facts; it does
267
+ not parse YAML or reconstruct proof in TypeScript.
268
+
269
+ ## Requirements
270
+
271
+ - Node.js 22 or newer (the tested floor; an older major is unsupported, not
272
+ refused, and `npm install` does not warn about it)
273
+ - for native execution or local snapshot capture, a compatible `nika` engine, resolved from `config.bin`, then `NIKA_BIN`
274
+ (absolute paths only), then the exact optional platform package; a bare
275
+ name or a relative path is refused because the operating system would
276
+ resolve it through `PATH` or the working directory, and a `nika` found on
277
+ `PATH` is deliberately never used
278
+ - a `.nika` workflow
279
+
280
+ ## Documentation
281
+
282
+ - [Architecture](docs/architecture.md) · Modules, Interface, Seam, Adapters,
283
+ lifecycle, and authority boundaries
284
+ - [HTTP contract](docs/http-api.md) · every live route, recovery, security,
285
+ idempotency, and schedule CAS
286
+ - [Testing and release evidence](docs/testing.md) · layered gauntlets and the
287
+ Socratic risk matrix
288
+ - [Migrating to 0.116](docs/migrating-to-0.116.md) · the intentional breaking
289
+ migration to the smaller durable client surface
290
+ - [docs.nika.sh](https://docs.nika.sh) · the language, the engine and the
291
+ other doors
6
292
 
7
293
  ## Install
8
294
 
9
- ```bash
10
- # Global install
11
- npm install -g @supernovae-st/nika
295
+ Pin the version you tested, then verify the package the project actually
296
+ resolved:
12
297
 
13
- # Or run directly
14
- npx @supernovae-st/nika
298
+ ```sh
299
+ npm install @supernovae-st/nika@0.118.7
300
+ node -p "require('@supernovae-st/nika/package.json').version"
301
+ ```
302
+
303
+ This package metadata subpath is exported for CommonJS, ESM build tools and CI
304
+ pin checks. It reports the installed dependency, not a moving registry tag.
305
+ The native payloads `@supernovae-st/nika-<os>-<arch>` are optional
306
+ dependencies; npm installs the one that matches your platform.
307
+
308
+ Earlier packages expose the retired `LocalNika`/HTTP split and do not
309
+ implement the root facade documented here. This package carries the product's
310
+ name: up to 0.115.0 it was published as `@supernovae-st/nika-client`. That
311
+ name receives no further releases from this repository and stays installable
312
+ for the versions it already holds. It is **not** marked deprecated on the npm
313
+ registry: its published versions carry no `deprecated` field, so `npm install
314
+ @supernovae-st/nika-client` still succeeds without a warning and installs the
315
+ retired 0.115.0 API. The move to the new name says nothing about the
316
+ registry; check it yourself with `npm view @supernovae-st/nika-client
317
+ deprecated`. The repository keeps its name (`supernovae-st/nika-client`).
15
318
 
16
- # Or as a project dependency
17
- npm install @supernovae-st/nika
319
+ ## Scaffold with the engine
320
+
321
+ The lowest-friction creation door is the engine-owned scaffold:
322
+
323
+ ```sh
324
+ ./node_modules/.bin/nika init --project-file
325
+ ./node_modules/.bin/nika new 01-hello hello.nika
18
326
  ```
19
327
 
20
- ## Usage
328
+ `nika.yaml` is the project control plane. `hello.nika` is executable
329
+ workflow intent and is the file passed to `check()` and `run()`. The scaffold
330
+ writes the engine's own annotated `01-hello` example (its task is named
331
+ `greet` and its prompt asks for French); the contract this README relies on is
332
+ the `outputs.greeting` key and the `mock/echo` model, which the hand-written
333
+ file above satisfies too.
334
+
335
+ ## Verify a local trace
21
336
 
22
- ```bash
23
- nika run workflow.nika.yaml # Execute a workflow
24
- nika check workflow.nika.yaml # Validate syntax + DAG
25
- nika ui # Terminal UI
26
- nika provider list # Check API key status
27
- nika init # Interactive project setup
28
- nika course next # Start the learning course
337
+ Local terminal results carry an engine-issued receipt when tracing is enabled.
338
+ Pass that receipt back unchanged:
339
+
340
+ ```ts
341
+ if (!result.receipt) throw new Error('run did not issue a receipt');
342
+ const proof = await nika.traceVerify(result.receipt);
343
+ if (!proof.verified) throw new Error(proof.output ?? 'trace verification failed');
29
344
  ```
30
345
 
31
- ## Supported Platforms
346
+ The SDK does not implement cryptography or inspect the trace itself. It asks the
347
+ engine to verify the receipt and its signed binding. A receipt from a native
348
+ run carries the proof-bearing fields (`chain_head`, `chain_len`, `sealed`,
349
+ `trace_path`) and verifies locally. A receipt from a `nika serve` job carries
350
+ identity only (`job_id`, `execution_id`, `trace_id`, `snapshot_digest`,
351
+ `origin`): the resident writes no trace journal yet, so that receipt verifies
352
+ through no door today, and the same `NikaReceipt` type covers both shapes.
353
+ Persist it as the job's identity, not as evidence. The remote endpoint
354
+ currently returns `{ verified: false, verdict: "unavailable", reason:
355
+ "trace_journal_unavailable" }` because the server has no path-free journal
356
+ authority; the typed verdict is preserved instead of being hidden as a 404.
357
+ `/health.supportedCapabilities` names authorities that can currently complete
358
+ their operation. It therefore does not advertise remote trace verification
359
+ while this diagnostic route can only return the typed unavailable verdict.
360
+ A resident with journal authority will answer the CLI's tiers (`OK`, `SEALED`,
361
+ `ANCHORED`, `REPLAYED` hold; `INCOMPLETE`, `TAMPERED` do not), with no
362
+ `reason` on a verdict that holds; `verified` reads them the same way.
32
363
 
33
- | OS | Architecture |
34
- |---------|-------------|
35
- | macOS | arm64 (Apple Silicon) |
36
- | macOS | x64 (Intel) |
37
- | Linux | x64 |
38
- | Linux | arm64 |
364
+ Run-signing keys remain engine-owned. `nika key init`, `nika key trust`, and
365
+ `nika key rotate` manage their lifecycle. Nika prefers the OS keychain and uses
366
+ 0600 files under `~/.nika/keys/` only as the local fallback; CI can inject an
367
+ explicit pair through `NIKA_RUN_KEY_FILE` and `NIKA_RUN_PUB_FILE`. Applications
368
+ should persist receipts and public trust material, never copy a private run key
369
+ into SDK configuration, source control, workflow inputs, or an HTTP request.
39
370
 
40
- ## License
371
+ ## Cancel a run
372
+
373
+ ```ts
374
+ const run = await nika.run('slow.nika');
375
+ const cancellation = await run.cancel();
376
+ const result = await run.result();
377
+
378
+ console.log(cancellation.accepted, result.status);
379
+ ```
380
+
381
+ Cancellation is idempotent per `NikaRun`: every `run.cancel()` returns the one
382
+ request. An `AbortSignal` passed to `check`, `events`, or `traceVerify` only
383
+ stops that request or observer; it never stands in for `run.cancel()`.
384
+
385
+ Over HTTP a running job answers the request with 202: `cancellation` reads
386
+ `{ accepted: true, status: 'cancellation_requested' }` and `run.result()`
387
+ settles on the terminal the resident records, `cancelled`, `succeeded`,
388
+ `failed`, or `interrupted` once its grace expired. A job that already ended
389
+ replays its result with `accepted: false` and `status: 'already_settled'`. The
390
+ native transport signals its process the same way; the result is whatever the
391
+ engine then wrote, `cancelled` when it settled the request (the released
392
+ 0.118.7 engine does), or `interrupted` when the process ended with no
393
+ settlement frame.
394
+
395
+ ## Connect to `nika serve`
396
+
397
+ A contained workflow name such as `hello.nika` or
398
+ `daily/report.nika` is resolved by the resident registry. `check()` and
399
+ `run()` send that name without a local engine or a local workflow file.
400
+ Use `listWorkflows()` to discover the served names.
401
+
402
+ To capture your local file instead, pass an explicit path such as
403
+ `./hello.nika`. The compatible local engine captures an immutable
404
+ snapshot, and the SDK sends its exact bytes and verifies the acknowledgement.
405
+ Only this path needs `bin`, `NIKA_BIN`, or the exact optional native package.
406
+ Observation and scheduling also use the server identity alone.
407
+
408
+ The current persistent server requires a project file. If you ran
409
+ `nika init --project-file` above you already have one (it carries a default
410
+ cost ceiling); do not overwrite it. Otherwise a minimal `nika.yaml` is enough:
411
+
412
+ ```yaml
413
+ nika: my-project
414
+ ```
415
+
416
+ Create a private bearer-token file and start the listener:
417
+
418
+ ```sh
419
+ mkdir -p .nika
420
+ umask 077
421
+ openssl rand -hex 24 > .nika/serve.token
422
+ chmod 600 .nika/serve.token
423
+
424
+ nika serve \
425
+ --bind 127.0.0.1:8787 \
426
+ --workflows . \
427
+ --token-file .nika/serve.token \
428
+ --state-root .nika/serve
429
+ ```
430
+
431
+ Connect from Node:
432
+
433
+ ```ts
434
+ import { readFile } from 'node:fs/promises';
435
+ import { Nika } from '@supernovae-st/nika';
436
+
437
+ const token = (await readFile('.nika/serve.token', 'utf8')).trim();
438
+ const nika = new Nika({
439
+ url: 'http://127.0.0.1:8787',
440
+ token,
441
+ allowInsecureHttp: true, // required for explicit loopback HTTP
442
+ cwd: process.cwd(),
443
+ // bin: '/absolute/path/to/nika',
444
+ });
445
+
446
+ const report = await nika.check('hello.nika');
447
+ const run = await nika.run('hello.nika', {
448
+ idempotencyKey: 'hello-2026-08-30', // persist before admission; reuse on retry
449
+ });
450
+ for await (const event of run.events()) {
451
+ console.log(event.sequence, event.kind, event.status);
452
+ }
453
+ console.log(await run.result());
454
+ ```
455
+
456
+ The application code after `new Nika(...)` is the same as the local one: the
457
+ events read `run.started` then `run.settled`, in the same words. The resident
458
+ streams no per-task frame today, so an HTTP run yields no `task.*` event; the
459
+ SDK never invents one. `event.sequence` is the resident's replay cursor and
460
+ exists only over HTTP.
461
+
462
+ HTTP `run()` requires a caller-owned `idempotencyKey` before it sends a request.
463
+ Persist a unique key for each business operation. If the response is lost or times
464
+ out, retry the same request with that key; a new key can admit a second job.
465
+ Direct native runs omit the key and reject one if supplied.
466
+
467
+ If the Node process restarts after admission, recover the durable job without
468
+ submitting the workflow again:
469
+
470
+ ```ts
471
+ const recovered = await nika.attachRun(saved.jobId, {
472
+ lastEventId: saved.lastEventSequence,
473
+ });
474
+ for await (const event of recovered.events()) {
475
+ await saveApplicationCheckpoint(recovered.id, event.sequence);
476
+ }
477
+ console.log(await recovered.result());
478
+ ```
479
+
480
+ `attachRun` is the one recovery door, and it returns a full `NikaRun`. A run
481
+ handle is process-bound and is not serialized: persist the **job id** and the
482
+ last committed `event.sequence` in application state. That id is durable only
483
+ over HTTP, where `run.id` is the resident's job id. A native `run.id` is an
484
+ ephemeral correlation id of the SDK process: it appears in no journal, cannot
485
+ be recovered after the process ends, and `attachRun` refuses it. The
486
+ idempotency namespace spans the server's entire `state-root` and currently has
487
+ no TTL; use globally unique business keys and do not recycle them between
488
+ workflows.
41
489
 
42
- AGPL-3.0-or-later
490
+ When observation loses connectivity past its retry budget, the SDK performs
491
+ one final durable read before giving up: a terminal record settles
492
+ `run.result()` from the workflow's truth, and a still-running record rejects
493
+ with `NikaObservationInterrupted`, whose `lastSequence` feeds
494
+ `attachRun(id, { lastEventId })` to resume. That error is about this client's
495
+ view, not about the run, which may still be running. It is not the engine's
496
+ own `interrupted` state: a resident that lost an execution says so with a
497
+ `run.interrupted` event and `result.status === 'interrupted'`, as data.
43
498
 
44
- ## Links
499
+ Plain HTTP is accepted only for a loopback host (`localhost`, `127.0.0.0/8`,
500
+ `[::1]`), and only when `allowInsecureHttp: true` is explicit. Every other host
501
+ must use HTTPS: the opt-in widens the scheme, never the destination, so the
502
+ bearer token never leaves the machine in plaintext. A URL may not contain
503
+ credentials, a query, or a fragment, and a 32–512 byte visible-ASCII token is
504
+ mandatory.
505
+
506
+ A remote run by served name carries per-call `inputs` once the resident
507
+ advertises `jobInputs` (see [Workflow inputs](#workflow-inputs)); a snapshot
508
+ takes none. There is no request envelope for `model`, and none for the
509
+ deprecated `vars`: declare a model in the workflow. There is no per-run spend
510
+ bound over HTTP at all today: `maxCostUsd` is refused, the workflow language
511
+ has no budget field, and the resident applies its own server-wide default
512
+ ceiling. Bound a remote run by its model and `max_tokens` until the request
513
+ envelope carries a ceiling. Likewise, remote `check` does not accept `model`
514
+ or `nativeStrict` overrides. Supplying these options returns a typed
515
+ compatibility refusal instead of silently dropping them.
516
+
517
+ ## Resident schedules
518
+
519
+ Scheduling belongs to the resident HTTP authority. A direct native-process
520
+ client refuses `schedule` and `scheduleStatus` because a short-lived process
521
+ cannot honestly own durable schedule state.
522
+
523
+ ```ts
524
+ const applied = await nika.schedule('hello.nika', {
525
+ id: 'weekday-hello',
526
+ when: { kind: 'cadence', expression: 'TZ=Europe/Paris 0 9 * * 1-5' },
527
+ maxCostUsd: 0.01,
528
+ missed: 'catch-up-once',
529
+ overlap: 'skip',
530
+ afterSkip: 'next_slot',
531
+ });
532
+
533
+ const status = await nika.scheduleStatus('weekday-hello');
534
+ console.log(applied.changed, status.next, status.lastDecision);
535
+
536
+ await nika.schedule('hello.nika', {
537
+ id: 'weekday-hello',
538
+ when: { kind: 'cadence', expression: 'TZ=Europe/Paris 0 9 * * 1-5' },
539
+ maxCostUsd: 0.01,
540
+ missed: 'catch-up-once',
541
+ overlap: 'skip',
542
+ afterSkip: 'next_slot',
543
+ revision: status.revision,
544
+ active: false,
545
+ pauseReason: 'maintenance',
546
+ pauseUntil: '2026-09-01',
547
+ });
548
+ ```
549
+
550
+ Creates use `If-None-Match: *`; updates use the exact prior revision through
551
+ `If-Match`. Revisions are the opaque `sha256:<64 lowercase hex>` values returned
552
+ by the engine; callers must not invent placeholders. Stale well-formed writers
553
+ receive a typed operation error with the current revision. Returned planning
554
+ facts are engine-owned and additive.
555
+
556
+ Treat any `status.finding` recovered from older state as non-runnable. New active
557
+ declarations the current engine cannot plan are refused before durable mutation.
558
+ Timed hash jitter is currently unsupported and returns a typed refusal.
559
+ Cron expressions carry their zone as `TZ=<IANA zone> ...`; `tolerance` uses
560
+ `m/k`; `afterSkip` requires `overlap: "skip"` (the engine's default, so an
561
+ omitted `overlap` satisfies it); and `active: false` requires a `pauseReason`
562
+ together with a `pauseUntil` ISO calendar date (`YYYY-MM-DD`). Schedules refuse
563
+ `maxCostUsd: 0` ("must be positive and finite") where a native `run()` accepts
564
+ it; a scheduled budget is always a real number.
565
+
566
+ ## Transport matrix
567
+
568
+ | Operation | Native process | HTTP |
569
+ |---|---|---|
570
+ | `check` | yes; `model` and `nativeStrict` allowed | yes; those two overrides refused |
571
+ | `run` | yes; `model`, `maxCostUsd` allowed | yes; `idempotencyKey` required; `model`, `maxCostUsd` refused |
572
+ | `run` `inputs` | literal JSON over stdin; engine must advertise `inputsLiteral` | literal JSON by served name; resident must advertise `jobInputs`; a snapshot refuses them |
573
+ | `run` `vars` (deprecated) | the `--var` operator channel, unchanged | typed refusal |
574
+ | `attachRun` | typed refusal: a native run is process-bound | reattach to a durable job with an optional SSE cursor |
575
+ | `run.status()` | typed refusal; await `run.result()` | durable status projection |
576
+ | `run.events()` | lifecycle words over the engine's task and run frames | the same lifecycle words over sequenced SSE frames with bounded replay; no per-task frame |
577
+ | `run.cancel()` | signal-backed, idempotent | 200 settles the job; 202 accepts the request and `run.result()` settles on the resident's terminal |
578
+ | `run.id` | ephemeral correlation id; never durable | the resident's durable job id; the one to persist |
579
+ | `traceVerify` | engine verification + signed receipt binding | typed verdict: `unavailable` until remote journal authority exists, then the CLI's tiers |
580
+ | `schedule` / `scheduleStatus` | typed refusal | resident schedule authority |
581
+ | `listWorkflows` / `workflow` | typed refusal | contained path-free workflow catalog |
582
+
583
+ The lifecycle vocabulary is one; the cardinality is not. Native execution
584
+ exposes detailed task frames, HTTP exposes durable sequenced execution frames,
585
+ and the SDK names only the facts a transport actually emitted. Consumers must
586
+ not assume identical cardinality across transports. The protocol vocabulary
587
+ under `event.raw` stays deliberately open.
588
+
589
+ ## API
590
+
591
+ ### `new Nika(config?)`
592
+
593
+ Shared options:
594
+
595
+ - `cwd`: engine working directory and snapshot root
596
+ - `bin`: explicit engine path
597
+ - `eventBufferSize`: how many of a run's most recent frames a session retains
598
+ for a view opened after the fact, and the largest `bufferSize` a view may
599
+ ask for; default 4096. See [Observing a run after the fact](#observing-a-run-after-the-fact)
600
+ - `machineBufferBytes`: machine frame/diagnostic ceiling, default 64 KiB
601
+
602
+ Remote-only options:
603
+
604
+ - `url`, `token`
605
+ - `allowInsecureHttp`
606
+ - `requestTimeout`, default 30 seconds
607
+ - `fetch`, for a custom standards-compatible implementation
608
+
609
+ ### Methods
610
+
611
+ | Method | Result |
612
+ |---|---|
613
+ | `check(workflow, options?)` | `clean` plus the native check report or resident acknowledgement/refusal |
614
+ | `run(workflow, options?)` | admitted `NikaRun`; rejects without one when the engine refuses |
615
+ | `attachRun(id, options?)` | reattached durable HTTP `NikaRun`: the one recovery door |
616
+ | `traceVerify(receipt, options?)` | `NikaTraceVerifyResult` |
617
+ | `schedule(workflow, options)` | durable apply acknowledgement |
618
+ | `scheduleStatus(id)` | fresh engine schedule projection |
619
+ | `listWorkflows()` | contained resident workflow names |
620
+ | `workflow(name)` | path-free resident workflow metadata |
621
+
622
+ ### `NikaRun`
623
+
624
+ | Member | Result |
625
+ |---|---|
626
+ | `run.id` | `NikaRunId`: the durable job id over HTTP, an ephemeral correlation id natively |
627
+ | `run.events(options?)` | bounded `AsyncIterable<NikaRunEvent>` in the lifecycle vocabulary |
628
+ | `run.result()` | `Promise<NikaRunResult>`, settled once; an admitted failure resolves |
629
+ | `run.status()` | current durable HTTP status; a typed refusal natively |
630
+ | `run.cancel()` | `NikaCancelResult`; idempotent |
631
+ | `run.done` | compatibility alias of `run.result()`: the same promise |
632
+
633
+ Every member is bound to its run, so it can be extracted:
634
+ `const { events, result } = run`. The handle is process-bound; it carries no
635
+ `list`, `search`, proof, catalog or authoring door.
636
+
637
+ ### Observing a run after the fact
638
+
639
+ ```ts
640
+ const run = await nika.run('wide.nika');
641
+ const result = await run.result(); // first the result,
642
+ for await (const event of run.events()) {} // then every frame the session saw
643
+ ```
644
+
645
+ A view opened late is seeded with every frame the session observed, or it is
646
+ refused. It is never handed a shortened replay. How many frames a session
647
+ retains is `eventBufferSize`, **4096 by default**.
648
+
649
+ **The measured frame count: `3N + 3`, for one shape.** On the released 0.118.7
650
+ engine, a clean run of N independent `mock/echo` `infer` tasks wrote
651
+ `workflow_started`, then `task_scheduled`, `task_started` and `task_completed`
652
+ per task, then `workflow_completed` and `run_settled`. Two points were
653
+ measured: 1 task is 6 frames, 90 tasks are 273. That is this fixture, not a
654
+ law of N-task workflows. Other shapes write more: a `nika:wait` task and a
655
+ `nika:assert` task each showed one extra `permit_checked` frame, a failed task
656
+ writes `task_failed`, and retries, agents and `for_each` were not measured.
657
+ Count your own run rather than deriving it: `error.observed` below is the
658
+ number. A `nika serve` job was measured at two frames, so the bound matters on
659
+ a native process.
660
+
661
+ | `eventBufferSize` | frames retained | in the measured shape only |
662
+ |---|---|---|
663
+ | 256, the default up to 0.118.7 | 256 | below the 273 frames of the 90-task run |
664
+ | 4096, the default | 4096 | 15 times those 273 frames |
665
+
666
+ **What the bound costs.** It is finite on purpose and is never `Infinity`.
667
+ Every frame is bounded by `machineBufferBytes` (64 KiB), so the retained
668
+ **history** holds at most `eventBufferSize × machineBufferBytes` of frame text:
669
+ 4096 × 64 KiB = 256 MiB per run at both defaults, where 256 frames gave
670
+ 16 MiB. That is arithmetic, not a measurement: the 273 measured frames total
671
+ 0.15 MiB (mean 571 bytes, largest 1501). It bounds the history only, not the
672
+ session or the process: a frame already handed to your code lives as long as
673
+ you keep it, and every open view and every concurrent run adds its own. Nothing
674
+ here is a claim about heap or process memory. If that ceiling matters to you,
675
+ set `eventBufferSize` yourself: an explicit value is kept exactly as given, so
676
+ `eventBufferSize: 256` behaves as it always did.
677
+
678
+ **Past the bound.** A run that writes more frames than the bound still runs and
679
+ still succeeds: `run.result()` resolves as usual and is never affected. Only a
680
+ view is refused, with `NikaEventBufferOverflowError`, and its `reason` says
681
+ which of two different bounds was exceeded:
682
+
683
+ | `error.reason` | What happened | What to do |
684
+ |---|---|---|
685
+ | `replay_truncated` | the view was opened after the run had produced more frames (`error.observed`) than it can be given (`error.limit`); `error.retained` is how many the session still holds | when `retained === observed` nothing is lost: open the view with `bufferSize >= observed`. Otherwise the earlier frames are gone from this process: set `eventBufferSize >= observed` for the next run, or observe it live |
686
+ | `live_backpressure` | a view that was observing live fell more than `error.limit` frames behind the stream | read faster, or raise that view's `bufferSize`. Other views and the result are unaffected |
687
+
688
+ ```ts
689
+ try {
690
+ for await (const event of run.events()) render(event);
691
+ } catch (error) {
692
+ if (error instanceof NikaEventBufferOverflowError && error.reason === 'replay_truncated') {
693
+ // The run is fine. Its history is longer than this view can replay.
694
+ console.warn(`${error.observed} frames, ${error.retained} retained; run ${result.status}`);
695
+ } else throw error;
696
+ }
697
+ ```
698
+
699
+ Neither refusal skips a frame, and neither is about the run. `error.observed`
700
+ is a count taken when the view was opened. The engine's journal stays the
701
+ source of truth for a native trace (`receipt.trace_path`, `traceVerify`): a
702
+ late `run.events()` is a convenience over what this process already saw, and
703
+ the SDK reads no journal to extend it. Over HTTP the resident holds the job,
704
+ so recover there with `attachRun(id, { lastEventId })`.
705
+
706
+ ### The lifecycle vocabulary
707
+
708
+ `run.events()` yields `NikaRunEvent`: a lifecycle `kind` that is the same on
709
+ both transports, plus the exact protocol frame on `raw`.
710
+
711
+ | `event.kind` | Native frame (`event.raw.kind`) | HTTP frame (`event.raw.kind`) |
712
+ |---|---|---|
713
+ | `run.started` | `workflow_started` | `execution.started` |
714
+ | `task.scheduled` · `task.started` · `task.completed` · `task.failed` | `task_scheduled` · `task_started` · `task_completed` · `task_failed` | none: the resident streams no per-task frame |
715
+ | `run.waiting` | `run_settled` carrying `paused` | `execution.settled` carrying `paused` |
716
+ | `run.settled` | `run_settled` carrying `succeeded` · `failed` · `cancelled` | `execution.settled` carrying `succeeded` · `failed` · `cancelled`; `execution.cancelled` carrying `cancelled` only; `execution.refused` carrying `failed` only |
717
+ | `run.interrupted` | `workflow_interrupted` carrying `interrupted` | `execution.interrupted` · `interrupted` carrying `interrupted` |
718
+ | `run.sealed` | `run_sealed` | none |
719
+ | `engine.event` | every other frame (`workflow_completed`, `workflow_paused`, `permit_checked`, …) | every other frame, a `null` or future kind included |
720
+
721
+ The SDK names a fact only when the engine wrote it. A frame that speaks of the
722
+ run's state earns its name only for a (kind, status) pair a producer defines;
723
+ the pairs are the ones listed above and nothing is computed from them. The
724
+ engine's state word decides and is never defaulted, so an absent, null, future
725
+ or still-running status stays an `engine.event`, and so does a terminal word
726
+ on the wrong dedicated kind: `execution.refused` carrying `succeeded` or
727
+ `cancelled`, or `execution.cancelled` carrying `succeeded` or `failed`,
728
+ contradicts itself and is never called settled. `event.raw` still holds that
729
+ frame, and `run.result()` still reads the state word the engine wrote. The
730
+ projection keeps no state between frames, deduplicates nothing, and never
731
+ synthesizes an event, so transports differ in cardinality but never in names.
732
+
733
+ ```ts
734
+ for await (const event of run.events()) {
735
+ switch (event.kind) {
736
+ case 'run.started': break;
737
+ case 'task.completed': console.log('done:', event.task); break;
738
+ case 'task.failed': console.error(event.task, event.error?.code); break;
739
+ case 'run.waiting': console.log('a human gate holds the run'); break;
740
+ case 'run.settled': console.log('ended:', event.status); break;
741
+ case 'run.interrupted': console.warn('the engine lost this execution'); break;
742
+ default: break; // additive vocabulary: event.raw.kind names the frame
743
+ }
744
+ }
745
+ ```
746
+
747
+ `event.status` is always the engine's own word (a waiting run reads `paused`);
748
+ `event.task` is the task a `task.*` frame named; `event.error` is the failure
749
+ a `task.failed` frame or a `failed` settlement named, and no other state
750
+ carries one; `event.sequence` is the resident's replay cursor and exists only
751
+ over HTTP.
752
+ An `engine.event` is given no lifecycle meaning: it carries its cursor and
753
+ `raw`, never a `status`.
754
+
755
+ `run.waiting` is not `run.settled`: a human gate holds a resumable run, which
756
+ has neither failed nor completed. `run.interrupted` is the engine's report
757
+ that it lost an execution, whose settlement is unknown; it is unrelated to
758
+ the thrown `NikaObservationInterrupted`, which means this client lost its view
759
+ of a run that may still be running.
760
+
761
+ ### Migrating to the Run-owned lifecycle
762
+
763
+ The client-level lifecycle methods are deprecated and stay for one release
764
+ train. They keep the ownership check: a run this client did not create still
765
+ throws `NikaRunOwnershipError`, because there is no global run registry.
766
+
767
+ **The compatibility window.** A release train is one published
768
+ SDK-and-engine version, the meaning this repository already uses (see
769
+ [Keeping it fresh](#keeping-it-fresh)). The window is counted from
770
+ publication, never from a merge:
771
+
772
+ 1. It opens with the first train **published to npm** whose package carries
773
+ the Run-owned lifecycle. That train ships the wrappers, unchanged, next to
774
+ the new API.
775
+ 2. The earliest train that may remove them is the one **after** it. Removal
776
+ is not automatic: it is decided by the One SDK baseline owner
777
+ ([#114](https://github.com/supernovae-st/nika-client/issues/114)) and is
778
+ announced in the release notes of the train that performs it.
779
+ 3. No version and no date are fixed here. Until a train carrying this API is
780
+ published, nothing has started counting and the wrappers stay.
781
+
782
+ | Deprecated | Use | What changes |
783
+ |---|---|---|
784
+ | `nika.events(run, options?)` | `run.events(options?)` | lifecycle `kind`; the protocol frame the wrapper yields is `event.raw` |
785
+ | `nika.cancel(run)` | `run.cancel()` | nothing: the same memoized request |
786
+ | `nika.status(run)` | `run.status()` | nothing |
787
+ | `await run.done` | `await run.result()` | nothing: `done` stays as an alias of the same promise |
788
+
789
+ `nika.events(run)` still yields the protocol vocabulary exactly as before, so
790
+ existing consumers keep working unchanged while they migrate:
791
+
792
+ ```ts
793
+ // before // after
794
+ for await (const e of nika.events(run)) { for await (const e of run.events()) {
795
+ if (e.kind === 'workflow_started' || if (e.kind === 'run.started') start();
796
+ e.kind === 'execution.started') start();
797
+ } }
798
+ ```
799
+
800
+ ### Typed protocol events, outputs, and identities
801
+
802
+ `NikaEvent` is the protocol frame under `event.raw` (and what the deprecated
803
+ `nika.events(run)` yields): a discriminated union over the known protocol
804
+ kinds of both transports. A native engine process emits `workflow_started`,
805
+ `task_scheduled`, `task_started`, `task_completed`, `workflow_completed`,
806
+ `workflow_failed`, `workflow_interrupted`, `run_settled`, and `run_sealed`. A
807
+ `nika serve` job streams `execution.started`, `execution.settled`,
808
+ `execution.cancelled`, `execution.refused`, and `execution.interrupted` (a
809
+ resident that restarts marks an orphaned running job `interrupted`). Kinds
810
+ this SDK version does not know yet stay representable through the
811
+ `NikaUnknownEvent` fallback, so the union is intentionally non-exhaustive and
812
+ every variant keeps its future fields open.
813
+
814
+ `run` and `attachRun` accept one `Outputs` type argument. It types the
815
+ terminal settlement — `run.result()` and, on the protocol frame, the
816
+ `run_settled` / `execution.settled` / `workflow_completed` frames — without
817
+ any runtime validation, and defaults to `Record<string, unknown>` so untyped
818
+ callers see no change:
819
+
820
+ ```ts
821
+ const run = await nika.run<{ answer: number }>('flow.nika');
822
+ const result = await run.result(); // result.outputs?: { answer: number }
823
+
824
+ for await (const event of run.events()) {
825
+ if (isNikaRunSettledEvent(event.raw)) {
826
+ // The settlement frame of either transport (`run_settled` natively,
827
+ // `execution.settled` over HTTP): status, outputs, and receipt typed
828
+ // together on the one frame that carries all three.
829
+ console.log(event.raw.status, event.raw.outputs?.answer, event.raw.receipt);
830
+ }
831
+ }
832
+ ```
833
+
834
+ The protocol guards read `event.raw`. A run can also end without settling
835
+ outputs — cancelled, refused, or interrupted. `isNikaTerminalEvent(event.raw)`
836
+ narrows those too: it reads the engine-reported `status` (`succeeded`,
837
+ `failed`, `interrupted`, `cancelled`) rather than the kind, so it holds on
838
+ either transport and on kinds this SDK version does not know yet:
839
+
840
+ ```ts
841
+ for await (const event of run.events()) {
842
+ if (isNikaTerminalEvent(event.raw)) {
843
+ console.log('no further frames for this run:', event.raw.status);
844
+ }
845
+ }
846
+ ```
847
+
848
+ Run, execution, and job identities are branded opaque strings (`NikaRunId`,
849
+ `NikaExecutionId`, `NikaJobId`). They remain assignable to `string`, but a
850
+ plain `string` no longer stands in for one. Four words name three things:
851
+ a **workflow** is the file (or resident name) you pass in; a **run** is this
852
+ client's handle on one admission (`run.id`), and over HTTP that same string
853
+ is the server's **job** id (`/v1/jobs/{id}`, `attachRun(jobId)`); an
854
+ **execution** is the engine's own identity for what actually ran
855
+ (`execution_id`, the `execution.*` event kinds), distinct from the run id and
856
+ carried by the receipt together with the `trace_id`.
857
+
858
+ ## Errors
859
+
860
+ Every error the SDK raises for an engine, transport, configuration, or
861
+ compatibility condition extends `NikaError`. Misuse of the API itself (an empty
862
+ workflow name, a negative event cursor, a receipt that is not an object, a
863
+ workflow name that escapes the catalog) throws a plain `TypeError` or
864
+ `RangeError` before any engine or network work starts:
865
+
866
+ ```text
867
+ NikaError
868
+ ├── NikaConfigurationError
869
+ ├── NikaEngineUnavailable
870
+ ├── NikaTransportError
871
+ │ ├── NikaProtocolError
872
+ │ └── NikaObservationInterrupted
873
+ ├── NikaCompatibilityError
874
+ ├── NikaOperationError
875
+ ├── NikaEventBufferOverflowError
876
+ └── NikaRunOwnershipError
877
+ ```
878
+
879
+ `NikaEventBufferOverflowError` is never about the run: `run.result()` is
880
+ unaffected by it. Its `reason` tells a live view that fell behind
881
+ (`live_backpressure`) from a view opened after more frames than it can replay
882
+ (`replay_truncated`, with `observed` and `retained`); see
883
+ [Observing a run after the fact](#observing-a-run-after-the-fact).
884
+
885
+ Native engine event vocabulary stays open. HTTP events instead enforce the
886
+ closed, redacted `JobEvent` projection advertised by the pinned OpenAPI contract;
887
+ unknown HTTP fields are rejected at the trust boundary. The SDK never turns an
888
+ unpriced model into `$0`.
889
+
890
+ A refusal that `nika serve` types as `{ error: { code, message } }` surfaces as
891
+ `NikaOperationError` with the HTTP `status`, the server `code` (for example
892
+ `unauthorized`, `job_not_found`, `idempotency_conflict`, `malformed_snapshot`,
893
+ or a stamped `NIKA-…` admission code) and the `operation` that was refused.
894
+ Server messages are engine-owned and path-free; a reflected bearer token is
895
+ redacted before it reaches an error message. A non-2xx answer without that
896
+ typed body stays a `NikaTransportError` whose body is redacted entirely.
897
+
898
+ A native engine refuses a workflow before admitting it: a red check, a cost
899
+ floor above `maxCostUsd`, a required input left unset, a file it cannot read.
900
+ `run()` then rejects, before any `NikaRun` exists, with a `NikaOperationError`
901
+ carrying `operation: 'run'` and the engine's exit status in `status`:
902
+
903
+ ```ts
904
+ try {
905
+ const run = await nika.run('./workflow.nika');
906
+ const result = await run.result(); // admitted: a failure here is result data
907
+ } catch (error) {
908
+ if (error instanceof NikaOperationError && error.operation === 'run') {
909
+ console.error(error.code); // 'NIKA-SEC-004'
910
+ for (const finding of error.findings ?? []) console.error(finding.message);
911
+ } else throw error;
912
+ }
913
+ ```
914
+
915
+ - `code` is the engine's own code: the first check finding that names one
916
+ (`NIKA-SEC-004`, `NIKA-PARSE-005`, `NIKA-AUTH-006`, …) or the refusal's code
917
+ (`NIKA-1709`, `NIKA-1708`). `machineCode` repeats it. When the engine named
918
+ none, as for an unreadable file, `code` is the SDK's `run_refused` and
919
+ `machineCode` is absent; the SDK never supplies an engine code.
920
+ - `findings` holds the engine's check findings untouched, as
921
+ `NikaCheckFinding` (`code?`, `message`, `severity`, `gate`, `kind`, `task`,
922
+ `docs_url`). A budget or launch refusal has no findings.
923
+ - Nothing was admitted, so nothing else exists: no run id, no events, no
924
+ trace, no receipt.
925
+
926
+ Output that proves neither an admission nor a refusal (a line that is not
927
+ machine output, a truncated or oversized report, an engine that exits without
928
+ a frame) rejects `run()` with `NikaProtocolError` instead.
929
+
930
+ ## Security boundaries
931
+
932
+ - Token files stay out of argv and must be private (`0600`, 32–512 visible
933
+ ASCII bytes).
934
+ - The constructor refuses plaintext HTTP off loopback, and requires the
935
+ explicit `allowInsecureHttp: true` opt-in on loopback.
936
+ - `permits` remain default-deny engine policy; SDK types do not grant authority.
937
+ - Machine frames, diagnostics, SSE lines, and observer queues are bounded.
938
+ - Receipts and traces are engine-issued proof. The SDK never synthesizes them.
939
+ - This package does not export a webhook-signature verifier. Verify webhook raw
940
+ bodies with the sender's official library before admitting a Nika workflow.
941
+
942
+ ## Development proof
943
+
944
+ ```sh
945
+ npm test
946
+ npx tsc --noEmit
947
+ npm run build
948
+
949
+ # Five clean installations from an npm tarball
950
+ NIKA_BIN=/absolute/path/to/nika npm run gauntlet:projects
951
+
952
+ # Concurrency, cancellation, corrupt streams/traces, redaction, and soak
953
+ NIKA_BIN=/absolute/path/to/nika npm run gauntlet:hostile
954
+ ```
955
+
956
+ The repository also carries 100 distinct use-case workflows and provider proof
957
+ under `gauntlet/`.
958
+
959
+ <!-- engine hero pinned to the release tag it demonstrates · re-pin on lockstep bumps -->
960
+ ![nika check audits the workflow, then runs and seals its trace](https://raw.githubusercontent.com/supernovae-st/nika/v0.118.7/media/nika-hero.gif)
961
+
962
+ ## Keeping it fresh
963
+
964
+ The client and engine follow one release train. `nika doctor` reports installed
965
+ drift without treating it as a workflow failure.
966
+
967
+ ```sh
968
+ nika doctor
969
+ brew upgrade nika
970
+ npm update @supernovae-st/nika
971
+ ```
972
+
973
+ <!-- city:map -->
974
+ ## The city · where this repo sits
975
+
976
+ ```text
977
+ 📜 nika-spec ──── language law and conformance
978
+ │
979
+ ▼
980
+ ⚙️ nika ───────── engine, admission, execution, receipts and schedules
981
+ │
982
+ ▼
983
+ 🔌 nika-client ── this door, published as @supernovae-st/nika: native process or authenticated HTTP
984
+ │
985
+ ▼
986
+ 🧩 Node.js applications
987
+ ```
988
+
989
+ This repository consumes engine behavior and serves TypeScript/JavaScript
990
+ applications. It is not authoritative for the workflow language.
991
+
992
+ All the buildings: [nika-spec](https://github.com/supernovae-st/nika-spec) ·
993
+ [nika](https://github.com/supernovae-st/nika) ·
994
+ [nika.sh](https://github.com/supernovae-st/nika.sh) ·
995
+ [nika-docs](https://github.com/supernovae-st/nika-docs) ·
996
+ [nika-client](https://github.com/supernovae-st/nika-client) ·
997
+ [nika-vscode](https://github.com/supernovae-st/nika-vscode) ·
998
+ [nika-plugins](https://github.com/supernovae-st/nika-plugins) ·
999
+ [gh-nika](https://github.com/supernovae-st/gh-nika) ·
1000
+ [homebrew-tap](https://github.com/supernovae-st/homebrew-tap) ·
1001
+ [nika-action](https://github.com/supernovae-st/nika-action) ·
1002
+ [nika-actions-starter](https://github.com/supernovae-st/nika-actions-starter) ·
1003
+ [nika-registry](https://github.com/supernovae-st/nika-registry) ·
1004
+ [nika-estate](https://github.com/supernovae-st/nika-estate).
1005
+ <!-- /city:map -->
1006
+
1007
+ ## License
45
1008
 
46
- - [GitHub Repository](https://github.com/supernovae-st/nika)
47
- - [SuperNovae Studio](https://supernovae.studio)
1009
+ [Apache-2.0](LICENSE). The engine remains AGPL-3.0-or-later; importing this SDK
1010
+ does not impose the engine's copyleft license on your application.