@ixo/decision-oracle 0.0.0-stage → 0.2.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 (1118) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +794 -2
  3. package/dist/act.d.ts +87 -0
  4. package/dist/act.d.ts.map +1 -0
  5. package/dist/act.js +20 -0
  6. package/dist/act.js.map +1 -0
  7. package/dist/app.d.ts +22 -0
  8. package/dist/app.d.ts.map +1 -0
  9. package/dist/app.js +128 -0
  10. package/dist/app.js.map +1 -0
  11. package/dist/c2pa.wasm +0 -0
  12. package/dist/cli/commands/api.d.ts +50 -0
  13. package/dist/cli/commands/api.d.ts.map +1 -0
  14. package/dist/cli/commands/api.js +737 -0
  15. package/dist/cli/commands/api.js.map +1 -0
  16. package/dist/cli/commands/create-collection.d.ts +40 -0
  17. package/dist/cli/commands/create-collection.d.ts.map +1 -0
  18. package/dist/cli/commands/create-collection.js +318 -0
  19. package/dist/cli/commands/create-collection.js.map +1 -0
  20. package/dist/cli/commands/create-deed.d.ts +31 -0
  21. package/dist/cli/commands/create-deed.d.ts.map +1 -0
  22. package/dist/cli/commands/create-deed.js +208 -0
  23. package/dist/cli/commands/create-deed.js.map +1 -0
  24. package/dist/cli/commands/delegation.d.ts +26 -0
  25. package/dist/cli/commands/delegation.d.ts.map +1 -0
  26. package/dist/cli/commands/delegation.js +162 -0
  27. package/dist/cli/commands/delegation.js.map +1 -0
  28. package/dist/cli/commands/derive.d.ts +22 -0
  29. package/dist/cli/commands/derive.d.ts.map +1 -0
  30. package/dist/cli/commands/derive.js +53 -0
  31. package/dist/cli/commands/derive.js.map +1 -0
  32. package/dist/cli/commands/engine.d.ts +25 -0
  33. package/dist/cli/commands/engine.d.ts.map +1 -0
  34. package/dist/cli/commands/engine.js +107 -0
  35. package/dist/cli/commands/engine.js.map +1 -0
  36. package/dist/cli/commands/erase.d.ts +17 -0
  37. package/dist/cli/commands/erase.d.ts.map +1 -0
  38. package/dist/cli/commands/erase.js +117 -0
  39. package/dist/cli/commands/erase.js.map +1 -0
  40. package/dist/cli/commands/facts-run.d.ts +30 -0
  41. package/dist/cli/commands/facts-run.d.ts.map +1 -0
  42. package/dist/cli/commands/facts-run.js +409 -0
  43. package/dist/cli/commands/facts-run.js.map +1 -0
  44. package/dist/cli/commands/facts-scaffold.d.ts +22 -0
  45. package/dist/cli/commands/facts-scaffold.d.ts.map +1 -0
  46. package/dist/cli/commands/facts-scaffold.js +208 -0
  47. package/dist/cli/commands/facts-scaffold.js.map +1 -0
  48. package/dist/cli/commands/init.d.ts +18 -0
  49. package/dist/cli/commands/init.d.ts.map +1 -0
  50. package/dist/cli/commands/init.js +34 -0
  51. package/dist/cli/commands/init.js.map +1 -0
  52. package/dist/cli/commands/login.d.ts +9 -0
  53. package/dist/cli/commands/login.d.ts.map +1 -0
  54. package/dist/cli/commands/login.js +16 -0
  55. package/dist/cli/commands/login.js.map +1 -0
  56. package/dist/cli/commands/logout.d.ts +10 -0
  57. package/dist/cli/commands/logout.d.ts.map +1 -0
  58. package/dist/cli/commands/logout.js +28 -0
  59. package/dist/cli/commands/logout.js.map +1 -0
  60. package/dist/cli/commands/manifest.d.ts +12 -0
  61. package/dist/cli/commands/manifest.d.ts.map +1 -0
  62. package/dist/cli/commands/manifest.js +48 -0
  63. package/dist/cli/commands/manifest.js.map +1 -0
  64. package/dist/cli/commands/oracle-init.d.ts +48 -0
  65. package/dist/cli/commands/oracle-init.d.ts.map +1 -0
  66. package/dist/cli/commands/oracle-init.js +1122 -0
  67. package/dist/cli/commands/oracle-init.js.map +1 -0
  68. package/dist/cli/commands/org.d.ts +28 -0
  69. package/dist/cli/commands/org.d.ts.map +1 -0
  70. package/dist/cli/commands/org.js +309 -0
  71. package/dist/cli/commands/org.js.map +1 -0
  72. package/dist/cli/commands/protocol-review.d.ts +20 -0
  73. package/dist/cli/commands/protocol-review.d.ts.map +1 -0
  74. package/dist/cli/commands/protocol-review.js +162 -0
  75. package/dist/cli/commands/protocol-review.js.map +1 -0
  76. package/dist/cli/commands/publish-protocol.d.ts +43 -0
  77. package/dist/cli/commands/publish-protocol.d.ts.map +1 -0
  78. package/dist/cli/commands/publish-protocol.js +345 -0
  79. package/dist/cli/commands/publish-protocol.js.map +1 -0
  80. package/dist/cli/commands/register.d.ts +16 -0
  81. package/dist/cli/commands/register.d.ts.map +1 -0
  82. package/dist/cli/commands/register.js +54 -0
  83. package/dist/cli/commands/register.js.map +1 -0
  84. package/dist/cli/commands/relink.d.ts +11 -0
  85. package/dist/cli/commands/relink.d.ts.map +1 -0
  86. package/dist/cli/commands/relink.js +36 -0
  87. package/dist/cli/commands/relink.js.map +1 -0
  88. package/dist/cli/commands/rubric-fetch-form.d.ts +16 -0
  89. package/dist/cli/commands/rubric-fetch-form.d.ts.map +1 -0
  90. package/dist/cli/commands/rubric-fetch-form.js +83 -0
  91. package/dist/cli/commands/rubric-fetch-form.js.map +1 -0
  92. package/dist/cli/commands/rubric-validate.d.ts +72 -0
  93. package/dist/cli/commands/rubric-validate.d.ts.map +1 -0
  94. package/dist/cli/commands/rubric-validate.js +163 -0
  95. package/dist/cli/commands/rubric-validate.js.map +1 -0
  96. package/dist/cli/commands/scan.d.ts +13 -0
  97. package/dist/cli/commands/scan.d.ts.map +1 -0
  98. package/dist/cli/commands/scan.js +39 -0
  99. package/dist/cli/commands/scan.js.map +1 -0
  100. package/dist/cli/commands/skills.d.ts +11 -0
  101. package/dist/cli/commands/skills.d.ts.map +1 -0
  102. package/dist/cli/commands/skills.js +19 -0
  103. package/dist/cli/commands/skills.js.map +1 -0
  104. package/dist/cli/commands/submit-claim.d.ts +25 -0
  105. package/dist/cli/commands/submit-claim.d.ts.map +1 -0
  106. package/dist/cli/commands/submit-claim.js +155 -0
  107. package/dist/cli/commands/submit-claim.js.map +1 -0
  108. package/dist/cli/index.d.ts +15 -0
  109. package/dist/cli/index.d.ts.map +1 -0
  110. package/dist/cli/index.js +206 -0
  111. package/dist/cli/index.js.map +1 -0
  112. package/dist/cli/lib/agent-card-schema.d.ts +8 -0
  113. package/dist/cli/lib/agent-card-schema.d.ts.map +1 -0
  114. package/dist/cli/lib/agent-card-schema.js +169 -0
  115. package/dist/cli/lib/agent-card-schema.js.map +1 -0
  116. package/dist/cli/lib/ai-rest.d.ts +12 -0
  117. package/dist/cli/lib/ai-rest.d.ts.map +1 -0
  118. package/dist/cli/lib/ai-rest.js +36 -0
  119. package/dist/cli/lib/ai-rest.js.map +1 -0
  120. package/dist/cli/lib/args.d.ts +33 -0
  121. package/dist/cli/lib/args.d.ts.map +1 -0
  122. package/dist/cli/lib/args.js +24 -0
  123. package/dist/cli/lib/args.js.map +1 -0
  124. package/dist/cli/lib/authhub.d.ts +7 -0
  125. package/dist/cli/lib/authhub.d.ts.map +1 -0
  126. package/dist/cli/lib/authhub.js +210 -0
  127. package/dist/cli/lib/authhub.js.map +1 -0
  128. package/dist/cli/lib/chain.d.ts +264 -0
  129. package/dist/cli/lib/chain.d.ts.map +1 -0
  130. package/dist/cli/lib/chain.js +584 -0
  131. package/dist/cli/lib/chain.js.map +1 -0
  132. package/dist/cli/lib/devvars.d.ts +59 -0
  133. package/dist/cli/lib/devvars.d.ts.map +1 -0
  134. package/dist/cli/lib/devvars.js +206 -0
  135. package/dist/cli/lib/devvars.js.map +1 -0
  136. package/dist/cli/lib/engine.d.ts +54 -0
  137. package/dist/cli/lib/engine.d.ts.map +1 -0
  138. package/dist/cli/lib/engine.js +112 -0
  139. package/dist/cli/lib/engine.js.map +1 -0
  140. package/dist/cli/lib/identity-cards.d.ts +188 -0
  141. package/dist/cli/lib/identity-cards.d.ts.map +1 -0
  142. package/dist/cli/lib/identity-cards.js +261 -0
  143. package/dist/cli/lib/identity-cards.js.map +1 -0
  144. package/dist/cli/lib/matrix-account.d.ts +79 -0
  145. package/dist/cli/lib/matrix-account.d.ts.map +1 -0
  146. package/dist/cli/lib/matrix-account.js +228 -0
  147. package/dist/cli/lib/matrix-account.js.map +1 -0
  148. package/dist/cli/lib/oracle-account.d.ts +108 -0
  149. package/dist/cli/lib/oracle-account.d.ts.map +1 -0
  150. package/dist/cli/lib/oracle-account.js +207 -0
  151. package/dist/cli/lib/oracle-account.js.map +1 -0
  152. package/dist/cli/lib/project.d.ts +22 -0
  153. package/dist/cli/lib/project.d.ts.map +1 -0
  154. package/dist/cli/lib/project.js +62 -0
  155. package/dist/cli/lib/project.js.map +1 -0
  156. package/dist/cli/lib/session.d.ts +109 -0
  157. package/dist/cli/lib/session.d.ts.map +1 -0
  158. package/dist/cli/lib/session.js +354 -0
  159. package/dist/cli/lib/session.js.map +1 -0
  160. package/dist/cli/lib/workorder.d.ts +57 -0
  161. package/dist/cli/lib/workorder.d.ts.map +1 -0
  162. package/dist/cli/lib/workorder.js +101 -0
  163. package/dist/cli/lib/workorder.js.map +1 -0
  164. package/dist/config.d.ts +221 -0
  165. package/dist/config.d.ts.map +1 -0
  166. package/dist/config.js +431 -0
  167. package/dist/config.js.map +1 -0
  168. package/dist/context.d.ts +88 -0
  169. package/dist/context.d.ts.map +1 -0
  170. package/dist/context.js +9 -0
  171. package/dist/context.js.map +1 -0
  172. package/dist/contracts/api.d.ts +3367 -0
  173. package/dist/contracts/api.d.ts.map +1 -0
  174. package/dist/contracts/api.js +1303 -0
  175. package/dist/contracts/api.js.map +1 -0
  176. package/dist/contracts/capabilities.d.ts +126 -0
  177. package/dist/contracts/capabilities.d.ts.map +1 -0
  178. package/dist/contracts/capabilities.js +164 -0
  179. package/dist/contracts/capabilities.js.map +1 -0
  180. package/dist/contracts/claim-source.d.ts +98 -0
  181. package/dist/contracts/claim-source.d.ts.map +1 -0
  182. package/dist/contracts/claim-source.js +17 -0
  183. package/dist/contracts/claim-source.js.map +1 -0
  184. package/dist/contracts/errors.d.ts +150 -0
  185. package/dist/contracts/errors.d.ts.map +1 -0
  186. package/dist/contracts/errors.js +121 -0
  187. package/dist/contracts/errors.js.map +1 -0
  188. package/dist/contracts/index.d.ts +15 -0
  189. package/dist/contracts/index.d.ts.map +1 -0
  190. package/dist/contracts/index.js +11 -0
  191. package/dist/contracts/index.js.map +1 -0
  192. package/dist/contracts/payment-gate.d.ts +129 -0
  193. package/dist/contracts/payment-gate.d.ts.map +1 -0
  194. package/dist/contracts/payment-gate.js +24 -0
  195. package/dist/contracts/payment-gate.js.map +1 -0
  196. package/dist/contracts/start-api-claim.d.ts +90 -0
  197. package/dist/contracts/start-api-claim.d.ts.map +1 -0
  198. package/dist/contracts/start-api-claim.js +22 -0
  199. package/dist/contracts/start-api-claim.js.map +1 -0
  200. package/dist/contracts/webhooks.d.ts +1487 -0
  201. package/dist/contracts/webhooks.d.ts.map +1 -0
  202. package/dist/contracts/webhooks.js +71 -0
  203. package/dist/contracts/webhooks.js.map +1 -0
  204. package/dist/contracts/work-order.d.ts +29 -0
  205. package/dist/contracts/work-order.d.ts.map +1 -0
  206. package/dist/contracts/work-order.js +12 -0
  207. package/dist/contracts/work-order.js.map +1 -0
  208. package/dist/cron/ai-cost.d.ts +29 -0
  209. package/dist/cron/ai-cost.d.ts.map +1 -0
  210. package/dist/cron/ai-cost.js +80 -0
  211. package/dist/cron/ai-cost.js.map +1 -0
  212. package/dist/cron/api-lane.d.ts +39 -0
  213. package/dist/cron/api-lane.d.ts.map +1 -0
  214. package/dist/cron/api-lane.js +203 -0
  215. package/dist/cron/api-lane.js.map +1 -0
  216. package/dist/cron/backlog.d.ts +50 -0
  217. package/dist/cron/backlog.d.ts.map +1 -0
  218. package/dist/cron/backlog.js +115 -0
  219. package/dist/cron/backlog.js.map +1 -0
  220. package/dist/cron/billing.d.ts +11 -0
  221. package/dist/cron/billing.d.ts.map +1 -0
  222. package/dist/cron/billing.js +69 -0
  223. package/dist/cron/billing.js.map +1 -0
  224. package/dist/cron/collection.d.ts +50 -0
  225. package/dist/cron/collection.d.ts.map +1 -0
  226. package/dist/cron/collection.js +118 -0
  227. package/dist/cron/collection.js.map +1 -0
  228. package/dist/cron/funding.d.ts +16 -0
  229. package/dist/cron/funding.d.ts.map +1 -0
  230. package/dist/cron/funding.js +262 -0
  231. package/dist/cron/funding.js.map +1 -0
  232. package/dist/cron/payer-billing.d.ts +38 -0
  233. package/dist/cron/payer-billing.d.ts.map +1 -0
  234. package/dist/cron/payer-billing.js +119 -0
  235. package/dist/cron/payer-billing.js.map +1 -0
  236. package/dist/cron/scan.d.ts +43 -0
  237. package/dist/cron/scan.d.ts.map +1 -0
  238. package/dist/cron/scan.js +250 -0
  239. package/dist/cron/scan.js.map +1 -0
  240. package/dist/db/client.d.ts +14 -0
  241. package/dist/db/client.d.ts.map +1 -0
  242. package/dist/db/client.js +14 -0
  243. package/dist/db/client.js.map +1 -0
  244. package/dist/db/index.d.ts +7 -0
  245. package/dist/db/index.d.ts.map +1 -0
  246. package/dist/db/index.js +7 -0
  247. package/dist/db/index.js.map +1 -0
  248. package/dist/db/schema.d.ts +6738 -0
  249. package/dist/db/schema.d.ts.map +1 -0
  250. package/dist/db/schema.js +1433 -0
  251. package/dist/db/schema.js.map +1 -0
  252. package/dist/define-oracle.d.ts +32 -0
  253. package/dist/define-oracle.d.ts.map +1 -0
  254. package/dist/define-oracle.js +80 -0
  255. package/dist/define-oracle.js.map +1 -0
  256. package/dist/dev-oracle.d.ts +9 -0
  257. package/dist/dev-oracle.d.ts.map +1 -0
  258. package/dist/dev-oracle.js +19 -0
  259. package/dist/dev-oracle.js.map +1 -0
  260. package/dist/dev-worker.d.ts +10 -0
  261. package/dist/dev-worker.d.ts.map +1 -0
  262. package/dist/dev-worker.js +12 -0
  263. package/dist/dev-worker.js.map +1 -0
  264. package/dist/env.d.ts +146 -0
  265. package/dist/env.d.ts.map +1 -0
  266. package/dist/env.js +10 -0
  267. package/dist/env.js.map +1 -0
  268. package/dist/facts/exif.d.ts +43 -0
  269. package/dist/facts/exif.d.ts.map +1 -0
  270. package/dist/facts/exif.js +224 -0
  271. package/dist/facts/exif.js.map +1 -0
  272. package/dist/facts/fact.d.ts +62 -0
  273. package/dist/facts/fact.d.ts.map +1 -0
  274. package/dist/facts/fact.js +105 -0
  275. package/dist/facts/fact.js.map +1 -0
  276. package/dist/facts/geo.d.ts +55 -0
  277. package/dist/facts/geo.d.ts.map +1 -0
  278. package/dist/facts/geo.js +267 -0
  279. package/dist/facts/geo.js.map +1 -0
  280. package/dist/facts/index.d.ts +31 -0
  281. package/dist/facts/index.d.ts.map +1 -0
  282. package/dist/facts/index.js +27 -0
  283. package/dist/facts/index.js.map +1 -0
  284. package/dist/facts/linked-doc.d.ts +60 -0
  285. package/dist/facts/linked-doc.d.ts.map +1 -0
  286. package/dist/facts/linked-doc.js +101 -0
  287. package/dist/facts/linked-doc.js.map +1 -0
  288. package/dist/facts/types.d.ts +278 -0
  289. package/dist/facts/types.d.ts.map +1 -0
  290. package/dist/facts/types.js +19 -0
  291. package/dist/facts/types.js.map +1 -0
  292. package/dist/index.d.ts +25 -0
  293. package/dist/index.d.ts.map +1 -0
  294. package/dist/index.js +19 -0
  295. package/dist/index.js.map +1 -0
  296. package/dist/lib/api-lane/accept.d.ts +29 -0
  297. package/dist/lib/api-lane/accept.d.ts.map +1 -0
  298. package/dist/lib/api-lane/accept.js +218 -0
  299. package/dist/lib/api-lane/accept.js.map +1 -0
  300. package/dist/lib/api-lane/claim-list.d.ts +44 -0
  301. package/dist/lib/api-lane/claim-list.d.ts.map +1 -0
  302. package/dist/lib/api-lane/claim-list.js +92 -0
  303. package/dist/lib/api-lane/claim-list.js.map +1 -0
  304. package/dist/lib/api-lane/company.d.ts +56 -0
  305. package/dist/lib/api-lane/company.d.ts.map +1 -0
  306. package/dist/lib/api-lane/company.js +103 -0
  307. package/dist/lib/api-lane/company.js.map +1 -0
  308. package/dist/lib/api-lane/events.d.ts +21 -0
  309. package/dist/lib/api-lane/events.d.ts.map +1 -0
  310. package/dist/lib/api-lane/events.js +111 -0
  311. package/dist/lib/api-lane/events.js.map +1 -0
  312. package/dist/lib/api-lane/ids.d.ts +39 -0
  313. package/dist/lib/api-lane/ids.d.ts.map +1 -0
  314. package/dist/lib/api-lane/ids.js +78 -0
  315. package/dist/lib/api-lane/ids.js.map +1 -0
  316. package/dist/lib/api-lane/index.d.ts +16 -0
  317. package/dist/lib/api-lane/index.d.ts.map +1 -0
  318. package/dist/lib/api-lane/index.js +16 -0
  319. package/dist/lib/api-lane/index.js.map +1 -0
  320. package/dist/lib/api-lane/linked.d.ts +264 -0
  321. package/dist/lib/api-lane/linked.d.ts.map +1 -0
  322. package/dist/lib/api-lane/linked.js +272 -0
  323. package/dist/lib/api-lane/linked.js.map +1 -0
  324. package/dist/lib/api-lane/media-type.d.ts +15 -0
  325. package/dist/lib/api-lane/media-type.d.ts.map +1 -0
  326. package/dist/lib/api-lane/media-type.js +73 -0
  327. package/dist/lib/api-lane/media-type.js.map +1 -0
  328. package/dist/lib/api-lane/member-emails.d.ts +83 -0
  329. package/dist/lib/api-lane/member-emails.d.ts.map +1 -0
  330. package/dist/lib/api-lane/member-emails.js +213 -0
  331. package/dist/lib/api-lane/member-emails.js.map +1 -0
  332. package/dist/lib/api-lane/members.d.ts +42 -0
  333. package/dist/lib/api-lane/members.d.ts.map +1 -0
  334. package/dist/lib/api-lane/members.js +75 -0
  335. package/dist/lib/api-lane/members.js.map +1 -0
  336. package/dist/lib/api-lane/notify.d.ts +47 -0
  337. package/dist/lib/api-lane/notify.d.ts.map +1 -0
  338. package/dist/lib/api-lane/notify.js +240 -0
  339. package/dist/lib/api-lane/notify.js.map +1 -0
  340. package/dist/lib/api-lane/pause.d.ts +41 -0
  341. package/dist/lib/api-lane/pause.d.ts.map +1 -0
  342. package/dist/lib/api-lane/pause.js +155 -0
  343. package/dist/lib/api-lane/pause.js.map +1 -0
  344. package/dist/lib/api-lane/record.d.ts +76 -0
  345. package/dist/lib/api-lane/record.d.ts.map +1 -0
  346. package/dist/lib/api-lane/record.js +337 -0
  347. package/dist/lib/api-lane/record.js.map +1 -0
  348. package/dist/lib/api-lane/retention.d.ts +43 -0
  349. package/dist/lib/api-lane/retention.d.ts.map +1 -0
  350. package/dist/lib/api-lane/retention.js +176 -0
  351. package/dist/lib/api-lane/retention.js.map +1 -0
  352. package/dist/lib/api-lane/review.d.ts +76 -0
  353. package/dist/lib/api-lane/review.d.ts.map +1 -0
  354. package/dist/lib/api-lane/review.js +355 -0
  355. package/dist/lib/api-lane/review.js.map +1 -0
  356. package/dist/lib/api-lane/reviewers.d.ts +109 -0
  357. package/dist/lib/api-lane/reviewers.d.ts.map +1 -0
  358. package/dist/lib/api-lane/reviewers.js +755 -0
  359. package/dist/lib/api-lane/reviewers.js.map +1 -0
  360. package/dist/lib/api-lane/source-delete.d.ts +16 -0
  361. package/dist/lib/api-lane/source-delete.d.ts.map +1 -0
  362. package/dist/lib/api-lane/source-delete.js +50 -0
  363. package/dist/lib/api-lane/source-delete.js.map +1 -0
  364. package/dist/lib/api-lane/source.d.ts +19 -0
  365. package/dist/lib/api-lane/source.d.ts.map +1 -0
  366. package/dist/lib/api-lane/source.js +226 -0
  367. package/dist/lib/api-lane/source.js.map +1 -0
  368. package/dist/lib/api-lane/submitter.d.ts +22 -0
  369. package/dist/lib/api-lane/submitter.d.ts.map +1 -0
  370. package/dist/lib/api-lane/submitter.js +34 -0
  371. package/dist/lib/api-lane/submitter.js.map +1 -0
  372. package/dist/lib/api-lane/timeline.d.ts +20 -0
  373. package/dist/lib/api-lane/timeline.d.ts.map +1 -0
  374. package/dist/lib/api-lane/timeline.js +23 -0
  375. package/dist/lib/api-lane/timeline.js.map +1 -0
  376. package/dist/lib/api-lane/webhook-crypto.d.ts +44 -0
  377. package/dist/lib/api-lane/webhook-crypto.d.ts.map +1 -0
  378. package/dist/lib/api-lane/webhook-crypto.js +103 -0
  379. package/dist/lib/api-lane/webhook-crypto.js.map +1 -0
  380. package/dist/lib/api-lane/webhooks.d.ts +117 -0
  381. package/dist/lib/api-lane/webhooks.d.ts.map +1 -0
  382. package/dist/lib/api-lane/webhooks.js +503 -0
  383. package/dist/lib/api-lane/webhooks.js.map +1 -0
  384. package/dist/lib/archive.d.ts +64 -0
  385. package/dist/lib/archive.d.ts.map +1 -0
  386. package/dist/lib/archive.js +305 -0
  387. package/dist/lib/archive.js.map +1 -0
  388. package/dist/lib/bech32.d.ts +8 -0
  389. package/dist/lib/bech32.d.ts.map +1 -0
  390. package/dist/lib/bech32.js +39 -0
  391. package/dist/lib/bech32.js.map +1 -0
  392. package/dist/lib/claim-erase.d.ts +22 -0
  393. package/dist/lib/claim-erase.d.ts.map +1 -0
  394. package/dist/lib/claim-erase.js +44 -0
  395. package/dist/lib/claim-erase.js.map +1 -0
  396. package/dist/lib/cursor.d.ts +11 -0
  397. package/dist/lib/cursor.d.ts.map +1 -0
  398. package/dist/lib/cursor.js +20 -0
  399. package/dist/lib/cursor.js.map +1 -0
  400. package/dist/lib/email/send.d.ts +43 -0
  401. package/dist/lib/email/send.d.ts.map +1 -0
  402. package/dist/lib/email/send.js +71 -0
  403. package/dist/lib/email/send.js.map +1 -0
  404. package/dist/lib/email/template.d.ts +217 -0
  405. package/dist/lib/email/template.d.ts.map +1 -0
  406. package/dist/lib/email/template.js +713 -0
  407. package/dist/lib/email/template.js.map +1 -0
  408. package/dist/lib/engine/adr36.d.ts +56 -0
  409. package/dist/lib/engine/adr36.d.ts.map +1 -0
  410. package/dist/lib/engine/adr36.js +76 -0
  411. package/dist/lib/engine/adr36.js.map +1 -0
  412. package/dist/lib/engine/client.d.ts +162 -0
  413. package/dist/lib/engine/client.d.ts.map +1 -0
  414. package/dist/lib/engine/client.js +473 -0
  415. package/dist/lib/engine/client.js.map +1 -0
  416. package/dist/lib/engine/types.d.ts +1356 -0
  417. package/dist/lib/engine/types.d.ts.map +1 -0
  418. package/dist/lib/engine/types.js +562 -0
  419. package/dist/lib/engine/types.js.map +1 -0
  420. package/dist/lib/errors.d.ts +73 -0
  421. package/dist/lib/errors.d.ts.map +1 -0
  422. package/dist/lib/errors.js +96 -0
  423. package/dist/lib/errors.js.map +1 -0
  424. package/dist/lib/facts/builder.d.ts +88 -0
  425. package/dist/lib/facts/builder.d.ts.map +1 -0
  426. package/dist/lib/facts/builder.js +239 -0
  427. package/dist/lib/facts/builder.js.map +1 -0
  428. package/dist/lib/facts/ctx.d.ts +34 -0
  429. package/dist/lib/facts/ctx.d.ts.map +1 -0
  430. package/dist/lib/facts/ctx.js +51 -0
  431. package/dist/lib/facts/ctx.js.map +1 -0
  432. package/dist/lib/facts/expected.d.ts +23 -0
  433. package/dist/lib/facts/expected.d.ts.map +1 -0
  434. package/dist/lib/facts/expected.js +16 -0
  435. package/dist/lib/facts/expected.js.map +1 -0
  436. package/dist/lib/facts/fact-cache.d.ts +469 -0
  437. package/dist/lib/facts/fact-cache.d.ts.map +1 -0
  438. package/dist/lib/facts/fact-cache.js +52 -0
  439. package/dist/lib/facts/fact-cache.js.map +1 -0
  440. package/dist/lib/facts/factset.d.ts +608 -0
  441. package/dist/lib/facts/factset.d.ts.map +1 -0
  442. package/dist/lib/facts/factset.js +226 -0
  443. package/dist/lib/facts/factset.js.map +1 -0
  444. package/dist/lib/facts/producer.d.ts +190 -0
  445. package/dist/lib/facts/producer.d.ts.map +1 -0
  446. package/dist/lib/facts/producer.js +15 -0
  447. package/dist/lib/facts/producer.js.map +1 -0
  448. package/dist/lib/facts/runner.d.ts +111 -0
  449. package/dist/lib/facts/runner.d.ts.map +1 -0
  450. package/dist/lib/facts/runner.js +438 -0
  451. package/dist/lib/facts/runner.js.map +1 -0
  452. package/dist/lib/facts/stock/ai-check.d.ts +100 -0
  453. package/dist/lib/facts/stock/ai-check.d.ts.map +1 -0
  454. package/dist/lib/facts/stock/ai-check.js +526 -0
  455. package/dist/lib/facts/stock/ai-check.js.map +1 -0
  456. package/dist/lib/facts/stock/ai-inputs.d.ts +104 -0
  457. package/dist/lib/facts/stock/ai-inputs.d.ts.map +1 -0
  458. package/dist/lib/facts/stock/ai-inputs.js +332 -0
  459. package/dist/lib/facts/stock/ai-inputs.js.map +1 -0
  460. package/dist/lib/facts/stock/ai-provider.d.ts +105 -0
  461. package/dist/lib/facts/stock/ai-provider.d.ts.map +1 -0
  462. package/dist/lib/facts/stock/ai-provider.js +250 -0
  463. package/dist/lib/facts/stock/ai-provider.js.map +1 -0
  464. package/dist/lib/facts/stock/ai-unique.d.ts +85 -0
  465. package/dist/lib/facts/stock/ai-unique.d.ts.map +1 -0
  466. package/dist/lib/facts/stock/ai-unique.js +208 -0
  467. package/dist/lib/facts/stock/ai-unique.js.map +1 -0
  468. package/dist/lib/facts/stock/archive.d.ts +63 -0
  469. package/dist/lib/facts/stock/archive.d.ts.map +1 -0
  470. package/dist/lib/facts/stock/archive.js +349 -0
  471. package/dist/lib/facts/stock/archive.js.map +1 -0
  472. package/dist/lib/facts/stock/c2pa-trust-list.d.ts +16 -0
  473. package/dist/lib/facts/stock/c2pa-trust-list.d.ts.map +1 -0
  474. package/dist/lib/facts/stock/c2pa-trust-list.js +637 -0
  475. package/dist/lib/facts/stock/c2pa-trust-list.js.map +1 -0
  476. package/dist/lib/facts/stock/c2pa.d.ts +56 -0
  477. package/dist/lib/facts/stock/c2pa.d.ts.map +1 -0
  478. package/dist/lib/facts/stock/c2pa.js +234 -0
  479. package/dist/lib/facts/stock/c2pa.js.map +1 -0
  480. package/dist/lib/facts/stock/call.d.ts +91 -0
  481. package/dist/lib/facts/stock/call.d.ts.map +1 -0
  482. package/dist/lib/facts/stock/call.js +281 -0
  483. package/dist/lib/facts/stock/call.js.map +1 -0
  484. package/dist/lib/facts/stock/capability.d.ts +39 -0
  485. package/dist/lib/facts/stock/capability.d.ts.map +1 -0
  486. package/dist/lib/facts/stock/capability.js +59 -0
  487. package/dist/lib/facts/stock/capability.js.map +1 -0
  488. package/dist/lib/facts/stock/ext-source.d.ts +51 -0
  489. package/dist/lib/facts/stock/ext-source.d.ts.map +1 -0
  490. package/dist/lib/facts/stock/ext-source.js +201 -0
  491. package/dist/lib/facts/stock/ext-source.js.map +1 -0
  492. package/dist/lib/facts/stock/frequency-fact.d.ts +59 -0
  493. package/dist/lib/facts/stock/frequency-fact.d.ts.map +1 -0
  494. package/dist/lib/facts/stock/frequency-fact.js +86 -0
  495. package/dist/lib/facts/stock/frequency-fact.js.map +1 -0
  496. package/dist/lib/facts/stock/frequency.d.ts +32 -0
  497. package/dist/lib/facts/stock/frequency.d.ts.map +1 -0
  498. package/dist/lib/facts/stock/frequency.js +48 -0
  499. package/dist/lib/facts/stock/frequency.js.map +1 -0
  500. package/dist/lib/facts/stock/index.d.ts +27 -0
  501. package/dist/lib/facts/stock/index.d.ts.map +1 -0
  502. package/dist/lib/facts/stock/index.js +28 -0
  503. package/dist/lib/facts/stock/index.js.map +1 -0
  504. package/dist/lib/facts/stock/normalize.d.ts +45 -0
  505. package/dist/lib/facts/stock/normalize.d.ts.map +1 -0
  506. package/dist/lib/facts/stock/normalize.js +135 -0
  507. package/dist/lib/facts/stock/normalize.js.map +1 -0
  508. package/dist/lib/facts/stock/phash.d.ts +55 -0
  509. package/dist/lib/facts/stock/phash.d.ts.map +1 -0
  510. package/dist/lib/facts/stock/phash.js +256 -0
  511. package/dist/lib/facts/stock/phash.js.map +1 -0
  512. package/dist/lib/facts/stock/rule-defaults.d.ts +72 -0
  513. package/dist/lib/facts/stock/rule-defaults.d.ts.map +1 -0
  514. package/dist/lib/facts/stock/rule-defaults.js +55 -0
  515. package/dist/lib/facts/stock/rule-defaults.js.map +1 -0
  516. package/dist/lib/facts/stock/send.d.ts +52 -0
  517. package/dist/lib/facts/stock/send.d.ts.map +1 -0
  518. package/dist/lib/facts/stock/send.js +148 -0
  519. package/dist/lib/facts/stock/send.js.map +1 -0
  520. package/dist/lib/facts/stock/unique-score.d.ts +138 -0
  521. package/dist/lib/facts/stock/unique-score.d.ts.map +1 -0
  522. package/dist/lib/facts/stock/unique-score.js +257 -0
  523. package/dist/lib/facts/stock/unique-score.js.map +1 -0
  524. package/dist/lib/facts/stock/unique-store.d.ts +420 -0
  525. package/dist/lib/facts/stock/unique-store.d.ts.map +1 -0
  526. package/dist/lib/facts/stock/unique-store.js +328 -0
  527. package/dist/lib/facts/stock/unique-store.js.map +1 -0
  528. package/dist/lib/facts/stock/unique.d.ts +35 -0
  529. package/dist/lib/facts/stock/unique.d.ts.map +1 -0
  530. package/dist/lib/facts/stock/unique.js +327 -0
  531. package/dist/lib/facts/stock/unique.js.map +1 -0
  532. package/dist/lib/facts/tools.d.ts +57 -0
  533. package/dist/lib/facts/tools.d.ts.map +1 -0
  534. package/dist/lib/facts/tools.js +325 -0
  535. package/dist/lib/facts/tools.js.map +1 -0
  536. package/dist/lib/facts/validate.d.ts +33 -0
  537. package/dist/lib/facts/validate.d.ts.map +1 -0
  538. package/dist/lib/facts/validate.js +137 -0
  539. package/dist/lib/facts/validate.js.map +1 -0
  540. package/dist/lib/hash.d.ts +44 -0
  541. package/dist/lib/hash.d.ts.map +1 -0
  542. package/dist/lib/hash.js +80 -0
  543. package/dist/lib/hash.js.map +1 -0
  544. package/dist/lib/http/client.d.ts +113 -0
  545. package/dist/lib/http/client.d.ts.map +1 -0
  546. package/dist/lib/http/client.js +330 -0
  547. package/dist/lib/http/client.js.map +1 -0
  548. package/dist/lib/ids.d.ts +17 -0
  549. package/dist/lib/ids.d.ts.map +1 -0
  550. package/dist/lib/ids.js +26 -0
  551. package/dist/lib/ids.js.map +1 -0
  552. package/dist/lib/ixo/attachments.d.ts +116 -0
  553. package/dist/lib/ixo/attachments.d.ts.map +1 -0
  554. package/dist/lib/ixo/attachments.js +290 -0
  555. package/dist/lib/ixo/attachments.js.map +1 -0
  556. package/dist/lib/ixo/authz.d.ts +40 -0
  557. package/dist/lib/ixo/authz.d.ts.map +1 -0
  558. package/dist/lib/ixo/authz.js +113 -0
  559. package/dist/lib/ixo/authz.js.map +1 -0
  560. package/dist/lib/ixo/blocksync/index.d.ts +90 -0
  561. package/dist/lib/ixo/blocksync/index.d.ts.map +1 -0
  562. package/dist/lib/ixo/blocksync/index.js +251 -0
  563. package/dist/lib/ixo/blocksync/index.js.map +1 -0
  564. package/dist/lib/ixo/blocksync/queries.d.ts +15 -0
  565. package/dist/lib/ixo/blocksync/queries.d.ts.map +1 -0
  566. package/dist/lib/ixo/blocksync/queries.js +144 -0
  567. package/dist/lib/ixo/blocksync/queries.js.map +1 -0
  568. package/dist/lib/ixo/blocksync/types.d.ts +184 -0
  569. package/dist/lib/ixo/blocksync/types.d.ts.map +1 -0
  570. package/dist/lib/ixo/blocksync/types.js +108 -0
  571. package/dist/lib/ixo/blocksync/types.js.map +1 -0
  572. package/dist/lib/ixo/cellnode.d.ts +35 -0
  573. package/dist/lib/ixo/cellnode.d.ts.map +1 -0
  574. package/dist/lib/ixo/cellnode.js +43 -0
  575. package/dist/lib/ixo/cellnode.js.map +1 -0
  576. package/dist/lib/ixo/chain-write.d.ts +130 -0
  577. package/dist/lib/ixo/chain-write.d.ts.map +1 -0
  578. package/dist/lib/ixo/chain-write.js +460 -0
  579. package/dist/lib/ixo/chain-write.js.map +1 -0
  580. package/dist/lib/ixo/chain.d.ts +1046 -0
  581. package/dist/lib/ixo/chain.d.ts.map +1 -0
  582. package/dist/lib/ixo/chain.js +279 -0
  583. package/dist/lib/ixo/chain.js.map +1 -0
  584. package/dist/lib/ixo/claim-bot.d.ts +49 -0
  585. package/dist/lib/ixo/claim-bot.d.ts.map +1 -0
  586. package/dist/lib/ixo/claim-bot.js +141 -0
  587. package/dist/lib/ixo/claim-bot.js.map +1 -0
  588. package/dist/lib/ixo/delegation-verify.d.ts +44 -0
  589. package/dist/lib/ixo/delegation-verify.d.ts.map +1 -0
  590. package/dist/lib/ixo/delegation-verify.js +101 -0
  591. package/dist/lib/ixo/delegation-verify.js.map +1 -0
  592. package/dist/lib/ixo/did.d.ts +13 -0
  593. package/dist/lib/ixo/did.d.ts.map +1 -0
  594. package/dist/lib/ixo/did.js +85 -0
  595. package/dist/lib/ixo/did.js.map +1 -0
  596. package/dist/lib/ixo/grant-chain.d.ts +55 -0
  597. package/dist/lib/ixo/grant-chain.d.ts.map +1 -0
  598. package/dist/lib/ixo/grant-chain.js +185 -0
  599. package/dist/lib/ixo/grant-chain.js.map +1 -0
  600. package/dist/lib/ixo/identity.d.ts +32 -0
  601. package/dist/lib/ixo/identity.d.ts.map +1 -0
  602. package/dist/lib/ixo/identity.js +104 -0
  603. package/dist/lib/ixo/identity.js.map +1 -0
  604. package/dist/lib/ixo/index.d.ts +118 -0
  605. package/dist/lib/ixo/index.d.ts.map +1 -0
  606. package/dist/lib/ixo/index.js +395 -0
  607. package/dist/lib/ixo/index.js.map +1 -0
  608. package/dist/lib/ixo/payment.d.ts +104 -0
  609. package/dist/lib/ixo/payment.d.ts.map +1 -0
  610. package/dist/lib/ixo/payment.js +136 -0
  611. package/dist/lib/ixo/payment.js.map +1 -0
  612. package/dist/lib/ixo/ucan-store.d.ts +30 -0
  613. package/dist/lib/ixo/ucan-store.d.ts.map +1 -0
  614. package/dist/lib/ixo/ucan-store.js +134 -0
  615. package/dist/lib/ixo/ucan-store.js.map +1 -0
  616. package/dist/lib/ixo/ucan.d.ts +96 -0
  617. package/dist/lib/ixo/ucan.d.ts.map +1 -0
  618. package/dist/lib/ixo/ucan.js +216 -0
  619. package/dist/lib/ixo/ucan.js.map +1 -0
  620. package/dist/lib/ixo/verified-read.d.ts +43 -0
  621. package/dist/lib/ixo/verified-read.d.ts.map +1 -0
  622. package/dist/lib/ixo/verified-read.js +73 -0
  623. package/dist/lib/ixo/verified-read.js.map +1 -0
  624. package/dist/lib/ixo/vfs.d.ts +27 -0
  625. package/dist/lib/ixo/vfs.d.ts.map +1 -0
  626. package/dist/lib/ixo/vfs.js +42 -0
  627. package/dist/lib/ixo/vfs.js.map +1 -0
  628. package/dist/lib/ixo/web-did.d.ts +7 -0
  629. package/dist/lib/ixo/web-did.d.ts.map +1 -0
  630. package/dist/lib/ixo/web-did.js +9 -0
  631. package/dist/lib/ixo/web-did.js.map +1 -0
  632. package/dist/lib/logger.d.ts +34 -0
  633. package/dist/lib/logger.d.ts.map +1 -0
  634. package/dist/lib/logger.js +126 -0
  635. package/dist/lib/logger.js.map +1 -0
  636. package/dist/lib/matrix/api.d.ts +57 -0
  637. package/dist/lib/matrix/api.d.ts.map +1 -0
  638. package/dist/lib/matrix/api.js +58 -0
  639. package/dist/lib/matrix/api.js.map +1 -0
  640. package/dist/lib/matrix/client.d.ts +41 -0
  641. package/dist/lib/matrix/client.d.ts.map +1 -0
  642. package/dist/lib/matrix/client.js +104 -0
  643. package/dist/lib/matrix/client.js.map +1 -0
  644. package/dist/lib/matrix/deliver.d.ts +224 -0
  645. package/dist/lib/matrix/deliver.d.ts.map +1 -0
  646. package/dist/lib/matrix/deliver.js +788 -0
  647. package/dist/lib/matrix/deliver.js.map +1 -0
  648. package/dist/lib/matrix/events.d.ts +114 -0
  649. package/dist/lib/matrix/events.d.ts.map +1 -0
  650. package/dist/lib/matrix/events.js +143 -0
  651. package/dist/lib/matrix/events.js.map +1 -0
  652. package/dist/lib/matrix/mxid.d.ts +24 -0
  653. package/dist/lib/matrix/mxid.d.ts.map +1 -0
  654. package/dist/lib/matrix/mxid.js +47 -0
  655. package/dist/lib/matrix/mxid.js.map +1 -0
  656. package/dist/lib/metrics.d.ts +105 -0
  657. package/dist/lib/metrics.d.ts.map +1 -0
  658. package/dist/lib/metrics.js +204 -0
  659. package/dist/lib/metrics.js.map +1 -0
  660. package/dist/lib/oauth/approvals.d.ts +160 -0
  661. package/dist/lib/oauth/approvals.d.ts.map +1 -0
  662. package/dist/lib/oauth/approvals.js +273 -0
  663. package/dist/lib/oauth/approvals.js.map +1 -0
  664. package/dist/lib/oauth/standing.d.ts +152 -0
  665. package/dist/lib/oauth/standing.d.ts.map +1 -0
  666. package/dist/lib/oauth/standing.js +299 -0
  667. package/dist/lib/oauth/standing.js.map +1 -0
  668. package/dist/lib/oauth/token.d.ts +60 -0
  669. package/dist/lib/oauth/token.d.ts.map +1 -0
  670. package/dist/lib/oauth/token.js +129 -0
  671. package/dist/lib/oauth/token.js.map +1 -0
  672. package/dist/lib/payments/extend.d.ts +42 -0
  673. package/dist/lib/payments/extend.d.ts.map +1 -0
  674. package/dist/lib/payments/extend.js +98 -0
  675. package/dist/lib/payments/extend.js.map +1 -0
  676. package/dist/lib/payments/facilitator.d.ts +220 -0
  677. package/dist/lib/payments/facilitator.d.ts.map +1 -0
  678. package/dist/lib/payments/facilitator.js +451 -0
  679. package/dist/lib/payments/facilitator.js.map +1 -0
  680. package/dist/lib/payments/gate.d.ts +207 -0
  681. package/dist/lib/payments/gate.d.ts.map +1 -0
  682. package/dist/lib/payments/gate.js +481 -0
  683. package/dist/lib/payments/gate.js.map +1 -0
  684. package/dist/lib/payments/header-seal.d.ts +33 -0
  685. package/dist/lib/payments/header-seal.d.ts.map +1 -0
  686. package/dist/lib/payments/header-seal.js +76 -0
  687. package/dist/lib/payments/header-seal.js.map +1 -0
  688. package/dist/lib/payments/index.d.ts +12 -0
  689. package/dist/lib/payments/index.d.ts.map +1 -0
  690. package/dist/lib/payments/index.js +11 -0
  691. package/dist/lib/payments/index.js.map +1 -0
  692. package/dist/lib/payments/price.d.ts +25 -0
  693. package/dist/lib/payments/price.d.ts.map +1 -0
  694. package/dist/lib/payments/price.js +43 -0
  695. package/dist/lib/payments/price.js.map +1 -0
  696. package/dist/lib/payments/reconcile.d.ts +77 -0
  697. package/dist/lib/payments/reconcile.d.ts.map +1 -0
  698. package/dist/lib/payments/reconcile.js +317 -0
  699. package/dist/lib/payments/reconcile.js.map +1 -0
  700. package/dist/lib/payments/refund.d.ts +34 -0
  701. package/dist/lib/payments/refund.d.ts.map +1 -0
  702. package/dist/lib/payments/refund.js +123 -0
  703. package/dist/lib/payments/refund.js.map +1 -0
  704. package/dist/lib/payments/review-billing.d.ts +222 -0
  705. package/dist/lib/payments/review-billing.d.ts.map +1 -0
  706. package/dist/lib/payments/review-billing.js +609 -0
  707. package/dist/lib/payments/review-billing.js.map +1 -0
  708. package/dist/lib/payments/settle.d.ts +44 -0
  709. package/dist/lib/payments/settle.d.ts.map +1 -0
  710. package/dist/lib/payments/settle.js +306 -0
  711. package/dist/lib/payments/settle.js.map +1 -0
  712. package/dist/lib/payments/terms.d.ts +27 -0
  713. package/dist/lib/payments/terms.d.ts.map +1 -0
  714. package/dist/lib/payments/terms.js +21 -0
  715. package/dist/lib/payments/terms.js.map +1 -0
  716. package/dist/lib/payouts.d.ts +20 -0
  717. package/dist/lib/payouts.d.ts.map +1 -0
  718. package/dist/lib/payouts.js +30 -0
  719. package/dist/lib/payouts.js.map +1 -0
  720. package/dist/lib/readiness.d.ts +42 -0
  721. package/dist/lib/readiness.d.ts.map +1 -0
  722. package/dist/lib/readiness.js +130 -0
  723. package/dist/lib/readiness.js.map +1 -0
  724. package/dist/lib/redact.d.ts +23 -0
  725. package/dist/lib/redact.d.ts.map +1 -0
  726. package/dist/lib/redact.js +67 -0
  727. package/dist/lib/redact.js.map +1 -0
  728. package/dist/lib/register.d.ts +85 -0
  729. package/dist/lib/register.d.ts.map +1 -0
  730. package/dist/lib/register.js +390 -0
  731. package/dist/lib/register.js.map +1 -0
  732. package/dist/lib/review/attestation.d.ts +92 -0
  733. package/dist/lib/review/attestation.d.ts.map +1 -0
  734. package/dist/lib/review/attestation.js +264 -0
  735. package/dist/lib/review/attestation.js.map +1 -0
  736. package/dist/lib/review/reviewer-grant.d.ts +146 -0
  737. package/dist/lib/review/reviewer-grant.d.ts.map +1 -0
  738. package/dist/lib/review/reviewer-grant.js +359 -0
  739. package/dist/lib/review/reviewer-grant.js.map +1 -0
  740. package/dist/lib/review/tasks.d.ts +18 -0
  741. package/dist/lib/review/tasks.d.ts.map +1 -0
  742. package/dist/lib/review/tasks.js +36 -0
  743. package/dist/lib/review/tasks.js.map +1 -0
  744. package/dist/lib/sentry.d.ts +89 -0
  745. package/dist/lib/sentry.d.ts.map +1 -0
  746. package/dist/lib/sentry.js +284 -0
  747. package/dist/lib/sentry.js.map +1 -0
  748. package/dist/lib/spans.d.ts +14 -0
  749. package/dist/lib/spans.d.ts.map +1 -0
  750. package/dist/lib/spans.js +28 -0
  751. package/dist/lib/spans.js.map +1 -0
  752. package/dist/lib/tracing.d.ts +31 -0
  753. package/dist/lib/tracing.d.ts.map +1 -0
  754. package/dist/lib/tracing.js +66 -0
  755. package/dist/lib/tracing.js.map +1 -0
  756. package/dist/lib/ttl-cache.d.ts +26 -0
  757. package/dist/lib/ttl-cache.d.ts.map +1 -0
  758. package/dist/lib/ttl-cache.js +61 -0
  759. package/dist/lib/ttl-cache.js.map +1 -0
  760. package/dist/lib/validator.d.ts +19 -0
  761. package/dist/lib/validator.d.ts.map +1 -0
  762. package/dist/lib/validator.js +32 -0
  763. package/dist/lib/validator.js.map +1 -0
  764. package/dist/lib/work-order/form.d.ts +40 -0
  765. package/dist/lib/work-order/form.d.ts.map +1 -0
  766. package/dist/lib/work-order/form.js +316 -0
  767. package/dist/lib/work-order/form.js.map +1 -0
  768. package/dist/lib/work-order/index.d.ts +20 -0
  769. package/dist/lib/work-order/index.d.ts.map +1 -0
  770. package/dist/lib/work-order/index.js +66 -0
  771. package/dist/lib/work-order/index.js.map +1 -0
  772. package/dist/middleware/api/audit.d.ts +9 -0
  773. package/dist/middleware/api/audit.d.ts.map +1 -0
  774. package/dist/middleware/api/audit.js +38 -0
  775. package/dist/middleware/api/audit.js.map +1 -0
  776. package/dist/middleware/api/auth.d.ts +29 -0
  777. package/dist/middleware/api/auth.d.ts.map +1 -0
  778. package/dist/middleware/api/auth.js +105 -0
  779. package/dist/middleware/api/auth.js.map +1 -0
  780. package/dist/middleware/api/console.d.ts +92 -0
  781. package/dist/middleware/api/console.d.ts.map +1 -0
  782. package/dist/middleware/api/console.js +446 -0
  783. package/dist/middleware/api/console.js.map +1 -0
  784. package/dist/middleware/api/errors.d.ts +48 -0
  785. package/dist/middleware/api/errors.d.ts.map +1 -0
  786. package/dist/middleware/api/errors.js +133 -0
  787. package/dist/middleware/api/errors.js.map +1 -0
  788. package/dist/middleware/api/idempotency.d.ts +23 -0
  789. package/dist/middleware/api/idempotency.d.ts.map +1 -0
  790. package/dist/middleware/api/idempotency.js +158 -0
  791. package/dist/middleware/api/idempotency.js.map +1 -0
  792. package/dist/middleware/api/keys.d.ts +14 -0
  793. package/dist/middleware/api/keys.d.ts.map +1 -0
  794. package/dist/middleware/api/keys.js +66 -0
  795. package/dist/middleware/api/keys.js.map +1 -0
  796. package/dist/middleware/api/membership.d.ts +32 -0
  797. package/dist/middleware/api/membership.d.ts.map +1 -0
  798. package/dist/middleware/api/membership.js +48 -0
  799. package/dist/middleware/api/membership.js.map +1 -0
  800. package/dist/middleware/api/oauth.d.ts +28 -0
  801. package/dist/middleware/api/oauth.d.ts.map +1 -0
  802. package/dist/middleware/api/oauth.js +109 -0
  803. package/dist/middleware/api/oauth.js.map +1 -0
  804. package/dist/middleware/api/rate-limit.d.ts +23 -0
  805. package/dist/middleware/api/rate-limit.d.ts.map +1 -0
  806. package/dist/middleware/api/rate-limit.js +70 -0
  807. package/dist/middleware/api/rate-limit.js.map +1 -0
  808. package/dist/middleware/api/types.d.ts +92 -0
  809. package/dist/middleware/api/types.d.ts.map +1 -0
  810. package/dist/middleware/api/types.js +7 -0
  811. package/dist/middleware/api/types.js.map +1 -0
  812. package/dist/middleware/api/wait.d.ts +21 -0
  813. package/dist/middleware/api/wait.d.ts.map +1 -0
  814. package/dist/middleware/api/wait.js +85 -0
  815. package/dist/middleware/api/wait.js.map +1 -0
  816. package/dist/middleware/capability.d.ts +32 -0
  817. package/dist/middleware/capability.d.ts.map +1 -0
  818. package/dist/middleware/capability.js +113 -0
  819. package/dist/middleware/capability.js.map +1 -0
  820. package/dist/middleware/database.d.ts +14 -0
  821. package/dist/middleware/database.d.ts.map +1 -0
  822. package/dist/middleware/database.js +18 -0
  823. package/dist/middleware/database.js.map +1 -0
  824. package/dist/middleware/error-handler.d.ts +13 -0
  825. package/dist/middleware/error-handler.d.ts.map +1 -0
  826. package/dist/middleware/error-handler.js +127 -0
  827. package/dist/middleware/error-handler.js.map +1 -0
  828. package/dist/middleware/observability.d.ts +32 -0
  829. package/dist/middleware/observability.d.ts.map +1 -0
  830. package/dist/middleware/observability.js +134 -0
  831. package/dist/middleware/observability.js.map +1 -0
  832. package/dist/middleware/rate-limit.d.ts +16 -0
  833. package/dist/middleware/rate-limit.d.ts.map +1 -0
  834. package/dist/middleware/rate-limit.js +56 -0
  835. package/dist/middleware/rate-limit.js.map +1 -0
  836. package/dist/middleware/ucan-auth.d.ts +39 -0
  837. package/dist/middleware/ucan-auth.d.ts.map +1 -0
  838. package/dist/middleware/ucan-auth.js +278 -0
  839. package/dist/middleware/ucan-auth.js.map +1 -0
  840. package/dist/networks.d.ts +34 -0
  841. package/dist/networks.d.ts.map +1 -0
  842. package/dist/networks.js +49 -0
  843. package/dist/networks.js.map +1 -0
  844. package/dist/oracle.d.ts +193 -0
  845. package/dist/oracle.d.ts.map +1 -0
  846. package/dist/oracle.js +245 -0
  847. package/dist/oracle.js.map +1 -0
  848. package/dist/routes/health.d.ts +16 -0
  849. package/dist/routes/health.d.ts.map +1 -0
  850. package/dist/routes/health.js +29 -0
  851. package/dist/routes/health.js.map +1 -0
  852. package/dist/routes/v1/admin.d.ts +18 -0
  853. package/dist/routes/v1/admin.d.ts.map +1 -0
  854. package/dist/routes/v1/admin.js +101 -0
  855. package/dist/routes/v1/admin.js.map +1 -0
  856. package/dist/routes/v1/api/admin-claims.d.ts +14 -0
  857. package/dist/routes/v1/api/admin-claims.d.ts.map +1 -0
  858. package/dist/routes/v1/api/admin-claims.js +82 -0
  859. package/dist/routes/v1/api/admin-claims.js.map +1 -0
  860. package/dist/routes/v1/api/admin-facilitator.d.ts +20 -0
  861. package/dist/routes/v1/api/admin-facilitator.d.ts.map +1 -0
  862. package/dist/routes/v1/api/admin-facilitator.js +64 -0
  863. package/dist/routes/v1/api/admin-facilitator.js.map +1 -0
  864. package/dist/routes/v1/api/admin.d.ts +9 -0
  865. package/dist/routes/v1/api/admin.d.ts.map +1 -0
  866. package/dist/routes/v1/api/admin.js +110 -0
  867. package/dist/routes/v1/api/admin.js.map +1 -0
  868. package/dist/routes/v1/api/audit.d.ts +8 -0
  869. package/dist/routes/v1/api/audit.d.ts.map +1 -0
  870. package/dist/routes/v1/api/audit.js +56 -0
  871. package/dist/routes/v1/api/audit.js.map +1 -0
  872. package/dist/routes/v1/api/batch.d.ts +17 -0
  873. package/dist/routes/v1/api/batch.d.ts.map +1 -0
  874. package/dist/routes/v1/api/batch.js +192 -0
  875. package/dist/routes/v1/api/batch.js.map +1 -0
  876. package/dist/routes/v1/api/claims-read.d.ts +43 -0
  877. package/dist/routes/v1/api/claims-read.d.ts.map +1 -0
  878. package/dist/routes/v1/api/claims-read.js +123 -0
  879. package/dist/routes/v1/api/claims-read.js.map +1 -0
  880. package/dist/routes/v1/api/claims.d.ts +131 -0
  881. package/dist/routes/v1/api/claims.d.ts.map +1 -0
  882. package/dist/routes/v1/api/claims.js +407 -0
  883. package/dist/routes/v1/api/claims.js.map +1 -0
  884. package/dist/routes/v1/api/collections-link.d.ts +14 -0
  885. package/dist/routes/v1/api/collections-link.d.ts.map +1 -0
  886. package/dist/routes/v1/api/collections-link.js +97 -0
  887. package/dist/routes/v1/api/collections-link.js.map +1 -0
  888. package/dist/routes/v1/api/export.d.ts +48 -0
  889. package/dist/routes/v1/api/export.d.ts.map +1 -0
  890. package/dist/routes/v1/api/export.js +251 -0
  891. package/dist/routes/v1/api/export.js.map +1 -0
  892. package/dist/routes/v1/api/index.d.ts +16 -0
  893. package/dist/routes/v1/api/index.d.ts.map +1 -0
  894. package/dist/routes/v1/api/index.js +68 -0
  895. package/dist/routes/v1/api/index.js.map +1 -0
  896. package/dist/routes/v1/api/keys.d.ts +9 -0
  897. package/dist/routes/v1/api/keys.d.ts.map +1 -0
  898. package/dist/routes/v1/api/keys.js +95 -0
  899. package/dist/routes/v1/api/keys.js.map +1 -0
  900. package/dist/routes/v1/api/me.d.ts +21 -0
  901. package/dist/routes/v1/api/me.d.ts.map +1 -0
  902. package/dist/routes/v1/api/me.js +122 -0
  903. package/dist/routes/v1/api/me.js.map +1 -0
  904. package/dist/routes/v1/api/members.d.ts +17 -0
  905. package/dist/routes/v1/api/members.d.ts.map +1 -0
  906. package/dist/routes/v1/api/members.js +406 -0
  907. package/dist/routes/v1/api/members.js.map +1 -0
  908. package/dist/routes/v1/api/meta.d.ts +12 -0
  909. package/dist/routes/v1/api/meta.d.ts.map +1 -0
  910. package/dist/routes/v1/api/meta.js +59 -0
  911. package/dist/routes/v1/api/meta.js.map +1 -0
  912. package/dist/routes/v1/api/openapi.gen.d.ts +2 -0
  913. package/dist/routes/v1/api/openapi.gen.d.ts.map +1 -0
  914. package/dist/routes/v1/api/openapi.gen.js +7 -0
  915. package/dist/routes/v1/api/openapi.gen.js.map +1 -0
  916. package/dist/routes/v1/api/protocols.d.ts +22 -0
  917. package/dist/routes/v1/api/protocols.d.ts.map +1 -0
  918. package/dist/routes/v1/api/protocols.js +153 -0
  919. package/dist/routes/v1/api/protocols.js.map +1 -0
  920. package/dist/routes/v1/api/review-billing.d.ts +18 -0
  921. package/dist/routes/v1/api/review-billing.d.ts.map +1 -0
  922. package/dist/routes/v1/api/review-billing.js +196 -0
  923. package/dist/routes/v1/api/review-billing.js.map +1 -0
  924. package/dist/routes/v1/api/review.d.ts +30 -0
  925. package/dist/routes/v1/api/review.d.ts.map +1 -0
  926. package/dist/routes/v1/api/review.js +302 -0
  927. package/dist/routes/v1/api/review.js.map +1 -0
  928. package/dist/routes/v1/api/sources.d.ts +10 -0
  929. package/dist/routes/v1/api/sources.d.ts.map +1 -0
  930. package/dist/routes/v1/api/sources.js +293 -0
  931. package/dist/routes/v1/api/sources.js.map +1 -0
  932. package/dist/routes/v1/api/uploads.d.ts +37 -0
  933. package/dist/routes/v1/api/uploads.d.ts.map +1 -0
  934. package/dist/routes/v1/api/uploads.js +414 -0
  935. package/dist/routes/v1/api/uploads.js.map +1 -0
  936. package/dist/routes/v1/api/views.d.ts +18 -0
  937. package/dist/routes/v1/api/views.d.ts.map +1 -0
  938. package/dist/routes/v1/api/views.js +95 -0
  939. package/dist/routes/v1/api/views.js.map +1 -0
  940. package/dist/routes/v1/api/webhooks.d.ts +14 -0
  941. package/dist/routes/v1/api/webhooks.d.ts.map +1 -0
  942. package/dist/routes/v1/api/webhooks.js +282 -0
  943. package/dist/routes/v1/api/webhooks.js.map +1 -0
  944. package/dist/routes/v1/api/work-orders.d.ts +45 -0
  945. package/dist/routes/v1/api/work-orders.d.ts.map +1 -0
  946. package/dist/routes/v1/api/work-orders.js +171 -0
  947. package/dist/routes/v1/api/work-orders.js.map +1 -0
  948. package/dist/routes/v1/claims.d.ts +21 -0
  949. package/dist/routes/v1/claims.d.ts.map +1 -0
  950. package/dist/routes/v1/claims.js +153 -0
  951. package/dist/routes/v1/claims.js.map +1 -0
  952. package/dist/routes/v1/collections.d.ts +45 -0
  953. package/dist/routes/v1/collections.d.ts.map +1 -0
  954. package/dist/routes/v1/collections.js +201 -0
  955. package/dist/routes/v1/collections.js.map +1 -0
  956. package/dist/routes/v1/index.d.ts +13 -0
  957. package/dist/routes/v1/index.d.ts.map +1 -0
  958. package/dist/routes/v1/index.js +41 -0
  959. package/dist/routes/v1/index.js.map +1 -0
  960. package/dist/routes/v1/info.d.ts +14 -0
  961. package/dist/routes/v1/info.d.ts.map +1 -0
  962. package/dist/routes/v1/info.js +75 -0
  963. package/dist/routes/v1/info.js.map +1 -0
  964. package/dist/routes/v1/mcp/index.d.ts +33 -0
  965. package/dist/routes/v1/mcp/index.d.ts.map +1 -0
  966. package/dist/routes/v1/mcp/index.js +252 -0
  967. package/dist/routes/v1/mcp/index.js.map +1 -0
  968. package/dist/routes/v1/mcp/oauth.d.ts +27 -0
  969. package/dist/routes/v1/mcp/oauth.d.ts.map +1 -0
  970. package/dist/routes/v1/mcp/oauth.js +500 -0
  971. package/dist/routes/v1/mcp/oauth.js.map +1 -0
  972. package/dist/routes/v1/mcp/tools.d.ts +71 -0
  973. package/dist/routes/v1/mcp/tools.d.ts.map +1 -0
  974. package/dist/routes/v1/mcp/tools.js +432 -0
  975. package/dist/routes/v1/mcp/tools.js.map +1 -0
  976. package/dist/routes/v1/mcp/ui.d.ts +45 -0
  977. package/dist/routes/v1/mcp/ui.d.ts.map +1 -0
  978. package/dist/routes/v1/mcp/ui.js +454 -0
  979. package/dist/routes/v1/mcp/ui.js.map +1 -0
  980. package/dist/routes/v1/oauth.d.ts +29 -0
  981. package/dist/routes/v1/oauth.d.ts.map +1 -0
  982. package/dist/routes/v1/oauth.js +256 -0
  983. package/dist/routes/v1/oauth.js.map +1 -0
  984. package/dist/routes/v1/reviewers.d.ts +22 -0
  985. package/dist/routes/v1/reviewers.d.ts.map +1 -0
  986. package/dist/routes/v1/reviewers.js +165 -0
  987. package/dist/routes/v1/reviewers.js.map +1 -0
  988. package/dist/routes/v1/reviews.d.ts +38 -0
  989. package/dist/routes/v1/reviews.d.ts.map +1 -0
  990. package/dist/routes/v1/reviews.js +234 -0
  991. package/dist/routes/v1/reviews.js.map +1 -0
  992. package/dist/routes/v1/shared.d.ts +586 -0
  993. package/dist/routes/v1/shared.d.ts.map +1 -0
  994. package/dist/routes/v1/shared.js +229 -0
  995. package/dist/routes/v1/shared.js.map +1 -0
  996. package/dist/routes/well-known.d.ts +15 -0
  997. package/dist/routes/well-known.d.ts.map +1 -0
  998. package/dist/routes/well-known.js +35 -0
  999. package/dist/routes/well-known.js.map +1 -0
  1000. package/dist/runtime.d.ts +100 -0
  1001. package/dist/runtime.d.ts.map +1 -0
  1002. package/dist/runtime.js +144 -0
  1003. package/dist/runtime.js.map +1 -0
  1004. package/dist/testing/fake-tools.d.ts +104 -0
  1005. package/dist/testing/fake-tools.d.ts.map +1 -0
  1006. package/dist/testing/fake-tools.js +213 -0
  1007. package/dist/testing/fake-tools.js.map +1 -0
  1008. package/dist/testing/index.d.ts +54 -0
  1009. package/dist/testing/index.d.ts.map +1 -0
  1010. package/dist/testing/index.js +104 -0
  1011. package/dist/testing/index.js.map +1 -0
  1012. package/dist/testing/samples.d.ts +70 -0
  1013. package/dist/testing/samples.d.ts.map +1 -0
  1014. package/dist/testing/samples.js +92 -0
  1015. package/dist/testing/samples.js.map +1 -0
  1016. package/dist/workflows/claim-ingest.d.ts +250 -0
  1017. package/dist/workflows/claim-ingest.d.ts.map +1 -0
  1018. package/dist/workflows/claim-ingest.js +1020 -0
  1019. package/dist/workflows/claim-ingest.js.map +1 -0
  1020. package/dist/workflows/claim-source.d.ts +25 -0
  1021. package/dist/workflows/claim-source.d.ts.map +1 -0
  1022. package/dist/workflows/claim-source.js +107 -0
  1023. package/dist/workflows/claim-source.js.map +1 -0
  1024. package/dist/workflows/review-complete.d.ts +82 -0
  1025. package/dist/workflows/review-complete.d.ts.map +1 -0
  1026. package/dist/workflows/review-complete.js +455 -0
  1027. package/dist/workflows/review-complete.js.map +1 -0
  1028. package/dist/workflows/steps.d.ts +125 -0
  1029. package/dist/workflows/steps.d.ts.map +1 -0
  1030. package/dist/workflows/steps.js +325 -0
  1031. package/dist/workflows/steps.js.map +1 -0
  1032. package/docs/api.md +2595 -0
  1033. package/docs/guide.md +1525 -0
  1034. package/migrations/0000_init.sql +108 -0
  1035. package/migrations/0001_review_attestation.sql +1 -0
  1036. package/migrations/0002_decisions_facts.sql +1 -0
  1037. package/migrations/0003_api_lane.sql +171 -0
  1038. package/migrations/0004_api_v1_1.sql +19 -0
  1039. package/migrations/0005_member_notify.sql +3 -0
  1040. package/migrations/0006_source_collections.sql +16 -0
  1041. package/migrations/0007_api_settlement.sql +27 -0
  1042. package/migrations/0008_review_extend.sql +1 -0
  1043. package/migrations/0009_card_refunds.sql +4 -0
  1044. package/migrations/0010_even_scorpion.sql +16 -0
  1045. package/migrations/0011_mcp_oauth.sql +27 -0
  1046. package/migrations/0012_mcp_standing.sql +21 -0
  1047. package/migrations/0013_unknown_krista_starr.sql +2 -0
  1048. package/migrations/0014_last_carnage.sql +14 -0
  1049. package/migrations/0015_company_authority.sql +26 -0
  1050. package/migrations/0016_owner_only_links.sql +6 -0
  1051. package/migrations/0017_organisations.sql +35 -0
  1052. package/migrations/0018_remove_app_signed.sql +4 -0
  1053. package/migrations/0019_submitted_via.sql +2 -0
  1054. package/migrations/0020_drop_decision_to_pay.sql +4 -0
  1055. package/migrations/0021_manual_erase.sql +25 -0
  1056. package/migrations/0022_errored_facts.sql +1 -0
  1057. package/migrations/0023_review_billing.sql +20 -0
  1058. package/migrations/0024_pending_task_details.sql +1 -0
  1059. package/migrations/meta/0000_snapshot.json +707 -0
  1060. package/migrations/meta/0001_snapshot.json +714 -0
  1061. package/migrations/meta/0002_snapshot.json +721 -0
  1062. package/migrations/meta/0003_snapshot.json +1845 -0
  1063. package/migrations/meta/0004_snapshot.json +1969 -0
  1064. package/migrations/meta/0005_snapshot.json +1991 -0
  1065. package/migrations/meta/0006_snapshot.json +2097 -0
  1066. package/migrations/meta/0007_snapshot.json +2128 -0
  1067. package/migrations/meta/0008_snapshot.json +2133 -0
  1068. package/migrations/meta/0009_snapshot.json +2161 -0
  1069. package/migrations/meta/0010_snapshot.json +2265 -0
  1070. package/migrations/meta/0011_snapshot.json +2448 -0
  1071. package/migrations/meta/0012_snapshot.json +2577 -0
  1072. package/migrations/meta/0013_snapshot.json +2594 -0
  1073. package/migrations/meta/0014_snapshot.json +2602 -0
  1074. package/migrations/meta/0015_snapshot.json +2778 -0
  1075. package/migrations/meta/0016_snapshot.json +2778 -0
  1076. package/migrations/meta/0017_snapshot.json +2792 -0
  1077. package/migrations/meta/0018_snapshot.json +2778 -0
  1078. package/migrations/meta/0019_snapshot.json +2792 -0
  1079. package/migrations/meta/0020_snapshot.json +2785 -0
  1080. package/migrations/meta/0021_snapshot.json +2762 -0
  1081. package/migrations/meta/0022_snapshot.json +2769 -0
  1082. package/migrations/meta/0023_snapshot.json +2900 -0
  1083. package/migrations/meta/0024_snapshot.json +2907 -0
  1084. package/migrations/meta/_journal.json +181 -0
  1085. package/openapi/decision-api.yaml +12204 -0
  1086. package/package.json +123 -4
  1087. package/skills/README.md +19 -0
  1088. package/skills/add-openhands-support/SKILL.md +113 -0
  1089. package/skills/create-collection/SKILL.md +199 -0
  1090. package/skills/create-collection/reference/claims-stack.md +106 -0
  1091. package/skills/create-collection/reference/collection.md +111 -0
  1092. package/skills/create-oracle/SKILL.md +400 -0
  1093. package/skills/create-rubric/SKILL.md +130 -0
  1094. package/skills/create-rubric/reference/format-cheatsheet.md +186 -0
  1095. package/skills/create-rubric/templates/claim-form.example.json +60 -0
  1096. package/skills/decision-api-client/SKILL.md +236 -0
  1097. package/skills/decision-api-oracle/SKILL.md +336 -0
  1098. package/skills/publish-protocol/SKILL.md +151 -0
  1099. package/skills/publish-protocol/reference/publishing.md +116 -0
  1100. package/skills/reference/engine-contract.md +222 -0
  1101. package/skills/write-facts/SKILL.md +221 -0
  1102. package/skills/write-facts/reference/examples/http-vendor.ts +88 -0
  1103. package/skills/write-facts/reference/examples/price-matches-agreement.ts +67 -0
  1104. package/skills/write-facts/reference/facts-api.md +232 -0
  1105. package/templates/project/.dev.vars.example +93 -0
  1106. package/templates/project/.editorconfig +9 -0
  1107. package/templates/project/CLAUDE.md +45 -0
  1108. package/templates/project/README.md +199 -0
  1109. package/templates/project/biome.json +37 -0
  1110. package/templates/project/gitignore +29 -0
  1111. package/templates/project/package.json +32 -0
  1112. package/templates/project/src/facts/index.ts +19 -0
  1113. package/templates/project/src/index.ts +25 -0
  1114. package/templates/project/test/api-lane.test.ts +57 -0
  1115. package/templates/project/test/facts.test.ts +14 -0
  1116. package/templates/project/tsconfig.json +25 -0
  1117. package/templates/project/vitest.config.ts +13 -0
  1118. package/templates/project/wrangler.jsonc +446 -0
package/docs/api.md ADDED
@@ -0,0 +1,2595 @@
1
+ # `@ixo/decision-oracle` — API reference
2
+
3
+ Every export of the package, what it does, what it defaults to, and what it guarantees. If you are
4
+ starting from zero, read [`guide.md`](./guide.md) first — it is the narrative; this is the lookup.
5
+
6
+ The package is four import paths and one CLI. The root is the **worker**: it pulls the Hono app,
7
+ the Workflow classes and the chain SDK into your bundle, so it is imported once, from
8
+ `src/index.ts`. `./facts` and `./testing` are **node-safe** — no `cloudflare:*`, no wasm, no D1, no
9
+ KV in their import graph — which is why a handler test is plain vitest with no Workers pool.
10
+ `./runtime` is the other worker-only path: what a provider's own route, cron or Workflow calls when
11
+ it needs the kit to do something the lanes do not do by themselves (below).
12
+
13
+ ---
14
+
15
+ ## 1. Import map
16
+
17
+ ### `@ixo/decision-oracle` — worker only
18
+
19
+ ```ts
20
+ import { defineOracle, NETWORKS, networkDefaults } from "@ixo/decision-oracle";
21
+ import type {
22
+ ActContext,
23
+ ActDecision,
24
+ ActFacts,
25
+ ActPolicy,
26
+ Config,
27
+ IxoNetwork,
28
+ OracleBindings,
29
+ OracleConfig,
30
+ OracleEnv,
31
+ OracleVars,
32
+ OracleWorker,
33
+ } from "@ixo/decision-oracle";
34
+ ```
35
+
36
+ | Export | Kind | What it is | Use it when |
37
+ | --- | --- | --- | --- |
38
+ | `defineOracle` | function | The whole runtime surface: validates + registers your config, returns everything the Workers runtime needs | Once, in `src/index.ts` |
39
+ | `OracleConfig` | type | What you pass to `defineOracle` — name, listing, price, policies, `facts`, `archive` | Typing a config you build elsewhere |
40
+ | `OracleWorker` | type | What `defineOracle` returns: `fetch`, `scheduled`, `ClaimIngest`, `ReviewComplete`, `SequenceManagerDO` | Rarely — the three re-exports are structural |
41
+ | `OracleEnv` | type | `OracleBindings & OracleVars` — the runtime contract your `wrangler.jsonc` must satisfy | Typing your own helpers that take `env` |
42
+ | `OracleBindings` | type | Every binding by name (`DB`, `CACHE_KV`, …) | Extending it with your own bindings: `OracleEnv & { MY_KV: KVNamespace }` |
43
+ | `OracleVars` | type | Every var and secret, as wrangler hands them over (strings, `undefined` when unset) | Same |
44
+ | `Config` | type | The **validated** config — the output of the Zod schema, so the eight per-network URLs are `string`, never `string \| undefined` | Typing a function that takes `tools.config` |
45
+ | `NETWORKS` | const | `Record<IxoNetwork, NetworkDefaults>` — the one per-network map | Reading a network's engine/blocksync/portal host without hardcoding it |
46
+ | `networkDefaults` | function | `(network: IxoNetwork) => NetworkDefaults` | Same, by name |
47
+ | `IxoNetwork` | type | `"devnet" \| "testnet" \| "mainnet"` | Typing a network switch |
48
+ | `ActPolicy` | type | `(ctx: ActContext) => Promise<ActDecision> \| ActDecision` — the `policies.act` hook's own signature (`src/act.ts`) | Typing a `policies.act` function you write |
49
+ | `ActContext` | type | Everything the act lane knows about the claim it is about to sign for — every field is documented where `policies.act` is (below) | Destructuring inside your `act` hook |
50
+ | `ActDecision` | type | `{ mode: "sign", amount?, reason? } \| { mode: "hold", reason? } \| { mode: "skip", reason? }` — what the hook tells the lane to do next | Returning from your `act` hook |
51
+ | `ActFacts` | type | `{ ext, ai }` — the frozen facts by name, as sent to the engine, never answer values | Typing `ActContext["facts"]` in a helper |
52
+ | `ArchiveHooks` | type | `{ describe?, answer? }` — the `archive` option's own shape (§2) | Typing the hooks you write |
53
+ | `ArchiveAsk` / `ArchiveDoc` | type | What `archive.describe` is handed: the ask's collection and documents | Reading `#done`, `#pro` inside `describe` |
54
+ | `ArchiveAbout` / `ArchiveKind` | type | `about.json` — the view `describe` returns part of | Returning from `describe` |
55
+ | `ArchiveAnswerView` | type | `{ text?, place? }` — what `archive.answer` returns | Returning from `answer` |
56
+
57
+ ### `@ixo/decision-oracle/facts` — node-safe, what a handler imports
58
+
59
+ ```ts
60
+ import {
61
+ beforeAfter,
62
+ defineFacts,
63
+ fact,
64
+ group,
65
+ haversineMeters,
66
+ located,
67
+ photoFactsFromJpeg,
68
+ linkedDocUrl,
69
+ readAnswer,
70
+ readLinkedDoc,
71
+ RetryLater,
72
+ rootQuestionName,
73
+ withinRadius,
74
+ } from "@ixo/decision-oracle/facts";
75
+ import type {
76
+ AiHandler,
77
+ AiInput,
78
+ AiResult,
79
+ AiSendEntry,
80
+ AiTools,
81
+ ChainTools,
82
+ ClaimIntent,
83
+ ExtHandler,
84
+ ExtInput,
85
+ ExtResult,
86
+ ExtTools,
87
+ FactErrorDraft,
88
+ FactGroup,
89
+ FactInputBase,
90
+ FactsConfig,
91
+ FactsDeclaration,
92
+ FactTools,
93
+ FrequencyHandler,
94
+ FrequencyInput,
95
+ GroupHandler,
96
+ GroupInput,
97
+ GroupResult,
98
+ HttpJsonInit,
99
+ HttpJsonResult,
100
+ LinkedDocRead,
101
+ LinkedDocRef,
102
+ LinkedResource,
103
+ PhotoFacts,
104
+ ReadLinkedDocOptions,
105
+ UniqueHandler,
106
+ UniqueInput,
107
+ } from "@ixo/decision-oracle/facts";
108
+ ```
109
+
110
+ | Export | Kind | What it is | Use it when |
111
+ | --- | --- | --- | --- |
112
+ | `defineFacts` | function | Normalizes your handler table into a `FactsConfig` | Once, in `src/facts/index.ts` |
113
+ | `group` | function | `group(["a", "b"], handler)` — ONE handler answering several declared sources from one call | Four criteria judged by one model call |
114
+ | `fact` | object | `fact.ok`, `fact.ai.ok`, `fact.error`, `fact.retryLater` — what a handler returns when a bare `true` isn't enough | In every handler that says *why* |
115
+ | `RetryLater` | class | The tagged error `fact.retryLater()` builds | `err instanceof RetryLater` in your own wrapper |
116
+ | `readAnswer` | function | `(answers, "$q.row[*].cell") => unknown` — the `$path` reader the package itself uses, the engine binder's grammar | Reading an answer the rubric's `send` map did not name |
117
+ | `rootQuestionName` | function | `("$deliveryPhoto.0") => "deliveryPhoto"` — the top-level question of a `$ref` | Matching a `$ref` against `attachment.question` |
118
+ | `ExtHandler` / `AiHandler` / `UniqueHandler` / `FrequencyHandler` / `GroupHandler` | types | The five handler signatures | Typing every handler file |
119
+ | `ExtInput` / `AiInput` / `UniqueInput` / `FrequencyInput` / `GroupInput` / `FactInputBase` | types | What each handler is handed | Destructuring in a helper |
120
+ | `ExtResult` / `AiResult` / `GroupResult` / `FactErrorDraft` | types | What each may hand back | A helper that builds a return value |
121
+ | `FactGroup` | type | What `group(...)` builds — an entry in `defineFacts({ groups })` | A helper that assembles groups |
122
+ | `FactTools` / `ExtTools` / `AiTools` | types | The injected machinery; the lane-typed aliases fix what `stock()` returns | A helper that takes `tools` |
123
+ | `FactsConfig` / `FactsDeclaration` | types | `defineFacts`'s output / input | Building a table programmatically |
124
+ | `HttpJsonInit` / `HttpJsonResult` | types | `tools.http.json`'s options and result | Wrapping `http.json` |
125
+ | `AiSendEntry` | type | One model input: `{ kind: "text", key, value }` or `{ kind: "attachment", key, attachment }` | Re-slicing `inputs` before `ai.judge` |
126
+ | `ChainTools` / `LinkedResource` | types | `tools.chain` and the shape its blocksync read answers with | A helper that takes a chain read's result |
127
+ | `readLinkedDoc` | function | `(resource, opts?) => Promise<LinkedDocRead>` — a chain-anchored document by its `LinkedResource` (`{ serviceEndpoint, proof? }`). Reads the IXO Vault (`https://<network.>vfs.ixo.earth/public/cid/<cid>`), the IXO IPFS gateway (`https://ipfs.gateway.ixo.world/ipfs/<cid>`, or a bare `ipfs:<cid>` / `ipfs://<cid>` resolved through `opts.ipfsGateway`, default IXO's) and Matrix media download URLs. Hashes EXACTLY the bytes served against `proof`: a raw-sha256 CIDv1 (`bafkrei…`) or 64 hex (sha256); no proof ⇒ the CID the URL names. A mismatch is `{ ok: false, reason: "mismatch", retryable: false }` — fail closed, never try another store. A proof of another shape (a dag-pb `bafybei…`) cannot be recomputed: the read is `ok` with `verified: false`. Failures: `unsupported` (`matrix:<cid>` room state, `mxc://`), `unreachable` / `status` (`retryable` on a network error, 408, 429 or 5xx), `too-large` (`opts.maxBytes`, default 2 MiB), `not-json`. `opts.fetch` swaps the transport (default global `fetch`, `opts.timeoutMs` 10 s) | An ask's `#done` / `#payoutTerms`, a `#rub` for its labels, any entity doc an adapter reads — `if (!read.ok) throw read.retryable ? fact.retryLater(read.message) : …` |
128
+ | `linkedDocUrl` | function | `(serviceEndpoint, ipfsGateway?) => string \| null` — the URL `readLinkedDoc` would fetch, or `null` for an address that is no document | Linking to a document without reading it |
129
+ | `LinkedDocRef` / `LinkedDocRead` / `ReadLinkedDocOptions` | types | `readLinkedDoc`'s input, result (`{ ok: true, url, bytes, json, verified }` or `{ ok: false, url, reason, retryable, status?, message }`) and options | Wrapping it |
130
+ | `ClaimIntent` | type | What `tools.chain.claimIntent(...)` answers — the agent's filed commitment, `{ time, transactionHash }`, or `null` when it filed none | A helper that measures delivery from the intent, or a test that builds one |
131
+ | `withinRadius` | handler | Ready-made `ExtHandler` — is the claim within `meters` of a centre? `send: { lat?, lon?, photo?, centerLat, centerLon, meters }`. The point comes from the answer's own `lat`/`lon`, else the `photo` attachment's EXIF GPS, else `capturedAt.photo` / `capturedAt.after` ([geo evidence](#geo-evidence-grades-and-capturedat)). No point from any source is an **error** (the rubric's `onError`), never `false` | Registering a "delivered nearby" source: `ext: { deliveredNearby: withinRadius }` |
132
+ | `located` | handler | Ready-made `ExtHandler` — does the claim carry coordinates at all, from any of the same sources? `send: { lat?, lon?, photo? }`. The one geo check where "none" is `false`: presence is its question | A rubric that only needs "has a location" |
133
+ | `beforeAfter` | handler | Ready-made `ExtHandler` — do two photo attachments show progress, in order and (optionally) nearby? `send: { before, after, meters?, lat?, lon? }`. `after` must be taken at or after `before` (both photos' EXIF, else both `capturedAt` readings); with `meters`, each photo's place (EXIF GPS, else its `capturedAt`, else the answer's `lat`/`lon`) must be within it. `meters` absent needs no GPS at all. A missing photo, time or place is an **error**, never `false` | A before/after delivery-photo gate |
134
+ | `haversineMeters` | function | `(a, b) => metres` — great-circle distance between two `{ lat, lon }` points | Building your own distance check on top of `photoFactsFromJpeg` |
135
+ | `photoFactsFromJpeg` | function | `(bytes) => PhotoFacts` — a JPEG's EXIF GPS point and shutter time (`DateTimeOriginal`, falling back to `DateTime`), or nulls for anything that isn't a JPEG with EXIF | Reading a photo's own coordinates/timestamp outside the three handlers above |
136
+ | `PhotoFacts` | type | `{ point: { lat, lon } \| null; takenAt: string \| null; software: string \| null }` — what `photoFactsFromJpeg` returns (`software` is IFD0 `Software`, verbatim) | Typing a helper that wraps it |
137
+
138
+ #### Geo evidence: grades and `capturedAt`
139
+
140
+ The geo handlers grade every time and place by who reported it, and write the grade into the
141
+ fact's `reason` (so it reaches the trace and the receipt), e.g. `412 m from centre, limit 500 m;
142
+ evidence: camera (photo EXIF)` or `after is 600 s after before, 3 m apart; evidence: time
143
+ browser-reported (capturedAt), place browser-reported (capturedAt.before) / camera (photo EXIF)`:
144
+
145
+ | Grade | Source |
146
+ | --- | --- |
147
+ | `camera` | The photo's own EXIF (GPS, `DateTimeOriginal`) |
148
+ | `browser-reported` | EXIF the capturing app wrote from the browser's reading into a photo that had none (IFD0 `Software` = `openhands-app`), or the claim's per-photo `capturedAt` |
149
+ | `claim` | The answer's own `send.lat` / `send.lon` |
150
+
151
+ `capturedAt` is the supported form field for per-photo capture metadata: an object keyed by the
152
+ photo's role — `before` and `after` for `beforeAfter`, `photo` (else `after`) for `withinRadius` /
153
+ `located` — each `{ at: <ISO instant>, lat?, lon?, accuracy? }`. A stamp with no zone is read as
154
+ UTC; `accuracy` is carried, not used.
155
+
156
+ ```json
157
+ { "capturedAt": {
158
+ "before": { "at": "2026-09-20T09:00:00Z", "lat": 46.05, "lon": 14.50, "accuracy": 12 },
159
+ "after": { "at": "2026-09-20T09:40:00Z", "lat": 46.05, "lon": 14.50 } } }
160
+ ```
161
+
162
+ Missing evidence is an **error**, never `false`: a phone with location off, a photo forwarded
163
+ through a messenger (EXIF stripped), a screenshot or a canvas resize answer nothing either way, so
164
+ the rubric's `onError` decides. `false` means only "the evidence says no": too far, or out of order.
165
+ EXIF and `capturedAt` are both client-supplied; a grade says where a value came from, never that it
166
+ is true.
167
+
168
+ ### `@ixo/decision-oracle/testing` — node-safe, what a test imports
169
+
170
+ ```ts
171
+ import {
172
+ fakeTools,
173
+ httpResponse,
174
+ runFacts,
175
+ sampleClaim,
176
+ sampleCtx,
177
+ sampleManifest,
178
+ } from "@ixo/decision-oracle/testing";
179
+ import type {
180
+ ChainCall,
181
+ ClaimIntentQuery,
182
+ FactIssue,
183
+ FakeChain,
184
+ FakeHttpRoute,
185
+ FakeTools,
186
+ FakeToolsOverrides,
187
+ HttpResponseEnvelope,
188
+ LogRecord,
189
+ ProducedFacts,
190
+ RunFactsInput,
191
+ SampleClaimOptions,
192
+ SampleManifestDecl,
193
+ } from "@ixo/decision-oracle/testing";
194
+ ```
195
+
196
+ | Export | Kind | What it is | Use it when |
197
+ | --- | --- | --- | --- |
198
+ | `runFacts` | function | The **same runner** production uses (minus the fact cache and D1), then the **same** `validateFacts` | Every handler test |
199
+ | `fakeTools` | function | `FactTools` with every lane answered from a literal, plus a `logs` array | Every handler test |
200
+ | `httpResponse` | function | `httpResponse(503, body)` — a canned answer whose **status** is the point | Testing a failure path |
201
+ | `sampleClaim` | function | A `PulledClaim` whose body is your answers | Every handler test |
202
+ | `sampleManifest` | function | The engine's work order for exactly the names you list | Every handler test |
203
+ | `sampleCtx` | function | The frozen clock read + collection window | Pinning a date rule |
204
+ | `ProducedFacts` | type | `{ ext, ai, unique?, frequency? }` — what `runFacts` returns, plus `issues` | Naming the result |
205
+ | `FactIssue` | type | `{ code, path, message }` — one coverage failure | Asserting on `issues` |
206
+ | `FakeTools` / `FakeToolsOverrides` / `FakeHttpRoute` / `HttpResponseEnvelope` / `LogRecord` | types | `fakeTools`'s shape and options | A shared test helper |
207
+ | `ChainCall` / `ClaimIntentQuery` / `FakeChain` | types | What `chainCalls` records, the query a `chain.claimIntent` function is handed (`{ agentAddress, collectionId, before, after? }`), and the `chain` override's own shape | Asserting on the chain reads a handler made |
208
+ | `RunFactsInput` / `SampleClaimOptions` / `SampleManifestDecl` | types | The three builders' options | A shared test helper |
209
+
210
+ ### `@ixo/decision-oracle/runtime` — worker only, what a route/cron/Workflow calls directly
211
+
212
+ ```ts
213
+ import {
214
+ AppError,
215
+ broadcastAsOracle,
216
+ decisions,
217
+ getCollection,
218
+ getDb,
219
+ getIxoSdk,
220
+ getOracleSigner,
221
+ getQueryClient,
222
+ loadConfig,
223
+ registerCollection,
224
+ scanCollection,
225
+ signEvaluation,
226
+ watchedCollections,
227
+ } from "@ixo/decision-oracle/runtime";
228
+ import type {
229
+ BlocksyncDeps,
230
+ ChainWriteOutcome,
231
+ CollectionNode,
232
+ Config,
233
+ Database,
234
+ DecisionRow,
235
+ OracleSigner,
236
+ RegisterInput,
237
+ WatchedCollection,
238
+ } from "@ixo/decision-oracle/runtime";
239
+ ```
240
+
241
+ Separate from the root because the root is what your entry module imports and `/facts`/`/testing`
242
+ are what your node test suite imports; this subpath pulls in D1, the signer and the
243
+ sequence-manager DO, so it can only ever be loaded inside a Worker. Every function takes `env`
244
+ rather than the kit's internal deps — it assembles the config, the logger, the metrics, the trace
245
+ and the database handle from it exactly as the cron does (`cron/scan.ts`'s `cronContext`).
246
+
247
+ | Export | Kind | What it is | Use it when |
248
+ | --- | --- | --- | --- |
249
+ | `signEvaluation` | function | `(env, claimId, amount?) => Promise<ChainWriteOutcome>` — signs a decision the act policy `held` back (the release pass, `lib/ixo/chain-write.ts`). `amount`, when given, is the payout to put on `MsgEvaluateClaim` — the whole point of holding (a payout pooled across several claims) — under the same owner opt-in as the hook's (`operatorAmounts`; off ⇒ the lane's default, logged). Every guard of the lane still applies, so it is safe to call twice: an already-`submitted` row answers `"skipped"`, never a second signature | Your own release trigger for a pooled payout — a batch job, an operator action, a cron of your own |
250
+ | `scanCollection` | function | `(env, collectionId) => Promise<number>` — runs the cron's per-collection discovery pass, right now, for ONE collection. Builds its context with the SAME `cronContext` helper the tick itself uses, so a webhook and the cron racing on one claim start it exactly once. Throws `notFound` if the collection isn't registered, `conflict` if it is paused | Reacting to your own webhook the instant a claim is submitted, instead of waiting for the collection's next turn in the tick's rotation |
251
+ | `registerCollection` | function | `(env, input: RegisterInput) => Promise<WatchedCollection>` — the SAME four gates (WHO/HIRED/PROTOCOL/PAID), delegation resolution, upsert and paused→active notice `POST /v1/collections/register` runs (`lib/register.ts`), as a function. `input.cursor` is the one thing the HTTP body schema never exposes: applied on FIRST registration only — a re-register can never rewind it (it is not in the upsert's conflict `set`). `null` scans the collection from claim zero (the existing NULL-cursor path, blocksync `CLAIMS_FROM_START`) for a collection whose claims may predate this oracle's registration; absent seeds the cursor to now, the HTTP route's own behaviour, unchanged | Your own Workflow registering a collection AS the oracle's own principal, after minting its own evaluate grant — there is no inbound request to authenticate or a body to validate |
252
+ | `broadcastAsOracle` | function | `(env, msgs, memo?) => Promise<{ code, transactionHash, rawLog? }>` — signs and broadcasts arbitrary messages as THIS oracle's account, the same sequence-managed signer the act lane uses (`getOracleSigner`, `lib/ixo/chain.ts`). Memo defaults to your oracle's own `name`. Does **not** inspect `res.code` — unlike `signEvaluation` this is a raw broadcast primitive, not a decision lane, so a non-zero code is yours to interpret. Throws `AppError.unavailable` when `ORACLE_SECP_MNEMONIC` is unset, before the signer is ever built | Composing your own `MsgExec` around kit-issued messages |
253
+ | `AppError` | class | The kit's typed error — `.notFound()`, `.forbidden()`, `.conflict()`, `.unavailable()`, `.internal()`, … (§13) | Catching/rethrowing what the four functions above throw, in your own error handling |
254
+ | `ChainWriteOutcome` | type | `"submitted" \| "skipped" \| "blocked" \| "held" \| "owner_submitted"` — what `signEvaluation` resolves to, and (alongside `"pending"`, the row's initial state before any write is attempted) the full `decisions.chainStatus` enum | Branching on the result, or reading `chainStatus` off `GET /v1/claims/:claimId` |
255
+ | `RegisterInput` | type | `{ collectionId, principal: { did, address }, contactEmail?, delegationCar?, cursor? }`. No `contactEmail` sent and none stored ⇒ the row takes the `contactEmail` of the oldest-activated `active` Decision API source whose `companyEntityDid` is the collection's COMPANY as the engine derives it (`GET /v1/collections/:id/company`, UCAN `collection/company-read`: the collection's entity, or the org a deed is linked to both ways); a `disputed` company, a collection the engine does not know, or none such source ⇒ stays empty. Its `503` or any other failure never fails the register (logged `register.company-unavailable`, the row stays empty for a later register to fill). An explicit or stored contact is never overwritten. The SAME answer (one engine read per register) also links the collection to every `active` source of that company (`api_source_collections`, `linkedBy` = the company's entity DID, audit `source.collection_linked` by `system:company-link`; idempotent — an existing link is left alone); only the engine's `ok` links. The engine is asked only when no contact is sent or stored, or an `active` source is not yet linked to the collection. | Building the input to `registerCollection` |
256
+ | `WatchedCollection` | type | The `watched_collections` row `registerCollection` and `scanCollection` read and write | Typing a helper that takes one |
257
+
258
+ **Plumbing, verbatim.** The chain/db pieces the four functions above are themselves built from,
259
+ re-exported here with no wrapper and no behaviour change — because the package's `exports` block
260
+ only opens the root, `/facts`, `/testing` and this subpath, a deep import is not an option:
261
+
262
+ | Export | From | What it's for |
263
+ | --- | --- | --- |
264
+ | `getIxoSdk` / `getQueryClient` / `getOracleSigner` / `OracleSigner` | `lib/ixo/chain.ts` | The lazily-loaded chain SDK, the memoized RPC query client, and the oracle account signer `broadcastAsOracle` itself calls |
265
+ | `loadConfig` / `Config` | `config.ts` | The same validated config every lane reads |
266
+ | `getDb` / `Database` | `db/client.ts` | The Drizzle handle over the `DB` binding |
267
+ | `watchedCollections` / `decisions` / `DecisionRow` | `db/schema.ts` | The two tables (`WatchedCollection` is above), for a query of your own alongside these functions |
268
+ | `getCollection` / `BlocksyncDeps` / `CollectionNode` | `lib/ixo/blocksync` | The on-chain collection read `registerCollection` itself calls |
269
+
270
+ **`policies.act`, in full.** `ActPolicy` (exported from the package **root**, §1 above — it's a
271
+ type-only seam with nothing to validate at registration, so it doesn't live under `/runtime`):
272
+
273
+ ```ts
274
+ type ActPolicy = (ctx: ActContext) => Promise<ActDecision> | ActDecision;
275
+ ```
276
+
277
+ `ActContext` — everything the act lane knows about the claim it is about to sign for, handed once
278
+ every one of the lane's own guards has passed and immediately before it reads the authz grant:
279
+
280
+ | Field | Type | What it is |
281
+ | --- | --- | --- |
282
+ | `claimId` | `string` | |
283
+ | `collectionId` | `string` | |
284
+ | `evaluationStatus` | `1 \| 2` | The chain's `EvaluationStatus` the lane is about to sign — 1 approve, 2 reject |
285
+ | `verdict` | `string \| null` | The engine's verdict string |
286
+ | `decision` | `Decision` | The engine's decision object verbatim (`decisions.decision`) |
287
+ | `receiptCid` | `string` | The engine's signed receipt CID |
288
+ | `facts` | `ActFacts` | The facts the decision was made on, frozen on the decision row (`decisions.facts`) — `ext.<name>` / `ai.<name>` only, never answer values |
289
+ | `payment` | `ClaimPayment \| undefined` | The claim's payment plane (`lib/ixo/payment.ts`): its own coins, and whether an intent governs |
290
+ | `defaultAmount` | `{ denom, amount } \| undefined` | What the lane would put on `MsgEvaluateClaim` without the policy — `undefined` ⇒ the chain's own default |
291
+ | `operatorAmounts` | `"off" \| "allowed"` | Whether a `sign` `amount` will stand: `"allowed"` only while the collection's **current** chain owner opted in (`PATCH /v1/collections/:id/settings`; a yes set by a previous owner reads `"off"`). On `"off"` the lane pays `defaultAmount` whatever you return — `hold` or `skip` if that is not the payout you mean |
292
+ | `claim` | `ClaimNode` | The on-chain claim as Blocksync returns it (`lib/ixo/blocksync` `getClaimById`) |
293
+ | `env` | `OracleEnv` | |
294
+ | `logger` | `Logger` | |
295
+
296
+ `ActDecision` is what the hook returns: `{ mode: "sign", amount?, reason? }` proceeds (`amount`,
297
+ when given, **replaces** what the lane would have sent — denom included — but only on a collection
298
+ whose owner set `operatorAmounts: "allowed"` (`PATCH /v1/collections/:id/settings`) and still
299
+ owns it — a transfer turns the yes off until the new owner sets it again; while it is
300
+ `"off"`, the default, the lane pays its own default and logs `act.amount-ignored-not-opted-in`,
301
+ and on an intent claim, where the chain overwrites it anyway, it logs `act.amount-ignored-intent`); `{ mode: "hold", reason? }` parks the row
302
+ `chainStatus: "held"` for a later `signEvaluation`; `{ mode: "skip", reason? }` leaves the row
303
+ untouched, the same clean no-op as `AUTO_SIGN` being off. The hook runs inside the act step's own
304
+ retry, so a throw is a retry — never a silent signature: the lane records the error on the row and
305
+ rethrows.
306
+
307
+ **Writing one that cannot be gamed by a bad rubric.** The lane guarantees only its own half: nothing
308
+ but an explicit `sign` reaches the grant read and the broadcast, and an answer it cannot read — a
309
+ typo, an old shape, `undefined` — parks the row `blocked` rather than signing it. The other half is
310
+ yours, and it turns on two questions.
311
+
312
+ *Is the fact really there?* `ctx.facts` is keyed by the **rubric's** names, so a name the rubric never
313
+ declared is simply absent, and a handler that failed leaves `status: "error"` with no `value` at all.
314
+ Written as `facts.ext.siteWork?.value === true ? hold : sign`, absent, errored and honestly-false all
315
+ collapse into one branch. Read presence and status **before** value, and give the unknown case a
316
+ branch of its own:
317
+
318
+ ```ts
319
+ const site = ctx.facts.ext.siteWork;
320
+ if (!site || site.status !== "ok") { /* the unknown branch — decide it deliberately, below */ }
321
+ ```
322
+
323
+ *Which of your modes is the expensive one?* There is no universally safe default, because `hold`
324
+ means whatever your release pass makes it mean. Where a held row waits for a person to look at it,
325
+ holding the unknown is the cautious answer. Where a held row is entered into a **pooled payout** and
326
+ settles for a share of the whole pot when the group closes, holding is the most expensive thing you
327
+ can do with the claim you understand least — and if the marker fact is declared on only some asks,
328
+ "absent" is the *normal* state of every other ask, so holding it strands ordinary work behind your
329
+ stale timer. Work out which mode costs more in **your** design, make the unknown take the cheaper
330
+ one, and make it loud: log the fact's `detail`, or sign with a `reason` naming the value you did not
331
+ expect. A rubric that has stopped matching its claims shows up first as an unknown you swallowed.
332
+
333
+ ### Types you were not given an export for
334
+
335
+ The published types reference internal shapes (`PulledClaim`, `Manifest`, `Attachment`, …) that
336
+ have no name on the public surface — deliberately: they belong to the package's internals and are
337
+ reachable from the handler types by indexed access, which can never drift from them.
338
+
339
+ ```ts
340
+ import type { AiInput, ExtInput, ExtTools, FactTools, UniqueHandler } from "@ixo/decision-oracle/facts";
341
+
342
+ type PulledClaim = ExtInput["claim"];
343
+ type Manifest = ExtInput["manifest"];
344
+ type ManifestExt = ExtInput["expected"];
345
+ type ManifestAi = AiInput["check"];
346
+ type CtxFacts = ExtInput["ctx"];
347
+ type FactSettings = ExtInput["settings"];
348
+ type Attachment = ReturnType<FactTools["attachments"]["on"]>[number];
349
+ type AttachmentBody = Awaited<ReturnType<FactTools["attachments"]["bytes"]>>;
350
+ type Agreement = NonNullable<Awaited<ReturnType<FactTools["agreement"]>>>;
351
+ type Provenance = NonNullable<Awaited<ReturnType<FactTools["c2pa"]["screen"]>>>;
352
+ type QueryClient = Awaited<ReturnType<FactTools["chain"]["query"]>>;
353
+ type ExtFact = Awaited<ReturnType<ExtTools["stock"]>>;
354
+ type UniqueFact = Awaited<ReturnType<UniqueHandler>>;
355
+ ```
356
+
357
+ ---
358
+
359
+ ## 2. `defineOracle(config)`
360
+
361
+ ```ts
362
+ // src/index.ts — the whole file
363
+ import { defineOracle } from "@ixo/decision-oracle";
364
+ import { facts } from "./facts";
365
+
366
+ const oracle = defineOracle({
367
+ name: "my-oracle",
368
+ description: "Evaluates delivery claims against the collection's rubric.",
369
+ supportedProtocols: [],
370
+ pricing: { evaluation: { denom: "uixo", minAmount: "1000000" } },
371
+ policies: { autoSign: true, allowAiChecks: true, allowImageChecks: true, allowStandingApprovals: false },
372
+ facts,
373
+ });
374
+
375
+ export default oracle;
376
+ export const { ClaimIngest, ReviewComplete, SequenceManagerDO } = oracle;
377
+ ```
378
+
379
+ Those **three re-exports are mandatory**: the Workers runtime instantiates Workflow and Durable
380
+ Object classes *by export name*, and the names here must match `class_name` in your
381
+ `wrangler.jsonc` (`workflows[].class_name` → `ClaimIngest` / `ReviewComplete`;
382
+ `durable_objects.bindings[].class_name` → `SequenceManagerDO`).
383
+
384
+ ### `OracleConfig`
385
+
386
+ | Field | Type | Validation | Meaning / default |
387
+ | --- | --- | --- | --- |
388
+ | `name` | `string` | non-empty | The marketplace label on `GET /v1/info`, and the memo on every `MsgEvaluateClaim` this oracle signs. Also the default `EMAIL_FROM_NAME` |
389
+ | `description` | `string` | any string | The listing blurb |
390
+ | `supportedProtocols` | `string[]` | every entry must start with `did:ixo:entity:` | Register gate 3: a collection whose protocol is not listed is refused `409`. `[]` ⇒ **no restriction**. Convention: exactly one entry — one oracle, one protocol |
391
+ | `pricing.evaluation.denom` | `string` | non-empty | The denom a collection's on-chain `Payments.evaluation` must be in |
392
+ | `pricing.evaluation.minAmount` | `string` | `/^\d+$/` — an **integer base-unit string** | The minimum that payment must carry, compared with `BigInt`. Below it, register is refused `402`: the chain's 80/10/10 split of that coin is how the oracle earns. Both fields are replaced, per deployment, by the `EVALUATION_DENOM` / `EVALUATION_MIN_AMOUNT` vars when those are set (required on mainnet) — what register charges and `/v1/info` serves is the result (`effectivePricing`) |
393
+ | `pricing.byProtocol` | `Record<protocolDid, { denom, minAmount }>` | optional; each entry the same shape/validation as `pricing.evaluation` | Per-protocol override, keyed by `did:ixo:entity:…`. Register's gate 4 resolves the collection's protocol against this map first, falling back to `pricing.evaluation` when unlisted. `minAmount: "0"` lets that protocol's collections register with an EMPTY `Payments.evaluation` leg — for an oracle whose income for it isn't the evaluation split. `GET /v1/info` advertises this map alongside `pricing.evaluation` |
394
+ | `pricing.api.amountMicroUsd` | `number` | optional (`pricing.api` whole is optional); an integer ≥ 10 000 and a multiple of 10 000 (whole cents ≥ 1¢), micro-USD (1 upay = 1 µUSD; $1.20 = `1_200_000`) | The Decision API lane's price per decision — a separate thing from the chain fees above. Published at `GET /v1/api/protocols[/:did]` as `price` (public, `cache-control: public, max-age=300`), quoted in every 402, and checked by the facilitator against every offer (`price_mismatch` otherwise). A function of the protocol alone — there are no per-source prices; a partner who should not pay gets billing mode `none`. Every price is a whole number of cents, at least 1¢ (a multiple of 10 000 µUSD — anything else fails at startup), so `price.amountCents` and `price.amount` (upay) are both exact |
395
+ | `payouts` | `Record<protocolDid, { amount: string; currency: string \| { path: string } \| { asset: string; decimals: number } }>` | optional; `amount` / `currency.path` are a path like `$.invoice.total` or `$.lines[0].amount` (`$` then `.key` / `[n]` only); `currency` an ISO 4217 code (`EUR`), or `{ asset: "eip155:<chain>/erc20:0x…", decimals }`; nothing else (a `payee` or `readers` key is refused); with a non-empty `supportedProtocols`, every key must be in it (refused at module evaluation) | S16 Decision Payouts: where a protocol's claims carry the amount to pay. The oracle sends it to the engine as the `payout` block (outside the facts) on the decide call and the fold of EVERY claim of the protocol; the engine reads the amount from the claim's answers and, when the claim is approved, binds the payment into the decision's one credential: `ClaimRecord.decision.receipt` is then also typed `DecisionToPayCredential` and carries the payment binding, public at the engine's `/v1/receipts/:cid` (contracts 1.13.0 removed the separate `decisionToPay`). The engine derives the payee — a chain claim's on-chain agent; an API claim's `submitter`, else the answer the source's company link names (`payeeField`) — and answers `decisionToPayRefused: "NO_PAYEE"` (a receipt with no binding, logged `payout.refused`) when there is none. Submitter ids must be **opaque** (never a name, email or phone), at most 256 characters: the public receipt names the payee by that id. Absent ⇒ no payout block, nothing changes |
396
+ | `pricing.api.byProtocol` | `Record<protocolDid, number>` | optional; each value micro-USD held to the same rule as `amountMicroUsd` — an integer ≥ 10 000 and a multiple of 10 000 (whole cents ≥ 1¢); with a non-empty `supportedProtocols`, every key must be in it (refused at module evaluation) | Per-protocol override of `pricing.api.amountMicroUsd`. Resolution: `byProtocol[did]` → `amountMicroUsd` → the `API_PRICE_USD_CENTS` var × 10 000 → none: the protocol answers `price: null`, and a submit under it is `503 SOURCE_PAUSED { payments-disabled }` (nothing is offered at a price the facilitator cannot check) |
397
+
398
+ Card is offered for a protocol only when its price is **≥ 50¢ (500 000 µUSD) per decision**. That is Stripe's minimum capture, and the facilitator checks every claim separately, so a batch doesn't lift it. Below that only `ixo-pay` is offered. Changing the price is a redeploy (the code value, or `API_PRICE_USD_CENTS` when `pricing.api` is absent). The facilitator re-reads prices within ~5 min; an offer refused `price_mismatch` in that window is retried.
399
+
400
+ | `policies.autoSign` | `boolean` | required | Sign `MsgEvaluateClaim` on a terminal verdict unattended. The `AUTO_SIGN` var must be on **too** — both, or no chain write |
401
+ | `policies.allowAiChecks` | `boolean` | required | `false` ⇒ every AI fact freezes as `error / ai-checks-disabled` with **no model call**, and the rubric's own `onError` carries. Also disables the duplicate lane's same-event judge (`aiOutcome: "skipped"`) |
402
+ | `policies.allowImageChecks` | `boolean` | required | Screen an AI check's image inputs for C2PA Content Credentials before the model call. Needs the `C2PA_IMAGE_CHECK` var on too. Fail-safe: it can only ever **add** a rejection |
403
+ | `policies.actSummary` | `string` | optional, 1–500 chars | How your `act` policy picks an amount, in your own plain words — published on `GET /v1/info` as `payouts.operatorAmount.summary` (only while `act` is set) so a company sees how it is paid before it opts in to `operatorAmounts`. The oracle never writes one for you |
404
+ | `policies.allowStandingApprovals` | `boolean` | required | Let an AI app's **standing** approval (the user's own auto-approve limits on the hosted MCP server) pay for a submit without a per-claim approval. Off ⇒ AuthHub cannot set one (an approval answer with `standing`, and `PUT /v1/oauth/standing/:grantId`, are `403 FORBIDDEN`, `details.reason: standing_approvals_off`), a standing already stored never pays, is never read back (`GET /v1/oauth/standing/:grantId` is `404`) and is deleted by the hourly housekeeping sweep, a card payment one parked for 3-D Secure is not resumed (`FORBIDDEN`, `standing_approvals_off`; the retry asks for the claim's own approval), and every OAuth submit needs the user's approval of that claim. `GET /v1/info` shows it under `policies` |
405
+ | `policies.act` | `ActPolicy` | optional; typed, not validated by Zod (`src/act.ts`) | Your say over what the act lane does with a decided verdict, once every other guard has passed and just before it reads the authz grant: `sign` (optionally overriding the amount, where the collection's owner opted in — `operatorAmounts`), `hold` (parks the row `chainStatus: "held"` for a later `signEvaluation`), or `skip` (a clean no-op, same as `AUTO_SIGN` off). Absent ⇒ the lane signs on its own, as today. Full `ActContext` field list in the `@ixo/decision-oracle/runtime` section (§1) |
406
+ | `facts` | `FactsConfig \| FactsProducer` | carried through untouched | Absent ⇒ the stock lanes for everything |
407
+ | `archive` | `ArchiveHooks` | optional; typed, not validated by Zod | How the answer archive (`ANSWERS_ARCHIVE`) reads your asks and answers. `describe(ask: ArchiveAsk) => Partial<ArchiveAbout>` (may be async): `ask` is `{ askId, entity, alsoKnownAs, collection, docs, docsOf(did) }` — the chain collection as Blocksync returns it, the entity's LinkedResources and settings as `{ id, type, json, error? }` (up to 12, 256 KB each, proof-checked by `readLinkedDoc`), and the same read for another entity; return any of `title`, `question`, `kind` (`photo` · `observation` · `url-check` · `other`), `place {name?, lat?, lon?, radiusM?}`, `reward {amount, denom}`, `postedBy {name?}`, `opensAt`, `closesAt` — `askId` and `proof` are always the chain's. `answer({ body }) => ArchiveAnswerView`: `{ text?, place?: {lat, lon, accuracyM?, at?} }`, exact. Absent ⇒ the `#pro` name (else `alsoKnownAs`), the collection's window and approval pay, nothing else ([guide §12](./guide.md#12-operating-it)) |
408
+
409
+ Validation runs at **module evaluation**, not per request: a bad `minAmount` or an empty `name`
410
+ fails the isolate loudly. `facts` rides through Zod untouched — the TypeScript type is its contract.
411
+
412
+ `defineOracle` also registers the config in a one-per-isolate registry. Calling it twice with two
413
+ *different* config objects throws `defineOracle() was already called — one oracle per worker`;
414
+ re-evaluating the same module (bundlers, test pools) is harmless, because the check is on the
415
+ identity of the object you passed. Reading the registry before any `defineOracle` call throws
416
+ `defineOracle() was never called — export it from your worker entry`.
417
+
418
+ ### The whole-producer escape hatch
419
+
420
+ `facts` also accepts a single function, for the one case a per-name table cannot express — you
421
+ assemble the whole answer yourself:
422
+
423
+ ```ts
424
+ facts: async (claim, expected, deps) => ({ ext: {…}, ai: {…} }),
425
+ ```
426
+
427
+ It is structural (the type has no exported name): `expected` is the manifest, `deps` carries
428
+ `config`, `logger`, `ctx`, `settings`, `engine`, `db`, `env`, and the return type is
429
+ `ProducedFacts` (nameable from `./testing`). A whole-producer replaces the runner's wiring, so none
430
+ of §7's guarantees apply to it — prefer `defineFacts`.
431
+
432
+ ---
433
+
434
+ ## 3. `defineFacts({ ext, ai, groups, unique, frequency })`
435
+
436
+ ```ts
437
+ // src/facts/index.ts
438
+ import { defineFacts, group } from "@ixo/decision-oracle/facts";
439
+ import { deliveryConfirmed } from "./deliveryConfirmed";
440
+ import { workScores } from "./workScores";
441
+
442
+ export const facts = defineFacts({
443
+ ext: { deliveryConfirmed }, // unlisted ⇒ the STOCK ext lane
444
+ ai: { "*": (_input, tools) => tools.stock() }, // "*" ⇒ every check you did not name
445
+ groups: [group(["quality", "price"], workScores)], // ONE call answers both names
446
+ });
447
+ ```
448
+
449
+ Every field is optional; `defineFacts` fills the gaps (`ext: {}`, `ai: {}`, `groups: []`) so the
450
+ runner never branches on `undefined`.
451
+
452
+ | Key | Type | Unlisted / absent means |
453
+ | --- | --- | --- |
454
+ | `ext` | `Record<string, ExtHandler>` | The stock external-source lane: a self-signed UCAN POST to the declared `endpoint` with the resolved `send` map plus `claimBody`, read against the strict `{ valid }` / `{ score }` contract (§ "How an external source is called") |
455
+ | `ai` | `Record<string, AiHandler>` | The stock judge: the configured AI provider (`AI_PROVIDER`) with the rubric's own `instructions` over the declared inputs |
456
+ | `ai["*"]` | `AiHandler` | The one wildcard — it catches every check the table does not name. A named handler still wins |
457
+ | `groups` | `FactGroup[]` | Nothing grouped: every source is answered on its own. Each `group(names, handler)` takes over those `ext` names and answers them from ONE call (§4) |
458
+ | `unique` | `UniqueHandler` | The stock duplicate lane: this oracle's D1 registries, a keyed body digest, CID + perceptual-hash attachment lanes, a JS trigram near-text lane and the gray-zone same-event judge |
459
+ | `frequency` | `FrequencyHandler` | The stock submission count: one Blocksync read over the claim's own calendar window |
460
+
461
+ **A table is a DIFF against stock, never a replacement of it.** A handler for a name the manifest
462
+ does not declare produces nothing and logs `facts.handler-unused` once — a renamed rubric entry
463
+ shows up as that warning, not as a broken build.
464
+
465
+ **One name, one handler.** A name listed in `ext` *and* claimed by a group — or claimed by two
466
+ groups — throws at module evaluation (`defineFacts: 'quality' is answered twice …`), because the
467
+ alternative is a fact whose value depends on which lane wrote last.
468
+
469
+ ---
470
+
471
+ ## 4. Handlers
472
+
473
+ ```ts
474
+ type ExtHandler = (input: ExtInput, tools: ExtTools) => Promise<ExtResult> | ExtResult;
475
+ type AiHandler = (input: AiInput, tools: AiTools) => Promise<AiResult> | AiResult;
476
+ type GroupHandler = (input: GroupInput, tools: FactTools) => Promise<GroupResult> | GroupResult;
477
+ type UniqueHandler = (input: UniqueInput, tools: FactTools) => Promise<UniqueFact> | UniqueFact;
478
+ type FrequencyHandler = (input: FrequencyInput, tools: FactTools) => Promise<FrequencyFact> | FrequencyFact;
479
+ ```
480
+
481
+ Synchronous handlers are fine — every signature accepts a value or a promise.
482
+
483
+ ### What every handler is handed (`FactInputBase`)
484
+
485
+ | Field | Type | What it is |
486
+ | --- | --- | --- |
487
+ | `claim` | `PulledClaim` | `claimId`, `collectionId`, `network`, `lane` (the store the body came from: `"vfs"` \| `"vfs-public"` \| `"matrix"` \| `"ipfs"` \| `"cellnode"`), `metadata` (`agentDid`, `agentAddress`, `schemaType`, `submissionDate`, `amount`, `payment`, `evaluation`), `body`, `attachments` (CID/URL **descriptors**, never bytes), `media` (that store: `{ origin, cidUrl(cid), auth?() }` — holds its auth, never log it; `null` on the cellnode lane) |
488
+ | `answers` | `Record<string, unknown>` | `claim.body` — the credentialSubject, verbatim |
489
+ | `ctx` | `CtxFacts` | The build's **one** clock read and the collection window: `{ now, collection: { startDate, endDate }, claim: { submitter, submitterAddress, submissionDate, useIntent? } }`. All dates are `Z`-suffixed ISO or `null`. **Never read the clock in a handler** — `ctx.now` is what the engine's date rules compare against, and a second read makes a fact that cannot be replayed |
490
+ | `settings` | `FactSettings` | `{ allowAiChecks, allowImageChecks }` — this build's policy, from `defineOracle` |
491
+ | `manifest` | `Manifest` | The whole work order (§8): `fields`, `derived`, `ctx`, `usedBy`, every `ext`/`ai` declaration, `unique`/`frequency`, `sizeProbes` |
492
+
493
+ ### `ExtHandler` — one external source
494
+
495
+ `ExtInput` adds:
496
+
497
+ | Field | Type | What it is |
498
+ | --- | --- | --- |
499
+ | `name` | `string` | The `<name>` in `ext.<name>` — the work-order key this handler answers |
500
+ | `expected` | `ManifestExt` | The declaration for this name: `expect` (`"boolean"` \| `"score"`), `onError`, `endpoint`, `audience`, `capability?`, `send`, `timeoutMs?`, `usedBy` |
501
+ | `send` | `Record<string, unknown>` | The declared `send` map **already resolved** against the answers and `ctx`: a `$path` ref becomes the answer (`null` when it walks off the data), a `ctx.*` token the frozen value, a literal itself, and a `~derived` slot `null` |
502
+ | `unresolved` | `string[]` | The `~derived` refs that `send` names — reported, never guessed. Their expressions are on `manifest.derived` |
503
+
504
+ Return: `boolean` · an **integer** `number` (basis points 0–10000, for `expect: "score"`) ·
505
+ `fact.ok(...)` · `fact.error(...)`.
506
+
507
+ ```ts
508
+ import { type ExtHandler, fact } from "@ixo/decision-oracle/facts";
509
+
510
+ export const deliveryConfirmed: ExtHandler = async ({ send, expected }, tools) => {
511
+ const res = await tools.http.json<{ status?: string }>(expected.endpoint, {
512
+ method: "POST",
513
+ body: { reference: send.reference },
514
+ timeoutMs: expected.timeoutMs ?? 5000,
515
+ });
516
+ if (res.status === null || res.status >= 500) {
517
+ throw fact.retryLater(`courier unavailable (${res.status ?? "no response"})`);
518
+ }
519
+ if (!res.ok) return fact.error(`courier answered ${res.status}`, { transcript: res.transcript });
520
+ return res.body?.status === "delivered"; // a plain boolean ⇒ an `ok` fact
521
+ };
522
+ ```
523
+
524
+ A `score` source returns integer basis points, never a float and never a percentage:
525
+
526
+ ```ts
527
+ export const onTimeRate: ExtHandler = async ({ expected }, tools) => {
528
+ const res = await tools.http.json<{ on_time_pct?: unknown }>(expected.endpoint);
529
+ const pct = res.body?.on_time_pct;
530
+ if (!res.ok || typeof pct !== "number") return fact.error(`courier answered ${res.status}`);
531
+ return fact.ok(Math.round(pct * 100), { reason: `${pct}% on time` });
532
+ };
533
+ ```
534
+
535
+ ### `AiHandler` — one AI check
536
+
537
+ `AiInput` adds `name`, `check: ManifestAi` (`expect: "boolean"`, `onError`, `instructions`, `send`,
538
+ `usedBy`), `send`, `unresolved`, and:
539
+
540
+ | Field | Type | What it is |
541
+ | --- | --- | --- |
542
+ | `inputs` | `AiSendEntry[]` | The `send` map as the judge's typed, **declared-order** inputs: a scalar becomes `{ kind: "text", key, value }`; a `$ref` whose root question carries files becomes one `{ kind: "attachment", key, attachment }` per file (all of them, not just the first) |
543
+
544
+ Return: `boolean` · `fact.ai.ok(...)` · `fact.error(...)`.
545
+
546
+ The common shape is a guard in front of the stock judge — `tools.stock()` is typed as this lane's
547
+ own fact, so it needs no cast:
548
+
549
+ ```ts
550
+ import { type AiHandler, fact } from "@ixo/decision-oracle/facts";
551
+
552
+ export const handoverPhotoLooksReal: AiHandler = async (_input, tools) => {
553
+ if (tools.attachments.on("handoverPhoto").length === 0) {
554
+ // Never `false`: that would claim a model looked at a photo and found it fake.
555
+ return fact.error("the claim carries no handoverPhoto attachment");
556
+ }
557
+ return tools.stock();
558
+ };
559
+ ```
560
+
561
+ Your own judge, your own rules, your own model:
562
+
563
+ ```ts
564
+ export const photoShowsHandover: AiHandler = async ({ name, check }, tools) => {
565
+ const images = tools.ai.inputsFor(check).filter((entry) => entry.kind === "attachment");
566
+ if (images.length === 0) return fact.error(`${name}: the claim carries no image to judge`);
567
+ const { JUDGE_MODEL } = tools.env as typeof tools.env & { JUDGE_MODEL?: string };
568
+ return tools.ai.judge(
569
+ [...check.instructions, "The parcel must be in the recipient's hands, not on a doorstep."],
570
+ images,
571
+ JUDGE_MODEL ? { model: JUDGE_MODEL } : {},
572
+ );
573
+ };
574
+ ```
575
+
576
+ ### `GroupHandler` — several sources, one call
577
+
578
+ `group(["quality", "price"], handler)` in `defineFacts({ groups: [...] })` takes those `ext` names
579
+ over and answers them **from one call**. The case it exists for: four criteria judged by a single
580
+ structured model call. As four single handlers that is four completions for one judgment; as a group
581
+ it is one — and it is the sanctioned way to share a call between facts (a private cache inside a
582
+ handler still is not; the runner's own fact cache is the only cache).
583
+
584
+ AI checks cannot currently be grouped; `group()` answers `ext` sources only.
585
+
586
+ `GroupInput` is `FactInputBase` plus:
587
+
588
+ | Field | Type | What it is |
589
+ | --- | --- | --- |
590
+ | `names` | `string[]` | The names THIS call must answer: the group's names, minus any the manifest does not declare and any a previous attempt already paid for (a cache hit is kept, and the call it would have taken is what the cache saves) |
591
+ | `sources` | `Record<string, ExtInput>` | Per name, the same input a single `ExtHandler` gets — its own `expected` declaration and its own resolved `send` |
592
+
593
+ Return: `Record<name, ExtResult>` — one fact per name, each of them anything a single ext handler
594
+ may return (`boolean` · integer basis points · `fact.ok(...)` · `fact.error(...)`).
595
+
596
+ ```ts
597
+ import { type GroupHandler, fact } from "@ixo/decision-oracle/facts";
598
+
599
+ export const workScores: GroupHandler = async ({ names, answers }, tools) => {
600
+ const res = await tools.http.json<{ quality?: unknown; price?: unknown }>(
601
+ "https://judge.example/score",
602
+ { method: "POST", body: { summary: answers.workSummary }, timeoutMs: 45_000, attempts: 2 },
603
+ );
604
+ // One call, one failure: every name it owed becomes an error carrying the rubric's own `onError`.
605
+ // Never a neutral score for a judgment that was never made.
606
+ if (!res.ok) {
607
+ const failed = `judge answered ${res.status ?? "nothing"}`;
608
+ return Object.fromEntries(names.map((name) => [name, fact.error(failed)]));
609
+ }
610
+ const bps = (v: unknown, what: string) =>
611
+ typeof v === "number" ? fact.ok(Math.round(v * 100)) : fact.error(`judge scored no ${what}`);
612
+ return { quality: bps(res.body?.quality, "quality"), price: bps(res.body?.price, "price") };
613
+ };
614
+ ```
615
+
616
+ The runner holds a group to exactly the rules a single handler meets, **per name**: each fact
617
+ carries its own declaration's `onError`, each is cached on its own, and a throw freezes every name
618
+ the call owed (only `fact.retryLater()` still gets out). On top of that:
619
+
620
+ - **A name you leave out is that name's own error** — `handler returned no fact for <name>` with the
621
+ rubric's `onError`. Silence is not an answer and never reads as one.
622
+ - **A name nobody asked for is dropped** with one `facts.group-extra-fact` warning: a name the
623
+ rubric does not declare, or one already answered from the cache. Nothing is overwritten.
624
+ - **The group is one slot in the fan-out cap**, not one per name — the cap counts the call, which is
625
+ what costs money.
626
+ - **`tools.stock()` throws inside a group.** It could only mean "the stock lane for which of these
627
+ names", so a grouped name's lane is the group; leave a name out of the group to keep its stock
628
+ lane.
629
+
630
+ ### `UniqueHandler` — the duplicate fact
631
+
632
+ `UniqueInput` adds `rule: UniqueRule`, the manifest's declaration **with the engine's own defaults
633
+ already applied**:
634
+
635
+ | Field | Type | Default when the manifest omits it |
636
+ | --- | --- | --- |
637
+ | `id` | `string` | — (always the manifest's `ruleId`) |
638
+ | `by` | `(string \| { ref: string; granularity?: "day" \| "month" \| "year" })[]` | absent ⇒ the whole canonical body is the identity |
639
+ | `countPrior` | `("approved" \| "pending_review" \| "queued")[]` | `["approved", "pending_review", "queued"]` |
640
+ | `attachments` | `boolean` | `true` |
641
+ | `ai` | `{ enabled: boolean; maxPairs: number }` | `{ enabled: true, maxPairs: 3 }` |
642
+ | `thresholds` | `{ rejectAtBps: number; reviewAtBps: number }` | `{ rejectAtBps: 9000, reviewAtBps: 5000 }` |
643
+
644
+ What the **stock** lane counts as a prior claim: every claim this oracle registered for the
645
+ collection **before** this one (ingest order, so a later copy never flags the original when the
646
+ original's evaluate step retries), whose verdict `countPrior` lists, or that is still waiting for one.
647
+ A claim that never reached a decision (a facts-lane outcome such as `invalid_facts`, a content
648
+ refusal, an `unusable` rubric, an ingest that failed or ran out of retries) is marked `void` and
649
+ never counts, so an honest resubmission of the same evidence is not its duplicate. The same filter
650
+ applies to every lane: exact digest, attachment CID, perceptual hash and near text.
651
+
652
+ Who submitted it decides the exact-digest score: the SAME submitter repeating a claim scores
653
+ 10000 (`exact_body`, the reject band), another submitter's copy 8800 (`exact_body_cross_agent`,
654
+ review: a front-run copy is held for a person, never auto-rejected). The submitter is the chain
655
+ claim's agent address, or the API claim's `submitter` (keyed as its HMAC digest, the same key the
656
+ frequency count uses, so it survives erasure). Unknown on either side ⇒ different submitters.
657
+
658
+ Return the **wire shape** (§9): `ruleId`, `digest`, `scoreBps`, `band` are what the engine reads;
659
+ anything else you add rides loose into its trace. `tools.stock()` **throws** here by design — a
660
+ custom `unique` handler replaces its lane wholesale.
661
+
662
+ ```ts
663
+ import type { UniqueHandler } from "@ixo/decision-oracle/facts";
664
+
665
+ export const unique: UniqueHandler = async ({ rule, claim }, tools) => {
666
+ const res = await tools.http.json<{ digest?: unknown; scoreBps?: unknown }>(
667
+ "https://registry.example/duplicates",
668
+ { method: "POST", body: { claimId: claim.claimId, collectionId: claim.collectionId } },
669
+ );
670
+ const digest = res.body?.digest;
671
+ if (!res.ok || typeof digest !== "string") {
672
+ // A registry we could not reach is never a pass.
673
+ return { ruleId: rule.id, digest: "registry-unavailable", scoreBps: 0, band: "review" };
674
+ }
675
+ const scoreBps = typeof res.body?.scoreBps === "number" ? res.body.scoreBps : 0;
676
+ return {
677
+ ruleId: rule.id,
678
+ digest,
679
+ scoreBps,
680
+ band:
681
+ scoreBps >= rule.thresholds.rejectAtBps
682
+ ? "reject"
683
+ : scoreBps >= rule.thresholds.reviewAtBps
684
+ ? "review"
685
+ : "pass",
686
+ vendor: "registry.example", // extra fields ride into the engine's trace
687
+ };
688
+ };
689
+ ```
690
+
691
+ `band` is the branch the engine takes: `pass` is silent, `review` downgrades the decision to a human
692
+ review, `reject` fails the claim. It is a closed enum — a typo fails validation here rather than
693
+ misrouting there.
694
+
695
+ ### `FrequencyHandler` — the submission-rate fact
696
+
697
+ `FrequencyInput` adds `rule: FrequencyRule | null`:
698
+
699
+ | Field | Type | Default |
700
+ | --- | --- | --- |
701
+ | `id` | `string` | the manifest's `ruleId` |
702
+ | `max` | `number` | **none** — a declaration without it cannot run |
703
+ | `per` | `"day" \| "week" \| "month" \| "year"` | **none** — same |
704
+ | `onBreach` | `"review" \| "reject"` | `"reject"` (the engine's default; informational: the engine rejects every breach regardless) |
705
+
706
+ `rule` is `null` when the manifest declared the rule without `max`/`per`. The **stock** lane is
707
+ never asked in that case (the build is refused as `FACT_MISSING "manifest lacks max/per"`); a
708
+ **custom** handler still is, because it may know the limit another way.
709
+
710
+ ```ts
711
+ import { fact, type FrequencyHandler } from "@ixo/decision-oracle/facts";
712
+
713
+ export const submissionRate: FrequencyHandler = async ({ rule, manifest, claim }, tools) => {
714
+ const ruleId = rule?.id ?? manifest.frequency?.ruleId ?? "frequency";
715
+ const max = rule?.max ?? 0;
716
+ const per = rule?.per ?? "day";
717
+ const res = await tools.http.json<{ count?: unknown }>(
718
+ `https://ledger.example/claims?agent=${claim.metadata.agentAddress ?? ""}&per=${per}`,
719
+ );
720
+ // An outage is not an answer: `unresolved` is a final reject on the engine, so retry instead.
721
+ if (!res.ok) throw fact.retryLater(`ledger answered ${res.status}`);
722
+ const count = res.body?.count;
723
+ if (typeof count !== "number") {
724
+ // `unresolved` is its own state — the engine fails it closed, never reads it as headroom.
725
+ return {
726
+ ruleId, count: 0, max, per,
727
+ windowStartISO: null, windowEndISO: null,
728
+ breach: false, unresolved: true,
729
+ };
730
+ }
731
+ return { ruleId, count, max, per, breach: count > max };
732
+ };
733
+ ```
734
+
735
+ `count` **includes** the claim under evaluation (it is on chain by the time it is judged), so
736
+ `breach` is `count > max`. Windows are **UTC calendar** periods; weeks are ISO weeks (Monday 00:00
737
+ UTC), derived from the claim's own on-chain `submissionDate` — never from evaluation time. The
738
+ rubric's `Frequency` declaration has no time-zone field, so there is no local day: "30 a day" means
739
+ 30 per UTC day by chain submission time (a Sydney courier's working day spans two UTC days).
740
+
741
+ The stock lane freezes `unresolved` only when the window cannot be counted at all — the claim has
742
+ no submitter, or no usable on-chain `submissionDate`. A count that cannot be **read** (Blocksync
743
+ down, a 5xx, a timeout, an error payload) throws `unavailable` and the workflow step retries: the
744
+ engine rejects an `unresolved` fact for good, and an outage is not evidence. A custom handler owes
745
+ the same: `throw fact.retryLater(...)` on trouble, `unresolved` only for a deterministic miss.
746
+
747
+ ### What a return becomes
748
+
749
+ | Return | Frozen fact |
750
+ | --- | --- |
751
+ | `true` / `false` (ext) | `{ status: "ok", value, reason: null }` |
752
+ | `8200` (ext, `expect: "score"`) | `{ status: "ok", value: 8200, reason: null }` — an integer 0–10000, never clamped |
753
+ | `true` / `false` (ai) | `{ status: "ok", value, reason: null, failedRules: [], score: 100 }` |
754
+ | `fact.ok(...)` / `fact.ai.ok(...)` | passed through verbatim |
755
+ | `fact.error(detail)` | `{ status: "error", onError: <the manifest's>, detail }` |
756
+ | anything thrown | the same error fact, logged `facts.handler-threw` |
757
+ | `throw fact.retryLater(detail)` | **not** a fact — the workflow step retries; facts already answered are kept in the fact cache. On the step's last attempt: the error fact `still unavailable on the last attempt: <detail>` |
758
+ | anything else (a string, `undefined`, `null`, a bare object) | an error fact naming the problem: `handler returned <kind> — expected boolean/number/a fact` |
759
+
760
+ ---
761
+
762
+ ## 5. `fact` helpers
763
+
764
+ ```ts
765
+ fact.ok(
766
+ value: boolean | number,
767
+ extra?: { reason?: string | null; confidence?: number; transcript?: Transcript },
768
+ ): ExtFact
769
+
770
+ fact.ai.ok(
771
+ value: boolean,
772
+ extra?: {
773
+ reason?: string | null;
774
+ failedRules?: string[];
775
+ score?: number;
776
+ confidence?: number;
777
+ transcript?: Transcript;
778
+ },
779
+ ): AiFact
780
+
781
+ fact.error(detail: string, extra?: { transcript?: Transcript }): FactErrorDraft
782
+
783
+ fact.retryLater(detail: string): RetryLater // `throw` it
784
+ ```
785
+
786
+ | Helper | Produces | Defaults |
787
+ | --- | --- | --- |
788
+ | `fact.ok` | `{ status: "ok", value, reason }` (+ `confidence`, `transcript` when given) | `reason: null`; `confidence` omitted unless passed (integer basis points 0–10000) |
789
+ | `fact.ai.ok` | `{ status: "ok", value, reason, failedRules, score }` (+ `confidence`, `transcript`) | `reason: null`, `failedRules: []`, `score: 100`. `score` is confidence in the **verdict** (0–100), not input quality |
790
+ | `fact.error` | `{ status: "error", detail }` — a **draft** | The runner stamps the `onError` |
791
+ | `fact.retryLater` | A `RetryLater` error. Throwing it surfaces as `AppError.unavailable`, so the workflow step retries | — |
792
+
793
+ **`fact.error` takes no `onError` and there is no way to pass one.** The runner stamps the
794
+ manifest's own value over whatever a handler produced, in both directions. That is the invariant the
795
+ whole design rests on: a handler can never turn its own failure into an approval.
796
+
797
+ ---
798
+
799
+ ## 6. `FactTools`
800
+
801
+ Built **per fact** and injected, so `stock()` means "the stock lane for *this* fact". The type
802
+ parameter is which fact that is: `ExtTools = FactTools<ExtFact>` in an ext handler, `AiTools =
803
+ FactTools<AiFact>` in an ai one — which is why `return tools.stock()` type-checks with no cast.
804
+ `unique`/`frequency` handlers see the bare union, and there `stock()` throws.
805
+
806
+ ### `tools.stock(): Promise<ExtFact | AiFact>`
807
+
808
+ Runs this fact's stock lane with **exactly** the input the runner would have handed it, and
809
+ normalizes the answer the same way. Wrap it, fall back to it, or ignore it — but never call it
810
+ *and* the declared endpoint yourself: that is two calls, and two charges, for one fact.
811
+
812
+ In a `unique`/`frequency` handler it throws `AppError.internal` explaining that such a handler
813
+ replaces the lane wholesale.
814
+
815
+ ```ts
816
+ const base = await tools.stock();
817
+ if (base.status === "error") return fact.error(`stock lane: ${base.detail}`);
818
+ return base;
819
+ ```
820
+
821
+ ### `tools.http.json<T>(url, init?): Promise<HttpJsonResult<T>>`
822
+
823
+ ```ts
824
+ interface HttpJsonInit {
825
+ method?: "GET" | "POST"; // default "GET"
826
+ headers?: Record<string, string>;
827
+ body?: unknown; // POST only — sent as JSON with a content-type
828
+ timeoutMs?: number; // default 15_000, per attempt
829
+ attempts?: number; // default 3
830
+ // Sign the call as this oracle, for THE HOST OF `url` (audience did:web:<host>, never named by
831
+ // you): `true` ⇒ { can: "*", with: "ixo:external-source" }; `{ capability }` ⇒ yours
832
+ ucan?: true | { capability: { can: string; with: string } };
833
+ }
834
+ interface HttpJsonResult<T> {
835
+ ok: boolean; // a 2xx on the last attempt
836
+ status: number | null; // null = NO HTTP response at all on any attempt
837
+ body: T | null; // the parsed JSON, null when absent or not JSON
838
+ transcript: Transcript; // hand it back on the fact
839
+ }
840
+ ```
841
+
842
+ - **Never throws for an HTTP outcome.** A 404 and a 500 come back as results, because only the
843
+ handler knows whether a 404 means "no such batch, so false" or "unanswerable".
844
+ - **Retry budget:** up to `attempts`, exponential backoff (1 s, 2 s, …), a ~30 s whole-budget cap.
845
+ A 5xx, a `408` or a `429` burns an attempt and retries; any other 4xx ends the loop on the first
846
+ answer (the same request gets the same answer, so `ok: false`, that `status`, `attempts: 1`).
847
+ Pass `attempts: 1` for one shot even on a 5xx.
848
+ - **SSRF guard:** the URL is held to `assertPublicHttpUrl` (http/https only; `localhost`, private
849
+ IPv4/IPv6 ranges and internal suffixes refused) because a handler's URL often comes from a claim
850
+ answer. A refusal is an **outcome** — `{ ok: false, status: null, attempts: 0 }` — not a throw.
851
+ Redirects are followed by hand, at most 3, each hop held to the same guard; a hop to a private
852
+ host, an https → http downgrade or a malformed `Location` ends the call as `status: null`, never
853
+ retried. A hop to **another origin** carries none of your headers but `accept` and
854
+ `content-type` — no `authorization`, no vendor key like `x-api-key`, no idempotency key — so a
855
+ UCAN minted for one host, or a secret meant for one API, never reaches another. The whole call,
856
+ redirects included, stays inside the ~30 s budget.
857
+ - **`ucan`:** signs the call as this oracle (below, "How an external source is called") for the
858
+ URL's own host. This is the way to call a source that authenticates you: the audience cannot
859
+ drift from where the token goes.
860
+ - **Transcript:** the last attempt's request and response, the attempt count and elapsed ms.
861
+ Credentials never reach it. A header is dropped when its name ends in `key` (`x-api-key`,
862
+ `apikey`, `X-RapidAPI-Key`, `Ocp-Apim-Subscription-Key`, `api_key` …) or contains
863
+ `authorization`, `token`, `secret`, `password`, `credential` or `signature`
864
+ (`proxy-authorization`, `cf-aig-authorization`, `x-auth-token` …), and `cookie` / `set-cookie`
865
+ always go; `idempotency-key` is kept. In the recorded URL, the value of a query parameter whose
866
+ name contains `key`, `token`, `secret`, `password`, `signature`, `sig`, `auth` or `credential`
867
+ becomes `[redacted]` (`?api_key=[redacted]`); the names and the rest of the URL are kept. A
868
+ response body over 64 KiB is recorded as `{ truncated: true, bytes, sha256 }` — the result's own
869
+ `body` is still whole. Name the response type or `body` is `unknown`.
870
+
871
+ ### Signing a call as this oracle: `http.json(url, { ucan })`
872
+
873
+ A self-signed UCAN invocation from this oracle's `did:web:<ORACLE_HOSTNAME>` to the did:web of the
874
+ host `url` names, minted per `http.json` call (300 s TTL, a nonce, never cached — the same token
875
+ rides that call's retries and same-origin redirect hops) and sent as
876
+ `authorization: Bearer <token>` + `x-auth-type: ucan`. `ucan: true` signs
877
+ `{ can: "*", with: "ixo:external-source" }` — exactly what the stock connector presents, so a
878
+ handler calling a source by hand looks identical to it. Pass your own capability when your API
879
+ wants one; it is **yours**, set in code — never `expected.capability` (the rubric's, which the
880
+ oracle ignores; see below).
881
+
882
+ ```ts
883
+ const res = await tools.http.json(expected.endpoint, {
884
+ method: "POST",
885
+ ucan: true,
886
+ body: { ...send, claimBody: answers },
887
+ });
888
+
889
+ // Your API scopes its callers? Name the capability it checks — in YOUR code:
890
+ await tools.http.json("https://api.example/shipments", {
891
+ ucan: { capability: { can: "shipments/read", with: "did:web:api.example" } },
892
+ });
893
+ ```
894
+
895
+ An IXO service capability — any `ixo:` resource other than exactly `ixo:external-source`, or any
896
+ resource with whitespace or a control character in it — is refused with a plain `Error`. The runner
897
+ freezes that as an `error` fact carrying the rubric's `onError` (`facts.handler-threw`): a
898
+ deterministic negative, recorded, never a retry.
899
+
900
+ ### `tools.http.ucanHeaders(audience, capability?): Promise<Record<string, string>>`
901
+
902
+ The same token as a header pair, for a call you make yourself — kept for compatibility. **The
903
+ audience must be the did:web of the host you send it to**; nothing checks that for you, so prefer
904
+ `http.json(url, { ucan })`, which binds it. The same capability default and the same refusal
905
+ apply.
906
+
907
+ ### How an external source is called
908
+
909
+ What the stock `ext` lane (and a handler calling `http.json(endpoint, { method: "POST", ucan: true,
910
+ … })`) sends, so the API behind a source knows what to expect:
911
+
912
+ - **Request:** `POST <endpoint>` with a JSON body — the rubric's `send` map resolved against the
913
+ claim (`$path` answers, `ctx.*` values, literals) plus the whole credentialSubject under the
914
+ reserved `claimBody` key (a `send` entry of that name loses).
915
+ - **Headers:** `Authorization: Bearer <UCAN>`, `X-Auth-Type: ucan`,
916
+ `Idempotency-Key: <claimId>:<name>` — a retry of the same fact carries the same key.
917
+ - **The UCAN:** issued by this oracle's `did:web:<ORACLE_HOSTNAME>`, audience the **endpoint's own
918
+ did:web** (`did:web:<endpoint host>`, a `:port` percent-encoded as `%3A`), capability
919
+ `{ can: "*", with: "ixo:external-source" }` by default or the one your handler passes, a 300 s
920
+ expiry and a nonce (every token is unique).
921
+ - **Redirects:** followed at most 3 times, each hop re-checked against the public-host guard (no
922
+ https → http downgrade); a hop to another origin carries only `accept` / `content-type` — never
923
+ the `Authorization` or `Idempotency-Key`. The token is one per fact's call: its retries and
924
+ same-origin hops reuse it.
925
+ - **What the API may verify:** the issuer's key from `https://<ORACLE_HOSTNAME>/.well-known/did.json`,
926
+ that the audience is itself, and the expiry — and, if it keeps a list, which oracles it answers.
927
+ It may equally ignore the token and answer anyone; the capability is a label the oracle's
928
+ developer chose, not a grant anyone else issued.
929
+ - **Response:** `200` with `{ "valid": boolean }` (`expect: "boolean"`) or
930
+ `{ "score": 0..10000 }` (an integer, `expect: "score"`), optional `reason`. A 5xx, `408`, `429`,
931
+ network error or contract miss is retried in the call (3 attempts, ~30 s, a `Retry-After` honoured
932
+ when the budget allows). **Down is not "no":** if the last attempt was still a 5xx, `408`, `429`
933
+ or no answer at all, the lane throws `unavailable` and the workflow step retries (about 4 hours
934
+ of backoff), so an outage never becomes a decision. Only a definitive answer becomes an `error`
935
+ fact carrying the rubric's `onError`: any other 4xx (one call), or a malformed or off-contract
936
+ `2xx`. On the step's **last** attempt an outage that is still there freezes as that `error` fact
937
+ too, so it is bounded.
938
+
939
+ **The rubric's `capability` is ignored, and the audience is bound to the endpoint.** A rubric
940
+ belongs to the protocol — written by its owner and shared by every oracle serving it — so it may say
941
+ *where* to call and *what* to send, never what this oracle's key grants. The key's grants are its
942
+ holder's: the stock lane always signs `ixo:external-source`, logs `ext.source.capability-ignored`
943
+ (warn) when the manifest declares one, and signs only when `audience` is exactly the endpoint's own
944
+ did:web. A mismatch signs and sends nothing: the fact is `error` with the rubric's `onError` and the
945
+ detail `the source's audience … must be the endpoint's own did:web …` (transcript `attempts: 0`).
946
+ `decision-oracle protocol review` and `rubric validate` print both findings per source, and exit `1` on
947
+ a refused source; `facts scaffold` writes no stub for one — the rubric is what has to change.
948
+
949
+ **No IXO service capability ever leaves for an external call.** The engine and the payment
950
+ facilitator accept a token self-signed by this oracle's did:web *as this oracle*, so a capability on
951
+ `ixo:eval-engine`, `ixo:ixo-pay-facilitator`, `ixo:eval-oracle`, `ixo:oracle/…` or any other `ixo:`
952
+ resource except `ixo:external-source` is refused by the stock lane, `http.json({ ucan })` and
953
+ `ucanHeaders` alike — as an `error` fact with the rubric's `onError`, never a crash. The check
954
+ becomes redundant once those services accept oracle authority only from the oracle itself; it
955
+ stays until then.
956
+
957
+ ### `tools.ai.judge(instructions, inputs, opts?): Promise<AiFact>`
958
+
959
+ The stock judge with **your** rules: the configured AI provider, a static compliance-evaluator prompt with your
960
+ numbered ruleset appended, the typed inputs last, strict structured output.
961
+
962
+ - **Policy gate:** `settings.allowAiChecks === false` ⇒ returns `error / ai-checks-disabled` with
963
+ **no model call**. Never approve on a check that did not run.
964
+ - **Model:** `opts.model` overrides the pinned `AI_CHECK_MODEL`.
965
+ - **Images:** sent at the pinned `AI_IMAGE_DETAIL` (default `high`), and the transcript records it.
966
+ - **Provider:** `AI_PROVIDER` — the Cloudflare AI Gateway (the `AI` binding + `AI_GATEWAY_ID`) by
967
+ default, OpenRouter with `OPENROUTER_API_KEY`. A missing piece throws `AppError.internal` — loud,
968
+ so the step retries rather than freezing a verdict on a config fault.
969
+ - **Audio/video:** sent only with `AI_AUDIO_VIDEO=true` (OpenRouter only); otherwise each such
970
+ attachment reaches the model as a "content not sent" descriptor.
971
+ - **Idempotency:** each call is keyed `${claimId}:${factName}#${n}`, where `n` counts calls within
972
+ one fact's tools. The counter restarts with the tools, so a step retry replays the same keys, and
973
+ two calls under one name can never collide on the provider's cache.
974
+ - **Three outcomes per rule:** the strict schema is `{ failed_rules, undeterminable_rules, reason,
975
+ valid, score }`. A rule the readable evidence refutes **fails**; a rule the model cannot judge
976
+ either way because an input is too blurred, dark, cropped, truncated or not sent is
977
+ **undeterminable**. Any undeterminable rule makes the fact an `error` (the rubric's `onError`,
978
+ detail `undeterminable: R1 (<rule text>) — <model reason>`), never `valid: false`, even when
979
+ another rule failed: an unreadable photo is not evidence that the work was not done.
980
+ - **Comparison guidance:** when two or more images reach the model (a before/after pair), the
981
+ prompt adds a COMPARISON section: changes the work or the moment cause (dirt or growth removed, a
982
+ colour revealed by cleaning, repairs, light, colour cast, angle, crop) are expected and never by
983
+ themselves mean a different subject; sameness is judged from what the work cannot change (shape,
984
+ structure, fixed markings, fittings, surroundings). Rubric authors no longer need to write that
985
+ into each rule.
986
+ - **Verdict:** `valid` is derived **in code** from `failed_rules` (the model's own `valid` is
987
+ ignored); `score` is clamped 0–100; a score below `AI_CHECK_MIN_CONFIDENCE` (default 60) is the
988
+ unreachable path (an `error` fact), never a trusted verdict. So is any non-2xx, unparseable body,
989
+ schema miss or empty completion. A 5xx, `408`, `429`, network error, malformed answer or a score
990
+ below the floor is retried (each attempt a fresh sample, and a fresh paid call); any other 4xx
991
+ ends the loop after that one call. A 5xx, `408`, `429`, network
992
+ error or AI Gateway credits exhausted that is still there after the in-call attempts THROWS
993
+ `unavailable` (the workflow step retries; the fact cache keeps every answer already paid for), and
994
+ freezes as the `error` fact only on the step's last attempt.
995
+ - **`onError`:** inherited from the AI check being answered; `"review"` anywhere else.
996
+ - **Timeout:** 3 minutes per attempt (a file-bearing call needs room), under the same 3-attempt
997
+ budget. On the gateway an attempt past its deadline is aborted, and the gateway gets its own
998
+ deadline 2 s sooner (`cf-aig-request-timeout`), so the provider call ends too.
999
+ - The transcript freezes the model id, the recorded request (attachments as CID + digest, never
1000
+ base64), the raw response, the provider's call id as `genId` (OpenRouter's generation id, or the
1001
+ gateway log id) and `usage`. `usage` (tokens + USD) is set only when the provider reports cost at
1002
+ call time (OpenRouter); on the gateway it is `null` and the USD of every billed attempt (a retry
1003
+ included) lands in the `ai_calls` table within ~5 minutes (cron `ai-cost-backfill`, from the
1004
+ gateway's log). A log not found for an hour, or with no price, is closed `unavailable`; the log
1005
+ API failing pauses the job without closing anything, unless it has refused a row for a day. `unique.aiCostUsd` is
1006
+ omitted when no judged pair has a known cost — never a fake 0.
1007
+
1008
+ ```ts
1009
+ return tools.ai.judge(["The photo shows a sealed parcel"], input.inputs);
1010
+ ```
1011
+
1012
+ ### `tools.ai.inputsFor(check): AiSendEntry[]`
1013
+
1014
+ The declared `send` map as the judge's typed, ordered entries — what `AiInput.inputs` already is,
1015
+ exposed so you can re-slice it (drop the text, keep the images, reorder, add your own).
1016
+
1017
+ ```ts
1018
+ const images = tools.ai.inputsFor(input.check).filter((e) => e.kind === "attachment");
1019
+ ```
1020
+
1021
+ ### `tools.attachments`
1022
+
1023
+ ```ts
1024
+ on(question: string): Attachment[];
1025
+ bytes(att: Attachment, opts?: { maxBytes?: number; timeoutMs?: number }): Promise<AttachmentBody>;
1026
+ size(att: Attachment): Promise<number | null>;
1027
+ ```
1028
+
1029
+ - `on` filters `claim.attachments` by the **root question name** — `"deliveryPhoto"`, not
1030
+ `"$deliveryPhoto"`. Use `rootQuestionName("$deliveryPhoto.0")` to get one from a `$ref`.
1031
+ - `bytes` defaults `maxBytes` to **10 MiB** — the same cap the AI lane uses, deliberately, so a
1032
+ handler reading a file itself never quietly gets a different limit. `content-length` is checked
1033
+ *before* the body is read, and the cap holds *while* it is read: a body streamed without a length
1034
+ (the VFS) stops at the cap, and its `size` is then `maxBytes + 1` — a lower bound, never counted
1035
+ further. Returns
1036
+ `{ ok: true, bytes }` · `{ ok: false, reason: "too-large", size }` · `{ ok: false, reason: "unfetchable" }`.
1037
+ "Too large" and "could not be read" are different answers: the first is a known file you can still
1038
+ describe, the second taught you nothing.
1039
+ - `size` reads `content-length` without consuming the body. A store that answers 200 with no
1040
+ `content-length` (the VFS streams its files, so the header never arrives) has its body counted
1041
+ instead, up to 25 MiB. `null` on any failure, an empty body, or past that cap — and `null` is what
1042
+ the engine's null rule then decides on.
1043
+ - The store's auth (the bot UCAN, or a fresh single-use VFS token per request) is sent **only** to
1044
+ that store's host; a `data:` URL carries its own bytes. `bytes` and `size` follow at most 3
1045
+ redirects, each hop held to the public-host guard (no https → http downgrade), and a hop to
1046
+ another origin carries none of the store's auth; a refused hop is `unfetchable` / `null`.
1047
+
1048
+ ```ts
1049
+ const [photo] = tools.attachments.on("handoverPhoto");
1050
+ if (!photo) return fact.ok(false, { reason: "no handover photo on the claim" });
1051
+ const body = await tools.attachments.bytes(photo, { maxBytes: 5_000_000 });
1052
+ if (!body.ok) return fact.error(`photo could not be read: ${body.reason}`);
1053
+ ```
1054
+
1055
+ ### `tools.c2pa.screen(bytes, mediaType?): Promise<Provenance | null>`
1056
+
1057
+ Reads C2PA "Content Credentials" off image bytes and verifies the certificate chain against the
1058
+ official trust list. Returns `null` on **anything inconclusive** — no manifest (the common case: a
1059
+ screenshot strips it), unreadable bytes, any internal error. Fail-safe by construction: it can only
1060
+ ever add a rejection.
1061
+
1062
+ `Provenance` is `{ vendor, issuer?, model?, modelVersion?, aiGenerated, watermarked, signedAt?,
1063
+ trusted, failures[] }`. **`trusted` is the only thing that makes the rest mean anything** — anyone
1064
+ can self-sign a manifest naming any issuer.
1065
+
1066
+ Note the asymmetry: `policies.allowImageChecks` gates the **stock** AI lane's provenance
1067
+ short-circuit, not this call. A handler that asks for the read gets it.
1068
+
1069
+ ```ts
1070
+ const provenance = await tools.c2pa.screen(body.bytes, photo.mediaType);
1071
+ if (!provenance) return fact.ok(true, { reason: "no Content Credentials on the photo" });
1072
+ return fact.ok(!(provenance.trusted && provenance.aiGenerated));
1073
+ ```
1074
+
1075
+ ### `tools.chain` — the chain, read-only
1076
+
1077
+ Deep imports are blocked by the package `exports`, so these reads ARE a handler's access to
1078
+ Blocksync and the chain. Generic on purpose: nothing here knows what an agent card is. The act lane
1079
+ (signing) stays the package's, never a handler's.
1080
+
1081
+ ```ts
1082
+ interface ChainTools {
1083
+ linkedResources(entityDid: string): Promise<LinkedResource[]>;
1084
+ claimIntent(params: {
1085
+ agentAddress: string;
1086
+ collectionId: string;
1087
+ before: string;
1088
+ after?: string;
1089
+ }): Promise<{ time: string; transactionHash: string } | null>;
1090
+ query(): Promise<QueryClient>;
1091
+ }
1092
+ ```
1093
+
1094
+ - **`linkedResources(entityDid)`** — what an entity anchors on chain (`#vct`, `#rub`, a card…),
1095
+ each with its `proof` content hash. The same read the package's own rubric lane uses. To read
1096
+ one's bytes, hand it to [`readLinkedDoc`](#ixo-decision-oraclefacts--node-safe-what-a-handler-imports)
1097
+ (Vault, IPFS or Matrix, checked against that `proof`).
1098
+ - **`claimIntent({ agentAddress, collectionId, before, after? })`** — the newest `MsgClaimIntent`
1099
+ this agent filed on this collection before `before` (normally the claim's submission date),
1100
+ **bare or authz-wrapped**: a delivery clock and the tx hash to cite for it, or `null` when it
1101
+ filed none. Pass the agreement's consent time as `after` so an earlier engagement's intent can
1102
+ never become the clock.
1103
+ - **`query()`** — the `@ixo/impactxclient-sdk` query client for this oracle's network (balances,
1104
+ grants, module state). The SDK is heavy, so it loads on **first call** and the client is memoised
1105
+ per isolate; a handler that never asks never pays for it.
1106
+
1107
+ **There is no GENERAL message-history read, and a handler must not build one.** `claimIntent` is the
1108
+ one lookup an oracle gets about what preceded a claim, and it is here so it stays ONE server-side
1109
+ filtered query: Blocksync leaves `messages.from` NULL on an intent, so the indexer matches the agent
1110
+ and collection inside the message's jsonb `value` (Postgres `@>`), bounds the time on the
1111
+ transaction, and returns at most one row. An intent filed through authz — what the portal and the
1112
+ boilerplate chain client both do — is nested inside a `/cosmos.authz.v1beta1.MsgExec` with an ARRAY
1113
+ `value`, and `@>` is type-exact inside the json (the indexer's spelling of `collectionId` is its own
1114
+ choice too), so each `or` arm carries its own `typeUrl`: two shapes × two id spellings, four arms,
1115
+ still one round trip. Paging the chain for anything is a bug, not a fact.
1116
+
1117
+ **An empty answer is a fact; an unreachable indexer is not.** `[]` means "this entity anchors
1118
+ nothing" and `null` means "this agent filed no intent" — answer on either. Blocksync being down
1119
+ throws `AppError.unavailable`, which the runner lets
1120
+ through as the workflow step's retry signal, so a chain outage retries instead of freezing a verdict.
1121
+ Catch it only when "could not read the chain" is genuinely the fact you mean, and then say so with
1122
+ `fact.error`.
1123
+
1124
+ ```ts
1125
+ // "Is the offer still the version we agreed to?" — an ext fact answered from the chain, not an
1126
+ // endpoint. An entity that anchors nothing answers `[]`; a Blocksync outage throws and the step
1127
+ // retries, so neither can read as a pass.
1128
+ const cardUnchanged: ExtHandler = async ({ send }, tools) => {
1129
+ const { oracleEntityDid, cardProof } = send;
1130
+ if (typeof oracleEntityDid !== "string" || typeof cardProof !== "string") {
1131
+ return fact.error("the rubric's send map names no oracle entity / card proof to compare");
1132
+ }
1133
+ const card = (await tools.chain.linkedResources(oracleEntityDid)).find((r) => r.id === "#acard");
1134
+ if (!card?.proof) return fact.error(`${oracleEntityDid} anchors no readable #acard`);
1135
+ return card.proof === cardProof;
1136
+ };
1137
+ ```
1138
+
1139
+ ### `tools.agreement(counterparty?): Promise<Agreement | null>`
1140
+
1141
+ The engine's buyer statement for this collection: `{ id, digest, counterparty, doc }`, where `doc`
1142
+ is never interpreted by the package beyond its `kind`.
1143
+
1144
+ - **Omit `counterparty`** for the claim's own agent (`claim.metadata.agentAddress`) — the usual
1145
+ question.
1146
+ - **Pass an explicit `null`** for the collection's own terms, the statement that names no
1147
+ counterparty. The two are different questions and do not collapse.
1148
+ - Returns `null` (and warns `facts.agreement-no-engine` once per fact) when the build carries no
1149
+ engine client — tests and CLI dry runs.
1150
+
1151
+ **Read it LAST**, right before returning the fact it feeds: the engine records the snapshot current
1152
+ at decision time on its receipt, so reading it last is what makes the receipt name the version your
1153
+ facts actually used.
1154
+
1155
+ **The agreement is read once per claim and shared.** The kit memoises the read per build, keyed by
1156
+ the counterparty asked for, so five handlers calling `tools.agreement()` cost ONE engine round trip
1157
+ and — the part that matters — all judge against the same snapshot. That is the kit's cache, not a
1158
+ licence for a handler-private one: the runner's fact cache is still the only place an *answer* is
1159
+ kept.
1160
+
1161
+ ### `tools.logger` / `tools.config` / `tools.env`
1162
+
1163
+ - `logger` — structured (`debug`/`info`/`warn`/`error`, `child()`), landing in the oracle's Workers
1164
+ Logs stream. Never log answer values or `claim.media`.
1165
+ - `config` — the validated `Config`: `IXO_NETWORK`, the eight resolved per-network URLs, hostnames,
1166
+ model and policy values. Secrets are on it too; do not log them.
1167
+ - `env` — the step's worker env, so a handler can reach **its own** binding or secret. It is a
1168
+ getter that **throws** `AppError.internal("tools.env: this build carries no worker env")` in tests
1169
+ and CLI dry runs, where there is none — loud beats reading a binding off `undefined` three lines
1170
+ later.
1171
+
1172
+ ```ts
1173
+ const { COURIER_API_KEY } = tools.env as typeof tools.env & { COURIER_API_KEY?: string };
1174
+ if (!COURIER_API_KEY) return fact.error("COURIER_API_KEY is not configured on this oracle");
1175
+ ```
1176
+
1177
+ ---
1178
+
1179
+ ## 7. What the runner guarantees
1180
+
1181
+ One code path runs every handler, stock or yours, so these hold identically for both.
1182
+
1183
+ 1. **Normalization.** A plain `true` / `8200` becomes an `ok` fact; a `fact.error` draft becomes a
1184
+ full error fact; a full fact passes through verbatim; anything else becomes an error fact that
1185
+ *names* the bad return.
1186
+ 2. **Fail closed.** An error fact carries the **manifest's** `onError`, stamped here, overriding
1187
+ whatever the handler put there. A throw is the same thing.
1188
+ 3. **One retry signal.** Only `fact.retryLater()` — and the package's own `AppError`s, thrown by the
1189
+ stock lanes for a signing/config fault or an outage (the ext and AI lanes' 5xx / 429 / timeout /
1190
+ AI credits exhausted) — get out, as `unavailable`, so the workflow step retries instead of
1191
+ freezing a verdict. Every other throw is an error fact, logged `facts.handler-threw`. Bounded: on
1192
+ the evaluate step's last attempt a `retryLater` or an `unavailable` freezes as the rubric's
1193
+ `onError` (`still unavailable on the last attempt: …`, logged `facts.retries-exhausted`), so an
1194
+ outage that outlives every retry still ends as a decision, never as a dead ingest.
1195
+ 4. **The fact cache.** Every resolved `ext`/`ai` fact is written to this oracle's D1 as it lands and
1196
+ read back *once*, before any handler runs, on a later attempt of the same claim under the same
1197
+ rubric: a hit is returned verbatim — transcript, error status and all — and **the handler never
1198
+ runs**. So a crashed step never re-buys a paid AI or vendor call. A re-anchored `#rub` is a new
1199
+ work order: its facts run again, even the same-named ones. `unique`/`frequency` never cache:
1200
+ registry state and calendar windows must be fresh per attempt. Both sides are fail-soft — a cache
1201
+ error means "no cache", never a failed fact.
1202
+ 5. **The fan-out cap.** At most **4** ext+ai handlers in flight (`MAX_CONCURRENT_CONNECTORS`); the
1203
+ isolate allows 6 outbound connections and a 30-check rubric must degrade to "slower", not to a
1204
+ socket storm. `unique`/`frequency` run beside the pool, one read each. **Do not add your own
1205
+ concurrency.**
1206
+ 6. **Unused names.** A handler for a name the manifest does not declare is skipped with one
1207
+ `facts.handler-unused { kind, name }` warning — including a `unique`/`frequency` handler written
1208
+ for a rubric that declares no such rule.
1209
+ 7. **`ai["*"]`.** The wildcard catches every check not named in the table; a named handler wins.
1210
+ 8. **Groups.** A `group([...], handler)` runs **once** per claim and takes **one** slot in the cap;
1211
+ its grouped names never also run the single/stock lane. Every guarantee above then applies per
1212
+ NAME — own `onError`, own cache entry, a crash frozen on each — plus: a name the handler omitted
1213
+ is that name's `handler returned no fact for <name>` error, and a name nobody asked for is
1214
+ dropped with a `facts.group-extra-fact` warning. Two handlers for one name is a **boot** error.
1215
+ 9. **Derived refs.** The stock lanes refuse a `send` map naming a `~derived` ref (an `error` fact
1216
+ with the rubric's `onError`, logged `stock.derived-unsupported`). A **custom** handler is not
1217
+ short-circuited: it gets the refs in `unresolved` and the expressions on `manifest.derived`.
1218
+ 10. **Rule-less frequency.** A `frequency` declaration without `max`/`per` is warned
1219
+ (`stock.frequency-unrunnable`) and omitted from the FactSet — unless you wrote a custom handler,
1220
+ which is asked with `rule: null`.
1221
+ 11. **Coverage.** `validateFacts` (§9) runs over the whole answer before anything is frozen.
1222
+
1223
+ And around the runner, in the build: `ctx` is resolved **before** any handler runs (one clock read,
1224
+ one Blocksync read), and after the FactSet is frozen there is no network, no clock and no AI
1225
+ anywhere downstream. That boundary is the replay guarantee a review fold depends on.
1226
+
1227
+ ---
1228
+
1229
+ ## 8. The work order — `Manifest`
1230
+
1231
+ What the engine's binder resolved the protocol's rubric to want, read from
1232
+ `GET /v1/rubrics/manifest?protocol=<did>` (public, KV-cached for 5 minutes). **This oracle never
1233
+ parses a rubric** — the manifest is the only work order, and `bunx decision-oracle protocol review <did>`
1234
+ prints it in plain words.
1235
+
1236
+ | Field | Type | What it is |
1237
+ | --- | --- | --- |
1238
+ | `rubricId` | `string` | The verified rubric id — sha256 of its anchored bytes, canonical lowercase |
1239
+ | `formProof` | `{ pinned, live: string \| null, drifted: boolean }` | The rubric's pinned `#vct` proof vs the live claim form. `drifted` is the engine's call; `protocol review` prints a warning for it |
1240
+ | `ext` | `Record<string, ManifestExt>` | Every declared external source |
1241
+ | `ai` | `Record<string, ManifestAi>` | Every declared AI check |
1242
+ | `hasAiChecks` | `boolean` | Shorthand for "this rubric spends on a model" |
1243
+ | `ctx` | `string[]` | The `ctx.*` tokens the rules name explicitly |
1244
+ | `fields` | `{ path: string; kind: string }[]` | Claim-form catalog paths the rules read, with the live form's kinds |
1245
+ | `derived` | `{ name: string; expression: string }[]` | `~name` values the **engine** computes — with the source expression, so a custom handler can reproduce one |
1246
+ | `review` | `{ tasks: string[]; taskDetails?: { id, prompt, answer }[] }` (optional) | The human-review tasks a `pending_review` decision can name. `answer` is `{ type: "boolean" \| "enum" \| "number" \| "text" }` (enum carries `options`). `taskDetails` also lists the engine's synthetic tasks after the rubric's: `unique:<ruleId>` (a possible duplicate) and `checks:held` (held only by a failed check), answered `"approve"` or `"reject"` — never a boolean |
1247
+ | `unique` | `ManifestUnique \| null` (optional) | `ruleId` plus the full post-default rule: `by?`, `countPrior?`, `attachments?`, `ai?`, `thresholds?` |
1248
+ | `frequency` | `ManifestFrequency \| null` (optional) | `ruleId`, `max?`, `per?`, `onBreach?` |
1249
+ | `sizeProbes` | `{ path: string; maxSizeMB: number }[]` (optional) | The **bare root question names** a `maxSizeMB` rule reads, with the minimum limit declared. The only attachments the package probes a byte size for |
1250
+
1251
+ `ManifestExt`: `expect` (`"boolean"` ⇒ the fact's `value` is a boolean · `"score"` ⇒ an integer
1252
+ 0–10000), `onError` (`"review"` \| `"reject"`, always present), `endpoint`, `audience` (the source's
1253
+ `did:web` — it must be the endpoint's own, or the stock lane does not call it), `capability?` (the
1254
+ rubric's; **ignored** — the oracle's developer owns the capability), `send`, `timeoutMs?`, `usedBy`
1255
+ (`"gate:<code>"` / `"criterion:<code>"` — every rule whose bound deps read this fact).
1256
+
1257
+ `ManifestAi`: `expect: "boolean"` (v1 checks are yes/no only), `onError`, `instructions` (the
1258
+ plain-language rules — **all** must hold for a `true`), `send`, `usedBy`.
1259
+
1260
+ Every object is parsed **loosely**: the engine may add fields without a wire bump, so an unknown key
1261
+ is tolerated, never fatal.
1262
+
1263
+ **The unusable answer.** A rubric the engine cannot resolve or bind comes back as
1264
+ `{ status: "unusable", issues: [{ code, message, path }] }` — a `200`, not an HTTP error. That is
1265
+ recorded as the claim's outcome with **no handler run and no decide call**, and the collection is
1266
+ paused `rubric-unusable`. There is nothing to build against until the rubric or its `#vct`/`#rub`
1267
+ anchoring is fixed.
1268
+
1269
+ ---
1270
+
1271
+ ## 9. Wire facts, and what `validateFacts` refuses
1272
+
1273
+ These are the shapes that ride to the engine inside a `FactSet v1`. The FactSet itself is **strict**
1274
+ (the engine 400s on an unknown key) and is assembled by the package around your answer: `version`,
1275
+ `frozenAt` (identical to `ctx.now`), `answers`, `attachments`, `ctx`, `ext`, `ai`, `unique?`,
1276
+ `frequency?`, `settings`.
1277
+
1278
+ | Shape | Fields | Notes |
1279
+ | --- | --- | --- |
1280
+ | `ExtFact` (ok) | `status: "ok"`, `value: boolean \| number`, `reason: string \| null`, `confidence?`, `transcript?` | Strict. `confidence` is integer basis points 0–10000 |
1281
+ | `ExtFact` (error) | `status: "error"`, `onError: "review" \| "reject"`, `detail: string`, `transcript?` | Strict |
1282
+ | `AiFact` (ok) | `status: "ok"`, `value: boolean`, `reason: string \| null`, `failedRules: string[]`, `score: 0..100`, `confidence?`, `transcript?` | Strict. `value` is derived in code, never the model's own |
1283
+ | `AiFact` (error) | same as `ExtFact` (error) | Strict |
1284
+ | `UniqueFact` | `ruleId`, `digest`, `scoreBps: 0..10000`, `band: "pass" \| "review" \| "reject"` | **Loose** — everything else you freeze (winner, candidates, `aiOutcome`, `aiPairs`, …) rides into the engine's trace |
1285
+ | `FrequencyFact` | `ruleId`, `count: int`, `max: int`, `per?`, `windowStartISO?`, `windowEndISO?`, `breach: boolean`, `unresolved?: true` | **Loose**. `count`/`max` compose the engine's reason text; `breach`/`unresolved` are its branches |
1286
+ | `AttachmentFact` | `question`, `name`, `mediaType`, `cid: string \| null`, `size: number \| null` | The package builds these; bytes never leave Matrix |
1287
+ | `Transcript` | `request { url, body, headers }`, `response { status, body } \| null`, `attempts: int`, `elapsedMs: int`, `latencyMs?`, `genId?`, `usage?`, `provenance?` | Optional on the wire; the stock lanes always produce one. `headers` never carries a credential header and `url` has credential query values masked (`http.json` above); a `response.body` over 64 KiB is `{ truncated: true, bytes, sha256 }`; `response: null` means no HTTP response at all |
1288
+
1289
+ ### Coverage: `FactIssue` codes
1290
+
1291
+ `validateFacts` mirrors the engine's own fact-coverage check and runs **before** anything is frozen
1292
+ or sent, so a producer bug is loud here instead of a billed `unusable` there. `runFacts` returns the
1293
+ same issues.
1294
+
1295
+ | Code | Raised when | Path |
1296
+ | --- | --- | --- |
1297
+ | `FACT_MISSING` | A declared `ext`/`ai` name was not produced; a declared `unique`/`frequency` rule produced no fact. For a `frequency` declaration the engine sent without `max`/`per`, the message is `manifest lacks max/per` | `/facts/ext/<name>` · `/facts/ai/<name>` · `/facts/unique` · `/facts/frequency` |
1298
+ | `FACT_EXTRA` | A name the rubric never declared is present (the engine would ignore it, but no rule can ever read it — so here it is a bug); a `unique`/`frequency` fact with no declared rule | same |
1299
+ | `FACT_KIND` | `expect: "boolean"` but the value is not a JS boolean; `expect: "score"` but the value is not an **integer 0–10000** (never clamped); a `unique`/`frequency` fact naming a different `ruleId` than the manifest | same |
1300
+ | `FACT_ON_ERROR` | An error fact carries an `onError` other than the rubric's (only reachable from a whole-producer — the runner stamps it) | same |
1301
+ | `FACT_SHAPE` | The fact fails its wire schema; the `ext`/`ai` lane is not an object | `/facts/ext` · `/facts/ai` · per-name paths |
1302
+
1303
+ A failed validation is recorded as `invalid_facts` for the claim and fails the ingest loudly. It is
1304
+ never sent.
1305
+
1306
+ ---
1307
+
1308
+ ## 10. Testing — `@ixo/decision-oracle/testing`
1309
+
1310
+ `runFacts` is the **same runner** production uses, minus the fact cache and D1, followed by the
1311
+ **same** `validateFacts`. `issues: []` means the engine would accept this FactSet.
1312
+
1313
+ ### `runFacts(facts, input)`
1314
+
1315
+ ```ts
1316
+ interface RunFactsInput {
1317
+ claim: PulledClaim; // sampleClaim(...)
1318
+ manifest: Manifest; // sampleManifest(...)
1319
+ tools?: FactTools; // default: fakeTools()
1320
+ ctx?: CtxFacts; // default: sampleCtx()
1321
+ settings?: FactSettings; // default: { allowAiChecks: true, allowImageChecks: true }
1322
+ /** Per-lane replacements for what an unlisted name — and `tools.stock()` — runs instead. */
1323
+ stock?: Partial<{
1324
+ ext: ExtHandler;
1325
+ ai: AiHandler;
1326
+ unique: UniqueHandler;
1327
+ frequency: FrequencyHandler;
1328
+ }>;
1329
+ }
1330
+ // → Promise<ProducedFacts & { issues: FactIssue[] }>
1331
+ ```
1332
+
1333
+ Both policy lanes are open by default: a handler under test should run, not be gated by a policy the
1334
+ test never mentioned. Pass `settings` to exercise the gated path.
1335
+
1336
+ The default `stock` lanes: `ext` and `ai` both call `tools.stock()` (so `fakeTools({ stock })` is
1337
+ what answers); `unique` **throws** `no stock unique lane in tests — pass stock.unique or a
1338
+ facts.unique handler`; `frequency` returns the fail-closed `unresolved` fact.
1339
+
1340
+ `runFacts` also binds `tools.attachments.on` and `tools.ai.inputsFor` to the claim you passed (they
1341
+ are pure functions of it, and `fakeTools` has no claim). Only the `fakeTools` stubs are replaced —
1342
+ an implementation you wrote yourself survives.
1343
+
1344
+ A `groups` entry runs here exactly as it does in production: declare every grouped name on the
1345
+ `sampleManifest`, assert each one's fact, and test the half that matters — the failed call, where
1346
+ one `fact.error` has to come back for *every* name the group owed.
1347
+
1348
+ ### `fakeTools(overrides?)`
1349
+
1350
+ | Override | Type | Default |
1351
+ | --- | --- | --- |
1352
+ | `http` | `Record<string, FakeHttpRoute>` | `{}`. A **matched** URL answers its value as the body under a `200`; an `httpResponse(status, body)` envelope sets the status; a **function** of `init` returns either. An **unmatched** URL is a network failure: `{ ok: false, status: null, body: null }` with `attempts: 0` — exactly what the real lane produces for a URL it refused to call |
1353
+ | `stock` | `ExtFact \| AiFact \| (() => …)` | `{ status: "error", onError: "review", detail: "stock lane is not available in tests" }`. There is no external source and no model behind a test; the runner re-stamps the manifest's `onError` over it, exactly as in production |
1354
+ | `ai.judge` | `(instructions, inputs, opts?) => AiFact` | `fact.ai.ok(true)` |
1355
+ | `attachments` | `Record<string, Uint8Array>` | `{}`, keyed by the attachment's **url**. `bytes` → `{ ok: false, reason: "unfetchable" }` when absent; `size` → the byte length or `null` |
1356
+ | `c2pa` | `Provenance \| null` | `null` |
1357
+ | `agreement` | `Agreement \| null` | `null` |
1358
+ | `chain` | `{ linkedResources?: Record<string, LinkedResource[]>; claimIntent?: ClaimIntent \| null \| ((q: ClaimIntentQuery) => ClaimIntent \| null); query?: QueryClient }` | An entity DID the map does not name anchors `[]` (what the real read answers); `claimIntent` is `null` — an agent that filed nothing — unless you supply one, and a **function** is handed the `ClaimIntentQuery` so a test can assert on what was asked (or throw, to stand in for a blocksync outage, which a handler must let through rather than read as "no intent"); `query()` **throws** "not available in tests" unless you supply a client |
1359
+ | `env` | `Record<string, unknown>` | `{}` — reaches the handler as `tools.env` |
1360
+
1361
+ Plus, always: `logs: LogRecord[]` collecting everything the handler logged (`{ level, msg, data? }`,
1362
+ in order); `chainCalls: ChainCall[]` collecting every `tools.chain` call in order
1363
+ (`{ call: "linkedResources", entityDid }` / `{ call: "claimIntent", params }` / `{ call: "query" }`),
1364
+ so a test can assert on the entity its handler read and on the intent query it asked
1365
+ (`ClaimIntentQuery` — `{ agentAddress, collectionId, before, after? }`, exported from
1366
+ `@ixo/decision-oracle/testing`); a real `config` from the same Zod schema the Worker validates at boot (so
1367
+ `tools.config.IXO_NETWORK` is `"devnet"`, not a stub with holes); and `http.json(url, { ucan })` /
1368
+ `http.ucanHeaders` producing a readable fake token (`Bearer fake-ucan-for-<audience>[-<can>-<with>]`,
1369
+ the audience `did:web:<url host>` for `ucan`) so a test can assert on the audience and capability
1370
+ its handler addressed — an IXO service capability throws there exactly as in production.
1371
+
1372
+ `httpResponse(status, body)` is a branded envelope — it exists precisely so that
1373
+ `{ body: "hello", sender: "bob" }` reads as a *body*, not a status/body pair.
1374
+
1375
+ ### `sampleClaim(answers, opts?)`
1376
+
1377
+ | Option | Default |
1378
+ | --- | --- |
1379
+ | `claimId` | `"claim1"` |
1380
+ | `collectionId` | `"1"` |
1381
+ | `agentAddress` | `null` (the dup/rate lanes key on it) |
1382
+ | `attachments` | `[]` — each entry is `{ question, name, mediaType, url?, cid? }`; `url` defaults to `""`, and `url` is what `fakeTools({ attachments })` is keyed by, so give each one its own as soon as more than one needs bytes |
1383
+
1384
+ `network` is `"devnet"`, `lane` is `"cellnode"` and `media` is `null` (the no-store posture: nothing
1385
+ to fetch bytes from in a test), and the claim is an ordinary unpaid, non-escrow one.
1386
+
1387
+ ### `sampleManifest(decl?)`
1388
+
1389
+ Only `expect` (a source) and `instructions` (a check) must be given; everything else is the call
1390
+ detail the *stock* lane would need, which a handler under test usually ignores.
1391
+
1392
+ | Key | Default filled in |
1393
+ | --- | --- |
1394
+ | `ext[name]` | `onError: "review"`, `endpoint: "https://source.example/<name>"`, `audience: "did:web:source.example"`, `send: {}`, `usedBy: []` |
1395
+ | `ai[name]` | `expect: "boolean"`, `onError: "review"`, `send: {}`, `usedBy: []` |
1396
+ | `unique` / `frequency` | key present **only** when declared — the runner produces such a fact iff it is there |
1397
+ | `fields` / `derived` / `ctx` | `[]` |
1398
+
1399
+ `rubricId` is 64 zeros, `formProof` is undrifted, `hasAiChecks` is derived from the `ai` keys.
1400
+
1401
+ ### `sampleCtx(over?)`
1402
+
1403
+ `now: "2026-01-01T00:00:00.000Z"`, an empty collection window and an empty claim plane — one fixed
1404
+ instant, so digests repeat. Override any field.
1405
+
1406
+ ### Three worked tests
1407
+
1408
+ **An ext handler over a canned route.** Cover both halves — the answer, and the failure it will
1409
+ actually meet:
1410
+
1411
+ ```ts
1412
+ import { fakeTools, httpResponse, runFacts, sampleClaim, sampleManifest } from "@ixo/decision-oracle/testing";
1413
+ import { facts } from "../src/facts";
1414
+
1415
+ const manifest = sampleManifest({
1416
+ ext: {
1417
+ deliveryConfirmed: {
1418
+ expect: "boolean",
1419
+ onError: "review",
1420
+ endpoint: "https://courier.example/deliveries",
1421
+ send: { reference: "$reference" },
1422
+ },
1423
+ },
1424
+ });
1425
+
1426
+ it("answers ok when the courier says delivered", async () => {
1427
+ const out = await runFacts(facts, {
1428
+ claim: sampleClaim({ reference: "ABC" }),
1429
+ manifest,
1430
+ tools: fakeTools({
1431
+ env: { COURIER_API_KEY: "test-key" },
1432
+ http: { "https://courier.example/deliveries": { status: "delivered" } },
1433
+ }),
1434
+ });
1435
+ expect(out.issues).toEqual([]);
1436
+ expect(out.ext.deliveryConfirmed).toMatchObject({ status: "ok", value: true });
1437
+ });
1438
+
1439
+ it("answers error — never false — when the courier sends a body it cannot read", async () => {
1440
+ const out = await runFacts(facts, {
1441
+ claim: sampleClaim({ reference: "ABC" }),
1442
+ manifest,
1443
+ tools: fakeTools({
1444
+ env: { COURIER_API_KEY: "test-key" },
1445
+ http: { "https://courier.example/deliveries": httpResponse(422, { detail: "bad ref" }) },
1446
+ }),
1447
+ });
1448
+ expect(out.ext.deliveryConfirmed).toMatchObject({ status: "error", onError: "review" });
1449
+ });
1450
+ ```
1451
+
1452
+ **An ai handler that forwards to stock.** Can the stock lane, so the forward proves something —
1453
+ and test the guard with a stock lane that *would* have said yes:
1454
+
1455
+ ```ts
1456
+ const manifest = sampleManifest({
1457
+ ai: {
1458
+ handoverPhotoLooksReal: {
1459
+ instructions: ["The photo shows an original handover, not a screenshot"],
1460
+ onError: "review",
1461
+ },
1462
+ },
1463
+ });
1464
+
1465
+ it("hands a claim that carries a photo to the stock lane", async () => {
1466
+ const out = await runFacts(facts, {
1467
+ claim: sampleClaim({ reference: "ABC" }, {
1468
+ attachments: [{
1469
+ question: "handoverPhoto",
1470
+ name: "handover.jpg",
1471
+ mediaType: "image/jpeg",
1472
+ url: "https://media.example/handover.jpg",
1473
+ }],
1474
+ }),
1475
+ manifest,
1476
+ tools: fakeTools({ stock: fact.ai.ok(true, { reason: "reads as original", score: 90 }) }),
1477
+ });
1478
+ expect(out.issues).toEqual([]);
1479
+ expect(out.ai.handoverPhotoLooksReal).toMatchObject({ status: "ok", value: true, score: 90 });
1480
+ });
1481
+
1482
+ it("guards the missing photo before the judge is ever reached", async () => {
1483
+ const out = await runFacts(facts, {
1484
+ claim: sampleClaim({ reference: "ABC" }),
1485
+ manifest,
1486
+ tools: fakeTools({ stock: fact.ai.ok(true) }),
1487
+ });
1488
+ expect(out.ai.handoverPhotoLooksReal).toEqual({
1489
+ status: "error",
1490
+ onError: "review",
1491
+ detail: "the claim carries no handoverPhoto attachment",
1492
+ });
1493
+ });
1494
+ ```
1495
+
1496
+ **A custom unique lane.** Declaring the rule is what makes the runner ask for the fact:
1497
+
1498
+ ```ts
1499
+ const manifest = sampleManifest({ unique: { ruleId: "no-double-claims" } });
1500
+
1501
+ it("bands a vendor score against the rule's own thresholds", async () => {
1502
+ const out = await runFacts(facts, {
1503
+ claim: sampleClaim({ reference: "ABC" }),
1504
+ manifest,
1505
+ tools: fakeTools({
1506
+ http: { "https://registry.example/duplicates": { digest: "d1", scoreBps: 6000 } },
1507
+ }),
1508
+ });
1509
+ expect(out.issues).toEqual([]);
1510
+ // Defaults from the engine's own schema: reject ≥ 9000, review ≥ 5000.
1511
+ expect(out.unique).toMatchObject({ ruleId: "no-double-claims", band: "review" });
1512
+ });
1513
+ ```
1514
+
1515
+ ---
1516
+
1517
+ ## 11. Configuration
1518
+
1519
+ ### Bindings (`OracleBindings`) — names are the contract, never rename one
1520
+
1521
+ | Binding | Type | What it backs |
1522
+ | --- | --- | --- |
1523
+ | `DB` | `D1Database` | Every ledger: watched collections, claim ingests, decisions, reviews, the duplicate registries, the fact cache, Matrix delivery markers. Migrations **ship with the package** — point `migrations_dir` at `node_modules/@ixo/decision-oracle/migrations` and never run drizzle-kit |
1524
+ | `CACHE_KV` | `KVNamespace` | Caches only: UCAN principals, peer `did:web` documents, the engine-registered marker, the rubric manifest (5 min), chain grants, claim-bot tokens, the C2PA trust list. Nothing here is a source of truth |
1525
+ | `API_RATE_LIMITER` | `RateLimit` | The coarse per-IP guard on the whole API. `limit`/`period` live in `wrangler.jsonc`, not in code |
1526
+ | `EMAIL` | `SendEmail` (optional) | Cloudflare Email Sending for pause/resume/review notices, the API lane's member and review emails, and the `balance.low` email. Absent ⇒ the email lane no-ops |
1527
+ | `API_KEY_RATE_LIMITER` | `RateLimit` (optional) | The Decision API lane's per-key / per-source limit — its own namespace, read only with `API_LANE` on |
1528
+ | `API_UPLOADS` | `R2Bucket` (optional) | The Decision API lane's uploads. Absent ⇒ `PUT /v1/api/uploads` answers 501 `UPLOADS_DISABLED`; URL attachments still work |
1529
+ | `ANSWERS_ARCHIVE` | `R2Bucket` (optional) | The answer archive ([guide §12](./guide.md#12-operating-it), "The answer archive"): every evaluated ask and answer under `<askId>-<slug>/` — `about.json`, then per claim `answer.json`, `claim.json` (the body verbatim) and the attachment bytes, each with custom metadata; shaped by `OracleConfig.archive`. Bound ⇒ on; absent ⇒ nothing is archived. Fail-soft: an R2 failure never fails an evaluation. Holds exact locations and photos — keep the bucket private |
1530
+ | `METRICS` | `AnalyticsEngineDataset` (optional) | Workers Analytics Engine: request, upstream, cron and cache metrics you can chart as p50/p95/p99 and rates ([guide §12](./guide.md#12-operating-it)). Absent ⇒ every metric is a `msg = "metric"` log line only |
1531
+ | `CLAIM_WORKFLOW` | `Workflow` | `ClaimIngest` — one instance per claim |
1532
+ | `REVIEW_WORKFLOW` | `Workflow` | `ReviewComplete` — the review fold |
1533
+ | `SEQUENCE_MANAGER` | `DurableObjectNamespace` | The SDK's `SequenceManagerDO`: one instance per signer, allocating account sequence numbers so concurrent chain writes never race |
1534
+
1535
+ Your own bindings ride alongside — the type is open (`OracleEnv & YourEnv`); only the names above
1536
+ are reserved.
1537
+
1538
+ ### Vars and secrets (`OracleVars`)
1539
+
1540
+ Non-secret vars live in `wrangler.jsonc` `vars`, **redeclared per named environment** (named envs
1541
+ inherit no bindings or vars). Secrets live in `.dev.vars` locally and `wrangler secret put <NAME>`
1542
+ once deployed. Every value is validated by Zod at isolate start, so a bad env fails loudly and early.
1543
+
1544
+ | Var | Where | Default | Meaning |
1545
+ | --- | --- | --- | --- |
1546
+ | `ENVIRONMENT` | vars | `development` | `development` \| `staging` \| `production`. `development` is the only one where an external source may be plain `http` |
1547
+ | `LOG_LEVEL` | vars | `info` | `debug` \| `info` \| `warn` \| `error` |
1548
+ | `LOG_PRETTY` | `.dev.vars` only | `false` | Colorized local logs. Deployed envs must emit JSON — that is what Workers Logs ingests |
1549
+ | `SERVICE_NAME` / `SERVICE_VERSION` | vars | `eval-oracle` / `0.0.0` | Log and `/health` identity |
1550
+ | `CORS_ORIGINS` | vars | `*` | Comma-separated origins, or `*` |
1551
+ | `CRON_COLLECTIONS_PER_TICK` | vars | `25` | Watched collections visited per cron tick. A fleet larger than this rotates in across ticks, least-recently-scanned first. Raise it for a large fleet |
1552
+ | `IXO_NETWORK` | vars | `devnet` | `devnet` \| `testnet` \| `mainnet` — supplies eight URLs below |
1553
+ | `EVALUATION_DENOM` / `EVALUATION_MIN_AMOUNT` | vars | unset — **required when `IXO_NETWORK` is `mainnet`** | The evaluation fee for this network: the denom a collection's `Payments.evaluation` must be in (matched exactly) and its minimum, an integer base-unit string. Set both or neither (one alone fails at isolate start). When set they replace `pricing.evaluation` at register and on `/v1/info`; a `pricing.byProtocol` entry still wins for its protocol. No default in code: on mainnet an unset pair fails config validation at isolate start, so a stand-in coin written in `defineOracle` can never be charged there. The template sets `uixo` / `1000000` on devnet and testnet and Noble USDC (`ibc/6BBE9BD4246F8E04948D5A4EEE7164B2630263B9EBB5E7DC5F0A46C62A2FF97B`, 6 decimals) / `1000000` on production |
1554
+ | `BLOCKSYNC_GRAPHQL_URL` | vars | per network | Override only for a self-hosted indexer |
1555
+ | `CHAIN_RPC_URL` | vars | per network | The SDK query client and the chain write |
1556
+ | `MATRIX_HOMESERVER_URL` | vars | per network | Fallback home server when an entity declares none |
1557
+ | `UCAN_STORE_URL` | vars | per network | Where an owner→oracle delegation is fetched from |
1558
+ | `VFS_URL` | vars | per network | The IXO Virtual Filesystem — the first claim-body stores: the collection's claims lane (read under your Evaluate grant), then public copies |
1559
+ | `IPFS_GATEWAY_URL` | vars | per network | The IPFS gateway a claim body is read from when neither the VFS nor Matrix has it |
1560
+ | `PORTAL_BASE_URL` | vars | per network | Deep links in notices. No trailing slash |
1561
+ | `EVAL_ENGINE_HOSTNAME` | vars | per network | The engine you ask for decisions. A host you **call** — its `did:web` is the invocation audience. Set it for a local engine or a tunnel |
1562
+ | `CLAIM_BOT_URL_OVERRIDE` | `.dev.vars` | derived | Local harnesses only — the bot URL is normally derived as `https://claim.bot.<homeserver domain>` |
1563
+ | `IXO_CLIENT_SEQUENCE_MIN_DELAY_MS` | vars | `1000` | Read by the sequence keeper off `env`: the minimum gap between two sequence allocations |
1564
+ | `ORACLE_HOSTNAME` | vars | **required** | *Your* canonical `did:web` host. Every inbound invocation's audience is pinned to this, never the request `Host`; it is also the issuer of every invocation you mint. It must equal the Custom Domain in `routes` for that env |
1565
+ | `ORACLE_DID` | vars | **required** | `did:ixo:<address>` — the runtime chain identity |
1566
+ | `ORACLE_ADDRESS` | vars | **required** | Must be the `ORACLE_SECP_MNEMONIC` wallet's address — asserted at first use, fails loud |
1567
+ | `ORACLE_MATRIX_USER_ID` | vars | **required** | The MXID derived from `ORACLE_DID` (cross-checked, never re-derived) |
1568
+ | `ORACLE_ENTITY_DID` | vars | unset | The on-chain oracle **entity** (`did:ixo:entity:…`, from `oracle init`) — a different DID from `ORACLE_DID`. **Required for a deployed oracle**: unset while `ORACLE_HOSTNAME` is not local (`localhost`, `127.0.0.1`, `*.localhost`), readiness fails its `identity` check (`/health/ready` 503) — the worker still boots, so `oracle init` can read it. The portal resolves your API from its `#eval` service, so the Matrix review card names it. Unset (or `""`) ⇒ falls back to `ORACLE_DID`: the card is still delivered but can only be answered from the portal's claim page |
1569
+ | `AI_CHECK_MODEL` | vars | `openai/gpt-6-luna` | The pinned model for AI checks (any provider — the gateway's `compat` route takes the same id) |
1570
+ | `AI_PROVIDER` | vars | `cloudflare` | `cloudflare`: every AI call goes through the Cloudflare AI Gateway via the `AI` binding (`"ai": { "binding": "AI" }`), paid from Unified Billing credits or the gateway's stored keys. `openrouter`: the OpenRouter API with `OPENROUTER_API_KEY` |
1571
+ | `AI_GATEWAY_ID` | vars | unset | The gateway the `cloudflare` provider routes through. Unset (or no `AI` binding) ⇒ an AI fact throws `AppError.internal` at use |
1572
+ | `AI_AUDIO_VIDEO` | vars | `false` | Send audio/video attachments to the model. **OpenRouter only**: on with any other provider, config refuses to load. Off ⇒ each reaches the model as a "content not sent" descriptor (a change from earlier versions, where OpenRouter always got them) |
1573
+ | `AI_CHECK_MIN_CONFIDENCE` | vars | `60` | A verdict scoring below this is the unreachable path (an `error` fact), not a trusted answer |
1574
+ | `AI_IMAGE_DETAIL` | vars | `high` | `low` / `high` / `auto` — the `image_url.detail` every image rides to the model at (an AI check's photos, the same-event judge's pair evidence). `high` because a photographed screen (moiré, a pixel grid, a cursor) or a generated image (garbled text) is only visible at full resolution. **Cost:** `high` spends more image tokens per call than `low`'s flat handful — set `low` only when no check turns on fine detail |
1575
+ | `C2PA_IMAGE_CHECK` | vars | `false` | The provenance gate in the stock AI lane. Needs `policies.allowImageChecks` on too |
1576
+ | `AUTO_SIGN` | vars | `true` | Sign `MsgEvaluateClaim` unattended. Needs `policies.autoSign` on too |
1577
+ | `API_LANE` | vars | `false` | Mount the Decision API router (`/v1/api`). Off ⇒ the lane is dormant and the chain lane runs exactly as before |
1578
+ | `API_PAYMENTS` | vars | `false` | The x402 gate. Off ⇒ every new submit answers `503 SOURCE_PAUSED { reason: payments-disabled }` — nothing is accepted unpaid |
1579
+ | `API_PRICE_USD_CENTS` | vars | unset | The fallback price per decision in whole USD cents, for every protocol `pricing.api` does not price (the config wins). Unset with no `pricing.api` ⇒ `price: null` and the gate refuses loudly at use |
1580
+ | `X402_FACILITATOR_URL` | vars | unset | The x402 facilitator (verify + settle). No trailing slash. **Required** when `API_PAYMENTS` is on — config refuses to load without it |
1581
+ | `X402_NETWORK` | vars | unset | The chain every 402 names, CAIP-2: `cosmos:<chain-id>` (`cosmos:devnet-1`, `cosmos:pandora-8`, `cosmos:ixo-5`) |
1582
+ | `EARNINGS_ADDRESS` | vars | unset | Where your earnings for decisions land in PAY and USDC on IXO: your own `ixo1…` account, sent as each offer's `payTo` (the facilitator checks it, never picks one). Checked at start, checksum included. Needed only when IXO's plan for the oracle takes PAY (the engine's `GET /v1/oracles/me` `accepts` holds `upay`; KV-cached 5 min): unset then ⇒ new x402 submits answer `503 SOURCE_PAUSED { reason: payments-disabled }`. A plan without PAY (card and/or USDC on Base) sends no ixo-pay terms and never reads it; a plan the engine cannot tell is the same 503 |
1583
+ | `EARNINGS_BASE_WALLET` | vars | unset | Your Base wallet (`0x…`) for USDC on Base. Unset ⇒ that way to pay is not offered |
1584
+ | `BILLING_ENGINE_URL` | vars | unset | Charge lookups and the spend figures on `/v1/api/me`. No trailing slash |
1585
+ | `CONSOLE_URL` | vars | unset | The Decision Console — every error's `docsUrl`, the 402's `fundingUrl`, and every email link (the `balance.low` email's is `<CONSOLE_URL>/credits`). Unset ⇒ the API lane's emails are logged skips. No trailing slash |
1586
+ | `AUTH_HUB_URL` | vars | per `IXO_NETWORK` (`https://dev.auth.ixo.earth`, `https://test.auth.ixo.earth`, `https://auth.ixo.earth`) | IXO AuthHub, the OAuth server of `/v1/mcp`: the protected-resource metadata's `authorization_servers`, the exact `iss` of an access token (its JWKS at `<url>/.well-known/jwks.json`), the approve link's host, and the one `did:web` (`did:web:<its host>`) the approvals back-channel accepts. Set it only for a local AuthHub. No trailing slash |
1587
+ | `ORACLE_ADMIN_DIDS` | vars | unset | Comma-separated `did:ixo` admins of this oracle — the oracle's operator's own people — allowed on `GET /v1/admin/status` and `/v1/api/admin/*`. Unset ⇒ nobody |
1588
+ | `STATUS_READER_DIDS` | vars | unset | Comma-separated DIDs of any method (`did:web:…`, `did:ixo:…`, each checked as `did:<method>:<id>`) allowed to read `GET /v1/admin/status` and nothing else — for a monitoring service, e.g. your decision engine's status board, identified by its DID. Kept apart from `ORACLE_ADMIN_DIDS` so a monitor is never made an operator. Unset ⇒ nobody |
1589
+ | `SERVICE_ED25519_MNEMONIC` | secret | — | The `did:web` key: `sha256(mnemonic)` → Ed25519 seed. Signs every UCAN invocation you mint and backs `/.well-known/did.json` |
1590
+ | `ORACLE_SECP_MNEMONIC` | secret | — | The chain account key (secp256k1) that signs `MsgEvaluateClaim`. **Unset ⇒ the chain-write lane logs one line and skips** — the decision itself is unaffected |
1591
+ | `OWNER_DELEGATION_CAR` | secret | — | The oracle owner's UCAN delegation to the engine (base64 CAR), sent once at register so the engine can find and bill their subscription. Its issuer is the oracle's owner, who may not review (`oracle-owner`). **Unset ⇒ register refuses, loudly** |
1592
+ | `ORACLE_MATRIX_ACCESS_TOKEN` | secret | — | The oracle bot's Matrix token, **produced by `decision-oracle oracle init`** (its `matrix` step creates the account and writes this line into `.dev.vars`; `wrangler secret put` it for a deployed env). Unset ⇒ every notification lane logs one line and skips; no claim is ever blocked on it |
1593
+ | `OPENROUTER_API_KEY` | secret | — | Only with `AI_PROVIDER=openrouter`: required when a rubric asks for an AI fact (or a `unique` rule with its judge on) |
1594
+ | `UNIQUE_HMAC_KEY` | secret | — | Keyed digest for duplicate detection and for recording an API submit's `submitter`. **Required when `API_LANE` is on** (the worker refuses to start without it) and when a rubric declares `unique`. **One long-lived secret per oracle** — rotating it orphans every registered digest |
1595
+ | `WEBHOOK_ENCRYPTION_KEY` | secret | — | Encrypts the Decision API lane's bearer secrets at rest (`v1:` AES-256-GCM): webhook signing secrets (every endpoint's and each source's own), every verified `PAYMENT-SIGNATURE` (on `api_charges.paymentHeader` and the KV `x402-paid:` copy — it embeds the payer's reusable payment delegation; rows written before 2026-10-14 are sealed by the hourly housekeeping job `header-seal`, and read either way meanwhile) and each source's review-billing delegation. **One per deployment, for life** — rotating it strands every stored secret. **Required when `API_LANE` is on**: config load fails without it |
1596
+ | `SENTRY_DSN` | secret | — | Unset ⇒ error reporting is a no-op |
1597
+ | `SENTRY_RELEASE` | stamped by your deploy | — | Never in `wrangler.jsonc` |
1598
+ | `SENTRY_TRACES_SAMPLE_RATE` | vars | `0.1` | Share of requests Sentry traces, `0`–`1` |
1599
+ | `SENTRY_ENVIRONMENT` | vars | `ENVIRONMENT` | The Sentry environment tag — set it to tell apart two deploys that share an `ENVIRONMENT` (`testnet`, `mainnet`) |
1600
+ | `OPS_ALERT_WEBHOOK_URL` | secret | — | A Slack-compatible incoming webhook for ops alerts — one message per condition per 30 minutes ([guide §12](guide.md#12-operating-it)). Unset ⇒ alerts reach Sentry only |
1601
+ | `EMAIL_FROM_ADDRESS` | secret | — | Unset ⇒ the whole email lane no-ops (the same "unset ⇒ off" contract) |
1602
+ | `EMAIL_FROM_NAME` | secret | the oracle's `name` | Display name on outbound notices |
1603
+
1604
+ After any binding or var change: `bun run cf-typegen`.
1605
+
1606
+ ### The identities behind those vars
1607
+
1608
+ Four identities, three of which have on-chain or homeserver state that the vars alone do not create.
1609
+ `decision-oracle oracle init` is what creates that state, once per network — thirteen idempotent steps
1610
+ (`fund`, `iid`, `key`, `matrix`, `entity`, `orz`, `profile`, `domain`, `acard`, `linked`, `service`,
1611
+ `backlink`, `index`), a dry run unless `--yes`, `--only <step>` for one. Only `iid`, `key` and
1612
+ `matrix` need the oracle's own mnemonics; the rest run on the AuthHub session and the public vars. **A full on-chain identity
1613
+ is mandatory**: the entity, its `#pro` profile, its `#dmn` domain card and its `#acard` Agent Card
1614
+ (validated against the engine's Agent Card schema). `--logo <url>` is optional and recommended —
1615
+ without it the portal and the console show a placeholder. `--openhands` adds a `check-openhands`
1616
+ service to the Agent Card (the OpenHands app's checker marker), priced in PAY from `/v1/info`'s
1617
+ `pricing.api` for `--protocol`, else `--price-pay <n>` (1 PAY = 1 USD).
1618
+
1619
+ **Where the documents live.** Every document the CLI anchors — `oracle init`'s `#orz`, `#pro`,
1620
+ `#dmn`, `#acard`, `publish-protocol`'s `#vct` / `#rub`, `org create`'s `#pro` — is stored on the
1621
+ IXO Vault (`POST <vfs>/api/fs/files?public=true` under a one-write `fs/write` UCAN from the AuthHub
1622
+ session's Ed25519 key, the entity owner) and anchored by its public, unauthenticated
1623
+ `<vfs>/public/cid/<cid>` URL. A Vault refusal prints one `⚠` line and the document goes to the IXO
1624
+ IPFS gateway instead (`https://ipfs.gateway.ixo.world/ipfs/<cid>`); nothing is uploaded to Matrix
1625
+ media any more. The proof rules are unchanged: sha256 hex for `#vct` / `#rub` / `#orz`, the
1626
+ `bafkrei…` CID for `#pro` / `#dmn` / `#acard`. An `oracle init` re-run moves an `#orz`, `#pro`,
1627
+ `#dmn` or `#acard` still on Matrix media onto the Vault (same content, delete + add in one tx);
1628
+ readers keep reading Matrix media (`readLinkedDoc`).
1629
+
1630
+ | Identity | What exists, and where | Who creates it | Why it matters |
1631
+ | --- | --- | --- | --- |
1632
+ | `did:web:<ORACLE_HOSTNAME>` | `/.well-known/did.json`, served by this worker from the `SERVICE_ED25519_MNEMONIC` key | you, by deploying to that host | the issuer of every invocation this oracle mints, and the audience every inbound one is pinned to |
1633
+ | `did:ixo:<ORACLE_ADDRESS>` — its **IID document** and the **Ed25519 verification method** carrying the `SERVICE_ED25519_MNEMONIC` public key (`authentication` + `assertionMethod`) | on chain | `oracle init`, steps `iid` and `key`, signed by the oracle's **own** wallet (only its key may write its own document) | the claim bot, the UCAN Store and the VFS claims lane resolve this DID and check the invocation's signature against that key. **Without the document, or without the key on it, all three answer 401** and no claim body can be pulled from Matrix or the VFS claims lane. The wallet also needs a balance (step `fund`) to sign `MsgEvaluateClaim` |
1634
+ | `ORACLE_ENTITY_DID` | the oracle **entity** on chain, with `#eval`, `#orz`, `#pro`, `#dmn` (domain card), `#acard` (Agent Card) and the protocol edges | `oracle init`, steps `entity` onwards, signed by the AuthHub session that owns it. Set (per network: `env.<network>.vars`, else top-level; `oracle init` writes it into `.dev.vars` the moment it creates the entity), it pins the entity for every later run — refused unless the entity is bound to this oracle; unset, `oracle init` finds THIS oracle's among the session's oracle entities by `#orz` granting `ORACLE_ADDRESS`, else `#eval` = `--kit-url` — never another oracle's | how the portal finds this oracle from a protocol, what the domain indexer / portal / console list it by, and what the Matrix review card resolves an API from |
1635
+ | `ORACLE_MATRIX_USER_ID` | the oracle bot's Matrix account on the network's homeserver | `oracle init`, step `matrix` — it also writes `ORACLE_MATRIX_ACCESS_TOKEN` into `.dev.vars` | the account the review-card lane posts as. No account, no token, and that lane is a logged skip |
1636
+
1637
+ ### Per-network defaults
1638
+
1639
+ `IXO_NETWORK` supplies all of these; **any var set explicitly wins**. A wrong network still fails
1640
+ loud at the first `did.json` fetch.
1641
+
1642
+ | | devnet | testnet | mainnet |
1643
+ | --- | --- | --- | --- |
1644
+ | engine host | `decision-engine.devnet.ixo.earth` | `decision-engine.testnet.ixo.earth` | `decision-engine.ixo.earth` |
1645
+ | blocksync | `devnet-blocksync-graphql.ixo.earth/graphql` | `testnet-blocksync-graphql.ixo.earth/graphql` | `blocksync-graphql.ixo.earth/graphql` |
1646
+ | rpc | `devnet.ixo.earth/rpc/` | `testnet.ixo.earth/rpc/` | `impacthub.ixo.world/rpc/` |
1647
+ | rest | `devnet.ixo.earth/rest` | `testnet.ixo.earth/rest` | `impacthub.ixo.world/rest` |
1648
+ | matrix | `devmx.ixo.earth` | `testmx.ixo.earth` | `mx.ixo.earth` |
1649
+ | ucan store | `devnet.store.ucan.ixo.earth` | `testnet.store.ucan.ixo.earth` | `store.ucan.ixo.earth` |
1650
+ | vfs | `devnet.vfs.ixo.earth` | `testnet.vfs.ixo.earth` | `vfs.ixo.earth` |
1651
+ | ipfs gateway | `ipfs.gateway.ixo.world` | `ipfs.gateway.ixo.world` | `ipfs.gateway.ixo.world` |
1652
+ | portal | `dev.portal.qi.space` | `test.portal.qi.space` | `portal.qi.space` |
1653
+ | authHub | `dev.auth.ixo.earth` | `test.auth.ixo.earth` | `auth.ixo.earth` |
1654
+ | facilitator | `facilitator.devnet.ixo.earth` | `facilitator.testnet.ixo.earth` | `facilitator.ixo.earth` |
1655
+ | relayer node | the network's relayer entity DID (used by the publishing commands only) | — | — |
1656
+
1657
+ `facilitator` is the CLI's only (the audience `api review-billing enable` signs to, as the Decision
1658
+ Console's preset does); the worker's facilitator is `X402_FACILITATOR_URL`, never this row.
1659
+
1660
+ `NETWORKS[network]` and `networkDefaults(network)` expose the same row to your code and to the CLI's
1661
+ `--network` flag.
1662
+
1663
+ ### The policy triangle
1664
+
1665
+ Three switches decide what actually runs. Each has a config half (`defineOracle`) and, for two of
1666
+ them, an env half — **both** must be on.
1667
+
1668
+ | Behaviour | Config | Env | Off means |
1669
+ | --- | --- | --- | --- |
1670
+ | Sign `MsgEvaluateClaim` unattended | `policies.autoSign` | `AUTO_SIGN` | The decision is still recorded and receipted; submitting it is a human's job |
1671
+ | Run AI checks at all | `policies.allowAiChecks` | — | Every AI fact freezes `error / ai-checks-disabled` with no model call, and the rubric's `onError` carries. The duplicate lane's judge is skipped too |
1672
+ | Screen images for Content Credentials | `policies.allowImageChecks` | `C2PA_IMAGE_CHECK` | The stock AI lane makes no provenance read. `tools.c2pa.screen()` still works — a handler asking for the read gets it |
1673
+
1674
+ ---
1675
+
1676
+ ## 12. What the package does at runtime
1677
+
1678
+ Everything in this section already exists. Do not rebuild any of it in a handler.
1679
+
1680
+ ### HTTP routes
1681
+
1682
+ Middleware order is fixed: `observability → database → secureHeaders → cors → rateLimit → bodyLimit
1683
+ → prettyJSON → routes → onError/notFound`. The body cap is 1 MiB. Every `/v1/*` route requires a
1684
+ **UCAN invocation** addressed to `did:web:<ORACLE_HOSTNAME>` (never the request `Host`) unless it is
1685
+ on the explicit public allowlist. It is signed by the caller's own key — a delegated chain (root ≠
1686
+ invoker) is `401` — and carries exactly ONE capability, `{ with: "ixo:eval-oracle", can: <action> }`,
1687
+ where the action is the route's own, named in the Auth column below (`src/middleware/capability.ts`
1688
+ `ROUTE_ACTIONS`; `HEAD` takes its `GET`'s). A different action is `403`, and so is any action on a
1689
+ route with no entry; a `can` that is none of the table's actions is `401` before it is validated. The
1690
+ legacy `can: "*"` is still accepted on every route while its callers switch, and each use is logged
1691
+ at info as `ucan.wildcard` (`route`, `invoker`) and counted as the `ucan.wildcard` metric (`route`
1692
+ only) — a strict release will refuse it. The package's own CLI (`register`, `scan`, `relink`, `erase`), the decision
1693
+ engine's status board and `examples/agent-work/scripts/e2e-devnet.ts` already sign the route's
1694
+ action; portals and consoles are the known `*` senders still to switch.
1695
+ An invocation is **short-lived**: it must carry an `exp` at most 600 s ahead, and when it states an
1696
+ `nbf`, `exp − nbf` may not exceed 600 s either (an invocation has no issued-at, so a far-future `exp`
1697
+ would otherwise pass in its last 10 minutes) — each is `401` ("valid for too long — at most 600 s",
1698
+ "it has no expiry"). The CLI signs 120 s. Replay protection is off: a token may be reused within its
1699
+ window, and the verified token is cached (in-isolate and KV) no longer than that window.
1700
+ That proves key possession, audience and the action — **authorization is each route's own identity
1701
+ check.**
1702
+
1703
+ | Route | Auth | Gate |
1704
+ | --- | --- | --- |
1705
+ | `GET /health` | public, rate-limit exempt | — |
1706
+ | `GET /health/ready` | public, **rate-limited** (per IP) | `{ ok }` only — `200` when every critical check passes, `503` otherwise. Checks `d1`, `kv`, `engine` (its `/.well-known/did.json`) and, with `API_LANE` and `API_PAYMENTS` on, `facilitator` (`X402_FACILITATOR_URL/supported`), in parallel, 2 s each, plus `identity` (a config read: fails when `ORACLE_ENTITY_DID` is unset on a non-local `ORACLE_HOSTNAME`) and `owner-delegation` (fails when `OWNER_DELEGATION_CAR` is set but is not a UCAN delegation; unset passes); the settled answer is cached per isolate for 15 s. A failure before the route runs (a config that does not load) is also `503 { ok: false }`. Names no check — the detail is `/v1/admin/status`'s |
1707
+ | `GET /.well-known/did.json` | public, rate-limit exempt | Needs `SERVICE_ED25519_MNEMONIC` |
1708
+ | `GET /.well-known/oauth-protected-resource/v1/mcp` | public, rate-limit exempt, `Access-Control-Allow-Origin: *`, `cache-control: public, max-age=300` | RFC 9728 metadata for the MCP server: `{ resource: "https://<ORACLE_HOSTNAME>/v1/mcp", authorization_servers: [AUTH_HUB_URL], scopes_supported: ["claims:write", "claims:read"], bearer_methods_supported: ["header"] }`. `404` unless `API_LANE` is on. Outside the `/v1/api` contract |
1709
+ | `GET /v1/info` | **public** (the only public `/v1` path) | —. The marketplace listing: `name`, `description`, `did`, `address`, `entityDid` (the on-chain oracle entity from `ORACLE_ENTITY_DID`, `null` when unset), `serviceDid`, `supportedProtocols`, `pricing`, `policies`, `earnings: { address, baseWallet }` (the operator's `EARNINGS_ADDRESS` / `EARNINGS_BASE_WALLET`, `null` when unset — read-only, set in the oracle's config), `payouts: { decisionToPay, operatorAmount }` — how a payout amount is chosen, for a portal to show before a company opts in: `decisionToPay` is the config's `payouts` map verbatim (per protocol DID, `{ amount: <answer path>, currency: <ISO 4217 code> | { path } | { asset, decimals } }`; `{}` when none, and a protocol absent from it gets no decision to pay), `operatorAmount` is `{ summary }` from `policies.actSummary` when an `act` policy is configured, else `null` (`actSummary` is served only here, never under `policies`), and `reviewBilling: { mode, pricePerReview }` — how a collection pays for its reviews: `mode: "x402"` (per-review billing ACTIVE: IXO's plan for the oracle allows it — the engine's `GET /v1/oracles/me` `reviewBilling`, KV-cached 5 min — on an oracle running `API_LANE`: per review from the company's credits, an empty evaluation leg) with `pricePerReview: { denom: "upay", amount }` the published per-review price (the one the supported protocols share, else `pricing.api.amountMicroUsd` / `API_PRICE_USD_CENTS`; `null` when none is published), or `mode: "escrow"` (the collection's on-chain evaluation payment, `pricing`) with `pricePerReview: null`. `cache-control: public, max-age=300`. A portal is built against these exact field names |
1710
+ | `POST /v1/collections/register` | UCAN `collections/register` | The four gates, in order: **who** (the collection entity's chain owner, or a holder of an active on-chain `EvaluateClaimAuthorization` — read uncached) · **hired** (this oracle's `ORACLE_ADDRESS` holds that grant too, else `409`) · **protocol** (in `supportedProtocols`, else `409` — checked before payment) · **paid** (`Payments.evaluation` matches `pricing`, else `402`; while per-review billing is active (IXO's plan), the collection's company — ONE engine read, `GET /v1/collections/:id/company`, its blip or a `disputed` company `503 company_unavailable` — holding a live review-billing delegation on its Decision API source, still signed by the organisation's current chain owner, with a published price and cap left, registers with an EMPTY evaluation leg (`billingSourceId` set, never billed twice) once the payer's credits cover one review: `GET /v1/balances` with the stored delegation in `X-Payment-Delegation`, both `available` and `delegationRemaining` ≥ the price, else `402 insufficient_credits` (`details.price`, `details.fundingUrl`), an unreadable answer `503 credits_unavailable`; no usable delegation ⇒ the escrow gate as before, its refusal `402 no_review_billing` naming both ways in). Body: `{ collectionId, delegationCar?, contactEmail? }`. No `contactEmail` sent and none stored ⇒ the row takes the `contactEmail` of the oldest-activated `active` Decision API source whose `companyEntityDid` is the collection's COMPANY as the engine derives it (`GET /v1/collections/:id/company`, UCAN `collection/company-read`: the collection's entity, or the org a deed is linked to both ways); a `disputed` company, a collection the engine does not know, or none such source ⇒ stays empty. Its `503` or any other failure never fails the register (logged `register.company-unavailable`, the row stays empty for a later register to fill). An explicit or stored contact is never overwritten. The SAME answer (one engine read per register) links the collection to every `active` Decision API source of that company, so its chain claims read with the source's (contracts 1.21.0: `LinkedCollection.linkedBy` = the company's entity DID, audit `source.collection_linked` by `system:company-link`, re-checked hourly against the engine and dropped `not_company` when it names another company or `disputed`; only such a positive finding hides a link — an unreachable engine keeps it in the reads, contracts 1.23.0); idempotent, and only the engine's `ok` links. The engine is asked only when no contact is sent or stored, or an `active` source is not yet linked to the collection. Every refusal carries a stable machine code in `details.reason` beside the human message: `404 collection_not_found`, `403 owner_unresolvable` · `not_owner_or_grantee` · `contact_change_forbidden` · `resume_forbidden`, `409 not_hired` · `unsupported_protocol`, `402 evaluation_underpaid` · `no_review_billing` · `insufficient_credits`, `503 company_unavailable` · `credits_unavailable`. |
1711
+ | `GET /v1/collections` | UCAN `collections/read` | The rows the caller owns or enrolled |
1712
+ | `POST /v1/collections/:id/pause` | UCAN `collections/pause` + read gate + owner, enroller (while it holds a live grant) or `ORACLE_ADMIN_DIDS` (a plain grant holder is `403`) | Sets `status: paused`, clears the reason — the cron enqueues nothing |
1713
+ | `POST /v1/collections/:id/resume` | UCAN `collections/resume` + read gate + owner, enroller (while it holds a live grant) or `ORACLE_ADMIN_DIDS` (a plain grant holder is `403`) | Clears whichever reason the engine set; sends the paired "resumed" notice on a real flip |
1714
+ | `POST /v1/collections/:id/scan` | UCAN `collections/scan` + read gate | Runs the cron's per-collection pass **now**. `409` if paused. Same dedupe and cursor rules, so an eager caller can never double-start a claim |
1715
+ | `PATCH /v1/collections/:id/settings` | UCAN `collections/settings` (never `*`) + the collection's **current** chain owner (the entity's owner, read live off Blocksync; no enroller, grant holder or `ORACLE_ADMIN_DIDS`) — `403` otherwise, `404` unwatched | `{ operatorAmounts: "off" \| "allowed" }` (strict). Whether the act seam's on-chain amount (`policies.act`, `signEvaluation`) may stand on this collection: `"off"`, the default, ⇒ the lane pays its default and logs `act.amount-ignored-not-opted-in`. Recorded with the owner who set it: `"allowed"` stands only while that address still owns the collection's entity, so after a transfer it reads `"off"` until the new owner sets it. Answers the collection row, as `GET /v1/collections` does |
1716
+ | `GET /v1/collections/:id/settings` | UCAN `collections/settings-read` (never `*`) + the collection's **current** chain owner, as the PATCH — `403` otherwise, `404` unwatched, `503` on chain trouble | `{ operatorAmounts, operatorAmountsBy, effective }`: what the owner set, the address that set it, and whether it is in force NOW — `effective` is the act lane's own reading (`operatorAmountsEffective`, `lib/ixo/chain-write.ts`): `true` only when `operatorAmounts` is `"allowed"` and `operatorAmountsBy` is the live owner, so after a transfer the value reads `"allowed"` with `effective: false` until the new owner says yes |
1717
+ | `GET /v1/claims?collectionId=&limit=&cursor=` | UCAN `claims/read` + read gate | Summary list, newest first, keyset-paginated (`limit` default 100, max 500) |
1718
+ | `POST /v1/collections/:id/reviewers` | UCAN `collections/reviewers-grant` (never `*`) + a **current controller** of the collection's entity (read live off Blocksync), who must be the grant's issuer | `{ delegation }` — a base64 CAR the controller signed: a reviewer grant (below). Checked as the review gate checks it (no revocation step), then filed in `collection_reviewers`; a grant to the collection's owner is `400 REVIEWER_GRANT_INVALID` (the owner already sits in the review room). `201` + the grant row (`memberDid`, `rootDid`, `delegationCid`, `delegation`, `expiresAt`, `revokedAt`, `createdAt`). The same CAR again answers the same row; a revoked one is `409` (sign a new one). `400 REVIEWER_GRANT_INVALID` / `403 REVIEWER_NOT_AUTHORIZED` as the gate, `404` unwatched |
1719
+ | `GET /v1/collections/:id/reviewers` | UCAN `collections/reviewers-read` (never `*`) | A current controller: `{ reviewers: [...] }`, every grant on the collection; a reviewer: only their own, CAR included (the Portal sends it with an answer); anyone else `403` |
1720
+ | `POST /v1/collections/:id/reviewers/:delegationCid/revoke` | UCAN `collections/reviewers-revoke` (never `*`) + a current controller | Stamps `revokedAt` (once) and kicks the reviewer from the collection's review room. Every later answer and every pending fold under that grant is refused. `404` for an unknown grant |
1721
+ | `GET /v1/claims/:claimId` | UCAN `claims/read` + read gate, or a live reviewer of the claim's collection (a filed, unrevoked grant inside its window whose root is still a controller — read live, so Blocksync down is `503`), or the claim's own submitter (matched by EITHER the claim's on-chain agent DID or its agent address, invoking as themselves; one Blocksync claim read, cached a day — a claim Blocksync does not know yet is not cached — tried only after the other doors; Blocksync down is `503`) | The full record: ingest ⟕ decision ⟕ review. The submitter sees the decision and its public receipt (which names who reviewed), but never the review's answers: `review.answers` and `review.reviewerDid` come back `null`, and so do the internal failure texts `ingest.error`, `decision.chainError` and `decision.erroredFacts`. `404` when this oracle has not seen the claim. The submitter gets no other read: not the list, not `/v1/reviews`, not another claim |
1722
+ | `DELETE /v1/claims/:claimId/body` | UCAN `claims/erase` (never `*`) + the collection's **current** chain owner (read live off Blocksync; the enroller, grant holders and `ORACLE_ADMIN_DIDS` are `403`) — `404` when this oracle has not seen the claim or it is a Decision API claim, `503` on chain trouble | `204`; `409 CONFLICT` while the claim is still `queued` or `processing` (erase it once it is decided). Erases the claim's answer copies now: strips every `transcript` from `decisions.facts` (the AI prompts and HTTP payloads; the fact values stay), nulls the duplicate registry's `normalizedText`, drops its fact-cache rows, and stamps `claim_ingests.erasedAt`. The decision, its receipt and the registry digests stay, so the receipt still verifies and duplicates are still caught. Idempotent: `204` again, the first `erasedAt` kept. Logged `claims.erased`, counted `claims.erased` (`op: chain`). The CLI's `decision-oracle erase` loops over it |
1723
+ | `GET /v1/reviews/:claimId` | UCAN `reviews/read` + read gate, or a live reviewer of the claim's collection | The pending tasks, the stored answers, why a fold failed, and `erroredFacts` (below; null when none errored) |
1724
+ | `GET /v1/admin/status` | UCAN `admin/status` + `ORACLE_ADMIN_DIDS` or `STATUS_READER_DIDS` (`403` otherwise — everyone, while both are unset) | `{ readiness: { ok, checks: { <name>: { ok, ms, critical, detail? } } }, backlog: { review: { pending }, collections: { paused: { total, <reason>: n } }, sources?: { paused }, webhooks?: { due, oldestAgeS } }, engine: { billingStatus, manageSubscriptionUrl } \| null }` — `sources`/`webhooks` only with `API_LANE` on; `engine` is the engine's `GET /v1/oracles/me`, `null` when it did not answer within 2 s; `detail` is set only on a failed `identity` or `owner-delegation` check and names the command to run. Outside the `/v1/api` contract |
1725
+ | `POST /v1/admin/collections/relink` | UCAN `admin/collections-relink` (strict: never `*`) + `ORACLE_ADMIN_DIDS` (`403` otherwise) | The operator's backfill of register's company links. Body `{ collectionIds?: string[] }` (1–100 ids); absent ⇒ every watched collection, lowest id first, up to 100 (`truncated: true` says more exist — name them). Per collection, the same engine read and the same link register makes (no `active` source left unlinked ⇒ `unchanged`, no engine call) → `{ items: [{ collectionId, outcome: "linked" \| "unchanged" \| "no-company" \| "disputed" \| "unavailable" \| "not-watched", linkedSources }], truncated }`. Idempotent. CLI: `decision-oracle relink [--collection <id>]…`. Outside the `/v1/api` contract |
1726
+ | `GET /v1/oauth/approvals/:approvalId` | UCAN `oauth/approval/read`, invoker AuthHub's `did:web` only (`403` otherwise; `*` is never accepted here); no `X-Auth-Type` needed | What AuthHub's approve page shows: `{ did, grantId, clientName, protocol: { did, name \| null }, price: { display, maxAmountUpay }, billingMode: "x402" \| "none", status, expiresAt }` (`expiresAt` in unix seconds). `404` unless the approval exists, is `pending` and has not expired. Never the delegation. `404` without `API_LANE`. Outside the `/v1/api` contract |
1727
+ | `POST /v1/oauth/approvals/:approvalId` | UCAN `oauth/approval/approve`, same invoker rule | `{ decision: "approve", did, delegation: string \| null }` or `{ decision: "decline", did }` → `{ status: "approved" \| "declined" }`. `404` unless pending and `did` is the approval's. For billing `x402` the delegation must be a payment delegation (`parsePaymentDelegation`) whose root issuer is `did`, whose `oracles` name this oracle's did:web, whose audience is this oracle's facilitator (`X402_FACILITATOR_URL`'s did.json), whose `maxAmount` is exactly `maxAmountUpay` (a total across rails: more would be standing authority), and which has not expired — else `400 VALIDATION` with `details.reason` one of `invalid_delegation`, `issuer_mismatch`, `oracle_mismatch`, `facilitator_mismatch` (also logged `oauth.approval.facilitator_mismatch` with both DIDs: AuthHub and this oracle name different facilitators), `amount_mismatch`, `expired`, and the approval stays pending. `price.display` is informational; AuthHub computes what it shows from `maxAmountUpay`. For `none` nothing is kept. Approving gives the app 15 minutes to use it. Audited as `oauth.approval_approved` / `oauth.approval_declined` (actor kind `oauth`). Outside the `/v1/api` contract |
1728
+ | `GET /v1/oauth/standing/:grantId` | UCAN `oauth/standing/read`, AuthHub's `did:web` only, never `*` | A grant's auto-approve: `{ perClaimUpay, totalUpay, spentUpay, until }` (upay strings, unix seconds), never the delegation. `404` when there is none, it is revoked, `until` has passed, or `policies.allowStandingApprovals` is off |
1729
+ | `PUT /v1/oauth/standing/:grantId` | UCAN `oauth/standing/set`, same | `403 FORBIDDEN` (`details.reason: standing_approvals_off`) unless `policies.allowStandingApprovals`. `{ did, delegation, perClaimUpay, totalUpay, until }` replaces it: the same delegation checks (`maxAmount` = `totalUpay`, expiration ≥ `until`); `did` must be the grant's user as this oracle has seen it (its standing, else an approval) — else `404`. `spentUpay` is never reset (the total is this auto-approve's total across every delegation): a total below it is `400 VALIDATION` (`details.reason: limits_invalid`), nothing changed |
1730
+ | `DELETE /v1/oauth/standing/:grantId` | UCAN `oauth/standing/revoke`, same | `{ did }`: deletes it, delegation and all; `200` whether or not it existed; another user's `did` is `404` |
1731
+ | `POST /v1/reviews/:claimId` | UCAN `reviews/submit` (never `*`) + **the reviewer gate** (below) — never the read gate: an evaluate grant alone does not answer | `{ answers: { taskId → boolean \| string }, attestation, reviewerGrant }` — the answers must cover **exactly** the pending tasks (all-or-nothing). `202` + the review row, and the `ReviewComplete` workflow spawns. Answered-once until folded: a re-POST before the fold replaces the answers and the grant and re-spawns (that is also how a `foldError` is cleared); after the fold it `409`s |
1732
+
1733
+ **The oracle never deletes an organisation's claim data on its own** (owner, 2026-10-11; company
1734
+ authority D12). Nothing expires a chain claim's transcripts or registry text, nor an API claim's
1735
+ answers, uploads, webhook payloads, events, charges or audit rows: the organisation erases a claim
1736
+ by hand — the collection's owner with `DELETE /v1/claims/:claimId/body` (above, or
1737
+ `decision-oracle erase`), an API source with `DELETE /v1/api/claims/:claimId/body`, which also deletes
1738
+ the claim's uploads; an upload no claim names goes with `DELETE /v1/api/uploads/:uploadId`
1739
+ (`uploads/delete`, the same `settings/manage` say; `409` for a claim's file, `204` for one already
1740
+ gone). Either erase is `409 CONFLICT` while the claim is still being decided — a running evaluate
1741
+ would write the transcripts back — so erase it once it is decided. The hourly
1742
+ housekeeping sweep only expires what is not the organisation's data: idempotency answers (24 h),
1743
+ OAuth approvals and auto-approvals past their window, and the objects an organisation's own action
1744
+ marked (an export bundle after its day, a deleted source's files).
1745
+
1746
+ **Reviewer grants (company authority).** Only a reviewer the company named answers a chain
1747
+ collection's reviews. A current controller of the collection's entity (Blocksync
1748
+ `entity.controller`, `did:ixo:ixo1…` accounts only) signs them one `@ixo/ucan` delegation and files
1749
+ it with `POST /v1/collections/:id/reviewers`; the reviewer sends that grant as `reviewerGrant`
1750
+ with every answer, the answer stores — and the fold forwards — the bytes the controller filed (not
1751
+ the reviewer's copy), and the engine checks its root again and records
1752
+ it in the receipt (`review.root`, `review.memberDid`). The gate (`lib/review/reviewer-grant.ts`)
1753
+ runs at the answer **and again inside the fold**, before the re-pull, so a revoke between the two
1754
+ refuses the fold with no engine call:
1755
+
1756
+ 1. **The CAR** — standard, padded base64 (decoding and re-encoding gives back the very string;
1757
+ base64url or unpadded is refused, as on every CAR this oracle accepts, the Decision Console's
1758
+ included); exactly one delegation, no proofs, no extra block; its signature verifies against
1759
+ a key the issuer's `did:ixo` publishes on chain; its capabilities are exactly
1760
+ `[{ with: "ixo:oracle/<this oracle's did:web>/collection/<collectionId>", can: "claims/review" }]`
1761
+ (no wildcard); issuer and audience are two different `did:ixo:ixo1…` accounts; `nbf` and `exp`
1762
+ are both set, `exp − nbf` ≤ 30 days (UCANs carry no issued-at), `nbf ≤ now + 60 s` (the
1763
+ engine's clock skew) and `now < exp`.
1764
+ 2. **The reviewer** — its audience is the invoking principal (= the attestation's `issuer`).
1765
+ 3. **Revocation** — its canonical CID is a `collection_reviewers` row of this collection, not revoked.
1766
+ 4. **No oracle** — none of `ORACLE_DID`, `did:ixo:<ORACLE_ADDRESS>`, the did:web or
1767
+ `ORACLE_ENTITY_DID` is in it (`oracle`), nor the oracle's **owner** — the issuer of
1768
+ `OWNER_DELEGATION_CAR`, read once per isolate; skipped when it is unset (`oracle-owner`). The
1769
+ engine refuses the owner's fold, so it is refused at the answer.
1770
+ 5. **Live root** — the issuer is a current controller of the collection's entity (uncached).
1771
+ 6. **No evaluator** — neither the issuer nor the reviewer holds an `EvaluateClaimAuthorization` on
1772
+ the collection (read uncached): that power already signs `MsgEvaluateClaim` and moves the
1773
+ collection's payments, so it never also reviews.
1774
+
1775
+ `POST /v1/collections/:id/reviewers` runs 1, 4, 5 and 6, and requires the caller to be the issuer.
1776
+ Every refusal carries `details.reason` in the engine's vocabulary — 400 `REVIEWER_GRANT_INVALID`:
1777
+ `absent`, `unparseable`, `extra-block`, `shape`, `audience`, `identity`, `capability`, `resource`,
1778
+ `lifetime`, `not-yet-valid`, `expired`, `too-long`, `signature`; 403 `REVIEWER_NOT_AUTHORIZED`:
1779
+ `root-not-controller`, `oracle`, `oracle-owner`, `evaluator`, and the oracle's own `revoked`. A collection with no
1780
+ entity is a 503 (fail closed). A fold refused either way stores `foldError` as
1781
+ `CODE (reason): message`.
1782
+ The collection's review room follows the grants that stand (unrevoked, in their window, root still
1783
+ a controller): the hourly pass invites them and kicks the rest — never the owner; Blocksync down
1784
+ skips the pass.
1785
+
1786
+ `attestation` is the reviewer's own signed statement over those answers — a compact JWS
1787
+ `ClaimReviewAttestation` credential, signed with the key their `did:ixo` publishes on chain, whose
1788
+ `credentialSubject` binds `{ claimId, oracleDid, answeredAgainst, answers }`. **It is required on
1789
+ every review**: a review answer IS a signed statement, and there is no flag and no unsigned path.
1790
+ It is verified before anything is stored (`issuer` = the invoking principal first, then `kid` ∈ the
1791
+ issuer's published keys, the signature, and each binding) and refused otherwise — a refused
1792
+ attestation never replaces stored answers, which is what keeps a stored answer signed: replacing
1793
+ one takes another signature. The bytes are kept verbatim and forwarded to the engine, which
1794
+ verifies them again; the review row reports `signed: true`, and the JWS itself is never returned by
1795
+ a read route. The fold's Matrix card edit then names the reviewer and the attestation's CID.
1796
+
1797
+ | Refusal | Status | When |
1798
+ | --- | --- | --- |
1799
+ | `ATTESTATION_INVALID` | 400 | Not a compact JWS / not a `ClaimReviewAttestation` / payload is not canonical JSON / the signature does not verify |
1800
+ | `ATTESTATION_KEY_UNKNOWN` | 400 | The issuer publishes no such key — including a DID that resolved to no document or to no Ed25519 method. Deterministic: retrying cannot change it |
1801
+ | `ATTESTATION_ISSUER_MISMATCH` | 403 | `issuer` is not the invoking principal |
1802
+ | `ATTESTATION_MISMATCH` | 400 | `oracleDid` / `claimId` / `answeredAgainst` / `answers` do not bind this review |
1803
+ | `ATTESTATION_UNRESOLVABLE` | 503 | The resolver could not REACH its source (Blocksync down, host unreachable). Transient — retry |
1804
+ | `ATTESTATION_REQUIRED` | 400 | No `attestation` on the request — refused before any lookup or write |
1805
+ | `REVIEWER_GRANT_INVALID` | 400 | No `reviewerGrant` (`details.reason: "absent"`, refused before any lookup or write), or gate checks 1–2 fail |
1806
+ | `REVIEWER_NOT_AUTHORIZED` | 403 | Gate checks 3–6 fail: never filed or revoked, an oracle identity (or the oracle's owner, `oracle-owner`) in it, a root no longer a controller, an evaluator in it |
1807
+ | `UNAVAILABLE` | 503 | Blocksync, the chain or a DID resolver could not be reached while checking the grant — never a pass. Retry |
1808
+
1809
+ **Deploy order.** The portal ships first — an older package's review body schema is not strict, so
1810
+ the extra `attestation` field rides along harmlessly. The engine and this package then deploy
1811
+ **together**: both require the field, and the engine's fold body schema is strict, so a package
1812
+ that forwards `attestation` to an older engine turns every fold into a 400 recorded as `foldError`.
1813
+ A review row written before this (no stored attestation) cannot fold at all — the workflow says so
1814
+ on the row and the reviewer answers again, signed.
1815
+
1816
+ The **read gate** (`assertCanRead`) is the chain's answer **now**, never the row's memory: the
1817
+ collection entity's current chain owner (read off Blocksync, cached 5 min), or any holder of a live
1818
+ evaluate grant for it (cached 5 min) — the same permission surface as the portal's "Grant Role"
1819
+ flow. The enroller passes only while it still holds a grant, and an owner who transferred the
1820
+ entity reads nothing. A collection this oracle does not watch is a `404`. One claim's record also
1821
+ opens to that claim's submitter (its on-chain agent DID or agent address) — that claim only,
1822
+ never the collection.
1823
+
1824
+ There is deliberately **no claim-ingestion endpoint**: the chain is truth, so a pushed claimId adds
1825
+ nothing worth trusting.
1826
+
1827
+ The Decision API lane's routes (`/v1/api/*`, `API_LANE` on) are specified by
1828
+ `openapi/decision-api.yaml`, shipped in the package and served at `GET /v1/api/openapi.json`. A
1829
+ console invocation (`X-Auth-Type: ucan`) carries ONE capability whose `can` is the operation's own
1830
+ `x-ucan-action` (`API_ACTIONS` in `src/contracts/capabilities.ts`, e.g. `claims/read` for `GET
1831
+ /v1/api/claims`); any other `can` — another operation's action, or the legacy `*` — is `403
1832
+ FORBIDDEN`, with a message naming the action the route takes. API keys are unaffected.
1833
+
1834
+ **Console roles** (`ROLE_CAPABILITIES`, `src/contracts/capabilities.ts`) are the five presets a
1835
+ member grant must match exactly: `owner` = `source/*` (covers every capability), `developer` =
1836
+ `claims/read` · `claims/review` · `keys/manage` · `webhooks/manage` · `spend/read` · `audit/read`,
1837
+ `reviewer` = `claims/read` · `claims/review`, `viewer` = `claims/read` · `spend/read`, and
1838
+ `submitter` = `claims/submit` · `claims/read` (v1.9). `claims/submit` is what submitting (one claim
1839
+ or a batch), `PUT /v1/api/uploads` and `POST /v1/api/uploads/links` require; `claims/read` covers a
1840
+ claim's record, receipt and report and `GET /v1/api/uploads/:uploadId` — source-wide, never the
1841
+ answers.
1842
+
1843
+ **A source's organisation (company authority D1, D10, v1.8).** An organisation IS its on-chain
1844
+ entity, and a source is that organisation's access to this oracle — it never exists without one,
1845
+ and there is no personal root. `POST /v1/api/sources` takes `{ name, contactEmail, entityDid }` and
1846
+ is `403` unless the invoker is a CURRENT controller of `entityDid` (Blocksync `entity.controller`,
1847
+ `did:ixo:ixo1…` accounts, read live and uncached; `503` when it cannot be read); the creator pays
1848
+ (`ownerDid`) but gets no membership. The source starts **`pending-link`**: it answers its lists,
1849
+ `GET /v1/api/me`, the sync, revoke/remove and delete, and refuses every other route — claims,
1850
+ uploads, keys (even an existing one, on any route), invites and their acceptance, reviews,
1851
+ settings, webhooks — with `409 SOURCE_PENDING_LINK`, checked where the caller is built (console,
1852
+ key, OAuth). Until it is first active (`activatedAt`, set once; on the `Source` view since 1.17.0,
1853
+ null for a source that never held claims), only its organisation's controllers can list it — not
1854
+ the operator (`/admin/sources`) — and its id is in no log line, metric, alert or Sentry event (logs
1855
+ carry `sourceIdHash`, the first 12 hex of its sha256; `/v1/api` log lines carry the route pattern,
1856
+ not the path; the D1 audit row keeps the id). The organisation's link lives at the decision
1857
+ engine: a controller signs a `SourceCompanyLink`, the console PUTs it there, then calls `POST /v1/api/sources/:sourceId/company/sync` (`sources/company-sync`), which
1858
+ reads the engine's stored state (`company/read`) and mirrors it: a link for the source's own
1859
+ entity ⇒ **`active`** (a source with a `pausedReason` stays `paused`, read in the same statement)
1860
+ with `payeeField` and the link (verbatim) — a read older than the stored link is
1861
+ ignored; a link for another entity, or the engine's `disputed` ⇒ **`disputed`** (`409
1862
+ SOURCE_DISPUTED` on the same routes; the `api.company-link.disputed` alert); nothing stored ⇒
1863
+ nothing changes (a source already linked keeps its state — an engine-side transient — with the
1864
+ `api.company-link.unlinked` alert). The sync never writes `pausedReason`. Any
1865
+ non-2xx from the engine (a `404` included) is `503` and changes nothing. The oracle never
1866
+ verifies a link JWS. **Roots:** the entity's current controllers, wherever the API used to say
1867
+ "owner" — a controller invokes any source route as root with no proof and no membership
1868
+ (`X-Source-Id`, `with: ixo:oracle/<did:web>/source/<id>`, the route's action; `GET /v1/api/me`
1869
+ answers `caller.kind: "controller"`), every member grant must be rooted at one, and a former
1870
+ controller with no membership is a stranger (`404`) at once. `DELETE /v1/api/sources/:sourceId`
1871
+ (`sources/delete`, a controller only) deletes the source and every row hanging off it — the way
1872
+ out of `disputed`; its uploads and exports are made due at once, so the hourly housekeeping sweep
1873
+ deletes their R2 objects, then the rows. `GET /v1/api/organisations/:entityDid/sources`
1874
+ (`listOrganisationSources`, `sources/list-organisation`, 1.17.0) answers an organisation's sources
1875
+ here in EVERY state — `pending-link`, `active`, `paused`, `disputed` — newest first, keyset-paged
1876
+ `{ items, nextCursor }`, to a current controller of the entity only (read live; `403` otherwise,
1877
+ `503` when the chain cannot be read). It is how a controller finds a source an abandoned "Add
1878
+ oracle" or a CLI sign-up left `pending-link` (sync it, or delete it — nothing deletes one
1879
+ automatically). `GET /v1/api/sources` (`sources/list`) is the account's memberships alone, each
1880
+ source carrying its `companyEntityDid`; a stale `?entity=` on it is `400 VALIDATION`, naming the
1881
+ new route.
1882
+
1883
+ **Review billing (per-review checker billing for chain claims, contracts 1.22.0).** Only while it
1884
+ is active — IXO's plan for the oracle allows it (the engine's `GET /v1/oracles/me`
1885
+ `reviewBilling`, IXO-admin only, default false, KV-cached 5 min; an oracle never turns it on
1886
+ by itself, and the facilitator — the real lock — refuses chain-lane reservations otherwise) on an
1887
+ oracle running `API_LANE` + `API_PAYMENTS` with a 0.7.0 facilitator — otherwise all three are
1888
+ `404`. A collection registered per review while it was active, whose plan no longer allows it,
1889
+ pauses `payer-delegation` rather than be decided unpaid. `PUT
1890
+ /v1/api/source/review-billing` (`review-billing/set`, `keys/manage`: a member's grant or a
1891
+ controller) stores the source's ONE standing payment delegation: `{ delegation }`, an x402-ixo-pay
1892
+ payment delegation (base64 CAR, ≤ 16 KiB) — one `billing/pay` on `billing:decision-api` addressed to
1893
+ the facilitator (`X402_FACILITATOR_URL`'s did:web), `nb: { maxAmount, denom: "upay", oracles:
1894
+ [<this oracle's did:web>] }` (exactly this oracle), an `exp` at least 24 h out (any later one,
1895
+ however far), an `nbf` (if any) not more than 5 min ahead, no proofs, no facts, its signature
1896
+ verified against the issuer's keys on chain. Its issuer must be the organisation's CURRENT chain
1897
+ owner (`getEntityOwner` on the source's `companyEntityDid`, uncached): `403 FORBIDDEN` `reason:
1898
+ not_org_owner` otherwise, whoever stores it — the facilitator debits the issuer's subscription deed.
1899
+ Also refused, nothing stored: `409 CONFLICT` `billing_mode_none` (the source is not billed) or
1900
+ `review_billing_elsewhere` (another source of the same organisation holds a live one;
1901
+ `details.sourceId` — one paying source per organisation), `503 SOURCE_PAUSED` `payments-disabled`
1902
+ (`API_PAYMENTS` off or no facilitator), and `400 VALIDATION` with `reason`
1903
+ `not_a_payment_delegation` · `wrong_audience` · `not_oracle_bound` · `expires_too_soon` ·
1904
+ `not_yet_valid` · `cap_below_price` (`maxAmount` below the lowest published price) ·
1905
+ `bad_signature`. Stored sealed in `review_billing_delegations` (one row per source, `v1:` under
1906
+ `WEBHOOK_ENCRYPTION_KEY`; audited `source.review_billing_set` by CID, payer, `maxAmount`,
1907
+ `expiresAt` — never the token); the same CID again keeps its count, a new one starts at 0. `GET`
1908
+ (`review-billing/read`, `spend/read`) answers `ReviewBilling` — `state` (`active` · `expired` ·
1909
+ `exhausted` · `payer_not_owner`), `payer`, `delegationCid`, `maxAmount`, `spent` / `remaining` (the
1910
+ oracle's own count of what it reserved against `maxAmount`: the facilitator exposes none, and the
1911
+ delegation names this oracle alone), `expiresAt`, `credits` (the payer's facilitator balance, null
1912
+ until readable), `pricePerReview`, `pausedCollections` — or `404`. `DELETE` (`review-billing/delete`,
1913
+ `keys/manage`) drops it: `200 { deleted, message }`, idempotent; the delegation itself stays valid
1914
+ until its `exp` — a full stop is a revoke in the UCAN store (the Console's). A row is deleted with
1915
+ its source, and 30 days past its `exp` by the housekeeping sweep.
1916
+
1917
+ **Who a claim is about (`submitter`, owner 2026-10-10).** `ctx.claim.submitter` on the API lane is
1918
+ the caller's explicit `submitter`, else the answer the source's organisation link names
1919
+ (`answers[payeeField]`: a string or finite integer, trimmed, 1–256 characters — the engine's own
1920
+ `submitterFor` rule, `lib/api-lane/submitter.ts`), else null — never an oracle-side fill. A protocol whose work order has a frequency rule refuses a
1921
+ claim with neither `400 VALIDATION` at `submitter` — at `answers.<payeeField>` when the source has
1922
+ one — before any payment (MCP: `SUBMITTER_REQUIRED`, before any approval). An AI app over OAuth acts
1923
+ for one person: an explicit `submitter` other than the signed-in user's DID is `400 VALIDATION` at
1924
+ `submitter`. `ClaimRecord.submittedBy` is who SENT the claim, set server-side from the caller (the
1925
+ OAuth user's DID; null for an API key or a console submit) — never part of the content.
1926
+ `ClaimRecord.submittedVia` (v1.12.0) is WHICH AI app sent it over OAuth, `{ name, verified }`; null
1927
+ otherwise. `name` is the client's registered name (the AuthHub token's `client_name`, else its
1928
+ `client_id`), trimmed and capped at 100 characters — the app declares it at dynamic registration, so
1929
+ it is a display hint, never an authorisation input. `verified` is the token's `verified` claim
1930
+ (exactly `true`, else false): AuthHub sets it only when every redirect URI the client registered is
1931
+ Claude's or ChatGPT's own callback, so a local app (Claude Code, Codex) is `false`. Stored as
1932
+ `api_claims.submitted_via` + `submitted_via_verified`.
1933
+
1934
+ **A URL attachment's `mediaType` is detected at accept when the caller sends none** (an AI app
1935
+ over MCP sends a photo as `{ question, url }`). `recordApiClaims` (`lib/api-lane/media-type.ts`)
1936
+ reads it from the URL path's extension (jpg/jpeg, png, webp, gif, heic/heif, avif, pdf, mp4, mov,
1937
+ webm), else from one `HEAD` through the SSRF guard (3 s, at most 6 at once across a batch,
1938
+ `Content-Type` parameters stripped, a 403/405 left unknown — no `GET` fallback), and stores it on
1939
+ the claim's attachment, so every evaluate, retry and review fold reads the same value. A declared
1940
+ `mediaType` always wins; one it cannot tell stays absent and the facts lane's
1941
+ `application/octet-stream` default applies. Detection never fails the submit, and the
1942
+ `Idempotency-Key` comparison is still over the caller's own request. An upload's type is the one
1943
+ sniffed at upload.
1944
+
1945
+ **Who answers an API claim's review (`POST /v1/api/claims/:claimId/review`, contracts 1.11.0).**
1946
+ Every answer carries `reviewerGrant`, a base64 CAR (≤ 16 KiB): the engine refuses a company
1947
+ decision's fold without one (every source has a company, D10), so a missing one is `400
1948
+ REVIEWER_GRANT_INVALID` (`details.reason: "absent"`) before any read or write. Every link must
1949
+ state `nbf` and `exp` with `exp − nbf` ≤ 30 days, `nbf ≤ now + 60 s`, `now < exp`, carry a
1950
+ `claims/review` capability on `ixo:oracle/<did:web>/source/<sourceId>`, and no wildcard in any
1951
+ `can` or `with`; no identity of this oracle may appear in it — its owner (the issuer of
1952
+ `OWNER_DELEGATION_CAR`) included: `403 REVIEWER_NOT_AUTHORIZED`, `details.reason: "oracle-owner"`,
1953
+ before anything is stored, whether the owner answers as a controller, as a member or is the
1954
+ controller who granted a member's access (contracts 1.15.0).
1955
+
1956
+ - A **member** signs the attestation with the `did:key` they registered on the source
1957
+ (`api_members.reviewerDid`) and must hold `claims/review` over the claim's protocol now
1958
+ (`mayReview`: the stored grant's capabilities and `nb.protocols` — the engine does not read
1959
+ `nb.protocols`, so this is where the protocol limit holds). Its grant is exactly **two links**:
1960
+ (1) its STORED, unrevoked role grant (matched by canonical CID against `api_members.delegationCid`
1961
+ — anything else is `403 REVIEWER_NOT_AUTHORIZED`, `revoked`), issued by a current controller of
1962
+ the organisation (`403`, `root-not-controller`) to the member's `did:ixo`; then (2) the member's
1963
+ `did:ixo` → that registered `did:key`, exactly `{ with: <source resource>, can: "claims/review" }`,
1964
+ with link 1 its only proof. Each signature is checked against its issuer's IID. A grant reached
1965
+ through a co-owner (three links) or an `owner`-role grant (`source/*`, a wildcard) cannot review:
1966
+ the member needs a `reviewer` or `developer` grant straight from a controller. So the first link
1967
+ can stand, a role grant carrying `claims/review` (`PUT /v1/api/members/:memberId/delegation`)
1968
+ must have `nbf` and `exp` at most 30 days apart (else `400 VALIDATION`, "reviewer grants last at
1969
+ most 30 days") — re-grant before it runs out.
1970
+ - A **root** — a current controller of the source's organisation, invoking as itself — needs no
1971
+ member row: its attestation's `issuer` is its own `did:ixo`, the `kid` a `did:key` on that DID's
1972
+ document (resolved like the chain lane's), and its grant is ONE delegation from the controller
1973
+ to itself, exactly `claims/review` on the source, signed by that same `kid`. Its controller-ness
1974
+ is the console's live root check (no second chain read); the engine records it as root and
1975
+ member (`rootKind: company-controller`).
1976
+
1977
+ Refusals carry the engine's `details.reason` (`absent`, `unparseable`, `shape`, `extra-block`,
1978
+ `signature`, `capability`, `resource`, `identity`, `lifetime`, `too-long`, `not-yet-valid`,
1979
+ `expired`, `audience` → 400 `REVIEWER_GRANT_INVALID`; `revoked`, `root-not-controller`, `oracle`,
1980
+ `oracle-owner` → 403 `REVIEWER_NOT_AUTHORIZED`). The review row stores the grant beside `reviewerDid`. Before it
1981
+ forwards both, the fold re-checks what can change after the answer: a member's row must still
1982
+ review the claim's protocol (not revoked, expired or narrowed) and still name the grant's first
1983
+ link; a root must still be a controller; neither may be the oracle's owner. A miss is a `foldError` with the same code and reason
1984
+ (`revoked`, `root-not-controller`), nothing is sent, and the reviewer answers again. The engine
1985
+ checks the chain again, live.
1986
+
1987
+ **Telling the console up front (contracts 1.15.0).** The review view (`GET
1988
+ /v1/api/claims/:claimId/review`) carries `canAnswer: { ok, reason? }` and `GET /v1/api/me` carries
1989
+ `reviewBlocked` (`"oracle-owner" | "not-reviewer" | null`), both for the CALLER and computed only
1990
+ from what is cheap and certain: `oracle-owner` when the caller's `did:ixo` is the oracle's owner,
1991
+ `not-reviewer` when the caller does not sign answers — an API key (it may still relay a
1992
+ member-signed answer), a member without `claims/review`, or an AI app without it or without the
1993
+ token's `claims:write` scope. Neither names the owner, and an
1994
+ answer runs every check whatever they said — an owner-granted member's answer is still refused at
1995
+ the answer (`oracle-owner`) though its hint is `ok`.
1996
+
1997
+ **Why it was held (contracts 1.20.0).** When an `ext`/`ai` fact comes back `error` with the
1998
+ rubric's `onError: review`, the engine's reason line is generic ("External check unreachable after
1999
+ retries — held for review") — it is hashed into `resultDigest`, so it never names the check. The
2000
+ engine also answers `erroredFacts: [{ name, detail }]` on the decide, its stored-decision read and the fold: one entry per
2001
+ fact that errored, `name` lane-prefixed (`ext.beforeAfter`, `ai.sameBoat`), `detail` that fact's own
2002
+ error text (what the handler or stock lane wrote — never an answer field), `ext` then `ai`. The
2003
+ oracle stores ONLY that list on the decision row (`decisions.erroredFacts`, each detail capped at
2004
+ 500 chars; never logged), written with the decision, replaced by a fold that carries one, and serves
2005
+ it as `ClaimRecord.decision.erroredFacts` and the review view's `erroredFacts` — both omitted when
2006
+ nothing errored or the engine sent no list (an older engine) — and on the chain lane's
2007
+ `GET /v1/claims/:claimId` (`decision.erroredFacts`, null for the claim's submitter) and
2008
+ `GET /v1/reviews/:claimId`, null when none.
2009
+
2010
+ **What a held duplicate matched (contracts 1.24.0).** For each pending `unique:<ruleId>` task the
2011
+ engine also answers `pendingTaskDetails["unique:<ruleId>"] = { kind: "duplicate", ruleId, score?,
2012
+ priorClaimId? }` — the frozen unique fact's score in basis points and its winning earlier claim. The
2013
+ oracle stores that map on the decision row (`decisions.pendingTaskDetails`, with the decision only)
2014
+ and the review view turns it into `duplicates: [{ taskId, ruleId, score?, prior }]`: `prior.outcome`
2015
+ is this oracle's decision on the earlier claim (`null` when it has none); `prior.claimId`,
2016
+ `prior.submittedAt` and `prior.files` only when the earlier claim is the same source's, under the
2017
+ same protocol. The files are the earlier claim's attachments whose CID this claim also registered in
2018
+ the duplicate lane (`claim_attachment_cids`) — a near-image match shares no CID, and then they are the
2019
+ earlier claim's images — each URL signed like the view's own `files`. Another organisation's claim
2020
+ never shows its id or files. Omitted when no duplicate task pends. The chain lane's
2021
+ `GET /v1/reviews/:claimId` does not carry it.
2022
+
2023
+ One of the lane's review rules is a payment rule: a paid claim decided `review` asks the
2024
+ facilitator to extend its x402 reservation (up to 30 days from verify). The review's **`answerBy`** is that reservation's
2025
+ expiry — the extended one, else the default 24 h from verify — less 5 minutes
2026
+ (`ANSWER_BY_MARGIN_MS`: the fold and then the settle still run after the answer), on the claim
2027
+ record's `review` and on the reviewer view (`GET /v1/api/claims/:claimId/review`); null for a
2028
+ `none`-billed source and once the claim no longer awaits review. An answer after it is refused
2029
+ `409 REVIEW_EXPIRED` (`details.answerBy`) before anything is verified or written — the claim could
2030
+ no longer be paid — except the same signed answer re-sent after it first landed in time (a retried
2031
+ `503`). The view keeps serving the claim. The review emails and reminders state the deadline
2032
+ ("Answer by 30 Oct 2026, 11:57 UTC, or this claim can't be paid.") only for a claim that has one,
2033
+ and nobody is emailed or posted about a claim already past it.
2034
+
2035
+ **Who is emailed about a claim waiting for review.** Every accepted member whose live grant carries
2036
+ `claims/review` over the claim's protocol, with an address and `notifyReviews` on, gets the
2037
+ review-needed email at once — at most one per member per source per 15 minutes: claims that arrive
2038
+ inside the window go out together as one email when it ends — and the 3- and 14-day reminders. The
2039
+ source's `contactEmail` gets the same three emails, on its own 15-minute window (an organisation
2040
+ admin needs no member row to hear of a claim), unless a member with that same address
2041
+ (case-insensitive) is already sent every claim it would be. The source's Matrix review room takes
2042
+ every review-capable member.
2043
+
2044
+ **Webhooks go to the registered origin only.** A delivery follows at most 3 redirects, each hop
2045
+ https and public, and only within the endpoint's own origin, re-sent as the same signed POST; a
2046
+ redirect to another origin is not followed — the delivery is `dead` at once, its `lastError` naming
2047
+ the target's origin — so a source registers the final URL.
2048
+
2049
+ **Where a webhook goes (contracts 1.16.0).** Every active endpoint subscribed to the event type
2050
+ (`events: []` = all), each signed with its own secret; and, for `claim.*`, the claim's own
2051
+ `webhookUrl` or else the source's default URL (`defaultWebhookUrl`), signed with the source's
2052
+ secret — whatever endpoints exist, so adding an endpoint never cuts the default receiver off. A
2053
+ default (or claim) URL that is exactly a subscribed endpoint's URL is posted once, as the
2054
+ endpoint's delivery. `source.*`, `payment.settled` and `balance.low` go to endpoints only.
2055
+
2056
+ **A redeliver re-reads its target (contracts 1.18.0).** `POST
2057
+ /v1/api/webhooks/deliveries/:deliveryId/redeliver` posts the frozen envelope to where the row's
2058
+ target points NOW: an endpoint's row to the endpoint's current URL (its secret); a row without an
2059
+ endpoint to the claim's own `webhookUrl`, else the source's CURRENT default (the source's secret) —
2060
+ never the URL frozen on the row. It is refused `409 CONFLICT` before anything is reset or posted when
2061
+ the endpoint was deleted (`details.reason: "endpoint-deleted"`) or turned off (`"endpoint-disabled"`),
2062
+ or a default-URL row's source has no default URL (`"no-webhook-url"`); like any answer, that 409 is
2063
+ stored under its `Idempotency-Key`, so a retry after the fix sends a new key. A default-URL or
2064
+ claim-URL redeliver always goes to the current target, signed with the SOURCE's secret, even when an
2065
+ endpoint now has that same URL (the enqueue-time de-dupe does not apply to one explicit row);
2066
+ receivers de-duplicate on `Oracle-Event-Id`. An erased row stays erased and goes `dead`.
2067
+
2068
+ **A low balance is told twice.** With a facilitator configured, the balance watch (every 5
2069
+ minutes) reads each active source's payer balance against its `refillFloorCents`. The first read
2070
+ below the floor sends one `balance.low` webhook (`{ sourceId, balanceCents, floorCents }`) and one
2071
+ email to the source's `contactEmail`: the source's name, the balance and the floor as dollars, and
2072
+ an "Add credits" link to `${CONSOLE_URL}/credits`. The webhook goes once per crossing (a recovery
2073
+ above the floor re-arms it). The email goes once per UTC day while the balance stays under the
2074
+ floor (`notice_balance_low:<sourceId>:<yyyy-mm-dd>`): still low tomorrow is a new email, a balance
2075
+ bobbing around its floor within a day is one, and a send that fails is retried on the next check.
2076
+ No `contactEmail`, or no `EMAIL` binding, `EMAIL_FROM_ADDRESS` or `CONSOLE_URL` ⇒ the email is a
2077
+ logged skip (`email.notice_balance_low.skipped`) and the webhook still goes; the
2078
+ `email.notice_balance_low` metric counts `sent` / `skipped` / `failed`.
2079
+
2080
+ **A review that closes unanswered is announced once.** The hourly pass writes a `review_expired`
2081
+ timeline event (`data.answerBy` only) and sends the `claim.review_expired` webhook with the record.
2082
+ The record's status stays what the engine said (`pending_review`: nothing was decided), so
2083
+ `review.answerBy` in the past together with that event means the claim is **closed unpaid** — its
2084
+ reservation was released and no receipt will come.
2085
+
2086
+ **Operators get a card-facilitator credential.**
2087
+ `GET /v1/api/admin/facilitator-delegation[?actions=][&ttl=]` (console sign-in on the account — no
2088
+ `X-Source-Id`, invoker in `ORACLE_ADMIN_DIDS`; an API key is `401 UNAUTHORIZED`, any other account or a
2089
+ source-scoped invocation `403 FORBIDDEN`) answers `{ delegation, issuer, audience, capabilities,
2090
+ expiresAt }`: a UCAN delegation (base64 CAR, as a console role grant) from
2091
+ `did:web:<ORACLE_HOSTNAME>` — signed with `SERVICE_ED25519_MNEMONIC`'s key, the one
2092
+ `/.well-known/did.json` publishes — to the operator's did:ixo, whose `att` is exactly one `{ with:
2093
+ "ixo:ixo-pay-facilitator", can: <action> }` per requested action, no `nb` (`capabilities` echoes
2094
+ it). `actions` is a comma-separated list of `operators/onboard`, `operators/read`,
2095
+ `operators/refund`, `operators/evidence` (default all four; deduplicated, listed in that order);
2096
+ anything else — `*`, the retired `operators/manage`, any `payouts/*` action, an unknown name, an
2097
+ empty list — is `400 VALIDATION`. `ttl` is 60–3600 s (default 3600; `400 VALIDATION` outside): the delegation
2098
+ carries `nbf` = its issue time − 60 s (a UCAN is valid only once `now > nbf`, so it is backdated past
2099
+ skew) and `exp` = `min(issue + ttl, nbf + 3600)` (unix seconds; `expiresAt` is that `exp`), so
2100
+ `exp − nbf ≤ 3600` always — the default gives `exp` = issue + 3540. The facilitator refuses a grant
2101
+ without `nbf` or living longer. Every call mints a new one (a nonce
2102
+ each, `Cache-Control: no-store`) and writes an `api_audit` row `facilitator.delegation_minted` (the
2103
+ audience, the actions, `expiresAt` and the delegation's `cid`, never the token); `503 UNAVAILABLE`
2104
+ when the signing key is unset.
2105
+
2106
+ Payout access is not minted here: the payouts facilitator holds each company's payout team itself
2107
+ (the controllers of its company entity, `GET /v1/payouts/info` `{ entityDid, admins, rails }`) and
2108
+ accepts no oracle-minted payout grant, so the Console manages that team on the facilitator.
2109
+
2110
+ #### Use with Claude Code and other MCP clients
2111
+
2112
+ With `API_LANE` on, every oracle also hosts an MCP server at `https://<ORACLE_HOSTNAME>/v1/mcp` (MCP
2113
+ over Streamable HTTP, stateless, JSON answers; `POST /v1/mcp` only — any other method or path under
2114
+ it answers `405` with `Allow: POST`, after the key check below, so a request without
2115
+ `Authorization` is `401` first). Nothing to install:
2116
+
2117
+ ```bash
2118
+ claude mcp add --transport http ixo https://<oracle>/v1/mcp --header "Authorization: Bearer eo_live_…"
2119
+ ```
2120
+
2121
+ The credentials are headers on the connection: `Authorization: Bearer <API key>` (required — a
2122
+ request without one is `401 UNAUTHORIZED`, with `WWW-Authenticate: Bearer realm="ixo-decision-api"`),
2123
+ plus `X-Payment-Delegation: <IXO_PAYMENT_DELEGATION>` for a source in billing mode `x402` (and, optionally,
2124
+ `X-Payment-Prefer: card | upay`, case-insensitive — which way to pay when a 402 offers both; without
2125
+ it, or with any other value, the payer's own console default, read from the facilitator with the
2126
+ delegation, and PAY when that can't be read). The key is
2127
+ checked at connect: an `initialize` (alone or in a batch) calls `GET /v1/api/me` with it, and an
2128
+ invalid or revoked key is that same `401` there; every other request checks only that a bearer is
2129
+ present, and each tool call is authorised by the route it calls. Exactly five tools, each the SDK
2130
+ (`@ixo/decision-oracle-client`) calling this oracle's own route in-process with that key — so scopes,
2131
+ `SCOPE`, the per-key and per-IP limits (the MCP request counts against the caller's IP, and each
2132
+ tool call also counts its inner route calls — a paid submit is three: the MCP request, the `402`,
2133
+ the paid resend), the daily spend cap, idempotency, the audit log and "never the body" hold exactly
2134
+ as over HTTP. Only the SDK's own headers (plus the caller's IP, request id and `traceparent`) reach
2135
+ the inner request — never a caller's `X-Auth-Type` / `X-Source-Id`:
2136
+
2137
+ | Tool | Arguments | Calls | Returns |
2138
+ | --- | --- | --- | --- |
2139
+ | `submit_claim` | `protocolDid?`, `answers`, `attachments?`, `submitter?`, `metadata?`, `webhookUrl?`, `idempotencyKey?`, `resumePayment?` (after a card 3-D Secure refusal: its `paymentRequired`, once the payer has confirmed at `confirmUrl`) | `POST /v1/api/claims` with `Prefer: wait=90` and an `Idempotency-Key` — the argument, else `mcp_` + hex `sha256(canonicalJson(request))`; a 402 is paid by at most one attempt per rail with `X-Payment-Delegation`, as the SDK's payer does (the card authorize goes to this oracle's `X402_FACILITATOR_URL`, never the delegation's audience; a `resumePayment` that is not this oracle's card 402 is a `VALIDATION` refusal) | the decided record, or `{ status: "pending", claimId, pollUrl }` |
2140
+ | `upload_file` | `sourceId?` (OAuth only) | `POST /v1/api/uploads/links` | `{ uploadId, uploadUrl, expiresAt }` as `structuredContent`; the text is instructions instead of the JSON (the upload card, or `curl -sS -T <path> '<uploadUrl>'`, then `submit_claim` with `{ question, uploadId }`) |
2141
+ | `get_decision` | `claimId` | `GET /v1/api/claims/:claimId` | the record |
2142
+ | `list_protocols` | `protocolDid?` | `GET /v1/api/protocols`, or `…/:did` for one protocol's work order (answers JSON Schema, files, facts, price, example) | the list (`{ protocolDid, name, price }`: `name` is the protocol entity's `#pro` profile name — hash-checked, KV-cached 1 h — else its claim form's title, else `null`; a failed read never fails the list), or the work order |
2143
+ | `verify_receipt` | `claimId`, or `jws` (+ `cid?`) | `GET /v1/api/claims/:claimId/receipt` (with `claimId` alone; given both, the `jws` is verified and must name that `claimId`), then `verifyReceipt` trusting only this oracle's engine (`did:web:<EVAL_ENGINE_HOSTNAME>`) | `{ valid: true, issuer, claimId, verdict, status, outcome, protocolDid, oracleDid, kind, evaluatedAt }` or `{ valid: false, reason, message }` |
2144
+
2145
+ **An attachment named by `uploadId` alone gets its `cid` filled in.** Before anything else is
2146
+ asked or charged, `submit_claim` reads `GET /v1/api/uploads/:uploadId` on the source the submit
2147
+ will use and sets the attachment's `cid`; a `404` (the link not used yet) is the tool's own
2148
+ `UPLOAD_PENDING` refusal, with no approval filed and nothing submitted.
2149
+
2150
+ **The default `Idempotency-Key` is derived from the request, not random.** It hashes the request
2151
+ after the `cid`s are filled, so a retry that passes the `cid` names the same claim. An MCP client often gives
2152
+ up on a tool call at 60 s while `submit_claim` still holds its 90 s wait, and an agent's blind retry
2153
+ under a fresh key would be a second claim — on a paid source, a second price held. So an identical
2154
+ claim submitted again within 24 h (the oracle's idempotency window, scoped per source) returns the
2155
+ first claim's decision and is never charged again; pass a distinct `idempotencyKey` to submit the
2156
+ same claim twice on purpose. The SDK inside retries a transient failure once (`maxRetries: 1`): a
2157
+ 5xx after the gate reserved the price must be resent with the SAME `PAYMENT-SIGNATURE`, which only
2158
+ the SDK holds — the agent's own retry would arrive unpaid and be offered again.
2159
+
2160
+ Results are `structuredContent` plus the same JSON as a text block. A refusal is an `isError` result
2161
+ carrying `{ error: { code, message, requestId, docsUrl, fundingUrl?, details?, retryAfterSeconds?,
2162
+ idempotencyKey? } }` — the route's own envelope, plus the route's `Retry-After` in whole seconds
2163
+ when it set one and, on a failed `submit_claim`, the key to retry with (the same claim, never a
2164
+ second charge) — never a JSON-RPC error. A failure that is none of these is `{ error: { code:
2165
+ "INTERNAL", message } }`, logged on the oracle, with no stack and no input. A `submit_claim` whose claim was RECORDED but
2166
+ whose answer then failed (the wait, the record read) is not an `isError` result: the ledger is read
2167
+ for the claim the request's key names, and the tool answers `{ claimId, status, pollUrl,
2168
+ idempotencyKey, note }` — never a refusal without the id, which would invite a resubmit. A refusal
2169
+ of the tool's own, and `IDEMPOTENCY_MISMATCH`, stay refusals. No review answering over
2170
+ MCP.
2171
+
2172
+ **Cards (MCP Apps).** The server implements the `io.modelcontextprotocol/ui` extension (stable
2173
+ 2026-01-26). `submit_claim` and `get_decision` carry `_meta.ui.resourceUri:
2174
+ "ui://eval-oracle/decision-card"`, `verify_receipt` carries `"ui://eval-oracle/receipt"` and
2175
+ `list_protocols` `"ui://eval-oracle/protocols"` and `upload_file` `"ui://eval-oracle/upload"` (each
2176
+ plus the pre-GA flat `_meta["ui/resourceUri"]`), and the views are listed by `resources/list` and
2177
+ served by `resources/read` as `text/html;profile=mcp-app` with `_meta.ui.csp` declaring no domain —
2178
+ one self-contained page each, inline CSS and JS, nothing fetched — save the upload card, whose one
2179
+ `connectDomains` entry is `https://<ORACLE_HOSTNAME>`: its "Choose file" button `PUT`s the chosen
2180
+ file (≤ 25 MB, checked before sending) to `uploadUrl` and shows the upload's name, size, type and
2181
+ cid, or the refusal in plain words (`403` expired, `409` already uploaded, `413` too large). A host that renders them hands the
2182
+ view the tool's own `structuredContent`; the card shows the verdict, the checks (`gates`,
2183
+ `criteria`, `reasons`), the price and payment status and the claim id, never the answers or
2184
+ `metadata`. Its "Verify receipt" button is a `tools/call` of `verify_receipt` through the host; on
2185
+ an approval ask it shows the price and an "Approve on IXO" button that asks the host to open
2186
+ `approveUrl` (`ui/open-link`, AuthHub's origin only) — it never approves or pays itself. The
2187
+ protocols view lists each protocol's name and price, or one work order's answer fields (label,
2188
+ type, required, hint) and file questions; it submits nothing. The views are linked
2189
+ unconditionally (the server is stateless, so `tools/list` never sees the client's `initialize`
2190
+ capabilities); a client without the extension ignores `_meta`, and every result's text is its
2191
+ `structuredContent` as JSON, as before — save the approval asks below.
2192
+
2193
+ **Connecting by signing in (OAuth).** A bearer that is not an `eo_` key and is shaped like a JWT is
2194
+ an access token from IXO AuthHub (`AUTH_HUB_URL`), the one authorization server of every oracle. The
2195
+ `401` on `/v1/mcp` carries `WWW-Authenticate: Bearer realm="ixo-decision-api",
2196
+ resource_metadata="https://<ORACLE_HOSTNAME>/.well-known/oauth-protected-resource/v1/mcp"`, which is
2197
+ how Claude, ChatGPT and Claude Code find AuthHub. The token is checked on every request: EdDSA only,
2198
+ `typ: at+jwt`, a key from AuthHub's JWKS (cached in KV for 5 minutes; a miss or an unknown `kid` fetches it, at most every 30 s per isolate —
2199
+ an unreachable AuthHub is `503`), `iss` exactly `AUTH_HUB_URL`, `aud` exactly
2200
+ `https://<ORACLE_HOSTNAME>/v1/mcp` (one audience, never a list), unexpired within 60 s of skew,
2201
+ issued (`iat`, required) at most 10 minutes ago, and `sub` a `did:ixo` account. A
2202
+ refused token is `401` with `error="invalid_token"` added. The token is never passed on, and
2203
+ `/v1/api` never accepts one (it is a `401` there like any other non-key): the tools call the routes
2204
+ in-process as an `oauth` caller, marked on the request object the handler builds, never by a header.
2205
+
2206
+ The `oauth` caller acts on the sources the user stands on, read on every request: as ROOT (every
2207
+ capability) on any `active` source whose organisation entity lists the user among its CURRENT
2208
+ controllers (Blocksync, uncached, one read per organisation per request; a controller needs no
2209
+ membership, as in the console; when the read fails, a source only that standing would add is left
2210
+ out and logged `mcp.oauth.controllers-unavailable`), else as a live member (accepted, unrevoked,
2211
+ unexpired, the stored grant's chain unrevoked and its co-owner live — the console's checks over the
2212
+ stored grant). A source the user both controls and joined is one source, as root. Its rights are the role's capabilities intersected with
2213
+ the token's scopes (`claims:write`, `claims:read`), and it reaches ONLY the operations the four
2214
+ tools call (`submitClaim`, `getClaim`, `getClaimReceipt`, `listProtocols`, `getProtocol`,
2215
+ `createUploadLink`, `getUpload`) — any other is `403 SCOPE`, whatever the role, so it never reaches
2216
+ erasure, reviews, the raw upload, batches, keys,
2217
+ members, webhooks, settings or spend. It is rate-limited per grant (`gid`) and per source, like a key.
2218
+
2219
+ - **Choosing a source.** One usable source (`claims/submit` to submit or upload, `claims/read` to
2220
+ read) is used; `submit_claim` and `upload_file` choose it the same way.
2221
+ With several, `submit_claim` takes `sourceId`, and without it answers `SOURCE_REQUIRED` with
2222
+ `sources: [{ sourceId, name }]`. With none, `NO_SOURCE` with `consoleUrl` (`CONSOLE_URL`, else
2223
+ `PORTAL_BASE_URL`). A named source that is not linked answers `SOURCE_PENDING_LINK` /
2224
+ `SOURCE_DISPUTED` before any approval. OAuth never creates an account or a source.
2225
+ - **Every submit is approved.** The first `submit_claim` for a request files an `mcp_approvals` row
2226
+ (bound to the grant and to `sha256` over the protocol, source, canonical body, attachments and
2227
+ idempotency key) and answers `APPROVAL_REQUIRED` with `approveUrl`
2228
+ (`<AUTH_HUB_URL>/connect/approve?oracle=<ORACLE_HOSTNAME>&id=<id>`, the id 128 random bits),
2229
+ `price` and `expiresAt` (15 minutes). The same request answers `APPROVAL_PENDING` until the user
2230
+ answers on AuthHub. These three asks (`APPROVAL_REQUIRED`, `APPROVAL_PENDING`, `APPROVAL_STALE`)
2231
+ are NOT `isError` results — a host renders no card for an error, and the card holds the approve
2232
+ button: `structuredContent` is `{ status: "approval_required", error: { code, message,
2233
+ approveUrl, price, expiresAt } }`, and the text opens `NOT SUBMITTED. Needs the user's approval on
2234
+ IXO: <message>` before the same JSON. Every other refusal stays `isError`. Once approved, one conditional `UPDATE` (`approved` → `used`) spends the row
2235
+ before anything is paid, so two racing retries pay once; the loser answers `{ status: "pending",
2236
+ claimId, pollUrl }`. The claim is then paid in-process with the delegation the user signed. What
2237
+ becomes of the approval depends on how the payment ended. It is put back to `approved` (one
2238
+ conditional `UPDATE … WHERE status='used' AND expires_at > now`, delegation kept) ONLY when the
2239
+ payment is proven to have reserved nothing — the submit route never left `/verify` reserving and
2240
+ no card hold was sent — and the refusal is one the user can fix without re-signing:
2241
+ `INSUFFICIENT_CREDITS`, `SPEND_CAP`, a refused card (`CARD_DECLINED`, `CARD_BLOCKED`,
2242
+ `CARD_NOT_SAVED`, `CARD_NOT_OFFERED`), `NO_SUPPORTED_PAYMENT`, or the payment lane's `503`
2243
+ before `/verify` — never `CARD_REFUSED` (the SDK's catch-all: a hold may exist), never the resume
2244
+ of a confirmed 3-D Secure payment (a second challenge retires it too: the user approves again,
2245
+ and no second hold is ever placed), and never when the row can't be made live again (it lapsed, or an identical request
2246
+ filed its own approval meanwhile): those retire the delegation. Once any attempt left `/verify`
2247
+ reserving, a later refusal does not count. The resume of a confirmed card offer is exempt from
2248
+ the per-grant cap, which that offer already passed. A card that wants 3-D Secure parks it as `awaiting_3ds`, bound to
2249
+ that offer and PaymentIntent: only a `submit_claim` whose `resumePayment` names that offer may
2250
+ spend it (anything else, or `X-Payment-Prefer: upay`, is `AWAITING_CARD_CONFIRMATION`), and a
2251
+ resume carrying any other payment is `409 CONFLICT` before the route. Anything else — paid, a
2252
+ refused delegation (`INVALID_DELEGATION`, `DELEGATION_EXPIRED`, `DELEGATION_WRONG_ORACLE`), or
2253
+ ambiguous (a 5xx or a timeout once a payment was sent: `/verify` may have reserved) — retires the
2254
+ delegation, and the same request asks for a new approval. A price or billing mode that changed since is `APPROVAL_STALE`, with a
2255
+ new `approveUrl`; nothing is paid at the new price. `maxAmountUpay` is the published price in upay
2256
+ (PAY is USD-pegged, so a card-only price in cents × 10 000 is the same number); billing mode
2257
+ `none` still needs the approval, with no delegation. A repeat of a claim that already exists is
2258
+ answered like any repeat, with nothing to approve.
2259
+ - **Auto-approve** (spec 2026-10-06 "Auto-approve"), only on an oracle whose config sets
2260
+ `policies.allowStandingApprovals` (company authority D8): elsewhere an answer carrying `standing`
2261
+ is `403 FORBIDDEN` (`details.reason: standing_approvals_off`), nothing changed, and a standing
2262
+ already stored never pays, is not resumed from a 3-D Secure park, and is deleted by the housekeeping
2263
+ sweep — every submit asks the user. Where it is on, AuthHub may answer an approval with
2264
+ `standing: { perClaimUpay, totalUpay, until }` (upay strings, unix seconds) beside the delegation;
2265
+ that delegation must then have `maxAmount` EQUAL to `totalUpay` (not the claim's price), with
2266
+ `perClaimUpay` ≥ this claim's price and ≤ `totalUpay`, `totalUpay` ≤ 100 000 000 ($100), `until`
2267
+ ahead and at most 7 days out (+ 5 min skew), and an expiration of at least `until` — plus the
2268
+ issuer, oracle and facilitator checks. It approves the claim and keeps the delegation as the
2269
+ grant's standing authority (`mcp_standing`), this claim counted as spent. A grant is one app on
2270
+ this oracle, so one standing covers every source the user submits for through it. `spentUpay` is
2271
+ never reset: `totalUpay` is the auto-approve's total, a PUT must keep `totalUpay ≥ spentUpay`, and
2272
+ what is left is `totalUpay − spentUpay`.
2273
+ - A later `submit_claim` (any source of the user's) pays from it with no link when ONE batch
2274
+ lands: this request's lock row (`mcp_standing_spends`, PK `(grant, request digest)`) and the
2275
+ conditional `spentUpay` update that applies only if that lock is this attempt's, within
2276
+ `perClaimUpay`, `totalUpay` (an exhausted standing approves nothing, free claims included),
2277
+ `until`, and the grant's $20.00 day summed over the counter rows — which one-claim approvals'
2278
+ spends write too, so the two paths together never pass $20.00 a day. So concurrent submits never pass
2279
+ the total or the day's cap on this path, and a retry of the same request never spends or pays
2280
+ again: it gets the claim if it was recorded, else `APPROVAL_REQUIRED`. The answer carries
2281
+ `autoApproved: true` and `autoApproveRemaining`.
2282
+ - The price and the lock are given back only when nothing can have been reserved: no `/verify`
2283
+ left reserving, no card hold sent, no claim recorded, and a fixable refusal or one the route
2284
+ gave before `/verify` (400, 403, 409, 429). An ambiguous failure keeps both. 3-D Secure parks an
2285
+ `awaiting_3ds` approval holding the standing delegation, for the resume.
2286
+ - Revoking deletes the standing and expires every approval of the grant still holding ANY of its
2287
+ delegations (one a PUT replaced included) — never a one-claim approval's own exact-price one.
2288
+ An answer is one batch: each answer (with limits or without) claims the approval's one marker
2289
+ row first, and the flip and the standing write need that answer's own marker — so of two
2290
+ concurrent answers exactly one lands, the claim is counted once, and auto-approve is never
2291
+ turned on by an answer that did not land. Limits whose total would not cover what this
2292
+ auto-approve has already spent plus this claim are refused BEFORE anything changes: `400
2293
+ VALIDATION` (`details.reason: limits_invalid`), and the approval stays pending. The tool
2294
+ allowlist still applies. The housekeeping sweep deletes
2295
+ standing past `until` and lock rows after 2 days.
2296
+ - **A daily cap per grant.** Each charge records `via: oauth:<gid>` (`api_charges.via`), and a
2297
+ grant's spends over the last 24 h stop at $20.00: `SPEND_CAP` (`details.reason: grant_daily_cap`).
2298
+ It is atomic across BOTH paths: one-claim approvals and auto-approve spend through the same
2299
+ per-grant counter (`mcp_standing_spends`) in the same batch that lets the payment happen, so
2300
+ concurrent submits on either path cannot pass $20.00 together. A restore gives its share back; a
2301
+ 3-D Secure resume adds nothing. The check over all charges also runs before the payment is
2302
+ reserved.
2303
+ - **Audit and timeline.** The approval's answer is an `api_audit` row with actor kind `oauth`
2304
+ (`clientId`, `clientName`); the claim's `accepted` event carries `via: oauth:<client_id>`. The
2305
+ delegation is never logged or returned, is nulled when the row is used, declined or expired, and
2306
+ the hourly housekeeping sweep deletes approvals past `expiresAt`.
2307
+
2308
+ ### Calling the engine
2309
+
2310
+ Every authenticated engine call is a UCAN invocation self-signed by `did:web:<ORACLE_HOSTNAME>`,
2311
+ addressed to the engine's did:web, carrying exactly ONE capability `{ with: "ixo:eval-engine",
2312
+ can: <the route's action> }` — never `*`, never a delegated proof. A minted token lives 5 minutes and
2313
+ is reused only for the same action until 30 s before it expires.
2314
+
2315
+ | Engine route | `can` | Called by |
2316
+ | --- | --- | --- |
2317
+ | `POST /v1/oracles/register` | `oracle/register` | the first decide, a `403 NOT_REGISTERED`, `decision-oracle engine register` |
2318
+ | `GET /v1/oracles/me` | `oracle/me` | the billing cron, `/v1/admin/status`, `decision-oracle engine register` |
2319
+ | `POST /v1/decisions` | `decision/decide` | the `evaluate` step |
2320
+ | `GET /v1/decisions/:claimId` | `decision/read` | an `evaluate` retry |
2321
+ | `POST /v1/decisions/preview` | `decision/preview` | `decision-oracle facts run --preview` |
2322
+ | `POST /v1/reviews/:claimId` | `review/submit` | the `ReviewComplete` fold |
2323
+ | `GET /v1/collections/:id/agreements` | `agreement/read` | `tools.agreement()` |
2324
+ | `GET /v1/receipts/:cid/report` | `receipt/report` | the Decision API's report route |
2325
+ | `GET /v1/sources/:oracleDid/:sourceId/company` | `company/read` | the Decision API's company sync (any non-2xx, a `404` included, is `503` and changes nothing — never read as "unlinked") |
2326
+
2327
+ The rubric manifest (`GET /v1/rubrics/manifest`) and an oracle's public stats are read with no
2328
+ invocation.
2329
+
2330
+ ### Cron (three triggers)
2331
+
2332
+ The oracle's `scheduled` handler routes by the trigger that fired (`event.cron`); each job runs in
2333
+ exactly one tier, and every one of the three must be in `triggers.crons` (every env), or its tier
2334
+ never runs. An unknown cron string is a `cron.unknown` warn and runs nothing — a provider's own
2335
+ trigger is handled in its own `scheduled` before it delegates to the oracle's.
2336
+
2337
+ | Trigger | Tier | Jobs |
2338
+ | --- | --- | --- |
2339
+ | `* * * * *` | minute | 1, 2, 4 (the resume) and 5 below; with `API_LANE` the lane's source resume, webhook deliveries and retries, review-batch emails and backlog gauges |
2340
+ | `*/5 * * * *` | five | `ai-cost-backfill`; with a facilitator, `payments-reconcile` and `balance-watch` |
2341
+ | `0 * * * *` | hour | 3 below; with `API_LANE` `retention` (housekeeping), `header-seal`, `review-billing-reminders`, `review-reminders`, `review-expiry`, `review-rooms`, `collection-links` |
2342
+
2343
+ Sentry cron monitors: `oracle-scan` and (with `API_LANE`) `oracle-api-lane` per minute,
2344
+ `oracle-cron-five`, `oracle-cron-hour`.
2345
+
2346
+ 1. `resumeEngineCredits` — while any collection is paused `engine-credits`, ask the engine's
2347
+ `GET /v1/oracles/me` **once** and resume them all when `billingStatus` is `ok`.
2348
+ 2. `resumeAdminFunds` — while any is paused `admin-unfunded`, read the collection admin's spendable
2349
+ balance, resume when it covers the largest single parked delivery per denom (the evaluation
2350
+ payment plus that decision's verdict payment: an ordinary approve's payout — the claim's own
2351
+ coins, else the collection's approval default — or a reject's rejection payment; an intent
2352
+ claim's approval is escrowed and adds nothing; no parked decision ⇒ the evaluation payment),
2353
+ send the paired notice, and **re-deliver** that collection's parked decisions (bounded per
2354
+ tick). Only once the evaluation payment alone is covered are the parked approves' claims
2355
+ read. At most 20 such collections are probed per
2356
+ tick, least recently probed first, 5 balance reads at a time; the resumes run one after another,
2357
+ and a collection still unfunded moves to the back, so the rest are reached on later minutes.
2358
+ 3. `review-rooms` — hourly: the chain review rooms (20 a pass, least recently synced first) are
2359
+ brought in line with their reviewer grants — an expired grant's reviewer kicked, a failed invite
2360
+ retried.
2361
+ 4. `payer-billing-resume` (in the Decision API lane's tick, `cron/payer-billing.ts`) — while any
2362
+ per-review collection is paused `payer-credits` / `payer-delegation` and IXO's plan allows
2363
+ per-review billing: up to 20 a minute, oldest pause first, re-resolve who pays (the engine's
2364
+ company → a live delegation signed by the organisation's owner → price, cap left; for
2365
+ `payer-credits` also the payer's credits read with the delegation) and resume those that can pay
2366
+ — the paired notice, then restart (`CLAIM_WORKFLOW.get(id).restart()`, at most 20) the claims whose
2367
+ run gave up while paused (ledger `failed`, error `payer-billing:…`). Hourly, `review-billing-reminders`
2368
+ emails each delegation's payer 30 and 7 days before its `exp`, once each (`matrix_deliveries`
2369
+ `rb_expiring:<cid>:<30|7>`; to the payer's member address, else the source's contact); one
2370
+ ending later than 30 days out never gets one. The same job sends the cap warnings: a live
2371
+ delegation whose `spentUpay` has crossed 80% of its cap emails its payer once, and once more at
2372
+ 95% (`rb_cap:<cid>:<80|95>`, same address rule; only rows whose current stage has no `sent` /
2373
+ `sending` row are read, 100 a run), with what is left in PAY, about how many reviews that is at
2374
+ the lowest published price, and how to raise it (`decision-oracle api review-billing enable --source
2375
+ <id> --cap <PAY>`, the OpenHands web app as the owner, or the Console's `CONSOLE_URL/billing/reviews`). Keyed by the CID, so a new
2376
+ delegation — a raised cap — starts its own pair.
2377
+ 5. `scan` — a bounded, rotating batch of active watched collections (least recently scanned first),
2378
+ a forward-only inclusive cursor per collection, one `ClaimIngest` instance per unseen claim.
2379
+ Duplicate-safe by construction: instance ids are idempotent and a claim with an existing ingest
2380
+ row is skipped. Cursors only advance after instances start, so a failed tick simply re-scans.
2381
+
2382
+ ### Workflows
2383
+
2384
+ - **`ClaimIngest`** — one instance per claim. `record-queued` → `evaluate` → `act` →
2385
+ `mark-processed`, each checkpointed and retried independently. `evaluate` is pull → facts →
2386
+ decide in **one** step, because the FactSet contains the claim answers and may never be a step
2387
+ result; only the small decision summary leaves it. A retry that finds the decision row returns it
2388
+ — no second pull, no second handler run. A retry that finds the row mid-flight asks the engine's
2389
+ stored decision first. Per-review billing (active by IXO's plan; a collection registered
2390
+ with a paying source): before the pull, a claim id the facilitator cannot key (not a lowercase
2391
+ base32 CIDv1, `^b[a-z2-7]{50,100}$`) is recorded `skipped` (`error: unbillable-claim-id`, logged
2392
+ `reserve.unbillable-claim-id`) — no offer, no decide, no chain write, no pause — and who pays is
2393
+ resolved fresh (the engine's company → the organisation's live delegation → its signer still the
2394
+ chain owner → the published price → cap left; none ⇒ pause `payer-delegation`); AFTER the facts
2395
+ lane built, so a facts-lane `unusable` / `invalid_facts` never spends the cap, the price is
2396
+ reserved: `POST /offers` (`lane: "chain"`, `collectionId`, the claim; PAY only), the charge row
2397
+ `offered` with the spend on the delegation's mirror in one batch, `/verify` with the stored
2398
+ delegation wrapped in the offer's `PaymentPayload` → `reserved`. A refusal deletes the row and
2399
+ gives the spend back. By x402-ixo-pay 0.7.0's codes (never its `detail`): `insufficient_credits` /
2400
+ `spend_cap` pause `payer-credits`; `invalid_delegation` / `wrong_audience` pause
2401
+ `payer-delegation`; `expired` (an expired offer OR delegation) gets one fresh offer, and a second
2402
+ `expired` pauses `payer-delegation`; `review_billing_not_allowed` / `plan_inactive` (also an
2403
+ `/offers` 422) pause `oracle-plan`; `nonce_used` retries with a fresh offer; `terms_mismatch` and
2404
+ any unknown code page (`payments.review.verify-unexpected`) and retry. The decision settles on its
2405
+ receipt (an engine `unusable` included), a `pending_review` extends its hold (`lane` +
2406
+ `collectionId` on the extend), the fold settles, and a reserved claim that will never get a
2407
+ receipt (a re-pinned rebuild the facts lane could not build, an engine refusal, a run that gave
2408
+ up) is released (`POST /v1/reservations/release`, `x402/release`; the amount back on the deed and
2409
+ the cap). Paused for engine credits or for payment, a retried `evaluate` does nothing until the
2410
+ collection resumes — no pull, no handler, no AI spend. `act` runs only for a `decided` result (a
2411
+ `pending_review` or `unusable` row has nothing to sign). With `ANSWERS_ARCHIVE` bound, a `submitted`
2412
+ chain write is followed by an `archive-paid` step (`answer.json` says `paid: yes`; fail-soft).
2413
+ - **`ReviewComplete`** — `fold` → `act` (again only for a `decided` fold). On a chain claim it first
2414
+ re-runs the reviewer gate over the stored `reviewerGrant` (a refusal is `foldError`
2415
+ `REVIEWER_GRANT_INVALID` / `REVIEWER_NOT_AUTHORIZED`, no pull, no engine call; a 503 retries).
2416
+ It re-pulls the claim (byte-identical, CID-verified), sends
2417
+ the stored answers and the grant to the engine, and the engine rebuilds its FactSet from the stored trace: **no
2418
+ new network, no new AI calls, no new charges**, and the same decision is amended rather than a
2419
+ second one created. The engine's own `REVIEWER_GRANT_INVALID` / `REVIEWER_NOT_AUTHORIZED` are
2420
+ non-retryable and land on `foldError` with its `details.reason`.
2421
+
2422
+ ### Pause reasons, and who lifts them
2423
+
2424
+ | `pausedReason` | Set when | Lifted by |
2425
+ | --- | --- | --- |
2426
+ | `engine-credits` | The engine answered `402 INSUFFICIENT_CREDITS` — the **owner's** credits are exhausted. Every watched collection pauses | The billing cron, automatically, once the engine says `ok` |
2427
+ | `engine-no-grant` | The engine refused with `NO_EVALUATE_GRANT` after one retry — this oracle is not (or no longer) hired for the collection | The owner grants again, then re-registers |
2428
+ | `rubric-unusable` | The engine's manifest could not bind the rubric, or a decide/fold answered `unusable`. Every claim would be refused the same way, so a broken catalog entry is paid for at most once | Fix the anchored rubric or `#vct`, then `POST /v1/collections/:id/resume` |
2429
+ | `admin-unfunded` | The chain refused a payment: the collection's **admin account** (the deed's entity-module account) cannot pay what the `MsgEvaluateClaim` draws — the evaluation payment on every one, plus the approval payment on an ordinary approve or the rejection payment on a reject (the message fails whole unless every leg is covered). The notice names that full amount per denom | The funding cron, automatically, once the balance covers the largest single parked delivery — and it re-delivers the parked decisions |
2430
+
2431
+ | `payer-credits` | Per-review billing: the facilitator refused a review's reservation for want of the paying organisation's credits (`insufficient_credits`) or of its send permission (`spend_cap`) | The payer cron, automatically, once the payer's credits (read with the stored delegation) cover one review — and it restarts the claims that gave up meanwhile |
2432
+ | `oracle-plan` | Per-review billing: the facilitator says IXO's plan does not let this oracle bill reviews (`review_billing_not_allowed` / `plan_inactive`), or the engine's plan no longer allows it. Pages IXO (`payments.review.oracle-plan`); the owner is NOT told — not theirs to fix | The payer cron, automatically, once the engine's plan allows per-review billing again |
2433
+ | `payer-delegation` | Per-review billing: nobody can pay — no live delegation for the company, expired, its cap used up, its signer no longer the organisation's chain owner, the company `disputed`, or no published price (that one also pages: the operator's config) | The payer cron once someone pays again; a `PUT /v1/api/source/review-billing` resumes its organisation's collections at once |
2434
+
2435
+ An operator's own pause (`POST …/pause`) has no reason, and no cron ever lifts it.
2436
+
2437
+ ### Notices
2438
+
2439
+ A review request and each pause/resume is delivered twice: a card in the collection's Matrix review
2440
+ room (the primary channel, keyed on the stable `(collectionId, ownerDid)` pair) and its email twin
2441
+ to the registered `contactEmail`. The room's members follow the collection's reviewer grants: every
2442
+ live one is invited when a review request is sent (one `crvi:<collectionId>:<mxid>` delivery row
2443
+ per person, the MXID derived from their `did:ixo`), a revoke kicks at once, and an hourly pass
2444
+ (the hourly cron trigger) kicks expired grants and retries failed invites. Both lanes are **idempotent** (a durable delivery marker plus a
2445
+ deterministic Matrix transaction id) and **fail-soft**: nothing here ever fails or blocks a claim,
2446
+ and an unset `ORACLE_MATRIX_ACCESS_TOKEN` / `EMAIL_FROM_ADDRESS` turns its lane into a logged skip.
2447
+ A pause is told once per collection, and a pause that follows a recovery is a fresh notice rather
2448
+ than a suppressed one.
2449
+
2450
+ ### Billing — who pays whom
2451
+
2452
+ - **You pay the model.** AI checks and the duplicate judge are your AI provider spend — Cloudflare AI Gateway credits by default (per-call USD in `ai_calls`), or OpenRouter.
2453
+ - **The collection owner pays the engine.** Every decision is billed to the owner's IXO subscription,
2454
+ found through the `OWNER_DELEGATION_CAR` you posted at register. An exhausted balance is the `402`
2455
+ above: the owner tops up at the portal URL the engine names, and the cron resumes everything.
2456
+ - **The chain pays you.** The collection's on-chain `Payments.evaluation` — the coin your
2457
+ `pricing.evaluation` gate insists on — is split 80/10/10 by the chain on every `MsgEvaluateClaim`
2458
+ you sign. That split is the oracle's income, which is why registering a collection that pays less
2459
+ than your minimum is refused.
2460
+ - **The collection's admin account pays the evaluation leg**, on every evaluation, approve or reject
2461
+ — and, on the same message, the approval payment of an ordinary approve (an intent claim's comes
2462
+ from escrow) or the rejection payment of a reject. The chain fails the message whole unless the
2463
+ account covers every leg; a short one is the `admin-unfunded` pause.
2464
+
2465
+ ### The chain write
2466
+
2467
+ `deliverChainEvaluation` reads everything from the decision row and the chain. It acts **only** on a
2468
+ terminal `approve`/`reject`, only when `policies.autoSign` **and** `AUTO_SIGN` are both on, and only
2469
+ when `ORACLE_SECP_MNEMONIC` is set (otherwise: one log line, skip). `verification_proof` is the
2470
+ engine's signed receipt CID — a terminal decision without one is parked `blocked`, never delivered
2471
+ with an invented proof. Delivered-once is enforced on `chainStatus`; an evaluation already on chain
2472
+ records as `submitted` (ours) or `owner_submitted` (someone else's). Payment lane: an ordinary claim
2473
+ echoes its coins, an intent (escrow) claim sends none.
2474
+
2475
+ `chainStatus` on the decision row is one of `pending` (not yet attempted), `submitted`,
2476
+ `owner_submitted`, `blocked`, `skipped`, or **`held`** — before any of the guards above run, your own
2477
+ `policies.act` hook gets one say (`ActContext`/`ActDecision`, "the `/runtime` subpath" above): `hold`
2478
+ parks the row `held` — deliberately **not** treated as delivered by the delivered-once guard — for a
2479
+ payout you compute later (a pooled batch, say). `signEvaluation` (`@ixo/decision-oracle/runtime`) is the
2480
+ release pass: it re-runs every one of the lane's own guards and signs, so it is safe to call on a
2481
+ row that already moved on (an already-`submitted` row just answers `"skipped"`).
2482
+
2483
+ ---
2484
+
2485
+ ## 13. Errors
2486
+
2487
+ Every HTTP error is one envelope:
2488
+
2489
+ ```json
2490
+ { "error": { "code": "...", "message": "...", "requestId": "...", "details": {} } }
2491
+ ```
2492
+
2493
+ Inside a handler, throwing anything other than `fact.retryLater()` is an error fact, not an error
2494
+ response — see §7.
2495
+
2496
+ ### CLI exit codes
2497
+
2498
+ | Code | Meaning |
2499
+ | --- | --- |
2500
+ | `0` | Success — every declared fact answered, or the command did what it said |
2501
+ | `1` | A real failure: the engine refused or was unreachable, the oracle's Decision API refused (`api …` prints the envelope's `code`, `message`, `requestId`), validation issues (`facts run`), a handler threw |
2502
+ | `2` | The rubric is **unusable** — `protocol review` and `facts run` print the engine's issues; there is nothing to build against yet |
2503
+ | `64` | Usage (`EX_USAGE`): a missing flag, an unknown command, or a handlers module that exports no `facts` |
2504
+
2505
+ The full command table lives in the package README. Commands used while writing handlers:
2506
+ `protocol review <did>`, `facts scaffold <did>`, `facts run --protocol <did> --claim <file>
2507
+ [--preview]`, `manifest <did>`.
2508
+
2509
+ `engine register [--env <name>] [--env-file <path>] [--engine-url <url>] [--network <n>] [--yes]
2510
+ [--json]` registers the oracle with the decision engine explicitly (`POST /v1/oracles/register`: the
2511
+ ADR-36 proof over `ORACLE_SECP_MNEMONIC`'s address plus `OWNER_DELEGATION_CAR`, minted as
2512
+ `did:web:<ORACLE_HOSTNAME>` of the named wrangler env), then reads `GET /v1/oracles/me` and prints
2513
+ the billing status. It reads `GET /v1/oracles/me` first too: when the engine already binds that
2514
+ did:web to a DIFFERENT address it prints old → new and exits 1 unless `--yes` (an identity change —
2515
+ the engine's record of the oracle moves to the new address); the same address, or not registered
2516
+ yet, proceeds. The Worker registers lazily at its first decide; running this after the first deploy
2517
+ makes the oracle payable at once — an oracle with no plan row of its own runs at the engine's
2518
+ Standard plan terms (payable, listed). A third-party oracle needs nothing more; IXO staff move
2519
+ IXO's OWN oracles to the IXO-owned plan. The engine resolves the did:web from the live host, so run
2520
+ it against a deployed env. Idempotent. Exit 1 with the engine's code on a refusal (a 402 is the
2521
+ owner's credits).
2522
+
2523
+ The Decision API lane's operator commands (`API_LANE` on) are `decision-oracle api …`: `source create
2524
+ --name <n> --contact <email> --entity <did:ixo:entity:…>` (the login must control the organisation;
2525
+ the webhook secret is printed once; the source stays `pending-link` until its link syncs), `source
2526
+ link <sourceId> [--engine-url <url>]` (the console's "Add oracle" steps 2–3: the login signs the
2527
+ `SourceCompanyLink` statement — docs/ENGINE-CONTRACT "The company link", `kid` the did:key of its
2528
+ Ed25519 key, the payload canonical JSON, `payeeField` null — PUTs it to the engine's `PUT
2529
+ /v1/sources/:oracleDid/:sourceId/company` as `{ with: "ixo:eval-engine", can: "company/link" }`, then
2530
+ calls the oracle's `POST /v1/api/sources/:sourceId/company/sync` (`sources/company-sync`) and prints
2531
+ the source, `active` or `disputed`; the entity is the source's own, read from `GET /v1/api/me`; a
2532
+ source that is not `pending-link` is printed and left alone; an engine refusal prints its envelope
2533
+ and exits 1 without syncing), `source delete <sourceId>` (the undo for a wrong link, and the
2534
+ way out of `disputed`: the plan names the source's name, status and organisation, read from `GET
2535
+ /v1/api/me`; `--yes` sends `DELETE /v1/api/sources/:sourceId` (`sources/delete`, root only — a
2536
+ current controller, no proof), which deletes the source and every row off it, and prints `{
2537
+ "deleted": true, "sourceId" }`; a refusal prints its envelope and exits 1), `sources`, `key create --source
2538
+ <id> --scopes <scope,…> [--name] [--max-spend-cents]` (the key is printed once), `keys --source
2539
+ <id>`, `key revoke --source <id> <prefix> [--grace-days <n>]` (the prefix, or the masked
2540
+ `eo_test_<prefix>_…` / whole key — only the 8-char prefix is sent), and, for a DID in
2541
+ `ORACLE_ADMIN_DIDS`, `admin sources | pause <id> [--note] | resume <id> | protocols <id> <did,…>|--none
2542
+ | billing-mode <id> x402|none` (`--none` sets the empty allow-list: nothing is allowed), and
2543
+ `review-billing enable | status | disable --source <id>` (below). `api
2544
+ --help` prints the list. Each is one console invocation signed as the AuthHub `login`: audience the oracle's
2545
+ did:web (read off its `/.well-known/did.json`), capability `{ with: "ixo:oracle/<did:web>", can:
2546
+ <the route's action> }` (its `x-ucan-action`) — `…/source/<id>` plus `X-Source-Id` on a source's
2547
+ routes — 120 s, no proofs, so on a source the login must be its owner. Every POST/PUT/DELETE carries a fresh `Idempotency-Key`. The oracle
2548
+ is `--kit`, else the project's `ORACLE_HOSTNAME` (a project whose config fails to load is reported
2549
+ as that failure); `--network` defaults to the project's `IXO_NETWORK`, else devnet. `--json`
2550
+ prints the answer as the API gave it and nothing else on stdout; a source link or delete, a revoke and
2551
+ every `admin` change are dry runs without `--yes` (exit 0), their plan on stderr under `--json` — so a
2552
+ `--json` dry run prints nothing on stdout.
2553
+
2554
+ `api review-billing …` is the Console's "Pay for reviews" (the review-billing routes above).
2555
+ `enable --source <id> [--cap <PAY>]` refuses, before anything is signed and exit 1: an oracle whose
2556
+ public `/v1/info` `reviewBilling.mode` is not `x402` (or that publishes no `pricePerReview`), a
2557
+ `--cap` below one review's price, a source whose `billingMode` is not `x402`, and a login that is not
2558
+ the CURRENT chain owner of the source's `companyEntityDid` (`GET /v1/api/me`, then Blocksync's
2559
+ `entity.owner`, compared as `did:ixo:<owner>`). Then — a dry run without `--yes`, the plan naming
2560
+ the payer, the audience, `nb.oracles`, the price, the cap and the expiry — the login's Ed25519 key
2561
+ (`edSigningMnemonic`, the key its IID publishes) signs x402-ixo-pay's `buildPaymentDelegation`:
2562
+ issuer the login's did:ixo, audience `did:web:<NETWORKS[network].facilitator host>`, one `{ can:
2563
+ "billing/pay", with: "billing:decision-api", nb: { maxAmount, denom: "upay", oracles:
2564
+ [<the oracle's /v1/info serviceDid>] } }`, `exp` now + 100 years, no `nbf`, no proofs, no facts.
2565
+ `maxAmount` is `--cap` × 1 000 000 upay (default `1000` PAY, at most 6 decimals) — cumulative over
2566
+ the delegation's whole life (the facilitator never refunds or resets it); `enable` again with a
2567
+ higher `--cap` stores a new delegation that replaces the old one and starts at 0 spent. The token
2568
+ is PUT as `{ delegation }` (`review-billing/set`), then deposited in the network's UCAN store
2569
+ (`POST /api/delegations`, `{ can: "store/add", with: "ixo:ucan-store" }`, fail-soft: a miss is a
2570
+ warning) — the store revokes by CID only what it holds, and the oracle never returns the token —
2571
+ and the command prints `GET` (`review-billing/read`), as `status --source <id>` does on its own (a
2572
+ `404` there is "none held", exit 0). `disable --source <id>` reads that state, then (with `--yes`)
2573
+ `DELETE`s it (`review-billing/delete`) and, when the login is its `payer`, revokes its CID in the
2574
+ UCAN store (`POST /api/revocations` `{ cid, reason }`, `{ can: "store/revoke" }` — permanent). A
2575
+ store without the copy (`404`, e.g. one signed in the Console), another signer, or a store error
2576
+ is printed as "not revoked" with what a full stop takes; the exit is still 0, since the oracle
2577
+ no longer holds it and `nb.oracles` lets no other oracle charge it. `--json` prints the GET body
2578
+ (enable, status) or `{ deleted, message, revoked, delegationCid }` (disable).
2579
+
2580
+ An organisation's own commands, signed as the same `login`: `org create --name <name> [--logo
2581
+ <https url>] [--network <n>] [--yes]` is the Decision Console's "Create organisation" — the profile
2582
+ JSON-LD (`orgName`/`name`/`brand` = the name, `image`/`logo` = the logo) stored on the IXO Vault
2583
+ under the login's key (else the IXO IPFS gateway) and anchored as `{id}#pro` (`Settings`, proof = the CIDv1 raw sha2-256 of its
2584
+ bytes) inside ONE `MsgCreateEntity` `{ entityType: "dao", controller: [login DID], ownerDid,
2585
+ ownerAddress }` on the network's relayer node; idempotent by name (a `dao` whose IID controllers include the login DID — the console's org
2586
+ list, owned or co-controlled — whose `#pro` `name` equals `--name`, trimmed, is printed with `created: false` and nothing is
2587
+ signed; an unreadable profile fails the command rather than risk a second organisation); after a
2588
+ create it polls Blocksync up to ~30 s for the new entity. Stdout is one JSON line `{ entityDid,
2589
+ created }`; the plan, the tx hash and progress go to stderr; a dry run without `--yes`. `org balance
2590
+ --entity <did> [--network <n>]` prints `{ adminAddress, balances: [{ denom, amount }] }`: the
2591
+ entity's `admin` account from Blocksync `accounts`, every denom from the chain's
2592
+ `/cosmos/bank/v1beta1/balances/<address>` (all pages, `NETWORKS[network].rest`), amounts as the
2593
+ chain's base-unit strings. `logout [--network <n>]` deletes `~/.ixo/publish-session-<network>.json`
2594
+ and prints `{ removed, path }` (never the file's contents; `removed: false` when there was none).
2595
+