@danceiny/gotry 0.0.1-rc.17 → 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 +185 -148
  2. package/README.zh-CN.md +195 -154
  3. package/bin/gotry-bootstrap.js +246 -26
  4. package/bin/gotry-inner.js +68 -38
  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 +115 -44
  41. package/dist/scripts/build-metrics-report.js +339 -0
  42. package/dist/scripts/channel-probe-tests.js +148 -0
  43. package/dist/scripts/channel-probe.js +252 -0
  44. package/dist/scripts/channel-registry-tests.js +262 -0
  45. package/dist/scripts/doctor-tests.js +121 -0
  46. package/dist/scripts/effect-tests.js +274 -1
  47. package/dist/scripts/extension-tests.js +129 -11
  48. package/dist/scripts/fact-gate-tests.js +91 -2
  49. package/dist/scripts/flyai-tests.js +17 -1
  50. package/dist/scripts/hbcli-e2e-tests.js +1 -3
  51. package/dist/scripts/metrics-report-tests.js +288 -0
  52. package/dist/scripts/persona-surface-guard-tests.js +19 -0
  53. package/dist/scripts/session-tests.js +401 -1
  54. package/dist/scripts/smoke.js +100 -83
  55. package/dist/scripts/turn-handoff-collect-tests.js +216 -0
  56. package/dist/scripts/turn-handoff-collect.js +189 -0
  57. package/dist/scripts/turn-policy-tests.js +56 -0
  58. package/dist/scripts/typed-contract-canary.js +261 -0
  59. package/dist/scripts/visa-policy-tests.js +75 -0
  60. package/dist/scripts/wish-channel-gate-tests.js +111 -0
  61. package/dist/src/artifact-gate.js +91 -3
  62. package/dist/src/benchmark-agent-conformance.js +7 -4
  63. package/dist/src/benchmark-environment-bridge.js +68 -7
  64. package/dist/src/bookable-facts.js +62 -3
  65. package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
  66. package/dist/src/booking-surface/canonical-schema.js +8 -22
  67. package/dist/src/booking-surface/contracts.js +70 -19
  68. package/dist/src/booking-surface/dsh-planner.js +310 -177
  69. package/dist/src/booking-surface/dsh-plugin.js +3 -3
  70. package/dist/src/booking-surface/index.js +1 -5
  71. package/dist/src/booking-surface/profile.js +1 -1
  72. package/dist/src/booking-surface/runtime.js +1682 -247
  73. package/dist/src/booking-surface/server.js +371 -185
  74. package/dist/src/booking-surface/startup.js +17 -27
  75. package/dist/src/booking-surface/validation.js +270 -760
  76. package/dist/src/index.js +735 -149
  77. package/dist/src/turn-deadline.js +292 -0
  78. package/dist/src/turn-policy.js +146 -0
  79. package/dist/src/wish-pool.js +5 -0
  80. package/extension/background.js +23 -4
  81. package/extension/content-main.js +47 -9
  82. package/extension/manifest.json +27 -9
  83. package/package.json +6 -26
  84. package/schemas/booking.surface.schema.json +2593 -0
  85. package/ts/capabilities/agent-reach.ts +4 -4
  86. package/ts/capabilities/flyai.ts +21 -3
  87. package/ts/capabilities/session/action-cache.ts +1 -1
  88. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  89. package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
  90. package/ts/capabilities/session/extension-bridge.ts +32 -2
  91. package/ts/capabilities/session/extension-channel.ts +9 -8
  92. package/ts/capabilities/session/extension-distribution.ts +3 -1
  93. package/ts/capabilities/session/health-watch.ts +1 -1
  94. package/ts/capabilities/session/read-guard.ts +1 -1
  95. package/ts/capabilities/session/wizard.ts +1 -1
  96. package/ts/capabilities/session-search.ts +323 -1
  97. package/ts/package.json +1 -0
  98. package/ts/scripts/turn-handoff-collect.ts +178 -0
  99. package/ts/src/artifact-gate.ts +82 -2
  100. package/ts/src/benchmark-agent-conformance.ts +14 -6
  101. package/ts/src/benchmark-environment-bridge.ts +29 -10
  102. package/ts/src/bookable-facts.ts +109 -5
  103. package/ts/src/booking-saga.ts +1 -1
  104. package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
  105. package/ts/src/booking-surface/canonical-schema.js +8 -22
  106. package/ts/src/booking-surface/contracts.ts +278 -239
  107. package/ts/src/booking-surface/dsh-planner.ts +333 -194
  108. package/ts/src/booking-surface/dsh-plugin.js +3 -3
  109. package/ts/src/booking-surface/index.ts +1 -5
  110. package/ts/src/booking-surface/profile.ts +11 -11
  111. package/ts/src/booking-surface/runtime.ts +1351 -351
  112. package/ts/src/booking-surface/server.ts +282 -175
  113. package/ts/src/booking-surface/startup.ts +36 -48
  114. package/ts/src/booking-surface/validation.ts +194 -442
  115. package/ts/src/contracts.ts +1 -1
  116. package/ts/src/index.ts +489 -171
  117. package/ts/src/loop.ts +1 -1
  118. package/ts/src/state-ledger.ts +1 -1
  119. package/ts/src/turn-deadline.ts +361 -0
  120. package/ts/src/turn-policy.ts +154 -0
  121. package/ts/src/wish-pool.ts +17 -4
  122. package/dist/scripts/agent-planning-budget-e2e.js +0 -227
  123. package/dist/scripts/agent-planning-budget-tests.js +0 -173
  124. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
  125. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
  126. package/dist/src/booking-surface/contracts-v2.js +0 -89
  127. package/dist/src/booking-surface/runtime-v2.js +0 -1771
  128. package/dist/src/booking-surface/server-v2.js +0 -334
  129. package/dist/src/booking-surface/validation-v2.js +0 -319
  130. package/dist/src/tool-budget.js +0 -136
  131. package/schemas/booking.surface.v1.schema.json +0 -927
  132. package/schemas/booking.surface.v2.schema.json +0 -61
  133. package/ts/src/booking-surface/contracts-v2.ts +0 -118
  134. package/ts/src/booking-surface/runtime-v2.ts +0 -1466
  135. package/ts/src/booking-surface/server-v2.ts +0 -247
  136. package/ts/src/booking-surface/validation-v2.ts +0 -205
  137. package/ts/src/tool-budget.ts +0 -165
@@ -1,72 +1,38 @@
1
1
  import assert from 'node:assert/strict';
2
- import { createHash } from 'node:crypto';
3
2
  import { readFileSync } from 'node:fs';
4
- import { fileURLToPath } from 'node:url';
5
- import { BOOKING_READ_ACTION_KINDS, BOOKING_RECEIPT_STATUSES, BOOKING_SURFACE_EVENT_KINDS, BOOKING_SURFACE_SCHEMA_SHA256, EMBEDDED_BOOKING_CAPABILITY_IDS, bookingOperationFromEvent, embeddedBookingProfile, validateActionReceiptV1, validateBookingCopilotIngressTurnV1, validateBookingCopilotTurnV1, validateBookingReadActionV1, validateBookingSurfaceEventV1 } from '../src/booking-surface/index.js';
6
- const schemaPath = fileURLToPath(new URL('../../schemas/booking.surface.v1.schema.json', import.meta.url));
7
- const schema = JSON.parse(readFileSync(schemaPath, 'utf8'));
8
- const schemaBytes = readFileSync(schemaPath);
9
- assert.equal(createHash('sha256').update(schemaBytes).digest('hex'), BOOKING_SURFACE_SCHEMA_SHA256, 'exported schema hash matches canonical bytes');
10
- assert.equal(schema.$id, 'https://gotry.dev/schemas/booking.surface.v1.schema.json');
11
- assert.ok(schema.$defs?.BookingReadActionV1, 'canonical schema declares BookingReadActionV1');
12
- assert.ok(schema.$defs?.ActionReceiptV1, 'canonical schema declares ActionReceiptV1');
13
- assert.ok(schema.$defs?.BookingSurfaceEventV1, 'canonical schema declares typed SSE data events');
14
- assert.deepEqual(BOOKING_READ_ACTION_KINDS, [
3
+ import { createHash } from 'node:crypto';
4
+ import { BOOKING_READ_ACTION_KINDS, BOOKING_GAP_CODES, BOOKING_BLOCKER_CODES, BOOKING_SURFACE_SCHEMA_SHA256, bookingSurfaceAllowedActions } from '../src/booking-surface/contracts.js';
5
+ import { validateBookingSurface, validateBookingReadAction, validateBookingSurfaceEvent, validateCriterionBlocker, validateRelaxationApproval, validateApprovalAgainstBlocker } from '../src/booking-surface/validation.js';
6
+ let positive = 0;
7
+ let negative = 0;
8
+ function accept(label, value) {
9
+ assert.equal(validateBookingSurface(value).ok, true, label);
10
+ positive++;
11
+ }
12
+ function reject(label, value) {
13
+ assert.equal(validateBookingSurface(value).ok, false, label);
14
+ negative++;
15
+ }
16
+ assert.deepEqual(bookingSurfaceAllowedActions('storefront'), [
17
+ 'search.patch',
18
+ 'search.run',
19
+ 'results.view.patch',
20
+ 'hotel.focus'
21
+ ], 'storefront UAT availability observation is least privilege');
22
+ assert.deepEqual(bookingSurfaceAllowedActions('payment_link'), [
15
23
  'search.patch',
16
24
  'search.run',
17
25
  'results.view.patch',
18
26
  'hotel.focus',
19
- 'hotel.select',
20
- 'offers.query',
21
- 'offers.view.patch',
22
- 'offers.compare',
23
- 'offer.select',
24
- 'offer.check',
25
- 'checkout.prepare',
26
- 'order.observe'
27
- ]);
28
- assert.deepEqual(BOOKING_RECEIPT_STATUSES, [
29
- 'applied',
30
- 'needs_input',
31
- 'partial',
32
- 'no_match',
33
- 'unavailable',
34
- 'changed',
35
- 'stale',
36
- 'unsupported',
37
- 'failed'
38
- ]);
39
- assert.deepEqual(BOOKING_SURFACE_EVENT_KINDS, [
40
- 'status',
41
- 'question',
42
- 'operation',
43
- 'explanation',
44
- 'terminal',
45
- 'error'
46
- ]);
47
- assert.deepEqual(EMBEDDED_BOOKING_CAPABILITY_IDS, [
48
- 'search-hotels',
49
- 'refine-results',
50
- 'find-room-offers',
51
- 'compare-offers',
52
- 'prepare-booking',
53
- 'observe-booking'
54
- ]);
55
- const registeredActions = embeddedBookingProfile.capabilities.flatMap((capability)=>capability.actions);
56
- assert.deepEqual([
57
- ...new Set(registeredActions)
58
- ].sort(), [
59
- ...BOOKING_READ_ACTION_KINDS
60
- ].sort(), 'profile registers the full read-action closure exactly once');
61
- const forbiddenWriteActions = new Set([
62
- 'book',
63
- 'trade.book',
64
- 'gotry_book'
65
- ]);
66
- assert.ok(!registeredActions.some((action)=>forbiddenWriteActions.has(action)), 'embedded profile exposes no Book action');
67
- assert.ok(embeddedBookingProfile.capabilities.every((capability)=>capability.effect === 'read'), 'all embedded capabilities are read-side effects');
68
- const ingressWorkspace = {
69
- schemaVersion: 'booking.surface.v1',
27
+ 'hotel.select'
28
+ ], 'payment_link UAT availability observation is least privilege');
29
+ assert.ok(!bookingSurfaceAllowedActions('storefront').includes('offers.query'), 'storefront availability cannot expand into offers');
30
+ assert.ok(!bookingSurfaceAllowedActions('payment_link').includes('checkout.prepare'), 'payment_link availability cannot expand into checkout');
31
+ const hash = 'a'.repeat(64);
32
+ const workspace = {
33
+ schemaVersion: 'booking.surface',
34
+ contextRef: 'ctx-1',
35
+ surface: 'tenant',
70
36
  revision: 0,
71
37
  locale: 'zh-CN',
72
38
  currency: 'AED',
@@ -76,318 +42,1180 @@ const ingressWorkspace = {
76
42
  },
77
43
  visibleHotels: [],
78
44
  loadedOffers: [],
79
- shortlistedOfferRefs: []
45
+ shortlistedOfferRefs: [],
46
+ capabilities: {
47
+ surface: 'tenant',
48
+ allowedActions: [
49
+ ...BOOKING_READ_ACTION_KINDS
50
+ ]
51
+ }
80
52
  };
81
- const ingress = {
82
- schemaVersion: 'booking.surface.v1',
83
- kind: 'user.turn.ingress',
84
- surfaceHint: 'tenant',
85
- workspace: ingressWorkspace,
53
+ const ingressWorkspace = {
54
+ ...workspace
55
+ };
56
+ delete ingressWorkspace.contextRef;
57
+ delete ingressWorkspace.surface;
58
+ delete ingressWorkspace.capabilities;
59
+ const inputs = {
60
+ 'search.patch': {
61
+ patch: {
62
+ destination: {
63
+ query: 'Dubai'
64
+ }
65
+ }
66
+ },
67
+ 'search.run': {},
68
+ 'results.view.patch': {
69
+ patch: {
70
+ sort: 'recommended'
71
+ }
72
+ },
73
+ 'hotel.focus': {
74
+ hotelRef: 'hotel-1'
75
+ },
76
+ 'hotel.select': {
77
+ hotelRef: 'hotel-1'
78
+ },
79
+ 'offers.query': {
80
+ hotelRefs: [
81
+ 'hotel-1'
82
+ ],
83
+ criteria: {}
84
+ },
85
+ 'offers.view.patch': {
86
+ hotelRef: 'hotel-1',
87
+ criteria: {}
88
+ },
89
+ 'offers.compare': {
90
+ offerRefs: [
91
+ 'offer-1'
92
+ ],
93
+ requestedCount: 1
94
+ },
95
+ 'offer.select': {
96
+ offerRef: 'offer-1',
97
+ offerVersionRef: 'offer-1:v1'
98
+ },
99
+ 'offer.check': {
100
+ offerRef: 'offer-1',
101
+ offerVersionRef: 'offer-1:v1'
102
+ },
103
+ 'checkout.prepare': {
104
+ offerRef: 'offer-1',
105
+ offerVersionRef: 'offer-1:v1',
106
+ verifiedOfferRef: 'verified-1'
107
+ },
108
+ 'order.observe': {
109
+ orderRef: 'order-1'
110
+ }
111
+ };
112
+ const actions = Object.fromEntries(BOOKING_READ_ACTION_KINDS.map((kind)=>[
113
+ kind,
114
+ {
115
+ schemaVersion: 'booking.surface',
116
+ kind,
117
+ actionId: `a-${kind.replaceAll('.', '-')}`,
118
+ contextRef: 'ctx-1',
119
+ expectedRevision: 0,
120
+ reason: 'typed request',
121
+ factRefs: [],
122
+ input: inputs[kind]
123
+ }
124
+ ]));
125
+ const approvalRef = {
126
+ approvalId: 'ap-1',
127
+ blockerId: 'b-1',
128
+ contextRef: 'ctx-1',
129
+ sourceTurnId: 'turn-1',
130
+ presentationRequestKey: 'approval:task-1:turn-1:a-search-run:' + hash,
131
+ sourceActionId: 'a-search-run',
132
+ targetActionId: 'a-search-run',
133
+ sourceRevision: 0,
134
+ targetActionKind: 'search.run',
135
+ to: 'prefer',
136
+ expiresAt: '2099-01-01T00:00:00.000Z',
137
+ nonce: 'nonce-1',
138
+ sourceReceiptDigest: hash,
139
+ scope: 'offer',
140
+ code: 'hotel_rates_failed',
141
+ criterionPath: 'offers.totalPriceMax',
142
+ valueDigest: hash
143
+ };
144
+ accept('valid relaxation approval ref action', {
145
+ ...actions['search.run'],
146
+ relaxationApprovalRef: approvalRef
147
+ });
148
+ reject('approval context replay', {
149
+ ...actions['search.run'],
150
+ relaxationApprovalRef: {
151
+ ...approvalRef,
152
+ contextRef: 'ctx-other'
153
+ }
154
+ });
155
+ reject('approval action replay', {
156
+ ...actions['search.run'],
157
+ relaxationApprovalRef: {
158
+ ...approvalRef,
159
+ targetActionId: 'a-other'
160
+ }
161
+ });
162
+ reject('approval future revision replay', {
163
+ ...actions['search.run'],
164
+ relaxationApprovalRef: {
165
+ ...approvalRef,
166
+ sourceRevision: 1
167
+ }
168
+ });
169
+ reject('approval old revision replay', {
170
+ ...actions['search.run'],
171
+ expectedRevision: 1,
172
+ relaxationApprovalRef: {
173
+ ...approvalRef,
174
+ sourceRevision: 0
175
+ }
176
+ });
177
+ reject('approval target kind replay', {
178
+ ...actions['search.run'],
179
+ relaxationApprovalRef: {
180
+ ...approvalRef,
181
+ targetActionKind: 'offer.check'
182
+ }
183
+ });
184
+ reject('approval bad nonce', {
185
+ ...actions['search.run'],
186
+ relaxationApprovalRef: {
187
+ ...approvalRef,
188
+ nonce: 'Bearer token'
189
+ }
190
+ });
191
+ reject('approval bad expiry', {
192
+ ...actions['search.run'],
193
+ relaxationApprovalRef: {
194
+ ...approvalRef,
195
+ expiresAt: 'not-a-date'
196
+ }
197
+ });
198
+ const openInput = (kind)=>{
199
+ const input = inputs[kind];
200
+ return input && typeof input === 'object' && !Array.isArray(input) ? {
201
+ ...input,
202
+ open: true
203
+ } : {
204
+ open: true
205
+ };
206
+ };
207
+ for (const kind of BOOKING_READ_ACTION_KINDS){
208
+ accept(`valid action ${kind}`, actions[kind]);
209
+ reject(`missing input ${kind}`, {
210
+ ...actions[kind],
211
+ input: undefined
212
+ });
213
+ reject(`open input ${kind}`, {
214
+ ...actions[kind],
215
+ input: openInput(kind)
216
+ });
217
+ }
218
+ reject('offer.select missing version', {
219
+ ...actions['offer.select'],
220
+ input: {
221
+ offerRef: 'offer-1'
222
+ }
223
+ });
224
+ reject('offer.check missing version', {
225
+ ...actions['offer.check'],
226
+ input: {
227
+ offerRef: 'offer-1'
228
+ }
229
+ });
230
+ reject('checkout.prepare missing version', {
231
+ ...actions['checkout.prepare'],
232
+ input: {
233
+ offerRef: 'offer-1',
234
+ verifiedOfferRef: 'verified-1'
235
+ }
236
+ });
237
+ const versionedWorkspace = {
238
+ ...workspace,
239
+ visibleHotels: [
240
+ {
241
+ hotelRef: 'hotel-1',
242
+ name: 'Hotel 1',
243
+ factRefs: [
244
+ 'hotel-fact-1'
245
+ ]
246
+ }
247
+ ],
248
+ loadedOffers: [
249
+ {
250
+ offerRef: 'offer-1',
251
+ offerVersionRef: 'offer-1:v1',
252
+ hotelRef: 'hotel-1',
253
+ evidenceLevel: 'rate_loaded',
254
+ factRefs: [
255
+ 'offer-fact-1'
256
+ ]
257
+ }
258
+ ],
259
+ selectedOfferRef: 'offer-1',
260
+ verifiedOffer: {
261
+ offerRef: 'offer-1',
262
+ offerVersionRef: 'offer-1:v1',
263
+ verifiedOfferRef: 'verified-1',
264
+ expiresAt: '2030-02-28T23:59:59+04:00'
265
+ }
266
+ };
267
+ const versionedTurn = {
268
+ schemaVersion: 'booking.surface',
269
+ kind: 'user.turn',
270
+ taskId: 'versioned-task',
271
+ turnId: 'versioned-turn',
272
+ workspace: versionedWorkspace,
86
273
  request: {
87
- text: '迪拜五星、含早可退,给我 3 个报价'
274
+ text: 'find hotels'
88
275
  }
89
276
  };
90
- assert.deepEqual(validateBookingCopilotIngressTurnV1(ingress), {
91
- ok: true
277
+ accept('versioned loaded offer and verified tuple', versionedTurn);
278
+ reject('verified offer invalid calendar expiry', {
279
+ ...versionedTurn,
280
+ workspace: {
281
+ ...versionedWorkspace,
282
+ verifiedOffer: {
283
+ ...versionedWorkspace.verifiedOffer,
284
+ expiresAt: '2026-02-30T00:00:00.000Z'
285
+ }
286
+ }
92
287
  });
93
- assert.equal(validateBookingCopilotIngressTurnV1({
94
- ...ingress,
95
- actor: 'user-1'
96
- }).ok, false, 'ingress cannot self-assert actor');
97
- assert.equal(validateBookingCopilotIngressTurnV1({
98
- ...ingress,
99
- tenant: 'tenant-1'
100
- }).ok, false, 'ingress cannot self-assert tenant');
101
- assert.equal(validateBookingCopilotIngressTurnV1({
102
- ...ingress,
103
- customer: 'customer-1'
104
- }).ok, false, 'ingress cannot self-assert customer');
105
- assert.equal(validateBookingCopilotIngressTurnV1({
106
- ...ingress,
107
- contextRef: 'client-forged-ref'
108
- }).ok, false, 'ingress cannot mint a non-null contextRef');
109
- assert.deepEqual(validateBookingCopilotIngressTurnV1({
110
- ...ingress,
111
- contextRef: null
112
- }), {
113
- ok: true
114
- }, 'explicit null contextRef is accepted for bootstrap');
115
- const hotelSelectionReceipt = {
116
- schemaVersion: 'booking.surface.v1',
288
+ reject('verified offer malformed expiry', {
289
+ ...versionedTurn,
290
+ workspace: {
291
+ ...versionedWorkspace,
292
+ verifiedOffer: {
293
+ ...versionedWorkspace.verifiedOffer,
294
+ expiresAt: 'tomorrow'
295
+ }
296
+ }
297
+ });
298
+ reject('verified offer missing expiry', {
299
+ ...versionedTurn,
300
+ workspace: {
301
+ ...versionedWorkspace,
302
+ verifiedOffer: {
303
+ ...versionedWorkspace.verifiedOffer,
304
+ expiresAt: undefined
305
+ }
306
+ }
307
+ });
308
+ reject('verified offer mismatched logical tuple', {
309
+ ...versionedTurn,
310
+ workspace: {
311
+ ...versionedWorkspace,
312
+ verifiedOffer: {
313
+ ...versionedWorkspace.verifiedOffer,
314
+ offerRef: 'offer-other',
315
+ offerVersionRef: 'offer-other:v1'
316
+ }
317
+ }
318
+ });
319
+ reject('verified offer mismatched version tuple', {
320
+ ...versionedTurn,
321
+ workspace: {
322
+ ...versionedWorkspace,
323
+ verifiedOffer: {
324
+ ...versionedWorkspace.verifiedOffer,
325
+ offerVersionRef: 'offer-1:v2'
326
+ }
327
+ }
328
+ });
329
+ reject('workspace duplicate logical offer refs', {
330
+ ...versionedTurn,
331
+ workspace: {
332
+ ...versionedWorkspace,
333
+ loadedOffers: [
334
+ ...versionedWorkspace.loadedOffers,
335
+ {
336
+ ...versionedWorkspace.loadedOffers[0]
337
+ }
338
+ ]
339
+ }
340
+ });
341
+ reject('workspace duplicate offer version refs', {
342
+ ...versionedTurn,
343
+ workspace: {
344
+ ...versionedWorkspace,
345
+ loadedOffers: [
346
+ ...versionedWorkspace.loadedOffers,
347
+ {
348
+ offerRef: 'offer-2',
349
+ offerVersionRef: 'offer-1:v1',
350
+ hotelRef: 'hotel-1',
351
+ evidenceLevel: 'rate_loaded',
352
+ factRefs: []
353
+ }
354
+ ]
355
+ }
356
+ });
357
+ reject('Book', {
358
+ ...actions['search.run'],
359
+ kind: 'book'
360
+ });
361
+ reject('root minimum', {
362
+ schemaVersion: 'booking.surface'
363
+ });
364
+ reject('workspace nested extra', {
365
+ ...workspace,
366
+ results: {
367
+ status: 'idle',
368
+ open: true
369
+ }
370
+ });
371
+ reject('workspace capability Book', {
372
+ ...workspace,
373
+ capabilities: {
374
+ ...workspace.capabilities,
375
+ allowedActions: [
376
+ 'book'
377
+ ]
378
+ }
379
+ });
380
+ for (const field of [
381
+ 'schemaVersion',
382
+ 'kind',
383
+ 'actionId',
384
+ 'contextRef',
385
+ 'expectedRevision'
386
+ ])reject(`action missing ${field}`, Object.fromEntries(Object.entries(actions['search.run']).filter(([key])=>key !== field)));
387
+ const blocker = {
388
+ blockerId: 'b-1',
389
+ sourceActionId: 'a-offers-query',
390
+ sourceReceiptDigest: hash,
391
+ scope: 'offer',
392
+ code: 'hotel_rates_failed',
393
+ criterionPath: 'offers.totalPriceMax',
394
+ strength: 'must',
395
+ valueDigest: hash,
396
+ evidence: {
397
+ factRefs: [
398
+ 'fact-1'
399
+ ],
400
+ gapCodes: [
401
+ 'hotel_rates_failed'
402
+ ],
403
+ requested: 2,
404
+ actual: 1
405
+ }
406
+ };
407
+ const approval = {
408
+ taskId: 'task-1',
409
+ contextRef: 'ctx-1',
410
+ sourceTurnId: 'turn-1',
411
+ presentationRequestKey: 'approval:task-1:turn-1:a-offers-query:' + hash,
412
+ optionDigest: hash,
413
+ approvalId: 'ap-1',
414
+ deliveryNonce: 'delivery-nonce-1',
415
+ blockerId: 'b-1',
416
+ sourceActionId: blocker.sourceActionId,
417
+ sourceReceiptDigest: hash,
418
+ scope: 'offer',
419
+ code: blocker.code,
420
+ criterionPath: blocker.criterionPath,
421
+ valueDigest: hash,
422
+ from: 'must',
423
+ to: 'prefer',
424
+ approved: true
425
+ };
426
+ assert.equal(validateCriterionBlocker(blocker).ok, true);
427
+ positive++;
428
+ assert.equal(validateRelaxationApproval(approval).ok, true);
429
+ positive++;
430
+ assert.equal(validateApprovalAgainstBlocker(approval, blocker).ok, true);
431
+ positive++;
432
+ for (const field of [
433
+ 'sourceReceiptDigest',
434
+ 'valueDigest'
435
+ ]){
436
+ assert.equal(validateCriterionBlocker({
437
+ ...blocker,
438
+ [field]: 'bad'
439
+ }).ok, false);
440
+ negative++;
441
+ }
442
+ for (const field of [
443
+ 'code',
444
+ 'scope',
445
+ 'criterionPath'
446
+ ]){
447
+ assert.equal(validateCriterionBlocker({
448
+ ...blocker,
449
+ [field]: field === 'criterionPath' ? 'open.path' : 'dynamic'
450
+ }).ok, false);
451
+ negative++;
452
+ }
453
+ assert.equal(validateCriterionBlocker({
454
+ ...blocker,
455
+ evidence: {
456
+ ...blocker.evidence,
457
+ factRefs: [
458
+ 'fact-1',
459
+ 'fact-1'
460
+ ]
461
+ }
462
+ }).ok, false);
463
+ negative++;
464
+ assert.equal(validateCriterionBlocker({
465
+ ...blocker,
466
+ evidence: {
467
+ ...blocker.evidence,
468
+ open: true
469
+ }
470
+ }).ok, false);
471
+ negative++;
472
+ const receipt = {
473
+ schemaVersion: 'booking.surface',
117
474
  kind: 'action.receipt',
118
- actionId: 'select-1',
475
+ actionId: 'a-search-run',
119
476
  contextRef: 'ctx-1',
120
- status: 'applied',
477
+ status: 'partial',
121
478
  revision: 1,
122
479
  observation: {
123
- kind: 'hotel.selection',
124
- hotelRef: 'hotel-1'
480
+ kind: 'gap',
481
+ code: 'search_failed',
482
+ factRefs: [
483
+ 'fact-1'
484
+ ]
125
485
  },
126
486
  resultContract: {
127
- outcome: 'complete',
128
- hardCriteriaMet: true,
129
- factRefs: [],
130
- gapCodes: []
487
+ outcome: 'partial',
488
+ hardCriteriaMet: false,
489
+ factRefs: [
490
+ 'fact-1'
491
+ ],
492
+ gapCodes: [
493
+ ...BOOKING_GAP_CODES
494
+ ],
495
+ blockers: [
496
+ blocker
497
+ ],
498
+ relaxationsApplied: []
131
499
  }
132
500
  };
133
- assert.deepEqual(validateActionReceiptV1(hotelSelectionReceipt), {
134
- ok: true
135
- }, 'hotel.select emits hotel.selection');
136
- assert.equal(validateActionReceiptV1({
137
- ...hotelSelectionReceipt,
501
+ accept('valid receipt all gap codes', receipt);
502
+ reject('bad status', {
503
+ ...receipt,
504
+ status: 'unknown'
505
+ });
506
+ reject('bad revision', {
507
+ ...receipt,
508
+ revision: -1
509
+ });
510
+ reject('bad outcome', {
511
+ ...receipt,
512
+ resultContract: {
513
+ ...receipt.resultContract,
514
+ outcome: 'bad'
515
+ }
516
+ });
517
+ reject('dynamic gap', {
518
+ ...receipt,
519
+ resultContract: {
520
+ ...receipt.resultContract,
521
+ gapCodes: [
522
+ 'dynamic'
523
+ ]
524
+ }
525
+ });
526
+ reject('receipt observation extra', {
527
+ ...receipt,
138
528
  observation: {
139
- kind: 'hotel.select',
140
- hotelRef: 'hotel-1'
529
+ ...receipt.observation,
530
+ open: true
141
531
  }
142
- }).ok, false, 'hotel.select is not an observation kind');
143
- const strictWorkspace = {
144
- ...ingressWorkspace,
145
- contextRef: 'ctx-server-minted',
146
- surface: 'tenant',
147
- capabilities: {
148
- surface: 'tenant',
149
- allowedActions: [
150
- ...BOOKING_READ_ACTION_KINDS
532
+ });
533
+ for (const code of BOOKING_GAP_CODES){
534
+ accept(`valid declared gap ${code}`, {
535
+ ...receipt,
536
+ resultContract: {
537
+ ...receipt.resultContract,
538
+ gapCodes: [
539
+ code
540
+ ]
541
+ }
542
+ });
543
+ reject(`dynamic gap ${code}`, {
544
+ ...receipt,
545
+ resultContract: {
546
+ ...receipt.resultContract,
547
+ gapCodes: [
548
+ `${code}:dynamic`
549
+ ]
550
+ }
551
+ });
552
+ }
553
+ const observations = [
554
+ {
555
+ kind: 'search.state',
556
+ searchSessionRef: 'session-1',
557
+ resultCount: 1
558
+ },
559
+ {
560
+ kind: 'results.state',
561
+ matchedHotelRefs: [
562
+ 'hotel-1'
563
+ ],
564
+ visibleCount: 1
565
+ },
566
+ {
567
+ kind: 'hotel.focus',
568
+ hotelRef: 'hotel-1'
569
+ },
570
+ {
571
+ kind: 'hotel.selection',
572
+ hotelRef: 'hotel-1'
573
+ },
574
+ {
575
+ kind: 'offers.state',
576
+ hotelRefs: [
577
+ 'hotel-1'
578
+ ],
579
+ offerRefs: [
580
+ 'offer-1'
581
+ ],
582
+ loadedHotelCount: 1
583
+ },
584
+ {
585
+ kind: 'offer.selection',
586
+ offerRef: 'offer-1',
587
+ offerVersionRef: 'offer-1:v1'
588
+ },
589
+ {
590
+ kind: 'offer.availability',
591
+ offerRef: 'offer-1',
592
+ checkedOfferVersionRef: 'offer-1:v1',
593
+ currentOfferVersionRef: 'offer-1:v1',
594
+ available: true,
595
+ changedFactRefs: []
596
+ },
597
+ {
598
+ kind: 'checkout.handoff',
599
+ offerRef: 'offer-1',
600
+ offerVersionRef: 'offer-1:v1',
601
+ verifiedOfferRef: 'verified-1',
602
+ handoffRef: 'handoff-1'
603
+ },
604
+ {
605
+ kind: 'order.state',
606
+ orderRef: 'order-1',
607
+ state: 'pending'
608
+ },
609
+ {
610
+ kind: 'gap',
611
+ code: 'search_failed',
612
+ factRefs: [
613
+ 'fact-1'
151
614
  ]
152
615
  }
153
- };
154
- const plannerTurn = {
155
- schemaVersion: 'booking.surface.v1',
616
+ ];
617
+ for (const observation of observations){
618
+ accept(`valid observation ${observation.kind}`, {
619
+ ...receipt,
620
+ observation
621
+ });
622
+ reject(`observation extra ${observation.kind}`, {
623
+ ...receipt,
624
+ observation: {
625
+ ...observation,
626
+ extra: true
627
+ }
628
+ });
629
+ }
630
+ for (const observation of observations.filter((item)=>[
631
+ 'search.state',
632
+ 'results.state',
633
+ 'offers.state',
634
+ 'offer.availability',
635
+ 'order.state'
636
+ ].includes(item.kind))){
637
+ reject(`observation dynamic gap ${observation.kind}`, {
638
+ ...receipt,
639
+ observation: {
640
+ ...observation,
641
+ gapCodes: [
642
+ 'dynamic'
643
+ ]
644
+ }
645
+ });
646
+ }
647
+ reject('gap observation dynamic code', {
648
+ ...receipt,
649
+ observation: {
650
+ kind: 'gap',
651
+ code: 'dynamic',
652
+ factRefs: []
653
+ }
654
+ });
655
+ const turn = {
656
+ schemaVersion: 'booking.surface',
156
657
  kind: 'user.turn',
157
658
  taskId: 'task-1',
158
- workspace: strictWorkspace,
659
+ turnId: 'turn-1',
660
+ workspace,
159
661
  request: {
160
- text: ingress.request.text
662
+ text: 'find hotels'
161
663
  }
162
664
  };
163
- assert.deepEqual(validateBookingCopilotTurnV1(plannerTurn), {
164
- ok: true
165
- });
166
- assert.equal(validateBookingCopilotTurnV1({
167
- ...plannerTurn,
168
- workspace: {
169
- ...strictWorkspace,
170
- capabilities: {
171
- surface: 'tenant',
172
- allowedActions: [
173
- ...BOOKING_READ_ACTION_KINDS
174
- ]
175
- }
665
+ const ingress = {
666
+ schemaVersion: 'booking.surface',
667
+ kind: 'user.turn.ingress',
668
+ requestKey: 'request-1',
669
+ taskHandle: 'opaque-task-handle-1',
670
+ surfaceHint: 'tenant',
671
+ workspace: ingressWorkspace,
672
+ request: {
673
+ text: 'find hotels'
176
674
  }
177
- }).ok, true, 'full closed 12-action registry is canonical-valid');
178
- assert.equal(validateBookingCopilotTurnV1({
179
- ...plannerTurn,
180
- workspace: {
181
- ...strictWorkspace,
182
- results: {}
183
- }
184
- }).ok, false, 'canonical turn rejects results without status');
185
- const { contextRef: _omitted, ...workspaceWithoutContext } = strictWorkspace;
186
- assert.equal(validateBookingCopilotTurnV1({
187
- ...plannerTurn,
188
- workspace: workspaceWithoutContext
189
- }).ok, false, 'planner turn requires the server-minted contextRef');
190
- const statusEvent = {
191
- schemaVersion: 'booking.surface.v1',
192
- eventId: 'event-1',
675
+ };
676
+ const continuation = {
677
+ schemaVersion: 'booking.surface',
678
+ kind: 'action.receipt.continuation',
193
679
  taskId: 'task-1',
194
- contextRef: 'ctx-server-minted',
195
- sequence: 1,
196
- emittedAt: '2026-08-30T12:00:00.000Z',
197
- kind: 'status',
198
- status: 'submitted'
680
+ workspace,
681
+ receipt
199
682
  };
200
- assert.deepEqual(validateBookingSurfaceEventV1(statusEvent), {
201
- ok: true
683
+ accept('valid user turn', turn);
684
+ accept('valid ingress', ingress);
685
+ accept('valid ingress approval carries executable typed approval', {
686
+ ...ingress,
687
+ request: {
688
+ text: 'relax must criterion',
689
+ approval
690
+ }
202
691
  });
203
- const { contextRef: _eventContext, ...eventWithoutContext } = statusEvent;
204
- assert.equal(validateBookingSurfaceEventV1(eventWithoutContext).ok, false, 'all outbound events carry contextRef');
205
- assert.equal(validateBookingSurfaceEventV1({
206
- ...statusEvent,
207
- status: 'unknown'
208
- }).ok, false, 'status payload is a closed enum');
209
- const { status: _status, ...eventBase } = statusEvent;
210
- assert.equal(validateBookingSurfaceEventV1({
211
- ...eventBase,
212
- kind: 'question',
213
- question: {}
214
- }).ok, false, 'question payload is typed, not presence-only');
215
- assert.equal(validateBookingSurfaceEventV1({
216
- ...eventBase,
217
- kind: 'operation',
218
- action: '{"kind":"search.run"}'
219
- }).ok, false, 'operation rejects JSON-in-text');
220
- assert.equal(validateBookingSurfaceEventV1({
221
- ...eventBase,
222
- kind: 'terminal',
223
- terminal: {
224
- status: 'failed',
225
- summary: 'x',
226
- factRefs: []
692
+ accept('valid continuation', continuation);
693
+ reject('empty text', {
694
+ ...turn,
695
+ request: {
696
+ text: ''
227
697
  }
228
- }).ok, false, 'terminal status is closed');
229
- const errorWithJsonLookingText = {
230
- ...eventBase,
231
- kind: 'error',
232
- error: {
233
- code: 'UPSTREAM_FAILED',
234
- message: '{"kind":"search.run","input":{}}',
235
- retryable: true
698
+ });
699
+ reject('multiple approvals', {
700
+ ...turn,
701
+ request: {
702
+ text: 'x',
703
+ approval: [
704
+ approval,
705
+ approval
706
+ ]
236
707
  }
708
+ });
709
+ reject('ingress multiple approvals', {
710
+ ...ingress,
711
+ request: {
712
+ text: 'x',
713
+ approval: [
714
+ approval,
715
+ approval
716
+ ]
717
+ }
718
+ });
719
+ accept('text only remains non-approval', {
720
+ ...turn,
721
+ request: {
722
+ text: 'please relax must'
723
+ }
724
+ });
725
+ const selectedIngressWorkspace = {
726
+ ...ingressWorkspace,
727
+ visibleHotels: [
728
+ {
729
+ hotelRef: 'hotel-1',
730
+ name: 'Hotel 1',
731
+ factRefs: [
732
+ 'hotel-fact-1'
733
+ ]
734
+ }
735
+ ],
736
+ loadedOffers: [
737
+ {
738
+ offerRef: 'offer-1',
739
+ offerVersionRef: 'offer-1:v1',
740
+ hotelRef: 'hotel-1',
741
+ evidenceLevel: 'rate_loaded',
742
+ factRefs: [
743
+ 'offer-fact-1'
744
+ ]
745
+ }
746
+ ],
747
+ focusedHotelRef: 'hotel-1',
748
+ selectedOfferRef: 'offer-1'
237
749
  };
238
- assert.deepEqual(validateBookingSurfaceEventV1(errorWithJsonLookingText), {
239
- ok: true
240
- });
241
- assert.equal(bookingOperationFromEvent(errorWithJsonLookingText), null, 'JSON-looking error text never becomes an executable action');
242
- const validAction = {
243
- schemaVersion: 'booking.surface.v1',
244
- kind: 'search.run',
245
- actionId: 'action-1',
246
- contextRef: 'ctx-server-minted',
247
- expectedRevision: 0,
248
- reason: 'Run the existing search form.',
249
- factRefs: [],
250
- input: {}
251
- };
252
- assert.deepEqual(validateBookingReadActionV1(validAction), {
253
- ok: true
750
+ accept('ingress preserves visible focus and loaded selection', {
751
+ ...ingress,
752
+ workspace: selectedIngressWorkspace
254
753
  });
255
- assert.equal(validateBookingReadActionV1({
256
- ...validAction,
257
- kind: 'book'
258
- }).ok, false, 'Book is outside the closed action registry');
259
- assert.equal(validateBookingReadActionV1({
260
- ...validAction,
261
- holder: {
262
- email: 'x@example.com'
263
- }
264
- }).ok, false, 'holder/guest patches cannot enter actions');
265
- assert.equal(validateBookingReadActionV1('{"kind":"search.run"}').ok, false, 'JSON-in-text is not an action');
266
- const validSearchPatchAction = {
267
- ...validAction,
268
- kind: 'search.patch',
269
- input: {
270
- patch: {
271
- destination: {
272
- query: 'Dubai'
273
- },
274
- budget: {
275
- strength: 'must',
276
- value: {
277
- max: {
278
- amount: '1000.00',
279
- currency: 'AED',
280
- sourceFactRef: 'fact-budget-1'
281
- }
282
- }
754
+ reject('ingress rejects invisible focus', {
755
+ ...ingress,
756
+ workspace: {
757
+ ...selectedIngressWorkspace,
758
+ focusedHotelRef: 'hotel-other'
759
+ }
760
+ });
761
+ reject('ingress rejects unloaded selection', {
762
+ ...ingress,
763
+ workspace: {
764
+ ...selectedIngressWorkspace,
765
+ selectedOfferRef: 'offer-other'
766
+ }
767
+ });
768
+ reject('ingress rejects selection whose hotel is not visible', {
769
+ ...ingress,
770
+ workspace: {
771
+ ...selectedIngressWorkspace,
772
+ visibleHotels: [
773
+ {
774
+ hotelRef: 'hotel-other',
775
+ name: 'Other',
776
+ factRefs: []
283
777
  }
778
+ ]
779
+ }
780
+ });
781
+ reject('ingress never accepts verified authority', {
782
+ ...ingress,
783
+ workspace: {
784
+ ...selectedIngressWorkspace,
785
+ verifiedOfferRef: 'verified-offer-1'
786
+ }
787
+ });
788
+ reject('missing user turn identity', {
789
+ ...turn,
790
+ turnId: undefined
791
+ });
792
+ reject('missing ingress request key', {
793
+ ...ingress,
794
+ requestKey: undefined
795
+ });
796
+ reject('unsafe user turn identity', {
797
+ ...turn,
798
+ turnId: 'Bearer token'
799
+ });
800
+ reject('unsafe ingress task handle', {
801
+ ...ingress,
802
+ taskHandle: 'user@example.com'
803
+ });
804
+ reject('missing user task identity', {
805
+ ...turn,
806
+ taskId: undefined
807
+ });
808
+ reject('browser task identity injection', {
809
+ ...ingress,
810
+ taskId: 'browser-task'
811
+ });
812
+ reject('browser turn identity injection', {
813
+ ...ingress,
814
+ turnId: 'browser-turn'
815
+ });
816
+ reject('browser context identity injection', {
817
+ ...ingress,
818
+ contextRef: null
819
+ });
820
+ reject('browser verified authority injection', {
821
+ ...ingress,
822
+ workspace: {
823
+ ...ingressWorkspace,
824
+ verifiedOfferRef: 'verified-offer-1'
825
+ }
826
+ });
827
+ assert.equal(validateCriterionBlocker({
828
+ ...blocker,
829
+ evidence: {
830
+ factRefs: [],
831
+ gapCodes: []
832
+ }
833
+ }).ok, false);
834
+ negative++;
835
+ assert.equal(validateApprovalAgainstBlocker({
836
+ ...approval,
837
+ blockerId: 'wrong'
838
+ }, blocker).ok, false);
839
+ negative++;
840
+ const continuationBlocked = {
841
+ ...continuation,
842
+ receipt: {
843
+ ...receipt,
844
+ resultContract: {
845
+ ...receipt.resultContract,
846
+ hardCriteriaMet: true
284
847
  }
285
848
  }
286
849
  };
287
- assert.deepEqual(validateBookingReadActionV1(validSearchPatchAction), {
288
- ok: true
850
+ reject('continuation hard criteria with blocker', continuationBlocked);
851
+ reject('formatted phone', {
852
+ ...turn,
853
+ request: {
854
+ text: '+971 (50) 123 4567'
855
+ }
289
856
  });
290
- assert.equal(validateBookingReadActionV1({
291
- ...validSearchPatchAction,
292
- input: {
293
- patch: {
294
- ...validSearchPatchAction.input.patch,
295
- unknownCriterion: {
296
- strength: 'must',
297
- value: true
857
+ reject('dotted phone', {
858
+ ...turn,
859
+ request: {
860
+ text: '+971.50.123.4567'
861
+ }
862
+ });
863
+ reject('slash phone', {
864
+ ...turn,
865
+ request: {
866
+ text: 'call +971/50/123/4567'
867
+ }
868
+ });
869
+ reject('comma phone', {
870
+ ...turn,
871
+ request: {
872
+ text: 'call +971,50,123,4567'
873
+ }
874
+ });
875
+ reject('unicode dash phone', {
876
+ ...turn,
877
+ request: {
878
+ text: 'call +971—50—123—4567'
879
+ }
880
+ });
881
+ reject('nbsp phone', {
882
+ ...turn,
883
+ request: {
884
+ text: '+971\u00a050\u00a0123\u00a04567'
885
+ }
886
+ });
887
+ reject('embedded phone', {
888
+ ...turn,
889
+ request: {
890
+ text: 'call +971 50 123 4567 please'
891
+ }
892
+ });
893
+ reject('payment card', {
894
+ ...turn,
895
+ request: {
896
+ text: '4111 1111 1111 1111'
897
+ }
898
+ });
899
+ reject('supplier cost', {
900
+ ...turn,
901
+ request: {
902
+ text: 'supplier net cost AED 42'
903
+ }
904
+ });
905
+ reject('supplier cost colon', {
906
+ ...turn,
907
+ request: {
908
+ text: 'supplier net cost: AED 42'
909
+ }
910
+ });
911
+ reject('supplier possessive cost', {
912
+ ...turn,
913
+ request: {
914
+ text: "supplier's net cost AED 42"
915
+ }
916
+ });
917
+ reject('supplier cost reversed currency', {
918
+ ...turn,
919
+ request: {
920
+ text: 'supplier cost 42 AED'
921
+ }
922
+ });
923
+ reject('supplier wholesale', {
924
+ ...turn,
925
+ request: {
926
+ text: 'supplier wholesale rate AED 42'
927
+ }
928
+ });
929
+ reject('supplier base bypass', {
930
+ ...turn,
931
+ request: {
932
+ text: 'supplier base cost AED 42'
933
+ }
934
+ });
935
+ reject('vendor cost', {
936
+ ...turn,
937
+ request: {
938
+ text: 'vendor net cost AED 42'
939
+ }
940
+ });
941
+ reject('vendor compact amount', {
942
+ ...turn,
943
+ request: {
944
+ text: 'vendor price AED42'
945
+ }
946
+ });
947
+ reject('internal supplier cost', {
948
+ ...turn,
949
+ request: {
950
+ text: 'internal supplier cost AED 42'
951
+ }
952
+ });
953
+ reject('internal reversed amount', {
954
+ ...turn,
955
+ request: {
956
+ text: 'internal rate 42 USD'
957
+ }
958
+ });
959
+ reject('contact email bypass', {
960
+ ...turn,
961
+ request: {
962
+ text: 'contact user@hotelbyte.com'
963
+ }
964
+ });
965
+ reject('quoted email local part', {
966
+ ...turn,
967
+ request: {
968
+ text: 'contact "user"@hotelbyte.com'
969
+ }
970
+ });
971
+ reject('unicode email local part', {
972
+ ...turn,
973
+ request: {
974
+ text: '联系 用户@hotelbyte.com'
975
+ }
976
+ });
977
+ reject('technical-looking real mailbox', {
978
+ ...turn,
979
+ request: {
980
+ text: 'version@company.com'
981
+ }
982
+ });
983
+ accept('benign trace identifier', {
984
+ ...turn,
985
+ request: {
986
+ text: 'trace 0000000000000000'
987
+ }
988
+ });
989
+ accept('benign sk-ills sentence', {
990
+ ...turn,
991
+ request: {
992
+ text: 'sk-ills are useful'
993
+ }
994
+ });
995
+ accept('benign version email', {
996
+ ...turn,
997
+ request: {
998
+ text: 'version@release.example'
999
+ }
1000
+ });
1001
+ accept('benign schema identifier', {
1002
+ ...turn,
1003
+ request: {
1004
+ text: 'schema@v2.example'
1005
+ }
1006
+ });
1007
+ accept('benign budget', {
1008
+ ...turn,
1009
+ request: {
1010
+ text: 'budget AED 1000'
1011
+ }
1012
+ });
1013
+ accept('benign total price', {
1014
+ ...turn,
1015
+ request: {
1016
+ text: 'total price under AED 1000'
1017
+ }
1018
+ });
1019
+ const common = {
1020
+ schemaVersion: 'booking.surface',
1021
+ eventId: 'e-1',
1022
+ taskId: 'task-1',
1023
+ contextRef: 'ctx-1',
1024
+ sequence: 1,
1025
+ emittedAt: '2026-08-30T12:00:00.000Z'
1026
+ };
1027
+ const questionEvent = {
1028
+ ...common,
1029
+ kind: 'question',
1030
+ question: {
1031
+ questionId: 'q-1',
1032
+ prompt: 'Relax?',
1033
+ missingFields: [],
1034
+ type: 'relaxation_approval_required',
1035
+ blocker,
1036
+ approvalOptions: [
1037
+ {
1038
+ approval
298
1039
  }
1040
+ ]
1041
+ }
1042
+ };
1043
+ const events = [
1044
+ {
1045
+ ...common,
1046
+ kind: 'status',
1047
+ status: 'working'
1048
+ },
1049
+ questionEvent,
1050
+ {
1051
+ ...common,
1052
+ kind: 'operation',
1053
+ action: actions['search.run']
1054
+ },
1055
+ {
1056
+ ...common,
1057
+ kind: 'explanation',
1058
+ explanation: {
1059
+ text: 'x',
1060
+ factRefs: []
1061
+ }
1062
+ },
1063
+ {
1064
+ ...common,
1065
+ kind: 'terminal',
1066
+ terminal: {
1067
+ status: 'completed',
1068
+ summary: 'x',
1069
+ factRefs: []
1070
+ }
1071
+ },
1072
+ {
1073
+ ...common,
1074
+ kind: 'error',
1075
+ error: {
1076
+ code: 'ERR',
1077
+ message: 'x',
1078
+ retryable: false
299
1079
  }
300
1080
  }
301
- }).ok, false, 'unknown nested search criteria are rejected before an operation is issued');
302
- assert.equal(validateBookingReadActionV1({
303
- ...validSearchPatchAction,
304
- input: {
305
- patch: {
306
- budget: {
307
- strength: 'must',
308
- value: {
309
- max: {
310
- amount: 1000,
311
- currency: 'AED',
312
- sourceFactRef: 'fact-budget-1'
313
- }
1081
+ ];
1082
+ assert.equal(validateBookingSurfaceEvent({
1083
+ ...questionEvent,
1084
+ question: {
1085
+ ...questionEvent.question,
1086
+ approvalOptions: [
1087
+ {
1088
+ approval: {
1089
+ ...approval,
1090
+ blockerId: 'wrong'
314
1091
  }
315
1092
  }
316
- }
1093
+ ]
317
1094
  }
318
- }).ok, false, 'Money.amount is a decimal string, never a model-computed number');
319
- assert.equal(validateBookingReadActionV1({
320
- ...validSearchPatchAction,
321
- input: {
322
- patch: {
323
- destination: {
324
- query: 'Dubai'
325
- },
326
- holder: {
327
- email: 'x@example.com'
1095
+ }).ok, false);
1096
+ negative++;
1097
+ assert.equal(validateBookingSurfaceEvent({
1098
+ ...questionEvent,
1099
+ question: {
1100
+ ...questionEvent.question,
1101
+ approvalOptions: [
1102
+ {
1103
+ approval: {
1104
+ ...approval,
1105
+ taskId: 'task-other'
1106
+ }
328
1107
  }
329
- }
1108
+ ]
330
1109
  }
331
- }).ok, false, 'holder data is rejected even when nested inside a typed patch');
332
- assert.equal(validateBookingReadActionV1({
333
- ...validAction,
334
- kind: 'offers.query',
335
- input: {
336
- hotelRefs: [
337
- 'hotel-1'
338
- ],
339
- criteria: {
340
- roomType: {
341
- strength: 'must',
342
- value: [
343
- 'Twin'
344
- ]
345
- },
346
- guest: {
347
- name: 'x'
1110
+ }).ok, false);
1111
+ negative++;
1112
+ assert.equal(validateBookingSurfaceEvent({
1113
+ ...questionEvent,
1114
+ question: {
1115
+ ...questionEvent.question,
1116
+ approvalOptions: [
1117
+ {
1118
+ approval: {
1119
+ ...approval,
1120
+ contextRef: 'ctx-other'
1121
+ }
348
1122
  }
349
- }
1123
+ ]
350
1124
  }
351
- }).ok, false, 'guest data is rejected even when nested inside offer criteria');
352
- const validReceipt = {
353
- schemaVersion: 'booking.surface.v1',
354
- kind: 'action.receipt',
355
- actionId: 'action-1',
356
- contextRef: 'ctx-server-minted',
357
- status: 'applied',
358
- revision: 1,
359
- observation: {
360
- kind: 'search.state',
361
- resultCount: 3
362
- },
363
- resultContract: {
364
- outcome: 'complete',
365
- hardCriteriaMet: true,
1125
+ }).ok, false);
1126
+ negative++;
1127
+ for (const event of events)accept(`valid event ${event.kind}`, event);
1128
+ assert.equal(validateBookingSurfaceEvent({
1129
+ ...events[5],
1130
+ eventId: 'event-4242-4242-4242-4242'
1131
+ }).ok, true);
1132
+ positive++;
1133
+ assert.equal(validateBookingSurfaceEvent({
1134
+ ...events[3],
1135
+ explanation: {
1136
+ text: 'card 4111 1111 1111 1111',
1137
+ factRefs: []
1138
+ }
1139
+ }).ok, false);
1140
+ negative++;
1141
+ reject('event mixed payload', {
1142
+ ...events[0],
1143
+ question: {}
1144
+ });
1145
+ reject('event missing payload', {
1146
+ ...events[1],
1147
+ question: undefined
1148
+ });
1149
+ reject('event open nested', {
1150
+ ...events[4],
1151
+ terminal: {
1152
+ status: 'completed',
1153
+ summary: 'x',
366
1154
  factRefs: [],
367
- gapCodes: []
1155
+ open: true
368
1156
  }
369
- };
370
- assert.deepEqual(validateActionReceiptV1(validReceipt), {
371
- ok: true
372
1157
  });
373
- assert.equal(validateActionReceiptV1({
374
- ...validReceipt,
375
- observation: {
376
- kind: 'book'
377
- }
378
- }).ok, false, 'receipt observations are a closed typed union');
379
- assert.equal(validateActionReceiptV1({
380
- ...validReceipt,
381
- observation: null
382
- }).ok, false, 'receipt observation is never null');
383
- assert.equal(validateActionReceiptV1({
384
- ...validReceipt,
385
- resultContract: {
386
- ...validReceipt.resultContract,
387
- supplierCost: 1
1158
+ reject('request key whitespace', {
1159
+ ...ingress,
1160
+ requestKey: 'request key'
1161
+ });
1162
+ reject('request key newline', {
1163
+ ...ingress,
1164
+ requestKey: 'request-1\nnext'
1165
+ });
1166
+ reject('request key path', {
1167
+ ...ingress,
1168
+ requestKey: 'request/path'
1169
+ });
1170
+ reject('request key email', {
1171
+ ...ingress,
1172
+ requestKey: 'user@example.com'
1173
+ });
1174
+ reject('request key secret-like', {
1175
+ ...ingress,
1176
+ requestKey: 'sk-live-abcdefghijkl'
1177
+ });
1178
+ reject('request key too short', {
1179
+ ...ingress,
1180
+ requestKey: 'r1'
1181
+ });
1182
+ accept('ISO date and currency', {
1183
+ ...workspace,
1184
+ searchDraft: {
1185
+ stay: {
1186
+ checkIn: '2026-09-01',
1187
+ checkOut: '2026-09-03'
1188
+ }
388
1189
  }
389
- }).ok, false, 'receipt result contract rejects supplier cost');
390
- console.log('BOOKING SURFACE CONTRACT PROOF: registry/schema/ingress-planner boundary OK');
1190
+ });
1191
+ reject('email secret', {
1192
+ ...turn,
1193
+ request: {
1194
+ text: 'user@example.com'
1195
+ }
1196
+ });
1197
+ reject('Bearer secret', {
1198
+ ...turn,
1199
+ request: {
1200
+ text: 'Bearer abc'
1201
+ }
1202
+ });
1203
+ reject('sk secret', {
1204
+ ...turn,
1205
+ request: {
1206
+ text: 'sk-live-abcdefghijkl'
1207
+ }
1208
+ });
1209
+ reject('JWT secret', {
1210
+ ...turn,
1211
+ request: {
1212
+ text: 'eyJabc.def.ghi'
1213
+ }
1214
+ });
1215
+ accept('E164-like ordinary date safe', turn);
1216
+ assert.equal(createHash('sha256').update(readFileSync(new URL('../../schemas/booking.surface.schema.json', import.meta.url))).digest('hex'), BOOKING_SURFACE_SCHEMA_SHA256);
1217
+ console.log(`BOOKING SURFACE CONTRACT PROOF: positive=${positive} negative=${negative}`);
1218
+ assert.ok(positive >= 65 && negative >= 105);
391
1219
 
392
1220
 
393
1221
  //# sourceURL=ts/scripts/booking-surface-contract-proof-tests.ts