@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.
@@ -0,0 +1,146 @@
1
+ # SDK architecture
2
+
3
+ The package has one public facade and two execution adapters:
4
+
5
+ ```text
6
+ application
7
+ |
8
+ v
9
+ Nika facade
10
+ |
11
+ v
12
+ Transport interface <--- lifecycle and authority seam
13
+ | |
14
+ v v
15
+ NativeProcessTransport HttpTransport
16
+ | |
17
+ v v
18
+ local nika process authenticated nika serve
19
+ ```
20
+
21
+ ## Modules and responsibilities
22
+
23
+ - `src/index.ts` is the public Module. It validates caller-owned values, owns
24
+ run handles, and exposes one stable vocabulary.
25
+ - `src/lib/transport.ts` is the Interface. It describes the operations both
26
+ Adapters must implement and makes unsupported authority explicit.
27
+ - `src/lib/native-process-transport.ts` is the local Adapter. It spawns the
28
+ selected engine without a shell and consumes newline-delimited machine
29
+ events. It reads the first machine frame before it returns a run, so
30
+ `run()` resolves on the engine's admission and rejects on its refusal.
31
+ - `src/lib/run-refusal.ts` reads the engine's one pre-run refusal object (its
32
+ check report carrying `clean: false`, or its `{ error }` envelope) and turns
33
+ it into the typed `NikaOperationError`. It judges nothing: a shape the
34
+ engine was not measured to write is a protocol fault, never a refusal.
35
+ - `src/lib/legacy-run-refusals.ts` is temporary. It recovers the three refusal
36
+ dialects of engines up to 0.119.0, which predate the one `run --json`
37
+ grammar, and is deleted with its call sites once no supported engine writes
38
+ them.
39
+ - `src/lib/http-transport.ts` is the remote Adapter. It verifies the remote
40
+ server identity once per client, resolves and verifies a local engine only
41
+ for caller-owned snapshot capture, then uses the authenticated HTTP
42
+ contract.
43
+ - `src/lib/run-session.ts` is the lifecycle Seam. It owns the eager event
44
+ pump, bounded independent observers, cancellation memoization, and the sole
45
+ terminal settlement. It builds the `NikaRun` handle as closures over itself,
46
+ so the Run owns `events()`, `result()`, `status()` and `cancel()`.
47
+ - `src/lib/run-events.ts` is the semantic Adapter: a pure projection of one
48
+ protocol frame onto the SDK's lifecycle vocabulary, with the frame kept by
49
+ identity on `raw`. It is applied at the edge of an observer view. There is
50
+ one history and one bounded queue per observer, and no second event bus.
51
+ - `openapi.json` and `src/generated/openapi.d.ts` pin the HTTP contract judged
52
+ by CI. `scripts/check-sdk-coverage.js` fails if a live runtime path is
53
+ missing or if the SDK names a path outside that contract.
54
+
55
+ ## Authority rules
56
+
57
+ The SDK transports engine facts; it does not reproduce engine decisions.
58
+ Parsing, admission, scheduling, cancellation settlement, receipts, trace
59
+ verification, permits, and cost remain engine-owned.
60
+
61
+ Some operations deliberately have one authority:
62
+
63
+ - resident workflow discovery, durable status, and schedules require HTTP;
64
+ - a direct native process refuses those operations with
65
+ `NikaCompatibilityError`;
66
+ - remote execution by contained workflow name uses the resident registry;
67
+ explicit local paths need a compatible local engine to capture a snapshot;
68
+ - when that capture is red the HTTP adapter returns the local engine's plain
69
+ `nika check --json` report, so `findings[]` stays canonical and no workflow
70
+ bytes are sent;
71
+ - HTTP observation (attach, durable status, events, cancel, workflow catalog,
72
+ schedule status, trace verdicts) needs no local engine;
73
+ - remote trace verification currently returns the engine's typed unavailable
74
+ verdict because the server has no path-free journal authority.
75
+
76
+ ## Lifecycle invariants
77
+
78
+ 1. `run()` resolves only after stable admission and returns an immutable
79
+ `NikaRun` handle: `id`, `events()`, `result()`, `status()`, `cancel()`, and
80
+ `done`, the compatibility alias of `result()`. Its members are closures
81
+ over the run's one session, so an extracted method still works. The handle
82
+ owns the lifecycle and nothing else: checking, proof, catalogs and
83
+ authoring stay on the facade. A run handle never means "maybe a run": a refusal
84
+ before admission rejects `run()` with `NikaOperationError` and no handle
85
+ exists. Over HTTP a red local snapshot refuses before any request is sent;
86
+ on the native transport the first machine frame decides. A run event is the
87
+ engine's admission evidence and is replayed as the first event; a refusal
88
+ object is the whole stream. The one spawn that executes the workflow is the
89
+ one that judges it: the SDK adds no preflight check and never spawns twice.
90
+ 2. `run.result()` is the only terminal promise (`run.done` is the same one).
91
+ Workflow failure is result data; configuration, transport, protocol, and
92
+ compatibility failures throw. A `paused` result is a human gate: neither a
93
+ failure nor a completed execution.
94
+ 3. A session's history retains at most `eventBufferSize` frames (default 4096,
95
+ never unbounded; each frame is bounded by `machineBufferBytes`). That
96
+ product bounds the retained history only, not the session or the process:
97
+ frames already handed to a consumer, other views and other runs are not
98
+ counted in it. Two different
99
+ bounds can be exceeded and they are never confused: a live view that falls
100
+ behind fails with `reason: 'live_backpressure'`, and a view opened after
101
+ more frames than it can be given is refused with
102
+ `reason: 'replay_truncated'` plus what was `observed` and `retained`.
103
+ Neither skips a frame, neither touches another view, and neither touches
104
+ the result: a run longer than the bound still settles normally. The
105
+ session is memory over what this process saw. It is not a control plane,
106
+ keeps nothing on disk, and reads no engine journal to extend a replay.
107
+ `run.events()` creates an independent bounded observer in the lifecycle
108
+ vocabulary. Aborting an observer never cancels the run. The vocabulary is a
109
+ stateless per-frame projection: it names a fact only for a (kind, status)
110
+ pair a producer defines, never defaults a state word, never deduplicates,
111
+ and never synthesizes a frame a transport did not emit. Every other frame
112
+ keeps flowing as `engine.event`, so transports differ in cardinality but
113
+ never in names, and `event.raw` is always the untouched protocol frame.
114
+ 4. `run.cancel()` is idempotent per run: every call returns the one request.
115
+ 5. `run.status()` reads the durable HTTP projection and refuses a native-only
116
+ run instead of guessing from local process state.
117
+ 6. Schedule creation uses `If-None-Match: *`; updates require the exact opaque
118
+ revision in `If-Match`.
119
+ 7. `attachRun()` is the one recovery door. It creates a fresh owned session
120
+ for an existing HTTP job; its initial sequence is caller-owned durable
121
+ checkpoint state, never inferred from in-memory SDK history. A native run
122
+ is process-bound: its `run.id` is an ephemeral correlation id, and the SDK
123
+ keeps no local job store, starts no hidden `nika serve`, and reads no
124
+ journal to fake durability.
125
+ 8. Ownership has no registry. The facade maps each handle it created to its
126
+ session in a `WeakMap`. The deprecated `nika.events(run)`,
127
+ `nika.cancel(run)` and `nika.status(run)` wrappers resolve through it, so a
128
+ foreign, reconstructed, serialized, or other-client handle still throws
129
+ `NikaRunOwnershipError`. `nika.events(run)` keeps yielding the protocol
130
+ vocabulary unchanged. The wrappers stay for one release train counted from
131
+ publication: they ship unchanged in the first published train that carries
132
+ the Run-owned lifecycle, the earliest train that may remove them is the one
133
+ after it, and the removal is the One SDK baseline owner's decision (#114),
134
+ announced in that train's release notes. No version or date is fixed.
135
+ 9. An interrupted observation is a typed transport error carrying its cursor
136
+ (`NikaObservationInterrupted`), never an engine failure: the run may still
137
+ be running. The engine's own `interrupted` evidence state is data instead:
138
+ a `run.interrupted` event and `result.status === 'interrupted'`.
139
+
140
+ ## Deletion test
141
+
142
+ If one Adapter is removed, the facade and lifecycle Seam remain coherent and
143
+ the other Adapter still compiles. If the Transport Interface is removed,
144
+ authority differences leak into every public method. If `run-session.ts` is
145
+ removed, each consumer must reimplement buffering, ownership, cancellation,
146
+ and settlement. Those boundaries therefore carry real architectural load.
@@ -0,0 +1,160 @@
1
+ # HTTP contract
2
+
3
+ `openapi.json` is the checked-in contract pin. The SDK authenticates every
4
+ route except public `GET /health`; bearer tokens are redacted from failures.
5
+ A non-2xx answer typed as `{ error: { code, message } }` becomes a
6
+ `NikaOperationError` carrying `status`, `code`, and the refused `operation`;
7
+ For a check by served name, a typed 404 or 422 instead returns
8
+ `{ clean: false, error }`; authentication and transport failures still throw.
9
+ Any other non-2xx body is discarded and reported as a redacted
10
+ `NikaTransportError`.
11
+
12
+ | HTTP route | SDK surface | Contract |
13
+ |---|---|---|
14
+ | `GET /health` | internal identity handshake | public liveness and protocol versions |
15
+ | `GET /v1/openapi.json` | generation only | authenticated OpenAPI 3.1 document |
16
+ | `GET /v1/workflows` | `listWorkflows()` | contained relative workflow names |
17
+ | `GET /v1/workflows/{name}` | `workflow(name)` | path-free metadata, never source bytes |
18
+ | `POST /v1/check` | `check()` | validates a served name or immutable snapshot bytes without a job |
19
+ | `POST /v1/jobs` | `run()` | admits a served name or exact snapshot bytes with an idempotency key |
20
+ | `GET /v1/jobs/{id}` | internal settlement | durable job identity, outputs, receipt, settlement, or redacted error |
21
+ | `GET /v1/jobs/{id}/status` | `run.status()` | current status only |
22
+ | `GET /v1/jobs/{id}/events` | `run.events()` / `attachRun()` | bounded, sequenced SSE with replay |
23
+ | `POST /v1/jobs/{id}/cancel` | `run.cancel()` | 200 a settled job or its terminal replay; 202 the request accepted on a running job, settled later by observation |
24
+ | `GET /v1/jobs/{id}/trace/verify` | `traceVerify(receipt)` | engine-owned typed trace verdict; `reason` only on a verdict that does not hold |
25
+ | `GET/PUT /v1/schedules/{id}` | `scheduleStatus()` / `schedule()` | resident schedule projection and CAS mutation |
26
+
27
+ ## Connection rules
28
+
29
+ - HTTPS is required for every host except loopback. Plain HTTP is accepted
30
+ only for `localhost`, `127.0.0.0/8`, or `[::1]`, and only with an explicit
31
+ `allowInsecureHttp: true`; that opt-in never admits a routable host.
32
+ - URLs containing credentials, a query, or a fragment are rejected.
33
+ - Tokens must contain 32–512 visible ASCII bytes and are never sent to
34
+ `/health`.
35
+ - Each request has a bounded timeout and each JSON/SSE machine frame has a
36
+ byte ceiling.
37
+ - Remote `check()` refuses `model` and `nativeStrict`; remote `run()` refuses
38
+ `model`, `maxCostUsd` and the deprecated `vars` until a request envelope
39
+ owns them.
40
+ - Remote `run()` sends `inputs` as `JobByName.inputs` only for a served name
41
+ and only when `GET /health` advertises `jobInputs`. That capability, not a
42
+ 202, is the negotiation: a resident from before the envelope accepts the
43
+ extra field and ignores its values, so the SDK refuses it after `/health`
44
+ alone with `NikaCompatibilityError` (`capability: 'jobInputs'`). The
45
+ `inputs` envelope is engine-owned (nika#1642) and lands in the pinned
46
+ `openapi.json` when the engine pin reaches a release that serves it.
47
+ - A snapshot body takes no `inputs` overlay, an empty map included: `run()` of
48
+ a local path with `inputs` is refused before any capture or request.
49
+ - The serialized `inputs` map is bounded at 1 MiB by the SDK, the bound the
50
+ native channel reads. The resident's whole-request ceiling is its own and may
51
+ be lower.
52
+ - Caller-provided workflow catalog names must be contained slash-separated
53
+ paths. Absolute paths, backslashes, empty segments, `.` and `..` are
54
+ rejected before network I/O.
55
+
56
+ A contained `.nika` name uses the resident registry without a local
57
+ engine. Prefix a local file with `./` to capture and submit its snapshot.
58
+ A successful by-name check returns `clean: true` and the compact resident
59
+ acknowledgement; no local check report or exit code is fabricated.
60
+
61
+ ## Settlement
62
+
63
+ The terminal `execution.settled` frame and the durable job nest the run's
64
+ `settlement` whole (engine 0.118, ADR-128): its `status` and `cause`, the
65
+ elapsed time, the task tally, the spend with its qualifier, and the failure
66
+ named with its task. The SDK types every known field, refuses a settlement
67
+ whose `status` contradicts the record carrying it, keeps fields it does not
68
+ know, and never derives a settlement from an exit code; a job the resident
69
+ lost (`interrupted`) carries none.
70
+
71
+ ## Frame time and journal evidence (engine main)
72
+
73
+ Both resident projections are closed, and the SDK refuses any field it does
74
+ not know. Engine main adds two optional fields that are
75
+ ahead of the pinned `openapi.json`: no released engine writes them yet, and a
76
+ resident that predates them never sends them, so nothing changes against a
77
+ released resident. They are read so that a resident built from engine main can
78
+ be observed at all; the pin itself moves only with a release.
79
+
80
+ - `JobEvent.at` is when the resident admitted the event: an RFC 3339 timestamp
81
+ in UTC, outside the event's hash chain. It rides `event.raw.at` untouched.
82
+ Anything that is not such a timestamp is a `NikaProtocolError`. The durable
83
+ `Job` declares no `at`, so one there is still an unknown field.
84
+ - `evidence`, on the terminal frame and on the durable `Job`, reports that the
85
+ run's journal mirror stopped recording. It is exactly a `status` and a
86
+ `reason`. The one status is `mirror_lost`. The reason is `write_failed`
87
+ (opening, writing or syncing the journal failed) or `record_refused` (a
88
+ record could not be admitted within the writer's bounds): a coarse class,
89
+ never OS text and never a path. Any other shape or word is a
90
+ `NikaProtocolError`, as the engine itself refuses one, and its value is
91
+ never quoted in the error.
92
+
93
+ `evidence` is independent of the execution and is never a verdict: a run can
94
+ settle `succeeded` while its mirror is lost. It never changes `result.status`,
95
+ the settlement, or the receipt's identity checks. `run.result()` copies it to
96
+ `result.evidence` from the frame or record that settled the run, so a caller
97
+ who never iterates events still learns the trace may be incomplete before
98
+ trusting `traceVerify`. Its absence claims nothing: not that a journal exists,
99
+ only that no loss was reported. A native run never carries it.
100
+
101
+ ## Lifecycle vocabulary over the resident's frames
102
+
103
+ `run.events()` names the resident's closed `JobEvent` frames in the SDK's
104
+ lifecycle vocabulary and keeps each frame on `event.raw`:
105
+
106
+ | `JobEvent.kind` | `JobEvent.status` | `event.kind` |
107
+ |---|---|---|
108
+ | `execution.started` | any | `run.started` |
109
+ | `execution.settled` | `paused` | `run.waiting` |
110
+ | `execution.settled` | `succeeded` · `failed` · `cancelled` | `run.settled` |
111
+ | `execution.cancelled` | `cancelled` only | `run.settled` |
112
+ | `execution.refused` | `failed` only | `run.settled` |
113
+ | `execution.interrupted` · `interrupted` | `interrupted` | `run.interrupted` |
114
+ | anything else: a `null` or future kind; an end kind whose status is absent, `null`, future, `queued` or `running`; or a pair that contradicts itself (`execution.refused` carrying `succeeded` or `cancelled`, `execution.cancelled` carrying `succeeded` or `failed`, an end kind carrying `interrupted`) | | `engine.event` |
115
+
116
+ The pairs above are exhaustive and listed, never computed as a product of
117
+ kinds and words. An unnamed frame is still delivered with `event.raw` intact,
118
+ and `run.result()` still reads the state word the engine wrote on it.
119
+
120
+ The resident streams no per-task frame, so an HTTP run yields no `task.*`
121
+ event: the SDK never synthesizes one. `event.sequence` is the validated SSE
122
+ id, the cursor to persist. The engine's `interrupted` (execution ownership
123
+ was lost, settlement unknown) is data: a `run.interrupted` event and
124
+ `result.status`. It is unrelated to `NikaObservationInterrupted` below, which
125
+ is this client losing its view of a run that may still be running.
126
+
127
+ ## SSE recovery
128
+
129
+ The client checks that SSE ids are canonical positive integers and equal
130
+ `data.sequence`. An identical duplicate is ignored. A conflicting duplicate,
131
+ gap, or out-of-order frame is a protocol failure. After a reset the client
132
+ asks durable job state before reconnecting with `Last-Event-ID`; retry delays
133
+ and attempts are bounded.
134
+
135
+ A replacement Node process can call `attachRun(jobId, { lastEventId })`. The
136
+ SDK proves that the durable job exists before returning an owned run handle,
137
+ then sends the cursor as `Last-Event-ID`. Persist the job id and last event
138
+ sequence in the same application transaction that records each consumed event.
139
+ A cursor means “fully processed”, not merely “received”.
140
+
141
+ ## Idempotency and schedules
142
+
143
+ HTTP `run()` requires a caller-owned `idempotencyKey` of 1–255 bytes. Omitting
144
+ it throws `NikaConfigurationError` before network I/O or local snapshot capture.
145
+ Persist the key before admission and retry the exact request with the same key
146
+ if the response is lost or times out. The SDK never generates a hidden key or
147
+ retries admission automatically. Reusing a key with different request bytes
148
+ is an engine conflict, not a retry success. Direct native runs still omit the
149
+ key and reject it if supplied.
150
+
151
+ The namespace is the whole durable job store under the server's configured
152
+ `state-root`, across workflows, clients, schedules, and server restarts. The
153
+ current engine has no time-based eviction: keys remain bound while that state
154
+ root exists and still count toward its configured job capacity. Use globally
155
+ unique, business-stable keys; do not recycle daily counters or workflow-local
156
+ names.
157
+
158
+ Schedules use compare-and-swap semantics. Create omits `revision`; update must
159
+ carry the exact previous `sha256:...` revision. The SDK never fabricates or
160
+ normalizes schedule facts.
@@ -0,0 +1,162 @@
1
+ # Migrating to 0.116
2
+
3
+ Version 0.116 is an intentional breaking consolidation of the two published
4
+ 0.115 clients into one `Nika` facade. This is a 0.x minor release, but existing
5
+ 0.115 imports and method calls do not all remain source-compatible. Migrate in
6
+ a branch and run the packed-package gauntlets before upgrading production.
7
+
8
+ ## Removed 0.115 surfaces
9
+
10
+ - The `@supernovae-st/nika-client/local` export and `LocalNika` class are
11
+ removed. Use `new Nika({ bin, cwd })`; call `check()`, `run()`, `events()`,
12
+ and `traceVerify()` on that instance.
13
+ - The root `nika.jobs` and `nika.workflows` namespaces are removed. Use the
14
+ facade methods shown below.
15
+ - `Nika.fromEnv()`, `nika.health()`, `Nika.verifyWebhook()`, and the exported
16
+ webhook helper are removed. Construct the client explicitly; health is now
17
+ an internal compatibility preflight. Keep webhook verification in the
18
+ application boundary that owns its signing format.
19
+ - Preview artifact, workflow-source/reload, and `runAndCollect` helpers are
20
+ removed instead of continuing as methods that always refuse.
21
+ - Node 18 and 20 are no longer supported; the package now requires Node 22 or
22
+ newer.
23
+
24
+ ## Constructor migration
25
+
26
+ The 0.115 root constructor was HTTP-only. In 0.116, no URL means the native
27
+ process transport; supplying `url` and `token` selects HTTP. Remote `check()`
28
+ and `run()` also need a local Nika binary (`bin`, `NIKA_BIN`, or the exact
29
+ optional host payload package) because
30
+ the SDK captures and validates immutable snapshot bytes before admission.
31
+ The current by-name HTTP path also accepts contained workflow names without a
32
+ local engine; prefix a local file with `./` to retain snapshot capture.
33
+
34
+ ```ts
35
+ // 0.115
36
+ const oldClient = new Nika({ url, token, timeout: 30_000 });
37
+
38
+ // 0.116
39
+ const nika = new Nika({
40
+ url,
41
+ token,
42
+ bin: process.env.NIKA_BIN,
43
+ requestTimeout: 30_000,
44
+ allowInsecureHttp: url.startsWith('http://127.0.0.1'),
45
+ });
46
+ ```
47
+
48
+ `timeout`, retries, polling, concurrency, logger, and per-run `signal` options
49
+ from the old HTTP client are gone. Request and frame bounds are client
50
+ invariants; observe or cancel a returned run through its owned lifecycle.
51
+
52
+ ## Method mapping
53
+
54
+ | 0.115 | 0.116 |
55
+ | --- | --- |
56
+ | `local.check(file)` | `nika.check(file)` |
57
+ | `local.run(file)` | `await nika.run(file)`, then `nika.events(run)` and `run.done` |
58
+ | `local.runToEnd(file)` | `const run = await nika.run(file); await run.done` |
59
+ | `local.traceVerify(path)` | No path-based replacement; retain the engine-issued receipt and call `nika.traceVerify(receipt)` |
60
+ | `nika.jobs.submit(workflow)` | `nika.run(workflow)` |
61
+ | `nika.jobs.status(id)` | retain the owned run; `nika.status(run)` |
62
+ | `nika.jobs.stream(id)` | `attachRun(id)`, then `events(run)` |
63
+ | `nika.jobs.cancel(id)` | `nika.cancel(run)` |
64
+ | `nika.workflows.list()` | `nika.listWorkflows()` |
65
+ | `nika.workflows.metadata(name)` | `nika.workflow(name)` |
66
+
67
+ In 0.116 the run handle was only `{ id, done }`. The Run now owns its
68
+ lifecycle: `run.events()`, `run.result()`, `run.status()` and `run.cancel()`,
69
+ with `run.done` kept as the alias of `run.result()`. The `nika.events(run)`,
70
+ `nika.cancel(run)` and `nika.status(run)` forms this guide shows still work
71
+ unchanged as deprecated wrappers, and `nika.events(run)` still yields the
72
+ protocol vocabulary; `run.events()` yields one lifecycle vocabulary on both
73
+ transports with that frame on `event.raw`. The wrappers stay for one release
74
+ train counted from the first published train that carries the new API, with
75
+ no version or date fixed; "Migrating to the Run-owned lifecycle" in the
76
+ README defines that window.
77
+
78
+ The client-level methods reject a look-alike object and a run from another
79
+ client, so persist the job id and reattach after a process restart instead of
80
+ rebuilding a handle by hand.
81
+
82
+ `LocalNika.version()`, `dryRunPlan()`, and `test()` have no One SDK method in
83
+ 0.116. Keep those CLI-facing probes in deployment/CI (`nika --version`,
84
+ `nika run --dry-run --json`, and `nika test`) until a future typed authority is
85
+ explicitly admitted. This release does not silently emulate them.
86
+
87
+ ## The check report changed shape
88
+
89
+ `check()` still returns `clean` and `exitCode`, but the object around them is
90
+ no longer the 0.115 `LocalCheckReport` (eleven curated fields plus a `raw`
91
+ escape hatch). It is the engine's own machine report, passed through: the
92
+ former `raw` contents are now the top level, `reportVersion` is
93
+ `report_version`, and `parseFatal` and `warnings` are gone. The engine emits
94
+ its identity under both casings (`engineVersion` and `engine_version`,
95
+ `buildSha` and `build_sha`, `specSha` and `spec_sha`, `checkReportVersion`
96
+ and `report_version`); read either, do not diff them. In TypeScript only
97
+ `report_version`, `clean`, and `exitCode` are typed; every other field,
98
+ including `cost`, `findings`, and `hints`, is `unknown` behind an index
99
+ signature, so strict callers narrow it themselves. Two problems that look like
100
+ findings (a missing `permits:` block, a missing `max_tokens:`) are reported
101
+ under `hints[]` (`{ kind, task, advice }`, no `code`), not `findings[]`, in
102
+ both versions.
103
+
104
+ `runToEnd()` returned `{ ok, exitCode, events[] }` with every event buffered.
105
+ `run.done` returns the terminal result only; the events ride `events(run)`
106
+ as a bounded iterator. In 0.116 a session retained 256 frames, below the 273 a
107
+ measured clean native run of 90 `mock/echo` tasks writes, so such a run had to
108
+ be observed concurrently or its prefix was gone. The default is now 4096 and a view opened
109
+ after the result replays every retained frame; past the bound it is refused
110
+ with `reason: 'replay_truncated'`, never shortened, and the result is
111
+ unaffected. An explicit `eventBufferSize` keeps its cap. The removed methods (`version()`, `dryRunPlan()`, path-based
112
+ `traceVerify()`) are absent, not stubbed: calling them throws a plain
113
+ `TypeError: … is not a function`, and a trace that only exists as a file
114
+ path in a later process has no SDK verification door in 0.116.
115
+
116
+ ## New resident discovery
117
+
118
+ ```ts
119
+ const names = await nika.listWorkflows();
120
+ const metadata = await nika.workflow(names[0]);
121
+ ```
122
+
123
+ These methods require an HTTP client. A native-process client returns a typed
124
+ `NikaCompatibilityError` with capability `workflowCatalog`.
125
+
126
+ ## Durable status
127
+
128
+ ```ts
129
+ const run = await nika.run('flow.nika');
130
+ console.log(await nika.status(run));
131
+ console.log(await run.done);
132
+ ```
133
+
134
+ `status(run)` is an observation, not terminal settlement. Keep `run.done` as
135
+ the sole terminal promise. Native-process runs refuse `status()` because a
136
+ short-lived process has no independent durable status authority.
137
+
138
+ ## Durable run recovery
139
+
140
+ ```ts
141
+ const recovered = await nika.attachRun(saved.jobId, {
142
+ lastEventId: saved.lastEventSequence,
143
+ });
144
+ for await (const event of nika.events(recovered)) {
145
+ await saveApplicationCheckpoint(recovered.id, event.sequence);
146
+ }
147
+ console.log(await recovered.done);
148
+ ```
149
+
150
+ `attachRun()` is HTTP-only. It proves the job exists, returns a normal owned
151
+ run handle, and resumes the stream with `Last-Event-ID`. Save the job id and
152
+ event cursor in application durable state before the original process exits.
153
+ If more events arrive before the application subscribes than its configured
154
+ buffer can retain, `events()` refuses with `NikaEventBufferOverflowError`
155
+ instead of silently skipping a replay prefix.
156
+
157
+ ## Contract and release alignment
158
+
159
+ Version 0.116 targets the engine train whose OpenAPI contract contains check,
160
+ jobs, status, events, cancellation, typed trace verification, resident
161
+ workflow discovery, and schedule CAS. Do not publish the SDK before the
162
+ matching engine release and native payload assets exist.
@@ -0,0 +1,173 @@
1
+ # Testing and release evidence
2
+
3
+ The release judge is the packed package consumed from an isolated Node
4
+ project, not a source import. Local development still starts with the fast
5
+ gates:
6
+
7
+ ```sh
8
+ npm ci
9
+ npm test
10
+ npm run build
11
+ npm run check:coverage
12
+ npm run check:release-evidence
13
+ NIKA_BIN=/path/to/nika npm run gauntlet:check
14
+ NIKA_BIN=/path/to/nika npm run gauntlet:run
15
+ NIKA_BIN=/path/to/nika npm run gauntlet:projects
16
+ NIKA_BIN=/path/to/nika npm run gauntlet:depth
17
+ NIKA_BIN=/path/to/nika npm run gauntlet:hostile
18
+ NIKA_BIN=/path/to/nika npm run gauntlet:recovery
19
+ NIKA_BIN=/path/to/nika npm run gauntlet:one-door
20
+ npm audit
21
+ npm pack --dry-run
22
+ ```
23
+
24
+ All engine-backed gauntlets use `NIKA_BIN` as the canonical explicit binary.
25
+ `NIKA_GAUNTLET_BIN` remains a compatibility fallback for the corpus-only
26
+ scripts. Evidence is invalid when the recorded engine identity does not match
27
+ the intended release candidate. `npm run check:release-evidence` binds every
28
+ current committed gauntlet result and packed tarball identity to the root
29
+ package version. Historical ledgers are limited to an explicit allowlist and
30
+ must remain labelled as non-gating evidence.
31
+
32
+ CI adds a behavioral provenance replay. It downloads the Linux x64 asset for
33
+ the exact root package version, verifies its GitHub attestation and published
34
+ `SHA256SUMS` entry, then reruns all 100 deterministic workflows, the hostile suite, all five mini-SaaS projects, all five depth projects,
35
+ the two-process recovery scenario, and five scenarios through six execution
36
+ doors from a freshly packed SDK. The runner
37
+ mints an ephemeral run-signing key. Its
38
+ cancellation fixtures retain the in-process `nika:wait` cases and add an
39
+ owned loopback rendezvous for controlled task-boundary cancellation. They
40
+ need no shell command, platform sandbox, or sandbox waiver. Cancellation
41
+ and sealed-trace claims are exercised against the public binary. The
42
+ cancellation fixtures cancel an execution that is observably inside its 10 s
43
+ `nika:wait` (the durable status reads `running`, no longer `queued`, and a
44
+ further delay has passed) and record both the cancel reply and the terminal it
45
+ leads to. On engine 0.118 the resident answers 202 `cancellation_requested`;
46
+ its execution owner then records `execution.interrupted` with
47
+ `status=interrupted` once the grace expires inside a task, or, when the request
48
+ lands at a task boundary, `execution.cancelled` (the `cancel_job` writer) or
49
+ `execution.settled` (the racing settlement writer) with `status=cancelled` and a
50
+ settlement whose cause is `operator`. A cancel that lands before the execution
51
+ starts is a 200 `cancelled` whose terminal is one of those two writer kinds,
52
+ only with `status=cancelled`. The verifiers bind each cancel reply to the
53
+ terminals it may lead to, demand the run status of that terminal, and refuse any
54
+ other pairing. The parsed deterministic and packed-project results must match
55
+ exactly except for the recovery job UUID. The hostile comparison excludes
56
+ `generated_at` and per-scenario duration and canonicalizes only the two ratified
57
+ writer kinds of a cancelled terminal after checking the exact pairing. This proves that the
58
+ attested public release currently reproduces the committed behavioral claims.
59
+ It does not claim cryptographic proof of when the committed JSON file itself
60
+ was originally written.
61
+
62
+ ## Test layers
63
+
64
+ 1. Unit tests cover configuration, local process framing, HTTP protocol
65
+ validation, SSE recovery, independent observer backpressure, scheduling,
66
+ receipts, and typed errors.
67
+ 2. The generated corpus holds 100 distinct use cases and 100 valid workflows.
68
+ 3. The deterministic runner executes every workflow with `mock/echo` and
69
+ seals trace evidence without paid-provider dependence.
70
+ 4. Project gauntlets install the tarball into fresh applications and exercise
71
+ realistic multi-step use cases.
72
+ 5. Hostile tests mutate transport frames, timing, status codes, identities,
73
+ revisions, and replay order.
74
+ 6. Public Personas use only the README, exported types, packed package, public
75
+ binary/help, loopback HTTP, and public documentation. They are synthetic
76
+ users, never substitutes for human usability evidence.
77
+
78
+ The latest public-only first-contact wave and its convergent debt are recorded
79
+ in [`gauntlet/personas/REPORT.md`](../gauntlet/personas/REPORT.md).
80
+
81
+ ## Socratic risk matrix
82
+
83
+ Every release wave must ask and demonstrate an answer to these questions:
84
+
85
+ - Can a first-time Node user succeed from the README without repository
86
+ knowledge?
87
+ - Do ESM and CommonJS load from the packed tarball on every supported Node
88
+ major?
89
+ - Does a workflow the engine refuses reject native `run()` with the engine's
90
+ code and findings, on every refusal dialect a supported engine writes, with
91
+ no run handle, no second spawn and no preflight check? Does output that
92
+ proves neither admission nor refusal stay a protocol fault? The answer is
93
+ `test/native-run-admission.test.ts`. Its `wire-*` cases replay stdout and
94
+ stderr captured byte-for-byte from real engines
95
+ (`test/fixtures/run-wire/README.md` records which, and how); its
96
+ `SYNTHETIC` cases are invented hostile shapes. A replay proves the SDK
97
+ decodes those bytes, never that an engine still writes them: a new engine
98
+ release needs a new capture.
99
+ - What happens if the server dies after admission but before the first SSE
100
+ frame?
101
+ - What happens if SSE reconnects after a duplicate, gap, conflicting replay,
102
+ or terminal race?
103
+ - Can one slow observer overflow without damaging another observer or
104
+ `run.result()`?
105
+ - Does one application read the same lifecycle words over the native process
106
+ and over HTTP, with `event.raw` still the protocol frame, and without a
107
+ per-task event the resident never streamed?
108
+ - Does a frame whose state word is absent, null, future, or still running
109
+ stay an `engine.event` instead of being named settled?
110
+ - Is a human gate (`paused`) kept apart from both failure and completion, and
111
+ the engine's `interrupted` evidence apart from a broken observation?
112
+ - Can two clients race the same idempotency key with equal and unequal
113
+ snapshots?
114
+ - Does cancellation win or replay honestly when settlement races it?
115
+ - Does a stale schedule writer receive the current revision without mutating
116
+ durable state?
117
+ - Do process and server restarts preserve the facts the API claims are
118
+ durable?
119
+ - Can a replacement client reattach with its last committed SSE cursor without
120
+ replaying an application side effect?
121
+ - Are auth failures, token rotation, malformed content types, compressed
122
+ bodies, oversized frames, invalid UTF-8, and timeouts typed and redacted?
123
+ - Are receipt job, execution, and trace identities consistent across SSE,
124
+ durable state, and verification?
125
+ - Is the run-signing private key still confined to engine custody, with only
126
+ public trust material entering application infrastructure?
127
+ - Does every live OpenAPI route have a deliberate SDK treatment?
128
+ - Does every documented example compile and run from the tarball?
129
+ - Does the version agree across package metadata, lockfile, optional native
130
+ packages, OpenAPI identity, engine release, npm, and GitHub?
131
+ - Can a claimed capability be deleted without a gate becoming red? If yes,
132
+ the capability is not yet wired.
133
+
134
+ ## Release evidence
135
+
136
+ Record exact commands, versions, commit SHAs, platform, run counts, cost, and
137
+ the path to machine-readable results. A green unit suite alone is never release
138
+ evidence. A failed or skipped lane stays named; it is not rounded into a pass.
139
+
140
+ The release ceremony is deliberately two-step. `release.yml` validates the
141
+ tagged engine assets, starts the released Linux binary, proves the live
142
+ OpenAPI/types pin, embeds the exact prepared commit and release version in all
143
+ five package manifests before packing, and publishes four payloads plus the SDK
144
+ through npm trusted publishing with GitHub OIDC and Sigstore provenance bound
145
+ to the workflow identity. Every package registers organization `supernovae-st`,
146
+ repository `nika-client`, workflow filename `release.yml`, and environment
147
+ `npm-publish`, with direct `npm publish` enabled. The GitHub-hosted publish job
148
+ uses Node 24, npm 11.19.1 and `id-token: write`; it receives no npm write token.
149
+ `release-heal.yml` dispatches that same file on `main`; it does not publish or
150
+ exchange an OIDC token itself. All five package manifests identify the SDK
151
+ repository; native `SOURCE.json` still identifies the separate engine source.
152
+ See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/).
153
+ An occupied version is accepted only after its exact
154
+ prepared tarball integrity and fetched registry bytes match; errors other than
155
+ an explicit registry 404 refuse publication. `release-finalize.yml` refuses to create the SDK tag and GitHub
156
+ Release until all five exact versions are publicly observable on npm and every
157
+ published manifest carries the same prepared commit and version.
158
+
159
+ ## One-door parity and process supervision
160
+
161
+ `gauntlet:one-door` compares CLI, raw HTTP by name and snapshot, and packed SDK
162
+ native, by-name and snapshot execution. It checks success, failure, recovery,
163
+ paused observation and controlled cancellation. `NIKA_ONE_DOOR_REPORT` names
164
+ its output file; CI retains it alongside the replay results. Development mode
165
+ uses an offline installation of the freshly packed SDK with the explicit
166
+ `NIKA_BIN`. Public npm parity requires `NIKA_PUBLIC_SDK_VERSION` and an outer
167
+ artifact-provenance gate; runtime agreement alone is not an attestation.
168
+
169
+ All harnesses own their child processes, impose finite deadlines and await
170
+ cleanup before emitting green evidence. The corpus runs in a fresh project and
171
+ HOME. Changed fixtures require new measured results: old committed ledgers
172
+ remain historical observations until a successful exact-version replay replaces
173
+ them. Never relabel an old binary or weaken the replay comparison.