@apifuse/provider-sdk 2.2.0-beta.3 → 2.2.0-beta.31

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 (342) hide show
  1. package/AUTHORING.md +493 -5
  2. package/CHANGELOG.md +131 -1
  3. package/README.md +52 -6
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +106 -62
  6. package/bin/apifuse-create.ts +1 -1
  7. package/bin/apifuse-dev.ts +63 -55
  8. package/bin/apifuse-pack-check.ts +22 -2
  9. package/bin/apifuse-pack-smoke.ts +78 -82
  10. package/bin/apifuse-pack-types.ts +583 -0
  11. package/bin/apifuse-perf.ts +59 -140
  12. package/bin/apifuse-record.ts +698 -113
  13. package/bin/apifuse-submit-check.ts +517 -44
  14. package/bin/apifuse-sync-assets.ts +117 -0
  15. package/bin/apifuse.ts +1 -1
  16. package/bin/submit-check-delimited-text.ts +50 -0
  17. package/bin/submit-check-xml.ts +1 -1
  18. package/dist/auth-turn/index.d.ts +4 -4
  19. package/dist/auth-turn/index.js +1 -1
  20. package/dist/auth.d.ts +16 -2
  21. package/dist/auth.js +76 -18
  22. package/dist/ceremonies/index.d.ts +9 -1
  23. package/dist/ceremonies/index.js +117 -29
  24. package/dist/cli/commands.d.ts +1 -1
  25. package/dist/cli/commands.js +8 -0
  26. package/dist/cli/create.d.ts +3 -0
  27. package/dist/cli/create.js +34 -35
  28. package/dist/cli/prompt-assets.d.ts +80 -0
  29. package/dist/cli/prompt-assets.js +743 -0
  30. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  31. package/dist/cli/templates/provider/README.md.tpl +4 -4
  32. package/dist/config/loader.d.ts +176 -17
  33. package/dist/config/loader.js +434 -161
  34. package/dist/contract-serialization.d.ts +2 -2
  35. package/dist/contract-serialization.js +7 -14
  36. package/dist/contract-types.d.ts +3 -2
  37. package/dist/contract.d.ts +3 -3
  38. package/dist/contract.js +6 -6
  39. package/dist/declaration-validation.d.ts +23 -0
  40. package/dist/declaration-validation.js +159 -0
  41. package/dist/define.d.ts +13 -1
  42. package/dist/define.js +391 -122
  43. package/dist/dev.d.ts +1 -1
  44. package/dist/dev.js +1 -1
  45. package/dist/error-resolution.d.ts +4 -0
  46. package/dist/error-resolution.js +123 -0
  47. package/dist/errors.d.ts +19 -1
  48. package/dist/errors.js +41 -3
  49. package/dist/fixture-sanitization.d.ts +26 -0
  50. package/dist/fixture-sanitization.js +216 -0
  51. package/dist/i18n/catalog.d.ts +2 -2
  52. package/dist/i18n/catalog.js +4 -10
  53. package/dist/i18n/index.d.ts +2 -2
  54. package/dist/i18n/index.js +2 -2
  55. package/dist/i18n/keys.d.ts +2 -2
  56. package/dist/index.d.ts +50 -42
  57. package/dist/index.js +41 -37
  58. package/dist/lint.d.ts +6 -1
  59. package/dist/lint.js +370 -18
  60. package/dist/native-address.d.ts +43 -0
  61. package/dist/native-address.js +281 -0
  62. package/dist/native-egress-policy.d.ts +31 -0
  63. package/dist/native-egress-policy.js +288 -0
  64. package/dist/observability.d.ts +5 -2
  65. package/dist/observability.js +48 -1
  66. package/dist/provider.d.ts +13 -11
  67. package/dist/provider.js +10 -9
  68. package/dist/public-schema-field-lint.d.ts +1 -1
  69. package/dist/recipes/gov-api.js +1 -1
  70. package/dist/runtime/auth-flow.d.ts +3 -1
  71. package/dist/runtime/auth-flow.js +8 -3
  72. package/dist/runtime/browser.d.ts +1 -1
  73. package/dist/runtime/browser.js +138 -40
  74. package/dist/runtime/cache.d.ts +2 -1
  75. package/dist/runtime/cache.js +173 -23
  76. package/dist/runtime/choice-wordlist.d.ts +9 -0
  77. package/dist/runtime/choice-wordlist.js +138 -0
  78. package/dist/runtime/choice.d.ts +14 -1
  79. package/dist/runtime/choice.js +566 -101
  80. package/dist/runtime/credential.d.ts +1 -1
  81. package/dist/runtime/credential.js +1 -1
  82. package/dist/runtime/env.d.ts +1 -1
  83. package/dist/runtime/executor.d.ts +1 -1
  84. package/dist/runtime/executor.js +25 -3
  85. package/dist/runtime/http.d.ts +3 -2
  86. package/dist/runtime/http.js +517 -55
  87. package/dist/runtime/insights.d.ts +1 -1
  88. package/dist/runtime/insights.js +6 -13
  89. package/dist/runtime/instrumentation.d.ts +2 -2
  90. package/dist/runtime/instrumentation.js +371 -23
  91. package/dist/runtime/keyring.js +1 -1
  92. package/dist/runtime/namespace.js +1 -1
  93. package/dist/runtime/native-network-errors.d.ts +33 -0
  94. package/dist/runtime/native-network-errors.js +69 -0
  95. package/dist/runtime/native-network.d.ts +96 -0
  96. package/dist/runtime/native-network.js +1232 -0
  97. package/dist/runtime/ocr.d.ts +29 -0
  98. package/dist/runtime/ocr.js +440 -0
  99. package/dist/runtime/otlp.d.ts +1 -1
  100. package/dist/runtime/perf.d.ts +1 -1
  101. package/dist/runtime/provider.d.ts +1 -1
  102. package/dist/runtime/provider.js +1 -2
  103. package/dist/runtime/proxy-errors.d.ts +1 -1
  104. package/dist/runtime/proxy-errors.js +9 -7
  105. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  106. package/dist/runtime/proxy-nodemaven.js +146 -0
  107. package/dist/runtime/proxy-retry-policy.d.ts +2 -2
  108. package/dist/runtime/proxy-retry-policy.js +2 -2
  109. package/dist/runtime/proxy-telemetry.d.ts +2 -1
  110. package/dist/runtime/proxy-telemetry.js +58 -52
  111. package/dist/runtime/redirects.d.ts +29 -0
  112. package/dist/runtime/redirects.js +36 -0
  113. package/dist/runtime/redis.d.ts +1 -1
  114. package/dist/runtime/redis.js +5 -5
  115. package/dist/runtime/request-options.d.ts +68 -1
  116. package/dist/runtime/request-options.js +548 -0
  117. package/dist/runtime/resolver-config.d.ts +6 -0
  118. package/dist/runtime/resolver-config.js +6 -0
  119. package/dist/runtime/resolver-public.d.ts +1 -0
  120. package/dist/runtime/resolver-public.js +1 -0
  121. package/dist/runtime/resolver-shared.d.ts +3 -0
  122. package/dist/runtime/resolver-shared.js +12 -0
  123. package/dist/runtime/resolver-vendors/bindings.d.ts +48 -0
  124. package/dist/runtime/resolver-vendors/bindings.js +40 -0
  125. package/dist/runtime/resolver-vendors/browser.d.ts +20 -0
  126. package/dist/runtime/resolver-vendors/browser.js +282 -0
  127. package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
  128. package/dist/runtime/resolver-vendors/hosts.js +33 -0
  129. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
  130. package/dist/runtime/resolver-vendors/twocaptcha.js +368 -0
  131. package/dist/runtime/resolver-vendors/types.d.ts +83 -0
  132. package/dist/runtime/resolver-vendors/types.js +69 -0
  133. package/dist/runtime/resolver.d.ts +59 -0
  134. package/dist/runtime/resolver.js +705 -0
  135. package/dist/runtime/secrets.d.ts +27 -0
  136. package/dist/runtime/secrets.js +51 -0
  137. package/dist/runtime/state.d.ts +5 -2
  138. package/dist/runtime/state.js +280 -74
  139. package/dist/runtime/stealth-cookies.d.ts +20 -0
  140. package/dist/runtime/stealth-cookies.js +111 -0
  141. package/dist/runtime/stealth.d.ts +30 -5
  142. package/dist/runtime/stealth.js +523 -259
  143. package/dist/runtime/stt.d.ts +1 -1
  144. package/dist/runtime/stt.js +12 -27
  145. package/dist/runtime/timeout.d.ts +5 -0
  146. package/dist/runtime/timeout.js +12 -0
  147. package/dist/runtime/trace.d.ts +2 -2
  148. package/dist/runtime/trace.js +2 -4
  149. package/dist/runtime/waterfall.d.ts +1 -1
  150. package/dist/schema.d.ts +1 -1
  151. package/dist/schema.js +7 -15
  152. package/dist/serve.d.ts +1 -1
  153. package/dist/serve.js +1 -1
  154. package/dist/server/index.d.ts +7 -7
  155. package/dist/server/index.js +6 -6
  156. package/dist/server/self-test-input-tokens.d.ts +2 -1
  157. package/dist/server/self-test-input-tokens.js +18 -14
  158. package/dist/server/self-test-redaction.d.ts +1 -1
  159. package/dist/server/self-test-redaction.js +1 -1
  160. package/dist/server/self-test.d.ts +117 -3
  161. package/dist/server/self-test.js +787 -151
  162. package/dist/server/serve-implementation.d.ts +210 -0
  163. package/dist/server/serve-implementation.js +2078 -0
  164. package/dist/server/serve.d.ts +1 -70
  165. package/dist/server/serve.js +1 -1143
  166. package/dist/server/types.d.ts +34 -9
  167. package/dist/server/types.js +8 -1
  168. package/dist/stateful/errors.d.ts +19 -0
  169. package/dist/stateful/errors.js +24 -0
  170. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  171. package/dist/stateful/http-provider-event-emitter.js +237 -0
  172. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  173. package/dist/stateful/http-session-owner-registry.js +210 -0
  174. package/dist/stateful/index.d.ts +18 -0
  175. package/dist/stateful/index.js +18 -0
  176. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  177. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  178. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  179. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  180. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  181. package/dist/stateful/provider-event-pipeline.js +1 -0
  182. package/dist/stateful/provider-events.d.ts +101 -0
  183. package/dist/stateful/provider-events.js +289 -0
  184. package/dist/stateful/session-key.d.ts +15 -0
  185. package/dist/stateful/session-key.js +86 -0
  186. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  187. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  188. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  189. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  190. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  191. package/dist/stateful/stateful-provider-adapter.js +287 -0
  192. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  193. package/dist/stateful/stateful-provider-observability.js +161 -0
  194. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  195. package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
  196. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  197. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  198. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  199. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  200. package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
  201. package/dist/stateful/stateful-provider-session-routing.js +345 -0
  202. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  203. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  204. package/dist/stateful-signing.d.ts +18 -0
  205. package/dist/stateful-signing.js +27 -0
  206. package/dist/stealth/profiles.d.ts +1 -1
  207. package/dist/stealth/profiles.js +21 -21
  208. package/dist/stream-evidence.d.ts +74 -0
  209. package/dist/stream-evidence.js +785 -0
  210. package/dist/stream.d.ts +1 -1
  211. package/dist/stream.js +7 -1
  212. package/dist/testing/index.d.ts +3 -2
  213. package/dist/testing/index.js +3 -2
  214. package/dist/testing/run.d.ts +32 -2
  215. package/dist/testing/run.js +488 -28
  216. package/dist/types.d.ts +566 -19
  217. package/dist/types.js +1 -0
  218. package/dist/user-input.d.ts +30 -0
  219. package/dist/user-input.js +66 -0
  220. package/package.json +42 -7
  221. package/src/auth-turn/index.ts +2 -2
  222. package/src/auth.ts +146 -86
  223. package/src/ceremonies/index.ts +167 -92
  224. package/src/cli/commands.ts +10 -0
  225. package/src/cli/create.ts +42 -35
  226. package/src/cli/prompt-assets.ts +865 -0
  227. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  228. package/src/cli/templates/provider/README.md.tpl +4 -4
  229. package/src/config/loader.ts +667 -289
  230. package/src/contract-serialization.ts +10 -18
  231. package/src/contract-types.ts +3 -2
  232. package/src/contract.ts +14 -28
  233. package/src/declaration-validation.ts +202 -0
  234. package/src/define.ts +631 -495
  235. package/src/dev.ts +4 -9
  236. package/src/error-resolution.ts +128 -0
  237. package/src/errors.ts +56 -11
  238. package/src/fixture-sanitization.ts +247 -0
  239. package/src/i18n/catalog.ts +10 -32
  240. package/src/i18n/index.ts +2 -2
  241. package/src/i18n/keys.ts +5 -11
  242. package/src/index.ts +158 -44
  243. package/src/lint.ts +488 -154
  244. package/src/native-address.ts +340 -0
  245. package/src/native-egress-policy.ts +358 -0
  246. package/src/observability.ts +51 -1
  247. package/src/provider.ts +66 -11
  248. package/src/public-schema-field-lint.ts +7 -33
  249. package/src/recipes/gov-api.ts +2 -5
  250. package/src/runtime/auth-flow.ts +13 -7
  251. package/src/runtime/browser.ts +252 -207
  252. package/src/runtime/cache.ts +209 -81
  253. package/src/runtime/choice-wordlist.ts +145 -0
  254. package/src/runtime/choice.ts +758 -197
  255. package/src/runtime/credential.ts +2 -2
  256. package/src/runtime/env.ts +1 -1
  257. package/src/runtime/executor.ts +37 -19
  258. package/src/runtime/http.ts +645 -65
  259. package/src/runtime/insights.ts +15 -53
  260. package/src/runtime/instrumentation.ts +530 -67
  261. package/src/runtime/keyring.ts +7 -19
  262. package/src/runtime/namespace.ts +2 -7
  263. package/src/runtime/native-network-errors.ts +99 -0
  264. package/src/runtime/native-network.ts +1605 -0
  265. package/src/runtime/ocr.ts +523 -0
  266. package/src/runtime/otlp.ts +12 -23
  267. package/src/runtime/perf.ts +1 -1
  268. package/src/runtime/provider.ts +4 -9
  269. package/src/runtime/proxy-errors.ts +29 -42
  270. package/src/runtime/proxy-nodemaven.ts +221 -0
  271. package/src/runtime/proxy-retry-policy.ts +3 -3
  272. package/src/runtime/proxy-telemetry.ts +84 -77
  273. package/src/runtime/redirects.ts +66 -0
  274. package/src/runtime/redis.ts +10 -13
  275. package/src/runtime/request-options.ts +679 -9
  276. package/src/runtime/resolver-config.ts +6 -0
  277. package/src/runtime/resolver-public.ts +18 -0
  278. package/src/runtime/resolver-shared.ts +17 -0
  279. package/src/runtime/resolver-vendors/bindings.ts +56 -0
  280. package/src/runtime/resolver-vendors/browser.ts +408 -0
  281. package/src/runtime/resolver-vendors/hosts.ts +38 -0
  282. package/src/runtime/resolver-vendors/twocaptcha.ts +500 -0
  283. package/src/runtime/resolver-vendors/types.ts +173 -0
  284. package/src/runtime/resolver.ts +1060 -0
  285. package/src/runtime/secrets.ts +64 -0
  286. package/src/runtime/state.ts +399 -161
  287. package/src/runtime/stealth-cookies.ts +132 -0
  288. package/src/runtime/stealth.ts +681 -295
  289. package/src/runtime/stt.ts +39 -113
  290. package/src/runtime/timeout.ts +18 -0
  291. package/src/runtime/trace.ts +14 -44
  292. package/src/runtime/waterfall.ts +5 -18
  293. package/src/schema.ts +23 -84
  294. package/src/serve.ts +6 -1
  295. package/src/server/index.ts +30 -7
  296. package/src/server/self-test-input-tokens.ts +29 -14
  297. package/src/server/self-test-redaction.ts +2 -2
  298. package/src/server/self-test.ts +1030 -180
  299. package/src/server/serve-implementation.ts +3062 -0
  300. package/src/server/serve.ts +1 -1781
  301. package/src/server/types.ts +12 -13
  302. package/src/stateful/README.md +146 -0
  303. package/src/stateful/errors.ts +35 -0
  304. package/src/stateful/http-provider-event-emitter.ts +314 -0
  305. package/src/stateful/http-session-owner-registry.ts +306 -0
  306. package/src/stateful/index.ts +18 -0
  307. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  308. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  309. package/src/stateful/provider-event-pipeline.ts +61 -0
  310. package/src/stateful/provider-events.ts +462 -0
  311. package/src/stateful/session-key.ts +111 -0
  312. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  313. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  314. package/src/stateful/stateful-provider-adapter.ts +562 -0
  315. package/src/stateful/stateful-provider-observability.ts +261 -0
  316. package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
  317. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  318. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  319. package/src/stateful/stateful-provider-session-routing.ts +546 -0
  320. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  321. package/src/stateful-signing.ts +46 -0
  322. package/src/stealth/profiles.ts +27 -33
  323. package/src/stream-evidence.ts +988 -0
  324. package/src/stream.ts +16 -20
  325. package/src/testing/index.ts +11 -2
  326. package/src/testing/run.ts +668 -74
  327. package/src/types.ts +665 -35
  328. package/src/user-input.ts +118 -0
  329. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  330. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  331. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  332. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  333. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  334. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  335. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  336. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  337. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  338. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  339. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  340. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  341. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  342. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -4,32 +4,49 @@ import {
4
4
  createHash,
5
5
  createHmac,
6
6
  randomBytes,
7
+ randomInt,
7
8
  timingSafeEqual,
8
9
  } from "node:crypto";
9
10
  import {
10
11
  assertFreshProviderChoiceIssuedAt,
11
12
  ProviderChoiceTokenError,
12
13
  type ProviderChoiceTokenPayload,
13
- } from "../choice-token";
14
- import { ProviderError } from "../errors";
14
+ } from "../choice-token.js";
15
+ import { isProviderError, ProviderError } from "../errors.js";
16
+ import {
17
+ CHOICE_WORDLIST_SIZE,
18
+ choiceWordAt,
19
+ HIGH_CHOICE_WORD_COUNT,
20
+ isChoiceWord,
21
+ STANDARD_CHOICE_WORD_COUNT,
22
+ } from "./choice-wordlist.js";
15
23
  import type {
16
24
  CredentialContext,
17
25
  EnvContext,
18
26
  ProviderChoiceBindingOptions,
27
+ ProviderChoiceConsumeMode,
28
+ ProviderChoiceConsumeResult,
19
29
  ProviderChoiceContext,
30
+ ProviderChoiceExplicitParseResult,
20
31
  ProviderChoiceIssueOptions,
21
32
  ProviderChoiceParseOptions,
22
33
  ProviderChoiceStorageOptions,
23
34
  ProviderRequestContext,
24
35
  ProviderRuntimeState,
25
36
  ProviderStateDurationString,
26
- } from "../types";
37
+ StateValue,
38
+ } from "../types.js";
27
39
 
28
40
  export const PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV =
29
41
  "APIFUSE__PROVIDER_RUNTIME__CHOICE_TOKEN_MASTER_SECRET";
42
+ export const PROVIDER_RUNTIME_CHOICE_WORD_ISSUANCE_ENV =
43
+ "APIFUSE__PROVIDER_RUNTIME__CHOICE_WORD_ISSUANCE";
30
44
 
31
45
  const PRIMARY_CHOICE_TOKEN_KID = "v1";
32
46
  const MANAGED_CHOICE_TOKEN_VERSION = 1;
47
+ const SERVER_STORED_CHOICE_RECORD_VERSION = 1;
48
+ const SERVER_STORED_CHOICE_ISSUE_ATTEMPTS = 5;
49
+ const WORD_CHOICE_NOT_FOUND_MESSAGE = "Provider choice token was not found.";
33
50
 
34
51
  type ManagedChoiceEnvelope = {
35
52
  readonly v: typeof MANAGED_CHOICE_TOKEN_VERSION;
@@ -51,6 +68,32 @@ type ServerChoiceHandlePayload = {
51
68
  readonly created_at_ms: number;
52
69
  };
53
70
 
71
+ type ServerStoredChoiceRecord = {
72
+ readonly v: typeof SERVER_STORED_CHOICE_RECORD_VERSION;
73
+ readonly storage: "server";
74
+ readonly status: "active" | "consumed";
75
+ readonly provider_id: string;
76
+ readonly purpose: string;
77
+ readonly issued_at_ms: number;
78
+ readonly ttl_ms: number;
79
+ readonly binding?: ManagedChoiceEnvelope["binding"];
80
+ readonly prefix: string;
81
+ readonly payload: ProviderChoiceTokenPayload;
82
+ readonly payload_digest: string;
83
+ readonly replay_key: string;
84
+ };
85
+
86
+ export type ProviderChoiceTelemetryEvent = {
87
+ readonly providerId: string;
88
+ readonly purpose: string;
89
+ readonly operation: "parse" | "consume";
90
+ readonly format: "word" | "legacy";
91
+ readonly outcome: "success" | "not-found" | "invalid" | "unsupported" | "error";
92
+ readonly consumeMode: ProviderChoiceConsumeMode;
93
+ readonly consumed: boolean;
94
+ readonly replay: boolean;
95
+ };
96
+
54
97
  export type CreateProviderChoiceContextOptions = {
55
98
  readonly providerId: string;
56
99
  readonly env?: EnvContext;
@@ -59,6 +102,8 @@ export type CreateProviderChoiceContextOptions = {
59
102
  readonly state?: ProviderRuntimeState;
60
103
  readonly masterSecret?: string;
61
104
  readonly kid?: string;
105
+ /** Receives allowlisted metadata only; token and payload values are never included. */
106
+ readonly onTelemetry?: (event: ProviderChoiceTelemetryEvent) => void;
62
107
  };
63
108
 
64
109
  export function createProviderChoiceContext(
@@ -74,24 +119,74 @@ export function createProviderChoiceContext(
74
119
  ): string;
75
120
  function issue<TPayload extends ProviderChoiceTokenPayload>(
76
121
  issueOptions: ProviderChoiceIssueOptions<TPayload> & {
77
- readonly storage: Extract<
78
- ProviderChoiceStorageOptions,
79
- { readonly mode: "server" }
80
- >;
122
+ readonly storage: Extract<ProviderChoiceStorageOptions, { readonly mode: "server" }>;
81
123
  },
82
124
  ): Promise<string>;
83
125
  function issue<TPayload extends ProviderChoiceTokenPayload>(
84
126
  issueOptions: ProviderChoiceIssueOptions<TPayload> & {
85
- readonly storage: Extract<
86
- ProviderChoiceStorageOptions,
87
- { readonly mode: "auto" }
88
- >;
127
+ readonly storage: Extract<ProviderChoiceStorageOptions, { readonly mode: "auto" }>;
89
128
  },
90
129
  ): string | Promise<string>;
91
130
  function issue<TPayload extends ProviderChoiceTokenPayload>(
92
131
  issueOptions: ProviderChoiceIssueOptions<TPayload>,
93
132
  ): string | Promise<string> {
94
133
  const issuedAtMs = issueOptions.nowMs ?? Date.now();
134
+ const resolvedStorage = resolveIssueStorage(issueOptions.storage, issueOptions.payload);
135
+ if (resolvedStorage.mode === "server") {
136
+ const issuance = resolveChoiceWordIssuance(options.env);
137
+ const keys = hasRequestedChoiceBinding(issueOptions.bind)
138
+ ? deriveManagedChoiceKeys({
139
+ masterSecret: resolveMasterSecret(),
140
+ providerId: options.providerId,
141
+ purpose: issueOptions.purpose,
142
+ kid,
143
+ })
144
+ : undefined;
145
+ const binding = hasRequestedChoiceBinding(issueOptions.bind)
146
+ ? createChoiceBinding({
147
+ keys: keys!,
148
+ options: issueOptions.bind,
149
+ request: options.request,
150
+ credential: options.credential,
151
+ required: true,
152
+ })
153
+ : undefined;
154
+ const baseEnvelope = {
155
+ v: MANAGED_CHOICE_TOKEN_VERSION,
156
+ provider_id: options.providerId,
157
+ purpose: issueOptions.purpose,
158
+ issued_at_ms: issuedAtMs,
159
+ ttl_ms: issueOptions.ttlMs,
160
+ binding,
161
+ } satisfies Omit<ManagedChoiceEnvelope, "payload">;
162
+ if (issuance === "legacy") {
163
+ const legacyKeys =
164
+ keys ??
165
+ deriveManagedChoiceKeys({
166
+ masterSecret: resolveMasterSecret(),
167
+ providerId: options.providerId,
168
+ purpose: issueOptions.purpose,
169
+ kid,
170
+ });
171
+ return issueLegacyServerStoredChoice({
172
+ baseEnvelope,
173
+ issueOptions,
174
+ storage: resolvedStorage.storage,
175
+ contextState: options.state,
176
+ kid,
177
+ keys: legacyKeys,
178
+ issuedAtMs,
179
+ });
180
+ }
181
+ return issueWordServerStoredChoice({
182
+ baseEnvelope: {
183
+ ...baseEnvelope,
184
+ },
185
+ issueOptions,
186
+ storage: resolvedStorage.storage,
187
+ contextState: options.state,
188
+ });
189
+ }
95
190
  const keys = deriveManagedChoiceKeys({
96
191
  masterSecret: resolveMasterSecret(),
97
192
  providerId: options.providerId,
@@ -112,21 +207,6 @@ export function createProviderChoiceContext(
112
207
  required: true,
113
208
  }),
114
209
  };
115
- const resolvedStorage = resolveIssueStorage(
116
- issueOptions.storage,
117
- issueOptions.payload,
118
- );
119
- if (resolvedStorage.mode === "server") {
120
- return issueServerStoredChoice({
121
- baseEnvelope,
122
- issueOptions,
123
- storage: resolvedStorage.storage,
124
- contextState: options.state,
125
- kid,
126
- keys,
127
- issuedAtMs,
128
- });
129
- }
130
210
  const envelope: ManagedChoiceEnvelope = {
131
211
  ...baseEnvelope,
132
212
  payload: issueOptions.payload,
@@ -139,6 +219,9 @@ export function createProviderChoiceContext(
139
219
  });
140
220
  }
141
221
 
222
+ function parse(
223
+ parseOptions: ProviderChoiceParseOptions & { readonly consume: "explicit" },
224
+ ): Promise<ProviderChoiceExplicitParseResult>;
142
225
  function parse(
143
226
  parseOptions: ProviderChoiceParseOptions & {
144
227
  readonly storage?: { readonly mode: "inline" };
@@ -146,94 +229,168 @@ export function createProviderChoiceContext(
146
229
  ): ProviderChoiceTokenPayload;
147
230
  function parse(
148
231
  parseOptions: ProviderChoiceParseOptions & {
149
- readonly storage: Extract<
150
- ProviderChoiceStorageOptions,
151
- { readonly mode: "server" }
152
- >;
232
+ readonly storage: Extract<ProviderChoiceStorageOptions, { readonly mode: "server" }>;
153
233
  },
154
234
  ): Promise<ProviderChoiceTokenPayload>;
155
235
  function parse(
156
236
  parseOptions: ProviderChoiceParseOptions & {
157
- readonly storage: Extract<
158
- ProviderChoiceStorageOptions,
159
- { readonly mode: "auto" }
160
- >;
237
+ readonly storage: Extract<ProviderChoiceStorageOptions, { readonly mode: "auto" }>;
161
238
  },
162
239
  ): ProviderChoiceTokenPayload | Promise<ProviderChoiceTokenPayload>;
163
240
  function parse(
164
241
  parseOptions: ProviderChoiceParseOptions,
165
- ): ProviderChoiceTokenPayload | Promise<ProviderChoiceTokenPayload> {
166
- const [
167
- actualPrefix,
168
- tokenKid,
169
- encodedIv,
170
- encryptedPayload,
171
- authTag,
172
- signature,
173
- ] = parseManagedChoiceTokenParts(parseOptions.token);
174
- if (
175
- actualPrefix !== parseOptions.prefix ||
176
- tokenKid !== kid ||
177
- !encodedIv ||
178
- !encryptedPayload ||
179
- !authTag ||
180
- !signature
181
- ) {
182
- throw new ProviderChoiceTokenError(
183
- "invalid_shape",
184
- "Provider choice token shape is invalid.",
185
- );
186
- }
187
-
188
- const keys = deriveManagedChoiceKeys({
189
- masterSecret: resolveMasterSecret(),
190
- providerId: options.providerId,
191
- purpose: parseOptions.purpose,
192
- kid: tokenKid,
193
- });
194
- const signedBody = [
195
- parseOptions.prefix,
196
- tokenKid,
197
- encodedIv,
198
- encryptedPayload,
199
- authTag,
200
- ].join(".");
201
- assertManagedChoiceSignature({
202
- signedBody,
203
- signature,
204
- signingKey: keys.signing,
205
- });
206
- const envelope = decryptManagedChoiceToken({
207
- encodedIv,
208
- encryptedPayload,
209
- authTag,
210
- encryptionKey: keys.encryption,
211
- });
212
- assertManagedChoiceEnvelope(envelope, {
213
- providerId: options.providerId,
214
- purpose: parseOptions.purpose,
215
- ttlMs: parseOptions.ttlMs,
216
- nowMs: parseOptions.nowMs,
217
- futureToleranceMs: parseOptions.futureToleranceMs,
242
+ ):
243
+ | ProviderChoiceTokenPayload
244
+ | ProviderChoiceExplicitParseResult
245
+ | Promise<ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult> {
246
+ const consumeMode = parseOptions.consume ?? "never";
247
+ const wordStateKey = parseWordChoiceStateKey({
248
+ token: parseOptions.token,
249
+ prefix: parseOptions.prefix,
218
250
  });
219
- assertChoiceBindingMatches({
220
- actual: envelope.binding,
221
- expected: createChoiceBinding({
222
- keys,
223
- options: parseOptions.bind,
251
+ if (wordStateKey) {
252
+ const parsed = parseWordServerStoredChoice({
253
+ stateKey: wordStateKey,
254
+ parseOptions,
255
+ contextState: options.state,
256
+ providerId: options.providerId,
224
257
  request: options.request,
225
258
  credential: options.credential,
226
- required: true,
227
- }),
228
- });
229
- if (isServerChoiceHandlePayload(envelope.payload)) {
230
- return parseServerStoredChoice({
231
- handle: envelope.payload,
232
- storage: parseOptions.storage,
233
- contextState: options.state,
259
+ resolveBindingKeys: () =>
260
+ deriveManagedChoiceKeys({
261
+ masterSecret: resolveMasterSecret(),
262
+ providerId: options.providerId,
263
+ purpose: parseOptions.purpose,
264
+ kid,
265
+ }),
266
+ onConsume: (result) =>
267
+ emitChoiceTelemetry(options.onTelemetry, {
268
+ providerId: options.providerId,
269
+ purpose: parseOptions.purpose,
270
+ operation: "consume",
271
+ format: "word",
272
+ outcome: "success",
273
+ consumeMode,
274
+ consumed: result.status === "consumed",
275
+ replay: result.status === "already-consumed",
276
+ }),
277
+ });
278
+ return observeChoiceParse<ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult>(
279
+ parsed,
280
+ {
281
+ onTelemetry: options.onTelemetry,
282
+ providerId: options.providerId,
283
+ purpose: parseOptions.purpose,
284
+ format: "word",
285
+ consumeMode,
286
+ },
287
+ );
288
+ }
289
+
290
+ // Legacy encrypted-envelope compatibility fallback. Removal is gated on
291
+ // the last legacy mint plus the maximum issued TTL; see ADR 0006.
292
+ // A structurally valid word token returns above, so lookup, expiry,
293
+ // consumption, and binding failures can never enter this branch.
294
+ try {
295
+ const [actualPrefix, tokenKid, encodedIv, encryptedPayload, authTag, signature] =
296
+ parseManagedChoiceTokenParts(parseOptions.token);
297
+ if (
298
+ actualPrefix !== parseOptions.prefix ||
299
+ tokenKid !== kid ||
300
+ !encodedIv ||
301
+ !encryptedPayload ||
302
+ !authTag ||
303
+ !signature
304
+ ) {
305
+ throw new ProviderChoiceTokenError(
306
+ "invalid_shape",
307
+ "Provider choice token shape is invalid.",
308
+ );
309
+ }
310
+
311
+ const keys = deriveManagedChoiceKeys({
312
+ masterSecret: resolveMasterSecret(),
313
+ providerId: options.providerId,
314
+ purpose: parseOptions.purpose,
315
+ kid: tokenKid,
316
+ });
317
+ const signedBody = [parseOptions.prefix, tokenKid, encodedIv, encryptedPayload, authTag].join(
318
+ ".",
319
+ );
320
+ assertManagedChoiceSignature({
321
+ signedBody,
322
+ signature,
323
+ signingKey: keys.signing,
324
+ });
325
+ const envelope = decryptManagedChoiceToken({
326
+ encodedIv,
327
+ encryptedPayload,
328
+ authTag,
329
+ encryptionKey: keys.encryption,
330
+ });
331
+ assertManagedChoiceEnvelope(envelope, {
332
+ providerId: options.providerId,
333
+ purpose: parseOptions.purpose,
334
+ ttlMs: parseOptions.ttlMs,
335
+ nowMs: parseOptions.nowMs,
336
+ futureToleranceMs: parseOptions.futureToleranceMs,
234
337
  });
338
+ assertChoiceBindingMatches({
339
+ actual: envelope.binding,
340
+ expected: createChoiceBinding({
341
+ keys,
342
+ options: parseOptions.bind,
343
+ request: options.request,
344
+ credential: options.credential,
345
+ required: true,
346
+ }),
347
+ });
348
+ const payload = isServerChoiceHandlePayload(envelope.payload)
349
+ ? parseLegacyServerStoredChoice({
350
+ handle: envelope.payload,
351
+ storage: parseOptions.storage,
352
+ contextState: options.state,
353
+ })
354
+ : envelope.payload;
355
+ const parsed =
356
+ consumeMode === "explicit"
357
+ ? Promise.resolve(payload).then((resolvedPayload) =>
358
+ createLegacyExplicitParseResult({
359
+ payload: resolvedPayload,
360
+ replayKey: digestChoiceReplayKey(parseOptions.token),
361
+ onConsume: () =>
362
+ emitChoiceTelemetry(options.onTelemetry, {
363
+ providerId: options.providerId,
364
+ purpose: parseOptions.purpose,
365
+ operation: "consume",
366
+ format: "legacy",
367
+ outcome: "unsupported",
368
+ consumeMode,
369
+ consumed: false,
370
+ replay: false,
371
+ }),
372
+ }),
373
+ )
374
+ : payload;
375
+ return observeChoiceParse<ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult>(
376
+ parsed,
377
+ {
378
+ onTelemetry: options.onTelemetry,
379
+ providerId: options.providerId,
380
+ purpose: parseOptions.purpose,
381
+ format: "legacy",
382
+ consumeMode,
383
+ },
384
+ );
385
+ } catch (error) {
386
+ emitChoiceParseFailure(options.onTelemetry, error, {
387
+ providerId: options.providerId,
388
+ purpose: parseOptions.purpose,
389
+ format: "legacy",
390
+ consumeMode,
391
+ });
392
+ throw error;
235
393
  }
236
- return envelope.payload;
237
394
  }
238
395
 
239
396
  return { issue, parse };
@@ -247,32 +404,144 @@ export function createTestProviderChoiceContext(
247
404
  return createProviderChoiceContext({
248
405
  ...options,
249
406
  masterSecret:
250
- options.masterSecret ??
251
- "apifuse-test-provider-runtime-choice-token-master-secret",
407
+ options.masterSecret ?? "apifuse-test-provider-runtime-choice-token-master-secret",
252
408
  });
253
409
  }
254
410
 
255
- function resolveChoiceMasterSecret(
256
- options: CreateProviderChoiceContextOptions,
257
- ): string {
258
- const configured =
259
- options.masterSecret ??
260
- options.env?.get(PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV);
261
- const trimmed = configured?.trim();
262
- if (trimmed) return trimmed;
411
+ type ChoiceParseTelemetryBase = {
412
+ readonly onTelemetry?: (event: ProviderChoiceTelemetryEvent) => void;
413
+ readonly providerId: string;
414
+ readonly purpose: string;
415
+ readonly format: "word" | "legacy";
416
+ readonly consumeMode: ProviderChoiceConsumeMode;
417
+ };
418
+
419
+ function observeChoiceParse<T>(
420
+ result: T | Promise<T>,
421
+ base: ChoiceParseTelemetryBase,
422
+ ): T | Promise<T> {
423
+ if (result instanceof Promise) {
424
+ return result.then(
425
+ (value) => {
426
+ emitChoiceParseSuccess(base, value);
427
+ return value;
428
+ },
429
+ (error: unknown) => {
430
+ emitChoiceParseFailure(base.onTelemetry, error, base);
431
+ throw error;
432
+ },
433
+ );
434
+ }
435
+ emitChoiceParseSuccess(base, result);
436
+ return result;
437
+ }
438
+
439
+ function emitChoiceParseSuccess(base: ChoiceParseTelemetryBase, result: unknown): void {
440
+ const replay = isConsumedChoiceReplay(result);
441
+ emitChoiceTelemetry(base.onTelemetry, {
442
+ providerId: base.providerId,
443
+ purpose: base.purpose,
444
+ operation: "parse",
445
+ format: base.format,
446
+ outcome: "success",
447
+ consumeMode: base.consumeMode,
448
+ consumed: replay || (base.format === "word" && base.consumeMode === "on-parse"),
449
+ replay,
450
+ });
451
+ }
452
+
453
+ function isConsumedChoiceReplay(value: unknown): boolean {
454
+ return (
455
+ value !== null &&
456
+ typeof value === "object" &&
457
+ "status" in value &&
458
+ value.status === "consumed" &&
459
+ "replayKey" in value &&
460
+ typeof value.replayKey === "string"
461
+ );
462
+ }
463
+
464
+ function emitChoiceParseFailure(
465
+ onTelemetry: CreateProviderChoiceContextOptions["onTelemetry"],
466
+ error: unknown,
467
+ base: Omit<ChoiceParseTelemetryBase, "onTelemetry">,
468
+ ): void {
469
+ const outcome =
470
+ error instanceof ProviderChoiceTokenError
471
+ ? base.format === "word" && error.message === WORD_CHOICE_NOT_FOUND_MESSAGE
472
+ ? "not-found"
473
+ : "invalid"
474
+ : "error";
475
+ emitChoiceTelemetry(onTelemetry, {
476
+ providerId: base.providerId,
477
+ purpose: base.purpose,
478
+ operation: "parse",
479
+ format: base.format,
480
+ outcome,
481
+ consumeMode: base.consumeMode,
482
+ consumed: false,
483
+ replay: false,
484
+ });
485
+ }
486
+
487
+ function emitChoiceTelemetry(
488
+ onTelemetry: CreateProviderChoiceContextOptions["onTelemetry"],
489
+ event: ProviderChoiceTelemetryEvent,
490
+ ): void {
491
+ try {
492
+ onTelemetry?.(event);
493
+ } catch {
494
+ // Observability must never change provider token semantics.
495
+ }
496
+ }
497
+
498
+ function createLegacyExplicitParseResult(options: {
499
+ readonly payload: ProviderChoiceTokenPayload;
500
+ readonly replayKey: string;
501
+ readonly onConsume: () => void;
502
+ }): ProviderChoiceExplicitParseResult {
503
+ return {
504
+ status: "active",
505
+ payload: options.payload,
506
+ replayKey: options.replayKey,
507
+ consume: async () => {
508
+ options.onConsume();
509
+ return { status: "unsupported" };
510
+ },
511
+ };
512
+ }
513
+
514
+ function resolveChoiceWordIssuance(env?: EnvContext): "legacy" | "word" {
515
+ const configured = env?.get(PROVIDER_RUNTIME_CHOICE_WORD_ISSUANCE_ENV);
516
+ const value = configured?.trim() ?? "";
517
+ if (value === "" || value === "legacy") return "legacy";
518
+ if (value === "word") return "word";
263
519
  throw new ProviderError(
264
- "Provider runtime choice-token master secret is not configured.",
520
+ `Unsupported provider choice word issuance mode "${value}". Expected "legacy" or "word".`,
265
521
  {
266
- code: "CHOICE_TOKEN_MASTER_SECRET_NOT_CONFIGURED",
267
- category: "internal_error",
522
+ code: "CHOICE_WORD_ISSUANCE_INVALID",
523
+ category: "input_validation",
268
524
  retryable: false,
269
- details: {
270
- secret: PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV,
271
- },
525
+ details: { env: PROVIDER_RUNTIME_CHOICE_WORD_ISSUANCE_ENV },
272
526
  },
273
527
  );
274
528
  }
275
529
 
530
+ function resolveChoiceMasterSecret(options: CreateProviderChoiceContextOptions): string {
531
+ const configured =
532
+ options.masterSecret ?? options.env?.get(PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV);
533
+ const trimmed = configured?.trim();
534
+ if (trimmed) return trimmed;
535
+ throw new ProviderError("Provider runtime choice-token master secret is not configured.", {
536
+ code: "CHOICE_TOKEN_MASTER_SECRET_NOT_CONFIGURED",
537
+ category: "internal_error",
538
+ retryable: false,
539
+ details: {
540
+ secret: PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV,
541
+ },
542
+ });
543
+ }
544
+
276
545
  type ManagedChoiceKeyInput = {
277
546
  readonly masterSecret: string;
278
547
  readonly providerId: string;
@@ -286,9 +555,7 @@ type ManagedChoiceKeys = {
286
555
  readonly binding: Buffer;
287
556
  };
288
557
 
289
- function deriveManagedChoiceKeys(
290
- input: ManagedChoiceKeyInput,
291
- ): ManagedChoiceKeys {
558
+ function deriveManagedChoiceKeys(input: ManagedChoiceKeyInput): ManagedChoiceKeys {
292
559
  return {
293
560
  encryption: deriveManagedChoiceKey(input, "encryption"),
294
561
  signing: deriveManagedChoiceKey(input, "signing"),
@@ -327,50 +594,94 @@ function encryptManagedChoiceToken(options: {
327
594
  ]).toString("base64url");
328
595
  const authTag = cipher.getAuthTag().toString("base64url");
329
596
  const encodedIv = iv.toString("base64url");
330
- const signedBody = [
331
- options.prefix,
332
- options.kid,
333
- encodedIv,
334
- encryptedPayload,
335
- authTag,
336
- ].join(".");
597
+ const signedBody = [options.prefix, options.kid, encodedIv, encryptedPayload, authTag].join(".");
337
598
  const signature = createHmac("sha256", options.keys.signing)
338
599
  .update(signedBody)
339
600
  .digest("base64url");
340
601
  return `${signedBody}.${signature}`;
341
602
  }
342
603
 
343
- async function issueServerStoredChoice<
344
- TPayload extends ProviderChoiceTokenPayload,
345
- >(options: {
604
+ async function issueWordServerStoredChoice<TPayload extends ProviderChoiceTokenPayload>(options: {
346
605
  readonly baseEnvelope: Omit<ManagedChoiceEnvelope, "payload">;
347
606
  readonly issueOptions: ProviderChoiceIssueOptions<TPayload>;
348
607
  readonly storage: ServerProviderChoiceStorageOptions;
349
608
  readonly contextState?: ProviderRuntimeState;
350
- readonly kid: string;
351
- readonly keys: ManagedChoiceKeys;
352
- readonly issuedAtMs: number;
353
609
  }): Promise<string> {
354
- const serializedPayload = serializeChoicePayload(
355
- options.issueOptions.payload,
356
- );
357
- const payloadBytes = Buffer.byteLength(serializedPayload, "utf8");
358
- if (payloadBytes > options.storage.maxValueBytes) {
359
- throw new ProviderError(
360
- "Provider choice payload exceeds state storage policy.",
361
- {
610
+ const serializedPayload = serializeChoicePayload(options.issueOptions.payload);
611
+ const payloadDigest = digestChoicePayload(serializedPayload);
612
+ const namespace = resolveChoiceStateNamespace({
613
+ storage: options.storage,
614
+ contextState: options.contextState,
615
+ ttlMs: options.issueOptions.ttlMs,
616
+ });
617
+ const wordCount =
618
+ options.issueOptions.strength === "high" ? HIGH_CHOICE_WORD_COUNT : STANDARD_CHOICE_WORD_COUNT;
619
+ for (let attempt = 0; attempt < SERVER_STORED_CHOICE_ISSUE_ATTEMPTS; attempt += 1) {
620
+ const stateKey = generateChoiceWordSequence(wordCount);
621
+ const token = `${options.issueOptions.prefix}${stateKey}`;
622
+ const record: ServerStoredChoiceRecord = {
623
+ v: SERVER_STORED_CHOICE_RECORD_VERSION,
624
+ storage: "server",
625
+ status: "active",
626
+ provider_id: options.baseEnvelope.provider_id,
627
+ purpose: options.baseEnvelope.purpose,
628
+ issued_at_ms: options.baseEnvelope.issued_at_ms,
629
+ ttl_ms: options.baseEnvelope.ttl_ms,
630
+ binding: options.baseEnvelope.binding,
631
+ prefix: options.issueOptions.prefix,
632
+ payload: options.issueOptions.payload,
633
+ payload_digest: payloadDigest,
634
+ replay_key: digestChoiceReplayKey(token),
635
+ };
636
+ const valueBytes = Buffer.byteLength(JSON.stringify(record), "utf8");
637
+ if (valueBytes > options.storage.maxValueBytes) {
638
+ throw new ProviderError("Provider choice payload exceeds state storage policy.", {
362
639
  code: "CHOICE_STATE_PAYLOAD_TOO_LARGE",
363
640
  category: "input_validation",
364
641
  retryable: false,
365
642
  details: {
366
643
  maxValueBytes: options.storage.maxValueBytes,
367
- payloadBytes,
644
+ valueBytes,
368
645
  },
369
- },
370
- );
646
+ });
647
+ }
648
+ const result = await namespace.compareAndSet(optionsStateKey(stateKey), 0, record, {
649
+ ttl: stateTtl(options.storage, options.issueOptions.ttlMs),
650
+ });
651
+ if (result.ok) return token;
652
+ }
653
+ throw new ProviderError("Provider choice state storage is not available.", {
654
+ code: "CHOICE_STATE_UNAVAILABLE",
655
+ category: "internal_error",
656
+ retryable: false,
657
+ });
658
+ }
659
+
660
+ /**
661
+ * Beta.28-compatible server handle issuance. The state stores the payload and
662
+ * the client receives the existing six-part encrypted envelope whose payload
663
+ * is a server-state handle.
664
+ */
665
+ async function issueLegacyServerStoredChoice<TPayload extends ProviderChoiceTokenPayload>(options: {
666
+ readonly baseEnvelope: Omit<ManagedChoiceEnvelope, "payload">;
667
+ readonly issueOptions: ProviderChoiceIssueOptions<TPayload>;
668
+ readonly storage: ServerProviderChoiceStorageOptions;
669
+ readonly contextState?: ProviderRuntimeState;
670
+ readonly kid: string;
671
+ readonly keys: ManagedChoiceKeys;
672
+ readonly issuedAtMs: number;
673
+ }): Promise<string> {
674
+ const serializedPayload = serializeChoicePayload(options.issueOptions.payload);
675
+ const payloadBytes = Buffer.byteLength(serializedPayload, "utf8");
676
+ if (payloadBytes > options.storage.maxValueBytes) {
677
+ throw new ProviderError("Provider choice payload exceeds state storage policy.", {
678
+ code: "CHOICE_STATE_PAYLOAD_TOO_LARGE",
679
+ category: "input_validation",
680
+ retryable: false,
681
+ details: { maxValueBytes: options.storage.maxValueBytes, payloadBytes },
682
+ });
371
683
  }
372
684
  const stateId = `choice_${randomBytes(16).toString("base64url")}`;
373
- const digest = digestChoicePayload(serializedPayload);
374
685
  const namespace = resolveChoiceStateNamespace({
375
686
  storage: options.storage,
376
687
  contextState: options.contextState,
@@ -384,7 +695,7 @@ async function issueServerStoredChoice<
384
695
  payload: {
385
696
  storage: "server",
386
697
  state_id: stateId,
387
- payload_digest: digest,
698
+ payload_digest: digestChoicePayload(serializedPayload),
388
699
  created_at_ms: options.issuedAtMs,
389
700
  },
390
701
  };
@@ -396,7 +707,149 @@ async function issueServerStoredChoice<
396
707
  });
397
708
  }
398
709
 
399
- async function parseServerStoredChoice(options: {
710
+ async function parseWordServerStoredChoice(options: {
711
+ readonly stateKey: string;
712
+ readonly parseOptions: ProviderChoiceParseOptions;
713
+ readonly contextState?: ProviderRuntimeState;
714
+ readonly providerId: string;
715
+ readonly request?: ProviderRequestContext;
716
+ readonly credential?: CredentialContext;
717
+ readonly resolveBindingKeys: () => ManagedChoiceKeys;
718
+ readonly onConsume: (result: ProviderChoiceConsumeResult) => void;
719
+ }): Promise<ProviderChoiceTokenPayload | ProviderChoiceExplicitParseResult> {
720
+ const storage = resolveParseStorage(options.parseOptions.storage);
721
+ const namespace = resolveChoiceStateNamespace({
722
+ storage,
723
+ contextState: options.contextState,
724
+ ttlMs: options.parseOptions.ttlMs,
725
+ });
726
+ let stored: StateValue<ServerStoredChoiceRecord> | null;
727
+ try {
728
+ stored = await namespace.get<ServerStoredChoiceRecord>(optionsStateKey(options.stateKey));
729
+ } catch (error) {
730
+ if (isProviderError(error)) throw error;
731
+ throw wordChoiceNotFoundError();
732
+ }
733
+ if (!stored || !isServerStoredChoiceRecord(stored.value)) {
734
+ throw wordChoiceNotFoundError();
735
+ }
736
+
737
+ const record = stored.value;
738
+ const expectedReplayKey = digestChoiceReplayKey(
739
+ `${options.parseOptions.prefix}${options.stateKey}`,
740
+ );
741
+ try {
742
+ if (
743
+ record.provider_id !== options.providerId ||
744
+ record.purpose !== options.parseOptions.purpose ||
745
+ record.prefix !== options.parseOptions.prefix
746
+ ) {
747
+ throw wordChoiceNotFoundError();
748
+ }
749
+ assertFreshProviderChoiceIssuedAt(record.issued_at_ms, {
750
+ ttlMs:
751
+ options.parseOptions.ttlMs != null
752
+ ? Math.min(options.parseOptions.ttlMs, record.ttl_ms)
753
+ : record.ttl_ms,
754
+ nowMs: options.parseOptions.nowMs,
755
+ futureToleranceMs: options.parseOptions.futureToleranceMs,
756
+ });
757
+ assertPayloadDigestMatches({
758
+ actual: digestChoicePayload(serializeChoicePayload(record.payload)),
759
+ expected: record.payload_digest,
760
+ });
761
+ assertPayloadDigestMatches({ actual: expectedReplayKey, expected: record.replay_key });
762
+ assertWordChoiceBindingMatches({
763
+ actual: record.binding,
764
+ requested: options.parseOptions.bind,
765
+ request: options.request,
766
+ credential: options.credential,
767
+ resolveKeys: options.resolveBindingKeys,
768
+ });
769
+ } catch (error) {
770
+ if (
771
+ error instanceof ProviderChoiceTokenError ||
772
+ (isProviderError(error) && error.code === "CHOICE_CONTEXT_REQUIRED")
773
+ ) {
774
+ throw wordChoiceNotFoundError();
775
+ }
776
+ throw error;
777
+ }
778
+
779
+ const consumeMode = options.parseOptions.consume ?? "never";
780
+ if (record.status === "consumed") {
781
+ if (consumeMode === "explicit") {
782
+ return { status: "consumed", replayKey: record.replay_key };
783
+ }
784
+ throw wordChoiceNotFoundError();
785
+ }
786
+ if (consumeMode === "never") return record.payload;
787
+ if (consumeMode === "explicit") {
788
+ return {
789
+ status: "active",
790
+ payload: record.payload,
791
+ replayKey: record.replay_key,
792
+ consume: async () => {
793
+ const result = await consumeWordServerStoredChoice({
794
+ stateKey: options.stateKey,
795
+ stored,
796
+ record,
797
+ storage,
798
+ contextState: options.contextState,
799
+ });
800
+ options.onConsume(result);
801
+ return result;
802
+ },
803
+ };
804
+ }
805
+ const consumed = await consumeWordServerStoredChoice({
806
+ stateKey: options.stateKey,
807
+ stored,
808
+ record,
809
+ storage,
810
+ contextState: options.contextState,
811
+ });
812
+ if (consumed.status !== "consumed") throw wordChoiceNotFoundError();
813
+ return record.payload;
814
+ }
815
+
816
+ async function consumeWordServerStoredChoice(options: {
817
+ readonly stateKey: string;
818
+ readonly stored: StateValue<ServerStoredChoiceRecord>;
819
+ readonly record: ServerStoredChoiceRecord;
820
+ readonly storage: ServerProviderChoiceStorageOptions;
821
+ readonly contextState?: ProviderRuntimeState;
822
+ }): Promise<ProviderChoiceConsumeResult> {
823
+ const namespace = resolveChoiceStateNamespace({
824
+ storage: options.storage,
825
+ contextState: options.contextState,
826
+ ttlMs: options.record.ttl_ms,
827
+ });
828
+ try {
829
+ const consumed = await namespace.compareAndSet(
830
+ optionsStateKey(options.stateKey),
831
+ options.stored.version,
832
+ { ...options.record, status: "consumed" } satisfies ServerStoredChoiceRecord,
833
+ { ttl: remainingStateTtl(options.stored.expiresAt) },
834
+ );
835
+ if (consumed.ok) return { status: "consumed" };
836
+ if (
837
+ consumed.current &&
838
+ isServerStoredChoiceRecord(consumed.current.value) &&
839
+ consumed.current.value.status === "consumed" &&
840
+ consumed.current.value.replay_key === options.record.replay_key
841
+ ) {
842
+ return { status: "already-consumed" };
843
+ }
844
+ throw wordChoiceNotFoundError();
845
+ } catch (error) {
846
+ if (isProviderError(error)) throw error;
847
+ if (error instanceof ProviderChoiceTokenError) throw error;
848
+ throw wordChoiceNotFoundError();
849
+ }
850
+ }
851
+
852
+ async function parseLegacyServerStoredChoice(options: {
400
853
  readonly handle: ServerChoiceHandlePayload;
401
854
  readonly storage?: ProviderChoiceStorageOptions;
402
855
  readonly contextState?: ProviderRuntimeState;
@@ -406,9 +859,29 @@ async function parseServerStoredChoice(options: {
406
859
  storage,
407
860
  contextState: options.contextState,
408
861
  });
409
- const record = await namespace.get<ProviderChoiceTokenPayload>(
410
- optionsStateKey(options.handle.state_id),
411
- );
862
+ // Reading a server-stored choice back deserializes a persisted value. A
863
+ // corrupt/undecodable value would otherwise surface as a raw JSON.parse
864
+ // SyntaxError (or another unexpected throwable) that escapes the choice error
865
+ // taxonomy, gets masked as internal_error 500, and is treated as retryable by
866
+ // the hub -> reservation restart loop (2026-07-22 catchtable RCA, candidate A).
867
+ // Convert any non-branded throwable into a branded invalid_payload so it maps
868
+ // to a clean, non-retryable 400. Branded ProviderChoiceTokenError and genuine
869
+ // ProviderError (e.g. Redis-unavailable / state-unavailable) pass through so
870
+ // their category/retryable semantics are preserved.
871
+ let record: StateValue<ProviderChoiceTokenPayload> | null;
872
+ try {
873
+ record = await namespace.get<ProviderChoiceTokenPayload>(
874
+ optionsStateKey(options.handle.state_id),
875
+ );
876
+ } catch (error) {
877
+ if (error instanceof ProviderChoiceTokenError || isProviderError(error)) {
878
+ throw error;
879
+ }
880
+ throw new ProviderChoiceTokenError(
881
+ "invalid_payload",
882
+ "Provider choice token state payload could not be decoded.",
883
+ );
884
+ }
412
885
  if (!record) {
413
886
  throw new ProviderChoiceTokenError(
414
887
  "invalid_payload",
@@ -423,6 +896,83 @@ async function parseServerStoredChoice(options: {
423
896
  return record.value;
424
897
  }
425
898
 
899
+ function generateChoiceWordSequence(wordCount: number): string {
900
+ return Array.from({ length: wordCount }, () =>
901
+ choiceWordAt(randomInt(CHOICE_WORDLIST_SIZE)),
902
+ ).join("-");
903
+ }
904
+
905
+ function parseWordChoiceStateKey(options: {
906
+ readonly token: string;
907
+ readonly prefix: string;
908
+ }): string | null {
909
+ if (!options.token.startsWith(options.prefix)) return null;
910
+ const body = options.token.slice(options.prefix.length);
911
+ // The official list contains one hyphenated entry (`yo-yo`), so structural
912
+ // recognition uses dictionary-aware segmentation instead of assuming every
913
+ // hyphen is a word boundary.
914
+ if (!/^[a-z]+(?:-[a-z]+){3,9}$/.test(body)) return null;
915
+ const segments = body.split("-");
916
+ if (
917
+ !canSegmentChoiceWords(segments, 0, STANDARD_CHOICE_WORD_COUNT) &&
918
+ !canSegmentChoiceWords(segments, 0, HIGH_CHOICE_WORD_COUNT)
919
+ ) {
920
+ return null;
921
+ }
922
+ return body;
923
+ }
924
+
925
+ function canSegmentChoiceWords(
926
+ segments: readonly string[],
927
+ segmentIndex: number,
928
+ wordsRemaining: number,
929
+ ): boolean {
930
+ if (wordsRemaining === 0) return segmentIndex === segments.length;
931
+ const segmentsRemaining = segments.length - segmentIndex;
932
+ if (segmentsRemaining < wordsRemaining) return false;
933
+ for (let end = segmentIndex + 1; end <= segments.length - (wordsRemaining - 1); end += 1) {
934
+ const candidate = segments.slice(segmentIndex, end).join("-");
935
+ if (candidate.length > 10) break;
936
+ if (isChoiceWord(candidate) && canSegmentChoiceWords(segments, end, wordsRemaining - 1)) {
937
+ return true;
938
+ }
939
+ }
940
+ return false;
941
+ }
942
+
943
+ function isServerStoredChoiceRecord(value: unknown): value is ServerStoredChoiceRecord {
944
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
945
+ return (
946
+ "v" in value &&
947
+ value.v === SERVER_STORED_CHOICE_RECORD_VERSION &&
948
+ "storage" in value &&
949
+ value.storage === "server" &&
950
+ "status" in value &&
951
+ (value.status === "active" || value.status === "consumed") &&
952
+ "provider_id" in value &&
953
+ typeof value.provider_id === "string" &&
954
+ "purpose" in value &&
955
+ typeof value.purpose === "string" &&
956
+ "issued_at_ms" in value &&
957
+ typeof value.issued_at_ms === "number" &&
958
+ "ttl_ms" in value &&
959
+ typeof value.ttl_ms === "number" &&
960
+ (!("binding" in value) || value.binding === undefined || isChoiceBinding(value.binding)) &&
961
+ "prefix" in value &&
962
+ typeof value.prefix === "string" &&
963
+ "payload" in value &&
964
+ isChoicePayload(value.payload) &&
965
+ "payload_digest" in value &&
966
+ typeof value.payload_digest === "string" &&
967
+ "replay_key" in value &&
968
+ typeof value.replay_key === "string"
969
+ );
970
+ }
971
+
972
+ function wordChoiceNotFoundError(): ProviderChoiceTokenError {
973
+ return new ProviderChoiceTokenError("invalid_payload", WORD_CHOICE_NOT_FOUND_MESSAGE);
974
+ }
975
+
426
976
  type ServerProviderChoiceStorageOptions = Extract<
427
977
  ProviderChoiceStorageOptions,
428
978
  { readonly mode: "server" | "auto" }
@@ -439,10 +989,7 @@ function resolveIssueStorage<TPayload extends ProviderChoiceTokenPayload>(
439
989
  } {
440
990
  if (!storage || storage.mode === "inline") return { mode: "inline" };
441
991
  if (storage.mode === "server") return { mode: "server", storage };
442
- const payloadBytes = Buffer.byteLength(
443
- serializeChoicePayload(payload),
444
- "utf8",
445
- );
992
+ const payloadBytes = Buffer.byteLength(serializeChoicePayload(payload), "utf8");
446
993
  if (payloadBytes <= storage.maxInlineBytes) return { mode: "inline" };
447
994
  return { mode: "server", storage };
448
995
  }
@@ -487,6 +1034,11 @@ function stateTtl(
487
1034
  return storage.ttl ?? `${ttlMs ?? 1}ms`;
488
1035
  }
489
1036
 
1037
+ function remainingStateTtl(expiresAt: string): ProviderStateDurationString {
1038
+ const remainingMs = Date.parse(expiresAt) - Date.now();
1039
+ return `${Number.isFinite(remainingMs) ? Math.max(1, Math.floor(remainingMs)) : 1}ms`;
1040
+ }
1041
+
490
1042
  function optionsStateKey(stateId: string): string {
491
1043
  return stateId;
492
1044
  }
@@ -499,6 +1051,10 @@ function digestChoicePayload(serializedPayload: string): string {
499
1051
  return createHash("sha256").update(serializedPayload).digest("base64url");
500
1052
  }
501
1053
 
1054
+ function digestChoiceReplayKey(token: string): string {
1055
+ return createHash("sha256").update(token).digest("hex");
1056
+ }
1057
+
502
1058
  function isServerChoiceHandlePayload(
503
1059
  value: ProviderChoiceTokenPayload,
504
1060
  ): value is ServerChoiceHandlePayload {
@@ -536,10 +1092,7 @@ function parseManagedChoiceTokenParts(
536
1092
  ] {
537
1093
  const parts = token.split(".");
538
1094
  if (parts.length !== 6) {
539
- throw new ProviderChoiceTokenError(
540
- "invalid_shape",
541
- "Provider choice token shape is invalid.",
542
- );
1095
+ throw new ProviderChoiceTokenError("invalid_shape", "Provider choice token shape is invalid.");
543
1096
  }
544
1097
  return [parts[0], parts[1], parts[2], parts[3], parts[4], parts[5]];
545
1098
  }
@@ -601,9 +1154,7 @@ function decryptManagedChoiceToken(options: {
601
1154
  }
602
1155
  }
603
1156
 
604
- function isManagedChoiceEnvelope(
605
- value: unknown,
606
- ): value is ManagedChoiceEnvelope {
1157
+ function isManagedChoiceEnvelope(value: unknown): value is ManagedChoiceEnvelope {
607
1158
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
608
1159
  if (!("payload" in value) || !isChoicePayload(value.payload)) return false;
609
1160
  return (
@@ -617,9 +1168,7 @@ function isManagedChoiceEnvelope(
617
1168
  typeof value.issued_at_ms === "number" &&
618
1169
  "ttl_ms" in value &&
619
1170
  typeof value.ttl_ms === "number" &&
620
- (!("binding" in value) ||
621
- value.binding === undefined ||
622
- isChoiceBinding(value.binding))
1171
+ (!("binding" in value) || value.binding === undefined || isChoiceBinding(value.binding))
623
1172
  );
624
1173
  }
625
1174
 
@@ -627,13 +1176,10 @@ function isChoicePayload(value: unknown): value is ProviderChoiceTokenPayload {
627
1176
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
628
1177
  }
629
1178
 
630
- function isChoiceBinding(
631
- value: unknown,
632
- ): value is ManagedChoiceEnvelope["binding"] {
1179
+ function isChoiceBinding(value: unknown): value is ManagedChoiceEnvelope["binding"] {
633
1180
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
634
1181
  return (
635
- (!("connection_hash" in value) ||
636
- typeof value.connection_hash === "string") &&
1182
+ (!("connection_hash" in value) || typeof value.connection_hash === "string") &&
637
1183
  (!("credential_hash" in value) || typeof value.credential_hash === "string")
638
1184
  );
639
1185
  }
@@ -648,10 +1194,7 @@ function assertManagedChoiceEnvelope(
648
1194
  readonly futureToleranceMs?: number;
649
1195
  },
650
1196
  ): void {
651
- if (
652
- envelope.provider_id !== options.providerId ||
653
- envelope.purpose !== options.purpose
654
- ) {
1197
+ if (envelope.provider_id !== options.providerId || envelope.purpose !== options.purpose) {
655
1198
  throw new ProviderChoiceTokenError(
656
1199
  "invalid_payload",
657
1200
  "Provider choice token payload is invalid.",
@@ -660,10 +1203,7 @@ function assertManagedChoiceEnvelope(
660
1203
  assertFreshProviderChoiceIssuedAt(envelope.issued_at_ms, {
661
1204
  // Clamp to the issuer's embedded TTL so a caller-supplied value cannot
662
1205
  // silently extend token validity past the deadline the issuer intended.
663
- ttlMs:
664
- options.ttlMs != null
665
- ? Math.min(options.ttlMs, envelope.ttl_ms)
666
- : envelope.ttl_ms,
1206
+ ttlMs: options.ttlMs != null ? Math.min(options.ttlMs, envelope.ttl_ms) : envelope.ttl_ms,
667
1207
  nowMs: options.nowMs,
668
1208
  futureToleranceMs: options.futureToleranceMs,
669
1209
  });
@@ -676,9 +1216,7 @@ function createChoiceBinding(options: {
676
1216
  readonly credential?: CredentialContext;
677
1217
  readonly required: boolean;
678
1218
  }): ManagedChoiceEnvelope["binding"] {
679
- const connectionHash = options.options?.connection
680
- ? hashRequiredConnection(options)
681
- : undefined;
1219
+ const connectionHash = options.options?.connection ? hashRequiredConnection(options) : undefined;
682
1220
  const credentialHash = options.options?.credentialKeys?.length
683
1221
  ? hashCredentialKeys(options)
684
1222
  : undefined;
@@ -689,6 +1227,35 @@ function createChoiceBinding(options: {
689
1227
  };
690
1228
  }
691
1229
 
1230
+ function hasRequestedChoiceBinding(options?: ProviderChoiceBindingOptions): boolean {
1231
+ return options?.connection === true || Boolean(options?.credentialKeys?.length);
1232
+ }
1233
+
1234
+ function assertWordChoiceBindingMatches(options: {
1235
+ readonly actual: ManagedChoiceEnvelope["binding"];
1236
+ readonly requested?: ProviderChoiceBindingOptions;
1237
+ readonly request?: ProviderRequestContext;
1238
+ readonly credential?: CredentialContext;
1239
+ readonly resolveKeys: () => ManagedChoiceKeys;
1240
+ }): void {
1241
+ const hasStoredBinding = Boolean(
1242
+ options.actual?.connection_hash || options.actual?.credential_hash,
1243
+ );
1244
+ const hasRequestedBinding = hasRequestedChoiceBinding(options.requested);
1245
+ if (!hasStoredBinding && !hasRequestedBinding) return;
1246
+ if (hasStoredBinding !== hasRequestedBinding) throw wordChoiceNotFoundError();
1247
+ assertChoiceBindingMatches({
1248
+ actual: options.actual,
1249
+ expected: createChoiceBinding({
1250
+ keys: options.resolveKeys(),
1251
+ options: options.requested,
1252
+ request: options.request,
1253
+ credential: options.credential,
1254
+ required: true,
1255
+ }),
1256
+ });
1257
+ }
1258
+
692
1259
  function hashRequiredConnection(options: {
693
1260
  readonly keys: ManagedChoiceKeys;
694
1261
  readonly request?: ProviderRequestContext;
@@ -697,14 +1264,11 @@ function hashRequiredConnection(options: {
697
1264
  const connectionId = options.request?.connectionId;
698
1265
  if (!connectionId) {
699
1266
  if (!options.required) return undefined;
700
- throw new ProviderError(
701
- "Provider choice tokens require connection context.",
702
- {
703
- code: "CHOICE_CONTEXT_REQUIRED",
704
- category: "input_validation",
705
- retryable: false,
706
- },
707
- );
1267
+ throw new ProviderError("Provider choice tokens require connection context.", {
1268
+ code: "CHOICE_CONTEXT_REQUIRED",
1269
+ category: "input_validation",
1270
+ retryable: false,
1271
+ });
708
1272
  }
709
1273
  return createHmac("sha256", options.keys.binding)
710
1274
  .update("connection")
@@ -722,15 +1286,12 @@ function hashCredentialKeys(options: {
722
1286
  const material = credentialKeys.map((key) => {
723
1287
  const value = options.credential?.get(key);
724
1288
  if (typeof value !== "string" || value.length === 0) {
725
- throw new ProviderError(
726
- "Provider choice tokens require configured credential binding.",
727
- {
728
- code: "CHOICE_CONTEXT_REQUIRED",
729
- category: "input_validation",
730
- retryable: false,
731
- details: { credentialKey: key },
732
- },
733
- );
1289
+ throw new ProviderError("Provider choice tokens require configured credential binding.", {
1290
+ code: "CHOICE_CONTEXT_REQUIRED",
1291
+ category: "input_validation",
1292
+ retryable: false,
1293
+ details: { credentialKey: key },
1294
+ });
734
1295
  }
735
1296
  return [key, value];
736
1297
  });