@danceiny/gotry 0.0.1-rc.8 → 0.2.0-rc.27

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 (506) hide show
  1. package/README.md +172 -217
  2. package/README.zh-CN.md +222 -0
  3. package/bin/gotry-backend.js +53 -0
  4. package/bin/gotry-booking-copilot.js +53 -0
  5. package/bin/gotry-bootstrap.js +1744 -0
  6. package/bin/gotry-inner.js +733 -83
  7. package/bin/gotry-process-liveness.d.ts +27 -0
  8. package/bin/gotry-process-liveness.js +97 -0
  9. package/bin/gotry-runtime-resolution.d.ts +27 -0
  10. package/bin/gotry-runtime-resolution.js +49 -0
  11. package/bin/gotry.js +1 -1
  12. package/client/client.js +203 -0
  13. package/cordis.gotry-patch.yml +80 -49
  14. package/data/airline-airports.json +39 -0
  15. package/data/stations-12306-verify.json +524 -0
  16. package/dist/capabilities/agent-reach-deep.js +45 -122
  17. package/dist/capabilities/agent-reach.js +101 -146
  18. package/dist/capabilities/anything.js +135 -94
  19. package/dist/capabilities/artifacts.js +370 -0
  20. package/dist/capabilities/channel-health.js +131 -0
  21. package/dist/capabilities/channel-registry.js +256 -0
  22. package/dist/capabilities/doctor.js +546 -0
  23. package/dist/capabilities/effect.js +394 -0
  24. package/dist/capabilities/external-event.js +149 -0
  25. package/dist/capabilities/fact-log.js +55 -0
  26. package/dist/capabilities/flyai-config.js +337 -0
  27. package/dist/capabilities/flyai.js +968 -0
  28. package/dist/capabilities/fx-contract.js +293 -0
  29. package/dist/capabilities/geo-atlas.js +246 -0
  30. package/dist/capabilities/ground-transfer.js +509 -0
  31. package/dist/capabilities/hbcli.js +260 -123
  32. package/dist/capabilities/hotelbyte-transaction.js +194 -0
  33. package/dist/capabilities/incident-log.js +131 -61
  34. package/dist/capabilities/itinerary-artifact.js +190 -0
  35. package/dist/capabilities/itinerary-deck-artifact.js +194 -0
  36. package/dist/capabilities/itinerary-deck-export.js +339 -0
  37. package/dist/capabilities/lavish-local.js +924 -0
  38. package/dist/capabilities/model-override.js +38 -0
  39. package/dist/capabilities/opensky.js +54 -43
  40. package/dist/capabilities/resilience.js +191 -0
  41. package/dist/capabilities/session/action-cache.js +126 -0
  42. package/dist/capabilities/session/adapters/ctrip-flight.js +141 -0
  43. package/dist/capabilities/session/adapters/ctrip-hotel.js +222 -0
  44. package/dist/capabilities/session/adapters/dida-portal.js +274 -0
  45. package/dist/capabilities/session/adapters/meituan-local.js +54 -0
  46. package/dist/capabilities/session/adapters/rail-12306.js +342 -0
  47. package/dist/capabilities/session/benchmark.js +189 -0
  48. package/dist/capabilities/session/bridge-ledger.js +224 -0
  49. package/dist/capabilities/session/extension-bridge.js +636 -0
  50. package/dist/capabilities/session/extension-channel.js +109 -0
  51. package/dist/capabilities/session/extension-distribution.js +230 -0
  52. package/dist/capabilities/session/extract.js +50 -0
  53. package/dist/capabilities/session/golden-score.js +114 -0
  54. package/dist/capabilities/session/health-watch.js +215 -0
  55. package/dist/capabilities/session/read-guard.js +107 -0
  56. package/dist/capabilities/session/static-flight-golden.js +150 -0
  57. package/dist/capabilities/session/transport.js +109 -0
  58. package/dist/capabilities/session/wizard.js +117 -0
  59. package/dist/capabilities/session-consent.js +178 -0
  60. package/dist/capabilities/session-login.js +170 -0
  61. package/dist/capabilities/session-search.js +686 -0
  62. package/dist/capabilities/spawn-bounded.js +203 -0
  63. package/dist/capabilities/sponsor-plugin.js +341 -0
  64. package/dist/capabilities/visa-policy.js +140 -0
  65. package/dist/capabilities/weather.js +243 -138
  66. package/dist/data/session-golden-20.json +25 -0
  67. package/dist/data/sf-golden-manifest.json +103 -0
  68. package/dist/data/sf-static-routes.json +91 -0
  69. package/dist/scripts/action-cache-tests.js +72 -0
  70. package/dist/scripts/agent-planning-turn-deadline-e2e.js +242 -0
  71. package/dist/scripts/agent-planning-turn-deadline-tests.js +185 -0
  72. package/dist/scripts/agent-reach-deep-tests.js +30 -40
  73. package/dist/scripts/agent-reach-error-tests.js +354 -0
  74. package/dist/scripts/agent-reach-tests.js +26 -18
  75. package/dist/scripts/agent-reach-wrapper-tests.js +50 -50
  76. package/dist/scripts/anything-tests.js +116 -60
  77. package/dist/scripts/artifact-client-contract-tests.js +220 -0
  78. package/dist/scripts/artifacts-capability-tests.js +727 -0
  79. package/dist/scripts/async-collect.js +68 -10
  80. package/dist/scripts/benchmark-environment-bridge-e2e.js +741 -0
  81. package/dist/scripts/benchmark-environment-bridge-tests.js +1452 -0
  82. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +126 -0
  83. package/dist/scripts/booking-copilot-availability-policy-tests.js +265 -0
  84. package/dist/scripts/booking-copilot-bin-proof-tests.js +66 -0
  85. package/dist/scripts/booking-copilot-cleanup-proof-helper.js +76 -0
  86. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +144 -0
  87. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +424 -0
  88. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +2839 -0
  89. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +309 -0
  90. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +238 -0
  91. package/dist/scripts/booking-copilot-dsh-warmer-proof.js +123 -0
  92. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +156 -0
  93. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +70 -0
  94. package/dist/scripts/booking-copilot-intent-proof-tests.js +422 -0
  95. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +184 -0
  96. package/dist/scripts/booking-copilot-planner-live-probe.js +95 -0
  97. package/dist/scripts/booking-copilot-proof-runtime.js +42 -0
  98. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +210 -0
  99. package/dist/scripts/booking-copilot-runtime-proof-tests.js +2349 -0
  100. package/dist/scripts/booking-copilot-server-proof-tests.js +784 -0
  101. package/dist/scripts/booking-copilot-startup-proof-tests.js +94 -0
  102. package/dist/scripts/booking-executor-tests.js +80 -0
  103. package/dist/scripts/booking-recovery-chain-tests.js +182 -0
  104. package/dist/scripts/booking-saga-tests.js +125 -0
  105. package/dist/scripts/booking-surface-contract-proof-tests.js +212 -0
  106. package/dist/scripts/bootstrap-tests.js +1479 -0
  107. package/dist/scripts/bridge-events-tests.js +239 -0
  108. package/dist/scripts/bridge-ledger-tests.js +428 -0
  109. package/dist/scripts/build-changelog.js +238 -0
  110. package/dist/scripts/build-metrics-report.js +331 -0
  111. package/dist/scripts/changelog-tests.js +118 -0
  112. package/dist/scripts/channel-probe-tests.js +103 -0
  113. package/dist/scripts/channel-probe.js +169 -0
  114. package/dist/scripts/channel-registry-tests.js +208 -0
  115. package/dist/scripts/companion-tests.js +67 -0
  116. package/dist/scripts/dida-cookie-probe.js +87 -0
  117. package/dist/scripts/dida-runner-stop-tests.js +311 -0
  118. package/dist/scripts/dida-sw-probe.js +114 -0
  119. package/dist/scripts/diff-test.js +17 -12
  120. package/dist/scripts/doctor-tests.js +564 -0
  121. package/dist/scripts/dsh-artifact-e2e.js +279 -0
  122. package/dist/scripts/dsh-artifact-web-e2e.js +675 -0
  123. package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
  124. package/dist/scripts/dsh-runtime-closure-tests.js +182 -0
  125. package/dist/scripts/dsh-runtime-closure.js +162 -0
  126. package/dist/scripts/dsh-session-v3-migration-proof.js +109 -0
  127. package/dist/scripts/dsh-subprocess-local-proof.js +150 -0
  128. package/dist/scripts/dsh-target-closure-proof.js +178 -0
  129. package/dist/scripts/effect-tests.js +539 -0
  130. package/dist/scripts/engine-run.js +1 -3
  131. package/dist/scripts/engine-tests.js +16 -10
  132. package/dist/scripts/evaluation-cadence-tests.js +174 -0
  133. package/dist/scripts/evaluation-contract-tests.js +268 -0
  134. package/dist/scripts/extension-distribution-cli.js +39 -0
  135. package/dist/scripts/extension-distribution-tests.js +443 -0
  136. package/dist/scripts/extension-tests.js +988 -0
  137. package/dist/scripts/external-event-tests.js +350 -0
  138. package/dist/scripts/fact-gate-tests.js +869 -0
  139. package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
  140. package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
  141. package/dist/scripts/flight-malformed-tests.js +159 -0
  142. package/dist/scripts/flyai-setup-tests.js +228 -0
  143. package/dist/scripts/flyai-setup-tool-tests.js +54 -0
  144. package/dist/scripts/flyai-tests.js +692 -0
  145. package/dist/scripts/flyai-tool-registration-contract-tests.js +91 -0
  146. package/dist/scripts/fx-contract-tests.js +201 -0
  147. package/dist/scripts/g5-guard-tests.js +131 -0
  148. package/dist/scripts/g5-guard.js +189 -0
  149. package/dist/scripts/gen-lunar-anchors.js +106 -0
  150. package/dist/scripts/geo-atlas-tests.js +283 -0
  151. package/dist/scripts/gotry-backend-kernel-tests.js +206 -0
  152. package/dist/scripts/gotry-try-demo-tests.js +79 -0
  153. package/dist/scripts/gotry-try-demo.js +49 -0
  154. package/dist/scripts/ground-transfer-tests.js +956 -0
  155. package/dist/scripts/hbcli-e2e-tests.js +159 -0
  156. package/dist/scripts/hbcli-live-optin-tests.js +96 -0
  157. package/dist/scripts/hbcli-release-contract-tests.js +122 -0
  158. package/dist/scripts/hbcli-tests.js +121 -69
  159. package/dist/scripts/health-watch-cli.js +73 -0
  160. package/dist/scripts/hotel-date-gate-tests.js +496 -0
  161. package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
  162. package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
  163. package/dist/scripts/i18n-tests.js +41 -0
  164. package/dist/scripts/incident-tests.js +281 -87
  165. package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
  166. package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
  167. package/dist/scripts/issue-271-liveness-tests.js +569 -0
  168. package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
  169. package/dist/scripts/issue-289-model-retry-tests.js +441 -0
  170. package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
  171. package/dist/scripts/issue-338-home-city-e2e.js +577 -0
  172. package/dist/scripts/issue343-real-entry-e2e.js +160 -0
  173. package/dist/scripts/issue436-persisted-routing-e2e.js +247 -0
  174. package/dist/scripts/itinerary-artifact-tests.js +368 -0
  175. package/dist/scripts/itinerary-deck-artifact-tests.js +416 -0
  176. package/dist/scripts/itinerary-deck-export-tests.js +552 -0
  177. package/dist/scripts/itinerary-deck-tests.js +435 -0
  178. package/dist/scripts/itinerary-html-tests.js +426 -0
  179. package/dist/scripts/journey-tests.js +22 -27
  180. package/dist/scripts/kernel-manifest-evidence.js +109 -0
  181. package/dist/scripts/kernel-manifest-gate.js +126 -0
  182. package/dist/scripts/kernel-manifest-tests.js +212 -0
  183. package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
  184. package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
  185. package/dist/scripts/kernel-manifest.js +369 -0
  186. package/dist/scripts/lavish-local-tests.js +1226 -0
  187. package/dist/scripts/lavish-product-tools-tests.js +537 -0
  188. package/dist/scripts/ledger-repair-apply-tests.js +316 -0
  189. package/dist/scripts/ledger-repair-plan-tests.js +375 -0
  190. package/dist/scripts/ledger-tests.js +480 -0
  191. package/dist/scripts/ledger-workflow-crash.js +32 -0
  192. package/dist/scripts/managed-dsh-cleanup-diagnostic-proof.js +184 -0
  193. package/dist/scripts/managed-dsh-close-failure-proof.js +172 -0
  194. package/dist/scripts/managed-dsh-run-port-proof.js +153 -0
  195. package/dist/scripts/managed-dsh-terminal-proof.js +252 -0
  196. package/dist/scripts/map-tools-vendor-package-proof.js +365 -0
  197. package/dist/scripts/memory-capture-tests.js +242 -61
  198. package/dist/scripts/memory-decay-tests.js +49 -0
  199. package/dist/scripts/memory-lifecycle-tests.js +657 -0
  200. package/dist/scripts/memory-lifecycle.js +196 -0
  201. package/dist/scripts/memory-metrics.js +29 -31
  202. package/dist/scripts/memory-value-report.js +631 -0
  203. package/dist/scripts/memory-value-tests.js +328 -0
  204. package/dist/scripts/metrics-report-tests.js +151 -0
  205. package/dist/scripts/model-override-e2e.js +137 -0
  206. package/dist/scripts/nightly-evidence-tests.js +161 -0
  207. package/dist/scripts/nightly-evidence.js +286 -0
  208. package/dist/scripts/nudge-digest.js +43 -42
  209. package/dist/scripts/observability-tests.js +381 -0
  210. package/dist/scripts/onboarding-tests.js +161 -0
  211. package/dist/scripts/opensky-check.js +18 -13
  212. package/dist/scripts/opensky-tests.js +28 -28
  213. package/dist/scripts/persona-surface-guard-tests.js +49 -0
  214. package/dist/scripts/pnpm-dsh-closure-proof.js +17 -0
  215. package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
  216. package/dist/scripts/price-drift-tests.js +340 -0
  217. package/dist/scripts/price-drift-watch.js +447 -0
  218. package/dist/scripts/probe-poi-tests.js +53 -45
  219. package/dist/scripts/product-metrics.js +483 -0
  220. package/dist/scripts/publish-preverify.js +110 -0
  221. package/dist/scripts/realtime-pricing-tests.js +76 -0
  222. package/dist/scripts/recall-tests.js +513 -0
  223. package/dist/scripts/replay-async.js +20 -18
  224. package/dist/scripts/replay-real.js +18 -18
  225. package/dist/scripts/replay.js +52 -82
  226. package/dist/scripts/session-attach-diagnose.js +31 -0
  227. package/dist/scripts/session-attach-poc.js +62 -0
  228. package/dist/scripts/session-benchmark.js +180 -0
  229. package/dist/scripts/session-dida-live-e2e.js +199 -0
  230. package/dist/scripts/session-extract-tests.js +60 -0
  231. package/dist/scripts/session-link-tests.js +478 -0
  232. package/dist/scripts/session-login.js +29 -0
  233. package/dist/scripts/session-tests.js +862 -0
  234. package/dist/scripts/session-zones-tests.js +358 -0
  235. package/dist/scripts/sf-live-benchmark.js +367 -0
  236. package/dist/scripts/sf-live-challenge-stop-tests.js +453 -0
  237. package/dist/scripts/sf-live-cli-tests.js +92 -0
  238. package/dist/scripts/sf-live-options.js +46 -0
  239. package/dist/scripts/sf-manifest-tests.js +30 -0
  240. package/dist/scripts/sf-manifest.js +33 -0
  241. package/dist/scripts/sf-soft-score-tests.js +67 -0
  242. package/dist/scripts/sf-summary-tests.js +324 -0
  243. package/dist/scripts/sf-summary.js +525 -0
  244. package/dist/scripts/share-tests.js +432 -0
  245. package/dist/scripts/skeleton-check.js +16 -14
  246. package/dist/scripts/skeleton-integration-test.js +28 -11
  247. package/dist/scripts/skills-contract-tests.js +51 -46
  248. package/dist/scripts/smoke-session-gate-tests.js +21 -0
  249. package/dist/scripts/smoke.js +703 -219
  250. package/dist/scripts/spawn-bounded-abort-tests.js +241 -0
  251. package/dist/scripts/sponsor-reuse-tests.js +262 -0
  252. package/dist/scripts/state-cli-tests.js +261 -0
  253. package/dist/scripts/state-cli.js +585 -0
  254. package/dist/scripts/state-repair-cli.js +66 -0
  255. package/dist/scripts/state-repair-tests.js +140 -0
  256. package/dist/scripts/static-golden-tests.js +165 -0
  257. package/dist/scripts/time-eval-tests.js +210 -192
  258. package/dist/scripts/travel-timeline-tests.js +59 -0
  259. package/dist/scripts/turn-handoff-collect-tests.js +169 -0
  260. package/dist/scripts/turn-handoff-collect.js +161 -0
  261. package/dist/scripts/turn-policy-tests.js +55 -0
  262. package/dist/scripts/typed-contract-canary.js +176 -0
  263. package/dist/scripts/tz-resolver-sanity.js +50 -0
  264. package/dist/scripts/tz-resolver-tests.js +584 -0
  265. package/dist/scripts/unified-tests.js +23 -19
  266. package/dist/scripts/visa-policy-tests.js +62 -0
  267. package/dist/scripts/weather-tests.js +276 -41
  268. package/dist/scripts/wish-channel-gate-tests.js +54 -0
  269. package/dist/scripts/write-gate-tests.js +522 -0
  270. package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
  271. package/dist/scripts/z3-lifecycle-tests.js +170 -0
  272. package/dist/scripts/z3-race-repeat-tests.js +39 -0
  273. package/dist/scripts/z3-race-tests.js +41 -0
  274. package/dist/src/artifact-gate.js +829 -0
  275. package/dist/src/backend/kernel.js +91 -0
  276. package/dist/src/backend/modules/booking-copilot.js +92 -0
  277. package/dist/src/backend/modules/booking-executor.js +124 -0
  278. package/dist/src/backend/modules/session-search.js +551 -0
  279. package/dist/src/benchmark-agent-conformance.js +578 -0
  280. package/dist/src/benchmark-environment-bridge.js +515 -0
  281. package/dist/src/benchmark-headless-child-diagnostics.js +166 -0
  282. package/dist/src/benchmark-tool-isolation.js +151 -0
  283. package/dist/src/bookable-facts.js +533 -0
  284. package/dist/src/booking-saga.js +97 -0
  285. package/dist/src/booking-surface/availability-policy.js +697 -0
  286. package/dist/src/booking-surface/booking-intent.js +43 -0
  287. package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
  288. package/dist/src/booking-surface/canonical-schema.js +116 -0
  289. package/dist/src/booking-surface/contracts.js +75 -0
  290. package/dist/src/booking-surface/dsh-planner.js +1854 -0
  291. package/dist/src/booking-surface/dsh-plugin.js +378 -0
  292. package/dist/src/booking-surface/dsh-worker.js +98 -0
  293. package/dist/src/booking-surface/error-codes.js +231 -0
  294. package/dist/src/booking-surface/index.js +9 -0
  295. package/dist/src/booking-surface/managed-dsh-cleanup-diagnostic.js +97 -0
  296. package/dist/src/booking-surface/managed-dsh-run-port.js +244 -0
  297. package/dist/src/booking-surface/profile.js +24 -0
  298. package/dist/src/booking-surface/recovery-chain.js +367 -0
  299. package/dist/src/booking-surface/runtime.js +1968 -0
  300. package/dist/src/booking-surface/server.js +674 -0
  301. package/dist/src/booking-surface/startup.js +168 -0
  302. package/dist/src/booking-surface/validation.js +261 -0
  303. package/dist/src/bridge.js +20 -13
  304. package/dist/src/companions.js +86 -0
  305. package/dist/src/contracts.js +21 -43
  306. package/dist/src/dsh-llm.js +97 -111
  307. package/dist/src/engine.js +276 -193
  308. package/dist/src/evaluation-cadence.js +97 -0
  309. package/dist/src/evaluation-contracts.js +283 -0
  310. package/dist/src/flight-pack-adapter.js +54 -0
  311. package/dist/src/flight-pack-contract.js +2 -0
  312. package/dist/src/flyai-setup-tool.js +70 -0
  313. package/dist/src/gotry-backend.js +125 -0
  314. package/dist/src/hotel-date-gate.js +118 -0
  315. package/dist/src/i18n.js +88 -0
  316. package/dist/src/index.js +2305 -731
  317. package/dist/src/itinerary-deck.js +139 -0
  318. package/dist/src/itinerary-doc-shared.js +661 -0
  319. package/dist/src/itinerary-html.js +131 -0
  320. package/dist/src/journey.js +155 -108
  321. package/dist/src/lavish-tools.js +400 -0
  322. package/dist/src/ledger-repair-apply.js +538 -0
  323. package/dist/src/ledger-repair-plan.js +463 -0
  324. package/dist/src/loop.js +358 -162
  325. package/dist/src/memory-capture.js +180 -32
  326. package/dist/src/memory-decay.js +47 -0
  327. package/dist/src/memory-lifecycle.js +1310 -0
  328. package/dist/src/memory-utility.js +27 -40
  329. package/dist/src/mock-llm.js +36 -61
  330. package/dist/src/model.js +81 -36
  331. package/dist/src/realtime-pricing.js +103 -0
  332. package/dist/src/recall/card.js +74 -0
  333. package/dist/src/recall/evaluator.js +140 -0
  334. package/dist/src/recall/integration.js +17 -0
  335. package/dist/src/recall/tick.js +166 -0
  336. package/dist/src/session-link/plan-it-action.js +48 -0
  337. package/dist/src/session-link/session-link.js +248 -0
  338. package/dist/src/session-zones.js +507 -0
  339. package/dist/src/share/adapters.js +51 -0
  340. package/dist/src/share/share-consent.js +76 -0
  341. package/dist/src/share/share-deck.js +58 -0
  342. package/dist/src/share/share-token.js +146 -0
  343. package/dist/src/slot-spec.js +84 -106
  344. package/dist/src/state-ledger.js +767 -0
  345. package/dist/src/state-repair.js +299 -0
  346. package/dist/src/subagent-job-id-guard.js +58 -0
  347. package/dist/src/time-anchor.js +231 -33
  348. package/dist/src/tool-packet.js +20 -8
  349. package/dist/src/travel-slots.js +58 -35
  350. package/dist/src/travel-timeline.js +59 -0
  351. package/dist/src/turn-deadline.js +287 -0
  352. package/dist/src/turn-policy.js +118 -0
  353. package/dist/src/tz-resolver.js +143 -0
  354. package/dist/src/unified.js +538 -360
  355. package/dist/src/wish-pool.js +42 -12
  356. package/dist/src/write-gate.js +495 -0
  357. package/dist/src/z3-shared.js +284 -0
  358. package/extension/README.md +21 -0
  359. package/extension/README.zh-CN.md +21 -0
  360. package/extension/background.js +463 -0
  361. package/extension/content-bridge.js +270 -0
  362. package/extension/content-main.js +111 -0
  363. package/extension/manifest.json +60 -0
  364. package/package.json +407 -14
  365. package/schemas/booking.intent.schema.json +193 -0
  366. package/schemas/booking.surface.schema.json +2622 -0
  367. package/ts/capabilities/agent-reach-deep.ts +114 -0
  368. package/ts/capabilities/agent-reach.ts +198 -0
  369. package/ts/capabilities/anything.ts +255 -0
  370. package/ts/capabilities/artifacts.ts +428 -0
  371. package/ts/capabilities/fact-log.ts +54 -0
  372. package/ts/capabilities/flyai-config.ts +388 -0
  373. package/ts/capabilities/flyai.ts +1274 -0
  374. package/ts/capabilities/hbcli.ts +253 -71
  375. package/ts/capabilities/incident-log.ts +88 -59
  376. package/ts/capabilities/itinerary-artifact.ts +214 -0
  377. package/ts/capabilities/itinerary-deck-artifact.ts +218 -0
  378. package/ts/capabilities/itinerary-deck-export.ts +378 -0
  379. package/ts/capabilities/opensky.ts +121 -0
  380. package/ts/capabilities/session/action-cache.ts +148 -0
  381. package/ts/capabilities/session/adapters/ctrip-flight.ts +181 -0
  382. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  383. package/ts/capabilities/session/adapters/dida-portal.ts +338 -0
  384. package/ts/capabilities/session/adapters/meituan-local.ts +70 -0
  385. package/ts/capabilities/session/adapters/rail-12306.ts +392 -0
  386. package/ts/capabilities/session/benchmark.ts +276 -0
  387. package/ts/capabilities/session/bridge-ledger.ts +333 -0
  388. package/ts/capabilities/session/extension-bridge.ts +794 -0
  389. package/ts/capabilities/session/extension-channel.ts +144 -0
  390. package/ts/capabilities/session/extension-distribution.ts +266 -0
  391. package/ts/capabilities/session/extract.ts +54 -0
  392. package/ts/capabilities/session/golden-score.ts +139 -0
  393. package/ts/capabilities/session/health-watch.ts +264 -0
  394. package/ts/capabilities/session/read-guard.ts +141 -0
  395. package/ts/capabilities/session/static-flight-golden.ts +209 -0
  396. package/ts/capabilities/session/transport.ts +146 -0
  397. package/ts/capabilities/session/wizard.ts +140 -0
  398. package/ts/capabilities/session-consent.ts +211 -0
  399. package/ts/capabilities/session-login.ts +208 -0
  400. package/ts/capabilities/session-search.ts +893 -0
  401. package/ts/capabilities/weather.ts +330 -0
  402. package/ts/dsh-runtime/vendor/README.md +68 -0
  403. package/ts/dsh-runtime/vendor/dsh-map-tools/LICENSE +21 -0
  404. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +405 -0
  405. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +404 -0
  406. package/ts/dsh-runtime/vendor/dsh-map-tools/client/client.js +1584 -0
  407. package/ts/dsh-runtime/vendor/dsh-map-tools/cordis.patch.yml +5 -0
  408. package/ts/dsh-runtime/vendor/dsh-map-tools/icon.svg +5 -0
  409. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/amap.js +595 -0
  410. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/nominatim.js +63 -0
  411. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/osrm.js +58 -0
  412. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/photon.js +55 -0
  413. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-file.js +87 -0
  414. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-route.js +131 -0
  415. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config.js +16 -0
  416. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/geo.js +232 -0
  417. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/index.js +120 -0
  418. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +40 -0
  419. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/staticmap-route.js +115 -0
  420. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/geocode.js +127 -0
  421. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/poi.js +84 -0
  422. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/routes.js +267 -0
  423. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/amap.d.ts +90 -0
  424. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/nominatim.d.ts +17 -0
  425. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/osrm.d.ts +15 -0
  426. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/photon.d.ts +22 -0
  427. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-file.d.ts +33 -0
  428. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-route.d.ts +23 -0
  429. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config.d.ts +23 -0
  430. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/geo.d.ts +61 -0
  431. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/index.d.ts +18 -0
  432. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +14 -0
  433. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/staticmap-route.d.ts +31 -0
  434. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/geocode.d.ts +11 -0
  435. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/poi.d.ts +9 -0
  436. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/routes.d.ts +75 -0
  437. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/types.d.ts +68 -0
  438. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types.js +16 -0
  439. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +97 -0
  440. package/ts/package.json +296 -3
  441. package/ts/scripts/async-collect.ts +91 -0
  442. package/ts/scripts/map-tools-vendor-package-proof.ts +404 -0
  443. package/ts/scripts/skeleton-check.ts +1 -1
  444. package/ts/scripts/skeleton-integration-test.ts +23 -1
  445. package/ts/scripts/state-cli.ts +568 -0
  446. package/ts/scripts/turn-handoff-collect.ts +178 -0
  447. package/ts/src/artifact-gate.ts +991 -0
  448. package/ts/src/benchmark-agent-conformance.ts +619 -0
  449. package/ts/src/benchmark-environment-bridge.ts +538 -0
  450. package/ts/src/benchmark-headless-child-diagnostics.ts +184 -0
  451. package/ts/src/benchmark-tool-isolation.ts +166 -0
  452. package/ts/src/bookable-facts.ts +837 -0
  453. package/ts/src/booking-saga.ts +133 -0
  454. package/ts/src/booking-surface/availability-policy.ts +522 -0
  455. package/ts/src/booking-surface/booking-intent.ts +86 -0
  456. package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
  457. package/ts/src/booking-surface/canonical-schema.js +116 -0
  458. package/ts/src/booking-surface/contracts.ts +475 -0
  459. package/ts/src/booking-surface/dsh-planner.ts +1949 -0
  460. package/ts/src/booking-surface/dsh-plugin.js +378 -0
  461. package/ts/src/booking-surface/dsh-worker.ts +110 -0
  462. package/ts/src/booking-surface/error-codes.ts +241 -0
  463. package/ts/src/booking-surface/index.ts +9 -0
  464. package/ts/src/booking-surface/managed-dsh-cleanup-diagnostic.ts +151 -0
  465. package/ts/src/booking-surface/managed-dsh-run-port.ts +273 -0
  466. package/ts/src/booking-surface/profile.ts +42 -0
  467. package/ts/src/booking-surface/recovery-chain.ts +409 -0
  468. package/ts/src/booking-surface/runtime.ts +1793 -0
  469. package/ts/src/booking-surface/server.ts +733 -0
  470. package/ts/src/booking-surface/startup.ts +201 -0
  471. package/ts/src/booking-surface/validation.ts +217 -0
  472. package/ts/src/companions.ts +113 -0
  473. package/ts/src/contracts.ts +13 -3
  474. package/ts/src/dsh-llm.ts +44 -11
  475. package/ts/src/flyai-setup-tool.ts +77 -0
  476. package/ts/src/index.ts +2107 -224
  477. package/ts/src/itinerary-deck.ts +189 -0
  478. package/ts/src/itinerary-doc-shared.ts +741 -0
  479. package/ts/src/itinerary-html.ts +171 -0
  480. package/ts/src/loop.ts +278 -40
  481. package/ts/src/memory-capture.ts +283 -0
  482. package/ts/src/memory-decay.ts +55 -0
  483. package/ts/src/memory-utility.ts +70 -0
  484. package/ts/src/mock-llm.ts +4 -8
  485. package/ts/src/model.ts +88 -2
  486. package/ts/src/recall/card.ts +106 -0
  487. package/ts/src/recall/evaluator.ts +170 -0
  488. package/ts/src/recall/integration.ts +35 -0
  489. package/ts/src/recall/tick.ts +213 -0
  490. package/ts/src/session-link/plan-it-action.ts +78 -0
  491. package/ts/src/session-link/session-link.ts +275 -0
  492. package/ts/src/share/adapters.ts +93 -0
  493. package/ts/src/share/share-consent.ts +105 -0
  494. package/ts/src/share/share-deck.ts +85 -0
  495. package/ts/src/share/share-token.ts +166 -0
  496. package/ts/src/slot-spec.ts +172 -0
  497. package/ts/src/state-ledger.ts +912 -0
  498. package/ts/src/subagent-job-id-guard.ts +72 -0
  499. package/ts/src/time-anchor.ts +338 -0
  500. package/ts/src/tool-packet.ts +32 -0
  501. package/ts/src/travel-slots.ts +219 -0
  502. package/ts/src/travel-timeline.ts +83 -0
  503. package/ts/src/turn-deadline.ts +366 -0
  504. package/ts/src/turn-policy.ts +154 -0
  505. package/ts/src/unified.ts +360 -89
  506. package/ts/src/wish-pool.ts +79 -0
@@ -0,0 +1,1793 @@
1
+ /** Durable, task-scoped runtime for the embedded Booking Copilot. */
2
+
3
+ import { createHash, randomUUID } from 'node:crypto'
4
+ import type { StateLedger } from '../state-ledger.ts'
5
+ import {
6
+ BOOKING_READ_ACTION_KINDS,
7
+ BOOKING_COPILOT_MAX_OPERATIONS,
8
+ type ActionReceipt,
9
+ type BookingCopilotTurn,
10
+ type BookingReadAction,
11
+ type BookingReadActionKind,
12
+ type BookingSurfaceEvent,
13
+ type BookingSurface,
14
+ type BookingWorkspaceSnapshot,
15
+ type BookingWorkspaceIngressSnapshot,
16
+ type BookingRequestKey,
17
+ type BookingInternalDecisionKey,
18
+ type BookingIngressPrincipal,
19
+ type CriterionBlocker,
20
+ type IngressTurn,
21
+ type RelaxationApprovalRef,
22
+ type RelaxationApproval,
23
+ type UserTurn,
24
+ } from './contracts.ts'
25
+ import {
26
+ validateApprovalAgainstBlocker,
27
+ validateCriterionBlocker,
28
+ validateBookingReadAction,
29
+ validateBookingIntentProjection,
30
+ validateBookingSurfaceEvent,
31
+ validateBookingSurface,
32
+ isBookingDateTime,
33
+ isSafeBookingRequestKey,
34
+ } from './validation.ts'
35
+ import {
36
+ assertWorkspaceLoadedOfferRefsUnique,
37
+ canIssueOfferCheck,
38
+ createAvailabilityPolicy,
39
+ reduceAvailabilityAction,
40
+ reduceAvailabilityReceipt,
41
+ validateOffersReceiptWorkspace,
42
+ availabilityPolicyResult,
43
+ validateAvailabilityPolicy,
44
+ type AvailabilityExhaustion,
45
+ type AvailabilityPolicyState,
46
+ } from './availability-policy.ts'
47
+ import { normalizeBookingErrorCode, safeBookingErrorMessage } from './error-codes.ts'
48
+ import {
49
+ BOOKING_INTENT_ACTION_ORDINAL,
50
+ BOOKING_INTENT_TARGET_ACTION,
51
+ BOOKING_INTENT_TARGET_ORDINAL,
52
+ type BookingIntentCheckpoint,
53
+ type BookingIntentProjection,
54
+ } from './booking-intent.ts'
55
+
56
+ const LEDGER_SCHEMA = 'booking.copilot.ledger' as const
57
+ const ACTOR = 'system:booking-copilot'
58
+ const STARTED = 'booking.copilot.task.started'
59
+ const TURN = 'booking.copilot.user.turn.observed'
60
+ const ACTION = 'booking.copilot.action.issued'
61
+ const RECEIPT = 'booking.copilot.receipt.observed'
62
+ const EVENT = 'booking.copilot.event.emitted'
63
+ const APPROVAL_GRANTED = 'booking.copilot.approval.granted'
64
+ const APPROVAL_OFFERED = 'booking.copilot.approval.option.offered'
65
+ const APPROVAL_CONSUMED = 'booking.copilot.approval.consumed'
66
+ const DECISION_BATCH = 'booking.copilot.decision.batch'
67
+ const REQUEST_BINDING = 'booking.copilot.request.binding'
68
+
69
+ export type BookingCopilotTaskPhase = 'planning' | 'submitted' | 'working' | 'waiting_receipt' | 'input_required' | 'terminal' | 'error'
70
+
71
+ export interface BookingApprovalState {
72
+ approval?: RelaxationApproval
73
+ blocker: CriterionBlocker
74
+ options: RelaxationApproval[]
75
+ optionsEmitted: boolean
76
+ nonce: string
77
+ expiresAt: string
78
+ sourceTurnId: string
79
+ presentationRequestKey: string
80
+ }
81
+
82
+ export interface BookingActionCheckpoint {
83
+ actionId: string
84
+ kind: BookingReadActionKind
85
+ contextRef: string
86
+ expectedRevision: number
87
+ factRefs: string[]
88
+ input: Record<string, any>
89
+ reasonDigest: string
90
+ inputDigest: string
91
+ actionDigest: string
92
+ eventId: string
93
+ sequence: number
94
+ emittedAt: string
95
+ sourceTurnId: string
96
+ relaxationApprovalRef?: RelaxationApprovalRef
97
+ }
98
+
99
+ export interface BookingCopilotTaskState {
100
+ schemaVersion: 'booking.surface'
101
+ taskId: string
102
+ contextRef: string
103
+ surface: BookingSurface
104
+ revision: number
105
+ allowedActions: BookingReadActionKind[]
106
+ userTurnCount: number
107
+ /** Durable task-level operation ordinal, folded from action ledger rows. */
108
+ operationCount: number
109
+ lastTurnId?: string
110
+ lastTurnRequestDigest?: string
111
+ phase: BookingCopilotTaskPhase
112
+ lastSequence: number
113
+ pendingAction?: BookingActionCheckpoint
114
+ /** Last action whose exact receipt was accepted and folded from the ledger. */
115
+ lastCompletedAction?: BookingActionCheckpoint
116
+ /** Durable PII-safe goal; model processes may be retired between receipts. */
117
+ activeIntent?: BookingIntentCheckpoint
118
+ lastReceipt?: ActionReceipt
119
+ awaitingApproval?: BookingApprovalState
120
+ workspaceDigest?: string
121
+ workspaceSemanticDigest?: string
122
+ workspaceSnapshot?: BookingWorkspaceSnapshot
123
+ availability: AvailabilityPolicyState
124
+ replayUpgradeRequired?: boolean
125
+ legacySuppressedDecisionRequestKeys?: string[]
126
+ legacySuppressedRequestBindingKeys?: string[]
127
+ legacySuppressedReceiptActionIds?: string[]
128
+ legacySuppressedApprovalTargetActionIds?: string[]
129
+ }
130
+
131
+ export type BookingSurfaceEventDraft =
132
+ | Pick<Extract<BookingSurfaceEvent, { kind: 'status' }>, 'kind' | 'status'>
133
+ | Pick<Extract<BookingSurfaceEvent, { kind: 'question' }>, 'kind' | 'question'>
134
+ | Pick<Extract<BookingSurfaceEvent, { kind: 'explanation' }>, 'kind' | 'explanation'>
135
+ | Pick<Extract<BookingSurfaceEvent, { kind: 'terminal' }>, 'kind' | 'terminal'>
136
+ | Pick<Extract<BookingSurfaceEvent, { kind: 'error' }>, 'kind' | 'error'>
137
+
138
+ export type BookingPlannerDecision =
139
+ | { kind: 'operation'; action: BookingReadAction; intent: BookingIntentProjection }
140
+ | BookingSurfaceEventDraft
141
+
142
+ export interface BookingPlannerSession {
143
+ next(input: { turn: BookingCopilotTurn; task: BookingCopilotTaskState }): Promise<readonly BookingPlannerDecision[]>
144
+ /** Releases task-scoped planner resources after a durable terminal/error state. */
145
+ close?(): Promise<void>
146
+ }
147
+
148
+ export type BookingPlannerSessionFactory = (initialTask: BookingCopilotTaskState) => BookingPlannerSession
149
+
150
+ export interface BookingCopilotRuntimeOptions {
151
+ idFactory?: (prefix: string) => string
152
+ contextRefFactory?: () => string
153
+ now?: () => string
154
+ approvalTtlMs?: number
155
+ }
156
+
157
+ export interface BookingDecisionBatchOptions {
158
+ /** Server-owned progress is persisted separately before planner execution. */
159
+ suppressProgressStatuses?: boolean
160
+ }
161
+
162
+ interface Row { seq: number; kind: string; payload: string }
163
+ interface BasePayload { schema: typeof LEDGER_SCHEMA; taskId: string; contextRef: string; [key: string]: unknown }
164
+ interface StartedPayload extends BasePayload { surface: BookingSurface; revision: number; allowedActions: BookingReadActionKind[]; workspaceDigest: string; workspaceSemanticDigest: string; workspace: BookingWorkspaceSnapshot; availability: AvailabilityPolicyState; availabilityDigest: string }
165
+ interface TurnPayload extends BasePayload { requestDigest: string; workspaceDigest: string; workspaceSemanticDigest: string; workspace: BookingWorkspaceSnapshot; turnId: string; preserveIntent?: boolean }
166
+ interface ActionPayload extends BasePayload { action: BookingActionCheckpoint; operationCount?: number; availability: AvailabilityPolicyState; availabilityDigest: string; intent?: BookingIntentCheckpoint }
167
+ interface ReceiptPayload extends BasePayload { receipt: ActionReceipt; receiptDigest: string; operationCount?: number; workspaceDigest: string; workspaceSemanticDigest: string; workspace: BookingWorkspaceSnapshot; availability: AvailabilityPolicyState; availabilityDigest: string; availabilityTerminal?: AvailabilityExhaustion }
168
+ interface EventPayload extends BasePayload { eventId: string; sequence: number; emittedAt: string; eventKind: Exclude<BookingSurfaceEvent['kind'], 'operation'>; status?: 'submitted'|'working'|'waiting_receipt'|'input_required'; contentDigest: string }
169
+ interface ApprovalPayload extends BasePayload { approval: BookingApprovalState; ref?: RelaxationApprovalRef; approvalDigest: string }
170
+ interface DecisionBatchPayload extends BasePayload { requestKey: string; events: BookingSurfaceEvent[] }
171
+ interface RequestBindingPayload extends BasePayload { requestKey: BookingRequestKey; requestDigest: string; turnId: string; workspaceDigest: string; workspaceSemanticDigest: string; surface: BookingSurface; capabilityDigest: string; principalDigest: string; scopeDigest: string; taskHandle?: string }
172
+ export interface BookingIngressRequestBindingInput { requestKey: BookingRequestKey; principal: BookingIngressPrincipal; taskHandle?: string }
173
+ interface ReplayConsumedIdentities { events: Map<string, EventPayload>; actions: Map<string, BookingActionCheckpoint>; pendingBatchKeys: Set<string>; pendingBatchOpen: boolean }
174
+
175
+ function stable(value: unknown): unknown {
176
+ if (Array.isArray(value)) return value.map(stable)
177
+ if (!value || typeof value !== 'object') return value
178
+ const out: Record<string, unknown> = {}
179
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) out[key] = stable((value as Record<string, unknown>)[key])
180
+ return out
181
+ }
182
+
183
+ export function bookingDigest(value: unknown): string {
184
+ return createHash('sha256').update(JSON.stringify(stable(value))).digest('hex')
185
+ }
186
+
187
+ export function bookingWorkspaceDigest(workspace: BookingWorkspaceSnapshot | BookingWorkspaceIngressSnapshot): string {
188
+ const { contextRef: _contextRef, ...contextBoundWorkspace } = workspace as Record<string, unknown>
189
+ return bookingDigest(contextBoundWorkspace)
190
+ }
191
+
192
+ function bookingWorkspaceSemanticDigest(workspace: BookingWorkspaceSnapshot | BookingWorkspaceIngressSnapshot): string {
193
+ const { contextRef: _contextRef, revision: _revision, ...semanticWorkspace } = workspace as Record<string, unknown>
194
+ return bookingDigest(semanticWorkspace)
195
+ }
196
+
197
+ export function bookingTurnDigest(turn: Extract<BookingCopilotTurn, { kind: 'user.turn' | 'user.turn.ingress' }>): string {
198
+ return bookingDigest(turn)
199
+ }
200
+
201
+ function actionAdvancesIntent(
202
+ action: { kind: BookingReadActionKind; input: Record<string, any> },
203
+ intent: BookingIntentProjection,
204
+ ): boolean {
205
+ // Existing-order observation is an independent surface waypoint. Checkout
206
+ // preparation is deliberately not a prefix: the user-authorized Checkout
207
+ // flow creates a new trusted order context before observation can begin.
208
+ if (intent.target === 'order.observed') return action.kind === 'order.observe'
209
+ const actionOrdinal = BOOKING_INTENT_ACTION_ORDINAL[action.kind]
210
+ const targetOrdinal = BOOKING_INTENT_TARGET_ORDINAL[intent.target]
211
+ // Earlier path steps may advance toward a later target; an action after
212
+ // that target is an overshoot and cannot be bound to the same intent.
213
+ if (actionOrdinal === undefined || targetOrdinal === undefined || actionOrdinal > targetOrdinal) return false
214
+ if (intent.offerCriteria && (action.kind === 'offers.query' || action.kind === 'offers.view.patch')) {
215
+ if (bookingDigest(action.input.criteria) !== bookingDigest(intent.offerCriteria)) return false
216
+ }
217
+ if (intent.target === 'offers.compared' && action.kind === 'offers.compare') {
218
+ const targetCount = intent.offerCriteria?.targetCount ?? 3
219
+ if (action.input.requestedCount !== targetCount) return false
220
+ }
221
+ return true
222
+ }
223
+
224
+ function intentCheckpointDigest(value: Omit<BookingIntentCheckpoint, 'intentDigest'>): string {
225
+ return bookingDigest(value)
226
+ }
227
+
228
+ function validateIntentCheckpoint(
229
+ checkpoint: BookingIntentCheckpoint,
230
+ state: Pick<BookingCopilotTaskState, 'taskId' | 'contextRef' | 'allowedActions' | 'lastTurnId' | 'lastTurnRequestDigest' | 'activeIntent'>,
231
+ action: BookingActionCheckpoint,
232
+ ): void {
233
+ const projection = validateBookingIntentProjection(checkpoint?.projection)
234
+ const { intentDigest: _digest, ...base } = checkpoint ?? {} as BookingIntentCheckpoint
235
+ if (!projection.ok
236
+ || checkpoint.intentDigest !== intentCheckpointDigest(base)
237
+ || checkpoint.taskId !== state.taskId
238
+ || checkpoint.contextRef !== state.contextRef
239
+ || !state.allowedActions.includes(BOOKING_INTENT_TARGET_ACTION[checkpoint.projection.target])
240
+ || (state.activeIntent
241
+ ? bookingDigest(state.activeIntent) !== bookingDigest(checkpoint)
242
+ : checkpoint.sourceTurnId !== action.sourceTurnId
243
+ || checkpoint.sourceTurnId !== state.lastTurnId
244
+ || checkpoint.sourceRequestDigest !== state.lastTurnRequestDigest)) {
245
+ throw new Error('intent_checkpoint_invalid')
246
+ }
247
+ if (!actionAdvancesIntent(action, checkpoint.projection)) throw new Error('intent_checkpoint_invalid')
248
+ }
249
+
250
+ function bindIntentCheckpoint(
251
+ state: BookingCopilotTaskState,
252
+ action: BookingReadAction,
253
+ projection: BookingIntentProjection,
254
+ ): BookingIntentCheckpoint {
255
+ if (state.activeIntent) {
256
+ if (projection && bookingDigest(projection) !== bookingDigest(state.activeIntent.projection)) {
257
+ throw new Error('intent_mutation_forbidden')
258
+ }
259
+ return state.activeIntent
260
+ }
261
+ if (!state.lastTurnId || !state.lastTurnRequestDigest) throw new Error('intent_source_turn_missing')
262
+ const nextProjection = projection
263
+ const checked = validateBookingIntentProjection(nextProjection)
264
+ if (!checked.ok) throw new Error('invalid_booking_intent')
265
+ if (!state.allowedActions.includes(BOOKING_INTENT_TARGET_ACTION[nextProjection.target])) throw new Error('intent_target_unsupported')
266
+ const base = {
267
+ taskId: state.taskId,
268
+ contextRef: state.contextRef,
269
+ sourceTurnId: state.lastTurnId,
270
+ sourceRequestDigest: state.lastTurnRequestDigest,
271
+ projection: JSON.parse(JSON.stringify(nextProjection)) as BookingIntentProjection,
272
+ }
273
+ return { ...base, intentDigest: intentCheckpointDigest(base) }
274
+ }
275
+
276
+ function canonicalReceiptDigest(value: ActionReceipt): string { return bookingDigest(value) }
277
+ function replayDigestMatches(recordedDigest: unknown, raw: unknown, normalized: unknown): boolean {
278
+ return typeof recordedDigest === 'string' && (recordedDigest === bookingDigest(raw) || recordedDigest === bookingDigest(normalized))
279
+ }
280
+
281
+ function replayWorkspaceDigestMatches(recordedDigest: unknown, raw: BookingWorkspaceSnapshot, normalized: BookingWorkspaceSnapshot): boolean {
282
+ return typeof recordedDigest === 'string' && (recordedDigest === bookingWorkspaceDigest(raw) || recordedDigest === bookingWorkspaceDigest(normalized))
283
+ }
284
+
285
+ function replayWorkspaceSemanticDigestMatches(recordedDigest: unknown, raw: BookingWorkspaceSnapshot, normalized: BookingWorkspaceSnapshot): boolean {
286
+ return typeof recordedDigest === 'string' && (recordedDigest === bookingWorkspaceSemanticDigest(raw) || recordedDigest === bookingWorkspaceSemanticDigest(normalized))
287
+ }
288
+
289
+ function rememberReplayWorkspaceDigest(digests: Map<string, string>, recordedDigest: unknown, raw: BookingWorkspaceSnapshot, normalized: BookingWorkspaceSnapshot): void {
290
+ const normalizedDigest = bookingWorkspaceDigest(normalized)
291
+ digests.set(bookingWorkspaceDigest(raw), normalizedDigest)
292
+ if (typeof recordedDigest === 'string') digests.set(recordedDigest, normalizedDigest)
293
+ }
294
+
295
+ function legacyOfferVersionRef(contextRef: string, offer: Record<string, unknown>): string {
296
+ return `legacy-offer-version:${bookingDigest({
297
+ contextRef,
298
+ offerRef: offer.offerRef,
299
+ hotelRef: offer.hotelRef,
300
+ evidenceLevel: offer.evidenceLevel,
301
+ factRefs: Array.isArray(offer.factRefs) ? offer.factRefs : [],
302
+ }).slice(0, 40)}`
303
+ }
304
+
305
+ function normalizeWorkspaceForReplay(raw: BookingWorkspaceSnapshot): BookingWorkspaceSnapshot {
306
+ const candidate = raw as unknown as Record<string, unknown>
307
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !Array.isArray(candidate.loadedOffers) || typeof candidate.contextRef !== 'string') throw new Error('booking_malformed_workspace')
308
+ const workspace = structuredClone(raw) as BookingWorkspaceSnapshot & { verifiedOfferRef?: string }
309
+ workspace.loadedOffers = workspace.loadedOffers.map((offer) => offer.offerVersionRef ? offer : { ...offer, offerVersionRef: legacyOfferVersionRef(workspace.contextRef, offer as unknown as Record<string, unknown>) })
310
+ delete workspace.verifiedOfferRef
311
+ return workspace
312
+ }
313
+
314
+ function resolveReplayOfferVersion(offerRef: string, workspace?: BookingWorkspaceSnapshot, action?: BookingActionCheckpoint, actionId?: string, priorAvailability?: AvailabilityPolicyState): string {
315
+ const priorAttempt = actionId ? priorAvailability?.attempts.find((attempt) => attempt.actionId === actionId) : undefined
316
+ if (priorAttempt?.offerVersionRef) return priorAttempt.offerVersionRef
317
+ if (action?.kind === 'offer.check' && action.actionId === actionId && action.input.offerRef === offerRef && typeof action.input.offerVersionRef === 'string') return action.input.offerVersionRef
318
+ const match = workspace?.loadedOffers.find((offer) => offer.offerRef === offerRef)
319
+ if (match?.offerVersionRef) return match.offerVersionRef
320
+ throw new Error('booking_replan_required:offer_version_unbound')
321
+ }
322
+
323
+ function normalizeAvailabilityPolicyForReplay(raw: AvailabilityPolicyState, workspace?: BookingWorkspaceSnapshot, action?: BookingActionCheckpoint, priorAvailability?: AvailabilityPolicyState, workspaceDigestMap = new Map<string, string>(), receipt?: ActionReceipt): AvailabilityPolicyState {
324
+ const normalized = structuredClone(raw) as AvailabilityPolicyState
325
+ if (normalized.hotels && typeof normalized.hotels === 'object') {
326
+ for (const [hotelRef, hotel] of Object.entries(normalized.hotels) as Array<[string, AvailabilityPolicyState['hotels'][string]]>) {
327
+ const rawHotel = (raw.hotels as Record<string, unknown> | undefined)?.[hotelRef] as Record<string, unknown> | undefined
328
+ const priorHotel = priorAvailability?.hotels[hotelRef]
329
+ if (!Array.isArray(hotel.tombstonedOfferRefs)) hotel.tombstonedOfferRefs = priorHotel ? [...priorHotel.tombstonedOfferRefs] : []
330
+ if (!Array.isArray(hotel.tombstonedOfferVersionRefs)) hotel.tombstonedOfferVersionRefs = priorHotel ? [...priorHotel.tombstonedOfferVersionRefs] : []
331
+ if (rawHotel && Array.isArray(rawHotel.tombstonedOfferRefs) && priorHotel) {
332
+ for (const ref of priorHotel.tombstonedOfferRefs) if (!hotel.tombstonedOfferRefs.includes(ref)) throw new Error('booking_replan_required:tombstone_regression')
333
+ }
334
+ if (rawHotel && Array.isArray(rawHotel.tombstonedOfferVersionRefs) && priorHotel) {
335
+ for (const ref of priorHotel.tombstonedOfferVersionRefs) if (!hotel.tombstonedOfferVersionRefs.includes(ref)) throw new Error('booking_replan_required:tombstone_regression')
336
+ }
337
+ const source = hotel.currentGeneration?.source
338
+ if (source?.kind === 'workspace_snapshot') {
339
+ const mappedDigest = workspaceDigestMap.get(source.workspaceDigest)
340
+ if (mappedDigest) source.workspaceDigest = mappedDigest
341
+ }
342
+ }
343
+ }
344
+ if (Array.isArray(normalized.attempts)) {
345
+ normalized.attempts = normalized.attempts.map((attempt) => attempt.offerVersionRef ? attempt : { ...attempt, offerVersionRef: resolveReplayOfferVersion(attempt.offerRef, workspace, action, attempt.actionId, priorAvailability) })
346
+ }
347
+ const availability = receipt?.observation.kind === 'offer.availability' ? receipt.observation : undefined
348
+ if (receipt && action?.kind === 'offer.check' && availability && availability.offerRef === action.input.offerRef && availability.checkedOfferVersionRef === action.input.offerVersionRef) {
349
+ const attempt = normalized.attempts.find((candidate) => candidate.actionId === action.actionId)
350
+ const hotel = attempt ? normalized.hotels[attempt.hotelRef] : undefined
351
+ if (hotel) {
352
+ if (receipt.status === 'changed' && availability.available === true && availability.currentOfferVersionRef && availability.currentOfferVersionRef !== action.input.offerVersionRef) {
353
+ if (!hotel.tombstonedOfferVersionRefs.includes(action.input.offerVersionRef)) hotel.tombstonedOfferVersionRefs.push(action.input.offerVersionRef)
354
+ }
355
+ const completeNegative = (receipt.status === 'unavailable' || receipt.status === 'no_match')
356
+ && receipt.resultContract.outcome === 'empty'
357
+ && receipt.resultContract.gapCodes.length === 0
358
+ && receipt.resultContract.blockers.length === 0
359
+ && availability.available === false
360
+ && !availability.currentOfferVersionRef
361
+ && !availability.verifiedOfferRef
362
+ && !availability.gapCodes?.length
363
+ if (completeNegative) {
364
+ if (!hotel.tombstonedOfferVersionRefs.includes(action.input.offerVersionRef)) hotel.tombstonedOfferVersionRefs.push(action.input.offerVersionRef)
365
+ if (!hotel.tombstonedOfferRefs.includes(action.input.offerRef)) hotel.tombstonedOfferRefs.push(action.input.offerRef)
366
+ }
367
+ }
368
+ }
369
+ return normalized
370
+ }
371
+
372
+ function normalizeActionCheckpointForReplay(raw: BookingActionCheckpoint, workspace?: BookingWorkspaceSnapshot): BookingActionCheckpoint {
373
+ if (!['offer.select', 'offer.check', 'checkout.prepare'].includes(raw.kind) || typeof raw.input.offerVersionRef === 'string') return raw
374
+ if (typeof raw.input.offerRef !== 'string') throw new Error('booking_replan_required:offer_version_unbound')
375
+ const offerVersionRef = resolveReplayOfferVersion(raw.input.offerRef, workspace, undefined, raw.actionId)
376
+ const input: Record<string, any> = { ...raw.input, offerVersionRef }
377
+ if (raw.kind === 'checkout.prepare' && (!workspace?.verifiedOffer || workspace.verifiedOffer.offerRef !== input.offerRef || workspace.verifiedOffer.offerVersionRef !== offerVersionRef || workspace.verifiedOffer.verifiedOfferRef !== input.verifiedOfferRef)) throw new Error('booking_replan_required:legacy_checkout_unverified')
378
+ const { actionDigest: _rawActionDigest, ...rawBase } = raw
379
+ const normalizedBase: Omit<BookingActionCheckpoint, 'actionDigest'> = { ...rawBase, input, inputDigest: bookingDigest(input) }
380
+ return { ...normalizedBase, actionDigest: checkpointDigest(normalizedBase) }
381
+ }
382
+
383
+ function checkpointDigestMatchesForReplay(raw: BookingActionCheckpoint, normalized: BookingActionCheckpoint): boolean {
384
+ const { actionDigest: _rawDigest, ...rawBase } = raw
385
+ const { actionDigest: _normalizedDigest, ...normalizedBase } = normalized
386
+ return raw.actionDigest === checkpointDigest(rawBase) || raw.actionDigest === checkpointDigest(normalizedBase)
387
+ }
388
+
389
+ function replanAfterReplayUpgradeGap(state: BookingCopilotTaskState, workspace = state.workspaceSnapshot, revision = workspace?.revision): BookingCopilotTaskState {
390
+ if (!workspace) return { ...state, phase: 'error', pendingAction: undefined }
391
+ const safeRevision = typeof revision === 'number' && Number.isSafeInteger(revision) ? revision : state.revision
392
+ return {
393
+ ...state,
394
+ revision: safeRevision,
395
+ phase: 'planning',
396
+ pendingAction: undefined,
397
+ lastCompletedAction: undefined,
398
+ lastReceipt: undefined,
399
+ activeIntent: undefined,
400
+ awaitingApproval: undefined,
401
+ workspaceDigest: bookingWorkspaceDigest(workspace),
402
+ workspaceSemanticDigest: bookingWorkspaceSemanticDigest(workspace),
403
+ workspaceSnapshot: workspace,
404
+ availability: createAvailabilityPolicy(workspace),
405
+ replayUpgradeRequired: true,
406
+ }
407
+ }
408
+
409
+ function assertReplayUpgradeReanchored(state: Pick<BookingCopilotTaskState, 'replayUpgradeRequired'>): void {
410
+ if (state.replayUpgradeRequired) throw new Error('reanchor_turn_required')
411
+ }
412
+
413
+ function consumedIdentityKey(eventId: string, sequence: number): string { return `${eventId}\0${sequence}` }
414
+
415
+ function clearPendingBatch(consumed: ReplayConsumedIdentities): void {
416
+ consumed.pendingBatchKeys.clear()
417
+ consumed.pendingBatchOpen = false
418
+ }
419
+
420
+ function rememberPendingBatchKey(consumed: ReplayConsumedIdentities, key: string): void {
421
+ consumed.pendingBatchKeys.add(key)
422
+ }
423
+
424
+ function rememberConsumedEventIdentity(consumed: ReplayConsumedIdentities, payload: EventPayload): void {
425
+ if (payload.eventKind === 'status' && (payload.status === 'submitted' || payload.status === 'working') && !consumed.pendingBatchOpen) {
426
+ clearPendingBatch(consumed)
427
+ consumed.pendingBatchOpen = true
428
+ }
429
+ const key = consumedIdentityKey(payload.eventId, payload.sequence)
430
+ consumed.events.set(key, payload)
431
+ rememberPendingBatchKey(consumed, key)
432
+ }
433
+
434
+ function rememberConsumedActionIdentity(consumed: ReplayConsumedIdentities, action: BookingActionCheckpoint): void {
435
+ const key = consumedIdentityKey(action.eventId, action.sequence)
436
+ consumed.actions.set(key, action)
437
+ rememberPendingBatchKey(consumed, key)
438
+ }
439
+
440
+ function consumeSkippedActionOrdinal(state: BookingCopilotTaskState, payload: ActionPayload, consumed?: ReplayConsumedIdentities): void {
441
+ const action = payload.action
442
+ const operationCount = payload.operationCount ?? state.operationCount + 1
443
+ if (!action || typeof action !== 'object') throw new Error('skip_action_invalid')
444
+ const { actionDigest: _actionDigest, ...actionBase } = action
445
+ if (action.actionDigest !== checkpointDigest(actionBase) || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount + 1 || operationCount > BOOKING_COPILOT_MAX_OPERATIONS || !Number.isSafeInteger(action.sequence) || action.sequence <= state.lastSequence) throw new Error('skip_action_invalid')
446
+ state.operationCount = operationCount
447
+ state.lastSequence = action.sequence
448
+ if (consumed) {
449
+ rememberConsumedActionIdentity(consumed, action)
450
+ }
451
+ }
452
+
453
+ function eventDraftDigest(event: Exclude<BookingSurfaceEvent, { kind: 'operation' }>): string {
454
+ if (event.kind === 'status') return bookingDigest({ kind: event.kind, status: event.status })
455
+ if (event.kind === 'question') return bookingDigest({ kind: event.kind, question: event.question })
456
+ if (event.kind === 'explanation') return bookingDigest({ kind: event.kind, explanation: event.explanation })
457
+ if (event.kind === 'terminal') return bookingDigest({ kind: event.kind, terminal: event.terminal })
458
+ return bookingDigest({ kind: event.kind, error: event.error })
459
+ }
460
+
461
+ function eventPayloadMatchesEvent(payload: EventPayload, event: Exclude<BookingSurfaceEvent, { kind: 'operation' }>): boolean {
462
+ const expectedDigest = eventDraftDigest(event)
463
+ const legacyTerminalDigest = event.kind === 'terminal' ? bookingDigest(event.terminal) : undefined
464
+ return payload.eventId === event.eventId
465
+ && payload.sequence === event.sequence
466
+ && payload.emittedAt === event.emittedAt
467
+ && payload.contextRef === event.contextRef
468
+ && payload.eventKind === event.kind
469
+ && (event.kind !== 'status' || payload.status === event.status)
470
+ && (payload.contentDigest === expectedDigest || payload.contentDigest === legacyTerminalDigest)
471
+ }
472
+
473
+ function actionFromOperationEventForReplay(event: Extract<BookingSurfaceEvent, { kind: 'operation' }>, checkpoint: BookingActionCheckpoint): BookingReadAction {
474
+ const action = event.action
475
+ if ((action.kind === 'offer.select' || action.kind === 'offer.check' || action.kind === 'checkout.prepare') && typeof action.input.offerRef === 'string' && typeof action.input.offerVersionRef !== 'string' && typeof checkpoint.input.offerVersionRef === 'string') {
476
+ return { ...action, input: { ...action.input, offerVersionRef: checkpoint.input.offerVersionRef } } as BookingReadAction
477
+ }
478
+ if ((action.kind === 'offer.select' || action.kind === 'offer.check' || action.kind === 'checkout.prepare') && typeof action.input.offerRef === 'string' && typeof action.input.offerVersionRef === 'string' && typeof checkpoint.input.offerVersionRef !== 'string') {
479
+ // A pre-versioned ACTION row is the authority during a skipped legacy
480
+ // tail. Its duplicate decision envelope may still contain a newer
481
+ // offerVersionRef field. Ignore that unbound field for envelope identity;
482
+ // the action itself is never resumed and a fresh intent/turn is required.
483
+ const { offerVersionRef: _unboundVersion, ...legacyInput } = action.input
484
+ return { ...action, input: legacyInput } as BookingReadAction
485
+ }
486
+ return action
487
+ }
488
+
489
+ function operationEventMatchesAction(event: Extract<BookingSurfaceEvent, { kind: 'operation' }>, checkpoint: BookingActionCheckpoint): boolean {
490
+ return event.eventId === checkpoint.eventId
491
+ && event.sequence === checkpoint.sequence
492
+ && event.emittedAt === checkpoint.emittedAt
493
+ && event.contextRef === checkpoint.contextRef
494
+ && matchesCheckpoint(actionFromOperationEventForReplay(event, checkpoint), checkpoint)
495
+ }
496
+
497
+ function normalizeDecisionBatchEnvelopeForReplay(payload: DecisionBatchPayload, consumed: ReplayConsumedIdentities, requireSchemaValid = false): BookingSurfaceEvent[] {
498
+ if (typeof payload.requestKey !== 'string' || !payload.requestKey || !payload.events.length) throw new Error('skip_decision_batch_invalid')
499
+ let lastBatchSequence = -1
500
+ const seen = new Set<string>()
501
+ const normalizedEvents: BookingSurfaceEvent[] = []
502
+ for (const event of payload.events) {
503
+ if (!Number.isSafeInteger(event.sequence) || event.sequence <= lastBatchSequence) throw new Error('skip_decision_batch_invalid')
504
+ lastBatchSequence = event.sequence
505
+ const key = consumedIdentityKey(event.eventId, event.sequence)
506
+ if (seen.has(key)) throw new Error('skip_decision_batch_invalid')
507
+ seen.add(key)
508
+ if (event.kind === 'operation') {
509
+ const checkpoint = consumed.actions.get(key)
510
+ if (!checkpoint || !operationEventMatchesAction(event, checkpoint)) throw new Error('skip_decision_batch_invalid')
511
+ const normalized = { ...event, action: actionFromOperationEventForReplay(event, checkpoint) } as BookingSurfaceEvent
512
+ if (requireSchemaValid && !validateBookingSurfaceEvent(normalized).ok) throw new Error('skip_decision_batch_invalid')
513
+ normalizedEvents.push(normalized)
514
+ } else {
515
+ const recorded = consumed.events.get(key)
516
+ if (!recorded || !validateBookingSurfaceEvent(event).ok || !eventPayloadMatchesEvent(recorded, event)) throw new Error('skip_decision_batch_invalid')
517
+ normalizedEvents.push(event)
518
+ }
519
+ if (!consumed.pendingBatchKeys.has(key)) throw new Error('skip_decision_batch_invalid')
520
+ }
521
+ if (seen.size !== consumed.pendingBatchKeys.size) throw new Error('skip_decision_batch_invalid')
522
+ for (const key of consumed.pendingBatchKeys) if (!seen.has(key)) throw new Error('skip_decision_batch_invalid')
523
+ clearPendingBatch(consumed)
524
+ return normalizedEvents
525
+ }
526
+
527
+ function consumeSkippedEventSequence(state: BookingCopilotTaskState, payload: EventPayload | DecisionBatchPayload, consumed: ReplayConsumedIdentities): void {
528
+ if (!payload || typeof payload !== 'object') throw new Error('skip_event_invalid')
529
+ if ('sequence' in payload) {
530
+ const eventPayload = payload as EventPayload
531
+ if (typeof eventPayload.eventId !== 'string' || !eventPayload.eventId || !['status', 'question', 'explanation', 'terminal', 'error'].includes(String(eventPayload.eventKind)) || typeof eventPayload.contentDigest !== 'string' || !/^[0-9a-f]{64}$/.test(eventPayload.contentDigest) || typeof eventPayload.sequence !== 'number' || !Number.isSafeInteger(eventPayload.sequence) || eventPayload.sequence <= state.lastSequence) throw new Error('skip_event_invalid')
532
+ state.lastSequence = eventPayload.sequence
533
+ rememberConsumedEventIdentity(consumed, eventPayload)
534
+ } else if ('events' in payload && Array.isArray(payload.events)) {
535
+ normalizeDecisionBatchEnvelopeForReplay(payload, consumed)
536
+ }
537
+ }
538
+
539
+ function normalizeReceiptForReplay(raw: ActionReceipt, action: BookingActionCheckpoint, workspace: BookingWorkspaceSnapshot): ActionReceipt {
540
+ const receipt = structuredClone(raw) as ActionReceipt
541
+ if (action.kind === 'offer.select' && receipt.observation.kind === 'offer.selection' && receipt.observation.offerRef === action.input.offerRef && !receipt.observation.offerVersionRef) {
542
+ receipt.observation.offerVersionRef = action.input.offerVersionRef
543
+ }
544
+ if (action.kind === 'checkout.prepare' && receipt.observation.kind === 'checkout.handoff' && receipt.observation.offerRef === action.input.offerRef && !receipt.observation.offerVersionRef) {
545
+ if (!workspace.verifiedOffer || workspace.verifiedOffer.offerRef !== action.input.offerRef || workspace.verifiedOffer.offerVersionRef !== action.input.offerVersionRef || workspace.verifiedOffer.verifiedOfferRef !== action.input.verifiedOfferRef) throw new Error('booking_replan_required:legacy_checkout_unverified')
546
+ receipt.observation.offerVersionRef = action.input.offerVersionRef
547
+ }
548
+ if (action.kind === 'offer.check' && receipt.observation.kind === 'offer.availability' && receipt.observation.offerRef === action.input.offerRef) {
549
+ if (!receipt.observation.checkedOfferVersionRef) receipt.observation.checkedOfferVersionRef = action.input.offerVersionRef
550
+ if (receipt.status === 'applied' && receipt.observation.available === true && !receipt.observation.currentOfferVersionRef) receipt.observation.currentOfferVersionRef = action.input.offerVersionRef
551
+ if (receipt.status === 'changed' && receipt.observation.available === true && !receipt.observation.currentOfferVersionRef) {
552
+ const current = workspace.loadedOffers.find((offer) => offer.offerRef === action.input.offerRef && offer.offerVersionRef !== action.input.offerVersionRef)
553
+ if (!current) throw new Error('booking_replan_required:changed_version_unbound')
554
+ receipt.observation.currentOfferVersionRef = current.offerVersionRef
555
+ }
556
+ if (receipt.status === 'applied' && receipt.observation.verifiedOfferRef && (!workspace.verifiedOffer || workspace.verifiedOffer.offerRef !== action.input.offerRef || workspace.verifiedOffer.offerVersionRef !== action.input.offerVersionRef || workspace.verifiedOffer.verifiedOfferRef !== receipt.observation.verifiedOfferRef)) throw new Error('booking_replan_required:legacy_verified_scalar')
557
+ }
558
+ if (receipt.resultContract.blockers.length && bookingDigest(raw) !== bookingDigest(receipt)) throw new Error('booking_replan_required:legacy_blocker_digest')
559
+ return receipt
560
+ }
561
+
562
+ function errorText(result: { ok: true } | { ok: false; errors: string[] }): string { return result.ok ? '' : result.errors.join('; ') }
563
+ function assertTaskId(taskId: string): void { if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(taskId)) throw new Error('invalid_task_id') }
564
+ function assertSafeRef(value: string): void { if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value)) throw new Error('unsafe_opaque_ref') }
565
+ function safeIdentity(value: unknown): value is string { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value) }
566
+ function sameActions(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && [...a].sort().every((x, i) => x === [...b].sort()[i]) }
567
+ function availabilityPolicyTerminal(state: AvailabilityPolicyState): AvailabilityExhaustion | undefined { return availabilityPolicyResult(state) }
568
+ function availabilityTerminalEndsTask(terminal: AvailabilityExhaustion | undefined): boolean {
569
+ return Boolean(terminal && terminal.code !== 'availability_confirmed')
570
+ }
571
+ function taskHasTerminalBudget(state: Pick<BookingCopilotTaskState, 'operationCount' | 'availability'>): boolean {
572
+ return state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS || availabilityTerminalEndsTask(state.availability.terminal)
573
+ }
574
+ function assertRequestKey(requestKey: string): asserts requestKey is BookingRequestKey {
575
+ if (!isSafeBookingRequestKey(requestKey)) throw new Error('unsafe_request_key')
576
+ }
577
+ /** Decision batches are runtime-owned and may contain long task/turn/action refs. */
578
+ function assertInternalDecisionKey(requestKey: string): asserts requestKey is BookingInternalDecisionKey {
579
+ if (typeof requestKey !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,2047}$/.test(requestKey)) throw new Error('unsafe_internal_decision_key')
580
+ }
581
+
582
+ function progressDecisionKey(requestKey: string): BookingInternalDecisionKey {
583
+ return `progress:${bookingDigest(requestKey)}`
584
+ }
585
+
586
+ function assertDecisionBatchFinality(decisions: readonly BookingPlannerDecision[]): void {
587
+ let finalSeen = false
588
+ for (const decision of decisions) {
589
+ const isFinal = decision.kind === 'terminal' || decision.kind === 'error'
590
+ if (finalSeen) throw new Error('decision_batch_finality')
591
+ if (isFinal) finalSeen = true
592
+ }
593
+ }
594
+ function assertPrincipal(principal: BookingIngressPrincipal): void {
595
+ if (!principal || typeof principal.subject !== 'string' || !principal.subject || principal.subject.length > 256 || typeof principal.scope !== 'string' || !principal.scope || principal.scope.length > 256) throw new Error('trusted_principal_required')
596
+ }
597
+
598
+ function actionAllowedAfterAvailabilityConfirmed(state: BookingCopilotTaskState, action: BookingReadAction, now?: string): boolean {
599
+ const confirmed = state.lastReceipt
600
+ return state.availability.terminal?.code === 'availability_confirmed'
601
+ && confirmed?.status === 'applied'
602
+ && confirmed.observation.kind === 'offer.availability'
603
+ && confirmed.observation.available === true
604
+ && confirmed.observation.currentOfferVersionRef === confirmed.observation.checkedOfferVersionRef
605
+ && action.kind === 'checkout.prepare'
606
+ && action.input.offerRef === confirmed.observation.offerRef
607
+ && action.input.offerVersionRef === confirmed.observation.checkedOfferVersionRef
608
+ && action.input.verifiedOfferRef === confirmed.observation.verifiedOfferRef
609
+ && Boolean(state.workspaceSnapshot && actionHitsCurrentOfferVersion(state.workspaceSnapshot, action, now))
610
+ }
611
+
612
+ function assertAvailabilityActionCompatible(state: BookingCopilotTaskState, action: BookingReadAction, now?: string): void {
613
+ if (!state.availability.recoveryStarted) return
614
+ if (!state.workspaceSnapshot) throw new Error('availability_operation_required')
615
+ if (state.availability.terminal?.code === 'availability_confirmed') {
616
+ if (!actionAllowedAfterAvailabilityConfirmed(state, action, now)) throw new Error('availability_operation_incompatible')
617
+ return
618
+ }
619
+ if (state.availability.terminal) throw new Error('availability_operation_incompatible')
620
+ const activeHotelRef = state.availability.hotelRefs[state.availability.activeHotelOrdinal]
621
+ if (!activeHotelRef) throw new Error('availability_active_hotel_missing')
622
+ if (state.availability.availabilityPhase === 'need_offers') {
623
+ if (action.kind !== 'offers.query' || action.input.hotelRefs.length !== 1 || action.input.hotelRefs[0] !== activeHotelRef) throw new Error('availability_operation_incompatible')
624
+ return
625
+ }
626
+ if (state.availability.availabilityPhase === 'need_check') {
627
+ if (action.kind !== 'offer.check' || !state.availability.hotels[activeHotelRef]?.currentOfferRefs.includes(action.input.offerRef) || !canIssueOfferCheck(state.availability, state.workspaceSnapshot, action.input.offerRef, action.input.offerVersionRef).ok) throw new Error('availability_operation_incompatible')
628
+ return
629
+ }
630
+ throw new Error('availability_operation_incompatible')
631
+ }
632
+
633
+ function checkoutReceiptPreparedHandoff(receipt: ActionReceipt): boolean {
634
+ return receipt.status === 'applied'
635
+ && receipt.observation.kind === 'checkout.handoff'
636
+ && receipt.resultContract.outcome === 'complete'
637
+ && receipt.resultContract.hardCriteriaMet
638
+ && receipt.resultContract.gapCodes.length === 0
639
+ && receipt.resultContract.blockers.length === 0
640
+ }
641
+
642
+ function assertAvailabilityLiveness(state: BookingCopilotTaskState, decisions: readonly BookingPlannerDecision[], now?: string): void {
643
+ if (!state.availability.recoveryStarted) return
644
+ const operations = decisions.filter((decision): decision is Extract<BookingPlannerDecision, { kind: 'operation' }> => decision.kind === 'operation')
645
+ if (state.availability.terminal?.code === 'availability_confirmed') {
646
+ if (!state.workspaceSnapshot) throw new Error('availability_operation_required')
647
+ if (operations.length === 0) {
648
+ const last = decisions.at(-1)
649
+ if (!last || !['terminal', 'error'].includes(last.kind) || decisions.some((decision) => decision.kind !== 'explanation' && decision.kind !== 'terminal' && decision.kind !== 'error')) throw new Error('availability_operation_required')
650
+ return
651
+ }
652
+ if (operations.length !== 1 || decisions.length !== 1) throw new Error('availability_operation_required')
653
+ assertAvailabilityActionCompatible(state, operations[0].action, now)
654
+ return
655
+ }
656
+ if (operations.length !== 1) throw new Error('availability_operation_required')
657
+ assertAvailabilityActionCompatible(state, operations[0].action, now)
658
+ }
659
+
660
+ function checkpointDigest(action: Omit<BookingActionCheckpoint, 'actionDigest'>): string {
661
+ return bookingDigest(action)
662
+ }
663
+
664
+ function checkpointFor(action: BookingReadAction, eventId: string, sequence: number, emittedAt: string, sourceTurnId: string): BookingActionCheckpoint {
665
+ const base: Omit<BookingActionCheckpoint, 'actionDigest'> = {
666
+ actionId: action.actionId,
667
+ kind: action.kind,
668
+ contextRef: action.contextRef,
669
+ expectedRevision: action.expectedRevision,
670
+ factRefs: [...action.factRefs],
671
+ reasonDigest: bookingDigest(action.reason),
672
+ inputDigest: bookingDigest(action.input),
673
+ input: action.input,
674
+ eventId,
675
+ sequence,
676
+ emittedAt,
677
+ sourceTurnId,
678
+ ...(action.relaxationApprovalRef ? { relaxationApprovalRef: action.relaxationApprovalRef } : {}),
679
+ }
680
+ return { ...base, actionDigest: checkpointDigest(base) }
681
+ }
682
+
683
+ function matchesCheckpoint(action: BookingReadAction, checkpoint: BookingActionCheckpoint): boolean {
684
+ const candidate = checkpointFor(
685
+ action.relaxationApprovalRef || !checkpoint.relaxationApprovalRef ? action : { ...action, relaxationApprovalRef: checkpoint.relaxationApprovalRef },
686
+ checkpoint.eventId,
687
+ checkpoint.sequence,
688
+ checkpoint.emittedAt,
689
+ checkpoint.sourceTurnId,
690
+ )
691
+ const { actionDigest: _ignored, ...candidateBase } = candidate
692
+ return checkpoint.actionDigest === checkpointDigest(candidateBase)
693
+ }
694
+
695
+ function receiptSourceDigest(receipt: ActionReceipt): string {
696
+ return bookingDigest({
697
+ ...receipt,
698
+ resultContract: {
699
+ ...receipt.resultContract,
700
+ blockers: receipt.resultContract.blockers.map(({ sourceReceiptDigest: _ignored, ...blocker }) => blocker),
701
+ },
702
+ })
703
+ }
704
+
705
+ function assertCanonicalReceipt(receipt: ActionReceipt): void {
706
+ const expected = receiptSourceDigest(receipt)
707
+ if (receipt.resultContract.blockers.some((blocker) => blocker.sourceReceiptDigest !== expected)) throw new Error('receipt_source_digest_mismatch')
708
+ }
709
+
710
+ function receiptObservationMatchesAction(observation: ActionReceipt['observation']['kind'], action: BookingReadActionKind): boolean {
711
+ if (observation === 'gap') return true
712
+ const expected: Partial<Record<BookingReadActionKind, ActionReceipt['observation']['kind']>> = {
713
+ 'search.patch': 'search.state', 'search.run': 'search.state', 'results.view.patch': 'results.state',
714
+ 'hotel.focus': 'hotel.focus', 'hotel.select': 'hotel.selection', 'offers.query': 'offers.state',
715
+ 'offers.view.patch': 'offers.state', 'offers.compare': 'offers.state', 'offer.select': 'offer.selection',
716
+ 'offer.check': 'offer.availability', 'checkout.prepare': 'checkout.handoff', 'order.observe': 'order.state',
717
+ }
718
+ return expected[action] === observation
719
+ }
720
+
721
+ function sameRefSet(a: readonly string[], b: readonly string[]): boolean {
722
+ return a.length === b.length && [...a].sort().every((value, index) => value === [...b].sort()[index])
723
+ }
724
+
725
+ function receiptTargetMatchesAction(receipt: ActionReceipt, action: { kind: BookingReadActionKind; input: Record<string, any> }): boolean {
726
+ const observation = receipt.observation
727
+ const input = action.input
728
+ if (action.kind === 'hotel.focus' && observation.kind === 'hotel.focus') return observation.hotelRef === input.hotelRef
729
+ if (action.kind === 'hotel.select' && observation.kind === 'hotel.selection') return observation.hotelRef === input.hotelRef
730
+ if (action.kind === 'offer.select' && observation.kind === 'offer.selection') return observation.offerRef === input.offerRef && observation.offerVersionRef === input.offerVersionRef
731
+ if (action.kind === 'offer.check' && observation.kind === 'offer.availability') return observation.offerRef === input.offerRef && observation.checkedOfferVersionRef === input.offerVersionRef
732
+ if (action.kind === 'checkout.prepare' && observation.kind === 'checkout.handoff') return observation.offerRef === input.offerRef && observation.offerVersionRef === input.offerVersionRef && observation.verifiedOfferRef === input.verifiedOfferRef
733
+ if (action.kind === 'offers.query' && observation.kind === 'offers.state') return sameRefSet(observation.hotelRefs, input.hotelRefs)
734
+ if (action.kind === 'offers.view.patch' && observation.kind === 'offers.state') return observation.hotelRefs.includes(input.hotelRef)
735
+ if (action.kind === 'offers.compare' && observation.kind === 'offers.state') {
736
+ return observation.offerRefs.every((offerRef) => input.offerRefs.includes(offerRef))
737
+ && receipt.resultContract.requestedCount === input.requestedCount
738
+ && receipt.resultContract.actualCount === observation.offerRefs.length
739
+ }
740
+ if (action.kind === 'order.observe' && observation.kind === 'order.state') return observation.orderRef === input.orderRef
741
+ return observation.kind === 'gap' || action.kind === 'search.patch' || action.kind === 'search.run' || action.kind === 'results.view.patch'
742
+ }
743
+
744
+ /**
745
+ * An offers.state receipt is only executable evidence when it is reconciled
746
+ * against the page's post-action loaded offer registry. The planner may
747
+ * propose the receipt shape, but it cannot create offer refs or counts that
748
+ * are absent from that registry.
749
+ */
750
+ function assertOffersReceiptWorkspaceBinding(
751
+ receipt: ActionReceipt,
752
+ workspace: BookingWorkspaceSnapshot,
753
+ action: { kind: BookingReadActionKind; input: Record<string, any> },
754
+ ): void {
755
+ if (receipt.observation.kind !== 'offers.state') return
756
+ if (action.kind !== 'offers.query' && action.kind !== 'offers.view.patch') return
757
+ const hotelRefs = action.kind === 'offers.query' ? action.input.hotelRefs : [action.input.hotelRef]
758
+ validateOffersReceiptWorkspace(receipt, workspace, hotelRefs)
759
+ if (receipt.resultContract.actualCount !== undefined
760
+ && receipt.resultContract.actualCount !== receipt.observation.offerRefs.length) {
761
+ throw new Error('availability_offers_count_mismatch')
762
+ }
763
+ }
764
+
765
+ function workspaceBoundaryMatches(a: BookingWorkspaceSnapshot, b: BookingWorkspaceSnapshot): boolean {
766
+ return a.locale === b.locale && a.currency === b.currency && a.surface === b.surface && a.contextRef === b.contextRef && a.capabilities.surface === b.capabilities.surface && sameActions(a.capabilities.allowedActions, b.capabilities.allowedActions)
767
+ }
768
+
769
+ function workspaceHasOfferVersion(workspace: BookingWorkspaceSnapshot, offerRef: string, offerVersionRef: string): boolean {
770
+ return workspace.loadedOffers.some((offer) => offer.offerRef === offerRef && offer.offerVersionRef === offerVersionRef)
771
+ }
772
+
773
+ function verifiedOfferMatches(workspace: BookingWorkspaceSnapshot, input: { offerRef: string; offerVersionRef: string; verifiedOfferRef: string }): boolean {
774
+ return workspace.verifiedOffer?.offerRef === input.offerRef && workspace.verifiedOffer.offerVersionRef === input.offerVersionRef && workspace.verifiedOffer.verifiedOfferRef === input.verifiedOfferRef
775
+ }
776
+
777
+ function verifiedOfferUnexpired(workspace: BookingWorkspaceSnapshot, now: string): boolean {
778
+ return Boolean(workspace.verifiedOffer && isBookingDateTime(workspace.verifiedOffer.expiresAt) && Date.parse(workspace.verifiedOffer.expiresAt) > Date.parse(now))
779
+ }
780
+
781
+ function actionHitsCurrentOfferVersion(workspace: BookingWorkspaceSnapshot, action: BookingReadAction, now?: string): boolean {
782
+ if (action.kind === 'offer.select' || action.kind === 'offer.check') return workspaceHasOfferVersion(workspace, action.input.offerRef, action.input.offerVersionRef)
783
+ if (action.kind === 'checkout.prepare') return workspaceHasOfferVersion(workspace, action.input.offerRef, action.input.offerVersionRef) && verifiedOfferMatches(workspace, action.input) && (now === undefined || verifiedOfferUnexpired(workspace, now))
784
+ return true
785
+ }
786
+
787
+ function shortlistAfterCheckedOffer(previous: readonly string[], current: readonly string[], checkedOfferRef: string): boolean {
788
+ if (sameActions(previous, current)) return true
789
+ const removedChecked = previous.filter((ref) => ref !== checkedOfferRef)
790
+ return removedChecked.length === previous.length - 1 && sameActions(removedChecked, current)
791
+ }
792
+
793
+ function loadedOffersUnchanged(previous: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot): boolean {
794
+ return sameActions(previous.loadedOffers.map((offer) => bookingDigest(offer)), current.loadedOffers.map((offer) => bookingDigest(offer)))
795
+ }
796
+
797
+ function changedFactRefsMatch(previousFactRefs: readonly string[], currentFactRefs: readonly string[], changedFactRefs: readonly string[]): boolean {
798
+ if (!changedFactRefs.length) return false
799
+ const before = new Set(previousFactRefs)
800
+ const after = new Set(currentFactRefs)
801
+ const diff = [
802
+ ...previousFactRefs.filter((ref) => !after.has(ref)),
803
+ ...currentFactRefs.filter((ref) => !before.has(ref)),
804
+ ]
805
+ return sameActions(diff, changedFactRefs)
806
+ }
807
+
808
+ function loadedOffersChangedVersionOnly(previous: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot, offerRef: string, currentOfferVersionRef: string, changedFactRefs: readonly string[]): boolean {
809
+ if (previous.loadedOffers.length !== current.loadedOffers.length) return false
810
+ const previousChecked = previous.loadedOffers.find((offer) => offer.offerRef === offerRef)
811
+ const currentChecked = current.loadedOffers.find((offer) => offer.offerRef === offerRef)
812
+ if (!previousChecked || !currentChecked || previousChecked.hotelRef !== currentChecked.hotelRef || currentChecked.offerVersionRef !== currentOfferVersionRef || previousChecked.offerVersionRef === currentChecked.offerVersionRef) return false
813
+ if (previousChecked.evidenceLevel !== currentChecked.evidenceLevel) return false
814
+ if (!changedFactRefsMatch(previousChecked.factRefs, currentChecked.factRefs, changedFactRefs)) return false
815
+ const previousOther = previous.loadedOffers.filter((offer) => offer.offerRef !== offerRef).map((offer) => bookingDigest(offer))
816
+ const currentOther = current.loadedOffers.filter((offer) => offer.offerRef !== offerRef).map((offer) => bookingDigest(offer))
817
+ return sameActions(previousOther, currentOther)
818
+ }
819
+
820
+ function availabilityReceiptIsComplete(receipt: ActionReceipt): boolean {
821
+ return receipt.resultContract.outcome === 'complete'
822
+ && receipt.resultContract.hardCriteriaMet
823
+ && receipt.resultContract.gapCodes.length === 0
824
+ && receipt.resultContract.blockers.length === 0
825
+ }
826
+
827
+ function offerCheckPostActionMatches(previous: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot, action: { kind: 'offer.check'; input: { offerRef: string; offerVersionRef: string } }, receipt: ActionReceipt): boolean {
828
+ const checkedOfferRef = action.input.offerRef
829
+ const checkedOfferVersionRef = action.input.offerVersionRef
830
+ const observation = receipt.observation.kind === 'offer.availability' ? receipt.observation : undefined
831
+ const negative = receipt.status === 'unavailable' || receipt.status === 'no_match'
832
+ if (['applied', 'changed', 'unavailable', 'no_match'].includes(receipt.status) && !observation) return false
833
+ if (receipt.status === 'applied' && observation?.available && observation.verifiedOfferRef) {
834
+ if (!availabilityReceiptIsComplete(receipt) || observation.gapCodes?.length) return false
835
+ if (!loadedOffersUnchanged(previous, current)) return false
836
+ if (observation.checkedOfferVersionRef !== checkedOfferVersionRef || observation.currentOfferVersionRef !== checkedOfferVersionRef) return false
837
+ if (current.selectedOfferRef !== checkedOfferRef) return false
838
+ if (!sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs)) return false
839
+ if (!current.verifiedOffer || current.verifiedOffer.offerRef !== checkedOfferRef || current.verifiedOffer.offerVersionRef !== checkedOfferVersionRef || current.verifiedOffer.verifiedOfferRef !== observation.verifiedOfferRef || Number.isNaN(Date.parse(current.verifiedOffer.expiresAt))) return false
840
+ return true
841
+ }
842
+ if (receipt.status === 'changed' && observation?.available && observation.currentOfferVersionRef) {
843
+ if (observation.checkedOfferVersionRef !== checkedOfferVersionRef || observation.currentOfferVersionRef === checkedOfferVersionRef || observation.verifiedOfferRef || !observation.changedFactRefs.length) return false
844
+ if (!loadedOffersChangedVersionOnly(previous, current, checkedOfferRef, observation.currentOfferVersionRef, observation.changedFactRefs)) return false
845
+ if (current.selectedOfferRef !== checkedOfferRef || current.verifiedOffer !== undefined) return false
846
+ return sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs)
847
+ }
848
+ if (!loadedOffersUnchanged(previous, current)) return false
849
+ if (negative) {
850
+ if (current.selectedOfferRef !== undefined || current.verifiedOffer !== undefined) return false
851
+ if (!shortlistAfterCheckedOffer(previous.shortlistedOfferRefs, current.shortlistedOfferRefs, checkedOfferRef)) return false
852
+ return !current.shortlistedOfferRefs.includes(checkedOfferRef)
853
+ }
854
+ if (!sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs)) return false
855
+ if (previous.selectedOfferRef !== current.selectedOfferRef) return false
856
+ if (bookingDigest(previous.verifiedOffer ?? null) !== bookingDigest(current.verifiedOffer ?? null)) return false
857
+ return true
858
+ }
859
+
860
+ function offerSelectPostActionMatches(previous: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot, action: { kind: 'offer.select'; input: { offerRef: string; offerVersionRef: string } }, receipt: ActionReceipt): boolean {
861
+ if (receipt.status !== 'applied') return current.selectedOfferRef === previous.selectedOfferRef && loadedOffersUnchanged(previous, current)
862
+ if (receipt.observation.kind !== 'offer.selection' || receipt.observation.offerRef !== action.input.offerRef || receipt.observation.offerVersionRef !== action.input.offerVersionRef) return false
863
+ if (current.selectedOfferRef !== action.input.offerRef) return false
864
+ return loadedOffersUnchanged(previous, current)
865
+ }
866
+
867
+ const SEARCH_INVALIDATION_FIELDS = [
868
+ 'results', 'visibleHotels', 'focusedHotelRef', 'loadedOffers',
869
+ 'shortlistedOfferRefs', 'selectedOfferRef', 'verifiedOffer',
870
+ ] as const
871
+
872
+ function searchPostActionMatches(current: BookingWorkspaceSnapshot, action: BookingActionCheckpoint, receipt: ActionReceipt): boolean {
873
+ if (receipt.revision === action.expectedRevision) return true
874
+ return current.focusedHotelRef === undefined
875
+ && current.loadedOffers.length === 0
876
+ && current.shortlistedOfferRefs.length === 0
877
+ && current.selectedOfferRef === undefined
878
+ && current.verifiedOffer === undefined
879
+ }
880
+
881
+ function workspaceFieldDigest(value: unknown): string {
882
+ // Hash an envelope so an absent optional field remains comparable instead
883
+ // of passing undefined to crypto.Hash.update(). Null stays distinguishable.
884
+ return bookingDigest({ value })
885
+ }
886
+
887
+ function workspacePostActionMatches(previous: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot, action: BookingActionCheckpoint, receipt: ActionReceipt): boolean {
888
+ if (!workspaceBoundaryMatches(previous, current)) return false
889
+ const searchAction = action.kind === 'search.patch' || action.kind === 'search.run'
890
+ const searchMutation = searchAction && receipt.revision > action.expectedRevision
891
+ if (searchAction && !searchPostActionMatches(current, action, receipt)) return false
892
+ const mutable: Partial<Record<BookingReadActionKind, string[]>> = {
893
+ 'search.patch': ['searchDraft', ...SEARCH_INVALIDATION_FIELDS], 'search.run': [...SEARCH_INVALIDATION_FIELDS], 'results.view.patch': ['results'],
894
+ 'hotel.focus': ['focusedHotelRef'], 'hotel.select': ['focusedHotelRef'],
895
+ 'offers.query': ['loadedOffers'], 'offers.view.patch': ['loadedOffers'],
896
+ 'offer.select': ['selectedOfferRef'], 'offer.check': ['selectedOfferRef', 'shortlistedOfferRefs', 'verifiedOffer', 'loadedOffers'],
897
+ }
898
+ if (action.kind === 'offer.select' && !offerSelectPostActionMatches(previous, current, { kind: 'offer.select', input: { offerRef: action.input.offerRef, offerVersionRef: action.input.offerVersionRef } }, receipt)) return false
899
+ if (action.kind === 'offer.check' && !offerCheckPostActionMatches(previous, current, { kind: 'offer.check', input: { offerRef: action.input.offerRef, offerVersionRef: action.input.offerVersionRef } }, receipt)) return false
900
+ const allowed = new Set(['revision', ...(searchAction && !searchMutation ? [] : (mutable[action.kind] ?? []))])
901
+ const keys = new Set([...Object.keys(previous), ...Object.keys(current)])
902
+ for (const key of keys) {
903
+ if (allowed.has(key)) continue
904
+ if (workspaceFieldDigest((previous as unknown as Record<string, unknown>)[key]) !== workspaceFieldDigest((current as unknown as Record<string, unknown>)[key])) return false
905
+ }
906
+ return true
907
+ }
908
+
909
+ function approvalOption(taskId: string, contextRef: string, sourceTurnId: string, presentationRequestKey: string, blocker: CriterionBlocker, to: RelaxationApproval['to'], deliveryNonce: string): RelaxationApproval {
910
+ const base = {
911
+ taskId, contextRef, sourceTurnId, presentationRequestKey,
912
+ approvalId: `approval-${blocker.blockerId}-${to}`, deliveryNonce, blockerId: blocker.blockerId,
913
+ sourceActionId: blocker.sourceActionId, sourceReceiptDigest: blocker.sourceReceiptDigest,
914
+ scope: blocker.scope, code: blocker.code, criterionPath: blocker.criterionPath,
915
+ valueDigest: blocker.valueDigest, from: 'must' as const, to, approved: true as const,
916
+ }
917
+ return { ...base, optionDigest: bookingDigest(base) }
918
+ }
919
+
920
+ function approvalOptionDigest(approval: RelaxationApproval): string {
921
+ const { optionDigest: _optionDigest, ...base } = approval
922
+ return bookingDigest(base)
923
+ }
924
+
925
+ function presentationKey(taskId: string, sourceTurnId: string, actionId: string, receiptDigest: string): string {
926
+ return `approval:${taskId}:${sourceTurnId}:${actionId}:${receiptDigest}`
927
+ }
928
+
929
+ type ApprovalTarget = Pick<BookingReadAction, 'actionId' | 'expectedRevision' | 'kind' | 'contextRef'>
930
+ function assertApprovalRef(ref: RelaxationApprovalRef, state: BookingCopilotTaskState, action: ApprovalTarget): void {
931
+ const approval = state.awaitingApproval?.approval
932
+ const blocker = state.awaitingApproval?.blocker
933
+ if (!approval || !blocker || ref.approvalId !== approval.approvalId || ref.blockerId !== blocker.blockerId || ref.contextRef !== state.contextRef || ref.sourceTurnId !== state.awaitingApproval?.sourceTurnId || ref.presentationRequestKey !== state.awaitingApproval?.presentationRequestKey || ref.sourceActionId !== blocker.sourceActionId || ref.targetActionId !== action.actionId || ref.sourceRevision !== action.expectedRevision || ref.targetActionKind !== action.kind || ref.to !== approval.to || ref.expiresAt !== state.awaitingApproval?.expiresAt || ref.nonce !== state.awaitingApproval?.nonce || ref.sourceReceiptDigest !== blocker.sourceReceiptDigest || ref.scope !== blocker.scope || ref.code !== blocker.code || ref.criterionPath !== blocker.criterionPath || ref.valueDigest !== blocker.valueDigest) throw new Error('ledger_corrupt:approval_ref')
934
+ }
935
+
936
+ export class BookingCopilotTaskRuntime {
937
+ private readonly idFactory: (prefix: string) => string
938
+ private readonly contextRefFactory: () => string
939
+ private readonly now: () => string
940
+ private readonly approvalTtlMs: number
941
+
942
+ constructor(private readonly ledger: StateLedger, options: BookingCopilotRuntimeOptions = {}) {
943
+ this.idFactory = options.idFactory ?? ((prefix) => `${prefix}-${randomUUID()}`)
944
+ this.contextRefFactory = options.contextRefFactory ?? (() => this.idFactory('context'))
945
+ this.now = options.now ?? (() => new Date().toISOString())
946
+ this.approvalTtlMs = options.approvalTtlMs ?? 120_000
947
+ }
948
+
949
+ /** Read-only conflict check used before startTask can append a new turn. */
950
+ assertRequestBinding(requestKey: string, turn: UserTurn, input: BookingIngressRequestBindingInput): void {
951
+ assertRequestKey(requestKey)
952
+ assertPrincipal(input.principal)
953
+ if (input.requestKey !== requestKey) throw new Error('request_conflict')
954
+ const existing = this.requestBinding(requestKey)
955
+ if (!existing) return
956
+ const candidate = this.requestBindingPayload(requestKey, turn, input)
957
+ if (existing.requestDigest !== candidate.requestDigest || existing.turnId !== candidate.turnId || existing.workspaceDigest !== candidate.workspaceDigest || existing.workspaceSemanticDigest !== candidate.workspaceSemanticDigest || existing.surface !== candidate.surface || existing.capabilityDigest !== candidate.capabilityDigest || existing.contextRef !== turn.workspace.contextRef || existing.principalDigest !== candidate.principalDigest || existing.scopeDigest !== candidate.scopeDigest || existing.taskHandle !== candidate.taskHandle) throw new Error('request_conflict')
958
+ }
959
+
960
+ /** Verifies a direct planner turn against its durable TURN row. */
961
+ assertTurnBinding(taskId: string, turn: UserTurn): void {
962
+ assertTaskId(taskId)
963
+ const row = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ? ORDER BY seq DESC`).all(this.ledger.tenant, taskId, TURN) as Array<{ payload: string }>
964
+ const observed = row.map(({ payload }) => JSON.parse(payload) as TurnPayload).find((candidate) => candidate.turnId === turn.turnId)
965
+ if (!observed || observed.requestDigest !== bookingTurnDigest(turn) || observed.workspaceDigest !== bookingWorkspaceDigest(turn.workspace) || observed.workspaceSemanticDigest !== bookingWorkspaceSemanticDigest(turn.workspace) || observed.contextRef !== turn.workspace.contextRef) throw new Error('turn_conflict')
966
+ }
967
+
968
+ /** Rejects a canonical approval after its durable runtime consumption. */
969
+ assertApprovalNotConsumed(taskId: string, approval: RelaxationApproval): void {
970
+ assertTaskId(taskId)
971
+ const rows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ?`).all(this.ledger.tenant, taskId, APPROVAL_CONSUMED) as Array<{ payload: string }>
972
+ for (const row of rows) {
973
+ const consumed = JSON.parse(row.payload) as ApprovalPayload
974
+ if (consumed.approval?.approval && bookingDigest(consumed.approval.approval) === bookingDigest(approval)) throw new Error('approval_replayed')
975
+ }
976
+ }
977
+
978
+ /** Persists the per-requestKey receipt/replay binding after task creation. */
979
+ persistRequestBinding(requestKey: string, turn: UserTurn, input: BookingIngressRequestBindingInput): void {
980
+ assertRequestKey(requestKey)
981
+ assertPrincipal(input.principal)
982
+ if (input.requestKey !== requestKey) throw new Error('request_conflict')
983
+ const run = this.ledger.db.transaction(() => {
984
+ const state = this.requireTask(turn.taskId)
985
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
986
+ assertReplayUpgradeReanchored(state)
987
+ this.appendRequestBindingInTransaction(requestKey, turn, input)
988
+ })
989
+ run.immediate()
990
+ }
991
+
992
+ /** Binds the untrusted ingress snapshot to server-owned task/context/capabilities. */
993
+ bindIngressTurn(turn: IngressTurn, task: BookingCopilotTaskState, turnId: string): UserTurn {
994
+ assertSafeRef(turnId)
995
+ const approval = turn.request.approval
996
+ if (approval && (approval.taskId !== task.taskId || approval.contextRef !== task.contextRef)) {
997
+ throw new Error('invalid_ingress_approval_authority')
998
+ }
999
+ return {
1000
+ schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId,
1001
+ workspace: {
1002
+ ...turn.workspace,
1003
+ schemaVersion: 'booking.surface', contextRef: task.contextRef, surface: task.surface,
1004
+ capabilities: { surface: task.surface, allowedActions: [...task.allowedActions] },
1005
+ },
1006
+ request: approval
1007
+ ? { text: turn.request.text, approval: { ...approval } }
1008
+ : { text: turn.request.text },
1009
+ }
1010
+ }
1011
+
1012
+ startTask(turn: BookingCopilotTurn, requestBinding?: BookingIngressRequestBindingInput): BookingCopilotTaskState {
1013
+ const validation = validateBookingSurface(turn)
1014
+ if (!validation.ok) throw new Error(`invalid_planner_turn:${errorText(validation)}`)
1015
+ if (turn.kind === 'user.turn.ingress') throw new Error('ingress_binding_required')
1016
+ if (turn.kind === 'action.receipt.continuation') throw new Error('receipt_continuation_requires_continue')
1017
+ const userTurn = turn as UserTurn
1018
+ if (!userTurn.taskId) throw new Error('invalid_planner_turn:task_id_required')
1019
+ const taskId = userTurn.taskId
1020
+ assertTaskId(taskId)
1021
+ const turnId = userTurn.turnId
1022
+ if (!turnId) throw new Error('invalid_planner_turn:turn_id_required')
1023
+ assertSafeRef(turnId)
1024
+ const contextRef = userTurn.workspace.contextRef
1025
+ const surface = userTurn.workspace.surface
1026
+ const revision = userTurn.workspace.revision
1027
+ const allowedActions = [...userTurn.workspace.capabilities.allowedActions]
1028
+ const requestDigest = bookingTurnDigest(turn)
1029
+ const boundWorkspace = userTurn.workspace
1030
+ assertWorkspaceLoadedOfferRefsUnique(boundWorkspace)
1031
+ const workspaceDigest = bookingWorkspaceDigest(boundWorkspace)
1032
+ const workspaceSemanticDigest = bookingWorkspaceSemanticDigest(boundWorkspace)
1033
+ const availability = createAvailabilityPolicy(boundWorkspace)
1034
+ const run = this.ledger.db.transaction(() => {
1035
+ const existing = this.resumeTask(taskId)
1036
+ if (existing) {
1037
+ if (existing.contextRef !== contextRef) throw new Error('task_conflict:context_mismatch')
1038
+ if (existing.surface !== surface) throw new Error('task_conflict:surface_mismatch')
1039
+ if (existing.revision !== revision && existing.phase !== 'waiting_receipt' && !existing.replayUpgradeRequired) throw new Error('task_conflict:revision_mismatch')
1040
+ if (!sameActions(existing.allowedActions, allowedActions)) throw new Error('task_conflict:capability_mismatch')
1041
+ if (existing.availability.terminal?.code === 'availability_confirmed' && (existing.revision !== revision || existing.workspaceSemanticDigest !== workspaceSemanticDigest)) throw new Error('workspace_mismatch')
1042
+ if (existing.phase === 'terminal' || existing.phase === 'error') throw new Error('task_terminal')
1043
+ if (turnId && existing.lastTurnId === turnId) {
1044
+ this.assertTurnBinding(taskId, userTurn)
1045
+ if (requestBinding) {
1046
+ assertReplayUpgradeReanchored(existing)
1047
+ this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding)
1048
+ }
1049
+ return existing
1050
+ }
1051
+ if (existing.phase === 'waiting_receipt') throw new Error('receipt_required')
1052
+ if (!existing.replayUpgradeRequired && existing.revision === revision && existing.workspaceSemanticDigest !== workspaceSemanticDigest) throw new Error('workspace_mismatch')
1053
+ const approval = userTurn && 'approval' in userTurn.request ? userTurn.request.approval : undefined
1054
+ let approvalState: BookingApprovalState | undefined
1055
+ if (approval) {
1056
+ assertReplayUpgradeReanchored(existing)
1057
+ const awaiting = existing.awaitingApproval
1058
+ if (!awaiting) throw new Error('approval_not_awaiting')
1059
+ if (!awaiting.optionsEmitted) throw new Error('approval_not_presented')
1060
+ if (Date.parse(awaiting.expiresAt) <= Date.parse(this.now())) throw new Error('approval_expired')
1061
+ const checked = validateApprovalAgainstBlocker(approval, awaiting.blocker)
1062
+ const option = awaiting.options.find((candidate) => candidate.optionDigest === approval.optionDigest && bookingDigest(candidate) === bookingDigest(approval))
1063
+ if (!checked.ok || !option || approval.taskId !== existing.taskId || approval.contextRef !== existing.contextRef || approval.sourceTurnId !== awaiting.sourceTurnId || approval.presentationRequestKey !== awaiting.presentationRequestKey || approval.optionDigest !== approvalOptionDigest(approval) || (awaiting.approval && bookingDigest(approval) !== bookingDigest(awaiting.approval))) throw new Error('approval_mismatch')
1064
+ if (awaiting.approval && bookingDigest(awaiting.approval) === bookingDigest(approval)) {
1065
+ // The canonical approval may only be advanced by its original
1066
+ // turn identity. A fresh key must not join the in-flight planner
1067
+ // and receive the operation batch before durable consumption.
1068
+ throw new Error('approval_replayed')
1069
+ }
1070
+ approvalState = { ...awaiting, approval }
1071
+ this.appendApproval(taskId, existing.contextRef, approvalState)
1072
+ } else if (existing.awaitingApproval) {
1073
+ throw new Error('approval_required')
1074
+ }
1075
+ this.appendTurn(taskId, existing.contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, existing.userTurnCount + 1, turnId, boundWorkspace, Boolean(approval))
1076
+ if (requestBinding) this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding)
1077
+ return this.requireTask(taskId)
1078
+ }
1079
+ if (userTurn?.request.approval) throw new Error('approval_not_awaiting')
1080
+ const started: StartedPayload = { schema: LEDGER_SCHEMA, taskId, contextRef, surface, revision, allowedActions, workspaceDigest, workspaceSemanticDigest, workspace: boundWorkspace, availability, availabilityDigest: bookingDigest(availability) }
1081
+ this.append(STARTED, taskId, started, `booking-copilot:task:${taskId}`)
1082
+ this.appendTurn(taskId, contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, 1, turnId, boundWorkspace, false)
1083
+ if (requestBinding) this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding)
1084
+ return this.requireTask(taskId)
1085
+ })
1086
+ return run.immediate()
1087
+ }
1088
+
1089
+ resumeTask(taskId: string): BookingCopilotTaskState | null {
1090
+ assertTaskId(taskId)
1091
+ const rows = this.rows(taskId)
1092
+ if (!rows.length) return null
1093
+ let state: BookingCopilotTaskState | null = null
1094
+ let skipLegacyUpgradeTail = false
1095
+ const replayWorkspaceDigests = new Map<string, string>()
1096
+ const consumed: ReplayConsumedIdentities = { events: new Map(), actions: new Map(), pendingBatchKeys: new Set(), pendingBatchOpen: false }
1097
+ const suppressedDecisionRequestKeys = new Set<string>()
1098
+ const suppressedRequestBindingKeys = new Set<string>()
1099
+ const suppressedReceiptActionIds = new Set<string>()
1100
+ const suppressedApprovalTargetActionIds = new Set<string>()
1101
+ for (const row of rows) {
1102
+ const payload = JSON.parse(row.payload) as BasePayload & Record<string, any>
1103
+ if (payload.schema !== LEDGER_SCHEMA || payload.taskId !== taskId) throw new Error(`ledger_corrupt:${taskId}:seq=${row.seq}`)
1104
+ if (row.kind === STARTED) {
1105
+ skipLegacyUpgradeTail = false
1106
+ if (state) throw new Error(`ledger_corrupt:${taskId}:duplicate_start`)
1107
+ const started = payload as StartedPayload
1108
+ let startedWorkspace: BookingWorkspaceSnapshot
1109
+ try { startedWorkspace = normalizeWorkspaceForReplay(started.workspace) } catch { throw new Error(`ledger_corrupt:${taskId}:start_workspace`) }
1110
+ let initialAvailability: AvailabilityPolicyState
1111
+ try { initialAvailability = createAvailabilityPolicy(startedWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:start_availability`) }
1112
+ try { assertWorkspaceLoadedOfferRefsUnique(startedWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:start_workspace`) }
1113
+ rememberReplayWorkspaceDigest(replayWorkspaceDigests, started.workspaceDigest, started.workspace, startedWorkspace)
1114
+ const startedAvailability = normalizeAvailabilityPolicyForReplay(started.availability, startedWorkspace, undefined, undefined, replayWorkspaceDigests)
1115
+ if (!started.workspaceDigest || !started.workspaceSemanticDigest || !started.workspace || !started.availability || !started.availabilityDigest || !validateAvailabilityPolicy(startedAvailability) || !replayDigestMatches(started.availabilityDigest, started.availability, startedAvailability) || bookingDigest(initialAvailability) !== bookingDigest(startedAvailability) || !replayWorkspaceDigestMatches(started.workspaceDigest, started.workspace, startedWorkspace) || !replayWorkspaceSemanticDigestMatches(started.workspaceSemanticDigest, started.workspace, startedWorkspace)) throw new Error(`ledger_corrupt:${taskId}:start_workspace`)
1116
+ state = { schemaVersion: 'booking.surface', taskId, contextRef: started.contextRef, surface: started.surface, revision: started.revision, allowedActions: [...started.allowedActions], userTurnCount: 0, operationCount: 0, phase: availabilityTerminalEndsTask(startedAvailability.terminal) ? 'terminal' : 'planning', lastSequence: 0, workspaceDigest: bookingWorkspaceDigest(startedWorkspace), workspaceSemanticDigest: bookingWorkspaceSemanticDigest(startedWorkspace), workspaceSnapshot: startedWorkspace, availability: startedAvailability }
1117
+ } else {
1118
+ if (!state) throw new Error(`ledger_corrupt:${taskId}:event_before_start`)
1119
+ if (payload.contextRef !== state.contextRef) throw new Error(`ledger_corrupt:${taskId}:context_drift`)
1120
+ if (row.kind === REQUEST_BINDING) {
1121
+ if (skipLegacyUpgradeTail) {
1122
+ const binding = payload as RequestBindingPayload
1123
+ if (typeof binding.requestKey === 'string') suppressedRequestBindingKeys.add(binding.requestKey)
1124
+ continue
1125
+ }
1126
+ const binding = payload as RequestBindingPayload
1127
+ const turnRows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ?`).all(this.ledger.tenant, taskId, TURN) as Array<{ payload: string }>
1128
+ const rawBoundTurn = turnRows.map(({ payload }) => JSON.parse(payload) as TurnPayload).find((candidate) => candidate.turnId === binding.turnId)
1129
+ let boundTurn: TurnPayload | undefined
1130
+ try { boundTurn = rawBoundTurn ? { ...rawBoundTurn, workspace: normalizeWorkspaceForReplay(rawBoundTurn.workspace) } : undefined } catch { throw new Error(`ledger_corrupt:${taskId}:request_binding`) }
1131
+ if (!isSafeBookingRequestKey(binding.requestKey) || !safeIdentity(binding.turnId) || !safeIdentity(binding.contextRef) || (binding.taskHandle !== undefined && !safeIdentity(binding.taskHandle)) || !/^[0-9a-f]{64}$/.test(binding.requestDigest) || !/^[0-9a-f]{64}$/.test(binding.workspaceDigest) || !/^[0-9a-f]{64}$/.test(binding.workspaceSemanticDigest) || !/^[0-9a-f]{64}$/.test(binding.capabilityDigest) || !/^[0-9a-f]{64}$/.test(binding.principalDigest) || !/^[0-9a-f]{64}$/.test(binding.scopeDigest) || !['tenant', 'customer_portal', 'storefront', 'payment_link'].includes(binding.surface) || !boundTurn || boundTurn.requestDigest !== binding.requestDigest || boundTurn.workspaceDigest !== binding.workspaceDigest || boundTurn.workspaceSemanticDigest !== binding.workspaceSemanticDigest || bookingDigest(boundTurn.workspace.capabilities) !== binding.capabilityDigest || boundTurn.workspace.surface !== binding.surface) throw new Error(`ledger_corrupt:${taskId}:request_binding`)
1132
+ } else if (row.kind === TURN) {
1133
+ skipLegacyUpgradeTail = false
1134
+ const t = payload as TurnPayload
1135
+ if (Object.prototype.hasOwnProperty.call(payload, 'approval')) throw new Error(`ledger_corrupt:${taskId}:turn_approval`)
1136
+ let turnWorkspace: BookingWorkspaceSnapshot
1137
+ try { turnWorkspace = normalizeWorkspaceForReplay(t.workspace) } catch { throw new Error(`ledger_corrupt:${taskId}:turn_workspace`) }
1138
+ try { assertWorkspaceLoadedOfferRefsUnique(turnWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:turn_workspace`) }
1139
+ if (!t.workspaceDigest || !t.workspaceSemanticDigest || !t.workspace || !replayWorkspaceDigestMatches(t.workspaceDigest, t.workspace, turnWorkspace) || !replayWorkspaceSemanticDigestMatches(t.workspaceSemanticDigest, t.workspace, turnWorkspace)) throw new Error(`ledger_corrupt:${taskId}:turn_workspace`)
1140
+ // Same-revision replay guard: an ordinary TURN row must not silently
1141
+ // rewrite the durable workspace semantic digest. A ledger written by
1142
+ // an old public startTask (no live-time same-revision guard) would
1143
+ // otherwise smuggle a verifiedOffer/loadedOffer/selection drift into
1144
+ // replay and turn the replayed workspace into checkout authority.
1145
+ // The explicit replayUpgradeRequired reanchor stays intact for the
1146
+ // legacy-tail replan path.
1147
+ if (state.availability.terminal?.code === 'availability_confirmed' && (state.revision !== turnWorkspace.revision || state.workspaceSemanticDigest !== bookingWorkspaceSemanticDigest(turnWorkspace))) throw new Error(`ledger_corrupt:${taskId}:confirmed_workspace_drift`)
1148
+ if (!state.replayUpgradeRequired && state.revision === turnWorkspace.revision && state.workspaceSemanticDigest !== bookingWorkspaceSemanticDigest(turnWorkspace)) throw new Error(`ledger_corrupt:${taskId}:same_revision_turn_workspace_drift`)
1149
+ rememberReplayWorkspaceDigest(replayWorkspaceDigests, t.workspaceDigest, t.workspace, turnWorkspace)
1150
+ state.userTurnCount++
1151
+ state.lastTurnId = t.turnId
1152
+ if (!/^[0-9a-f]{64}$/.test(t.requestDigest)) throw new Error(`ledger_corrupt:${taskId}:turn_request_digest`)
1153
+ state.lastTurnRequestDigest = t.requestDigest
1154
+ if (!t.preserveIntent) {
1155
+ delete state.activeIntent
1156
+ delete state.lastCompletedAction
1157
+ delete state.lastReceipt
1158
+ }
1159
+ state.workspaceDigest = bookingWorkspaceDigest(turnWorkspace)
1160
+ state.workspaceSemanticDigest = bookingWorkspaceSemanticDigest(turnWorkspace)
1161
+ state.workspaceSnapshot = turnWorkspace
1162
+ state.revision = turnWorkspace.revision
1163
+ delete state.replayUpgradeRequired
1164
+ // Approval authority is established only by APPROVAL_GRANTED; a turn
1165
+ // is merely an observation and cannot restore or mutate approval state.
1166
+ } else if (row.kind === APPROVAL_GRANTED) {
1167
+ if (skipLegacyUpgradeTail) continue
1168
+ const granted = payload as ApprovalPayload
1169
+ if (!granted.approval || granted.approvalDigest !== bookingDigest(granted.approval) || !granted.approval.sourceTurnId || !granted.approval.presentationRequestKey || !validateCriterionBlocker(granted.approval.blocker).ok || !granted.approval.options?.length || granted.approval.options.some((option) => !validateApprovalAgainstBlocker(option, granted.approval!.blocker).ok || option.taskId !== taskId || option.contextRef !== state!.contextRef || option.sourceTurnId !== granted.approval!.sourceTurnId || option.presentationRequestKey !== granted.approval!.presentationRequestKey || option.optionDigest !== approvalOptionDigest(option)) || (granted.approval.approval && !granted.approval.options.some((option) => bookingDigest(option) === bookingDigest(granted.approval!.approval!)))) throw new Error(`ledger_corrupt:${taskId}:approval_granted`)
1170
+ state.awaitingApproval = granted.approval; state.phase = 'input_required'
1171
+ } else if (row.kind === APPROVAL_OFFERED) {
1172
+ if (skipLegacyUpgradeTail) continue
1173
+ const offered = payload as ApprovalPayload
1174
+ if (!offered.approval || offered.approvalDigest !== bookingDigest(offered.approval) || !state.awaitingApproval || bookingDigest(offered.approval) !== bookingDigest(state.awaitingApproval)) throw new Error(`ledger_corrupt:${taskId}:approval_offered`)
1175
+ // This is a durable outbox intent only. It is deliberately not
1176
+ // evidence that the question entered a replayable output batch:
1177
+ // a crash between this row and DECISION_BATCH must still reject an
1178
+ // approval until the exact question can be replayed.
1179
+ state.phase = 'input_required'
1180
+ } else if (row.kind === ACTION) {
1181
+ const actionPayload = payload as ActionPayload
1182
+ if (skipLegacyUpgradeTail) { try { consumeSkippedActionOrdinal(state, actionPayload, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:action`) } continue }
1183
+ const rawAction = actionPayload.action
1184
+ const operationCount = actionPayload.operationCount ?? state.operationCount + 1
1185
+ const { actionDigest: _rawActionDigest, ...rawActionBase } = rawAction
1186
+ if (rawAction.actionDigest !== checkpointDigest(rawActionBase) || rawAction.contextRef !== state.contextRef || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount + 1 || operationCount > BOOKING_COPILOT_MAX_OPERATIONS || !Number.isSafeInteger(rawAction.sequence) || rawAction.sequence <= state.lastSequence) throw new Error(`ledger_corrupt:${taskId}:action`)
1187
+ const hasIntent = Object.prototype.hasOwnProperty.call(actionPayload, 'intent')
1188
+ if (!hasIntent) {
1189
+ // Pre-intent rows remain readable without being upgraded into a
1190
+ // guessed user goal. Stop before applying newer action invariants,
1191
+ // preserve the ordinal, and suppress the legacy tail until a fresh
1192
+ // user turn reanchors the workspace and intent.
1193
+ state = replanAfterReplayUpgradeGap(state)
1194
+ try { consumeSkippedActionOrdinal(state, actionPayload, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:action`) }
1195
+ skipLegacyUpgradeTail = true
1196
+ continue
1197
+ }
1198
+ let a: BookingActionCheckpoint
1199
+ try { a = normalizeActionCheckpointForReplay(rawAction, state.workspaceSnapshot) } catch (error) {
1200
+ if (error instanceof Error && error.message.startsWith('booking_replan_required:')) { state = replanAfterReplayUpgradeGap(state); try { consumeSkippedActionOrdinal(state, actionPayload, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:action`) } skipLegacyUpgradeTail = true; continue }
1201
+ throw error
1202
+ }
1203
+ const { actionDigest: _actionDigest, ...actionBase } = a
1204
+ if (!actionPayload.availability || actionPayload.availabilityDigest !== bookingDigest(actionPayload.availability)) throw new Error(`ledger_corrupt:${taskId}:action`)
1205
+ let actionAvailability: AvailabilityPolicyState
1206
+ try { actionAvailability = normalizeAvailabilityPolicyForReplay(actionPayload.availability, state.workspaceSnapshot, a, state.availability, replayWorkspaceDigests) } catch (error) {
1207
+ if (error instanceof Error && error.message.startsWith('booking_replan_required:')) { state = replanAfterReplayUpgradeGap(state); try { consumeSkippedActionOrdinal(state, actionPayload, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:action`) } skipLegacyUpgradeTail = true; continue }
1208
+ throw error
1209
+ }
1210
+ if (!checkpointDigestMatchesForReplay(rawAction, a) || a.actionDigest !== checkpointDigest(actionBase) || a.sourceTurnId !== state.lastTurnId || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount + 1 || operationCount > BOOKING_COPILOT_MAX_OPERATIONS || !validateAvailabilityPolicy(actionAvailability) || !replayDigestMatches(actionPayload.availabilityDigest, actionPayload.availability, actionAvailability) || !state.workspaceSnapshot) throw new Error(`ledger_corrupt:${taskId}:action`)
1211
+ if (actionPayload.intent) {
1212
+ try { validateIntentCheckpoint(actionPayload.intent, state, a) } catch { throw new Error(`ledger_corrupt:${taskId}:intent`) }
1213
+ } else throw new Error(`ledger_corrupt:${taskId}:intent`)
1214
+ if (a.relaxationApprovalRef) assertApprovalRef(a.relaxationApprovalRef, state, a)
1215
+ if (state.pendingAction) throw new Error(`ledger_corrupt:${taskId}:parallel_actions`)
1216
+ let expectedAvailability: AvailabilityPolicyState
1217
+ try { expectedAvailability = reduceAvailabilityAction(state.availability, state.workspaceSnapshot, a) } catch { throw new Error(`ledger_corrupt:${taskId}:action_transition`) }
1218
+ if (bookingDigest(expectedAvailability) !== bookingDigest(actionAvailability)) throw new Error(`ledger_corrupt:${taskId}:action_transition`)
1219
+ state.operationCount = operationCount
1220
+ state.availability = actionAvailability
1221
+ if (actionPayload.intent) state.activeIntent = actionPayload.intent
1222
+ state.pendingAction = a; state.phase = 'waiting_receipt'; state.lastSequence = Math.max(state.lastSequence, a.sequence)
1223
+ {
1224
+ rememberConsumedActionIdentity(consumed, a)
1225
+ }
1226
+ delete state.awaitingApproval
1227
+ } else if (row.kind === RECEIPT) {
1228
+ if (skipLegacyUpgradeTail) {
1229
+ const r = payload as ReceiptPayload
1230
+ if (typeof r.receipt?.actionId === 'string') suppressedReceiptActionIds.add(r.receipt.actionId)
1231
+ // A standalone legacy action has no decision envelope to consume
1232
+ // its pending identity set. Its receipt closes that skipped unit;
1233
+ // otherwise a later, freshly anchored batch is falsely treated as
1234
+ // a graft containing identities from the legacy action.
1235
+ clearPendingBatch(consumed)
1236
+ continue
1237
+ }
1238
+ const r = payload as ReceiptPayload
1239
+ const operationCount = r.operationCount ?? state.operationCount
1240
+ if (!state.pendingAction || state.pendingAction.actionId !== r.receipt.actionId) throw new Error(`ledger_corrupt:${taskId}:orphan_receipt`)
1241
+ let receiptWorkspace: BookingWorkspaceSnapshot
1242
+ try { receiptWorkspace = normalizeWorkspaceForReplay(r.workspace) } catch { throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`) }
1243
+ try { assertWorkspaceLoadedOfferRefsUnique(receiptWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`) }
1244
+ if (!r.workspaceDigest || !r.workspaceSemanticDigest || !r.workspace || !replayWorkspaceDigestMatches(r.workspaceDigest, r.workspace, receiptWorkspace) || !replayWorkspaceSemanticDigestMatches(r.workspaceSemanticDigest, r.workspace, receiptWorkspace)) throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`)
1245
+ rememberReplayWorkspaceDigest(replayWorkspaceDigests, r.workspaceDigest, r.workspace, receiptWorkspace)
1246
+ if (!replayDigestMatches(r.receiptDigest, r.receipt, r.receipt) || r.receipt.contextRef !== state.contextRef || r.receipt.actionId !== state.pendingAction.actionId || !Number.isSafeInteger(r.receipt.revision) || r.receipt.revision < state.pendingAction.expectedRevision || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount) throw new Error(`ledger_corrupt:${taskId}:receipt`)
1247
+ if (!r.availability || r.availabilityDigest !== bookingDigest(r.availability)) throw new Error(`ledger_corrupt:${taskId}:receipt`)
1248
+ let receipt: ActionReceipt
1249
+ try { receipt = normalizeReceiptForReplay(r.receipt, state.pendingAction, receiptWorkspace) } catch (error) {
1250
+ if (error instanceof Error && error.message.startsWith('booking_replan_required:')) { clearPendingBatch(consumed); state = replanAfterReplayUpgradeGap(state, receiptWorkspace, r.receipt.revision); skipLegacyUpgradeTail = true; continue }
1251
+ throw error
1252
+ }
1253
+ let receiptAvailability: AvailabilityPolicyState
1254
+ try { receiptAvailability = normalizeAvailabilityPolicyForReplay(r.availability, receiptWorkspace, state.pendingAction, state.availability, replayWorkspaceDigests, receipt) } catch (error) {
1255
+ if (error instanceof Error && error.message.startsWith('booking_replan_required:')) { clearPendingBatch(consumed); state = replanAfterReplayUpgradeGap(state, receiptWorkspace, r.receipt.revision); skipLegacyUpgradeTail = true; continue }
1256
+ throw error
1257
+ }
1258
+ if (!replayDigestMatches(r.receiptDigest, r.receipt, receipt) || !validateBookingSurface(receipt).ok || !r.availability || !r.availabilityDigest || !validateAvailabilityPolicy(receiptAvailability) || !replayDigestMatches(r.availabilityDigest, r.availability, receiptAvailability)) throw new Error(`ledger_corrupt:${taskId}:receipt`)
1259
+ if (Boolean(r.availabilityTerminal) !== Boolean(receiptAvailability.terminal) || (r.availabilityTerminal && receiptAvailability.terminal && bookingDigest(r.availabilityTerminal) !== bookingDigest(receiptAvailability.terminal))) throw new Error(`ledger_corrupt:${taskId}:availability_terminal`)
1260
+ assertCanonicalReceipt(receipt)
1261
+ try { assertOffersReceiptWorkspaceBinding(receipt, receiptWorkspace, state.pendingAction) } catch { throw new Error(`ledger_corrupt:${taskId}:receipt_transition`) }
1262
+ if (!state.workspaceSnapshot || !workspacePostActionMatches(state.workspaceSnapshot, receiptWorkspace, state.pendingAction, receipt) || !receiptObservationMatchesAction(receipt.observation.kind, state.pendingAction.kind) || !receiptTargetMatchesAction(receipt, state.pendingAction)) throw new Error(`ledger_corrupt:${taskId}:receipt_transition`)
1263
+ let expectedAvailability: AvailabilityPolicyState
1264
+ try { expectedAvailability = reduceAvailabilityReceipt(state.availability, receiptWorkspace, receipt, state.pendingAction) } catch { throw new Error(`ledger_corrupt:${taskId}:receipt_transition`) }
1265
+ if (bookingDigest(expectedAvailability) !== bookingDigest(receiptAvailability)) throw new Error(`ledger_corrupt:${taskId}:receipt_transition`)
1266
+ state.lastCompletedAction = state.pendingAction
1267
+ state.lastReceipt = receipt; state.revision = receipt.revision; state.workspaceDigest = bookingWorkspaceDigest(receiptWorkspace); state.workspaceSemanticDigest = bookingWorkspaceSemanticDigest(receiptWorkspace); state.workspaceSnapshot = receiptWorkspace; state.availability = receiptAvailability; delete state.pendingAction; state.phase = availabilityTerminalEndsTask(receiptAvailability.terminal) || state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS ? 'terminal' : 'planning'
1268
+ clearPendingBatch(consumed)
1269
+ delete state.awaitingApproval
1270
+ } else if (row.kind === APPROVAL_CONSUMED) {
1271
+ if (skipLegacyUpgradeTail) {
1272
+ const consumed = payload as ApprovalPayload
1273
+ if (typeof consumed.ref?.targetActionId === 'string') suppressedApprovalTargetActionIds.add(consumed.ref.targetActionId)
1274
+ continue
1275
+ }
1276
+ const consumed = payload as ApprovalPayload
1277
+ if (!consumed.ref || consumed.approvalDigest !== bookingDigest(consumed.ref) || !consumed.approval?.approval || !consumed.approval.options?.some((option) => bookingDigest(option) === bookingDigest(consumed.approval!.approval!)) || !state.awaitingApproval || bookingDigest(consumed.approval) !== bookingDigest(state.awaitingApproval) || consumed.ref.approvalId !== consumed.approval.approval.approvalId || consumed.ref.blockerId !== consumed.approval.blocker.blockerId || consumed.ref.contextRef !== state.contextRef || consumed.ref.sourceTurnId !== consumed.approval.sourceTurnId || consumed.ref.presentationRequestKey !== consumed.approval.presentationRequestKey || consumed.ref.sourceActionId !== consumed.approval.blocker.sourceActionId || consumed.ref.targetActionId === '' || consumed.ref.sourceReceiptDigest !== consumed.approval.blocker.sourceReceiptDigest || consumed.ref.nonce !== consumed.approval.nonce || consumed.ref.nonce !== consumed.approval.approval.deliveryNonce || consumed.ref.to !== consumed.approval.approval.to || consumed.ref.scope !== consumed.approval.blocker.scope || consumed.ref.code !== consumed.approval.blocker.code || consumed.ref.criterionPath !== consumed.approval.blocker.criterionPath || consumed.ref.valueDigest !== consumed.approval.blocker.valueDigest) throw new Error(`ledger_corrupt:${taskId}:approval`)
1278
+ } else if (row.kind === DECISION_BATCH) {
1279
+ const batch = payload as DecisionBatchPayload
1280
+ if (skipLegacyUpgradeTail) {
1281
+ try { consumeSkippedEventSequence(state, batch, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:decision_batch`) }
1282
+ if (typeof batch.requestKey === 'string') suppressedDecisionRequestKeys.add(batch.requestKey)
1283
+ continue
1284
+ }
1285
+ if (!batch.requestKey || !Array.isArray(batch.events) || batch.events.some((event) => event.taskId !== taskId)) throw new Error(`ledger_corrupt:${taskId}:decision_batch`)
1286
+ let batchEvents: BookingSurfaceEvent[]
1287
+ try { batchEvents = normalizeDecisionBatchEnvelopeForReplay(batch, consumed, true) } catch { throw new Error(`ledger_corrupt:${taskId}:decision_batch`) }
1288
+ for (const event of batchEvents) {
1289
+ if (event.kind !== 'question') continue
1290
+ const awaiting = state.awaitingApproval
1291
+ if (!awaiting || bookingDigest(event.question.blocker) !== bookingDigest(awaiting.blocker) || event.question.approvalOptions.length !== awaiting.options.length || event.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => bookingDigest(option) === bookingDigest(approval)))) throw new Error(`ledger_corrupt:${taskId}:decision_batch_question`)
1292
+ // Only the atomic, durable batch makes the presentation nonce
1293
+ // usable. APPROVAL_OFFERED alone is intentionally insufficient.
1294
+ awaiting.optionsEmitted = true
1295
+ state.phase = 'input_required'
1296
+ }
1297
+ } else if (row.kind === EVENT) {
1298
+ const event = payload as EventPayload
1299
+ if (skipLegacyUpgradeTail) { try { consumeSkippedEventSequence(state, event, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:event`) } continue }
1300
+ state.lastSequence = Math.max(state.lastSequence, event.sequence)
1301
+ {
1302
+ rememberConsumedEventIdentity(consumed, event)
1303
+ }
1304
+ if (event.eventKind === 'status') state.phase = event.status === 'submitted' ? 'submitted' : event.status === 'working' ? 'working' : event.status === 'waiting_receipt' ? 'waiting_receipt' : 'input_required'
1305
+ else if (event.eventKind === 'question') state.phase = 'input_required'
1306
+ else if (event.eventKind === 'terminal') state.phase = 'terminal'
1307
+ else if (event.eventKind === 'error') state.phase = 'error'
1308
+ }
1309
+ }
1310
+ }
1311
+ if (state) {
1312
+ if (suppressedDecisionRequestKeys.size) state.legacySuppressedDecisionRequestKeys = [...suppressedDecisionRequestKeys]
1313
+ if (suppressedRequestBindingKeys.size) state.legacySuppressedRequestBindingKeys = [...suppressedRequestBindingKeys]
1314
+ if (suppressedReceiptActionIds.size) state.legacySuppressedReceiptActionIds = [...suppressedReceiptActionIds]
1315
+ if (suppressedApprovalTargetActionIds.size) state.legacySuppressedApprovalTargetActionIds = [...suppressedApprovalTargetActionIds]
1316
+ }
1317
+ return state
1318
+ }
1319
+
1320
+ withReceiptDigest<T extends ActionReceipt>(receipt: T): T {
1321
+ // The source receipt digest is the authority used by the approval binding.
1322
+ const sourceDigest = receiptSourceDigest(receipt)
1323
+ return { ...receipt, resultContract: { ...receipt.resultContract, blockers: receipt.resultContract.blockers.map((b) => ({ ...b, sourceReceiptDigest: sourceDigest })) } }
1324
+ }
1325
+
1326
+ receiptDigest(receipt: ActionReceipt): string { return canonicalReceiptDigest(receipt) }
1327
+
1328
+ /** Builds the only relaxation question exposed to the planner stream. The
1329
+ * blocker is ledger-derived; a planner cannot invent its tuple or ref. */
1330
+ approvalQuestion(taskId: string): BookingSurfaceEventDraft {
1331
+ const task = this.requireTask(taskId)
1332
+ const awaiting = task.awaitingApproval
1333
+ if (!awaiting) throw new Error('approval_not_awaiting')
1334
+ return {
1335
+ kind: 'question',
1336
+ question: {
1337
+ questionId: `question-${awaiting.blocker.blockerId}`,
1338
+ prompt: `Relax required criterion ${awaiting.blocker.criterionPath}?`,
1339
+ missingFields: [awaiting.blocker.criterionPath],
1340
+ type: 'relaxation_approval_required',
1341
+ blocker: awaiting.blocker,
1342
+ approvalOptions: awaiting.options.map((approval) => ({ approval })),
1343
+ },
1344
+ }
1345
+ }
1346
+
1347
+ approvalPresentationRequestKey(taskId: string): string {
1348
+ const task = this.requireTask(taskId)
1349
+ const awaiting = task.awaitingApproval
1350
+ if (!awaiting) throw new Error('approval_not_awaiting')
1351
+ return awaiting.presentationRequestKey
1352
+ }
1353
+
1354
+ commitDecisionBatch(taskId: string, requestKey: string, decisions: readonly BookingPlannerDecision[]): BookingSurfaceEvent[] {
1355
+ return this.applyDecisionBatch(taskId, requestKey, decisions)
1356
+ }
1357
+
1358
+ /**
1359
+ * Persists the server's initial SSE progress before planner execution. The
1360
+ * progress batch has a derived idempotency key, so the final decision batch
1361
+ * keeps the public request key and never repeats these status events.
1362
+ */
1363
+ ensureProgressBatch(taskId: string, requestKey: string, includeSubmitted = false): BookingSurfaceEvent[] {
1364
+ assertInternalDecisionKey(requestKey)
1365
+ const progressKey = progressDecisionKey(requestKey)
1366
+ const prior = this.readDecisionBatch(taskId, progressKey)
1367
+ if (prior) return prior
1368
+ if (this.readDecisionBatch(taskId, requestKey)) return []
1369
+ const run = this.ledger.db.transaction(() => {
1370
+ const state = this.requireTask(taskId)
1371
+ if (state.phase === 'terminal' || state.phase === 'error') return []
1372
+ const recorded = this.readDecisionBatch(taskId, progressKey)
1373
+ if (recorded) return recorded
1374
+ const events: BookingSurfaceEvent[] = []
1375
+ if (includeSubmitted) events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'submitted' }))
1376
+ events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'working' }))
1377
+ this.append(DECISION_BATCH, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, requestKey: progressKey, events }, `booking-copilot:decision-batch:${taskId}:${progressKey}`)
1378
+ return events
1379
+ })
1380
+ return run.immediate()
1381
+ }
1382
+
1383
+ readProgressBatch(taskId: string, requestKey: string): BookingSurfaceEvent[] | null {
1384
+ assertInternalDecisionKey(requestKey)
1385
+ return this.readDecisionBatch(taskId, progressDecisionKey(requestKey))
1386
+ }
1387
+
1388
+ readDecisionBatch(taskId: string, requestKey: string): BookingSurfaceEvent[] | null {
1389
+ const state = this.requireTask(taskId)
1390
+ if (state.legacySuppressedDecisionRequestKeys?.includes(requestKey)) throw new Error('request_key_invalidated')
1391
+ const rows = this.ledger.db.prepare('SELECT seq, payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ? ORDER BY seq DESC').all(this.ledger.tenant, taskId, DECISION_BATCH) as Array<{ seq: number; payload: string }>
1392
+ for (const row of rows) {
1393
+ const payload = JSON.parse(row.payload) as DecisionBatchPayload
1394
+ if (payload.requestKey !== requestKey) continue
1395
+ if (payload.schema !== LEDGER_SCHEMA || payload.taskId !== taskId || payload.contextRef !== state.contextRef || !Array.isArray(payload.events) || payload.events.some((event) => event.taskId !== taskId || event.contextRef !== state.contextRef)) throw new Error(`ledger_corrupt:${taskId}:decision_batch`)
1396
+ const events = this.normalizeDecisionBatchFromDurableRows(taskId, payload.events, row.seq)
1397
+ if (!events) throw new Error(`ledger_corrupt:${taskId}:decision_batch`)
1398
+ for (const event of events) {
1399
+ if (event.kind !== 'question') continue
1400
+ const awaiting = state.awaitingApproval
1401
+ if (!awaiting || requestKey !== awaiting.presentationRequestKey || bookingDigest(event.question.blocker) !== bookingDigest(awaiting.blocker) || event.question.approvalOptions.length !== awaiting.options.length || event.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => option.optionDigest === approval.optionDigest && bookingDigest(option) === bookingDigest(approval)))) throw new Error(`ledger_corrupt:${taskId}:decision_batch_question`)
1402
+ }
1403
+ return events
1404
+ }
1405
+ return null
1406
+ }
1407
+
1408
+ /** Materializes the durable policy/budget terminal response once, before SSE. */
1409
+ terminalDecisionBatch(taskId: string, requestKey: string): BookingSurfaceEvent[] {
1410
+ assertInternalDecisionKey(requestKey)
1411
+ const prior = this.readDecisionBatch(taskId, requestKey)
1412
+ if (prior) return prior
1413
+ const state = this.requireTask(taskId)
1414
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
1415
+ assertReplayUpgradeReanchored(state)
1416
+ const run = this.ledger.db.transaction(() => {
1417
+ const state = this.requireTask(taskId)
1418
+ if (!taskHasTerminalBudget(state) || state.phase !== 'terminal') throw new Error('task_not_terminal')
1419
+ assertReplayUpgradeReanchored(state)
1420
+ const event = this.terminalEvent(state)
1421
+ this.appendTerminalEvent(taskId, event)
1422
+ this.append(DECISION_BATCH, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, requestKey, events: [event] }, `booking-copilot:decision-batch:${taskId}:${requestKey}`)
1423
+ return [event]
1424
+ })
1425
+ return run.immediate()
1426
+ }
1427
+
1428
+ /** Atomically applies planner decisions and records the exact SSE batch before it is sent. */
1429
+ applyDecisionBatch(taskId: string, requestKey: string, decisions: readonly BookingPlannerDecision[], includeSubmitted = false, options: BookingDecisionBatchOptions = {}): BookingSurfaceEvent[] {
1430
+ assertInternalDecisionKey(requestKey)
1431
+ const prior = this.readDecisionBatch(taskId, requestKey)
1432
+ if (prior) return prior
1433
+ const current = this.requireTask(taskId)
1434
+ if (current.phase === 'terminal' || current.phase === 'error') throw new Error('task_terminal')
1435
+ assertReplayUpgradeReanchored(current)
1436
+ assertDecisionBatchFinality(decisions)
1437
+ assertAvailabilityLiveness(current, decisions, this.now())
1438
+ if (current.availability.recoveryStarted && !current.availability.terminal && decisions.some((decision) => decision.kind === 'terminal' || decision.kind === 'error')) throw new Error('availability_terminal_policy_owned')
1439
+ for (const decision of decisions) {
1440
+ if (decision.kind === 'operation') {
1441
+ const checked = validateBookingReadAction(decision.action)
1442
+ if (!checked.ok) throw new Error(`invalid_action:${errorText(checked)}`)
1443
+ if (!validateBookingIntentProjection(decision.intent).ok) throw new Error('invalid_booking_intent')
1444
+ }
1445
+ }
1446
+ const run = this.ledger.db.transaction(() => {
1447
+ const events: BookingSurfaceEvent[] = []
1448
+ const before = this.requireTask(taskId)
1449
+ assertReplayUpgradeReanchored(before)
1450
+ assertAvailabilityLiveness(before, decisions, this.now())
1451
+ if (before.availability.recoveryStarted && !before.availability.terminal && decisions.some((decision) => decision.kind === 'terminal' || decision.kind === 'error')) throw new Error('availability_terminal_policy_owned')
1452
+ for (const decision of decisions) {
1453
+ if (decision.kind !== 'question') continue
1454
+ const awaiting = before.awaitingApproval
1455
+ if (!awaiting || requestKey !== awaiting.presentationRequestKey || bookingDigest(decision.question.blocker) !== bookingDigest(awaiting.blocker) || decision.question.approvalOptions.length !== awaiting.options.length || decision.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => option.optionDigest === approval.optionDigest && bookingDigest(option) === bookingDigest(approval)))) throw new Error('approval_presentation_key_mismatch')
1456
+ this.ensureApprovalOffered(taskId, before.contextRef, awaiting, requestKey)
1457
+ }
1458
+ if (!options.suppressProgressStatuses) {
1459
+ if (includeSubmitted) events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'submitted' }))
1460
+ events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'working' }))
1461
+ }
1462
+ for (const decision of decisions) {
1463
+ if (decision.kind === 'operation') {
1464
+ events.push(this.issueOperationInTransaction(taskId, decision.action, decision.intent))
1465
+ events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'waiting_receipt' }))
1466
+ }
1467
+ else events.push(this.emitEventInTransaction(taskId, decision))
1468
+ }
1469
+ const state = this.requireTask(taskId)
1470
+ this.append(DECISION_BATCH, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, requestKey, events }, `booking-copilot:decision-batch:${taskId}:${requestKey}`)
1471
+ return events
1472
+ })
1473
+ return run.immediate()
1474
+ }
1475
+
1476
+ issueOperation(taskId: string, candidate: BookingReadAction, intentProjection: BookingIntentProjection): { schemaVersion: 'booking.surface'; eventId: string; taskId: string; contextRef: string; sequence: number; emittedAt: string; kind: 'operation'; action: BookingReadAction } {
1477
+ const validation = validateBookingReadAction(candidate)
1478
+ if (!validation.ok) throw new Error(`invalid_action:${errorText(validation)}`)
1479
+ assertTaskId(taskId); assertSafeRef(candidate.actionId); candidate.factRefs.forEach(assertSafeRef)
1480
+ return this.ledger.db.transaction(() => this.issueOperationInTransaction(taskId, candidate, intentProjection)).immediate()
1481
+ }
1482
+
1483
+ private issueOperationInTransaction(taskId: string, candidate: BookingReadAction, intentProjection?: BookingIntentProjection) {
1484
+ if (intentProjection === undefined || intentProjection === null) throw new Error('booking_intent_required')
1485
+ assertSafeRef(candidate.actionId); candidate.factRefs.forEach(assertSafeRef)
1486
+ const state = this.requireTask(taskId)
1487
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
1488
+ assertReplayUpgradeReanchored(state)
1489
+ if (state.pendingAction) {
1490
+ if (state.pendingAction.actionId !== candidate.actionId) throw new Error('receipt_required')
1491
+ if (!matchesCheckpoint(candidate, state.pendingAction)) throw new Error('action_conflict')
1492
+ return this.operationEvent(taskId, state.pendingAction, candidate)
1493
+ }
1494
+ if (candidate.contextRef !== state.contextRef) throw new Error('context_mismatch')
1495
+ if (!state.allowedActions.includes(candidate.kind)) throw new Error('unsupported_action')
1496
+ if (candidate.expectedRevision !== state.revision) throw new Error('stale_revision')
1497
+ if (state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS) throw new Error('operation_limit_reached')
1498
+ if (candidate.relaxationApprovalRef) throw new Error('approval_ref_planner_owned_forbidden')
1499
+ if (!state.workspaceSnapshot) throw new Error(`ledger_corrupt:${taskId}:workspace_missing`)
1500
+ const intent = bindIntentCheckpoint(state, candidate, intentProjection)
1501
+ if (!actionAdvancesIntent(candidate, intent.projection)) throw new Error('action_intent_mismatch')
1502
+ assertAvailabilityActionCompatible(state, candidate, this.now())
1503
+ if (!actionHitsCurrentOfferVersion(state.workspaceSnapshot, candidate, this.now())) throw new Error('offer_version_not_loaded')
1504
+ let action = candidate
1505
+ const availability = reduceAvailabilityAction(state.availability, state.workspaceSnapshot, candidate)
1506
+ if (state.awaitingApproval) {
1507
+ const approval = state.awaitingApproval
1508
+ if (!approval.approval || Date.parse(approval.expiresAt) <= Date.parse(this.now())) throw new Error('approval_expired')
1509
+ const ref: RelaxationApprovalRef = {
1510
+ approvalId: approval.approval.approvalId, blockerId: approval.blocker.blockerId,
1511
+ contextRef: state.contextRef, sourceTurnId: approval.sourceTurnId,
1512
+ presentationRequestKey: approval.presentationRequestKey, sourceActionId: approval.blocker.sourceActionId,
1513
+ targetActionId: candidate.actionId, sourceRevision: candidate.expectedRevision,
1514
+ targetActionKind: candidate.kind, to: approval.approval.to, expiresAt: approval.expiresAt,
1515
+ nonce: approval.nonce, sourceReceiptDigest: approval.blocker.sourceReceiptDigest,
1516
+ scope: approval.blocker.scope, code: approval.blocker.code, criterionPath: approval.blocker.criterionPath,
1517
+ valueDigest: approval.blocker.valueDigest,
1518
+ }
1519
+ action = { ...candidate, relaxationApprovalRef: ref }
1520
+ this.append(APPROVAL_CONSUMED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, approval, ref, approvalDigest: bookingDigest(ref) }, `booking-copilot:approval-consumed:${taskId}:${approval.approval.approvalId}:${approval.nonce}`)
1521
+ }
1522
+ const eventId = this.idFactory('event'); const emittedAt = this.now(); const sequence = state.lastSequence + 1
1523
+ const event = { schemaVersion: 'booking.surface' as const, eventId, taskId, contextRef: state.contextRef, sequence, emittedAt, kind: 'operation' as const, action }
1524
+ const eventValidation = validateBookingSurfaceEvent(event)
1525
+ if (!eventValidation.ok) throw new Error(`invalid_operation_event:${errorText(eventValidation)}`)
1526
+ if (!state.lastTurnId) throw new Error(`ledger_corrupt:${taskId}:turn_id`)
1527
+ this.appendAction(taskId, state.contextRef, checkpointFor(action, eventId, sequence, emittedAt, state.lastTurnId), state.operationCount + 1, availability, intent)
1528
+ return event
1529
+ }
1530
+
1531
+ continueWithReceipt(turn: Extract<BookingCopilotTurn, { kind: 'action.receipt.continuation' }>): BookingCopilotTaskState {
1532
+ const validation = validateBookingSurface(turn)
1533
+ if (!validation.ok) throw new Error(`invalid_receipt_continuation:${errorText(validation)}`)
1534
+ assertWorkspaceLoadedOfferRefsUnique(turn.workspace)
1535
+ assertCanonicalReceipt(turn.receipt)
1536
+ const run = this.ledger.db.transaction(() => {
1537
+ const state = this.requireTask(turn.taskId)
1538
+ assertReplayUpgradeReanchored(state)
1539
+ if (turn.workspace.contextRef !== state.contextRef || turn.receipt.contextRef !== state.contextRef) throw new Error('context_mismatch')
1540
+ const receiptWorkspace = bookingWorkspaceDigest(turn.workspace)
1541
+ const receiptWorkspaceSemantic = bookingWorkspaceSemanticDigest(turn.workspace)
1542
+ if (!state.workspaceSnapshot || !workspaceBoundaryMatches(state.workspaceSnapshot, turn.workspace)) throw new Error('workspace_mismatch')
1543
+ const existing = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ? ORDER BY seq`).all(this.ledger.tenant, turn.taskId, RECEIPT) as Array<{ payload: string }>
1544
+ const receiptDigest = canonicalReceiptDigest(turn.receipt)
1545
+ if (state.legacySuppressedReceiptActionIds?.includes(turn.receipt.actionId)) throw new Error('request_key_invalidated')
1546
+ for (const row of existing) {
1547
+ const prior = JSON.parse(row.payload) as ReceiptPayload
1548
+ if (prior.receipt.actionId !== turn.receipt.actionId) continue
1549
+ if (prior.receiptDigest !== receiptDigest) throw new Error('receipt_conflict')
1550
+ if (prior.workspaceDigest !== receiptWorkspace) throw new Error('workspace_mismatch')
1551
+ return state
1552
+ }
1553
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
1554
+ if (!state.pendingAction) throw new Error('unexpected_receipt')
1555
+ if (state.pendingAction.actionId !== turn.receipt.actionId) throw new Error('receipt_action_mismatch')
1556
+ if (turn.receipt.revision < state.pendingAction.expectedRevision) throw new Error('revision_regression')
1557
+ if (turn.receipt.resultContract.blockers.some((blocker) => blocker.sourceActionId !== state.pendingAction!.actionId)) throw new Error('receipt_source_action_mismatch')
1558
+ if (!workspacePostActionMatches(state.workspaceSnapshot, turn.workspace, state.pendingAction, turn.receipt)) throw new Error('workspace_mismatch')
1559
+ if (!receiptObservationMatchesAction(turn.receipt.observation.kind, state.pendingAction.kind)) throw new Error('receipt_observation_action_mismatch')
1560
+ if (!receiptTargetMatchesAction(turn.receipt, state.pendingAction)) throw new Error('receipt_target_mismatch')
1561
+ assertOffersReceiptWorkspaceBinding(turn.receipt, turn.workspace, state.pendingAction)
1562
+ if (state.pendingAction.kind === 'offer.check' && turn.receipt.status === 'applied' && !verifiedOfferUnexpired(turn.workspace, this.now())) throw new Error('receipt_verified_offer_expired')
1563
+ // Failed/stale query receipts may carry a typed gap observation; only an
1564
+ // offers.state observation can claim passive offer provenance.
1565
+ if (turn.receipt.resultContract.relaxationsApplied.some((approval) => !this.approvalWasConsumed(turn.taskId, approval, state.pendingAction!.actionId))) throw new Error('receipt_relaxation_unauthorized')
1566
+ const pending = state.pendingAction
1567
+ if (turn.receipt.revision !== turn.workspace.revision) throw new Error('revision_mismatch')
1568
+ const availability = reduceAvailabilityReceipt(state.availability, turn.workspace, turn.receipt, pending)
1569
+ const availabilityTerminal = availabilityPolicyTerminal(availability)
1570
+ this.append(RECEIPT, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, receipt: turn.receipt, receiptDigest, operationCount: state.operationCount, workspaceDigest: receiptWorkspace, workspaceSemanticDigest: receiptWorkspaceSemantic, workspace: turn.workspace, availability, availabilityDigest: bookingDigest(availability), ...(availabilityTerminal ? { availabilityTerminal } : {}) }, `booking-copilot:receipt:${turn.taskId}:${turn.receipt.actionId}`)
1571
+ if (availabilityTerminalEndsTask(availabilityTerminal) || state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS) {
1572
+ const terminalState = this.requireTask(turn.taskId)
1573
+ const terminalEvent = this.terminalEvent(terminalState)
1574
+ this.appendTerminalEvent(turn.taskId, terminalEvent)
1575
+ this.append(DECISION_BATCH, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, requestKey: `receipt:${turn.receipt.actionId}:${receiptDigest}`, events: [terminalEvent] }, `booking-copilot:decision-batch:${turn.taskId}:receipt:${turn.receipt.actionId}:${receiptDigest}`)
1576
+ return terminalState
1577
+ }
1578
+ if (pending.kind === 'checkout.prepare' && state.activeIntent?.projection.target === 'checkout.prepared') {
1579
+ const terminalState = this.requireTask(turn.taskId)
1580
+ const terminalEvent = this.checkoutHandoffTerminalEvent(terminalState, checkoutReceiptPreparedHandoff(turn.receipt))
1581
+ this.appendTerminalEvent(turn.taskId, terminalEvent)
1582
+ this.append(DECISION_BATCH, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, requestKey: `receipt:${turn.receipt.actionId}:${receiptDigest}`, events: [terminalEvent] }, `booking-copilot:decision-batch:${turn.taskId}:receipt:${turn.receipt.actionId}:${receiptDigest}`)
1583
+ return this.requireTask(turn.taskId)
1584
+ }
1585
+ const blocker = turn.receipt.resultContract.blockers[0]
1586
+ if (blocker) {
1587
+ // Never derive this claim from the injectable id factory: production
1588
+ // and crash proofs must not make a presentation nonce guessable from
1589
+ // deterministic task/event ids.
1590
+ const nonce = randomUUID()
1591
+ const sourceTurnId = state.pendingAction?.sourceTurnId ?? state.lastTurnId ?? `ordinal:${state.userTurnCount}`
1592
+ const requestKey = presentationKey(turn.taskId, sourceTurnId, turn.receipt.actionId, receiptDigest)
1593
+ const approval = { blocker, options: [approvalOption(turn.taskId, state.contextRef, sourceTurnId, requestKey, blocker, 'prefer', nonce), approvalOption(turn.taskId, state.contextRef, sourceTurnId, requestKey, blocker, 'drop', nonce)], optionsEmitted: false, approval: undefined as never, nonce, expiresAt: new Date(Date.parse(this.now()) + this.approvalTtlMs).toISOString(), sourceTurnId, presentationRequestKey: requestKey }
1594
+ this.append(APPROVAL_GRANTED, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, approval, approvalDigest: bookingDigest(approval) }, `booking-copilot:approval-awaiting:${turn.taskId}:${turn.receipt.actionId}`)
1595
+ }
1596
+ return this.requireTask(turn.taskId)
1597
+ })
1598
+ return run.immediate()
1599
+ }
1600
+
1601
+ emitEvent(taskId: string, draft: BookingSurfaceEventDraft): Exclude<BookingSurfaceEvent, { kind: 'operation' }> {
1602
+ return this.ledger.db.transaction(() => {
1603
+ const state = this.requireTask(taskId)
1604
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
1605
+ assertReplayUpgradeReanchored(state)
1606
+ return this.emitEventInTransaction(taskId, draft)
1607
+ }).immediate()
1608
+ }
1609
+
1610
+ private emitEventInTransaction(taskId: string, draft: BookingSurfaceEventDraft) {
1611
+ const state = this.requireTask(taskId)
1612
+ assertReplayUpgradeReanchored(state)
1613
+ const safeDraft = draft.kind === 'error'
1614
+ ? {
1615
+ ...draft,
1616
+ error: {
1617
+ ...draft.error,
1618
+ code: normalizeBookingErrorCode(draft.error.code),
1619
+ message: safeBookingErrorMessage(normalizeBookingErrorCode(draft.error.code)),
1620
+ // An error event closes this task immediately. Until the public
1621
+ // protocol has an idempotent same-task retry command, claiming
1622
+ // that such an event is retryable would send the client into a
1623
+ // guaranteed task_terminal response on its next request.
1624
+ retryable: false,
1625
+ },
1626
+ }
1627
+ : draft
1628
+ const event = { schemaVersion: 'booking.surface' as const, eventId: this.idFactory('event'), taskId, contextRef: state.contextRef, sequence: state.lastSequence + 1, emittedAt: this.now(), ...safeDraft } as Exclude<BookingSurfaceEvent, { kind: 'operation' }>
1629
+ const checked = validateBookingSurfaceEvent(event)
1630
+ if (!checked.ok) throw new Error(`invalid_event:${errorText(checked)}`)
1631
+ this.append(EVENT, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, eventId: event.eventId, sequence: event.sequence, emittedAt: event.emittedAt, eventKind: event.kind, ...(event.kind === 'status' ? { status: event.status } : {}), contentDigest: bookingDigest(safeDraft) }, `booking-copilot:event:${taskId}:${event.eventId}`)
1632
+ return event
1633
+ }
1634
+
1635
+ private operationEvent(taskId: string, checkpoint: BookingActionCheckpoint, action: BookingReadAction) {
1636
+ const replayAction = action.relaxationApprovalRef || !checkpoint.relaxationApprovalRef ? action : { ...action, relaxationApprovalRef: checkpoint.relaxationApprovalRef }
1637
+ return { schemaVersion: 'booking.surface' as const, eventId: checkpoint.eventId, taskId, contextRef: checkpoint.contextRef, sequence: checkpoint.sequence, emittedAt: checkpoint.emittedAt, kind: 'operation' as const, action: replayAction }
1638
+ }
1639
+ private terminalEvent(state: BookingCopilotTaskState): Extract<BookingSurfaceEvent, { kind: 'terminal' }> {
1640
+ if (!taskHasTerminalBudget(state)) throw new Error('task_not_terminal')
1641
+ const terminalCode = state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS ? 'operation_limit_reached' : state.availability.terminal?.code ?? 'operation_limit_reached'
1642
+ const event = { schemaVersion: 'booking.surface' as const, eventId: `terminal-${state.taskId}-${state.operationCount}`, taskId: state.taskId, contextRef: state.contextRef, sequence: state.lastSequence + 1, emittedAt: this.now(), kind: 'terminal' as const, terminal: { status: 'stopped' as const, summary: terminalCode, factRefs: [] } }
1643
+ const checked = validateBookingSurfaceEvent(event)
1644
+ if (!checked.ok) throw new Error(`invalid_terminal_event:${errorText(checked)}`)
1645
+ return event
1646
+ }
1647
+
1648
+ private checkoutHandoffTerminalEvent(state: BookingCopilotTaskState, prepared: boolean): Extract<BookingSurfaceEvent, { kind: 'terminal' }> {
1649
+ const event = { schemaVersion: 'booking.surface' as const, eventId: `terminal-${state.taskId}-${state.operationCount}`, taskId: state.taskId, contextRef: state.contextRef, sequence: state.lastSequence + 1, emittedAt: this.now(), kind: 'terminal' as const, terminal: { status: prepared ? 'completed' as const : 'stopped' as const, summary: prepared ? 'checkout_handoff_prepared' : 'checkout_handoff_not_prepared', factRefs: [] } }
1650
+ const checked = validateBookingSurfaceEvent(event)
1651
+ if (!checked.ok) throw new Error(`invalid_terminal_event:${errorText(checked)}`)
1652
+ return event
1653
+ }
1654
+
1655
+ private requireTask(taskId: string): BookingCopilotTaskState { const state = this.resumeTask(taskId); if (!state) throw new Error('task_not_found'); return state }
1656
+ private requestBinding(requestKey: string): RequestBindingPayload | null {
1657
+ const rows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND kind = ? ORDER BY seq DESC`).all(this.ledger.tenant, REQUEST_BINDING) as Array<{ payload: string }>
1658
+ for (const row of rows) {
1659
+ const binding = JSON.parse(row.payload) as RequestBindingPayload
1660
+ if (binding.requestKey === requestKey) {
1661
+ const state = this.resumeTask(binding.taskId)
1662
+ if (state?.legacySuppressedRequestBindingKeys?.includes(requestKey)) throw new Error('request_key_invalidated')
1663
+ return binding
1664
+ }
1665
+ }
1666
+ return null
1667
+ }
1668
+ private requestBindingPayload(requestKey: string, turn: UserTurn, input: BookingIngressRequestBindingInput): Omit<RequestBindingPayload, 'schema' | 'taskId' | 'contextRef'> {
1669
+ assertPrincipal(input.principal)
1670
+ assertRequestKey(requestKey)
1671
+ if (input.taskHandle) assertSafeRef(input.taskHandle)
1672
+ return {
1673
+ requestKey,
1674
+ requestDigest: bookingTurnDigest(turn),
1675
+ turnId: turn.turnId,
1676
+ workspaceDigest: bookingWorkspaceDigest(turn.workspace),
1677
+ workspaceSemanticDigest: bookingWorkspaceSemanticDigest(turn.workspace),
1678
+ surface: turn.workspace.surface,
1679
+ capabilityDigest: bookingDigest(turn.workspace.capabilities),
1680
+ principalDigest: bookingDigest({ subject: input.principal.subject }),
1681
+ scopeDigest: bookingDigest({ scope: input.principal.scope }),
1682
+ ...(input.taskHandle ? { taskHandle: input.taskHandle } : {}),
1683
+ }
1684
+ }
1685
+ private appendRequestBindingInTransaction(requestKey: string, turn: UserTurn, input: BookingIngressRequestBindingInput): void {
1686
+ assertRequestKey(requestKey)
1687
+ assertPrincipal(input.principal)
1688
+ const payload = this.requestBindingPayload(requestKey, turn, input)
1689
+ if (input.taskHandle) {
1690
+ const prior = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND kind = ?`).all(this.ledger.tenant, REQUEST_BINDING) as Array<{ payload: string }>
1691
+ for (const row of prior) {
1692
+ const binding = JSON.parse(row.payload) as RequestBindingPayload
1693
+ if (binding.taskHandle !== input.taskHandle) continue
1694
+ if (binding.principalDigest !== payload.principalDigest || binding.scopeDigest !== payload.scopeDigest) throw new Error('principal_conflict')
1695
+ if (binding.taskId !== turn.taskId || binding.contextRef !== turn.workspace.contextRef) throw new Error('task_handle_conflict')
1696
+ }
1697
+ }
1698
+ const existing = this.requestBinding(requestKey)
1699
+ if (existing) {
1700
+ if (existing.taskId !== turn.taskId || existing.contextRef !== turn.workspace.contextRef || existing.requestDigest !== payload.requestDigest || existing.turnId !== payload.turnId || existing.workspaceDigest !== payload.workspaceDigest || existing.workspaceSemanticDigest !== payload.workspaceSemanticDigest || existing.surface !== payload.surface || existing.capabilityDigest !== payload.capabilityDigest || existing.principalDigest !== payload.principalDigest || existing.scopeDigest !== payload.scopeDigest || existing.taskHandle !== payload.taskHandle) throw new Error('request_conflict')
1701
+ return
1702
+ }
1703
+ this.append(REQUEST_BINDING, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: turn.workspace.contextRef, ...payload }, `booking-copilot:request-binding:${requestKey}`)
1704
+ }
1705
+ private normalizeDecisionBatchFromDurableRows(taskId: string, batchEvents: readonly BookingSurfaceEvent[], batchSeq: number): BookingSurfaceEvent[] | null {
1706
+ const individualRows = this.ledger.db.prepare(`SELECT kind, payload FROM events WHERE tenant_id = ? AND run_id = ? AND seq < ? AND kind IN (?, ?) ORDER BY seq`).all(this.ledger.tenant, taskId, batchSeq, EVENT, ACTION) as Array<{ kind: string; payload: string }>
1707
+ const turnRows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ? AND seq < ?`).all(this.ledger.tenant, taskId, TURN, batchSeq) as Array<{ payload: string }>
1708
+ const sourceWorkspaces = new Map<string, BookingWorkspaceSnapshot>()
1709
+ for (const row of turnRows) {
1710
+ try {
1711
+ const turn = JSON.parse(row.payload) as TurnPayload
1712
+ sourceWorkspaces.set(turn.turnId, normalizeWorkspaceForReplay(turn.workspace))
1713
+ } catch {
1714
+ return null
1715
+ }
1716
+ }
1717
+ const recordedEvents = new Map<string, EventPayload>()
1718
+ const recordedActions = new Map<string, BookingActionCheckpoint>()
1719
+ for (const row of individualRows) {
1720
+ if (row.kind === EVENT) {
1721
+ const payload = JSON.parse(row.payload) as EventPayload
1722
+ const key = consumedIdentityKey(payload.eventId, payload.sequence)
1723
+ if (recordedEvents.has(key) || recordedActions.has(key)) return null
1724
+ recordedEvents.set(key, payload)
1725
+ } else if (row.kind === ACTION) {
1726
+ const payload = JSON.parse(row.payload) as ActionPayload
1727
+ let action = payload.action
1728
+ try {
1729
+ action = normalizeActionCheckpointForReplay(action, sourceWorkspaces.get(action.sourceTurnId))
1730
+ } catch {
1731
+ return null
1732
+ }
1733
+ const key = consumedIdentityKey(action.eventId, action.sequence)
1734
+ if (recordedEvents.has(key) || recordedActions.has(key)) return null
1735
+ recordedActions.set(key, action)
1736
+ }
1737
+ }
1738
+ let lastBatchSequence = -1
1739
+ const seen = new Set<string>()
1740
+ const normalizedEvents: BookingSurfaceEvent[] = []
1741
+ for (const event of batchEvents) {
1742
+ if (!Number.isSafeInteger(event.sequence) || event.sequence <= lastBatchSequence) return null
1743
+ lastBatchSequence = event.sequence
1744
+ const key = consumedIdentityKey(event.eventId, event.sequence)
1745
+ if (seen.has(key)) return null
1746
+ seen.add(key)
1747
+ if (event.kind === 'operation') {
1748
+ const checkpoint = recordedActions.get(key)
1749
+ if (!checkpoint || !operationEventMatchesAction(event, checkpoint)) return null
1750
+ const normalized = { ...event, action: actionFromOperationEventForReplay(event, checkpoint) } as BookingSurfaceEvent
1751
+ if (!validateBookingSurfaceEvent(normalized).ok) return null
1752
+ normalizedEvents.push(normalized)
1753
+ } else {
1754
+ const recorded = recordedEvents.get(key)
1755
+ if (!recorded || !validateBookingSurfaceEvent(event).ok || !eventPayloadMatchesEvent(recorded, event)) return null
1756
+ normalizedEvents.push(event)
1757
+ }
1758
+ }
1759
+ return normalizedEvents
1760
+ }
1761
+ private rows(taskId: string): Row[] { return this.ledger.db.prepare(`SELECT seq, kind, payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ORDER BY seq`).all(this.ledger.tenant, taskId, STARTED, TURN, ACTION, RECEIPT, EVENT, APPROVAL_GRANTED, APPROVAL_OFFERED, APPROVAL_CONSUMED, DECISION_BATCH, REQUEST_BINDING) as Row[] }
1762
+ private append(kind: string, taskId: string, payload: BasePayload, idemKey: string): void {
1763
+ const result = this.ledger.db.prepare(`INSERT OR IGNORE INTO events (tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(this.ledger.tenant, this.now(), ACTOR, kind, taskId, JSON.stringify(payload), idemKey, taskId)
1764
+ if (result.changes === 0) throw new Error(`ledger_write_failed:${kind}`)
1765
+ }
1766
+ private appendTurn(taskId: string, contextRef: string, requestDigest: string, workspaceDigest: string, workspaceSemanticDigest: string, ordinal: number, turnId: string, workspace?: BookingWorkspaceSnapshot, preserveIntent = false): void { if (!workspace) throw new Error('ledger_write_failed:turn_workspace'); this.append(TURN, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, workspace, turnId, preserveIntent }, `booking-copilot:turn:${taskId}:${turnId}`) }
1767
+ private appendApproval(taskId: string, contextRef: string, approval: BookingApprovalState): void { this.append(APPROVAL_GRANTED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, approval, approvalDigest: bookingDigest(approval) }, `booking-copilot:approval-granted:${taskId}:${approval.approval?.approvalId ?? 'pending'}:${approval.nonce}`) }
1768
+ private appendAction(taskId: string, contextRef: string, action: BookingActionCheckpoint, operationCount: number, availability: AvailabilityPolicyState, intent: BookingIntentCheckpoint): void { this.append(ACTION, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, action, operationCount, availability, availabilityDigest: bookingDigest(availability), intent }, `booking-copilot:action:${taskId}:${action.actionId}`) }
1769
+ private appendTerminalEvent(taskId: string, event: Extract<BookingSurfaceEvent, { kind: 'terminal' }>): void {
1770
+ const payload: EventPayload = { schema: LEDGER_SCHEMA, taskId, contextRef: event.contextRef, eventId: event.eventId, sequence: event.sequence, emittedAt: event.emittedAt, eventKind: 'terminal', contentDigest: bookingDigest(event.terminal) }
1771
+ const idemKey = `booking-copilot:terminal-event:${taskId}`
1772
+ const result = this.ledger.db.prepare(`INSERT OR IGNORE INTO events (tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(this.ledger.tenant, this.now(), ACTOR, EVENT, taskId, JSON.stringify(payload), idemKey, taskId)
1773
+ if (result.changes === 0) {
1774
+ const existing = this.ledger.db.prepare('SELECT payload FROM events WHERE tenant_id = ? AND idem_key = ?').get(this.ledger.tenant, idemKey) as { payload: string } | undefined
1775
+ if (!existing || (JSON.parse(existing.payload) as EventPayload).contentDigest !== payload.contentDigest) throw new Error('ledger_write_failed:terminal_event')
1776
+ }
1777
+ }
1778
+ private ensureApprovalOffered(taskId: string, contextRef: string, approval: BookingApprovalState, requestKey: string): void {
1779
+ if (requestKey !== approval.presentationRequestKey) throw new Error('approval_presentation_key_mismatch')
1780
+ const idemKey = `booking-copilot:approval-offered:${taskId}:${requestKey}`
1781
+ const exists = this.ledger.db.prepare('SELECT 1 AS present FROM events WHERE tenant_id = ? AND idem_key = ?').get(this.ledger.tenant, idemKey) as { present: number } | undefined
1782
+ if (!exists) this.append(APPROVAL_OFFERED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, approval, approvalDigest: bookingDigest(approval) }, idemKey)
1783
+ }
1784
+ private approvalWasConsumed(taskId: string, approval: RelaxationApproval, targetActionId: string): boolean {
1785
+ const state = this.resumeTask(taskId)
1786
+ if (state?.legacySuppressedApprovalTargetActionIds?.includes(targetActionId)) return false
1787
+ const rows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ?`).all(this.ledger.tenant, taskId, APPROVAL_CONSUMED) as Array<{ payload: string }>
1788
+ return rows.some(({ payload }) => {
1789
+ const consumed = JSON.parse(payload) as ApprovalPayload
1790
+ return consumed.ref?.targetActionId === targetActionId && consumed.approval?.approval && bookingDigest(consumed.approval.approval) === bookingDigest(approval)
1791
+ })
1792
+ }
1793
+ }