okengine 0.22.0 → 0.23.1

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 (312) 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 +14 -10
  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/channel/index.mdx +4 -4
  13. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  14. package/site/content/docs/elements/channel/sms.mdx +2 -1
  15. package/site/content/docs/elements/flow/index.mdx +22 -18
  16. package/site/content/docs/elements/gate/auth.mdx +4 -1
  17. package/site/content/docs/elements/signal/index.mdx +15 -14
  18. package/site/content/docs/elements/store/index.mdx +1 -1
  19. package/site/content/docs/elements/store/kv.mdx +5 -5
  20. package/site/content/docs/plugins/otp.mdx +3 -3
  21. package/site/content/docs/recipes/dragonfly.mdx +5 -5
  22. package/site/content/docs/recipes/index.mdx +6 -6
  23. package/site/content/docs/recipes/postgres.mdx +12 -8
  24. package/site/content/docs/recipes/redis.mdx +4 -4
  25. package/site/content/docs/reference/cli.mdx +2 -1
  26. package/site/content/docs/reference/configuration.mdx +9 -7
  27. package/site/content/docs/reference/fx.mdx +19 -10
  28. package/src/auth/api-key-sql.test.ts +44 -0
  29. package/src/auth/api-key-sql.ts +1 -0
  30. package/src/auth/api-keys.ts +30 -12
  31. package/src/auth/config.ts +6 -2
  32. package/src/auth/gate-auth.test.ts +20 -0
  33. package/src/auth/tenants.ts +17 -0
  34. package/src/cli/agents-md.test.ts +25 -0
  35. package/src/cli/ask-vault-gaps.test.ts +38 -1
  36. package/src/cli/ask-vault-gaps.ts +35 -2
  37. package/src/cli/decide.ts +174 -0
  38. package/src/cli/decision-lock-watch.test.ts +45 -0
  39. package/src/cli/decision-lock-watch.ts +51 -0
  40. package/src/cli/dev-app-runner.ts +34 -8
  41. package/src/cli/dev-auth-secret.test.ts +49 -0
  42. package/src/cli/dev-auth-secret.ts +49 -0
  43. package/src/cli/dev-hot.test.ts +41 -0
  44. package/src/cli/dev-hot.ts +48 -0
  45. package/src/cli/dev.test.ts +1 -1
  46. package/src/cli/dev.ts +24 -6
  47. package/src/cli/doctor-diff.test.ts +29 -1
  48. package/src/cli/doctor-diff.ts +35 -0
  49. package/src/cli/eval.ts +142 -1
  50. package/src/cli/index.ts +5 -0
  51. package/src/cli/load-config.images.test.ts +2 -2
  52. package/src/cli/load-config.ts +1 -1
  53. package/src/cli/manifest-pr-diff.ts +16 -0
  54. package/src/cli/mcp-from-console.ts +48 -0
  55. package/src/cli/registry.ts +35 -1
  56. package/src/client/agent.ts +307 -0
  57. package/src/client/transport.test.ts +20 -0
  58. package/src/client/transport.ts +2 -1
  59. package/src/client/types.ts +1 -1
  60. package/src/client-react/index.ts +7 -1
  61. package/src/client-react/use-agent-run.test.ts +271 -0
  62. package/src/client-react/use-agent-run.ts +193 -0
  63. package/src/compiler/ai-approval.test.ts +36 -0
  64. package/src/compiler/ai-repair.test.ts +22 -0
  65. package/src/compiler/decisions.extract.test.ts +164 -0
  66. package/src/compiler/effects-fetch.test.ts +77 -0
  67. package/src/compiler/effects-infer.ts +114 -17
  68. package/src/compiler/effects-join.test.ts +48 -0
  69. package/src/compiler/extract.test.ts +28 -0
  70. package/src/compiler/extract.ts +279 -13
  71. package/src/compiler/fx-index.ts +58 -4
  72. package/src/compiler/response.ts +35 -2
  73. package/src/config/index.ts +21 -3
  74. package/src/console/index.ts +1 -1
  75. package/src/console/server/ai-runs-flows.ts +269 -0
  76. package/src/console/server/ai-runs.test.ts +323 -0
  77. package/src/console/server/ai-runs.ts +562 -0
  78. package/src/console/server/ai.ts +12 -1
  79. package/src/console/server/app.ts +10 -1
  80. package/src/console/server/decisions-flows.ts +157 -0
  81. package/src/console/server/decisions.test.ts +66 -0
  82. package/src/console/server/decisions.ts +229 -0
  83. package/src/console/server/flows.ts +50 -2
  84. package/src/console/server/invoke-user-flow.ts +9 -0
  85. package/src/console/server/runs-ingest.ts +16 -0
  86. package/src/console/server/serve.ts +5 -0
  87. package/src/console/server/state.ts +25 -1
  88. package/src/console/server/store-stats.test.ts +47 -0
  89. package/src/console/server/store-stats.ts +92 -18
  90. package/src/console/ui-next/dist/assets/access-page-C_N4qvai.js +4 -0
  91. package/src/console/ui-next/dist/assets/{agent-disclosure-Bkohc_8X.js → agent-disclosure-Cq1TxniW.js} +1 -1
  92. package/src/console/ui-next/dist/assets/{cache-glyph-rL0lHxat.js → cache-glyph-BaI4uxZz.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{call-pii-button-Deo4PP18.js → call-pii-button-DNyK7IIY.js} +1 -1
  94. package/src/console/ui-next/dist/assets/{collapsible-CoJ6amHf.js → collapsible-CRhBJMZR.js} +1 -1
  95. package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
  96. package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
  97. package/src/console/ui-next/dist/assets/decisions-page-D9twj8Xy.js +1 -0
  98. package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
  99. package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
  100. package/src/console/ui-next/dist/assets/duration-tone-CsHWlVPU.js +9 -0
  101. package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
  102. package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
  103. package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
  104. package/src/console/ui-next/dist/assets/flows-page-ZaTJAhR9.js +1 -0
  105. package/src/console/ui-next/dist/assets/{highlighted-json-CPYBzDh0.js → highlighted-json-DQ-EMAfz.js} +1 -1
  106. package/src/console/ui-next/dist/assets/{http-method-DTPevKmM.js → http-method-Cn_Fl79s.js} +1 -1
  107. package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
  108. package/src/console/ui-next/dist/assets/index-CmRFeWdT.js +58 -0
  109. package/src/console/ui-next/dist/assets/observability-page-Cn6R_dXz.js +8 -0
  110. package/src/console/ui-next/dist/assets/{replica-lag-BM27lXld.js → replica-lag-Bx6EZhSe.js} +7 -7
  111. package/src/console/ui-next/dist/assets/request-meta-DmdiUrfh.js +1 -0
  112. package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
  113. package/src/console/ui-next/dist/assets/store-page-8P6vGXnn.js +41 -0
  114. package/src/console/ui-next/dist/assets/trace-detail-sheet-BfGvpFSv.js +2 -0
  115. package/src/console/ui-next/dist/assets/tree-expand-toggle-CWdUHbc0.js +55 -0
  116. package/src/console/ui-next/dist/assets/units-page-CZOFZzay.js +1 -0
  117. package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
  118. package/src/console/ui-next/dist/assets/vault-page-ki_ishmX.js +2 -0
  119. package/src/console/ui-next/dist/index.html +4 -3
  120. package/src/console/ui-next/seed-parked-approval.ts +126 -0
  121. package/src/console/ui-next/src/client.ts +224 -1
  122. package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
  123. package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
  124. package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
  125. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
  126. package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
  127. package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
  128. package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
  129. package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
  130. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
  131. package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
  132. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  133. package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
  134. package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
  135. package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
  136. package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
  137. package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
  138. package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
  139. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
  140. package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
  141. package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
  142. package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
  143. package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
  144. package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
  145. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
  146. package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
  147. package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
  148. package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
  149. package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
  150. package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
  151. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
  152. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +6 -2
  153. package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
  154. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
  155. package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
  156. package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
  157. package/src/console/ui-next/src/router.tsx +17 -0
  158. package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
  159. package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
  160. package/src/console/ui-next/vite.config.ts +9 -0
  161. package/src/docker/recipes/dragonfly.ts +3 -2
  162. package/src/docker/recipes/redis.ts +1 -1
  163. package/src/drivers/ai-anthropic.ts +212 -21
  164. package/src/drivers/ai-mock.ts +34 -1
  165. package/src/drivers/ai-openai-compatible.ts +67 -26
  166. package/src/drivers/ai-preconnect.ts +18 -0
  167. package/src/drivers/ai-stream.test.ts +62 -0
  168. package/src/drivers/ai-types.ts +20 -0
  169. package/src/drivers/journal-postgres.test.ts +22 -1
  170. package/src/drivers/journal-postgres.ts +511 -13
  171. package/src/drivers/memory-ddl.test.ts +18 -0
  172. package/src/drivers/memory.ts +21 -1
  173. package/src/drivers/pg-rls.test.ts +31 -0
  174. package/src/drivers/pg-rls.ts +15 -0
  175. package/src/drivers/postgres.test.ts +9 -0
  176. package/src/drivers/postgres.ts +49 -11
  177. package/src/drivers/signal-compete.test.ts +234 -0
  178. package/src/drivers/signal-postgres.ts +39 -19
  179. package/src/drivers/signal-redis.ts +167 -11
  180. package/src/drivers/signal-types.ts +12 -0
  181. package/src/elements/ai/agent-event-slot.ts +27 -0
  182. package/src/elements/ai/approval-http.ts +207 -0
  183. package/src/elements/ai/approval.test.ts +741 -0
  184. package/src/elements/ai/approval.ts +228 -0
  185. package/src/elements/ai/decisions/bind.ts +163 -0
  186. package/src/elements/ai/decisions/certificate.test.ts +291 -0
  187. package/src/elements/ai/decisions/certificate.ts +377 -0
  188. package/src/elements/ai/decisions/certify.ts +314 -0
  189. package/src/elements/ai/decisions/decide.test.ts +953 -0
  190. package/src/elements/ai/decisions/e2e.test.ts +399 -0
  191. package/src/elements/ai/decisions/export.test.ts +120 -0
  192. package/src/elements/ai/decisions/export.ts +146 -0
  193. package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
  194. package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
  195. package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
  196. package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
  197. package/src/elements/ai/decisions/http.test.ts +154 -0
  198. package/src/elements/ai/decisions/http.ts +240 -0
  199. package/src/elements/ai/decisions/labels.test.ts +282 -0
  200. package/src/elements/ai/decisions/labels.ts +257 -0
  201. package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
  202. package/src/elements/ai/decisions/openrouter.ts +36 -0
  203. package/src/elements/ai/decisions/provider.ts +102 -0
  204. package/src/elements/ai/decisions/typesafe.ts +36 -0
  205. package/src/elements/ai/declare.ts +252 -1
  206. package/src/elements/ai/events.test.ts +379 -0
  207. package/src/elements/ai/events.ts +138 -0
  208. package/src/elements/ai/mcp-protocol.ts +5 -6
  209. package/src/elements/ai/run-events.test.ts +461 -0
  210. package/src/elements/ai/run-events.ts +522 -0
  211. package/src/elements/ai/runtime.ts +1187 -105
  212. package/src/elements/ai/stream-turn.ts +214 -0
  213. package/src/elements/ai/stream.live.test.ts +76 -0
  214. package/src/elements/ai/subagent.test.ts +136 -0
  215. package/src/elements/ai.test.ts +497 -3
  216. package/src/elements/ai.ts +16 -1
  217. package/src/elements/channel/runtime.ts +11 -0
  218. package/src/elements/channel/sql-ledger.ts +251 -0
  219. package/src/elements/clock/durable.ts +27 -1
  220. package/src/elements/signal/runtime.ts +9 -0
  221. package/src/elements/signal.test.ts +1 -0
  222. package/src/elements/store/cache.test.ts +120 -15
  223. package/src/elements/store/cache.ts +200 -39
  224. package/src/elements/store/prepare-row.test.ts +6 -2
  225. package/src/elements/store/sql-session.test.ts +53 -0
  226. package/src/elements/store/sql-session.ts +206 -10
  227. package/src/elements/store.ts +1 -0
  228. package/src/i18n/catalogs/ar.ts +5 -0
  229. package/src/i18n/catalogs/en.ts +5 -0
  230. package/src/index.ts +2 -0
  231. package/src/kernel/agent-event-store.ts +448 -0
  232. package/src/kernel/app.ts +135 -17
  233. package/src/kernel/auto-cache.test.ts +228 -0
  234. package/src/kernel/boot-bind/ai.ts +3 -0
  235. package/src/kernel/boot-bind/channel.ts +63 -2
  236. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  237. package/src/kernel/boot-bind/signal.ts +61 -25
  238. package/src/kernel/boot-bind/store.test.ts +26 -0
  239. package/src/kernel/boot-bind/store.ts +82 -6
  240. package/src/kernel/boot.ts +31 -6
  241. package/src/kernel/builtin-errors.ts +2 -0
  242. package/src/kernel/capability.ts +3 -0
  243. package/src/kernel/decision-label-store.ts +441 -0
  244. package/src/kernel/effects.test.ts +2 -1
  245. package/src/kernel/effects.ts +13 -1
  246. package/src/kernel/element-registries.ts +3 -0
  247. package/src/kernel/errors-compiler.ts +11 -0
  248. package/src/kernel/errors-text.ts +20 -0
  249. package/src/kernel/errors.ts +10 -0
  250. package/src/kernel/flow.test.ts +2 -3
  251. package/src/kernel/flow.ts +33 -3
  252. package/src/kernel/fx-call-types.test.ts +32 -0
  253. package/src/kernel/fx-decide.ts +765 -0
  254. package/src/kernel/fx-sql-handle.ts +46 -7
  255. package/src/kernel/fx.test.ts +13 -8
  256. package/src/kernel/fx.ts +339 -45
  257. package/src/kernel/http-frame.test.ts +94 -0
  258. package/src/kernel/http-frame.ts +203 -0
  259. package/src/kernel/idempotency.test.ts +2 -1
  260. package/src/kernel/journal.test.ts +114 -0
  261. package/src/kernel/journal.ts +471 -51
  262. package/src/kernel/json-result.ts +10 -0
  263. package/src/kernel/pipeline-tenant.ts +5 -1
  264. package/src/kernel/pipeline.test.ts +45 -0
  265. package/src/kernel/pipeline.ts +5 -0
  266. package/src/kernel/signal-tx.ts +28 -0
  267. package/src/kernel/sse-id.ts +32 -0
  268. package/src/kernel/store-transaction.test.ts +95 -0
  269. package/src/manifest/diff.ts +1 -0
  270. package/src/manifest/types.ts +29 -2
  271. package/src/mcp/ai-tools.test.ts +202 -0
  272. package/src/mcp/authorization.ts +66 -0
  273. package/src/mcp/docs-server.ts +19 -2
  274. package/src/mcp/mcp.test.ts +83 -0
  275. package/src/mcp/protocol.ts +8 -2
  276. package/src/mcp/server.ts +23 -2
  277. package/src/mcp/session.ts +3 -0
  278. package/src/mcp/tools.ts +168 -0
  279. package/src/mcp/versions.ts +60 -0
  280. package/src/plugins/index.ts +1 -0
  281. package/src/plugins/mena.ts +56 -0
  282. package/src/release/http-graph.test.ts +2 -0
  283. package/src/release/http-graph.ts +5 -0
  284. package/src/release/measure.ts +39 -14
  285. package/src/runs/collect.ts +4 -1
  286. package/src/runs/parquet.test.ts +27 -1
  287. package/src/runs/parquet.ts +19 -0
  288. package/src/runs/types.ts +31 -0
  289. package/src/runtime/json-code-block.test.ts +106 -47
  290. package/src/runtime/json-code-block.ts +1209 -46
  291. package/src/term.test.ts +13 -0
  292. package/src/term.ts +81 -49
  293. package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
  294. package/src/console/ui-next/dist/assets/access-page-Ze6ckjdd.js +0 -4
  295. package/src/console/ui-next/dist/assets/copy-inline-button-CWG5_ZOh.js +0 -1
  296. package/src/console/ui-next/dist/assets/detail-header-b_ZzIsmU.js +0 -1
  297. package/src/console/ui-next/dist/assets/dropdown-menu-nwXEO1Ac.js +0 -1
  298. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +0 -9
  299. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +0 -1
  300. package/src/console/ui-next/dist/assets/explorer-empty-C8sSCqYT.js +0 -1
  301. package/src/console/ui-next/dist/assets/flows-page-D2E7BtKK.js +0 -1
  302. package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
  303. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +0 -58
  304. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +0 -4
  305. package/src/console/ui-next/dist/assets/request-meta-BjFT7DNl.js +0 -1
  306. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +0 -1
  307. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +0 -41
  308. package/src/console/ui-next/dist/assets/trace-detail-sheet-Cmitq3da.js +0 -2
  309. package/src/console/ui-next/dist/assets/tree-expand-toggle-CbDIB-7x.js +0 -55
  310. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +0 -1
  311. package/src/console/ui-next/dist/assets/use-vault-list-Cs45Czkt.js +0 -1
  312. package/src/console/ui-next/dist/assets/vault-page-BOMr0go9.js +0 -2
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Decision labels and the drift flag on the journal driver.
3
+ *
4
+ * A sibling of `oke_idempotency`. Rows are not journal runs. Tables are
5
+ * created only when this store is opened, which boot does when the app
6
+ * declares decisions.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import { mkdir } from "node:fs/promises";
12
+ import { dirname } from "node:path";
13
+ import type { DecisionCandidate, DecisionLabel } from "../elements/ai/decisions/certificate.ts";
14
+
15
+ /** SQL surface the postgres journal client already exposes. */
16
+ export interface DecisionLabelSql {
17
+ query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
18
+ exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
19
+ }
20
+
21
+ /** Drift for one decision. A later certificate (`certifiedAt` newer) ignores it. */
22
+ export interface DecisionDriftFlag {
23
+ readonly suspended: boolean;
24
+ /** Epoch ms of the certificate that was current when the flag was written. */
25
+ readonly certifiedAt: number;
26
+ }
27
+
28
+ /** @deprecated Use {@link DecisionDriftFlag}. Kept for stored rows. */
29
+ export interface DecisionDriftRecord extends DecisionDriftFlag {}
30
+
31
+ /** Thrown when the postgres label store cannot open or query. */
32
+ export class DecisionLabelStoreError extends Error {
33
+ /**
34
+ * @param message - What failed
35
+ * @param options - Underlying driver error
36
+ */
37
+ constructor(message: string, options?: { cause?: unknown }) {
38
+ super(message, options);
39
+ this.name = "DecisionLabelStoreError";
40
+ }
41
+ }
42
+
43
+ /** Persistence for review labels and the app drift flag. */
44
+ export interface DecisionLabelStore {
45
+ insert(label: DecisionLabel, at: number): Promise<void>;
46
+ /**
47
+ * Labels for one decision. Omit `tenant` to read every tenant.
48
+ *
49
+ * @param decision - Decision name
50
+ * @param tenant - Tenant filter
51
+ */
52
+ list(decision?: string, tenant?: string | null): Promise<DecisionLabel[]>;
53
+ /** Drift flags keyed by decision name. */
54
+ drift(): Promise<Readonly<Record<string, DecisionDriftFlag>>>;
55
+ /**
56
+ * Persist one decision's suspension flag.
57
+ *
58
+ * @param decision - Decision name
59
+ * @param record - Flag and the certificate time it belongs to
60
+ */
61
+ setDrift(decision: string, record: DecisionDriftFlag): Promise<void>;
62
+ /**
63
+ * Store one candidate on this journal. Any instance can read it after a restart.
64
+ *
65
+ * @param decision - Decision name
66
+ * @param entry - Lock entry the clock fitted
67
+ */
68
+ putCandidate(decision: string, entry: DecisionCandidate): Promise<void>;
69
+ /**
70
+ * Candidate last written for this decision.
71
+ *
72
+ * @param decision - Decision name
73
+ */
74
+ getCandidate(decision: string): Promise<DecisionCandidate | undefined>;
75
+ /** Decision names that have a candidate. */
76
+ listCandidates(): Promise<readonly string[]>;
77
+ }
78
+
79
+ interface StoredLabel extends DecisionLabel {
80
+ readonly at: number;
81
+ }
82
+
83
+ /**
84
+ * In-memory label store.
85
+ */
86
+ export function createMemoryDecisionLabelStore(): DecisionLabelStore {
87
+ const rows: StoredLabel[] = [];
88
+ const candidates = new Map<string, DecisionCandidate>();
89
+ const driftFlags = new Map<string, DecisionDriftFlag>();
90
+ return {
91
+ async insert(label, at) {
92
+ rows.push({ ...label, at });
93
+ },
94
+ async list(decision, tenant) {
95
+ return rows
96
+ .filter((row) => decision === undefined || row.decision === decision)
97
+ .filter((row) => tenant === undefined || (row.tenant ?? null) === tenant)
98
+ .map(toLabel);
99
+ },
100
+ async drift() {
101
+ return Object.fromEntries(driftFlags);
102
+ },
103
+ async setDrift(decision, next) {
104
+ if (next.suspended) driftFlags.set(decision, next);
105
+ else driftFlags.delete(decision);
106
+ },
107
+ async putCandidate(decision, entry) {
108
+ candidates.set(decision, entry);
109
+ },
110
+ async getCandidate(decision) {
111
+ return candidates.get(decision);
112
+ },
113
+ async listCandidates() {
114
+ return [...candidates.keys()];
115
+ },
116
+ };
117
+ }
118
+
119
+ /**
120
+ * File-backed label store beside the journal file.
121
+ *
122
+ * @param path - JSON file path
123
+ */
124
+ export function createFileDecisionLabelStore(path: string): DecisionLabelStore {
125
+ let rows: StoredLabel[] | undefined;
126
+ let candidates: Record<string, DecisionCandidate> = {};
127
+ let driftFlags: Record<string, DecisionDriftFlag> = {};
128
+ const load = async (): Promise<void> => {
129
+ if (rows) return;
130
+ rows = [];
131
+ const file = Bun.file(path);
132
+ if (await file.exists()) {
133
+ const raw = (await file.json()) as {
134
+ rows?: StoredLabel[];
135
+ suspended?: boolean;
136
+ certifiedAt?: number;
137
+ drifts?: Record<string, DecisionDriftFlag>;
138
+ candidates?: Record<string, DecisionCandidate>;
139
+ };
140
+ rows = raw.rows ?? [];
141
+ candidates = raw.candidates ?? {};
142
+ driftFlags = raw.drifts ?? {};
143
+ const legacy =
144
+ driftFlags["*"] ??
145
+ (raw.suspended === true && Object.keys(driftFlags).length === 0
146
+ ? { suspended: true as const, certifiedAt: raw.certifiedAt ?? 0 }
147
+ : undefined);
148
+ if (legacy?.suspended) {
149
+ delete driftFlags["*"];
150
+ for (const name of declaredDriftDecisions) {
151
+ driftFlags[name] = { suspended: legacy.suspended, certifiedAt: legacy.certifiedAt };
152
+ }
153
+ await mkdir(dirname(path), { recursive: true });
154
+ await Bun.write(path, JSON.stringify({ rows, candidates, drifts: driftFlags }));
155
+ }
156
+ }
157
+ };
158
+ const flush = async (): Promise<void> => {
159
+ await mkdir(dirname(path), { recursive: true });
160
+ await Bun.write(
161
+ path,
162
+ JSON.stringify({
163
+ rows,
164
+ candidates,
165
+ drifts: driftFlags,
166
+ }),
167
+ );
168
+ };
169
+ return {
170
+ async insert(label, at) {
171
+ await load();
172
+ rows!.push({ ...label, at });
173
+ await flush();
174
+ },
175
+ async list(decision, tenant) {
176
+ await load();
177
+ return rows!
178
+ .filter((row) => decision === undefined || row.decision === decision)
179
+ .filter((row) => tenant === undefined || (row.tenant ?? null) === tenant)
180
+ .map(toLabel);
181
+ },
182
+ async drift() {
183
+ await load();
184
+ return driftFlags;
185
+ },
186
+ async setDrift(decision, next) {
187
+ await load();
188
+ if (next.suspended) driftFlags[decision] = next;
189
+ else delete driftFlags[decision];
190
+ await flush();
191
+ },
192
+ async putCandidate(decision, entry) {
193
+ await load();
194
+ candidates[decision] = entry;
195
+ await flush();
196
+ },
197
+ async getCandidate(decision) {
198
+ await load();
199
+ return candidates[decision];
200
+ },
201
+ async listCandidates() {
202
+ await load();
203
+ return Object.keys(candidates);
204
+ },
205
+ };
206
+ }
207
+
208
+ /** Decision names an old app-wide drift flag is copied onto. Boot sets this before open. */
209
+ let declaredDriftDecisions: readonly string[] = [];
210
+
211
+ /**
212
+ * Declare which decisions receive a legacy app-wide drift flag.
213
+ *
214
+ * @param names - Decision names from the manifest
215
+ */
216
+ export function setDeclaredDriftDecisions(names: readonly string[]): void {
217
+ declaredDriftDecisions = names;
218
+ }
219
+
220
+ /**
221
+ * Postgres label store on the journal connection. Creates the tables.
222
+ * A failed init or query throws {@link DecisionLabelStoreError}.
223
+ *
224
+ * @param sql - Journal SQL client
225
+ */
226
+ export async function createPostgresDecisionLabelStore(
227
+ sql: DecisionLabelSql,
228
+ ): Promise<DecisionLabelStore> {
229
+ try {
230
+ await sql.exec(`CREATE TABLE IF NOT EXISTS oke_decision_labels (
231
+ decision_id TEXT NOT NULL,
232
+ question TEXT NOT NULL,
233
+ value TEXT NOT NULL,
234
+ propensity DOUBLE PRECISION NOT NULL,
235
+ reviewer TEXT NOT NULL,
236
+ locale TEXT,
237
+ model TEXT,
238
+ tenant TEXT,
239
+ score DOUBLE PRECISION,
240
+ loss INTEGER,
241
+ raw TEXT,
242
+ at BIGINT NOT NULL,
243
+ input TEXT
244
+ )`);
245
+ await sql.exec(`ALTER TABLE oke_decision_labels ADD COLUMN IF NOT EXISTS input TEXT`);
246
+ await sql.exec(`ALTER TABLE oke_decision_labels ADD COLUMN IF NOT EXISTS review_id TEXT`);
247
+ await migrateDecisionDriftTable(sql, declaredDriftDecisions);
248
+ await sql.exec(`CREATE TABLE IF NOT EXISTS oke_decision_candidates (
249
+ decision_id TEXT PRIMARY KEY,
250
+ body TEXT NOT NULL
251
+ )`);
252
+ } catch (cause) {
253
+ throw new DecisionLabelStoreError("decision label store failed to open", { cause });
254
+ }
255
+ const query = async (
256
+ statement: string,
257
+ params?: readonly unknown[],
258
+ ): Promise<Record<string, unknown>[]> => {
259
+ try {
260
+ return await sql.query(statement, params);
261
+ } catch (cause) {
262
+ throw new DecisionLabelStoreError("decision label store query failed", { cause });
263
+ }
264
+ };
265
+ const exec = async (statement: string, params?: readonly unknown[]): Promise<void> => {
266
+ try {
267
+ await sql.exec(statement, params);
268
+ } catch (cause) {
269
+ throw new DecisionLabelStoreError("decision label store query failed", { cause });
270
+ }
271
+ };
272
+ return {
273
+ async insert(label, at) {
274
+ await exec(
275
+ `INSERT INTO oke_decision_labels
276
+ (decision_id, question, value, propensity, reviewer, locale, model, tenant, score, loss, raw, at, input, review_id)
277
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
278
+ [
279
+ label.decision,
280
+ label.question,
281
+ JSON.stringify(label.value),
282
+ label.propensity,
283
+ label.reviewer,
284
+ label.locale ?? null,
285
+ label.model ?? null,
286
+ label.tenant ?? null,
287
+ label.score ?? null,
288
+ label.loss ?? null,
289
+ label.raw === undefined ? null : JSON.stringify(label.raw),
290
+ at,
291
+ label.input === undefined ? null : JSON.stringify(label.input),
292
+ label.reviewId ?? null,
293
+ ],
294
+ );
295
+ },
296
+ async list(decision, tenant) {
297
+ const clauses: string[] = [];
298
+ const args: (string | null)[] = [];
299
+ if (decision !== undefined) {
300
+ clauses.push("decision_id = ?");
301
+ args.push(decision);
302
+ }
303
+ if (tenant !== undefined) {
304
+ clauses.push("tenant IS NOT DISTINCT FROM ?");
305
+ args.push(tenant);
306
+ }
307
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
308
+ const found = await query(
309
+ `SELECT decision_id, question, value, propensity, reviewer, locale, model, tenant, score, loss, raw, at, input, review_id
310
+ FROM oke_decision_labels ${where}`,
311
+ args,
312
+ );
313
+ return found.map((row) => ({
314
+ decision: String(row.decision_id),
315
+ question: String(row.question),
316
+ value: JSON.parse(String(row.value)) as unknown,
317
+ propensity: Number(row.propensity),
318
+ reviewer: String(row.reviewer),
319
+ ...(row.locale != null ? { locale: String(row.locale) } : {}),
320
+ ...(row.model != null ? { model: String(row.model) } : {}),
321
+ ...(row.tenant != null ? { tenant: String(row.tenant) } : {}),
322
+ ...(row.score != null ? { score: Number(row.score) } : {}),
323
+ ...(row.loss != null ? { loss: Number(row.loss) } : {}),
324
+ ...(row.raw != null ? { raw: JSON.parse(String(row.raw)) as unknown } : {}),
325
+ ...(row.input != null ? { input: JSON.parse(String(row.input)) as unknown } : {}),
326
+ ...(row.review_id != null ? { reviewId: String(row.review_id) } : {}),
327
+ at: Number(row.at),
328
+ }));
329
+ },
330
+ async drift() {
331
+ const rows = await query(
332
+ `SELECT decision_id, suspended, certified_at FROM oke_decision_drift`,
333
+ );
334
+ const flags: Record<string, DecisionDriftFlag> = {};
335
+ for (const row of rows) {
336
+ const name = String(row.decision_id ?? "");
337
+ if (!name) continue;
338
+ flags[name] = {
339
+ suspended: Number(row.suspended) === 1,
340
+ certifiedAt: Number(row.certified_at ?? 0),
341
+ };
342
+ }
343
+ return flags;
344
+ },
345
+ async setDrift(decision, next) {
346
+ if (!next.suspended) {
347
+ await exec(`DELETE FROM oke_decision_drift WHERE decision_id = ?`, [decision]);
348
+ return;
349
+ }
350
+ await exec(
351
+ `INSERT INTO oke_decision_drift (decision_id, suspended, certified_at) VALUES (?, ?, ?)
352
+ ON CONFLICT (decision_id) DO UPDATE SET suspended = excluded.suspended, certified_at = excluded.certified_at`,
353
+ [decision, 1, next.certifiedAt],
354
+ );
355
+ },
356
+ async putCandidate(decision, entry) {
357
+ await exec(
358
+ `INSERT INTO oke_decision_candidates (decision_id, body) VALUES (?, ?)
359
+ ON CONFLICT (decision_id) DO UPDATE SET body = excluded.body`,
360
+ [decision, JSON.stringify(entry)],
361
+ );
362
+ },
363
+ async getCandidate(decision) {
364
+ const rows = await query(`SELECT body FROM oke_decision_candidates WHERE decision_id = ?`, [
365
+ decision,
366
+ ]);
367
+ const body = rows[0]?.body;
368
+ if (typeof body !== "string") return undefined;
369
+ const parsed = JSON.parse(body) as DecisionCandidate;
370
+ if (!parsed || typeof parsed.model !== "string" || !parsed.questions) return undefined;
371
+ return parsed;
372
+ },
373
+ async listCandidates() {
374
+ const rows = await query(`SELECT decision_id FROM oke_decision_candidates`);
375
+ return rows.map((row) => String(row.decision_id));
376
+ },
377
+ };
378
+ }
379
+
380
+ function toLabel(row: StoredLabel): DecisionLabel {
381
+ return {
382
+ decision: row.decision,
383
+ question: row.question,
384
+ value: row.value,
385
+ propensity: row.propensity,
386
+ reviewer: row.reviewer,
387
+ ...(row.locale !== undefined ? { locale: row.locale } : {}),
388
+ ...(row.model !== undefined ? { model: row.model } : {}),
389
+ ...(row.tenant !== undefined ? { tenant: row.tenant } : {}),
390
+ ...(row.score !== undefined ? { score: row.score } : {}),
391
+ ...(row.loss !== undefined ? { loss: row.loss } : {}),
392
+ ...(row.raw !== undefined ? { raw: row.raw } : {}),
393
+ ...(row.input !== undefined ? { input: row.input } : {}),
394
+ ...(row.reviewId !== undefined ? { reviewId: row.reviewId } : {}),
395
+ at: row.at,
396
+ };
397
+ }
398
+
399
+ /**
400
+ * Recreate `oke_decision_drift` when it still uses an app-wide `id` row.
401
+ * A suspended flag is copied onto every declared decision.
402
+ *
403
+ * @param sql - Journal SQL client
404
+ * @param names - Declared decision names
405
+ */
406
+ async function migrateDecisionDriftTable(
407
+ sql: DecisionLabelSql,
408
+ names: readonly string[],
409
+ ): Promise<void> {
410
+ const create = `CREATE TABLE IF NOT EXISTS oke_decision_drift (
411
+ decision_id TEXT PRIMARY KEY,
412
+ suspended INTEGER NOT NULL,
413
+ certified_at BIGINT NOT NULL
414
+ )`;
415
+ let columns: string[] = [];
416
+ try {
417
+ const rows = await sql.query(
418
+ `SELECT column_name FROM information_schema.columns WHERE table_name = 'oke_decision_drift'`,
419
+ );
420
+ columns = rows.map((row) => String(row.column_name ?? "").toLowerCase());
421
+ } catch {
422
+ columns = [];
423
+ }
424
+ if (columns.includes("id") && !columns.includes("decision_id")) {
425
+ const old = await sql.query(`SELECT * FROM oke_decision_drift`);
426
+ const flag = old[0];
427
+ await sql.exec(`DROP TABLE oke_decision_drift`);
428
+ await sql.exec(create.replace("IF NOT EXISTS ", ""));
429
+ if (flag && Number(flag.suspended) === 1) {
430
+ const certifiedAt = Number(flag.certified_at ?? 0);
431
+ for (const name of names) {
432
+ await sql.exec(
433
+ `INSERT INTO oke_decision_drift (decision_id, suspended, certified_at) VALUES (?, ?, ?)`,
434
+ [name, 1, certifiedAt],
435
+ );
436
+ }
437
+ }
438
+ return;
439
+ }
440
+ await sql.exec(create);
441
+ }
@@ -21,9 +21,10 @@ describe("effects — reversibility tiers", () => {
21
21
  secret: "capability",
22
22
  call: "portal",
23
23
  fetch: "irreversible",
24
+ decide: "irreversible",
24
25
  };
25
26
 
26
- expect(EFFECT_KIND_TIERS).toHaveLength(9);
27
+ expect(EFFECT_KIND_TIERS).toHaveLength(10);
27
28
  for (const { kind, reversibility } of EFFECT_KIND_TIERS) {
28
29
  expect(reversibility).toBe(expected[kind]);
29
30
  expect(reversibilityOf(kind)).toBe(expected[kind]);
@@ -17,7 +17,8 @@ export type EffectKind =
17
17
  | "embed"
18
18
  | "secret"
19
19
  | "call"
20
- | "fetch";
20
+ | "fetch"
21
+ | "decide";
21
22
 
22
23
  /**
23
24
  * Reversibility tier — console §9.1 ranking, applied to all effect kinds.
@@ -65,6 +66,11 @@ export interface EffectEntry {
65
66
  * Omitted for in-process work (PGlite, mock AI, local channel console).
66
67
  */
67
68
  readonly external?: EffectExternal;
69
+ /**
70
+ * Agent run this `fx.run` or nested agent call started.
71
+ * Absent on prompt asks and on calls that are not agents.
72
+ */
73
+ readonly agentRunId?: string;
68
74
  }
69
75
 
70
76
  /** Append-only ledger of effect entries for one flow invocation. */
@@ -92,6 +98,7 @@ const TIER_BY_KIND: Readonly<Record<EffectKind, ReversibilityTier>> = {
92
98
  secret: "capability",
93
99
  call: "portal",
94
100
  fetch: "irreversible",
101
+ decide: "irreversible",
95
102
  };
96
103
 
97
104
  /**
@@ -143,6 +150,7 @@ export async function recordEffect<T>(
143
150
  externalOf?:
144
151
  | EffectExternal
145
152
  | ((result: T | undefined, error: unknown) => EffectExternal | undefined),
153
+ stamp?: (result: T | undefined) => { readonly agentRunId?: string } | undefined,
146
154
  ): Promise<T> {
147
155
  const timestamp = now();
148
156
  const t0 = performance.now();
@@ -163,6 +171,7 @@ export async function recordEffect<T>(
163
171
  timestamp,
164
172
  resolveDurationMs(now() - timestamp, performance.now() - t0),
165
173
  external,
174
+ stamp?.(result),
166
175
  );
167
176
  }
168
177
  }
@@ -187,7 +196,9 @@ export function recordObservedEffect(
187
196
  timestamp: number,
188
197
  duration: number,
189
198
  external?: EffectExternal,
199
+ stamp?: { readonly agentRunId?: string },
190
200
  ): void {
201
+ const agentRunId = stamp?.agentRunId;
191
202
  ledger.record({
192
203
  kind,
193
204
  resource,
@@ -195,6 +206,7 @@ export function recordObservedEffect(
195
206
  duration,
196
207
  reversibility: reversibilityOf(kind),
197
208
  ...(external !== undefined ? { external } : {}),
209
+ ...(agentRunId !== undefined && agentRunId.length > 0 ? { agentRunId } : {}),
198
210
  });
199
211
  }
200
212
 
@@ -16,6 +16,7 @@
16
16
 
17
17
  import type {
18
18
  AiAgentDecl,
19
+ AiDecisionDecl,
19
20
  AiEmbedDecl,
20
21
  AiMcpServerDecl,
21
22
  AiModelDecl,
@@ -52,3 +53,5 @@ export const aiEmbedRegistry: AiEmbedDecl[] = [];
52
53
  export const aiAgentRegistry: AiAgentDecl[] = [];
53
54
  /** `ai.mcpServer` declarations since the last reset. */
54
55
  export const aiMcpServerRegistry: AiMcpServerDecl[] = [];
56
+ /** `ai.decision` declarations since the last reset. */
57
+ export const aiDecisionRegistry: AiDecisionDecl[] = [];
@@ -12,6 +12,17 @@ import type { OkeErrorDefinition } from "./errors.ts";
12
12
  * A Flow hides `fx` from effect inference, or an explicit `effects` block
13
13
  * omits an effect the walk can see.
14
14
  */
15
+ /**
16
+ * A durable Flow calls `fx.fetch` or `fx.send` outside `fx.step`.
17
+ * Those effects are irreversible and must be an explicit step so replay skips them.
18
+ */
19
+ export const DURABLE_BARE_EFFECT: OkeErrorDefinition = {
20
+ code: 1901,
21
+ domain: "compiler",
22
+ cause: 'Durable flow "{flow}" calls fx.{effect} outside fx.step.',
23
+ fix: "Wrap fx.fetch and fx.send in fx.step so replay returns the journaled value instead of repeating the call.",
24
+ };
25
+
15
26
  export const FX_INFERENCE_OPAQUE: OkeErrorDefinition = {
16
27
  code: 1900,
17
28
  domain: "compiler",
@@ -52,6 +52,10 @@ export const OKE_ERROR_TEXT: Readonly<Record<string, OkeErrorText>> = {
52
52
  cause: 'Flow "{flow}" embeds with "{resource}" without declaring it.',
53
53
  fix: 'Add "{resource}" to this flow\'s effects.embeds.',
54
54
  },
55
+ UNDECLARED_DECIDE: {
56
+ cause: 'Flow "{flow}" decides "{resource}" without declaring it.',
57
+ fix: 'Add "{resource}" to this flow\'s effects.decides.',
58
+ },
55
59
  NO_EFFECTS_DECLARED: {
56
60
  cause: 'Flow "{flow}" has no declared effects and no Manifest to derive them from.{extract}',
57
61
  fix:
@@ -80,6 +84,22 @@ export const OKE_ERROR_TEXT: Readonly<Record<string, OkeErrorText>> = {
80
84
  'Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}).',
81
85
  fix: "Use a different gate or path-param filter, or drop the extra route.",
82
86
  },
87
+ JOURNAL_STALE_LEASE: {
88
+ cause: 'Journal run "{runId}" rejected a write from an expired lease holder.',
89
+ fix: "Resume the run so the current holder acquires a new lease token. Do not write with a token from a previous holder.",
90
+ },
91
+ JOURNAL_REPLAY_DIVERGENCE: {
92
+ cause: 'Journal run "{runId}" expected {expected} but the code called {actual}.',
93
+ fix: "Keep step names, sleep labels, and effect order stable for in-flight runs. A code change that reorders journal calls cannot resume the old run.",
94
+ },
95
+ JOURNAL_CODE_VERSION: {
96
+ cause: 'Journal run "{runId}" was started on code {expected} and this process is {actual}.',
97
+ fix: "Finish or abandon in-flight runs before deploying a different code version. Do not replay them on the new build.",
98
+ },
99
+ JOURNAL_VALUE_NOT_JSON: {
100
+ cause: "A durable journal entry held a value that is not JSON ({detail}).",
101
+ fix: "Return plain JSON from fx.step. fx.fetch and fx.send must run inside fx.step so the journal can store a JSON snapshot.",
102
+ },
83
103
  MCP_TOOL_DUPLICATE: {
84
104
  cause: 'MCP tool "{tool}" is bound twice (flow "{flow}").',
85
105
  fix: "Give each MCP tool exposure a unique tool name.",
@@ -270,6 +270,8 @@ const OKE_ERROR_ROWS = {
270
270
  UNDECLARED_FETCH: { code: 1008, domain: "kernel" },
271
271
  /** Flow embeds via a model not listed in `effects.embeds`. */
272
272
  UNDECLARED_EMBED: { code: 1009, domain: "kernel" },
273
+ /** Flow decides a decision not listed in `effects.decides`. */
274
+ UNDECLARED_DECIDE: { code: 1010, domain: "kernel" },
273
275
  /**
274
276
  * Flow has no declared `effects` and no Manifest-derived effects were
275
277
  * available to stamp at boot (dev+compose / prod — never a silent open token).
@@ -292,6 +294,14 @@ const OKE_ERROR_ROWS = {
292
294
  LIVE_EXPOSURE_DUPLICATE: { code: 1050, domain: "kernel" },
293
295
  /** Two MCP tool bindings share the same exposed tool name. */
294
296
  MCP_TOOL_DUPLICATE: { code: 1060, domain: "kernel" },
297
+ /** A journal write arrived from a holder whose lease token no longer matches. */
298
+ JOURNAL_STALE_LEASE: { code: 1074, domain: "kernel" },
299
+ /** Replay called a different step or effect than the next journal entry. */
300
+ JOURNAL_REPLAY_DIVERGENCE: { code: 1075, domain: "kernel" },
301
+ /** Resume of a run stamped with a different code version. */
302
+ JOURNAL_CODE_VERSION: { code: 1076, domain: "kernel" },
303
+ /** A journaled value could not be stored as JSON. */
304
+ JOURNAL_VALUE_NOT_JSON: { code: 1077, domain: "kernel" },
295
305
  /**
296
306
  * Emit target has no subscriber (unified-theory §21).
297
307
  * Thrown at emit when `optional` is false and nobody is subscribed.
@@ -46,9 +46,8 @@ describe("flow — one species", () => {
46
46
  on(signal, shared);
47
47
 
48
48
  const app = oke({ autoBoot: false, name: "one-species" });
49
- expect(app.bindings).toHaveLength(2);
50
- expect(app.bindings[0]?.flow).toBe(shared);
51
- expect(app.bindings[1]?.flow).toBe(shared);
49
+ const sharedBindings = app.bindings.filter((binding) => binding.flow === shared);
50
+ expect(sharedBindings).toHaveLength(2);
52
51
 
53
52
  const httpResult = await app.execute(shared, { n: 3 }, shared.triggers[0]!);
54
53
  const signalResults = await app.dispatchSignal("order-placed", { n: 4 });
@@ -49,9 +49,9 @@ export interface FlowOptions<I = unknown, O = unknown> {
49
49
  */
50
50
  readonly retry?: FxRetryOptions;
51
51
  /**
52
- * Cache policy. Read-only flows cache automatically from inferred
53
- * effects (`true` / omitted). `false` opts out; a duration string
54
- * (`"30s"`) adds a TTL on top of write invalidation.
52
+ * Cache policy. Omitted caches a pure store-read flow. `true` or a duration
53
+ * (`"30s"`) opts one flow in when the app set `cache: { auto: false }`.
54
+ * `false` always disables. The key includes tenant, locale, scopes, and roles.
55
55
  */
56
56
  readonly cache?: boolean | string;
57
57
  /**
@@ -124,6 +124,10 @@ export type InferFlowOut<Opts> = Opts extends { readonly do: FlowHandler<infer _
124
124
  * `FlowDef<…, undefined>` remains assignable into `on()` before rebinding.
125
125
  */
126
126
  declare const triggerPhantom: unique symbol;
127
+ /** Covariant input phantom so `fx.call` can read `I` without hitting `do`. */
128
+ declare const inputPhantom: unique symbol;
129
+ /** Covariant output phantom so `fx.call` can read `O` without hitting `do`. */
130
+ declare const outputPhantom: unique symbol;
127
131
 
128
132
  /**
129
133
  * A Flow definition — one species, trigger-agnostic until {@link on} binds.
@@ -151,6 +155,10 @@ export interface FlowDef<
151
155
  * `$trigger` / `triggers[0]` for the value.
152
156
  */
153
157
  readonly [triggerPhantom]?: T;
158
+ /** Type-level input. Not present at runtime. */
159
+ readonly [inputPhantom]?: I;
160
+ /** Type-level output. Not present at runtime. */
161
+ readonly [outputPhantom]?: O;
154
162
  /** Stable name (auto-assigned when omitted). */
155
163
  readonly name: string;
156
164
  /** Unit scope — derived from `name`'s first dot segment (e.g. `"auth.refresh"` → `"auth"`). */
@@ -255,6 +263,28 @@ export interface FlowDef<
255
263
  ): FlowDef<I, O, E, D & (P extends PluginDef<infer PD> ? PD : Record<string, never>), T>;
256
264
  }
257
265
 
266
+ /**
267
+ * Input type of a Flow handle. `unknown` when `F` is a string name.
268
+ *
269
+ * @typeParam F - Flow handle or other call target
270
+ */
271
+ export type FlowInput<F> = F extends { readonly [inputPhantom]?: infer I }
272
+ ? [I] extends [undefined]
273
+ ? unknown
274
+ : Exclude<I, undefined>
275
+ : unknown;
276
+
277
+ /**
278
+ * Output type of a Flow handle. `unknown` when `F` is a string name.
279
+ *
280
+ * @typeParam F - Flow handle or other call target
281
+ */
282
+ export type FlowOutput<F> = F extends { readonly [outputPhantom]?: infer O }
283
+ ? [O] extends [undefined]
284
+ ? unknown
285
+ : Exclude<O, undefined>
286
+ : unknown;
287
+
258
288
  /** Unique brand symbol for {@link FlowDef}. */
259
289
  export const flowBrand: unique symbol = Symbol("oke.flow");
260
290