@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,453 +1,205 @@
1
- import {
2
- BOOKING_READ_ACTION_KINDS,
3
- BOOKING_RECEIPT_STATUSES,
4
- BOOKING_SURFACES,
5
- BOOKING_SURFACE_EVENT_KINDS,
6
- type ActionReceiptV1,
7
- type BookingCopilotIngressTurnV1,
8
- type BookingCopilotTurnV1,
9
- type BookingReadActionKindV1,
10
- type BookingReadActionV1,
11
- type BookingSurfaceEventV1,
12
- type BookingWorkspaceIngressSnapshotV1,
13
- type BookingWorkspaceSnapshotV1,
14
- } from './contracts.ts'
15
- import { Ajv2020, type ErrorObject, type ValidateFunction } from 'ajv/dist/2020.js'
16
1
  import { readFileSync } from 'node:fs'
17
-
18
- const CANONICAL_SCHEMA = JSON.parse(readFileSync(
19
- new URL('../../../schemas/booking.surface.v1.schema.json', import.meta.url),
20
- 'utf8',
21
- )) as Record<string, unknown>
22
- const CANONICAL_SCHEMA_ID = 'https://gotry.dev/schemas/booking.surface.v1.schema.json'
23
- const canonicalAjv = new Ajv2020({ allErrors: true, strict: true })
24
- canonicalAjv.addSchema(CANONICAL_SCHEMA)
25
- const validateCanonicalAction = canonicalAjv.compile({
26
- $ref: `${CANONICAL_SCHEMA_ID}#/$defs/BookingReadActionV1`,
27
- }) as ValidateFunction
28
- const validateCanonicalReceipt = canonicalAjv.compile({
29
- $ref: `${CANONICAL_SCHEMA_ID}#/$defs/ActionReceiptV1`,
30
- }) as ValidateFunction
31
- const validateCanonicalWorkspace = canonicalAjv.compile({ $ref: `${CANONICAL_SCHEMA_ID}#/$defs/BookingWorkspaceSnapshotV1` }) as ValidateFunction
32
- const validateCanonicalIngressTurn = canonicalAjv.compile({ $ref: `${CANONICAL_SCHEMA_ID}#/$defs/BookingCopilotIngressTurnV1` }) as ValidateFunction
33
- const validateCanonicalTurn = canonicalAjv.compile({ $ref: `${CANONICAL_SCHEMA_ID}#/$defs/BookingCopilotTurnV1` }) as ValidateFunction
34
-
35
- export type BookingSurfaceValidationResult =
36
- | { ok: true }
37
- | { ok: false; errors: string[] }
38
-
39
- const ACTION_KINDS = new Set<string>(BOOKING_READ_ACTION_KINDS)
40
- const RECEIPT_STATUSES = new Set<string>(BOOKING_RECEIPT_STATUSES)
41
- const SURFACES = new Set<string>(BOOKING_SURFACES)
42
- const EVENT_KINDS = new Set<string>(BOOKING_SURFACE_EVENT_KINDS)
43
-
44
- function isRecord(value: unknown): value is Record<string, unknown> {
45
- return typeof value === 'object' && value !== null && !Array.isArray(value)
46
- }
47
-
48
- function isNonEmptyString(value: unknown): value is string {
49
- return typeof value === 'string' && value.length > 0
50
- }
51
-
52
- function isRevision(value: unknown): value is number {
53
- return Number.isInteger(value) && Number(value) >= 0
54
- }
55
-
56
- function isStringArray(value: unknown): value is string[] {
57
- return Array.isArray(value) && value.every(isNonEmptyString)
58
- }
59
-
60
- function isCount(value: unknown): value is number {
61
- return Number.isInteger(value) && Number(value) >= 0
62
- }
63
- function isSafeCode(value: unknown): value is string { return typeof value === 'string' && /^[a-z][a-z0-9._-]*$/.test(value) }
64
-
65
- function receiptContainsUnsafeOpaqueText(value: unknown): boolean {
66
- if (typeof value === 'string') return /[^\s@]+@[^\s@]+\.[^\s@]+|Bearer\s+[^\s]+|\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/i.test(value)
67
- if (Array.isArray(value)) return value.some(receiptContainsUnsafeOpaqueText)
68
- if (isRecord(value)) return Object.values(value).some(receiptContainsUnsafeOpaqueText)
69
- return false
70
- }
71
-
72
- function exactKeys(
73
- value: Record<string, unknown>,
74
- allowed: readonly string[],
75
- required: readonly string[],
76
- at: string,
77
- errors: string[],
78
- ): void {
79
- const allow = new Set(allowed)
80
- for (const key of Object.keys(value)) if (!allow.has(key)) errors.push(`${at}.${key}: additional property is forbidden`)
81
- for (const key of required) if (!Object.hasOwn(value, key)) errors.push(`${at}.${key}: required`)
82
- }
83
-
84
- function validateIngressWorkspace(value: unknown, at: string, errors: string[]): value is BookingWorkspaceIngressSnapshotV1 {
85
- if (!isRecord(value)) { errors.push(`${at}: object required`); return false }
86
- exactKeys(value, [
87
- 'schemaVersion', 'revision', 'locale', 'currency', 'searchDraft', 'results',
88
- 'visibleHotels', 'loadedOffers', 'focusedHotelRef', 'shortlistedOfferRefs',
89
- 'selectedOfferRef', 'verifiedOfferRef',
90
- ], [
91
- 'schemaVersion', 'revision', 'locale', 'currency', 'searchDraft', 'results',
92
- 'visibleHotels', 'loadedOffers', 'shortlistedOfferRefs',
93
- ], at, errors)
94
- if (value.schemaVersion !== 'booking.surface.v1') errors.push(`${at}.schemaVersion: booking.surface.v1 required`)
95
- if (!isRevision(value.revision)) errors.push(`${at}.revision: non-negative integer required`)
96
- if (!isNonEmptyString(value.locale)) errors.push(`${at}.locale: non-empty string required`)
97
- if (typeof value.currency !== 'string' || !/^[A-Z]{3}$/.test(value.currency)) errors.push(`${at}.currency: ISO-4217 code required`)
98
- if (!isRecord(value.searchDraft)) errors.push(`${at}.searchDraft: object required`)
99
- if (!isRecord(value.results)) errors.push(`${at}.results: object required`)
100
- if (!Array.isArray(value.visibleHotels)) errors.push(`${at}.visibleHotels: array required`)
101
- if (!Array.isArray(value.loadedOffers)) errors.push(`${at}.loadedOffers: array required`)
102
- if (!isStringArray(value.shortlistedOfferRefs)) errors.push(`${at}.shortlistedOfferRefs: opaque-ref array required`)
103
- return errors.length === 0
104
- }
105
-
106
- function validateWorkspace(value: unknown, at: string, errors: string[]): value is BookingWorkspaceSnapshotV1 {
107
- if (!isRecord(value)) { errors.push(`${at}: object required`); return false }
108
- const {
109
- contextRef: _contextRef,
110
- surface: _surface,
111
- capabilities: _capabilities,
112
- ...ingressShape
113
- } = value
114
- validateIngressWorkspace(ingressShape, at, errors)
115
- const allowed = [
116
- 'schemaVersion', 'contextRef', 'surface', 'revision', 'locale', 'currency',
117
- 'searchDraft', 'results', 'visibleHotels', 'loadedOffers', 'focusedHotelRef',
118
- 'shortlistedOfferRefs', 'selectedOfferRef', 'verifiedOfferRef', 'capabilities',
119
- ]
120
- exactKeys(value, allowed, [
121
- 'schemaVersion', 'contextRef', 'surface', 'revision', 'locale', 'currency',
122
- 'searchDraft', 'results', 'visibleHotels', 'loadedOffers', 'shortlistedOfferRefs',
123
- 'capabilities',
124
- ], at, errors)
125
- if (!isNonEmptyString(value.contextRef)) errors.push(`${at}.contextRef: server-minted opaque ref required`)
126
- if (typeof value.surface !== 'string' || !SURFACES.has(value.surface)) errors.push(`${at}.surface: unsupported surface`)
127
- if (!isRecord(value.capabilities)) {
128
- errors.push(`${at}.capabilities: object required`)
129
- } else {
130
- exactKeys(value.capabilities, ['surface', 'allowedActions'], ['surface', 'allowedActions'], `${at}.capabilities`, errors)
131
- if (value.capabilities.surface !== value.surface) errors.push(`${at}.capabilities.surface: must equal workspace.surface`)
132
- const actionKinds = value.capabilities.allowedActions
133
- if (!Array.isArray(actionKinds) || !actionKinds.every((kind) => typeof kind === 'string' && ACTION_KINDS.has(kind))) {
134
- errors.push(`${at}.capabilities.allowedActions: closed action-kind array required`)
135
- } else if (new Set(actionKinds).size !== actionKinds.length) {
136
- errors.push(`${at}.capabilities.allowedActions: duplicates forbidden`)
137
- }
138
- }
139
- appendCanonicalErrors(validateCanonicalWorkspace, value, at, errors)
140
- return errors.length === 0
141
- }
142
-
143
- const ACTION_INPUT_KEYS: Record<BookingReadActionKindV1, { allowed: readonly string[]; required: readonly string[] }> = {
144
- 'search.patch': { allowed: ['patch'], required: ['patch'] },
145
- 'search.run': { allowed: [], required: [] },
146
- 'results.view.patch': { allowed: ['patch'], required: ['patch'] },
147
- 'hotel.focus': { allowed: ['hotelRef'], required: ['hotelRef'] },
148
- 'hotel.select': { allowed: ['hotelRef'], required: ['hotelRef'] },
149
- 'offers.query': { allowed: ['hotelRefs', 'criteria'], required: ['hotelRefs', 'criteria'] },
150
- 'offers.view.patch': { allowed: ['hotelRef', 'criteria'], required: ['hotelRef', 'criteria'] },
151
- 'offers.compare': { allowed: ['offerRefs', 'requestedCount'], required: ['offerRefs', 'requestedCount'] },
152
- 'offer.select': { allowed: ['offerRef'], required: ['offerRef'] },
153
- 'offer.check': { allowed: ['offerRef'], required: ['offerRef'] },
154
- 'checkout.prepare': { allowed: ['offerRef', 'verifiedOfferRef'], required: ['offerRef', 'verifiedOfferRef'] },
155
- 'order.observe': { allowed: ['orderRef'], required: ['orderRef'] },
156
- }
157
-
158
- function collectBookingReadActionErrors(value: unknown, at: string): string[] {
159
- const errors: string[] = []
160
- if (!isRecord(value)) return [`${at}: typed action object required; text is never executable`]
161
- exactKeys(value, [
162
- 'schemaVersion', 'kind', 'actionId', 'contextRef', 'expectedRevision',
163
- 'reason', 'factRefs', 'input',
164
- ], [
165
- 'schemaVersion', 'kind', 'actionId', 'contextRef', 'expectedRevision',
166
- 'reason', 'factRefs', 'input',
167
- ], at, errors)
168
- if (value.schemaVersion !== 'booking.surface.v1') errors.push(`${at}.schemaVersion: booking.surface.v1 required`)
169
- if (typeof value.kind !== 'string' || !ACTION_KINDS.has(value.kind)) {
170
- errors.push(`${at}.kind: unregistered read action`)
171
- }
172
- if (!isNonEmptyString(value.actionId)) errors.push(`${at}.actionId: opaque ref required`)
173
- if (!isNonEmptyString(value.contextRef)) errors.push(`${at}.contextRef: opaque ref required`)
174
- if (!isRevision(value.expectedRevision)) errors.push(`${at}.expectedRevision: non-negative integer required`)
175
- if (!isNonEmptyString(value.reason)) errors.push(`${at}.reason: non-empty explanation required`)
176
- if (!isStringArray(value.factRefs)) errors.push(`${at}.factRefs: opaque-ref array required`)
177
- if (!isRecord(value.input)) {
178
- errors.push(`${at}.input: typed object required`)
179
- } else if (typeof value.kind === 'string' && ACTION_KINDS.has(value.kind)) {
180
- const spec = ACTION_INPUT_KEYS[value.kind as BookingReadActionKindV1]
181
- exactKeys(value.input, spec.allowed, spec.required, `${at}.input`, errors)
182
- }
183
- if (!validateCanonicalAction(value)) {
184
- for (const error of validateCanonicalAction.errors ?? []) {
185
- errors.push(canonicalError(error, at))
186
- }
187
- }
188
- return errors
189
- }
190
-
191
- function canonicalError(error: ErrorObject, at: string): string {
192
- const path = error.instancePath ? error.instancePath.replaceAll('/', '.') : ''
193
- const property = error.keyword === 'additionalProperties'
194
- ? `.${String((error.params as { additionalProperty?: unknown }).additionalProperty ?? '')}`
195
- : ''
196
- return `${at}${path}${property}: ${error.message ?? error.keyword}`
197
- }
198
-
199
- function appendCanonicalErrors(validate: ValidateFunction, value: unknown, at: string, errors: string[]): void {
200
- if (!validate(value)) for (const error of validate.errors ?? []) errors.push(canonicalError(error, at))
201
- }
202
-
203
- export function validateBookingReadActionV1(value: unknown): BookingSurfaceValidationResult {
204
- const errors = collectBookingReadActionErrors(value, '$')
205
- return errors.length === 0 ? { ok: true } : { ok: false, errors }
206
- }
207
-
208
- function collectReceiptErrors(value: unknown, at: string): string[] {
209
- const errors: string[] = []
210
- if (!isRecord(value)) return [`${at}: typed receipt object required`]
211
- exactKeys(value, [
212
- 'schemaVersion', 'kind', 'actionId', 'contextRef', 'status', 'revision',
213
- 'observation', 'resultContract', 'undoToken',
214
- ], [
215
- 'schemaVersion', 'kind', 'actionId', 'contextRef', 'status', 'revision',
216
- 'observation', 'resultContract',
217
- ], at, errors)
218
- if (value.schemaVersion !== 'booking.surface.v1') errors.push(`${at}.schemaVersion: booking.surface.v1 required`)
219
- if (value.kind !== 'action.receipt') errors.push(`${at}.kind: action.receipt required`)
220
- if (!isNonEmptyString(value.actionId)) errors.push(`${at}.actionId: opaque ref required`)
221
- if (!isNonEmptyString(value.contextRef)) errors.push(`${at}.contextRef: opaque ref required`)
222
- if (typeof value.status !== 'string' || !RECEIPT_STATUSES.has(value.status)) errors.push(`${at}.status: unregistered receipt status`)
223
- if (!isRevision(value.revision)) errors.push(`${at}.revision: non-negative integer required`)
224
- if (value.undoToken !== undefined && !isNonEmptyString(value.undoToken)) errors.push(`${at}.undoToken: opaque ref required`)
225
- validateActionObservation(value.observation, `${at}.observation`, errors)
226
- validateResultContract(value.resultContract, `${at}.resultContract`, errors)
227
- return errors
228
- }
229
-
230
- function validateActionObservation(value: unknown, at: string, errors: string[]): void {
231
- if (!isRecord(value)) {
232
- errors.push(`${at}: discriminated object required`)
233
- return
234
- }
235
-
236
- switch (value.kind) {
237
- case 'search.state':
238
- exactKeys(value, ['kind', 'searchSessionRef', 'resultCount'], ['kind'], at, errors)
239
- if (value.searchSessionRef !== undefined && !isNonEmptyString(value.searchSessionRef)) errors.push(`${at}.searchSessionRef: opaque ref required`)
240
- if (value.resultCount !== undefined && !isCount(value.resultCount)) errors.push(`${at}.resultCount: non-negative integer required`)
241
- return
242
- case 'results.state':
243
- exactKeys(value, ['kind', 'matchedHotelRefs', 'visibleCount'], ['kind', 'matchedHotelRefs', 'visibleCount'], at, errors)
244
- if (!isStringArray(value.matchedHotelRefs)) errors.push(`${at}.matchedHotelRefs: opaque-ref array required`)
245
- if (!isCount(value.visibleCount)) errors.push(`${at}.visibleCount: non-negative integer required`)
246
- return
247
- case 'hotel.focus':
248
- exactKeys(value, ['kind', 'hotelRef'], ['kind', 'hotelRef'], at, errors)
249
- if (!isNonEmptyString(value.hotelRef)) errors.push(`${at}.hotelRef: opaque ref required`)
250
- return
251
- case 'hotel.selection':
252
- exactKeys(value, ['kind', 'hotelRef'], ['kind', 'hotelRef'], at, errors)
253
- if (!isNonEmptyString(value.hotelRef)) errors.push(`${at}.hotelRef: opaque ref required`)
254
- return
255
- case 'offers.state':
256
- exactKeys(value, ['kind', 'hotelRefs', 'offerRefs', 'loadedHotelCount'], ['kind', 'hotelRefs', 'offerRefs', 'loadedHotelCount'], at, errors)
257
- if (!isStringArray(value.hotelRefs)) errors.push(`${at}.hotelRefs: opaque-ref array required`)
258
- if (!isStringArray(value.offerRefs)) errors.push(`${at}.offerRefs: opaque-ref array required`)
259
- if (!isCount(value.loadedHotelCount)) errors.push(`${at}.loadedHotelCount: non-negative integer required`)
260
- return
261
- case 'offer.selection':
262
- exactKeys(value, ['kind', 'offerRef'], ['kind', 'offerRef'], at, errors)
263
- if (!isNonEmptyString(value.offerRef)) errors.push(`${at}.offerRef: opaque ref required`)
264
- return
265
- case 'offer.availability':
266
- exactKeys(value, ['kind', 'offerRef', 'verifiedOfferRef', 'available', 'changedFactRefs'], ['kind', 'offerRef', 'available', 'changedFactRefs'], at, errors)
267
- if (!isNonEmptyString(value.offerRef)) errors.push(`${at}.offerRef: opaque ref required`)
268
- if (value.verifiedOfferRef !== undefined && !isNonEmptyString(value.verifiedOfferRef)) errors.push(`${at}.verifiedOfferRef: opaque ref required`)
269
- if (typeof value.available !== 'boolean') errors.push(`${at}.available: boolean required`)
270
- if (!isStringArray(value.changedFactRefs)) errors.push(`${at}.changedFactRefs: opaque-ref array required`)
271
- return
272
- case 'checkout.handoff':
273
- exactKeys(value, ['kind', 'offerRef', 'verifiedOfferRef', 'handoffRef'], ['kind', 'offerRef', 'verifiedOfferRef', 'handoffRef'], at, errors)
274
- if (!isNonEmptyString(value.offerRef)) errors.push(`${at}.offerRef: opaque ref required`)
275
- if (!isNonEmptyString(value.verifiedOfferRef)) errors.push(`${at}.verifiedOfferRef: opaque ref required`)
276
- if (!isNonEmptyString(value.handoffRef)) errors.push(`${at}.handoffRef: opaque ref required`)
277
- return
278
- case 'order.state': {
279
- exactKeys(value, ['kind', 'orderRef', 'state'], ['kind', 'orderRef', 'state'], at, errors)
280
- const states = new Set(['pending', 'verified', 'failed', 'unknown'])
281
- if (!isNonEmptyString(value.orderRef)) errors.push(`${at}.orderRef: opaque ref required`)
282
- if (typeof value.state !== 'string' || !states.has(value.state)) errors.push(`${at}.state: unregistered order state`)
283
- return
2
+ import { Ajv2020, type ValidateFunction } from 'ajv/dist/2020.js'
3
+ import { BOOKING_READ_ACTION_KINDS, BOOKING_BLOCKER_CODES, BOOKING_GAP_CODES } from './contracts.ts'
4
+
5
+ const schema = JSON.parse(readFileSync(new URL('../../../schemas/booking.surface.schema.json', import.meta.url), 'utf8')) as Record<string, unknown>
6
+ const ajv = new Ajv2020({ allErrors: true, strict: true })
7
+ ajv.addSchema(schema)
8
+ const validate: ValidateFunction = ajv.compile(schema)
9
+ const validateActionCanonical = ajv.compile({ $ref: `${schema.$id}#/$defs/Action` })
10
+ const validateBlockerCanonical = ajv.compile({ $ref: `${schema.$id}#/$defs/Blocker` })
11
+ const validateApprovalCanonical = ajv.compile({ $ref: `${schema.$id}#/$defs/Approval` })
12
+ const validateEventCanonical = ajv.compile({ $ref: `${schema.$id}#/$defs/Event` })
13
+ const kinds = new Set<string>(BOOKING_READ_ACTION_KINDS)
14
+ const codes = new Set<string>(BOOKING_BLOCKER_CODES)
15
+ const gapCodes = new Set<string>(BOOKING_GAP_CODES)
16
+ const safeOpaque = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value)
17
+ const safeRequestKey = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{7,127}$/.test(value) && !secret.test(value)
18
+ const secret = /(?:Bearer\s+\S|\bsk-(?!ill(?:s)?\b)[A-Za-z0-9_-]{12,}|\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i
19
+ // `.example` is reserved for documentation, so these identifiers cannot be a
20
+ // real mailbox. Do not allowlist lookalikes on routable domains.
21
+ const technicalEmail = /^(?:schema|version)@[a-z0-9-]+\.example$/i
22
+ const currencyAmount = /\b(?:[A-Z]{3}\s*\d+(?:\.\d+)?|\d+(?:\.\d+)?\s*[A-Z]{3})\b/
23
+ const rfc3339DateTime = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/
24
+
25
+ export function isBookingDateTime(value: unknown): value is string {
26
+ if (typeof value !== 'string') return false
27
+ const match = rfc3339DateTime.exec(value)
28
+ if (!match) return false
29
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText] = match
30
+ const year = Number(yearText)
31
+ const month = Number(monthText)
32
+ const day = Number(dayText)
33
+ const hour = Number(hourText)
34
+ const minute = Number(minuteText)
35
+ const second = Number(secondText)
36
+ const offsetHour = match[9] === undefined ? 0 : Number(match[9])
37
+ const offsetMinute = match[10] === undefined ? 0 : Number(match[10])
38
+ if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) return false
39
+ const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate()
40
+ if (day < 1 || day > daysInMonth) return false
41
+ return !Number.isNaN(Date.parse(value))
42
+ }
43
+
44
+ function normalizedWords(value: string): string {
45
+ return value.normalize('NFKC').replace(/[^\p{L}\p{N}.]+/gu, ' ').trim()
46
+ }
47
+
48
+ function hasSupplierCost(value: string): boolean {
49
+ const normalized = normalizedWords(value)
50
+ const lower = normalized.toLowerCase()
51
+ return (
52
+ /\b(?:supplier|vendor|internal)\b/.test(lower) &&
53
+ /\b(?:cost|rate|price)\b/.test(lower) &&
54
+ currencyAmount.test(normalized.toUpperCase())
55
+ )
56
+ }
57
+
58
+ function hasInternationalPhone(value: string): boolean {
59
+ const normalized = value.normalize('NFKC')
60
+ for (const plusIndex of normalized.split('').flatMap((char, index) => char === '+' ? [index] : [])) {
61
+ let digits = ''
62
+ for (const char of normalized.slice(plusIndex + 1, plusIndex + 48)) {
63
+ if (/\d/u.test(char)) digits += char
64
+ else if (/\p{L}|@/u.test(char)) break
65
+ if (digits.length > 15) break
284
66
  }
285
- case 'gap':
286
- exactKeys(value, ['kind', 'code', 'factRefs'], ['kind', 'code', 'factRefs'], at, errors)
287
- if (!isSafeCode(value.code)) errors.push(`${at}.code: safe identifier required`)
288
- if (!isStringArray(value.factRefs)) errors.push(`${at}.factRefs: opaque-ref array required`)
289
- return
290
- default:
291
- errors.push(`${at}.kind: unregistered observation kind`)
292
- }
293
- }
294
-
295
- function validateResultContract(value: unknown, at: string, errors: string[]): void {
296
- if (!isRecord(value)) {
297
- errors.push(`${at}: object required`)
298
- return
67
+ if (/^[1-9]\d{7,14}$/.test(digits)) return true
299
68
  }
300
- exactKeys(value, ['outcome', 'requestedCount', 'actualCount', 'hardCriteriaMet', 'factRefs', 'gapCodes'], ['outcome', 'hardCriteriaMet', 'factRefs', 'gapCodes'], at, errors)
301
- const outcomes = new Set(['complete', 'partial', 'empty'])
302
- if (typeof value.outcome !== 'string' || !outcomes.has(value.outcome)) errors.push(`${at}.outcome: complete, partial, or empty required`)
303
- if (value.requestedCount !== undefined && !isCount(value.requestedCount)) errors.push(`${at}.requestedCount: non-negative integer required`)
304
- if (value.actualCount !== undefined && !isCount(value.actualCount)) errors.push(`${at}.actualCount: non-negative integer required`)
305
- if (typeof value.hardCriteriaMet !== 'boolean') errors.push(`${at}.hardCriteriaMet: boolean required`)
306
- if (!isStringArray(value.factRefs)) errors.push(`${at}.factRefs: opaque-ref array required`)
307
- if (!Array.isArray(value.gapCodes) || !value.gapCodes.every(isSafeCode)) errors.push(`${at}.gapCodes: safe identifier array required`)
308
- }
309
-
310
- export function validateActionReceiptV1(value: unknown): BookingSurfaceValidationResult {
311
- const errors = collectReceiptErrors(value, '$')
312
- if (receiptContainsUnsafeOpaqueText(value)) errors.push('$: receipt contains unsafe non-opaque text')
313
- if (!validateCanonicalReceipt(value)) {
314
- for (const error of validateCanonicalReceipt.errors ?? []) errors.push(canonicalError(error, '$'))
315
- }
316
- return errors.length === 0 ? { ok: true } : { ok: false, errors }
317
- }
318
-
319
- export function validateBookingCopilotIngressTurnV1(value: unknown): BookingSurfaceValidationResult {
320
- const errors: string[] = []
321
- if (!isRecord(value)) return { ok: false, errors: ['$: object required'] }
322
- exactKeys(value, [
323
- 'schemaVersion', 'kind', 'taskId', 'contextRef', 'surfaceHint', 'workspace', 'request',
324
- ], ['schemaVersion', 'kind', 'surfaceHint', 'workspace', 'request'], '$', errors)
325
- if (value.schemaVersion !== 'booking.surface.v1') errors.push('$.schemaVersion: booking.surface.v1 required')
326
- if (value.kind !== 'user.turn.ingress') errors.push('$.kind: user.turn.ingress required')
327
- if (value.contextRef !== undefined && value.contextRef !== null) errors.push('$.contextRef: bootstrap may only omit it or send null')
328
- if (typeof value.surfaceHint !== 'string' || !SURFACES.has(value.surfaceHint)) errors.push('$.surfaceHint: unsupported surface hint')
329
- validateIngressWorkspace(value.workspace, '$.workspace', errors)
330
- if (!isRecord(value.request)) {
331
- errors.push('$.request: object required')
332
- } else {
333
- exactKeys(value.request, ['text'], ['text'], '$.request', errors)
334
- if (!isNonEmptyString(value.request.text)) errors.push('$.request.text: non-empty string required')
335
- }
336
- appendCanonicalErrors(validateCanonicalIngressTurn, value, '$', errors)
337
- return errors.length === 0 ? { ok: true } : { ok: false, errors }
338
- }
339
-
340
- export function validateBookingCopilotTurnV1(value: unknown): BookingSurfaceValidationResult {
341
- const errors: string[] = []
342
- if (!isRecord(value)) return { ok: false, errors: ['$: object required'] }
343
- if (value.kind === 'user.turn') {
344
- exactKeys(value, ['schemaVersion', 'kind', 'taskId', 'workspace', 'request'], ['schemaVersion', 'kind', 'workspace', 'request'], '$', errors)
345
- if (!isRecord(value.request)) errors.push('$.request: object required')
346
- else {
347
- exactKeys(value.request, ['text'], ['text'], '$.request', errors)
348
- if (!isNonEmptyString(value.request.text)) errors.push('$.request.text: non-empty string required')
349
- }
350
- } else if (value.kind === 'action.receipt.continuation') {
351
- exactKeys(value, ['schemaVersion', 'kind', 'taskId', 'workspace', 'receipt'], ['schemaVersion', 'kind', 'taskId', 'workspace', 'receipt'], '$', errors)
352
- if (!isNonEmptyString(value.taskId)) errors.push('$.taskId: opaque ref required')
353
- errors.push(...collectReceiptErrors(value.receipt, '$.receipt'))
354
- const receiptValidation = validateActionReceiptV1(value.receipt)
355
- if (!receiptValidation.ok) errors.push(...receiptValidation.errors.map((error) => `$.receipt${error.slice(1)}`))
356
- } else {
357
- errors.push('$.kind: user.turn or action.receipt.continuation required')
358
- }
359
- if (value.schemaVersion !== 'booking.surface.v1') errors.push('$.schemaVersion: booking.surface.v1 required')
360
- validateWorkspace(value.workspace, '$.workspace', errors)
361
- if (value.kind === 'action.receipt.continuation' && isRecord(value.workspace) && isRecord(value.receipt)) {
362
- if (value.receipt.contextRef !== value.workspace.contextRef) errors.push('$.receipt.contextRef: must equal workspace.contextRef')
363
- if (value.receipt.revision !== value.workspace.revision) errors.push('$.receipt.revision: must equal workspace.revision')
364
- }
365
- appendCanonicalErrors(validateCanonicalTurn, value, '$', errors)
366
- return errors.length === 0 ? { ok: true } : { ok: false, errors }
69
+ return false
367
70
  }
368
71
 
369
- export function validateBookingSurfaceEventV1(value: unknown): BookingSurfaceValidationResult {
370
- const errors: string[] = []
371
- if (!isRecord(value)) return { ok: false, errors: ['$: typed event object required'] }
372
- const common = ['schemaVersion', 'eventId', 'taskId', 'contextRef', 'sequence', 'emittedAt', 'kind'] as const
373
- const branchKeys: Record<string, string> = {
374
- status: 'status',
375
- question: 'question',
376
- operation: 'action',
377
- explanation: 'explanation',
378
- terminal: 'terminal',
379
- error: 'error',
380
- }
381
- const branchKey = typeof value.kind === 'string' ? branchKeys[value.kind] : undefined
382
- exactKeys(value, [...common, ...(branchKey ? [branchKey] : [])], [...common, ...(branchKey ? [branchKey] : [])], '$', errors)
383
- if (value.schemaVersion !== 'booking.surface.v1') errors.push('$.schemaVersion: booking.surface.v1 required')
384
- if (!isNonEmptyString(value.eventId)) errors.push('$.eventId: opaque ref required')
385
- if (!isNonEmptyString(value.taskId)) errors.push('$.taskId: opaque ref required')
386
- if (!isNonEmptyString(value.contextRef)) errors.push('$.contextRef: server-minted opaque ref required')
387
- if (!Number.isInteger(value.sequence) || Number(value.sequence) < 1) errors.push('$.sequence: positive integer required')
388
- if (!isNonEmptyString(value.emittedAt)) errors.push('$.emittedAt: timestamp required')
389
- if (typeof value.kind !== 'string' || !EVENT_KINDS.has(value.kind)) errors.push('$.kind: unregistered event kind')
390
- if (value.kind === 'status') {
391
- const statuses = new Set(['submitted', 'working', 'waiting_receipt', 'input_required'])
392
- if (typeof value.status !== 'string' || !statuses.has(value.status)) errors.push('$.status: unregistered task status')
393
- }
394
- if (value.kind === 'question') {
395
- if (!isRecord(value.question)) {
396
- errors.push('$.question: typed object required')
397
- } else {
398
- exactKeys(value.question, ['questionId', 'prompt', 'missingFields'], ['questionId', 'prompt', 'missingFields'], '$.question', errors)
399
- if (!isNonEmptyString(value.question.questionId)) errors.push('$.question.questionId: opaque ref required')
400
- if (!isNonEmptyString(value.question.prompt)) errors.push('$.question.prompt: non-empty string required')
401
- if (!isStringArray(value.question.missingFields)) errors.push('$.question.missingFields: string array required')
402
- }
403
- }
404
- if (value.kind === 'operation') errors.push(...collectBookingReadActionErrors(value.action, '$.action'))
405
- if (value.kind === 'explanation') {
406
- if (!isRecord(value.explanation)) {
407
- errors.push('$.explanation: typed object required')
408
- } else {
409
- exactKeys(value.explanation, ['text', 'factRefs'], ['text', 'factRefs'], '$.explanation', errors)
410
- if (!isNonEmptyString(value.explanation.text)) errors.push('$.explanation.text: non-empty string required')
411
- if (!isStringArray(value.explanation.factRefs)) errors.push('$.explanation.factRefs: opaque-ref array required')
72
+ function hasPaymentCard(value: string): boolean {
73
+ const normalized = value.normalize('NFKC')
74
+ const candidates = normalized.match(/(?:\d[ -]?){12,18}\d/g) ?? []
75
+ const explicitlyLabeled = /\b(?:card|payment|visa|mastercard)\b/i.test(normalized)
76
+ return candidates.some((candidate) => (explicitlyLabeled || /[ -]/.test(candidate)) && luhn(candidate))
77
+ }
78
+
79
+ function hasSensitiveText(value: string, opts: { allowOpaqueNumericId?: boolean } = {}): boolean {
80
+ const normalized = value.normalize('NFKC')
81
+ if (normalized.includes('@') && !technicalEmail.test(normalized.trim())) return true
82
+ return secret.test(normalized) || hasInternationalPhone(normalized) || hasSupplierCost(normalized) || (!opts.allowOpaqueNumericId && hasPaymentCard(normalized))
83
+ }
84
+ function luhn(value: string): boolean { const digits = value.replaceAll(/\D/g, ''); if (digits.length < 13 || digits.length > 19) return false; let sum = 0; for (let i = digits.length - 1, parity = 0; i >= 0; i--, parity++) { let n = Number(digits[i]); if (parity % 2 === 1) { n *= 2; if (n > 9) n -= 9 } sum += n } return sum % 10 === 0 }
85
+ const opaqueFieldNames = new Set([
86
+ 'eventId', 'taskId', 'contextRef', 'actionId', 'hotelRef', 'offerRef', 'offerVersionRef', 'checkedOfferVersionRef', 'currentOfferVersionRef', 'verifiedOfferRef',
87
+ 'orderRef', 'searchSessionRef', 'handoffRef', 'questionId', 'blockerId', 'approvalId',
88
+ 'sourceActionId', 'targetActionId', 'sourceTurnId', 'turnId', 'requestKey', 'taskHandle',
89
+ 'nonce', 'deliveryNonce', 'optionDigest', 'sourceReceiptDigest', 'valueDigest',
90
+ ])
91
+ function isOpaqueFieldPath(path: string): boolean {
92
+ const clean = path.replace(/\[\]$/g, '')
93
+ const field = clean.split('.').at(-1)
94
+ return field ? opaqueFieldNames.has(field) || field.endsWith('Ref') || field.endsWith('Refs') : false
95
+ }
96
+ const scan = (v: unknown, path = '$'): boolean => typeof v === 'string'
97
+ ? hasSensitiveText(v, { allowOpaqueNumericId: isOpaqueFieldPath(path) })
98
+ : Array.isArray(v)
99
+ ? v.some((child) => scan(child, `${path}[]`))
100
+ : !!v && typeof v === 'object'
101
+ ? Object.entries(v).some(([k,x]) => /cost|price/i.test(k) ? /supplier|internal|wholesale/i.test(String(x)) : scan(x, `${path}.${k}`))
102
+ : false
103
+ export function isSafeBookingRequestKey(value: unknown): value is string { return safeRequestKey(value) }
104
+ export type BookingSurfaceValidationResult = { ok: true } | { ok: false; errors: string[] }
105
+ function errors(v: ValidateFunction): string[] { return (v.errors ?? []).map(e => `${e.instancePath || '$'}: ${e.message ?? e.keyword}`) }
106
+ function workspaceErrors(workspace: Record<string, any> | undefined, path: string): string[] {
107
+ const e: string[] = []
108
+ if (!workspace || typeof workspace !== 'object' || Array.isArray(workspace)) return e
109
+ const loadedOffers = Array.isArray(workspace.loadedOffers) ? workspace.loadedOffers : []
110
+ const offerRefs = loadedOffers.map((offer: any) => offer?.offerRef).filter((ref: unknown): ref is string => typeof ref === 'string')
111
+ const offerVersionRefs = loadedOffers.map((offer: any) => offer?.offerVersionRef).filter((ref: unknown): ref is string => typeof ref === 'string')
112
+ if (new Set(offerRefs).size !== offerRefs.length) e.push(`${path}.loadedOffers: duplicate offerRef`)
113
+ if (new Set(offerVersionRefs).size !== offerVersionRefs.length) e.push(`${path}.loadedOffers: duplicate offerVersionRef`)
114
+ const verified = workspace.verifiedOffer
115
+ if (verified !== undefined) {
116
+ if (!loadedOffers.some((offer: any) => offer?.offerRef === verified.offerRef && offer?.offerVersionRef === verified.offerVersionRef)) e.push(`${path}.verifiedOffer: loaded offer version required`)
117
+ if (!safeOpaque(verified.verifiedOfferRef) || !isBookingDateTime(verified.expiresAt)) e.push(`${path}.verifiedOffer: safe ref and parseable expiry required`)
118
+ }
119
+ return e
120
+ }
121
+ function cross(value: unknown): string[] {
122
+ const e: string[] = []
123
+ if (scan(value)) e.push('$: unsafe PII/payment/supplier-cost/credential text')
124
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return e
125
+ const x = value as Record<string, any>
126
+ const semantic = x.kind === 'action.receipt.continuation' ? x.receipt : x
127
+ if (x.workspace && x.kind !== 'user.turn.ingress') e.push(...workspaceErrors(x.workspace, '$.workspace'))
128
+ if (x.schemaVersion === 'booking.surface' && Array.isArray(x.loadedOffers)) e.push(...workspaceErrors(x, '$'))
129
+ if (x.kind === 'user.turn.ingress') {
130
+ if (!safeRequestKey(x.requestKey)) e.push('$.requestKey: safe opaque request key required')
131
+ const workspace = x.workspace as Record<string, any> | undefined
132
+ e.push(...workspaceErrors(workspace, '$.workspace'))
133
+ const visibleHotels = Array.isArray(workspace?.visibleHotels) ? workspace.visibleHotels : []
134
+ const loadedOffers = Array.isArray(workspace?.loadedOffers) ? workspace.loadedOffers : []
135
+ if (workspace?.focusedHotelRef !== undefined && !visibleHotels.some((hotel: any) => hotel?.hotelRef === workspace.focusedHotelRef)) e.push('$.workspace.focusedHotelRef: visible hotel required')
136
+ const selectedOffer = workspace?.selectedOfferRef === undefined ? undefined : loadedOffers.find((offer: any) => offer?.offerRef === workspace.selectedOfferRef)
137
+ if (workspace?.selectedOfferRef !== undefined && !selectedOffer) e.push('$.workspace.selectedOfferRef: loaded offer required')
138
+ if (selectedOffer && !visibleHotels.some((hotel: any) => hotel?.hotelRef === selectedOffer.hotelRef)) e.push('$.workspace.selectedOfferRef: offer hotel must be visible')
139
+ }
140
+ if (semantic !== x && semantic?.resultContract?.hardCriteriaMet === true && semantic.resultContract.blockers?.length) e.push('$.receipt.resultContract: hardCriteriaMet cannot coexist with blockers')
141
+ if (kinds.has(x.kind)) {
142
+ const allowed: Record<string, string[]> = {
143
+ 'search.patch': ['patch'], 'search.run': [], 'results.view.patch': ['patch'],
144
+ 'hotel.focus': ['hotelRef'], 'hotel.select': ['hotelRef'],
145
+ 'offers.query': ['hotelRefs', 'criteria'], 'offers.view.patch': ['hotelRef', 'criteria'],
146
+ 'offers.compare': ['offerRefs', 'requestedCount'], 'offer.select': ['offerRef', 'offerVersionRef'],
147
+ 'offer.check': ['offerRef', 'offerVersionRef'], 'checkout.prepare': ['offerRef', 'offerVersionRef', 'verifiedOfferRef'], 'order.observe': ['orderRef'],
412
148
  }
413
- }
414
- if (value.kind === 'terminal') {
415
- if (!isRecord(value.terminal)) {
416
- errors.push('$.terminal: typed object required')
417
- } else {
418
- exactKeys(value.terminal, ['status', 'summary', 'factRefs'], ['status', 'summary', 'factRefs'], '$.terminal', errors)
419
- const statuses = new Set(['completed', 'stopped'])
420
- if (typeof value.terminal.status !== 'string' || !statuses.has(value.terminal.status)) errors.push('$.terminal.status: completed or stopped required')
421
- if (!isNonEmptyString(value.terminal.summary)) errors.push('$.terminal.summary: non-empty string required')
422
- if (!isStringArray(value.terminal.factRefs)) errors.push('$.terminal.factRefs: opaque-ref array required')
149
+ const input = x.input
150
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(k => !allowed[x.kind].includes(k))) e.push('$.input: closed kind-specific input required')
151
+ }
152
+ if (x.kind === 'action.receipt' && x.resultContract?.hardCriteriaMet === true && x.resultContract.blockers?.length) e.push('$.resultContract: hardCriteriaMet cannot coexist with blockers')
153
+ if (x.kind === 'user.turn' && Array.isArray(x.request?.approval)) e.push('$.request.approval: at most one approval')
154
+ if (x.kind === 'action.receipt' || x.kind === 'action.receipt.continuation') {
155
+ const r = x.kind === 'action.receipt.continuation' ? x.receipt : x
156
+ if (r?.resultContract?.gapCodes?.some((code: unknown) => !gapCodes.has(String(code)))) e.push('$.resultContract.gapCodes: unregistered gap code')
157
+ const observation = r?.observation
158
+ if (observation?.kind === 'offer.availability') {
159
+ if (r.status === 'applied' && (observation.available !== true || !observation.verifiedOfferRef || observation.currentOfferVersionRef !== observation.checkedOfferVersionRef || observation.changedFactRefs?.length || observation.gapCodes?.length)) e.push('$.observation: applied availability requires unchanged checked/current version, verified ref, and no gaps')
160
+ if (r.status === 'applied' && (r.resultContract?.outcome !== 'complete' || r.resultContract?.hardCriteriaMet !== true || r.resultContract?.gapCodes?.length || r.resultContract?.blockers?.length)) e.push('$.resultContract: applied availability requires complete hard-criteria result without gaps or blockers')
161
+ if (r.status === 'changed' && (observation.available !== true || !observation.currentOfferVersionRef || observation.currentOfferVersionRef === observation.checkedOfferVersionRef || !observation.changedFactRefs?.length || observation.verifiedOfferRef)) e.push('$.observation: changed availability requires available replacement current version without verified ref')
162
+ if ((r.status === 'unavailable' || r.status === 'no_match') && (observation.available !== false || observation.currentOfferVersionRef || observation.verifiedOfferRef)) e.push('$.observation: negative availability must not expose current or verified version')
163
+ if (r.status !== 'applied' && observation.verifiedOfferRef) e.push('$.observation: only applied availability may expose verified ref')
423
164
  }
424
165
  }
425
- if (value.kind === 'error') {
426
- if (!isRecord(value.error)) {
427
- errors.push('$.error: typed object required')
428
- } else {
429
- exactKeys(value.error, ['code', 'message', 'retryable'], ['code', 'message', 'retryable'], '$.error', errors)
430
- if (!isNonEmptyString(value.error.code)) errors.push('$.error.code: non-empty string required')
431
- if (!isNonEmptyString(value.error.message)) errors.push('$.error.message: non-empty string required')
432
- if (typeof value.error.retryable !== 'boolean') errors.push('$.error.retryable: boolean required')
166
+ const blocker = x.blocker ?? x.question?.blocker
167
+ if (blocker && !/^(searchDraft|results|offers|checkout)(\.[A-Za-z][A-Za-z0-9_]*){0,3}$/.test(blocker.criterionPath ?? '')) e.push('$.blocker.criterionPath: closed criterion path required')
168
+ if (blocker?.evidence?.factRefs && new Set(blocker.evidence.factRefs).size !== blocker.evidence.factRefs.length) e.push('$.blocker.evidence.factRefs: unique refs required')
169
+ if (x.kind === 'question' && blocker && Array.isArray(x.question?.approvalOptions)) {
170
+ for (const [i, option] of x.question.approvalOptions.entries()) {
171
+ const result = validateApprovalAgainstBlocker(option?.approval, blocker)
172
+ if (!result.ok) e.push(...result.errors.map(error => `$.question.approvalOptions[${i}].${error.slice(2)}`))
173
+ if (option?.approval?.taskId !== x.taskId) e.push(`$.question.approvalOptions[${i}].approval.taskId: must match event`)
174
+ if (option?.approval?.contextRef !== x.contextRef) e.push(`$.question.approvalOptions[${i}].approval.contextRef: must match event`)
433
175
  }
434
176
  }
435
- return errors.length === 0 ? { ok: true } : { ok: false, errors }
436
- }
437
-
438
- /**
439
- * The only executable event seam. It performs no text parsing: non-operation
440
- * events always return null even when their prose happens to look like JSON.
441
- */
442
- export function bookingOperationFromEvent(value: unknown): BookingReadActionV1 | null {
443
- if (!isRecord(value) || value.kind !== 'operation') return null
444
- if (!validateBookingSurfaceEventV1(value).ok) return null
445
- return value.action as BookingReadActionV1
177
+ if (kinds.has(String(x.kind)) && x.relaxationApprovalRef) {
178
+ const ref = x.relaxationApprovalRef
179
+ if (ref.contextRef !== x.contextRef) e.push('$.relaxationApprovalRef.contextRef: must match action')
180
+ if (ref.sourceRevision !== x.expectedRevision) e.push('$.relaxationApprovalRef.sourceRevision: must equal action revision')
181
+ if (ref.targetActionKind !== x.kind) e.push('$.relaxationApprovalRef.targetActionKind: must match action kind')
182
+ if (ref.targetActionId !== x.actionId) e.push('$.relaxationApprovalRef.targetActionId: must match action id')
183
+ // Expiry and one-time consumption are runtime concerns; validation checks only structure/binding.
184
+ if (!safeOpaque(ref.nonce) || !isBookingDateTime(ref.expiresAt)) e.push('$.relaxationApprovalRef: invalid expiry or nonce')
185
+ }
186
+ if (x.kind === 'question' && (!Array.isArray(x.question?.approvalOptions) || x.question.approvalOptions.length < 1 || x.question.approvalOptions.length > 2)) e.push('$.question.approvalOptions: one or two options required')
187
+ return e
188
+ }
189
+ export function validateBookingSurface(value: unknown): BookingSurfaceValidationResult { const ok = validate(value); const e = ok ? [] : errors(validate); e.push(...cross(value)); return e.length ? { ok: false, errors: e } : { ok: true } }
190
+ function fromCanonical(fn: ValidateFunction, value: unknown): BookingSurfaceValidationResult { const ok = fn(value); const e = ok ? [] : errors(fn); e.push(...cross(value)); return e.length ? { ok: false, errors: e } : { ok: true } }
191
+ export function validateBookingReadAction(value: unknown): BookingSurfaceValidationResult { return fromCanonical(validateActionCanonical, value) }
192
+ export function validateCriterionBlocker(value: unknown): BookingSurfaceValidationResult { return fromCanonical(validateBlockerCanonical, value) }
193
+ export function validateRelaxationApproval(value: unknown): BookingSurfaceValidationResult { return fromCanonical(validateApprovalCanonical, value) }
194
+ export function validateBookingSurfaceEvent(value: unknown): BookingSurfaceValidationResult { return fromCanonical(validateEventCanonical, value) }
195
+ export function validateApprovalAgainstBlocker(approval: unknown, blocker: unknown): BookingSurfaceValidationResult {
196
+ const a = approval as any
197
+ const b = blocker as any
198
+ const blockerResult = validateCriterionBlocker(b)
199
+ if (!blockerResult.ok) return blockerResult
200
+ const approvalResult = validateRelaxationApproval(a)
201
+ if (!approvalResult.ok) return approvalResult
202
+ const fields = ['blockerId', 'sourceActionId', 'sourceReceiptDigest', 'scope', 'code', 'criterionPath', 'valueDigest']
203
+ const mismatches = fields.filter(field => a[field] !== b?.[field])
204
+ return mismatches.length ? { ok: false, errors: mismatches.map(field => `$.${field}: approval does not match blocker`) } : { ok: true }
446
205
  }
447
-
448
- // Keep the public predicates assignable to their declared wire types.
449
- void (null as unknown as BookingCopilotIngressTurnV1)
450
- void (null as unknown as BookingCopilotTurnV1)
451
- void (null as unknown as BookingReadActionV1)
452
- void (null as unknown as ActionReceiptV1)
453
- void (null as unknown as BookingSurfaceEventV1)