@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,130 +1,887 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import './contracts.js';
3
- import { validateBookingCopilotTurnV1, validateActionReceiptV1, validateBookingReadActionV1, validateBookingSurfaceEventV1 } from './validation.js';
2
+ import { BOOKING_READ_ACTION_KINDS, BOOKING_COPILOT_MAX_OPERATIONS } from './contracts.js';
3
+ import { validateApprovalAgainstBlocker, validateCriterionBlocker, validateBookingReadAction, validateBookingSurfaceEvent, validateBookingSurface, isBookingDateTime, isSafeBookingRequestKey } from './validation.js';
4
+ import { assertWorkspaceLoadedOfferRefsUnique, canIssueOfferCheck, createAvailabilityPolicy, reduceAvailabilityAction, reduceAvailabilityReceipt, validateOffersReceiptWorkspace, availabilityPolicyResult, validateAvailabilityPolicy } from './availability-policy.js';
4
5
  import { normalizeBookingErrorCode, safeBookingErrorMessage } from './error-codes.js';
5
- const LEDGER_SCHEMA = 'booking.copilot.ledger.v1';
6
- const LEDGER_ACTOR = 'system:booking-copilot';
7
- const TASK_STARTED = 'booking.copilot.task.started';
8
- const USER_TURN_OBSERVED = 'booking.copilot.user.turn.observed';
9
- const ACTION_ISSUED = 'booking.copilot.action.issued';
10
- const RECEIPT_OBSERVED = 'booking.copilot.receipt.observed';
11
- const EVENT_EMITTED = 'booking.copilot.event.emitted';
12
- function stableValue(value) {
13
- if (Array.isArray(value)) return value.map(stableValue);
14
- if (typeof value !== 'object' || value === null) return value;
15
- const source = value;
16
- const target = {};
17
- for (const key of Object.keys(source).sort())target[key] = stableValue(source[key]);
18
- return target;
19
- }
20
- function digest(value) {
21
- return createHash('sha256').update(JSON.stringify(stableValue(value))).digest('hex');
6
+ const LEDGER_SCHEMA = 'booking.copilot.ledger';
7
+ const ACTOR = 'system:booking-copilot';
8
+ const STARTED = 'booking.copilot.task.started';
9
+ const TURN = 'booking.copilot.user.turn.observed';
10
+ const ACTION = 'booking.copilot.action.issued';
11
+ const RECEIPT = 'booking.copilot.receipt.observed';
12
+ const EVENT = 'booking.copilot.event.emitted';
13
+ const APPROVAL_GRANTED = 'booking.copilot.approval.granted';
14
+ const APPROVAL_OFFERED = 'booking.copilot.approval.option.offered';
15
+ const APPROVAL_CONSUMED = 'booking.copilot.approval.consumed';
16
+ const DECISION_BATCH = 'booking.copilot.decision.batch';
17
+ const REQUEST_BINDING = 'booking.copilot.request.binding';
18
+ function stable(value) {
19
+ if (Array.isArray(value)) return value.map(stable);
20
+ if (!value || typeof value !== 'object') return value;
21
+ const out = {};
22
+ for (const key of Object.keys(value).sort())out[key] = stable(value[key]);
23
+ return out;
24
+ }
25
+ export function bookingDigest(value) {
26
+ return createHash('sha256').update(JSON.stringify(stable(value))).digest('hex');
27
+ }
28
+ export function bookingWorkspaceDigest(workspace) {
29
+ const { contextRef: _contextRef, ...contextBoundWorkspace } = workspace;
30
+ return bookingDigest(contextBoundWorkspace);
31
+ }
32
+ function bookingWorkspaceSemanticDigest(workspace) {
33
+ const { contextRef: _contextRef, revision: _revision, ...semanticWorkspace } = workspace;
34
+ return bookingDigest(semanticWorkspace);
35
+ }
36
+ export function bookingTurnDigest(turn) {
37
+ return bookingDigest(turn);
38
+ }
39
+ function canonicalReceiptDigest(value) {
40
+ return bookingDigest(value);
41
+ }
42
+ function replayDigestMatches(recordedDigest, raw, normalized) {
43
+ return typeof recordedDigest === 'string' && (recordedDigest === bookingDigest(raw) || recordedDigest === bookingDigest(normalized));
44
+ }
45
+ function replayWorkspaceDigestMatches(recordedDigest, raw, normalized) {
46
+ return typeof recordedDigest === 'string' && (recordedDigest === bookingWorkspaceDigest(raw) || recordedDigest === bookingWorkspaceDigest(normalized));
47
+ }
48
+ function replayWorkspaceSemanticDigestMatches(recordedDigest, raw, normalized) {
49
+ return typeof recordedDigest === 'string' && (recordedDigest === bookingWorkspaceSemanticDigest(raw) || recordedDigest === bookingWorkspaceSemanticDigest(normalized));
50
+ }
51
+ function rememberReplayWorkspaceDigest(digests, recordedDigest, raw, normalized) {
52
+ const normalizedDigest = bookingWorkspaceDigest(normalized);
53
+ digests.set(bookingWorkspaceDigest(raw), normalizedDigest);
54
+ if (typeof recordedDigest === 'string') digests.set(recordedDigest, normalizedDigest);
55
+ }
56
+ function legacyOfferVersionRef(contextRef, offer) {
57
+ return `legacy-offer-version:${bookingDigest({
58
+ contextRef,
59
+ offerRef: offer.offerRef,
60
+ hotelRef: offer.hotelRef,
61
+ evidenceLevel: offer.evidenceLevel,
62
+ factRefs: Array.isArray(offer.factRefs) ? offer.factRefs : []
63
+ }).slice(0, 40)}`;
64
+ }
65
+ function normalizeWorkspaceForReplay(raw) {
66
+ const candidate = raw;
67
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !Array.isArray(candidate.loadedOffers) || typeof candidate.contextRef !== 'string') throw new Error('booking_malformed_workspace');
68
+ const workspace = structuredClone(raw);
69
+ workspace.loadedOffers = workspace.loadedOffers.map((offer)=>offer.offerVersionRef ? offer : {
70
+ ...offer,
71
+ offerVersionRef: legacyOfferVersionRef(workspace.contextRef, offer)
72
+ });
73
+ delete workspace.verifiedOfferRef;
74
+ return workspace;
75
+ }
76
+ function resolveReplayOfferVersion(offerRef, workspace, action, actionId, priorAvailability) {
77
+ const priorAttempt = actionId ? priorAvailability?.attempts.find((attempt)=>attempt.actionId === actionId) : undefined;
78
+ if (priorAttempt?.offerVersionRef) return priorAttempt.offerVersionRef;
79
+ if (action?.kind === 'offer.check' && action.actionId === actionId && action.input.offerRef === offerRef && typeof action.input.offerVersionRef === 'string') return action.input.offerVersionRef;
80
+ const match = workspace?.loadedOffers.find((offer)=>offer.offerRef === offerRef);
81
+ if (match?.offerVersionRef) return match.offerVersionRef;
82
+ throw new Error('booking_replan_required:offer_version_unbound');
83
+ }
84
+ function normalizeAvailabilityPolicyForReplay(raw, workspace, action, priorAvailability, workspaceDigestMap = new Map(), receipt) {
85
+ const normalized = structuredClone(raw);
86
+ if (normalized.hotels && typeof normalized.hotels === 'object') {
87
+ for (const [hotelRef, hotel] of Object.entries(normalized.hotels)){
88
+ const rawHotel = raw.hotels?.[hotelRef];
89
+ const priorHotel = priorAvailability?.hotels[hotelRef];
90
+ if (!Array.isArray(hotel.tombstonedOfferRefs)) hotel.tombstonedOfferRefs = priorHotel ? [
91
+ ...priorHotel.tombstonedOfferRefs
92
+ ] : [];
93
+ if (!Array.isArray(hotel.tombstonedOfferVersionRefs)) hotel.tombstonedOfferVersionRefs = priorHotel ? [
94
+ ...priorHotel.tombstonedOfferVersionRefs
95
+ ] : [];
96
+ if (rawHotel && Array.isArray(rawHotel.tombstonedOfferRefs) && priorHotel) {
97
+ for (const ref of priorHotel.tombstonedOfferRefs)if (!hotel.tombstonedOfferRefs.includes(ref)) throw new Error('booking_replan_required:tombstone_regression');
98
+ }
99
+ if (rawHotel && Array.isArray(rawHotel.tombstonedOfferVersionRefs) && priorHotel) {
100
+ for (const ref of priorHotel.tombstonedOfferVersionRefs)if (!hotel.tombstonedOfferVersionRefs.includes(ref)) throw new Error('booking_replan_required:tombstone_regression');
101
+ }
102
+ const source = hotel.currentGeneration?.source;
103
+ if (source?.kind === 'workspace_snapshot') {
104
+ const mappedDigest = workspaceDigestMap.get(source.workspaceDigest);
105
+ if (mappedDigest) source.workspaceDigest = mappedDigest;
106
+ }
107
+ }
108
+ }
109
+ if (Array.isArray(normalized.attempts)) {
110
+ normalized.attempts = normalized.attempts.map((attempt)=>attempt.offerVersionRef ? attempt : {
111
+ ...attempt,
112
+ offerVersionRef: resolveReplayOfferVersion(attempt.offerRef, workspace, action, attempt.actionId, priorAvailability)
113
+ });
114
+ }
115
+ const availability = receipt?.observation.kind === 'offer.availability' ? receipt.observation : undefined;
116
+ if (receipt && action?.kind === 'offer.check' && availability && availability.offerRef === action.input.offerRef && availability.checkedOfferVersionRef === action.input.offerVersionRef) {
117
+ const attempt = normalized.attempts.find((candidate)=>candidate.actionId === action.actionId);
118
+ const hotel = attempt ? normalized.hotels[attempt.hotelRef] : undefined;
119
+ if (hotel) {
120
+ if (receipt.status === 'changed' && availability.available === true && availability.currentOfferVersionRef && availability.currentOfferVersionRef !== action.input.offerVersionRef) {
121
+ if (!hotel.tombstonedOfferVersionRefs.includes(action.input.offerVersionRef)) hotel.tombstonedOfferVersionRefs.push(action.input.offerVersionRef);
122
+ }
123
+ const completeNegative = (receipt.status === 'unavailable' || receipt.status === 'no_match') && receipt.resultContract.outcome === 'empty' && receipt.resultContract.gapCodes.length === 0 && receipt.resultContract.blockers.length === 0 && availability.available === false && !availability.currentOfferVersionRef && !availability.verifiedOfferRef && !availability.gapCodes?.length;
124
+ if (completeNegative) {
125
+ if (!hotel.tombstonedOfferVersionRefs.includes(action.input.offerVersionRef)) hotel.tombstonedOfferVersionRefs.push(action.input.offerVersionRef);
126
+ if (!hotel.tombstonedOfferRefs.includes(action.input.offerRef)) hotel.tombstonedOfferRefs.push(action.input.offerRef);
127
+ }
128
+ }
129
+ }
130
+ return normalized;
131
+ }
132
+ function normalizeActionCheckpointForReplay(raw, workspace) {
133
+ if (![
134
+ 'offer.select',
135
+ 'offer.check',
136
+ 'checkout.prepare'
137
+ ].includes(raw.kind) || typeof raw.input.offerVersionRef === 'string') return raw;
138
+ if (typeof raw.input.offerRef !== 'string') throw new Error('booking_replan_required:offer_version_unbound');
139
+ const offerVersionRef = resolveReplayOfferVersion(raw.input.offerRef, workspace, undefined, raw.actionId);
140
+ const input = {
141
+ ...raw.input,
142
+ offerVersionRef
143
+ };
144
+ 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');
145
+ const { actionDigest: _rawActionDigest, ...rawBase } = raw;
146
+ const normalizedBase = {
147
+ ...rawBase,
148
+ input,
149
+ inputDigest: bookingDigest(input)
150
+ };
151
+ return {
152
+ ...normalizedBase,
153
+ actionDigest: checkpointDigest(normalizedBase)
154
+ };
155
+ }
156
+ function checkpointDigestMatchesForReplay(raw, normalized) {
157
+ const { actionDigest: _rawDigest, ...rawBase } = raw;
158
+ const { actionDigest: _normalizedDigest, ...normalizedBase } = normalized;
159
+ return raw.actionDigest === checkpointDigest(rawBase) || raw.actionDigest === checkpointDigest(normalizedBase);
160
+ }
161
+ function replanAfterReplayUpgradeGap(state, workspace = state.workspaceSnapshot, revision = workspace?.revision) {
162
+ if (!workspace) return {
163
+ ...state,
164
+ phase: 'error',
165
+ pendingAction: undefined
166
+ };
167
+ const safeRevision = typeof revision === 'number' && Number.isSafeInteger(revision) ? revision : state.revision;
168
+ return {
169
+ ...state,
170
+ revision: safeRevision,
171
+ phase: 'planning',
172
+ pendingAction: undefined,
173
+ awaitingApproval: undefined,
174
+ workspaceDigest: bookingWorkspaceDigest(workspace),
175
+ workspaceSemanticDigest: bookingWorkspaceSemanticDigest(workspace),
176
+ workspaceSnapshot: workspace,
177
+ availability: createAvailabilityPolicy(workspace),
178
+ replayUpgradeRequired: true
179
+ };
180
+ }
181
+ function assertReplayUpgradeReanchored(state) {
182
+ if (state.replayUpgradeRequired) throw new Error('reanchor_turn_required');
183
+ }
184
+ function consumedIdentityKey(eventId, sequence) {
185
+ return `${eventId}\0${sequence}`;
186
+ }
187
+ function clearPendingBatch(consumed) {
188
+ consumed.pendingBatchKeys.clear();
189
+ consumed.pendingBatchOpen = false;
190
+ }
191
+ function rememberPendingBatchKey(consumed, key) {
192
+ consumed.pendingBatchKeys.add(key);
193
+ }
194
+ function rememberConsumedEventIdentity(consumed, payload) {
195
+ if (payload.eventKind === 'status' && (payload.status === 'submitted' || payload.status === 'working') && !consumed.pendingBatchOpen) {
196
+ clearPendingBatch(consumed);
197
+ consumed.pendingBatchOpen = true;
198
+ }
199
+ const key = consumedIdentityKey(payload.eventId, payload.sequence);
200
+ consumed.events.set(key, payload);
201
+ rememberPendingBatchKey(consumed, key);
202
+ }
203
+ function rememberConsumedActionIdentity(consumed, action) {
204
+ const key = consumedIdentityKey(action.eventId, action.sequence);
205
+ consumed.actions.set(key, action);
206
+ rememberPendingBatchKey(consumed, key);
207
+ }
208
+ function consumeSkippedActionOrdinal(state, payload, consumed) {
209
+ const action = payload.action;
210
+ const operationCount = payload.operationCount ?? state.operationCount + 1;
211
+ if (!action || typeof action !== 'object') throw new Error('skip_action_invalid');
212
+ const { actionDigest: _actionDigest, ...actionBase } = action;
213
+ 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');
214
+ state.operationCount = operationCount;
215
+ state.lastSequence = action.sequence;
216
+ if (consumed) {
217
+ rememberConsumedActionIdentity(consumed, action);
218
+ }
219
+ }
220
+ function eventDraftDigest(event) {
221
+ if (event.kind === 'status') return bookingDigest({
222
+ kind: event.kind,
223
+ status: event.status
224
+ });
225
+ if (event.kind === 'question') return bookingDigest({
226
+ kind: event.kind,
227
+ question: event.question
228
+ });
229
+ if (event.kind === 'explanation') return bookingDigest({
230
+ kind: event.kind,
231
+ explanation: event.explanation
232
+ });
233
+ if (event.kind === 'terminal') return bookingDigest({
234
+ kind: event.kind,
235
+ terminal: event.terminal
236
+ });
237
+ return bookingDigest({
238
+ kind: event.kind,
239
+ error: event.error
240
+ });
241
+ }
242
+ function eventPayloadMatchesEvent(payload, event) {
243
+ const expectedDigest = eventDraftDigest(event);
244
+ const legacyTerminalDigest = event.kind === 'terminal' ? bookingDigest(event.terminal) : undefined;
245
+ return payload.eventId === event.eventId && payload.sequence === event.sequence && payload.emittedAt === event.emittedAt && payload.contextRef === event.contextRef && payload.eventKind === event.kind && (event.kind !== 'status' || payload.status === event.status) && (payload.contentDigest === expectedDigest || payload.contentDigest === legacyTerminalDigest);
246
+ }
247
+ function actionFromOperationEventForReplay(event, checkpoint) {
248
+ const action = event.action;
249
+ 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') {
250
+ return {
251
+ ...action,
252
+ input: {
253
+ ...action.input,
254
+ offerVersionRef: checkpoint.input.offerVersionRef
255
+ }
256
+ };
257
+ }
258
+ return action;
259
+ }
260
+ function operationEventMatchesAction(event, checkpoint) {
261
+ return event.eventId === checkpoint.eventId && event.sequence === checkpoint.sequence && event.emittedAt === checkpoint.emittedAt && event.contextRef === checkpoint.contextRef && matchesCheckpoint(actionFromOperationEventForReplay(event, checkpoint), checkpoint);
262
+ }
263
+ function normalizeDecisionBatchEnvelopeForReplay(payload, consumed, requireSchemaValid = false) {
264
+ if (typeof payload.requestKey !== 'string' || !payload.requestKey || !payload.events.length) throw new Error('skip_decision_batch_invalid');
265
+ let lastBatchSequence = -1;
266
+ const seen = new Set();
267
+ const normalizedEvents = [];
268
+ for (const event of payload.events){
269
+ if (!Number.isSafeInteger(event.sequence) || event.sequence <= lastBatchSequence) throw new Error('skip_decision_batch_invalid');
270
+ lastBatchSequence = event.sequence;
271
+ const key = consumedIdentityKey(event.eventId, event.sequence);
272
+ if (seen.has(key)) throw new Error('skip_decision_batch_invalid');
273
+ seen.add(key);
274
+ if (event.kind === 'operation') {
275
+ const checkpoint = consumed.actions.get(key);
276
+ if (!checkpoint || !operationEventMatchesAction(event, checkpoint)) throw new Error('skip_decision_batch_invalid');
277
+ const normalized = {
278
+ ...event,
279
+ action: actionFromOperationEventForReplay(event, checkpoint)
280
+ };
281
+ if (requireSchemaValid && !validateBookingSurfaceEvent(normalized).ok) throw new Error('skip_decision_batch_invalid');
282
+ normalizedEvents.push(normalized);
283
+ } else {
284
+ const recorded = consumed.events.get(key);
285
+ if (!recorded || !validateBookingSurfaceEvent(event).ok || !eventPayloadMatchesEvent(recorded, event)) throw new Error('skip_decision_batch_invalid');
286
+ normalizedEvents.push(event);
287
+ }
288
+ if (!consumed.pendingBatchKeys.has(key)) throw new Error('skip_decision_batch_invalid');
289
+ }
290
+ if (seen.size !== consumed.pendingBatchKeys.size) throw new Error('skip_decision_batch_invalid');
291
+ for (const key of consumed.pendingBatchKeys)if (!seen.has(key)) throw new Error('skip_decision_batch_invalid');
292
+ clearPendingBatch(consumed);
293
+ return normalizedEvents;
294
+ }
295
+ function consumeSkippedEventSequence(state, payload, consumed) {
296
+ if (!payload || typeof payload !== 'object') throw new Error('skip_event_invalid');
297
+ if ('sequence' in payload) {
298
+ const eventPayload = payload;
299
+ if (typeof eventPayload.eventId !== 'string' || !eventPayload.eventId || ![
300
+ 'status',
301
+ 'question',
302
+ 'explanation',
303
+ 'terminal',
304
+ 'error'
305
+ ].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');
306
+ state.lastSequence = eventPayload.sequence;
307
+ rememberConsumedEventIdentity(consumed, eventPayload);
308
+ } else if ('events' in payload && Array.isArray(payload.events)) {
309
+ normalizeDecisionBatchEnvelopeForReplay(payload, consumed);
310
+ }
311
+ }
312
+ function normalizeReceiptForReplay(raw, action, workspace) {
313
+ const receipt = structuredClone(raw);
314
+ if (action.kind === 'offer.select' && receipt.observation.kind === 'offer.selection' && receipt.observation.offerRef === action.input.offerRef && !receipt.observation.offerVersionRef) {
315
+ receipt.observation.offerVersionRef = action.input.offerVersionRef;
316
+ }
317
+ if (action.kind === 'checkout.prepare' && receipt.observation.kind === 'checkout.handoff' && receipt.observation.offerRef === action.input.offerRef && !receipt.observation.offerVersionRef) {
318
+ 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');
319
+ receipt.observation.offerVersionRef = action.input.offerVersionRef;
320
+ }
321
+ if (action.kind === 'offer.check' && receipt.observation.kind === 'offer.availability' && receipt.observation.offerRef === action.input.offerRef) {
322
+ if (!receipt.observation.checkedOfferVersionRef) receipt.observation.checkedOfferVersionRef = action.input.offerVersionRef;
323
+ if (receipt.status === 'applied' && receipt.observation.available === true && !receipt.observation.currentOfferVersionRef) receipt.observation.currentOfferVersionRef = action.input.offerVersionRef;
324
+ if (receipt.status === 'changed' && receipt.observation.available === true && !receipt.observation.currentOfferVersionRef) {
325
+ const current = workspace.loadedOffers.find((offer)=>offer.offerRef === action.input.offerRef && offer.offerVersionRef !== action.input.offerVersionRef);
326
+ if (!current) throw new Error('booking_replan_required:changed_version_unbound');
327
+ receipt.observation.currentOfferVersionRef = current.offerVersionRef;
328
+ }
329
+ 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');
330
+ }
331
+ if (receipt.resultContract.blockers.length && bookingDigest(raw) !== bookingDigest(receipt)) throw new Error('booking_replan_required:legacy_blocker_digest');
332
+ return receipt;
333
+ }
334
+ function errorText(result) {
335
+ return result.ok ? '' : result.errors.join('; ');
336
+ }
337
+ function assertTaskId(taskId) {
338
+ if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(taskId)) throw new Error('invalid_task_id');
339
+ }
340
+ function assertSafeRef(value) {
341
+ if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value)) throw new Error('unsafe_opaque_ref');
342
+ }
343
+ function safeIdentity(value) {
344
+ return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value);
345
+ }
346
+ function sameActions(a, b) {
347
+ return a.length === b.length && [
348
+ ...a
349
+ ].sort().every((x, i)=>x === [
350
+ ...b
351
+ ].sort()[i]);
352
+ }
353
+ function availabilityPolicyTerminal(state) {
354
+ return availabilityPolicyResult(state);
355
+ }
356
+ function taskHasTerminalBudget(state) {
357
+ return state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS || Boolean(state.availability.terminal);
358
+ }
359
+ function assertRequestKey(requestKey) {
360
+ if (!isSafeBookingRequestKey(requestKey)) throw new Error('unsafe_request_key');
361
+ }
362
+ function assertInternalDecisionKey(requestKey) {
363
+ if (typeof requestKey !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,2047}$/.test(requestKey)) throw new Error('unsafe_internal_decision_key');
364
+ }
365
+ function assertDecisionBatchFinality(decisions) {
366
+ let finalSeen = false;
367
+ for (const decision of decisions){
368
+ const isFinal = decision.kind === 'terminal' || decision.kind === 'error';
369
+ if (finalSeen) throw new Error('decision_batch_finality');
370
+ if (isFinal) finalSeen = true;
371
+ }
372
+ }
373
+ function assertPrincipal(principal) {
374
+ 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');
375
+ }
376
+ function assertAvailabilityLiveness(state, decisions) {
377
+ if (!state.availability.recoveryStarted || state.awaitingApproval) return;
378
+ const operations = decisions.filter((decision)=>decision.kind === 'operation');
379
+ if (operations.length !== 1 || !state.workspaceSnapshot) throw new Error('availability_operation_required');
380
+ const action = operations[0].action;
381
+ const activeHotelRef = state.availability.hotelRefs[state.availability.activeHotelOrdinal];
382
+ if (!activeHotelRef) throw new Error('availability_active_hotel_missing');
383
+ if (state.availability.availabilityPhase === 'need_offers') {
384
+ if (action.kind !== 'offers.query' || action.input.hotelRefs.length !== 1 || action.input.hotelRefs[0] !== activeHotelRef) throw new Error('availability_operation_incompatible');
385
+ } else if (state.availability.availabilityPhase === 'need_check') {
386
+ 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');
387
+ } else {
388
+ throw new Error('availability_operation_incompatible');
389
+ }
22
390
  }
23
391
  function checkpointDigest(action) {
24
- return digest({
392
+ return bookingDigest(action);
393
+ }
394
+ function checkpointFor(action, eventId, sequence, emittedAt, sourceTurnId) {
395
+ const base = {
25
396
  actionId: action.actionId,
26
397
  kind: action.kind,
27
398
  contextRef: action.contextRef,
28
399
  expectedRevision: action.expectedRevision,
29
- factRefs: action.factRefs,
30
- reasonDigest: action.reasonDigest,
31
- inputDigest: action.inputDigest,
32
- eventId: action.eventId,
33
- sequence: action.sequence,
34
- emittedAt: action.emittedAt
35
- });
400
+ factRefs: [
401
+ ...action.factRefs
402
+ ],
403
+ reasonDigest: bookingDigest(action.reason),
404
+ inputDigest: bookingDigest(action.input),
405
+ input: action.input,
406
+ eventId,
407
+ sequence,
408
+ emittedAt,
409
+ sourceTurnId,
410
+ ...action.relaxationApprovalRef ? {
411
+ relaxationApprovalRef: action.relaxationApprovalRef
412
+ } : {}
413
+ };
414
+ return {
415
+ ...base,
416
+ actionDigest: checkpointDigest(base)
417
+ };
36
418
  }
37
- function actionMatchesCheckpoint(action, checkpoint) {
38
- return checkpoint.actionDigest === checkpointDigest({
39
- actionId: checkpoint.actionId,
40
- kind: action.kind,
41
- contextRef: action.contextRef,
42
- expectedRevision: action.expectedRevision,
43
- factRefs: action.factRefs,
44
- reasonDigest: digest(action.reason),
45
- inputDigest: digest(action.input),
46
- eventId: checkpoint.eventId,
47
- sequence: checkpoint.sequence,
48
- emittedAt: checkpoint.emittedAt
49
- });
419
+ function matchesCheckpoint(action, checkpoint) {
420
+ const candidate = checkpointFor(action.relaxationApprovalRef || !checkpoint.relaxationApprovalRef ? action : {
421
+ ...action,
422
+ relaxationApprovalRef: checkpoint.relaxationApprovalRef
423
+ }, checkpoint.eventId, checkpoint.sequence, checkpoint.emittedAt, checkpoint.sourceTurnId);
424
+ const { actionDigest: _ignored, ...candidateBase } = candidate;
425
+ return checkpoint.actionDigest === checkpointDigest(candidateBase);
50
426
  }
51
- function assertSafeFactRefs(action) {
52
- const opaque = (ref)=>/^[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);
53
- if (!opaque(action.actionId)) throw new Error('unsafe_action_id');
54
- if (action.factRefs.some((ref)=>!opaque(ref))) throw new Error('unsafe_fact_ref');
427
+ function receiptSourceDigest(receipt) {
428
+ return bookingDigest({
429
+ ...receipt,
430
+ resultContract: {
431
+ ...receipt.resultContract,
432
+ blockers: receipt.resultContract.blockers.map(({ sourceReceiptDigest: _ignored, ...blocker })=>blocker)
433
+ }
434
+ });
55
435
  }
56
- function validationMessage(result) {
57
- return result.ok ? '' : result.errors.join('; ');
436
+ function assertCanonicalReceipt(receipt) {
437
+ const expected = receiptSourceDigest(receipt);
438
+ if (receipt.resultContract.blockers.some((blocker)=>blocker.sourceReceiptDigest !== expected)) throw new Error('receipt_source_digest_mismatch');
58
439
  }
59
- function assertTaskId(taskId) {
60
- if (!taskId) throw new Error('invalid_task_id');
440
+ function receiptObservationMatchesAction(observation, action) {
441
+ if (observation === 'gap') return true;
442
+ const expected = {
443
+ 'search.patch': 'search.state',
444
+ 'search.run': 'search.state',
445
+ 'results.view.patch': 'results.state',
446
+ 'hotel.focus': 'hotel.focus',
447
+ 'hotel.select': 'hotel.selection',
448
+ 'offers.query': 'offers.state',
449
+ 'offers.view.patch': 'offers.state',
450
+ 'offers.compare': 'offers.state',
451
+ 'offer.select': 'offer.selection',
452
+ 'offer.check': 'offer.availability',
453
+ 'checkout.prepare': 'checkout.handoff',
454
+ 'order.observe': 'order.state'
455
+ };
456
+ return expected[action] === observation;
61
457
  }
62
- function sameActionAllowlist(left, right) {
63
- return left.length === right.length && [
64
- ...left
65
- ].sort().every((kind, index)=>kind === [
66
- ...right
458
+ function sameRefSet(a, b) {
459
+ return a.length === b.length && [
460
+ ...a
461
+ ].sort().every((value, index)=>value === [
462
+ ...b
67
463
  ].sort()[index]);
68
464
  }
465
+ function receiptTargetMatchesAction(receipt, action) {
466
+ const observation = receipt.observation;
467
+ const input = action.input;
468
+ if (action.kind === 'hotel.focus' && observation.kind === 'hotel.focus') return observation.hotelRef === input.hotelRef;
469
+ if (action.kind === 'hotel.select' && observation.kind === 'hotel.selection') return observation.hotelRef === input.hotelRef;
470
+ if (action.kind === 'offer.select' && observation.kind === 'offer.selection') return observation.offerRef === input.offerRef && observation.offerVersionRef === input.offerVersionRef;
471
+ if (action.kind === 'offer.check' && observation.kind === 'offer.availability') return observation.offerRef === input.offerRef && observation.checkedOfferVersionRef === input.offerVersionRef;
472
+ if (action.kind === 'checkout.prepare' && observation.kind === 'checkout.handoff') return observation.offerRef === input.offerRef && observation.offerVersionRef === input.offerVersionRef && observation.verifiedOfferRef === input.verifiedOfferRef;
473
+ if (action.kind === 'offers.query' && observation.kind === 'offers.state') return sameRefSet(observation.hotelRefs, input.hotelRefs);
474
+ if (action.kind === 'offers.view.patch' && observation.kind === 'offers.state') return observation.hotelRefs.includes(input.hotelRef);
475
+ if (action.kind === 'offers.compare' && observation.kind === 'offers.state') return input.offerRefs.every((offerRef)=>observation.offerRefs.includes(offerRef));
476
+ if (action.kind === 'order.observe' && observation.kind === 'order.state') return observation.orderRef === input.orderRef;
477
+ return observation.kind === 'gap' || action.kind === 'search.patch' || action.kind === 'search.run' || action.kind === 'results.view.patch';
478
+ }
479
+ function workspaceBoundaryMatches(a, b) {
480
+ 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);
481
+ }
482
+ function workspaceHasOfferVersion(workspace, offerRef, offerVersionRef) {
483
+ return workspace.loadedOffers.some((offer)=>offer.offerRef === offerRef && offer.offerVersionRef === offerVersionRef);
484
+ }
485
+ function verifiedOfferMatches(workspace, input) {
486
+ return workspace.verifiedOffer?.offerRef === input.offerRef && workspace.verifiedOffer.offerVersionRef === input.offerVersionRef && workspace.verifiedOffer.verifiedOfferRef === input.verifiedOfferRef;
487
+ }
488
+ function verifiedOfferUnexpired(workspace, now) {
489
+ return Boolean(workspace.verifiedOffer && isBookingDateTime(workspace.verifiedOffer.expiresAt) && Date.parse(workspace.verifiedOffer.expiresAt) > Date.parse(now));
490
+ }
491
+ function actionHitsCurrentOfferVersion(workspace, action, now) {
492
+ if (action.kind === 'offer.select' || action.kind === 'offer.check') return workspaceHasOfferVersion(workspace, action.input.offerRef, action.input.offerVersionRef);
493
+ if (action.kind === 'checkout.prepare') return workspaceHasOfferVersion(workspace, action.input.offerRef, action.input.offerVersionRef) && verifiedOfferMatches(workspace, action.input) && (now === undefined || verifiedOfferUnexpired(workspace, now));
494
+ return true;
495
+ }
496
+ function shortlistAfterCheckedOffer(previous, current, checkedOfferRef) {
497
+ if (sameActions(previous, current)) return true;
498
+ const removedChecked = previous.filter((ref)=>ref !== checkedOfferRef);
499
+ return removedChecked.length === previous.length - 1 && sameActions(removedChecked, current);
500
+ }
501
+ function loadedOffersUnchanged(previous, current) {
502
+ return sameActions(previous.loadedOffers.map((offer)=>bookingDigest(offer)), current.loadedOffers.map((offer)=>bookingDigest(offer)));
503
+ }
504
+ function changedFactRefsMatch(previousFactRefs, currentFactRefs, changedFactRefs) {
505
+ if (!changedFactRefs.length) return false;
506
+ const before = new Set(previousFactRefs);
507
+ const after = new Set(currentFactRefs);
508
+ const diff = [
509
+ ...previousFactRefs.filter((ref)=>!after.has(ref)),
510
+ ...currentFactRefs.filter((ref)=>!before.has(ref))
511
+ ];
512
+ return sameActions(diff, changedFactRefs);
513
+ }
514
+ function loadedOffersChangedVersionOnly(previous, current, offerRef, currentOfferVersionRef, changedFactRefs) {
515
+ if (previous.loadedOffers.length !== current.loadedOffers.length) return false;
516
+ const previousChecked = previous.loadedOffers.find((offer)=>offer.offerRef === offerRef);
517
+ const currentChecked = current.loadedOffers.find((offer)=>offer.offerRef === offerRef);
518
+ if (!previousChecked || !currentChecked || previousChecked.hotelRef !== currentChecked.hotelRef || currentChecked.offerVersionRef !== currentOfferVersionRef || previousChecked.offerVersionRef === currentChecked.offerVersionRef) return false;
519
+ if (previousChecked.evidenceLevel !== currentChecked.evidenceLevel) return false;
520
+ if (!changedFactRefsMatch(previousChecked.factRefs, currentChecked.factRefs, changedFactRefs)) return false;
521
+ const previousOther = previous.loadedOffers.filter((offer)=>offer.offerRef !== offerRef).map((offer)=>bookingDigest(offer));
522
+ const currentOther = current.loadedOffers.filter((offer)=>offer.offerRef !== offerRef).map((offer)=>bookingDigest(offer));
523
+ return sameActions(previousOther, currentOther);
524
+ }
525
+ function availabilityReceiptIsComplete(receipt) {
526
+ return receipt.resultContract.outcome === 'complete' && receipt.resultContract.hardCriteriaMet && receipt.resultContract.gapCodes.length === 0 && receipt.resultContract.blockers.length === 0;
527
+ }
528
+ function offerCheckPostActionMatches(previous, current, action, receipt) {
529
+ const checkedOfferRef = action.input.offerRef;
530
+ const checkedOfferVersionRef = action.input.offerVersionRef;
531
+ const observation = receipt.observation.kind === 'offer.availability' ? receipt.observation : undefined;
532
+ const negative = receipt.status === 'unavailable' || receipt.status === 'no_match';
533
+ if ([
534
+ 'applied',
535
+ 'changed',
536
+ 'unavailable',
537
+ 'no_match'
538
+ ].includes(receipt.status) && !observation) return false;
539
+ if (receipt.status === 'applied' && observation?.available && observation.verifiedOfferRef) {
540
+ if (!availabilityReceiptIsComplete(receipt) || observation.gapCodes?.length) return false;
541
+ if (!loadedOffersUnchanged(previous, current)) return false;
542
+ if (observation.checkedOfferVersionRef !== checkedOfferVersionRef || observation.currentOfferVersionRef !== checkedOfferVersionRef) return false;
543
+ if (current.selectedOfferRef !== checkedOfferRef) return false;
544
+ if (!sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs)) return false;
545
+ 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;
546
+ return true;
547
+ }
548
+ if (receipt.status === 'changed' && observation?.available && observation.currentOfferVersionRef) {
549
+ if (observation.checkedOfferVersionRef !== checkedOfferVersionRef || observation.currentOfferVersionRef === checkedOfferVersionRef || observation.verifiedOfferRef || !observation.changedFactRefs.length) return false;
550
+ if (!loadedOffersChangedVersionOnly(previous, current, checkedOfferRef, observation.currentOfferVersionRef, observation.changedFactRefs)) return false;
551
+ if (current.selectedOfferRef !== checkedOfferRef || current.verifiedOffer !== undefined) return false;
552
+ return sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs);
553
+ }
554
+ if (!loadedOffersUnchanged(previous, current)) return false;
555
+ if (negative) {
556
+ if (current.selectedOfferRef !== undefined || current.verifiedOffer !== undefined) return false;
557
+ if (!shortlistAfterCheckedOffer(previous.shortlistedOfferRefs, current.shortlistedOfferRefs, checkedOfferRef)) return false;
558
+ return !current.shortlistedOfferRefs.includes(checkedOfferRef);
559
+ }
560
+ if (!sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs)) return false;
561
+ if (previous.selectedOfferRef !== current.selectedOfferRef) return false;
562
+ if (bookingDigest(previous.verifiedOffer ?? null) !== bookingDigest(current.verifiedOffer ?? null)) return false;
563
+ return true;
564
+ }
565
+ function offerSelectPostActionMatches(previous, current, action, receipt) {
566
+ if (receipt.status !== 'applied') return current.selectedOfferRef === previous.selectedOfferRef && loadedOffersUnchanged(previous, current);
567
+ if (receipt.observation.kind !== 'offer.selection' || receipt.observation.offerRef !== action.input.offerRef || receipt.observation.offerVersionRef !== action.input.offerVersionRef) return false;
568
+ if (current.selectedOfferRef !== action.input.offerRef) return false;
569
+ return loadedOffersUnchanged(previous, current);
570
+ }
571
+ function workspacePostActionMatches(previous, current, action, receipt) {
572
+ if (!workspaceBoundaryMatches(previous, current)) return false;
573
+ const mutable = {
574
+ 'search.patch': [
575
+ 'searchDraft'
576
+ ],
577
+ 'search.run': [
578
+ 'results',
579
+ 'visibleHotels'
580
+ ],
581
+ 'results.view.patch': [
582
+ 'results'
583
+ ],
584
+ 'hotel.focus': [
585
+ 'focusedHotelRef'
586
+ ],
587
+ 'hotel.select': [
588
+ 'focusedHotelRef'
589
+ ],
590
+ 'offers.query': [
591
+ 'loadedOffers'
592
+ ],
593
+ 'offers.view.patch': [
594
+ 'loadedOffers'
595
+ ],
596
+ 'offer.select': [
597
+ 'selectedOfferRef'
598
+ ],
599
+ 'offer.check': [
600
+ 'selectedOfferRef',
601
+ 'shortlistedOfferRefs',
602
+ 'verifiedOffer',
603
+ 'loadedOffers'
604
+ ]
605
+ };
606
+ if (action.kind === 'offer.select' && !offerSelectPostActionMatches(previous, current, {
607
+ kind: 'offer.select',
608
+ input: {
609
+ offerRef: action.input.offerRef,
610
+ offerVersionRef: action.input.offerVersionRef
611
+ }
612
+ }, receipt)) return false;
613
+ if (action.kind === 'offer.check' && !offerCheckPostActionMatches(previous, current, {
614
+ kind: 'offer.check',
615
+ input: {
616
+ offerRef: action.input.offerRef,
617
+ offerVersionRef: action.input.offerVersionRef
618
+ }
619
+ }, receipt)) return false;
620
+ const allowed = new Set([
621
+ 'revision',
622
+ ...mutable[action.kind] ?? []
623
+ ]);
624
+ const keys = new Set([
625
+ ...Object.keys(previous),
626
+ ...Object.keys(current)
627
+ ]);
628
+ for (const key of keys){
629
+ if (allowed.has(key)) continue;
630
+ if (bookingDigest(previous[key]) !== bookingDigest(current[key])) return false;
631
+ }
632
+ return true;
633
+ }
634
+ function approvalOption(taskId, contextRef, sourceTurnId, presentationRequestKey, blocker, to, deliveryNonce) {
635
+ const base = {
636
+ taskId,
637
+ contextRef,
638
+ sourceTurnId,
639
+ presentationRequestKey,
640
+ approvalId: `approval-${blocker.blockerId}-${to}`,
641
+ deliveryNonce,
642
+ blockerId: blocker.blockerId,
643
+ sourceActionId: blocker.sourceActionId,
644
+ sourceReceiptDigest: blocker.sourceReceiptDigest,
645
+ scope: blocker.scope,
646
+ code: blocker.code,
647
+ criterionPath: blocker.criterionPath,
648
+ valueDigest: blocker.valueDigest,
649
+ from: 'must',
650
+ to,
651
+ approved: true
652
+ };
653
+ return {
654
+ ...base,
655
+ optionDigest: bookingDigest(base)
656
+ };
657
+ }
658
+ function approvalOptionDigest(approval) {
659
+ const { optionDigest: _optionDigest, ...base } = approval;
660
+ return bookingDigest(base);
661
+ }
662
+ function presentationKey(taskId, sourceTurnId, actionId, receiptDigest) {
663
+ return `approval:${taskId}:${sourceTurnId}:${actionId}:${receiptDigest}`;
664
+ }
665
+ function assertApprovalRef(ref, state, action) {
666
+ const approval = state.awaitingApproval?.approval;
667
+ const blocker = state.awaitingApproval?.blocker;
668
+ 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');
669
+ }
69
670
  export class BookingCopilotTaskRuntime {
70
671
  ledger;
71
672
  idFactory;
673
+ contextRefFactory;
72
674
  now;
675
+ approvalTtlMs;
73
676
  constructor(ledger, options = {}){
74
677
  this.ledger = ledger;
75
678
  this.idFactory = options.idFactory ?? ((prefix)=>`${prefix}-${randomUUID()}`);
679
+ this.contextRefFactory = options.contextRefFactory ?? (()=>this.idFactory('context'));
76
680
  this.now = options.now ?? (()=>new Date().toISOString());
681
+ this.approvalTtlMs = options.approvalTtlMs ?? 120_000;
682
+ }
683
+ assertRequestBinding(requestKey, turn, input) {
684
+ assertRequestKey(requestKey);
685
+ assertPrincipal(input.principal);
686
+ if (input.requestKey !== requestKey) throw new Error('request_conflict');
687
+ const existing = this.requestBinding(requestKey);
688
+ if (!existing) return;
689
+ const candidate = this.requestBindingPayload(requestKey, turn, input);
690
+ 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');
691
+ }
692
+ assertTurnBinding(taskId, turn) {
693
+ assertTaskId(taskId);
694
+ 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);
695
+ const observed = row.map(({ payload })=>JSON.parse(payload)).find((candidate)=>candidate.turnId === turn.turnId);
696
+ 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');
697
+ }
698
+ assertApprovalNotConsumed(taskId, approval) {
699
+ assertTaskId(taskId);
700
+ 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);
701
+ for (const row of rows){
702
+ const consumed = JSON.parse(row.payload);
703
+ if (consumed.approval?.approval && bookingDigest(consumed.approval.approval) === bookingDigest(approval)) throw new Error('approval_replayed');
704
+ }
705
+ }
706
+ persistRequestBinding(requestKey, turn, input) {
707
+ assertRequestKey(requestKey);
708
+ assertPrincipal(input.principal);
709
+ if (input.requestKey !== requestKey) throw new Error('request_conflict');
710
+ const run = this.ledger.db.transaction(()=>{
711
+ const state = this.requireTask(turn.taskId);
712
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal');
713
+ assertReplayUpgradeReanchored(state);
714
+ this.appendRequestBindingInTransaction(requestKey, turn, input);
715
+ });
716
+ run.immediate();
717
+ }
718
+ bindIngressTurn(turn, task, turnId) {
719
+ assertSafeRef(turnId);
720
+ const approval = turn.request.approval;
721
+ if (approval && (approval.taskId !== task.taskId || approval.contextRef !== task.contextRef)) {
722
+ throw new Error('invalid_ingress_approval_authority');
723
+ }
724
+ return {
725
+ schemaVersion: 'booking.surface',
726
+ kind: 'user.turn',
727
+ taskId: task.taskId,
728
+ turnId,
729
+ workspace: {
730
+ ...turn.workspace,
731
+ schemaVersion: 'booking.surface',
732
+ contextRef: task.contextRef,
733
+ surface: task.surface,
734
+ capabilities: {
735
+ surface: task.surface,
736
+ allowedActions: [
737
+ ...task.allowedActions
738
+ ]
739
+ }
740
+ },
741
+ request: approval ? {
742
+ text: turn.request.text,
743
+ approval: {
744
+ ...approval
745
+ }
746
+ } : {
747
+ text: turn.request.text
748
+ }
749
+ };
77
750
  }
78
- startTask(turn) {
79
- const validation = validateBookingCopilotTurnV1(turn);
80
- if (!validation.ok || turn.kind !== 'user.turn') throw new Error(`invalid_planner_turn:${validationMessage(validation)}`);
81
- const taskId = turn.taskId ?? this.idFactory('task');
751
+ startTask(turn, requestBinding) {
752
+ const validation = validateBookingSurface(turn);
753
+ if (!validation.ok) throw new Error(`invalid_planner_turn:${errorText(validation)}`);
754
+ if (turn.kind === 'user.turn.ingress') throw new Error('ingress_binding_required');
755
+ if (turn.kind === 'action.receipt.continuation') throw new Error('receipt_continuation_requires_continue');
756
+ const userTurn = turn;
757
+ if (!userTurn.taskId) throw new Error('invalid_planner_turn:task_id_required');
758
+ const taskId = userTurn.taskId;
82
759
  assertTaskId(taskId);
83
- const requestDigest = digest(turn.request.text);
84
- const apply = this.ledger.db.transaction(()=>{
760
+ const turnId = userTurn.turnId;
761
+ if (!turnId) throw new Error('invalid_planner_turn:turn_id_required');
762
+ assertSafeRef(turnId);
763
+ const contextRef = userTurn.workspace.contextRef;
764
+ const surface = userTurn.workspace.surface;
765
+ const revision = userTurn.workspace.revision;
766
+ const allowedActions = [
767
+ ...userTurn.workspace.capabilities.allowedActions
768
+ ];
769
+ const requestDigest = bookingTurnDigest(turn);
770
+ const boundWorkspace = userTurn.workspace;
771
+ assertWorkspaceLoadedOfferRefsUnique(boundWorkspace);
772
+ const workspaceDigest = bookingWorkspaceDigest(boundWorkspace);
773
+ const workspaceSemanticDigest = bookingWorkspaceSemanticDigest(boundWorkspace);
774
+ const availability = createAvailabilityPolicy(boundWorkspace);
775
+ const run = this.ledger.db.transaction(()=>{
85
776
  const existing = this.resumeTask(taskId);
86
777
  if (existing) {
87
- if (existing.contextRef !== turn.workspace.contextRef) throw new Error('task_conflict:context_mismatch');
88
- if (existing.surface !== turn.workspace.surface) throw new Error('task_conflict:surface_mismatch');
89
- if (existing.revision !== turn.workspace.revision) throw new Error('task_conflict:revision_mismatch');
90
- if (!sameActionAllowlist(existing.allowedActions, turn.workspace.capabilities.allowedActions)) {
91
- throw new Error('task_conflict:capability_mismatch');
778
+ if (existing.contextRef !== contextRef) throw new Error('task_conflict:context_mismatch');
779
+ if (existing.surface !== surface) throw new Error('task_conflict:surface_mismatch');
780
+ if (existing.revision !== revision && existing.phase !== 'waiting_receipt' && !existing.replayUpgradeRequired) throw new Error('task_conflict:revision_mismatch');
781
+ if (!sameActions(existing.allowedActions, allowedActions)) throw new Error('task_conflict:capability_mismatch');
782
+ if (existing.phase === 'terminal' || existing.phase === 'error') throw new Error('task_terminal');
783
+ if (turnId && existing.lastTurnId === turnId) {
784
+ this.assertTurnBinding(taskId, userTurn);
785
+ if (requestBinding) {
786
+ assertReplayUpgradeReanchored(existing);
787
+ this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding);
788
+ }
789
+ return existing;
92
790
  }
93
791
  if (existing.phase === 'waiting_receipt') throw new Error('receipt_required');
94
- this.observeUserTurn(taskId, existing.contextRef, requestDigest, existing.userTurnCount + 1);
792
+ const approval = userTurn && 'approval' in userTurn.request ? userTurn.request.approval : undefined;
793
+ let approvalState;
794
+ if (approval) {
795
+ assertReplayUpgradeReanchored(existing);
796
+ const awaiting = existing.awaitingApproval;
797
+ if (!awaiting) throw new Error('approval_not_awaiting');
798
+ if (!awaiting.optionsEmitted) throw new Error('approval_not_presented');
799
+ if (Date.parse(awaiting.expiresAt) <= Date.parse(this.now())) throw new Error('approval_expired');
800
+ const checked = validateApprovalAgainstBlocker(approval, awaiting.blocker);
801
+ const option = awaiting.options.find((candidate)=>candidate.optionDigest === approval.optionDigest && bookingDigest(candidate) === bookingDigest(approval));
802
+ 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');
803
+ if (awaiting.approval && bookingDigest(awaiting.approval) === bookingDigest(approval)) {
804
+ throw new Error('approval_replayed');
805
+ }
806
+ approvalState = {
807
+ ...awaiting,
808
+ approval
809
+ };
810
+ this.appendApproval(taskId, existing.contextRef, approvalState);
811
+ } else if (existing.awaitingApproval) {
812
+ throw new Error('approval_required');
813
+ }
814
+ this.appendTurn(taskId, existing.contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, existing.userTurnCount + 1, turnId, boundWorkspace);
815
+ if (requestBinding) this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding);
95
816
  return this.requireTask(taskId);
96
817
  }
97
- const foreign = this.ledger.db.prepare("SELECT 1 AS present FROM events WHERE tenant_id = ? AND run_id = ? AND kind LIKE 'booking.copilot.v2.%' LIMIT 1").get(this.ledger.tenant, taskId);
98
- if (foreign) throw new Error('task_conflict:protocol_version');
99
- const payload = {
818
+ if (userTurn?.request.approval) throw new Error('approval_not_awaiting');
819
+ const started = {
100
820
  schema: LEDGER_SCHEMA,
101
821
  taskId,
102
- contextRef: turn.workspace.contextRef,
103
- surface: turn.workspace.surface,
104
- revision: turn.workspace.revision,
105
- allowedActions: [
106
- ...turn.workspace.capabilities.allowedActions
107
- ]
822
+ contextRef,
823
+ surface,
824
+ revision,
825
+ allowedActions,
826
+ workspaceDigest,
827
+ workspaceSemanticDigest,
828
+ workspace: boundWorkspace,
829
+ availability,
830
+ availabilityDigest: bookingDigest(availability)
108
831
  };
109
- this.appendLedgerEvent(TASK_STARTED, taskId, payload, `booking-copilot:task:${taskId}`);
110
- this.observeUserTurn(taskId, turn.workspace.contextRef, requestDigest, 1);
832
+ this.append(STARTED, taskId, started, `booking-copilot:task:${taskId}`);
833
+ this.appendTurn(taskId, contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, 1, turnId, boundWorkspace);
834
+ if (requestBinding) this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding);
111
835
  return this.requireTask(taskId);
112
836
  });
113
- return apply.immediate();
837
+ return run.immediate();
114
838
  }
115
839
  resumeTask(taskId) {
116
840
  assertTaskId(taskId);
117
- const rows = this.readTaskRows(taskId);
118
- if (rows.length === 0) return null;
841
+ const rows = this.rows(taskId);
842
+ if (!rows.length) return null;
119
843
  let state = null;
844
+ let skipLegacyUpgradeTail = false;
845
+ const replayWorkspaceDigests = new Map();
846
+ const consumed = {
847
+ events: new Map(),
848
+ actions: new Map(),
849
+ pendingBatchKeys: new Set(),
850
+ pendingBatchOpen: false
851
+ };
852
+ const suppressedDecisionRequestKeys = new Set();
853
+ const suppressedRequestBindingKeys = new Set();
854
+ const suppressedReceiptActionIds = new Set();
855
+ const suppressedApprovalTargetActionIds = new Set();
120
856
  for (const row of rows){
121
857
  const payload = JSON.parse(row.payload);
122
858
  if (payload.schema !== LEDGER_SCHEMA || payload.taskId !== taskId) throw new Error(`ledger_corrupt:${taskId}:seq=${row.seq}`);
123
- if (row.kind === TASK_STARTED) {
124
- const started = payload;
859
+ if (row.kind === STARTED) {
860
+ skipLegacyUpgradeTail = false;
125
861
  if (state) throw new Error(`ledger_corrupt:${taskId}:duplicate_start`);
862
+ const started = payload;
863
+ let startedWorkspace;
864
+ try {
865
+ startedWorkspace = normalizeWorkspaceForReplay(started.workspace);
866
+ } catch {
867
+ throw new Error(`ledger_corrupt:${taskId}:start_workspace`);
868
+ }
869
+ let initialAvailability;
870
+ try {
871
+ initialAvailability = createAvailabilityPolicy(startedWorkspace);
872
+ } catch {
873
+ throw new Error(`ledger_corrupt:${taskId}:start_availability`);
874
+ }
875
+ try {
876
+ assertWorkspaceLoadedOfferRefsUnique(startedWorkspace);
877
+ } catch {
878
+ throw new Error(`ledger_corrupt:${taskId}:start_workspace`);
879
+ }
880
+ rememberReplayWorkspaceDigest(replayWorkspaceDigests, started.workspaceDigest, started.workspace, startedWorkspace);
881
+ const startedAvailability = normalizeAvailabilityPolicyForReplay(started.availability, startedWorkspace, undefined, undefined, replayWorkspaceDigests);
882
+ 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`);
126
883
  state = {
127
- schemaVersion: 'booking.surface.v1',
884
+ schemaVersion: 'booking.surface',
128
885
  taskId,
129
886
  contextRef: started.contextRef,
130
887
  surface: started.surface,
@@ -133,217 +890,895 @@ export class BookingCopilotTaskRuntime {
133
890
  ...started.allowedActions
134
891
  ],
135
892
  userTurnCount: 0,
136
- phase: 'planning',
137
- lastSequence: 0
893
+ operationCount: 0,
894
+ phase: startedAvailability.terminal ? 'terminal' : 'planning',
895
+ lastSequence: 0,
896
+ workspaceDigest: bookingWorkspaceDigest(startedWorkspace),
897
+ workspaceSemanticDigest: bookingWorkspaceSemanticDigest(startedWorkspace),
898
+ workspaceSnapshot: startedWorkspace,
899
+ availability: startedAvailability
138
900
  };
139
- continue;
140
- }
141
- if (!state) throw new Error(`ledger_corrupt:${taskId}:event_before_start`);
142
- if (payload.contextRef !== state.contextRef) throw new Error(`ledger_corrupt:${taskId}:context_drift`);
143
- if (row.kind === USER_TURN_OBSERVED) {
144
- const observed = payload;
145
- state.userTurnCount += 1;
146
- state.lastUserTurnDigest = observed.requestDigest;
147
- } else if (row.kind === ACTION_ISSUED) {
148
- const issued = payload;
149
- if (issued.action.actionDigest !== checkpointDigest(issued.action)) throw new Error(`ledger_corrupt:${taskId}:action`);
150
- if (state.pendingAction) throw new Error(`ledger_corrupt:${taskId}:parallel_actions`);
151
- state.pendingAction = issued.action;
152
- state.phase = 'waiting_receipt';
153
- state.lastSequence = Math.max(state.lastSequence, issued.action.sequence);
154
- } else if (row.kind === RECEIPT_OBSERVED) {
155
- const observed = payload;
156
- if (observed.receiptDigest !== digest(observed.receipt) || !validateActionReceiptV1(observed.receipt).ok) {
157
- throw new Error(`ledger_corrupt:${taskId}:receipt`);
158
- }
159
- if (!state.pendingAction || state.pendingAction.actionId !== observed.receipt.actionId) {
160
- throw new Error(`ledger_corrupt:${taskId}:orphan_receipt`);
901
+ } else {
902
+ if (!state) throw new Error(`ledger_corrupt:${taskId}:event_before_start`);
903
+ if (payload.contextRef !== state.contextRef) throw new Error(`ledger_corrupt:${taskId}:context_drift`);
904
+ if (row.kind === REQUEST_BINDING) {
905
+ if (skipLegacyUpgradeTail) {
906
+ const binding = payload;
907
+ if (typeof binding.requestKey === 'string') suppressedRequestBindingKeys.add(binding.requestKey);
908
+ continue;
909
+ }
910
+ const binding = payload;
911
+ const turnRows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ?`).all(this.ledger.tenant, taskId, TURN);
912
+ const rawBoundTurn = turnRows.map(({ payload })=>JSON.parse(payload)).find((candidate)=>candidate.turnId === binding.turnId);
913
+ let boundTurn;
914
+ try {
915
+ boundTurn = rawBoundTurn ? {
916
+ ...rawBoundTurn,
917
+ workspace: normalizeWorkspaceForReplay(rawBoundTurn.workspace)
918
+ } : undefined;
919
+ } catch {
920
+ throw new Error(`ledger_corrupt:${taskId}:request_binding`);
921
+ }
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) || ![
923
+ 'tenant',
924
+ 'customer_portal',
925
+ 'storefront',
926
+ 'payment_link'
927
+ ].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`);
928
+ } else if (row.kind === TURN) {
929
+ skipLegacyUpgradeTail = false;
930
+ const t = payload;
931
+ if (Object.prototype.hasOwnProperty.call(payload, 'approval')) throw new Error(`ledger_corrupt:${taskId}:turn_approval`);
932
+ let turnWorkspace;
933
+ try {
934
+ turnWorkspace = normalizeWorkspaceForReplay(t.workspace);
935
+ } catch {
936
+ throw new Error(`ledger_corrupt:${taskId}:turn_workspace`);
937
+ }
938
+ try {
939
+ assertWorkspaceLoadedOfferRefsUnique(turnWorkspace);
940
+ } catch {
941
+ throw new Error(`ledger_corrupt:${taskId}:turn_workspace`);
942
+ }
943
+ 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`);
944
+ rememberReplayWorkspaceDigest(replayWorkspaceDigests, t.workspaceDigest, t.workspace, turnWorkspace);
945
+ state.userTurnCount++;
946
+ state.lastTurnId = t.turnId;
947
+ state.workspaceDigest = bookingWorkspaceDigest(turnWorkspace);
948
+ state.workspaceSemanticDigest = bookingWorkspaceSemanticDigest(turnWorkspace);
949
+ state.workspaceSnapshot = turnWorkspace;
950
+ state.revision = turnWorkspace.revision;
951
+ delete state.replayUpgradeRequired;
952
+ } else if (row.kind === APPROVAL_GRANTED) {
953
+ if (skipLegacyUpgradeTail) continue;
954
+ const granted = payload;
955
+ 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`);
956
+ state.awaitingApproval = granted.approval;
957
+ state.phase = 'input_required';
958
+ } else if (row.kind === APPROVAL_OFFERED) {
959
+ if (skipLegacyUpgradeTail) continue;
960
+ const offered = payload;
961
+ if (!offered.approval || offered.approvalDigest !== bookingDigest(offered.approval) || !state.awaitingApproval || bookingDigest(offered.approval) !== bookingDigest(state.awaitingApproval)) throw new Error(`ledger_corrupt:${taskId}:approval_offered`);
962
+ state.phase = 'input_required';
963
+ } else if (row.kind === ACTION) {
964
+ const actionPayload = payload;
965
+ if (skipLegacyUpgradeTail) {
966
+ try {
967
+ consumeSkippedActionOrdinal(state, actionPayload, consumed);
968
+ } catch {
969
+ throw new Error(`ledger_corrupt:${taskId}:action`);
970
+ }
971
+ continue;
972
+ }
973
+ const rawAction = actionPayload.action;
974
+ const operationCount = actionPayload.operationCount ?? state.operationCount + 1;
975
+ const { actionDigest: _rawActionDigest, ...rawActionBase } = rawAction;
976
+ 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`);
977
+ let a;
978
+ try {
979
+ a = normalizeActionCheckpointForReplay(rawAction, state.workspaceSnapshot);
980
+ } catch (error) {
981
+ if (error instanceof Error && error.message.startsWith('booking_replan_required:')) {
982
+ state = replanAfterReplayUpgradeGap(state);
983
+ try {
984
+ consumeSkippedActionOrdinal(state, actionPayload, consumed);
985
+ } catch {
986
+ throw new Error(`ledger_corrupt:${taskId}:action`);
987
+ }
988
+ skipLegacyUpgradeTail = true;
989
+ continue;
990
+ }
991
+ throw error;
992
+ }
993
+ const { actionDigest: _actionDigest, ...actionBase } = a;
994
+ if (!actionPayload.availability || actionPayload.availabilityDigest !== bookingDigest(actionPayload.availability)) throw new Error(`ledger_corrupt:${taskId}:action`);
995
+ let actionAvailability;
996
+ try {
997
+ actionAvailability = normalizeAvailabilityPolicyForReplay(actionPayload.availability, state.workspaceSnapshot, a, state.availability, replayWorkspaceDigests);
998
+ } catch (error) {
999
+ if (error instanceof Error && error.message.startsWith('booking_replan_required:')) {
1000
+ state = replanAfterReplayUpgradeGap(state);
1001
+ try {
1002
+ consumeSkippedActionOrdinal(state, actionPayload, consumed);
1003
+ } catch {
1004
+ throw new Error(`ledger_corrupt:${taskId}:action`);
1005
+ }
1006
+ skipLegacyUpgradeTail = true;
1007
+ continue;
1008
+ }
1009
+ throw error;
1010
+ }
1011
+ 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`);
1012
+ if (a.relaxationApprovalRef) assertApprovalRef(a.relaxationApprovalRef, state, a);
1013
+ if (state.pendingAction) throw new Error(`ledger_corrupt:${taskId}:parallel_actions`);
1014
+ let expectedAvailability;
1015
+ try {
1016
+ expectedAvailability = reduceAvailabilityAction(state.availability, state.workspaceSnapshot, a);
1017
+ } catch {
1018
+ throw new Error(`ledger_corrupt:${taskId}:action_transition`);
1019
+ }
1020
+ if (bookingDigest(expectedAvailability) !== bookingDigest(actionAvailability)) throw new Error(`ledger_corrupt:${taskId}:action_transition`);
1021
+ state.operationCount = operationCount;
1022
+ state.availability = actionAvailability;
1023
+ state.pendingAction = a;
1024
+ state.phase = 'waiting_receipt';
1025
+ state.lastSequence = Math.max(state.lastSequence, a.sequence);
1026
+ {
1027
+ rememberConsumedActionIdentity(consumed, a);
1028
+ }
1029
+ delete state.awaitingApproval;
1030
+ } else if (row.kind === RECEIPT) {
1031
+ if (skipLegacyUpgradeTail) {
1032
+ const r = payload;
1033
+ if (typeof r.receipt?.actionId === 'string') suppressedReceiptActionIds.add(r.receipt.actionId);
1034
+ continue;
1035
+ }
1036
+ const r = payload;
1037
+ const operationCount = r.operationCount ?? state.operationCount;
1038
+ if (!state.pendingAction || state.pendingAction.actionId !== r.receipt.actionId) throw new Error(`ledger_corrupt:${taskId}:orphan_receipt`);
1039
+ let receiptWorkspace;
1040
+ try {
1041
+ receiptWorkspace = normalizeWorkspaceForReplay(r.workspace);
1042
+ } catch {
1043
+ throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`);
1044
+ }
1045
+ try {
1046
+ assertWorkspaceLoadedOfferRefsUnique(receiptWorkspace);
1047
+ } catch {
1048
+ throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`);
1049
+ }
1050
+ 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`);
1051
+ rememberReplayWorkspaceDigest(replayWorkspaceDigests, r.workspaceDigest, r.workspace, receiptWorkspace);
1052
+ 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`);
1053
+ if (!r.availability || r.availabilityDigest !== bookingDigest(r.availability)) throw new Error(`ledger_corrupt:${taskId}:receipt`);
1054
+ let receipt;
1055
+ try {
1056
+ receipt = normalizeReceiptForReplay(r.receipt, state.pendingAction, receiptWorkspace);
1057
+ } catch (error) {
1058
+ if (error instanceof Error && error.message.startsWith('booking_replan_required:')) {
1059
+ clearPendingBatch(consumed);
1060
+ state = replanAfterReplayUpgradeGap(state, receiptWorkspace, r.receipt.revision);
1061
+ skipLegacyUpgradeTail = true;
1062
+ continue;
1063
+ }
1064
+ throw error;
1065
+ }
1066
+ let receiptAvailability;
1067
+ try {
1068
+ receiptAvailability = normalizeAvailabilityPolicyForReplay(r.availability, receiptWorkspace, state.pendingAction, state.availability, replayWorkspaceDigests, receipt);
1069
+ } catch (error) {
1070
+ if (error instanceof Error && error.message.startsWith('booking_replan_required:')) {
1071
+ clearPendingBatch(consumed);
1072
+ state = replanAfterReplayUpgradeGap(state, receiptWorkspace, r.receipt.revision);
1073
+ skipLegacyUpgradeTail = true;
1074
+ continue;
1075
+ }
1076
+ throw error;
1077
+ }
1078
+ 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`);
1079
+ 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`);
1080
+ assertCanonicalReceipt(receipt);
1081
+ 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`);
1082
+ let expectedAvailability;
1083
+ try {
1084
+ expectedAvailability = reduceAvailabilityReceipt(state.availability, receiptWorkspace, receipt, state.pendingAction);
1085
+ } catch {
1086
+ throw new Error(`ledger_corrupt:${taskId}:receipt_transition`);
1087
+ }
1088
+ if (bookingDigest(expectedAvailability) !== bookingDigest(receiptAvailability)) throw new Error(`ledger_corrupt:${taskId}:receipt_transition`);
1089
+ state.lastReceipt = receipt;
1090
+ state.revision = receipt.revision;
1091
+ state.workspaceDigest = bookingWorkspaceDigest(receiptWorkspace);
1092
+ state.workspaceSemanticDigest = bookingWorkspaceSemanticDigest(receiptWorkspace);
1093
+ state.workspaceSnapshot = receiptWorkspace;
1094
+ state.availability = receiptAvailability;
1095
+ delete state.pendingAction;
1096
+ state.phase = receiptAvailability.terminal || state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS ? 'terminal' : 'planning';
1097
+ clearPendingBatch(consumed);
1098
+ delete state.awaitingApproval;
1099
+ } else if (row.kind === APPROVAL_CONSUMED) {
1100
+ if (skipLegacyUpgradeTail) {
1101
+ const consumed = payload;
1102
+ if (typeof consumed.ref?.targetActionId === 'string') suppressedApprovalTargetActionIds.add(consumed.ref.targetActionId);
1103
+ continue;
1104
+ }
1105
+ const consumed = payload;
1106
+ 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`);
1107
+ } else if (row.kind === DECISION_BATCH) {
1108
+ const batch = payload;
1109
+ if (skipLegacyUpgradeTail) {
1110
+ try {
1111
+ consumeSkippedEventSequence(state, batch, consumed);
1112
+ } catch {
1113
+ throw new Error(`ledger_corrupt:${taskId}:decision_batch`);
1114
+ }
1115
+ if (typeof batch.requestKey === 'string') suppressedDecisionRequestKeys.add(batch.requestKey);
1116
+ continue;
1117
+ }
1118
+ if (!batch.requestKey || !Array.isArray(batch.events) || batch.events.some((event)=>event.taskId !== taskId)) throw new Error(`ledger_corrupt:${taskId}:decision_batch`);
1119
+ let batchEvents;
1120
+ try {
1121
+ batchEvents = normalizeDecisionBatchEnvelopeForReplay(batch, consumed, true);
1122
+ } catch {
1123
+ throw new Error(`ledger_corrupt:${taskId}:decision_batch`);
1124
+ }
1125
+ for (const event of batchEvents){
1126
+ if (event.kind !== 'question') continue;
1127
+ const awaiting = state.awaitingApproval;
1128
+ 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`);
1129
+ awaiting.optionsEmitted = true;
1130
+ state.phase = 'input_required';
1131
+ }
1132
+ } else if (row.kind === EVENT) {
1133
+ const event = payload;
1134
+ if (skipLegacyUpgradeTail) {
1135
+ try {
1136
+ consumeSkippedEventSequence(state, event, consumed);
1137
+ } catch {
1138
+ throw new Error(`ledger_corrupt:${taskId}:event`);
1139
+ }
1140
+ continue;
1141
+ }
1142
+ state.lastSequence = Math.max(state.lastSequence, event.sequence);
1143
+ {
1144
+ rememberConsumedEventIdentity(consumed, event);
1145
+ }
1146
+ if (event.eventKind === 'status') state.phase = event.status === 'submitted' ? 'submitted' : event.status === 'working' ? 'working' : event.status === 'waiting_receipt' ? 'waiting_receipt' : 'input_required';
1147
+ else if (event.eventKind === 'question') state.phase = 'input_required';
1148
+ else if (event.eventKind === 'terminal') state.phase = 'terminal';
1149
+ else if (event.eventKind === 'error') state.phase = 'error';
161
1150
  }
162
- state.lastReceipt = observed.receipt;
163
- state.revision = observed.receipt.revision;
164
- delete state.pendingAction;
165
- state.phase = 'planning';
166
- } else if (row.kind === EVENT_EMITTED) {
167
- const emitted = payload;
168
- state.lastSequence = Math.max(state.lastSequence, emitted.sequence);
169
1151
  }
170
1152
  }
1153
+ if (state) {
1154
+ if (suppressedDecisionRequestKeys.size) state.legacySuppressedDecisionRequestKeys = [
1155
+ ...suppressedDecisionRequestKeys
1156
+ ];
1157
+ if (suppressedRequestBindingKeys.size) state.legacySuppressedRequestBindingKeys = [
1158
+ ...suppressedRequestBindingKeys
1159
+ ];
1160
+ if (suppressedReceiptActionIds.size) state.legacySuppressedReceiptActionIds = [
1161
+ ...suppressedReceiptActionIds
1162
+ ];
1163
+ if (suppressedApprovalTargetActionIds.size) state.legacySuppressedApprovalTargetActionIds = [
1164
+ ...suppressedApprovalTargetActionIds
1165
+ ];
1166
+ }
171
1167
  return state;
172
1168
  }
173
- issueOperation(taskId, action) {
174
- const validation = validateBookingReadActionV1(action);
175
- if (!validation.ok) throw new Error(`invalid_action:${validation.errors.join('; ')}`);
176
- assertSafeFactRefs(action);
177
- const apply = this.ledger.db.transaction(()=>{
1169
+ withReceiptDigest(receipt) {
1170
+ const sourceDigest = receiptSourceDigest(receipt);
1171
+ return {
1172
+ ...receipt,
1173
+ resultContract: {
1174
+ ...receipt.resultContract,
1175
+ blockers: receipt.resultContract.blockers.map((b)=>({
1176
+ ...b,
1177
+ sourceReceiptDigest: sourceDigest
1178
+ }))
1179
+ }
1180
+ };
1181
+ }
1182
+ receiptDigest(receipt) {
1183
+ return canonicalReceiptDigest(receipt);
1184
+ }
1185
+ approvalQuestion(taskId) {
1186
+ const task = this.requireTask(taskId);
1187
+ const awaiting = task.awaitingApproval;
1188
+ if (!awaiting) throw new Error('approval_not_awaiting');
1189
+ return {
1190
+ kind: 'question',
1191
+ question: {
1192
+ questionId: `question-${awaiting.blocker.blockerId}`,
1193
+ prompt: `Relax required criterion ${awaiting.blocker.criterionPath}?`,
1194
+ missingFields: [
1195
+ awaiting.blocker.criterionPath
1196
+ ],
1197
+ type: 'relaxation_approval_required',
1198
+ blocker: awaiting.blocker,
1199
+ approvalOptions: awaiting.options.map((approval)=>({
1200
+ approval
1201
+ }))
1202
+ }
1203
+ };
1204
+ }
1205
+ approvalPresentationRequestKey(taskId) {
1206
+ const task = this.requireTask(taskId);
1207
+ const awaiting = task.awaitingApproval;
1208
+ if (!awaiting) throw new Error('approval_not_awaiting');
1209
+ return awaiting.presentationRequestKey;
1210
+ }
1211
+ commitDecisionBatch(taskId, requestKey, decisions) {
1212
+ return this.applyDecisionBatch(taskId, requestKey, decisions);
1213
+ }
1214
+ readDecisionBatch(taskId, requestKey) {
1215
+ const state = this.requireTask(taskId);
1216
+ if (state.legacySuppressedDecisionRequestKeys?.includes(requestKey)) throw new Error('request_key_invalidated');
1217
+ 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);
1218
+ for (const row of rows){
1219
+ const payload = JSON.parse(row.payload);
1220
+ if (payload.requestKey !== requestKey) continue;
1221
+ 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`);
1222
+ const events = this.normalizeDecisionBatchFromDurableRows(taskId, payload.events, row.seq);
1223
+ if (!events) throw new Error(`ledger_corrupt:${taskId}:decision_batch`);
1224
+ for (const event of events){
1225
+ if (event.kind !== 'question') continue;
1226
+ const awaiting = state.awaitingApproval;
1227
+ 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`);
1228
+ }
1229
+ return events;
1230
+ }
1231
+ return null;
1232
+ }
1233
+ terminalDecisionBatch(taskId, requestKey) {
1234
+ assertInternalDecisionKey(requestKey);
1235
+ const prior = this.readDecisionBatch(taskId, requestKey);
1236
+ if (prior) return prior;
1237
+ const state = this.requireTask(taskId);
1238
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal');
1239
+ assertReplayUpgradeReanchored(state);
1240
+ const run = this.ledger.db.transaction(()=>{
178
1241
  const state = this.requireTask(taskId);
179
- const reasonDigest = digest(action.reason);
180
- const inputDigest = digest(action.input);
181
- if (state.pendingAction) {
182
- if (state.pendingAction.actionId !== action.actionId) throw new Error('receipt_required');
183
- if (!actionMatchesCheckpoint(action, state.pendingAction)) throw new Error('action_conflict');
184
- return {
185
- schemaVersion: 'booking.surface.v1',
186
- eventId: state.pendingAction.eventId,
187
- taskId,
188
- contextRef: state.pendingAction.contextRef,
189
- sequence: state.pendingAction.sequence,
190
- emittedAt: state.pendingAction.emittedAt,
191
- kind: 'operation',
192
- action
193
- };
1242
+ if (!taskHasTerminalBudget(state) || state.phase !== 'terminal') throw new Error('task_not_terminal');
1243
+ assertReplayUpgradeReanchored(state);
1244
+ const event = this.terminalEvent(state);
1245
+ this.appendTerminalEvent(taskId, event);
1246
+ this.append(DECISION_BATCH, taskId, {
1247
+ schema: LEDGER_SCHEMA,
1248
+ taskId,
1249
+ contextRef: state.contextRef,
1250
+ requestKey,
1251
+ events: [
1252
+ event
1253
+ ]
1254
+ }, `booking-copilot:decision-batch:${taskId}:${requestKey}`);
1255
+ return [
1256
+ event
1257
+ ];
1258
+ });
1259
+ return run.immediate();
1260
+ }
1261
+ applyDecisionBatch(taskId, requestKey, decisions, includeSubmitted = false) {
1262
+ assertInternalDecisionKey(requestKey);
1263
+ const prior = this.readDecisionBatch(taskId, requestKey);
1264
+ if (prior) return prior;
1265
+ const current = this.requireTask(taskId);
1266
+ if (current.phase === 'terminal' || current.phase === 'error') throw new Error('task_terminal');
1267
+ assertReplayUpgradeReanchored(current);
1268
+ assertDecisionBatchFinality(decisions);
1269
+ assertAvailabilityLiveness(current, decisions);
1270
+ if (current.availability.recoveryStarted && !current.availability.terminal && decisions.some((decision)=>decision.kind === 'terminal' || decision.kind === 'error')) throw new Error('availability_terminal_policy_owned');
1271
+ for (const decision of decisions){
1272
+ if (decision.kind === 'operation') {
1273
+ const checked = validateBookingReadAction(decision.action);
1274
+ if (!checked.ok) throw new Error(`invalid_action:${errorText(checked)}`);
1275
+ }
1276
+ }
1277
+ const run = this.ledger.db.transaction(()=>{
1278
+ const events = [];
1279
+ const before = this.requireTask(taskId);
1280
+ assertReplayUpgradeReanchored(before);
1281
+ assertAvailabilityLiveness(before, decisions);
1282
+ if (before.availability.recoveryStarted && !before.availability.terminal && decisions.some((decision)=>decision.kind === 'terminal' || decision.kind === 'error')) throw new Error('availability_terminal_policy_owned');
1283
+ for (const decision of decisions){
1284
+ if (decision.kind !== 'question') continue;
1285
+ const awaiting = before.awaitingApproval;
1286
+ 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');
1287
+ this.ensureApprovalOffered(taskId, before.contextRef, awaiting, requestKey);
1288
+ }
1289
+ if (includeSubmitted) events.push(this.emitEventInTransaction(taskId, {
1290
+ kind: 'status',
1291
+ status: 'submitted'
1292
+ }));
1293
+ events.push(this.emitEventInTransaction(taskId, {
1294
+ kind: 'status',
1295
+ status: 'working'
1296
+ }));
1297
+ for (const decision of decisions){
1298
+ if (decision.kind === 'operation') {
1299
+ events.push(this.issueOperationInTransaction(taskId, decision.action));
1300
+ events.push(this.emitEventInTransaction(taskId, {
1301
+ kind: 'status',
1302
+ status: 'waiting_receipt'
1303
+ }));
1304
+ } else events.push(this.emitEventInTransaction(taskId, decision));
194
1305
  }
195
- const historicalReceipt = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ?`).all(this.ledger.tenant, taskId, RECEIPT_OBSERVED);
196
- if (historicalReceipt.some((row)=>JSON.parse(row.payload).receipt?.actionId === action.actionId)) throw new Error('action_already_receipted');
197
- if (state.lastReceipt?.actionId === action.actionId) throw new Error('action_already_receipted');
198
- if (action.contextRef !== state.contextRef) throw new Error('context_mismatch');
199
- if (!new Set(state.allowedActions).has(action.kind)) throw new Error('unsupported_action');
200
- if (action.expectedRevision !== state.revision) throw new Error('stale_revision');
201
- const event = {
202
- schemaVersion: 'booking.surface.v1',
203
- eventId: this.idFactory('event'),
1306
+ const state = this.requireTask(taskId);
1307
+ this.append(DECISION_BATCH, taskId, {
1308
+ schema: LEDGER_SCHEMA,
204
1309
  taskId,
205
1310
  contextRef: state.contextRef,
206
- sequence: state.lastSequence + 1,
207
- emittedAt: this.now(),
208
- kind: 'operation',
209
- action
210
- };
211
- const eventValidation = validateBookingSurfaceEventV1(event);
212
- if (!eventValidation.ok) throw new Error(`invalid_operation_event:${eventValidation.errors.join('; ')}`);
213
- const checkpointBase = {
214
- actionId: action.actionId,
215
- kind: action.kind,
216
- contextRef: action.contextRef,
217
- expectedRevision: action.expectedRevision,
218
- factRefs: [
219
- ...action.factRefs
220
- ],
221
- reasonDigest,
222
- inputDigest,
223
- eventId: event.eventId,
224
- sequence: event.sequence,
225
- emittedAt: event.emittedAt
1311
+ requestKey,
1312
+ events
1313
+ }, `booking-copilot:decision-batch:${taskId}:${requestKey}`);
1314
+ return events;
1315
+ });
1316
+ return run.immediate();
1317
+ }
1318
+ issueOperation(taskId, candidate) {
1319
+ const validation = validateBookingReadAction(candidate);
1320
+ if (!validation.ok) throw new Error(`invalid_action:${errorText(validation)}`);
1321
+ assertTaskId(taskId);
1322
+ assertSafeRef(candidate.actionId);
1323
+ candidate.factRefs.forEach(assertSafeRef);
1324
+ return this.ledger.db.transaction(()=>this.issueOperationInTransaction(taskId, candidate)).immediate();
1325
+ }
1326
+ issueOperationInTransaction(taskId, candidate) {
1327
+ assertSafeRef(candidate.actionId);
1328
+ candidate.factRefs.forEach(assertSafeRef);
1329
+ const state = this.requireTask(taskId);
1330
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal');
1331
+ assertReplayUpgradeReanchored(state);
1332
+ if (state.pendingAction) {
1333
+ if (state.pendingAction.actionId !== candidate.actionId) throw new Error('receipt_required');
1334
+ if (!matchesCheckpoint(candidate, state.pendingAction)) throw new Error('action_conflict');
1335
+ return this.operationEvent(taskId, state.pendingAction, candidate);
1336
+ }
1337
+ if (candidate.contextRef !== state.contextRef) throw new Error('context_mismatch');
1338
+ if (!state.allowedActions.includes(candidate.kind)) throw new Error('unsupported_action');
1339
+ if (candidate.expectedRevision !== state.revision) throw new Error('stale_revision');
1340
+ if (state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS) throw new Error('operation_limit_reached');
1341
+ if (candidate.relaxationApprovalRef) throw new Error('approval_ref_planner_owned_forbidden');
1342
+ if (!state.workspaceSnapshot) throw new Error(`ledger_corrupt:${taskId}:workspace_missing`);
1343
+ if (!actionHitsCurrentOfferVersion(state.workspaceSnapshot, candidate, this.now())) throw new Error('offer_version_not_loaded');
1344
+ let action = candidate;
1345
+ const availability = reduceAvailabilityAction(state.availability, state.workspaceSnapshot, candidate);
1346
+ if (state.awaitingApproval) {
1347
+ const approval = state.awaitingApproval;
1348
+ if (!approval.approval || Date.parse(approval.expiresAt) <= Date.parse(this.now())) throw new Error('approval_expired');
1349
+ const ref = {
1350
+ approvalId: approval.approval.approvalId,
1351
+ blockerId: approval.blocker.blockerId,
1352
+ contextRef: state.contextRef,
1353
+ sourceTurnId: approval.sourceTurnId,
1354
+ presentationRequestKey: approval.presentationRequestKey,
1355
+ sourceActionId: approval.blocker.sourceActionId,
1356
+ targetActionId: candidate.actionId,
1357
+ sourceRevision: candidate.expectedRevision,
1358
+ targetActionKind: candidate.kind,
1359
+ to: approval.approval.to,
1360
+ expiresAt: approval.expiresAt,
1361
+ nonce: approval.nonce,
1362
+ sourceReceiptDigest: approval.blocker.sourceReceiptDigest,
1363
+ scope: approval.blocker.scope,
1364
+ code: approval.blocker.code,
1365
+ criterionPath: approval.blocker.criterionPath,
1366
+ valueDigest: approval.blocker.valueDigest
226
1367
  };
227
- const checkpoint = {
228
- ...checkpointBase,
229
- actionDigest: checkpointDigest(checkpointBase)
1368
+ action = {
1369
+ ...candidate,
1370
+ relaxationApprovalRef: ref
230
1371
  };
231
- const payload = {
1372
+ this.append(APPROVAL_CONSUMED, taskId, {
232
1373
  schema: LEDGER_SCHEMA,
233
1374
  taskId,
234
1375
  contextRef: state.contextRef,
235
- action: checkpoint
236
- };
237
- this.appendActionIssued(taskId, payload, `booking-copilot:action:${taskId.length}:${taskId}:${action.actionId.length}:${action.actionId}`);
238
- return event;
239
- });
240
- return apply.immediate();
1376
+ approval,
1377
+ ref,
1378
+ approvalDigest: bookingDigest(ref)
1379
+ }, `booking-copilot:approval-consumed:${taskId}:${approval.approval.approvalId}:${approval.nonce}`);
1380
+ }
1381
+ const eventId = this.idFactory('event');
1382
+ const emittedAt = this.now();
1383
+ const sequence = state.lastSequence + 1;
1384
+ const event = {
1385
+ schemaVersion: 'booking.surface',
1386
+ eventId,
1387
+ taskId,
1388
+ contextRef: state.contextRef,
1389
+ sequence,
1390
+ emittedAt,
1391
+ kind: 'operation',
1392
+ action
1393
+ };
1394
+ const eventValidation = validateBookingSurfaceEvent(event);
1395
+ if (!eventValidation.ok) throw new Error(`invalid_operation_event:${errorText(eventValidation)}`);
1396
+ if (!state.lastTurnId) throw new Error(`ledger_corrupt:${taskId}:turn_id`);
1397
+ this.appendAction(taskId, state.contextRef, checkpointFor(action, eventId, sequence, emittedAt, state.lastTurnId), state.operationCount + 1, availability);
1398
+ return event;
241
1399
  }
242
1400
  continueWithReceipt(turn) {
243
- const validation = validateBookingCopilotTurnV1(turn);
244
- if (!validation.ok || turn.kind !== 'action.receipt.continuation') {
245
- throw new Error(`invalid_receipt_continuation:${validationMessage(validation)}`);
246
- }
247
- const receiptDigest = digest(turn.receipt);
248
- const apply = this.ledger.db.transaction(()=>{
1401
+ const validation = validateBookingSurface(turn);
1402
+ if (!validation.ok) throw new Error(`invalid_receipt_continuation:${errorText(validation)}`);
1403
+ assertWorkspaceLoadedOfferRefsUnique(turn.workspace);
1404
+ assertCanonicalReceipt(turn.receipt);
1405
+ const run = this.ledger.db.transaction(()=>{
249
1406
  const state = this.requireTask(turn.taskId);
1407
+ assertReplayUpgradeReanchored(state);
250
1408
  if (turn.workspace.contextRef !== state.contextRef || turn.receipt.contextRef !== state.contextRef) throw new Error('context_mismatch');
251
- const history = 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_OBSERVED);
252
- for (const row of history){
1409
+ const receiptWorkspace = bookingWorkspaceDigest(turn.workspace);
1410
+ const receiptWorkspaceSemantic = bookingWorkspaceSemanticDigest(turn.workspace);
1411
+ if (!state.workspaceSnapshot || !workspaceBoundaryMatches(state.workspaceSnapshot, turn.workspace)) throw new Error('workspace_mismatch');
1412
+ 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);
1413
+ const receiptDigest = canonicalReceiptDigest(turn.receipt);
1414
+ if (state.legacySuppressedReceiptActionIds?.includes(turn.receipt.actionId)) throw new Error('request_key_invalidated');
1415
+ for (const row of existing){
253
1416
  const prior = JSON.parse(row.payload);
254
- if (prior.receipt?.actionId !== turn.receipt.actionId) continue;
255
- 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');
1417
+ if (prior.receipt.actionId !== turn.receipt.actionId) continue;
256
1418
  if (prior.receiptDigest !== receiptDigest) throw new Error('receipt_conflict');
1419
+ if (prior.workspaceDigest !== receiptWorkspace) throw new Error('workspace_mismatch');
257
1420
  return state;
258
1421
  }
1422
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal');
259
1423
  if (!state.pendingAction) throw new Error('unexpected_receipt');
260
1424
  if (state.pendingAction.actionId !== turn.receipt.actionId) throw new Error('receipt_action_mismatch');
261
1425
  if (turn.receipt.revision < state.pendingAction.expectedRevision) throw new Error('revision_regression');
262
- const payload = {
1426
+ if (turn.receipt.resultContract.blockers.some((blocker)=>blocker.sourceActionId !== state.pendingAction.actionId)) throw new Error('receipt_source_action_mismatch');
1427
+ if (!workspacePostActionMatches(state.workspaceSnapshot, turn.workspace, state.pendingAction, turn.receipt)) throw new Error('workspace_mismatch');
1428
+ if (!receiptObservationMatchesAction(turn.receipt.observation.kind, state.pendingAction.kind)) throw new Error('receipt_observation_action_mismatch');
1429
+ if (!receiptTargetMatchesAction(turn.receipt, state.pendingAction)) throw new Error('receipt_target_mismatch');
1430
+ if (state.pendingAction.kind === 'offer.check' && turn.receipt.status === 'applied' && !verifiedOfferUnexpired(turn.workspace, this.now())) throw new Error('receipt_verified_offer_expired');
1431
+ if (state.pendingAction.kind === 'offers.query' && turn.receipt.observation.kind === 'offers.state') validateOffersReceiptWorkspace(turn.receipt, turn.workspace, state.pendingAction.input.hotelRefs);
1432
+ if (turn.receipt.resultContract.relaxationsApplied.some((approval)=>!this.approvalWasConsumed(turn.taskId, approval, state.pendingAction.actionId))) throw new Error('receipt_relaxation_unauthorized');
1433
+ const pending = state.pendingAction;
1434
+ if (turn.receipt.revision !== turn.workspace.revision) throw new Error('revision_mismatch');
1435
+ const availability = reduceAvailabilityReceipt(state.availability, turn.workspace, turn.receipt, pending);
1436
+ const availabilityTerminal = availabilityPolicyTerminal(availability);
1437
+ this.append(RECEIPT, turn.taskId, {
263
1438
  schema: LEDGER_SCHEMA,
264
1439
  taskId: turn.taskId,
265
1440
  contextRef: state.contextRef,
266
1441
  receipt: turn.receipt,
267
- receiptDigest
268
- };
269
- const idemKey = `booking-copilot:receipt:${turn.taskId.length}:${turn.taskId}:${turn.receipt.actionId.length}:${turn.receipt.actionId}`;
270
- const info = this.ledger.db.prepare(`INSERT INTO events (tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(this.ledger.tenant, this.now(), LEDGER_ACTOR, RECEIPT_OBSERVED, turn.taskId, JSON.stringify(payload), idemKey, turn.taskId);
271
- if (info.changes !== 1) throw new Error('ledger_write_failed:receipt');
1442
+ receiptDigest,
1443
+ operationCount: state.operationCount,
1444
+ workspaceDigest: receiptWorkspace,
1445
+ workspaceSemanticDigest: receiptWorkspaceSemantic,
1446
+ workspace: turn.workspace,
1447
+ availability,
1448
+ availabilityDigest: bookingDigest(availability),
1449
+ ...availabilityTerminal ? {
1450
+ availabilityTerminal
1451
+ } : {}
1452
+ }, `booking-copilot:receipt:${turn.taskId}:${turn.receipt.actionId}`);
1453
+ if (availabilityTerminal || state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS) {
1454
+ const terminalState = this.requireTask(turn.taskId);
1455
+ const terminalEvent = this.terminalEvent(terminalState);
1456
+ this.appendTerminalEvent(turn.taskId, terminalEvent);
1457
+ this.append(DECISION_BATCH, turn.taskId, {
1458
+ schema: LEDGER_SCHEMA,
1459
+ taskId: turn.taskId,
1460
+ contextRef: state.contextRef,
1461
+ requestKey: `receipt:${turn.receipt.actionId}:${receiptDigest}`,
1462
+ events: [
1463
+ terminalEvent
1464
+ ]
1465
+ }, `booking-copilot:decision-batch:${turn.taskId}:receipt:${turn.receipt.actionId}:${receiptDigest}`);
1466
+ return terminalState;
1467
+ }
1468
+ const blocker = turn.receipt.resultContract.blockers[0];
1469
+ if (blocker) {
1470
+ const nonce = randomUUID();
1471
+ const sourceTurnId = state.pendingAction?.sourceTurnId ?? state.lastTurnId ?? `ordinal:${state.userTurnCount}`;
1472
+ const requestKey = presentationKey(turn.taskId, sourceTurnId, turn.receipt.actionId, receiptDigest);
1473
+ const approval = {
1474
+ blocker,
1475
+ options: [
1476
+ approvalOption(turn.taskId, state.contextRef, sourceTurnId, requestKey, blocker, 'prefer', nonce),
1477
+ approvalOption(turn.taskId, state.contextRef, sourceTurnId, requestKey, blocker, 'drop', nonce)
1478
+ ],
1479
+ optionsEmitted: false,
1480
+ approval: undefined,
1481
+ nonce,
1482
+ expiresAt: new Date(Date.parse(this.now()) + this.approvalTtlMs).toISOString(),
1483
+ sourceTurnId,
1484
+ presentationRequestKey: requestKey
1485
+ };
1486
+ this.append(APPROVAL_GRANTED, turn.taskId, {
1487
+ schema: LEDGER_SCHEMA,
1488
+ taskId: turn.taskId,
1489
+ contextRef: state.contextRef,
1490
+ approval,
1491
+ approvalDigest: bookingDigest(approval)
1492
+ }, `booking-copilot:approval-awaiting:${turn.taskId}:${turn.receipt.actionId}`);
1493
+ }
272
1494
  return this.requireTask(turn.taskId);
273
1495
  });
274
- return apply.immediate();
1496
+ return run.immediate();
275
1497
  }
276
1498
  emitEvent(taskId, draft) {
277
- const apply = this.ledger.db.transaction(()=>{
1499
+ return this.ledger.db.transaction(()=>{
278
1500
  const state = this.requireTask(taskId);
279
- const safeDraft = draft.kind === 'error' ? {
280
- ...draft,
281
- error: {
282
- ...draft.error,
283
- code: normalizeBookingErrorCode(draft.error.code),
284
- message: safeBookingErrorMessage(normalizeBookingErrorCode(draft.error.code))
285
- }
286
- } : draft;
287
- const event = {
288
- schemaVersion: 'booking.surface.v1',
289
- eventId: this.idFactory('event'),
290
- taskId,
291
- contextRef: state.contextRef,
292
- sequence: state.lastSequence + 1,
293
- emittedAt: this.now(),
294
- ...safeDraft
295
- };
296
- const validation = validateBookingSurfaceEventV1(event);
297
- if (!validation.ok) throw new Error(`invalid_event:${validation.errors.join('; ')}`);
298
- const payload = {
299
- schema: LEDGER_SCHEMA,
300
- taskId,
301
- contextRef: state.contextRef,
302
- eventId: event.eventId,
303
- sequence: event.sequence,
304
- emittedAt: event.emittedAt,
305
- eventKind: event.kind,
306
- contentDigest: digest(safeDraft)
307
- };
308
- this.appendLedgerEvent(EVENT_EMITTED, taskId, payload, `booking-copilot:event:${taskId}:${event.eventId}`);
309
- return event;
310
- });
311
- return apply.immediate();
1501
+ if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal');
1502
+ assertReplayUpgradeReanchored(state);
1503
+ return this.emitEventInTransaction(taskId, draft);
1504
+ }).immediate();
1505
+ }
1506
+ emitEventInTransaction(taskId, draft) {
1507
+ const state = this.requireTask(taskId);
1508
+ assertReplayUpgradeReanchored(state);
1509
+ const safeDraft = draft.kind === 'error' ? {
1510
+ ...draft,
1511
+ error: {
1512
+ ...draft.error,
1513
+ code: normalizeBookingErrorCode(draft.error.code),
1514
+ message: safeBookingErrorMessage(normalizeBookingErrorCode(draft.error.code))
1515
+ }
1516
+ } : draft;
1517
+ const event = {
1518
+ schemaVersion: 'booking.surface',
1519
+ eventId: this.idFactory('event'),
1520
+ taskId,
1521
+ contextRef: state.contextRef,
1522
+ sequence: state.lastSequence + 1,
1523
+ emittedAt: this.now(),
1524
+ ...safeDraft
1525
+ };
1526
+ const checked = validateBookingSurfaceEvent(event);
1527
+ if (!checked.ok) throw new Error(`invalid_event:${errorText(checked)}`);
1528
+ this.append(EVENT, taskId, {
1529
+ schema: LEDGER_SCHEMA,
1530
+ taskId,
1531
+ contextRef: state.contextRef,
1532
+ eventId: event.eventId,
1533
+ sequence: event.sequence,
1534
+ emittedAt: event.emittedAt,
1535
+ eventKind: event.kind,
1536
+ ...event.kind === 'status' ? {
1537
+ status: event.status
1538
+ } : {},
1539
+ contentDigest: bookingDigest(safeDraft)
1540
+ }, `booking-copilot:event:${taskId}:${event.eventId}`);
1541
+ return event;
1542
+ }
1543
+ operationEvent(taskId, checkpoint, action) {
1544
+ const replayAction = action.relaxationApprovalRef || !checkpoint.relaxationApprovalRef ? action : {
1545
+ ...action,
1546
+ relaxationApprovalRef: checkpoint.relaxationApprovalRef
1547
+ };
1548
+ return {
1549
+ schemaVersion: 'booking.surface',
1550
+ eventId: checkpoint.eventId,
1551
+ taskId,
1552
+ contextRef: checkpoint.contextRef,
1553
+ sequence: checkpoint.sequence,
1554
+ emittedAt: checkpoint.emittedAt,
1555
+ kind: 'operation',
1556
+ action: replayAction
1557
+ };
1558
+ }
1559
+ terminalEvent(state) {
1560
+ if (!taskHasTerminalBudget(state)) throw new Error('task_not_terminal');
1561
+ const terminalCode = state.availability.terminal?.code ?? 'operation_limit_reached';
1562
+ const event = {
1563
+ schemaVersion: 'booking.surface',
1564
+ eventId: `terminal-${state.taskId}-${state.operationCount}`,
1565
+ taskId: state.taskId,
1566
+ contextRef: state.contextRef,
1567
+ sequence: state.lastSequence + 1,
1568
+ emittedAt: this.now(),
1569
+ kind: 'terminal',
1570
+ terminal: {
1571
+ status: state.availability.terminal?.code === 'availability_confirmed' ? 'completed' : 'stopped',
1572
+ summary: terminalCode,
1573
+ factRefs: []
1574
+ }
1575
+ };
1576
+ const checked = validateBookingSurfaceEvent(event);
1577
+ if (!checked.ok) throw new Error(`invalid_terminal_event:${errorText(checked)}`);
1578
+ return event;
312
1579
  }
313
1580
  requireTask(taskId) {
314
1581
  const state = this.resumeTask(taskId);
315
1582
  if (!state) throw new Error('task_not_found');
316
1583
  return state;
317
1584
  }
318
- readTaskRows(taskId) {
319
- return this.ledger.db.prepare(`SELECT seq, kind, payload FROM events
320
- WHERE tenant_id = ? AND run_id = ?
321
- AND kind IN (?, ?, ?, ?, ?)
322
- ORDER BY seq`).all(this.ledger.tenant, taskId, TASK_STARTED, USER_TURN_OBSERVED, ACTION_ISSUED, RECEIPT_OBSERVED, EVENT_EMITTED);
1585
+ requestBinding(requestKey) {
1586
+ 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);
1587
+ for (const row of rows){
1588
+ const binding = JSON.parse(row.payload);
1589
+ if (binding.requestKey === requestKey) {
1590
+ const state = this.resumeTask(binding.taskId);
1591
+ if (state?.legacySuppressedRequestBindingKeys?.includes(requestKey)) throw new Error('request_key_invalidated');
1592
+ return binding;
1593
+ }
1594
+ }
1595
+ return null;
323
1596
  }
324
- observeUserTurn(taskId, contextRef, requestDigest, ordinal) {
325
- const payload = {
1597
+ requestBindingPayload(requestKey, turn, input) {
1598
+ assertPrincipal(input.principal);
1599
+ assertRequestKey(requestKey);
1600
+ if (input.taskHandle) assertSafeRef(input.taskHandle);
1601
+ return {
1602
+ requestKey,
1603
+ requestDigest: bookingTurnDigest(turn),
1604
+ turnId: turn.turnId,
1605
+ workspaceDigest: bookingWorkspaceDigest(turn.workspace),
1606
+ workspaceSemanticDigest: bookingWorkspaceSemanticDigest(turn.workspace),
1607
+ surface: turn.workspace.surface,
1608
+ capabilityDigest: bookingDigest(turn.workspace.capabilities),
1609
+ principalDigest: bookingDigest({
1610
+ subject: input.principal.subject
1611
+ }),
1612
+ scopeDigest: bookingDigest({
1613
+ scope: input.principal.scope
1614
+ }),
1615
+ ...input.taskHandle ? {
1616
+ taskHandle: input.taskHandle
1617
+ } : {}
1618
+ };
1619
+ }
1620
+ appendRequestBindingInTransaction(requestKey, turn, input) {
1621
+ assertRequestKey(requestKey);
1622
+ assertPrincipal(input.principal);
1623
+ const payload = this.requestBindingPayload(requestKey, turn, input);
1624
+ if (input.taskHandle) {
1625
+ const prior = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND kind = ?`).all(this.ledger.tenant, REQUEST_BINDING);
1626
+ for (const row of prior){
1627
+ const binding = JSON.parse(row.payload);
1628
+ if (binding.taskHandle !== input.taskHandle) continue;
1629
+ if (binding.principalDigest !== payload.principalDigest || binding.scopeDigest !== payload.scopeDigest) throw new Error('principal_conflict');
1630
+ if (binding.taskId !== turn.taskId || binding.contextRef !== turn.workspace.contextRef) throw new Error('task_handle_conflict');
1631
+ }
1632
+ }
1633
+ const existing = this.requestBinding(requestKey);
1634
+ if (existing) {
1635
+ 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');
1636
+ return;
1637
+ }
1638
+ this.append(REQUEST_BINDING, turn.taskId, {
1639
+ schema: LEDGER_SCHEMA,
1640
+ taskId: turn.taskId,
1641
+ contextRef: turn.workspace.contextRef,
1642
+ ...payload
1643
+ }, `booking-copilot:request-binding:${requestKey}`);
1644
+ }
1645
+ normalizeDecisionBatchFromDurableRows(taskId, batchEvents, batchSeq) {
1646
+ 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);
1647
+ 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);
1648
+ const sourceWorkspaces = new Map();
1649
+ for (const row of turnRows){
1650
+ try {
1651
+ const turn = JSON.parse(row.payload);
1652
+ sourceWorkspaces.set(turn.turnId, normalizeWorkspaceForReplay(turn.workspace));
1653
+ } catch {
1654
+ return null;
1655
+ }
1656
+ }
1657
+ const recordedEvents = new Map();
1658
+ const recordedActions = new Map();
1659
+ for (const row of individualRows){
1660
+ if (row.kind === EVENT) {
1661
+ const payload = JSON.parse(row.payload);
1662
+ const key = consumedIdentityKey(payload.eventId, payload.sequence);
1663
+ if (recordedEvents.has(key) || recordedActions.has(key)) return null;
1664
+ recordedEvents.set(key, payload);
1665
+ } else if (row.kind === ACTION) {
1666
+ const payload = JSON.parse(row.payload);
1667
+ let action = payload.action;
1668
+ try {
1669
+ action = normalizeActionCheckpointForReplay(action, sourceWorkspaces.get(action.sourceTurnId));
1670
+ } catch {
1671
+ return null;
1672
+ }
1673
+ const key = consumedIdentityKey(action.eventId, action.sequence);
1674
+ if (recordedEvents.has(key) || recordedActions.has(key)) return null;
1675
+ recordedActions.set(key, action);
1676
+ }
1677
+ }
1678
+ let lastBatchSequence = -1;
1679
+ const seen = new Set();
1680
+ const normalizedEvents = [];
1681
+ for (const event of batchEvents){
1682
+ if (!Number.isSafeInteger(event.sequence) || event.sequence <= lastBatchSequence) return null;
1683
+ lastBatchSequence = event.sequence;
1684
+ const key = consumedIdentityKey(event.eventId, event.sequence);
1685
+ if (seen.has(key)) return null;
1686
+ seen.add(key);
1687
+ if (event.kind === 'operation') {
1688
+ const checkpoint = recordedActions.get(key);
1689
+ if (!checkpoint || !operationEventMatchesAction(event, checkpoint)) return null;
1690
+ const normalized = {
1691
+ ...event,
1692
+ action: actionFromOperationEventForReplay(event, checkpoint)
1693
+ };
1694
+ if (!validateBookingSurfaceEvent(normalized).ok) return null;
1695
+ normalizedEvents.push(normalized);
1696
+ } else {
1697
+ const recorded = recordedEvents.get(key);
1698
+ if (!recorded || !validateBookingSurfaceEvent(event).ok || !eventPayloadMatchesEvent(recorded, event)) return null;
1699
+ normalizedEvents.push(event);
1700
+ }
1701
+ }
1702
+ return normalizedEvents;
1703
+ }
1704
+ rows(taskId) {
1705
+ 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);
1706
+ }
1707
+ append(kind, taskId, payload, idemKey) {
1708
+ 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);
1709
+ if (result.changes === 0) throw new Error(`ledger_write_failed:${kind}`);
1710
+ }
1711
+ appendTurn(taskId, contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, ordinal, turnId, workspace) {
1712
+ if (!workspace) throw new Error('ledger_write_failed:turn_workspace');
1713
+ this.append(TURN, taskId, {
1714
+ schema: LEDGER_SCHEMA,
1715
+ taskId,
1716
+ contextRef,
1717
+ requestDigest,
1718
+ workspaceDigest,
1719
+ workspaceSemanticDigest,
1720
+ workspace,
1721
+ turnId
1722
+ }, `booking-copilot:turn:${taskId}:${turnId}`);
1723
+ }
1724
+ appendApproval(taskId, contextRef, approval) {
1725
+ this.append(APPROVAL_GRANTED, taskId, {
326
1726
  schema: LEDGER_SCHEMA,
327
1727
  taskId,
328
1728
  contextRef,
329
- requestDigest
1729
+ approval,
1730
+ approvalDigest: bookingDigest(approval)
1731
+ }, `booking-copilot:approval-granted:${taskId}:${approval.approval?.approvalId ?? 'pending'}:${approval.nonce}`);
1732
+ }
1733
+ appendAction(taskId, contextRef, action, operationCount, availability) {
1734
+ this.append(ACTION, taskId, {
1735
+ schema: LEDGER_SCHEMA,
1736
+ taskId,
1737
+ contextRef,
1738
+ action,
1739
+ operationCount,
1740
+ availability,
1741
+ availabilityDigest: bookingDigest(availability)
1742
+ }, `booking-copilot:action:${taskId}:${action.actionId}`);
1743
+ }
1744
+ appendTerminalEvent(taskId, event) {
1745
+ const payload = {
1746
+ schema: LEDGER_SCHEMA,
1747
+ taskId,
1748
+ contextRef: event.contextRef,
1749
+ eventId: event.eventId,
1750
+ sequence: event.sequence,
1751
+ emittedAt: event.emittedAt,
1752
+ eventKind: 'terminal',
1753
+ contentDigest: bookingDigest(event.terminal)
330
1754
  };
331
- this.appendLedgerEvent(USER_TURN_OBSERVED, taskId, payload, `booking-copilot:user-turn:${taskId}:${ordinal}`);
332
- }
333
- appendLedgerEvent(kind, taskId, payload, idemKey) {
334
- const result = this.ledger.db.prepare(`INSERT OR IGNORE INTO events
335
- (tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id)
336
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(this.ledger.tenant, this.now(), LEDGER_ACTOR, kind, taskId, JSON.stringify(payload), idemKey, taskId);
337
- if (result.changes > 0) return Number(result.lastInsertRowid);
338
- const existing = this.ledger.db.prepare('SELECT seq FROM events WHERE tenant_id = ? AND idem_key = ?').get(this.ledger.tenant, idemKey);
339
- if (!existing) throw new Error(`ledger_write_failed:${kind}`);
340
- return existing.seq;
341
- }
342
- appendActionIssued(taskId, payload, idemKey) {
343
- const result = this.ledger.db.prepare(`INSERT INTO events (tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id)
344
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(this.ledger.tenant, this.now(), LEDGER_ACTOR, ACTION_ISSUED, taskId, JSON.stringify(payload), idemKey, taskId);
345
- if (result.changes !== 1) throw new Error('ledger_write_failed:action');
346
- return Number(result.lastInsertRowid);
1755
+ const idemKey = `booking-copilot:terminal-event:${taskId}`;
1756
+ 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);
1757
+ if (result.changes === 0) {
1758
+ const existing = this.ledger.db.prepare('SELECT payload FROM events WHERE tenant_id = ? AND idem_key = ?').get(this.ledger.tenant, idemKey);
1759
+ if (!existing || JSON.parse(existing.payload).contentDigest !== payload.contentDigest) throw new Error('ledger_write_failed:terminal_event');
1760
+ }
1761
+ }
1762
+ ensureApprovalOffered(taskId, contextRef, approval, requestKey) {
1763
+ if (requestKey !== approval.presentationRequestKey) throw new Error('approval_presentation_key_mismatch');
1764
+ const idemKey = `booking-copilot:approval-offered:${taskId}:${requestKey}`;
1765
+ const exists = this.ledger.db.prepare('SELECT 1 AS present FROM events WHERE tenant_id = ? AND idem_key = ?').get(this.ledger.tenant, idemKey);
1766
+ if (!exists) this.append(APPROVAL_OFFERED, taskId, {
1767
+ schema: LEDGER_SCHEMA,
1768
+ taskId,
1769
+ contextRef,
1770
+ approval,
1771
+ approvalDigest: bookingDigest(approval)
1772
+ }, idemKey);
1773
+ }
1774
+ approvalWasConsumed(taskId, approval, targetActionId) {
1775
+ const state = this.resumeTask(taskId);
1776
+ if (state?.legacySuppressedApprovalTargetActionIds?.includes(targetActionId)) return false;
1777
+ 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);
1778
+ return rows.some(({ payload })=>{
1779
+ const consumed = JSON.parse(payload);
1780
+ return consumed.ref?.targetActionId === targetActionId && consumed.approval?.approval && bookingDigest(consumed.approval.approval) === bookingDigest(approval);
1781
+ });
347
1782
  }
348
1783
  }
349
1784