@danceiny/gotry 0.0.1-rc.16 → 0.0.1-rc.17

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 (229) hide show
  1. package/README.md +47 -13
  2. package/README.zh-CN.md +20 -10
  3. package/bin/gotry-booking-copilot.js +53 -0
  4. package/bin/gotry-bootstrap.js +111 -51
  5. package/bin/gotry-inner.js +442 -60
  6. package/bin/gotry-runtime-resolution.d.ts +27 -0
  7. package/bin/gotry-runtime-resolution.js +50 -0
  8. package/bin/gotry.js +1 -1
  9. package/cordis.gotry-patch.yml +5 -1
  10. package/dist/capabilities/agent-reach-deep.js +1 -1
  11. package/dist/capabilities/agent-reach.js +1 -1
  12. package/dist/capabilities/anything.js +1 -1
  13. package/dist/capabilities/artifacts.js +1 -1
  14. package/dist/capabilities/effect.js +1 -1
  15. package/dist/capabilities/fact-log.js +1 -1
  16. package/dist/capabilities/flyai.js +20 -6
  17. package/dist/capabilities/hbcli.js +1 -1
  18. package/dist/capabilities/incident-log.js +1 -1
  19. package/dist/capabilities/model-override.js +18 -0
  20. package/dist/capabilities/opensky.js +1 -1
  21. package/dist/capabilities/resilience.js +1 -1
  22. package/dist/capabilities/session/action-cache.js +1 -1
  23. package/dist/capabilities/session/adapters/ctrip-flight.js +1 -1
  24. package/dist/capabilities/session/adapters/meituan-local.js +1 -1
  25. package/dist/capabilities/session/benchmark.js +1 -1
  26. package/dist/capabilities/session/extension-bridge.js +19 -6
  27. package/dist/capabilities/session/extension-channel.js +3 -3
  28. package/dist/capabilities/session/extension-distribution.js +234 -0
  29. package/dist/capabilities/session/extract.js +1 -1
  30. package/dist/capabilities/session/golden-score.js +92 -0
  31. package/dist/capabilities/session/health-watch.js +1 -1
  32. package/dist/capabilities/session/read-guard.js +1 -1
  33. package/dist/capabilities/session/static-flight-golden.js +137 -0
  34. package/dist/capabilities/session/transport.js +1 -1
  35. package/dist/capabilities/session/wizard.js +21 -251
  36. package/dist/capabilities/session-consent.js +1 -1
  37. package/dist/capabilities/session-login.js +11 -5
  38. package/dist/capabilities/session-search.js +46 -4
  39. package/dist/capabilities/weather.js +168 -46
  40. package/dist/data/session-golden-20.json +25 -0
  41. package/dist/data/sf-golden-manifest.json +102 -0
  42. package/dist/data/sf-static-routes.json +91 -0
  43. package/dist/scripts/action-cache-tests.js +1 -1
  44. package/dist/scripts/agent-planning-budget-e2e.js +227 -0
  45. package/dist/scripts/agent-planning-budget-tests.js +173 -0
  46. package/dist/scripts/agent-reach-deep-tests.js +1 -1
  47. package/dist/scripts/agent-reach-tests.js +1 -1
  48. package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
  49. package/dist/scripts/anything-tests.js +1 -1
  50. package/dist/scripts/async-collect.js +1 -1
  51. package/dist/scripts/benchmark-environment-bridge-e2e.js +981 -0
  52. package/dist/scripts/benchmark-environment-bridge-tests.js +2544 -0
  53. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +335 -0
  54. package/dist/scripts/booking-copilot-availability-policy-v2-tests.js +1355 -0
  55. package/dist/scripts/booking-copilot-bin-proof-tests.js +89 -0
  56. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +113 -0
  57. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +356 -0
  58. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +557 -0
  59. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +140 -0
  60. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +202 -0
  61. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +102 -0
  62. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +374 -0
  63. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +447 -0
  64. package/dist/scripts/booking-copilot-runtime-proof-tests.js +205 -0
  65. package/dist/scripts/booking-copilot-server-proof-tests.js +317 -0
  66. package/dist/scripts/booking-copilot-startup-proof-tests.js +283 -0
  67. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +3935 -0
  68. package/dist/scripts/booking-saga-tests.js +1 -1
  69. package/dist/scripts/booking-surface-contract-proof-tests.js +393 -0
  70. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +1174 -0
  71. package/dist/scripts/bootstrap-tests.js +30 -10
  72. package/dist/scripts/build-changelog.js +1 -1
  73. package/dist/scripts/changelog-tests.js +1 -1
  74. package/dist/scripts/companion-tests.js +1 -1
  75. package/dist/scripts/diff-test.js +1 -1
  76. package/dist/scripts/dsh-runtime-closure-tests.js +225 -0
  77. package/dist/scripts/dsh-runtime-closure.js +150 -0
  78. package/dist/scripts/effect-tests.js +1 -1
  79. package/dist/scripts/engine-run.js +1 -1
  80. package/dist/scripts/engine-tests.js +1 -1
  81. package/dist/scripts/evaluation-cadence-tests.js +347 -0
  82. package/dist/scripts/evaluation-contract-tests.js +574 -0
  83. package/dist/scripts/extension-distribution-cli.js +35 -0
  84. package/dist/scripts/extension-distribution-tests.js +341 -0
  85. package/dist/scripts/extension-tests.js +128 -23
  86. package/dist/scripts/fact-gate-tests.js +1 -1
  87. package/dist/scripts/flyai-tests.js +1 -1
  88. package/dist/scripts/hbcli-e2e-tests.js +1 -1
  89. package/dist/scripts/hbcli-tests.js +1 -1
  90. package/dist/scripts/health-watch-cli.js +1 -1
  91. package/dist/scripts/i18n-tests.js +1 -1
  92. package/dist/scripts/incident-tests.js +1 -1
  93. package/dist/scripts/journey-tests.js +1 -1
  94. package/dist/scripts/ledger-tests.js +1 -1
  95. package/dist/scripts/ledger-workflow-crash.js +1 -1
  96. package/dist/scripts/memory-capture-tests.js +1 -1
  97. package/dist/scripts/memory-decay-tests.js +1 -1
  98. package/dist/scripts/memory-metrics.js +1 -1
  99. package/dist/scripts/memory-value-report.js +1 -1
  100. package/dist/scripts/model-override-e2e.js +176 -0
  101. package/dist/scripts/nightly-evidence-tests.js +1 -1
  102. package/dist/scripts/nightly-evidence.js +1 -1
  103. package/dist/scripts/nudge-digest.js +1 -1
  104. package/dist/scripts/onboarding-tests.js +21 -53
  105. package/dist/scripts/opensky-check.js +1 -1
  106. package/dist/scripts/opensky-tests.js +1 -1
  107. package/dist/scripts/pnpm-dsh-closure-proof.js +20 -0
  108. package/dist/scripts/price-drift-tests.js +1 -1
  109. package/dist/scripts/price-drift-watch.js +1 -1
  110. package/dist/scripts/probe-poi-tests.js +1 -1
  111. package/dist/scripts/product-metrics.js +1 -1
  112. package/dist/scripts/publish-preverify.js +40 -4
  113. package/dist/scripts/realtime-pricing-tests.js +1 -1
  114. package/dist/scripts/replay-async.js +1 -1
  115. package/dist/scripts/replay-real.js +1 -1
  116. package/dist/scripts/replay.js +1 -1
  117. package/dist/scripts/session-attach-diagnose.js +1 -1
  118. package/dist/scripts/session-attach-poc.js +1 -1
  119. package/dist/scripts/session-benchmark.js +1 -1
  120. package/dist/scripts/session-extract-tests.js +1 -1
  121. package/dist/scripts/session-login.js +1 -1
  122. package/dist/scripts/session-tests.js +70 -20
  123. package/dist/scripts/sf-live-benchmark.js +338 -0
  124. package/dist/scripts/sf-live-cli-tests.js +21 -0
  125. package/dist/scripts/sf-soft-score-tests.js +108 -0
  126. package/dist/scripts/sf-summary.js +93 -0
  127. package/dist/scripts/skeleton-check.js +1 -1
  128. package/dist/scripts/skeleton-integration-test.js +1 -1
  129. package/dist/scripts/skills-contract-tests.js +1 -1
  130. package/dist/scripts/smoke-session-gate-tests.js +29 -0
  131. package/dist/scripts/smoke.js +79 -33
  132. package/dist/scripts/state-cli-tests.js +1 -1
  133. package/dist/scripts/state-cli.js +1 -1
  134. package/dist/scripts/static-golden-tests.js +299 -0
  135. package/dist/scripts/time-eval-tests.js +1 -1
  136. package/dist/scripts/travel-timeline-tests.js +1 -1
  137. package/dist/scripts/unified-tests.js +1 -1
  138. package/dist/scripts/weather-tests.js +694 -44
  139. package/dist/scripts/z3-race-tests.js +1 -1
  140. package/dist/src/artifact-gate.js +1 -1
  141. package/dist/src/benchmark-agent-conformance.js +370 -0
  142. package/dist/src/benchmark-environment-bridge.js +384 -0
  143. package/dist/src/benchmark-headless-child-diagnostics.js +173 -0
  144. package/dist/src/benchmark-tool-isolation.js +124 -0
  145. package/dist/src/bookable-facts.js +1 -1
  146. package/dist/src/booking-saga.js +1 -1
  147. package/dist/src/booking-surface/availability-policy-v2.js +830 -0
  148. package/dist/src/booking-surface/canonical-schema.js +113 -0
  149. package/dist/src/booking-surface/contracts-v2.js +89 -0
  150. package/dist/src/booking-surface/contracts.js +46 -0
  151. package/dist/src/booking-surface/dsh-planner.js +453 -0
  152. package/dist/src/booking-surface/dsh-plugin.js +93 -0
  153. package/dist/src/booking-surface/error-codes.js +94 -0
  154. package/dist/src/booking-surface/index.js +15 -0
  155. package/dist/src/booking-surface/profile.js +68 -0
  156. package/dist/src/booking-surface/runtime-v2.js +1771 -0
  157. package/dist/src/booking-surface/runtime.js +351 -0
  158. package/dist/src/booking-surface/server-v2.js +334 -0
  159. package/dist/src/booking-surface/server.js +302 -0
  160. package/dist/src/booking-surface/startup.js +159 -0
  161. package/dist/src/booking-surface/validation-v2.js +319 -0
  162. package/dist/src/booking-surface/validation.js +809 -0
  163. package/dist/src/bridge.js +1 -1
  164. package/dist/src/companions.js +1 -1
  165. package/dist/src/contracts.js +1 -1
  166. package/dist/src/dsh-llm.js +1 -1
  167. package/dist/src/engine.js +1 -1
  168. package/dist/src/evaluation-cadence.js +234 -0
  169. package/dist/src/evaluation-contracts.js +906 -0
  170. package/dist/src/i18n.js +1 -1
  171. package/dist/src/index.js +50 -19
  172. package/dist/src/journey.js +1 -1
  173. package/dist/src/loop.js +1 -1
  174. package/dist/src/memory-capture.js +1 -1
  175. package/dist/src/memory-decay.js +1 -1
  176. package/dist/src/memory-utility.js +1 -1
  177. package/dist/src/mock-llm.js +1 -1
  178. package/dist/src/model.js +1 -1
  179. package/dist/src/realtime-pricing.js +1 -1
  180. package/dist/src/slot-spec.js +1 -1
  181. package/dist/src/state-ledger.js +2 -1
  182. package/dist/src/time-anchor.js +1 -1
  183. package/dist/src/tool-budget.js +136 -0
  184. package/dist/src/tool-packet.js +1 -1
  185. package/dist/src/travel-slots.js +1 -1
  186. package/dist/src/travel-timeline.js +1 -1
  187. package/dist/src/unified.js +1 -1
  188. package/dist/src/wish-pool.js +1 -1
  189. package/dist/src/z3-shared.js +1 -1
  190. package/extension/README.md +31 -7
  191. package/package.json +286 -11
  192. package/schemas/booking.surface.v1.schema.json +927 -0
  193. package/schemas/booking.surface.v2.schema.json +61 -0
  194. package/ts/capabilities/flyai.ts +16 -3
  195. package/ts/capabilities/session/extension-bridge.ts +37 -14
  196. package/ts/capabilities/session/extension-channel.ts +6 -3
  197. package/ts/capabilities/session/extension-distribution.ts +264 -0
  198. package/ts/capabilities/session/golden-score.ts +139 -0
  199. package/ts/capabilities/session/health-watch.ts +1 -1
  200. package/ts/capabilities/session/static-flight-golden.ts +209 -0
  201. package/ts/capabilities/session/wizard.ts +34 -176
  202. package/ts/capabilities/session-login.ts +15 -5
  203. package/ts/capabilities/session-search.ts +40 -3
  204. package/ts/capabilities/weather.ts +141 -52
  205. package/ts/package.json +3 -3
  206. package/ts/src/benchmark-agent-conformance.ts +448 -0
  207. package/ts/src/benchmark-environment-bridge.ts +348 -0
  208. package/ts/src/benchmark-headless-child-diagnostics.ts +184 -0
  209. package/ts/src/benchmark-tool-isolation.ts +166 -0
  210. package/ts/src/booking-surface/availability-policy-v2.ts +523 -0
  211. package/ts/src/booking-surface/canonical-schema.js +113 -0
  212. package/ts/src/booking-surface/contracts-v2.ts +118 -0
  213. package/ts/src/booking-surface/contracts.ts +380 -0
  214. package/ts/src/booking-surface/dsh-planner.ts +452 -0
  215. package/ts/src/booking-surface/dsh-plugin.js +93 -0
  216. package/ts/src/booking-surface/error-codes.ts +101 -0
  217. package/ts/src/booking-surface/index.ts +12 -0
  218. package/ts/src/booking-surface/profile.ts +42 -0
  219. package/ts/src/booking-surface/runtime-v2.ts +1466 -0
  220. package/ts/src/booking-surface/runtime.ts +483 -0
  221. package/ts/src/booking-surface/server-v2.ts +247 -0
  222. package/ts/src/booking-surface/server.ts +324 -0
  223. package/ts/src/booking-surface/startup.ts +196 -0
  224. package/ts/src/booking-surface/validation-v2.ts +205 -0
  225. package/ts/src/booking-surface/validation.ts +453 -0
  226. package/ts/src/index.ts +64 -11
  227. package/ts/src/state-ledger.ts +1 -0
  228. package/ts/src/tool-budget.ts +165 -0
  229. package/dist/scripts/wizard-bootstrap.js +0 -32
@@ -0,0 +1,1466 @@
1
+ /** Durable task-scoped runtime for the closed Booking Surface v2 contract. */
2
+
3
+ import { createHash, randomUUID } from 'node:crypto'
4
+ import type { StateLedger } from '../state-ledger.ts'
5
+ import {
6
+ BOOKING_READ_ACTION_KINDS_V2,
7
+ BOOKING_COPILOT_MAX_OPERATIONS_V2,
8
+ type ActionReceiptV2,
9
+ type BookingCopilotTurnV2,
10
+ type BookingReadActionV2,
11
+ type BookingReadActionKindV2,
12
+ type BookingSurfaceEventV2,
13
+ type BookingSurfaceV2,
14
+ type BookingWorkspaceSnapshotV2,
15
+ type BookingWorkspaceIngressSnapshotV2,
16
+ type BookingRequestKeyV2,
17
+ type BookingInternalDecisionKeyV2,
18
+ type BookingIngressPrincipalV2,
19
+ type CriterionBlockerV2,
20
+ type IngressTurnV2,
21
+ type RelaxationApprovalRefV2,
22
+ type RelaxationApprovalV2,
23
+ type UserTurnV2,
24
+ } from './contracts-v2.ts'
25
+ import {
26
+ validateApprovalAgainstBlocker,
27
+ validateCriterionBlockerV2,
28
+ validateBookingReadActionV2,
29
+ validateBookingSurfaceEventV2,
30
+ validateBookingSurfaceV2,
31
+ isBookingDateTimeV2,
32
+ isSafeBookingRequestKeyV2,
33
+ } from './validation-v2.ts'
34
+ import {
35
+ assertWorkspaceLoadedOfferRefsUniqueV2,
36
+ canIssueOfferCheckV2,
37
+ createAvailabilityPolicyV2,
38
+ reduceAvailabilityActionV2,
39
+ reduceAvailabilityReceiptV2,
40
+ validateOffersReceiptWorkspaceV2,
41
+ availabilityPolicyResultV2,
42
+ validateAvailabilityPolicyV2,
43
+ type AvailabilityExhaustionV2,
44
+ type AvailabilityPolicyStateV2,
45
+ } from './availability-policy-v2.ts'
46
+ import { normalizeBookingErrorCode, safeBookingErrorMessage } from './error-codes.ts'
47
+
48
+ const LEDGER_SCHEMA = 'booking.copilot.ledger.v2' as const
49
+ const ACTOR = 'system:booking-copilot-v2'
50
+ const STARTED = 'booking.copilot.v2.task.started'
51
+ const TURN = 'booking.copilot.v2.user.turn.observed'
52
+ const ACTION = 'booking.copilot.v2.action.issued'
53
+ const RECEIPT = 'booking.copilot.v2.receipt.observed'
54
+ const EVENT = 'booking.copilot.v2.event.emitted'
55
+ const APPROVAL_GRANTED = 'booking.copilot.v2.approval.granted'
56
+ const APPROVAL_OFFERED = 'booking.copilot.v2.approval.option.offered'
57
+ const APPROVAL_CONSUMED = 'booking.copilot.v2.approval.consumed'
58
+ const DECISION_BATCH = 'booking.copilot.v2.decision.batch'
59
+ const REQUEST_BINDING = 'booking.copilot.v2.request.binding'
60
+
61
+ export type BookingCopilotTaskPhaseV2 = 'planning' | 'submitted' | 'working' | 'waiting_receipt' | 'input_required' | 'terminal' | 'error'
62
+
63
+ export interface BookingApprovalStateV2 {
64
+ approval?: RelaxationApprovalV2
65
+ blocker: CriterionBlockerV2
66
+ options: RelaxationApprovalV2[]
67
+ optionsEmitted: boolean
68
+ nonce: string
69
+ expiresAt: string
70
+ sourceTurnId: string
71
+ presentationRequestKey: string
72
+ }
73
+
74
+ export interface BookingActionCheckpointV2 {
75
+ actionId: string
76
+ kind: BookingReadActionKindV2
77
+ contextRef: string
78
+ expectedRevision: number
79
+ factRefs: string[]
80
+ input: Record<string, any>
81
+ reasonDigest: string
82
+ inputDigest: string
83
+ actionDigest: string
84
+ eventId: string
85
+ sequence: number
86
+ emittedAt: string
87
+ sourceTurnId: string
88
+ relaxationApprovalRef?: RelaxationApprovalRefV2
89
+ }
90
+
91
+ export interface BookingCopilotTaskStateV2 {
92
+ schemaVersion: 'booking.surface.v2'
93
+ taskId: string
94
+ contextRef: string
95
+ surface: BookingSurfaceV2
96
+ revision: number
97
+ allowedActions: BookingReadActionKindV2[]
98
+ userTurnCount: number
99
+ /** Durable task-level operation ordinal, folded from action ledger rows. */
100
+ operationCount: number
101
+ lastTurnId?: string
102
+ phase: BookingCopilotTaskPhaseV2
103
+ lastSequence: number
104
+ pendingAction?: BookingActionCheckpointV2
105
+ lastReceipt?: ActionReceiptV2
106
+ awaitingApproval?: BookingApprovalStateV2
107
+ workspaceDigest?: string
108
+ workspaceSemanticDigest?: string
109
+ workspaceSnapshot?: BookingWorkspaceSnapshotV2
110
+ availability: AvailabilityPolicyStateV2
111
+ replayUpgradeRequired?: boolean
112
+ legacySuppressedDecisionRequestKeys?: string[]
113
+ legacySuppressedRequestBindingKeys?: string[]
114
+ legacySuppressedReceiptActionIds?: string[]
115
+ legacySuppressedApprovalTargetActionIds?: string[]
116
+ }
117
+
118
+ export type BookingSurfaceEventDraftV2 =
119
+ | Pick<Extract<BookingSurfaceEventV2, { kind: 'status' }>, 'kind' | 'status'>
120
+ | Pick<Extract<BookingSurfaceEventV2, { kind: 'question' }>, 'kind' | 'question'>
121
+ | Pick<Extract<BookingSurfaceEventV2, { kind: 'explanation' }>, 'kind' | 'explanation'>
122
+ | Pick<Extract<BookingSurfaceEventV2, { kind: 'terminal' }>, 'kind' | 'terminal'>
123
+ | Pick<Extract<BookingSurfaceEventV2, { kind: 'error' }>, 'kind' | 'error'>
124
+
125
+ export type BookingPlannerDecisionV2 =
126
+ | { kind: 'operation'; action: BookingReadActionV2 }
127
+ | BookingSurfaceEventDraftV2
128
+
129
+ export interface BookingPlannerSessionV2 {
130
+ next(input: { turn: BookingCopilotTurnV2; task: BookingCopilotTaskStateV2 }): Promise<readonly BookingPlannerDecisionV2[]>
131
+ }
132
+
133
+ export type BookingPlannerSessionFactoryV2 = (initialTask: BookingCopilotTaskStateV2) => BookingPlannerSessionV2
134
+
135
+ export interface BookingCopilotRuntimeOptionsV2 {
136
+ idFactory?: (prefix: string) => string
137
+ contextRefFactory?: () => string
138
+ now?: () => string
139
+ approvalTtlMs?: number
140
+ }
141
+
142
+ interface Row { seq: number; kind: string; payload: string }
143
+ interface BasePayload { schema: typeof LEDGER_SCHEMA; taskId: string; contextRef: string; [key: string]: unknown }
144
+ interface StartedPayload extends BasePayload { surface: BookingSurfaceV2; revision: number; allowedActions: BookingReadActionKindV2[]; workspaceDigest: string; workspaceSemanticDigest: string; workspace: BookingWorkspaceSnapshotV2; availability: AvailabilityPolicyStateV2; availabilityDigest: string }
145
+ interface TurnPayload extends BasePayload { requestDigest: string; workspaceDigest: string; workspaceSemanticDigest: string; workspace: BookingWorkspaceSnapshotV2; turnId: string }
146
+ interface ActionPayload extends BasePayload { action: BookingActionCheckpointV2; operationCount?: number; availability: AvailabilityPolicyStateV2; availabilityDigest: string }
147
+ interface ReceiptPayload extends BasePayload { receipt: ActionReceiptV2; receiptDigest: string; operationCount?: number; workspaceDigest: string; workspaceSemanticDigest: string; workspace: BookingWorkspaceSnapshotV2; availability: AvailabilityPolicyStateV2; availabilityDigest: string; availabilityTerminal?: AvailabilityExhaustionV2 }
148
+ interface EventPayload extends BasePayload { eventId: string; sequence: number; emittedAt: string; eventKind: Exclude<BookingSurfaceEventV2['kind'], 'operation'>; status?: 'submitted'|'working'|'waiting_receipt'|'input_required'; contentDigest: string }
149
+ interface ApprovalPayload extends BasePayload { approval: BookingApprovalStateV2; ref?: RelaxationApprovalRefV2; approvalDigest: string }
150
+ interface DecisionBatchPayload extends BasePayload { requestKey: string; events: BookingSurfaceEventV2[] }
151
+ interface RequestBindingPayload extends BasePayload { requestKey: BookingRequestKeyV2; requestDigest: string; turnId: string; workspaceDigest: string; workspaceSemanticDigest: string; surface: BookingSurfaceV2; capabilityDigest: string; principalDigest: string; scopeDigest: string; taskHandle?: string }
152
+ export interface BookingIngressRequestBindingInputV2 { requestKey: BookingRequestKeyV2; principal: BookingIngressPrincipalV2; taskHandle?: string }
153
+ interface ReplayConsumedIdentities { events: Map<string, EventPayload>; actions: Map<string, BookingActionCheckpointV2>; pendingBatchKeys: Set<string>; pendingBatchOpen: boolean }
154
+
155
+ function stable(value: unknown): unknown {
156
+ if (Array.isArray(value)) return value.map(stable)
157
+ if (!value || typeof value !== 'object') return value
158
+ const out: Record<string, unknown> = {}
159
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) out[key] = stable((value as Record<string, unknown>)[key])
160
+ return out
161
+ }
162
+
163
+ export function bookingV2Digest(value: unknown): string {
164
+ return createHash('sha256').update(JSON.stringify(stable(value))).digest('hex')
165
+ }
166
+
167
+ export function bookingV2WorkspaceDigest(workspace: BookingWorkspaceSnapshotV2 | BookingWorkspaceIngressSnapshotV2): string {
168
+ const { contextRef: _contextRef, ...contextBoundWorkspace } = workspace as Record<string, unknown>
169
+ return bookingV2Digest(contextBoundWorkspace)
170
+ }
171
+
172
+ function bookingV2WorkspaceSemanticDigest(workspace: BookingWorkspaceSnapshotV2 | BookingWorkspaceIngressSnapshotV2): string {
173
+ const { contextRef: _contextRef, revision: _revision, ...semanticWorkspace } = workspace as Record<string, unknown>
174
+ return bookingV2Digest(semanticWorkspace)
175
+ }
176
+
177
+ export function bookingV2TurnDigest(turn: Extract<BookingCopilotTurnV2, { kind: 'user.turn' | 'user.turn.ingress' }>): string {
178
+ return bookingV2Digest(turn)
179
+ }
180
+
181
+ function canonicalReceiptDigest(value: ActionReceiptV2): string { return bookingV2Digest(value) }
182
+ function replayDigestMatches(recordedDigest: unknown, raw: unknown, normalized: unknown): boolean {
183
+ return typeof recordedDigest === 'string' && (recordedDigest === bookingV2Digest(raw) || recordedDigest === bookingV2Digest(normalized))
184
+ }
185
+
186
+ function replayWorkspaceDigestMatches(recordedDigest: unknown, raw: BookingWorkspaceSnapshotV2, normalized: BookingWorkspaceSnapshotV2): boolean {
187
+ return typeof recordedDigest === 'string' && (recordedDigest === bookingV2WorkspaceDigest(raw) || recordedDigest === bookingV2WorkspaceDigest(normalized))
188
+ }
189
+
190
+ function replayWorkspaceSemanticDigestMatches(recordedDigest: unknown, raw: BookingWorkspaceSnapshotV2, normalized: BookingWorkspaceSnapshotV2): boolean {
191
+ return typeof recordedDigest === 'string' && (recordedDigest === bookingV2WorkspaceSemanticDigest(raw) || recordedDigest === bookingV2WorkspaceSemanticDigest(normalized))
192
+ }
193
+
194
+ function rememberReplayWorkspaceDigest(digests: Map<string, string>, recordedDigest: unknown, raw: BookingWorkspaceSnapshotV2, normalized: BookingWorkspaceSnapshotV2): void {
195
+ const normalizedDigest = bookingV2WorkspaceDigest(normalized)
196
+ digests.set(bookingV2WorkspaceDigest(raw), normalizedDigest)
197
+ if (typeof recordedDigest === 'string') digests.set(recordedDigest, normalizedDigest)
198
+ }
199
+
200
+ function legacyOfferVersionRef(contextRef: string, offer: Record<string, unknown>): string {
201
+ return `legacy-offer-version:${bookingV2Digest({
202
+ contextRef,
203
+ offerRef: offer.offerRef,
204
+ hotelRef: offer.hotelRef,
205
+ evidenceLevel: offer.evidenceLevel,
206
+ factRefs: Array.isArray(offer.factRefs) ? offer.factRefs : [],
207
+ }).slice(0, 40)}`
208
+ }
209
+
210
+ function normalizeWorkspaceForReplay(raw: BookingWorkspaceSnapshotV2): BookingWorkspaceSnapshotV2 {
211
+ const candidate = raw as unknown as Record<string, unknown>
212
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !Array.isArray(candidate.loadedOffers) || typeof candidate.contextRef !== 'string') throw new Error('booking_v2_malformed_workspace')
213
+ const workspace = structuredClone(raw) as BookingWorkspaceSnapshotV2 & { verifiedOfferRef?: string }
214
+ workspace.loadedOffers = workspace.loadedOffers.map((offer) => offer.offerVersionRef ? offer : { ...offer, offerVersionRef: legacyOfferVersionRef(workspace.contextRef, offer as unknown as Record<string, unknown>) })
215
+ delete workspace.verifiedOfferRef
216
+ return workspace
217
+ }
218
+
219
+ function resolveReplayOfferVersion(offerRef: string, workspace?: BookingWorkspaceSnapshotV2, action?: BookingActionCheckpointV2, actionId?: string, priorAvailability?: AvailabilityPolicyStateV2): string {
220
+ const priorAttempt = actionId ? priorAvailability?.attempts.find((attempt) => attempt.actionId === actionId) : undefined
221
+ if (priorAttempt?.offerVersionRef) return priorAttempt.offerVersionRef
222
+ if (action?.kind === 'offer.check' && action.actionId === actionId && action.input.offerRef === offerRef && typeof action.input.offerVersionRef === 'string') return action.input.offerVersionRef
223
+ const match = workspace?.loadedOffers.find((offer) => offer.offerRef === offerRef)
224
+ if (match?.offerVersionRef) return match.offerVersionRef
225
+ throw new Error('booking_v2_replan_required:offer_version_unbound')
226
+ }
227
+
228
+ function normalizeAvailabilityPolicyForReplay(raw: AvailabilityPolicyStateV2, workspace?: BookingWorkspaceSnapshotV2, action?: BookingActionCheckpointV2, priorAvailability?: AvailabilityPolicyStateV2, workspaceDigestMap = new Map<string, string>(), receipt?: ActionReceiptV2): AvailabilityPolicyStateV2 {
229
+ const normalized = structuredClone(raw) as AvailabilityPolicyStateV2
230
+ if (normalized.hotels && typeof normalized.hotels === 'object') {
231
+ for (const [hotelRef, hotel] of Object.entries(normalized.hotels) as Array<[string, AvailabilityPolicyStateV2['hotels'][string]]>) {
232
+ const rawHotel = (raw.hotels as Record<string, unknown> | undefined)?.[hotelRef] as Record<string, unknown> | undefined
233
+ const priorHotel = priorAvailability?.hotels[hotelRef]
234
+ if (!Array.isArray(hotel.tombstonedOfferRefs)) hotel.tombstonedOfferRefs = priorHotel ? [...priorHotel.tombstonedOfferRefs] : []
235
+ if (!Array.isArray(hotel.tombstonedOfferVersionRefs)) hotel.tombstonedOfferVersionRefs = priorHotel ? [...priorHotel.tombstonedOfferVersionRefs] : []
236
+ if (rawHotel && Array.isArray(rawHotel.tombstonedOfferRefs) && priorHotel) {
237
+ for (const ref of priorHotel.tombstonedOfferRefs) if (!hotel.tombstonedOfferRefs.includes(ref)) throw new Error('booking_v2_replan_required:tombstone_regression')
238
+ }
239
+ if (rawHotel && Array.isArray(rawHotel.tombstonedOfferVersionRefs) && priorHotel) {
240
+ for (const ref of priorHotel.tombstonedOfferVersionRefs) if (!hotel.tombstonedOfferVersionRefs.includes(ref)) throw new Error('booking_v2_replan_required:tombstone_regression')
241
+ }
242
+ const source = hotel.currentGeneration?.source
243
+ if (source?.kind === 'workspace_snapshot') {
244
+ const mappedDigest = workspaceDigestMap.get(source.workspaceDigest)
245
+ if (mappedDigest) source.workspaceDigest = mappedDigest
246
+ }
247
+ }
248
+ }
249
+ if (Array.isArray(normalized.attempts)) {
250
+ normalized.attempts = normalized.attempts.map((attempt) => attempt.offerVersionRef ? attempt : { ...attempt, offerVersionRef: resolveReplayOfferVersion(attempt.offerRef, workspace, action, attempt.actionId, priorAvailability) })
251
+ }
252
+ const availability = receipt?.observation.kind === 'offer.availability' ? receipt.observation : undefined
253
+ if (receipt && action?.kind === 'offer.check' && availability && availability.offerRef === action.input.offerRef && availability.checkedOfferVersionRef === action.input.offerVersionRef) {
254
+ const attempt = normalized.attempts.find((candidate) => candidate.actionId === action.actionId)
255
+ const hotel = attempt ? normalized.hotels[attempt.hotelRef] : undefined
256
+ if (hotel) {
257
+ if (receipt.status === 'changed' && availability.available === true && availability.currentOfferVersionRef && availability.currentOfferVersionRef !== action.input.offerVersionRef) {
258
+ if (!hotel.tombstonedOfferVersionRefs.includes(action.input.offerVersionRef)) hotel.tombstonedOfferVersionRefs.push(action.input.offerVersionRef)
259
+ }
260
+ const completeNegative = (receipt.status === 'unavailable' || receipt.status === 'no_match')
261
+ && receipt.resultContract.outcome === 'empty'
262
+ && receipt.resultContract.gapCodes.length === 0
263
+ && receipt.resultContract.blockers.length === 0
264
+ && availability.available === false
265
+ && !availability.currentOfferVersionRef
266
+ && !availability.verifiedOfferRef
267
+ && !availability.gapCodes?.length
268
+ if (completeNegative) {
269
+ if (!hotel.tombstonedOfferVersionRefs.includes(action.input.offerVersionRef)) hotel.tombstonedOfferVersionRefs.push(action.input.offerVersionRef)
270
+ if (!hotel.tombstonedOfferRefs.includes(action.input.offerRef)) hotel.tombstonedOfferRefs.push(action.input.offerRef)
271
+ }
272
+ }
273
+ }
274
+ return normalized
275
+ }
276
+
277
+ function normalizeActionCheckpointForReplay(raw: BookingActionCheckpointV2, workspace?: BookingWorkspaceSnapshotV2): BookingActionCheckpointV2 {
278
+ if (!['offer.select', 'offer.check', 'checkout.prepare'].includes(raw.kind) || typeof raw.input.offerVersionRef === 'string') return raw
279
+ if (typeof raw.input.offerRef !== 'string') throw new Error('booking_v2_replan_required:offer_version_unbound')
280
+ const offerVersionRef = resolveReplayOfferVersion(raw.input.offerRef, workspace, undefined, raw.actionId)
281
+ const input: Record<string, any> = { ...raw.input, offerVersionRef }
282
+ 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_v2_replan_required:legacy_checkout_unverified')
283
+ const { actionDigest: _rawActionDigest, ...rawBase } = raw
284
+ const normalizedBase: Omit<BookingActionCheckpointV2, 'actionDigest'> = { ...rawBase, input, inputDigest: bookingV2Digest(input) }
285
+ return { ...normalizedBase, actionDigest: checkpointDigest(normalizedBase) }
286
+ }
287
+
288
+ function checkpointDigestMatchesForReplay(raw: BookingActionCheckpointV2, normalized: BookingActionCheckpointV2): boolean {
289
+ const { actionDigest: _rawDigest, ...rawBase } = raw
290
+ const { actionDigest: _normalizedDigest, ...normalizedBase } = normalized
291
+ return raw.actionDigest === checkpointDigest(rawBase) || raw.actionDigest === checkpointDigest(normalizedBase)
292
+ }
293
+
294
+ function replanAfterReplayUpgradeGap(state: BookingCopilotTaskStateV2, workspace = state.workspaceSnapshot, revision = workspace?.revision): BookingCopilotTaskStateV2 {
295
+ if (!workspace) return { ...state, phase: 'error', pendingAction: undefined }
296
+ const safeRevision = typeof revision === 'number' && Number.isSafeInteger(revision) ? revision : state.revision
297
+ return {
298
+ ...state,
299
+ revision: safeRevision,
300
+ phase: 'planning',
301
+ pendingAction: undefined,
302
+ awaitingApproval: undefined,
303
+ workspaceDigest: bookingV2WorkspaceDigest(workspace),
304
+ workspaceSemanticDigest: bookingV2WorkspaceSemanticDigest(workspace),
305
+ workspaceSnapshot: workspace,
306
+ availability: createAvailabilityPolicyV2(workspace),
307
+ replayUpgradeRequired: true,
308
+ }
309
+ }
310
+
311
+ function assertReplayUpgradeReanchored(state: Pick<BookingCopilotTaskStateV2, 'replayUpgradeRequired'>): void {
312
+ if (state.replayUpgradeRequired) throw new Error('reanchor_turn_required')
313
+ }
314
+
315
+ function consumedIdentityKey(eventId: string, sequence: number): string { return `${eventId}\0${sequence}` }
316
+
317
+ function clearPendingBatch(consumed: ReplayConsumedIdentities): void {
318
+ consumed.pendingBatchKeys.clear()
319
+ consumed.pendingBatchOpen = false
320
+ }
321
+
322
+ function rememberPendingBatchKey(consumed: ReplayConsumedIdentities, key: string): void {
323
+ consumed.pendingBatchKeys.add(key)
324
+ }
325
+
326
+ function rememberConsumedEventIdentity(consumed: ReplayConsumedIdentities, payload: EventPayload): void {
327
+ if (payload.eventKind === 'status' && (payload.status === 'submitted' || payload.status === 'working') && !consumed.pendingBatchOpen) {
328
+ clearPendingBatch(consumed)
329
+ consumed.pendingBatchOpen = true
330
+ }
331
+ const key = consumedIdentityKey(payload.eventId, payload.sequence)
332
+ consumed.events.set(key, payload)
333
+ rememberPendingBatchKey(consumed, key)
334
+ }
335
+
336
+ function rememberConsumedActionIdentity(consumed: ReplayConsumedIdentities, action: BookingActionCheckpointV2): void {
337
+ const key = consumedIdentityKey(action.eventId, action.sequence)
338
+ consumed.actions.set(key, action)
339
+ rememberPendingBatchKey(consumed, key)
340
+ }
341
+
342
+ function consumeSkippedActionOrdinal(state: BookingCopilotTaskStateV2, payload: ActionPayload, consumed?: ReplayConsumedIdentities): void {
343
+ const action = payload.action
344
+ const operationCount = payload.operationCount ?? state.operationCount + 1
345
+ if (!action || typeof action !== 'object') throw new Error('skip_action_invalid')
346
+ const { actionDigest: _actionDigest, ...actionBase } = action
347
+ if (action.actionDigest !== checkpointDigest(actionBase) || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount + 1 || operationCount > BOOKING_COPILOT_MAX_OPERATIONS_V2 || !Number.isSafeInteger(action.sequence) || action.sequence <= state.lastSequence) throw new Error('skip_action_invalid')
348
+ state.operationCount = operationCount
349
+ state.lastSequence = action.sequence
350
+ if (consumed) {
351
+ rememberConsumedActionIdentity(consumed, action)
352
+ }
353
+ }
354
+
355
+ function eventDraftDigest(event: Exclude<BookingSurfaceEventV2, { kind: 'operation' }>): string {
356
+ if (event.kind === 'status') return bookingV2Digest({ kind: event.kind, status: event.status })
357
+ if (event.kind === 'question') return bookingV2Digest({ kind: event.kind, question: event.question })
358
+ if (event.kind === 'explanation') return bookingV2Digest({ kind: event.kind, explanation: event.explanation })
359
+ if (event.kind === 'terminal') return bookingV2Digest({ kind: event.kind, terminal: event.terminal })
360
+ return bookingV2Digest({ kind: event.kind, error: event.error })
361
+ }
362
+
363
+ function eventPayloadMatchesEvent(payload: EventPayload, event: Exclude<BookingSurfaceEventV2, { kind: 'operation' }>): boolean {
364
+ const expectedDigest = eventDraftDigest(event)
365
+ const legacyTerminalDigest = event.kind === 'terminal' ? bookingV2Digest(event.terminal) : undefined
366
+ return payload.eventId === event.eventId
367
+ && payload.sequence === event.sequence
368
+ && payload.emittedAt === event.emittedAt
369
+ && payload.contextRef === event.contextRef
370
+ && payload.eventKind === event.kind
371
+ && (event.kind !== 'status' || payload.status === event.status)
372
+ && (payload.contentDigest === expectedDigest || payload.contentDigest === legacyTerminalDigest)
373
+ }
374
+
375
+ function actionFromOperationEventForReplay(event: Extract<BookingSurfaceEventV2, { kind: 'operation' }>, checkpoint: BookingActionCheckpointV2): BookingReadActionV2 {
376
+ const action = event.action
377
+ 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') {
378
+ return { ...action, input: { ...action.input, offerVersionRef: checkpoint.input.offerVersionRef } } as BookingReadActionV2
379
+ }
380
+ return action
381
+ }
382
+
383
+ function operationEventMatchesAction(event: Extract<BookingSurfaceEventV2, { kind: 'operation' }>, checkpoint: BookingActionCheckpointV2): boolean {
384
+ return event.eventId === checkpoint.eventId
385
+ && event.sequence === checkpoint.sequence
386
+ && event.emittedAt === checkpoint.emittedAt
387
+ && event.contextRef === checkpoint.contextRef
388
+ && matchesCheckpoint(actionFromOperationEventForReplay(event, checkpoint), checkpoint)
389
+ }
390
+
391
+ function normalizeDecisionBatchEnvelopeForReplay(payload: DecisionBatchPayload, consumed: ReplayConsumedIdentities, requireSchemaValid = false): BookingSurfaceEventV2[] {
392
+ if (typeof payload.requestKey !== 'string' || !payload.requestKey || !payload.events.length) throw new Error('skip_decision_batch_invalid')
393
+ let lastBatchSequence = -1
394
+ const seen = new Set<string>()
395
+ const normalizedEvents: BookingSurfaceEventV2[] = []
396
+ for (const event of payload.events) {
397
+ if (!Number.isSafeInteger(event.sequence) || event.sequence <= lastBatchSequence) throw new Error('skip_decision_batch_invalid')
398
+ lastBatchSequence = event.sequence
399
+ const key = consumedIdentityKey(event.eventId, event.sequence)
400
+ if (seen.has(key)) throw new Error('skip_decision_batch_invalid')
401
+ seen.add(key)
402
+ if (event.kind === 'operation') {
403
+ const checkpoint = consumed.actions.get(key)
404
+ if (!checkpoint || !operationEventMatchesAction(event, checkpoint)) throw new Error('skip_decision_batch_invalid')
405
+ const normalized = { ...event, action: actionFromOperationEventForReplay(event, checkpoint) } as BookingSurfaceEventV2
406
+ if (requireSchemaValid && !validateBookingSurfaceEventV2(normalized).ok) throw new Error('skip_decision_batch_invalid')
407
+ normalizedEvents.push(normalized)
408
+ } else {
409
+ const recorded = consumed.events.get(key)
410
+ if (!recorded || !validateBookingSurfaceEventV2(event).ok || !eventPayloadMatchesEvent(recorded, event)) throw new Error('skip_decision_batch_invalid')
411
+ normalizedEvents.push(event)
412
+ }
413
+ if (!consumed.pendingBatchKeys.has(key)) throw new Error('skip_decision_batch_invalid')
414
+ }
415
+ if (seen.size !== consumed.pendingBatchKeys.size) throw new Error('skip_decision_batch_invalid')
416
+ for (const key of consumed.pendingBatchKeys) if (!seen.has(key)) throw new Error('skip_decision_batch_invalid')
417
+ clearPendingBatch(consumed)
418
+ return normalizedEvents
419
+ }
420
+
421
+ function consumeSkippedEventSequence(state: BookingCopilotTaskStateV2, payload: EventPayload | DecisionBatchPayload, consumed: ReplayConsumedIdentities): void {
422
+ if (!payload || typeof payload !== 'object') throw new Error('skip_event_invalid')
423
+ if ('sequence' in payload) {
424
+ const eventPayload = payload as EventPayload
425
+ 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')
426
+ state.lastSequence = eventPayload.sequence
427
+ rememberConsumedEventIdentity(consumed, eventPayload)
428
+ } else if ('events' in payload && Array.isArray(payload.events)) {
429
+ normalizeDecisionBatchEnvelopeForReplay(payload, consumed)
430
+ }
431
+ }
432
+
433
+ function normalizeReceiptForReplay(raw: ActionReceiptV2, action: BookingActionCheckpointV2, workspace: BookingWorkspaceSnapshotV2): ActionReceiptV2 {
434
+ const receipt = structuredClone(raw) as ActionReceiptV2
435
+ if (action.kind === 'offer.select' && receipt.observation.kind === 'offer.selection' && receipt.observation.offerRef === action.input.offerRef && !receipt.observation.offerVersionRef) {
436
+ receipt.observation.offerVersionRef = action.input.offerVersionRef
437
+ }
438
+ if (action.kind === 'checkout.prepare' && receipt.observation.kind === 'checkout.handoff' && receipt.observation.offerRef === action.input.offerRef && !receipt.observation.offerVersionRef) {
439
+ 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_v2_replan_required:legacy_checkout_unverified')
440
+ receipt.observation.offerVersionRef = action.input.offerVersionRef
441
+ }
442
+ if (action.kind === 'offer.check' && receipt.observation.kind === 'offer.availability' && receipt.observation.offerRef === action.input.offerRef) {
443
+ if (!receipt.observation.checkedOfferVersionRef) receipt.observation.checkedOfferVersionRef = action.input.offerVersionRef
444
+ if (receipt.status === 'applied' && receipt.observation.available === true && !receipt.observation.currentOfferVersionRef) receipt.observation.currentOfferVersionRef = action.input.offerVersionRef
445
+ if (receipt.status === 'changed' && receipt.observation.available === true && !receipt.observation.currentOfferVersionRef) {
446
+ const current = workspace.loadedOffers.find((offer) => offer.offerRef === action.input.offerRef && offer.offerVersionRef !== action.input.offerVersionRef)
447
+ if (!current) throw new Error('booking_v2_replan_required:changed_version_unbound')
448
+ receipt.observation.currentOfferVersionRef = current.offerVersionRef
449
+ }
450
+ 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_v2_replan_required:legacy_verified_scalar')
451
+ }
452
+ if (receipt.resultContract.blockers.length && bookingV2Digest(raw) !== bookingV2Digest(receipt)) throw new Error('booking_v2_replan_required:legacy_blocker_digest')
453
+ return receipt
454
+ }
455
+
456
+ function errorText(result: { ok: true } | { ok: false; errors: string[] }): string { return result.ok ? '' : result.errors.join('; ') }
457
+ function assertTaskId(taskId: string): void { if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(taskId)) throw new Error('invalid_task_id') }
458
+ function assertSafeRef(value: string): void { if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value)) throw new Error('unsafe_opaque_ref') }
459
+ function safeIdentity(value: unknown): value is string { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value) }
460
+ function sameActions(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && [...a].sort().every((x, i) => x === [...b].sort()[i]) }
461
+ function availabilityPolicyTerminal(state: AvailabilityPolicyStateV2): AvailabilityExhaustionV2 | undefined { return availabilityPolicyResultV2(state) }
462
+ function taskHasTerminalBudget(state: Pick<BookingCopilotTaskStateV2, 'operationCount' | 'availability'>): boolean {
463
+ return state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS_V2 || Boolean(state.availability.terminal)
464
+ }
465
+ function assertRequestKey(requestKey: string): asserts requestKey is BookingRequestKeyV2 {
466
+ if (!isSafeBookingRequestKeyV2(requestKey)) throw new Error('unsafe_request_key')
467
+ }
468
+ /** Decision batches are runtime-owned and may contain long task/turn/action refs. */
469
+ function assertInternalDecisionKey(requestKey: string): asserts requestKey is BookingInternalDecisionKeyV2 {
470
+ if (typeof requestKey !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,2047}$/.test(requestKey)) throw new Error('unsafe_internal_decision_key')
471
+ }
472
+
473
+ function assertDecisionBatchFinality(decisions: readonly BookingPlannerDecisionV2[]): void {
474
+ let finalSeen = false
475
+ for (const decision of decisions) {
476
+ const isFinal = decision.kind === 'terminal' || decision.kind === 'error'
477
+ if (finalSeen) throw new Error('decision_batch_finality')
478
+ if (isFinal) finalSeen = true
479
+ }
480
+ }
481
+ function assertPrincipal(principal: BookingIngressPrincipalV2): void {
482
+ 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')
483
+ }
484
+
485
+ function assertAvailabilityLiveness(state: BookingCopilotTaskStateV2, decisions: readonly BookingPlannerDecisionV2[]): void {
486
+ if (!state.availability.recoveryStarted || state.awaitingApproval) return
487
+ const operations = decisions.filter((decision): decision is Extract<BookingPlannerDecisionV2, { kind: 'operation' }> => decision.kind === 'operation')
488
+ if (operations.length !== 1 || !state.workspaceSnapshot) throw new Error('availability_operation_required')
489
+ const action = operations[0].action
490
+ const activeHotelRef = state.availability.hotelRefs[state.availability.activeHotelOrdinal]
491
+ if (!activeHotelRef) throw new Error('availability_active_hotel_missing')
492
+ if (state.availability.availabilityPhase === 'need_offers') {
493
+ if (action.kind !== 'offers.query' || action.input.hotelRefs.length !== 1 || action.input.hotelRefs[0] !== activeHotelRef) throw new Error('availability_operation_incompatible')
494
+ } else if (state.availability.availabilityPhase === 'need_check') {
495
+ if (action.kind !== 'offer.check' || action.input.offerRef === undefined || action.input.offerVersionRef === undefined || !state.availability.hotels[activeHotelRef]?.currentOfferRefs.includes(action.input.offerRef) || !canIssueOfferCheckV2(state.availability, state.workspaceSnapshot, action.input.offerRef, action.input.offerVersionRef).ok) throw new Error('availability_operation_incompatible')
496
+ } else {
497
+ throw new Error('availability_operation_incompatible')
498
+ }
499
+ }
500
+
501
+ function checkpointDigest(action: Omit<BookingActionCheckpointV2, 'actionDigest'>): string {
502
+ return bookingV2Digest(action)
503
+ }
504
+
505
+ function checkpointFor(action: BookingReadActionV2, eventId: string, sequence: number, emittedAt: string, sourceTurnId: string): BookingActionCheckpointV2 {
506
+ const base: Omit<BookingActionCheckpointV2, 'actionDigest'> = {
507
+ actionId: action.actionId,
508
+ kind: action.kind,
509
+ contextRef: action.contextRef,
510
+ expectedRevision: action.expectedRevision,
511
+ factRefs: [...action.factRefs],
512
+ reasonDigest: bookingV2Digest(action.reason),
513
+ inputDigest: bookingV2Digest(action.input),
514
+ input: action.input,
515
+ eventId,
516
+ sequence,
517
+ emittedAt,
518
+ sourceTurnId,
519
+ ...(action.relaxationApprovalRef ? { relaxationApprovalRef: action.relaxationApprovalRef } : {}),
520
+ }
521
+ return { ...base, actionDigest: checkpointDigest(base) }
522
+ }
523
+
524
+ function matchesCheckpoint(action: BookingReadActionV2, checkpoint: BookingActionCheckpointV2): boolean {
525
+ const candidate = checkpointFor(
526
+ action.relaxationApprovalRef || !checkpoint.relaxationApprovalRef ? action : { ...action, relaxationApprovalRef: checkpoint.relaxationApprovalRef },
527
+ checkpoint.eventId,
528
+ checkpoint.sequence,
529
+ checkpoint.emittedAt,
530
+ checkpoint.sourceTurnId,
531
+ )
532
+ const { actionDigest: _ignored, ...candidateBase } = candidate
533
+ return checkpoint.actionDigest === checkpointDigest(candidateBase)
534
+ }
535
+
536
+ function receiptSourceDigest(receipt: ActionReceiptV2): string {
537
+ return bookingV2Digest({
538
+ ...receipt,
539
+ resultContract: {
540
+ ...receipt.resultContract,
541
+ blockers: receipt.resultContract.blockers.map(({ sourceReceiptDigest: _ignored, ...blocker }) => blocker),
542
+ },
543
+ })
544
+ }
545
+
546
+ function assertCanonicalReceipt(receipt: ActionReceiptV2): void {
547
+ const expected = receiptSourceDigest(receipt)
548
+ if (receipt.resultContract.blockers.some((blocker) => blocker.sourceReceiptDigest !== expected)) throw new Error('receipt_source_digest_mismatch')
549
+ }
550
+
551
+ function receiptObservationMatchesAction(observation: ActionReceiptV2['observation']['kind'], action: BookingReadActionKindV2): boolean {
552
+ if (observation === 'gap') return true
553
+ const expected: Partial<Record<BookingReadActionKindV2, ActionReceiptV2['observation']['kind']>> = {
554
+ 'search.patch': 'search.state', 'search.run': 'search.state', 'results.view.patch': 'results.state',
555
+ 'hotel.focus': 'hotel.focus', 'hotel.select': 'hotel.selection', 'offers.query': 'offers.state',
556
+ 'offers.view.patch': 'offers.state', 'offers.compare': 'offers.state', 'offer.select': 'offer.selection',
557
+ 'offer.check': 'offer.availability', 'checkout.prepare': 'checkout.handoff', 'order.observe': 'order.state',
558
+ }
559
+ return expected[action] === observation
560
+ }
561
+
562
+ function sameRefSet(a: readonly string[], b: readonly string[]): boolean {
563
+ return a.length === b.length && [...a].sort().every((value, index) => value === [...b].sort()[index])
564
+ }
565
+
566
+ function receiptTargetMatchesAction(receipt: ActionReceiptV2, action: { kind: BookingReadActionKindV2; input: Record<string, any> }): boolean {
567
+ const observation = receipt.observation
568
+ const input = action.input
569
+ if (action.kind === 'hotel.focus' && observation.kind === 'hotel.focus') return observation.hotelRef === input.hotelRef
570
+ if (action.kind === 'hotel.select' && observation.kind === 'hotel.selection') return observation.hotelRef === input.hotelRef
571
+ if (action.kind === 'offer.select' && observation.kind === 'offer.selection') return observation.offerRef === input.offerRef && observation.offerVersionRef === input.offerVersionRef
572
+ if (action.kind === 'offer.check' && observation.kind === 'offer.availability') return observation.offerRef === input.offerRef && observation.checkedOfferVersionRef === input.offerVersionRef
573
+ if (action.kind === 'checkout.prepare' && observation.kind === 'checkout.handoff') return observation.offerRef === input.offerRef && observation.offerVersionRef === input.offerVersionRef && observation.verifiedOfferRef === input.verifiedOfferRef
574
+ if (action.kind === 'offers.query' && observation.kind === 'offers.state') return sameRefSet(observation.hotelRefs, input.hotelRefs)
575
+ if (action.kind === 'offers.view.patch' && observation.kind === 'offers.state') return observation.hotelRefs.includes(input.hotelRef)
576
+ if (action.kind === 'offers.compare' && observation.kind === 'offers.state') return input.offerRefs.every((offerRef: string) => observation.offerRefs.includes(offerRef))
577
+ if (action.kind === 'order.observe' && observation.kind === 'order.state') return observation.orderRef === input.orderRef
578
+ return observation.kind === 'gap' || action.kind === 'search.patch' || action.kind === 'search.run' || action.kind === 'results.view.patch'
579
+ }
580
+
581
+ function workspaceBoundaryMatches(a: BookingWorkspaceSnapshotV2, b: BookingWorkspaceSnapshotV2): boolean {
582
+ 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)
583
+ }
584
+
585
+ function workspaceHasOfferVersion(workspace: BookingWorkspaceSnapshotV2, offerRef: string, offerVersionRef: string): boolean {
586
+ return workspace.loadedOffers.some((offer) => offer.offerRef === offerRef && offer.offerVersionRef === offerVersionRef)
587
+ }
588
+
589
+ function verifiedOfferMatches(workspace: BookingWorkspaceSnapshotV2, input: { offerRef: string; offerVersionRef: string; verifiedOfferRef: string }): boolean {
590
+ return workspace.verifiedOffer?.offerRef === input.offerRef && workspace.verifiedOffer.offerVersionRef === input.offerVersionRef && workspace.verifiedOffer.verifiedOfferRef === input.verifiedOfferRef
591
+ }
592
+
593
+ function verifiedOfferUnexpired(workspace: BookingWorkspaceSnapshotV2, now: string): boolean {
594
+ return Boolean(workspace.verifiedOffer && isBookingDateTimeV2(workspace.verifiedOffer.expiresAt) && Date.parse(workspace.verifiedOffer.expiresAt) > Date.parse(now))
595
+ }
596
+
597
+ function actionHitsCurrentOfferVersion(workspace: BookingWorkspaceSnapshotV2, action: BookingReadActionV2, now?: string): boolean {
598
+ if (action.kind === 'offer.select' || action.kind === 'offer.check') return workspaceHasOfferVersion(workspace, action.input.offerRef, action.input.offerVersionRef)
599
+ if (action.kind === 'checkout.prepare') return workspaceHasOfferVersion(workspace, action.input.offerRef, action.input.offerVersionRef) && verifiedOfferMatches(workspace, action.input) && (now === undefined || verifiedOfferUnexpired(workspace, now))
600
+ return true
601
+ }
602
+
603
+ function shortlistAfterCheckedOffer(previous: readonly string[], current: readonly string[], checkedOfferRef: string): boolean {
604
+ if (sameActions(previous, current)) return true
605
+ const removedChecked = previous.filter((ref) => ref !== checkedOfferRef)
606
+ return removedChecked.length === previous.length - 1 && sameActions(removedChecked, current)
607
+ }
608
+
609
+ function loadedOffersUnchanged(previous: BookingWorkspaceSnapshotV2, current: BookingWorkspaceSnapshotV2): boolean {
610
+ return sameActions(previous.loadedOffers.map((offer) => bookingV2Digest(offer)), current.loadedOffers.map((offer) => bookingV2Digest(offer)))
611
+ }
612
+
613
+ function changedFactRefsMatch(previousFactRefs: readonly string[], currentFactRefs: readonly string[], changedFactRefs: readonly string[]): boolean {
614
+ if (!changedFactRefs.length) return false
615
+ const before = new Set(previousFactRefs)
616
+ const after = new Set(currentFactRefs)
617
+ const diff = [
618
+ ...previousFactRefs.filter((ref) => !after.has(ref)),
619
+ ...currentFactRefs.filter((ref) => !before.has(ref)),
620
+ ]
621
+ return sameActions(diff, changedFactRefs)
622
+ }
623
+
624
+ function loadedOffersChangedVersionOnly(previous: BookingWorkspaceSnapshotV2, current: BookingWorkspaceSnapshotV2, offerRef: string, currentOfferVersionRef: string, changedFactRefs: readonly string[]): boolean {
625
+ if (previous.loadedOffers.length !== current.loadedOffers.length) return false
626
+ const previousChecked = previous.loadedOffers.find((offer) => offer.offerRef === offerRef)
627
+ const currentChecked = current.loadedOffers.find((offer) => offer.offerRef === offerRef)
628
+ if (!previousChecked || !currentChecked || previousChecked.hotelRef !== currentChecked.hotelRef || currentChecked.offerVersionRef !== currentOfferVersionRef || previousChecked.offerVersionRef === currentChecked.offerVersionRef) return false
629
+ if (previousChecked.evidenceLevel !== currentChecked.evidenceLevel) return false
630
+ if (!changedFactRefsMatch(previousChecked.factRefs, currentChecked.factRefs, changedFactRefs)) return false
631
+ const previousOther = previous.loadedOffers.filter((offer) => offer.offerRef !== offerRef).map((offer) => bookingV2Digest(offer))
632
+ const currentOther = current.loadedOffers.filter((offer) => offer.offerRef !== offerRef).map((offer) => bookingV2Digest(offer))
633
+ return sameActions(previousOther, currentOther)
634
+ }
635
+
636
+ function availabilityReceiptIsComplete(receipt: ActionReceiptV2): boolean {
637
+ return receipt.resultContract.outcome === 'complete'
638
+ && receipt.resultContract.hardCriteriaMet
639
+ && receipt.resultContract.gapCodes.length === 0
640
+ && receipt.resultContract.blockers.length === 0
641
+ }
642
+
643
+ function offerCheckPostActionMatches(previous: BookingWorkspaceSnapshotV2, current: BookingWorkspaceSnapshotV2, action: { kind: 'offer.check'; input: { offerRef: string; offerVersionRef: string } }, receipt: ActionReceiptV2): boolean {
644
+ const checkedOfferRef = action.input.offerRef
645
+ const checkedOfferVersionRef = action.input.offerVersionRef
646
+ const observation = receipt.observation.kind === 'offer.availability' ? receipt.observation : undefined
647
+ const negative = receipt.status === 'unavailable' || receipt.status === 'no_match'
648
+ if (['applied', 'changed', 'unavailable', 'no_match'].includes(receipt.status) && !observation) return false
649
+ if (receipt.status === 'applied' && observation?.available && observation.verifiedOfferRef) {
650
+ if (!availabilityReceiptIsComplete(receipt) || observation.gapCodes?.length) return false
651
+ if (!loadedOffersUnchanged(previous, current)) return false
652
+ if (observation.checkedOfferVersionRef !== checkedOfferVersionRef || observation.currentOfferVersionRef !== checkedOfferVersionRef) return false
653
+ if (current.selectedOfferRef !== checkedOfferRef) return false
654
+ if (!sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs)) return false
655
+ 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
656
+ return true
657
+ }
658
+ if (receipt.status === 'changed' && observation?.available && observation.currentOfferVersionRef) {
659
+ if (observation.checkedOfferVersionRef !== checkedOfferVersionRef || observation.currentOfferVersionRef === checkedOfferVersionRef || observation.verifiedOfferRef || !observation.changedFactRefs.length) return false
660
+ if (!loadedOffersChangedVersionOnly(previous, current, checkedOfferRef, observation.currentOfferVersionRef, observation.changedFactRefs)) return false
661
+ if (current.selectedOfferRef !== checkedOfferRef || current.verifiedOffer !== undefined) return false
662
+ return sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs)
663
+ }
664
+ if (!loadedOffersUnchanged(previous, current)) return false
665
+ if (negative) {
666
+ if (current.selectedOfferRef !== undefined || current.verifiedOffer !== undefined) return false
667
+ if (!shortlistAfterCheckedOffer(previous.shortlistedOfferRefs, current.shortlistedOfferRefs, checkedOfferRef)) return false
668
+ return !current.shortlistedOfferRefs.includes(checkedOfferRef)
669
+ }
670
+ if (!sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs)) return false
671
+ if (previous.selectedOfferRef !== current.selectedOfferRef) return false
672
+ if (bookingV2Digest(previous.verifiedOffer ?? null) !== bookingV2Digest(current.verifiedOffer ?? null)) return false
673
+ return true
674
+ }
675
+
676
+ function offerSelectPostActionMatches(previous: BookingWorkspaceSnapshotV2, current: BookingWorkspaceSnapshotV2, action: { kind: 'offer.select'; input: { offerRef: string; offerVersionRef: string } }, receipt: ActionReceiptV2): boolean {
677
+ if (receipt.status !== 'applied') return current.selectedOfferRef === previous.selectedOfferRef && loadedOffersUnchanged(previous, current)
678
+ if (receipt.observation.kind !== 'offer.selection' || receipt.observation.offerRef !== action.input.offerRef || receipt.observation.offerVersionRef !== action.input.offerVersionRef) return false
679
+ if (current.selectedOfferRef !== action.input.offerRef) return false
680
+ return loadedOffersUnchanged(previous, current)
681
+ }
682
+
683
+ function workspacePostActionMatches(previous: BookingWorkspaceSnapshotV2, current: BookingWorkspaceSnapshotV2, action: BookingActionCheckpointV2, receipt: ActionReceiptV2): boolean {
684
+ if (!workspaceBoundaryMatches(previous, current)) return false
685
+ const mutable: Partial<Record<BookingReadActionKindV2, string[]>> = {
686
+ 'search.patch': ['searchDraft'], 'search.run': ['results', 'visibleHotels'], 'results.view.patch': ['results'],
687
+ 'hotel.focus': ['focusedHotelRef'], 'hotel.select': ['focusedHotelRef'],
688
+ 'offers.query': ['loadedOffers'], 'offers.view.patch': ['loadedOffers'],
689
+ 'offer.select': ['selectedOfferRef'], 'offer.check': ['selectedOfferRef', 'shortlistedOfferRefs', 'verifiedOffer', 'loadedOffers'],
690
+ }
691
+ if (action.kind === 'offer.select' && !offerSelectPostActionMatches(previous, current, { kind: 'offer.select', input: { offerRef: action.input.offerRef, offerVersionRef: action.input.offerVersionRef } }, receipt)) return false
692
+ if (action.kind === 'offer.check' && !offerCheckPostActionMatches(previous, current, { kind: 'offer.check', input: { offerRef: action.input.offerRef, offerVersionRef: action.input.offerVersionRef } }, receipt)) return false
693
+ const allowed = new Set(['revision', ...(mutable[action.kind] ?? [])])
694
+ const keys = new Set([...Object.keys(previous), ...Object.keys(current)])
695
+ for (const key of keys) {
696
+ if (allowed.has(key)) continue
697
+ if (bookingV2Digest((previous as unknown as Record<string, unknown>)[key]) !== bookingV2Digest((current as unknown as Record<string, unknown>)[key])) return false
698
+ }
699
+ return true
700
+ }
701
+
702
+ function approvalOption(taskId: string, contextRef: string, sourceTurnId: string, presentationRequestKey: string, blocker: CriterionBlockerV2, to: RelaxationApprovalV2['to'], deliveryNonce: string): RelaxationApprovalV2 {
703
+ const base = {
704
+ taskId, contextRef, sourceTurnId, presentationRequestKey,
705
+ approvalId: `approval-${blocker.blockerId}-${to}`, deliveryNonce, blockerId: blocker.blockerId,
706
+ sourceActionId: blocker.sourceActionId, sourceReceiptDigest: blocker.sourceReceiptDigest,
707
+ scope: blocker.scope, code: blocker.code, criterionPath: blocker.criterionPath,
708
+ valueDigest: blocker.valueDigest, from: 'must' as const, to, approved: true as const,
709
+ }
710
+ return { ...base, optionDigest: bookingV2Digest(base) }
711
+ }
712
+
713
+ function approvalOptionDigest(approval: RelaxationApprovalV2): string {
714
+ const { optionDigest: _optionDigest, ...base } = approval
715
+ return bookingV2Digest(base)
716
+ }
717
+
718
+ function presentationKey(taskId: string, sourceTurnId: string, actionId: string, receiptDigest: string): string {
719
+ return `approval:${taskId}:${sourceTurnId}:${actionId}:${receiptDigest}`
720
+ }
721
+
722
+ type ApprovalTarget = Pick<BookingReadActionV2, 'actionId' | 'expectedRevision' | 'kind' | 'contextRef'>
723
+ function assertApprovalRef(ref: RelaxationApprovalRefV2, state: BookingCopilotTaskStateV2, action: ApprovalTarget): void {
724
+ const approval = state.awaitingApproval?.approval
725
+ const blocker = state.awaitingApproval?.blocker
726
+ 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')
727
+ }
728
+
729
+ export class BookingCopilotTaskRuntimeV2 {
730
+ private readonly idFactory: (prefix: string) => string
731
+ private readonly contextRefFactory: () => string
732
+ private readonly now: () => string
733
+ private readonly approvalTtlMs: number
734
+
735
+ constructor(private readonly ledger: StateLedger, options: BookingCopilotRuntimeOptionsV2 = {}) {
736
+ this.idFactory = options.idFactory ?? ((prefix) => `${prefix}-${randomUUID()}`)
737
+ this.contextRefFactory = options.contextRefFactory ?? (() => this.idFactory('context'))
738
+ this.now = options.now ?? (() => new Date().toISOString())
739
+ this.approvalTtlMs = options.approvalTtlMs ?? 120_000
740
+ }
741
+
742
+ /** Read-only conflict check used before startTask can append a new turn. */
743
+ assertRequestBinding(requestKey: string, turn: UserTurnV2, input: BookingIngressRequestBindingInputV2): void {
744
+ assertRequestKey(requestKey)
745
+ assertPrincipal(input.principal)
746
+ if (input.requestKey !== requestKey) throw new Error('request_conflict')
747
+ const existing = this.requestBinding(requestKey)
748
+ if (!existing) return
749
+ const candidate = this.requestBindingPayload(requestKey, turn, input)
750
+ 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')
751
+ }
752
+
753
+ /** Verifies a direct planner turn against its durable TURN row. */
754
+ assertTurnBinding(taskId: string, turn: UserTurnV2): void {
755
+ assertTaskId(taskId)
756
+ 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 }>
757
+ const observed = row.map(({ payload }) => JSON.parse(payload) as TurnPayload).find((candidate) => candidate.turnId === turn.turnId)
758
+ if (!observed || observed.requestDigest !== bookingV2TurnDigest(turn) || observed.workspaceDigest !== bookingV2WorkspaceDigest(turn.workspace) || observed.workspaceSemanticDigest !== bookingV2WorkspaceSemanticDigest(turn.workspace) || observed.contextRef !== turn.workspace.contextRef) throw new Error('turn_conflict')
759
+ }
760
+
761
+ /** Persists the per-requestKey receipt/replay binding after task creation. */
762
+ persistRequestBinding(requestKey: string, turn: UserTurnV2, input: BookingIngressRequestBindingInputV2): void {
763
+ assertRequestKey(requestKey)
764
+ assertPrincipal(input.principal)
765
+ if (input.requestKey !== requestKey) throw new Error('request_conflict')
766
+ const run = this.ledger.db.transaction(() => {
767
+ const state = this.requireTask(turn.taskId)
768
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
769
+ assertReplayUpgradeReanchored(state)
770
+ this.appendRequestBindingInTransaction(requestKey, turn, input)
771
+ })
772
+ run.immediate()
773
+ }
774
+
775
+ /** Binds the untrusted ingress snapshot to server-owned task/context/capabilities. */
776
+ bindIngressTurn(turn: IngressTurnV2, task: BookingCopilotTaskStateV2, turnId: string): UserTurnV2 {
777
+ assertSafeRef(turnId)
778
+ return {
779
+ schemaVersion: 'booking.surface.v2', kind: 'user.turn', taskId: task.taskId, turnId,
780
+ workspace: {
781
+ ...turn.workspace,
782
+ schemaVersion: 'booking.surface.v2', contextRef: task.contextRef, surface: task.surface,
783
+ capabilities: { surface: task.surface, allowedActions: [...task.allowedActions] },
784
+ },
785
+ request: { text: turn.request.text },
786
+ }
787
+ }
788
+
789
+ startTask(turn: BookingCopilotTurnV2, requestBinding?: BookingIngressRequestBindingInputV2): BookingCopilotTaskStateV2 {
790
+ const validation = validateBookingSurfaceV2(turn)
791
+ if (!validation.ok) throw new Error(`invalid_planner_turn:${errorText(validation)}`)
792
+ if (turn.kind === 'user.turn.ingress') throw new Error('ingress_binding_required')
793
+ if (turn.kind === 'action.receipt.continuation') throw new Error('receipt_continuation_requires_continue')
794
+ const userTurn = turn as UserTurnV2
795
+ if (!userTurn.taskId) throw new Error('invalid_planner_turn:task_id_required')
796
+ const taskId = userTurn.taskId
797
+ assertTaskId(taskId)
798
+ const turnId = userTurn.turnId
799
+ if (!turnId) throw new Error('invalid_planner_turn:turn_id_required')
800
+ assertSafeRef(turnId)
801
+ const contextRef = userTurn.workspace.contextRef
802
+ const surface = userTurn.workspace.surface
803
+ const revision = userTurn.workspace.revision
804
+ const allowedActions = [...userTurn.workspace.capabilities.allowedActions]
805
+ const requestDigest = bookingV2TurnDigest(turn)
806
+ const boundWorkspace = userTurn.workspace
807
+ assertWorkspaceLoadedOfferRefsUniqueV2(boundWorkspace)
808
+ const workspaceDigest = bookingV2WorkspaceDigest(boundWorkspace)
809
+ const workspaceSemanticDigest = bookingV2WorkspaceSemanticDigest(boundWorkspace)
810
+ const availability = createAvailabilityPolicyV2(boundWorkspace)
811
+ const run = this.ledger.db.transaction(() => {
812
+ if (!this.resumeTask(taskId)) {
813
+ const foreign = this.ledger.db.prepare("SELECT 1 AS present FROM events WHERE tenant_id = ? AND run_id = ? AND kind LIKE 'booking.copilot.%' AND kind NOT LIKE 'booking.copilot.v2.%' LIMIT 1").get(this.ledger.tenant, taskId) as { present: number } | undefined
814
+ if (foreign) throw new Error('task_conflict:protocol_version')
815
+ }
816
+ const existing = this.resumeTask(taskId)
817
+ if (existing) {
818
+ if (existing.contextRef !== contextRef) throw new Error('task_conflict:context_mismatch')
819
+ if (existing.surface !== surface) throw new Error('task_conflict:surface_mismatch')
820
+ if (existing.revision !== revision && existing.phase !== 'waiting_receipt' && !existing.replayUpgradeRequired) throw new Error('task_conflict:revision_mismatch')
821
+ if (!sameActions(existing.allowedActions, allowedActions)) throw new Error('task_conflict:capability_mismatch')
822
+ if (existing.phase === 'terminal' || existing.phase === 'error') throw new Error('task_terminal')
823
+ if (turnId && existing.lastTurnId === turnId) {
824
+ this.assertTurnBinding(taskId, userTurn)
825
+ if (requestBinding) {
826
+ assertReplayUpgradeReanchored(existing)
827
+ this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding)
828
+ }
829
+ return existing
830
+ }
831
+ if (existing.phase === 'waiting_receipt') throw new Error('receipt_required')
832
+ const approval = userTurn && 'approval' in userTurn.request ? userTurn.request.approval : undefined
833
+ let approvalState: BookingApprovalStateV2 | undefined
834
+ if (approval) {
835
+ assertReplayUpgradeReanchored(existing)
836
+ const awaiting = existing.awaitingApproval
837
+ if (!awaiting) throw new Error('approval_not_awaiting')
838
+ if (!awaiting.optionsEmitted) throw new Error('approval_not_presented')
839
+ if (Date.parse(awaiting.expiresAt) <= Date.parse(this.now())) throw new Error('approval_expired')
840
+ const checked = validateApprovalAgainstBlocker(approval, awaiting.blocker)
841
+ const option = awaiting.options.find((candidate) => candidate.optionDigest === approval.optionDigest && bookingV2Digest(candidate) === bookingV2Digest(approval))
842
+ 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 && bookingV2Digest(approval) !== bookingV2Digest(awaiting.approval))) throw new Error('approval_mismatch')
843
+ if (awaiting.approval && bookingV2Digest(awaiting.approval) === bookingV2Digest(approval)) return existing
844
+ approvalState = { ...awaiting, approval }
845
+ this.appendApproval(taskId, existing.contextRef, approvalState)
846
+ } else if (existing.awaitingApproval) {
847
+ throw new Error('approval_required')
848
+ }
849
+ this.appendTurn(taskId, existing.contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, existing.userTurnCount + 1, turnId, boundWorkspace)
850
+ if (requestBinding) this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding)
851
+ return this.requireTask(taskId)
852
+ }
853
+ if (userTurn?.request.approval) throw new Error('approval_not_awaiting')
854
+ const started: StartedPayload = { schema: LEDGER_SCHEMA, taskId, contextRef, surface, revision, allowedActions, workspaceDigest, workspaceSemanticDigest, workspace: boundWorkspace, availability, availabilityDigest: bookingV2Digest(availability) }
855
+ this.append(STARTED, taskId, started, `booking-v2:task:${taskId}`)
856
+ this.appendTurn(taskId, contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, 1, turnId, boundWorkspace)
857
+ if (requestBinding) this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding)
858
+ return this.requireTask(taskId)
859
+ })
860
+ return run.immediate()
861
+ }
862
+
863
+ resumeTask(taskId: string): BookingCopilotTaskStateV2 | null {
864
+ assertTaskId(taskId)
865
+ const rows = this.rows(taskId)
866
+ if (!rows.length) return null
867
+ let state: BookingCopilotTaskStateV2 | null = null
868
+ let skipLegacyUpgradeTail = false
869
+ const replayWorkspaceDigests = new Map<string, string>()
870
+ const consumed: ReplayConsumedIdentities = { events: new Map(), actions: new Map(), pendingBatchKeys: new Set(), pendingBatchOpen: false }
871
+ const suppressedDecisionRequestKeys = new Set<string>()
872
+ const suppressedRequestBindingKeys = new Set<string>()
873
+ const suppressedReceiptActionIds = new Set<string>()
874
+ const suppressedApprovalTargetActionIds = new Set<string>()
875
+ for (const row of rows) {
876
+ const payload = JSON.parse(row.payload) as BasePayload & Record<string, any>
877
+ if (payload.schema !== LEDGER_SCHEMA || payload.taskId !== taskId) throw new Error(`ledger_corrupt:${taskId}:seq=${row.seq}`)
878
+ if (row.kind === STARTED) {
879
+ skipLegacyUpgradeTail = false
880
+ if (state) throw new Error(`ledger_corrupt:${taskId}:duplicate_start`)
881
+ const started = payload as StartedPayload
882
+ let startedWorkspace: BookingWorkspaceSnapshotV2
883
+ try { startedWorkspace = normalizeWorkspaceForReplay(started.workspace) } catch { throw new Error(`ledger_corrupt:${taskId}:start_workspace`) }
884
+ let initialAvailability: AvailabilityPolicyStateV2
885
+ try { initialAvailability = createAvailabilityPolicyV2(startedWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:start_availability`) }
886
+ try { assertWorkspaceLoadedOfferRefsUniqueV2(startedWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:start_workspace`) }
887
+ rememberReplayWorkspaceDigest(replayWorkspaceDigests, started.workspaceDigest, started.workspace, startedWorkspace)
888
+ const startedAvailability = normalizeAvailabilityPolicyForReplay(started.availability, startedWorkspace, undefined, undefined, replayWorkspaceDigests)
889
+ if (!started.workspaceDigest || !started.workspaceSemanticDigest || !started.workspace || !started.availability || !started.availabilityDigest || !validateAvailabilityPolicyV2(startedAvailability) || !replayDigestMatches(started.availabilityDigest, started.availability, startedAvailability) || bookingV2Digest(initialAvailability) !== bookingV2Digest(startedAvailability) || !replayWorkspaceDigestMatches(started.workspaceDigest, started.workspace, startedWorkspace) || !replayWorkspaceSemanticDigestMatches(started.workspaceSemanticDigest, started.workspace, startedWorkspace)) throw new Error(`ledger_corrupt:${taskId}:start_workspace`)
890
+ state = { schemaVersion: 'booking.surface.v2', taskId, contextRef: started.contextRef, surface: started.surface, revision: started.revision, allowedActions: [...started.allowedActions], userTurnCount: 0, operationCount: 0, phase: startedAvailability.terminal ? 'terminal' : 'planning', lastSequence: 0, workspaceDigest: bookingV2WorkspaceDigest(startedWorkspace), workspaceSemanticDigest: bookingV2WorkspaceSemanticDigest(startedWorkspace), workspaceSnapshot: startedWorkspace, availability: startedAvailability }
891
+ } else {
892
+ if (!state) throw new Error(`ledger_corrupt:${taskId}:event_before_start`)
893
+ if (payload.contextRef !== state.contextRef) throw new Error(`ledger_corrupt:${taskId}:context_drift`)
894
+ if (row.kind === REQUEST_BINDING) {
895
+ if (skipLegacyUpgradeTail) {
896
+ const binding = payload as RequestBindingPayload
897
+ if (typeof binding.requestKey === 'string') suppressedRequestBindingKeys.add(binding.requestKey)
898
+ continue
899
+ }
900
+ const binding = payload as RequestBindingPayload
901
+ 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 }>
902
+ const rawBoundTurn = turnRows.map(({ payload }) => JSON.parse(payload) as TurnPayload).find((candidate) => candidate.turnId === binding.turnId)
903
+ let boundTurn: TurnPayload | undefined
904
+ try { boundTurn = rawBoundTurn ? { ...rawBoundTurn, workspace: normalizeWorkspaceForReplay(rawBoundTurn.workspace) } : undefined } catch { throw new Error(`ledger_corrupt:${taskId}:request_binding`) }
905
+ if (!isSafeBookingRequestKeyV2(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 || bookingV2Digest(boundTurn.workspace.capabilities) !== binding.capabilityDigest || boundTurn.workspace.surface !== binding.surface) throw new Error(`ledger_corrupt:${taskId}:request_binding`)
906
+ } else if (row.kind === TURN) {
907
+ skipLegacyUpgradeTail = false
908
+ const t = payload as TurnPayload
909
+ if (Object.prototype.hasOwnProperty.call(payload, 'approval')) throw new Error(`ledger_corrupt:${taskId}:turn_approval`)
910
+ let turnWorkspace: BookingWorkspaceSnapshotV2
911
+ try { turnWorkspace = normalizeWorkspaceForReplay(t.workspace) } catch { throw new Error(`ledger_corrupt:${taskId}:turn_workspace`) }
912
+ try { assertWorkspaceLoadedOfferRefsUniqueV2(turnWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:turn_workspace`) }
913
+ 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`)
914
+ rememberReplayWorkspaceDigest(replayWorkspaceDigests, t.workspaceDigest, t.workspace, turnWorkspace)
915
+ state.userTurnCount++
916
+ state.lastTurnId = t.turnId
917
+ state.workspaceDigest = bookingV2WorkspaceDigest(turnWorkspace)
918
+ state.workspaceSemanticDigest = bookingV2WorkspaceSemanticDigest(turnWorkspace)
919
+ state.workspaceSnapshot = turnWorkspace
920
+ state.revision = turnWorkspace.revision
921
+ delete state.replayUpgradeRequired
922
+ // Approval authority is established only by APPROVAL_GRANTED; a turn
923
+ // is merely an observation and cannot restore or mutate approval state.
924
+ } else if (row.kind === APPROVAL_GRANTED) {
925
+ if (skipLegacyUpgradeTail) continue
926
+ const granted = payload as ApprovalPayload
927
+ if (!granted.approval || granted.approvalDigest !== bookingV2Digest(granted.approval) || !granted.approval.sourceTurnId || !granted.approval.presentationRequestKey || !validateCriterionBlockerV2(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) => bookingV2Digest(option) === bookingV2Digest(granted.approval!.approval!)))) throw new Error(`ledger_corrupt:${taskId}:approval_granted`)
928
+ state.awaitingApproval = granted.approval; state.phase = 'input_required'
929
+ } else if (row.kind === APPROVAL_OFFERED) {
930
+ if (skipLegacyUpgradeTail) continue
931
+ const offered = payload as ApprovalPayload
932
+ if (!offered.approval || offered.approvalDigest !== bookingV2Digest(offered.approval) || !state.awaitingApproval || bookingV2Digest(offered.approval) !== bookingV2Digest(state.awaitingApproval)) throw new Error(`ledger_corrupt:${taskId}:approval_offered`)
933
+ // This is a durable outbox intent only. It is deliberately not
934
+ // evidence that the question entered a replayable output batch:
935
+ // a crash between this row and DECISION_BATCH must still reject an
936
+ // approval until the exact question can be replayed.
937
+ state.phase = 'input_required'
938
+ } else if (row.kind === ACTION) {
939
+ const actionPayload = payload as ActionPayload
940
+ if (skipLegacyUpgradeTail) { try { consumeSkippedActionOrdinal(state, actionPayload, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:action`) } continue }
941
+ const rawAction = actionPayload.action
942
+ const operationCount = actionPayload.operationCount ?? state.operationCount + 1
943
+ const { actionDigest: _rawActionDigest, ...rawActionBase } = rawAction
944
+ if (rawAction.actionDigest !== checkpointDigest(rawActionBase) || rawAction.contextRef !== state.contextRef || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount + 1 || operationCount > BOOKING_COPILOT_MAX_OPERATIONS_V2 || !Number.isSafeInteger(rawAction.sequence) || rawAction.sequence <= state.lastSequence) throw new Error(`ledger_corrupt:${taskId}:action`)
945
+ let a: BookingActionCheckpointV2
946
+ try { a = normalizeActionCheckpointForReplay(rawAction, state.workspaceSnapshot) } catch (error) {
947
+ if (error instanceof Error && error.message.startsWith('booking_v2_replan_required:')) { state = replanAfterReplayUpgradeGap(state); try { consumeSkippedActionOrdinal(state, actionPayload, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:action`) } skipLegacyUpgradeTail = true; continue }
948
+ throw error
949
+ }
950
+ const { actionDigest: _actionDigest, ...actionBase } = a
951
+ if (!actionPayload.availability || actionPayload.availabilityDigest !== bookingV2Digest(actionPayload.availability)) throw new Error(`ledger_corrupt:${taskId}:action`)
952
+ let actionAvailability: AvailabilityPolicyStateV2
953
+ try { actionAvailability = normalizeAvailabilityPolicyForReplay(actionPayload.availability, state.workspaceSnapshot, a, state.availability, replayWorkspaceDigests) } catch (error) {
954
+ if (error instanceof Error && error.message.startsWith('booking_v2_replan_required:')) { state = replanAfterReplayUpgradeGap(state); try { consumeSkippedActionOrdinal(state, actionPayload, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:action`) } skipLegacyUpgradeTail = true; continue }
955
+ throw error
956
+ }
957
+ if (!checkpointDigestMatchesForReplay(rawAction, a) || a.actionDigest !== checkpointDigest(actionBase) || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount + 1 || operationCount > BOOKING_COPILOT_MAX_OPERATIONS_V2 || !validateAvailabilityPolicyV2(actionAvailability) || !replayDigestMatches(actionPayload.availabilityDigest, actionPayload.availability, actionAvailability) || !state.workspaceSnapshot) throw new Error(`ledger_corrupt:${taskId}:action`)
958
+ if (a.relaxationApprovalRef) assertApprovalRef(a.relaxationApprovalRef, state, a)
959
+ if (state.pendingAction) throw new Error(`ledger_corrupt:${taskId}:parallel_actions`)
960
+ let expectedAvailability: AvailabilityPolicyStateV2
961
+ try { expectedAvailability = reduceAvailabilityActionV2(state.availability, state.workspaceSnapshot, a) } catch { throw new Error(`ledger_corrupt:${taskId}:action_transition`) }
962
+ if (bookingV2Digest(expectedAvailability) !== bookingV2Digest(actionAvailability)) throw new Error(`ledger_corrupt:${taskId}:action_transition`)
963
+ state.operationCount = operationCount
964
+ state.availability = actionAvailability
965
+ state.pendingAction = a; state.phase = 'waiting_receipt'; state.lastSequence = Math.max(state.lastSequence, a.sequence)
966
+ {
967
+ rememberConsumedActionIdentity(consumed, a)
968
+ }
969
+ delete state.awaitingApproval
970
+ } else if (row.kind === RECEIPT) {
971
+ if (skipLegacyUpgradeTail) {
972
+ const r = payload as ReceiptPayload
973
+ if (typeof r.receipt?.actionId === 'string') suppressedReceiptActionIds.add(r.receipt.actionId)
974
+ continue
975
+ }
976
+ const r = payload as ReceiptPayload
977
+ const operationCount = r.operationCount ?? state.operationCount
978
+ if (!state.pendingAction || state.pendingAction.actionId !== r.receipt.actionId) throw new Error(`ledger_corrupt:${taskId}:orphan_receipt`)
979
+ let receiptWorkspace: BookingWorkspaceSnapshotV2
980
+ try { receiptWorkspace = normalizeWorkspaceForReplay(r.workspace) } catch { throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`) }
981
+ try { assertWorkspaceLoadedOfferRefsUniqueV2(receiptWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`) }
982
+ 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`)
983
+ rememberReplayWorkspaceDigest(replayWorkspaceDigests, r.workspaceDigest, r.workspace, receiptWorkspace)
984
+ 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`)
985
+ if (!r.availability || r.availabilityDigest !== bookingV2Digest(r.availability)) throw new Error(`ledger_corrupt:${taskId}:receipt`)
986
+ let receipt: ActionReceiptV2
987
+ try { receipt = normalizeReceiptForReplay(r.receipt, state.pendingAction, receiptWorkspace) } catch (error) {
988
+ if (error instanceof Error && error.message.startsWith('booking_v2_replan_required:')) { clearPendingBatch(consumed); state = replanAfterReplayUpgradeGap(state, receiptWorkspace, r.receipt.revision); skipLegacyUpgradeTail = true; continue }
989
+ throw error
990
+ }
991
+ let receiptAvailability: AvailabilityPolicyStateV2
992
+ try { receiptAvailability = normalizeAvailabilityPolicyForReplay(r.availability, receiptWorkspace, state.pendingAction, state.availability, replayWorkspaceDigests, receipt) } catch (error) {
993
+ if (error instanceof Error && error.message.startsWith('booking_v2_replan_required:')) { clearPendingBatch(consumed); state = replanAfterReplayUpgradeGap(state, receiptWorkspace, r.receipt.revision); skipLegacyUpgradeTail = true; continue }
994
+ throw error
995
+ }
996
+ if (!replayDigestMatches(r.receiptDigest, r.receipt, receipt) || !validateBookingSurfaceV2(receipt).ok || !r.availability || !r.availabilityDigest || !validateAvailabilityPolicyV2(receiptAvailability) || !replayDigestMatches(r.availabilityDigest, r.availability, receiptAvailability)) throw new Error(`ledger_corrupt:${taskId}:receipt`)
997
+ if (Boolean(r.availabilityTerminal) !== Boolean(receiptAvailability.terminal) || (r.availabilityTerminal && receiptAvailability.terminal && bookingV2Digest(r.availabilityTerminal) !== bookingV2Digest(receiptAvailability.terminal))) throw new Error(`ledger_corrupt:${taskId}:availability_terminal`)
998
+ assertCanonicalReceipt(receipt)
999
+ 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`)
1000
+ let expectedAvailability: AvailabilityPolicyStateV2
1001
+ try { expectedAvailability = reduceAvailabilityReceiptV2(state.availability, receiptWorkspace, receipt, state.pendingAction) } catch { throw new Error(`ledger_corrupt:${taskId}:receipt_transition`) }
1002
+ if (bookingV2Digest(expectedAvailability) !== bookingV2Digest(receiptAvailability)) throw new Error(`ledger_corrupt:${taskId}:receipt_transition`)
1003
+ state.lastReceipt = receipt; state.revision = receipt.revision; state.workspaceDigest = bookingV2WorkspaceDigest(receiptWorkspace); state.workspaceSemanticDigest = bookingV2WorkspaceSemanticDigest(receiptWorkspace); state.workspaceSnapshot = receiptWorkspace; state.availability = receiptAvailability; delete state.pendingAction; state.phase = receiptAvailability.terminal || state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS_V2 ? 'terminal' : 'planning'
1004
+ clearPendingBatch(consumed)
1005
+ delete state.awaitingApproval
1006
+ } else if (row.kind === APPROVAL_CONSUMED) {
1007
+ if (skipLegacyUpgradeTail) {
1008
+ const consumed = payload as ApprovalPayload
1009
+ if (typeof consumed.ref?.targetActionId === 'string') suppressedApprovalTargetActionIds.add(consumed.ref.targetActionId)
1010
+ continue
1011
+ }
1012
+ const consumed = payload as ApprovalPayload
1013
+ if (!consumed.ref || consumed.approvalDigest !== bookingV2Digest(consumed.ref) || !consumed.approval?.approval || !consumed.approval.options?.some((option) => bookingV2Digest(option) === bookingV2Digest(consumed.approval!.approval!)) || !state.awaitingApproval || bookingV2Digest(consumed.approval) !== bookingV2Digest(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`)
1014
+ } else if (row.kind === DECISION_BATCH) {
1015
+ const batch = payload as DecisionBatchPayload
1016
+ if (skipLegacyUpgradeTail) {
1017
+ try { consumeSkippedEventSequence(state, batch, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:decision_batch`) }
1018
+ if (typeof batch.requestKey === 'string') suppressedDecisionRequestKeys.add(batch.requestKey)
1019
+ continue
1020
+ }
1021
+ if (!batch.requestKey || !Array.isArray(batch.events) || batch.events.some((event) => event.taskId !== taskId)) throw new Error(`ledger_corrupt:${taskId}:decision_batch`)
1022
+ let batchEvents: BookingSurfaceEventV2[]
1023
+ try { batchEvents = normalizeDecisionBatchEnvelopeForReplay(batch, consumed, true) } catch { throw new Error(`ledger_corrupt:${taskId}:decision_batch`) }
1024
+ for (const event of batchEvents) {
1025
+ if (event.kind !== 'question') continue
1026
+ const awaiting = state.awaitingApproval
1027
+ if (!awaiting || bookingV2Digest(event.question.blocker) !== bookingV2Digest(awaiting.blocker) || event.question.approvalOptions.length !== awaiting.options.length || event.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => bookingV2Digest(option) === bookingV2Digest(approval)))) throw new Error(`ledger_corrupt:${taskId}:decision_batch_question`)
1028
+ // Only the atomic, durable batch makes the presentation nonce
1029
+ // usable. APPROVAL_OFFERED alone is intentionally insufficient.
1030
+ awaiting.optionsEmitted = true
1031
+ state.phase = 'input_required'
1032
+ }
1033
+ } else if (row.kind === EVENT) {
1034
+ const event = payload as EventPayload
1035
+ if (skipLegacyUpgradeTail) { try { consumeSkippedEventSequence(state, event, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:event`) } continue }
1036
+ state.lastSequence = Math.max(state.lastSequence, event.sequence)
1037
+ {
1038
+ rememberConsumedEventIdentity(consumed, event)
1039
+ }
1040
+ if (event.eventKind === 'status') state.phase = event.status === 'submitted' ? 'submitted' : event.status === 'working' ? 'working' : event.status === 'waiting_receipt' ? 'waiting_receipt' : 'input_required'
1041
+ else if (event.eventKind === 'question') state.phase = 'input_required'
1042
+ else if (event.eventKind === 'terminal') state.phase = 'terminal'
1043
+ else if (event.eventKind === 'error') state.phase = 'error'
1044
+ }
1045
+ }
1046
+ }
1047
+ if (state) {
1048
+ if (suppressedDecisionRequestKeys.size) state.legacySuppressedDecisionRequestKeys = [...suppressedDecisionRequestKeys]
1049
+ if (suppressedRequestBindingKeys.size) state.legacySuppressedRequestBindingKeys = [...suppressedRequestBindingKeys]
1050
+ if (suppressedReceiptActionIds.size) state.legacySuppressedReceiptActionIds = [...suppressedReceiptActionIds]
1051
+ if (suppressedApprovalTargetActionIds.size) state.legacySuppressedApprovalTargetActionIds = [...suppressedApprovalTargetActionIds]
1052
+ }
1053
+ return state
1054
+ }
1055
+
1056
+ withReceiptDigest<T extends ActionReceiptV2>(receipt: T): T {
1057
+ // The source receipt digest is the authority used by the approval binding.
1058
+ const sourceDigest = receiptSourceDigest(receipt)
1059
+ return { ...receipt, resultContract: { ...receipt.resultContract, blockers: receipt.resultContract.blockers.map((b) => ({ ...b, sourceReceiptDigest: sourceDigest })) } }
1060
+ }
1061
+
1062
+ receiptDigest(receipt: ActionReceiptV2): string { return canonicalReceiptDigest(receipt) }
1063
+
1064
+ /** Builds the only relaxation question exposed to the planner stream. The
1065
+ * blocker is ledger-derived; a planner cannot invent its tuple or ref. */
1066
+ approvalQuestion(taskId: string): BookingSurfaceEventDraftV2 {
1067
+ const task = this.requireTask(taskId)
1068
+ const awaiting = task.awaitingApproval
1069
+ if (!awaiting) throw new Error('approval_not_awaiting')
1070
+ return {
1071
+ kind: 'question',
1072
+ question: {
1073
+ questionId: `question-${awaiting.blocker.blockerId}`,
1074
+ prompt: `Relax required criterion ${awaiting.blocker.criterionPath}?`,
1075
+ missingFields: [awaiting.blocker.criterionPath],
1076
+ type: 'relaxation_approval_required',
1077
+ blocker: awaiting.blocker,
1078
+ approvalOptions: awaiting.options.map((approval) => ({ approval })),
1079
+ },
1080
+ }
1081
+ }
1082
+
1083
+ approvalPresentationRequestKey(taskId: string): string {
1084
+ const task = this.requireTask(taskId)
1085
+ const awaiting = task.awaitingApproval
1086
+ if (!awaiting) throw new Error('approval_not_awaiting')
1087
+ return awaiting.presentationRequestKey
1088
+ }
1089
+
1090
+ commitDecisionBatch(taskId: string, requestKey: string, decisions: readonly BookingPlannerDecisionV2[]): BookingSurfaceEventV2[] {
1091
+ return this.applyDecisionBatch(taskId, requestKey, decisions)
1092
+ }
1093
+
1094
+ readDecisionBatch(taskId: string, requestKey: string): BookingSurfaceEventV2[] | null {
1095
+ const state = this.requireTask(taskId)
1096
+ if (state.legacySuppressedDecisionRequestKeys?.includes(requestKey)) throw new Error('request_key_invalidated')
1097
+ 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 }>
1098
+ for (const row of rows) {
1099
+ const payload = JSON.parse(row.payload) as DecisionBatchPayload
1100
+ if (payload.requestKey !== requestKey) continue
1101
+ 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`)
1102
+ const events = this.normalizeDecisionBatchFromDurableRows(taskId, payload.events, row.seq)
1103
+ if (!events) throw new Error(`ledger_corrupt:${taskId}:decision_batch`)
1104
+ for (const event of events) {
1105
+ if (event.kind !== 'question') continue
1106
+ const awaiting = state.awaitingApproval
1107
+ if (!awaiting || requestKey !== awaiting.presentationRequestKey || bookingV2Digest(event.question.blocker) !== bookingV2Digest(awaiting.blocker) || event.question.approvalOptions.length !== awaiting.options.length || event.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => option.optionDigest === approval.optionDigest && bookingV2Digest(option) === bookingV2Digest(approval)))) throw new Error(`ledger_corrupt:${taskId}:decision_batch_question`)
1108
+ }
1109
+ return events
1110
+ }
1111
+ return null
1112
+ }
1113
+
1114
+ /** Materializes the durable policy/budget terminal response once, before SSE. */
1115
+ terminalDecisionBatch(taskId: string, requestKey: string): BookingSurfaceEventV2[] {
1116
+ assertInternalDecisionKey(requestKey)
1117
+ const prior = this.readDecisionBatch(taskId, requestKey)
1118
+ if (prior) return prior
1119
+ const state = this.requireTask(taskId)
1120
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
1121
+ assertReplayUpgradeReanchored(state)
1122
+ const run = this.ledger.db.transaction(() => {
1123
+ const state = this.requireTask(taskId)
1124
+ if (!taskHasTerminalBudget(state) || state.phase !== 'terminal') throw new Error('task_not_terminal')
1125
+ assertReplayUpgradeReanchored(state)
1126
+ const event = this.terminalEvent(state)
1127
+ this.appendTerminalEvent(taskId, event)
1128
+ this.append(DECISION_BATCH, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, requestKey, events: [event] }, `booking-v2:decision-batch:${taskId}:${requestKey}`)
1129
+ return [event]
1130
+ })
1131
+ return run.immediate()
1132
+ }
1133
+
1134
+ /** Atomically applies planner decisions and records the exact SSE batch before it is sent. */
1135
+ applyDecisionBatch(taskId: string, requestKey: string, decisions: readonly BookingPlannerDecisionV2[], includeSubmitted = false): BookingSurfaceEventV2[] {
1136
+ assertInternalDecisionKey(requestKey)
1137
+ const prior = this.readDecisionBatch(taskId, requestKey)
1138
+ if (prior) return prior
1139
+ const current = this.requireTask(taskId)
1140
+ if (current.phase === 'terminal' || current.phase === 'error') throw new Error('task_terminal')
1141
+ assertReplayUpgradeReanchored(current)
1142
+ assertDecisionBatchFinality(decisions)
1143
+ assertAvailabilityLiveness(current, decisions)
1144
+ if (current.availability.recoveryStarted && !current.availability.terminal && decisions.some((decision) => decision.kind === 'terminal' || decision.kind === 'error')) throw new Error('availability_terminal_policy_owned')
1145
+ for (const decision of decisions) {
1146
+ if (decision.kind === 'operation') {
1147
+ const checked = validateBookingReadActionV2(decision.action)
1148
+ if (!checked.ok) throw new Error(`invalid_action:${errorText(checked)}`)
1149
+ }
1150
+ }
1151
+ const run = this.ledger.db.transaction(() => {
1152
+ const events: BookingSurfaceEventV2[] = []
1153
+ const before = this.requireTask(taskId)
1154
+ assertReplayUpgradeReanchored(before)
1155
+ assertAvailabilityLiveness(before, decisions)
1156
+ if (before.availability.recoveryStarted && !before.availability.terminal && decisions.some((decision) => decision.kind === 'terminal' || decision.kind === 'error')) throw new Error('availability_terminal_policy_owned')
1157
+ for (const decision of decisions) {
1158
+ if (decision.kind !== 'question') continue
1159
+ const awaiting = before.awaitingApproval
1160
+ if (!awaiting || requestKey !== awaiting.presentationRequestKey || bookingV2Digest(decision.question.blocker) !== bookingV2Digest(awaiting.blocker) || decision.question.approvalOptions.length !== awaiting.options.length || decision.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => option.optionDigest === approval.optionDigest && bookingV2Digest(option) === bookingV2Digest(approval)))) throw new Error('approval_presentation_key_mismatch')
1161
+ this.ensureApprovalOffered(taskId, before.contextRef, awaiting, requestKey)
1162
+ }
1163
+ if (includeSubmitted) events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'submitted' }))
1164
+ events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'working' }))
1165
+ for (const decision of decisions) {
1166
+ if (decision.kind === 'operation') {
1167
+ events.push(this.issueOperationInTransaction(taskId, decision.action))
1168
+ events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'waiting_receipt' }))
1169
+ }
1170
+ else events.push(this.emitEventInTransaction(taskId, decision))
1171
+ }
1172
+ const state = this.requireTask(taskId)
1173
+ this.append(DECISION_BATCH, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, requestKey, events }, `booking-v2:decision-batch:${taskId}:${requestKey}`)
1174
+ return events
1175
+ })
1176
+ return run.immediate()
1177
+ }
1178
+
1179
+ issueOperation(taskId: string, candidate: BookingReadActionV2): { schemaVersion: 'booking.surface.v2'; eventId: string; taskId: string; contextRef: string; sequence: number; emittedAt: string; kind: 'operation'; action: BookingReadActionV2 } {
1180
+ const validation = validateBookingReadActionV2(candidate)
1181
+ if (!validation.ok) throw new Error(`invalid_action:${errorText(validation)}`)
1182
+ assertTaskId(taskId); assertSafeRef(candidate.actionId); candidate.factRefs.forEach(assertSafeRef)
1183
+ return this.ledger.db.transaction(() => this.issueOperationInTransaction(taskId, candidate)).immediate()
1184
+ }
1185
+
1186
+ private issueOperationInTransaction(taskId: string, candidate: BookingReadActionV2) {
1187
+ assertSafeRef(candidate.actionId); candidate.factRefs.forEach(assertSafeRef)
1188
+ const state = this.requireTask(taskId)
1189
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
1190
+ assertReplayUpgradeReanchored(state)
1191
+ if (state.pendingAction) {
1192
+ if (state.pendingAction.actionId !== candidate.actionId) throw new Error('receipt_required')
1193
+ if (!matchesCheckpoint(candidate, state.pendingAction)) throw new Error('action_conflict')
1194
+ return this.operationEvent(taskId, state.pendingAction, candidate)
1195
+ }
1196
+ if (candidate.contextRef !== state.contextRef) throw new Error('context_mismatch')
1197
+ if (!state.allowedActions.includes(candidate.kind)) throw new Error('unsupported_action')
1198
+ if (candidate.expectedRevision !== state.revision) throw new Error('stale_revision')
1199
+ if (state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS_V2) throw new Error('operation_limit_reached')
1200
+ if (candidate.relaxationApprovalRef) throw new Error('approval_ref_planner_owned_forbidden')
1201
+ if (!state.workspaceSnapshot) throw new Error(`ledger_corrupt:${taskId}:workspace_missing`)
1202
+ if (!actionHitsCurrentOfferVersion(state.workspaceSnapshot, candidate, this.now())) throw new Error('offer_version_not_loaded')
1203
+ let action = candidate
1204
+ const availability = reduceAvailabilityActionV2(state.availability, state.workspaceSnapshot, candidate)
1205
+ if (state.awaitingApproval) {
1206
+ const approval = state.awaitingApproval
1207
+ if (!approval.approval || Date.parse(approval.expiresAt) <= Date.parse(this.now())) throw new Error('approval_expired')
1208
+ const ref: RelaxationApprovalRefV2 = {
1209
+ approvalId: approval.approval.approvalId, blockerId: approval.blocker.blockerId,
1210
+ contextRef: state.contextRef, sourceTurnId: approval.sourceTurnId,
1211
+ presentationRequestKey: approval.presentationRequestKey, sourceActionId: approval.blocker.sourceActionId,
1212
+ targetActionId: candidate.actionId, sourceRevision: candidate.expectedRevision,
1213
+ targetActionKind: candidate.kind, to: approval.approval.to, expiresAt: approval.expiresAt,
1214
+ nonce: approval.nonce, sourceReceiptDigest: approval.blocker.sourceReceiptDigest,
1215
+ scope: approval.blocker.scope, code: approval.blocker.code, criterionPath: approval.blocker.criterionPath,
1216
+ valueDigest: approval.blocker.valueDigest,
1217
+ }
1218
+ action = { ...candidate, relaxationApprovalRef: ref }
1219
+ this.append(APPROVAL_CONSUMED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, approval, ref, approvalDigest: bookingV2Digest(ref) }, `booking-v2:approval-consumed:${taskId}:${approval.approval.approvalId}:${approval.nonce}`)
1220
+ }
1221
+ const eventId = this.idFactory('event'); const emittedAt = this.now(); const sequence = state.lastSequence + 1
1222
+ const event = { schemaVersion: 'booking.surface.v2' as const, eventId, taskId, contextRef: state.contextRef, sequence, emittedAt, kind: 'operation' as const, action }
1223
+ const eventValidation = validateBookingSurfaceEventV2(event)
1224
+ if (!eventValidation.ok) throw new Error(`invalid_operation_event:${errorText(eventValidation)}`)
1225
+ if (!state.lastTurnId) throw new Error(`ledger_corrupt:${taskId}:turn_id`)
1226
+ this.appendAction(taskId, state.contextRef, checkpointFor(action, eventId, sequence, emittedAt, state.lastTurnId), state.operationCount + 1, availability)
1227
+ return event
1228
+ }
1229
+
1230
+ continueWithReceipt(turn: Extract<BookingCopilotTurnV2, { kind: 'action.receipt.continuation' }>): BookingCopilotTaskStateV2 {
1231
+ const validation = validateBookingSurfaceV2(turn)
1232
+ if (!validation.ok) throw new Error(`invalid_receipt_continuation:${errorText(validation)}`)
1233
+ assertWorkspaceLoadedOfferRefsUniqueV2(turn.workspace)
1234
+ assertCanonicalReceipt(turn.receipt)
1235
+ const run = this.ledger.db.transaction(() => {
1236
+ const state = this.requireTask(turn.taskId)
1237
+ assertReplayUpgradeReanchored(state)
1238
+ if (turn.workspace.contextRef !== state.contextRef || turn.receipt.contextRef !== state.contextRef) throw new Error('context_mismatch')
1239
+ const receiptWorkspace = bookingV2WorkspaceDigest(turn.workspace)
1240
+ const receiptWorkspaceSemantic = bookingV2WorkspaceSemanticDigest(turn.workspace)
1241
+ if (!state.workspaceSnapshot || !workspaceBoundaryMatches(state.workspaceSnapshot, turn.workspace)) throw new Error('workspace_mismatch')
1242
+ 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 }>
1243
+ const receiptDigest = canonicalReceiptDigest(turn.receipt)
1244
+ if (state.legacySuppressedReceiptActionIds?.includes(turn.receipt.actionId)) throw new Error('request_key_invalidated')
1245
+ for (const row of existing) {
1246
+ const prior = JSON.parse(row.payload) as ReceiptPayload
1247
+ if (prior.receipt.actionId !== turn.receipt.actionId) continue
1248
+ if (prior.receiptDigest !== receiptDigest) throw new Error('receipt_conflict')
1249
+ if (prior.workspaceDigest !== receiptWorkspace) throw new Error('workspace_mismatch')
1250
+ return state
1251
+ }
1252
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
1253
+ if (!state.pendingAction) throw new Error('unexpected_receipt')
1254
+ if (state.pendingAction.actionId !== turn.receipt.actionId) throw new Error('receipt_action_mismatch')
1255
+ if (turn.receipt.revision < state.pendingAction.expectedRevision) throw new Error('revision_regression')
1256
+ if (turn.receipt.resultContract.blockers.some((blocker) => blocker.sourceActionId !== state.pendingAction!.actionId)) throw new Error('receipt_source_action_mismatch')
1257
+ if (!workspacePostActionMatches(state.workspaceSnapshot, turn.workspace, state.pendingAction, turn.receipt)) throw new Error('workspace_mismatch')
1258
+ if (!receiptObservationMatchesAction(turn.receipt.observation.kind, state.pendingAction.kind)) throw new Error('receipt_observation_action_mismatch')
1259
+ if (!receiptTargetMatchesAction(turn.receipt, state.pendingAction)) throw new Error('receipt_target_mismatch')
1260
+ if (state.pendingAction.kind === 'offer.check' && turn.receipt.status === 'applied' && !verifiedOfferUnexpired(turn.workspace, this.now())) throw new Error('receipt_verified_offer_expired')
1261
+ // Failed/stale query receipts may carry a typed gap observation; only an
1262
+ // offers.state observation can claim passive offer provenance.
1263
+ if (state.pendingAction.kind === 'offers.query' && turn.receipt.observation.kind === 'offers.state') validateOffersReceiptWorkspaceV2(turn.receipt, turn.workspace, state.pendingAction.input.hotelRefs)
1264
+ if (turn.receipt.resultContract.relaxationsApplied.some((approval) => !this.approvalWasConsumed(turn.taskId, approval, state.pendingAction!.actionId))) throw new Error('receipt_relaxation_unauthorized')
1265
+ const pending = state.pendingAction
1266
+ if (turn.receipt.revision !== turn.workspace.revision) throw new Error('revision_mismatch')
1267
+ const availability = reduceAvailabilityReceiptV2(state.availability, turn.workspace, turn.receipt, pending)
1268
+ const availabilityTerminal = availabilityPolicyTerminal(availability)
1269
+ 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: bookingV2Digest(availability), ...(availabilityTerminal ? { availabilityTerminal } : {}) }, `booking-v2:receipt:${turn.taskId}:${turn.receipt.actionId}`)
1270
+ if (availabilityTerminal || state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS_V2) {
1271
+ const terminalState = this.requireTask(turn.taskId)
1272
+ const terminalEvent = this.terminalEvent(terminalState)
1273
+ this.appendTerminalEvent(turn.taskId, terminalEvent)
1274
+ this.append(DECISION_BATCH, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, requestKey: `receipt:${turn.receipt.actionId}:${receiptDigest}`, events: [terminalEvent] }, `booking-v2:decision-batch:${turn.taskId}:receipt:${turn.receipt.actionId}:${receiptDigest}`)
1275
+ return terminalState
1276
+ }
1277
+ const blocker = turn.receipt.resultContract.blockers[0]
1278
+ if (blocker) {
1279
+ // Never derive this claim from the injectable id factory: production
1280
+ // and crash proofs must not make a presentation nonce guessable from
1281
+ // deterministic task/event ids.
1282
+ const nonce = randomUUID()
1283
+ const sourceTurnId = state.pendingAction?.sourceTurnId ?? state.lastTurnId ?? `ordinal:${state.userTurnCount}`
1284
+ const requestKey = presentationKey(turn.taskId, sourceTurnId, turn.receipt.actionId, receiptDigest)
1285
+ 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 }
1286
+ this.append(APPROVAL_GRANTED, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, approval, approvalDigest: bookingV2Digest(approval) }, `booking-v2:approval-awaiting:${turn.taskId}:${turn.receipt.actionId}`)
1287
+ }
1288
+ return this.requireTask(turn.taskId)
1289
+ })
1290
+ return run.immediate()
1291
+ }
1292
+
1293
+ emitEvent(taskId: string, draft: BookingSurfaceEventDraftV2): Exclude<BookingSurfaceEventV2, { kind: 'operation' }> {
1294
+ return this.ledger.db.transaction(() => {
1295
+ const state = this.requireTask(taskId)
1296
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
1297
+ assertReplayUpgradeReanchored(state)
1298
+ return this.emitEventInTransaction(taskId, draft)
1299
+ }).immediate()
1300
+ }
1301
+
1302
+ private emitEventInTransaction(taskId: string, draft: BookingSurfaceEventDraftV2) {
1303
+ const state = this.requireTask(taskId)
1304
+ assertReplayUpgradeReanchored(state)
1305
+ const safeDraft = draft.kind === 'error'
1306
+ ? { ...draft, error: { ...draft.error, code: normalizeBookingErrorCode(draft.error.code), message: safeBookingErrorMessage(normalizeBookingErrorCode(draft.error.code)) } }
1307
+ : draft
1308
+ const event = { schemaVersion: 'booking.surface.v2' as const, eventId: this.idFactory('event'), taskId, contextRef: state.contextRef, sequence: state.lastSequence + 1, emittedAt: this.now(), ...safeDraft } as Exclude<BookingSurfaceEventV2, { kind: 'operation' }>
1309
+ const checked = validateBookingSurfaceEventV2(event)
1310
+ if (!checked.ok) throw new Error(`invalid_event:${errorText(checked)}`)
1311
+ 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: bookingV2Digest(safeDraft) }, `booking-v2:event:${taskId}:${event.eventId}`)
1312
+ return event
1313
+ }
1314
+
1315
+ private operationEvent(taskId: string, checkpoint: BookingActionCheckpointV2, action: BookingReadActionV2) {
1316
+ const replayAction = action.relaxationApprovalRef || !checkpoint.relaxationApprovalRef ? action : { ...action, relaxationApprovalRef: checkpoint.relaxationApprovalRef }
1317
+ return { schemaVersion: 'booking.surface.v2' as const, eventId: checkpoint.eventId, taskId, contextRef: checkpoint.contextRef, sequence: checkpoint.sequence, emittedAt: checkpoint.emittedAt, kind: 'operation' as const, action: replayAction }
1318
+ }
1319
+ private terminalEvent(state: BookingCopilotTaskStateV2): Extract<BookingSurfaceEventV2, { kind: 'terminal' }> {
1320
+ if (!taskHasTerminalBudget(state)) throw new Error('task_not_terminal')
1321
+ const terminalCode = state.availability.terminal?.code ?? 'operation_limit_reached'
1322
+ const event = { schemaVersion: 'booking.surface.v2' 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: state.availability.terminal?.code === 'availability_confirmed' ? 'completed' as const : 'stopped' as const, summary: terminalCode, factRefs: [] } }
1323
+ const checked = validateBookingSurfaceEventV2(event)
1324
+ if (!checked.ok) throw new Error(`invalid_terminal_event:${errorText(checked)}`)
1325
+ return event
1326
+ }
1327
+
1328
+ private requireTask(taskId: string): BookingCopilotTaskStateV2 { const state = this.resumeTask(taskId); if (!state) throw new Error('task_not_found'); return state }
1329
+ private requestBinding(requestKey: string): RequestBindingPayload | null {
1330
+ 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 }>
1331
+ for (const row of rows) {
1332
+ const binding = JSON.parse(row.payload) as RequestBindingPayload
1333
+ if (binding.requestKey === requestKey) {
1334
+ const state = this.resumeTask(binding.taskId)
1335
+ if (state?.legacySuppressedRequestBindingKeys?.includes(requestKey)) throw new Error('request_key_invalidated')
1336
+ return binding
1337
+ }
1338
+ }
1339
+ return null
1340
+ }
1341
+ private requestBindingPayload(requestKey: string, turn: UserTurnV2, input: BookingIngressRequestBindingInputV2): Omit<RequestBindingPayload, 'schema' | 'taskId' | 'contextRef'> {
1342
+ assertPrincipal(input.principal)
1343
+ assertRequestKey(requestKey)
1344
+ if (input.taskHandle) assertSafeRef(input.taskHandle)
1345
+ return {
1346
+ requestKey,
1347
+ requestDigest: bookingV2TurnDigest(turn),
1348
+ turnId: turn.turnId,
1349
+ workspaceDigest: bookingV2WorkspaceDigest(turn.workspace),
1350
+ workspaceSemanticDigest: bookingV2WorkspaceSemanticDigest(turn.workspace),
1351
+ surface: turn.workspace.surface,
1352
+ capabilityDigest: bookingV2Digest(turn.workspace.capabilities),
1353
+ principalDigest: bookingV2Digest({ subject: input.principal.subject }),
1354
+ scopeDigest: bookingV2Digest({ scope: input.principal.scope }),
1355
+ ...(input.taskHandle ? { taskHandle: input.taskHandle } : {}),
1356
+ }
1357
+ }
1358
+ private appendRequestBindingInTransaction(requestKey: string, turn: UserTurnV2, input: BookingIngressRequestBindingInputV2): void {
1359
+ assertRequestKey(requestKey)
1360
+ assertPrincipal(input.principal)
1361
+ const payload = this.requestBindingPayload(requestKey, turn, input)
1362
+ if (input.taskHandle) {
1363
+ 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 }>
1364
+ for (const row of prior) {
1365
+ const binding = JSON.parse(row.payload) as RequestBindingPayload
1366
+ if (binding.taskHandle !== input.taskHandle) continue
1367
+ if (binding.principalDigest !== payload.principalDigest || binding.scopeDigest !== payload.scopeDigest) throw new Error('principal_conflict')
1368
+ if (binding.taskId !== turn.taskId || binding.contextRef !== turn.workspace.contextRef) throw new Error('task_handle_conflict')
1369
+ }
1370
+ }
1371
+ const existing = this.requestBinding(requestKey)
1372
+ if (existing) {
1373
+ 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')
1374
+ return
1375
+ }
1376
+ this.append(REQUEST_BINDING, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: turn.workspace.contextRef, ...payload }, `booking-v2:request-binding:${requestKey}`)
1377
+ }
1378
+ private normalizeDecisionBatchFromDurableRows(taskId: string, batchEvents: readonly BookingSurfaceEventV2[], batchSeq: number): BookingSurfaceEventV2[] | null {
1379
+ 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 }>
1380
+ 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 }>
1381
+ const sourceWorkspaces = new Map<string, BookingWorkspaceSnapshotV2>()
1382
+ for (const row of turnRows) {
1383
+ try {
1384
+ const turn = JSON.parse(row.payload) as TurnPayload
1385
+ sourceWorkspaces.set(turn.turnId, normalizeWorkspaceForReplay(turn.workspace))
1386
+ } catch {
1387
+ return null
1388
+ }
1389
+ }
1390
+ const recordedEvents = new Map<string, EventPayload>()
1391
+ const recordedActions = new Map<string, BookingActionCheckpointV2>()
1392
+ for (const row of individualRows) {
1393
+ if (row.kind === EVENT) {
1394
+ const payload = JSON.parse(row.payload) as EventPayload
1395
+ const key = consumedIdentityKey(payload.eventId, payload.sequence)
1396
+ if (recordedEvents.has(key) || recordedActions.has(key)) return null
1397
+ recordedEvents.set(key, payload)
1398
+ } else if (row.kind === ACTION) {
1399
+ const payload = JSON.parse(row.payload) as ActionPayload
1400
+ let action = payload.action
1401
+ try {
1402
+ action = normalizeActionCheckpointForReplay(action, sourceWorkspaces.get(action.sourceTurnId))
1403
+ } catch {
1404
+ return null
1405
+ }
1406
+ const key = consumedIdentityKey(action.eventId, action.sequence)
1407
+ if (recordedEvents.has(key) || recordedActions.has(key)) return null
1408
+ recordedActions.set(key, action)
1409
+ }
1410
+ }
1411
+ let lastBatchSequence = -1
1412
+ const seen = new Set<string>()
1413
+ const normalizedEvents: BookingSurfaceEventV2[] = []
1414
+ for (const event of batchEvents) {
1415
+ if (!Number.isSafeInteger(event.sequence) || event.sequence <= lastBatchSequence) return null
1416
+ lastBatchSequence = event.sequence
1417
+ const key = consumedIdentityKey(event.eventId, event.sequence)
1418
+ if (seen.has(key)) return null
1419
+ seen.add(key)
1420
+ if (event.kind === 'operation') {
1421
+ const checkpoint = recordedActions.get(key)
1422
+ if (!checkpoint || !operationEventMatchesAction(event, checkpoint)) return null
1423
+ const normalized = { ...event, action: actionFromOperationEventForReplay(event, checkpoint) } as BookingSurfaceEventV2
1424
+ if (!validateBookingSurfaceEventV2(normalized).ok) return null
1425
+ normalizedEvents.push(normalized)
1426
+ } else {
1427
+ const recorded = recordedEvents.get(key)
1428
+ if (!recorded || !validateBookingSurfaceEventV2(event).ok || !eventPayloadMatchesEvent(recorded, event)) return null
1429
+ normalizedEvents.push(event)
1430
+ }
1431
+ }
1432
+ return normalizedEvents
1433
+ }
1434
+ 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[] }
1435
+ private append(kind: string, taskId: string, payload: BasePayload, idemKey: string): void {
1436
+ 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)
1437
+ if (result.changes === 0) throw new Error(`ledger_write_failed:${kind}`)
1438
+ }
1439
+ private appendTurn(taskId: string, contextRef: string, requestDigest: string, workspaceDigest: string, workspaceSemanticDigest: string, ordinal: number, turnId: string, workspace?: BookingWorkspaceSnapshotV2): 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 }, `booking-v2:turn:${taskId}:${turnId}`) }
1440
+ private appendApproval(taskId: string, contextRef: string, approval: BookingApprovalStateV2): void { this.append(APPROVAL_GRANTED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, approval, approvalDigest: bookingV2Digest(approval) }, `booking-v2:approval-granted:${taskId}:${approval.approval?.approvalId ?? 'pending'}:${approval.nonce}`) }
1441
+ private appendAction(taskId: string, contextRef: string, action: BookingActionCheckpointV2, operationCount: number, availability: AvailabilityPolicyStateV2): void { this.append(ACTION, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, action, operationCount, availability, availabilityDigest: bookingV2Digest(availability) }, `booking-v2:action:${taskId}:${action.actionId}`) }
1442
+ private appendTerminalEvent(taskId: string, event: Extract<BookingSurfaceEventV2, { kind: 'terminal' }>): void {
1443
+ const payload: EventPayload = { schema: LEDGER_SCHEMA, taskId, contextRef: event.contextRef, eventId: event.eventId, sequence: event.sequence, emittedAt: event.emittedAt, eventKind: 'terminal', contentDigest: bookingV2Digest(event.terminal) }
1444
+ const idemKey = `booking-v2:terminal-event:${taskId}`
1445
+ 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)
1446
+ if (result.changes === 0) {
1447
+ 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
1448
+ if (!existing || (JSON.parse(existing.payload) as EventPayload).contentDigest !== payload.contentDigest) throw new Error('ledger_write_failed:terminal_event')
1449
+ }
1450
+ }
1451
+ private ensureApprovalOffered(taskId: string, contextRef: string, approval: BookingApprovalStateV2, requestKey: string): void {
1452
+ if (requestKey !== approval.presentationRequestKey) throw new Error('approval_presentation_key_mismatch')
1453
+ const idemKey = `booking-v2:approval-offered:${taskId}:${requestKey}`
1454
+ 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
1455
+ if (!exists) this.append(APPROVAL_OFFERED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, approval, approvalDigest: bookingV2Digest(approval) }, idemKey)
1456
+ }
1457
+ private approvalWasConsumed(taskId: string, approval: RelaxationApprovalV2, targetActionId: string): boolean {
1458
+ const state = this.resumeTask(taskId)
1459
+ if (state?.legacySuppressedApprovalTargetActionIds?.includes(targetActionId)) return false
1460
+ 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 }>
1461
+ return rows.some(({ payload }) => {
1462
+ const consumed = JSON.parse(payload) as ApprovalPayload
1463
+ return consumed.ref?.targetActionId === targetActionId && consumed.approval?.approval && bookingV2Digest(consumed.approval.approval) === bookingV2Digest(approval)
1464
+ })
1465
+ }
1466
+ }