@harperfast/harper 5.3.0-beta.3 → 5.3.0-beta.4

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 (311) hide show
  1. package/bin/cliOperations.ts +25 -3
  2. package/bin/run.ts +18 -0
  3. package/components/Application.ts +52 -78
  4. package/components/DESIGN.md +76 -3
  5. package/components/Scope.ts +10 -4
  6. package/components/anthropic/index.ts +10 -1
  7. package/components/bedrock/index.ts +10 -1
  8. package/components/componentLoader.ts +23 -14
  9. package/components/mcp/tools/operations.ts +3 -1
  10. package/components/ollama/index.ts +10 -1
  11. package/components/openai/index.ts +265 -18
  12. package/components/operations.js +40 -1
  13. package/config/configUtils.ts +3 -19
  14. package/dataLayer/DESIGN.md +29 -5
  15. package/dataLayer/harperBridge/ResourceBridge.ts +39 -3
  16. package/dataLayer/restoreMarker.ts +250 -14
  17. package/dataLayer/schema.ts +2 -7
  18. package/dataLayer/schemaDescribe.ts +10 -0
  19. package/dist/bin/cliOperations.js +28 -3
  20. package/dist/bin/cliOperations.js.map +1 -1
  21. package/dist/bin/run.js +19 -0
  22. package/dist/bin/run.js.map +1 -1
  23. package/dist/components/Application.d.ts +8 -2
  24. package/dist/components/Application.js +43 -69
  25. package/dist/components/Application.js.map +1 -1
  26. package/dist/components/Scope.d.ts +1 -1
  27. package/dist/components/Scope.js +9 -4
  28. package/dist/components/Scope.js.map +1 -1
  29. package/dist/components/anthropic/index.d.ts +0 -10
  30. package/dist/components/anthropic/index.js +9 -1
  31. package/dist/components/anthropic/index.js.map +1 -1
  32. package/dist/components/bedrock/index.d.ts +0 -12
  33. package/dist/components/bedrock/index.js +9 -1
  34. package/dist/components/bedrock/index.js.map +1 -1
  35. package/dist/components/componentLoader.js +23 -14
  36. package/dist/components/componentLoader.js.map +1 -1
  37. package/dist/components/mcp/tools/operations.js +1 -1
  38. package/dist/components/mcp/tools/operations.js.map +1 -1
  39. package/dist/components/ollama/index.d.ts +0 -13
  40. package/dist/components/ollama/index.js +9 -1
  41. package/dist/components/ollama/index.js.map +1 -1
  42. package/dist/components/openai/index.d.ts +11 -15
  43. package/dist/components/openai/index.js +224 -12
  44. package/dist/components/openai/index.js.map +1 -1
  45. package/dist/components/operations.d.ts +9 -0
  46. package/dist/components/operations.js +32 -2
  47. package/dist/components/operations.js.map +1 -1
  48. package/dist/config/configUtils.d.ts +1 -2
  49. package/dist/config/configUtils.js +7 -22
  50. package/dist/config/configUtils.js.map +1 -1
  51. package/dist/dataLayer/harperBridge/ResourceBridge.js +29 -2
  52. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  53. package/dist/dataLayer/restoreMarker.d.ts +25 -3
  54. package/dist/dataLayer/restoreMarker.js +213 -13
  55. package/dist/dataLayer/restoreMarker.js.map +1 -1
  56. package/dist/dataLayer/schema.js +1 -4
  57. package/dist/dataLayer/schema.js.map +1 -1
  58. package/dist/dataLayer/schemaDescribe.js +10 -0
  59. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.js.map +1 -1
  62. package/dist/json/systemSchema.json +28 -0
  63. package/dist/resources/DatabaseTransaction.d.ts +21 -1
  64. package/dist/resources/DatabaseTransaction.js +147 -8
  65. package/dist/resources/DatabaseTransaction.js.map +1 -1
  66. package/dist/resources/PrimaryRocksDatabase.js +1 -1
  67. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  68. package/dist/resources/Table.d.ts +31 -3
  69. package/dist/resources/Table.js +397 -79
  70. package/dist/resources/Table.js.map +1 -1
  71. package/dist/resources/auditStore.js +16 -10
  72. package/dist/resources/auditStore.js.map +1 -1
  73. package/dist/resources/blob.d.ts +1 -0
  74. package/dist/resources/blob.js +14 -5
  75. package/dist/resources/blob.js.map +1 -1
  76. package/dist/resources/branchDatabase.js +3 -3
  77. package/dist/resources/branchDatabase.js.map +1 -1
  78. package/dist/resources/databaseDropPreparation.d.ts +20 -0
  79. package/dist/resources/databaseDropPreparation.js +106 -0
  80. package/dist/resources/databaseDropPreparation.js.map +1 -0
  81. package/dist/resources/databases.d.ts +26 -21
  82. package/dist/resources/databases.js +1040 -204
  83. package/dist/resources/databases.js.map +1 -1
  84. package/dist/resources/derivedIndexRegistry.d.ts +23 -0
  85. package/dist/resources/derivedIndexRegistry.js +92 -0
  86. package/dist/resources/derivedIndexRegistry.js.map +1 -1
  87. package/dist/resources/derivedIndexRuntime.d.ts +10 -0
  88. package/dist/resources/derivedIndexRuntime.js +136 -25
  89. package/dist/resources/derivedIndexRuntime.js.map +1 -1
  90. package/dist/resources/derivedIndexes.d.ts +39 -0
  91. package/dist/resources/derivedIndexes.js +733 -0
  92. package/dist/resources/derivedIndexes.js.map +1 -0
  93. package/dist/resources/fullTextSchema.d.ts +8 -0
  94. package/dist/resources/fullTextSchema.js +42 -0
  95. package/dist/resources/fullTextSchema.js.map +1 -1
  96. package/dist/resources/fullTextSchemaLifecycle.d.ts +0 -5
  97. package/dist/resources/fullTextSchemaLifecycle.js +0 -9
  98. package/dist/resources/fullTextSchemaLifecycle.js.map +1 -1
  99. package/dist/resources/graphql.js +101 -9
  100. package/dist/resources/graphql.js.map +1 -1
  101. package/dist/resources/indexes/hnswDerivedIndex.d.ts +0 -10
  102. package/dist/resources/indexes/hnswDerivedIndex.js +0 -230
  103. package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -1
  104. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +3 -0
  105. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +57 -8
  106. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -1
  107. package/dist/resources/models/Models.d.ts +26 -3
  108. package/dist/resources/models/Models.js +256 -13
  109. package/dist/resources/models/Models.js.map +1 -1
  110. package/dist/resources/models/TestBackend.d.ts +3 -1
  111. package/dist/resources/models/TestBackend.js +35 -1
  112. package/dist/resources/models/TestBackend.js.map +1 -1
  113. package/dist/resources/models/analyticsTable.d.ts +2 -2
  114. package/dist/resources/models/analyticsTable.js +4 -2
  115. package/dist/resources/models/analyticsTable.js.map +1 -1
  116. package/dist/resources/models/backendHelpers.d.ts +24 -0
  117. package/dist/resources/models/backendHelpers.js +18 -1
  118. package/dist/resources/models/backendHelpers.js.map +1 -1
  119. package/dist/resources/models/backendRegistry.d.ts +10 -21
  120. package/dist/resources/models/backendRegistry.js +74 -18
  121. package/dist/resources/models/backendRegistry.js.map +1 -1
  122. package/dist/resources/models/bootstrap.d.ts +7 -0
  123. package/dist/resources/models/bootstrap.js +36 -4
  124. package/dist/resources/models/bootstrap.js.map +1 -1
  125. package/dist/resources/models/decideHook.d.ts +33 -0
  126. package/dist/resources/models/decideHook.js +105 -0
  127. package/dist/resources/models/decideHook.js.map +1 -0
  128. package/dist/resources/models/decision.d.ts +64 -0
  129. package/dist/resources/models/decision.js +507 -0
  130. package/dist/resources/models/decision.js.map +1 -0
  131. package/dist/resources/models/decisionStore.d.ts +134 -0
  132. package/dist/resources/models/decisionStore.js +350 -0
  133. package/dist/resources/models/decisionStore.js.map +1 -0
  134. package/dist/resources/models/embedHook.d.ts +66 -8
  135. package/dist/resources/models/embedHook.js +228 -61
  136. package/dist/resources/models/embedHook.js.map +1 -1
  137. package/dist/resources/models/generativeDecision.d.ts +58 -0
  138. package/dist/resources/models/generativeDecision.js +315 -0
  139. package/dist/resources/models/generativeDecision.js.map +1 -0
  140. package/dist/resources/models/types.d.ts +232 -4
  141. package/dist/security/authn/oidc/tokenExchange.js +3 -33
  142. package/dist/security/authn/oidc/tokenExchange.js.map +1 -1
  143. package/dist/security/authn/oidc/tokenUseTable.d.ts +9 -0
  144. package/dist/security/authn/oidc/tokenUseTable.js +28 -0
  145. package/dist/security/authn/oidc/tokenUseTable.js.map +1 -0
  146. package/dist/security/certificateVerification/certificateVerificationSource.d.ts +0 -1
  147. package/dist/security/certificateVerification/certificateVerificationSource.js +3 -3
  148. package/dist/security/certificateVerification/certificateVerificationSource.js.map +1 -1
  149. package/dist/security/certificateVerification/crlVerification.js +77 -165
  150. package/dist/security/certificateVerification/crlVerification.js.map +1 -1
  151. package/dist/security/certificateVerification/types.d.ts +0 -3
  152. package/dist/security/certificateVerification/verificationTables.d.ts +19 -0
  153. package/dist/security/certificateVerification/verificationTables.js +125 -0
  154. package/dist/security/certificateVerification/verificationTables.js.map +1 -0
  155. package/dist/security/certificateVerification/verificationUtils.d.ts +1 -1
  156. package/dist/security/certificateVerification/verificationUtils.js +6 -31
  157. package/dist/security/certificateVerification/verificationUtils.js.map +1 -1
  158. package/dist/security/jsLoader.d.ts +5 -0
  159. package/dist/security/jsLoader.js +8 -0
  160. package/dist/security/jsLoader.js.map +1 -1
  161. package/dist/server/Server.d.ts +1 -0
  162. package/dist/server/Server.js.map +1 -1
  163. package/dist/server/http.js +2 -2
  164. package/dist/server/http.js.map +1 -1
  165. package/dist/server/itc/serverHandlers.js +21 -2
  166. package/dist/server/itc/serverHandlers.js.map +1 -1
  167. package/dist/server/jobs/jobOwnership.d.ts +25 -0
  168. package/dist/server/jobs/jobOwnership.js +127 -0
  169. package/dist/server/jobs/jobOwnership.js.map +1 -0
  170. package/dist/server/jobs/jobProcess.js +9 -2
  171. package/dist/server/jobs/jobProcess.js.map +1 -1
  172. package/dist/server/jobs/jobs.js +18 -5
  173. package/dist/server/jobs/jobs.js.map +1 -1
  174. package/dist/server/loadRootComponents.js +10 -0
  175. package/dist/server/loadRootComponents.js.map +1 -1
  176. package/dist/server/serverHelpers/contentTypes.js +11 -1
  177. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  178. package/dist/server/serverHelpers/serverHandlers.js +12 -2
  179. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  180. package/dist/server/threads/itc.d.ts +4 -1
  181. package/dist/server/threads/itc.js +29 -6
  182. package/dist/server/threads/itc.js.map +1 -1
  183. package/dist/server/threads/manageThreads.d.ts +12 -4
  184. package/dist/server/threads/manageThreads.js +241 -119
  185. package/dist/server/threads/manageThreads.js.map +1 -1
  186. package/dist/server/threads/threadServer.d.ts +1 -0
  187. package/dist/server/threads/threadServer.js +15 -1
  188. package/dist/server/threads/threadServer.js.map +1 -1
  189. package/dist/server/threads/windowsProcessTree.d.ts +91 -0
  190. package/dist/server/threads/windowsProcessTree.js +361 -0
  191. package/dist/server/threads/windowsProcessTree.js.map +1 -0
  192. package/dist/server/throttle.d.ts +1 -1
  193. package/dist/server/throttle.js +8 -1
  194. package/dist/server/throttle.js.map +1 -1
  195. package/dist/upgrade/directives/5-3-0.js +53 -46
  196. package/dist/upgrade/directives/5-3-0.js.map +1 -1
  197. package/dist/utility/errors/hdbError.d.ts +10 -1
  198. package/dist/utility/errors/hdbError.js +55 -11
  199. package/dist/utility/errors/hdbError.js.map +1 -1
  200. package/dist/utility/fsync.d.ts +2 -0
  201. package/dist/utility/fsync.js +20 -0
  202. package/dist/utility/fsync.js.map +1 -0
  203. package/dist/utility/globalSchema.d.ts +18 -0
  204. package/dist/utility/hdbTerms.d.ts +3 -0
  205. package/dist/utility/hdbTerms.js +3 -0
  206. package/dist/utility/hdbTerms.js.map +1 -1
  207. package/dist/utility/signalling.d.ts +7 -1
  208. package/dist/utility/signalling.js +27 -3
  209. package/dist/utility/signalling.js.map +1 -1
  210. package/dist/utility/when.d.ts +1 -0
  211. package/dist/utility/when.js +21 -0
  212. package/dist/utility/when.js.map +1 -1
  213. package/dist/validation/configValidator.js +30 -3
  214. package/dist/validation/configValidator.js.map +1 -1
  215. package/index.ts +13 -0
  216. package/json/systemSchema.json +28 -0
  217. package/npm-shrinkwrap.json +151 -55
  218. package/package.json +14 -13
  219. package/resources/DESIGN.md +80 -100
  220. package/resources/DatabaseTransaction.ts +157 -10
  221. package/resources/PrimaryRocksDatabase.ts +2 -2
  222. package/resources/Table.ts +494 -101
  223. package/resources/auditStore.ts +18 -10
  224. package/resources/blob.ts +13 -6
  225. package/resources/branchDatabase.ts +3 -3
  226. package/resources/databaseDropPreparation.ts +120 -0
  227. package/resources/databases.ts +1155 -205
  228. package/resources/derivedIndexRegistry.ts +122 -0
  229. package/resources/derivedIndexRuntime.ts +157 -30
  230. package/resources/derivedIndexes.ts +856 -0
  231. package/resources/fullTextSchema.ts +55 -0
  232. package/resources/fullTextSchemaLifecycle.ts +0 -15
  233. package/resources/graphql.ts +107 -12
  234. package/resources/indexes/DESIGN.md +7 -0
  235. package/resources/indexes/hnswDerivedIndex.ts +0 -260
  236. package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +72 -9
  237. package/resources/models/DESIGN.md +39 -0
  238. package/resources/models/Models.ts +361 -18
  239. package/resources/models/TestBackend.ts +56 -1
  240. package/resources/models/analyticsTable.ts +6 -4
  241. package/resources/models/backendHelpers.ts +17 -0
  242. package/resources/models/backendRegistry.ts +76 -21
  243. package/resources/models/bootstrap.ts +57 -7
  244. package/resources/models/decideHook.ts +135 -0
  245. package/resources/models/decision.ts +505 -0
  246. package/resources/models/decisionStore.ts +419 -0
  247. package/resources/models/embedHook.ts +272 -55
  248. package/resources/models/generativeDecision.ts +459 -0
  249. package/resources/models/types.ts +231 -4
  250. package/security/DESIGN.md +22 -2
  251. package/security/authn/oidc/tokenExchange.ts +6 -34
  252. package/security/authn/oidc/tokenUseTable.ts +27 -0
  253. package/security/certificateVerification/certificateVerificationSource.ts +2 -3
  254. package/security/certificateVerification/crlVerification.ts +96 -174
  255. package/security/certificateVerification/types.ts +0 -3
  256. package/security/certificateVerification/verificationTables.ts +124 -0
  257. package/security/certificateVerification/verificationUtils.ts +8 -32
  258. package/security/jsLoader.ts +9 -1
  259. package/server/DESIGN.md +27 -1
  260. package/server/Server.ts +2 -1
  261. package/server/http.ts +4 -2
  262. package/server/itc/serverHandlers.js +29 -1
  263. package/server/jobs/jobOwnership.ts +89 -0
  264. package/server/jobs/jobProcess.ts +8 -2
  265. package/server/jobs/jobs.ts +15 -4
  266. package/server/loadRootComponents.js +11 -0
  267. package/server/serverHelpers/contentTypes.ts +13 -1
  268. package/server/serverHelpers/serverHandlers.js +11 -2
  269. package/server/threads/itc.js +32 -6
  270. package/server/threads/manageThreads.js +268 -122
  271. package/server/threads/threadServer.js +13 -1
  272. package/server/threads/windowsProcessTree.ts +409 -0
  273. package/server/throttle.ts +11 -1
  274. package/studio/web/assets/{Chat-CveGbxe6.js → Chat-Bq60AO-g.js} +1 -1
  275. package/studio/web/assets/{FloatingChat-IpCFgYWL.js → FloatingChat-D4560T0p.js} +3 -3
  276. package/studio/web/assets/{apiToken-CS8fFTgL.js → apiToken-BEvbFPMc.js} +1 -1
  277. package/studio/web/assets/{applications-D7F4Jopl.js → applications-CSUVTH4E.js} +1 -1
  278. package/studio/web/assets/{cssMode-Daw_oLPq.js → cssMode-DJyc9mHj.js} +1 -1
  279. package/studio/web/assets/{editor-B3Bs1zWX.js → editor-DDIsQfj6.js} +1 -1
  280. package/studio/web/assets/{html-CDaQwWiE.js → html-g1BJHxLy.js} +1 -1
  281. package/studio/web/assets/{htmlMode-Cb6emO4U.js → htmlMode-Vh5IglCQ.js} +1 -1
  282. package/studio/web/assets/{index-B4g2Fu_2.js → index-DGRAzvO4.js} +5 -5
  283. package/studio/web/assets/{index.lazy-NwhvF1q1.js → index.lazy-vgSic7et.js} +1 -1
  284. package/studio/web/assets/{javascript-2_86gB9Q.js → javascript-5VIolqf7.js} +1 -1
  285. package/studio/web/assets/{jsonMode-DX-i86uQ.js → jsonMode-DG4zQraI.js} +1 -1
  286. package/studio/web/assets/{languageServices-B85WN-Ej.js → languageServices-t5FBpX-i.js} +1 -1
  287. package/studio/web/assets/{lspLanguageFeatures-JCWHIsKg.js → lspLanguageFeatures-DgVnNPh-.js} +1 -1
  288. package/studio/web/assets/{notifications-D9ejzt52.js → notifications-BeWcX04m.js} +1 -1
  289. package/studio/web/assets/{notifications-B7hjKhai.js → notifications-DUGWrWoO.js} +1 -1
  290. package/studio/web/assets/{profile-D8B6No3F.js → profile-BvUEDBzW.js} +1 -1
  291. package/studio/web/assets/{regions-DFazIfTK.js → regions-CB-_2m7M.js} +1 -1
  292. package/studio/web/assets/{register-CwFLviLF.js → register-1w1Zr3b2.js} +2 -2
  293. package/studio/web/assets/{setComponentFile-VnJ80SSJ.js → setComponentFile-z4-qJCbA.js} +1 -1
  294. package/studio/web/assets/{setup-BYQxFy-C.js → setup-CS50uJ4x.js} +2 -2
  295. package/studio/web/assets/{status-Cn5XjmsL.js → status-CI0wOdOE.js} +1 -1
  296. package/studio/web/assets/{toggleHighContrast-g9xIh6TX.js → toggleHighContrast-BsAE4jPe.js} +1 -1
  297. package/studio/web/assets/{tsMode-EC-n0Wqc.js → tsMode-D83Gg3cq.js} +1 -1
  298. package/studio/web/assets/{typescript-DHmM9JCO.js → typescript-Dfp-GrNq.js} +1 -1
  299. package/studio/web/assets/{useEntityRestURL-BUdnVSVv.js → useEntityRestURL-BKetlINf.js} +1 -1
  300. package/studio/web/assets/{workers-BVl9bBB-.js → workers-CCvmDLH3.js} +1 -1
  301. package/studio/web/assets/{xml-C4_sqOkX.js → xml-06pZp00U.js} +1 -1
  302. package/studio/web/assets/{yaml-CVkbDqtW.js → yaml-iSnWMVlV.js} +1 -1
  303. package/studio/web/index.html +1 -1
  304. package/upgrade/directives/5-3-0.ts +61 -54
  305. package/utility/DESIGN.md +5 -1
  306. package/utility/errors/hdbError.ts +56 -8
  307. package/utility/fsync.ts +16 -0
  308. package/utility/hdbTerms.ts +3 -0
  309. package/utility/signalling.ts +33 -4
  310. package/utility/when.ts +22 -0
  311. package/validation/configValidator.ts +36 -7
@@ -20,7 +20,7 @@ export interface ModelCallRecord {
20
20
  app?: string;
21
21
  model?: string;
22
22
  backend: string;
23
- method: 'embed' | 'generate' | 'generateStream';
23
+ method: 'embed' | 'generate' | 'generateStream' | 'decide' | 'scoreChoices';
24
24
  adapter?: string;
25
25
  conversation_id?: string;
26
26
  prompt_tokens?: number;
@@ -134,15 +134,17 @@ export class ModelCallAnalyticsWriter {
134
134
  this.#cleanupTimer.unref?.();
135
135
  }
136
136
 
137
- write(record: ModelCallRecord): void {
138
- if (this.#stopped) return;
137
+ write(record: ModelCallRecord): number {
138
+ const id = getNextMonotonicTime();
139
+ if (this.#stopped) return id;
139
140
  // id last so a record that accidentally carries an `id` field can't override
140
141
  // the monotonic primary key.
141
- this.#buffer.push({ ...record, id: getNextMonotonicTime() });
142
+ this.#buffer.push({ ...record, id });
142
143
  if (this.#buffer.length >= this.#maxBufferSize) {
143
144
  // Out-of-cadence flush; swallow errors so a failing flush doesn't escape into the caller.
144
145
  this.flush().catch((err) => log.warn?.(`Model-call analytics flush failed: ${err?.message ?? err}`));
145
146
  }
147
+ return id;
146
148
  }
147
149
 
148
150
  async flush(): Promise<void> {
@@ -10,12 +10,29 @@
10
10
  * in via a constructor type so the thrown errors carry the backend's name
11
11
  * for `instanceof` matching in tests.
12
12
  */
13
+ import { ServerError } from '../../utility/errors/hdbError.ts';
13
14
  import { isUnresolvedEnvVarPlaceholder } from '../../utility/expandEnvVar.ts';
14
15
  import type { TokenUsage } from './types.ts';
15
16
 
16
17
  /** Constructor signature for backend-specific error classes. */
17
18
  export type BackendErrorCtor = new (message: string) => Error;
18
19
 
20
+ /**
21
+ * Thrown by a backend's `scoreChoices` for a call it cannot score (#2838), as distinct from a call
22
+ * that failed: the decision adapter votes instead in `auto` mode, and the facade records the
23
+ * attempt as `scoring_unsupported`. `usage` carries the tokens the attempt did consume (a
24
+ * completion that came back without log-probabilities is still billed), so the failure row
25
+ * accounts for them.
26
+ */
27
+ export class ChoiceScoringUnsupportedError extends ServerError {
28
+ declare usage?: TokenUsage;
29
+ constructor(message: string, usage?: TokenUsage) {
30
+ super(message);
31
+ this.name = 'ChoiceScoringUnsupportedError';
32
+ if (usage) this.usage = usage;
33
+ }
34
+ }
35
+
19
36
  /**
20
37
  * Combine a caller-supplied AbortSignal with a per-call timeout via
21
38
  * `AbortSignal.any`. Returns the caller signal directly when no timeout is
@@ -1,6 +1,13 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { ServerError } from '../../utility/errors/hdbError.ts';
3
- import type { DefineBackendSpec, GenerateResult, ModelBackend, ModelCapabilities, ToolCall } from './types.ts';
3
+ import type {
4
+ DefineBackendSpec,
5
+ GenerateResult,
6
+ ModelBackend,
7
+ ModelCapabilities,
8
+ ModelKind,
9
+ ToolCall,
10
+ } from './types.ts';
4
11
 
5
12
  /**
6
13
  * Process-wide model backend registry.
@@ -20,10 +27,11 @@ import type { DefineBackendSpec, GenerateResult, ModelBackend, ModelCapabilities
20
27
  * step) lands in Phase 2 alongside the first real backend.
21
28
  */
22
29
 
23
- type ModelKind = 'embedding' | 'generative';
24
-
25
- const embedding: Map<string, ModelBackend> = new Map();
26
- const generative: Map<string, ModelBackend> = new Map();
30
+ const registries: Record<ModelKind, Map<string, ModelBackend>> = {
31
+ embedding: new Map(),
32
+ generative: new Map(),
33
+ decision: new Map(),
34
+ };
27
35
 
28
36
  /** A registration a factory made during construction, deferred to the caller. */
29
37
  export interface CapturedInstall {
@@ -54,7 +62,7 @@ function install(kind: ModelKind, logicalName: string, backend: ModelBackend): v
54
62
  else slot.extras.push({ kind, logicalName, backend });
55
63
  return;
56
64
  }
57
- (kind === 'embedding' ? embedding : generative).set(logicalName, backend);
65
+ registries[kind].set(logicalName, backend);
58
66
  }
59
67
 
60
68
  /** Map `logicalName` to a backend for embedding calls. Re-set replaces. */
@@ -67,6 +75,11 @@ export function setGenerative(logicalName: string, backend: ModelBackend): void
67
75
  install('generative', logicalName, backend);
68
76
  }
69
77
 
78
+ /** Map `logicalName` to a backend for decide calls. Re-set replaces. */
79
+ export function setDecision(logicalName: string, backend: ModelBackend): void {
80
+ install('decision', logicalName, backend);
81
+ }
82
+
70
83
  /**
71
84
  * Build a backend through its normal registration path but return it instead of installing it, so a
72
85
  * config reload can install it conditionally. A scratch logical name would be briefly visible
@@ -99,7 +112,7 @@ export function replaceIfCurrent(
99
112
  expected: ModelBackend | undefined,
100
113
  next: ModelBackend
101
114
  ): boolean {
102
- const map = kind === 'embedding' ? embedding : generative;
115
+ const map = registries[kind];
103
116
  if (map.get(logicalName) !== expected) return false;
104
117
  map.set(logicalName, next);
105
118
  return true;
@@ -110,7 +123,7 @@ export function replaceIfCurrent(
110
123
  * removed. Withdrawing a credential must not delete a slot another writer has since taken over.
111
124
  */
112
125
  export function removeIfCurrent(kind: ModelKind, logicalName: string, expected: ModelBackend | undefined): boolean {
113
- const map = kind === 'embedding' ? embedding : generative;
126
+ const map = registries[kind];
114
127
  if (map.get(logicalName) !== expected) return false;
115
128
  map.delete(logicalName);
116
129
  return true;
@@ -118,7 +131,7 @@ export function removeIfCurrent(kind: ModelKind, logicalName: string, expected:
118
131
 
119
132
  /** Non-throwing lookup of the backend mapped to `logicalName` for `kind`, or `undefined`. Used by the router to assemble + filter candidate lists without exceptions. */
120
133
  export function getBackend(kind: ModelKind, logicalName: string): ModelBackend | undefined {
121
- return (kind === 'embedding' ? embedding : generative).get(logicalName);
134
+ return registries[kind].get(logicalName);
122
135
  }
123
136
 
124
137
  /**
@@ -127,8 +140,7 @@ export function getBackend(kind: ModelKind, logicalName: string): ModelBackend |
127
140
  * caller passes as `opts.model`, not the backend's own `.name`.
128
141
  */
129
142
  export function listBackends(kind: ModelKind): Array<{ logicalName: string; backend: ModelBackend }> {
130
- const map = kind === 'embedding' ? embedding : generative;
131
- return [...map.entries()].map(([logicalName, backend]) => ({ logicalName, backend }));
143
+ return [...registries[kind].entries()].map(([logicalName, backend]) => ({ logicalName, backend }));
132
144
  }
133
145
 
134
146
  /**
@@ -136,7 +148,7 @@ export function listBackends(kind: ModelKind): Array<{ logicalName: string; back
136
148
  * Throws `ModelBackendNotFoundError` if no backend is mapped.
137
149
  */
138
150
  export function resolveEmbedding(logicalName: string = 'default'): ModelBackend {
139
- const backend = embedding.get(logicalName);
151
+ const backend = registries.embedding.get(logicalName);
140
152
  if (!backend) throw new ModelBackendNotFoundError('embedding', logicalName);
141
153
  return backend;
142
154
  }
@@ -146,11 +158,21 @@ export function resolveEmbedding(logicalName: string = 'default'): ModelBackend
146
158
  * Throws `ModelBackendNotFoundError` if no backend is mapped.
147
159
  */
148
160
  export function resolveGenerative(logicalName: string = 'default'): ModelBackend {
149
- const backend = generative.get(logicalName);
161
+ const backend = registries.generative.get(logicalName);
150
162
  if (!backend) throw new ModelBackendNotFoundError('generative', logicalName);
151
163
  return backend;
152
164
  }
153
165
 
166
+ /**
167
+ * Resolve the decision backend mapped to `logicalName` (default: `'default'`).
168
+ * Throws `ModelBackendNotFoundError` if no backend is mapped.
169
+ */
170
+ export function resolveDecision(logicalName: string = 'default'): ModelBackend {
171
+ const backend = registries.decision.get(logicalName);
172
+ if (!backend) throw new ModelBackendNotFoundError('decision', logicalName);
173
+ return backend;
174
+ }
175
+
154
176
  /**
155
177
  * Public registration API (#1325).
156
178
  *
@@ -170,8 +192,10 @@ export function resolveGenerative(logicalName: string = 'default'): ModelBackend
170
192
  * derives them for you, so prefer it.
171
193
  */
172
194
  export function registerBackend(kind: ModelKind, id: string, backend: ModelBackend): void {
173
- if (kind !== 'embedding' && kind !== 'generative')
174
- throw new ModelBackendRegistrationError(`kind must be 'embedding' or 'generative', got '${String(kind)}'`);
195
+ if (kind !== 'embedding' && kind !== 'generative' && kind !== 'decision')
196
+ throw new ModelBackendRegistrationError(
197
+ `kind must be 'embedding', 'generative' or 'decision', got '${String(kind)}'`
198
+ );
175
199
  if (typeof id !== 'string' || id.length === 0)
176
200
  throw new ModelBackendRegistrationError('backend id must be a non-empty string');
177
201
  if (
@@ -185,6 +209,10 @@ export function registerBackend(kind: ModelKind, id: string, backend: ModelBacke
185
209
  if (typeof backend.embed !== 'function')
186
210
  throw new ModelBackendRegistrationError(`embedding backend '${id}' must implement embed()`);
187
211
  setEmbedding(id, backend);
212
+ } else if (kind === 'decision') {
213
+ if (typeof backend.decide !== 'function')
214
+ throw new ModelBackendRegistrationError(`decision backend '${id}' must implement decide()`);
215
+ setDecision(id, backend);
188
216
  } else {
189
217
  if (typeof backend.generate !== 'function' && typeof backend.generateStream !== 'function')
190
218
  throw new ModelBackendRegistrationError(
@@ -196,7 +224,8 @@ export function registerBackend(kind: ModelKind, id: string, backend: ModelBacke
196
224
 
197
225
  /**
198
226
  * Build a `ModelBackend` from just the methods it implements. `capabilities()`
199
- * is derived from which of `embed` / `generate` / `generateStream` are present;
227
+ * is derived from which of `embed` / `generate` / `generateStream` / `decide` /
228
+ * `scoreChoices` are present;
200
229
  * `tools` and `adapters` aren't inferable from method presence, so pass them
201
230
  * explicitly (both default `false`). Lowers the bar from authoring a class to
202
231
  * supplying a function — pair with `registerBackend`. See #1325.
@@ -204,16 +233,34 @@ export function registerBackend(kind: ModelKind, id: string, backend: ModelBacke
204
233
  export function defineBackend(spec: DefineBackendSpec): ModelBackend {
205
234
  if (!spec || typeof spec.name !== 'string' || spec.name.length === 0)
206
235
  throw new ModelBackendRegistrationError('defineBackend requires a non-empty name');
207
- const { name, embed, generate, generateStream, tools = false, adapters = false } = spec;
236
+ const {
237
+ name,
238
+ embed,
239
+ generate,
240
+ generateStream,
241
+ decide,
242
+ scoreChoices,
243
+ tools = false,
244
+ adapters = false,
245
+ calibrated = false,
246
+ structuredOutput = false,
247
+ noMatch = false,
248
+ calibratedNoMatch = false,
249
+ maxScoredChoices,
250
+ } = spec;
208
251
  // Gate on function-ness, not truthiness: a non-function value (`generate: 'oops'`)
209
252
  // must be rejected at definition time, not assigned and crash at call time.
210
253
  const hasEmbed = typeof embed === 'function';
211
254
  const hasGenerate = typeof generate === 'function';
212
255
  const hasStream = typeof generateStream === 'function';
213
- if (!hasEmbed && !hasGenerate && !hasStream)
256
+ const hasDecide = typeof decide === 'function';
257
+ const hasScore = typeof scoreChoices === 'function';
258
+ if (!hasEmbed && !hasGenerate && !hasStream && !hasDecide && !hasScore)
214
259
  throw new ModelBackendRegistrationError(
215
- `backend '${name}' must implement at least one of embed / generate / generateStream (as functions)`
260
+ `backend '${name}' must implement at least one of embed / generate / generateStream / decide / scoreChoices (as functions)`
216
261
  );
262
+ if (maxScoredChoices !== undefined && !(Number.isSafeInteger(maxScoredChoices) && maxScoredChoices > 0))
263
+ throw new ModelBackendRegistrationError(`backend '${name}': maxScoredChoices must be a positive integer`);
217
264
  const capabilities: ModelCapabilities = Object.freeze({
218
265
  embed: hasEmbed,
219
266
  // A stream-only backend gains generate() via the synthesis below.
@@ -221,11 +268,20 @@ export function defineBackend(spec: DefineBackendSpec): ModelBackend {
221
268
  stream: hasStream,
222
269
  tools,
223
270
  adapters,
271
+ decide: hasDecide,
272
+ calibrated: hasDecide && calibrated,
273
+ scoreChoices: hasScore,
274
+ structuredOutput: (hasGenerate || hasStream) && structuredOutput,
275
+ noMatch: hasDecide && noMatch,
276
+ calibratedNoMatch: hasDecide && noMatch && calibratedNoMatch,
277
+ ...(hasScore && maxScoredChoices !== undefined ? { maxScoredChoices } : {}),
224
278
  });
225
279
  const backend: ModelBackend = { name, capabilities: () => capabilities };
226
280
  if (hasEmbed) backend.embed = embed;
227
281
  if (hasGenerate) backend.generate = generate;
228
282
  if (hasStream) backend.generateStream = generateStream;
283
+ if (hasDecide) backend.decide = decide;
284
+ if (hasScore) backend.scoreChoices = scoreChoices;
229
285
  // Stream-only generative backend: synthesize generate() by draining the stream,
230
286
  // so a plain models.generate() works without the backend implementing both.
231
287
  if (hasStream && !hasGenerate) backend.generate = synthesizeGenerateFromStream(generateStream!);
@@ -261,8 +317,7 @@ function synthesizeGenerateFromStream(
261
317
 
262
318
  /** Remove all registrations. Test-only hygiene. */
263
319
  export function clearRegistry(): void {
264
- embedding.clear();
265
- generative.clear();
320
+ for (const map of Object.values(registries)) map.clear();
266
321
  }
267
322
 
268
323
  export class ModelBackendNotFoundError extends ServerError {
@@ -34,6 +34,7 @@ import { registerOllamaBackend, type OllamaBackendConfig } from '../../component
34
34
  import { registerOpenAIBackend, type OpenAIBackendConfig } from '../../components/openai/index.ts';
35
35
  import { registerAnthropicBackend, type AnthropicBackendConfig } from '../../components/anthropic/index.ts';
36
36
  import { registerBedrockBackend, type BedrockBackendConfig } from '../../components/bedrock/index.ts';
37
+ import { setModelsConfigHash } from './decisionStore.ts';
37
38
  import { isAbsolute, resolve as resolvePath } from 'node:path';
38
39
  import { pathToFileURL } from 'node:url';
39
40
  import { createRequire } from 'node:module';
@@ -48,7 +49,8 @@ import {
48
49
  } from './backendRegistry.ts';
49
50
  import { getSharedRootConfigWatcher, RootConfigWatcher } from '../../config/RootConfigWatcher.ts';
50
51
  import { validateModelsBlock } from '../../validation/configValidator.ts';
51
- import type { ModelBackend } from './types.ts';
52
+ import { registerGenerativeDecisionBackend, type GenerativeDecisionConfig } from './generativeDecision.ts';
53
+ import type { ModelBackend, ModelKind } from './types.ts';
52
54
 
53
55
  /**
54
56
  * Field names treated as credentials. When present in config as a literal
@@ -58,8 +60,6 @@ import type { ModelBackend } from './types.ts';
58
60
  */
59
61
  const CREDENTIAL_FIELDS = new Set(['apiKey']);
60
62
 
61
- type ModelKind = 'embedding' | 'generative';
62
-
63
63
  interface ModelEntry {
64
64
  backend?: string;
65
65
  host?: string;
@@ -71,6 +71,13 @@ interface ModelEntry {
71
71
  organization?: string;
72
72
  // bedrock
73
73
  region?: string;
74
+ // generative decision adapter
75
+ generative?: string;
76
+ samples?: number;
77
+ concurrency?: number;
78
+ temperature?: number;
79
+ scoring?: 'auto' | 'vote' | 'score';
80
+ requireStructuredOutput?: boolean;
74
81
  /** Ordered fallback group: other logical names tried, in order, after this one (#1326). */
75
82
  fallback?: string[];
76
83
  }
@@ -78,6 +85,7 @@ interface ModelEntry {
78
85
  interface ModelsConfig {
79
86
  embedding?: Record<string, ModelEntry>;
80
87
  generative?: Record<string, ModelEntry>;
88
+ decision?: Record<string, ModelEntry>;
81
89
  }
82
90
 
83
91
  interface RootConfig {
@@ -85,14 +93,33 @@ interface RootConfig {
85
93
  }
86
94
 
87
95
  type BackendRegisterFn = (args: { logicalName: string; kind: ModelKind; config: object }) => void | Promise<void>;
96
+ type ProviderKind = Exclude<ModelKind, 'decision'>;
88
97
 
98
+ // The provider casts hold because `builtinServesKind` refuses a `decision` entry before any factory runs.
89
99
  const FACTORIES: Record<string, BackendRegisterFn> = {
90
- ollama: (args) => registerOllamaBackend({ ...args, config: args.config as OllamaBackendConfig }),
91
- openai: (args) => registerOpenAIBackend({ ...args, config: args.config as OpenAIBackendConfig }),
92
- anthropic: (args) => registerAnthropicBackend({ ...args, config: args.config as AnthropicBackendConfig }),
93
- bedrock: (args) => registerBedrockBackend({ ...args, config: args.config as BedrockBackendConfig }),
100
+ ollama: (args) =>
101
+ registerOllamaBackend({ ...args, kind: args.kind as ProviderKind, config: args.config as OllamaBackendConfig }),
102
+ openai: (args) =>
103
+ registerOpenAIBackend({ ...args, kind: args.kind as ProviderKind, config: args.config as OpenAIBackendConfig }),
104
+ anthropic: (args) =>
105
+ registerAnthropicBackend({
106
+ ...args,
107
+ kind: args.kind as ProviderKind,
108
+ config: args.config as AnthropicBackendConfig,
109
+ }),
110
+ bedrock: (args) =>
111
+ registerBedrockBackend({ ...args, kind: args.kind as ProviderKind, config: args.config as BedrockBackendConfig }),
112
+ generative: (args) => registerGenerativeDecisionBackend({ ...args, config: args.config as GenerativeDecisionConfig }),
94
113
  };
95
114
 
115
+ // The provider factories treat every non-embedding kind as generative, so a `decision` entry naming
116
+ // one would silently register a backend with no `decide`; the adapter is the only built-in for that kind.
117
+ const DECISION_BUILTINS = new Set(['generative']);
118
+
119
+ function builtinServesKind(backend: string, kind: ModelKind): boolean {
120
+ return (kind === 'decision') === DECISION_BUILTINS.has(backend);
121
+ }
122
+
96
123
  // What the config projection currently has installed, per kind+logicalName slot. `backend` is the
97
124
  // exact instance this module put in the registry: reload swaps compare against it, so a
98
125
  // programmatic `registerBackend` that overrode a config entry keeps its documented precedence, and
@@ -106,6 +133,7 @@ interface InstalledSlot {
106
133
  entryJson: string;
107
134
  /** The entry's fallback group as applied, so a retained backend keeps its routing. */
108
135
  fallback?: string[];
136
+ configJson?: string;
109
137
  /** Whether the entry's backend is a built-in. Module-backed entries are restart-managed: reload
110
138
  * refuses to add, change, OR remove them, so a rename cannot half-apply as a bare removal. */
111
139
  builtin?: boolean;
@@ -223,6 +251,7 @@ interface DesiredEntry {
223
251
  logicalName: string;
224
252
  entry: ModelEntry;
225
253
  entryJson: string;
254
+ configJson?: string;
226
255
  }
227
256
 
228
257
  function collectKind(
@@ -291,6 +320,7 @@ function publishEntry(
291
320
  const builtin = Boolean(FACTORIES[entry.backend as string]);
292
321
  if (replaceIfCurrent(kind, logicalName, expected, backend)) {
293
322
  installedSlots.set(key, {
323
+ configJson: desiredEntry.configJson,
294
324
  kind,
295
325
  logicalName,
296
326
  backend,
@@ -303,6 +333,7 @@ function publishEntry(
303
333
  // Record the ask with no installed instance, so unchanged reloads skip instead of
304
334
  // re-losing this swap every apply; installed helpers stay recorded and removable.
305
335
  installedSlots.set(key, {
336
+ configJson: desiredEntry.configJson,
306
337
  kind,
307
338
  logicalName,
308
339
  entryJson,
@@ -339,6 +370,7 @@ async function applyModels(block: ModelsConfig | null | undefined, isBoot: boole
339
370
  const presentKeys = new Set<string>();
340
371
  collectKind('embedding', block?.embedding, desired, presentKeys);
341
372
  collectKind('generative', block?.generative, desired, presentKeys);
373
+ collectKind('decision', block?.decision, desired, presentKeys);
342
374
 
343
375
  const staged = new Map<string, { desiredEntry: DesiredEntry; backend: ModelBackend; extras: CapturedInstall[] }>();
344
376
  const failedKeys = new Set<string>();
@@ -358,6 +390,13 @@ async function applyModels(block: ModelsConfig | null | undefined, isBoot: boole
358
390
  // the incoming backend AND a currently-installed module-backed slot: a module→built-in rewrite
359
391
  // is still a change of a restart-managed entry, so it must not live-replace the module (which
360
392
  // would drop its helpers with none of the disposal a restart performs).
393
+ if (FACTORIES[entry.backend as string] && !builtinServesKind(entry.backend as string, kind)) {
394
+ harperLogger.error(
395
+ `models.${kind}.${logicalName}: backend '${entry.backend}' cannot serve ${kind} entries; skipping`
396
+ );
397
+ failedKeys.add(key);
398
+ continue;
399
+ }
361
400
  if (!isBoot && (!FACTORIES[entry.backend as string] || slot?.builtin === false)) {
362
401
  harperLogger.warn(
363
402
  `models.${kind}.${logicalName}: module-backed entries require a restart to add or change; ` +
@@ -378,6 +417,7 @@ async function applyModels(block: ModelsConfig | null | undefined, isBoot: boole
378
417
  // (env var unset) pass through unchanged — backend's required-field
379
418
  // validation catches them with a meaningful error.
380
419
  const config = expandEnvVarsDeep(entry);
420
+ desiredEntry.configJson = JSON.stringify(config);
381
421
  const { backend, extras } = await constructBackend(kind, logicalName, async () => {
382
422
  const builtin = FACTORIES[entry.backend as string];
383
423
  if (builtin) {
@@ -462,6 +502,16 @@ async function applyModels(block: ModelsConfig | null | undefined, isBoot: boole
462
502
  setFallbackGroup(slot.kind, slot.logicalName, slot.fallback);
463
503
  }
464
504
  }
505
+ setModelsConfigHash(installedConfiguration());
506
+ }
507
+
508
+ function installedConfiguration(): Record<string, unknown> | undefined {
509
+ const configuration: Record<string, unknown> = {};
510
+ for (const [key, slot] of installedSlots) {
511
+ if (slot.backend)
512
+ configuration[key] = JSON.parse(slot.configJson ?? JSON.stringify(expandEnvVarsDeep(JSON.parse(slot.entryJson))));
513
+ }
514
+ return Object.keys(configuration).length > 0 ? configuration : undefined;
465
515
  }
466
516
 
467
517
  // ── Hot reload wiring ─────────────────────────────────────────────────────────
@@ -0,0 +1,135 @@
1
+ import { ClientError } from '../../utility/errors/hdbError.ts';
2
+ import { isAllowedValue } from './decision.ts';
3
+ import {
4
+ runWriteJobs,
5
+ sanitizedHookError,
6
+ sourceState,
7
+ writeHookApplies,
8
+ type WriteHook,
9
+ type WriteHookContext,
10
+ } from './embedHook.ts';
11
+ import type { DecideInput, DecideOpts, Decision, DecisionLeaf } from './types.ts';
12
+
13
+ export type DecideConfig = {
14
+ source: string;
15
+ model: string;
16
+ confidence?: string;
17
+ /** A nullable `String` attribute that receives the durable `Decision.id`; the decision is recorded only when this is named (#2852). */
18
+ decision?: string;
19
+ instructions?: string;
20
+ /** Resolved from the attribute type and the directive arguments, and validated, when the schema loads. */
21
+ schema: DecisionLeaf;
22
+ };
23
+
24
+ export type DecideAttribute = {
25
+ name: string;
26
+ decide: DecideConfig;
27
+ };
28
+
29
+ /**
30
+ * `null` clears the attribute, its confidence and its decision id; `probability` is required when the
31
+ * directive names a confidence attribute. `id` must come from a `models.decide` call; an override for a
32
+ * directive with a `decision` attribute gets it from a `models.decide` call made with `persist: true`.
33
+ */
34
+ export type DeciderResult = { value: unknown; probability?: number; id?: string };
35
+ export type Decider = (record: any, hook?: WriteHookContext) => Promise<DeciderResult | null | undefined>;
36
+
37
+ type DecideFn = (state: DecideInput, schema: DecisionLeaf, opts: DecideOpts) => Promise<Decision>;
38
+
39
+ // Lazy-imported so this module can be unit-tested without loading the transaction
40
+ // stack `Models.ts` pulls in. Overridable via `__setDecideFnForTest`.
41
+ let _decideFn: DecideFn | undefined;
42
+ function resolveDecideFn(): DecideFn {
43
+ if (_decideFn) return _decideFn;
44
+ const { Models } = require('#src/resources/models/Models'); // eslint-disable-line @typescript-eslint/no-var-requires
45
+ const models = new Models();
46
+ _decideFn = (state, schema, opts) => models.decide(state, schema, opts);
47
+ return _decideFn;
48
+ }
49
+
50
+ /** Test seam: override the decide function. Pass `undefined` to reset to `Models.decide`. */
51
+ export function __setDecideFnForTest(fn: DecideFn | undefined): void {
52
+ _decideFn = fn;
53
+ }
54
+
55
+ export function createDefaultDecider(config: DecideConfig): Decider {
56
+ const { source, model, instructions, schema } = config;
57
+ const persist = Boolean(config.decision);
58
+ return async (record: any, hook?: WriteHookContext): Promise<DeciderResult | null> => {
59
+ const sourceValue = record?.[source];
60
+ if (sourceValue == null) return null;
61
+ // An object source is program state the primitive serializes itself; anything else is text.
62
+ const state: DecideInput = typeof sourceValue === 'object' ? sourceValue : String(sourceValue);
63
+ const decision = await resolveDecideFn()(state, schema, { model, instructions, signal: hook?.signal, persist });
64
+ return { value: decision.value, probability: decision.probability, id: decision.id };
65
+ };
66
+ }
67
+
68
+ export function buildDecideBefore(
69
+ record: any,
70
+ context: any,
71
+ options: any,
72
+ decideAttributes: DecideAttribute[] | undefined,
73
+ deciders: Record<string, Decider>
74
+ ): WriteHook | undefined {
75
+ if (!decideAttributes || decideAttributes.length === 0) return undefined;
76
+ const applies = writeHookApplies(record, context, options);
77
+ if (record && typeof record === 'object' && options?.isNotification !== true && context?.alreadyLogged !== true)
78
+ for (const attr of decideAttributes) {
79
+ const { decision, source } = attr.decide;
80
+ if (!decision || !(decision in record)) continue;
81
+ const state = applies ? sourceState(record, source) : 'absent';
82
+ // An outcome report trusts this id, so only a write that derives or clears it may carry it.
83
+ if (state !== 'value' && state !== 'null')
84
+ throw new ClientError(
85
+ `"${decision}" is written by @decide on "${attr.name}"; a write may carry it only with a value for "${source}"`,
86
+ 400
87
+ );
88
+ }
89
+ if (!applies) return undefined;
90
+ let present = false;
91
+ for (const attr of decideAttributes) if (sourceState(record, attr.decide?.source) !== 'absent') present = true;
92
+ if (!present) return undefined;
93
+ return (signal) =>
94
+ runWriteJobs(
95
+ decideAttributes.map((attr) => async (jobSignal) => {
96
+ const { source, confidence, decision, schema } = attr.decide;
97
+ const state = sourceState(record, source);
98
+ if (state === 'absent' || state === 'op') return;
99
+ const clear = () => {
100
+ record[attr.name] = null;
101
+ if (confidence) record[confidence] = null;
102
+ if (decision) record[decision] = null;
103
+ };
104
+ if (state === 'null') return clear();
105
+ const decider = deciders[attr.name];
106
+ // Committing the source without its pair would be indistinguishable from a decision later.
107
+ if (!decider) throw new Error(`No decider is registered for the @decide attribute "${attr.name}"`);
108
+ let result: DeciderResult | null | undefined;
109
+ try {
110
+ result = await decider(record, { signal: jobSignal });
111
+ } catch (err) {
112
+ // An aborted job failed because a sibling did; only that failure is worth a log line.
113
+ throw sanitizedHookError('Decider', 'decision', attr.name, err, !jobSignal.aborted);
114
+ }
115
+ if (result == null) return clear();
116
+ // The facade validates `models.decide` output; an author override is checked here so the
117
+ // stored pair keeps the closed-set guarantee a query relies on.
118
+ if (!isAllowedValue(schema, result.value))
119
+ throw new Error(`Decider for attribute "${attr.name}" returned a value outside its @decide set`);
120
+ if (confidence) {
121
+ const probability = result.probability;
122
+ if (!(typeof probability === 'number' && probability >= 0 && probability <= 1))
123
+ throw new Error(
124
+ `Decider for attribute "${attr.name}" must return a probability in [0, 1] for its confidence attribute "${confidence}"`
125
+ );
126
+ record[attr.name] = result.value;
127
+ record[confidence] = probability;
128
+ } else {
129
+ record[attr.name] = result.value;
130
+ }
131
+ if (decision) record[decision] = typeof result.id === 'string' && result.id !== '' ? result.id : null;
132
+ }),
133
+ signal
134
+ );
135
+ }