@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
@@ -3,203 +3,4001 @@ import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { ensureLedger } from '../src/state-ledger.js';
6
- import { BookingCopilotTaskRuntime } from '../src/booking-surface/runtime.js';
7
- const stateRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-copilot-'));
8
- const ledger = ensureLedger(stateRoot);
6
+ import { BookingCopilotTaskRuntime, bookingDigest } from '../src/booking-surface/runtime.js';
7
+ import { startBookingCopilotServer } from '../src/booking-surface/server.js';
8
+ import { BOOKING_READ_ACTION_KINDS, BOOKING_SURFACE_SCHEMA_SHA256, BOOKING_SURFACE_SCHEMA_VERSION } from '../src/booking-surface/contracts.js';
9
+ import { validateBookingSurface } from '../src/booking-surface/validation.js';
10
+ const stateRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-runtime-'));
11
+ const workspace = (revision = 0)=>({
12
+ schemaVersion: 'booking.surface',
13
+ contextRef: 'ctx-v2',
14
+ surface: 'tenant',
15
+ revision,
16
+ locale: 'en-US',
17
+ currency: 'AED',
18
+ searchDraft: {},
19
+ results: {
20
+ status: 'idle'
21
+ },
22
+ visibleHotels: [],
23
+ loadedOffers: [],
24
+ shortlistedOfferRefs: [],
25
+ capabilities: {
26
+ surface: 'tenant',
27
+ allowedActions: [
28
+ ...BOOKING_READ_ACTION_KINDS
29
+ ]
30
+ }
31
+ });
32
+ const loadedOffer = (offerRef, hotelRef, offerVersionRef = `${offerRef}:v1`)=>({
33
+ offerRef,
34
+ offerVersionRef,
35
+ hotelRef,
36
+ evidenceLevel: 'rate_loaded',
37
+ factRefs: []
38
+ });
39
+ const verifiedCapability = (offerRef, offerVersionRef = `${offerRef}:v1`, expiresAt = '2026-09-01T10:30:00.000Z')=>({
40
+ offerRef,
41
+ offerVersionRef,
42
+ verifiedOfferRef: `verified-${offerRef}`,
43
+ expiresAt
44
+ });
45
+ const availabilityObservation = (offerRef, checkedOfferVersionRef = `${offerRef}:v1`, available = false, currentOfferVersionRef)=>({
46
+ kind: 'offer.availability',
47
+ offerRef,
48
+ checkedOfferVersionRef,
49
+ currentOfferVersionRef: available ? currentOfferVersionRef ?? checkedOfferVersionRef : undefined,
50
+ available,
51
+ changedFactRefs: [],
52
+ gapCodes: []
53
+ });
54
+ const oldWorkspaceDigest = (value)=>{
55
+ const { contextRef: _contextRef, ...withoutContext } = value;
56
+ return bookingDigest(withoutContext);
57
+ };
58
+ const oldWorkspaceSemanticDigest = (value)=>{
59
+ const { contextRef: _contextRef, revision: _revision, ...semantic } = value;
60
+ return bookingDigest(semantic);
61
+ };
62
+ const legacyWorkspace = (value, verifiedOfferRef)=>{
63
+ const workspaceValue = structuredClone(value);
64
+ const loadedOffers = Array.isArray(workspaceValue.loadedOffers) ? workspaceValue.loadedOffers : [];
65
+ workspaceValue.loadedOffers = loadedOffers.map((loaded)=>{
66
+ const oldOffer = {
67
+ ...loaded
68
+ };
69
+ delete oldOffer.offerVersionRef;
70
+ return oldOffer;
71
+ });
72
+ delete workspaceValue.verifiedOffer;
73
+ if (verifiedOfferRef !== undefined) workspaceValue.verifiedOfferRef = verifiedOfferRef;
74
+ return workspaceValue;
75
+ };
76
+ const legacyAvailability = (value, workspaceDigest)=>{
77
+ const availability = structuredClone(value);
78
+ const hotels = availability.hotels;
79
+ if (hotels && typeof hotels === 'object' && !Array.isArray(hotels)) {
80
+ for (const hotel of Object.values(hotels)){
81
+ if (!hotel || typeof hotel !== 'object' || Array.isArray(hotel)) continue;
82
+ const oldHotel = hotel;
83
+ delete oldHotel.tombstonedOfferVersionRefs;
84
+ const generation = oldHotel.currentGeneration;
85
+ if (workspaceDigest && generation && typeof generation === 'object' && !Array.isArray(generation)) {
86
+ const source = generation.source;
87
+ if (source && typeof source === 'object' && !Array.isArray(source) && source.kind === 'workspace_snapshot') source.workspaceDigest = workspaceDigest;
88
+ }
89
+ }
90
+ }
91
+ if (Array.isArray(availability.attempts)) {
92
+ availability.attempts = availability.attempts.map((attempt)=>{
93
+ if (!attempt || typeof attempt !== 'object' || Array.isArray(attempt)) return attempt;
94
+ const oldAttempt = {
95
+ ...attempt
96
+ };
97
+ delete oldAttempt.offerVersionRef;
98
+ return oldAttempt;
99
+ });
100
+ }
101
+ return availability;
102
+ };
103
+ const stripLegacyReceipt = (value)=>{
104
+ const receipt = structuredClone(value);
105
+ const observation = receipt.observation;
106
+ if (observation && typeof observation === 'object' && !Array.isArray(observation)) {
107
+ const oldObservation = observation;
108
+ delete oldObservation.offerVersionRef;
109
+ delete oldObservation.checkedOfferVersionRef;
110
+ delete oldObservation.currentOfferVersionRef;
111
+ }
112
+ return receipt;
113
+ };
114
+ const legacyTurnDigest = (taskId, turnId, workspaceValue, text)=>bookingDigest({
115
+ schemaVersion: 'booking.surface',
116
+ kind: 'user.turn',
117
+ taskId,
118
+ turnId,
119
+ workspace: workspaceValue,
120
+ request: {
121
+ text
122
+ }
123
+ });
124
+ const turn = (taskId, revision = 0)=>({
125
+ schemaVersion: 'booking.surface',
126
+ kind: 'user.turn',
127
+ taskId,
128
+ turnId: `${taskId}-turn-${revision}`,
129
+ workspace: workspace(revision),
130
+ request: {
131
+ text: 'find hotels in Dubai'
132
+ }
133
+ });
134
+ const action = (id, revision = 0, extra = {})=>({
135
+ schemaVersion: 'booking.surface',
136
+ kind: 'search.run',
137
+ actionId: id,
138
+ contextRef: 'ctx-v2',
139
+ expectedRevision: revision,
140
+ reason: 'search current workspace',
141
+ factRefs: [],
142
+ input: {},
143
+ ...extra
144
+ });
9
145
  let id = 0;
10
- const runtimeOptions = {
146
+ const runtime = new BookingCopilotTaskRuntime(ensureLedger(stateRoot), {
11
147
  idFactory: (prefix)=>`${prefix}-${++id}`,
12
- now: ()=>'2026-08-30T12:00:00.000Z'
148
+ now: ()=>'2026-09-01T10:00:00.000Z'
149
+ });
150
+ const task = runtime.startTask(turn('task-v2'));
151
+ assert.equal(task.taskId, 'task-v2');
152
+ assert.throws(()=>runtime.startTask({
153
+ ...turn('task-no-id'),
154
+ taskId: undefined
155
+ }), /invalid_planner_turn/);
156
+ const directIngressRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-missing-task-'));
157
+ const directIngressLedger = ensureLedger(directIngressRoot);
158
+ const directIngressRuntime = new BookingCopilotTaskRuntime(directIngressLedger);
159
+ const directIngressBefore = directIngressLedger.countEvents();
160
+ assert.throws(()=>directIngressRuntime.startTask({
161
+ schemaVersion: 'booking.surface',
162
+ kind: 'user.turn.ingress',
163
+ requestKey: 'ingress-no-task',
164
+ surfaceHint: 'tenant',
165
+ workspace: {
166
+ schemaVersion: 'booking.surface',
167
+ revision: 0,
168
+ locale: 'en-US',
169
+ currency: 'AED',
170
+ searchDraft: {},
171
+ results: {
172
+ status: 'idle'
173
+ },
174
+ visibleHotels: [],
175
+ loadedOffers: [],
176
+ shortlistedOfferRefs: []
177
+ },
178
+ request: {
179
+ text: 'find hotels in Dubai'
180
+ }
181
+ }), /ingress_binding_required/);
182
+ assert.equal(directIngressLedger.countEvents(), directIngressBefore, 'browser ingress has no direct runtime ledger side effects');
183
+ const selectedIngressRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-selected-ingress-'));
184
+ const selectedIngressLedger = ensureLedger(selectedIngressRoot);
185
+ const selectedIngressRuntime = new BookingCopilotTaskRuntime(selectedIngressLedger, {
186
+ contextRefFactory: ()=>'ctx-selected-ingress'
187
+ });
188
+ const selectedIngress = {
189
+ schemaVersion: 'booking.surface',
190
+ kind: 'user.turn.ingress',
191
+ requestKey: 'selected-ingress-request',
192
+ taskHandle: 'opaque-task-handle',
193
+ surfaceHint: 'tenant',
194
+ workspace: {
195
+ schemaVersion: 'booking.surface',
196
+ revision: 0,
197
+ locale: 'en-US',
198
+ currency: 'AED',
199
+ searchDraft: {},
200
+ results: {
201
+ status: 'idle'
202
+ },
203
+ visibleHotels: [
204
+ {
205
+ hotelRef: 'hotel-selected',
206
+ name: 'Selected Hotel',
207
+ factRefs: []
208
+ }
209
+ ],
210
+ loadedOffers: [
211
+ loadedOffer('offer-selected', 'hotel-selected')
212
+ ],
213
+ focusedHotelRef: 'hotel-selected',
214
+ shortlistedOfferRefs: [],
215
+ selectedOfferRef: 'offer-selected'
216
+ },
217
+ request: {
218
+ text: 'is this selected room still bookable?'
219
+ }
220
+ };
221
+ const selectedIngressTask = selectedIngressRuntime.startTask(turn('task-selected-ingress'));
222
+ const selectedPlannerTurn = selectedIngressRuntime.bindIngressTurn(selectedIngress, selectedIngressTask, 'turn-selected-ingress');
223
+ assert.equal(selectedPlannerTurn.workspace.focusedHotelRef, 'hotel-selected');
224
+ assert.equal(selectedPlannerTurn.workspace.selectedOfferRef, 'offer-selected');
225
+ assert.equal(selectedPlannerTurn.workspace.verifiedOffer, undefined, 'initial ingress cannot assert verified availability authority');
226
+ const selectedApproval = {
227
+ taskId: selectedIngressTask.taskId,
228
+ contextRef: selectedIngressTask.contextRef,
229
+ sourceTurnId: 'turn-source-ingress',
230
+ presentationRequestKey: 'approval:task-selected-ingress:turn-source-ingress:action-source-ingress',
231
+ optionDigest: 'a'.repeat(64),
232
+ approvalId: 'approval-selected-ingress',
233
+ deliveryNonce: 'nonce-selected-ingress',
234
+ blockerId: 'blocker-selected-ingress',
235
+ sourceActionId: 'action-source-ingress',
236
+ sourceReceiptDigest: 'b'.repeat(64),
237
+ scope: 'offer',
238
+ code: 'criterion_must_not_met',
239
+ criterionPath: 'offers.totalPriceMax',
240
+ valueDigest: 'c'.repeat(64),
241
+ from: 'must',
242
+ to: 'prefer',
243
+ approved: true
244
+ };
245
+ const approvedSelectedIngress = {
246
+ ...selectedIngress,
247
+ request: {
248
+ text: selectedIngress.request.text,
249
+ approval: selectedApproval
250
+ }
251
+ };
252
+ const approvedSelectedPlannerTurn = selectedIngressRuntime.bindIngressTurn(approvedSelectedIngress, selectedIngressTask, 'turn-approved-selected-ingress');
253
+ assert.deepEqual(approvedSelectedPlannerTurn.request.approval, selectedApproval, 'runtime ingress binding preserves the exact canonical approval');
254
+ assert.notEqual(approvedSelectedPlannerTurn.request.approval, selectedApproval, 'runtime ingress binding clones approval data before planner admission');
255
+ assert.throws(()=>selectedIngressRuntime.bindIngressTurn({
256
+ ...approvedSelectedIngress,
257
+ request: {
258
+ ...approvedSelectedIngress.request,
259
+ approval: {
260
+ ...selectedApproval,
261
+ taskId: 'browser-forged-task'
262
+ }
263
+ }
264
+ }, selectedIngressTask, 'turn-forged-selected-ingress'), /invalid_ingress_approval_authority/);
265
+ assert.throws(()=>selectedIngressRuntime.bindIngressTurn({
266
+ ...approvedSelectedIngress,
267
+ request: {
268
+ ...approvedSelectedIngress.request,
269
+ approval: {
270
+ ...selectedApproval,
271
+ contextRef: 'browser-forged-context'
272
+ }
273
+ }
274
+ }, selectedIngressTask, 'turn-forged-context-selected-ingress'), /invalid_ingress_approval_authority/);
275
+ selectedIngressLedger.close();
276
+ rmSync(selectedIngressRoot, {
277
+ recursive: true,
278
+ force: true
279
+ });
280
+ const duplicateOfferWorkspace = {
281
+ ...workspace(0),
282
+ visibleHotels: [
283
+ {
284
+ hotelRef: 'hotel-dup-a',
285
+ name: 'Hotel Dup A',
286
+ factRefs: []
287
+ },
288
+ {
289
+ hotelRef: 'hotel-dup-b',
290
+ name: 'Hotel Dup B',
291
+ factRefs: []
292
+ }
293
+ ],
294
+ loadedOffers: [
295
+ loadedOffer('duplicate-offer-ref', 'hotel-dup-a'),
296
+ loadedOffer('duplicate-offer-ref', 'hotel-dup-b')
297
+ ]
298
+ };
299
+ const duplicateRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-duplicate-offer-'));
300
+ const duplicateLedger = ensureLedger(duplicateRoot);
301
+ const duplicateRuntime = new BookingCopilotTaskRuntime(duplicateLedger, {
302
+ contextRefFactory: ()=>'ctx-v2'
303
+ });
304
+ assert.equal(validateBookingSurface({
305
+ ...turn('validator-duplicate-logical'),
306
+ workspace: duplicateOfferWorkspace
307
+ }).ok, false, 'published validator rejects duplicate logical offer refs');
308
+ const duplicateVersionWorkspace = {
309
+ ...workspace(0),
310
+ visibleHotels: [
311
+ {
312
+ hotelRef: 'hotel-dup-a',
313
+ name: 'Hotel Dup A',
314
+ factRefs: []
315
+ },
316
+ {
317
+ hotelRef: 'hotel-dup-b',
318
+ name: 'Hotel Dup B',
319
+ factRefs: []
320
+ }
321
+ ],
322
+ loadedOffers: [
323
+ loadedOffer('offer-dup-a', 'hotel-dup-a', 'shared-offer:v1'),
324
+ loadedOffer('offer-dup-b', 'hotel-dup-b', 'shared-offer:v1')
325
+ ]
326
+ };
327
+ assert.equal(validateBookingSurface({
328
+ ...turn('validator-duplicate-version'),
329
+ workspace: duplicateVersionWorkspace
330
+ }).ok, false, 'published validator rejects duplicate offer version refs');
331
+ const verifiedVersionMismatchWorkspace = {
332
+ ...workspace(0),
333
+ visibleHotels: [
334
+ {
335
+ hotelRef: 'hotel-verified',
336
+ name: 'Verified Hotel',
337
+ factRefs: []
338
+ }
339
+ ],
340
+ loadedOffers: [
341
+ loadedOffer('offer-verified', 'hotel-verified', 'offer-verified:v1')
342
+ ],
343
+ verifiedOffer: verifiedCapability('offer-verified', 'offer-verified:v2')
344
+ };
345
+ assert.equal(validateBookingSurface({
346
+ ...turn('validator-verified-version-mismatch'),
347
+ workspace: verifiedVersionMismatchWorkspace
348
+ }).ok, false, 'standalone workspace validation rejects a verified capability for an unloaded version');
349
+ assert.equal(validateBookingSurface({
350
+ ...turn('validator-verified-logical-mismatch'),
351
+ workspace: {
352
+ ...verifiedVersionMismatchWorkspace,
353
+ verifiedOffer: verifiedCapability('other-offer', 'other-offer:v1')
354
+ }
355
+ }).ok, false, 'standalone workspace validation rejects a verified capability for another logical offer');
356
+ assert.throws(()=>duplicateRuntime.startTask({
357
+ ...turn('task-duplicate-offer'),
358
+ workspace: duplicateOfferWorkspace
359
+ }), /invalid_planner_turn|availability_duplicate_offer_ref/);
360
+ assert.equal(duplicateLedger.countEvents(), 0, 'duplicate loaded OfferRef is rejected at admission before ledger writes');
361
+ const duplicateContinuationRuntime = new BookingCopilotTaskRuntime(duplicateLedger, {
362
+ contextRefFactory: ()=>'ctx-v2'
363
+ });
364
+ const duplicateContinuationTask = duplicateContinuationRuntime.startTask({
365
+ ...turn('task-duplicate-continuation'),
366
+ workspace: {
367
+ ...workspace(0),
368
+ visibleHotels: [
369
+ {
370
+ hotelRef: 'hotel-dup-a',
371
+ name: 'Hotel Dup A',
372
+ factRefs: []
373
+ }
374
+ ],
375
+ loadedOffers: [
376
+ loadedOffer('unique-offer-ref', 'hotel-dup-a')
377
+ ]
378
+ }
379
+ });
380
+ duplicateContinuationRuntime.issueOperation(duplicateContinuationTask.taskId, {
381
+ ...action('duplicate-continuation-check'),
382
+ kind: 'offer.check',
383
+ input: {
384
+ offerRef: 'unique-offer-ref',
385
+ offerVersionRef: 'unique-offer-ref:v1'
386
+ }
387
+ });
388
+ assert.throws(()=>duplicateContinuationRuntime.continueWithReceipt({
389
+ schemaVersion: 'booking.surface',
390
+ kind: 'action.receipt.continuation',
391
+ taskId: duplicateContinuationTask.taskId,
392
+ workspace: {
393
+ ...duplicateOfferWorkspace,
394
+ revision: 1
395
+ },
396
+ receipt: {
397
+ schemaVersion: 'booking.surface',
398
+ kind: 'action.receipt',
399
+ actionId: 'duplicate-continuation-check',
400
+ contextRef: 'ctx-v2',
401
+ status: 'unavailable',
402
+ revision: 1,
403
+ observation: availabilityObservation('unique-offer-ref'),
404
+ resultContract: {
405
+ outcome: 'empty',
406
+ hardCriteriaMet: false,
407
+ factRefs: [],
408
+ gapCodes: [],
409
+ blockers: [],
410
+ relaxationsApplied: []
411
+ }
412
+ }
413
+ }), /invalid_receipt_continuation|availability_duplicate_offer_ref/);
414
+ duplicateContinuationRuntime.appendTurn('task-duplicate-continuation', 'ctx-v2', 'tampered', 'tampered', 'tampered', 2, 'duplicate-replay-turn', duplicateOfferWorkspace);
415
+ assert.throws(()=>duplicateContinuationRuntime.resumeTask('task-duplicate-continuation'), /ledger_corrupt:task-duplicate-continuation:turn_workspace/, 'duplicate loaded OfferRef is fail-closed during ledger replay');
416
+ duplicateLedger.close();
417
+ rmSync(duplicateRoot, {
418
+ recursive: true,
419
+ force: true
420
+ });
421
+ const selectRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-select-receipt-binding-'));
422
+ const selectLedger = ensureLedger(selectRoot);
423
+ const selectRuntime = new BookingCopilotTaskRuntime(selectLedger, {
424
+ contextRefFactory: ()=>'ctx-v2'
425
+ });
426
+ const selectWorkspace = {
427
+ ...workspace(0),
428
+ visibleHotels: [
429
+ {
430
+ hotelRef: 'hotel-a',
431
+ name: 'Hotel A',
432
+ factRefs: []
433
+ },
434
+ {
435
+ hotelRef: 'hotel-b',
436
+ name: 'Hotel B',
437
+ factRefs: []
438
+ }
439
+ ],
440
+ loadedOffers: [
441
+ loadedOffer('offer-a', 'hotel-a'),
442
+ loadedOffer('offer-b', 'hotel-b')
443
+ ],
444
+ shortlistedOfferRefs: [
445
+ 'offer-a',
446
+ 'offer-b'
447
+ ]
448
+ };
449
+ const selectTask = selectRuntime.startTask({
450
+ ...turn('task-select-receipt-binding'),
451
+ workspace: selectWorkspace
452
+ });
453
+ selectRuntime.issueOperation(selectTask.taskId, {
454
+ ...action('select-a'),
455
+ kind: 'offer.select',
456
+ input: {
457
+ offerRef: 'offer-a',
458
+ offerVersionRef: 'offer-a:v1'
459
+ }
460
+ });
461
+ assert.throws(()=>selectRuntime.continueWithReceipt({
462
+ schemaVersion: 'booking.surface',
463
+ kind: 'action.receipt.continuation',
464
+ taskId: selectTask.taskId,
465
+ workspace: {
466
+ ...selectWorkspace,
467
+ revision: 1,
468
+ selectedOfferRef: 'offer-b'
469
+ },
470
+ receipt: {
471
+ schemaVersion: 'booking.surface',
472
+ kind: 'action.receipt',
473
+ actionId: 'select-a',
474
+ contextRef: 'ctx-v2',
475
+ status: 'applied',
476
+ revision: 1,
477
+ observation: {
478
+ kind: 'offer.selection',
479
+ offerRef: 'offer-a',
480
+ offerVersionRef: 'offer-a:v1'
481
+ },
482
+ resultContract: {
483
+ outcome: 'complete',
484
+ hardCriteriaMet: true,
485
+ factRefs: [],
486
+ gapCodes: [],
487
+ blockers: [],
488
+ relaxationsApplied: []
489
+ }
490
+ }
491
+ }), /workspace_mismatch|receipt_transition/, 'offer.select receipt A cannot select offer B');
492
+ selectLedger.close();
493
+ rmSync(selectRoot, {
494
+ recursive: true,
495
+ force: true
496
+ });
497
+ const checkoutWorkspace = (revision, capability)=>({
498
+ ...workspace(revision),
499
+ visibleHotels: [
500
+ {
501
+ hotelRef: 'hotel-a',
502
+ name: 'Hotel A',
503
+ factRefs: []
504
+ },
505
+ {
506
+ hotelRef: 'hotel-b',
507
+ name: 'Hotel B',
508
+ factRefs: []
509
+ }
510
+ ],
511
+ loadedOffers: [
512
+ loadedOffer('offer-a', 'hotel-a'),
513
+ loadedOffer('offer-b', 'hotel-b')
514
+ ],
515
+ selectedOfferRef: 'offer-a',
516
+ verifiedOffer: capability
517
+ });
518
+ const checkoutReceipt = (actionId, offerRef, offerVersionRef, verifiedOfferRef)=>({
519
+ schemaVersion: 'booking.surface',
520
+ kind: 'action.receipt',
521
+ actionId,
522
+ contextRef: 'ctx-v2',
523
+ status: 'applied',
524
+ revision: 1,
525
+ observation: {
526
+ kind: 'checkout.handoff',
527
+ offerRef,
528
+ offerVersionRef,
529
+ verifiedOfferRef,
530
+ handoffRef: `${offerRef}-checkout`
531
+ },
532
+ resultContract: {
533
+ outcome: 'complete',
534
+ hardCriteriaMet: true,
535
+ factRefs: [],
536
+ gapCodes: [],
537
+ blockers: [],
538
+ relaxationsApplied: []
539
+ }
540
+ });
541
+ {
542
+ const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-checkout-capability-'));
543
+ const ledger = ensureLedger(root);
544
+ const rt = new BookingCopilotTaskRuntime(ledger, {
545
+ contextRefFactory: ()=>'ctx-v2',
546
+ now: ()=>'2026-09-01T10:00:00.000Z'
547
+ });
548
+ const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
549
+ const t = rt.startTask({
550
+ ...turn('task-checkout-capability-exact'),
551
+ workspace: checkoutWorkspace(0, capability)
552
+ });
553
+ const checkout = {
554
+ ...action('checkout-exact'),
555
+ kind: 'checkout.prepare',
556
+ input: {
557
+ offerRef: 'offer-a',
558
+ offerVersionRef: 'offer-a:v1',
559
+ verifiedOfferRef: capability.verifiedOfferRef
560
+ }
561
+ };
562
+ rt.issueOperation(t.taskId, checkout);
563
+ rt.continueWithReceipt({
564
+ schemaVersion: 'booking.surface',
565
+ kind: 'action.receipt.continuation',
566
+ taskId: t.taskId,
567
+ workspace: checkoutWorkspace(1, capability),
568
+ receipt: checkoutReceipt('checkout-exact', 'offer-a', 'offer-a:v1', capability.verifiedOfferRef)
569
+ });
570
+ assert.equal(rt.resumeTask(t.taskId)?.lastReceipt?.observation.kind, 'checkout.handoff', 'exact unexpired capability reaches checkout handoff');
571
+ ledger.close();
572
+ rmSync(root, {
573
+ recursive: true,
574
+ force: true
575
+ });
576
+ }{
577
+ const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-checkout-capability-cross-offer-'));
578
+ const ledger = ensureLedger(root);
579
+ const rt = new BookingCopilotTaskRuntime(ledger, {
580
+ contextRefFactory: ()=>'ctx-v2',
581
+ now: ()=>'2026-09-01T10:00:00.000Z'
582
+ });
583
+ const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
584
+ const t = rt.startTask({
585
+ ...turn('task-checkout-capability-cross-offer'),
586
+ workspace: checkoutWorkspace(0, capability)
587
+ });
588
+ assert.throws(()=>rt.issueOperation(t.taskId, {
589
+ ...action('checkout-cross-offer'),
590
+ kind: 'checkout.prepare',
591
+ input: {
592
+ offerRef: 'offer-b',
593
+ offerVersionRef: 'offer-b:v1',
594
+ verifiedOfferRef: capability.verifiedOfferRef
595
+ }
596
+ }), /verified_offer|offer_capability|offer_version/, 'offer A capability cannot authorize offer B');
597
+ ledger.close();
598
+ rmSync(root, {
599
+ recursive: true,
600
+ force: true
601
+ });
602
+ }{
603
+ const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-checkout-capability-expired-'));
604
+ const ledger = ensureLedger(root);
605
+ const rt = new BookingCopilotTaskRuntime(ledger, {
606
+ contextRefFactory: ()=>'ctx-v2',
607
+ now: ()=>'2026-09-01T10:00:00.000Z'
608
+ });
609
+ const expired = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T09:59:59.000Z');
610
+ const t = rt.startTask({
611
+ ...turn('task-checkout-capability-expired'),
612
+ workspace: checkoutWorkspace(0, expired)
613
+ });
614
+ assert.throws(()=>rt.issueOperation(t.taskId, {
615
+ ...action('checkout-expired'),
616
+ kind: 'checkout.prepare',
617
+ input: {
618
+ offerRef: 'offer-a',
619
+ offerVersionRef: 'offer-a:v1',
620
+ verifiedOfferRef: expired.verifiedOfferRef
621
+ }
622
+ }), /verified_offer_expired|offer_capability_expired|capability_expired|offer_version_not_loaded/, 'expired verified capability is rejected');
623
+ ledger.close();
624
+ rmSync(root, {
625
+ recursive: true,
626
+ force: true
627
+ });
628
+ }{
629
+ const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-expired-receipt-write-'));
630
+ const ledger = ensureLedger(root);
631
+ const rt = new BookingCopilotTaskRuntime(ledger, {
632
+ contextRefFactory: ()=>'ctx-v2',
633
+ now: ()=>'2026-09-01T11:00:00.000Z'
634
+ });
635
+ const expired = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
636
+ const before = checkoutWorkspace(0, expired);
637
+ const after = checkoutWorkspace(1, expired);
638
+ const t = rt.startTask({
639
+ ...turn('task-expired-receipt-write'),
640
+ workspace: before
641
+ });
642
+ rt.issueOperation(t.taskId, {
643
+ ...action('expired-receipt-check'),
644
+ kind: 'offer.check',
645
+ input: {
646
+ offerRef: 'offer-a',
647
+ offerVersionRef: 'offer-a:v1'
648
+ }
649
+ });
650
+ const receipt = {
651
+ schemaVersion: 'booking.surface',
652
+ kind: 'action.receipt',
653
+ actionId: 'expired-receipt-check',
654
+ contextRef: 'ctx-v2',
655
+ status: 'applied',
656
+ revision: 1,
657
+ observation: {
658
+ kind: 'offer.availability',
659
+ offerRef: 'offer-a',
660
+ checkedOfferVersionRef: 'offer-a:v1',
661
+ currentOfferVersionRef: 'offer-a:v1',
662
+ verifiedOfferRef: expired.verifiedOfferRef,
663
+ available: true,
664
+ changedFactRefs: [],
665
+ gapCodes: []
666
+ },
667
+ resultContract: {
668
+ outcome: 'complete',
669
+ hardCriteriaMet: true,
670
+ factRefs: [],
671
+ gapCodes: [],
672
+ blockers: [],
673
+ relaxationsApplied: []
674
+ }
675
+ };
676
+ assert.throws(()=>rt.continueWithReceipt({
677
+ schemaVersion: 'booking.surface',
678
+ kind: 'action.receipt.continuation',
679
+ taskId: t.taskId,
680
+ workspace: after,
681
+ receipt
682
+ }), /receipt_verified_offer_expired/, 'expired capability is rejected at receipt write time');
683
+ ledger.close();
684
+ rmSync(root, {
685
+ recursive: true,
686
+ force: true
687
+ });
688
+ }{
689
+ const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-expired-receipt-replay-'));
690
+ const ledger = ensureLedger(root);
691
+ const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
692
+ const before = checkoutWorkspace(0, capability);
693
+ const after = checkoutWorkspace(1, capability);
694
+ const writer = new BookingCopilotTaskRuntime(ledger, {
695
+ contextRefFactory: ()=>'ctx-v2',
696
+ now: ()=>'2026-09-01T10:00:00.000Z'
697
+ });
698
+ const t = writer.startTask({
699
+ ...turn('task-expired-receipt-replay'),
700
+ workspace: before
701
+ });
702
+ writer.issueOperation(t.taskId, {
703
+ ...action('historical-check'),
704
+ kind: 'offer.check',
705
+ input: {
706
+ offerRef: 'offer-a',
707
+ offerVersionRef: 'offer-a:v1'
708
+ }
709
+ });
710
+ const historicalReceipt = {
711
+ schemaVersion: 'booking.surface',
712
+ kind: 'action.receipt',
713
+ actionId: 'historical-check',
714
+ contextRef: 'ctx-v2',
715
+ status: 'applied',
716
+ revision: 1,
717
+ observation: {
718
+ kind: 'offer.availability',
719
+ offerRef: 'offer-a',
720
+ checkedOfferVersionRef: 'offer-a:v1',
721
+ currentOfferVersionRef: 'offer-a:v1',
722
+ verifiedOfferRef: capability.verifiedOfferRef,
723
+ available: true,
724
+ changedFactRefs: [],
725
+ gapCodes: []
726
+ },
727
+ resultContract: {
728
+ outcome: 'complete',
729
+ hardCriteriaMet: true,
730
+ factRefs: [],
731
+ gapCodes: [],
732
+ blockers: [],
733
+ relaxationsApplied: []
734
+ }
735
+ };
736
+ writer.continueWithReceipt({
737
+ schemaVersion: 'booking.surface',
738
+ kind: 'action.receipt.continuation',
739
+ taskId: t.taskId,
740
+ workspace: after,
741
+ receipt: historicalReceipt
742
+ });
743
+ const reader = new BookingCopilotTaskRuntime(ensureLedger(root), {
744
+ contextRefFactory: ()=>'ctx-v2',
745
+ now: ()=>'2026-09-01T11:00:00.000Z'
746
+ });
747
+ const replayed = reader.resumeTask(t.taskId);
748
+ assert.equal(replayed?.lastReceipt?.status, 'applied', 'historical accepted availability receipt remains replayable after capability expiry');
749
+ assert.equal(replayed?.lastReceipt?.observation.kind === 'offer.availability' ? replayed.lastReceipt.observation.verifiedOfferRef : undefined, capability.verifiedOfferRef);
750
+ reader['ledger'].close();
751
+ ledger.close();
752
+ rmSync(root, {
753
+ recursive: true,
754
+ force: true
755
+ });
756
+ }{
757
+ const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-partial-verified-offer-'));
758
+ const ledger = ensureLedger(root);
759
+ const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
760
+ const before = checkoutWorkspace(0, capability);
761
+ const after = checkoutWorkspace(1, capability);
762
+ const rt = new BookingCopilotTaskRuntime(ledger, {
763
+ contextRefFactory: ()=>'ctx-v2',
764
+ now: ()=>'2026-09-01T10:00:00.000Z'
765
+ });
766
+ const t = rt.startTask({
767
+ ...turn('task-partial-verified-offer'),
768
+ workspace: before
769
+ });
770
+ rt.issueOperation(t.taskId, {
771
+ ...action('partial-verified-check'),
772
+ kind: 'offer.check',
773
+ input: {
774
+ offerRef: 'offer-a',
775
+ offerVersionRef: 'offer-a:v1'
776
+ }
777
+ });
778
+ const partialReceipt = {
779
+ schemaVersion: 'booking.surface',
780
+ kind: 'action.receipt',
781
+ actionId: 'partial-verified-check',
782
+ contextRef: 'ctx-v2',
783
+ status: 'applied',
784
+ revision: 1,
785
+ observation: {
786
+ kind: 'offer.availability',
787
+ offerRef: 'offer-a',
788
+ checkedOfferVersionRef: 'offer-a:v1',
789
+ currentOfferVersionRef: 'offer-a:v1',
790
+ verifiedOfferRef: capability.verifiedOfferRef,
791
+ available: true,
792
+ changedFactRefs: [],
793
+ gapCodes: [
794
+ 'check_avail_unverified'
795
+ ]
796
+ },
797
+ resultContract: {
798
+ outcome: 'partial',
799
+ hardCriteriaMet: false,
800
+ factRefs: [],
801
+ gapCodes: [
802
+ 'check_avail_unverified'
803
+ ],
804
+ blockers: [],
805
+ relaxationsApplied: []
806
+ }
807
+ };
808
+ assert.throws(()=>rt.continueWithReceipt({
809
+ schemaVersion: 'booking.surface',
810
+ kind: 'action.receipt.continuation',
811
+ taskId: t.taskId,
812
+ workspace: after,
813
+ receipt: partialReceipt
814
+ }), /workspace_mismatch|receipt_verified|availability_receipt|invalid_receipt/, 'partial applied availability cannot publish a checkout-authorizing verified capability');
815
+ assert.throws(()=>rt.issueOperation(t.taskId, {
816
+ ...action('partial-verified-checkout'),
817
+ kind: 'checkout.prepare',
818
+ input: {
819
+ offerRef: 'offer-a',
820
+ offerVersionRef: 'offer-a:v1',
821
+ verifiedOfferRef: capability.verifiedOfferRef
822
+ }
823
+ }), /receipt_required|verified_offer|offer_version/, 'partial availability cannot authorize a later checkout');
824
+ ledger.close();
825
+ rmSync(root, {
826
+ recursive: true,
827
+ force: true
828
+ });
829
+ }const operation = runtime.issueOperation(task.taskId, action('action-v2'));
830
+ assert.equal(operation.action.actionId, 'action-v2');
831
+ assert.equal(operation.action.kind, 'search.run');
832
+ assert.equal(runtime.resumeTask(task.taskId)?.pendingAction?.actionId, 'action-v2');
833
+ assert.throws(()=>runtime.issueOperation(task.taskId, action('wrong-context', 0, {
834
+ contextRef: 'ctx-other'
835
+ })), /receipt_required/);
836
+ assert.throws(()=>runtime.continueWithReceipt({
837
+ schemaVersion: 'booking.surface',
838
+ kind: 'action.receipt.continuation',
839
+ taskId: task.taskId,
840
+ workspace: workspace(1),
841
+ receipt: {
842
+ schemaVersion: 'booking.surface',
843
+ kind: 'action.receipt',
844
+ actionId: 'wrong',
845
+ contextRef: 'ctx-v2',
846
+ status: 'applied',
847
+ revision: 1,
848
+ observation: {
849
+ kind: 'search.state',
850
+ resultCount: 1
851
+ },
852
+ resultContract: {
853
+ outcome: 'complete',
854
+ hardCriteriaMet: true,
855
+ factRefs: [],
856
+ gapCodes: [],
857
+ blockers: [],
858
+ relaxationsApplied: []
859
+ }
860
+ }
861
+ }), /receipt_action_mismatch/);
862
+ const receipt = {
863
+ schemaVersion: 'booking.surface',
864
+ kind: 'action.receipt',
865
+ actionId: 'action-v2',
866
+ contextRef: 'ctx-v2',
867
+ status: 'needs_input',
868
+ revision: 1,
869
+ observation: {
870
+ kind: 'gap',
871
+ code: 'criterion_must_not_met',
872
+ factRefs: [
873
+ 'fact-v2'
874
+ ]
875
+ },
876
+ resultContract: {
877
+ outcome: 'partial',
878
+ hardCriteriaMet: false,
879
+ factRefs: [
880
+ 'fact-v2'
881
+ ],
882
+ gapCodes: [
883
+ 'criterion_must_not_met'
884
+ ],
885
+ blockers: [
886
+ {
887
+ blockerId: 'blocker-v2',
888
+ sourceActionId: 'action-v2',
889
+ sourceReceiptDigest: '',
890
+ scope: 'search',
891
+ code: 'criterion_must_not_met',
892
+ criterionPath: 'searchDraft.destination',
893
+ strength: 'must',
894
+ valueDigest: 'b'.repeat(64),
895
+ valueLabel: 'Dubai',
896
+ evidence: {
897
+ factRefs: [
898
+ 'fact-v2'
899
+ ],
900
+ gapCodes: [
901
+ 'criterion_must_not_met'
902
+ ]
903
+ }
904
+ }
905
+ ],
906
+ relaxationsApplied: []
907
+ }
908
+ };
909
+ const receiptWithDigest = runtime.withReceiptDigest(receipt);
910
+ const afterReceipt = runtime.continueWithReceipt({
911
+ schemaVersion: 'booking.surface',
912
+ kind: 'action.receipt.continuation',
913
+ taskId: task.taskId,
914
+ workspace: workspace(1),
915
+ receipt: receiptWithDigest
916
+ });
917
+ assert.equal(afterReceipt.awaitingApproval?.blocker.blockerId, 'blocker-v2');
918
+ const offerRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-target-'));
919
+ const offerRuntime = new BookingCopilotTaskRuntime(ensureLedger(offerRoot), {
920
+ contextRefFactory: ()=>'ctx-v2'
921
+ });
922
+ const offerTurn = turn('task-offer-target');
923
+ const offerTask = offerRuntime.startTask({
924
+ ...offerTurn,
925
+ workspace: {
926
+ ...offerTurn.workspace,
927
+ visibleHotels: [
928
+ {
929
+ hotelRef: 'hotel-a',
930
+ name: 'Hotel A',
931
+ factRefs: []
932
+ }
933
+ ],
934
+ loadedOffers: [
935
+ loadedOffer('offer-a', 'hotel-a')
936
+ ]
937
+ }
938
+ });
939
+ offerRuntime.issueOperation(offerTask.taskId, {
940
+ ...action('offer-action'),
941
+ kind: 'offer.check',
942
+ input: {
943
+ offerRef: 'offer-a',
944
+ offerVersionRef: 'offer-a:v1'
945
+ }
946
+ });
947
+ const offerReceipt = offerRuntime.withReceiptDigest({
948
+ ...receipt,
949
+ actionId: 'offer-action',
950
+ observation: availabilityObservation('offer-b', 'offer-b:v1', true),
951
+ resultContract: {
952
+ ...receipt.resultContract,
953
+ blockers: [],
954
+ gapCodes: []
955
+ }
956
+ });
957
+ const offerWorkspace1 = {
958
+ ...offerTurn.workspace,
959
+ revision: 1,
960
+ visibleHotels: [
961
+ {
962
+ hotelRef: 'hotel-a',
963
+ name: 'Hotel A',
964
+ factRefs: []
965
+ }
966
+ ],
967
+ loadedOffers: [
968
+ loadedOffer('offer-a', 'hotel-a')
969
+ ]
970
+ };
971
+ assert.throws(()=>offerRuntime.continueWithReceipt({
972
+ schemaVersion: 'booking.surface',
973
+ kind: 'action.receipt.continuation',
974
+ taskId: offerTask.taskId,
975
+ workspace: offerWorkspace1,
976
+ receipt: offerReceipt
977
+ }), /receipt_target_mismatch/);
978
+ const offerCheckWorkspace = (revision, selectedOfferRef, shortlistedOfferRefs = [
979
+ 'offer-cleanup',
980
+ 'offer-peer'
981
+ ], verifiedOffer)=>({
982
+ ...workspace(revision),
983
+ visibleHotels: [
984
+ {
985
+ hotelRef: 'hotel-cleanup',
986
+ name: 'Hotel Cleanup',
987
+ factRefs: []
988
+ }
989
+ ],
990
+ loadedOffers: [
991
+ loadedOffer('offer-cleanup', 'hotel-cleanup'),
992
+ loadedOffer('offer-peer', 'hotel-cleanup')
993
+ ],
994
+ selectedOfferRef,
995
+ shortlistedOfferRefs,
996
+ ...verifiedOffer ? {
997
+ verifiedOffer
998
+ } : {}
999
+ });
1000
+ const checkReceipt = (actionId, status, available = status === 'changed')=>({
1001
+ schemaVersion: 'booking.surface',
1002
+ kind: 'action.receipt',
1003
+ actionId,
1004
+ contextRef: 'ctx-v2',
1005
+ status,
1006
+ revision: 1,
1007
+ observation: {
1008
+ kind: 'offer.availability',
1009
+ offerRef: 'offer-cleanup',
1010
+ checkedOfferVersionRef: 'offer-cleanup:v1',
1011
+ currentOfferVersionRef: available ? status === 'changed' ? 'offer-cleanup:v2' : 'offer-cleanup:v1' : undefined,
1012
+ available,
1013
+ verifiedOfferRef: available && status !== 'changed' ? 'verified-offer-cleanup' : undefined,
1014
+ changedFactRefs: status === 'changed' ? [
1015
+ 'price'
1016
+ ] : [],
1017
+ gapCodes: []
1018
+ },
1019
+ resultContract: {
1020
+ outcome: status === 'changed' ? 'partial' : available ? 'complete' : status === 'unavailable' || status === 'no_match' ? 'empty' : 'partial',
1021
+ hardCriteriaMet: available && status !== 'changed',
1022
+ factRefs: [],
1023
+ gapCodes: [],
1024
+ blockers: [],
1025
+ relaxationsApplied: []
1026
+ }
1027
+ });
1028
+ const assertOfferCheckContinuation = (name, before, after, receipt, pattern)=>{
1029
+ const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-${name}-`));
1030
+ const rt = new BookingCopilotTaskRuntime(ensureLedger(root), {
1031
+ contextRefFactory: ()=>'ctx-v2',
1032
+ now: ()=>'2026-09-01T10:00:00.000Z'
1033
+ });
1034
+ const t = rt.startTask({
1035
+ ...turn(`task-${name}`),
1036
+ workspace: before
1037
+ });
1038
+ rt.issueOperation(t.taskId, {
1039
+ ...action(`${name}-action`),
1040
+ kind: 'offer.check',
1041
+ input: {
1042
+ offerRef: 'offer-cleanup',
1043
+ offerVersionRef: 'offer-cleanup:v1'
1044
+ }
1045
+ });
1046
+ if (pattern) assert.throws(()=>rt.continueWithReceipt({
1047
+ schemaVersion: 'booking.surface',
1048
+ kind: 'action.receipt.continuation',
1049
+ taskId: t.taskId,
1050
+ workspace: after,
1051
+ receipt: {
1052
+ ...receipt,
1053
+ actionId: `${name}-action`
1054
+ }
1055
+ }), pattern);
1056
+ else rt.continueWithReceipt({
1057
+ schemaVersion: 'booking.surface',
1058
+ kind: 'action.receipt.continuation',
1059
+ taskId: t.taskId,
1060
+ workspace: after,
1061
+ receipt: {
1062
+ ...receipt,
1063
+ actionId: `${name}-action`
1064
+ }
1065
+ });
1066
+ rt['ledger'].close();
1067
+ rmSync(root, {
1068
+ recursive: true,
1069
+ force: true
1070
+ });
1071
+ };
1072
+ assertOfferCheckContinuation('offer-success-first-check', offerCheckWorkspace(0, undefined), offerCheckWorkspace(1, 'offer-cleanup', [
1073
+ 'offer-cleanup',
1074
+ 'offer-peer'
1075
+ ], verifiedCapability('offer-cleanup')), checkReceipt('offer-success-first-check-action', 'applied', true));
1076
+ assertOfferCheckContinuation('offer-success-peer-to-checked', offerCheckWorkspace(0, 'offer-peer'), offerCheckWorkspace(1, 'offer-cleanup', [
1077
+ 'offer-cleanup',
1078
+ 'offer-peer'
1079
+ ], verifiedCapability('offer-cleanup')), checkReceipt('offer-success-peer-to-checked-action', 'applied', true));
1080
+ assertOfferCheckContinuation('offer-success-cleared-forbidden', offerCheckWorkspace(0, 'offer-peer'), offerCheckWorkspace(1, undefined, [
1081
+ 'offer-peer'
1082
+ ], verifiedCapability('offer-cleanup')), checkReceipt('offer-success-cleared-forbidden-action', 'applied', true), /workspace_mismatch/);
1083
+ const cleanupRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-cleanup-'));
1084
+ const cleanupRuntime = new BookingCopilotTaskRuntime(ensureLedger(cleanupRoot), {
1085
+ contextRefFactory: ()=>'ctx-v2'
1086
+ });
1087
+ const cleanupWorkspace0 = {
1088
+ ...workspace(0),
1089
+ visibleHotels: [
1090
+ {
1091
+ hotelRef: 'hotel-cleanup',
1092
+ name: 'Hotel Cleanup',
1093
+ factRefs: []
1094
+ }
1095
+ ],
1096
+ loadedOffers: [
1097
+ loadedOffer('offer-cleanup', 'hotel-cleanup'),
1098
+ loadedOffer('offer-peer', 'hotel-cleanup')
1099
+ ],
1100
+ selectedOfferRef: 'offer-cleanup',
1101
+ shortlistedOfferRefs: [
1102
+ 'offer-cleanup',
1103
+ 'offer-peer'
1104
+ ],
1105
+ verifiedOffer: verifiedCapability('offer-cleanup', 'offer-cleanup:v1', '2026-09-01T10:30:00.000Z')
1106
+ };
1107
+ const cleanupTask = cleanupRuntime.startTask({
1108
+ ...turn('task-offer-cleanup'),
1109
+ workspace: cleanupWorkspace0
1110
+ });
1111
+ const cleanupAction = {
1112
+ ...action('offer-cleanup-action'),
1113
+ kind: 'offer.check',
1114
+ input: {
1115
+ offerRef: 'offer-cleanup',
1116
+ offerVersionRef: 'offer-cleanup:v1'
1117
+ }
1118
+ };
1119
+ cleanupRuntime.issueOperation(cleanupTask.taskId, cleanupAction);
1120
+ const cleanupReceipt = {
1121
+ schemaVersion: 'booking.surface',
1122
+ kind: 'action.receipt',
1123
+ actionId: 'offer-cleanup-action',
1124
+ contextRef: 'ctx-v2',
1125
+ status: 'unavailable',
1126
+ revision: 1,
1127
+ observation: {
1128
+ kind: 'offer.availability',
1129
+ offerRef: 'offer-cleanup',
1130
+ checkedOfferVersionRef: 'offer-cleanup:v1',
1131
+ available: false,
1132
+ changedFactRefs: [],
1133
+ gapCodes: []
1134
+ },
1135
+ resultContract: {
1136
+ outcome: 'empty',
1137
+ hardCriteriaMet: false,
1138
+ factRefs: [],
1139
+ gapCodes: [],
1140
+ blockers: [],
1141
+ relaxationsApplied: []
1142
+ }
1143
+ };
1144
+ const cleanupWorkspace1 = {
1145
+ ...cleanupWorkspace0,
1146
+ revision: 1,
1147
+ selectedOfferRef: undefined,
1148
+ shortlistedOfferRefs: [
1149
+ 'offer-peer'
1150
+ ],
1151
+ verifiedOffer: undefined
1152
+ };
1153
+ cleanupRuntime.continueWithReceipt({
1154
+ schemaVersion: 'booking.surface',
1155
+ kind: 'action.receipt.continuation',
1156
+ taskId: cleanupTask.taskId,
1157
+ workspace: cleanupWorkspace1,
1158
+ receipt: cleanupReceipt
1159
+ });
1160
+ assert.equal(cleanupRuntime.resumeTask(cleanupTask.taskId)?.lastReceipt?.status, 'unavailable', 'offer.check can clear only the checked selection/shortlist and stale verification while preserving loaded offer evidence');
1161
+ assertOfferCheckContinuation('offer-negative-kept-checked-forbidden', cleanupWorkspace0, {
1162
+ ...cleanupWorkspace1,
1163
+ selectedOfferRef: 'offer-cleanup',
1164
+ shortlistedOfferRefs: [
1165
+ 'offer-cleanup',
1166
+ 'offer-peer'
1167
+ ]
1168
+ }, cleanupReceipt, /workspace_mismatch/);
1169
+ const cleanupChangedWorkspace1 = {
1170
+ ...cleanupWorkspace0,
1171
+ revision: 1,
1172
+ loadedOffers: [
1173
+ {
1174
+ ...loadedOffer('offer-cleanup', 'hotel-cleanup', 'offer-cleanup:v2'),
1175
+ factRefs: [
1176
+ 'price'
1177
+ ]
1178
+ },
1179
+ loadedOffer('offer-peer', 'hotel-cleanup')
1180
+ ],
1181
+ verifiedOffer: undefined
1182
+ };
1183
+ const changedRuntimeRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-changed-runtime-'));
1184
+ const changedRuntime = new BookingCopilotTaskRuntime(ensureLedger(changedRuntimeRoot), {
1185
+ contextRefFactory: ()=>'ctx-v2'
1186
+ });
1187
+ const changedTask = changedRuntime.startTask({
1188
+ ...turn('task-offer-changed-runtime'),
1189
+ workspace: cleanupWorkspace0
1190
+ });
1191
+ changedRuntime.issueOperation(changedTask.taskId, {
1192
+ ...action('offer-changed-seed-query'),
1193
+ kind: 'offers.query',
1194
+ input: {
1195
+ hotelRefs: [
1196
+ 'hotel-cleanup'
1197
+ ],
1198
+ criteria: {}
1199
+ }
1200
+ });
1201
+ const changedSeedWorkspace = {
1202
+ ...cleanupWorkspace0,
1203
+ revision: 1
1204
+ };
1205
+ changedRuntime.continueWithReceipt({
1206
+ schemaVersion: 'booking.surface',
1207
+ kind: 'action.receipt.continuation',
1208
+ taskId: changedTask.taskId,
1209
+ workspace: changedSeedWorkspace,
1210
+ receipt: {
1211
+ schemaVersion: 'booking.surface',
1212
+ kind: 'action.receipt',
1213
+ actionId: 'offer-changed-seed-query',
1214
+ contextRef: 'ctx-v2',
1215
+ status: 'applied',
1216
+ revision: 1,
1217
+ observation: {
1218
+ kind: 'offers.state',
1219
+ hotelRefs: [
1220
+ 'hotel-cleanup'
1221
+ ],
1222
+ offerRefs: [
1223
+ 'offer-cleanup',
1224
+ 'offer-peer'
1225
+ ],
1226
+ loadedHotelCount: 1
1227
+ },
1228
+ resultContract: {
1229
+ outcome: 'complete',
1230
+ hardCriteriaMet: true,
1231
+ factRefs: [],
1232
+ gapCodes: [],
1233
+ blockers: [],
1234
+ relaxationsApplied: []
1235
+ }
1236
+ }
1237
+ });
1238
+ changedRuntime.issueOperation(changedTask.taskId, {
1239
+ ...action('offer-changed-runtime-check', 1),
1240
+ kind: 'offer.check',
1241
+ input: {
1242
+ offerRef: 'offer-cleanup',
1243
+ offerVersionRef: 'offer-cleanup:v1'
1244
+ }
1245
+ });
1246
+ changedRuntime.continueWithReceipt({
1247
+ schemaVersion: 'booking.surface',
1248
+ kind: 'action.receipt.continuation',
1249
+ taskId: changedTask.taskId,
1250
+ workspace: {
1251
+ ...cleanupChangedWorkspace1,
1252
+ revision: 2
1253
+ },
1254
+ receipt: {
1255
+ ...checkReceipt('offer-changed-runtime-check', 'changed'),
1256
+ revision: 2
1257
+ }
1258
+ });
1259
+ assert.equal(changedRuntime.resumeTask(changedTask.taskId)?.lastReceipt?.status, 'changed', 'runtime persists changed availability against a replacement offer version');
1260
+ changedRuntime['ledger'].close();
1261
+ rmSync(changedRuntimeRoot, {
1262
+ recursive: true,
1263
+ force: true
1264
+ });
1265
+ const directChangedRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-direct-offer-changed-'));
1266
+ const directChangedRuntime = new BookingCopilotTaskRuntime(ensureLedger(directChangedRoot), {
1267
+ contextRefFactory: ()=>'ctx-v2'
1268
+ });
1269
+ const directChangedTask = directChangedRuntime.startTask({
1270
+ ...turn('task-direct-offer-changed'),
1271
+ workspace: cleanupWorkspace0
1272
+ });
1273
+ directChangedRuntime.issueOperation(directChangedTask.taskId, {
1274
+ ...action('direct-offer-check-v1'),
1275
+ kind: 'offer.check',
1276
+ input: {
1277
+ offerRef: 'offer-cleanup',
1278
+ offerVersionRef: 'offer-cleanup:v1'
1279
+ }
1280
+ });
1281
+ const directChangedWorkspace = {
1282
+ ...cleanupChangedWorkspace1,
1283
+ revision: 1
1284
+ };
1285
+ directChangedRuntime.continueWithReceipt({
1286
+ schemaVersion: 'booking.surface',
1287
+ kind: 'action.receipt.continuation',
1288
+ taskId: directChangedTask.taskId,
1289
+ workspace: directChangedWorkspace,
1290
+ receipt: checkReceipt('direct-offer-check-v1', 'changed')
1291
+ });
1292
+ assert.equal(directChangedRuntime.resumeTask(directChangedTask.taskId)?.lastReceipt?.status, 'changed', 'direct first CheckAvail changed receipt is durable');
1293
+ const directChangedRestart = new BookingCopilotTaskRuntime(ensureLedger(directChangedRoot), {
1294
+ contextRefFactory: ()=>'ctx-v2'
1295
+ });
1296
+ const resumedDirect = directChangedRestart.resumeTask(directChangedTask.taskId);
1297
+ assert.equal(resumedDirect?.lastReceipt?.observation.kind, 'offer.availability');
1298
+ assert.equal(resumedDirect?.lastReceipt?.observation.kind === 'offer.availability' ? resumedDirect.lastReceipt.observation.currentOfferVersionRef : undefined, 'offer-cleanup:v2', 'restart retains replacement offer version');
1299
+ const recheckV2 = {
1300
+ ...action('direct-offer-check-v2', 1),
1301
+ kind: 'offer.check',
1302
+ input: {
1303
+ offerRef: 'offer-cleanup',
1304
+ offerVersionRef: 'offer-cleanup:v2'
1305
+ }
1306
+ };
1307
+ directChangedRestart.issueOperation(directChangedTask.taskId, recheckV2);
1308
+ directChangedRuntime['ledger'].close();
1309
+ directChangedRestart['ledger'].close();
1310
+ rmSync(directChangedRoot, {
1311
+ recursive: true,
1312
+ force: true
1313
+ });
1314
+ assertOfferCheckContinuation('offer-no-match-cleanup', cleanupWorkspace0, cleanupWorkspace1, {
1315
+ ...cleanupReceipt,
1316
+ status: 'no_match'
1317
+ });
1318
+ assertOfferCheckContinuation('offer-failed-drift-forbidden', cleanupWorkspace0, cleanupWorkspace1, {
1319
+ ...cleanupReceipt,
1320
+ status: 'failed',
1321
+ resultContract: {
1322
+ ...cleanupReceipt.resultContract,
1323
+ outcome: 'partial'
1324
+ }
1325
+ }, /workspace_mismatch/);
1326
+ assertOfferCheckContinuation('offer-stale-drift-forbidden', cleanupWorkspace0, cleanupWorkspace1, {
1327
+ ...cleanupReceipt,
1328
+ status: 'stale',
1329
+ resultContract: {
1330
+ ...cleanupReceipt.resultContract,
1331
+ outcome: 'partial'
1332
+ }
1333
+ }, /workspace_mismatch/);
1334
+ assertOfferCheckContinuation('offer-unsupported-drift-forbidden', cleanupWorkspace0, cleanupWorkspace1, {
1335
+ ...cleanupReceipt,
1336
+ status: 'unsupported',
1337
+ resultContract: {
1338
+ ...cleanupReceipt.resultContract,
1339
+ outcome: 'partial'
1340
+ }
1341
+ }, /workspace_mismatch/);
1342
+ const replacedRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-cleanup-replaced-'));
1343
+ const replacedRuntime = new BookingCopilotTaskRuntime(ensureLedger(replacedRoot), {
1344
+ contextRefFactory: ()=>'ctx-v2'
1345
+ });
1346
+ const replacedTask = replacedRuntime.startTask({
1347
+ ...turn('task-offer-cleanup-replaced'),
1348
+ workspace: cleanupWorkspace0
1349
+ });
1350
+ replacedRuntime.issueOperation(replacedTask.taskId, {
1351
+ ...cleanupAction,
1352
+ actionId: 'offer-cleanup-replaced'
1353
+ });
1354
+ assert.throws(()=>replacedRuntime.continueWithReceipt({
1355
+ schemaVersion: 'booking.surface',
1356
+ kind: 'action.receipt.continuation',
1357
+ taskId: replacedTask.taskId,
1358
+ workspace: {
1359
+ ...cleanupWorkspace1,
1360
+ selectedOfferRef: 'offer-peer'
1361
+ },
1362
+ receipt: {
1363
+ ...cleanupReceipt,
1364
+ actionId: 'offer-cleanup-replaced'
1365
+ }
1366
+ }), /workspace_mismatch/, 'offer.check cannot replace selection with a different offer');
1367
+ const loadedDriftRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-cleanup-loaded-drift-'));
1368
+ const loadedDriftRuntime = new BookingCopilotTaskRuntime(ensureLedger(loadedDriftRoot), {
1369
+ contextRefFactory: ()=>'ctx-v2'
1370
+ });
1371
+ const loadedDriftTask = loadedDriftRuntime.startTask({
1372
+ ...turn('task-offer-cleanup-loaded-drift'),
1373
+ workspace: cleanupWorkspace0
1374
+ });
1375
+ loadedDriftRuntime.issueOperation(loadedDriftTask.taskId, {
1376
+ ...cleanupAction,
1377
+ actionId: 'offer-cleanup-loaded-drift'
1378
+ });
1379
+ assert.throws(()=>loadedDriftRuntime.continueWithReceipt({
1380
+ schemaVersion: 'booking.surface',
1381
+ kind: 'action.receipt.continuation',
1382
+ taskId: loadedDriftTask.taskId,
1383
+ workspace: {
1384
+ ...cleanupWorkspace1,
1385
+ loadedOffers: [
1386
+ loadedOffer('offer-peer', 'hotel-cleanup')
1387
+ ]
1388
+ },
1389
+ receipt: {
1390
+ ...cleanupReceipt,
1391
+ actionId: 'offer-cleanup-loaded-drift'
1392
+ }
1393
+ }), /workspace_mismatch/, 'offer.check cannot mutate loadedOffers evidence');
1394
+ cleanupRuntime['ledger'].close();
1395
+ replacedRuntime['ledger'].close();
1396
+ loadedDriftRuntime['ledger'].close();
1397
+ rmSync(cleanupRoot, {
1398
+ recursive: true,
1399
+ force: true
1400
+ });
1401
+ rmSync(replacedRoot, {
1402
+ recursive: true,
1403
+ force: true
1404
+ });
1405
+ rmSync(loadedDriftRoot, {
1406
+ recursive: true,
1407
+ force: true
1408
+ });
1409
+ const approvalQuestion = runtime.approvalQuestion(task.taskId);
1410
+ assert.equal(approvalQuestion.kind, 'question');
1411
+ if (approvalQuestion.kind === 'question') {
1412
+ assert.equal(approvalQuestion.question.blocker.blockerId, 'blocker-v2');
1413
+ assert.equal(approvalQuestion.question.approvalOptions[0]?.approval.sourceActionId, 'action-v2');
1414
+ }
1415
+ const awaitingRestart = new BookingCopilotTaskRuntime(ensureLedger(stateRoot), {
1416
+ now: ()=>'2026-09-01T10:00:00.000Z'
1417
+ });
1418
+ assert.equal(awaitingRestart.resumeTask(task.taskId)?.awaitingApproval?.blocker.blockerId, 'blocker-v2');
1419
+ const approval = approvalQuestion.kind === 'question' ? approvalQuestion.question.approvalOptions[0].approval : (()=>{
1420
+ throw new Error('missing approval option');
1421
+ })();
1422
+ assert.throws(()=>runtime.startTask({
1423
+ ...turn(task.taskId, 1),
1424
+ request: {
1425
+ text: 'relax destination',
1426
+ approval
1427
+ }
1428
+ }), /approval_not_presented/);
1429
+ const awaitingBeforeBatch = runtime.resumeTask(task.taskId)?.awaitingApproval;
1430
+ assert.ok(awaitingBeforeBatch);
1431
+ const approvalPresentationKey = runtime.approvalPresentationRequestKey(task.taskId);
1432
+ runtime.ensureApprovalOffered(task.taskId, task.contextRef, awaitingBeforeBatch, approvalPresentationKey);
1433
+ assert.equal(runtime.resumeTask(task.taskId)?.awaitingApproval?.optionsEmitted, false);
1434
+ assert.throws(()=>runtime.startTask({
1435
+ ...turn(task.taskId, 1),
1436
+ request: {
1437
+ text: 'relax destination',
1438
+ approval
1439
+ }
1440
+ }), /approval_not_presented/);
1441
+ assert.throws(()=>runtime.commitDecisionBatch(task.taskId, 'turn:unrelated-turn', [
1442
+ approvalQuestion
1443
+ ]), /approval_presentation_key_mismatch/);
1444
+ assert.throws(()=>runtime.commitDecisionBatch(task.taskId, `approval:other-task:turn-1:action-v2:${'c'.repeat(64)}`, [
1445
+ approvalQuestion
1446
+ ]), /approval_presentation_key_mismatch/);
1447
+ const alteredSourceTurnQuestion = approvalQuestion.kind === 'question' ? {
1448
+ ...approvalQuestion,
1449
+ question: {
1450
+ ...approvalQuestion.question,
1451
+ approvalOptions: approvalQuestion.question.approvalOptions.map(({ approval: option })=>({
1452
+ approval: {
1453
+ ...option,
1454
+ sourceTurnId: 'turn-forged'
1455
+ }
1456
+ }))
1457
+ }
1458
+ } : approvalQuestion;
1459
+ assert.throws(()=>runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [
1460
+ alteredSourceTurnQuestion
1461
+ ]), /approval_presentation_key_mismatch/);
1462
+ const alteredReceiptQuestion = approvalQuestion.kind === 'question' ? {
1463
+ ...approvalQuestion,
1464
+ question: {
1465
+ ...approvalQuestion.question,
1466
+ approvalOptions: approvalQuestion.question.approvalOptions.map(({ approval: option })=>({
1467
+ approval: {
1468
+ ...option,
1469
+ sourceReceiptDigest: 'd'.repeat(64)
1470
+ }
1471
+ }))
1472
+ }
1473
+ } : approvalQuestion;
1474
+ assert.throws(()=>runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [
1475
+ alteredReceiptQuestion
1476
+ ]), /approval_presentation_key_mismatch/);
1477
+ const alteredOptionQuestion = approvalQuestion.kind === 'question' ? {
1478
+ ...approvalQuestion,
1479
+ question: {
1480
+ ...approvalQuestion.question,
1481
+ approvalOptions: approvalQuestion.question.approvalOptions.map(({ approval: option })=>({
1482
+ approval: {
1483
+ ...option,
1484
+ optionDigest: 'e'.repeat(64)
1485
+ }
1486
+ }))
1487
+ }
1488
+ } : approvalQuestion;
1489
+ assert.throws(()=>runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [
1490
+ alteredOptionQuestion
1491
+ ]), /approval_presentation_key_mismatch/);
1492
+ runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [
1493
+ approvalQuestion
1494
+ ]);
1495
+ assert.equal(runtime.resumeTask(task.taskId)?.awaitingApproval?.optionsEmitted, true, 'question batch is the presentation authority');
1496
+ assert.throws(()=>runtime.startTask({
1497
+ ...turn(task.taskId, 1),
1498
+ request: {
1499
+ text: 'relax destination',
1500
+ approval: {
1501
+ ...approval,
1502
+ deliveryNonce: 'forged-delivery-nonce'
1503
+ }
1504
+ }
1505
+ }), /approval_mismatch/);
1506
+ runtime.startTask({
1507
+ ...turn(task.taskId, 1),
1508
+ request: {
1509
+ text: 'relax destination',
1510
+ approval
1511
+ }
1512
+ });
1513
+ const approvalEventCount = runtime['ledger'].countEvents();
1514
+ runtime.startTask({
1515
+ ...turn(task.taskId, 1),
1516
+ request: {
1517
+ text: 'relax destination',
1518
+ approval
1519
+ }
1520
+ });
1521
+ assert.equal(runtime['ledger'].countEvents(), approvalEventCount, 'identical durable approval retry is idempotent');
1522
+ assert.throws(()=>runtime.startTask({
1523
+ ...turn(task.taskId, 1),
1524
+ turnId: 'tampered-approval-turn',
1525
+ request: {
1526
+ text: 'relax destination',
1527
+ approval: {
1528
+ ...approval,
1529
+ to: 'drop'
1530
+ }
1531
+ }
1532
+ }), /approval_mismatch/);
1533
+ const expiredRuntime = new BookingCopilotTaskRuntime(ensureLedger(stateRoot), {
1534
+ now: ()=>'2026-09-01T10:03:00.000Z'
1535
+ });
1536
+ assert.throws(()=>expiredRuntime.issueOperation(task.taskId, action('action-v2-expired', 1)), /approval_expired/);
1537
+ const approvedOperation = runtime.issueOperation(task.taskId, action('action-v2-relaxed', 1));
1538
+ assert.equal(approvedOperation.action.relaxationApprovalRef?.targetActionId, 'action-v2-relaxed');
1539
+ assert.equal(approvedOperation.action.relaxationApprovalRef?.targetActionKind, 'search.run');
1540
+ assert.throws(()=>runtime.issueOperation(task.taskId, action('action-v2-relaxed-again', 1)), /receipt_required/);
1541
+ assert.throws(()=>runtime.assertApprovalNotConsumed(task.taskId, approval), /approval_replayed/);
1542
+ const reopened = new BookingCopilotTaskRuntime(ensureLedger(stateRoot), {
1543
+ now: ()=>'2026-09-01T10:00:00.000Z'
1544
+ });
1545
+ assert.equal(reopened.resumeTask(task.taskId)?.lastReceipt?.actionId, 'action-v2');
1546
+ assert.equal(reopened.resumeTask(task.taskId)?.pendingAction?.actionId, 'action-v2-relaxed');
1547
+ assert.throws(()=>reopened.issueOperation(task.taskId, {
1548
+ ...action('forged-ref', 1),
1549
+ relaxationApprovalRef: {
1550
+ ...approvedOperation.action.relaxationApprovalRef,
1551
+ targetActionId: 'forged-ref'
1552
+ }
1553
+ }), /receipt_required/);
1554
+ const forgedTask = runtime.startTask(turn('task-forged', 1));
1555
+ assert.throws(()=>runtime.issueOperation(forgedTask.taskId, action('forged-ref', 1, {
1556
+ relaxationApprovalRef: {
1557
+ ...approvedOperation.action.relaxationApprovalRef,
1558
+ targetActionId: 'forged-ref'
1559
+ }
1560
+ })), /approval_ref_planner_owned_forbidden/);
1561
+ const forgedSourceRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-forged-source-'));
1562
+ const forgedSourceRuntime = new BookingCopilotTaskRuntime(ensureLedger(forgedSourceRoot), {
1563
+ contextRefFactory: ()=>'ctx-v2'
1564
+ });
1565
+ const forgedSourceTask = forgedSourceRuntime.startTask(turn('task-forged-source'));
1566
+ forgedSourceRuntime.issueOperation(forgedSourceTask.taskId, action('action-real'));
1567
+ const forgedSourceReceipt = forgedSourceRuntime.withReceiptDigest({
1568
+ ...receipt,
1569
+ actionId: 'action-real',
1570
+ resultContract: {
1571
+ ...receipt.resultContract,
1572
+ blockers: [
1573
+ {
1574
+ ...receipt.resultContract.blockers[0],
1575
+ sourceActionId: 'action-forged'
1576
+ }
1577
+ ]
1578
+ }
1579
+ });
1580
+ assert.throws(()=>forgedSourceRuntime.continueWithReceipt({
1581
+ schemaVersion: 'booking.surface',
1582
+ kind: 'action.receipt.continuation',
1583
+ taskId: forgedSourceTask.taskId,
1584
+ workspace: workspace(1),
1585
+ receipt: forgedSourceReceipt
1586
+ }), /receipt_source_action_mismatch/);
1587
+ assert.equal(forgedSourceRuntime.resumeTask(forgedSourceTask.taskId)?.pendingAction?.actionId, 'action-real', 'forged blocker is rejected before receipt append');
1588
+ const tamperRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-tamper-'));
1589
+ const tamperLedger = ensureLedger(tamperRoot);
1590
+ const tamperRuntime = new BookingCopilotTaskRuntime(tamperLedger);
1591
+ tamperRuntime.startTask(turn('task-tamper'));
1592
+ tamperLedger.insertEvent({
1593
+ actor: 'tamper',
1594
+ kind: 'booking.copilot.user.turn.observed',
1595
+ subjectId: 'task-tamper',
1596
+ runId: 'task-tamper',
1597
+ idemKey: 'tamper-turn',
1598
+ payload: {
1599
+ schema: 'booking.copilot.ledger',
1600
+ taskId: 'task-tamper',
1601
+ contextRef: 'ctx-v2',
1602
+ requestDigest: 'tampered',
1603
+ approval: {
1604
+ forged: true
1605
+ }
1606
+ }
1607
+ });
1608
+ assert.throws(()=>tamperRuntime.resumeTask('task-tamper'), /ledger_corrupt:task-tamper:turn_approval/);
1609
+ const serverRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-server-'));
1610
+ let serverId = 0;
1611
+ const serverRuntime = new BookingCopilotTaskRuntime(ensureLedger(serverRoot), {
1612
+ idFactory: (prefix)=>`${prefix}-server-${++serverId}`,
1613
+ contextRefFactory: ()=>'ctx-server'
1614
+ });
1615
+ let factoryCalls = 0;
1616
+ let plannerCalls = 0;
1617
+ let resolveApprovalPlannerEntered;
1618
+ const approvalPlannerEntered = new Promise((resolve)=>{
1619
+ resolveApprovalPlannerEntered = resolve;
1620
+ });
1621
+ let releaseApprovalPlanner;
1622
+ const approvalPlannerRelease = new Promise((resolve)=>{
1623
+ releaseApprovalPlanner = resolve;
1624
+ });
1625
+ let approvalPlannerDeferred = false;
1626
+ const server = await startBookingCopilotServer({
1627
+ apiKey: 'v2-server-key',
1628
+ runtime: serverRuntime,
1629
+ principal: {
1630
+ subject: 'bff-principal-a',
1631
+ scope: 'booking:read'
1632
+ },
1633
+ ingressBinding: {
1634
+ bind: (input)=>({
1635
+ taskId: 'task-server',
1636
+ turnId: `turn-${input.requestKey}`,
1637
+ contextRef: 'ctx-server',
1638
+ surface: 'tenant',
1639
+ allowedActions: [
1640
+ ...BOOKING_READ_ACTION_KINDS
1641
+ ]
1642
+ })
1643
+ },
1644
+ ingressMode: 'bff-ingress-binding',
1645
+ plannerFactory: (initial)=>{
1646
+ factoryCalls++;
1647
+ return {
1648
+ next: async ({ turn, task: current })=>{
1649
+ plannerCalls++;
1650
+ if (turn.kind === 'user.turn' && turn.request.approval && !approvalPlannerDeferred) {
1651
+ approvalPlannerDeferred = true;
1652
+ resolveApprovalPlannerEntered();
1653
+ await approvalPlannerRelease;
1654
+ }
1655
+ const decision = {
1656
+ kind: 'operation',
1657
+ action: {
1658
+ ...action(`server-action-${plannerCalls}`, current.revision),
1659
+ contextRef: current.contextRef
1660
+ }
1661
+ };
1662
+ return [
1663
+ decision
1664
+ ];
1665
+ }
1666
+ };
1667
+ }
1668
+ });
1669
+ const endpoint = `http://127.0.0.1:${server.port}/a2a/booking-copilot/turn`;
1670
+ const headers = {
1671
+ authorization: 'Bearer v2-server-key',
1672
+ 'content-type': 'application/json',
1673
+ 'x-booking-surface-version': BOOKING_SURFACE_SCHEMA_VERSION,
1674
+ 'x-booking-surface-schema-sha256': BOOKING_SURFACE_SCHEMA_SHA256
1675
+ };
1676
+ const ingress = {
1677
+ schemaVersion: 'booking.surface',
1678
+ kind: 'user.turn.ingress',
1679
+ requestKey: 'ingress-request-1',
1680
+ taskHandle: 'opaque-task-handle-1',
1681
+ surfaceHint: 'tenant',
1682
+ workspace: {
1683
+ schemaVersion: 'booking.surface',
1684
+ revision: 0,
1685
+ locale: 'en-US',
1686
+ currency: 'AED',
1687
+ searchDraft: {},
1688
+ results: {
1689
+ status: 'idle'
1690
+ },
1691
+ visibleHotels: [],
1692
+ loadedOffers: [],
1693
+ shortlistedOfferRefs: []
1694
+ },
1695
+ request: {
1696
+ text: 'find hotels in Dubai'
1697
+ }
1698
+ };
1699
+ const missingTurnLedgerEvents = serverRuntime['ledger'].countEvents();
1700
+ const missingIngress = await fetch(endpoint, {
1701
+ method: 'POST',
1702
+ headers,
1703
+ body: JSON.stringify({
1704
+ ...ingress,
1705
+ requestKey: undefined
1706
+ })
1707
+ });
1708
+ assert.equal(missingIngress.status, 400, 'missing ingress requestKey is rejected at HTTP schema boundary');
1709
+ const missingUser = await fetch(endpoint, {
1710
+ method: 'POST',
1711
+ headers,
1712
+ body: JSON.stringify({
1713
+ ...turn('missing-user'),
1714
+ turnId: undefined
1715
+ })
1716
+ });
1717
+ assert.equal(missingUser.status, 400, 'missing user turnId is rejected at HTTP schema boundary');
1718
+ const injectedIngressIdentity = await fetch(endpoint, {
1719
+ method: 'POST',
1720
+ headers,
1721
+ body: JSON.stringify({
1722
+ ...ingress,
1723
+ taskId: 'browser-task',
1724
+ turnId: 'browser-turn',
1725
+ contextRef: null
1726
+ })
1727
+ });
1728
+ assert.equal(injectedIngressIdentity.status, 400, 'browser-supplied identity is rejected at HTTP schema boundary');
1729
+ const missingUserTask = await fetch(endpoint, {
1730
+ method: 'POST',
1731
+ headers,
1732
+ body: JSON.stringify({
1733
+ ...turn('missing-user-task'),
1734
+ taskId: undefined
1735
+ })
1736
+ });
1737
+ assert.equal(missingUserTask.status, 400, 'missing user taskId is rejected at HTTP schema boundary');
1738
+ assert.equal(serverRuntime['ledger'].countEvents(), missingTurnLedgerEvents, 'missing taskId/turnId requests have no ledger side effects');
1739
+ assert.equal(plannerCalls, 0, 'missing taskId/turnId requests do not call planner');
1740
+ const first = await fetch(endpoint, {
1741
+ method: 'POST',
1742
+ headers,
1743
+ body: JSON.stringify(ingress)
1744
+ });
1745
+ assert.equal(first.status, 200);
1746
+ const firstBody = await first.text();
1747
+ assert.match(firstBody, /event: operation/);
1748
+ assert.equal(factoryCalls, 1);
1749
+ assert.equal(plannerCalls, 1);
1750
+ assert.match(firstBody, /ctx-server/);
1751
+ const ingressReplayWhileWaiting = await fetch(endpoint, {
1752
+ method: 'POST',
1753
+ headers,
1754
+ body: JSON.stringify(ingress)
1755
+ });
1756
+ assert.equal(ingressReplayWhileWaiting.status, 200, 'stable ingress turn identity replays while waiting for receipt');
1757
+ assert.equal(await ingressReplayWhileWaiting.text(), firstBody);
1758
+ assert.equal(plannerCalls, 1);
1759
+ const pending = serverRuntime.resumeTask('task-server')?.pendingAction;
1760
+ assert.ok(pending);
1761
+ const continuationWorkspace = {
1762
+ ...workspace(1),
1763
+ contextRef: 'ctx-server'
1764
+ };
1765
+ const postActionWorkspace = {
1766
+ ...continuationWorkspace,
1767
+ visibleHotels: [
1768
+ {
1769
+ hotelRef: 'hotel-a',
1770
+ name: 'Hotel A',
1771
+ factRefs: [
1772
+ 'fact-hotel-a'
1773
+ ]
1774
+ }
1775
+ ]
1776
+ };
1777
+ const receiptTurn = {
1778
+ schemaVersion: 'booking.surface',
1779
+ kind: 'action.receipt.continuation',
1780
+ taskId: 'task-server',
1781
+ workspace: postActionWorkspace,
1782
+ receipt: {
1783
+ schemaVersion: 'booking.surface',
1784
+ kind: 'action.receipt',
1785
+ actionId: pending.actionId,
1786
+ contextRef: 'ctx-server',
1787
+ status: 'applied',
1788
+ revision: 1,
1789
+ observation: {
1790
+ kind: 'search.state',
1791
+ resultCount: 1
1792
+ },
1793
+ resultContract: {
1794
+ outcome: 'complete',
1795
+ hardCriteriaMet: true,
1796
+ factRefs: [],
1797
+ gapCodes: [],
1798
+ blockers: [],
1799
+ relaxationsApplied: []
1800
+ }
1801
+ }
1802
+ };
1803
+ const [second, concurrentSecond] = await Promise.all([
1804
+ fetch(endpoint, {
1805
+ method: 'POST',
1806
+ headers,
1807
+ body: JSON.stringify(receiptTurn)
1808
+ }),
1809
+ fetch(endpoint, {
1810
+ method: 'POST',
1811
+ headers,
1812
+ body: JSON.stringify(receiptTurn)
1813
+ })
1814
+ ]);
1815
+ assert.equal(second.status, 200);
1816
+ assert.equal(concurrentSecond.status, 200);
1817
+ const secondBody = await second.text();
1818
+ const concurrentSecondBody = await concurrentSecond.text();
1819
+ assert.equal(concurrentSecondBody, secondBody, 'concurrent identical receipt requests receive the same durable typed batch');
1820
+ assert.equal(plannerCalls, 2);
1821
+ const replay = await fetch(endpoint, {
1822
+ method: 'POST',
1823
+ headers,
1824
+ body: JSON.stringify(receiptTurn)
1825
+ });
1826
+ assert.equal(replay.status, 200);
1827
+ const replayBody = await replay.text();
1828
+ assert.equal(plannerCalls, 2, 'identical durable receipt replay does not call planner again');
1829
+ assert.equal(replayBody, secondBody, 'receipt replay emits the exact durable SSE batch');
1830
+ const alteredReceiptWorkspace = await fetch(endpoint, {
1831
+ method: 'POST',
1832
+ headers,
1833
+ body: JSON.stringify({
1834
+ ...receiptTurn,
1835
+ workspace: {
1836
+ ...continuationWorkspace,
1837
+ currency: 'USD'
1838
+ }
1839
+ })
1840
+ });
1841
+ assert.equal(alteredReceiptWorkspace.status, 409, 'receipt replay binds the full workspace semantics');
1842
+ const alteredReceiptDraft = await fetch(endpoint, {
1843
+ method: 'POST',
1844
+ headers,
1845
+ body: JSON.stringify({
1846
+ ...receiptTurn,
1847
+ workspace: {
1848
+ ...postActionWorkspace,
1849
+ searchDraft: {
1850
+ destination: {
1851
+ query: 'Abu Dhabi'
1852
+ }
1853
+ }
1854
+ }
1855
+ })
1856
+ });
1857
+ assert.equal(alteredReceiptDraft.status, 409, 'receipt rejects unauthorized non-action workspace mutation');
1858
+ const pendingWithBlocker = serverRuntime.resumeTask('task-server')?.pendingAction;
1859
+ assert.ok(pendingWithBlocker);
1860
+ const blockerReceipt = serverRuntime.withReceiptDigest({
1861
+ ...receipt,
1862
+ actionId: pendingWithBlocker.actionId,
1863
+ contextRef: 'ctx-server',
1864
+ revision: 2,
1865
+ resultContract: {
1866
+ ...receipt.resultContract,
1867
+ blockers: [
1868
+ {
1869
+ ...receipt.resultContract.blockers[0],
1870
+ sourceActionId: pendingWithBlocker.actionId
1871
+ }
1872
+ ]
1873
+ }
1874
+ });
1875
+ const blockerTurn = {
1876
+ ...receiptTurn,
1877
+ workspace: {
1878
+ ...continuationWorkspace,
1879
+ revision: 2
1880
+ },
1881
+ receipt: blockerReceipt
1882
+ };
1883
+ const blockerResponse = await fetch(endpoint, {
1884
+ method: 'POST',
1885
+ headers,
1886
+ body: JSON.stringify(blockerTurn)
1887
+ });
1888
+ assert.equal(blockerResponse.status, 200);
1889
+ const blockerBody = await blockerResponse.text();
1890
+ assert.match(blockerBody, /relaxation_approval_required/);
1891
+ assert.equal(plannerCalls, 2, 'runtime-owned approval question does not call DSH planner');
1892
+ const questionApproval = serverRuntime.approvalQuestion('task-server');
1893
+ assert.equal(questionApproval.kind, 'question');
1894
+ const deliveredQuestionMatch = /event: question\ndata: ([^\n]+)/.exec(blockerBody);
1895
+ assert.ok(deliveredQuestionMatch, 'question SSE must be delivered before approval');
1896
+ const deliveredQuestion = JSON.parse(deliveredQuestionMatch[1]);
1897
+ const approved = questionApproval.kind === 'question' ? questionApproval.question.approvalOptions[0].approval : undefined;
1898
+ assert.ok(approved);
1899
+ assert.equal(deliveredQuestion.question.approvalOptions[0].approval.deliveryNonce, approved.deliveryNonce, 'SSE carries the durable unpredictable presentation nonce');
1900
+ const serverEventCountBeforeApprovalIngress = serverRuntime['ledger'].countEvents();
1901
+ const forgedApprovalIngress = {
1902
+ ...ingress,
1903
+ requestKey: 'forged-approval-ingress-request-1',
1904
+ workspace: {
1905
+ ...ingress.workspace,
1906
+ revision: 2
1907
+ },
1908
+ request: {
1909
+ text: 'prefer a nearby match',
1910
+ approval: {
1911
+ ...approved,
1912
+ taskId: 'browser-forged-task',
1913
+ contextRef: 'browser-forged-context'
1914
+ }
1915
+ }
1916
+ };
1917
+ const forgedApprovalResponse = await fetch(endpoint, {
1918
+ method: 'POST',
1919
+ headers,
1920
+ body: JSON.stringify(forgedApprovalIngress)
1921
+ });
1922
+ assert.equal(forgedApprovalResponse.status, 400, 'mismatched browser approval authority is rejected before planner/runtime side effects');
1923
+ assert.match(await forgedApprovalResponse.text(), /invalid_ingress_approval_authority/);
1924
+ assert.equal(plannerCalls, 2, 'forged approval ingress does not call the planner');
1925
+ assert.equal(serverRuntime['ledger'].countEvents(), serverEventCountBeforeApprovalIngress, 'forged approval ingress does not append ledger events');
1926
+ const approvedIngress = {
1927
+ ...ingress,
1928
+ requestKey: 'approval-ingress-request-1',
1929
+ workspace: {
1930
+ ...ingress.workspace,
1931
+ revision: 2
1932
+ },
1933
+ request: {
1934
+ text: 'prefer a nearby match',
1935
+ approval: {
1936
+ ...approved
1937
+ }
1938
+ }
1939
+ };
1940
+ const approvedResponsePromise = fetch(endpoint, {
1941
+ method: 'POST',
1942
+ headers,
1943
+ body: JSON.stringify(approvedIngress)
1944
+ });
1945
+ await approvalPlannerEntered;
1946
+ const concurrentApprovalPromises = [
1947
+ 'approval-concurrent-a',
1948
+ 'approval-concurrent-b'
1949
+ ].map((requestKey)=>fetch(endpoint, {
1950
+ method: 'POST',
1951
+ headers,
1952
+ body: JSON.stringify({
1953
+ ...approvedIngress,
1954
+ requestKey
1955
+ })
1956
+ }));
1957
+ let concurrentApprovalResponses;
1958
+ let concurrentApprovalFailure;
1959
+ let rejectConcurrentTimeout;
1960
+ const concurrentTimeout = setTimeout(()=>rejectConcurrentTimeout(new Error('concurrent approval replay joined the in-flight planner')), 2_000);
1961
+ try {
1962
+ concurrentApprovalResponses = await Promise.race([
1963
+ Promise.all(concurrentApprovalPromises),
1964
+ new Promise((_resolve, reject)=>{
1965
+ rejectConcurrentTimeout = reject;
1966
+ })
1967
+ ]);
1968
+ } catch (cause) {
1969
+ concurrentApprovalFailure = cause;
1970
+ } finally{
1971
+ clearTimeout(concurrentTimeout);
1972
+ releaseApprovalPlanner();
1973
+ }
1974
+ const approvedResponse = await approvedResponsePromise;
1975
+ if (concurrentApprovalFailure) throw concurrentApprovalFailure;
1976
+ assert.ok(concurrentApprovalResponses);
1977
+ assert.ok(concurrentApprovalResponses.every(({ status })=>status === 409), 'concurrent exact approvals with fresh keys fail closed before planner replay');
1978
+ for (const response of concurrentApprovalResponses)assert.match(await response.text(), /PLANNER_FAILED/, 'approval replay is fail-closed at the HTTP boundary');
1979
+ assert.equal(approvedResponse.status, 200);
1980
+ const approvedBody = await approvedResponse.text();
1981
+ assert.equal(plannerCalls, 3);
1982
+ assert.match(approvedBody, /event: operation/);
1983
+ assert.equal(plannerCalls, 3, 'concurrent approval retries never enter planner');
1984
+ assert.equal(serverRuntime['ledger'].db.prepare("SELECT COUNT(*) AS n FROM events WHERE kind = 'booking.copilot.approval.consumed' AND run_id = ?").get('task-server').n, 1, 'approval is consumed exactly once');
1985
+ const approvedPayloadRows = serverRuntime['ledger'].db.prepare("SELECT payload FROM events WHERE kind = 'booking.copilot.user.turn.observed' AND run_id = ? ORDER BY seq DESC").all('task-server');
1986
+ const approvedPayload = JSON.parse(approvedPayloadRows[0].payload);
1987
+ assert.equal(approvedPayload.contextRef, 'ctx-server');
1988
+ const consumedApprovalRows = serverRuntime['ledger'].db.prepare("SELECT payload FROM events WHERE kind = 'booking.copilot.approval.granted' AND run_id = ? ORDER BY seq DESC").all('task-server');
1989
+ const consumedApprovalPayload = JSON.parse(consumedApprovalRows[0].payload);
1990
+ assert.deepEqual(consumedApprovalPayload.approval.approval, approved, 'exact ingress approval is forwarded unchanged into runtime approval consumption');
1991
+ const crashRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-crash-replay-'));
1992
+ const crashLedger = ensureLedger(crashRoot);
1993
+ const crashV2 = new BookingCopilotTaskRuntime(crashLedger);
1994
+ const crashTurn = {
1995
+ ...turn('task-crash'),
1996
+ turnId: 'stable-turn-1'
1997
+ };
1998
+ crashV2.startTask(crashTurn);
1999
+ const crashCount = crashLedger.countEvents();
2000
+ crashV2.startTask(crashTurn);
2001
+ assert.equal(crashLedger.countEvents(), crashCount, 'stable turn identity retries after startTask without ledger delta');
2002
+ const duplicateTextTurn = {
2003
+ ...turn('task-crash'),
2004
+ turnId: 'stable-turn-2'
2005
+ };
2006
+ crashV2.startTask(duplicateTextTurn);
2007
+ assert.equal(crashV2.resumeTask('task-crash')?.userTurnCount, 2, 'same text with a new turn identity is a new turn');
2008
+ const atomicRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-binding-atomic-'));
2009
+ const atomicLedger = ensureLedger(atomicRoot);
2010
+ const atomicRuntime = new BookingCopilotTaskRuntime(atomicLedger);
2011
+ const atomicTurn = turn('task-atomic-binding');
2012
+ const atomicBinding = {
2013
+ requestKey: 'atomic-request-1',
2014
+ principal: {
2015
+ subject: 'bff-atomic',
2016
+ scope: 'booking:read'
2017
+ },
2018
+ taskHandle: 'atomic-task-handle'
2019
+ };
2020
+ atomicLedger.db.exec("CREATE TRIGGER abort_booking_request_binding BEFORE INSERT ON events WHEN NEW.kind = 'booking.copilot.request.binding' BEGIN SELECT RAISE(ABORT, 'crash_after_start_before_binding'); END");
2021
+ assert.throws(()=>atomicRuntime.startTask(atomicTurn, atomicBinding), /crash_after_start_before_binding|SQLITE_CONSTRAINT/);
2022
+ assert.equal(atomicLedger.countEvents(), 0, 'binding failure rolls back STARTED and TURN reservation');
2023
+ atomicLedger.db.exec('DROP TRIGGER abort_booking_request_binding');
2024
+ atomicRuntime.startTask(atomicTurn, atomicBinding);
2025
+ const atomicCount = atomicLedger.countEvents();
2026
+ atomicRuntime.startTask(atomicTurn, atomicBinding);
2027
+ assert.equal(atomicLedger.countEvents(), atomicCount, 'same request binding retry does not create a second task/planner turn');
2028
+ assert.equal(atomicLedger.db.prepare("SELECT COUNT(*) AS n FROM events WHERE kind = 'booking.copilot.request.binding'").get().n, 1);
2029
+ assert.throws(()=>atomicRuntime.startTask(atomicTurn, {
2030
+ ...atomicBinding,
2031
+ principal: {
2032
+ subject: 'other-principal',
2033
+ scope: 'booking:read'
2034
+ }
2035
+ }), /principal_conflict|request_conflict/, 'taskHandle cannot be rebound across principals');
2036
+ assert.throws(()=>atomicRuntime.startTask(turn('task-atomic-binding-other'), {
2037
+ ...atomicBinding,
2038
+ requestKey: 'atomic-request-2'
2039
+ }), /task_handle_conflict/, 'taskHandle cannot bind a second task in the same principal scope');
2040
+ atomicLedger.close();
2041
+ rmSync(atomicRoot, {
2042
+ recursive: true,
2043
+ force: true
2044
+ });
2045
+ const crossRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-cross-task-context-'));
2046
+ const crossLedger = ensureLedger(crossRoot);
2047
+ const crossRuntime = new BookingCopilotTaskRuntime(crossLedger);
2048
+ crossRuntime.startTask({
2049
+ ...turn('task-cross-context'),
2050
+ workspace: {
2051
+ ...workspace(),
2052
+ contextRef: 'ctx-cross'
2053
+ }
2054
+ });
2055
+ assert.throws(()=>crossRuntime.startTask({
2056
+ ...turn('task-cross-context'),
2057
+ workspace: {
2058
+ ...workspace(),
2059
+ contextRef: 'ctx-other'
2060
+ }
2061
+ }), /task_conflict:context_mismatch/, 'one task id cannot drift across contexts');
2062
+ const reverseRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-reverse-context-'));
2063
+ const reverseLedger = ensureLedger(reverseRoot);
2064
+ const reverseRuntime = new BookingCopilotTaskRuntime(reverseLedger);
2065
+ reverseRuntime.startTask({
2066
+ ...turn('task-reverse-context'),
2067
+ workspace: {
2068
+ ...workspace(),
2069
+ contextRef: 'ctx-a'
2070
+ }
2071
+ });
2072
+ assert.throws(()=>reverseRuntime.startTask({
2073
+ ...turn('task-reverse-context'),
2074
+ workspace: {
2075
+ ...workspace(),
2076
+ contextRef: 'ctx-b'
2077
+ }
2078
+ }), /task_conflict:context_mismatch/);
2079
+ const terminalRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-terminal-replay-'));
2080
+ const terminalLedger = ensureLedger(terminalRoot);
2081
+ const terminalRuntime = new BookingCopilotTaskRuntime(terminalLedger);
2082
+ const terminalTurn = {
2083
+ ...turn('task-terminal'),
2084
+ turnId: 'terminal-turn-1'
2085
+ };
2086
+ terminalRuntime.startTask(terminalTurn);
2087
+ terminalRuntime.applyDecisionBatch('task-terminal', 'turn:task-terminal:terminal-turn-1', [
2088
+ {
2089
+ kind: 'terminal',
2090
+ terminal: {
2091
+ status: 'completed',
2092
+ summary: 'read-only result',
2093
+ factRefs: []
2094
+ }
2095
+ }
2096
+ ], true);
2097
+ assert.equal(terminalRuntime.resumeTask('task-terminal')?.phase, 'terminal');
2098
+ const terminalCount = terminalLedger.countEvents();
2099
+ assert.throws(()=>terminalRuntime.startTask(terminalTurn), /task_terminal/);
2100
+ assert.equal(terminalLedger.countEvents(), terminalCount, 'terminal exact replay does not append a turn');
2101
+ assert.throws(()=>terminalRuntime.startTask({
2102
+ ...terminalTurn,
2103
+ turnId: 'terminal-new-turn'
2104
+ }), /task_terminal/);
2105
+ const finalityRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-batch-finality-'));
2106
+ const finalityLedger = ensureLedger(finalityRoot);
2107
+ const finalityRuntime = new BookingCopilotTaskRuntime(finalityLedger);
2108
+ finalityRuntime.startTask(turn('task-batch-finality'));
2109
+ const finalityRows = finalityLedger.countEvents();
2110
+ const finalityBytes = finalityLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n;
2111
+ const finalDecision = {
2112
+ kind: 'terminal',
2113
+ terminal: {
2114
+ status: 'completed',
2115
+ summary: 'one final',
2116
+ factRefs: []
2117
+ }
2118
+ };
2119
+ assert.throws(()=>finalityRuntime.applyDecisionBatch('task-batch-finality', 'batch-two-finals', [
2120
+ finalDecision,
2121
+ finalDecision
2122
+ ], true), /decision_batch_finality/);
2123
+ assert.throws(()=>finalityRuntime.applyDecisionBatch('task-batch-finality', 'batch-after-final', [
2124
+ finalDecision,
2125
+ {
2126
+ kind: 'explanation',
2127
+ explanation: {
2128
+ text: 'must not follow final',
2129
+ factRefs: []
2130
+ }
2131
+ }
2132
+ ], true), /decision_batch_finality/);
2133
+ assert.equal(finalityLedger.countEvents(), finalityRows, 'malformed final batches emit no events');
2134
+ assert.equal(finalityLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n, finalityBytes, 'malformed final batches do not alter payload bytes');
2135
+ finalityLedger.close();
2136
+ rmSync(finalityRoot, {
2137
+ recursive: true,
2138
+ force: true
2139
+ });
2140
+ const longRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-internal-key-'));
2141
+ const longLedger = ensureLedger(longRoot);
2142
+ const longRuntime = new BookingCopilotTaskRuntime(longLedger);
2143
+ const longTaskId = `task-${'t'.repeat(180)}`;
2144
+ const longTurnId = `turn-${'u'.repeat(180)}`;
2145
+ const longActionId = `action-${'a'.repeat(180)}`;
2146
+ longRuntime.startTask({
2147
+ ...turn(longTaskId),
2148
+ turnId: longTurnId
2149
+ });
2150
+ const longDecisionKey = `turn:${longTaskId}:${longTurnId}:${longActionId}`;
2151
+ const longDecisionEvents = longRuntime.applyDecisionBatch(longTaskId, longDecisionKey, [
2152
+ {
2153
+ kind: 'operation',
2154
+ action: action(longActionId)
2155
+ }
2156
+ ]);
2157
+ assert.equal(longDecisionEvents.some((event)=>event.kind === 'operation' && event.action.actionId === longActionId), true, 'long internal decision key is accepted');
2158
+ longLedger.close();
2159
+ rmSync(longRoot, {
2160
+ recursive: true,
2161
+ force: true
2162
+ });
2163
+ const budgetRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-operation-budget-'));
2164
+ const budgetLedger = ensureLedger(budgetRoot);
2165
+ const budgetRuntime = new BookingCopilotTaskRuntime(budgetLedger);
2166
+ const budgetTaskId = 'task-operation-budget';
2167
+ budgetRuntime.startTask(turn(budgetTaskId));
2168
+ let twentiethContinuation;
2169
+ for(let ordinal = 1; ordinal <= 20; ordinal++){
2170
+ const currentTurn = {
2171
+ ...turn(budgetTaskId),
2172
+ turnId: `budget-turn-${ordinal}`
2173
+ };
2174
+ if (ordinal > 1) budgetRuntime.startTask(currentTurn);
2175
+ const operation = budgetRuntime.issueOperation(budgetTaskId, action(`budget-action-${ordinal}`));
2176
+ const continuation = {
2177
+ schemaVersion: 'booking.surface',
2178
+ kind: 'action.receipt.continuation',
2179
+ taskId: budgetTaskId,
2180
+ workspace: workspace(0),
2181
+ receipt: budgetRuntime.withReceiptDigest({
2182
+ schemaVersion: 'booking.surface',
2183
+ kind: 'action.receipt',
2184
+ actionId: operation.action.actionId,
2185
+ contextRef: 'ctx-v2',
2186
+ status: 'applied',
2187
+ revision: 0,
2188
+ observation: {
2189
+ kind: 'search.state',
2190
+ resultCount: 0
2191
+ },
2192
+ resultContract: {
2193
+ outcome: 'complete',
2194
+ hardCriteriaMet: true,
2195
+ factRefs: [],
2196
+ gapCodes: [],
2197
+ blockers: [],
2198
+ relaxationsApplied: []
2199
+ }
2200
+ })
2201
+ };
2202
+ const folded = budgetRuntime.continueWithReceipt(continuation);
2203
+ assert.equal(folded.operationCount, ordinal);
2204
+ if (ordinal === 19) assert.equal(folded.phase, 'planning', '19th receipt remains continuable');
2205
+ if (ordinal === 20) {
2206
+ assert.equal(folded.phase, 'terminal');
2207
+ twentiethContinuation = continuation;
2208
+ }
2209
+ }
2210
+ assert.equal(budgetRuntime.resumeTask(budgetTaskId)?.operationCount, 20);
2211
+ assert.equal(budgetLedger.db.prepare("SELECT COUNT(*) AS n FROM events WHERE kind = 'booking.copilot.event.emitted' AND json_extract(payload, '$.eventKind') = 'terminal'").get().n, 1, '20th receipt emits one durable terminal event');
2212
+ const terminalBatchCount = budgetLedger.countEvents();
2213
+ const terminalBatchBytes = budgetLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n;
2214
+ const budgetRestart = new BookingCopilotTaskRuntime(ensureLedger(budgetRoot));
2215
+ assert.equal(budgetRestart.resumeTask(budgetTaskId)?.operationCount, 20, 'operation count survives restart');
2216
+ assert.throws(()=>budgetRestart.startTask({
2217
+ ...turn(budgetTaskId),
2218
+ turnId: 'budget-turn-21'
2219
+ }), /task_terminal/);
2220
+ assert.throws(()=>budgetRestart.issueOperation(budgetTaskId, action('budget-action-21')), /task_terminal/);
2221
+ assert.equal(budgetRestart.continueWithReceipt(twentiethContinuation).phase, 'terminal', 'exact terminal receipt replay is read-only');
2222
+ assert.throws(()=>budgetRestart.emitEvent(budgetTaskId, {
2223
+ kind: 'explanation',
2224
+ explanation: {
2225
+ text: 'must reject',
2226
+ factRefs: []
2227
+ }
2228
+ }), /task_terminal/);
2229
+ assert.throws(()=>budgetRestart.terminalDecisionBatch(budgetTaskId, 'arbitrary-terminal-key'), /task_terminal/);
2230
+ assert.throws(()=>budgetRestart.applyDecisionBatch(budgetTaskId, 'arbitrary-terminal-key-2', [
2231
+ {
2232
+ kind: 'terminal',
2233
+ terminal: {
2234
+ status: 'stopped',
2235
+ summary: 'must reject',
2236
+ factRefs: []
2237
+ }
2238
+ }
2239
+ ]), /task_terminal/);
2240
+ assert.throws(()=>budgetRestart.commitDecisionBatch(budgetTaskId, 'arbitrary-terminal-key-3', []), /task_terminal/);
2241
+ assert.equal(budgetLedger.countEvents(), terminalBatchCount, 'post-terminal attempts do not mutate ledger');
2242
+ assert.equal(budgetLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n, terminalBatchBytes, 'post-terminal attempts do not mutate ledger bytes');
2243
+ budgetRestart['ledger'].close();
2244
+ budgetLedger.close();
2245
+ rmSync(budgetRoot, {
2246
+ recursive: true,
2247
+ force: true
2248
+ });
2249
+ const bindingRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-request-binding-'));
2250
+ const bindingRuntime = new BookingCopilotTaskRuntime(ensureLedger(bindingRoot));
2251
+ const bindingTurn1 = {
2252
+ ...turn('task-request-binding'),
2253
+ turnId: 'binding-turn-1'
2254
+ };
2255
+ bindingRuntime.startTask(bindingTurn1);
2256
+ bindingRuntime.persistRequestBinding('request-one', bindingTurn1, {
2257
+ requestKey: 'request-one',
2258
+ principal: {
2259
+ subject: 'binding-principal',
2260
+ scope: 'booking:read'
2261
+ }
2262
+ });
2263
+ const firstBatch = bindingRuntime.applyDecisionBatch('task-request-binding', 'request-one', [
2264
+ {
2265
+ kind: 'explanation',
2266
+ explanation: {
2267
+ text: 'first',
2268
+ factRefs: []
2269
+ }
2270
+ }
2271
+ ], true);
2272
+ const afterFirstBatch = bindingRuntime['ledger'].countEvents();
2273
+ bindingRuntime.startTask({
2274
+ ...bindingTurn1,
2275
+ turnId: 'binding-turn-2'
2276
+ });
2277
+ const bindingTurn2 = {
2278
+ ...bindingTurn1,
2279
+ turnId: 'binding-turn-2'
2280
+ };
2281
+ bindingRuntime.persistRequestBinding('request-two', bindingTurn2, {
2282
+ requestKey: 'request-two',
2283
+ principal: {
2284
+ subject: 'binding-principal',
2285
+ scope: 'booking:read'
2286
+ }
2287
+ });
2288
+ assert.deepEqual(bindingRuntime.applyDecisionBatch('task-request-binding', 'request-one', [
2289
+ {
2290
+ kind: 'error',
2291
+ error: {
2292
+ code: 'forged',
2293
+ message: 'forged',
2294
+ retryable: false
2295
+ }
2296
+ }
2297
+ ]), firstBatch, 'R1 replay returns original durable batch after R2');
2298
+ assert.equal(bindingRuntime['ledger'].countEvents(), afterFirstBatch + 2, 'R1 replay and R2 binding do not append a replacement batch');
2299
+ assert.throws(()=>bindingRuntime.assertRequestBinding('request-one', {
2300
+ ...bindingTurn1,
2301
+ request: {
2302
+ text: 'changed body'
2303
+ }
2304
+ }, {
2305
+ requestKey: 'request-one',
2306
+ principal: {
2307
+ subject: 'binding-principal',
2308
+ scope: 'booking:read'
2309
+ }
2310
+ }), /request_conflict/);
2311
+ bindingRuntime['ledger'].close();
2312
+ rmSync(bindingRoot, {
2313
+ recursive: true,
2314
+ force: true
2315
+ });
2316
+ const legacyRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-ordinal-'));
2317
+ const legacyLedger = ensureLedger(legacyRoot);
2318
+ const legacyRuntime = new BookingCopilotTaskRuntime(legacyLedger);
2319
+ legacyRuntime.startTask(turn('task-legacy-ordinal'));
2320
+ const legacyOperation = legacyRuntime.issueOperation('task-legacy-ordinal', action('legacy-action'));
2321
+ legacyLedger.db.prepare("UPDATE events SET payload = json_remove(payload, '$.operationCount') WHERE kind = 'booking.copilot.action.issued'").run();
2322
+ const legacyReceipt = legacyRuntime.withReceiptDigest({
2323
+ schemaVersion: 'booking.surface',
2324
+ kind: 'action.receipt',
2325
+ actionId: legacyOperation.action.actionId,
2326
+ contextRef: 'ctx-v2',
2327
+ status: 'applied',
2328
+ revision: 0,
2329
+ observation: {
2330
+ kind: 'search.state',
2331
+ resultCount: 0
2332
+ },
2333
+ resultContract: {
2334
+ outcome: 'complete',
2335
+ hardCriteriaMet: true,
2336
+ factRefs: [],
2337
+ gapCodes: [],
2338
+ blockers: [],
2339
+ relaxationsApplied: []
2340
+ }
2341
+ });
2342
+ legacyRuntime.continueWithReceipt({
2343
+ schemaVersion: 'booking.surface',
2344
+ kind: 'action.receipt.continuation',
2345
+ taskId: 'task-legacy-ordinal',
2346
+ workspace: workspace(0),
2347
+ receipt: legacyReceipt
2348
+ });
2349
+ legacyLedger.db.prepare("UPDATE events SET payload = json_remove(payload, '$.operationCount') WHERE kind = 'booking.copilot.receipt.observed'").run();
2350
+ const legacyRestart = new BookingCopilotTaskRuntime(ensureLedger(legacyRoot));
2351
+ assert.equal(legacyRestart.resumeTask('task-legacy-ordinal')?.operationCount, 1, 'legacy action and receipt rows recover operation ordinal');
2352
+ legacyRestart['ledger'].close();
2353
+ legacyLedger.close();
2354
+ rmSync(legacyRoot, {
2355
+ recursive: true,
2356
+ force: true
2357
+ });
2358
+ const ordinalTamperRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-ordinal-tamper-'));
2359
+ const ordinalTamperLedger = ensureLedger(ordinalTamperRoot);
2360
+ const ordinalTamperRuntime = new BookingCopilotTaskRuntime(ordinalTamperLedger);
2361
+ ordinalTamperRuntime.startTask(turn('task-ordinal-tamper'));
2362
+ ordinalTamperRuntime.issueOperation('task-ordinal-tamper', action('ordinal-action'));
2363
+ ordinalTamperLedger.db.prepare("UPDATE events SET payload = json_set(payload, '$.operationCount', 7) WHERE kind = 'booking.copilot.action.issued'").run();
2364
+ const ordinalRestart = new BookingCopilotTaskRuntime(ensureLedger(ordinalTamperRoot));
2365
+ assert.throws(()=>ordinalRestart.resumeTask('task-ordinal-tamper'), /ledger_corrupt:task-ordinal-tamper:action/, 'inconsistent explicit operation ordinal is rejected');
2366
+ ordinalRestart['ledger'].close();
2367
+ ordinalTamperLedger.close();
2368
+ rmSync(ordinalTamperRoot, {
2369
+ recursive: true,
2370
+ force: true
2371
+ });
2372
+ const rewriteLegacyOfferRows = (ledger, taskId, text, scalarVerifiedOfferRef)=>{
2373
+ const rows = ledger.db.prepare("SELECT seq, kind, payload FROM events WHERE run_id = ? ORDER BY seq").all(taskId);
2374
+ const actionKinds = new Map();
2375
+ const actionWorkspaceDigests = new Map();
2376
+ const workspaceDigestMigration = new Map();
2377
+ let currentWorkspace;
2378
+ for (const row of rows){
2379
+ if (row.kind !== 'booking.copilot.action.issued') continue;
2380
+ const payload = JSON.parse(row.payload);
2381
+ const action = payload.action;
2382
+ if (action && typeof action.actionId === 'string' && typeof action.kind === 'string') actionKinds.set(action.actionId, action.kind);
2383
+ }
2384
+ for (const row of rows){
2385
+ const payload = JSON.parse(row.payload);
2386
+ const receiptActionId = row.kind === 'booking.copilot.receipt.observed' && payload.receipt && typeof payload.receipt.actionId === 'string' ? payload.receipt.actionId : undefined;
2387
+ if (payload.workspace && typeof payload.workspace === 'object') {
2388
+ const versionedWorkspace = structuredClone(payload.workspace);
2389
+ const oldWorkspace = legacyWorkspace(payload.workspace, scalarVerifiedOfferRef);
2390
+ payload.workspace = oldWorkspace;
2391
+ const oldDigest = oldWorkspaceDigest(oldWorkspace);
2392
+ workspaceDigestMigration.set(oldWorkspaceDigest(versionedWorkspace), oldDigest);
2393
+ payload.workspaceDigest = oldDigest;
2394
+ payload.workspaceSemanticDigest = oldWorkspaceSemanticDigest(oldWorkspace);
2395
+ currentWorkspace = oldWorkspace;
2396
+ }
2397
+ if (payload.availability && typeof payload.availability === 'object') {
2398
+ const actionId = payload.action && typeof payload.action === 'object' && typeof payload.action.actionId === 'string' ? payload.action.actionId : undefined;
2399
+ const availabilityValue = structuredClone(payload.availability);
2400
+ const sourceWorkspaceDigest = (()=>{
2401
+ const hotels = availabilityValue.hotels;
2402
+ if (!hotels || typeof hotels !== 'object' || Array.isArray(hotels)) return undefined;
2403
+ for (const hotel of Object.values(hotels)){
2404
+ if (!hotel || typeof hotel !== 'object' || Array.isArray(hotel)) continue;
2405
+ const generation = hotel.currentGeneration;
2406
+ if (!generation || typeof generation !== 'object' || Array.isArray(generation)) continue;
2407
+ const source = generation.source;
2408
+ if (source && typeof source === 'object' && !Array.isArray(source) && source.kind === 'workspace_snapshot' && typeof source.workspaceDigest === 'string') return source.workspaceDigest;
2409
+ }
2410
+ return undefined;
2411
+ })();
2412
+ const sourceDigest = workspaceDigestMigration.get(sourceWorkspaceDigest ?? '') ?? (receiptActionId ? actionWorkspaceDigests.get(receiptActionId) : currentWorkspace ? oldWorkspaceDigest(currentWorkspace) : undefined);
2413
+ const oldAvailability = legacyAvailability(payload.availability, sourceDigest);
2414
+ payload.availability = oldAvailability;
2415
+ payload.availabilityDigest = bookingDigest(oldAvailability);
2416
+ if (actionId) actionWorkspaceDigests.set(actionId, currentWorkspace ? oldWorkspaceDigest(currentWorkspace) : '');
2417
+ }
2418
+ if (row.kind === 'booking.copilot.user.turn.observed' && typeof payload.turnId === 'string' && payload.workspace && typeof payload.workspace === 'object') {
2419
+ payload.requestDigest = legacyTurnDigest(taskId, payload.turnId, payload.workspace, text);
2420
+ }
2421
+ if (row.kind === 'booking.copilot.action.issued' && payload.action && typeof payload.action === 'object') {
2422
+ const oldAction = payload.action;
2423
+ const input = oldAction.input;
2424
+ if (input && typeof input === 'object' && !Array.isArray(input) && [
2425
+ 'offer.select',
2426
+ 'offer.check',
2427
+ 'checkout.prepare'
2428
+ ].includes(String(oldAction.kind))) {
2429
+ delete input.offerVersionRef;
2430
+ }
2431
+ if (input && typeof input === 'object' && !Array.isArray(input)) oldAction.inputDigest = bookingDigest(input);
2432
+ const { actionDigest: _oldActionDigest, ...actionBase } = oldAction;
2433
+ oldAction.actionDigest = bookingDigest(actionBase);
2434
+ }
2435
+ if (row.kind === 'booking.copilot.receipt.observed' && payload.receipt && typeof payload.receipt === 'object') {
2436
+ const actionId = typeof payload.receipt.actionId === 'string' ? payload.receipt.actionId : '';
2437
+ const oldReceipt = stripLegacyReceipt(payload.receipt);
2438
+ if (actionKinds.get(actionId) === 'checkout.prepare' && oldReceipt.observation && typeof oldReceipt.observation === 'object') {
2439
+ ;
2440
+ oldReceipt.observation.verifiedOfferRef = scalarVerifiedOfferRef;
2441
+ }
2442
+ payload.receipt = oldReceipt;
2443
+ payload.receiptDigest = bookingDigest(oldReceipt);
2444
+ }
2445
+ if (row.kind === 'booking.copilot.decision.batch' && Array.isArray(payload.events)) {
2446
+ payload.events = payload.events.map((event)=>{
2447
+ if (!event || typeof event !== 'object' || Array.isArray(event)) return event;
2448
+ const oldEvent = event;
2449
+ if (oldEvent.kind !== 'operation' || !oldEvent.action || typeof oldEvent.action !== 'object' || Array.isArray(oldEvent.action)) return oldEvent;
2450
+ const oldAction = oldEvent.action;
2451
+ const input = oldAction.input;
2452
+ if (input && typeof input === 'object' && !Array.isArray(input) && [
2453
+ 'offer.select',
2454
+ 'offer.check',
2455
+ 'checkout.prepare'
2456
+ ].includes(String(oldAction.kind))) delete input.offerVersionRef;
2457
+ return oldEvent;
2458
+ });
2459
+ }
2460
+ ledger.db.prepare('UPDATE events SET payload = ? WHERE seq = ?').run(JSON.stringify(payload), row.seq);
2461
+ }
2462
+ };
2463
+ const legacyOfferRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-offer-version-'));
2464
+ const legacyOfferLedger = ensureLedger(legacyOfferRoot);
2465
+ const legacyOfferRuntime = new BookingCopilotTaskRuntime(legacyOfferLedger, {
2466
+ contextRefFactory: ()=>'ctx-v2'
2467
+ });
2468
+ const legacyWorkspace0 = {
2469
+ ...workspace(0),
2470
+ visibleHotels: [
2471
+ {
2472
+ hotelRef: 'legacy-hotel',
2473
+ name: 'Legacy Hotel',
2474
+ factRefs: []
2475
+ }
2476
+ ],
2477
+ loadedOffers: [
2478
+ loadedOffer('legacy-offer-a', 'legacy-hotel'),
2479
+ loadedOffer('legacy-offer-b', 'legacy-hotel')
2480
+ ],
2481
+ selectedOfferRef: 'legacy-offer-a',
2482
+ shortlistedOfferRefs: [
2483
+ 'legacy-offer-a',
2484
+ 'legacy-offer-b'
2485
+ ]
2486
+ };
2487
+ const legacyWorkspace1 = {
2488
+ ...legacyWorkspace0,
2489
+ revision: 1,
2490
+ loadedOffers: [
2491
+ {
2492
+ ...loadedOffer('legacy-offer-a', 'legacy-hotel', 'legacy-offer-a:v2'),
2493
+ factRefs: [
2494
+ 'price'
2495
+ ]
2496
+ },
2497
+ loadedOffer('legacy-offer-b', 'legacy-hotel')
2498
+ ]
2499
+ };
2500
+ const legacyOfferTask = legacyOfferRuntime.startTask({
2501
+ ...turn('task-legacy-offer-version'),
2502
+ workspace: legacyWorkspace0
2503
+ });
2504
+ legacyOfferRuntime.issueOperation(legacyOfferTask.taskId, {
2505
+ ...action('legacy-offer-check-v1'),
2506
+ kind: 'offer.check',
2507
+ input: {
2508
+ offerRef: 'legacy-offer-a',
2509
+ offerVersionRef: 'legacy-offer-a:v1'
2510
+ }
2511
+ });
2512
+ legacyOfferRuntime.continueWithReceipt({
2513
+ schemaVersion: 'booking.surface',
2514
+ kind: 'action.receipt.continuation',
2515
+ taskId: legacyOfferTask.taskId,
2516
+ workspace: legacyWorkspace1,
2517
+ receipt: {
2518
+ schemaVersion: 'booking.surface',
2519
+ kind: 'action.receipt',
2520
+ actionId: 'legacy-offer-check-v1',
2521
+ contextRef: 'ctx-v2',
2522
+ status: 'changed',
2523
+ revision: 1,
2524
+ observation: {
2525
+ kind: 'offer.availability',
2526
+ offerRef: 'legacy-offer-a',
2527
+ checkedOfferVersionRef: 'legacy-offer-a:v1',
2528
+ currentOfferVersionRef: 'legacy-offer-a:v2',
2529
+ available: true,
2530
+ changedFactRefs: [
2531
+ 'price'
2532
+ ],
2533
+ gapCodes: []
2534
+ },
2535
+ resultContract: {
2536
+ outcome: 'partial',
2537
+ hardCriteriaMet: false,
2538
+ factRefs: [],
2539
+ gapCodes: [],
2540
+ blockers: [],
2541
+ relaxationsApplied: []
2542
+ }
2543
+ }
2544
+ });
2545
+ legacyOfferRuntime.startTask({
2546
+ ...turn('task-legacy-offer-version', 1),
2547
+ workspace: legacyWorkspace1,
2548
+ turnId: 'task-legacy-offer-version-turn-1'
2549
+ });
2550
+ legacyOfferRuntime.issueOperation(legacyOfferTask.taskId, {
2551
+ ...action('legacy-offer-check-v2', 1),
2552
+ kind: 'offer.check',
2553
+ input: {
2554
+ offerRef: 'legacy-offer-a',
2555
+ offerVersionRef: 'legacy-offer-a:v2'
2556
+ }
2557
+ });
2558
+ rewriteLegacyOfferRows(legacyOfferLedger, legacyOfferTask.taskId, 'find hotels in Dubai');
2559
+ const legacyOfferRestart = new BookingCopilotTaskRuntime(ensureLedger(legacyOfferRoot), {
2560
+ contextRefFactory: ()=>'ctx-v2'
2561
+ });
2562
+ const legacyOfferRecovered = legacyOfferRestart.resumeTask(legacyOfferTask.taskId);
2563
+ assert.ok(legacyOfferRecovered && legacyOfferRecovered.pendingAction?.input.offerVersionRef, 'pre-versioned row family migrates to a version-bound pending action');
2564
+ assert.deepEqual(legacyOfferRecovered?.availability.attempts.map((attempt)=>attempt.offerVersionRef), [
2565
+ 'legacy-offer-version:' + bookingDigest({
2566
+ contextRef: 'ctx-v2',
2567
+ offerRef: 'legacy-offer-a',
2568
+ hotelRef: 'legacy-hotel',
2569
+ evidenceLevel: 'rate_loaded',
2570
+ factRefs: []
2571
+ }).slice(0, 40),
2572
+ 'legacy-offer-version:' + bookingDigest({
2573
+ contextRef: 'ctx-v2',
2574
+ offerRef: 'legacy-offer-a',
2575
+ hotelRef: 'legacy-hotel',
2576
+ evidenceLevel: 'rate_loaded',
2577
+ factRefs: [
2578
+ 'price'
2579
+ ]
2580
+ }).slice(0, 40)
2581
+ ], 'legacy A attempts retain distinct old v1 and changed v2 identities');
2582
+ legacyOfferRestart['ledger'].close();
2583
+ legacyOfferLedger.close();
2584
+ rmSync(legacyOfferRoot, {
2585
+ recursive: true,
2586
+ force: true
2587
+ });
2588
+ const legacyCheckoutRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-checkout-tail-'));
2589
+ const legacyCheckoutLedger = ensureLedger(legacyCheckoutRoot);
2590
+ const legacyCheckoutRuntime = new BookingCopilotTaskRuntime(legacyCheckoutLedger, {
2591
+ contextRefFactory: ()=>'ctx-v2',
2592
+ now: ()=>'2026-09-01T10:00:00.000Z'
2593
+ });
2594
+ const legacyCheckoutCapability = verifiedCapability('legacy-checkout', 'legacy-checkout:v1');
2595
+ const legacyCheckoutWorkspace = {
2596
+ ...workspace(1),
2597
+ visibleHotels: [
2598
+ {
2599
+ hotelRef: 'legacy-checkout-hotel',
2600
+ name: 'Legacy Checkout Hotel',
2601
+ factRefs: []
2602
+ }
2603
+ ],
2604
+ loadedOffers: [
2605
+ loadedOffer('legacy-checkout', 'legacy-checkout-hotel')
2606
+ ],
2607
+ selectedOfferRef: 'legacy-checkout',
2608
+ verifiedOffer: legacyCheckoutCapability
13
2609
  };
14
- const strictWorkspace = {
15
- schemaVersion: 'booking.surface.v1',
16
- contextRef: 'ctx-server-opaque-1',
17
- surface: 'tenant',
18
- revision: 0,
19
- locale: 'zh-CN',
20
- currency: 'AED',
21
- searchDraft: {},
22
- results: {
23
- status: 'idle'
24
- },
25
- visibleHotels: [],
26
- loadedOffers: [],
27
- shortlistedOfferRefs: [],
28
- capabilities: {
29
- surface: 'tenant',
30
- allowedActions: [
31
- 'search.patch',
32
- 'search.run',
33
- 'results.view.patch',
34
- 'hotel.focus'
35
- ]
2610
+ const legacyCheckoutTask = legacyCheckoutRuntime.startTask({
2611
+ ...turn('task-legacy-checkout-tail'),
2612
+ workspace: legacyCheckoutWorkspace
2613
+ });
2614
+ const legacyCheckoutAction = {
2615
+ ...action('legacy-checkout-action', 1),
2616
+ kind: 'checkout.prepare',
2617
+ input: {
2618
+ offerRef: 'legacy-checkout',
2619
+ offerVersionRef: 'legacy-checkout:v1',
2620
+ verifiedOfferRef: legacyCheckoutCapability.verifiedOfferRef
36
2621
  }
37
2622
  };
38
- const startTurn = {
39
- schemaVersion: 'booking.surface.v1',
40
- kind: 'user.turn',
41
- taskId: 'task-opaque-1',
42
- workspace: {
43
- ...strictWorkspace,
44
- capabilities: {
45
- ...strictWorkspace.capabilities,
46
- allowedActions: [
47
- ...strictWorkspace.capabilities.allowedActions
48
- ]
49
- }
2623
+ const legacyCheckoutBatch = legacyCheckoutRuntime.applyDecisionBatch(legacyCheckoutTask.taskId, 'legacy-checkout-batch', [
2624
+ {
2625
+ kind: 'operation',
2626
+ action: legacyCheckoutAction
50
2627
  },
2628
+ {
2629
+ kind: 'explanation',
2630
+ explanation: {
2631
+ text: 'checkout handoff is awaiting receipt',
2632
+ factRefs: []
2633
+ }
2634
+ }
2635
+ ], true);
2636
+ assert.equal(legacyCheckoutBatch.some((event)=>event.kind === 'operation' && event.action.actionId === legacyCheckoutAction.actionId), true, 'normal decision batch includes the checkout operation envelope');
2637
+ const appendLegacyEvent = (eventId, sequence, eventKind)=>{
2638
+ const safeContent = eventKind === 'terminal' ? {
2639
+ status: 'stopped',
2640
+ summary: 'legacy-terminal',
2641
+ factRefs: []
2642
+ } : {
2643
+ code: 'unhandled',
2644
+ message: 'legacy error',
2645
+ retryable: false
2646
+ };
2647
+ legacyCheckoutLedger.insertEvent({
2648
+ actor: 'system:booking-copilot',
2649
+ kind: 'booking.copilot.event.emitted',
2650
+ subjectId: legacyCheckoutTask.taskId,
2651
+ runId: legacyCheckoutTask.taskId,
2652
+ ts: '2026-09-01T10:00:01.000Z',
2653
+ idemKey: `legacy-tail:${eventId}`,
2654
+ payload: {
2655
+ schema: 'booking.copilot.ledger',
2656
+ taskId: legacyCheckoutTask.taskId,
2657
+ contextRef: 'ctx-v2',
2658
+ eventId,
2659
+ sequence,
2660
+ emittedAt: '2026-09-01T10:00:01.000Z',
2661
+ eventKind,
2662
+ contentDigest: bookingDigest(safeContent)
2663
+ }
2664
+ });
2665
+ };
2666
+ appendLegacyEvent('legacy-terminal-event', 6, 'terminal');
2667
+ appendLegacyEvent('legacy-error-event', 7, 'error');
2668
+ rewriteLegacyOfferRows(legacyCheckoutLedger, legacyCheckoutTask.taskId, 'find hotels in Dubai', legacyCheckoutCapability.verifiedOfferRef);
2669
+ const legacyCheckoutNextWorkspace = {
2670
+ ...legacyCheckoutWorkspace,
2671
+ revision: 2,
2672
+ verifiedOffer: undefined
2673
+ };
2674
+ legacyCheckoutRuntime.appendTurn(legacyCheckoutTask.taskId, 'ctx-v2', bookingDigest({
2675
+ schemaVersion: 'booking.surface',
2676
+ kind: 'user.turn',
2677
+ taskId: legacyCheckoutTask.taskId,
2678
+ turnId: 'legacy-checkout-next-turn',
2679
+ workspace: legacyCheckoutNextWorkspace,
51
2680
  request: {
52
- text: '联系 sensitive@example.com,找 13800001111 附近的酒店'
53
- }
54
- };
55
- const runtime = new BookingCopilotTaskRuntime(ledger, runtimeOptions);
56
- const started = runtime.startTask(startTurn);
57
- assert.equal(started.phase, 'planning');
58
- assert.equal(started.taskId, 'task-opaque-1');
59
- assert.equal(started.contextRef, strictWorkspace.contextRef);
60
- assert.equal(started.userTurnCount, 1, 'the initial user turn is a durable task-scoped turn observation');
61
- const afterRestart = new BookingCopilotTaskRuntime(ledger, runtimeOptions);
62
- assert.deepEqual(afterRestart.resumeTask('task-opaque-1'), started, 'task resumes from durable ledger state');
63
- const action = {
64
- schemaVersion: 'booking.surface.v1',
65
- kind: 'search.patch',
66
- actionId: 'action-opaque-1',
67
- contextRef: strictWorkspace.contextRef,
68
- expectedRevision: 0,
69
- reason: 'private villa for sensitive@example.com',
70
- factRefs: [
71
- 'fact-opaque-1'
72
- ],
2681
+ text: 'check the replacement room'
2682
+ }
2683
+ }), oldWorkspaceDigest(legacyCheckoutNextWorkspace), oldWorkspaceSemanticDigest(legacyCheckoutNextWorkspace), 2, 'legacy-checkout-next-turn', legacyCheckoutNextWorkspace);
2684
+ const legacyCheckoutRestart = new BookingCopilotTaskRuntime(ensureLedger(legacyCheckoutRoot), {
2685
+ contextRefFactory: ()=>'ctx-v2',
2686
+ now: ()=>'2026-09-01T10:00:00.000Z'
2687
+ });
2688
+ const legacyCheckoutRecovery = legacyCheckoutRestart.resumeTask(legacyCheckoutTask.taskId);
2689
+ assert.equal(legacyCheckoutRecovery?.workspaceSnapshot?.verifiedOffer, undefined, 'legacy scalar verifiedOfferRef never becomes a live capability');
2690
+ assert.equal(legacyCheckoutRecovery?.phase, 'planning', 'unsafe legacy checkout tail suppresses old terminal/error events and replans');
2691
+ assert.equal(legacyCheckoutRecovery?.operationCount, 1, 'legacy checkout action ordinal remains consumed after tail skip');
2692
+ assert.ok((legacyCheckoutRecovery?.lastSequence ?? 0) >= 7, 'legacy terminal/error sequence remains consumed for append-only continuation');
2693
+ legacyCheckoutRestart.issueOperation(legacyCheckoutTask.taskId, {
2694
+ ...action('legacy-checkout-recheck', 2),
2695
+ kind: 'offer.check',
73
2696
  input: {
74
- patch: {
75
- destination: {
76
- query: '13800001111 private address'
2697
+ offerRef: 'legacy-checkout',
2698
+ offerVersionRef: 'legacy-checkout:v1'
2699
+ }
2700
+ });
2701
+ const legacyCheckoutAfterAppend = legacyCheckoutRestart.resumeTask(legacyCheckoutTask.taskId);
2702
+ assert.equal(legacyCheckoutAfterAppend?.phase, 'waiting_receipt', 'new typed offer.check remains reachable after unsafe legacy tail');
2703
+ assert.equal(legacyCheckoutAfterAppend?.operationCount, 2);
2704
+ assert.equal(legacyCheckoutAfterAppend?.revision, 2);
2705
+ const legacyCheckoutNewEvent = legacyCheckoutRestart.emitEvent(legacyCheckoutTask.taskId, {
2706
+ kind: 'explanation',
2707
+ explanation: {
2708
+ text: 'new anchor event',
2709
+ factRefs: []
2710
+ }
2711
+ });
2712
+ assert.equal(legacyCheckoutNewEvent.sequence, (legacyCheckoutAfterAppend?.lastSequence ?? 0) + 1, 'new post-reanchor event receives the next unique sequence');
2713
+ legacyCheckoutRestart['ledger'].close();
2714
+ legacyCheckoutLedger.close();
2715
+ rmSync(legacyCheckoutRoot, {
2716
+ recursive: true,
2717
+ force: true
2718
+ });
2719
+ const legacyBatchVariantFixture = (variant)=>{
2720
+ const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-legacy-batch-${variant}-`));
2721
+ const ledger = ensureLedger(root);
2722
+ const writer = new BookingCopilotTaskRuntime(ledger, {
2723
+ contextRefFactory: ()=>'ctx-v2',
2724
+ now: ()=>'2026-09-01T10:00:00.000Z'
2725
+ });
2726
+ const taskId = `task-legacy-batch-${variant}`;
2727
+ const capability = verifiedCapability(`batch-${variant}-offer`);
2728
+ const batchWorkspace = {
2729
+ ...workspace(0),
2730
+ visibleHotels: [
2731
+ {
2732
+ hotelRef: `batch-${variant}-hotel`,
2733
+ name: 'Batch Hotel',
2734
+ factRefs: []
2735
+ }
2736
+ ],
2737
+ loadedOffers: [
2738
+ loadedOffer(`batch-${variant}-offer`, `batch-${variant}-hotel`)
2739
+ ],
2740
+ selectedOfferRef: `batch-${variant}-offer`,
2741
+ verifiedOffer: capability
2742
+ };
2743
+ writer.startTask({
2744
+ ...turn(taskId),
2745
+ workspace: batchWorkspace
2746
+ });
2747
+ const batchAction = {
2748
+ ...action(`batch-${variant}-checkout`),
2749
+ kind: 'checkout.prepare',
2750
+ input: {
2751
+ offerRef: `batch-${variant}-offer`,
2752
+ offerVersionRef: `batch-${variant}-offer:v1`,
2753
+ verifiedOfferRef: capability.verifiedOfferRef
2754
+ }
2755
+ };
2756
+ writer.applyDecisionBatch(taskId, `batch-envelope-${variant}`, [
2757
+ {
2758
+ kind: 'operation',
2759
+ action: batchAction
2760
+ },
2761
+ {
2762
+ kind: 'explanation',
2763
+ explanation: {
2764
+ text: 'batch envelope',
2765
+ factRefs: []
77
2766
  }
78
2767
  }
2768
+ ], true);
2769
+ rewriteLegacyOfferRows(ledger, taskId, 'find hotels in Dubai', capability.verifiedOfferRef);
2770
+ const row = ledger.db.prepare("SELECT seq, payload FROM events WHERE run_id = ? AND kind = 'booking.copilot.decision.batch'").get(taskId);
2771
+ const payload = JSON.parse(row.payload);
2772
+ if (variant === 'tampered') {
2773
+ const explanation = payload.events.find((event)=>event.kind === 'explanation');
2774
+ if (explanation && explanation.explanation && typeof explanation.explanation === 'object') explanation.explanation.text = 'edited envelope';
2775
+ } else if (variant === 'missing') {
2776
+ const operationIndex = payload.events.findIndex((event)=>event.kind === 'operation');
2777
+ assert.ok(operationIndex >= 0, 'missing batch fixture has a retained operation identity to remove');
2778
+ payload.events.splice(operationIndex, 1);
2779
+ } else if (variant === 'unknown') {
2780
+ const first = payload.events[0];
2781
+ if (first) first.eventId = 'unknown-envelope-event';
2782
+ } else {
2783
+ const first = payload.events[0];
2784
+ if (first) payload.events.push(structuredClone(first));
79
2785
  }
2786
+ ledger.db.prepare('UPDATE events SET payload = ? WHERE seq = ?').run(JSON.stringify(payload), row.seq);
2787
+ ledger.close();
2788
+ return {
2789
+ root,
2790
+ taskId
2791
+ };
80
2792
  };
81
- const operation = afterRestart.issueOperation('task-opaque-1', action);
82
- assert.equal(operation.kind, 'operation');
83
- assert.equal(operation.contextRef, strictWorkspace.contextRef);
84
- assert.deepEqual(operation.action, action);
85
- assert.equal(afterRestart.resumeTask('task-opaque-1')?.phase, 'waiting_receipt');
86
- assert.throws(()=>afterRestart.issueOperation('task-opaque-1', {
87
- ...action,
88
- actionId: 'action-opaque-2'
89
- }), /receipt_required/, 'one task cannot issue another operation before the current receipt');
90
- assert.throws(()=>afterRestart.startTask({
91
- ...startTurn,
92
- request: {
93
- text: '先继续筛选'
2793
+ for (const variant of [
2794
+ 'tampered',
2795
+ 'missing',
2796
+ 'unknown',
2797
+ 'duplicate'
2798
+ ]){
2799
+ const fixtureState = legacyBatchVariantFixture(variant);
2800
+ const replayLedger = ensureLedger(fixtureState.root);
2801
+ const replay = new BookingCopilotTaskRuntime(replayLedger, {
2802
+ contextRefFactory: ()=>'ctx-v2',
2803
+ now: ()=>'2026-09-01T10:00:00.000Z'
2804
+ });
2805
+ assert.throws(()=>replay.resumeTask(fixtureState.taskId), new RegExp(`ledger_corrupt:${fixtureState.taskId}:decision_batch`), `legacy duplicate batch ${variant} mismatch fails closed`);
2806
+ replayLedger.close();
2807
+ rmSync(fixtureState.root, {
2808
+ recursive: true,
2809
+ force: true
2810
+ });
2811
+ }
2812
+ const legacyTwoBatchFixture = (crossBatchGraft)=>{
2813
+ const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-legacy-two-batch-${crossBatchGraft ? 'graft' : 'clean'}-`));
2814
+ const ledger = ensureLedger(root);
2815
+ const writer = new BookingCopilotTaskRuntime(ledger, {
2816
+ contextRefFactory: ()=>'ctx-v2',
2817
+ now: ()=>'2026-09-01T10:00:00.000Z'
2818
+ });
2819
+ const taskId = `task-legacy-two-batch-${crossBatchGraft ? 'graft' : 'clean'}`;
2820
+ writer.startTask(turn(taskId));
2821
+ const standaloneStatus = writer.emitEvent(taskId, {
2822
+ kind: 'status',
2823
+ status: 'submitted'
2824
+ });
2825
+ const standaloneAction = writer.issueOperation(taskId, action(`${taskId}-standalone-action`));
2826
+ const standaloneReceipt = writer.withReceiptDigest({
2827
+ schemaVersion: 'booking.surface',
2828
+ kind: 'action.receipt',
2829
+ actionId: standaloneAction.action.actionId,
2830
+ contextRef: 'ctx-v2',
2831
+ status: 'applied',
2832
+ revision: 1,
2833
+ observation: {
2834
+ kind: 'search.state',
2835
+ resultCount: 0
2836
+ },
2837
+ resultContract: {
2838
+ outcome: 'complete',
2839
+ hardCriteriaMet: true,
2840
+ factRefs: [],
2841
+ gapCodes: [],
2842
+ blockers: [],
2843
+ relaxationsApplied: []
94
2844
  }
95
- }), /receipt_required/, 'a fresh user turn cannot bypass the pending operation receipt');
96
- const receipt = {
97
- schemaVersion: 'booking.surface.v1',
2845
+ });
2846
+ writer.continueWithReceipt({
2847
+ schemaVersion: 'booking.surface',
2848
+ kind: 'action.receipt.continuation',
2849
+ taskId,
2850
+ workspace: workspace(1),
2851
+ receipt: standaloneReceipt
2852
+ });
2853
+ const capability = verifiedCapability(`${taskId}-offer`);
2854
+ const batchWorkspace = {
2855
+ ...workspace(1),
2856
+ visibleHotels: [
2857
+ {
2858
+ hotelRef: `${taskId}-hotel`,
2859
+ name: 'Two Batch Hotel',
2860
+ factRefs: []
2861
+ }
2862
+ ],
2863
+ loadedOffers: [
2864
+ loadedOffer(`${taskId}-offer`, `${taskId}-hotel`)
2865
+ ],
2866
+ selectedOfferRef: `${taskId}-offer`,
2867
+ verifiedOffer: capability
2868
+ };
2869
+ writer.startTask({
2870
+ ...turn(taskId, 1),
2871
+ turnId: `${taskId}-anchor-turn`,
2872
+ workspace: batchWorkspace
2873
+ });
2874
+ const normalBatch = writer.applyDecisionBatch(taskId, `${taskId}-normal-batch`, [
2875
+ {
2876
+ kind: 'explanation',
2877
+ explanation: {
2878
+ text: 'normal completed batch',
2879
+ factRefs: []
2880
+ }
2881
+ }
2882
+ ], true);
2883
+ assert.ok(normalBatch.some((event)=>event.kind === 'explanation'), 'normal batch persists its own explanation');
2884
+ const unsafeAction = {
2885
+ ...action(`${taskId}-unsafe-checkout`, 1),
2886
+ kind: 'checkout.prepare',
2887
+ input: {
2888
+ offerRef: `${taskId}-offer`,
2889
+ offerVersionRef: `${taskId}-offer:v1`,
2890
+ verifiedOfferRef: capability.verifiedOfferRef
2891
+ }
2892
+ };
2893
+ writer.applyDecisionBatch(taskId, `${taskId}-unsafe-batch`, [
2894
+ {
2895
+ kind: 'operation',
2896
+ action: unsafeAction
2897
+ },
2898
+ {
2899
+ kind: 'explanation',
2900
+ explanation: {
2901
+ text: 'unsafe legacy batch',
2902
+ factRefs: []
2903
+ }
2904
+ }
2905
+ ], true);
2906
+ rewriteLegacyOfferRows(ledger, taskId, 'find hotels in Dubai', capability.verifiedOfferRef);
2907
+ if (crossBatchGraft) {
2908
+ const rows = ledger.db.prepare("SELECT seq, payload FROM events WHERE run_id = ? AND kind = 'booking.copilot.decision.batch' ORDER BY seq").all(taskId);
2909
+ assert.equal(rows.length, 2, 'two-batch fixture retains both decision envelopes');
2910
+ const firstPayload = JSON.parse(rows[0].payload);
2911
+ const secondPayload = JSON.parse(rows[1].payload);
2912
+ const graft = firstPayload.events.find((event)=>event.kind === 'explanation');
2913
+ assert.ok(graft, 'first batch has an event identity available for graft detection');
2914
+ const graftIndex = secondPayload.events.findIndex((event)=>event.kind === 'explanation');
2915
+ assert.ok(graftIndex >= 0, 'second batch has a retained event slot for graft detection');
2916
+ secondPayload.events[graftIndex] = structuredClone(graft);
2917
+ ledger.db.prepare('UPDATE events SET payload = ? WHERE seq = ?').run(JSON.stringify(secondPayload), rows[1].seq);
2918
+ }
2919
+ assert.equal(standaloneStatus.sequence < standaloneAction.sequence, true, 'standalone EVENT precedes standalone ACTION before batched work');
2920
+ ledger.close();
2921
+ return {
2922
+ root,
2923
+ taskId
2924
+ };
2925
+ };
2926
+ const cleanTwoBatch = legacyTwoBatchFixture(false);
2927
+ const cleanTwoBatchReplayLedger = ensureLedger(cleanTwoBatch.root);
2928
+ const cleanTwoBatchReplay = new BookingCopilotTaskRuntime(cleanTwoBatchReplayLedger, {
2929
+ contextRefFactory: ()=>'ctx-v2',
2930
+ now: ()=>'2026-09-01T10:00:00.000Z'
2931
+ });
2932
+ const cleanTwoBatchState = cleanTwoBatchReplay.resumeTask(cleanTwoBatch.taskId);
2933
+ assert.equal(cleanTwoBatchState?.phase, 'planning', 'normal batch replays before unsafe legacy batch enters replan');
2934
+ assert.equal(cleanTwoBatchState?.operationCount, 2, 'standalone action and unsafe batch action ordinals remain consumed');
2935
+ cleanTwoBatchReplayLedger.close();
2936
+ rmSync(cleanTwoBatch.root, {
2937
+ recursive: true,
2938
+ force: true
2939
+ });
2940
+ const graftedTwoBatch = legacyTwoBatchFixture(true);
2941
+ const graftedTwoBatchReplayLedger = ensureLedger(graftedTwoBatch.root);
2942
+ const graftedTwoBatchReplay = new BookingCopilotTaskRuntime(graftedTwoBatchReplayLedger, {
2943
+ contextRefFactory: ()=>'ctx-v2',
2944
+ now: ()=>'2026-09-01T10:00:00.000Z'
2945
+ });
2946
+ assert.throws(()=>graftedTwoBatchReplay.resumeTask(graftedTwoBatch.taskId), new RegExp(`ledger_corrupt:${graftedTwoBatch.taskId}:decision_batch`), 'cross-batch event graft fails closed within the second envelope');
2947
+ graftedTwoBatchReplayLedger.close();
2948
+ rmSync(graftedTwoBatch.root, {
2949
+ recursive: true,
2950
+ force: true
2951
+ });
2952
+ for (const kind of [
2953
+ 'offer.select',
2954
+ 'offer.check'
2955
+ ]){
2956
+ const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-legacy-safe-batch-${kind.replace('.', '-')}-`));
2957
+ const ledger = ensureLedger(root);
2958
+ const writer = new BookingCopilotTaskRuntime(ledger, {
2959
+ contextRefFactory: ()=>'ctx-v2',
2960
+ now: ()=>'2026-09-01T10:00:00.000Z'
2961
+ });
2962
+ const taskId = `task-legacy-safe-batch-${kind.replace('.', '-')}`;
2963
+ const offerRef = `${taskId}-offer`;
2964
+ const hotelRef = `${taskId}-hotel`;
2965
+ const safeWorkspace = {
2966
+ ...workspace(0),
2967
+ visibleHotels: [
2968
+ {
2969
+ hotelRef,
2970
+ name: 'Safe Batch Hotel',
2971
+ factRefs: []
2972
+ }
2973
+ ],
2974
+ loadedOffers: [
2975
+ loadedOffer(offerRef, hotelRef)
2976
+ ]
2977
+ };
2978
+ writer.startTask({
2979
+ ...turn(taskId),
2980
+ workspace: safeWorkspace
2981
+ });
2982
+ const safeAction = {
2983
+ ...action(`${taskId}-action`),
2984
+ kind,
2985
+ input: {
2986
+ offerRef,
2987
+ offerVersionRef: `${offerRef}:v1`
2988
+ }
2989
+ };
2990
+ const requestKey = `${taskId}-request`;
2991
+ writer.applyDecisionBatch(taskId, requestKey, [
2992
+ {
2993
+ kind: 'operation',
2994
+ action: safeAction
2995
+ }
2996
+ ], true);
2997
+ rewriteLegacyOfferRows(ledger, taskId, 'find hotels in Dubai');
2998
+ ledger.close();
2999
+ const restartLedger = ensureLedger(root);
3000
+ const restart = new BookingCopilotTaskRuntime(restartLedger, {
3001
+ contextRefFactory: ()=>'ctx-v2',
3002
+ now: ()=>'2026-09-01T10:00:00.000Z'
3003
+ });
3004
+ const recovered = restart.resumeTask(taskId);
3005
+ const recoveredOperation = recovered?.pendingAction;
3006
+ const expectedVersion = `legacy-offer-version:${bookingDigest({
3007
+ contextRef: 'ctx-v2',
3008
+ offerRef,
3009
+ hotelRef,
3010
+ evidenceLevel: 'rate_loaded',
3011
+ factRefs: []
3012
+ }).slice(0, 40)}`;
3013
+ const offerVersionFromAction = (candidate)=>{
3014
+ const input = candidate?.input;
3015
+ return input && typeof input === 'object' && !Array.isArray(input) && 'offerVersionRef' in input && typeof input.offerVersionRef === 'string' ? input.offerVersionRef : undefined;
3016
+ };
3017
+ assert.equal(recoveredOperation?.kind, kind, `legacy ${kind} batch restart retains operation kind`);
3018
+ assert.equal(offerVersionFromAction(recoveredOperation), expectedVersion, `legacy ${kind} batch restart binds deterministic synthetic version`);
3019
+ const replayBatch = restart.readDecisionBatch(taskId, requestKey);
3020
+ assert.ok(replayBatch, `legacy ${kind} batch is readable after restart`);
3021
+ const replayOperation = replayBatch?.find((event)=>event.kind === 'operation');
3022
+ assert.ok(replayOperation && validateBookingSurface(replayOperation).ok, `legacy ${kind} batch operation is schema-valid after replay normalization`);
3023
+ assert.equal(replayOperation?.kind === 'operation' ? offerVersionFromAction(replayOperation.action) : undefined, expectedVersion, `legacy ${kind} batch replay operation carries synthetic version`);
3024
+ restartLedger.close();
3025
+ rmSync(root, {
3026
+ recursive: true,
3027
+ force: true
3028
+ });
3029
+ }
3030
+ const legacyBlockerRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-blocker-replan-'));
3031
+ const legacyBlockerLedger = ensureLedger(legacyBlockerRoot);
3032
+ const legacyBlockerRuntime = new BookingCopilotTaskRuntime(legacyBlockerLedger, {
3033
+ contextRefFactory: ()=>'ctx-v2'
3034
+ });
3035
+ const blockerWorkspace0 = {
3036
+ ...workspace(0),
3037
+ visibleHotels: [
3038
+ {
3039
+ hotelRef: 'blocker-hotel',
3040
+ name: 'Blocker Hotel',
3041
+ factRefs: []
3042
+ }
3043
+ ],
3044
+ loadedOffers: [
3045
+ loadedOffer('blocker-offer', 'blocker-hotel')
3046
+ ]
3047
+ };
3048
+ const blockerTask = legacyBlockerRuntime.startTask({
3049
+ ...turn('task-legacy-blocker-lineage'),
3050
+ workspace: blockerWorkspace0
3051
+ });
3052
+ legacyBlockerRuntime.issueOperation(blockerTask.taskId, {
3053
+ ...action('legacy-blocker-check'),
3054
+ kind: 'offer.check',
3055
+ input: {
3056
+ offerRef: 'blocker-offer',
3057
+ offerVersionRef: 'blocker-offer:v1'
3058
+ }
3059
+ });
3060
+ const legacyLineageReceipt = legacyBlockerRuntime.withReceiptDigest({
3061
+ schemaVersion: 'booking.surface',
98
3062
  kind: 'action.receipt',
99
- actionId: action.actionId,
100
- contextRef: strictWorkspace.contextRef,
101
- status: 'applied',
3063
+ actionId: 'legacy-blocker-check',
3064
+ contextRef: 'ctx-v2',
3065
+ status: 'needs_input',
102
3066
  revision: 1,
103
3067
  observation: {
104
- kind: 'search.state',
105
- searchSessionRef: 'search-opaque-1',
106
- resultCount: 12
3068
+ kind: 'offer.availability',
3069
+ offerRef: 'blocker-offer',
3070
+ checkedOfferVersionRef: 'blocker-offer:v1',
3071
+ available: false,
3072
+ changedFactRefs: [],
3073
+ gapCodes: []
107
3074
  },
108
3075
  resultContract: {
109
- outcome: 'complete',
110
- hardCriteriaMet: true,
3076
+ outcome: 'partial',
3077
+ hardCriteriaMet: false,
111
3078
  factRefs: [
112
- 'fact-opaque-2'
3079
+ 'fact-free-cancellation'
113
3080
  ],
114
- gapCodes: []
115
- },
116
- undoToken: 'undo-opaque-1'
117
- };
118
- const continuation = {
119
- schemaVersion: 'booking.surface.v1',
3081
+ gapCodes: [
3082
+ 'criterion_must_not_met'
3083
+ ],
3084
+ blockers: [
3085
+ {
3086
+ blockerId: 'legacy-blocker',
3087
+ sourceActionId: 'legacy-blocker-check',
3088
+ sourceReceiptDigest: '',
3089
+ scope: 'availability',
3090
+ code: 'criterion_must_not_met',
3091
+ criterionPath: 'offers.freeCancellation',
3092
+ strength: 'must',
3093
+ valueDigest: 'c'.repeat(64),
3094
+ evidence: {
3095
+ factRefs: [
3096
+ 'fact-free-cancellation'
3097
+ ],
3098
+ gapCodes: [
3099
+ 'criterion_must_not_met'
3100
+ ]
3101
+ }
3102
+ }
3103
+ ],
3104
+ relaxationsApplied: []
3105
+ }
3106
+ });
3107
+ legacyBlockerRuntime.continueWithReceipt({
3108
+ schemaVersion: 'booking.surface',
120
3109
  kind: 'action.receipt.continuation',
121
- taskId: 'task-opaque-1',
3110
+ taskId: blockerTask.taskId,
122
3111
  workspace: {
123
- ...startTurn.workspace,
124
- revision: 1,
125
- results: {
126
- status: 'ready',
127
- resultCount: 12,
128
- searchSessionRef: 'search-opaque-1'
129
- }
3112
+ ...blockerWorkspace0,
3113
+ revision: 1
130
3114
  },
131
- receipt
3115
+ receipt: legacyLineageReceipt
3116
+ });
3117
+ rewriteLegacyOfferRows(legacyBlockerLedger, blockerTask.taskId, 'find hotels in Dubai');
3118
+ const legacyBlockerRestart = new BookingCopilotTaskRuntime(ensureLedger(legacyBlockerRoot), {
3119
+ contextRefFactory: ()=>'ctx-v2'
3120
+ });
3121
+ const blockerRecovery = legacyBlockerRestart.resumeTask(blockerTask.taskId);
3122
+ assert.equal(blockerRecovery?.phase, 'planning', 'normalized legacy blocker receipt fail-closes to replan');
3123
+ assert.equal(blockerRecovery?.replayUpgradeRequired, true, 'legacy blocker lineage records an explicit replay upgrade gap');
3124
+ assert.equal(blockerRecovery?.pendingAction, undefined);
3125
+ assert.throws(()=>legacyBlockerRestart.issueOperation(blockerTask.taskId, action('blocked-replan-action', 1)), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks direct operation before a new anchor turn');
3126
+ assert.throws(()=>legacyBlockerRestart.applyDecisionBatch(blockerTask.taskId, 'blocked-replan-batch', [
3127
+ {
3128
+ kind: 'operation',
3129
+ action: action('blocked-replan-decision', 1)
3130
+ }
3131
+ ]), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks decision batches before a new anchor turn');
3132
+ assert.throws(()=>legacyBlockerRestart.emitEvent(blockerTask.taskId, {
3133
+ kind: 'explanation',
3134
+ explanation: {
3135
+ text: 'must wait for a new anchor turn',
3136
+ factRefs: []
3137
+ }
3138
+ }), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks standalone event mutation');
3139
+ const blockedBindingTurn = {
3140
+ ...turn(blockerTask.taskId, 1),
3141
+ turnId: 'blocked-replan-binding-turn'
3142
+ };
3143
+ assert.throws(()=>legacyBlockerRestart.persistRequestBinding('blocked-replan-request', blockedBindingTurn, {
3144
+ requestKey: 'blocked-replan-request',
3145
+ principal: {
3146
+ subject: 'blocked-replan-principal',
3147
+ scope: 'booking:read'
3148
+ }
3149
+ }), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks request binding mutation');
3150
+ assert.throws(()=>legacyBlockerRestart.terminalDecisionBatch(blockerTask.taskId, 'blocked-replan-terminal-batch'), /replay_upgrade|reanchor|replan|turn_required|task_not_terminal/, 'replay upgrade gap blocks terminal decision mutation');
3151
+ const reanchorWorkspace = blockerRecovery.workspaceSnapshot;
3152
+ const reanchor = legacyBlockerRestart.startTask({
3153
+ ...turn(blockerTask.taskId, reanchorWorkspace.revision),
3154
+ turnId: 'legacy-blocker-reanchor-turn',
3155
+ workspace: reanchorWorkspace,
3156
+ request: {
3157
+ text: 're-anchor the upgraded workspace'
3158
+ }
3159
+ });
3160
+ assert.equal(reanchor.replayUpgradeRequired, undefined, 'validated new TURN clears replay upgrade gap');
3161
+ legacyBlockerRestart['ledger'].close();
3162
+ legacyBlockerLedger.close();
3163
+ rmSync(legacyBlockerRoot, {
3164
+ recursive: true,
3165
+ force: true
3166
+ });
3167
+ const skippedActionRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-skipped-action-tamper-'));
3168
+ const skippedActionLedger = ensureLedger(skippedActionRoot);
3169
+ const skippedActionWriter = new BookingCopilotTaskRuntime(skippedActionLedger, {
3170
+ contextRefFactory: ()=>'ctx-v2',
3171
+ now: ()=>'2026-09-01T10:00:00.000Z'
3172
+ });
3173
+ const skippedWorkspace = {
3174
+ ...workspace(0),
3175
+ visibleHotels: [
3176
+ {
3177
+ hotelRef: 'skipped-hotel',
3178
+ name: 'Skipped Hotel',
3179
+ factRefs: []
3180
+ }
3181
+ ],
3182
+ loadedOffers: [
3183
+ loadedOffer('skipped-offer', 'skipped-hotel')
3184
+ ],
3185
+ selectedOfferRef: 'skipped-offer',
3186
+ verifiedOffer: verifiedCapability('skipped-offer')
3187
+ };
3188
+ const skippedTask = skippedActionWriter.startTask({
3189
+ ...turn('task-legacy-skipped-action'),
3190
+ workspace: skippedWorkspace
3191
+ });
3192
+ skippedActionWriter.issueOperation(skippedTask.taskId, {
3193
+ ...action('unsafe-checkout-tail'),
3194
+ kind: 'checkout.prepare',
3195
+ input: {
3196
+ offerRef: 'skipped-offer',
3197
+ offerVersionRef: 'skipped-offer:v1',
3198
+ verifiedOfferRef: 'verified-skipped-offer'
3199
+ }
3200
+ });
3201
+ rewriteLegacyOfferRows(skippedActionLedger, skippedTask.taskId, 'find hotels in Dubai', 'verified-skipped-offer');
3202
+ skippedActionLedger.insertEvent({
3203
+ actor: 'system:booking-copilot',
3204
+ kind: 'booking.copilot.event.emitted',
3205
+ subjectId: skippedTask.taskId,
3206
+ runId: skippedTask.taskId,
3207
+ ts: '2026-09-01T10:00:01.000Z',
3208
+ idemKey: 'skipped-action-tail-event',
3209
+ payload: {
3210
+ schema: 'booking.copilot.ledger',
3211
+ taskId: skippedTask.taskId,
3212
+ contextRef: 'ctx-v2',
3213
+ eventId: 'skipped-action-tail-event',
3214
+ sequence: 2,
3215
+ emittedAt: '2026-09-01T10:00:01.000Z',
3216
+ eventKind: 'error',
3217
+ contentDigest: bookingDigest({
3218
+ code: 'unhandled',
3219
+ message: 'legacy error',
3220
+ retryable: false
3221
+ })
3222
+ }
3223
+ });
3224
+ const lateActionBase = {
3225
+ actionId: 'legacy-skipped-action',
3226
+ kind: 'search.run',
3227
+ contextRef: 'ctx-v2',
3228
+ expectedRevision: 0,
3229
+ factRefs: [],
3230
+ reasonDigest: bookingDigest('legacy search'),
3231
+ inputDigest: bookingDigest({}),
3232
+ input: {},
3233
+ eventId: 'legacy-skipped-operation',
3234
+ sequence: 3,
3235
+ emittedAt: '2026-09-01T10:00:02.000Z',
3236
+ sourceTurnId: 'task-legacy-skipped-action-turn-0'
3237
+ };
3238
+ const lateAction = {
3239
+ ...lateActionBase,
3240
+ actionDigest: bookingDigest(lateActionBase)
132
3241
  };
133
- assert.throws(()=>afterRestart.continueWithReceipt({
134
- ...continuation,
3242
+ const startAvailability = JSON.parse(skippedActionLedger.db.prepare("SELECT payload FROM events WHERE run_id = ? AND kind = 'booking.copilot.task.started'").get(skippedTask.taskId).payload).availability;
3243
+ skippedActionLedger.insertEvent({
3244
+ actor: 'system:booking-copilot',
3245
+ kind: 'booking.copilot.action.issued',
3246
+ subjectId: skippedTask.taskId,
3247
+ runId: skippedTask.taskId,
3248
+ ts: '2026-09-01T10:00:02.000Z',
3249
+ idemKey: 'legacy-skipped-action-row',
3250
+ payload: {
3251
+ schema: 'booking.copilot.ledger',
3252
+ taskId: skippedTask.taskId,
3253
+ contextRef: 'ctx-v2',
3254
+ action: lateAction,
3255
+ operationCount: 99,
3256
+ availability: legacyAvailability(startAvailability),
3257
+ availabilityDigest: bookingDigest(legacyAvailability(startAvailability))
3258
+ }
3259
+ });
3260
+ const skippedActionRestart = new BookingCopilotTaskRuntime(ensureLedger(skippedActionRoot), {
3261
+ contextRefFactory: ()=>'ctx-v2',
3262
+ now: ()=>'2026-09-01T10:00:00.000Z'
3263
+ });
3264
+ assert.throws(()=>skippedActionRestart.resumeTask(skippedTask.taskId), /ledger_corrupt:task-legacy-skipped-action:action/, 'tampered skipped ACTION ordinal fails closed during legacy tail replay');
3265
+ skippedActionRestart['ledger'].close();
3266
+ skippedActionLedger.close();
3267
+ rmSync(skippedActionRoot, {
3268
+ recursive: true,
3269
+ force: true
3270
+ });
3271
+ const approvedReplay = await fetch(endpoint, {
3272
+ method: 'POST',
3273
+ headers,
3274
+ body: JSON.stringify(approvedIngress)
3275
+ });
3276
+ assert.equal(approvedReplay.status, 200);
3277
+ assert.equal(await approvedReplay.text(), approvedBody, 'approved ingress retry replays the durable batch while waiting for receipt');
3278
+ assert.equal(plannerCalls, 3, 'approved ingress replay does not call planner or append');
3279
+ const alteredTextReplay = await fetch(endpoint, {
3280
+ method: 'POST',
3281
+ headers,
3282
+ body: JSON.stringify({
3283
+ ...approvedIngress,
3284
+ request: {
3285
+ ...approvedIngress.request,
3286
+ text: 'different request'
3287
+ }
3288
+ })
3289
+ });
3290
+ assert.equal(alteredTextReplay.status, 409, 'altered request text cannot replay a waiting batch');
3291
+ const alteredContextReplay = await fetch(endpoint, {
3292
+ method: 'POST',
3293
+ headers,
3294
+ body: JSON.stringify({
3295
+ ...approvedIngress,
3296
+ contextRef: 'ctx-forged'
3297
+ })
3298
+ });
3299
+ assert.equal(alteredContextReplay.status, 400, 'browser context injection is rejected at ingress schema boundary');
3300
+ const alteredApprovalReplay = await fetch(endpoint, {
3301
+ method: 'POST',
3302
+ headers,
3303
+ body: JSON.stringify({
3304
+ ...approvedIngress,
3305
+ request: {
3306
+ ...approvedIngress.request,
3307
+ approval: {
3308
+ ...approved,
3309
+ to: 'drop'
3310
+ }
3311
+ }
3312
+ })
3313
+ });
3314
+ assert.equal(alteredApprovalReplay.status, 409, 'altered approval tuple cannot replay a waiting batch');
3315
+ const alteredCapabilitiesReplay = await fetch(endpoint, {
3316
+ method: 'POST',
3317
+ headers,
3318
+ body: JSON.stringify({
3319
+ ...approvedIngress,
135
3320
  workspace: {
136
- ...continuation.workspace,
137
- contextRef: 'ctx-other'
3321
+ ...approvedIngress.workspace,
3322
+ capabilities: {
3323
+ surface: 'tenant',
3324
+ allowedActions: [
3325
+ 'search.run',
3326
+ 'search.run'
3327
+ ]
3328
+ }
3329
+ }
3330
+ })
3331
+ });
3332
+ assert.equal(alteredCapabilitiesReplay.status, 400, 'browser capability injection is rejected at ingress schema boundary');
3333
+ const ingressReplay = await fetch(endpoint, {
3334
+ method: 'POST',
3335
+ headers,
3336
+ body: JSON.stringify({
3337
+ ...ingress,
3338
+ workspace: {
3339
+ ...ingress.workspace,
3340
+ revision: 2
138
3341
  },
139
- receipt: {
140
- ...receipt,
141
- contextRef: 'ctx-other'
3342
+ request: {
3343
+ text: 'prefer a nearby match'
142
3344
  }
143
- }), /context_mismatch/, 'a receipt cannot cross task context');
144
- const continued = afterRestart.continueWithReceipt(continuation);
145
- assert.equal(continued.phase, 'planning');
146
- assert.equal(continued.revision, 1);
147
- assert.deepEqual(continued.lastReceipt, receipt);
148
- const eventCountAfterReceipt = ledger.countEvents();
149
- assert.deepEqual(afterRestart.continueWithReceipt(continuation), continued, 'identical receipt continuation is idempotent');
150
- assert.equal(ledger.countEvents(), eventCountAfterReceipt, 'duplicate receipt writes no second ledger event');
151
- const nextUserTurn = {
152
- ...startTurn,
153
- workspace: continuation.workspace,
154
- request: {
155
- text: '继续筛选,只保留含早和免费取消的报价'
3345
+ })
3346
+ });
3347
+ assert.equal(ingressReplay.status, 409, 'unbound ingress cannot replay an existing task');
3348
+ assert.equal(plannerCalls, 3);
3349
+ const staleHandshake = await fetch(endpoint, {
3350
+ method: 'POST',
3351
+ headers: {
3352
+ authorization: 'Bearer v2-server-key',
3353
+ 'content-type': 'application/json',
3354
+ 'x-booking-surface-version': 'booking.surface.retired',
3355
+ 'x-booking-surface-schema-sha256': '0'.repeat(64)
3356
+ },
3357
+ body: JSON.stringify({
3358
+ ...turn('task-server'),
3359
+ workspace: {
3360
+ ...workspace(1),
3361
+ contextRef: 'ctx-server'
3362
+ }
3363
+ })
3364
+ });
3365
+ assert.equal(staleHandshake.status, 409, 'a retired handshake can no longer reach the single contract server');
3366
+ await server.close();
3367
+ const defaultBindingRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-default-binding-'));
3368
+ const defaultBindingLedger = ensureLedger(defaultBindingRoot);
3369
+ const defaultBindingRuntime = new BookingCopilotTaskRuntime(defaultBindingLedger);
3370
+ let defaultBindingPlannerCalls = 0;
3371
+ await assert.rejects(startBookingCopilotServer({
3372
+ apiKey: 'partial-binding-key',
3373
+ runtime: defaultBindingRuntime,
3374
+ plannerFactory: ()=>({
3375
+ next: async ()=>[]
3376
+ }),
3377
+ principal: {
3378
+ subject: 'partial-only',
3379
+ scope: 'booking:read'
156
3380
  }
3381
+ }), /booking_copilot_ingress_binding_pair_required/);
3382
+ await assert.rejects(startBookingCopilotServer({
3383
+ apiKey: 'partial-principal-key',
3384
+ runtime: defaultBindingRuntime,
3385
+ plannerFactory: ()=>({
3386
+ next: async ()=>[]
3387
+ }),
3388
+ ingressBinding: {
3389
+ bind: ()=>({
3390
+ taskId: 'partial',
3391
+ turnId: 'partial',
3392
+ contextRef: 'partial',
3393
+ surface: 'tenant',
3394
+ allowedActions: [
3395
+ ...BOOKING_READ_ACTION_KINDS
3396
+ ]
3397
+ })
3398
+ }
3399
+ }), /booking_copilot_ingress_binding_pair_required/);
3400
+ const defaultBindingServer = await startBookingCopilotServer({
3401
+ apiKey: 'default-binding-key',
3402
+ runtime: defaultBindingRuntime,
3403
+ plannerFactory: ()=>({
3404
+ next: async ()=>{
3405
+ defaultBindingPlannerCalls++;
3406
+ return [
3407
+ {
3408
+ kind: 'terminal',
3409
+ terminal: {
3410
+ status: 'stopped',
3411
+ summary: 'default binding',
3412
+ factRefs: []
3413
+ }
3414
+ }
3415
+ ];
3416
+ }
3417
+ })
3418
+ });
3419
+ const defaultBindingEndpoint = `http://127.0.0.1:${defaultBindingServer.port}/a2a/booking-copilot/turn`;
3420
+ const defaultBindingHeaders = {
3421
+ ...headers,
3422
+ authorization: 'Bearer default-binding-key'
157
3423
  };
158
- const continuedTask = afterRestart.startTask(nextUserTurn);
159
- assert.equal(continuedTask.userTurnCount, 2, 'one task accepts a new user intent after its receipt');
160
- assert.notEqual(continuedTask.lastUserTurnDigest, started.lastUserTurnDigest, 'each user turn stores its own digest');
161
- assert.throws(()=>afterRestart.startTask({
162
- ...nextUserTurn,
3424
+ const defaultBindingBeforeIngress = defaultBindingLedger.countEvents();
3425
+ const rejectedDefaultIngress = await fetch(defaultBindingEndpoint, {
3426
+ method: 'POST',
3427
+ headers: defaultBindingHeaders,
3428
+ body: JSON.stringify(ingress)
3429
+ });
3430
+ assert.equal(rejectedDefaultIngress.status, 503, 'bound-turn-only mode rejects browser ingress before the ledger');
3431
+ assert.deepEqual(await rejectedDefaultIngress.json(), {
3432
+ error: {
3433
+ code: 'trusted_ingress_binding_required',
3434
+ mode: 'bff-bound-turn-only',
3435
+ acceptedTurnKinds: [
3436
+ 'user.turn',
3437
+ 'action.receipt.continuation'
3438
+ ]
3439
+ }
3440
+ });
3441
+ assert.equal(defaultBindingLedger.countEvents(), defaultBindingBeforeIngress, 'rejected browser ingress has no ledger side effects');
3442
+ const boundTurnForSurface = (taskId, surface, allowedActions)=>({
3443
+ ...turn(taskId),
3444
+ workspace: {
3445
+ ...workspace(),
3446
+ surface,
3447
+ capabilities: {
3448
+ surface,
3449
+ allowedActions
3450
+ }
3451
+ }
3452
+ });
3453
+ const invalidBoundTurns = [
3454
+ boundTurnForSurface('task-bound-storefront-escalation', 'storefront', [
3455
+ 'order.observe'
3456
+ ]),
3457
+ boundTurnForSurface('task-bound-payment-escalation', 'payment_link', [
3458
+ 'checkout.prepare'
3459
+ ]),
3460
+ {
3461
+ ...boundTurnForSurface('task-bound-capability-mismatch', 'storefront', [
3462
+ 'search.run'
3463
+ ]),
163
3464
  workspace: {
164
- ...nextUserTurn.workspace,
165
- revision: 0
3465
+ ...workspace(),
3466
+ surface: 'storefront',
3467
+ capabilities: {
3468
+ surface: 'tenant',
3469
+ allowedActions: [
3470
+ 'search.run'
3471
+ ]
3472
+ }
3473
+ }
3474
+ },
3475
+ boundTurnForSurface('task-bound-duplicate-capability', 'tenant', [
3476
+ 'search.run',
3477
+ 'search.run'
3478
+ ]),
3479
+ boundTurnForSurface('task-bound-unknown-capability', 'tenant', [
3480
+ 'book'
3481
+ ])
3482
+ ];
3483
+ for (const invalidBoundTurn of invalidBoundTurns){
3484
+ const before = defaultBindingLedger.countEvents();
3485
+ const response = await fetch(defaultBindingEndpoint, {
3486
+ method: 'POST',
3487
+ headers: defaultBindingHeaders,
3488
+ body: JSON.stringify(invalidBoundTurn)
3489
+ });
3490
+ assert.ok([
3491
+ 400,
3492
+ 403
3493
+ ].includes(response.status), `${invalidBoundTurn.taskId} is rejected at the schema/authority boundary`);
3494
+ assert.equal(defaultBindingLedger.countEvents(), before, `${invalidBoundTurn.taskId} has no ledger side effects`);
3495
+ }
3496
+ const invalidBoundContinuation = {
3497
+ schemaVersion: 'booking.surface',
3498
+ kind: 'action.receipt.continuation',
3499
+ taskId: 'task-bound-invalid-continuation',
3500
+ workspace: {
3501
+ ...workspace(1),
3502
+ surface: 'storefront',
3503
+ capabilities: {
3504
+ surface: 'storefront',
3505
+ allowedActions: [
3506
+ 'order.observe'
3507
+ ]
3508
+ }
3509
+ },
3510
+ receipt: {
3511
+ schemaVersion: 'booking.surface',
3512
+ kind: 'action.receipt',
3513
+ actionId: 'missing-action',
3514
+ contextRef: 'ctx-v2',
3515
+ status: 'failed',
3516
+ revision: 1,
3517
+ observation: {
3518
+ kind: 'gap',
3519
+ code: 'unhandled',
3520
+ factRefs: []
3521
+ },
3522
+ resultContract: {
3523
+ outcome: 'empty',
3524
+ hardCriteriaMet: false,
3525
+ factRefs: [],
3526
+ gapCodes: [
3527
+ 'unhandled'
3528
+ ],
3529
+ blockers: [],
3530
+ relaxationsApplied: []
3531
+ }
3532
+ }
3533
+ };
3534
+ const invalidContinuationBefore = defaultBindingLedger.countEvents();
3535
+ const invalidContinuationResponse = await fetch(defaultBindingEndpoint, {
3536
+ method: 'POST',
3537
+ headers: defaultBindingHeaders,
3538
+ body: JSON.stringify(invalidBoundContinuation)
3539
+ });
3540
+ assert.equal(invalidContinuationResponse.status, 403, 'receipt continuation cannot change to a surface-disallowed capability');
3541
+ assert.equal(defaultBindingLedger.countEvents(), invalidContinuationBefore, 'invalid bound continuation has no ledger side effects');
3542
+ assert.equal(defaultBindingPlannerCalls, 0, 'invalid bound turns do not call the planner');
3543
+ for (const [surface, allowedActions] of [
3544
+ [
3545
+ 'tenant',
3546
+ [
3547
+ 'search.run'
3548
+ ]
3549
+ ],
3550
+ [
3551
+ 'customer_portal',
3552
+ [
3553
+ 'offers.query'
3554
+ ]
3555
+ ],
3556
+ [
3557
+ 'storefront',
3558
+ [
3559
+ 'search.run'
3560
+ ]
3561
+ ],
3562
+ [
3563
+ 'payment_link',
3564
+ [
3565
+ 'hotel.select'
3566
+ ]
3567
+ ]
3568
+ ]){
3569
+ const response = await fetch(defaultBindingEndpoint, {
3570
+ method: 'POST',
3571
+ headers: defaultBindingHeaders,
3572
+ body: JSON.stringify(boundTurnForSurface(`task-bound-valid-${surface}`, surface, allowedActions))
3573
+ });
3574
+ assert.equal(response.status, 200, `valid ${surface} bound turn is accepted`);
3575
+ assert.match(await response.text(), /event: terminal/);
3576
+ }
3577
+ assert.equal(defaultBindingPlannerCalls, 4, 'only valid surface-bound turns call the planner');
3578
+ await defaultBindingServer.close();
3579
+ assert.equal(defaultBindingLedger.countEvents() > defaultBindingBeforeIngress, true);
3580
+ defaultBindingLedger.close();
3581
+ rmSync(defaultBindingRoot, {
3582
+ recursive: true,
3583
+ force: true
3584
+ });
3585
+ const terminalServerRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-http-terminal-replay-'));
3586
+ const terminalServerLedger = ensureLedger(terminalServerRoot);
3587
+ const terminalServerRuntime = new BookingCopilotTaskRuntime(terminalServerLedger);
3588
+ const terminalServer = await startBookingCopilotServer({
3589
+ apiKey: 'terminal-server-key',
3590
+ runtime: terminalServerRuntime,
3591
+ principal: {
3592
+ subject: 'bff-terminal',
3593
+ scope: 'booking:read'
3594
+ },
3595
+ ingressBinding: {
3596
+ bind: ()=>({
3597
+ taskId: 'task-http-terminal',
3598
+ turnId: 'http-terminal-turn',
3599
+ contextRef: 'ctx-server',
3600
+ surface: 'tenant',
3601
+ allowedActions: [
3602
+ ...BOOKING_READ_ACTION_KINDS
3603
+ ]
3604
+ })
3605
+ },
3606
+ ingressMode: 'bff-ingress-binding',
3607
+ plannerFactory: ()=>({
3608
+ next: async ()=>[
3609
+ {
3610
+ kind: 'terminal',
3611
+ terminal: {
3612
+ status: 'stopped',
3613
+ summary: 'terminal replay',
3614
+ factRefs: []
3615
+ }
3616
+ }
3617
+ ]
3618
+ })
3619
+ });
3620
+ const terminalIngressEndpoint = `http://127.0.0.1:${terminalServer.port}/a2a/booking-copilot/turn`;
3621
+ const terminalIngress = {
3622
+ ...ingress,
3623
+ requestKey: 'terminal-request-1'
3624
+ };
3625
+ const terminalIngressHeaders = {
3626
+ ...headers,
3627
+ authorization: 'Bearer terminal-server-key'
3628
+ };
3629
+ const terminalFirst = await fetch(terminalIngressEndpoint, {
3630
+ method: 'POST',
3631
+ headers: terminalIngressHeaders,
3632
+ body: JSON.stringify(terminalIngress)
3633
+ });
3634
+ assert.equal(terminalFirst.status, 200);
3635
+ const terminalFirstBody = await terminalFirst.text();
3636
+ const terminalIngressCount = terminalServerLedger.countEvents();
3637
+ const terminalReplay = await fetch(terminalIngressEndpoint, {
3638
+ method: 'POST',
3639
+ headers: terminalIngressHeaders,
3640
+ body: JSON.stringify(terminalIngress)
3641
+ });
3642
+ assert.equal(terminalReplay.status, 200);
3643
+ assert.equal(await terminalReplay.text(), terminalFirstBody, 'terminal ingress exact request batch replays after terminal');
3644
+ assert.equal(terminalServerLedger.countEvents(), terminalIngressCount);
3645
+ const terminalNewRequest = await fetch(terminalIngressEndpoint, {
3646
+ method: 'POST',
3647
+ headers: terminalIngressHeaders,
3648
+ body: JSON.stringify({
3649
+ ...terminalIngress,
3650
+ requestKey: 'terminal-request-2'
3651
+ })
3652
+ });
3653
+ assert.equal(terminalNewRequest.status, 409, 'new terminal ingress request key is rejected');
3654
+ assert.equal(terminalServerLedger.countEvents(), terminalIngressCount);
3655
+ await terminalServer.close();
3656
+ terminalServerLedger.close();
3657
+ rmSync(terminalServerRoot, {
3658
+ recursive: true,
3659
+ force: true
3660
+ });
3661
+ for (const [surface, taskId, allowedActions, hint] of [
3662
+ [
3663
+ 'storefront',
3664
+ 'task-storefront-escalation',
3665
+ [
3666
+ ...BOOKING_READ_ACTION_KINDS
3667
+ ],
3668
+ 'storefront'
3669
+ ],
3670
+ [
3671
+ 'payment_link',
3672
+ 'task-payment-escalation',
3673
+ [
3674
+ ...BOOKING_READ_ACTION_KINDS
3675
+ ],
3676
+ 'payment_link'
3677
+ ],
3678
+ [
3679
+ 'tenant',
3680
+ 'task-surface-mismatch',
3681
+ [
3682
+ ...BOOKING_READ_ACTION_KINDS
3683
+ ],
3684
+ 'storefront'
3685
+ ]
3686
+ ]){
3687
+ const matrixRoot = mkdtempSync(join(tmpdir(), `gotry-booking-v2-${taskId}-`));
3688
+ const matrixLedger = ensureLedger(matrixRoot);
3689
+ const matrixRuntime = new BookingCopilotTaskRuntime(matrixLedger);
3690
+ const matrixServer = await startBookingCopilotServer({
3691
+ apiKey: `${taskId}-key`,
3692
+ runtime: matrixRuntime,
3693
+ principal: {
3694
+ subject: 'bff-matrix',
3695
+ scope: 'booking:read'
3696
+ },
3697
+ ingressBinding: {
3698
+ bind: ()=>({
3699
+ taskId,
3700
+ turnId: `${taskId}-turn`,
3701
+ contextRef: 'ctx-server',
3702
+ surface,
3703
+ allowedActions: [
3704
+ ...allowedActions
3705
+ ]
3706
+ })
3707
+ },
3708
+ ingressMode: 'bff-ingress-binding',
3709
+ plannerFactory: ()=>({
3710
+ next: async ()=>[
3711
+ {
3712
+ kind: 'terminal',
3713
+ terminal: {
3714
+ status: 'stopped',
3715
+ summary: 'must not plan',
3716
+ factRefs: []
3717
+ }
3718
+ }
3719
+ ]
3720
+ })
3721
+ });
3722
+ const matrixResponse = await fetch(`http://127.0.0.1:${matrixServer.port}/a2a/booking-copilot/turn`, {
3723
+ method: 'POST',
3724
+ headers: {
3725
+ ...headers,
3726
+ authorization: `Bearer ${taskId}-key`
3727
+ },
3728
+ body: JSON.stringify({
3729
+ ...ingress,
3730
+ requestKey: `${taskId}-request`,
3731
+ surfaceHint: hint
3732
+ })
3733
+ });
3734
+ assert.equal(matrixResponse.status, 502, `${surface} binding escalation/mismatch is rejected before planning`);
3735
+ assert.equal(matrixLedger.countEvents(), 0);
3736
+ await matrixServer.close();
3737
+ matrixLedger.close();
3738
+ rmSync(matrixRoot, {
3739
+ recursive: true,
3740
+ force: true
3741
+ });
3742
+ }
3743
+ for (const [label, plannerFactory, expectedCode] of [
3744
+ [
3745
+ 'runtime-unsupported-action',
3746
+ ()=>({
3747
+ next: async ()=>[
3748
+ {
3749
+ kind: 'operation',
3750
+ action: {
3751
+ ...action('disallowed-http-action'),
3752
+ kind: 'offers.query',
3753
+ input: {
3754
+ hotelRefs: [
3755
+ 'hotel-a'
3756
+ ],
3757
+ criteria: {}
3758
+ }
3759
+ }
3760
+ }
3761
+ ]
3762
+ }),
3763
+ 'UNSUPPORTED_ACTION'
3764
+ ],
3765
+ [
3766
+ 'planner-surface-unsupported',
3767
+ ()=>({
3768
+ next: async ()=>{
3769
+ throw new Error('planner_surface_action_unsupported: raw internal detail');
3770
+ }
3771
+ }),
3772
+ 'PLANNER_SURFACE_ACTION_UNSUPPORTED'
3773
+ ]
3774
+ ]){
3775
+ const errorRoot = mkdtempSync(join(tmpdir(), `gotry-booking-v2-${label}-`));
3776
+ const errorLedger = ensureLedger(errorRoot);
3777
+ const errorRuntime = new BookingCopilotTaskRuntime(errorLedger);
3778
+ const errorTaskId = `task-${label}`;
3779
+ let errorPlannerCalls = 0;
3780
+ const errorServer = await startBookingCopilotServer({
3781
+ apiKey: `${label}-key`,
3782
+ runtime: errorRuntime,
3783
+ principal: {
3784
+ subject: `bff-${label}`,
3785
+ scope: 'booking:read'
3786
+ },
3787
+ ingressBinding: {
3788
+ bind: ()=>({
3789
+ taskId: errorTaskId,
3790
+ turnId: `${errorTaskId}-turn`,
3791
+ contextRef: 'ctx-v2',
3792
+ surface: 'tenant',
3793
+ allowedActions: [
3794
+ 'search.run'
3795
+ ]
3796
+ })
3797
+ },
3798
+ ingressMode: 'bff-ingress-binding',
3799
+ plannerFactory: (_initial)=>{
3800
+ const session = plannerFactory();
3801
+ return {
3802
+ next: async (_input)=>{
3803
+ errorPlannerCalls++;
3804
+ return session.next();
3805
+ }
3806
+ };
166
3807
  }
167
- }), /task_conflict:revision_mismatch/, 'a user turn must continue from the current semantic revision');
168
- assert.throws(()=>afterRestart.startTask({
169
- ...nextUserTurn,
3808
+ });
3809
+ const errorTurn = {
3810
+ ...turn(errorTaskId),
170
3811
  workspace: {
171
- ...nextUserTurn.workspace,
3812
+ ...workspace(),
172
3813
  capabilities: {
173
- ...nextUserTurn.workspace.capabilities,
3814
+ surface: 'tenant',
174
3815
  allowedActions: [
175
3816
  'search.run'
176
3817
  ]
177
3818
  }
178
3819
  }
179
- }), /task_conflict:capability_mismatch/, 'a task cannot silently broaden or shrink its surface allowlist');
180
- const resumedAgain = new BookingCopilotTaskRuntime(ledger, runtimeOptions).resumeTask('task-opaque-1');
181
- assert.deepEqual(resumedAgain?.lastReceipt, receipt, 'process-style restart recovers the last receipt checkpoint');
182
- assert.equal(resumedAgain?.userTurnCount, 2, 'process-style restart recovers every user-turn checkpoint');
183
- const status = new BookingCopilotTaskRuntime(ledger, runtimeOptions).emitEvent('task-opaque-1', {
184
- kind: 'status',
185
- status: 'working'
186
- });
187
- assert.ok(status.sequence > operation.sequence, 'event sequence remains monotonic after runtime restart');
188
- const persisted = ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind LIKE 'booking.copilot.%' ORDER BY seq`).all(ledger.tenant, 'task-opaque-1');
189
- const persistedText = persisted.map((row)=>row.payload).join('\n');
190
- assert.ok(!persistedText.includes('sensitive@example.com'), 'raw user/action prose is not persisted');
191
- assert.ok(!persistedText.includes('13800001111'), 'raw criteria payload is not persisted');
192
- assert.ok(!persistedText.includes('private villa'), 'action reason is persisted only as a digest');
193
- assert.match(persistedText, /requestDigest/, 'redacted request digest is durable');
194
- assert.match(persistedText, /inputDigest/, 'redacted action input digest is durable');
195
- const userTurnCountRow = ledger.db.prepare(`SELECT COUNT(*) AS count FROM events WHERE tenant_id = ? AND run_id = ? AND kind = 'booking.copilot.user.turn.observed'`).get(ledger.tenant, 'task-opaque-1');
196
- assert.equal(Number(userTurnCountRow?.count ?? 0), 2, 'task.started is separate from one digest-only row per accepted user turn');
197
- ledger.close();
3820
+ };
3821
+ const errorResponse = await fetch(`http://127.0.0.1:${errorServer.port}/a2a/booking-copilot/turn`, {
3822
+ method: 'POST',
3823
+ headers: {
3824
+ ...headers,
3825
+ authorization: `Bearer ${label}-key`
3826
+ },
3827
+ body: JSON.stringify(errorTurn)
3828
+ });
3829
+ assert.equal(errorResponse.status, 200, `${label} keeps the committed SSE status while returning a typed error`);
3830
+ const errorBody = await errorResponse.text();
3831
+ assert.match(errorBody, /event: error/, `${label} never returns an empty SSE body`);
3832
+ assert.match(errorBody, new RegExp(`"code":"${expectedCode}"`), `${label} normalizes to the closed uppercase code`);
3833
+ assert.doesNotMatch(errorBody, /raw internal detail/, `${label} does not expose internal error text`);
3834
+ assert.doesNotMatch(errorBody, /event: operation/, `${label} emits no disallowed operation`);
3835
+ const actionRows = errorLedger.db.prepare("SELECT count(*) AS count FROM events WHERE kind = 'booking.copilot.action.issued'").get();
3836
+ assert.equal(actionRows.count, 0, `${label} has zero disallowed operation side effect`);
3837
+ const afterErrorRows = errorLedger.countEvents();
3838
+ assert.equal(errorPlannerCalls, 1, `${label} calls planner once before durable typed error`);
3839
+ const errorReplay = await fetch(`http://127.0.0.1:${errorServer.port}/a2a/booking-copilot/turn`, {
3840
+ method: 'POST',
3841
+ headers: {
3842
+ ...headers,
3843
+ authorization: `Bearer ${label}-key`
3844
+ },
3845
+ body: JSON.stringify(errorTurn)
3846
+ });
3847
+ assert.equal(errorReplay.status, 200, `${label} terminal typed error request replays as SSE, not HTTP conflict`);
3848
+ assert.equal(await errorReplay.text(), errorBody, `${label} typed error replay is byte-identical`);
3849
+ assert.equal(errorLedger.countEvents(), afterErrorRows, `${label} typed error replay appends zero ledger rows`);
3850
+ assert.equal(errorPlannerCalls, 1, `${label} typed error replay does not call planner again`);
3851
+ const errorConflict = await fetch(`http://127.0.0.1:${errorServer.port}/a2a/booking-copilot/turn`, {
3852
+ method: 'POST',
3853
+ headers: {
3854
+ ...headers,
3855
+ authorization: `Bearer ${label}-key`
3856
+ },
3857
+ body: JSON.stringify({
3858
+ ...errorTurn,
3859
+ request: {
3860
+ text: `${errorTurn.request.text} changed`
3861
+ }
3862
+ })
3863
+ });
3864
+ assert.equal(errorConflict.status, 409, `${label} same turn identity with changed body remains a conflict`);
3865
+ await errorServer.close();
3866
+ errorLedger.close();
3867
+ rmSync(errorRoot, {
3868
+ recursive: true,
3869
+ force: true
3870
+ });
3871
+ }
3872
+ const boundOnlyRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-bound-only-'));
3873
+ const boundOnlyLedger = ensureLedger(boundOnlyRoot);
3874
+ const boundOnlyRuntime = new BookingCopilotTaskRuntime(boundOnlyLedger);
3875
+ const boundOnlyServer = await startBookingCopilotServer({
3876
+ apiKey: 'bound-only-key',
3877
+ runtime: boundOnlyRuntime,
3878
+ plannerFactory: ()=>({
3879
+ next: async ()=>[]
3880
+ })
3881
+ });
3882
+ const boundOnlyHealth = await fetch(`http://127.0.0.1:${boundOnlyServer.port}/healthz`, {
3883
+ headers: {
3884
+ authorization: 'Bearer bound-only-key'
3885
+ }
3886
+ });
3887
+ assert.equal(boundOnlyHealth.status, 200);
3888
+ assert.deepEqual(await boundOnlyHealth.json(), {
3889
+ schemaVersion: 'booking.surface',
3890
+ schemaSha256: BOOKING_SURFACE_SCHEMA_SHA256,
3891
+ status: 'ready',
3892
+ ingressMode: 'bff-bound-turn-only',
3893
+ acceptedTurnKinds: [
3894
+ 'user.turn',
3895
+ 'action.receipt.continuation'
3896
+ ]
3897
+ }, 'bound-turn-only health reports the closed turn-kind set');
3898
+ await boundOnlyServer.close();
3899
+ boundOnlyLedger.close();
3900
+ rmSync(boundOnlyRoot, {
3901
+ recursive: true,
3902
+ force: true
3903
+ });
3904
+ const ingressBindingRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-ingress-binding-'));
3905
+ const ingressBindingLedger = ensureLedger(ingressBindingRoot);
3906
+ const ingressBindingRuntime = new BookingCopilotTaskRuntime(ingressBindingLedger);
3907
+ const ingressBindingServer = await startBookingCopilotServer({
3908
+ apiKey: 'ingress-binding-key',
3909
+ runtime: ingressBindingRuntime,
3910
+ principal: {
3911
+ subject: 'bff-ingress-binding',
3912
+ scope: 'booking:read'
3913
+ },
3914
+ ingressBinding: {
3915
+ bind: ()=>({
3916
+ taskId: 'task-ingress-binding',
3917
+ turnId: 'turn-ingress-binding',
3918
+ contextRef: 'ctx-ingress-binding',
3919
+ surface: 'tenant',
3920
+ allowedActions: [
3921
+ ...BOOKING_READ_ACTION_KINDS
3922
+ ]
3923
+ })
3924
+ },
3925
+ ingressMode: 'bff-ingress-binding',
3926
+ plannerFactory: ()=>({
3927
+ next: async ()=>[]
3928
+ })
3929
+ });
3930
+ const ingressBindingHealth = await fetch(`http://127.0.0.1:${ingressBindingServer.port}/healthz`, {
3931
+ headers: {
3932
+ authorization: 'Bearer ingress-binding-key'
3933
+ }
3934
+ });
3935
+ assert.equal(ingressBindingHealth.status, 200);
3936
+ assert.deepEqual(await ingressBindingHealth.json(), {
3937
+ schemaVersion: 'booking.surface',
3938
+ schemaSha256: BOOKING_SURFACE_SCHEMA_SHA256,
3939
+ status: 'ready',
3940
+ ingressMode: 'bff-ingress-binding',
3941
+ acceptedTurnKinds: [
3942
+ 'user.turn',
3943
+ 'action.receipt.continuation',
3944
+ 'user.turn.ingress'
3945
+ ]
3946
+ }, 'ingress-binding health reports the complete trusted ingress mode');
3947
+ await ingressBindingServer.close();
3948
+ ingressBindingLedger.close();
3949
+ rmSync(ingressBindingRoot, {
3950
+ recursive: true,
3951
+ force: true
3952
+ });
3953
+ runtime['ledger'].close();
3954
+ crossLedger.close();
3955
+ awaitingRestart['ledger'].close();
3956
+ reopened['ledger'].close();
3957
+ serverRuntime['ledger'].close();
3958
+ crashLedger.close();
3959
+ reverseLedger.close();
3960
+ terminalLedger.close();
3961
+ forgedSourceRuntime['ledger'].close();
3962
+ tamperLedger.close();
3963
+ offerRuntime['ledger'].close();
198
3964
  rmSync(stateRoot, {
199
3965
  recursive: true,
200
3966
  force: true
201
3967
  });
202
- console.log('BOOKING COPILOT RUNTIME PROOF: task/operation/receipt/restart/idempotency/redaction OK');
3968
+ rmSync(serverRoot, {
3969
+ recursive: true,
3970
+ force: true
3971
+ });
3972
+ rmSync(forgedSourceRoot, {
3973
+ recursive: true,
3974
+ force: true
3975
+ });
3976
+ rmSync(tamperRoot, {
3977
+ recursive: true,
3978
+ force: true
3979
+ });
3980
+ rmSync(crashRoot, {
3981
+ recursive: true,
3982
+ force: true
3983
+ });
3984
+ rmSync(reverseRoot, {
3985
+ recursive: true,
3986
+ force: true
3987
+ });
3988
+ rmSync(crossRoot, {
3989
+ recursive: true,
3990
+ force: true
3991
+ });
3992
+ rmSync(terminalRoot, {
3993
+ recursive: true,
3994
+ force: true
3995
+ });
3996
+ rmSync(offerRoot, {
3997
+ recursive: true,
3998
+ force: true
3999
+ });
4000
+ console.log('BOOKING COPILOT RUNTIME PROOF: task scope, receipt binding, approval authority/one-time, recovery, ingress/SSE session OK');
203
4001
 
204
4002
 
205
4003
  //# sourceURL=ts/scripts/booking-copilot-runtime-proof-tests.ts