@ngockhoale/ukit 3.1.9 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,10 +2,24 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.2.0 - 2026-09-26
6
+
7
+ **Agent VM / Language-Compiled Runtime phase 2 (V-01..V-06)** — IR v2 flag-gated opcodes, decisionRuntime.vm stage-promotion machinery, plan library pins, VM decision nodes via unic-decision, host parity adapters, sanitized support bundle. All new surfaces ship `off`.
8
+
9
+ - **Host lane adapters for the owned runner (agent-vm-runtime V2).** `adapters.js` gains the frozen `HOST_ADAPTERS` registry plus `detectHost` (env-marker best-effort ambient engine resolution, ambiguous → `null`) and `probeHostAdapter` — honest per-host capability statuses `supported` / `unsupported-probe` / `unsupported` with typed reasons (`unknown_host`, `process_group_kill_unavailable`, `host_spawn_unavailable`, `live_host_e2e_unproven`); `supported` is reachable only via explicit `liveHostE2E` evidence, never inferred from primitive presence (G-V2). `buildHostCapabilityMap` in `runtimeSupport.js` exposes the map per lane; every lane reports `completionApi: 'eventStore-journal'` — no engine exposes a host-owned durable completion API. `createSupervisor` accepts `opts.host` and per-launch `spec.host` (precedence spec → opts → ambient detect → no lane); a lane probed `unsupported` refuses `start()` typed (`host_unknown`/`host_unsupported`, zero fs writes) while `unsupported-probe` lanes still run the identical spawn → journal → terminal contract. Live-E2E gap for claude-code/codex is documented in the task file — never claimed as supported.
10
+ - **Agent VM V-01 — IR v2 flag gate + versioned IR contract.** IR v2 ops (`PARALLEL`, `TIMEOUT`) now parse only behind an explicit opt-in — `ir.planVersion: 'v2'` on the artifact, `opts.irVersion: 'v2'` on `compilePlan`, a caller `allowedOps` list naming them, or a non-`off` `decisionRuntime.vm` stage forwarded by `planLibrary.loadPlan({config})`. Ungated v2 IR fails with `ir_v2_disabled`; unknown version literals fail `invalid_ir_version`. `contract.js` exports `IR_VERSIONS` (`['v1','v2']`) and `ValidatedPlan` carries `irVersion` derived from ops actually used — v1 plans compile byte-identical under every flag state (planVersion unchanged). RETRY lands as the bounded per-node `retry` policy (side-effect-class aware: non-auto-retryable classes can never claim `maxAttempts > 1`; a crashed non-idempotent node stays `recovery_required` and is never re-run blind). New fault coverage: TIMEOUT firing mid-node on deliver → deterministic `escalate` replay-identically; duplicate delivery at the join is idempotent across a crash (single `completed` fold in the wrapper journal); bounded fan-out honors `maxNodes`.
11
+
12
+ - **V4 plan library: release pinning + spec text.** `planLibrary.loadPlan` now returns `specText` (the human-authored spec each plan was compiled from) and `planVersion`, and enforces `PLAN_PINS` — a shipped plan whose compiled hash drifts from its pin fails with `plan_version_mismatch` instead of silently running a different workflow. Fourth reference plan `flag-promotion` (stage-promotion gate: baseline replay → shadow runs → promote/rollback branch, mirroring `promotion.js`) joins `bugfix-loop`, `handoff-review-batch`, `release-check`. Coverage: artifact-hash stability, vmEngine replay determinism per plan, malformed-artifact rejection, pin drift.
13
+ - **VM decision nodes via `unic-decision` (agent-vm-runtime V5).** `vmEngine` nodes may now carry a bounded `question` block (`{decisionKey?, instruction?, candidates?}`, validated by `validateNodeQuestion` in `contract.js` and carried through `planCompiler`). When `decisionRuntime.vm.stage` is promoted past `off` and no `classifyFn` is injected, unclassifiable events are routed by `createVmClassifyFn` (`src/decision/runtimeDecide.js`) through one bounded `runtime.node_route.v1` / `runtime.node_classify.v1` question — the only two keys a node may ask (registered `rolloutStage: 'off'`, owner `vmEngine`, deterministic escalate fallback). Model answers are data only (`tool_calls` never dispatched); `model_not_found`/timeout/invalid/adapter faults all resolve to the deterministic escalation lane with a `fallbackCode`, never a hard failure — a throwing `classifyFn` now escalates too. Stage `off` (default, absent, or malformed) installs no adapter: zero decision calls, byte-identical behaviour.
14
+
15
+ - **Agent VM V-06 — per-run support bundle** (`src/core/agentRuntime/diagnostics.js` `exportSupportBundle`, `eventStore.js` `readJournalExcerpt` + `summarizeEvent`, `telemetry.js` `buildTraceExcerpt`, `ukit telemetry export-run <operationId>`): one sanitized bundle per operation lands in the Data Foundation support layout — `Documents/UKit Support/proj-<sha256>/run-<sha256>/` with `journal.jsonl` (bounded code summaries only), `trace.jsonl` (double-gated span excerpt, deterministic pseudonyms), `SUMMARY.md` and a `ukit-support/1` manifest with sha256 checksums, importable via `ukit telemetry import`. Privacy: redaction before persist — secret/path/PII rules + `sanitizeForSupport` on trace records + a final assembled-bytes re-scan that blocks all writes on any residual secret; no raw prompt/diff/path content can appear. `decisionRuntime.diagnostics.stage: 'off'` → `skipped` with zero writes. Missing/corrupt journals still emit bundles with declared `gap` markers; a 256 KiB cap drops excerpt lines deterministically (`coverage.dropped_lines`).
16
+
5
17
  ## 3.1.9 - 2026-09-26
6
18
 
7
19
  - **Checkpoint locked to `unic-decision` for all classes.** Owner decision: the single `unic-decision` model answers every decision (English, multilingual, unknown) — the provider swaps the backend (Lava/JEV) behind that name, so UKit no longer routes per-language. `checkpoints.{default,english,unknownLanguage}` all resolve to `unic-decision` in code + shipped config; `unic-decision-multilingual` removed from defaults (remains configurable via `decisionPlane.checkpoints` if ever needed). Endpoint config unchanged: `ukit decision` / `gatewayDecision.json` / `UKIT_DECISION_*` env.
8
20
 
21
+ - **Agent VM V-03 — stage-promotion machinery** (`src/core/agentRuntime/promotion.js`, `src/core/runtimeConfig.js`): new `resolveDecisionRuntimeStage(config, key)` resolves `decisionRuntime.<key>.stage` with `decisionPlane` semantics (absent/malformed → `off`); new pure `promote(config, evidence)` advances one family flag `off → shadow → canary → default` only when the frozen `PROMOTION_CRITERIA` hold on ≥ `minSampledRuns` sampled runs (quality delta ≥ `-QUALITY_SCORE_FLOOR`, wall-p95 and cost ratios ≤ baseline), and instant rollback (`evidence.rollback` or any forbidden failure) resolves `off` — deterministic owners authoritative, zero VM calls. `off` never self-promotes and one noisy run never flips a default. All `decisionRuntime.*` stages stay `off`: machinery only, no rollout flip.
22
+
9
23
  ## 3.1.8 - 2026-09-26
10
24
 
11
25
  - **Agent VM IR v2**: `PARALLEL` (bounded fan-out, `join:'all'`) and `TIMEOUT` (deadline wrapper, `onTimeout: fail|escalate`, 30-minute ceiling) opcodes added to `planCompiler` + `vmEngine`; fault-injection coverage for crash-between-fan-out-and-join, non-idempotent child recovery, duplicate delivery. v2 plans hash under a `v2:`-prefixed serialization; v1 hashes unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.1.9",
3
+ "version": "3.2.0",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -42,7 +42,7 @@ import { validateSupportBundle } from '../../core/observability/support/import.j
42
42
  import { runEvaluation } from '../../core/observability/evaluation/runner.js';
43
43
 
44
44
  const HELP_FLAGS = new Set(['--help', '-h', 'help']);
45
- const SUBCOMMANDS = new Set(['collect', 'status', 'digest', 'export-support', 'import', 'evaluate']);
45
+ const SUBCOMMANDS = new Set(['collect', 'status', 'digest', 'export-support', 'export-run', 'import', 'evaluate']);
46
46
  const JSON_SUBCOMMANDS = new Set(['status', 'digest', 'evaluate']);
47
47
 
48
48
  // The automatic support refresh's canary gate lives in schedule.js via
@@ -62,6 +62,7 @@ function printUsage() {
62
62
  console.log(' status Show stage, segments, counters, support lag, crashes');
63
63
  console.log(' digest Rebuild the trace index; print anomalies + digest markdown');
64
64
  console.log(' export-support Write the sanitized UKit Support bundle now');
65
+ console.log(' export-run <op> Write a sanitized per-run support bundle (V-06)');
65
66
  console.log(' import <path> Validate a received support bundle (zip or directory)');
66
67
  console.log(' evaluate Run the AI evaluator lane (needs observability.evaluator config)');
67
68
  console.log('');
@@ -370,6 +371,45 @@ async function exportSupportCommand({ projectRoot, config }) {
370
371
  process.exitCode = 2;
371
372
  }
372
373
 
374
+ // --- export-run (V-06) -------------------------------------------------------
375
+
376
+ /**
377
+ * `export-run <operationId>` — stage-gated per-operation support bundle.
378
+ * Unlike export-support this is NOT lifted past the stage gate: the
379
+ * bundle lives under decisionRuntime.diagnostics, and 'off' means zero
380
+ * writes by contract (SPEC G7-FR04).
381
+ */
382
+ async function exportRunCommand({ projectRoot, config, operationId }) {
383
+ const { exportSupportBundle } = await import('../../core/agentRuntime/diagnostics.js');
384
+ const runtimeDir = path.join(projectRoot, '.ukit', 'storage', 'agent-runtime');
385
+ let res;
386
+ try {
387
+ res = await exportSupportBundle(runtimeDir, operationId, {
388
+ config,
389
+ projectKey: projectRoot,
390
+ traceRoot: segmentsRoot(projectRoot),
391
+ });
392
+ } catch {
393
+ res = { status: 'degraded', reason: 'bundle_error', bytes: 0 };
394
+ }
395
+ if (res.status === 'written') {
396
+ // Pseudonymous location only — the absolute path is user-private.
397
+ const proj = path.basename(path.dirname(res.dir));
398
+ const run = path.basename(res.dir);
399
+ console.log(
400
+ `export-run: written bytes=${res.bytes ?? 0} ` +
401
+ `dir=${SUPPORT_DIR_NAME}/${proj}/${run}`,
402
+ );
403
+ return;
404
+ }
405
+ console.log(
406
+ `export-run: ${res.status}` +
407
+ (res.reason ? ` reason=${res.reason}` : '') +
408
+ ` bytes=${res.bytes ?? 0}`,
409
+ );
410
+ process.exitCode = 2;
411
+ }
412
+
373
413
  async function importCommand({ bundlePath }) {
374
414
  const res = await validateSupportBundle({ path: bundlePath });
375
415
  if (!res.ok) {
@@ -434,7 +474,7 @@ export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
434
474
  const flags = rest.filter((a) => a.startsWith('-'));
435
475
  const allowed = JSON_SUBCOMMANDS.has(sub) ? new Set(['--json']) : new Set();
436
476
  const unknownFlags = flags.filter((f) => !allowed.has(f));
437
- const maxPositional = sub === 'import' ? 1 : 0;
477
+ const maxPositional = (sub === 'import' || sub === 'export-run') ? 1 : 0;
438
478
 
439
479
  if (unknownFlags.length > 0) {
440
480
  console.error(`[UKit] Unknown telemetry flag(s): ${unknownFlags.join(', ')}`);
@@ -442,7 +482,8 @@ export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
442
482
  process.exitCode = 1;
443
483
  return;
444
484
  }
445
- if (positional.length > maxPositional || (sub === 'import' && positional.length === 0)) {
485
+ const wantsOperand = sub === 'import' || sub === 'export-run';
486
+ if (positional.length > maxPositional || (wantsOperand && positional.length === 0)) {
446
487
  printUsage();
447
488
  process.exitCode = 1;
448
489
  return;
@@ -455,6 +496,7 @@ export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
455
496
  if (sub === 'status') return statusCommand({ projectRoot, config, json });
456
497
  if (sub === 'digest') return digestCommand({ projectRoot, json });
457
498
  if (sub === 'export-support') return exportSupportCommand({ projectRoot, config });
499
+ if (sub === 'export-run') return exportRunCommand({ projectRoot, config, operationId: positional[0] });
458
500
  if (sub === 'evaluate') return evaluateCommand({ projectRoot, config, json });
459
501
  return importCommand({ bundlePath: positional[0] });
460
502
  }
@@ -27,12 +27,22 @@
27
27
  * recorder via telemetry.js — synchronous, never-throw, stage-gated
28
28
  * inside emit(). `now` and `artifactStore` are injectable for tests.
29
29
  *
30
+ * Host lane adapters (V-02, AGENT_VM_RUNTIME_PLAN item V2) live at the
31
+ * end of this module: HOST_ADAPTERS is the frozen capability registry
32
+ * for the owned-runner host lanes (omp / claude-code / codex),
33
+ * detectHost best-effort resolves the ambient engine session from env
34
+ * markers, and probeHostAdapter evaluates one lane against the current
35
+ * environment + injectable primitives — honest statuses only
36
+ * ('supported' | 'unsupported-probe' | 'unsupported'), never a faked
37
+ * supported.
38
+ *
30
39
  * `artifactStore` protocol (injected): `put(buffer)` →
31
40
  * `Promise<string | { path: string }>`; the returned ref is recorded on
32
41
  * `event.artifactRefs` and in the result `artifactRefs` list.
33
42
  */
34
43
 
35
44
  import crypto from 'node:crypto';
45
+ import { spawn } from 'node:child_process';
36
46
 
37
47
  import {
38
48
  validateSemanticEvent,
@@ -333,3 +343,170 @@ export async function adaptOutput({
333
343
  }
334
344
  return { events: [event], artifactRefs, parseStatus };
335
345
  }
346
+
347
+ // --- V-02: host lane adapters (AGENT_VM_RUNTIME_PLAN item V2) ---------
348
+ // The owned runner (supervisor.js) is primitive-level identical on every
349
+ // engine: Node child_process spawn, detached process-group kill, and
350
+ // kill(pid, 0) liveness — UKit-owned children only; no engine's own
351
+ // process lifecycle is hijacked. The lanes below describe that contract
352
+ // per host honestly: no engine exposes a host-owned durable completion
353
+ // API, so completionApi is UKit's own durable journal on every lane.
354
+ //
355
+ // Status ladder (G-V2 — never fake a completion signal):
356
+ // 'supported' — primitives present AND live host E2E evidence
357
+ // was explicitly declared (opts.liveHostE2E).
358
+ // Absent live evidence a probe never upgrades.
359
+ // 'unsupported-probe' — the lane contract is available at the
360
+ // primitive level but live host E2E has NOT
361
+ // been proven in this environment. The owned
362
+ // runner may launch; capability reports stay
363
+ // honest about the evidence gap.
364
+ // 'unsupported' — a required primitive is absent (win32 process-
365
+ // group kill, no spawnImpl, …) or the host is
366
+ // unknown. Never runnable.
367
+
368
+ export const HOST_NAMES = Object.freeze(['omp', 'claude-code', 'codex']);
369
+
370
+ export const HOST_SUPPORTED = 'supported';
371
+ export const HOST_UNSUPPORTED_PROBE = 'unsupported-probe';
372
+ export const HOST_UNSUPPORTED = 'unsupported';
373
+
374
+ // No engine exposes a host-owned durable completion API today; every
375
+ // lane's durable completion channel is the eventStore journal.
376
+ export const OWNED_COMPLETION_API = 'eventStore-journal';
377
+
378
+ // Frozen per-lane descriptor — static contract only; environment truth
379
+ // comes from probeHostAdapter, never from this table.
380
+ export const HOST_ADAPTERS = Object.freeze({
381
+ 'omp': Object.freeze({
382
+ name: 'omp',
383
+ completionApi: OWNED_COMPLETION_API,
384
+ ownedProcessGroups: true,
385
+ }),
386
+ 'claude-code': Object.freeze({
387
+ name: 'claude-code',
388
+ completionApi: OWNED_COMPLETION_API,
389
+ ownedProcessGroups: true,
390
+ }),
391
+ 'codex': Object.freeze({
392
+ name: 'codex',
393
+ completionApi: OWNED_COMPLETION_API,
394
+ ownedProcessGroups: true,
395
+ }),
396
+ });
397
+
398
+ // Documented session env markers for ambient engine detection. Explicit
399
+ // identity always wins: UKIT_HOST, then UKIT_AGENT_ID, then the marker
400
+ // sets — a hit in >1 marker set is ambiguous and resolves null (never a
401
+ // guessed lane).
402
+ const HOST_ENV_MARKERS = {
403
+ 'omp': ['OMP_HOME', 'OMP_SESSION_ID', 'ORCA_OMP_SOURCE_AGENT_DIR'],
404
+ 'claude-code': ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'],
405
+ 'codex': ['CODEX_SANDBOX', 'CODEX_CI', 'ORCA_CODEX_HOME'],
406
+ };
407
+
408
+ /**
409
+ * Best-effort ambient host resolution.
410
+ * @param {object} [env] default process.env
411
+ * @returns {string|null} a HOST_NAMES member, or null when absent/ambiguous.
412
+ */
413
+ export function detectHost(env = process.env) {
414
+ const source = env && typeof env === 'object' ? env : {};
415
+ const explicit = source.UKIT_HOST ?? source.UKIT_AGENT_ID;
416
+ if (typeof explicit === 'string' && HOST_ADAPTERS[explicit]) return explicit;
417
+ const hits = HOST_NAMES.filter(
418
+ (name) => HOST_ENV_MARKERS[name].some((key) => source[key] != null),
419
+ );
420
+ return hits.length === 1 ? hits[0] : null;
421
+ }
422
+
423
+ /**
424
+ * Evaluate one host lane against the current environment.
425
+ *
426
+ * @param {string} name lane name (HOST_NAMES member or arbitrary string)
427
+ * @param {object} [opts]
428
+ * @param {boolean} [opts.liveHostE2E] declare live host E2E evidence —
429
+ * the ONLY path to 'supported'. Never derived from primitive presence.
430
+ * @param {Function} [opts.spawnImpl] required primitive (default Node spawn)
431
+ * @param {Function} [opts.killImpl] required primitive (default process.kill)
432
+ * @param {Function} [opts.probeImpl] required liveness primitive (default sig-0)
433
+ * @param {string} [opts.platform] default process.platform
434
+ * @returns {{name:string|null, supported:string, reason:string|null,
435
+ * completionApi:string|null, primitives:object}}
436
+ */
437
+ export function probeHostAdapter(name, opts = {}) {
438
+ const descriptor = HOST_ADAPTERS[name];
439
+ if (!descriptor) {
440
+ return {
441
+ name: null,
442
+ supported: HOST_UNSUPPORTED,
443
+ reason: 'unknown_host',
444
+ completionApi: null,
445
+ primitives: {},
446
+ };
447
+ }
448
+ const platform = typeof opts.platform === 'string' ? opts.platform : process.platform;
449
+ // `undefined` picks the Node defaults; any other non-function value is a
450
+ // caller-supplied (possibly deliberately absent) primitive — probes must
451
+ // evaluate what was given, never silently fall back.
452
+ const spawnImpl = opts.spawnImpl === undefined
453
+ ? (argv, spawnOpts) => spawn(argv[0], argv.slice(1), spawnOpts)
454
+ : opts.spawnImpl;
455
+ const killImpl = opts.killImpl === undefined
456
+ ? (signal, target) => process.kill(target, signal)
457
+ : opts.killImpl;
458
+ const probeImpl = opts.probeImpl === undefined
459
+ ? (pid) => {
460
+ try { process.kill(pid, 0); return true; } catch { return false; }
461
+ }
462
+ : opts.probeImpl;
463
+ const primitives = {
464
+ spawn: typeof spawnImpl === 'function',
465
+ processGroupKill: platform !== 'win32' && typeof killImpl === 'function',
466
+ livenessProbe: typeof probeImpl === 'function',
467
+ };
468
+ if (platform === 'win32') {
469
+ return {
470
+ name,
471
+ supported: HOST_UNSUPPORTED,
472
+ reason: 'process_group_kill_unavailable',
473
+ completionApi: descriptor.completionApi,
474
+ primitives,
475
+ };
476
+ }
477
+ if (!primitives.spawn || !primitives.livenessProbe || !primitives.processGroupKill) {
478
+ return {
479
+ name,
480
+ supported: HOST_UNSUPPORTED,
481
+ reason: 'host_spawn_unavailable',
482
+ completionApi: descriptor.completionApi,
483
+ primitives,
484
+ };
485
+ }
486
+ if (opts.liveHostE2E === true) {
487
+ return {
488
+ name,
489
+ supported: HOST_SUPPORTED,
490
+ reason: null,
491
+ completionApi: descriptor.completionApi,
492
+ primitives,
493
+ };
494
+ }
495
+ return {
496
+ name,
497
+ supported: HOST_UNSUPPORTED_PROBE,
498
+ reason: 'live_host_e2e_unproven',
499
+ completionApi: descriptor.completionApi,
500
+ primitives,
501
+ };
502
+ }
503
+
504
+ /**
505
+ * Probe every registered lane. Returns `{hostName: probe}` — one entry
506
+ * per HOST_NAMES member, in registry order.
507
+ */
508
+ export function listHostAdapters(opts = {}) {
509
+ const out = {};
510
+ for (const name of HOST_NAMES) out[name] = probeHostAdapter(name, opts);
511
+ return out;
512
+ }
@@ -52,6 +52,17 @@ export const SIDE_EFFECT_CLASSES = Object.freeze([
52
52
  'destructive',
53
53
  ]);
54
54
 
55
+ // ---------------------------------------------------------------------------
56
+ // V-01 — IR versioning (plan-language level, independent of CONTRACT_VERSION).
57
+
58
+ /**
59
+ * Frozen IR versions understood by planCompiler. 'v1' is the original opcode
60
+ * set (RUN, WAIT_EVENT, BRANCH, COMPLETE, ESCALATE); 'v2' adds the wrapper
61
+ * ops PARALLEL and TIMEOUT. A plan opts into v2 via `ir.planVersion: 'v2'`
62
+ * or an enabled caller channel — v1 IR never parses v2 ops by default.
63
+ */
64
+ export const IR_VERSIONS = Object.freeze(['v1', 'v2']);
65
+
55
66
  /** Classes that MAY auto-retry under bounded attempts + backoff (SPEC §5). */
56
67
  const AUTO_RETRYABLE = new Set(['pure', 'read_only', 'idempotent_write']);
57
68
 
@@ -245,3 +256,69 @@ export function validateRetry(sideEffectClass, attempt, policy = {}) {
245
256
 
246
257
  return ok();
247
258
  }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // V-05 — VM decision-node question block + classify verdict shapes.
262
+
263
+ /** Verdict actions a classifyFn may return (agent-vm-runtime V5). */
264
+ export const CLASSIFY_ACTIONS = Object.freeze(['route', 'escalate']);
265
+
266
+ /**
267
+ * Validate a classifyFn verdict — the bounded decision-node answer shape:
268
+ * `{action:'route', outcome:string}` routes the event into the node's
269
+ * deterministic transition table (which still gates the outcome); any other
270
+ * valid verdict escalates onto the deterministic lane.
271
+ *
272
+ * @param {object} verdict
273
+ * @returns {{ok:true}|{ok:false, code:string}}
274
+ */
275
+ export function validateClassifyVerdict(verdict) {
276
+ if (verdict === null || typeof verdict !== 'object' || Array.isArray(verdict)) {
277
+ return reject('malformed_verdict');
278
+ }
279
+ if (verdict.action === 'escalate') {
280
+ return ok();
281
+ }
282
+ if (verdict.action === 'route' && typeof verdict.outcome === 'string' && verdict.outcome.length > 0) {
283
+ return ok();
284
+ }
285
+ return reject('malformed_verdict');
286
+ }
287
+
288
+ /**
289
+ * Validate a VM node's optional `question` block — the bounded
290
+ * classify/select question the decision plane may answer when the plan IR
291
+ * cannot route an event. Absent is valid (nodes need not ask); a malformed
292
+ * block is rejected wholesale, never partially honored.
293
+ *
294
+ * @param {object} question `{decisionKey?, instruction?, candidates?}` —
295
+ * each declared field must be a non-empty bounded string / string list.
296
+ * @param {object} [bounds] `{maxText?, maxCandidates?}`
297
+ * @returns {{ok:true}|{ok:false, code:string}}
298
+ */
299
+ export function validateNodeQuestion(question, bounds = {}) {
300
+ if (question === undefined) {
301
+ return ok();
302
+ }
303
+ if (question === null || typeof question !== 'object' || Array.isArray(question)) {
304
+ return reject('malformed_question');
305
+ }
306
+ const maxText = Number.isInteger(bounds.maxText) && bounds.maxText > 0 ? bounds.maxText : 240;
307
+ const maxCandidates = Number.isInteger(bounds.maxCandidates) && bounds.maxCandidates > 0 ? bounds.maxCandidates : 8;
308
+ const bounded = (v) => typeof v === 'string' && v.length > 0 && v.length <= maxText;
309
+ if (question.decisionKey !== undefined && !bounded(question.decisionKey)) {
310
+ return reject('malformed_question');
311
+ }
312
+ if (question.instruction !== undefined && !bounded(question.instruction)) {
313
+ return reject('malformed_question');
314
+ }
315
+ if (question.candidates !== undefined) {
316
+ if (!Array.isArray(question.candidates)
317
+ || question.candidates.length === 0
318
+ || question.candidates.length > maxCandidates
319
+ || !question.candidates.every(bounded)) {
320
+ return reject('malformed_question');
321
+ }
322
+ }
323
+ return ok();
324
+ }