okengine 0.22.0 → 0.23.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 (262) hide show
  1. package/AGENTS.md +7 -0
  2. package/manifest.v1.schema.json +44 -0
  3. package/package.json +32 -19
  4. package/site/content/docs/ai/mcp.mdx +13 -9
  5. package/site/content/docs/ai/skills.mdx +1 -1
  6. package/site/content/docs/elements/ai/agents.mdx +61 -13
  7. package/site/content/docs/elements/ai/decide.mdx +536 -0
  8. package/site/content/docs/elements/ai/events.mdx +373 -0
  9. package/site/content/docs/elements/ai/index.mdx +9 -3
  10. package/site/content/docs/elements/ai/meta.json +1 -1
  11. package/site/content/docs/elements/ai/prompts.mdx +4 -2
  12. package/site/content/docs/elements/gate/auth.mdx +4 -1
  13. package/site/content/docs/elements/store/index.mdx +1 -1
  14. package/site/content/docs/elements/store/kv.mdx +5 -5
  15. package/site/content/docs/recipes/dragonfly.mdx +5 -5
  16. package/site/content/docs/recipes/index.mdx +6 -6
  17. package/site/content/docs/recipes/postgres.mdx +12 -8
  18. package/site/content/docs/recipes/redis.mdx +4 -4
  19. package/site/content/docs/reference/cli.mdx +2 -1
  20. package/site/content/docs/reference/configuration.mdx +9 -7
  21. package/site/content/docs/reference/fx.mdx +7 -6
  22. package/src/auth/api-key-sql.test.ts +44 -0
  23. package/src/auth/api-key-sql.ts +1 -0
  24. package/src/auth/api-keys.ts +30 -12
  25. package/src/auth/config.ts +6 -2
  26. package/src/auth/gate-auth.test.ts +20 -0
  27. package/src/cli/agents-md.test.ts +25 -0
  28. package/src/cli/ask-vault-gaps.test.ts +38 -1
  29. package/src/cli/ask-vault-gaps.ts +35 -2
  30. package/src/cli/decide.ts +174 -0
  31. package/src/cli/decision-lock-watch.test.ts +45 -0
  32. package/src/cli/decision-lock-watch.ts +51 -0
  33. package/src/cli/dev-app-runner.ts +34 -8
  34. package/src/cli/dev-auth-secret.test.ts +49 -0
  35. package/src/cli/dev-auth-secret.ts +49 -0
  36. package/src/cli/dev-hot.test.ts +41 -0
  37. package/src/cli/dev-hot.ts +48 -0
  38. package/src/cli/dev.test.ts +1 -1
  39. package/src/cli/dev.ts +24 -6
  40. package/src/cli/eval.ts +142 -1
  41. package/src/cli/index.ts +5 -0
  42. package/src/cli/load-config.images.test.ts +2 -2
  43. package/src/cli/load-config.ts +1 -1
  44. package/src/cli/mcp-from-console.ts +48 -0
  45. package/src/cli/registry.ts +35 -1
  46. package/src/client/agent.ts +307 -0
  47. package/src/client/transport.test.ts +20 -0
  48. package/src/client/transport.ts +2 -1
  49. package/src/client/types.ts +1 -1
  50. package/src/client-react/index.ts +7 -1
  51. package/src/client-react/use-agent-run.test.ts +271 -0
  52. package/src/client-react/use-agent-run.ts +193 -0
  53. package/src/compiler/ai-approval.test.ts +36 -0
  54. package/src/compiler/ai-repair.test.ts +22 -0
  55. package/src/compiler/decisions.extract.test.ts +164 -0
  56. package/src/compiler/effects-infer.ts +27 -1
  57. package/src/compiler/extract.ts +268 -12
  58. package/src/compiler/response.ts +35 -2
  59. package/src/config/index.ts +21 -3
  60. package/src/console/server/ai-runs-flows.ts +269 -0
  61. package/src/console/server/ai-runs.test.ts +323 -0
  62. package/src/console/server/ai-runs.ts +562 -0
  63. package/src/console/server/ai.ts +12 -1
  64. package/src/console/server/app.ts +10 -1
  65. package/src/console/server/decisions-flows.ts +157 -0
  66. package/src/console/server/decisions.test.ts +66 -0
  67. package/src/console/server/decisions.ts +229 -0
  68. package/src/console/server/flows.ts +50 -2
  69. package/src/console/server/invoke-user-flow.ts +9 -0
  70. package/src/console/server/runs-ingest.ts +16 -0
  71. package/src/console/server/serve.ts +5 -0
  72. package/src/console/server/state.ts +25 -1
  73. package/src/console/server/store-stats.test.ts +47 -0
  74. package/src/console/server/store-stats.ts +92 -18
  75. package/src/console/ui-next/dist/assets/access-page-DDKkhT9v.js +4 -0
  76. package/src/console/ui-next/dist/assets/{agent-disclosure-Bkohc_8X.js → agent-disclosure-62Xts8Xi.js} +1 -1
  77. package/src/console/ui-next/dist/assets/{cache-glyph-rL0lHxat.js → cache-glyph-DyeoKHKA.js} +1 -1
  78. package/src/console/ui-next/dist/assets/{call-pii-button-Deo4PP18.js → call-pii-button-DvfpLXsU.js} +1 -1
  79. package/src/console/ui-next/dist/assets/{collapsible-CoJ6amHf.js → collapsible-BY03SeCg.js} +1 -1
  80. package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
  81. package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
  82. package/src/console/ui-next/dist/assets/decisions-page-CgkKlA56.js +1 -0
  83. package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
  84. package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
  85. package/src/console/ui-next/dist/assets/duration-tone-Y6HLjyJ5.js +9 -0
  86. package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
  87. package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
  88. package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
  89. package/src/console/ui-next/dist/assets/flows-page-DLPfNy-_.js +1 -0
  90. package/src/console/ui-next/dist/assets/{highlighted-json-CPYBzDh0.js → highlighted-json-D7nNzpgB.js} +1 -1
  91. package/src/console/ui-next/dist/assets/{http-method-DTPevKmM.js → http-method-DdL19zzh.js} +1 -1
  92. package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
  93. package/src/console/ui-next/dist/assets/index-C0lc_s9d.js +58 -0
  94. package/src/console/ui-next/dist/assets/observability-page-CRlcyLCC.js +8 -0
  95. package/src/console/ui-next/dist/assets/{replica-lag-BM27lXld.js → replica-lag-Bb3FWUu9.js} +7 -7
  96. package/src/console/ui-next/dist/assets/request-meta-DYBMWhX8.js +1 -0
  97. package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
  98. package/src/console/ui-next/dist/assets/store-page-CcE-SXC8.js +41 -0
  99. package/src/console/ui-next/dist/assets/trace-detail-sheet-CKMSEQ4s.js +2 -0
  100. package/src/console/ui-next/dist/assets/tree-expand-toggle-QOfk0M0H.js +55 -0
  101. package/src/console/ui-next/dist/assets/units-page-DKC1CDDd.js +1 -0
  102. package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
  103. package/src/console/ui-next/dist/assets/vault-page-B54v4Yit.js +2 -0
  104. package/src/console/ui-next/dist/index.html +4 -3
  105. package/src/console/ui-next/seed-parked-approval.ts +126 -0
  106. package/src/console/ui-next/src/client.ts +224 -1
  107. package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
  108. package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
  109. package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
  110. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
  111. package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
  112. package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
  113. package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
  114. package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
  115. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
  116. package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
  117. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  118. package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
  119. package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
  120. package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
  121. package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
  122. package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
  123. package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
  124. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
  125. package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
  126. package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
  127. package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
  128. package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
  129. package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
  130. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
  131. package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
  132. package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
  133. package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
  134. package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
  135. package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
  136. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
  137. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +6 -2
  138. package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
  139. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
  140. package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
  141. package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
  142. package/src/console/ui-next/src/router.tsx +17 -0
  143. package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
  144. package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
  145. package/src/console/ui-next/vite.config.ts +9 -0
  146. package/src/docker/recipes/dragonfly.ts +3 -2
  147. package/src/docker/recipes/redis.ts +1 -1
  148. package/src/drivers/ai-anthropic.ts +212 -21
  149. package/src/drivers/ai-mock.ts +34 -1
  150. package/src/drivers/ai-openai-compatible.ts +67 -26
  151. package/src/drivers/ai-preconnect.ts +18 -0
  152. package/src/drivers/ai-stream.test.ts +62 -0
  153. package/src/drivers/ai-types.ts +20 -0
  154. package/src/drivers/journal-postgres.ts +289 -2
  155. package/src/drivers/memory-ddl.test.ts +18 -0
  156. package/src/drivers/memory.ts +21 -1
  157. package/src/drivers/pg-rls.test.ts +31 -0
  158. package/src/drivers/pg-rls.ts +15 -0
  159. package/src/drivers/postgres.test.ts +9 -0
  160. package/src/drivers/postgres.ts +49 -11
  161. package/src/elements/ai/agent-event-slot.ts +27 -0
  162. package/src/elements/ai/approval-http.ts +207 -0
  163. package/src/elements/ai/approval.test.ts +741 -0
  164. package/src/elements/ai/approval.ts +228 -0
  165. package/src/elements/ai/decisions/bind.ts +163 -0
  166. package/src/elements/ai/decisions/certificate.test.ts +291 -0
  167. package/src/elements/ai/decisions/certificate.ts +377 -0
  168. package/src/elements/ai/decisions/certify.ts +314 -0
  169. package/src/elements/ai/decisions/decide.test.ts +953 -0
  170. package/src/elements/ai/decisions/e2e.test.ts +399 -0
  171. package/src/elements/ai/decisions/export.test.ts +120 -0
  172. package/src/elements/ai/decisions/export.ts +146 -0
  173. package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
  174. package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
  175. package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
  176. package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
  177. package/src/elements/ai/decisions/http.test.ts +154 -0
  178. package/src/elements/ai/decisions/http.ts +240 -0
  179. package/src/elements/ai/decisions/labels.test.ts +282 -0
  180. package/src/elements/ai/decisions/labels.ts +257 -0
  181. package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
  182. package/src/elements/ai/decisions/openrouter.ts +36 -0
  183. package/src/elements/ai/decisions/provider.ts +102 -0
  184. package/src/elements/ai/decisions/typesafe.ts +36 -0
  185. package/src/elements/ai/declare.ts +252 -1
  186. package/src/elements/ai/events.test.ts +379 -0
  187. package/src/elements/ai/events.ts +138 -0
  188. package/src/elements/ai/run-events.test.ts +461 -0
  189. package/src/elements/ai/run-events.ts +522 -0
  190. package/src/elements/ai/runtime.ts +1186 -105
  191. package/src/elements/ai/stream-turn.ts +214 -0
  192. package/src/elements/ai/stream.live.test.ts +76 -0
  193. package/src/elements/ai/subagent.test.ts +136 -0
  194. package/src/elements/ai.test.ts +497 -3
  195. package/src/elements/ai.ts +16 -1
  196. package/src/elements/clock/durable.ts +27 -1
  197. package/src/elements/store/sql-session.test.ts +51 -0
  198. package/src/elements/store/sql-session.ts +111 -1
  199. package/src/i18n/catalogs/ar.ts +5 -0
  200. package/src/i18n/catalogs/en.ts +5 -0
  201. package/src/index.ts +2 -0
  202. package/src/kernel/agent-event-store.ts +448 -0
  203. package/src/kernel/app.ts +103 -1
  204. package/src/kernel/boot-bind/ai.ts +3 -0
  205. package/src/kernel/boot-bind/store.test.ts +26 -0
  206. package/src/kernel/boot-bind/store.ts +82 -6
  207. package/src/kernel/boot.ts +15 -4
  208. package/src/kernel/builtin-errors.ts +2 -0
  209. package/src/kernel/capability.ts +3 -0
  210. package/src/kernel/decision-label-store.ts +441 -0
  211. package/src/kernel/effects.test.ts +2 -1
  212. package/src/kernel/effects.ts +13 -1
  213. package/src/kernel/element-registries.ts +3 -0
  214. package/src/kernel/errors-text.ts +4 -0
  215. package/src/kernel/errors.ts +2 -0
  216. package/src/kernel/flow.test.ts +2 -3
  217. package/src/kernel/fx-decide.ts +761 -0
  218. package/src/kernel/fx.test.ts +12 -5
  219. package/src/kernel/fx.ts +312 -29
  220. package/src/kernel/http-frame.test.ts +94 -0
  221. package/src/kernel/http-frame.ts +203 -0
  222. package/src/kernel/journal.ts +104 -0
  223. package/src/kernel/json-result.ts +10 -0
  224. package/src/kernel/pipeline.test.ts +45 -0
  225. package/src/kernel/sse-id.ts +32 -0
  226. package/src/manifest/diff.ts +1 -0
  227. package/src/manifest/types.ts +29 -2
  228. package/src/mcp/ai-tools.test.ts +202 -0
  229. package/src/mcp/authorization.ts +66 -0
  230. package/src/mcp/session.ts +3 -0
  231. package/src/mcp/tools.ts +168 -0
  232. package/src/release/http-graph.test.ts +2 -0
  233. package/src/release/http-graph.ts +5 -0
  234. package/src/release/measure.ts +39 -14
  235. package/src/runs/collect.ts +4 -1
  236. package/src/runs/parquet.test.ts +27 -1
  237. package/src/runs/parquet.ts +19 -0
  238. package/src/runs/types.ts +31 -0
  239. package/src/runtime/json-code-block.test.ts +106 -47
  240. package/src/runtime/json-code-block.ts +1209 -46
  241. package/src/term.test.ts +13 -0
  242. package/src/term.ts +81 -49
  243. package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
  244. package/src/console/ui-next/dist/assets/access-page-Ze6ckjdd.js +0 -4
  245. package/src/console/ui-next/dist/assets/copy-inline-button-CWG5_ZOh.js +0 -1
  246. package/src/console/ui-next/dist/assets/detail-header-b_ZzIsmU.js +0 -1
  247. package/src/console/ui-next/dist/assets/dropdown-menu-nwXEO1Ac.js +0 -1
  248. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +0 -9
  249. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +0 -1
  250. package/src/console/ui-next/dist/assets/explorer-empty-C8sSCqYT.js +0 -1
  251. package/src/console/ui-next/dist/assets/flows-page-D2E7BtKK.js +0 -1
  252. package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
  253. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +0 -58
  254. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +0 -4
  255. package/src/console/ui-next/dist/assets/request-meta-BjFT7DNl.js +0 -1
  256. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +0 -1
  257. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +0 -41
  258. package/src/console/ui-next/dist/assets/trace-detail-sheet-Cmitq3da.js +0 -2
  259. package/src/console/ui-next/dist/assets/tree-expand-toggle-CbDIB-7x.js +0 -55
  260. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +0 -1
  261. package/src/console/ui-next/dist/assets/use-vault-list-Cs45Czkt.js +0 -1
  262. package/src/console/ui-next/dist/assets/vault-page-BOMr0go9.js +0 -2
@@ -0,0 +1,953 @@
1
+ /**
2
+ * fx.decide — replay, review lease, abstain, and audit.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import { z } from "zod";
7
+ import { mkdtemp } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { ai, resetAiDecls } from "../../ai.ts";
11
+ import { gate, resetGates } from "../../gate/declare.ts";
12
+ import { oke } from "../../../kernel/app.ts";
13
+ import { resetBindings } from "../../../kernel/on.ts";
14
+ import type { Manifest } from "../../../manifest/types.ts";
15
+ import { questionHash, resetDecisionCertificates, setDecisionLock } from "./certificate.ts";
16
+ import { createFx } from "../../../kernel/fx.ts";
17
+ import {
18
+ createJournal,
19
+ createMemoryJournalStore,
20
+ isJournalSuspend,
21
+ } from "../../../kernel/journal.ts";
22
+ import {
23
+ createPostgresJournalFake,
24
+ createPostgresJournalStore,
25
+ } from "../../../drivers/journal-postgres.ts";
26
+ import { DecisionConfigError, DecisionOutageError } from "./provider.ts";
27
+ import {
28
+ decisionProviderCalls,
29
+ decisionStepName,
30
+ readDecisionReview,
31
+ resetDecisionProvider,
32
+ resolveDecisionReview,
33
+ setDecisionGateAllow,
34
+ setDecisionProvider,
35
+ } from "../../../kernel/fx-decide.ts";
36
+ import {
37
+ flushDecisionLabels,
38
+ loadDecisionLabels,
39
+ openDecisionLabelStore,
40
+ closeDecisionLabelStore,
41
+ } from "./labels.ts";
42
+
43
+ afterEach(() => {
44
+ resetBindings();
45
+ resetAiDecls();
46
+ resetDecisionCertificates();
47
+ resetDecisionProvider();
48
+ resetGates();
49
+ setDecisionGateAllow(undefined);
50
+ closeDecisionLabelStore();
51
+ });
52
+
53
+ function choice() {
54
+ return ai.choice("which team", { billing: "Billing", technical: "Technical" });
55
+ }
56
+
57
+ function provider() {
58
+ setDecisionProvider(async () => ({
59
+ model: "typesafe/jev-1.13.0",
60
+ provider: "openrouter",
61
+ answers: {
62
+ team: {
63
+ type: "choice",
64
+ choice: "technical",
65
+ probabilities: { billing: 0.08, technical: 0.9, none_of_these: 0.02 },
66
+ },
67
+ },
68
+ usage: { inputTokens: 10, outputTokens: 2 },
69
+ }));
70
+ }
71
+
72
+ describe("fx.decide", () => {
73
+ test("replay makes one provider call", async () => {
74
+ provider();
75
+ const question = choice();
76
+ const decl = ai.decision("triage", {
77
+ onUncertain: "abstain",
78
+ autonomy: { maxError: 0.05, audit: 0 },
79
+ ask: { team: question },
80
+ });
81
+ setDecisionLock({
82
+ decisions: {
83
+ triage: {
84
+ model: "typesafe/jev-1.13.0",
85
+ questions: {
86
+ team: {
87
+ "": {
88
+ hash: questionHash(question),
89
+ calibrator: { kind: "temperature" as const, t: 1 },
90
+ threshold: 0.5,
91
+ },
92
+ },
93
+ },
94
+ },
95
+ },
96
+ });
97
+ const store = createMemoryJournalStore();
98
+ const session = await createJournal({ store, now: () => 1_000_000 }).start("run", {});
99
+ const fx = createFx({
100
+ flow: "run",
101
+ effects: { decides: ["triage"] },
102
+ journal: session,
103
+ runId: session.runId,
104
+ now: () => 1_000_000,
105
+ });
106
+ const first = (await fx.decide(decl, { ticket: "1" })) as {
107
+ team: string;
108
+ $: { team: { how: string; audited?: boolean } };
109
+ };
110
+ expect(first.team).toBe("technical");
111
+ expect(first.$.team.how).toBe("auto");
112
+ expect(first.$.team.audited).toBeUndefined();
113
+ expect(decisionProviderCalls()).toBe(1);
114
+ session.rewind();
115
+ const second = (await fx.decide(decl, { ticket: "1" })) as { team: string };
116
+ expect(second.team).toBe("technical");
117
+ expect(decisionProviderCalls()).toBe(1);
118
+ });
119
+
120
+ test("abstain in a non-durable flow returns null and does not park", async () => {
121
+ setDecisionProvider(async () => {
122
+ throw new DecisionOutageError("down");
123
+ });
124
+ const decl = ai.decision("triage", {
125
+ onUncertain: "abstain",
126
+ ask: { team: choice() },
127
+ });
128
+ const fx = createFx({ flow: "run", effects: { decides: ["triage"] }, now: () => 1 });
129
+ const result = (await fx.decide(decl, {})) as {
130
+ team: string | null;
131
+ $: { team: { how: string } };
132
+ };
133
+ expect(result.team).toBeNull();
134
+ expect(result.$.team.how).toBe("abstained");
135
+ expect(result.$.team.how).not.toBe("audited");
136
+ });
137
+
138
+ test("audit returns the auto value and journals one label review", async () => {
139
+ provider();
140
+ const question = choice();
141
+ const decl = ai.decision("triage", {
142
+ onUncertain: "abstain",
143
+ autonomy: { maxError: 0.05, audit: 1 },
144
+ ask: { team: question },
145
+ });
146
+ setDecisionLock({
147
+ decisions: {
148
+ triage: {
149
+ model: "typesafe/jev-1.13.0",
150
+ questions: {
151
+ team: {
152
+ "": {
153
+ hash: questionHash(question),
154
+ calibrator: { kind: "temperature" as const, t: 1 },
155
+ threshold: 0.5,
156
+ },
157
+ },
158
+ },
159
+ },
160
+ },
161
+ });
162
+ const store = createMemoryJournalStore();
163
+ const session = await createJournal({ store, now: () => 1_000_000 }).start("run", {});
164
+ const fx = createFx({
165
+ flow: "run",
166
+ effects: { decides: ["triage"] },
167
+ journal: session,
168
+ runId: session.runId,
169
+ now: () => 1_000_000,
170
+ });
171
+ const result = (await fx.decide(decl, {})) as {
172
+ team: string;
173
+ $: { team: { how: string; audited?: boolean } };
174
+ };
175
+ expect(result.team).toBe("technical");
176
+ expect(result.$.team.how).toBe("auto");
177
+ expect(result.$.team.audited).toBe(true);
178
+ const labels = session.run.entries.filter(
179
+ (entry) => entry.kind === "step" && entry.name.startsWith("ai-decision-label:"),
180
+ );
181
+ expect(labels).toHaveLength(1);
182
+ const sleeps = session.run.entries.filter((entry) => entry.kind === "sleep");
183
+ expect(sleeps).toHaveLength(0);
184
+ });
185
+
186
+ test("two concurrent resolves, then a finished review conflicts", async () => {
187
+ provider();
188
+ gate.policy("ops", (ctx) => ctx.operator.id !== null);
189
+ const decl = ai.decision("triage", {
190
+ review: "ops",
191
+ ask: { team: choice() },
192
+ });
193
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
194
+ const now = () => 1_000_000;
195
+ const session = await createJournal({ store, now }).start("run", {});
196
+ const fx = createFx({
197
+ flow: "run",
198
+ effects: { decides: ["triage"] },
199
+ journal: session,
200
+ runId: session.runId,
201
+ durable: true,
202
+ now,
203
+ });
204
+ let suspended = false;
205
+ try {
206
+ await fx.decide(decl, {});
207
+ } catch (err) {
208
+ suspended = isJournalSuspend(err);
209
+ if (!suspended) throw err;
210
+ }
211
+ expect(suspended).toBe(true);
212
+ const step = session.run.entries.find(
213
+ (entry) => entry.kind === "step" && entry.name.startsWith("ai-decision:"),
214
+ );
215
+ if (!step || step.kind !== "step") throw new Error("expected a parked decision");
216
+ const id = step.name.slice("ai-decision:".length);
217
+ expect(decisionStepName(id)).toBe(step.name);
218
+ const [left, right] = await Promise.all([
219
+ resolveDecisionReview(store, id, { values: { team: "billing" }, reviewer: "a" }, now),
220
+ resolveDecisionReview(store, id, { values: { team: "technical" }, reviewer: "b" }, now),
221
+ ]);
222
+ const wins = [left, right].filter((result) => result.ok);
223
+ const lost = [left, right].filter((result) => !result.ok);
224
+ expect(wins).toHaveLength(1);
225
+ expect(lost).toEqual([{ ok: false, status: 409, reason: "lease", retryAfterSeconds: 30 }]);
226
+ expect(
227
+ await resolveDecisionReview(store, id, { values: { team: "billing" }, reviewer: "c" }, now),
228
+ ).toEqual({ ok: false, status: 409, reason: "resolved" });
229
+ expect((await readDecisionReview(store, id))?.status).toBe("reviewed");
230
+ });
231
+
232
+ test("the parked review masks secret input and keeps the rest", async () => {
233
+ provider();
234
+ const decl = ai.decision("triage", {
235
+ review: "ops",
236
+ in: z.object({
237
+ ticket: z.string(),
238
+ token: z.string().describe("secret"),
239
+ }),
240
+ ask: { team: choice() },
241
+ });
242
+ const store = createMemoryJournalStore();
243
+ const session = await createJournal({ store, now: () => 1 }).start("run", {});
244
+ const fx = createFx({
245
+ flow: "run",
246
+ effects: { decides: ["triage"] },
247
+ journal: session,
248
+ runId: session.runId,
249
+ durable: true,
250
+ now: () => 1,
251
+ });
252
+ try {
253
+ await fx.decide(decl, { ticket: "14", token: "sekret" });
254
+ } catch (err) {
255
+ if (!isJournalSuspend(err)) throw err;
256
+ }
257
+ const step = session.run.entries.find(
258
+ (entry) => entry.kind === "step" && entry.name.startsWith("ai-decision:"),
259
+ );
260
+ if (!step || step.kind !== "step") throw new Error("expected a parked decision");
261
+ expect((step.value as { input?: unknown }).input).toEqual({
262
+ ticket: "14",
263
+ token: "[redacted]",
264
+ });
265
+ });
266
+
267
+ test("boot registers the candidate route when a decision has autonomy", async () => {
268
+ const manifest = {
269
+ oke: "1",
270
+ app: "decide-boot",
271
+ ai: {
272
+ decisions: {
273
+ triage: {
274
+ mode: "review",
275
+ questions: ["team"],
276
+ autonomy: { maxError: 0.05, audit: 0.1 },
277
+ },
278
+ },
279
+ },
280
+ } as unknown as Manifest;
281
+ const root = await mkdtemp(join(tmpdir(), "oke-decide-boot-"));
282
+ const app = oke({
283
+ name: "decide-boot",
284
+ env: "test",
285
+ startScheduler: false,
286
+ registry: "ignore",
287
+ gate: { unguardedHttp: "allow" },
288
+ manifest,
289
+ rootDir: root,
290
+ });
291
+ await app.boot({ env: "test" });
292
+ const res = await app.fetch(new Request("http://localhost/_oke/decisions/triage/candidate"));
293
+ expect(res.status === 401 || res.status === 403).toBe(true);
294
+ await app.bootResult?.close();
295
+ });
296
+
297
+ test("replay returns the journaled projection after the lock changes", async () => {
298
+ provider();
299
+ const question = choice();
300
+ const decl = ai.decision("triage", {
301
+ onUncertain: "abstain",
302
+ autonomy: { maxError: 0.05, audit: 0 },
303
+ ask: { team: question },
304
+ });
305
+ setDecisionLock({
306
+ decisions: {
307
+ triage: {
308
+ model: "typesafe/jev-1.13.0",
309
+ questions: {
310
+ team: {
311
+ "": {
312
+ hash: questionHash(question),
313
+ calibrator: { kind: "temperature" as const, t: 1 },
314
+ threshold: 0.5,
315
+ },
316
+ },
317
+ },
318
+ },
319
+ },
320
+ });
321
+ const store = createMemoryJournalStore();
322
+ const session = await createJournal({ store, now: () => 1_000_000 }).start("run", {});
323
+ const fx = createFx({
324
+ flow: "run",
325
+ effects: { decides: ["triage"] },
326
+ journal: session,
327
+ runId: session.runId,
328
+ now: () => 1_000_000,
329
+ });
330
+ const first = (await fx.decide(decl, {})) as { team: string; $: { team: { how: string } } };
331
+ expect(first.$.team.how).toBe("auto");
332
+ setDecisionLock(undefined);
333
+ session.rewind();
334
+ const second = (await fx.decide(decl, {})) as { team: string; $: { team: { how: string } } };
335
+ expect(second.team).toBe("technical");
336
+ expect(second.$.team.how).toBe("auto");
337
+ expect(decisionProviderCalls()).toBe(1);
338
+ });
339
+
340
+ test("the same decision can park twice in one run", async () => {
341
+ provider();
342
+ gate.policy("ops", (ctx) => ctx.operator.id !== null);
343
+ const decl = ai.decision("triage", { review: "ops", ask: { team: choice() } });
344
+ const store = createMemoryJournalStore();
345
+ const journal = createJournal({ store, now: () => 1_000_000 });
346
+ const session = await journal.start("run", {});
347
+ const fx = createFx({
348
+ flow: "run",
349
+ effects: { decides: ["triage"] },
350
+ journal: session,
351
+ runId: session.runId,
352
+ durable: true,
353
+ now: () => 1_000_000,
354
+ });
355
+ const park = async () => {
356
+ try {
357
+ await fx.decide(decl, {});
358
+ return false;
359
+ } catch (err) {
360
+ if (!isJournalSuspend(err)) throw err;
361
+ return true;
362
+ }
363
+ };
364
+ expect(await park()).toBe(true);
365
+ const first = session.run.entries.find(
366
+ (entry) => entry.kind === "step" && entry.name.startsWith("ai-decision:"),
367
+ );
368
+ if (!first || first.kind !== "step") throw new Error("expected the first park");
369
+ await resolveDecisionReview(
370
+ store,
371
+ first.name.slice("ai-decision:".length),
372
+ { values: { team: "billing" }, reviewer: "a" },
373
+ () => 1_000_000,
374
+ );
375
+ const resumed = await journal.resume(session.runId);
376
+ const again = createFx({
377
+ flow: "run",
378
+ effects: { decides: ["triage"] },
379
+ journal: resumed,
380
+ runId: resumed.runId,
381
+ durable: true,
382
+ now: () => 1_000_001,
383
+ });
384
+ const done = (await again.decide(decl, {})) as { team: string; $: { team: { how: string } } };
385
+ expect(done.team).toBe("billing");
386
+ expect(done.$.team.how).toBe("reviewed");
387
+ let secondPark = false;
388
+ try {
389
+ await again.decide(decl, {});
390
+ } catch (err) {
391
+ secondPark = isJournalSuspend(err);
392
+ if (!secondPark) throw err;
393
+ }
394
+ expect(secondPark).toBe(true);
395
+ const steps = resumed.run.entries.filter(
396
+ (entry) => entry.kind === "step" && entry.name.startsWith("ai-decision:"),
397
+ );
398
+ expect(steps).toHaveLength(2);
399
+ });
400
+
401
+ test("none_of_these is not auto", async () => {
402
+ setDecisionProvider(async () => ({
403
+ model: "typesafe/jev-1.13.0",
404
+ provider: "openrouter",
405
+ answers: {
406
+ team: {
407
+ type: "choice",
408
+ choice: "none_of_these",
409
+ probabilities: { billing: 0.1, technical: 0.1, none_of_these: 0.8 },
410
+ },
411
+ },
412
+ usage: {},
413
+ }));
414
+ const question = choice();
415
+ const decl = ai.decision("triage", {
416
+ onUncertain: "abstain",
417
+ autonomy: { maxError: 0.05, audit: 0 },
418
+ ask: { team: question },
419
+ });
420
+ setDecisionLock({
421
+ decisions: {
422
+ triage: {
423
+ model: "typesafe/jev-1.13.0",
424
+ questions: {
425
+ team: {
426
+ "": {
427
+ hash: questionHash(question),
428
+ calibrator: { kind: "temperature" as const, t: 1 },
429
+ threshold: 0.5,
430
+ },
431
+ },
432
+ },
433
+ },
434
+ },
435
+ });
436
+ const fx = createFx({ flow: "run", effects: { decides: ["triage"] }, now: () => 1 });
437
+ const result = (await fx.decide(decl, {})) as { team: null; $: { team: { how: string } } };
438
+ expect(result.team).toBeNull();
439
+ expect(result.$.team.how).toBe("abstained");
440
+ });
441
+
442
+ test("abstain leaves a certain question on auto", async () => {
443
+ setDecisionProvider(async () => ({
444
+ model: "typesafe/jev-1.13.0",
445
+ provider: "openrouter",
446
+ answers: {
447
+ team: {
448
+ type: "choice",
449
+ choice: "technical",
450
+ probabilities: { billing: 0.05, technical: 0.93, none_of_these: 0.02 },
451
+ },
452
+ urgent: { type: "noul", noul: 0.51 },
453
+ },
454
+ usage: {},
455
+ }));
456
+ const team = choice();
457
+ const urgent = ai.boolean("urgent?");
458
+ const decl = ai.decision("triage", {
459
+ onUncertain: "abstain",
460
+ autonomy: { maxError: 0.05, audit: 0 },
461
+ ask: { team, urgent },
462
+ });
463
+ setDecisionLock({
464
+ decisions: {
465
+ triage: {
466
+ model: "typesafe/jev-1.13.0",
467
+ questions: {
468
+ team: {
469
+ "": {
470
+ hash: questionHash(team),
471
+ calibrator: { kind: "temperature" as const, t: 1 },
472
+ threshold: 0.5,
473
+ },
474
+ },
475
+ urgent: {
476
+ "": {
477
+ hash: questionHash(urgent),
478
+ calibrator: { kind: "platt" as const, a: 1, b: 0 },
479
+ threshold: 0.99,
480
+ },
481
+ },
482
+ },
483
+ },
484
+ },
485
+ });
486
+ const fx = createFx({ flow: "run", effects: { decides: ["triage"] }, now: () => 1 });
487
+ const result = (await fx.decide(decl, {})) as {
488
+ team: string;
489
+ urgent: null;
490
+ $: { team: { how: string }; urgent: { how: string } };
491
+ };
492
+ expect(result.team).toBe("technical");
493
+ expect(result.$.team.how).toBe("auto");
494
+ expect(result.urgent).toBeNull();
495
+ expect(result.$.urgent.how).toBe("abstained");
496
+ });
497
+
498
+ test("a reviewer may resolve a choice as none_of_these", async () => {
499
+ provider();
500
+ gate.policy("ops", () => true);
501
+ const decl = ai.decision("triage", { review: "ops", ask: { team: choice() } });
502
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
503
+ const journal = createJournal({ store, now: () => 1 });
504
+ const session = await journal.start("run", {});
505
+ const fx = createFx({
506
+ flow: "run",
507
+ effects: { decides: ["triage"] },
508
+ journal: session,
509
+ runId: session.runId,
510
+ durable: true,
511
+ now: () => 1,
512
+ });
513
+ let suspended = false;
514
+ try {
515
+ await fx.decide(decl, {});
516
+ } catch (err) {
517
+ suspended = isJournalSuspend(err);
518
+ if (!suspended) throw err;
519
+ }
520
+ expect(suspended).toBe(true);
521
+ const step = session.run.entries.find(
522
+ (entry) => entry.kind === "step" && entry.name.startsWith("ai-decision:"),
523
+ );
524
+ if (!step || step.kind !== "step") throw new Error("expected a parked decision");
525
+ const id = step.name.slice("ai-decision:".length);
526
+ expect(
527
+ await resolveDecisionReview(
528
+ store,
529
+ id,
530
+ { values: { team: "none_of_these" }, reviewer: "a" },
531
+ () => 1,
532
+ ),
533
+ ).toEqual({ ok: true });
534
+ const resumed = await journal.resume(session.runId);
535
+ const again = createFx({
536
+ flow: "run",
537
+ effects: { decides: ["triage"] },
538
+ journal: resumed,
539
+ runId: resumed.runId,
540
+ durable: true,
541
+ now: () => 2,
542
+ });
543
+ const done = (await again.decide(decl, {})) as { team: string };
544
+ expect(done.team).toBe("none_of_these");
545
+ });
546
+
547
+ test("boolean value follows the calibrated probability", async () => {
548
+ setDecisionProvider(async () => ({
549
+ model: "typesafe/jev-1.13.0",
550
+ provider: "openrouter",
551
+ answers: { urgent: { type: "noul", noul: 0.2 } },
552
+ usage: {},
553
+ }));
554
+ const question = ai.boolean("urgent?");
555
+ const decl = ai.decision("triage", {
556
+ onUncertain: "abstain",
557
+ autonomy: { maxError: 0.05, audit: 0 },
558
+ ask: { urgent: question },
559
+ });
560
+ setDecisionLock({
561
+ decisions: {
562
+ triage: {
563
+ model: "typesafe/jev-1.13.0",
564
+ questions: {
565
+ urgent: {
566
+ "": {
567
+ hash: questionHash(question),
568
+ calibrator: { kind: "platt" as const, a: 1, b: 0 },
569
+ threshold: 0.5,
570
+ },
571
+ },
572
+ },
573
+ },
574
+ },
575
+ });
576
+ const fx = createFx({ flow: "run", effects: { decides: ["triage"] }, now: () => 1 });
577
+ const result = (await fx.decide(decl, {})) as { urgent: boolean };
578
+ expect(result.urgent).toBe(false);
579
+ });
580
+
581
+ test("resolve checks the gate, the tenant, and every open question", async () => {
582
+ provider();
583
+ gate.policy("ops", (ctx) => ctx.operator.id === "reviewer");
584
+ const decl = ai.decision("triage", { review: "ops", ask: { team: choice() } });
585
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
586
+ const session = await createJournal({ store, now: () => 1 }).start("run", {});
587
+ const fx = createFx({
588
+ flow: "run",
589
+ effects: { decides: ["triage"] },
590
+ journal: session,
591
+ runId: session.runId,
592
+ durable: true,
593
+ tenant: { id: "acme" },
594
+ now: () => 1,
595
+ });
596
+ try {
597
+ await fx.decide(decl, {});
598
+ } catch (err) {
599
+ if (!isJournalSuspend(err)) throw err;
600
+ }
601
+ const step = session.run.entries.find(
602
+ (entry) => entry.kind === "step" && entry.name.startsWith("ai-decision:"),
603
+ );
604
+ if (!step || step.kind !== "step") throw new Error("expected a parked decision");
605
+ const id = step.name.slice("ai-decision:".length);
606
+ expect((step.value as { propensity: number }).propensity).toBe(1);
607
+ expect(
608
+ await resolveDecisionReview(store, id, {
609
+ values: { team: "billing" },
610
+ reviewer: "other",
611
+ tenantId: "acme",
612
+ }),
613
+ ).toEqual({ ok: false, status: 403 });
614
+ expect(
615
+ await resolveDecisionReview(store, id, {
616
+ values: { team: "billing" },
617
+ reviewer: "reviewer",
618
+ tenantId: "other",
619
+ }),
620
+ ).toEqual({ ok: false, status: 403 });
621
+ expect(
622
+ await resolveDecisionReview(store, id, {
623
+ values: { team: "nope", extra: true },
624
+ reviewer: "reviewer",
625
+ tenantId: "acme",
626
+ }),
627
+ ).toEqual({ ok: false, status: 422 });
628
+ expect(
629
+ await resolveDecisionReview(store, id, {
630
+ values: { team: "billing" },
631
+ reviewer: "reviewer",
632
+ tenantId: "acme",
633
+ }),
634
+ ).toEqual({ ok: true });
635
+ });
636
+
637
+ test("pending review stamps the tenant", async () => {
638
+ provider();
639
+ const decl = ai.decision("triage", { review: "ops", ask: { team: choice() } });
640
+ const store = createMemoryJournalStore();
641
+ const session = await createJournal({ store, now: () => 1 }).start("run", {});
642
+ const fx = createFx({
643
+ flow: "run",
644
+ effects: { decides: ["triage"] },
645
+ journal: session,
646
+ runId: session.runId,
647
+ durable: true,
648
+ tenant: { id: "acme" },
649
+ now: () => 1,
650
+ });
651
+ try {
652
+ await fx.decide(decl, {});
653
+ } catch (err) {
654
+ if (!isJournalSuspend(err)) throw err;
655
+ }
656
+ const step = session.run.entries.find(
657
+ (entry) => entry.kind === "step" && entry.name.startsWith("ai-decision:"),
658
+ );
659
+ if (!step || step.kind !== "step") throw new Error("expected a parked decision");
660
+ expect((step.value as { tenant: string }).tenant).toBe("acme");
661
+ });
662
+
663
+ test("the provider key is not journaled and rotation is read on replay", async () => {
664
+ const secrets: Record<string, string> = { OPENROUTER_API_KEY: "key-v1" };
665
+ const seen: string[] = [];
666
+ const original = globalThis.fetch;
667
+ globalThis.fetch = (async (_url: unknown, init?: { headers?: { authorization?: string } }) => {
668
+ seen.push(init?.headers?.authorization ?? "");
669
+ return new Response(
670
+ JSON.stringify({
671
+ model: "typesafe/jev-1.13.0",
672
+ answers: {
673
+ team: {
674
+ type: "choice",
675
+ probabilities: { billing: 0.05, technical: 0.93, none_of_these: 0.02 },
676
+ },
677
+ },
678
+ usage: {},
679
+ }),
680
+ { status: 200, headers: { "content-type": "application/json" } },
681
+ );
682
+ }) as typeof fetch;
683
+ try {
684
+ const question = choice();
685
+ const decl = ai.decision("triage", {
686
+ onUncertain: "abstain",
687
+ model: "typesafe/jev-1.13",
688
+ autonomy: { maxError: 0.05, audit: 0 },
689
+ ask: { team: question },
690
+ });
691
+ setDecisionLock({
692
+ decisions: {
693
+ triage: {
694
+ model: "typesafe/jev-1.13.0",
695
+ questions: {
696
+ team: {
697
+ "": {
698
+ hash: questionHash(question),
699
+ calibrator: { kind: "temperature" as const, t: 1 },
700
+ threshold: 0.5,
701
+ },
702
+ },
703
+ },
704
+ },
705
+ },
706
+ });
707
+ const store = createMemoryJournalStore();
708
+ const session = await createJournal({ store, now: () => 1 }).start("run", {});
709
+ const fx = createFx({
710
+ flow: "run",
711
+ effects: { decides: ["triage"], secrets: ["OPENROUTER_API_KEY"] },
712
+ journal: session,
713
+ runId: session.runId,
714
+ secrets,
715
+ now: () => 1,
716
+ });
717
+ await fx.decide(decl, { ticket: "1" });
718
+ expect(seen).toEqual(["Bearer key-v1"]);
719
+ expect(JSON.stringify(session.run.entries)).not.toContain("key-v1");
720
+ const kept = session.run.entries.filter(
721
+ (entry) =>
722
+ !(
723
+ entry.kind === "effect" &&
724
+ (entry.effectKind === "decide" ||
725
+ entry.effectKind === "decide-provider" ||
726
+ entry.effectKind === "decide-view")
727
+ ),
728
+ );
729
+ session.run.entries.splice(0, session.run.entries.length, ...kept);
730
+ secrets.OPENROUTER_API_KEY = "key-v2";
731
+ session.rewind();
732
+ await fx.decide(decl, { ticket: "1" });
733
+ expect(seen[1]).toBe("Bearer key-v2");
734
+ expect(JSON.stringify(session.run.entries)).not.toContain("key-v2");
735
+ } finally {
736
+ globalThis.fetch = original;
737
+ }
738
+ });
739
+
740
+ test("an audit resolve writes one label per question", async () => {
741
+ provider();
742
+ gate.policy("ops", (ctx) => ctx.operator.id === "reviewer");
743
+ const team = choice();
744
+ const urgent = ai.boolean("urgent");
745
+ const decl = ai.decision("triage", {
746
+ onUncertain: "abstain",
747
+ autonomy: { maxError: 0.05, audit: 1 },
748
+ ask: { team, urgent },
749
+ });
750
+ setDecisionLock({
751
+ decisions: {
752
+ triage: {
753
+ model: "typesafe/jev-1.13.0",
754
+ questions: {
755
+ team: {
756
+ "": {
757
+ hash: questionHash(team),
758
+ calibrator: { kind: "temperature" as const, t: 1 },
759
+ threshold: 0.5,
760
+ },
761
+ },
762
+ urgent: {
763
+ "": {
764
+ hash: questionHash(urgent),
765
+ calibrator: { kind: "platt" as const, a: 1, b: 0 },
766
+ threshold: 0.5,
767
+ },
768
+ },
769
+ },
770
+ },
771
+ },
772
+ });
773
+ const store = createMemoryJournalStore();
774
+ await openDecisionLabelStore(store, () => undefined);
775
+ const session = await createJournal({ store, now: () => 1 }).start("run", {});
776
+ const fx = createFx({
777
+ flow: "run",
778
+ effects: { decides: ["triage"] },
779
+ journal: session,
780
+ runId: session.runId,
781
+ now: () => 1,
782
+ });
783
+ setDecisionProvider(async () => ({
784
+ model: "typesafe/jev-1.13.0",
785
+ answers: {
786
+ team: {
787
+ type: "choice",
788
+ probabilities: { billing: 0.05, technical: 0.93, none_of_these: 0.02 },
789
+ },
790
+ urgent: { type: "noul", noul: 0.2 },
791
+ },
792
+ usage: {},
793
+ }));
794
+ await fx.decide(decl, {});
795
+ const step = session.run.entries.find(
796
+ (entry) => entry.kind === "step" && entry.name.startsWith("ai-decision-label:"),
797
+ );
798
+ if (!step || step.kind !== "step") throw new Error("expected an audit row");
799
+ const id = step.name.slice("ai-decision-label:".length);
800
+ expect(
801
+ await resolveDecisionReview(
802
+ store,
803
+ id,
804
+ { values: { team: "technical" }, reviewer: "reviewer", tenantId: null },
805
+ () => 1,
806
+ true,
807
+ ),
808
+ ).toEqual({ ok: false, status: 422 });
809
+ expect(
810
+ await resolveDecisionReview(
811
+ store,
812
+ id,
813
+ { values: { team: "technical", urgent: false }, reviewer: "reviewer", tenantId: null },
814
+ () => 1,
815
+ true,
816
+ ),
817
+ ).toEqual({ ok: true });
818
+ await flushDecisionLabels();
819
+ const labels = await loadDecisionLabels("triage");
820
+ expect(labels.map((label) => label.question).sort()).toEqual(["team", "urgent"]);
821
+ expect(labels.every((label) => label.raw !== undefined)).toBe(true);
822
+ });
823
+
824
+ test("an operator resolves another tenant and a tenant reviewer cannot", async () => {
825
+ provider();
826
+ gate.policy("ops", (ctx) => ctx.auth.userId === "ada" && ctx.operator.id === "reviewer");
827
+ const decl = ai.decision("triage", { review: "ops", ask: { team: choice() } });
828
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
829
+ const session = await createJournal({ store, now: () => 1 }).start("run", {});
830
+ const fx = createFx({
831
+ flow: "run",
832
+ effects: { decides: ["triage"] },
833
+ journal: session,
834
+ runId: session.runId,
835
+ durable: true,
836
+ tenant: { id: "acme" },
837
+ now: () => 1,
838
+ });
839
+ try {
840
+ await fx.decide(decl, {});
841
+ } catch (err) {
842
+ if (!isJournalSuspend(err)) throw err;
843
+ }
844
+ const step = session.run.entries.find(
845
+ (entry) => entry.kind === "step" && entry.name.startsWith("ai-decision:"),
846
+ );
847
+ if (!step || step.kind !== "step") throw new Error("expected a parked decision");
848
+ const id = step.name.slice("ai-decision:".length);
849
+ const auth = { userId: "ada", scopes: new Set<string>() };
850
+ expect(
851
+ await resolveDecisionReview(store, id, {
852
+ values: { team: "billing" },
853
+ reviewer: "reviewer",
854
+ tenantId: "other",
855
+ auth,
856
+ }),
857
+ ).toEqual({ ok: false, status: 403 });
858
+ expect(
859
+ await resolveDecisionReview(store, id, {
860
+ values: { team: "billing" },
861
+ reviewer: "reviewer",
862
+ tenantId: null,
863
+ plane: "operator",
864
+ auth,
865
+ }),
866
+ ).toEqual({ ok: true });
867
+ expect((step.value as { tenant: string }).tenant).toBe("acme");
868
+ });
869
+
870
+ test("outage is not overwritten by a stale hash", async () => {
871
+ setDecisionProvider(async () => {
872
+ throw new DecisionOutageError("down");
873
+ });
874
+ const question = choice();
875
+ const decl = ai.decision("triage", { review: "ops", ask: { team: question } });
876
+ setDecisionLock({
877
+ decisions: {
878
+ triage: {
879
+ model: "typesafe/jev-1.13.0",
880
+ questions: {
881
+ team: {
882
+ "": {
883
+ hash: "stale",
884
+ calibrator: { kind: "temperature" as const, t: 1 },
885
+ threshold: 0.5,
886
+ },
887
+ },
888
+ },
889
+ },
890
+ },
891
+ });
892
+ const store = createMemoryJournalStore();
893
+ const session = await createJournal({ store, now: () => 1 }).start("run", {});
894
+ const fx = createFx({
895
+ flow: "run",
896
+ effects: { decides: ["triage"] },
897
+ journal: session,
898
+ runId: session.runId,
899
+ durable: true,
900
+ now: () => 1,
901
+ });
902
+ try {
903
+ await fx.decide(decl, {});
904
+ } catch (err) {
905
+ if (!isJournalSuspend(err)) throw err;
906
+ }
907
+ const step = session.run.entries.find(
908
+ (entry) => entry.kind === "step" && entry.name.startsWith("ai-decision:"),
909
+ );
910
+ expect(
911
+ (step && step.kind === "step" ? step.value : undefined) as { reason?: string },
912
+ ).toMatchObject({
913
+ reason: "outage",
914
+ });
915
+ });
916
+
917
+ test("a score answer keyed by index maps onto the declared levels", async () => {
918
+ setDecisionProvider(async () => ({
919
+ model: "typesafe/jev-1.13-20260917",
920
+ provider: "TypeSafe",
921
+ answers: {
922
+ severity: {
923
+ type: "score",
924
+ score: 1.82,
925
+ legend: { "0": "low", "1": "medium", "2": "high" },
926
+ probabilities: { "0": 0.01, "1": 0.16, "2": 0.83 },
927
+ confidence: 0.73,
928
+ },
929
+ },
930
+ usage: {},
931
+ }));
932
+ const decl = ai.decision("severity", {
933
+ onUncertain: "abstain",
934
+ ask: { severity: ai.score("How severe is this?", ["low", "medium", "high"]) },
935
+ });
936
+ const fx = createFx({ flow: "run", effects: { decides: ["severity"] } });
937
+ const result = (await fx.decide(decl, { ticket: "Checkout is blank after Pay." })) as {
938
+ $: { severity: { p: number; raw: unknown } };
939
+ };
940
+ expect(result.$.severity.p).toBeCloseTo(0.83);
941
+ expect(result.$.severity.raw).toEqual({ "0": 0.01, "1": 0.16, "2": 0.83 });
942
+ });
943
+
944
+ test("a missing secret is a config error", async () => {
945
+ const decl = ai.decision("triage", { onUncertain: "abstain", ask: { team: choice() } });
946
+ const fx = createFx({
947
+ flow: "run",
948
+ effects: { decides: ["triage"], secrets: ["OPENROUTER_API_KEY"] },
949
+ now: () => 1,
950
+ });
951
+ await expect(fx.decide(decl, {})).rejects.toBeInstanceOf(DecisionConfigError);
952
+ });
953
+ });