@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.
@@ -7,6 +7,7 @@
7
7
  * emitSpanRecord(telemetry, ctx, semanticName, payload) → EmitResult
8
8
  * durationMs(startNs, nowNs) → positive ms | null
9
9
  * projectRootFromRuntimeDir(runtimeDir) → projectRoot | null
10
+ * buildTraceExcerpt(records, opts) → {entries, gaps, stats} (V-06)
10
11
  *
11
12
  * Contracts:
12
13
  * - Emission is fire-and-forget: every helper is synchronous, wraps
@@ -31,6 +32,11 @@ import path from 'node:path';
31
32
  import { getRecorder } from '../observability/emit/lifecycle.js';
32
33
  import { RESOURCE_SOURCES } from '../observability/schema/constants.js';
33
34
  import { REASON_CODES } from '../observability/schema/registry.js';
35
+ import { validateSemanticRecord } from '../observability/schema/validate.js';
36
+ import { sanitizeForSupport } from '../observability/privacy/sanitizeForSupport.js';
37
+ import { MAX_SUPPORT_STRING_CHARS } from '../observability/privacy/allowlist.js';
38
+ import { redactString } from '../observability/privacy/redaction.js';
39
+ import { scanText } from '../sensitiveValueScanner.js';
34
40
 
35
41
  /** Monotonic nanoseconds — BigInt where available (process.hrtime). */
36
42
  export function nowNs() {
@@ -55,6 +61,10 @@ function isNonEmptyString(value) {
55
61
  return typeof value === 'string' && value.length > 0;
56
62
  }
57
63
 
64
+ function isPlainObject(value) {
65
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
66
+ }
67
+
58
68
  /**
59
69
  * agent_id source (SPEC §14): UKIT_AGENT_ID env first, config.agent.id
60
70
  * second, absent otherwise. The field is omitted entirely — never ''.
@@ -202,3 +212,116 @@ export function typedResource(resource) {
202
212
  if (source && source !== 'UNKNOWN') return { resource: { value, source }, complete: true };
203
213
  return { resource: { value, source: 'ESTIMATED' }, complete: true };
204
214
  }
215
+
216
+ // --- V-06 trace excerpt for support bundles --------------------------------
217
+ // `buildTraceExcerpt` is the read-side counterpart of emitSpanRecord: it
218
+ // turns recorder-fed SemanticRecords into bounded, sanitized trace lines
219
+ // for a support bundle. Every record passes validateSemanticRecord AND
220
+ // the independent sanitizeForSupport second gate; survivors keep only
221
+ // allowlisted envelope/payload fields, strings pass redactString, and
222
+ // caller-supplied pseudonyms replace raw ids. Non-conforming records are
223
+ // reported as typed gap entries — never silently smuggled, never thrown.
224
+
225
+ const TRACE_ID_FIELDS = Object.freeze([
226
+ 'record_id', 'trace_id', 'span_id', 'parent_span_id',
227
+ 'execution_id', 'session_id', 'project_ref', 'boot_id', 'writer_id',
228
+ ]);
229
+
230
+ const TRACE_ENVELOPE_KEEP = new Set([
231
+ 'record_type', 'semantic_name', 'schema_version', 'record_id', 'trace_id',
232
+ 'span_id', 'parent_span_id', 'execution_id', 'session_id', 'project_ref',
233
+ 'boot_id', 'writer_id', 'sequence', 'wall_time_utc', 'monotonic_ns',
234
+ 'importance', 'privacy_class', 'redaction_version', 'sampling',
235
+ ]);
236
+
237
+ /**
238
+ * Build a sanitized trace excerpt from SemanticRecords. Never throws.
239
+ *
240
+ * Every record passes the FULL two-gate chain — validateSemanticRecord
241
+ * then the independent sanitizeForSupport default-deny gate — so a
242
+ * survivor's payload is already allowlist-clean (codes, counts, timings
243
+ * only). The excerpt keeps only the envelope fields named in
244
+ * TRACE_ENVELOPE_KEEP, re-passes strings through redactString, and
245
+ * substitutes caller-supplied pseudonyms for raw ids. Rejects land as
246
+ * typed gap entries — never silently smuggled, never thrown.
247
+ *
248
+ * @param {unknown} records array of records (non-array → 'trace_unavailable')
249
+ * @param {object} [opts]
250
+ * @param {number} [opts.maxRecords=256] entry cap (gaps share the budget)
251
+ * @param {(value:string)=>{hasSecret:boolean}} [opts.scanner] defaults scanText
252
+ * @param {(value:string,kind:string)=>string} [opts.pseudonymize] id rewrite
253
+ * @returns {{ entries: Array, gaps: Array, stats: {in:number, kept:number,
254
+ * rejected:number, truncated:number} }}
255
+ */
256
+
257
+ export function buildTraceExcerpt(records, opts = {}) {
258
+ const maxRecords = Number.isInteger(opts.maxRecords) ? Math.max(0, opts.maxRecords) : 256;
259
+ const scanner = typeof opts.scanner === 'function' ? opts.scanner : scanText;
260
+ const pseudonymize = typeof opts.pseudonymize === 'function' ? opts.pseudonymize : null;
261
+ const local = { scanner, maxChars: MAX_SUPPORT_STRING_CHARS };
262
+
263
+ const entries = [];
264
+ const gaps = [];
265
+ const stats = { in: 0, kept: 0, rejected: 0, truncated: 0 };
266
+
267
+ if (!Array.isArray(records)) {
268
+ gaps.push({ index: 0, code: 'trace_unavailable' });
269
+ return { entries, gaps, stats };
270
+ }
271
+
272
+ for (const record of records) {
273
+ stats.in += 1;
274
+ if (entries.length + gaps.length >= maxRecords) {
275
+ stats.truncated += 1;
276
+ continue;
277
+ }
278
+ const index = stats.in - 1;
279
+ if (!isPlainObject(record)) {
280
+ stats.rejected += 1;
281
+ gaps.push({ index, code: 'record_not_object' });
282
+ continue;
283
+ }
284
+ const valid = validateSemanticRecord(record);
285
+ if (!valid.ok) {
286
+ stats.rejected += 1;
287
+ gaps.push({ index, code: 'schema_rejected' });
288
+ continue;
289
+ }
290
+ const gate = sanitizeForSupport(record, { scanner });
291
+ if (!gate.ok) {
292
+ stats.rejected += 1;
293
+ gaps.push({ index, code: 'support_gate_rejected' });
294
+ continue;
295
+ }
296
+
297
+ const out = {};
298
+ for (const key of Object.keys(gate.record)) {
299
+ if (!TRACE_ENVELOPE_KEEP.has(key)) continue;
300
+ const value = gate.record[key];
301
+ if (key === 'sampling') {
302
+ const s = isPlainObject(value) ? value : {};
303
+ out.sampling = {
304
+ policy_version: typeof s.policy_version === 'string'
305
+ ? redactString(s.policy_version, local) : null,
306
+ rate: typeof s.rate === 'number' && Number.isFinite(s.rate) ? s.rate : null,
307
+ };
308
+ continue;
309
+ }
310
+ if (TRACE_ID_FIELDS.includes(key) && typeof value === 'string') {
311
+ out[key] = pseudonymize ? pseudonymize(value, key) : redactString(value, local);
312
+ continue;
313
+ }
314
+ if (typeof value === 'string') {
315
+ out[key] = redactString(value, local);
316
+ continue;
317
+ }
318
+ out[key] = value;
319
+ }
320
+ // The gate already allowlist-pruned the payload — copy it verbatim.
321
+ out.payload = gate.record.payload;
322
+
323
+ entries.push(out);
324
+ stats.kept += 1;
325
+ }
326
+ return { entries, gaps, stats };
327
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * agentRuntime/vmEngine.js — decision-first-runtime G4 (DR-06), VM vertical
3
- * slice, omp-scoped, off-rollout (gated by host-side `decisionRuntime.vm`
4
- * flag; this module reads no config itself).
3
+ * slice, omp-scoped, off-rollout (`decisionRuntime.vm` gates the V-05
4
+ * decision-plane classifyFn default below; deterministic escalation stays
5
+ * the default lane regardless of the flag).
5
6
  *
6
7
  * Executes a compiled ValidatedPlan (SPEC §4 frozen shape — literal nodes
7
8
  * Map, entryNodes, topological order) purely against G1 durable
@@ -39,7 +40,11 @@ import {
39
40
  validateTransition,
40
41
  validateSemanticEvent,
41
42
  validateRetry,
43
+ validateClassifyVerdict,
42
44
  } from './contract.js';
45
+ import { resolveDecisionRuntimeStage } from '../runtimeConfig.js';
46
+ import { createDecisionClient } from '../../decision/client.js';
47
+ import { createVmClassifyFn } from '../../decision/runtimeDecide.js';
43
48
  import {
44
49
  registerContinuation,
45
50
  consumeContinuation,
@@ -88,18 +93,48 @@ async function writeFileAtomic(target, data) {
88
93
  await fs.rename(tmp, target);
89
94
  }
90
95
 
96
+
91
97
  /**
92
98
  * Migration contract (decision plane): `classifyFn` is invoked only when the
93
99
  * IR itself cannot route an event (see deliver()). It may only consult the
94
100
  * decision plane (unic-decision / createDecisionClient registry keys) for a
95
101
  * bounded route/escalate answer — never a general LLM call.
96
102
  *
97
- * @param {{dir:string, now?:()=>Date, classifyFn?:Function, startFn?:Function, hooks?:object}} opts
103
+ * V-05 default: when no `classifyFn` is injected AND
104
+ * `decisionRuntime.vm.stage` resolves non-'off', the engine installs the
105
+ * bounded runtimeDecide adapter (runtime.node_route.v1 /
106
+ * runtime.node_classify.v1 through `decisionClient` — or the default
107
+ * createDecisionClient when none is injected). Stage 'off' (absent or
108
+ * malformed) leaves `classifyFn` unset → zero decision calls, byte-identical
109
+ * behaviour to the deterministic escalation lane.
110
+ *
111
+ * @param {{dir:string, now?:()=>Date, classifyFn?:Function, startFn?:Function,
112
+ * hooks?:object, config?:object, decisionClient?:object,
113
+ * decider?:object}} opts
98
114
  */
99
- export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
115
+ export function createVmEngine({
116
+ dir,
117
+ now,
118
+ classifyFn,
119
+ startFn,
120
+ hooks,
121
+ config,
122
+ decisionClient,
123
+ decider,
124
+ } = {}) {
100
125
  if (typeof dir !== 'string' || dir === '') {
101
126
  throw new VmEngineError('malformed_opts', 'dir required');
102
127
  }
128
+ // The injected classifyFn always wins; the decision-plane adapter is a
129
+ // stage-gated default for embedders that opt in via runtime config.
130
+ const classify =
131
+ classifyFn
132
+ ?? (resolveDecisionRuntimeStage(config ?? null, 'vm') === 'off'
133
+ ? undefined
134
+ : createVmClassifyFn({
135
+ decider,
136
+ client: decisionClient ?? createDecisionClient({ config }),
137
+ }));
103
138
  const clock = typeof now === 'function' ? now : () => new Date();
104
139
  const fire = typeof startFn === 'function' ? startFn : async () => {};
105
140
  const instances = new Map(); // planInstanceId -> instance
@@ -712,20 +747,26 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
712
747
 
713
748
  // classify only when the IR itself cannot route the event (G4-FR05);
714
749
  // classifyFn may consult the decision plane for bounded route/escalate
715
- // answers only — never a general LLM call (see createVmEngine opts).
750
+ // answers only — never a general LLM call (see createVmEngine opts). A
751
+ // throwing classifyFn or a malformed verdict is an unclassifiable event,
752
+ // never a hard failure (V-05 never-throws contract).
716
753
  let outcome = event.safePayload?.outcome;
717
754
  let targetMatched = matched;
718
755
  if (!targetMatched) {
719
- if (typeof classifyFn !== 'function') {
756
+ if (typeof classify !== 'function') {
720
757
  return escalateUnclassified(inst, event);
721
758
  }
722
- const verdict = await classifyFn(event, { planInstanceId: pi, nodeId, node });
723
- if (verdict?.action === 'escalate' || verdict == null) {
724
- return escalateUnclassified(inst, event);
759
+ let verdict = null;
760
+ try {
761
+ verdict = await classify(event, { planInstanceId: pi, nodeId, node });
762
+ } catch {
763
+ verdict = null;
725
764
  }
726
- if (verdict?.action === 'route' && typeof verdict.outcome === 'string') {
765
+ if (validateClassifyVerdict(verdict).ok && verdict.action === 'route') {
727
766
  outcome = verdict.outcome;
728
767
  targetMatched = node != null && inst.nodes[nodeId]?.state === 'running';
768
+ } else {
769
+ return escalateUnclassified(inst, event);
729
770
  }
730
771
  }
731
772
  if (!targetMatched) return escalateUnclassified(inst, event);
@@ -204,6 +204,18 @@ export function resolveConfigStage(config = null, path) {
204
204
  return VALID_ROUTE_STAGES.has(node) ? node : 'off';
205
205
  }
206
206
 
207
+ // decisionRuntime family stage resolver (V-03): resolves
208
+ // `decisionRuntime.<key>.stage` for one pinned family flag ('vm',
209
+ // 'supervisor', 'scheduler', 'contract', 'adapters', 'context',
210
+ // 'completion', 'quality', 'promotion', 'resourcePolicy',
211
+ // 'diagnostics'). Same semantics as resolveConfigStage: absent config,
212
+ // absent family, non-string key, or malformed stage all resolve 'off'
213
+ // — a bad config can never promote a decisionRuntime slice.
214
+ export function resolveDecisionRuntimeStage(config = null, key = 'vm') {
215
+ if (typeof key !== 'string' || key.trim() === '') return 'off';
216
+ return resolveConfigStage(config, `decisionRuntime.${key}.stage`);
217
+ }
218
+
207
219
  // Pushes a stage-enum error when `node.stage` is present but not reserved.
208
220
  // Absent stage is valid (absence = 'off'); a non-object node is reported by
209
221
  // the caller's own "must be an object" check.
@@ -49,9 +49,9 @@ const DEFAULT_CHECKPOINTS = {
49
49
  english: 'unic-decision',
50
50
  unknownLanguage: 'unic-decision',
51
51
  };
52
- // Credential-safe alias: every UNIC decision credential binds the bare name
53
- // first — variants (unic-decision-multilingual, …) may lag. On model_not_found
54
- // the client falls back here once per batch.
52
+ // Credential-safe alias: every UNIC decision credential binds the bare name.
53
+ // On model_not_found the client falls back here once per batch — the bare
54
+ // name is also the only checkpoint name UKit ever sends.
55
55
  const FALLBACK_CHECKPOINT = 'unic-decision';
56
56
 
57
57
  const DEFAULT_TIMEOUT_MS = 5000;
@@ -360,8 +360,7 @@ export function createDecisionClient({
360
360
 
361
361
  const status = res?.status ?? (res?.ok === false ? 500 : 200);
362
362
  if (res?.ok === false || status < 200 || status >= 300) {
363
- // A variant checkpoint without bound credentials (e.g.
364
- // unic-decision-multilingual before provider binding) returns a 4xx
363
+ // A checkpoint without bound credentials returns a 4xx
365
364
  // model_not_found body — retry once on the bare `unic-decision`
366
365
  // model, the credential-safe alias the owner provisions first.
367
366
  if (checkpoint !== FALLBACK_CHECKPOINT) {
@@ -83,6 +83,46 @@ export const RUNTIME_DECISIONS = Object.freeze([
83
83
  'A runtime event may warrant a retry but no deterministic policy covers it. Decide conservatively.',
84
84
  candidates: ['retry', 'no_retry', 'escalate'],
85
85
  },
86
+ // V-05: VM decision nodes — the bounded route/classify questions a
87
+ // vmEngine classifyFn may ask when the plan IR cannot route an event.
88
+ // Owner is the deterministic escalation lane: every failure mode resolves
89
+ // to escalateUnclassified, never a hard failure.
90
+ {
91
+ decisionKey: 'runtime.node_route.v1',
92
+ schemaVersion: 1,
93
+ family: 'runtime',
94
+ owner: 'vmEngine',
95
+ kind: 'choice',
96
+ description: 'Outcome for a VM node event the plan IR could not route — bounded route/escalate answer only.',
97
+ candidatePolicy: 'node-outcomes-plus-escalate',
98
+ hardConstraints: ['data-only-no-tool-dispatch'],
99
+ probabilityPolicy: 'raw-label',
100
+ fallbackPolicy: 'frozen-conservative-fallback',
101
+ telemetryClass: 'decision',
102
+ rolloutStage: 'off',
103
+ cacheSensitivity: 'none',
104
+ instruction:
105
+ 'A VM node event did not match any plan selector. Choose the node outcome, or escalate to the deterministic lane.',
106
+ candidates: ['completed', 'failed', 'cancelled', 'escalate'],
107
+ },
108
+ {
109
+ decisionKey: 'runtime.node_classify.v1',
110
+ schemaVersion: 1,
111
+ family: 'runtime',
112
+ owner: 'vmEngine',
113
+ kind: 'choice',
114
+ description: 'Bounded node-declared classification for an unclassifiable VM event; candidates come from the node question block.',
115
+ candidatePolicy: 'node-declared-candidates',
116
+ hardConstraints: ['data-only-no-tool-dispatch'],
117
+ probabilityPolicy: 'raw-label',
118
+ fallbackPolicy: 'frozen-conservative-fallback',
119
+ telemetryClass: 'decision',
120
+ rolloutStage: 'off',
121
+ cacheSensitivity: 'none',
122
+ instruction:
123
+ 'Classify this unclassifiable VM node event into exactly one of the node-declared candidates.',
124
+ candidates: ['completed', 'failed', 'escalate'],
125
+ },
86
126
  ]);
87
127
 
88
128
  // Decision→fallback action map (frozen, SPEC §4). Fallbacks are conservative:
@@ -92,6 +132,9 @@ const FALLBACK_ACTIONS = Object.freeze({
92
132
  'runtime.stall_action.v1': 'continue_observe',
93
133
  'runtime.wake_needed.v1': 'escalate',
94
134
  'runtime.safe_retry.v1': 'no_retry',
135
+ // VM decision nodes fail closed onto the deterministic escalation lane.
136
+ 'runtime.node_route.v1': 'escalate',
137
+ 'runtime.node_classify.v1': 'escalate',
95
138
  });
96
139
 
97
140
  // Batch-level statuses → fallback codes. 'abstained' stays distinct so
@@ -135,22 +178,37 @@ function buildStatePacket(item) {
135
178
  decisionKey: item.decisionKey ?? null,
136
179
  reason: item.reason ?? null,
137
180
  eventType: item.event?.eventType ?? item.eventType ?? null,
181
+ nodeId: typeof item.nodeId === 'string' ? item.nodeId : null,
182
+ planInstanceId:
183
+ typeof item.planInstanceId === 'string' ? item.planInstanceId : null,
138
184
  };
139
185
  }
140
186
 
141
187
  function buildQuestion(item, entry) {
188
+ // A node-declared question block may override the registered instruction/
189
+ // candidates — it is still a bounded choice against one registered key.
190
+ const instruction =
191
+ typeof item.instruction === 'string' && item.instruction.length > 0
192
+ ? item.instruction
193
+ : typeof entry.instruction === 'string' && entry.instruction.length > 0
194
+ ? entry.instruction
195
+ : `Decide ${entry.decisionKey} for the described runtime event.`;
196
+ const candidates =
197
+ Array.isArray(item.candidates) && item.candidates.length > 0
198
+ ? item.candidates
199
+ : Array.isArray(entry.candidates)
200
+ ? entry.candidates
201
+ : [];
142
202
  return {
143
203
  decisionKey: entry.decisionKey,
144
204
  schemaVersion: entry.schemaVersion ?? 1,
145
205
  kind: entry.kind,
146
- instruction:
147
- typeof entry.instruction === 'string' && entry.instruction.length > 0
148
- ? entry.instruction
149
- : `Decide ${entry.decisionKey} for the described runtime event.`,
150
- candidates: Array.isArray(entry.candidates) ? entry.candidates : [],
206
+ instruction,
207
+ candidates,
151
208
  };
152
209
  }
153
210
 
211
+
154
212
  /**
155
213
  * @param {{client: {requestBatch: Function}, registry?: object,
156
214
  * confidenceFloor?: number|object, now?: Function}} options
@@ -240,3 +298,123 @@ export function createRuntimeDecider({
240
298
 
241
299
  return { decide };
242
300
  }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // V-05 — VM decision nodes (agent-vm-runtime V5): a classifyFn for vmEngine
304
+ // that asks one bounded runtime.node_route.v1 / runtime.node_classify.v1
305
+ // question per unclassifiable event. The model answer is data only — the
306
+ // client's tool_calls envelope is consumed as an answer list, NEVER
307
+ // dispatched. Every failure mode maps to the deterministic escalation lane
308
+ // with a fallbackCode; this adapter never throws.
309
+
310
+ // Registry keys a VM decision node may ask — anything else resolves to the
311
+ // deterministic escalation lane without touching the gateway.
312
+ export const VM_NODE_DECISION_KEYS = Object.freeze([
313
+ 'runtime.node_route.v1',
314
+ 'runtime.node_classify.v1',
315
+ ]);
316
+ const DEFAULT_NODE_DECISION_KEY = VM_NODE_DECISION_KEYS[0];
317
+
318
+ // Node question-block bounds — keeps a bounded choice small enough to stay
319
+ // inside the state/question budget.
320
+ const MAX_NODE_CANDIDATES = 8;
321
+ const MAX_NODE_QUESTION_TEXT = 240;
322
+
323
+ function isShortString(value) {
324
+ return typeof value === 'string'
325
+ && value.length > 0
326
+ && value.length <= MAX_NODE_QUESTION_TEXT;
327
+ }
328
+
329
+ // A node question block is valid only when every declared field is
330
+ // well-formed; a malformed block is rejected wholesale (never partially
331
+ // honored) so a bad plan cannot smuggle an unbounded question.
332
+ function validNodeQuestion(question) {
333
+ return isPlainObject(question)
334
+ && (question.decisionKey === undefined || isShortString(question.decisionKey))
335
+ && (question.instruction === undefined || isShortString(question.instruction))
336
+ && (question.candidates === undefined
337
+ || (Array.isArray(question.candidates)
338
+ && question.candidates.length > 0
339
+ && question.candidates.length <= MAX_NODE_CANDIDATES
340
+ && question.candidates.every(isShortString)));
341
+ }
342
+
343
+ /**
344
+ * Build a vmEngine-compatible `classifyFn(event, ctx)` backed by the decision
345
+ * plane. Returns only `{action:'route', outcome}` / `{action:'escalate',
346
+ * fallbackCode?}` verdicts — model unavailability, timeouts, malformed
347
+ * answers, and adapter faults all resolve to the deterministic escalation
348
+ * lane with a `fallbackCode`, and the function NEVER throws.
349
+ *
350
+ * @param {{client?: {requestBatch: Function}, decider?: {decide: Function},
351
+ * registry?: object, confidenceFloor?: number|object}} options
352
+ * `decider` wins over `client` (a custom decider composes its own client).
353
+ * Neither present → every call escalates 'unavailable' with zero I/O.
354
+ */
355
+ export function createVmClassifyFn({
356
+ client,
357
+ decider,
358
+ registry,
359
+ confidenceFloor,
360
+ } = {}) {
361
+ const resolvedDecider =
362
+ decider
363
+ ?? (client != null
364
+ ? createRuntimeDecider({ client, registry, confidenceFloor })
365
+ : null);
366
+
367
+ return async function vmClassify(event, ctx = {}) {
368
+ try {
369
+ const node = isPlainObject(ctx?.node) ? ctx.node : null;
370
+ const question = node?.question;
371
+ if (question !== undefined && !validNodeQuestion(question)) {
372
+ return { action: 'escalate', fallbackCode: 'malformed-question' };
373
+ }
374
+ const decisionKey = question?.decisionKey ?? DEFAULT_NODE_DECISION_KEY;
375
+ if (!VM_NODE_DECISION_KEYS.includes(decisionKey)) {
376
+ return { action: 'escalate', fallbackCode: 'unregistered-key' };
377
+ }
378
+ if (!resolvedDecider || typeof resolvedDecider.decide !== 'function') {
379
+ return { action: 'escalate', fallbackCode: 'unavailable' };
380
+ }
381
+
382
+ const decision = await resolvedDecider.decide({
383
+ action: 'model_decision',
384
+ decisionKey,
385
+ reason: `vm_unrouted:${String(event?.eventType ?? 'unknown')}`,
386
+ event: isPlainObject(event) ? event : null,
387
+ nodeId: typeof ctx?.nodeId === 'string' ? ctx.nodeId : null,
388
+ planInstanceId:
389
+ typeof ctx?.planInstanceId === 'string' ? ctx.planInstanceId : null,
390
+ ...(isShortString(question?.instruction)
391
+ ? { instruction: question.instruction }
392
+ : {}),
393
+ ...(Array.isArray(question?.candidates)
394
+ ? { candidates: question.candidates }
395
+ : {}),
396
+ });
397
+
398
+ if (!isPlainObject(decision)) {
399
+ return { action: 'escalate', fallbackCode: 'invalid' };
400
+ }
401
+ if (decision.decidedBy === 'fallback') {
402
+ return { action: 'escalate', fallbackCode: decision.code ?? 'invalid' };
403
+ }
404
+ const outcome = decision.action ?? decision.value;
405
+ if (outcome === 'escalate') {
406
+ return { action: 'escalate', decisionKey };
407
+ }
408
+ if (typeof outcome !== 'string' || outcome.length === 0) {
409
+ return { action: 'escalate', fallbackCode: 'invalid' };
410
+ }
411
+ // The engine's transition table still gates the outcome — an undeclared
412
+ // value fails closed to recovery_required downstream, never silently.
413
+ return { action: 'route', outcome, decisionKey };
414
+ } catch {
415
+ // Any adapter fault (bad ctx shape, throwing decider impl) resolves to
416
+ // the same deterministic lane — decision nodes never hard-fail the VM.
417
+ return { action: 'escalate', fallbackCode: 'adapter-error' };
418
+ }
419
+ };
420
+ }
@@ -718,8 +718,7 @@ export async function requestBatch(batch, {
718
718
 
719
719
  const status = res?.status ?? (res?.ok === false ? 500 : 200);
720
720
  if (res?.ok === false || status < 200 || status >= 300) {
721
- // A variant checkpoint without bound credentials (e.g.
722
- // unic-decision-multilingual before provider binding) returns a 4xx
721
+ // A checkpoint without bound credentials returns a 4xx
723
722
  // model_not_found body — retry once on the bare `unic-decision` model.
724
723
  if (checkpoint !== 'unic-decision') {
725
724
  let notFound = status === 404;