okengine 0.23.2 → 0.24.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.
Files changed (75) hide show
  1. package/AGENTS.md +5 -1
  2. package/manifest.v1.schema.json +53 -3
  3. package/package.json +2 -1
  4. package/site/content/docs/ai/mcp.mdx +1 -1
  5. package/site/content/docs/ai/skills.mdx +1 -1
  6. package/site/content/docs/elements/ai/decide.mdx +95 -417
  7. package/site/content/docs/elements/ai/deciders.mdx +154 -0
  8. package/site/content/docs/elements/ai/index.mdx +2 -1
  9. package/site/content/docs/elements/ai/meta.json +1 -1
  10. package/site/content/docs/reference/cli.mdx +2 -2
  11. package/site/content/docs/reference/configuration.mdx +2 -1
  12. package/site/content/docs/reference/fx.mdx +11 -9
  13. package/src/cli/agents-md.test.ts +8 -2
  14. package/src/cli/decide-certify.test.ts +166 -0
  15. package/src/cli/decide.ts +170 -3
  16. package/src/cli/decision-lock-watch.test.ts +4 -4
  17. package/src/cli/eval.ts +38 -59
  18. package/src/cli/mcp-from-console.ts +1 -1
  19. package/src/cli/registry.ts +45 -9
  20. package/src/compiler/decisions.extract.test.ts +86 -31
  21. package/src/compiler/effects-infer.ts +1 -0
  22. package/src/compiler/extract.ts +239 -24
  23. package/src/config/index.ts +6 -0
  24. package/src/console/server/decisions.test.ts +6 -1
  25. package/src/console/server/decisions.ts +32 -1
  26. package/src/console/ui-next/dist/assets/{access-page-zBMhZnFm.js → access-page-DX2dKLmq.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{agent-disclosure-CBj9f7Ju.js → agent-disclosure-CiRvA3M9.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{cache-glyph-D3MY49h3.js → cache-glyph-CC1yFJ8A.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{call-pii-button-L9JxdBhp.js → call-pii-button-CLXQgIDD.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{collapsible-BvFaX-Zt.js → collapsible-C0AThBZg.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{decisions-page-DgB76m-X.js → decisions-page-Bf3AhTAa.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{duration-tone-l1DSJ2Vz.js → duration-tone-Bptur2gX.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{flows-page-Bb9t1lu6.js → flows-page-DRZVeTZU.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{highlighted-json-DMvsi3Ti.js → highlighted-json-BKf0PjBK.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{http-method-BwhJBTcQ.js → http-method-uhrnWrGC.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{index-RhV2jT_7.js → index-Duxus_sG.js} +3 -3
  37. package/src/console/ui-next/dist/assets/{observability-page-BRfUILBq.js → observability-page-B-QM8X0O.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{replica-lag-BsceAzIG.js → replica-lag-DuqHCANi.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{request-meta-C6lTyCXp.js → request-meta-BTBZ3Ulc.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{store-page-YmE806bJ.js → store-page-fpo7VxYb.js} +1 -1
  41. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Di4irS49.js → trace-detail-sheet-DKtr1-K1.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DYeL6Rye.js → tree-expand-toggle-QeTceKKI.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{units-page-07agasRA.js → units-page-ChCp8mOy.js} +1 -1
  44. package/src/console/ui-next/dist/assets/{vault-page-CHBck4n_.js → vault-page-CEYLXhUX.js} +1 -1
  45. package/src/console/ui-next/dist/index.html +1 -1
  46. package/src/console/ui-next/src/client.ts +8 -0
  47. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +16 -0
  48. package/src/elements/ai/deciders/presets.ts +182 -0
  49. package/src/elements/ai/decisions/bind.ts +6 -5
  50. package/src/elements/ai/decisions/catalog.ts +93 -0
  51. package/src/elements/ai/decisions/certificate.test.ts +47 -19
  52. package/src/elements/ai/decisions/certificate.ts +107 -24
  53. package/src/elements/ai/decisions/certify.ts +104 -9
  54. package/src/elements/ai/decisions/codec.test.ts +142 -0
  55. package/src/elements/ai/decisions/codec.ts +302 -0
  56. package/src/elements/ai/decisions/decide.test.ts +108 -31
  57. package/src/elements/ai/decisions/decider-runtime.test.ts +415 -0
  58. package/src/elements/ai/decisions/decider.types.test.ts +62 -0
  59. package/src/elements/ai/decisions/e2e.test.ts +20 -16
  60. package/src/elements/ai/decisions/http.ts +15 -7
  61. package/src/elements/ai/decisions/labels.test.ts +6 -1
  62. package/src/elements/ai/decisions/labels.ts +4 -3
  63. package/src/elements/ai/decisions/openai.live.test.ts +42 -0
  64. package/src/elements/ai/decisions/openrouter.live.test.ts +5 -1
  65. package/src/elements/ai/decisions/provider.ts +22 -1
  66. package/src/elements/ai/declare.ts +208 -51
  67. package/src/elements/ai.ts +1 -0
  68. package/src/index.ts +1 -0
  69. package/src/kernel/app.ts +8 -0
  70. package/src/kernel/decision-budget-entry.ts +11 -0
  71. package/src/kernel/element-registries.ts +3 -0
  72. package/src/kernel/fx-decide.ts +276 -88
  73. package/src/manifest/types.ts +38 -2
  74. package/src/release/measure.ts +47 -8
  75. package/src/test/create-test-app.ts +21 -1
@@ -2,24 +2,23 @@
2
2
  * `fx.decide` — one provider request, journaled. Loaded with `lazyRequire`.
3
3
  */
4
4
 
5
- import { aiDecisionRegistry, gateRegistry } from "./element-registries.ts";
5
+ import { aiDeciderRegistry, aiDecisionRegistry, gateRegistry } from "./element-registries.ts";
6
6
  import type { GatePolicyContext } from "../elements/gate/declare.ts";
7
- import type { AiDecisionDecl, AiDecisionQuestion } from "../elements/ai/declare.ts";
7
+ import type { AiDeciderDecl, AiDecisionDecl, AiDecisionQuestion } from "../elements/ai/declare.ts";
8
8
  import {
9
9
  DecisionConfigError,
10
+ DecisionInputTooLarge,
10
11
  DecisionOutageError,
11
12
  type DecisionResponse,
12
- type WireQuestion,
13
13
  } from "../elements/ai/decisions/provider.ts";
14
14
  import { parseDurationMs } from "../elements/clock/duration.ts";
15
+ import { decisionHttp } from "../elements/ai/decisions/http.ts";
15
16
  import {
16
- createOpenRouterDecisionProvider,
17
- OPENROUTER_JEV_MODEL,
18
- } from "../elements/ai/decisions/openrouter.ts";
19
- import {
20
- createTypesafeDecisionProvider,
21
- TYPESAFE_JEV_MODEL,
22
- } from "../elements/ai/decisions/typesafe.ts";
17
+ encodeDecisionRequest,
18
+ estimateDecisionTokens,
19
+ normalizeDecisionResponse,
20
+ wireQuestions,
21
+ } from "../elements/ai/decisions/codec.ts";
23
22
  import {
24
23
  applyTemperature,
25
24
  calibrateBoolean,
@@ -28,7 +27,8 @@ import {
28
27
  questionHash,
29
28
  recordDecisionLabel,
30
29
  type DecisionCertSlice,
31
- type DecisionUncertainty,
30
+ type DecisionDeciderCert,
31
+ type DecisionWhy,
32
32
  } from "../elements/ai/decisions/certificate.ts";
33
33
  import { flushDecisionLabels, persistDecisionLabel } from "../elements/ai/decisions/labels.ts";
34
34
  import { decisionExportFields, maskDecisionInput } from "../elements/ai/decisions/export.ts";
@@ -96,7 +96,6 @@ export interface DecisionReviewRecord {
96
96
  readonly labelOnly: boolean;
97
97
  readonly values?: Readonly<Record<string, unknown>>;
98
98
  readonly reviewer?: string;
99
- readonly reason?: DecisionUncertainty;
100
99
  /** Question ids that are not auto. Resolve must answer each of them. */
101
100
  readonly open?: readonly string[];
102
101
  readonly model?: string;
@@ -105,6 +104,9 @@ export interface DecisionReviewRecord {
105
104
  readonly modelValues?: Readonly<Record<string, unknown>>;
106
105
  /** Calling input. Label export reads this. */
107
106
  readonly input?: unknown;
107
+ readonly why?: DecisionWhy;
108
+ /** Decider that answered, when one did. */
109
+ readonly by?: string;
108
110
  }
109
111
 
110
112
  /** Result of {@link resolveDecisionReview}. */
@@ -126,10 +128,21 @@ type ProviderFn = (
126
128
  decl: AiDecisionDecl,
127
129
  input: unknown,
128
130
  signal: AbortSignal,
131
+ decider: AiDeciderDecl,
129
132
  ) => Promise<DecisionResponse>;
130
133
 
134
+ /** Scripted probabilities, or a refusal signal that is not stored as a label. */
135
+ export type DecisionScriptAnswer =
136
+ | { readonly probabilities: Readonly<Record<string, number>> }
137
+ | { readonly probability: number }
138
+ | { readonly refusal: string };
139
+
131
140
  let providerOverride: ProviderFn | undefined;
132
141
  let providerCalls = 0;
142
+ let decisionTransport: "live" | "mock" = "live";
143
+ const decisionScripts = new Map<string, Readonly<Record<string, DecisionScriptAnswer>>>();
144
+ const inflight = new Map<string, number>();
145
+ const waiters = new Map<string, Array<() => void>>();
133
146
 
134
147
  /**
135
148
  * Replace the provider. Tests use this so no key is required.
@@ -153,6 +166,33 @@ export function decisionProviderCalls(): number {
153
166
  export function resetDecisionProvider(): void {
154
167
  providerOverride = undefined;
155
168
  providerCalls = 0;
169
+ decisionTransport = "live";
170
+ decisionScripts.clear();
171
+ inflight.clear();
172
+ waiters.clear();
173
+ }
174
+
175
+ /**
176
+ * `mock` answers locally. `live` calls the decider host.
177
+ *
178
+ * @param transport - Boot selection from `drivers.decide`
179
+ */
180
+ export function setDecisionTransport(transport: "live" | "mock"): void {
181
+ decisionTransport = transport;
182
+ }
183
+
184
+ /**
185
+ * Script one decision. `{ refusal }` is the test signal; the string is not stored.
186
+ *
187
+ * @param name - Decision name
188
+ * @param answers - Per-question script, or undefined to clear
189
+ */
190
+ export function setDecisionScript(
191
+ name: string,
192
+ answers: Readonly<Record<string, DecisionScriptAnswer>> | undefined,
193
+ ): void {
194
+ if (!answers) decisionScripts.delete(name);
195
+ else decisionScripts.set(name, answers);
156
196
  }
157
197
 
158
198
  /**
@@ -187,9 +227,9 @@ async function executeDecide(options: FxDecideInput, name: string): Promise<unkn
187
227
  : await callAndDraw(decl, options, ordinal);
188
228
  const view = options.journal
189
229
  ? ((await options.journal.effect("decide-view", slot, () =>
190
- project(decl, options.input, recorded),
230
+ project(decl, options.input, recorded, options.now()),
191
231
  )) as JournaledView)
192
- : project(decl, options.input, recorded);
232
+ : project(decl, options.input, recorded, options.now());
193
233
  if (view.auto) {
194
234
  if (view.audited && options.journal) {
195
235
  const id = reviewId(options.journal.runId, ordinal, name);
@@ -238,6 +278,7 @@ interface RecordedCall {
238
278
  readonly outage: boolean;
239
279
  readonly audited: boolean;
240
280
  readonly propensity: number;
281
+ readonly by?: string;
241
282
  }
242
283
 
243
284
  async function callAndDraw(
@@ -250,8 +291,14 @@ async function callAndDraw(
250
291
  const draw = auditDraw(`${run}:${decl.name}#${ordinal}`, rate);
251
292
  try {
252
293
  providerCalls += 1;
253
- const response = await callProvider(decl, options);
254
- return { response, outage: false, audited: draw.audited, propensity: draw.propensity };
294
+ const called = await callProvider(decl, options);
295
+ return {
296
+ response: called.response,
297
+ by: called.by,
298
+ outage: false,
299
+ audited: draw.audited,
300
+ propensity: draw.propensity,
301
+ };
255
302
  } catch (err) {
256
303
  if (err instanceof DecisionOutageError) {
257
304
  return { outage: true, audited: false, propensity: draw.propensity };
@@ -263,59 +310,166 @@ async function callAndDraw(
263
310
  async function callProvider(
264
311
  decl: AiDecisionDecl,
265
312
  options: FxDecideInput,
313
+ ): Promise<{ readonly response: DecisionResponse; readonly by: string }> {
314
+ const chain = deciderChain(decl);
315
+ let last: unknown;
316
+ for (let i = 0; i < chain.length; i += 1) {
317
+ const decider = chain[i];
318
+ if (!decider) continue;
319
+ assertDecisionContext(decider, options.input);
320
+ try {
321
+ const response = await withDeciderConcurrency(decider, () => callOne(decl, decider, options));
322
+ return { response, by: decider.name };
323
+ } catch (err) {
324
+ if (!(err instanceof DecisionOutageError) || i === chain.length - 1) throw err;
325
+ last = err;
326
+ }
327
+ }
328
+ throw last instanceof Error ? last : new DecisionOutageError("decision backup exhausted");
329
+ }
330
+
331
+ function deciderChain(decl: AiDecisionDecl): AiDeciderDecl[] {
332
+ const names = [decl.decider, ...decl.backup];
333
+ return names.map((name) => {
334
+ const decider = aiDeciderRegistry.find((item) => item.name === name);
335
+ if (!decider) throw new Error(`fx.decide: unknown decider "${name}"`);
336
+ return decider;
337
+ });
338
+ }
339
+
340
+ function assertDecisionContext(decider: AiDeciderDecl, input: unknown): void {
341
+ const max = decider.capabilities.maxContext;
342
+ if (typeof max !== "number") return;
343
+ const tokens = estimateDecisionTokens(input);
344
+ if (tokens > max) throw new DecisionInputTooLarge(decider.name, tokens, max);
345
+ }
346
+
347
+ async function withDeciderConcurrency<T>(
348
+ decider: AiDeciderDecl,
349
+ body: () => Promise<T>,
350
+ ): Promise<T> {
351
+ const limit = decider.concurrency;
352
+ if (limit === undefined || limit < 1) return body();
353
+ while ((inflight.get(decider.name) ?? 0) >= limit) {
354
+ await new Promise<void>((resolve) => {
355
+ const queue = waiters.get(decider.name) ?? [];
356
+ queue.push(resolve);
357
+ waiters.set(decider.name, queue);
358
+ });
359
+ }
360
+ inflight.set(decider.name, (inflight.get(decider.name) ?? 0) + 1);
361
+ try {
362
+ return await body();
363
+ } finally {
364
+ inflight.set(decider.name, Math.max(0, (inflight.get(decider.name) ?? 1) - 1));
365
+ waiters.get(decider.name)?.shift()?.();
366
+ }
367
+ }
368
+
369
+ async function callOne(
370
+ decl: AiDecisionDecl,
371
+ decider: AiDeciderDecl,
372
+ options: FxDecideInput,
266
373
  ): Promise<DecisionResponse> {
267
- if (providerOverride) return providerOverride(decl, options.input, options.signal);
268
- const model =
269
- decl.model ?? (decl.driverId === "typesafe" ? TYPESAFE_JEV_MODEL : OPENROUTER_JEV_MODEL);
270
- const keyName = decl.driverId === "typesafe" ? "TYPESAFE_API_KEY" : "OPENROUTER_API_KEY";
271
- const apiKey = await options.getSecret?.(keyName);
272
- if (!apiKey) throw new DecisionConfigError(keyName);
273
- const timeoutMs = decisionTimeoutMs(decl.timeout);
274
- const provider =
275
- decl.driverId === "typesafe"
276
- ? createTypesafeDecisionProvider(apiKey, timeoutMs)
277
- : createOpenRouterDecisionProvider(apiKey, timeoutMs);
278
- return provider.evaluate({
279
- model,
374
+ const script = decisionScripts.get(decl.name);
375
+ if (script && decider.name === decl.decider) return scriptedResponse(decl, decider, script);
376
+ if (providerOverride) {
377
+ return normalizeDecisionResponse(
378
+ decider.protocol,
379
+ await providerOverride(decl, options.input, options.signal, decider),
380
+ );
381
+ }
382
+ if (decisionTransport === "mock") return mockResponse(decl, decider);
383
+ const apiKey = await options.getSecret?.(decider.secret);
384
+ if (!apiKey) throw new DecisionConfigError(decider.secret);
385
+ const request = {
386
+ model: decider.model,
280
387
  state: options.input,
281
- questions: wireQuestions(decl),
388
+ questions: wireQuestions(decl.ask),
282
389
  signal: options.signal,
390
+ };
391
+ const raw = await decisionHttp({
392
+ url: decider.baseUrl,
393
+ apiKey,
394
+ request,
395
+ body: encodeDecisionRequest(decider.protocol, request),
396
+ timeoutMs: decisionTimeoutMs(decider.timeout),
397
+ breakerKey: decider.name,
283
398
  });
399
+ return normalizeDecisionResponse(decider.protocol, raw);
284
400
  }
285
401
 
286
- function decisionTimeoutMs(timeout: AiDecisionDecl["timeout"]): number {
287
- if (typeof timeout === "number" && timeout > 0) return timeout;
288
- if (typeof timeout === "string") {
289
- const parsed = parseDurationMs(timeout);
290
- if (parsed > 0) return parsed;
402
+ function scriptedResponse(
403
+ decl: AiDecisionDecl,
404
+ decider: AiDeciderDecl,
405
+ script: Readonly<Record<string, DecisionScriptAnswer>>,
406
+ ): DecisionResponse {
407
+ const answers: Record<string, unknown> = {};
408
+ for (const [id, question] of Object.entries(decl.ask)) {
409
+ const row = script[id];
410
+ if (!row) {
411
+ answers[id] = { type: "malformed" };
412
+ continue;
413
+ }
414
+ if ("refusal" in row) {
415
+ answers[id] = { type: "refusal" };
416
+ continue;
417
+ }
418
+ if ("probability" in row) {
419
+ answers[id] = { type: "boolean", probability: row.probability };
420
+ continue;
421
+ }
422
+ if (question.kind === "boolean") {
423
+ const probability = row.probabilities.true ?? row.probabilities.yes ?? 0;
424
+ answers[id] = { type: "boolean", probability };
425
+ continue;
426
+ }
427
+ const choice = argmaxKey(row.probabilities);
428
+ answers[id] =
429
+ question.kind === "score"
430
+ ? { type: "score", score: choice, probabilities: row.probabilities }
431
+ : { type: "choice", choice, probabilities: row.probabilities };
291
432
  }
292
- return 30_000;
433
+ return { model: decider.model, provider: "script", answers, usage: {} };
293
434
  }
294
435
 
295
- function wireQuestions(decl: AiDecisionDecl): Record<string, WireQuestion> {
296
- const questions: Record<string, WireQuestion> = {};
436
+ function mockResponse(decl: AiDecisionDecl, decider: AiDeciderDecl): DecisionResponse {
437
+ const answers: Record<string, unknown> = {};
297
438
  for (const [id, question] of Object.entries(decl.ask)) {
298
- questions[id] = wireQuestion(question);
439
+ if (question.kind === "boolean") answers[id] = { type: "boolean", probability: 0.5 };
440
+ else if (question.kind === "choice") {
441
+ answers[id] = {
442
+ type: "choice",
443
+ choice: "none_of_these",
444
+ probabilities: { none_of_these: 1 },
445
+ };
446
+ } else {
447
+ const level = question.levels[0] ?? "";
448
+ answers[id] = { type: "score", score: level, probabilities: { [level]: 1 } };
449
+ }
299
450
  }
300
- return questions;
451
+ return { model: decider.model, provider: "mock", answers, usage: {} };
301
452
  }
302
453
 
303
- function wireQuestion(question: AiDecisionQuestion): WireQuestion {
304
- if (question.kind === "choice") {
305
- return {
306
- type: "choice",
307
- instructions: question.instructions,
308
- criteria: { ...question.options, none_of_these: null },
309
- };
454
+ function argmaxKey(probabilities: Readonly<Record<string, number>>): string {
455
+ let best = "";
456
+ let score = -1;
457
+ for (const [key, value] of Object.entries(probabilities)) {
458
+ if (value > score) {
459
+ best = key;
460
+ score = value;
461
+ }
310
462
  }
311
- if (question.kind === "score") {
312
- return { type: "score", instructions: question.instructions, criteria: question.levels };
463
+ return best;
464
+ }
465
+
466
+ function decisionTimeoutMs(timeout: AiDeciderDecl["timeout"]): number {
467
+ if (typeof timeout === "number" && timeout > 0) return timeout;
468
+ if (typeof timeout === "string") {
469
+ const parsed = parseDurationMs(timeout);
470
+ if (parsed > 0) return parsed;
313
471
  }
314
- return {
315
- type: "noul",
316
- instructions: question.instructions,
317
- ...(question.criteria !== undefined ? { criteria: question.criteria } : {}),
318
- };
472
+ return 30_000;
319
473
  }
320
474
 
321
475
  /** One question as journaled. Replay returns this; it is not re-projected. */
@@ -327,12 +481,14 @@ interface JournaledQuestion {
327
481
  readonly audited?: boolean;
328
482
  /** True when this question did not clear autonomy. */
329
483
  readonly uncertain: boolean;
484
+ readonly why?: DecisionWhy;
330
485
  }
331
486
 
332
487
  /** Projection stored on the journal. The live lock is not consulted on replay. */
333
488
  interface JournaledView {
334
489
  readonly auto: boolean;
335
- readonly reason?: DecisionUncertainty;
490
+ readonly why?: DecisionWhy;
491
+ readonly by?: string;
336
492
  readonly locale?: string;
337
493
  readonly lockModel?: string;
338
494
  readonly audited: boolean;
@@ -345,57 +501,67 @@ interface JournaledView {
345
501
  };
346
502
  }
347
503
 
348
- function project(decl: AiDecisionDecl, input: unknown, recorded: RecordedCall): JournaledView {
504
+ function project(
505
+ decl: AiDecisionDecl,
506
+ input: unknown,
507
+ recorded: RecordedCall,
508
+ now: number,
509
+ ): JournaledView {
349
510
  const locale = decl.locale?.(input);
350
- let auto = !recorded.outage && !decisionDriftSuspended(decl.name);
351
- let reason: DecisionUncertainty | undefined = recorded.outage
511
+ const drifted = decisionDriftSuspended(decl.name);
512
+ const slot = recorded.by
513
+ ? getDecisionLock()?.decisions[decl.name]?.deciders[recorded.by]
514
+ : undefined;
515
+ const expired = slot?.pinned === false && slot.expiresAt !== undefined && now >= slot.expiresAt;
516
+ const mismatched = slot !== undefined && slot.model !== recorded.response?.model;
517
+ const lock = slot && !expired && !mismatched ? slot : undefined;
518
+ let blocked: DecisionWhy | undefined = recorded.outage
352
519
  ? "outage"
353
- : decisionDriftSuspended(decl.name)
520
+ : drifted
354
521
  ? "drift"
355
- : undefined;
356
- const lock = getDecisionLock()?.decisions[decl.name];
357
- if (!recorded.outage && !decisionDriftSuspended(decl.name)) {
358
- if (!lock || !decl.autonomy) {
359
- auto = false;
360
- reason = "missing-lock";
361
- } else if (lock.model !== recorded.response?.model) {
362
- auto = false;
363
- reason = "version-mismatch";
364
- }
365
- }
522
+ : !decl.autonomy || !lock
523
+ ? "uncertified"
524
+ : undefined;
366
525
  const questions: Record<string, JournaledQuestion> = {};
526
+ let auto = blocked === undefined;
367
527
  for (const [id, question] of Object.entries(decl.ask)) {
368
528
  const slice = sliceFor(lock, id, locale);
369
- const answer = recorded.response?.answers[id];
529
+ const answer = answerAt(recorded.response?.answers, id);
370
530
  const calibrated = calibrateAnswer(question, answer, slice);
531
+ let why: DecisionWhy | undefined = blocked;
371
532
  let questionAuto = auto && slice !== undefined && calibrated.p >= (slice?.threshold ?? 1);
372
- if (calibrated.value === "none_of_these") {
533
+ if (calibrated.refused) {
373
534
  questionAuto = false;
374
- reason = reason ?? "low-confidence";
375
- } else if (slice === undefined && !reason) {
535
+ why = "refused";
536
+ } else if (calibrated.value === "none_of_these") {
376
537
  questionAuto = false;
377
- reason = locale ? "uncertified-locale" : "missing-lock";
378
- } else if (slice && questionHash(question) !== slice.hash) {
538
+ why = why ?? "none_of_these";
539
+ } else if (!slice || questionHash(question) !== slice.hash) {
379
540
  questionAuto = false;
380
- reason = reason ?? "stale-hash";
381
- } else if (slice && calibrated.p < slice.threshold) {
541
+ why = why ?? "uncertified";
542
+ } else if (calibrated.p < slice.threshold) {
382
543
  questionAuto = false;
383
- reason = reason ?? "low-confidence";
544
+ why = why ?? "uncertain";
384
545
  }
385
546
  if (!questionAuto) auto = false;
386
547
  const how: DecisionHow = questionAuto ? "auto" : decl.mode === "abstain" ? "abstained" : "auto";
548
+ const value =
549
+ calibrated.refused || (!questionAuto && decl.mode === "abstain") ? null : calibrated.value;
387
550
  questions[id] = {
388
- value: questionAuto || decl.mode !== "abstain" ? calibrated.value : null,
551
+ value,
389
552
  how,
390
553
  p: calibrated.p,
391
554
  raw: calibrated.raw,
392
555
  uncertain: !questionAuto,
556
+ ...(!questionAuto && why ? { why } : {}),
393
557
  ...(recorded.audited && questionAuto ? { audited: true } : {}),
394
558
  };
395
559
  }
560
+ const why = Object.values(questions).find((question) => question.why)?.why;
396
561
  return {
397
562
  auto,
398
- ...(reason !== undefined ? { reason } : {}),
563
+ ...(why !== undefined ? { why } : {}),
564
+ ...(recorded.by !== undefined ? { by: recorded.by } : {}),
399
565
  ...(locale !== undefined ? { locale } : {}),
400
566
  ...(lock?.model !== undefined ? { lockModel: lock.model } : {}),
401
567
  audited: recorded.audited,
@@ -403,12 +569,17 @@ function project(decl: AiDecisionDecl, input: unknown, recorded: RecordedCall):
403
569
  questions,
404
570
  meta: {
405
571
  model: recorded.response?.model,
406
- provider: recorded.response?.provider ?? decl.driverId,
572
+ provider: recorded.response?.provider ?? recorded.by ?? "",
407
573
  usage: recorded.response?.usage ?? {},
408
574
  },
409
575
  };
410
576
  }
411
577
 
578
+ function answerAt(answers: DecisionResponse["answers"] | undefined, id: string): unknown {
579
+ if (!answers || Array.isArray(answers)) return undefined;
580
+ return (answers as Readonly<Record<string, unknown>>)[id];
581
+ }
582
+
412
583
  function materialize(view: JournaledView): Record<string, unknown> {
413
584
  const dollar: Record<string, unknown> = { meta: view.meta };
414
585
  const result: Record<string, unknown> = { $: dollar };
@@ -418,6 +589,8 @@ function materialize(view: JournaledView): Record<string, unknown> {
418
589
  how: question.how,
419
590
  p: question.p,
420
591
  raw: question.raw,
592
+ ...(view.by !== undefined ? { by: view.by } : {}),
593
+ ...(question.why !== undefined ? { why: question.why } : {}),
421
594
  ...(question.audited ? { audited: true } : {}),
422
595
  };
423
596
  }
@@ -425,9 +598,7 @@ function materialize(view: JournaledView): Record<string, unknown> {
425
598
  }
426
599
 
427
600
  function sliceFor(
428
- lock:
429
- | { readonly questions: Readonly<Record<string, Readonly<Record<string, DecisionCertSlice>>>> }
430
- | undefined,
601
+ lock: DecisionDeciderCert | undefined,
431
602
  question: string,
432
603
  locale: string | undefined,
433
604
  ): DecisionCertSlice | undefined {
@@ -441,10 +612,26 @@ function calibrateAnswer(
441
612
  question: AiDecisionQuestion,
442
613
  answer: unknown,
443
614
  slice: DecisionCertSlice | undefined,
444
- ): { readonly value: unknown; readonly p: number; readonly raw: unknown } {
615
+ ): {
616
+ readonly value: unknown;
617
+ readonly p: number;
618
+ readonly raw: unknown;
619
+ readonly refused?: boolean;
620
+ } {
445
621
  const record = answer && typeof answer === "object" ? (answer as Record<string, unknown>) : {};
622
+ if (record.type === "refusal") {
623
+ return { value: null, p: 0, raw: { type: "refusal" }, refused: true };
624
+ }
625
+ if (record.type === "malformed") {
626
+ return { value: null, p: 0, raw: { type: "malformed" } };
627
+ }
446
628
  if (question.kind === "boolean") {
447
- const noul = typeof record.noul === "number" ? record.noul : 0;
629
+ const noul =
630
+ typeof record.probability === "number"
631
+ ? record.probability
632
+ : typeof record.noul === "number"
633
+ ? record.noul
634
+ : 0;
448
635
  const calibrator =
449
636
  slice?.calibrator.kind === "platt" || slice?.calibrator.kind === "beta"
450
637
  ? slice.calibrator
@@ -538,7 +725,8 @@ function pendingRecord(
538
725
  modelValues: Object.fromEntries(
539
726
  Object.entries(view.questions).map(([id, question]) => [id, question.value]),
540
727
  ),
541
- ...(view.reason !== undefined ? { reason: view.reason } : {}),
728
+ ...(view.why !== undefined ? { why: view.why } : {}),
729
+ ...(view.by !== undefined ? { by: view.by } : {}),
542
730
  input: maskDecisionInput(options.input, decisionExportFields(decl.inputSchema)),
543
731
  };
544
732
  }
@@ -524,11 +524,42 @@ export interface AiMcpServer {
524
524
  tools: string[];
525
525
  }
526
526
 
527
+ /** What one decider can answer. Numeric limits apply only when present. */
528
+ export interface AiDeciderCapabilities {
529
+ boolean: boolean;
530
+ choice: boolean;
531
+ score: boolean;
532
+ refusal: boolean;
533
+ maxChoices?: number;
534
+ minLevels?: number;
535
+ maxLevels?: number;
536
+ maxContext?: number;
537
+ }
538
+
539
+ /** One model that can answer a decision. */
540
+ export interface AiDecider {
541
+ provider?: string;
542
+ driverId: "systemone" | "openai-decisions";
543
+ baseUrl: string;
544
+ model: string;
545
+ secret: string;
546
+ pinning: "dated" | "alias";
547
+ capabilities: AiDeciderCapabilities;
548
+ /** App-supplied. Presets have no verified region. */
549
+ region?: string;
550
+ regionStatus?: "declared" | "verified";
551
+ zdr?: boolean;
552
+ zdrStatus?: "declared" | "verified";
553
+ timeout?: number | string;
554
+ concurrency?: number;
555
+ }
556
+
527
557
  /** AI element catalogue. */
528
558
  export interface Ai {
529
559
  models?: Record<string, AiModel>;
530
560
  prompts?: Record<string, AiPrompt>;
531
561
  agents?: Record<string, AiAgent>;
562
+ deciders?: Record<string, AiDecider>;
532
563
  decisions?: Record<string, AiDecision>;
533
564
  mcpServers?: Record<string, AiMcpServer>;
534
565
  }
@@ -536,10 +567,15 @@ export interface Ai {
536
567
  /** A declared decision. The predicate and locale function stay in author code. */
537
568
  export interface AiDecision {
538
569
  mode: "review" | "abstain";
570
+ /** Gate name when `otherwise` is a gate. */
539
571
  review?: string;
540
- model?: string;
541
- driverId?: "openrouter" | "typesafe";
572
+ decider: string;
573
+ backup?: string[];
574
+ /** `"abstain"` or the gate name. */
575
+ otherwise: string;
542
576
  questions: string[];
577
+ /** Secret contracts inferred from the decider chain. */
578
+ secrets?: string[];
543
579
  evals?: string;
544
580
  autonomy?: { maxError: number; audit: number; risk?: number };
545
581
  }