@supernovae-st/nika 0.118.7 → 0.120.2

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
@@ -9,139 +9,380 @@
9
9
 
10
10
  <h1 align="center">@supernovae-st/nika</h1>
11
11
 
12
- <p align="center"><strong>One TypeScript surface for local Nika processes and authenticated Nika servers.</strong></p>
12
+ <p align="center">
13
+ <strong>Run AI workflows from TypeScript.</strong><br>
14
+ Audit a <code>.nika</code> file, run it, read the result, verify the receipt — locally
15
+ or against authenticated <code>nika serve</code>.
16
+ </p>
13
17
 
14
- `Nika` exposes one lifecycle vocabulary: `check`, `run`, `attachRun`, `status`, `events`,
15
- `cancel`, `traceVerify`, `listWorkflows`, `workflow`, `schedule`, and
16
- `scheduleStatus`.
17
- The engine remains authoritative for parsing, admission, execution, receipts,
18
- traces, permits, scheduling, and cost. The SDK transports those facts; it does
19
- not parse YAML or reconstruct proof in TypeScript.
18
+ <p align="center">
19
+ <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>
20
+ <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>
21
+ <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>
22
+ <a href="https://docs.nika.sh"><img src="https://img.shields.io/badge/docs-docs.nika.sh-8b8cf8.svg" alt="Documentation"></a>
23
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="Apache-2.0"></a>
24
+ </p>
20
25
 
21
- ## Requirements
26
+ <p align="center">
27
+ <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>
28
+ <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>
29
+ <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>
30
+ <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>
31
+ </p>
22
32
 
23
- - Node.js 22 or newer (the tested floor; an older major is unsupported, not
24
- refused, and `npm install` does not warn about it)
25
- - for native execution or local snapshot capture, a compatible `nika` engine, resolved from `config.bin`, then `NIKA_BIN`
26
- (absolute paths only), then the exact optional platform package; a bare
27
- name or a relative path is refused because the operating system would
28
- resolve it through `PATH` or the working directory, and a `nika` found on
29
- `PATH` is deliberately never used
30
- - a `.nika.yaml` workflow
33
+ ## Run a workflow from your app
31
34
 
32
- ## Documentation
35
+ Put repeatable AI work in a `.nika` file. From Node, run it and read the
36
+ result. No server and no API key for the first run: `mock/echo` is a
37
+ **local simulation** (output is prefixed `mock(echo) ·`, not a model answer).
33
38
 
34
- - [Architecture](docs/architecture.md) — Modules, Interface, Seam, Adapters,
35
- lifecycle, and authority boundaries
36
- - [HTTP contract](docs/http-api.md) — every live route, recovery, security,
37
- idempotency, and schedule CAS
38
- - [Testing and release evidence](docs/testing.md) — layered gauntlets and the
39
- Socratic risk matrix
40
- - [Migrating to 0.116](docs/migrating-to-0.116.md) — intentional breaking
41
- migration to the smaller durable client surface
39
+ ```sh
40
+ npm install @supernovae-st/nika
41
+ ```
42
42
 
43
- ## Install
43
+ Save as `hello.nika`:
44
44
 
45
- ```sh
46
- npm view @supernovae-st/nika@0.118.7 version # must report 0.118.7
47
- npm install @supernovae-st/nika@0.118.7
45
+ ```yaml
46
+ nika: hello
47
+ model: mock/echo
48
+ permits: {}
49
+ tasks:
50
+ greeting:
51
+ infer:
52
+ prompt: "Say hello from the Nika SDK."
53
+ max_tokens: 32
54
+ outputs:
55
+ greeting: ${{ tasks.greeting.output }}
48
56
  ```
49
57
 
50
- If the registry reports any other version, the 0.118.7 release train is not
51
- complete. Earlier packages expose the retired `LocalNika`/HTTP split and do
52
- not implement the root facade documented below. The publication is complete
53
- only when the four matching native payload packages and this root client are
54
- all visible on npm.
58
+ Save as `demo.mjs`:
55
59
 
56
- This package carries the product's name. Up to 0.115.0 it was published as
57
- `@supernovae-st/nika-client`; that name is deprecated on npm, stays installable
58
- for the versions it already holds, and receives no further releases. The
59
- native payloads were already `@supernovae-st/nika-<os>-<arch>`, and the
60
- repository keeps its name (`supernovae-st/nika-client`).
60
+ ```js
61
+ import { Nika, isNikaRunSucceeded } from '@supernovae-st/nika';
61
62
 
62
- Verify the package that the current project actually resolved:
63
+ const run = await new Nika({ cwd: process.cwd() }).run('hello.nika', { maxCostUsd: 0 });
64
+ const result = await run.result();
65
+ if (!isNikaRunSucceeded(result)) {
66
+ console.error(result.status, result.error?.code, result.error?.message);
67
+ process.exitCode = 1;
68
+ } else {
69
+ console.log(result.outputs);
70
+ // { greeting: "mock(echo) · Say hello from the Nika SDK." }
71
+ }
72
+ ```
63
73
 
64
74
  ```sh
65
- node -p "require('@supernovae-st/nika/package.json').version"
75
+ node demo.mjs
66
76
  ```
67
77
 
68
- This package metadata subpath is exported for CommonJS, ESM build tools and CI
69
- pin checks. It reports the installed dependency, not a moving registry tag.
78
+ `run()` already admits: a red file throws `NikaOperationError` and never
79
+ returns a handle. The `.nika` file is the contract; the SDK does not parse
80
+ YAML. Pin the version you tested — see [Install](#install): the SDK and the
81
+ standalone engine CLI release on independent clocks.
70
82
 
71
- ## First local run
83
+ `check()`, `run.events()`, and `traceVerify()` are the next steps. They stay
84
+ taught and tested; they are not required to see the first result.
72
85
 
73
- The lowest-friction creation door is the engine-owned scaffold:
86
+ ### Next: audit without running (`check`)
74
87
 
75
88
  ```sh
76
- ./node_modules/.bin/nika init --project-file
77
- ./node_modules/.bin/nika new 01-hello hello.nika.yaml
89
+ ./node_modules/.bin/nika check hello.nika
78
90
  ```
79
91
 
80
- `nika.yaml` is the project control plane. `hello.nika.yaml` is executable
81
- workflow intent and is the file passed to `check()` and `run()`. The scaffold
82
- writes the engine's own annotated `01-hello` example (its task is named
83
- `greet` and its prompt asks for French); the contract this README relies on is
84
- the `outputs.greeting` key and the `mock/echo` model, and the same file can be
85
- written by hand with this public envelope:
86
-
87
- ```yaml
88
- nika: sdk-hello
89
- model: mock/echo
90
- permits: {}
91
- tasks:
92
- greeting:
93
- infer:
94
- prompt: "Say hello from the Nika SDK."
95
- max_tokens: 32
96
- outputs:
97
- greeting: ${{ tasks.greeting.output }}
98
92
  ```
93
+ ✔ ORDER no exec: sits downstream of a net-effecting task · unauthored content never reaches a shell
94
+ ✔ PERMITS literal + const: args fit the boundary · computed paths + symlinks are the RUN's verdict
95
+ ✔ TRIFECTA no lethal trifecta over the declared permits: without a human gate
96
+ ✔ JOURNEY internal · 0 sources · 0 destinations · 1 model endpoint · no secret reaches an external destination
97
+ ✔ audited · 1 task · 1 wave · permits {} · est out ≤$0.0000 · 0 hints · risk low
98
+ layers · valid ✔ · access ready ✔ · capacity fit ✔ · run ready ✔
99
+ ```
100
+
101
+ The same report is `await nika.check('hello.nika', { nativeStrict: true })`.
102
+ A red check never becomes a run.
99
103
 
100
- Then drive the installed engine:
104
+ ### Next: watch the run (`run.events()`)
101
105
 
102
106
  ```ts
103
- import { Nika } from '@supernovae-st/nika';
107
+ for await (const event of run.events()) {
108
+ console.log([event.kind, event.task, event.status].filter(Boolean).join(' '));
109
+ }
110
+ ```
104
111
 
105
- const nika = new Nika({
106
- cwd: process.cwd(),
107
- // bin: '/absolute/path/to/nika', // or set NIKA_BIN
108
- });
112
+ Expected native events for this one-task file: `run.started`,
113
+ `task.scheduled greeting`, `task.started greeting`,
114
+ `task.completed greeting`, `engine.event` (`workflow_completed` on
115
+ `event.raw.kind`), then `run.settled succeeded`. Six `run.events()` frames,
116
+ sealed or not (probed on 0.120.0). A session retains the most recent 4096
117
+ frames by default; see
118
+ [Observing a run after the fact](#observing-a-run-after-the-fact).
109
119
 
110
- const report = await nika.check('hello.nika.yaml', {
111
- nativeStrict: true,
112
- });
113
- if (!report.clean) throw new Error('workflow did not pass nika check');
120
+ ### Next: verify a seal (`traceVerify`)
114
121
 
115
- const run = await nika.run('hello.nika.yaml', { maxCostUsd: 0 });
116
- const watching = (async () => {
117
- for await (const event of nika.events(run)) {
118
- // Native progress frames carry no status; only the terminal frame does.
119
- console.log(event.kind, event.status ?? '');
120
- }
121
- })();
122
+ `traceVerify` **verifies** an existing seal. It does not create one. A keyless
123
+ machine still **succeeds**; the receipt is unsealed — see
124
+ [Admission, execution, seal](#admission-execution-seal).
122
125
 
123
- const result = await run.done;
124
- await watching;
125
- console.log(result.status, result.outputs, result.receipt);
126
- ```
126
+ <p align="center">
127
+ <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">
128
+ </p>
127
129
 
128
- Expected output: `workflow_started`, `task_scheduled`, `task_started`,
129
- `task_completed`, `workflow_completed`, then `run_settled succeeded`, then the
130
- terminal `succeeded` line with the outputs and the receipt.
130
+ *Recorded by `scripts/media/render.sh` against this package and the released
131
+ engine; every line on screen is the SDK's own output. The recorded driver
132
+ predates the Run-owned lifecycle: it still calls `nika.events(run)`, a
133
+ deprecated wrapper that keeps working through the
134
+ [compatibility window](#migrating-to-the-run-owned-lifecycle), and the
135
+ `run.done` alias, which is not deprecated.*
131
136
 
132
137
  Native checks and explicit local snapshot checks preserve the engine's
133
138
  `findings[]` and `exitCode`. A check by served name returns the resident's
134
139
  compact acknowledgement with `clean: true`, or its typed workflow refusal
135
140
  with `clean: false`; it does not invent local findings or an exit code.
136
141
 
137
- `run()` returns after stable admission. `run.done` is the sole terminal result.
142
+ `run()` returns after stable admission, and the `NikaRun` it returns owns its
143
+ lifecycle: `run.events()`, `run.result()`, `run.status()` and `run.cancel()`.
144
+ `run.result()` is the sole terminal result (`run.done` is its compatibility
145
+ alias, the same promise).
146
+ A workflow the engine refuses before admission never yields a run: `run()`
147
+ itself rejects with a `NikaOperationError` that names the engine's code and,
148
+ for a red check, carries its `findings[]` (see [Errors](#errors)). The engine
149
+ checks on every run, so no `check()` is needed first to be protected or taught.
150
+
151
+ ### Workflow inputs
152
+
153
+ `inputs` binds the workflow's declared `inputs:` by name, and means the same
154
+ thing on both transports:
155
+
156
+ ```ts
157
+ const run = await nika.run('support-triage.nika', {
158
+ inputs: { ticketId: '42' },
159
+ // idempotencyKey: `triage-${ticket.id}`, // HTTP only
160
+ });
161
+ ```
162
+
163
+ Values are strict JSON and stay literal. `'42'` stays a string and `42` a
164
+ number; `'@env:HOME'` and `'${{ tasks.x.output }}'` are text, never read from
165
+ the environment or evaluated; nothing is coerced to the declared type. The
166
+ engine owns the verdict and refuses before any run exists: an undeclared key
167
+ (`unknown_input`), a value that does not fit its declared type
168
+ (`input_type_mismatch`), a required input left out (`NIKA-1708`). Each rejects
169
+ `run()` as a `NikaOperationError` with that code, native (`status: 3`) or HTTP
170
+ (`status: 422`). Supplied values are recorded with `api-caller` provenance;
171
+ declared defaults keep `file`.
172
+
173
+ The SDK refuses, before it spawns or sends anything, a value JSON would
174
+ silently lose: `undefined`, a function, a symbol, a bigint, `NaN` or
175
+ `Infinity`, a cycle, a class instance (a `Date`, a `Map`), an object whose
176
+ prototype only claims to be plain, an array hole, an accessor. That is a
177
+ `NikaConfigurationError` naming the path (`inputs.ticket.tags[1] is
178
+ undefined`), never the value. No caller code runs while the map is judged: a
179
+ getter is never invoked, and a `Proxy` is refused before it is read, so none
180
+ of its traps run. The serialized map is
181
+ bounded at 1 MiB on both transports. Do not put a secret in `inputs`.
182
+
183
+ The engine must advertise the channel, and the SDK checks before admission:
184
+
185
+ - Native: `inputsLiteral` in `nika --sdk-identity`. The map rides the engine's
186
+ stdin (`nika run --inputs-json -`), so a value never appears in a process
187
+ listing.
188
+ - HTTP: `jobInputs` in `GET /health`, for a workflow run by its served name.
189
+ An execution snapshot froze its inputs and takes no overlay, so an HTTP run
190
+ of a local path (`./flow.nika`) refuses `inputs`, an empty map included.
191
+
192
+ An engine without the capability rejects with `NikaCompatibilityError`
193
+ (`capability: 'inputsLiteral'` or `'jobInputs'`) and nothing runs. The SDK
194
+ never falls back to `--var`, and a resident that merely answers 202 has
195
+ negotiated nothing: one from before the envelope accepts the field and ignores
196
+ its values.
197
+
198
+ The **published 0.120.0** payload advertises `inputsLiteral` (`nika --sdk-identity`).
199
+ Native `run({ inputs })` works on it. It does **not** advertise `jobInputs`;
200
+ HTTP literal inputs stay a compatibility refusal until a resident that does.
201
+
202
+ `vars` is deprecated. It remains the native `--var KEY=VALUE` operator channel,
203
+ unchanged: the engine reads `@env:NAME` from its environment and coerces text
204
+ to the declared type, so it cannot carry literal values and has no HTTP form.
205
+ `inputs` and `vars` together reject `run()`; they are never merged.
138
206
  An admitted workflow failure is result data with `status: "failed"` and, when
139
207
  the engine named the failing task, `error: { code, message, task }`; transport,
140
208
  protocol, configuration, and compatibility failures throw typed SDK errors.
141
- A `try { await run.done } catch {}` alone therefore never catches a failed
209
+ A `try { await run.result() } catch {}` alone therefore never catches a failed
142
210
  workflow: a CI job or an application must read `result.status` and treat
143
211
  anything but `succeeded` as its own failure, or a red run passes silently.
144
212
 
213
+ `isNikaRunSucceeded` is exported by published **0.120.0**. Use it (or
214
+ `result.status === 'succeeded'`) before treating a result as success.
215
+ Paused, failed, cancelled, and interrupted all return false:
216
+
217
+ ```ts
218
+ import { Nika, isNikaRunSucceeded } from '@supernovae-st/nika';
219
+
220
+ const run = await new Nika().run<{ answer: number }>('flow.nika');
221
+ const result = await run.result();
222
+ if (isNikaRunSucceeded(result)) {
223
+ console.log(result.outputs?.answer); // outputs stay typed and optional
224
+ } else {
225
+ console.error(result.status, result.error?.code, result.error?.message);
226
+ process.exitCode = 1;
227
+ }
228
+ ```
229
+
230
+ A paused result is a human gate, not a successful completion and not a
231
+ failure. It arrives as a `run.waiting` event, never as `run.settled`, and
232
+ `result.status` keeps the engine's word, `paused`. Applications can render
233
+ that state separately; a CI job awaiting completion must not pass it as
234
+ success. The guard reads the engine's status and never turns absent outputs
235
+ into a fabricated output map.
236
+
237
+ ## Why this door
238
+
239
+ - **Audited before it runs.** `check()` returns the engine's verdict on the
240
+ order of effects, the permits, the lethal trifecta, the journey of every
241
+ secret and the cost floor. A red check never becomes a run.
242
+ - **Sovereign by default.** The same file runs on local models (Ollama,
243
+ llama.cpp, vLLM), on Mistral, Hugging Face, OpenAI, xAI, Anthropic and the
244
+ rest of the engine's catalog; `mock/echo` is a local simulation with no key
245
+ and no network.
246
+ - **Traced after.** A native run writes a hash-chained journal and a receipt.
247
+ `traceVerify()` asks the engine to verify that evidence. A succeeded run can
248
+ still be unsealed. The SDK never re-implements the proof.
249
+ - **One vocabulary, two transports.** `check`, `run`, `events`, `cancel`,
250
+ `traceVerify` and `schedule` read the same against a local process and an
251
+ authenticated `nika serve`; only the constructor changes. Run events carry
252
+ the same lifecycle words on both (`run.started`, `run.waiting`,
253
+ `run.settled`), with the engine's own frame kept on `event.raw`.
254
+
255
+ ## Admission, execution, seal
256
+
257
+ Three different facts. Do not collapse them.
258
+
259
+ | Fact | How you see it | What it is not |
260
+ |---|---|---|
261
+ | **Admission** | `check()` returns `clean: false` and findings. `run()` **throws** `NikaOperationError` (`NIKA-PARSE-005`, `NIKA-1708`, …) and yields no handle. | Not a successful execution. |
262
+ | **Execution** | `run.result()` / `isNikaRunSucceeded(result)`. Admitted failure is **data** (`status: "failed"`). A human gate is `paused` (`run.waiting`), not success. | Not proof the journal is sealed. |
263
+ | **Seal** | `result.receipt.sealed` and `traceVerify(receipt)`. Tamper-evident, not tamper-proof; not replayed unless you pass `--replay`; not anchored without a sidecar. | Not “the workflow was correct” and not “a human read the output”. |
264
+
265
+ Probed on published 0.120.0 (2026-09-19): `run.events()` still yields the
266
+ same six lifecycle frames with or without a signing key. With
267
+ `~/.nika/keys/run-signing.*`, the hello fixture sealed and `traceVerify`
268
+ returned `{ verified: true, verdict: "verified" }`. With an isolated `HOME`
269
+ and no key files (keychain skipped: stderr not a TTY), the same file
270
+ **succeeded**, `sealed: false`, and `traceVerify` returned
271
+ `{ verified: false, verdict: "invalid", reason: "receipt_mismatch" }` while
272
+ the engine still said the journal chain was OK and **UNSEALED**. In this
273
+ keyless case that reason means no signed binding, not a failed workflow.
274
+ `receipt_mismatch` is also the engine's word for a tampered or
275
+ field-mismatched receipt — do not treat every occurrence as merely unsigned.
276
+ The CLI verify line that counts journal events is not `run.events()`.
277
+
278
+ ## One vocabulary
279
+
280
+ `Nika` exposes `check`, `run`, `attachRun`, `traceVerify`, `listWorkflows`,
281
+ `workflow`, `schedule`, and `scheduleStatus`. The `NikaRun` it returns owns the
282
+ run's lifecycle:
283
+
284
+ ```
285
+ NikaRun
286
+ ├── id
287
+ ├── events() one lifecycle vocabulary · event.raw keeps the protocol frame
288
+ ├── result() the one settlement · admitted failure is data, never a throw
289
+ ├── status() durable over HTTP · a typed refusal on a native process
290
+ └── cancel() idempotent
291
+ ```
292
+
293
+ `nika.events(run)`, `nika.cancel(run)` and `nika.status(run)` remain as
294
+ deprecated wrappers for one release train, counted from the first published
295
+ train that carries this API; see
296
+ [Migrating to the Run-owned lifecycle](#migrating-to-the-run-owned-lifecycle).
297
+ The handle owns observation, settlement, `status` and cancellation and nothing
298
+ else: checking, proof, catalogs and authoring stay on `Nika`.
299
+
300
+ The engine remains authoritative for parsing, admission, execution, receipts,
301
+ traces, permits, scheduling, and cost. The SDK transports those facts; it does
302
+ not parse YAML or reconstruct proof in TypeScript.
303
+
304
+ ## Requirements
305
+
306
+ - Node.js 22 or newer (the tested floor; an older major is unsupported, not
307
+ refused, and `npm install` does not warn about it)
308
+ - for native execution or local snapshot capture, a compatible `nika` engine, resolved from `config.bin`, then `NIKA_BIN`
309
+ (absolute paths only), then the exact optional platform package; a bare
310
+ name or a relative path is refused because the operating system would
311
+ resolve it through `PATH` or the working directory, and a `nika` found on
312
+ `PATH` is deliberately never used
313
+ - a `.nika` workflow
314
+
315
+ ## Documentation
316
+
317
+ - [Architecture](docs/architecture.md) · Modules, Interface, Seam, Adapters,
318
+ lifecycle, and authority boundaries
319
+ - [HTTP contract](docs/http-api.md) · every live route, recovery, security,
320
+ idempotency, and schedule CAS
321
+ - [Testing and release evidence](docs/testing.md) · layered gauntlets and the
322
+ Socratic risk matrix
323
+ - [Migrating to 0.116](docs/migrating-to-0.116.md) · the intentional breaking
324
+ migration to the smaller durable client surface
325
+ - [docs.nika.sh](https://docs.nika.sh) · language and engine
326
+ - [SDK quickstart](https://docs.nika.sh/sdk/start/quickstart) · app walkthrough
327
+ (install **npm `@supernovae-st/nika`**, matching this README)
328
+
329
+ ## Install
330
+
331
+ ```sh
332
+ npm install @supernovae-st/nika
333
+ ```
334
+
335
+ Every SDK package bundles its own matching engine: the `nika` binary under
336
+ `node_modules/.bin` is the exact engine that package was qualified against.
337
+ The standalone engine CLI (GitHub releases, brew, install script) releases on
338
+ an independent clock, so its newest tag can be ahead of or behind the engine
339
+ bundled in the latest npm package. The
340
+ [npm registry](https://www.npmjs.com/package/@supernovae-st/nika?activeTab=versions)
341
+ lists current published versions. Pin the version you tested, then verify the
342
+ package the project actually resolved:
343
+
344
+ ```sh
345
+ node -p "require('@supernovae-st/nika/package.json').version"
346
+ ./node_modules/.bin/nika --version
347
+ ```
348
+
349
+ This repository's source train is **0.120.2** (`package.json`), lockstep with
350
+ public engine tag `v0.120.2` (`289a9adea`). A source train is not a published
351
+ npm version until the release workflow publishes it.
352
+
353
+ This package metadata subpath is exported for CommonJS, ESM build tools and CI
354
+ pin checks. It reports the installed dependency, not a moving registry tag.
355
+ The native payloads `@supernovae-st/nika-<os>-<arch>` are optional
356
+ dependencies; npm installs the one that matches your platform.
357
+
358
+ Earlier packages expose the retired `LocalNika`/HTTP split and do not
359
+ implement the root facade documented here. This package carries the product's
360
+ name: up to 0.115.0 it was published as `@supernovae-st/nika-client`. That
361
+ name receives no further releases from this repository and stays installable
362
+ for the versions it already holds. It is **not** marked deprecated on the npm
363
+ registry: its published versions carry no `deprecated` field, so `npm install
364
+ @supernovae-st/nika-client` still succeeds without a warning and installs the
365
+ retired 0.115.0 API. The move to the new name says nothing about the
366
+ registry; check it yourself with `npm view @supernovae-st/nika-client
367
+ deprecated`. The repository keeps its name (`supernovae-st/nika-client`).
368
+
369
+ ## Scaffold with the engine
370
+
371
+ On the published 0.120.0 binary, `nika new` is **retired** (`unrecognized
372
+ subcommand`). The creation door is `compile`. Exact skeleton name or `hello`;
373
+ free-text intent stays incomplete and writes nothing:
374
+
375
+ ```sh
376
+ ./node_modules/.bin/nika init --project-file
377
+ ./node_modules/.bin/nika compile hello hello.nika
378
+ ```
379
+
380
+ No destination means preview only. `nika.yaml` is the project control plane
381
+ (needed for `nika serve`). `hello.nika` is the executable contract passed to
382
+ `check()` and `run()`. The engine's `hello` skeleton names its task `greet`
383
+ and asks for French; the hand-written file above is enough if it keeps
384
+ `outputs.greeting` and `model: mock/echo`.
385
+
145
386
  ## Verify a local trace
146
387
 
147
388
  Local terminal results carry an engine-issued receipt when tracing is enabled.
@@ -150,7 +391,12 @@ Pass that receipt back unchanged:
150
391
  ```ts
151
392
  if (!result.receipt) throw new Error('run did not issue a receipt');
152
393
  const proof = await nika.traceVerify(result.receipt);
153
- if (!proof.verified) throw new Error(proof.output ?? 'trace verification failed');
394
+ // proof.verified is the seal/binding, not the workflow outcome.
395
+ if (isNikaRunSucceeded(result) && proof.verified) {
396
+ // run succeeded and the journal is sealed
397
+ } else if (isNikaRunSucceeded(result) && !proof.verified) {
398
+ // succeeded, unsigned or otherwise unverified — read proof.reason
399
+ }
154
400
  ```
155
401
 
156
402
  The SDK does not implement cryptography or inspect the trace itself. It asks the
@@ -181,33 +427,35 @@ into SDK configuration, source control, workflow inputs, or an HTTP request.
181
427
  ## Cancel a run
182
428
 
183
429
  ```ts
184
- const run = await nika.run('slow.nika.yaml');
185
- const cancellation = await nika.cancel(run);
186
- const result = await run.done;
430
+ const run = await nika.run('slow.nika');
431
+ const cancellation = await run.cancel();
432
+ const result = await run.result();
187
433
 
188
434
  console.log(cancellation.accepted, result.status);
189
435
  ```
190
436
 
191
- Cancellation is idempotent per `NikaRun`. An `AbortSignal` passed to `check`,
192
- `events`, or `traceVerify` only stops that request or observer; it never stands
193
- in for `cancel(run)`.
437
+ Cancellation is idempotent per `NikaRun`: every `run.cancel()` returns the one
438
+ request. An `AbortSignal` passed to `check`, `events`, or `traceVerify` only
439
+ stops that request or observer; it never stands in for `run.cancel()`.
194
440
 
195
441
  Over HTTP a running job answers the request with 202: `cancellation` reads
196
- `{ accepted: true, status: 'cancellation_requested' }` and `run.done` settles
197
- on the terminal the resident records, `cancelled`, `succeeded`, `failed`, or
198
- `interrupted` once its grace expired. A job that already ended replays its
199
- result with `accepted: false` and `status: 'already_settled'`. The native
200
- transport signals its process the same way and settles `interrupted`.
442
+ `{ accepted: true, status: 'cancellation_requested' }` and `run.result()`
443
+ settles on the terminal the resident records, `cancelled`, `succeeded`,
444
+ `failed`, or `interrupted` once its grace expired. A job that already ended
445
+ replays its result with `accepted: false` and `status: 'already_settled'`. The
446
+ native transport signals its process the same way; the result is whatever the
447
+ engine then wrote, `cancelled` when it settled the request, or `interrupted`
448
+ when the process ended with no settlement frame.
201
449
 
202
450
  ## Connect to `nika serve`
203
451
 
204
- A contained workflow name such as `hello.nika.yaml` or
205
- `daily/report.nika.yaml` is resolved by the resident registry. `check()` and
452
+ A contained workflow name such as `hello.nika` or
453
+ `daily/report.nika` is resolved by the resident registry. `check()` and
206
454
  `run()` send that name without a local engine or a local workflow file.
207
455
  Use `listWorkflows()` to discover the served names.
208
456
 
209
457
  To capture your local file instead, pass an explicit path such as
210
- `./hello.nika.yaml`. The compatible local engine captures an immutable
458
+ `./hello.nika`. The compatible local engine captures an immutable
211
459
  snapshot, and the SDK sends its exact bytes and verifies the acknowledgement.
212
460
  Only this path needs `bin`, `NIKA_BIN`, or the exact optional native package.
213
461
  Observation and scheduling also use the server identity alone.
@@ -250,16 +498,27 @@ const nika = new Nika({
250
498
  // bin: '/absolute/path/to/nika',
251
499
  });
252
500
 
253
- const report = await nika.check('hello.nika.yaml');
254
- const run = await nika.run('hello.nika.yaml', {
255
- idempotencyKey: 'hello-2026-08-30',
501
+ const report = await nika.check('hello.nika');
502
+ const run = await nika.run('hello.nika', {
503
+ idempotencyKey: 'hello-2026-08-30', // persist before admission; reuse on retry
256
504
  });
257
- for await (const event of nika.events(run)) {
505
+ for await (const event of run.events()) {
258
506
  console.log(event.sequence, event.kind, event.status);
259
507
  }
260
- console.log(await run.done);
508
+ console.log(await run.result());
261
509
  ```
262
510
 
511
+ The application code after `new Nika(...)` is the same as the local one: the
512
+ events read `run.started` then `run.settled`, in the same words. The resident
513
+ streams no per-task frame today, so an HTTP run yields no `task.*` event; the
514
+ SDK never invents one. `event.sequence` is the resident's replay cursor and
515
+ exists only over HTTP.
516
+
517
+ HTTP `run()` requires a caller-owned `idempotencyKey` before it sends a request.
518
+ Persist a unique key for each business operation. If the response is lost or times
519
+ out, retry the same request with that key; a new key can admit a second job.
520
+ Direct native runs omit the key and reject one if supplied.
521
+
263
522
  If the Node process restarts after admission, recover the durable job without
264
523
  submitting the workflow again:
265
524
 
@@ -267,22 +526,30 @@ submitting the workflow again:
267
526
  const recovered = await nika.attachRun(saved.jobId, {
268
527
  lastEventId: saved.lastEventSequence,
269
528
  });
270
- for await (const event of nika.events(recovered)) {
529
+ for await (const event of recovered.events()) {
271
530
  await saveApplicationCheckpoint(recovered.id, event.sequence);
272
531
  }
273
- console.log(await recovered.done);
532
+ console.log(await recovered.result());
274
533
  ```
275
534
 
276
- Persist the job id and last committed sequence in application state. The
535
+ `attachRun` is the one recovery door, and it returns a full `NikaRun`. A run
536
+ handle is process-bound and is not serialized: persist the **job id** and the
537
+ last committed `event.sequence` in application state. That id is durable only
538
+ over HTTP, where `run.id` is the resident's job id. A native `run.id` is an
539
+ ephemeral correlation id of the SDK process: it appears in no journal, cannot
540
+ be recovered after the process ends, and `attachRun` refuses it. The
277
541
  idempotency namespace spans the server's entire `state-root` and currently has
278
542
  no TTL; use globally unique business keys and do not recycle them between
279
543
  workflows.
280
544
 
281
545
  When observation loses connectivity past its retry budget, the SDK performs
282
- one final durable read before giving up: a terminal record settles `run.done`
283
- from the workflow's truth, and a still-running record rejects with
284
- `NikaObservationInterrupted`, whose `lastSequence` feeds
285
- `attachRun(id, { lastEventId })` to resume.
546
+ one final durable read before giving up: a terminal record settles
547
+ `run.result()` from the workflow's truth, and a still-running record rejects
548
+ with `NikaObservationInterrupted`, whose `lastSequence` feeds
549
+ `attachRun(id, { lastEventId })` to resume. That error is about this client's
550
+ view, not about the run, which may still be running. It is not the engine's
551
+ own `interrupted` state: a resident that lost an execution says so with a
552
+ `run.interrupted` event and `result.status === 'interrupted'`, as data.
286
553
 
287
554
  Plain HTTP is accepted only for a loopback host (`localhost`, `127.0.0.0/8`,
288
555
  `[::1]`), and only when `allowInsecureHttp: true` is explicit. Every other host
@@ -291,8 +558,10 @@ bearer token never leaves the machine in plaintext. A URL may not contain
291
558
  credentials, a query, or a fragment, and a 32–512 byte visible-ASCII token is
292
559
  mandatory.
293
560
 
294
- Remote snapshots currently do not have request envelopes for per-call `vars`
295
- or `model`; declare those facts in the workflow. There is no per-run spend
561
+ A remote run by served name carries per-call `inputs` once the resident
562
+ advertises `jobInputs` (see [Workflow inputs](#workflow-inputs)); a snapshot
563
+ takes none. There is no request envelope for `model`, and none for the
564
+ deprecated `vars`: declare a model in the workflow. There is no per-run spend
296
565
  bound over HTTP at all today: `maxCostUsd` is refused, the workflow language
297
566
  has no budget field, and the resident applies its own server-wide default
298
567
  ceiling. Bound a remote run by its model and `max_tokens` until the request
@@ -307,7 +576,7 @@ client refuses `schedule` and `scheduleStatus` because a short-lived process
307
576
  cannot honestly own durable schedule state.
308
577
 
309
578
  ```ts
310
- const applied = await nika.schedule('hello.nika.yaml', {
579
+ const applied = await nika.schedule('hello.nika', {
311
580
  id: 'weekday-hello',
312
581
  when: { kind: 'cadence', expression: 'TZ=Europe/Paris 0 9 * * 1-5' },
313
582
  maxCostUsd: 0.01,
@@ -319,7 +588,7 @@ const applied = await nika.schedule('hello.nika.yaml', {
319
588
  const status = await nika.scheduleStatus('weekday-hello');
320
589
  console.log(applied.changed, status.next, status.lastDecision);
321
590
 
322
- await nika.schedule('hello.nika.yaml', {
591
+ await nika.schedule('hello.nika', {
323
592
  id: 'weekday-hello',
324
593
  when: { kind: 'cadence', expression: 'TZ=Europe/Paris 0 9 * * 1-5' },
325
594
  maxCostUsd: 0.01,
@@ -354,18 +623,23 @@ it; a scheduled budget is always a real number.
354
623
  | Operation | Native process | HTTP |
355
624
  |---|---|---|
356
625
  | `check` | yes; `model` and `nativeStrict` allowed | yes; those two overrides refused |
357
- | `run` | yes; `vars`, `model`, `maxCostUsd` allowed | yes; `idempotencyKey` allowed |
358
- | `attachRun` | typed refusal | reattach to a durable job with an optional SSE cursor |
359
- | `status` | typed refusal; await `run.done` | durable status projection |
360
- | `events` | raw engine lifecycle frames | sequenced SSE frames with bounded replay |
361
- | `cancel` | signal-backed, idempotent | 200 settles the job; 202 accepts the request and `run.done` settles on the resident's terminal |
626
+ | `run` | yes; `model`, `maxCostUsd` allowed | yes; `idempotencyKey` required; `model`, `maxCostUsd` refused |
627
+ | `run` `inputs` | literal JSON over stdin; engine must advertise `inputsLiteral` | literal JSON by served name; resident must advertise `jobInputs`; a snapshot refuses them |
628
+ | `run` `vars` (deprecated) | the `--var` operator channel, unchanged | typed refusal |
629
+ | `attachRun` | typed refusal: a native run is process-bound | reattach to a durable job with an optional SSE cursor |
630
+ | `run.status()` | typed refusal; await `run.result()` | durable status projection |
631
+ | `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 |
632
+ | `run.cancel()` | signal-backed, idempotent | 200 settles the job; 202 accepts the request and `run.result()` settles on the resident's terminal |
633
+ | `run.id` | ephemeral correlation id; never durable | the resident's durable job id; the one to persist |
362
634
  | `traceVerify` | engine verification + signed receipt binding | typed verdict: `unavailable` until remote journal authority exists, then the CLI's tiers |
363
635
  | `schedule` / `scheduleStatus` | typed refusal | resident schedule authority |
364
636
  | `listWorkflows` / `workflow` | typed refusal | contained path-free workflow catalog |
365
637
 
366
- Event vocabulary is deliberately open. Native execution exposes detailed task
367
- lifecycle frames; HTTP exposes durable sequenced execution frames. Consumers
368
- must not assume identical cardinality across transports.
638
+ The lifecycle vocabulary is one; the cardinality is not. Native execution
639
+ exposes detailed task frames, HTTP exposes durable sequenced execution frames,
640
+ and the SDK names only the facts a transport actually emitted. Consumers must
641
+ not assume identical cardinality across transports. The protocol vocabulary
642
+ under `event.raw` stays deliberately open.
369
643
 
370
644
  ## API
371
645
 
@@ -375,7 +649,9 @@ Shared options:
375
649
 
376
650
  - `cwd`: engine working directory and snapshot root
377
651
  - `bin`: explicit engine path
378
- - `eventBufferSize`: per-client observer ceiling, default 256
652
+ - `eventBufferSize`: how many of a run's most recent frames a session retains
653
+ for a view opened after the fact, and the largest `bufferSize` a view may
654
+ ask for; default 4096. See [Observing a run after the fact](#observing-a-run-after-the-fact)
379
655
  - `machineBufferBytes`: machine frame/diagnostic ceiling, default 64 KiB
380
656
 
381
657
  Remote-only options:
@@ -390,21 +666,198 @@ Remote-only options:
390
666
  | Method | Result |
391
667
  |---|---|
392
668
  | `check(workflow, options?)` | `clean` plus the native check report or resident acknowledgement/refusal |
393
- | `run(workflow, options?)` | admitted `NikaRun` |
394
- | `attachRun(id, options?)` | reattached durable HTTP `NikaRun` |
395
- | `status(run)` | current durable HTTP status |
396
- | `events(run, options?)` | bounded `AsyncIterable<NikaEvent>` |
397
- | `cancel(run)` | `NikaCancelResult` |
669
+ | `run(workflow, options?)` | admitted `NikaRun`; rejects without one when the engine refuses |
670
+ | `attachRun(id, options?)` | reattached durable HTTP `NikaRun`: the one recovery door |
398
671
  | `traceVerify(receipt, options?)` | `NikaTraceVerifyResult` |
399
672
  | `schedule(workflow, options)` | durable apply acknowledgement |
400
673
  | `scheduleStatus(id)` | fresh engine schedule projection |
401
674
  | `listWorkflows()` | contained resident workflow names |
402
675
  | `workflow(name)` | path-free resident workflow metadata |
403
676
 
404
- ### Typed events, outputs, and identities
677
+ ### `NikaRun`
678
+
679
+ | Member | Result |
680
+ |---|---|
681
+ | `run.id` | `NikaRunId`: the durable job id over HTTP, an ephemeral correlation id natively |
682
+ | `run.events(options?)` | bounded `AsyncIterable<NikaRunEvent>` in the lifecycle vocabulary |
683
+ | `run.result()` | `Promise<NikaRunResult>`, settled once; an admitted failure resolves |
684
+ | `run.status()` | current durable HTTP status; a typed refusal natively |
685
+ | `run.cancel()` | `NikaCancelResult`; idempotent |
686
+ | `run.done` | compatibility alias of `run.result()`: the same promise |
687
+
688
+ Every member is bound to its run, so it can be extracted:
689
+ `const { events, result } = run`. The handle is process-bound; it carries no
690
+ `list`, `search`, proof, catalog or authoring door.
691
+
692
+ ### Observing a run after the fact
693
+
694
+ ```ts
695
+ const run = await nika.run('wide.nika');
696
+ const result = await run.result(); // first the result,
697
+ for await (const event of run.events()) {} // then every frame the session saw
698
+ ```
699
+
700
+ A view opened late is seeded with every frame the session observed, or it is
701
+ refused. It is never handed a shortened replay. How many frames a session
702
+ retains is `eventBufferSize`, **4096 by default**.
703
+
704
+ **The measured frame count: `3N + 3`, for one sealed shape.** On the released
705
+ 0.118.7 engine (and still the native 0.120.0 hello fixture when sealed), a
706
+ clean run of N independent `mock/echo` `infer` tasks wrote
707
+ `workflow_started`, then `task_scheduled`, `task_started` and `task_completed`
708
+ per task, then `workflow_completed` and `run_settled`. Two points were
709
+ measured: 1 task is 6 frames, 90 tasks are 273. That is this fixture, not a
710
+ law of N-task workflows. Other shapes write more: a `nika:wait` task and a
711
+ `nika:assert` task each showed one extra `permit_checked` frame, a failed task
712
+ writes `task_failed`, and retries, agents and `for_each` were not measured.
713
+ Count your own run rather than deriving it: `error.observed` below is the
714
+ number. A `nika serve` job was measured at two frames, so the bound matters on
715
+ a native process.
716
+
717
+ | `eventBufferSize` | frames retained | in the measured shape only |
718
+ |---|---|---|
719
+ | 256, the default up to 0.118.7 | 256 | below the 273 frames of the 90-task run |
720
+ | 4096, the default | 4096 | 15 times those 273 frames |
721
+
722
+ **What the bound costs.** It is finite on purpose and is never `Infinity`.
723
+ Every frame is bounded by `machineBufferBytes` (64 KiB), so the retained
724
+ **history** holds at most `eventBufferSize × machineBufferBytes` of frame text:
725
+ 4096 × 64 KiB = 256 MiB per run at both defaults, where 256 frames gave
726
+ 16 MiB. That is arithmetic, not a measurement: the 273 measured frames total
727
+ 0.15 MiB (mean 571 bytes, largest 1501). It bounds the history only, not the
728
+ session or the process: a frame already handed to your code lives as long as
729
+ you keep it, and every open view and every concurrent run adds its own. Nothing
730
+ here is a claim about heap or process memory. If that ceiling matters to you,
731
+ set `eventBufferSize` yourself: an explicit value is kept exactly as given, so
732
+ `eventBufferSize: 256` behaves as it always did.
733
+
734
+ **Past the bound.** A run that writes more frames than the bound still runs and
735
+ still succeeds: `run.result()` resolves as usual and is never affected. Only a
736
+ view is refused, with `NikaEventBufferOverflowError`, and its `reason` says
737
+ which of two different bounds was exceeded:
738
+
739
+ | `error.reason` | What happened | What to do |
740
+ |---|---|---|
741
+ | `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 |
742
+ | `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 |
743
+
744
+ ```ts
745
+ try {
746
+ for await (const event of run.events()) render(event);
747
+ } catch (error) {
748
+ if (error instanceof NikaEventBufferOverflowError && error.reason === 'replay_truncated') {
749
+ // The run is fine. Its history is longer than this view can replay.
750
+ console.warn(`${error.observed} frames, ${error.retained} retained; run ${result.status}`);
751
+ } else throw error;
752
+ }
753
+ ```
754
+
755
+ Neither refusal skips a frame, and neither is about the run. `error.observed`
756
+ is a count taken when the view was opened. The engine's journal stays the
757
+ source of truth for a native trace (`receipt.trace_path`, `traceVerify`): a
758
+ late `run.events()` is a convenience over what this process already saw, and
759
+ the SDK reads no journal to extend it. Over HTTP the resident holds the job,
760
+ so recover there with `attachRun(id, { lastEventId })`.
761
+
762
+ ### The lifecycle vocabulary
763
+
764
+ `run.events()` yields `NikaRunEvent`: a lifecycle `kind` that is the same on
765
+ both transports, plus the exact protocol frame on `raw`.
405
766
 
406
- `NikaEvent` is a discriminated union over the known lifecycle kinds of both
407
- transports. A native engine process emits `workflow_started`,
767
+ | `event.kind` | Native frame (`event.raw.kind`) | HTTP frame (`event.raw.kind`) |
768
+ |---|---|---|
769
+ | `run.started` | `workflow_started` | `execution.started` |
770
+ | `task.scheduled` · `task.started` · `task.completed` · `task.failed` | `task_scheduled` · `task_started` · `task_completed` · `task_failed` | none: the resident streams no per-task frame |
771
+ | `run.waiting` | `run_settled` carrying `paused` | `execution.settled` carrying `paused` |
772
+ | `run.settled` | `run_settled` carrying `succeeded` · `failed` · `cancelled` | `execution.settled` carrying `succeeded` · `failed` · `cancelled`; `execution.cancelled` carrying `cancelled` only; `execution.refused` carrying `failed` only |
773
+ | `run.interrupted` | `workflow_interrupted` carrying `interrupted` | `execution.interrupted` · `interrupted` carrying `interrupted` |
774
+ | `run.sealed` | `run_sealed` | none |
775
+ | `engine.event` | every other frame (`workflow_completed`, `workflow_paused`, `permit_checked`, …) | every other frame, a `null` or future kind included |
776
+
777
+ The SDK names a fact only when the engine wrote it. A frame that speaks of the
778
+ run's state earns its name only for a (kind, status) pair a producer defines;
779
+ the pairs are the ones listed above and nothing is computed from them. The
780
+ engine's state word decides and is never defaulted, so an absent, null, future
781
+ or still-running status stays an `engine.event`, and so does a terminal word
782
+ on the wrong dedicated kind: `execution.refused` carrying `succeeded` or
783
+ `cancelled`, or `execution.cancelled` carrying `succeeded` or `failed`,
784
+ contradicts itself and is never called settled. `event.raw` still holds that
785
+ frame, and `run.result()` still reads the state word the engine wrote. The
786
+ projection keeps no state between frames, deduplicates nothing, and never
787
+ synthesizes an event, so transports differ in cardinality but never in names.
788
+
789
+ ```ts
790
+ for await (const event of run.events()) {
791
+ switch (event.kind) {
792
+ case 'run.started': break;
793
+ case 'task.completed': console.log('done:', event.task); break;
794
+ case 'task.failed': console.error(event.task, event.error?.code); break;
795
+ case 'run.waiting': console.log('a human gate holds the run'); break;
796
+ case 'run.settled': console.log('ended:', event.status); break;
797
+ case 'run.interrupted': console.warn('the engine lost this execution'); break;
798
+ default: break; // additive vocabulary: event.raw.kind names the frame
799
+ }
800
+ }
801
+ ```
802
+
803
+ `event.status` is always the engine's own word (a waiting run reads `paused`);
804
+ `event.task` is the task a `task.*` frame named; `event.error` is the failure
805
+ a `task.failed` frame or a `failed` settlement named, and no other state
806
+ carries one; `event.sequence` is the resident's replay cursor and exists only
807
+ over HTTP.
808
+ An `engine.event` is given no lifecycle meaning: it carries its cursor and
809
+ `raw`, never a `status`.
810
+
811
+ `run.waiting` is not `run.settled`: a human gate holds a resumable run, which
812
+ has neither failed nor completed. `run.interrupted` is the engine's report
813
+ that it lost an execution, whose settlement is unknown; it is unrelated to
814
+ the thrown `NikaObservationInterrupted`, which means this client lost its view
815
+ of a run that may still be running.
816
+
817
+ ### Migrating to the Run-owned lifecycle
818
+
819
+ The client-level lifecycle methods are deprecated and stay for one release
820
+ train. They keep the ownership check: a run this client did not create still
821
+ throws `NikaRunOwnershipError`, because there is no global run registry.
822
+
823
+ **The compatibility window.** A release train is one published
824
+ SDK-and-engine version, the meaning this repository already uses (see
825
+ [Keeping it fresh](#keeping-it-fresh)). The window is counted from
826
+ publication, never from a merge:
827
+
828
+ 1. It opens with the first train **published to npm** whose package carries
829
+ the Run-owned lifecycle. That train ships the wrappers, unchanged, next to
830
+ the new API.
831
+ 2. The earliest train that may remove them is the one **after** it. Removal
832
+ is not automatic: it is decided by the One SDK baseline owner
833
+ ([#114](https://github.com/supernovae-st/nika-client/issues/114)) and is
834
+ announced in the release notes of the train that performs it.
835
+ 3. No version and no date are fixed here. Until a train carrying this API is
836
+ published, nothing has started counting and the wrappers stay.
837
+
838
+ | Deprecated | Use | What changes |
839
+ |---|---|---|
840
+ | `nika.events(run, options?)` | `run.events(options?)` | lifecycle `kind`; the protocol frame the wrapper yields is `event.raw` |
841
+ | `nika.cancel(run)` | `run.cancel()` | nothing: the same memoized request |
842
+ | `nika.status(run)` | `run.status()` | nothing |
843
+ | `await run.done` | `await run.result()` | nothing: `done` stays as an alias of the same promise |
844
+
845
+ `nika.events(run)` still yields the protocol vocabulary exactly as before, so
846
+ existing consumers keep working unchanged while they migrate:
847
+
848
+ ```ts
849
+ // before // after
850
+ for await (const e of nika.events(run)) { for await (const e of run.events()) {
851
+ if (e.kind === 'workflow_started' || if (e.kind === 'run.started') start();
852
+ e.kind === 'execution.started') start();
853
+ } }
854
+ ```
855
+
856
+ ### Typed protocol events, outputs, and identities
857
+
858
+ `NikaEvent` is the protocol frame under `event.raw` (and what the deprecated
859
+ `nika.events(run)` yields): a discriminated union over the known protocol
860
+ kinds of both transports. A native engine process emits `workflow_started`,
408
861
  `task_scheduled`, `task_started`, `task_completed`, `workflow_completed`,
409
862
  `workflow_failed`, `workflow_interrupted`, `run_settled`, and `run_sealed`. A
410
863
  `nika serve` job streams `execution.started`, `execution.settled`,
@@ -414,36 +867,36 @@ this SDK version does not know yet stay representable through the
414
867
  `NikaUnknownEvent` fallback, so the union is intentionally non-exhaustive and
415
868
  every variant keeps its future fields open.
416
869
 
417
- `run`, `attachRun`, and `events` accept one `Outputs` type argument. It types
418
- the terminal settlement — `run.done` and the `run_settled` /
419
- `execution.settled` / `workflow_completed` frames — without any runtime
420
- validation, and defaults to `Record<string, unknown>` so untyped callers see
421
- no change:
870
+ `run` and `attachRun` accept one `Outputs` type argument. It types the
871
+ terminal settlement — `run.result()` and, on the protocol frame, the
872
+ `run_settled` / `execution.settled` / `workflow_completed` frames — without
873
+ any runtime validation, and defaults to `Record<string, unknown>` so untyped
874
+ callers see no change:
422
875
 
423
876
  ```ts
424
- const run = await nika.run<{ answer: number }>('flow.nika.yaml');
425
- const result = await run.done; // result.outputs?: { answer: number }
877
+ const run = await nika.run<{ answer: number }>('flow.nika');
878
+ const result = await run.result(); // result.outputs?: { answer: number }
426
879
 
427
- for await (const event of nika.events(run)) {
428
- if (isNikaRunSettledEvent(event)) {
880
+ for await (const event of run.events()) {
881
+ if (isNikaRunSettledEvent(event.raw)) {
429
882
  // The settlement frame of either transport (`run_settled` natively,
430
883
  // `execution.settled` over HTTP): status, outputs, and receipt typed
431
884
  // together on the one frame that carries all three.
432
- console.log(event.status, event.outputs?.answer, event.receipt);
885
+ console.log(event.raw.status, event.raw.outputs?.answer, event.raw.receipt);
433
886
  }
434
887
  }
435
888
  ```
436
889
 
437
- A run can also end without settling outputs — cancelled, refused, or
438
- interrupted. `isNikaTerminalEvent(event)` narrows those too: it reads the
439
- engine-reported `status` (`succeeded`, `failed`, `interrupted`, `cancelled`)
440
- rather than the kind, so it holds on either transport and on kinds this SDK
441
- version does not know yet:
890
+ The protocol guards read `event.raw`. A run can also end without settling
891
+ outputs — cancelled, refused, or interrupted. `isNikaTerminalEvent(event.raw)`
892
+ narrows those too: it reads the engine-reported `status` (`succeeded`,
893
+ `failed`, `interrupted`, `cancelled`) rather than the kind, so it holds on
894
+ either transport and on kinds this SDK version does not know yet:
442
895
 
443
896
  ```ts
444
- for await (const event of nika.events(run)) {
445
- if (isNikaTerminalEvent(event)) {
446
- console.log('no further frames for this run:', event.status);
897
+ for await (const event of run.events()) {
898
+ if (isNikaTerminalEvent(event.raw)) {
899
+ console.log('no further frames for this run:', event.raw.status);
447
900
  }
448
901
  }
449
902
  ```
@@ -479,6 +932,12 @@ NikaError
479
932
  └── NikaRunOwnershipError
480
933
  ```
481
934
 
935
+ `NikaEventBufferOverflowError` is never about the run: `run.result()` is
936
+ unaffected by it. Its `reason` tells a live view that fell behind
937
+ (`live_backpressure`) from a view opened after more frames than it can replay
938
+ (`replay_truncated`, with `observed` and `retained`); see
939
+ [Observing a run after the fact](#observing-a-run-after-the-fact).
940
+
482
941
  Native engine event vocabulary stays open. HTTP events instead enforce the
483
942
  closed, redacted `JobEvent` projection advertised by the pinned OpenAPI contract;
484
943
  unknown HTTP fields are rejected at the trust boundary. The SDK never turns an
@@ -492,9 +951,37 @@ Server messages are engine-owned and path-free; a reflected bearer token is
492
951
  redacted before it reaches an error message. A non-2xx answer without that
493
952
  typed body stays a `NikaTransportError` whose body is redacted entirely.
494
953
 
495
- An engine refusal printed before a run starts — a `NIKA-…` code line such as a
496
- cost-floor refusal — settles `run.done` with a `NikaOperationError` carrying
497
- `operation: 'run'`, the engine's code, and its full refusal line.
954
+ A native engine refuses a workflow before admitting it: a red check, a cost
955
+ floor above `maxCostUsd`, a required input left unset, a file it cannot read.
956
+ `run()` then rejects, before any `NikaRun` exists, with a `NikaOperationError`
957
+ carrying `operation: 'run'` and the engine's exit status in `status`:
958
+
959
+ ```ts
960
+ try {
961
+ const run = await nika.run('./workflow.nika');
962
+ const result = await run.result(); // admitted: a failure here is result data
963
+ } catch (error) {
964
+ if (error instanceof NikaOperationError && error.operation === 'run') {
965
+ console.error(error.code); // 'NIKA-SEC-004'
966
+ for (const finding of error.findings ?? []) console.error(finding.message);
967
+ } else throw error;
968
+ }
969
+ ```
970
+
971
+ - `code` is the engine's own code: the first check finding that names one
972
+ (`NIKA-SEC-004`, `NIKA-PARSE-005`, `NIKA-AUTH-006`, …) or the refusal's code
973
+ (`NIKA-1709`, `NIKA-1708`). `machineCode` repeats it. When the engine named
974
+ none, as for an unreadable file, `code` is the SDK's `run_refused` and
975
+ `machineCode` is absent; the SDK never supplies an engine code.
976
+ - `findings` holds the engine's check findings untouched, as
977
+ `NikaCheckFinding` (`code?`, `message`, `severity`, `gate`, `kind`, `task`,
978
+ `docs_url`). A budget or launch refusal has no findings.
979
+ - Nothing was admitted, so nothing else exists: no run id, no events, no
980
+ trace, no receipt.
981
+
982
+ Output that proves neither an admission nor a refusal (a line that is not
983
+ machine output, a truncated or oversized report, an engine that exits without
984
+ a frame) rejects `run()` with `NikaProtocolError` instead.
498
985
 
499
986
  ## Security boundaries
500
987
 
@@ -526,7 +1013,7 @@ The repository also carries 100 distinct use-case workflows and provider proof
526
1013
  under `gauntlet/`.
527
1014
 
528
1015
  <!-- engine hero pinned to the release tag it demonstrates · re-pin on lockstep bumps -->
529
- ![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)
1016
+ ![nika check audits the workflow, then runs and seals its trace](https://raw.githubusercontent.com/supernovae-st/nika/v0.120.1/media/nika-hero.gif)
530
1017
 
531
1018
  ## Keeping it fresh
532
1019
 
@@ -549,7 +1036,7 @@ npm update @supernovae-st/nika
549
1036
  ⚙️ nika ───────── engine, admission, execution, receipts and schedules
550
1037
  │
551
1038
  ▼
552
- 🔌 nika-client ── this TypeScript door: native process or authenticated HTTP
1039
+ 🔌 nika-client ── this door, published as @supernovae-st/nika: native process or authenticated HTTP
553
1040
  │
554
1041
  ▼
555
1042
  🧩 Node.js applications