@danceiny/gotry 0.0.1-rc.18 → 0.0.1-rc.19

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 (137) hide show
  1. package/README.md +42 -16
  2. package/README.zh-CN.md +37 -15
  3. package/bin/gotry-bootstrap.js +236 -3
  4. package/bin/gotry-inner.js +66 -20
  5. package/bin/gotry-runtime-resolution.d.ts +1 -1
  6. package/bin/gotry-runtime-resolution.js +7 -8
  7. package/cordis.gotry-patch.yml +15 -5
  8. package/data/stations-12306-verify.json +524 -0
  9. package/dist/capabilities/agent-reach.js +4 -4
  10. package/dist/capabilities/channel-health.js +103 -0
  11. package/dist/capabilities/channel-registry.js +213 -0
  12. package/dist/capabilities/doctor.js +285 -0
  13. package/dist/capabilities/effect.js +178 -5
  14. package/dist/capabilities/flyai.js +13 -0
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
  16. package/dist/capabilities/session/adapters/rail-12306.js +285 -0
  17. package/dist/capabilities/session/extension-bridge.js +14 -1
  18. package/dist/capabilities/session/extension-channel.js +4 -2
  19. package/dist/capabilities/session/extension-distribution.js +1 -1
  20. package/dist/capabilities/session-search.js +311 -0
  21. package/dist/capabilities/visa-policy.js +126 -0
  22. package/dist/scripts/agent-planning-turn-deadline-e2e.js +304 -0
  23. package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
  24. package/dist/scripts/benchmark-environment-bridge-e2e.js +5 -10
  25. package/dist/scripts/benchmark-environment-bridge-tests.js +148 -142
  26. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
  27. package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
  28. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
  29. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
  30. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
  31. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
  32. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
  33. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
  34. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
  35. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
  36. package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
  37. package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
  38. package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
  39. package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
  40. package/dist/scripts/bootstrap-tests.js +87 -2
  41. package/dist/scripts/build-metrics-report.js +339 -0
  42. package/dist/scripts/channel-probe-tests.js +148 -0
  43. package/dist/scripts/channel-probe.js +252 -0
  44. package/dist/scripts/channel-registry-tests.js +262 -0
  45. package/dist/scripts/doctor-tests.js +121 -0
  46. package/dist/scripts/effect-tests.js +274 -1
  47. package/dist/scripts/extension-tests.js +129 -11
  48. package/dist/scripts/fact-gate-tests.js +91 -2
  49. package/dist/scripts/flyai-tests.js +17 -1
  50. package/dist/scripts/hbcli-e2e-tests.js +1 -3
  51. package/dist/scripts/metrics-report-tests.js +288 -0
  52. package/dist/scripts/persona-surface-guard-tests.js +19 -0
  53. package/dist/scripts/session-tests.js +401 -1
  54. package/dist/scripts/smoke.js +100 -83
  55. package/dist/scripts/turn-handoff-collect-tests.js +216 -0
  56. package/dist/scripts/turn-handoff-collect.js +189 -0
  57. package/dist/scripts/turn-policy-tests.js +56 -0
  58. package/dist/scripts/typed-contract-canary.js +261 -0
  59. package/dist/scripts/visa-policy-tests.js +75 -0
  60. package/dist/scripts/wish-channel-gate-tests.js +111 -0
  61. package/dist/src/artifact-gate.js +91 -3
  62. package/dist/src/benchmark-agent-conformance.js +7 -4
  63. package/dist/src/benchmark-environment-bridge.js +68 -7
  64. package/dist/src/bookable-facts.js +62 -3
  65. package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
  66. package/dist/src/booking-surface/canonical-schema.js +8 -22
  67. package/dist/src/booking-surface/contracts.js +70 -19
  68. package/dist/src/booking-surface/dsh-planner.js +310 -177
  69. package/dist/src/booking-surface/dsh-plugin.js +3 -3
  70. package/dist/src/booking-surface/index.js +1 -5
  71. package/dist/src/booking-surface/profile.js +1 -1
  72. package/dist/src/booking-surface/runtime.js +1682 -247
  73. package/dist/src/booking-surface/server.js +371 -185
  74. package/dist/src/booking-surface/startup.js +17 -27
  75. package/dist/src/booking-surface/validation.js +270 -760
  76. package/dist/src/index.js +735 -149
  77. package/dist/src/turn-deadline.js +292 -0
  78. package/dist/src/turn-policy.js +146 -0
  79. package/dist/src/wish-pool.js +5 -0
  80. package/extension/background.js +23 -4
  81. package/extension/content-main.js +47 -9
  82. package/extension/manifest.json +27 -9
  83. package/package.json +6 -26
  84. package/schemas/booking.surface.schema.json +2593 -0
  85. package/ts/capabilities/agent-reach.ts +4 -4
  86. package/ts/capabilities/flyai.ts +21 -3
  87. package/ts/capabilities/session/action-cache.ts +1 -1
  88. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  89. package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
  90. package/ts/capabilities/session/extension-bridge.ts +32 -2
  91. package/ts/capabilities/session/extension-channel.ts +9 -8
  92. package/ts/capabilities/session/extension-distribution.ts +3 -1
  93. package/ts/capabilities/session/health-watch.ts +1 -1
  94. package/ts/capabilities/session/read-guard.ts +1 -1
  95. package/ts/capabilities/session/wizard.ts +1 -1
  96. package/ts/capabilities/session-search.ts +323 -1
  97. package/ts/package.json +1 -0
  98. package/ts/scripts/turn-handoff-collect.ts +178 -0
  99. package/ts/src/artifact-gate.ts +82 -2
  100. package/ts/src/benchmark-agent-conformance.ts +14 -6
  101. package/ts/src/benchmark-environment-bridge.ts +29 -10
  102. package/ts/src/bookable-facts.ts +109 -5
  103. package/ts/src/booking-saga.ts +1 -1
  104. package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
  105. package/ts/src/booking-surface/canonical-schema.js +8 -22
  106. package/ts/src/booking-surface/contracts.ts +278 -239
  107. package/ts/src/booking-surface/dsh-planner.ts +333 -194
  108. package/ts/src/booking-surface/dsh-plugin.js +3 -3
  109. package/ts/src/booking-surface/index.ts +1 -5
  110. package/ts/src/booking-surface/profile.ts +11 -11
  111. package/ts/src/booking-surface/runtime.ts +1351 -351
  112. package/ts/src/booking-surface/server.ts +282 -175
  113. package/ts/src/booking-surface/startup.ts +36 -48
  114. package/ts/src/booking-surface/validation.ts +194 -442
  115. package/ts/src/contracts.ts +1 -1
  116. package/ts/src/index.ts +489 -171
  117. package/ts/src/loop.ts +1 -1
  118. package/ts/src/state-ledger.ts +1 -1
  119. package/ts/src/turn-deadline.ts +361 -0
  120. package/ts/src/turn-policy.ts +154 -0
  121. package/ts/src/wish-pool.ts +17 -4
  122. package/dist/scripts/agent-planning-budget-e2e.js +0 -227
  123. package/dist/scripts/agent-planning-budget-tests.js +0 -173
  124. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
  125. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
  126. package/dist/src/booking-surface/contracts-v2.js +0 -89
  127. package/dist/src/booking-surface/runtime-v2.js +0 -1771
  128. package/dist/src/booking-surface/server-v2.js +0 -334
  129. package/dist/src/booking-surface/validation-v2.js +0 -319
  130. package/dist/src/tool-budget.js +0 -136
  131. package/schemas/booking.surface.v1.schema.json +0 -927
  132. package/schemas/booking.surface.v2.schema.json +0 -61
  133. package/ts/src/booking-surface/contracts-v2.ts +0 -118
  134. package/ts/src/booking-surface/runtime-v2.ts +0 -1466
  135. package/ts/src/booking-surface/server-v2.ts +0 -247
  136. package/ts/src/booking-surface/validation-v2.ts +0 -205
  137. package/ts/src/tool-budget.ts +0 -165
@@ -1,483 +1,1483 @@
1
- /**
2
- * Durable, task-scoped runtime for the embedded Booking Copilot.
3
- *
4
- * This is deliberately not a chat transcript. The ledger stores task identity,
5
- * typed operation metadata, typed receipts and digests of prose/payloads. Raw
6
- * user text and raw action criteria never enter durable state.
7
- */
1
+ /** Durable, task-scoped runtime for the embedded Booking Copilot. */
8
2
 
9
3
  import { createHash, randomUUID } from 'node:crypto'
10
4
  import type { StateLedger } from '../state-ledger.ts'
11
5
  import {
12
- type ActionReceiptContinuationV1,
13
- type ActionReceiptV1,
14
- type BookingExplanationEventV1,
15
- type BookingQuestionEventV1,
16
- type BookingReadActionKindV1,
17
- type BookingReadActionV1,
18
- type BookingStatusEventV1,
19
- type BookingSurfaceEventV1,
20
- type BookingTerminalEventV1,
21
- type BookingErrorEventV1,
22
- type BookingOperationEventV1,
23
- type BookingSurfaceV1,
24
- type UserTurnV1,
6
+ BOOKING_READ_ACTION_KINDS,
7
+ BOOKING_COPILOT_MAX_OPERATIONS,
8
+ type ActionReceipt,
9
+ type BookingCopilotTurn,
10
+ type BookingReadAction,
11
+ type BookingReadActionKind,
12
+ type BookingSurfaceEvent,
13
+ type BookingSurface,
14
+ type BookingWorkspaceSnapshot,
15
+ type BookingWorkspaceIngressSnapshot,
16
+ type BookingRequestKey,
17
+ type BookingInternalDecisionKey,
18
+ type BookingIngressPrincipal,
19
+ type CriterionBlocker,
20
+ type IngressTurn,
21
+ type RelaxationApprovalRef,
22
+ type RelaxationApproval,
23
+ type UserTurn,
25
24
  } from './contracts.ts'
26
25
  import {
27
- validateBookingCopilotTurnV1,
28
- validateActionReceiptV1,
29
- validateBookingReadActionV1,
30
- validateBookingSurfaceEventV1,
26
+ validateApprovalAgainstBlocker,
27
+ validateCriterionBlocker,
28
+ validateBookingReadAction,
29
+ validateBookingSurfaceEvent,
30
+ validateBookingSurface,
31
+ isBookingDateTime,
32
+ isSafeBookingRequestKey,
31
33
  } from './validation.ts'
34
+ import {
35
+ assertWorkspaceLoadedOfferRefsUnique,
36
+ canIssueOfferCheck,
37
+ createAvailabilityPolicy,
38
+ reduceAvailabilityAction,
39
+ reduceAvailabilityReceipt,
40
+ validateOffersReceiptWorkspace,
41
+ availabilityPolicyResult,
42
+ validateAvailabilityPolicy,
43
+ type AvailabilityExhaustion,
44
+ type AvailabilityPolicyState,
45
+ } from './availability-policy.ts'
32
46
  import { normalizeBookingErrorCode, safeBookingErrorMessage } from './error-codes.ts'
33
47
 
34
- export type {
35
- ActionReceiptContinuationV1,
36
- ActionReceiptV1,
37
- BookingReadActionV1,
38
- BookingSurfaceEventV1,
39
- UserTurnV1,
40
- } from './contracts.ts'
48
+ const LEDGER_SCHEMA = 'booking.copilot.ledger' as const
49
+ const ACTOR = 'system:booking-copilot'
50
+ const STARTED = 'booking.copilot.task.started'
51
+ const TURN = 'booking.copilot.user.turn.observed'
52
+ const ACTION = 'booking.copilot.action.issued'
53
+ const RECEIPT = 'booking.copilot.receipt.observed'
54
+ const EVENT = 'booking.copilot.event.emitted'
55
+ const APPROVAL_GRANTED = 'booking.copilot.approval.granted'
56
+ const APPROVAL_OFFERED = 'booking.copilot.approval.option.offered'
57
+ const APPROVAL_CONSUMED = 'booking.copilot.approval.consumed'
58
+ const DECISION_BATCH = 'booking.copilot.decision.batch'
59
+ const REQUEST_BINDING = 'booking.copilot.request.binding'
41
60
 
42
- const LEDGER_SCHEMA = 'booking.copilot.ledger.v1' as const
43
- const LEDGER_ACTOR = 'system:booking-copilot'
44
- const TASK_STARTED = 'booking.copilot.task.started'
45
- const USER_TURN_OBSERVED = 'booking.copilot.user.turn.observed'
46
- const ACTION_ISSUED = 'booking.copilot.action.issued'
47
- const RECEIPT_OBSERVED = 'booking.copilot.receipt.observed'
48
- const EVENT_EMITTED = 'booking.copilot.event.emitted'
61
+ export type BookingCopilotTaskPhase = 'planning' | 'submitted' | 'working' | 'waiting_receipt' | 'input_required' | 'terminal' | 'error'
49
62
 
50
- export type BookingCopilotTaskPhaseV1 = 'planning' | 'waiting_receipt'
63
+ export interface BookingApprovalState {
64
+ approval?: RelaxationApproval
65
+ blocker: CriterionBlocker
66
+ options: RelaxationApproval[]
67
+ optionsEmitted: boolean
68
+ nonce: string
69
+ expiresAt: string
70
+ sourceTurnId: string
71
+ presentationRequestKey: string
72
+ }
51
73
 
52
- export interface BookingActionCheckpointV1 {
74
+ export interface BookingActionCheckpoint {
53
75
  actionId: string
54
- kind: BookingReadActionKindV1
76
+ kind: BookingReadActionKind
55
77
  contextRef: string
56
78
  expectedRevision: number
57
79
  factRefs: string[]
80
+ input: Record<string, any>
58
81
  reasonDigest: string
59
82
  inputDigest: string
60
83
  actionDigest: string
61
84
  eventId: string
62
85
  sequence: number
63
86
  emittedAt: string
87
+ sourceTurnId: string
88
+ relaxationApprovalRef?: RelaxationApprovalRef
64
89
  }
65
90
 
66
- export interface BookingCopilotTaskStateV1 {
67
- schemaVersion: 'booking.surface.v1'
91
+ export interface BookingCopilotTaskState {
92
+ schemaVersion: 'booking.surface'
68
93
  taskId: string
69
94
  contextRef: string
70
- surface: BookingSurfaceV1
95
+ surface: BookingSurface
71
96
  revision: number
72
- allowedActions: BookingReadActionKindV1[]
97
+ allowedActions: BookingReadActionKind[]
73
98
  userTurnCount: number
74
- lastUserTurnDigest?: string
75
- phase: BookingCopilotTaskPhaseV1
99
+ /** Durable task-level operation ordinal, folded from action ledger rows. */
100
+ operationCount: number
101
+ lastTurnId?: string
102
+ phase: BookingCopilotTaskPhase
76
103
  lastSequence: number
77
- pendingAction?: BookingActionCheckpointV1
78
- lastReceipt?: ActionReceiptV1
104
+ pendingAction?: BookingActionCheckpoint
105
+ lastReceipt?: ActionReceipt
106
+ awaitingApproval?: BookingApprovalState
107
+ workspaceDigest?: string
108
+ workspaceSemanticDigest?: string
109
+ workspaceSnapshot?: BookingWorkspaceSnapshot
110
+ availability: AvailabilityPolicyState
111
+ replayUpgradeRequired?: boolean
112
+ legacySuppressedDecisionRequestKeys?: string[]
113
+ legacySuppressedRequestBindingKeys?: string[]
114
+ legacySuppressedReceiptActionIds?: string[]
115
+ legacySuppressedApprovalTargetActionIds?: string[]
116
+ }
117
+
118
+ export type BookingSurfaceEventDraft =
119
+ | Pick<Extract<BookingSurfaceEvent, { kind: 'status' }>, 'kind' | 'status'>
120
+ | Pick<Extract<BookingSurfaceEvent, { kind: 'question' }>, 'kind' | 'question'>
121
+ | Pick<Extract<BookingSurfaceEvent, { kind: 'explanation' }>, 'kind' | 'explanation'>
122
+ | Pick<Extract<BookingSurfaceEvent, { kind: 'terminal' }>, 'kind' | 'terminal'>
123
+ | Pick<Extract<BookingSurfaceEvent, { kind: 'error' }>, 'kind' | 'error'>
124
+
125
+ export type BookingPlannerDecision =
126
+ | { kind: 'operation'; action: BookingReadAction }
127
+ | BookingSurfaceEventDraft
128
+
129
+ export interface BookingPlannerSession {
130
+ next(input: { turn: BookingCopilotTurn; task: BookingCopilotTaskState }): Promise<readonly BookingPlannerDecision[]>
79
131
  }
80
132
 
81
- export type BookingSurfaceEventDraftV1 =
82
- | Pick<BookingStatusEventV1, 'kind' | 'status'>
83
- | Pick<BookingQuestionEventV1, 'kind' | 'question'>
84
- | Pick<BookingExplanationEventV1, 'kind' | 'explanation'>
85
- | Pick<BookingTerminalEventV1, 'kind' | 'terminal'>
86
- | Pick<BookingErrorEventV1, 'kind' | 'error'>
133
+ export type BookingPlannerSessionFactory = (initialTask: BookingCopilotTaskState) => BookingPlannerSession
87
134
 
88
135
  export interface BookingCopilotRuntimeOptions {
89
136
  idFactory?: (prefix: string) => string
137
+ contextRefFactory?: () => string
90
138
  now?: () => string
139
+ approvalTtlMs?: number
91
140
  }
92
141
 
93
- interface LedgerPayloadBase {
94
- schema: typeof LEDGER_SCHEMA
95
- taskId: string
96
- contextRef: string
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: BookingSurface; revision: number; allowedActions: BookingReadActionKind[]; workspaceDigest: string; workspaceSemanticDigest: string; workspace: BookingWorkspaceSnapshot; availability: AvailabilityPolicyState; availabilityDigest: string }
145
+ interface TurnPayload extends BasePayload { requestDigest: string; workspaceDigest: string; workspaceSemanticDigest: string; workspace: BookingWorkspaceSnapshot; turnId: string }
146
+ interface ActionPayload extends BasePayload { action: BookingActionCheckpoint; operationCount?: number; availability: AvailabilityPolicyState; availabilityDigest: string }
147
+ interface ReceiptPayload extends BasePayload { receipt: ActionReceipt; receiptDigest: string; operationCount?: number; workspaceDigest: string; workspaceSemanticDigest: string; workspace: BookingWorkspaceSnapshot; availability: AvailabilityPolicyState; availabilityDigest: string; availabilityTerminal?: AvailabilityExhaustion }
148
+ interface EventPayload extends BasePayload { eventId: string; sequence: number; emittedAt: string; eventKind: Exclude<BookingSurfaceEvent['kind'], 'operation'>; status?: 'submitted'|'working'|'waiting_receipt'|'input_required'; contentDigest: string }
149
+ interface ApprovalPayload extends BasePayload { approval: BookingApprovalState; ref?: RelaxationApprovalRef; approvalDigest: string }
150
+ interface DecisionBatchPayload extends BasePayload { requestKey: string; events: BookingSurfaceEvent[] }
151
+ interface RequestBindingPayload extends BasePayload { requestKey: BookingRequestKey; requestDigest: string; turnId: string; workspaceDigest: string; workspaceSemanticDigest: string; surface: BookingSurface; capabilityDigest: string; principalDigest: string; scopeDigest: string; taskHandle?: string }
152
+ export interface BookingIngressRequestBindingInput { requestKey: BookingRequestKey; principal: BookingIngressPrincipal; taskHandle?: string }
153
+ interface ReplayConsumedIdentities { events: Map<string, EventPayload>; actions: Map<string, BookingActionCheckpoint>; 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
97
161
  }
98
162
 
99
- interface TaskStartedPayload extends LedgerPayloadBase {
100
- surface: BookingSurfaceV1
101
- revision: number
102
- allowedActions: BookingReadActionKindV1[]
163
+ export function bookingDigest(value: unknown): string {
164
+ return createHash('sha256').update(JSON.stringify(stable(value))).digest('hex')
103
165
  }
104
166
 
105
- interface UserTurnObservedPayload extends LedgerPayloadBase {
106
- requestDigest: string
167
+ export function bookingWorkspaceDigest(workspace: BookingWorkspaceSnapshot | BookingWorkspaceIngressSnapshot): string {
168
+ const { contextRef: _contextRef, ...contextBoundWorkspace } = workspace as Record<string, unknown>
169
+ return bookingDigest(contextBoundWorkspace)
107
170
  }
108
171
 
109
- interface ActionIssuedPayload extends LedgerPayloadBase {
110
- action: BookingActionCheckpointV1
172
+ function bookingWorkspaceSemanticDigest(workspace: BookingWorkspaceSnapshot | BookingWorkspaceIngressSnapshot): string {
173
+ const { contextRef: _contextRef, revision: _revision, ...semanticWorkspace } = workspace as Record<string, unknown>
174
+ return bookingDigest(semanticWorkspace)
111
175
  }
112
176
 
113
- interface ReceiptObservedPayload extends LedgerPayloadBase {
114
- receipt: ActionReceiptV1
115
- receiptDigest: string
177
+ export function bookingTurnDigest(turn: Extract<BookingCopilotTurn, { kind: 'user.turn' | 'user.turn.ingress' }>): string {
178
+ return bookingDigest(turn)
116
179
  }
117
180
 
118
- interface EventEmittedPayload extends LedgerPayloadBase {
119
- eventId: string
120
- sequence: number
121
- emittedAt: string
122
- eventKind: Exclude<BookingSurfaceEventV1['kind'], 'operation'>
123
- contentDigest: string
181
+ function canonicalReceiptDigest(value: ActionReceipt): string { return bookingDigest(value) }
182
+ function replayDigestMatches(recordedDigest: unknown, raw: unknown, normalized: unknown): boolean {
183
+ return typeof recordedDigest === 'string' && (recordedDigest === bookingDigest(raw) || recordedDigest === bookingDigest(normalized))
184
+ }
185
+
186
+ function replayWorkspaceDigestMatches(recordedDigest: unknown, raw: BookingWorkspaceSnapshot, normalized: BookingWorkspaceSnapshot): boolean {
187
+ return typeof recordedDigest === 'string' && (recordedDigest === bookingWorkspaceDigest(raw) || recordedDigest === bookingWorkspaceDigest(normalized))
188
+ }
189
+
190
+ function replayWorkspaceSemanticDigestMatches(recordedDigest: unknown, raw: BookingWorkspaceSnapshot, normalized: BookingWorkspaceSnapshot): boolean {
191
+ return typeof recordedDigest === 'string' && (recordedDigest === bookingWorkspaceSemanticDigest(raw) || recordedDigest === bookingWorkspaceSemanticDigest(normalized))
192
+ }
193
+
194
+ function rememberReplayWorkspaceDigest(digests: Map<string, string>, recordedDigest: unknown, raw: BookingWorkspaceSnapshot, normalized: BookingWorkspaceSnapshot): void {
195
+ const normalizedDigest = bookingWorkspaceDigest(normalized)
196
+ digests.set(bookingWorkspaceDigest(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:${bookingDigest({
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: BookingWorkspaceSnapshot): BookingWorkspaceSnapshot {
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_malformed_workspace')
213
+ const workspace = structuredClone(raw) as BookingWorkspaceSnapshot & { 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?: BookingWorkspaceSnapshot, action?: BookingActionCheckpoint, actionId?: string, priorAvailability?: AvailabilityPolicyState): 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_replan_required:offer_version_unbound')
226
+ }
227
+
228
+ function normalizeAvailabilityPolicyForReplay(raw: AvailabilityPolicyState, workspace?: BookingWorkspaceSnapshot, action?: BookingActionCheckpoint, priorAvailability?: AvailabilityPolicyState, workspaceDigestMap = new Map<string, string>(), receipt?: ActionReceipt): AvailabilityPolicyState {
229
+ const normalized = structuredClone(raw) as AvailabilityPolicyState
230
+ if (normalized.hotels && typeof normalized.hotels === 'object') {
231
+ for (const [hotelRef, hotel] of Object.entries(normalized.hotels) as Array<[string, AvailabilityPolicyState['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_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_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: BookingActionCheckpoint, workspace?: BookingWorkspaceSnapshot): BookingActionCheckpoint {
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_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_replan_required:legacy_checkout_unverified')
283
+ const { actionDigest: _rawActionDigest, ...rawBase } = raw
284
+ const normalizedBase: Omit<BookingActionCheckpoint, 'actionDigest'> = { ...rawBase, input, inputDigest: bookingDigest(input) }
285
+ return { ...normalizedBase, actionDigest: checkpointDigest(normalizedBase) }
286
+ }
287
+
288
+ function checkpointDigestMatchesForReplay(raw: BookingActionCheckpoint, normalized: BookingActionCheckpoint): 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: BookingCopilotTaskState, workspace = state.workspaceSnapshot, revision = workspace?.revision): BookingCopilotTaskState {
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: bookingWorkspaceDigest(workspace),
304
+ workspaceSemanticDigest: bookingWorkspaceSemanticDigest(workspace),
305
+ workspaceSnapshot: workspace,
306
+ availability: createAvailabilityPolicy(workspace),
307
+ replayUpgradeRequired: true,
308
+ }
309
+ }
310
+
311
+ function assertReplayUpgradeReanchored(state: Pick<BookingCopilotTaskState, '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: BookingActionCheckpoint): void {
337
+ const key = consumedIdentityKey(action.eventId, action.sequence)
338
+ consumed.actions.set(key, action)
339
+ rememberPendingBatchKey(consumed, key)
124
340
  }
125
341
 
126
- interface BookingLedgerRow {
127
- seq: number
128
- kind: string
129
- payload: string
342
+ function consumeSkippedActionOrdinal(state: BookingCopilotTaskState, 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 || !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<BookingSurfaceEvent, { kind: 'operation' }>): string {
356
+ if (event.kind === 'status') return bookingDigest({ kind: event.kind, status: event.status })
357
+ if (event.kind === 'question') return bookingDigest({ kind: event.kind, question: event.question })
358
+ if (event.kind === 'explanation') return bookingDigest({ kind: event.kind, explanation: event.explanation })
359
+ if (event.kind === 'terminal') return bookingDigest({ kind: event.kind, terminal: event.terminal })
360
+ return bookingDigest({ kind: event.kind, error: event.error })
361
+ }
362
+
363
+ function eventPayloadMatchesEvent(payload: EventPayload, event: Exclude<BookingSurfaceEvent, { kind: 'operation' }>): boolean {
364
+ const expectedDigest = eventDraftDigest(event)
365
+ const legacyTerminalDigest = event.kind === 'terminal' ? bookingDigest(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)
130
373
  }
131
374
 
132
- function stableValue(value: unknown): unknown {
133
- if (Array.isArray(value)) return value.map(stableValue)
134
- if (typeof value !== 'object' || value === null) return value
135
- const source = value as Record<string, unknown>
136
- const target: Record<string, unknown> = {}
137
- for (const key of Object.keys(source).sort()) target[key] = stableValue(source[key])
138
- return target
375
+ function actionFromOperationEventForReplay(event: Extract<BookingSurfaceEvent, { kind: 'operation' }>, checkpoint: BookingActionCheckpoint): BookingReadAction {
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 BookingReadAction
379
+ }
380
+ return action
139
381
  }
140
382
 
141
- function digest(value: unknown): string {
142
- return createHash('sha256').update(JSON.stringify(stableValue(value))).digest('hex')
383
+ function operationEventMatchesAction(event: Extract<BookingSurfaceEvent, { kind: 'operation' }>, checkpoint: BookingActionCheckpoint): 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)
143
389
  }
144
390
 
145
- function checkpointDigest(action: Pick<BookingActionCheckpointV1, 'actionId' | 'kind' | 'contextRef' | 'expectedRevision' | 'factRefs' | 'reasonDigest' | 'inputDigest' | 'eventId' | 'sequence' | 'emittedAt'>): string {
146
- return digest({
391
+ function normalizeDecisionBatchEnvelopeForReplay(payload: DecisionBatchPayload, consumed: ReplayConsumedIdentities, requireSchemaValid = false): BookingSurfaceEvent[] {
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: BookingSurfaceEvent[] = []
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 BookingSurfaceEvent
406
+ if (requireSchemaValid && !validateBookingSurfaceEvent(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 || !validateBookingSurfaceEvent(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: BookingCopilotTaskState, 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: ActionReceipt, action: BookingActionCheckpoint, workspace: BookingWorkspaceSnapshot): ActionReceipt {
434
+ const receipt = structuredClone(raw) as ActionReceipt
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_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_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_replan_required:legacy_verified_scalar')
451
+ }
452
+ if (receipt.resultContract.blockers.length && bookingDigest(raw) !== bookingDigest(receipt)) throw new Error('booking_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: AvailabilityPolicyState): AvailabilityExhaustion | undefined { return availabilityPolicyResult(state) }
462
+ function taskHasTerminalBudget(state: Pick<BookingCopilotTaskState, 'operationCount' | 'availability'>): boolean {
463
+ return state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS || Boolean(state.availability.terminal)
464
+ }
465
+ function assertRequestKey(requestKey: string): asserts requestKey is BookingRequestKey {
466
+ if (!isSafeBookingRequestKey(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 BookingInternalDecisionKey {
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 BookingPlannerDecision[]): 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: BookingIngressPrincipal): 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: BookingCopilotTaskState, decisions: readonly BookingPlannerDecision[]): void {
486
+ if (!state.availability.recoveryStarted || state.awaitingApproval) return
487
+ const operations = decisions.filter((decision): decision is Extract<BookingPlannerDecision, { 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) || !canIssueOfferCheck(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<BookingActionCheckpoint, 'actionDigest'>): string {
502
+ return bookingDigest(action)
503
+ }
504
+
505
+ function checkpointFor(action: BookingReadAction, eventId: string, sequence: number, emittedAt: string, sourceTurnId: string): BookingActionCheckpoint {
506
+ const base: Omit<BookingActionCheckpoint, 'actionDigest'> = {
147
507
  actionId: action.actionId,
148
508
  kind: action.kind,
149
509
  contextRef: action.contextRef,
150
510
  expectedRevision: action.expectedRevision,
151
- factRefs: action.factRefs,
152
- reasonDigest: action.reasonDigest,
153
- inputDigest: action.inputDigest,
154
- eventId: action.eventId,
155
- sequence: action.sequence,
156
- emittedAt: action.emittedAt,
157
- })
511
+ factRefs: [...action.factRefs],
512
+ reasonDigest: bookingDigest(action.reason),
513
+ inputDigest: bookingDigest(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) }
158
522
  }
159
523
 
160
- function actionMatchesCheckpoint(action: BookingReadActionV1, checkpoint: BookingActionCheckpointV1): boolean {
161
- return checkpoint.actionDigest === checkpointDigest({
162
- actionId: checkpoint.actionId,
163
- kind: action.kind,
164
- contextRef: action.contextRef,
165
- expectedRevision: action.expectedRevision,
166
- factRefs: action.factRefs,
167
- reasonDigest: digest(action.reason),
168
- inputDigest: digest(action.input),
169
- eventId: checkpoint.eventId,
170
- sequence: checkpoint.sequence,
171
- emittedAt: checkpoint.emittedAt,
524
+ function matchesCheckpoint(action: BookingReadAction, checkpoint: BookingActionCheckpoint): 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: ActionReceipt): string {
537
+ return bookingDigest({
538
+ ...receipt,
539
+ resultContract: {
540
+ ...receipt.resultContract,
541
+ blockers: receipt.resultContract.blockers.map(({ sourceReceiptDigest: _ignored, ...blocker }) => blocker),
542
+ },
172
543
  })
173
544
  }
174
545
 
175
- function assertSafeFactRefs(action: BookingReadActionV1): void {
176
- const opaque = (ref: string) => /^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(ref) && !/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/i.test(ref)
177
- if (!opaque(action.actionId)) throw new Error('unsafe_action_id')
178
- if (action.factRefs.some((ref) => !opaque(ref))) throw new Error('unsafe_fact_ref')
546
+ function assertCanonicalReceipt(receipt: ActionReceipt): 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: ActionReceipt['observation']['kind'], action: BookingReadActionKind): boolean {
552
+ if (observation === 'gap') return true
553
+ const expected: Partial<Record<BookingReadActionKind, ActionReceipt['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: ActionReceipt, action: { kind: BookingReadActionKind; 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: BookingWorkspaceSnapshot, b: BookingWorkspaceSnapshot): 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: BookingWorkspaceSnapshot, offerRef: string, offerVersionRef: string): boolean {
586
+ return workspace.loadedOffers.some((offer) => offer.offerRef === offerRef && offer.offerVersionRef === offerVersionRef)
587
+ }
588
+
589
+ function verifiedOfferMatches(workspace: BookingWorkspaceSnapshot, 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: BookingWorkspaceSnapshot, now: string): boolean {
594
+ return Boolean(workspace.verifiedOffer && isBookingDateTime(workspace.verifiedOffer.expiresAt) && Date.parse(workspace.verifiedOffer.expiresAt) > Date.parse(now))
595
+ }
596
+
597
+ function actionHitsCurrentOfferVersion(workspace: BookingWorkspaceSnapshot, action: BookingReadAction, 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: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot): boolean {
610
+ return sameActions(previous.loadedOffers.map((offer) => bookingDigest(offer)), current.loadedOffers.map((offer) => bookingDigest(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)
179
622
  }
180
623
 
181
- function validationMessage(result: ReturnType<typeof validateBookingCopilotTurnV1>): string {
182
- return result.ok ? '' : result.errors.join('; ')
624
+ function loadedOffersChangedVersionOnly(previous: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot, 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) => bookingDigest(offer))
632
+ const currentOther = current.loadedOffers.filter((offer) => offer.offerRef !== offerRef).map((offer) => bookingDigest(offer))
633
+ return sameActions(previousOther, currentOther)
183
634
  }
184
635
 
185
- function assertTaskId(taskId: string): void {
186
- if (!taskId) throw new Error('invalid_task_id')
636
+ function availabilityReceiptIsComplete(receipt: ActionReceipt): boolean {
637
+ return receipt.resultContract.outcome === 'complete'
638
+ && receipt.resultContract.hardCriteriaMet
639
+ && receipt.resultContract.gapCodes.length === 0
640
+ && receipt.resultContract.blockers.length === 0
187
641
  }
188
642
 
189
- function sameActionAllowlist(left: readonly BookingReadActionKindV1[], right: readonly BookingReadActionKindV1[]): boolean {
190
- return left.length === right.length && [...left].sort().every((kind, index) => kind === [...right].sort()[index])
643
+ function offerCheckPostActionMatches(previous: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot, action: { kind: 'offer.check'; input: { offerRef: string; offerVersionRef: string } }, receipt: ActionReceipt): 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 (bookingDigest(previous.verifiedOffer ?? null) !== bookingDigest(current.verifiedOffer ?? null)) return false
673
+ return true
674
+ }
675
+
676
+ function offerSelectPostActionMatches(previous: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot, action: { kind: 'offer.select'; input: { offerRef: string; offerVersionRef: string } }, receipt: ActionReceipt): 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: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot, action: BookingActionCheckpoint, receipt: ActionReceipt): boolean {
684
+ if (!workspaceBoundaryMatches(previous, current)) return false
685
+ const mutable: Partial<Record<BookingReadActionKind, 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 (bookingDigest((previous as unknown as Record<string, unknown>)[key]) !== bookingDigest((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: CriterionBlocker, to: RelaxationApproval['to'], deliveryNonce: string): RelaxationApproval {
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: bookingDigest(base) }
711
+ }
712
+
713
+ function approvalOptionDigest(approval: RelaxationApproval): string {
714
+ const { optionDigest: _optionDigest, ...base } = approval
715
+ return bookingDigest(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<BookingReadAction, 'actionId' | 'expectedRevision' | 'kind' | 'contextRef'>
723
+ function assertApprovalRef(ref: RelaxationApprovalRef, state: BookingCopilotTaskState, 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')
191
727
  }
192
728
 
193
729
  export class BookingCopilotTaskRuntime {
194
730
  private readonly idFactory: (prefix: string) => string
731
+ private readonly contextRefFactory: () => string
195
732
  private readonly now: () => string
733
+ private readonly approvalTtlMs: number
196
734
 
197
- constructor(
198
- private readonly ledger: StateLedger,
199
- options: BookingCopilotRuntimeOptions = {},
200
- ) {
735
+ constructor(private readonly ledger: StateLedger, options: BookingCopilotRuntimeOptions = {}) {
201
736
  this.idFactory = options.idFactory ?? ((prefix) => `${prefix}-${randomUUID()}`)
737
+ this.contextRefFactory = options.contextRefFactory ?? (() => this.idFactory('context'))
202
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: UserTurn, input: BookingIngressRequestBindingInput): 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')
203
751
  }
204
752
 
205
- startTask(turn: UserTurnV1): BookingCopilotTaskStateV1 {
206
- const validation = validateBookingCopilotTurnV1(turn)
207
- if (!validation.ok || turn.kind !== 'user.turn') throw new Error(`invalid_planner_turn:${validationMessage(validation)}`)
208
- const taskId = turn.taskId ?? this.idFactory('task')
753
+ /** Verifies a direct planner turn against its durable TURN row. */
754
+ assertTurnBinding(taskId: string, turn: UserTurn): void {
209
755
  assertTaskId(taskId)
210
- const requestDigest = digest(turn.request.text)
211
- // The task fold and its user-turn append must share one write transaction.
212
- // An immediate transaction serializes separate processes before either can
213
- // derive state (and therefore an ordinal/next event sequence) from a stale
214
- // snapshot.
215
- const apply = this.ledger.db.transaction(() => {
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 !== bookingTurnDigest(turn) || observed.workspaceDigest !== bookingWorkspaceDigest(turn.workspace) || observed.workspaceSemanticDigest !== bookingWorkspaceSemanticDigest(turn.workspace) || observed.contextRef !== turn.workspace.contextRef) throw new Error('turn_conflict')
759
+ }
760
+
761
+ /** Rejects a canonical approval after its durable runtime consumption. */
762
+ assertApprovalNotConsumed(taskId: string, approval: RelaxationApproval): void {
763
+ assertTaskId(taskId)
764
+ 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 }>
765
+ for (const row of rows) {
766
+ const consumed = JSON.parse(row.payload) as ApprovalPayload
767
+ if (consumed.approval?.approval && bookingDigest(consumed.approval.approval) === bookingDigest(approval)) throw new Error('approval_replayed')
768
+ }
769
+ }
770
+
771
+ /** Persists the per-requestKey receipt/replay binding after task creation. */
772
+ persistRequestBinding(requestKey: string, turn: UserTurn, input: BookingIngressRequestBindingInput): void {
773
+ assertRequestKey(requestKey)
774
+ assertPrincipal(input.principal)
775
+ if (input.requestKey !== requestKey) throw new Error('request_conflict')
776
+ const run = this.ledger.db.transaction(() => {
777
+ const state = this.requireTask(turn.taskId)
778
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
779
+ assertReplayUpgradeReanchored(state)
780
+ this.appendRequestBindingInTransaction(requestKey, turn, input)
781
+ })
782
+ run.immediate()
783
+ }
784
+
785
+ /** Binds the untrusted ingress snapshot to server-owned task/context/capabilities. */
786
+ bindIngressTurn(turn: IngressTurn, task: BookingCopilotTaskState, turnId: string): UserTurn {
787
+ assertSafeRef(turnId)
788
+ const approval = turn.request.approval
789
+ if (approval && (approval.taskId !== task.taskId || approval.contextRef !== task.contextRef)) {
790
+ throw new Error('invalid_ingress_approval_authority')
791
+ }
792
+ return {
793
+ schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId,
794
+ workspace: {
795
+ ...turn.workspace,
796
+ schemaVersion: 'booking.surface', contextRef: task.contextRef, surface: task.surface,
797
+ capabilities: { surface: task.surface, allowedActions: [...task.allowedActions] },
798
+ },
799
+ request: approval
800
+ ? { text: turn.request.text, approval: { ...approval } }
801
+ : { text: turn.request.text },
802
+ }
803
+ }
804
+
805
+ startTask(turn: BookingCopilotTurn, requestBinding?: BookingIngressRequestBindingInput): BookingCopilotTaskState {
806
+ const validation = validateBookingSurface(turn)
807
+ if (!validation.ok) throw new Error(`invalid_planner_turn:${errorText(validation)}`)
808
+ if (turn.kind === 'user.turn.ingress') throw new Error('ingress_binding_required')
809
+ if (turn.kind === 'action.receipt.continuation') throw new Error('receipt_continuation_requires_continue')
810
+ const userTurn = turn as UserTurn
811
+ if (!userTurn.taskId) throw new Error('invalid_planner_turn:task_id_required')
812
+ const taskId = userTurn.taskId
813
+ assertTaskId(taskId)
814
+ const turnId = userTurn.turnId
815
+ if (!turnId) throw new Error('invalid_planner_turn:turn_id_required')
816
+ assertSafeRef(turnId)
817
+ const contextRef = userTurn.workspace.contextRef
818
+ const surface = userTurn.workspace.surface
819
+ const revision = userTurn.workspace.revision
820
+ const allowedActions = [...userTurn.workspace.capabilities.allowedActions]
821
+ const requestDigest = bookingTurnDigest(turn)
822
+ const boundWorkspace = userTurn.workspace
823
+ assertWorkspaceLoadedOfferRefsUnique(boundWorkspace)
824
+ const workspaceDigest = bookingWorkspaceDigest(boundWorkspace)
825
+ const workspaceSemanticDigest = bookingWorkspaceSemanticDigest(boundWorkspace)
826
+ const availability = createAvailabilityPolicy(boundWorkspace)
827
+ const run = this.ledger.db.transaction(() => {
216
828
  const existing = this.resumeTask(taskId)
217
829
  if (existing) {
218
- if (existing.contextRef !== turn.workspace.contextRef) throw new Error('task_conflict:context_mismatch')
219
- if (existing.surface !== turn.workspace.surface) throw new Error('task_conflict:surface_mismatch')
220
- if (existing.revision !== turn.workspace.revision) throw new Error('task_conflict:revision_mismatch')
221
- if (!sameActionAllowlist(existing.allowedActions, turn.workspace.capabilities.allowedActions)) {
222
- throw new Error('task_conflict:capability_mismatch')
830
+ if (existing.contextRef !== contextRef) throw new Error('task_conflict:context_mismatch')
831
+ if (existing.surface !== surface) throw new Error('task_conflict:surface_mismatch')
832
+ if (existing.revision !== revision && existing.phase !== 'waiting_receipt' && !existing.replayUpgradeRequired) throw new Error('task_conflict:revision_mismatch')
833
+ if (!sameActions(existing.allowedActions, allowedActions)) throw new Error('task_conflict:capability_mismatch')
834
+ if (existing.phase === 'terminal' || existing.phase === 'error') throw new Error('task_terminal')
835
+ if (turnId && existing.lastTurnId === turnId) {
836
+ this.assertTurnBinding(taskId, userTurn)
837
+ if (requestBinding) {
838
+ assertReplayUpgradeReanchored(existing)
839
+ this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding)
840
+ }
841
+ return existing
223
842
  }
224
843
  if (existing.phase === 'waiting_receipt') throw new Error('receipt_required')
225
- this.observeUserTurn(taskId, existing.contextRef, requestDigest, existing.userTurnCount + 1)
844
+ const approval = userTurn && 'approval' in userTurn.request ? userTurn.request.approval : undefined
845
+ let approvalState: BookingApprovalState | undefined
846
+ if (approval) {
847
+ assertReplayUpgradeReanchored(existing)
848
+ const awaiting = existing.awaitingApproval
849
+ if (!awaiting) throw new Error('approval_not_awaiting')
850
+ if (!awaiting.optionsEmitted) throw new Error('approval_not_presented')
851
+ if (Date.parse(awaiting.expiresAt) <= Date.parse(this.now())) throw new Error('approval_expired')
852
+ const checked = validateApprovalAgainstBlocker(approval, awaiting.blocker)
853
+ const option = awaiting.options.find((candidate) => candidate.optionDigest === approval.optionDigest && bookingDigest(candidate) === bookingDigest(approval))
854
+ if (!checked.ok || !option || approval.taskId !== existing.taskId || approval.contextRef !== existing.contextRef || approval.sourceTurnId !== awaiting.sourceTurnId || approval.presentationRequestKey !== awaiting.presentationRequestKey || approval.optionDigest !== approvalOptionDigest(approval) || (awaiting.approval && bookingDigest(approval) !== bookingDigest(awaiting.approval))) throw new Error('approval_mismatch')
855
+ if (awaiting.approval && bookingDigest(awaiting.approval) === bookingDigest(approval)) {
856
+ // The canonical approval may only be advanced by its original
857
+ // turn identity. A fresh key must not join the in-flight planner
858
+ // and receive the operation batch before durable consumption.
859
+ throw new Error('approval_replayed')
860
+ }
861
+ approvalState = { ...awaiting, approval }
862
+ this.appendApproval(taskId, existing.contextRef, approvalState)
863
+ } else if (existing.awaitingApproval) {
864
+ throw new Error('approval_required')
865
+ }
866
+ this.appendTurn(taskId, existing.contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, existing.userTurnCount + 1, turnId, boundWorkspace)
867
+ if (requestBinding) this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding)
226
868
  return this.requireTask(taskId)
227
869
  }
228
- const foreign = this.ledger.db.prepare(
229
- "SELECT 1 AS present FROM events WHERE tenant_id = ? AND run_id = ? AND kind LIKE 'booking.copilot.v2.%' LIMIT 1",
230
- ).get(this.ledger.tenant, taskId) as { present: number } | undefined
231
- if (foreign) throw new Error('task_conflict:protocol_version')
232
- const payload: TaskStartedPayload = {
233
- schema: LEDGER_SCHEMA,
234
- taskId,
235
- contextRef: turn.workspace.contextRef,
236
- surface: turn.workspace.surface,
237
- revision: turn.workspace.revision,
238
- allowedActions: [...turn.workspace.capabilities.allowedActions],
239
- }
240
- this.appendLedgerEvent(TASK_STARTED, taskId, payload, `booking-copilot:task:${taskId}`)
241
- this.observeUserTurn(taskId, turn.workspace.contextRef, requestDigest, 1)
870
+ if (userTurn?.request.approval) throw new Error('approval_not_awaiting')
871
+ const started: StartedPayload = { schema: LEDGER_SCHEMA, taskId, contextRef, surface, revision, allowedActions, workspaceDigest, workspaceSemanticDigest, workspace: boundWorkspace, availability, availabilityDigest: bookingDigest(availability) }
872
+ this.append(STARTED, taskId, started, `booking-copilot:task:${taskId}`)
873
+ this.appendTurn(taskId, contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, 1, turnId, boundWorkspace)
874
+ if (requestBinding) this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding)
242
875
  return this.requireTask(taskId)
243
876
  })
244
- return apply.immediate()
877
+ return run.immediate()
245
878
  }
246
879
 
247
- resumeTask(taskId: string): BookingCopilotTaskStateV1 | null {
880
+ resumeTask(taskId: string): BookingCopilotTaskState | null {
248
881
  assertTaskId(taskId)
249
- const rows = this.readTaskRows(taskId)
250
- if (rows.length === 0) return null
251
- let state: BookingCopilotTaskStateV1 | null = null
882
+ const rows = this.rows(taskId)
883
+ if (!rows.length) return null
884
+ let state: BookingCopilotTaskState | null = null
885
+ let skipLegacyUpgradeTail = false
886
+ const replayWorkspaceDigests = new Map<string, string>()
887
+ const consumed: ReplayConsumedIdentities = { events: new Map(), actions: new Map(), pendingBatchKeys: new Set(), pendingBatchOpen: false }
888
+ const suppressedDecisionRequestKeys = new Set<string>()
889
+ const suppressedRequestBindingKeys = new Set<string>()
890
+ const suppressedReceiptActionIds = new Set<string>()
891
+ const suppressedApprovalTargetActionIds = new Set<string>()
252
892
  for (const row of rows) {
253
- const payload = JSON.parse(row.payload) as Record<string, unknown>
893
+ const payload = JSON.parse(row.payload) as BasePayload & Record<string, any>
254
894
  if (payload.schema !== LEDGER_SCHEMA || payload.taskId !== taskId) throw new Error(`ledger_corrupt:${taskId}:seq=${row.seq}`)
255
- if (row.kind === TASK_STARTED) {
256
- const started = payload as unknown as TaskStartedPayload
895
+ if (row.kind === STARTED) {
896
+ skipLegacyUpgradeTail = false
257
897
  if (state) throw new Error(`ledger_corrupt:${taskId}:duplicate_start`)
258
- state = {
259
- schemaVersion: 'booking.surface.v1',
260
- taskId,
261
- contextRef: started.contextRef,
262
- surface: started.surface,
263
- revision: started.revision,
264
- allowedActions: [...started.allowedActions],
265
- userTurnCount: 0,
266
- phase: 'planning',
267
- lastSequence: 0,
898
+ const started = payload as StartedPayload
899
+ let startedWorkspace: BookingWorkspaceSnapshot
900
+ try { startedWorkspace = normalizeWorkspaceForReplay(started.workspace) } catch { throw new Error(`ledger_corrupt:${taskId}:start_workspace`) }
901
+ let initialAvailability: AvailabilityPolicyState
902
+ try { initialAvailability = createAvailabilityPolicy(startedWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:start_availability`) }
903
+ try { assertWorkspaceLoadedOfferRefsUnique(startedWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:start_workspace`) }
904
+ rememberReplayWorkspaceDigest(replayWorkspaceDigests, started.workspaceDigest, started.workspace, startedWorkspace)
905
+ const startedAvailability = normalizeAvailabilityPolicyForReplay(started.availability, startedWorkspace, undefined, undefined, replayWorkspaceDigests)
906
+ if (!started.workspaceDigest || !started.workspaceSemanticDigest || !started.workspace || !started.availability || !started.availabilityDigest || !validateAvailabilityPolicy(startedAvailability) || !replayDigestMatches(started.availabilityDigest, started.availability, startedAvailability) || bookingDigest(initialAvailability) !== bookingDigest(startedAvailability) || !replayWorkspaceDigestMatches(started.workspaceDigest, started.workspace, startedWorkspace) || !replayWorkspaceSemanticDigestMatches(started.workspaceSemanticDigest, started.workspace, startedWorkspace)) throw new Error(`ledger_corrupt:${taskId}:start_workspace`)
907
+ state = { schemaVersion: 'booking.surface', 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: bookingWorkspaceDigest(startedWorkspace), workspaceSemanticDigest: bookingWorkspaceSemanticDigest(startedWorkspace), workspaceSnapshot: startedWorkspace, availability: startedAvailability }
908
+ } else {
909
+ if (!state) throw new Error(`ledger_corrupt:${taskId}:event_before_start`)
910
+ if (payload.contextRef !== state.contextRef) throw new Error(`ledger_corrupt:${taskId}:context_drift`)
911
+ if (row.kind === REQUEST_BINDING) {
912
+ if (skipLegacyUpgradeTail) {
913
+ const binding = payload as RequestBindingPayload
914
+ if (typeof binding.requestKey === 'string') suppressedRequestBindingKeys.add(binding.requestKey)
915
+ continue
916
+ }
917
+ const binding = payload as RequestBindingPayload
918
+ 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 }>
919
+ const rawBoundTurn = turnRows.map(({ payload }) => JSON.parse(payload) as TurnPayload).find((candidate) => candidate.turnId === binding.turnId)
920
+ let boundTurn: TurnPayload | undefined
921
+ try { boundTurn = rawBoundTurn ? { ...rawBoundTurn, workspace: normalizeWorkspaceForReplay(rawBoundTurn.workspace) } : undefined } catch { throw new Error(`ledger_corrupt:${taskId}:request_binding`) }
922
+ if (!isSafeBookingRequestKey(binding.requestKey) || !safeIdentity(binding.turnId) || !safeIdentity(binding.contextRef) || (binding.taskHandle !== undefined && !safeIdentity(binding.taskHandle)) || !/^[0-9a-f]{64}$/.test(binding.requestDigest) || !/^[0-9a-f]{64}$/.test(binding.workspaceDigest) || !/^[0-9a-f]{64}$/.test(binding.workspaceSemanticDigest) || !/^[0-9a-f]{64}$/.test(binding.capabilityDigest) || !/^[0-9a-f]{64}$/.test(binding.principalDigest) || !/^[0-9a-f]{64}$/.test(binding.scopeDigest) || !['tenant', 'customer_portal', 'storefront', 'payment_link'].includes(binding.surface) || !boundTurn || boundTurn.requestDigest !== binding.requestDigest || boundTurn.workspaceDigest !== binding.workspaceDigest || boundTurn.workspaceSemanticDigest !== binding.workspaceSemanticDigest || bookingDigest(boundTurn.workspace.capabilities) !== binding.capabilityDigest || boundTurn.workspace.surface !== binding.surface) throw new Error(`ledger_corrupt:${taskId}:request_binding`)
923
+ } else if (row.kind === TURN) {
924
+ skipLegacyUpgradeTail = false
925
+ const t = payload as TurnPayload
926
+ if (Object.prototype.hasOwnProperty.call(payload, 'approval')) throw new Error(`ledger_corrupt:${taskId}:turn_approval`)
927
+ let turnWorkspace: BookingWorkspaceSnapshot
928
+ try { turnWorkspace = normalizeWorkspaceForReplay(t.workspace) } catch { throw new Error(`ledger_corrupt:${taskId}:turn_workspace`) }
929
+ try { assertWorkspaceLoadedOfferRefsUnique(turnWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:turn_workspace`) }
930
+ 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`)
931
+ rememberReplayWorkspaceDigest(replayWorkspaceDigests, t.workspaceDigest, t.workspace, turnWorkspace)
932
+ state.userTurnCount++
933
+ state.lastTurnId = t.turnId
934
+ state.workspaceDigest = bookingWorkspaceDigest(turnWorkspace)
935
+ state.workspaceSemanticDigest = bookingWorkspaceSemanticDigest(turnWorkspace)
936
+ state.workspaceSnapshot = turnWorkspace
937
+ state.revision = turnWorkspace.revision
938
+ delete state.replayUpgradeRequired
939
+ // Approval authority is established only by APPROVAL_GRANTED; a turn
940
+ // is merely an observation and cannot restore or mutate approval state.
941
+ } else if (row.kind === APPROVAL_GRANTED) {
942
+ if (skipLegacyUpgradeTail) continue
943
+ const granted = payload as ApprovalPayload
944
+ if (!granted.approval || granted.approvalDigest !== bookingDigest(granted.approval) || !granted.approval.sourceTurnId || !granted.approval.presentationRequestKey || !validateCriterionBlocker(granted.approval.blocker).ok || !granted.approval.options?.length || granted.approval.options.some((option) => !validateApprovalAgainstBlocker(option, granted.approval!.blocker).ok || option.taskId !== taskId || option.contextRef !== state!.contextRef || option.sourceTurnId !== granted.approval!.sourceTurnId || option.presentationRequestKey !== granted.approval!.presentationRequestKey || option.optionDigest !== approvalOptionDigest(option)) || (granted.approval.approval && !granted.approval.options.some((option) => bookingDigest(option) === bookingDigest(granted.approval!.approval!)))) throw new Error(`ledger_corrupt:${taskId}:approval_granted`)
945
+ state.awaitingApproval = granted.approval; state.phase = 'input_required'
946
+ } else if (row.kind === APPROVAL_OFFERED) {
947
+ if (skipLegacyUpgradeTail) continue
948
+ const offered = payload as ApprovalPayload
949
+ if (!offered.approval || offered.approvalDigest !== bookingDigest(offered.approval) || !state.awaitingApproval || bookingDigest(offered.approval) !== bookingDigest(state.awaitingApproval)) throw new Error(`ledger_corrupt:${taskId}:approval_offered`)
950
+ // This is a durable outbox intent only. It is deliberately not
951
+ // evidence that the question entered a replayable output batch:
952
+ // a crash between this row and DECISION_BATCH must still reject an
953
+ // approval until the exact question can be replayed.
954
+ state.phase = 'input_required'
955
+ } else if (row.kind === ACTION) {
956
+ const actionPayload = payload as ActionPayload
957
+ if (skipLegacyUpgradeTail) { try { consumeSkippedActionOrdinal(state, actionPayload, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:action`) } continue }
958
+ const rawAction = actionPayload.action
959
+ const operationCount = actionPayload.operationCount ?? state.operationCount + 1
960
+ const { actionDigest: _rawActionDigest, ...rawActionBase } = rawAction
961
+ if (rawAction.actionDigest !== checkpointDigest(rawActionBase) || rawAction.contextRef !== state.contextRef || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount + 1 || operationCount > BOOKING_COPILOT_MAX_OPERATIONS || !Number.isSafeInteger(rawAction.sequence) || rawAction.sequence <= state.lastSequence) throw new Error(`ledger_corrupt:${taskId}:action`)
962
+ let a: BookingActionCheckpoint
963
+ try { a = normalizeActionCheckpointForReplay(rawAction, state.workspaceSnapshot) } catch (error) {
964
+ if (error instanceof Error && error.message.startsWith('booking_replan_required:')) { state = replanAfterReplayUpgradeGap(state); try { consumeSkippedActionOrdinal(state, actionPayload, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:action`) } skipLegacyUpgradeTail = true; continue }
965
+ throw error
966
+ }
967
+ const { actionDigest: _actionDigest, ...actionBase } = a
968
+ if (!actionPayload.availability || actionPayload.availabilityDigest !== bookingDigest(actionPayload.availability)) throw new Error(`ledger_corrupt:${taskId}:action`)
969
+ let actionAvailability: AvailabilityPolicyState
970
+ try { actionAvailability = normalizeAvailabilityPolicyForReplay(actionPayload.availability, state.workspaceSnapshot, a, state.availability, replayWorkspaceDigests) } catch (error) {
971
+ if (error instanceof Error && error.message.startsWith('booking_replan_required:')) { state = replanAfterReplayUpgradeGap(state); try { consumeSkippedActionOrdinal(state, actionPayload, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:action`) } skipLegacyUpgradeTail = true; continue }
972
+ throw error
973
+ }
974
+ if (!checkpointDigestMatchesForReplay(rawAction, a) || a.actionDigest !== checkpointDigest(actionBase) || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount + 1 || operationCount > BOOKING_COPILOT_MAX_OPERATIONS || !validateAvailabilityPolicy(actionAvailability) || !replayDigestMatches(actionPayload.availabilityDigest, actionPayload.availability, actionAvailability) || !state.workspaceSnapshot) throw new Error(`ledger_corrupt:${taskId}:action`)
975
+ if (a.relaxationApprovalRef) assertApprovalRef(a.relaxationApprovalRef, state, a)
976
+ if (state.pendingAction) throw new Error(`ledger_corrupt:${taskId}:parallel_actions`)
977
+ let expectedAvailability: AvailabilityPolicyState
978
+ try { expectedAvailability = reduceAvailabilityAction(state.availability, state.workspaceSnapshot, a) } catch { throw new Error(`ledger_corrupt:${taskId}:action_transition`) }
979
+ if (bookingDigest(expectedAvailability) !== bookingDigest(actionAvailability)) throw new Error(`ledger_corrupt:${taskId}:action_transition`)
980
+ state.operationCount = operationCount
981
+ state.availability = actionAvailability
982
+ state.pendingAction = a; state.phase = 'waiting_receipt'; state.lastSequence = Math.max(state.lastSequence, a.sequence)
983
+ {
984
+ rememberConsumedActionIdentity(consumed, a)
985
+ }
986
+ delete state.awaitingApproval
987
+ } else if (row.kind === RECEIPT) {
988
+ if (skipLegacyUpgradeTail) {
989
+ const r = payload as ReceiptPayload
990
+ if (typeof r.receipt?.actionId === 'string') suppressedReceiptActionIds.add(r.receipt.actionId)
991
+ continue
992
+ }
993
+ const r = payload as ReceiptPayload
994
+ const operationCount = r.operationCount ?? state.operationCount
995
+ if (!state.pendingAction || state.pendingAction.actionId !== r.receipt.actionId) throw new Error(`ledger_corrupt:${taskId}:orphan_receipt`)
996
+ let receiptWorkspace: BookingWorkspaceSnapshot
997
+ try { receiptWorkspace = normalizeWorkspaceForReplay(r.workspace) } catch { throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`) }
998
+ try { assertWorkspaceLoadedOfferRefsUnique(receiptWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`) }
999
+ 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`)
1000
+ rememberReplayWorkspaceDigest(replayWorkspaceDigests, r.workspaceDigest, r.workspace, receiptWorkspace)
1001
+ 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`)
1002
+ if (!r.availability || r.availabilityDigest !== bookingDigest(r.availability)) throw new Error(`ledger_corrupt:${taskId}:receipt`)
1003
+ let receipt: ActionReceipt
1004
+ try { receipt = normalizeReceiptForReplay(r.receipt, state.pendingAction, receiptWorkspace) } catch (error) {
1005
+ if (error instanceof Error && error.message.startsWith('booking_replan_required:')) { clearPendingBatch(consumed); state = replanAfterReplayUpgradeGap(state, receiptWorkspace, r.receipt.revision); skipLegacyUpgradeTail = true; continue }
1006
+ throw error
1007
+ }
1008
+ let receiptAvailability: AvailabilityPolicyState
1009
+ try { receiptAvailability = normalizeAvailabilityPolicyForReplay(r.availability, receiptWorkspace, state.pendingAction, state.availability, replayWorkspaceDigests, receipt) } catch (error) {
1010
+ if (error instanceof Error && error.message.startsWith('booking_replan_required:')) { clearPendingBatch(consumed); state = replanAfterReplayUpgradeGap(state, receiptWorkspace, r.receipt.revision); skipLegacyUpgradeTail = true; continue }
1011
+ throw error
1012
+ }
1013
+ if (!replayDigestMatches(r.receiptDigest, r.receipt, receipt) || !validateBookingSurface(receipt).ok || !r.availability || !r.availabilityDigest || !validateAvailabilityPolicy(receiptAvailability) || !replayDigestMatches(r.availabilityDigest, r.availability, receiptAvailability)) throw new Error(`ledger_corrupt:${taskId}:receipt`)
1014
+ if (Boolean(r.availabilityTerminal) !== Boolean(receiptAvailability.terminal) || (r.availabilityTerminal && receiptAvailability.terminal && bookingDigest(r.availabilityTerminal) !== bookingDigest(receiptAvailability.terminal))) throw new Error(`ledger_corrupt:${taskId}:availability_terminal`)
1015
+ assertCanonicalReceipt(receipt)
1016
+ 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`)
1017
+ let expectedAvailability: AvailabilityPolicyState
1018
+ try { expectedAvailability = reduceAvailabilityReceipt(state.availability, receiptWorkspace, receipt, state.pendingAction) } catch { throw new Error(`ledger_corrupt:${taskId}:receipt_transition`) }
1019
+ if (bookingDigest(expectedAvailability) !== bookingDigest(receiptAvailability)) throw new Error(`ledger_corrupt:${taskId}:receipt_transition`)
1020
+ state.lastReceipt = receipt; state.revision = receipt.revision; state.workspaceDigest = bookingWorkspaceDigest(receiptWorkspace); state.workspaceSemanticDigest = bookingWorkspaceSemanticDigest(receiptWorkspace); state.workspaceSnapshot = receiptWorkspace; state.availability = receiptAvailability; delete state.pendingAction; state.phase = receiptAvailability.terminal || state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS ? 'terminal' : 'planning'
1021
+ clearPendingBatch(consumed)
1022
+ delete state.awaitingApproval
1023
+ } else if (row.kind === APPROVAL_CONSUMED) {
1024
+ if (skipLegacyUpgradeTail) {
1025
+ const consumed = payload as ApprovalPayload
1026
+ if (typeof consumed.ref?.targetActionId === 'string') suppressedApprovalTargetActionIds.add(consumed.ref.targetActionId)
1027
+ continue
1028
+ }
1029
+ const consumed = payload as ApprovalPayload
1030
+ if (!consumed.ref || consumed.approvalDigest !== bookingDigest(consumed.ref) || !consumed.approval?.approval || !consumed.approval.options?.some((option) => bookingDigest(option) === bookingDigest(consumed.approval!.approval!)) || !state.awaitingApproval || bookingDigest(consumed.approval) !== bookingDigest(state.awaitingApproval) || consumed.ref.approvalId !== consumed.approval.approval.approvalId || consumed.ref.blockerId !== consumed.approval.blocker.blockerId || consumed.ref.contextRef !== state.contextRef || consumed.ref.sourceTurnId !== consumed.approval.sourceTurnId || consumed.ref.presentationRequestKey !== consumed.approval.presentationRequestKey || consumed.ref.sourceActionId !== consumed.approval.blocker.sourceActionId || consumed.ref.targetActionId === '' || consumed.ref.sourceReceiptDigest !== consumed.approval.blocker.sourceReceiptDigest || consumed.ref.nonce !== consumed.approval.nonce || consumed.ref.nonce !== consumed.approval.approval.deliveryNonce || consumed.ref.to !== consumed.approval.approval.to || consumed.ref.scope !== consumed.approval.blocker.scope || consumed.ref.code !== consumed.approval.blocker.code || consumed.ref.criterionPath !== consumed.approval.blocker.criterionPath || consumed.ref.valueDigest !== consumed.approval.blocker.valueDigest) throw new Error(`ledger_corrupt:${taskId}:approval`)
1031
+ } else if (row.kind === DECISION_BATCH) {
1032
+ const batch = payload as DecisionBatchPayload
1033
+ if (skipLegacyUpgradeTail) {
1034
+ try { consumeSkippedEventSequence(state, batch, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:decision_batch`) }
1035
+ if (typeof batch.requestKey === 'string') suppressedDecisionRequestKeys.add(batch.requestKey)
1036
+ continue
1037
+ }
1038
+ if (!batch.requestKey || !Array.isArray(batch.events) || batch.events.some((event) => event.taskId !== taskId)) throw new Error(`ledger_corrupt:${taskId}:decision_batch`)
1039
+ let batchEvents: BookingSurfaceEvent[]
1040
+ try { batchEvents = normalizeDecisionBatchEnvelopeForReplay(batch, consumed, true) } catch { throw new Error(`ledger_corrupt:${taskId}:decision_batch`) }
1041
+ for (const event of batchEvents) {
1042
+ if (event.kind !== 'question') continue
1043
+ const awaiting = state.awaitingApproval
1044
+ if (!awaiting || bookingDigest(event.question.blocker) !== bookingDigest(awaiting.blocker) || event.question.approvalOptions.length !== awaiting.options.length || event.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => bookingDigest(option) === bookingDigest(approval)))) throw new Error(`ledger_corrupt:${taskId}:decision_batch_question`)
1045
+ // Only the atomic, durable batch makes the presentation nonce
1046
+ // usable. APPROVAL_OFFERED alone is intentionally insufficient.
1047
+ awaiting.optionsEmitted = true
1048
+ state.phase = 'input_required'
1049
+ }
1050
+ } else if (row.kind === EVENT) {
1051
+ const event = payload as EventPayload
1052
+ if (skipLegacyUpgradeTail) { try { consumeSkippedEventSequence(state, event, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:event`) } continue }
1053
+ state.lastSequence = Math.max(state.lastSequence, event.sequence)
1054
+ {
1055
+ rememberConsumedEventIdentity(consumed, event)
1056
+ }
1057
+ if (event.eventKind === 'status') state.phase = event.status === 'submitted' ? 'submitted' : event.status === 'working' ? 'working' : event.status === 'waiting_receipt' ? 'waiting_receipt' : 'input_required'
1058
+ else if (event.eventKind === 'question') state.phase = 'input_required'
1059
+ else if (event.eventKind === 'terminal') state.phase = 'terminal'
1060
+ else if (event.eventKind === 'error') state.phase = 'error'
268
1061
  }
269
- continue
270
- }
271
- if (!state) throw new Error(`ledger_corrupt:${taskId}:event_before_start`)
272
- if (payload.contextRef !== state.contextRef) throw new Error(`ledger_corrupt:${taskId}:context_drift`)
273
- if (row.kind === USER_TURN_OBSERVED) {
274
- const observed = payload as unknown as UserTurnObservedPayload
275
- state.userTurnCount += 1
276
- state.lastUserTurnDigest = observed.requestDigest
277
- } else if (row.kind === ACTION_ISSUED) {
278
- const issued = payload as unknown as ActionIssuedPayload
279
- if (issued.action.actionDigest !== checkpointDigest(issued.action)) throw new Error(`ledger_corrupt:${taskId}:action`)
280
- if (state.pendingAction) throw new Error(`ledger_corrupt:${taskId}:parallel_actions`)
281
- state.pendingAction = issued.action
282
- state.phase = 'waiting_receipt'
283
- state.lastSequence = Math.max(state.lastSequence, issued.action.sequence)
284
- } else if (row.kind === RECEIPT_OBSERVED) {
285
- const observed = payload as unknown as ReceiptObservedPayload
286
- if (observed.receiptDigest !== digest(observed.receipt) || !validateActionReceiptV1(observed.receipt).ok) {
287
- throw new Error(`ledger_corrupt:${taskId}:receipt`)
288
- }
289
- if (!state.pendingAction || state.pendingAction.actionId !== observed.receipt.actionId) {
290
- throw new Error(`ledger_corrupt:${taskId}:orphan_receipt`)
291
- }
292
- state.lastReceipt = observed.receipt
293
- state.revision = observed.receipt.revision
294
- delete state.pendingAction
295
- state.phase = 'planning'
296
- } else if (row.kind === EVENT_EMITTED) {
297
- const emitted = payload as unknown as EventEmittedPayload
298
- state.lastSequence = Math.max(state.lastSequence, emitted.sequence)
299
1062
  }
300
1063
  }
1064
+ if (state) {
1065
+ if (suppressedDecisionRequestKeys.size) state.legacySuppressedDecisionRequestKeys = [...suppressedDecisionRequestKeys]
1066
+ if (suppressedRequestBindingKeys.size) state.legacySuppressedRequestBindingKeys = [...suppressedRequestBindingKeys]
1067
+ if (suppressedReceiptActionIds.size) state.legacySuppressedReceiptActionIds = [...suppressedReceiptActionIds]
1068
+ if (suppressedApprovalTargetActionIds.size) state.legacySuppressedApprovalTargetActionIds = [...suppressedApprovalTargetActionIds]
1069
+ }
301
1070
  return state
302
1071
  }
303
1072
 
304
- issueOperation(taskId: string, action: BookingReadActionV1): BookingOperationEventV1 {
305
- const validation = validateBookingReadActionV1(action)
306
- if (!validation.ok) throw new Error(`invalid_action:${validation.errors.join('; ')}`)
307
- assertSafeFactRefs(action)
308
- const apply = this.ledger.db.transaction(() => {
1073
+ withReceiptDigest<T extends ActionReceipt>(receipt: T): T {
1074
+ // The source receipt digest is the authority used by the approval binding.
1075
+ const sourceDigest = receiptSourceDigest(receipt)
1076
+ return { ...receipt, resultContract: { ...receipt.resultContract, blockers: receipt.resultContract.blockers.map((b) => ({ ...b, sourceReceiptDigest: sourceDigest })) } }
1077
+ }
1078
+
1079
+ receiptDigest(receipt: ActionReceipt): string { return canonicalReceiptDigest(receipt) }
1080
+
1081
+ /** Builds the only relaxation question exposed to the planner stream. The
1082
+ * blocker is ledger-derived; a planner cannot invent its tuple or ref. */
1083
+ approvalQuestion(taskId: string): BookingSurfaceEventDraft {
1084
+ const task = this.requireTask(taskId)
1085
+ const awaiting = task.awaitingApproval
1086
+ if (!awaiting) throw new Error('approval_not_awaiting')
1087
+ return {
1088
+ kind: 'question',
1089
+ question: {
1090
+ questionId: `question-${awaiting.blocker.blockerId}`,
1091
+ prompt: `Relax required criterion ${awaiting.blocker.criterionPath}?`,
1092
+ missingFields: [awaiting.blocker.criterionPath],
1093
+ type: 'relaxation_approval_required',
1094
+ blocker: awaiting.blocker,
1095
+ approvalOptions: awaiting.options.map((approval) => ({ approval })),
1096
+ },
1097
+ }
1098
+ }
1099
+
1100
+ approvalPresentationRequestKey(taskId: string): string {
1101
+ const task = this.requireTask(taskId)
1102
+ const awaiting = task.awaitingApproval
1103
+ if (!awaiting) throw new Error('approval_not_awaiting')
1104
+ return awaiting.presentationRequestKey
1105
+ }
1106
+
1107
+ commitDecisionBatch(taskId: string, requestKey: string, decisions: readonly BookingPlannerDecision[]): BookingSurfaceEvent[] {
1108
+ return this.applyDecisionBatch(taskId, requestKey, decisions)
1109
+ }
1110
+
1111
+ readDecisionBatch(taskId: string, requestKey: string): BookingSurfaceEvent[] | null {
1112
+ const state = this.requireTask(taskId)
1113
+ if (state.legacySuppressedDecisionRequestKeys?.includes(requestKey)) throw new Error('request_key_invalidated')
1114
+ 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 }>
1115
+ for (const row of rows) {
1116
+ const payload = JSON.parse(row.payload) as DecisionBatchPayload
1117
+ if (payload.requestKey !== requestKey) continue
1118
+ 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`)
1119
+ const events = this.normalizeDecisionBatchFromDurableRows(taskId, payload.events, row.seq)
1120
+ if (!events) throw new Error(`ledger_corrupt:${taskId}:decision_batch`)
1121
+ for (const event of events) {
1122
+ if (event.kind !== 'question') continue
1123
+ const awaiting = state.awaitingApproval
1124
+ if (!awaiting || requestKey !== awaiting.presentationRequestKey || bookingDigest(event.question.blocker) !== bookingDigest(awaiting.blocker) || event.question.approvalOptions.length !== awaiting.options.length || event.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => option.optionDigest === approval.optionDigest && bookingDigest(option) === bookingDigest(approval)))) throw new Error(`ledger_corrupt:${taskId}:decision_batch_question`)
1125
+ }
1126
+ return events
1127
+ }
1128
+ return null
1129
+ }
1130
+
1131
+ /** Materializes the durable policy/budget terminal response once, before SSE. */
1132
+ terminalDecisionBatch(taskId: string, requestKey: string): BookingSurfaceEvent[] {
1133
+ assertInternalDecisionKey(requestKey)
1134
+ const prior = this.readDecisionBatch(taskId, requestKey)
1135
+ if (prior) return prior
1136
+ const state = this.requireTask(taskId)
1137
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
1138
+ assertReplayUpgradeReanchored(state)
1139
+ const run = this.ledger.db.transaction(() => {
309
1140
  const state = this.requireTask(taskId)
310
- const reasonDigest = digest(action.reason)
311
- const inputDigest = digest(action.input)
312
- if (state.pendingAction) {
313
- if (state.pendingAction.actionId !== action.actionId) throw new Error('receipt_required')
314
- if (!actionMatchesCheckpoint(action, state.pendingAction)) throw new Error('action_conflict')
315
- return {
316
- schemaVersion: 'booking.surface.v1' as const, eventId: state.pendingAction.eventId, taskId,
317
- contextRef: state.pendingAction.contextRef, sequence: state.pendingAction.sequence,
318
- emittedAt: state.pendingAction.emittedAt, kind: 'operation' as const, action,
1141
+ if (!taskHasTerminalBudget(state) || state.phase !== 'terminal') throw new Error('task_not_terminal')
1142
+ assertReplayUpgradeReanchored(state)
1143
+ const event = this.terminalEvent(state)
1144
+ this.appendTerminalEvent(taskId, event)
1145
+ this.append(DECISION_BATCH, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, requestKey, events: [event] }, `booking-copilot:decision-batch:${taskId}:${requestKey}`)
1146
+ return [event]
1147
+ })
1148
+ return run.immediate()
1149
+ }
1150
+
1151
+ /** Atomically applies planner decisions and records the exact SSE batch before it is sent. */
1152
+ applyDecisionBatch(taskId: string, requestKey: string, decisions: readonly BookingPlannerDecision[], includeSubmitted = false): BookingSurfaceEvent[] {
1153
+ assertInternalDecisionKey(requestKey)
1154
+ const prior = this.readDecisionBatch(taskId, requestKey)
1155
+ if (prior) return prior
1156
+ const current = this.requireTask(taskId)
1157
+ if (current.phase === 'terminal' || current.phase === 'error') throw new Error('task_terminal')
1158
+ assertReplayUpgradeReanchored(current)
1159
+ assertDecisionBatchFinality(decisions)
1160
+ assertAvailabilityLiveness(current, decisions)
1161
+ if (current.availability.recoveryStarted && !current.availability.terminal && decisions.some((decision) => decision.kind === 'terminal' || decision.kind === 'error')) throw new Error('availability_terminal_policy_owned')
1162
+ for (const decision of decisions) {
1163
+ if (decision.kind === 'operation') {
1164
+ const checked = validateBookingReadAction(decision.action)
1165
+ if (!checked.ok) throw new Error(`invalid_action:${errorText(checked)}`)
1166
+ }
1167
+ }
1168
+ const run = this.ledger.db.transaction(() => {
1169
+ const events: BookingSurfaceEvent[] = []
1170
+ const before = this.requireTask(taskId)
1171
+ assertReplayUpgradeReanchored(before)
1172
+ assertAvailabilityLiveness(before, decisions)
1173
+ if (before.availability.recoveryStarted && !before.availability.terminal && decisions.some((decision) => decision.kind === 'terminal' || decision.kind === 'error')) throw new Error('availability_terminal_policy_owned')
1174
+ for (const decision of decisions) {
1175
+ if (decision.kind !== 'question') continue
1176
+ const awaiting = before.awaitingApproval
1177
+ if (!awaiting || requestKey !== awaiting.presentationRequestKey || bookingDigest(decision.question.blocker) !== bookingDigest(awaiting.blocker) || decision.question.approvalOptions.length !== awaiting.options.length || decision.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => option.optionDigest === approval.optionDigest && bookingDigest(option) === bookingDigest(approval)))) throw new Error('approval_presentation_key_mismatch')
1178
+ this.ensureApprovalOffered(taskId, before.contextRef, awaiting, requestKey)
1179
+ }
1180
+ if (includeSubmitted) events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'submitted' }))
1181
+ events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'working' }))
1182
+ for (const decision of decisions) {
1183
+ if (decision.kind === 'operation') {
1184
+ events.push(this.issueOperationInTransaction(taskId, decision.action))
1185
+ events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'waiting_receipt' }))
319
1186
  }
1187
+ else events.push(this.emitEventInTransaction(taskId, decision))
320
1188
  }
321
- const historicalReceipt = this.ledger.db.prepare(
322
- `SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ?`,
323
- ).all(this.ledger.tenant, taskId, RECEIPT_OBSERVED) as Array<{ payload: string }>
324
- if (historicalReceipt.some((row) => (JSON.parse(row.payload) as ReceiptObservedPayload).receipt?.actionId === action.actionId)) throw new Error('action_already_receipted')
325
- if (state.lastReceipt?.actionId === action.actionId) throw new Error('action_already_receipted')
326
- if (action.contextRef !== state.contextRef) throw new Error('context_mismatch')
327
- if (!new Set<string>(state.allowedActions).has(action.kind)) throw new Error('unsupported_action')
328
- if (action.expectedRevision !== state.revision) throw new Error('stale_revision')
329
- const event: BookingOperationEventV1 = {
330
- schemaVersion: 'booking.surface.v1', eventId: this.idFactory('event'), taskId,
331
- contextRef: state.contextRef, sequence: state.lastSequence + 1, emittedAt: this.now(), kind: 'operation', action,
1189
+ const state = this.requireTask(taskId)
1190
+ this.append(DECISION_BATCH, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, requestKey, events }, `booking-copilot:decision-batch:${taskId}:${requestKey}`)
1191
+ return events
1192
+ })
1193
+ return run.immediate()
1194
+ }
1195
+
1196
+ issueOperation(taskId: string, candidate: BookingReadAction): { schemaVersion: 'booking.surface'; eventId: string; taskId: string; contextRef: string; sequence: number; emittedAt: string; kind: 'operation'; action: BookingReadAction } {
1197
+ const validation = validateBookingReadAction(candidate)
1198
+ if (!validation.ok) throw new Error(`invalid_action:${errorText(validation)}`)
1199
+ assertTaskId(taskId); assertSafeRef(candidate.actionId); candidate.factRefs.forEach(assertSafeRef)
1200
+ return this.ledger.db.transaction(() => this.issueOperationInTransaction(taskId, candidate)).immediate()
1201
+ }
1202
+
1203
+ private issueOperationInTransaction(taskId: string, candidate: BookingReadAction) {
1204
+ assertSafeRef(candidate.actionId); candidate.factRefs.forEach(assertSafeRef)
1205
+ const state = this.requireTask(taskId)
1206
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
1207
+ assertReplayUpgradeReanchored(state)
1208
+ if (state.pendingAction) {
1209
+ if (state.pendingAction.actionId !== candidate.actionId) throw new Error('receipt_required')
1210
+ if (!matchesCheckpoint(candidate, state.pendingAction)) throw new Error('action_conflict')
1211
+ return this.operationEvent(taskId, state.pendingAction, candidate)
1212
+ }
1213
+ if (candidate.contextRef !== state.contextRef) throw new Error('context_mismatch')
1214
+ if (!state.allowedActions.includes(candidate.kind)) throw new Error('unsupported_action')
1215
+ if (candidate.expectedRevision !== state.revision) throw new Error('stale_revision')
1216
+ if (state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS) throw new Error('operation_limit_reached')
1217
+ if (candidate.relaxationApprovalRef) throw new Error('approval_ref_planner_owned_forbidden')
1218
+ if (!state.workspaceSnapshot) throw new Error(`ledger_corrupt:${taskId}:workspace_missing`)
1219
+ if (!actionHitsCurrentOfferVersion(state.workspaceSnapshot, candidate, this.now())) throw new Error('offer_version_not_loaded')
1220
+ let action = candidate
1221
+ const availability = reduceAvailabilityAction(state.availability, state.workspaceSnapshot, candidate)
1222
+ if (state.awaitingApproval) {
1223
+ const approval = state.awaitingApproval
1224
+ if (!approval.approval || Date.parse(approval.expiresAt) <= Date.parse(this.now())) throw new Error('approval_expired')
1225
+ const ref: RelaxationApprovalRef = {
1226
+ approvalId: approval.approval.approvalId, blockerId: approval.blocker.blockerId,
1227
+ contextRef: state.contextRef, sourceTurnId: approval.sourceTurnId,
1228
+ presentationRequestKey: approval.presentationRequestKey, sourceActionId: approval.blocker.sourceActionId,
1229
+ targetActionId: candidate.actionId, sourceRevision: candidate.expectedRevision,
1230
+ targetActionKind: candidate.kind, to: approval.approval.to, expiresAt: approval.expiresAt,
1231
+ nonce: approval.nonce, sourceReceiptDigest: approval.blocker.sourceReceiptDigest,
1232
+ scope: approval.blocker.scope, code: approval.blocker.code, criterionPath: approval.blocker.criterionPath,
1233
+ valueDigest: approval.blocker.valueDigest,
1234
+ }
1235
+ action = { ...candidate, relaxationApprovalRef: ref }
1236
+ this.append(APPROVAL_CONSUMED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, approval, ref, approvalDigest: bookingDigest(ref) }, `booking-copilot:approval-consumed:${taskId}:${approval.approval.approvalId}:${approval.nonce}`)
332
1237
  }
333
- const eventValidation = validateBookingSurfaceEventV1(event)
334
- if (!eventValidation.ok) throw new Error(`invalid_operation_event:${eventValidation.errors.join('; ')}`)
335
- const checkpointBase = { actionId: action.actionId, kind: action.kind, contextRef: action.contextRef, expectedRevision: action.expectedRevision, factRefs: [...action.factRefs], reasonDigest, inputDigest, eventId: event.eventId, sequence: event.sequence, emittedAt: event.emittedAt }
336
- const checkpoint: BookingActionCheckpointV1 = { ...checkpointBase, actionDigest: checkpointDigest(checkpointBase) }
337
- const payload: ActionIssuedPayload = { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, action: checkpoint }
338
- this.appendActionIssued(taskId, payload, `booking-copilot:action:${taskId.length}:${taskId}:${action.actionId.length}:${action.actionId}`)
1238
+ const eventId = this.idFactory('event'); const emittedAt = this.now(); const sequence = state.lastSequence + 1
1239
+ const event = { schemaVersion: 'booking.surface' as const, eventId, taskId, contextRef: state.contextRef, sequence, emittedAt, kind: 'operation' as const, action }
1240
+ const eventValidation = validateBookingSurfaceEvent(event)
1241
+ if (!eventValidation.ok) throw new Error(`invalid_operation_event:${errorText(eventValidation)}`)
1242
+ if (!state.lastTurnId) throw new Error(`ledger_corrupt:${taskId}:turn_id`)
1243
+ this.appendAction(taskId, state.contextRef, checkpointFor(action, eventId, sequence, emittedAt, state.lastTurnId), state.operationCount + 1, availability)
339
1244
  return event
340
- })
341
- return apply.immediate()
342
1245
  }
343
1246
 
344
- continueWithReceipt(turn: ActionReceiptContinuationV1): BookingCopilotTaskStateV1 {
345
- const validation = validateBookingCopilotTurnV1(turn)
346
- if (!validation.ok || turn.kind !== 'action.receipt.continuation') {
347
- throw new Error(`invalid_receipt_continuation:${validationMessage(validation)}`)
348
- }
349
- const receiptDigest = digest(turn.receipt)
350
- const apply = this.ledger.db.transaction(() => {
1247
+ continueWithReceipt(turn: Extract<BookingCopilotTurn, { kind: 'action.receipt.continuation' }>): BookingCopilotTaskState {
1248
+ const validation = validateBookingSurface(turn)
1249
+ if (!validation.ok) throw new Error(`invalid_receipt_continuation:${errorText(validation)}`)
1250
+ assertWorkspaceLoadedOfferRefsUnique(turn.workspace)
1251
+ assertCanonicalReceipt(turn.receipt)
1252
+ const run = this.ledger.db.transaction(() => {
351
1253
  const state = this.requireTask(turn.taskId)
1254
+ assertReplayUpgradeReanchored(state)
352
1255
  if (turn.workspace.contextRef !== state.contextRef || turn.receipt.contextRef !== state.contextRef) throw new Error('context_mismatch')
353
- // Receipt replay is keyed by task + action, not merely by the latest fold state.
354
- const history = this.ledger.db.prepare(
355
- `SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ? ORDER BY seq`,
356
- ).all(this.ledger.tenant, turn.taskId, RECEIPT_OBSERVED) as Array<{ payload: string }>
357
- for (const row of history) {
358
- const prior = JSON.parse(row.payload) as ReceiptObservedPayload
359
- if (prior.receipt?.actionId !== turn.receipt.actionId) continue
360
- if (prior.schema !== LEDGER_SCHEMA || prior.taskId !== turn.taskId || prior.contextRef !== state.contextRef || prior.receiptDigest !== digest(prior.receipt) || !validateActionReceiptV1(prior.receipt).ok) throw new Error('ledger_corrupt:receipt')
1256
+ const receiptWorkspace = bookingWorkspaceDigest(turn.workspace)
1257
+ const receiptWorkspaceSemantic = bookingWorkspaceSemanticDigest(turn.workspace)
1258
+ if (!state.workspaceSnapshot || !workspaceBoundaryMatches(state.workspaceSnapshot, turn.workspace)) throw new Error('workspace_mismatch')
1259
+ 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 }>
1260
+ const receiptDigest = canonicalReceiptDigest(turn.receipt)
1261
+ if (state.legacySuppressedReceiptActionIds?.includes(turn.receipt.actionId)) throw new Error('request_key_invalidated')
1262
+ for (const row of existing) {
1263
+ const prior = JSON.parse(row.payload) as ReceiptPayload
1264
+ if (prior.receipt.actionId !== turn.receipt.actionId) continue
361
1265
  if (prior.receiptDigest !== receiptDigest) throw new Error('receipt_conflict')
1266
+ if (prior.workspaceDigest !== receiptWorkspace) throw new Error('workspace_mismatch')
362
1267
  return state
363
1268
  }
1269
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
364
1270
  if (!state.pendingAction) throw new Error('unexpected_receipt')
365
1271
  if (state.pendingAction.actionId !== turn.receipt.actionId) throw new Error('receipt_action_mismatch')
366
1272
  if (turn.receipt.revision < state.pendingAction.expectedRevision) throw new Error('revision_regression')
367
- const payload: ReceiptObservedPayload = { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, receipt: turn.receipt, receiptDigest }
368
- const idemKey = `booking-copilot:receipt:${turn.taskId.length}:${turn.taskId}:${turn.receipt.actionId.length}:${turn.receipt.actionId}`
369
- const info = this.ledger.db.prepare(
370
- `INSERT INTO events (tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
371
- ).run(this.ledger.tenant, this.now(), LEDGER_ACTOR, RECEIPT_OBSERVED, turn.taskId, JSON.stringify(payload), idemKey, turn.taskId)
372
- if (info.changes !== 1) throw new Error('ledger_write_failed:receipt')
1273
+ if (turn.receipt.resultContract.blockers.some((blocker) => blocker.sourceActionId !== state.pendingAction!.actionId)) throw new Error('receipt_source_action_mismatch')
1274
+ if (!workspacePostActionMatches(state.workspaceSnapshot, turn.workspace, state.pendingAction, turn.receipt)) throw new Error('workspace_mismatch')
1275
+ if (!receiptObservationMatchesAction(turn.receipt.observation.kind, state.pendingAction.kind)) throw new Error('receipt_observation_action_mismatch')
1276
+ if (!receiptTargetMatchesAction(turn.receipt, state.pendingAction)) throw new Error('receipt_target_mismatch')
1277
+ if (state.pendingAction.kind === 'offer.check' && turn.receipt.status === 'applied' && !verifiedOfferUnexpired(turn.workspace, this.now())) throw new Error('receipt_verified_offer_expired')
1278
+ // Failed/stale query receipts may carry a typed gap observation; only an
1279
+ // offers.state observation can claim passive offer provenance.
1280
+ if (state.pendingAction.kind === 'offers.query' && turn.receipt.observation.kind === 'offers.state') validateOffersReceiptWorkspace(turn.receipt, turn.workspace, state.pendingAction.input.hotelRefs)
1281
+ if (turn.receipt.resultContract.relaxationsApplied.some((approval) => !this.approvalWasConsumed(turn.taskId, approval, state.pendingAction!.actionId))) throw new Error('receipt_relaxation_unauthorized')
1282
+ const pending = state.pendingAction
1283
+ if (turn.receipt.revision !== turn.workspace.revision) throw new Error('revision_mismatch')
1284
+ const availability = reduceAvailabilityReceipt(state.availability, turn.workspace, turn.receipt, pending)
1285
+ const availabilityTerminal = availabilityPolicyTerminal(availability)
1286
+ this.append(RECEIPT, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, receipt: turn.receipt, receiptDigest, operationCount: state.operationCount, workspaceDigest: receiptWorkspace, workspaceSemanticDigest: receiptWorkspaceSemantic, workspace: turn.workspace, availability, availabilityDigest: bookingDigest(availability), ...(availabilityTerminal ? { availabilityTerminal } : {}) }, `booking-copilot:receipt:${turn.taskId}:${turn.receipt.actionId}`)
1287
+ if (availabilityTerminal || state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS) {
1288
+ const terminalState = this.requireTask(turn.taskId)
1289
+ const terminalEvent = this.terminalEvent(terminalState)
1290
+ this.appendTerminalEvent(turn.taskId, terminalEvent)
1291
+ this.append(DECISION_BATCH, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, requestKey: `receipt:${turn.receipt.actionId}:${receiptDigest}`, events: [terminalEvent] }, `booking-copilot:decision-batch:${turn.taskId}:receipt:${turn.receipt.actionId}:${receiptDigest}`)
1292
+ return terminalState
1293
+ }
1294
+ const blocker = turn.receipt.resultContract.blockers[0]
1295
+ if (blocker) {
1296
+ // Never derive this claim from the injectable id factory: production
1297
+ // and crash proofs must not make a presentation nonce guessable from
1298
+ // deterministic task/event ids.
1299
+ const nonce = randomUUID()
1300
+ const sourceTurnId = state.pendingAction?.sourceTurnId ?? state.lastTurnId ?? `ordinal:${state.userTurnCount}`
1301
+ const requestKey = presentationKey(turn.taskId, sourceTurnId, turn.receipt.actionId, receiptDigest)
1302
+ 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 }
1303
+ this.append(APPROVAL_GRANTED, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, approval, approvalDigest: bookingDigest(approval) }, `booking-copilot:approval-awaiting:${turn.taskId}:${turn.receipt.actionId}`)
1304
+ }
373
1305
  return this.requireTask(turn.taskId)
374
1306
  })
375
- return apply.immediate()
1307
+ return run.immediate()
1308
+ }
1309
+
1310
+ emitEvent(taskId: string, draft: BookingSurfaceEventDraft): Exclude<BookingSurfaceEvent, { kind: 'operation' }> {
1311
+ return this.ledger.db.transaction(() => {
1312
+ const state = this.requireTask(taskId)
1313
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
1314
+ assertReplayUpgradeReanchored(state)
1315
+ return this.emitEventInTransaction(taskId, draft)
1316
+ }).immediate()
376
1317
  }
377
1318
 
378
- emitEvent(taskId: string, draft: BookingSurfaceEventDraftV1): Exclude<BookingSurfaceEventV1, BookingOperationEventV1> {
379
- // Sequence allocation is part of the same immediate transaction as the
380
- // durable append. This makes the fold read and next-sequence write one
381
- // critical section across independent runtime processes.
382
- const apply = this.ledger.db.transaction(() => {
1319
+ private emitEventInTransaction(taskId: string, draft: BookingSurfaceEventDraft) {
383
1320
  const state = this.requireTask(taskId)
1321
+ assertReplayUpgradeReanchored(state)
384
1322
  const safeDraft = draft.kind === 'error'
385
1323
  ? { ...draft, error: { ...draft.error, code: normalizeBookingErrorCode(draft.error.code), message: safeBookingErrorMessage(normalizeBookingErrorCode(draft.error.code)) } }
386
1324
  : draft
387
- const event = {
388
- schemaVersion: 'booking.surface.v1',
389
- eventId: this.idFactory('event'),
390
- taskId,
391
- contextRef: state.contextRef,
392
- sequence: state.lastSequence + 1,
393
- emittedAt: this.now(),
394
- ...safeDraft,
395
- } as Exclude<BookingSurfaceEventV1, BookingOperationEventV1>
396
- const validation = validateBookingSurfaceEventV1(event)
397
- if (!validation.ok) throw new Error(`invalid_event:${validation.errors.join('; ')}`)
398
- const payload: EventEmittedPayload = {
399
- schema: LEDGER_SCHEMA,
400
- taskId,
401
- contextRef: state.contextRef,
402
- eventId: event.eventId,
403
- sequence: event.sequence,
404
- emittedAt: event.emittedAt,
405
- eventKind: event.kind,
406
- contentDigest: digest(safeDraft),
407
- }
408
- this.appendLedgerEvent(EVENT_EMITTED, taskId, payload, `booking-copilot:event:${taskId}:${event.eventId}`)
1325
+ const event = { schemaVersion: 'booking.surface' as const, eventId: this.idFactory('event'), taskId, contextRef: state.contextRef, sequence: state.lastSequence + 1, emittedAt: this.now(), ...safeDraft } as Exclude<BookingSurfaceEvent, { kind: 'operation' }>
1326
+ const checked = validateBookingSurfaceEvent(event)
1327
+ if (!checked.ok) throw new Error(`invalid_event:${errorText(checked)}`)
1328
+ this.append(EVENT, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, eventId: event.eventId, sequence: event.sequence, emittedAt: event.emittedAt, eventKind: event.kind, ...(event.kind === 'status' ? { status: event.status } : {}), contentDigest: bookingDigest(safeDraft) }, `booking-copilot:event:${taskId}:${event.eventId}`)
409
1329
  return event
410
- })
411
- return apply.immediate()
412
1330
  }
413
1331
 
414
- private requireTask(taskId: string): BookingCopilotTaskStateV1 {
415
- const state = this.resumeTask(taskId)
416
- if (!state) throw new Error('task_not_found')
417
- return state
1332
+ private operationEvent(taskId: string, checkpoint: BookingActionCheckpoint, action: BookingReadAction) {
1333
+ const replayAction = action.relaxationApprovalRef || !checkpoint.relaxationApprovalRef ? action : { ...action, relaxationApprovalRef: checkpoint.relaxationApprovalRef }
1334
+ return { schemaVersion: 'booking.surface' as const, eventId: checkpoint.eventId, taskId, contextRef: checkpoint.contextRef, sequence: checkpoint.sequence, emittedAt: checkpoint.emittedAt, kind: 'operation' as const, action: replayAction }
1335
+ }
1336
+ private terminalEvent(state: BookingCopilotTaskState): Extract<BookingSurfaceEvent, { kind: 'terminal' }> {
1337
+ if (!taskHasTerminalBudget(state)) throw new Error('task_not_terminal')
1338
+ const terminalCode = state.availability.terminal?.code ?? 'operation_limit_reached'
1339
+ const event = { schemaVersion: 'booking.surface' as const, eventId: `terminal-${state.taskId}-${state.operationCount}`, taskId: state.taskId, contextRef: state.contextRef, sequence: state.lastSequence + 1, emittedAt: this.now(), kind: 'terminal' as const, terminal: { status: state.availability.terminal?.code === 'availability_confirmed' ? 'completed' as const : 'stopped' as const, summary: terminalCode, factRefs: [] } }
1340
+ const checked = validateBookingSurfaceEvent(event)
1341
+ if (!checked.ok) throw new Error(`invalid_terminal_event:${errorText(checked)}`)
1342
+ return event
418
1343
  }
419
1344
 
420
- private readTaskRows(taskId: string): BookingLedgerRow[] {
421
- return this.ledger.db.prepare(
422
- `SELECT seq, kind, payload FROM events
423
- WHERE tenant_id = ? AND run_id = ?
424
- AND kind IN (?, ?, ?, ?, ?)
425
- ORDER BY seq`,
426
- ).all(
427
- this.ledger.tenant,
428
- taskId,
429
- TASK_STARTED,
430
- USER_TURN_OBSERVED,
431
- ACTION_ISSUED,
432
- RECEIPT_OBSERVED,
433
- EVENT_EMITTED,
434
- ) as BookingLedgerRow[]
435
- }
436
-
437
- private observeUserTurn(taskId: string, contextRef: string, requestDigest: string, ordinal: number): void {
438
- const payload: UserTurnObservedPayload = {
439
- schema: LEDGER_SCHEMA,
440
- taskId,
441
- contextRef,
442
- requestDigest,
1345
+ private requireTask(taskId: string): BookingCopilotTaskState { const state = this.resumeTask(taskId); if (!state) throw new Error('task_not_found'); return state }
1346
+ private requestBinding(requestKey: string): RequestBindingPayload | null {
1347
+ 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 }>
1348
+ for (const row of rows) {
1349
+ const binding = JSON.parse(row.payload) as RequestBindingPayload
1350
+ if (binding.requestKey === requestKey) {
1351
+ const state = this.resumeTask(binding.taskId)
1352
+ if (state?.legacySuppressedRequestBindingKeys?.includes(requestKey)) throw new Error('request_key_invalidated')
1353
+ return binding
1354
+ }
1355
+ }
1356
+ return null
1357
+ }
1358
+ private requestBindingPayload(requestKey: string, turn: UserTurn, input: BookingIngressRequestBindingInput): Omit<RequestBindingPayload, 'schema' | 'taskId' | 'contextRef'> {
1359
+ assertPrincipal(input.principal)
1360
+ assertRequestKey(requestKey)
1361
+ if (input.taskHandle) assertSafeRef(input.taskHandle)
1362
+ return {
1363
+ requestKey,
1364
+ requestDigest: bookingTurnDigest(turn),
1365
+ turnId: turn.turnId,
1366
+ workspaceDigest: bookingWorkspaceDigest(turn.workspace),
1367
+ workspaceSemanticDigest: bookingWorkspaceSemanticDigest(turn.workspace),
1368
+ surface: turn.workspace.surface,
1369
+ capabilityDigest: bookingDigest(turn.workspace.capabilities),
1370
+ principalDigest: bookingDigest({ subject: input.principal.subject }),
1371
+ scopeDigest: bookingDigest({ scope: input.principal.scope }),
1372
+ ...(input.taskHandle ? { taskHandle: input.taskHandle } : {}),
1373
+ }
1374
+ }
1375
+ private appendRequestBindingInTransaction(requestKey: string, turn: UserTurn, input: BookingIngressRequestBindingInput): void {
1376
+ assertRequestKey(requestKey)
1377
+ assertPrincipal(input.principal)
1378
+ const payload = this.requestBindingPayload(requestKey, turn, input)
1379
+ if (input.taskHandle) {
1380
+ 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 }>
1381
+ for (const row of prior) {
1382
+ const binding = JSON.parse(row.payload) as RequestBindingPayload
1383
+ if (binding.taskHandle !== input.taskHandle) continue
1384
+ if (binding.principalDigest !== payload.principalDigest || binding.scopeDigest !== payload.scopeDigest) throw new Error('principal_conflict')
1385
+ if (binding.taskId !== turn.taskId || binding.contextRef !== turn.workspace.contextRef) throw new Error('task_handle_conflict')
1386
+ }
1387
+ }
1388
+ const existing = this.requestBinding(requestKey)
1389
+ if (existing) {
1390
+ 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')
1391
+ return
1392
+ }
1393
+ this.append(REQUEST_BINDING, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: turn.workspace.contextRef, ...payload }, `booking-copilot:request-binding:${requestKey}`)
1394
+ }
1395
+ private normalizeDecisionBatchFromDurableRows(taskId: string, batchEvents: readonly BookingSurfaceEvent[], batchSeq: number): BookingSurfaceEvent[] | null {
1396
+ 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 }>
1397
+ 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 }>
1398
+ const sourceWorkspaces = new Map<string, BookingWorkspaceSnapshot>()
1399
+ for (const row of turnRows) {
1400
+ try {
1401
+ const turn = JSON.parse(row.payload) as TurnPayload
1402
+ sourceWorkspaces.set(turn.turnId, normalizeWorkspaceForReplay(turn.workspace))
1403
+ } catch {
1404
+ return null
1405
+ }
1406
+ }
1407
+ const recordedEvents = new Map<string, EventPayload>()
1408
+ const recordedActions = new Map<string, BookingActionCheckpoint>()
1409
+ for (const row of individualRows) {
1410
+ if (row.kind === EVENT) {
1411
+ const payload = JSON.parse(row.payload) as EventPayload
1412
+ const key = consumedIdentityKey(payload.eventId, payload.sequence)
1413
+ if (recordedEvents.has(key) || recordedActions.has(key)) return null
1414
+ recordedEvents.set(key, payload)
1415
+ } else if (row.kind === ACTION) {
1416
+ const payload = JSON.parse(row.payload) as ActionPayload
1417
+ let action = payload.action
1418
+ try {
1419
+ action = normalizeActionCheckpointForReplay(action, sourceWorkspaces.get(action.sourceTurnId))
1420
+ } catch {
1421
+ return null
1422
+ }
1423
+ const key = consumedIdentityKey(action.eventId, action.sequence)
1424
+ if (recordedEvents.has(key) || recordedActions.has(key)) return null
1425
+ recordedActions.set(key, action)
1426
+ }
1427
+ }
1428
+ let lastBatchSequence = -1
1429
+ const seen = new Set<string>()
1430
+ const normalizedEvents: BookingSurfaceEvent[] = []
1431
+ for (const event of batchEvents) {
1432
+ if (!Number.isSafeInteger(event.sequence) || event.sequence <= lastBatchSequence) return null
1433
+ lastBatchSequence = event.sequence
1434
+ const key = consumedIdentityKey(event.eventId, event.sequence)
1435
+ if (seen.has(key)) return null
1436
+ seen.add(key)
1437
+ if (event.kind === 'operation') {
1438
+ const checkpoint = recordedActions.get(key)
1439
+ if (!checkpoint || !operationEventMatchesAction(event, checkpoint)) return null
1440
+ const normalized = { ...event, action: actionFromOperationEventForReplay(event, checkpoint) } as BookingSurfaceEvent
1441
+ if (!validateBookingSurfaceEvent(normalized).ok) return null
1442
+ normalizedEvents.push(normalized)
1443
+ } else {
1444
+ const recorded = recordedEvents.get(key)
1445
+ if (!recorded || !validateBookingSurfaceEvent(event).ok || !eventPayloadMatchesEvent(recorded, event)) return null
1446
+ normalizedEvents.push(event)
1447
+ }
443
1448
  }
444
- this.appendLedgerEvent(
445
- USER_TURN_OBSERVED,
446
- taskId,
447
- payload,
448
- `booking-copilot:user-turn:${taskId}:${ordinal}`,
449
- )
450
- }
451
-
452
- private appendLedgerEvent(kind: string, taskId: string, payload: LedgerPayloadBase, idemKey: string): number {
453
- const result = this.ledger.db.prepare(
454
- `INSERT OR IGNORE INTO events
455
- (tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id)
456
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
457
- ).run(
458
- this.ledger.tenant,
459
- this.now(),
460
- LEDGER_ACTOR,
461
- kind,
462
- taskId,
463
- JSON.stringify(payload),
464
- idemKey,
465
- taskId,
466
- )
467
- if (result.changes > 0) return Number(result.lastInsertRowid)
468
- const existing = this.ledger.db.prepare(
469
- 'SELECT seq FROM events WHERE tenant_id = ? AND idem_key = ?',
470
- ).get(this.ledger.tenant, idemKey) as { seq: number } | undefined
471
- if (!existing) throw new Error(`ledger_write_failed:${kind}`)
472
- return existing.seq
473
- }
474
-
475
- private appendActionIssued(taskId: string, payload: ActionIssuedPayload, idemKey: string): number {
476
- const result = this.ledger.db.prepare(
477
- `INSERT INTO events (tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id)
478
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
479
- ).run(this.ledger.tenant, this.now(), LEDGER_ACTOR, ACTION_ISSUED, taskId, JSON.stringify(payload), idemKey, taskId)
480
- if (result.changes !== 1) throw new Error('ledger_write_failed:action')
481
- return Number(result.lastInsertRowid)
1449
+ return normalizedEvents
1450
+ }
1451
+ 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[] }
1452
+ private append(kind: string, taskId: string, payload: BasePayload, idemKey: string): void {
1453
+ 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)
1454
+ if (result.changes === 0) throw new Error(`ledger_write_failed:${kind}`)
1455
+ }
1456
+ private appendTurn(taskId: string, contextRef: string, requestDigest: string, workspaceDigest: string, workspaceSemanticDigest: string, ordinal: number, turnId: string, workspace?: BookingWorkspaceSnapshot): 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-copilot:turn:${taskId}:${turnId}`) }
1457
+ private appendApproval(taskId: string, contextRef: string, approval: BookingApprovalState): void { this.append(APPROVAL_GRANTED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, approval, approvalDigest: bookingDigest(approval) }, `booking-copilot:approval-granted:${taskId}:${approval.approval?.approvalId ?? 'pending'}:${approval.nonce}`) }
1458
+ private appendAction(taskId: string, contextRef: string, action: BookingActionCheckpoint, operationCount: number, availability: AvailabilityPolicyState): void { this.append(ACTION, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, action, operationCount, availability, availabilityDigest: bookingDigest(availability) }, `booking-copilot:action:${taskId}:${action.actionId}`) }
1459
+ private appendTerminalEvent(taskId: string, event: Extract<BookingSurfaceEvent, { kind: 'terminal' }>): void {
1460
+ const payload: EventPayload = { schema: LEDGER_SCHEMA, taskId, contextRef: event.contextRef, eventId: event.eventId, sequence: event.sequence, emittedAt: event.emittedAt, eventKind: 'terminal', contentDigest: bookingDigest(event.terminal) }
1461
+ const idemKey = `booking-copilot:terminal-event:${taskId}`
1462
+ 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)
1463
+ if (result.changes === 0) {
1464
+ 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
1465
+ if (!existing || (JSON.parse(existing.payload) as EventPayload).contentDigest !== payload.contentDigest) throw new Error('ledger_write_failed:terminal_event')
1466
+ }
1467
+ }
1468
+ private ensureApprovalOffered(taskId: string, contextRef: string, approval: BookingApprovalState, requestKey: string): void {
1469
+ if (requestKey !== approval.presentationRequestKey) throw new Error('approval_presentation_key_mismatch')
1470
+ const idemKey = `booking-copilot:approval-offered:${taskId}:${requestKey}`
1471
+ 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
1472
+ if (!exists) this.append(APPROVAL_OFFERED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, approval, approvalDigest: bookingDigest(approval) }, idemKey)
1473
+ }
1474
+ private approvalWasConsumed(taskId: string, approval: RelaxationApproval, targetActionId: string): boolean {
1475
+ const state = this.resumeTask(taskId)
1476
+ if (state?.legacySuppressedApprovalTargetActionIds?.includes(targetActionId)) return false
1477
+ 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 }>
1478
+ return rows.some(({ payload }) => {
1479
+ const consumed = JSON.parse(payload) as ApprovalPayload
1480
+ return consumed.ref?.targetActionId === targetActionId && consumed.approval?.approval && bookingDigest(consumed.approval.approval) === bookingDigest(approval)
1481
+ })
482
1482
  }
483
1483
  }