@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,809 +1,319 @@
1
- import { BOOKING_READ_ACTION_KINDS, BOOKING_RECEIPT_STATUSES, BOOKING_SURFACES, BOOKING_SURFACE_EVENT_KINDS } from './contracts.js';
2
- import { Ajv2020 } from 'ajv/dist/2020.js';
3
1
  import { readFileSync } from 'node:fs';
4
- const CANONICAL_SCHEMA = JSON.parse(readFileSync(new URL('../../../schemas/booking.surface.v1.schema.json', import.meta.url), 'utf8'));
5
- const CANONICAL_SCHEMA_ID = 'https://gotry.dev/schemas/booking.surface.v1.schema.json';
6
- const canonicalAjv = new Ajv2020({
2
+ import { Ajv2020 } from 'ajv/dist/2020.js';
3
+ import { BOOKING_READ_ACTION_KINDS, BOOKING_BLOCKER_CODES, BOOKING_GAP_CODES } from './contracts.js';
4
+ const schema = JSON.parse(readFileSync(new URL('../../../schemas/booking.surface.schema.json', import.meta.url), 'utf8'));
5
+ const ajv = new Ajv2020({
7
6
  allErrors: true,
8
7
  strict: true
9
8
  });
10
- canonicalAjv.addSchema(CANONICAL_SCHEMA);
11
- const validateCanonicalAction = canonicalAjv.compile({
12
- $ref: `${CANONICAL_SCHEMA_ID}#/$defs/BookingReadActionV1`
13
- });
14
- const validateCanonicalReceipt = canonicalAjv.compile({
15
- $ref: `${CANONICAL_SCHEMA_ID}#/$defs/ActionReceiptV1`
9
+ ajv.addSchema(schema);
10
+ const validate = ajv.compile(schema);
11
+ const validateActionCanonical = ajv.compile({
12
+ $ref: `${schema.$id}#/$defs/Action`
16
13
  });
17
- const validateCanonicalWorkspace = canonicalAjv.compile({
18
- $ref: `${CANONICAL_SCHEMA_ID}#/$defs/BookingWorkspaceSnapshotV1`
14
+ const validateBlockerCanonical = ajv.compile({
15
+ $ref: `${schema.$id}#/$defs/Blocker`
19
16
  });
20
- const validateCanonicalIngressTurn = canonicalAjv.compile({
21
- $ref: `${CANONICAL_SCHEMA_ID}#/$defs/BookingCopilotIngressTurnV1`
17
+ const validateApprovalCanonical = ajv.compile({
18
+ $ref: `${schema.$id}#/$defs/Approval`
22
19
  });
23
- const validateCanonicalTurn = canonicalAjv.compile({
24
- $ref: `${CANONICAL_SCHEMA_ID}#/$defs/BookingCopilotTurnV1`
20
+ const validateEventCanonical = ajv.compile({
21
+ $ref: `${schema.$id}#/$defs/Event`
25
22
  });
26
- const ACTION_KINDS = new Set(BOOKING_READ_ACTION_KINDS);
27
- const RECEIPT_STATUSES = new Set(BOOKING_RECEIPT_STATUSES);
28
- const SURFACES = new Set(BOOKING_SURFACES);
29
- const EVENT_KINDS = new Set(BOOKING_SURFACE_EVENT_KINDS);
30
- function isRecord(value) {
31
- return typeof value === 'object' && value !== null && !Array.isArray(value);
32
- }
33
- function isNonEmptyString(value) {
34
- return typeof value === 'string' && value.length > 0;
35
- }
36
- function isRevision(value) {
37
- return Number.isInteger(value) && Number(value) >= 0;
23
+ const kinds = new Set(BOOKING_READ_ACTION_KINDS);
24
+ const codes = new Set(BOOKING_BLOCKER_CODES);
25
+ const gapCodes = new Set(BOOKING_GAP_CODES);
26
+ const safeOpaque = (value)=>typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value);
27
+ const safeRequestKey = (value)=>typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{7,127}$/.test(value) && !secret.test(value);
28
+ 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;
29
+ const technicalEmail = /^(?:schema|version)@[a-z0-9-]+\.example$/i;
30
+ const currencyAmount = /\b(?:[A-Z]{3}\s*\d+(?:\.\d+)?|\d+(?:\.\d+)?\s*[A-Z]{3})\b/;
31
+ const rfc3339DateTime = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/;
32
+ export function isBookingDateTime(value) {
33
+ if (typeof value !== 'string') return false;
34
+ const match = rfc3339DateTime.exec(value);
35
+ if (!match) return false;
36
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText] = match;
37
+ const year = Number(yearText);
38
+ const month = Number(monthText);
39
+ const day = Number(dayText);
40
+ const hour = Number(hourText);
41
+ const minute = Number(minuteText);
42
+ const second = Number(secondText);
43
+ const offsetHour = match[9] === undefined ? 0 : Number(match[9]);
44
+ const offsetMinute = match[10] === undefined ? 0 : Number(match[10]);
45
+ if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) return false;
46
+ const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
47
+ if (day < 1 || day > daysInMonth) return false;
48
+ return !Number.isNaN(Date.parse(value));
38
49
  }
39
- function isStringArray(value) {
40
- return Array.isArray(value) && value.every(isNonEmptyString);
50
+ function normalizedWords(value) {
51
+ return value.normalize('NFKC').replace(/[^\p{L}\p{N}.]+/gu, ' ').trim();
41
52
  }
42
- function isCount(value) {
43
- return Number.isInteger(value) && Number(value) >= 0;
53
+ function hasSupplierCost(value) {
54
+ const normalized = normalizedWords(value);
55
+ const lower = normalized.toLowerCase();
56
+ return /\b(?:supplier|vendor|internal)\b/.test(lower) && /\b(?:cost|rate|price)\b/.test(lower) && currencyAmount.test(normalized.toUpperCase());
44
57
  }
45
- function isSafeCode(value) {
46
- return typeof value === 'string' && /^[a-z][a-z0-9._-]*$/.test(value);
47
- }
48
- function receiptContainsUnsafeOpaqueText(value) {
49
- 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);
50
- if (Array.isArray(value)) return value.some(receiptContainsUnsafeOpaqueText);
51
- if (isRecord(value)) return Object.values(value).some(receiptContainsUnsafeOpaqueText);
58
+ function hasInternationalPhone(value) {
59
+ const normalized = value.normalize('NFKC');
60
+ for (const plusIndex of normalized.split('').flatMap((char, index)=>char === '+' ? [
61
+ index
62
+ ] : [])){
63
+ let digits = '';
64
+ for (const char of normalized.slice(plusIndex + 1, plusIndex + 48)){
65
+ if (/\d/u.test(char)) digits += char;
66
+ else if (/\p{L}|@/u.test(char)) break;
67
+ if (digits.length > 15) break;
68
+ }
69
+ if (/^[1-9]\d{7,14}$/.test(digits)) return true;
70
+ }
52
71
  return false;
53
72
  }
54
- function exactKeys(value, allowed, required, at, errors) {
55
- const allow = new Set(allowed);
56
- for (const key of Object.keys(value))if (!allow.has(key)) errors.push(`${at}.${key}: additional property is forbidden`);
57
- for (const key of required)if (!Object.hasOwn(value, key)) errors.push(`${at}.${key}: required`);
58
- }
59
- function validateIngressWorkspace(value, at, errors) {
60
- if (!isRecord(value)) {
61
- errors.push(`${at}: object required`);
62
- return false;
63
- }
64
- exactKeys(value, [
65
- 'schemaVersion',
66
- 'revision',
67
- 'locale',
68
- 'currency',
69
- 'searchDraft',
70
- 'results',
71
- 'visibleHotels',
72
- 'loadedOffers',
73
- 'focusedHotelRef',
74
- 'shortlistedOfferRefs',
75
- 'selectedOfferRef',
76
- 'verifiedOfferRef'
77
- ], [
78
- 'schemaVersion',
79
- 'revision',
80
- 'locale',
81
- 'currency',
82
- 'searchDraft',
83
- 'results',
84
- 'visibleHotels',
85
- 'loadedOffers',
86
- 'shortlistedOfferRefs'
87
- ], at, errors);
88
- if (value.schemaVersion !== 'booking.surface.v1') errors.push(`${at}.schemaVersion: booking.surface.v1 required`);
89
- if (!isRevision(value.revision)) errors.push(`${at}.revision: non-negative integer required`);
90
- if (!isNonEmptyString(value.locale)) errors.push(`${at}.locale: non-empty string required`);
91
- if (typeof value.currency !== 'string' || !/^[A-Z]{3}$/.test(value.currency)) errors.push(`${at}.currency: ISO-4217 code required`);
92
- if (!isRecord(value.searchDraft)) errors.push(`${at}.searchDraft: object required`);
93
- if (!isRecord(value.results)) errors.push(`${at}.results: object required`);
94
- if (!Array.isArray(value.visibleHotels)) errors.push(`${at}.visibleHotels: array required`);
95
- if (!Array.isArray(value.loadedOffers)) errors.push(`${at}.loadedOffers: array required`);
96
- if (!isStringArray(value.shortlistedOfferRefs)) errors.push(`${at}.shortlistedOfferRefs: opaque-ref array required`);
97
- return errors.length === 0;
73
+ function hasPaymentCard(value) {
74
+ const normalized = value.normalize('NFKC');
75
+ const candidates = normalized.match(/(?:\d[ -]?){12,18}\d/g) ?? [];
76
+ const explicitlyLabeled = /\b(?:card|payment|visa|mastercard)\b/i.test(normalized);
77
+ return candidates.some((candidate)=>(explicitlyLabeled || /[ -]/.test(candidate)) && luhn(candidate));
98
78
  }
99
- function validateWorkspace(value, at, errors) {
100
- if (!isRecord(value)) {
101
- errors.push(`${at}: object required`);
102
- return false;
103
- }
104
- const { contextRef: _contextRef, surface: _surface, capabilities: _capabilities, ...ingressShape } = value;
105
- validateIngressWorkspace(ingressShape, at, errors);
106
- const allowed = [
107
- 'schemaVersion',
108
- 'contextRef',
109
- 'surface',
110
- 'revision',
111
- 'locale',
112
- 'currency',
113
- 'searchDraft',
114
- 'results',
115
- 'visibleHotels',
116
- 'loadedOffers',
117
- 'focusedHotelRef',
118
- 'shortlistedOfferRefs',
119
- 'selectedOfferRef',
120
- 'verifiedOfferRef',
121
- 'capabilities'
122
- ];
123
- exactKeys(value, allowed, [
124
- 'schemaVersion',
125
- 'contextRef',
126
- 'surface',
127
- 'revision',
128
- 'locale',
129
- 'currency',
130
- 'searchDraft',
131
- 'results',
132
- 'visibleHotels',
133
- 'loadedOffers',
134
- 'shortlistedOfferRefs',
135
- 'capabilities'
136
- ], at, errors);
137
- if (!isNonEmptyString(value.contextRef)) errors.push(`${at}.contextRef: server-minted opaque ref required`);
138
- if (typeof value.surface !== 'string' || !SURFACES.has(value.surface)) errors.push(`${at}.surface: unsupported surface`);
139
- if (!isRecord(value.capabilities)) {
140
- errors.push(`${at}.capabilities: object required`);
141
- } else {
142
- exactKeys(value.capabilities, [
143
- 'surface',
144
- 'allowedActions'
145
- ], [
146
- 'surface',
147
- 'allowedActions'
148
- ], `${at}.capabilities`, errors);
149
- if (value.capabilities.surface !== value.surface) errors.push(`${at}.capabilities.surface: must equal workspace.surface`);
150
- const actionKinds = value.capabilities.allowedActions;
151
- if (!Array.isArray(actionKinds) || !actionKinds.every((kind)=>typeof kind === 'string' && ACTION_KINDS.has(kind))) {
152
- errors.push(`${at}.capabilities.allowedActions: closed action-kind array required`);
153
- } else if (new Set(actionKinds).size !== actionKinds.length) {
154
- errors.push(`${at}.capabilities.allowedActions: duplicates forbidden`);
155
- }
156
- }
157
- appendCanonicalErrors(validateCanonicalWorkspace, value, at, errors);
158
- return errors.length === 0;
79
+ function hasSensitiveText(value, opts = {}) {
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);
159
83
  }
160
- const ACTION_INPUT_KEYS = {
161
- 'search.patch': {
162
- allowed: [
163
- 'patch'
164
- ],
165
- required: [
166
- 'patch'
167
- ]
168
- },
169
- 'search.run': {
170
- allowed: [],
171
- required: []
172
- },
173
- 'results.view.patch': {
174
- allowed: [
175
- 'patch'
176
- ],
177
- required: [
178
- 'patch'
179
- ]
180
- },
181
- 'hotel.focus': {
182
- allowed: [
183
- 'hotelRef'
184
- ],
185
- required: [
186
- 'hotelRef'
187
- ]
188
- },
189
- 'hotel.select': {
190
- allowed: [
191
- 'hotelRef'
192
- ],
193
- required: [
194
- 'hotelRef'
195
- ]
196
- },
197
- 'offers.query': {
198
- allowed: [
199
- 'hotelRefs',
200
- 'criteria'
201
- ],
202
- required: [
203
- 'hotelRefs',
204
- 'criteria'
205
- ]
206
- },
207
- 'offers.view.patch': {
208
- allowed: [
209
- 'hotelRef',
210
- 'criteria'
211
- ],
212
- required: [
213
- 'hotelRef',
214
- 'criteria'
215
- ]
216
- },
217
- 'offers.compare': {
218
- allowed: [
219
- 'offerRefs',
220
- 'requestedCount'
221
- ],
222
- required: [
223
- 'offerRefs',
224
- 'requestedCount'
225
- ]
226
- },
227
- 'offer.select': {
228
- allowed: [
229
- 'offerRef'
230
- ],
231
- required: [
232
- 'offerRef'
233
- ]
234
- },
235
- 'offer.check': {
236
- allowed: [
237
- 'offerRef'
238
- ],
239
- required: [
240
- 'offerRef'
241
- ]
242
- },
243
- 'checkout.prepare': {
244
- allowed: [
245
- 'offerRef',
246
- 'verifiedOfferRef'
247
- ],
248
- required: [
249
- 'offerRef',
250
- 'verifiedOfferRef'
251
- ]
252
- },
253
- 'order.observe': {
254
- allowed: [
255
- 'orderRef'
256
- ],
257
- required: [
258
- 'orderRef'
259
- ]
260
- }
261
- };
262
- function collectBookingReadActionErrors(value, at) {
263
- const errors = [];
264
- if (!isRecord(value)) return [
265
- `${at}: typed action object required; text is never executable`
266
- ];
267
- exactKeys(value, [
268
- 'schemaVersion',
269
- 'kind',
270
- 'actionId',
271
- 'contextRef',
272
- 'expectedRevision',
273
- 'reason',
274
- 'factRefs',
275
- 'input'
276
- ], [
277
- 'schemaVersion',
278
- 'kind',
279
- 'actionId',
280
- 'contextRef',
281
- 'expectedRevision',
282
- 'reason',
283
- 'factRefs',
284
- 'input'
285
- ], at, errors);
286
- if (value.schemaVersion !== 'booking.surface.v1') errors.push(`${at}.schemaVersion: booking.surface.v1 required`);
287
- if (typeof value.kind !== 'string' || !ACTION_KINDS.has(value.kind)) {
288
- errors.push(`${at}.kind: unregistered read action`);
289
- }
290
- if (!isNonEmptyString(value.actionId)) errors.push(`${at}.actionId: opaque ref required`);
291
- if (!isNonEmptyString(value.contextRef)) errors.push(`${at}.contextRef: opaque ref required`);
292
- if (!isRevision(value.expectedRevision)) errors.push(`${at}.expectedRevision: non-negative integer required`);
293
- if (!isNonEmptyString(value.reason)) errors.push(`${at}.reason: non-empty explanation required`);
294
- if (!isStringArray(value.factRefs)) errors.push(`${at}.factRefs: opaque-ref array required`);
295
- if (!isRecord(value.input)) {
296
- errors.push(`${at}.input: typed object required`);
297
- } else if (typeof value.kind === 'string' && ACTION_KINDS.has(value.kind)) {
298
- const spec = ACTION_INPUT_KEYS[value.kind];
299
- exactKeys(value.input, spec.allowed, spec.required, `${at}.input`, errors);
300
- }
301
- if (!validateCanonicalAction(value)) {
302
- for (const error of validateCanonicalAction.errors ?? []){
303
- errors.push(canonicalError(error, at));
84
+ function luhn(value) {
85
+ const digits = value.replaceAll(/\D/g, '');
86
+ if (digits.length < 13 || digits.length > 19) return false;
87
+ let sum = 0;
88
+ for(let i = digits.length - 1, parity = 0; i >= 0; i--, parity++){
89
+ let n = Number(digits[i]);
90
+ if (parity % 2 === 1) {
91
+ n *= 2;
92
+ if (n > 9) n -= 9;
304
93
  }
94
+ sum += n;
305
95
  }
306
- return errors;
96
+ return sum % 10 === 0;
307
97
  }
308
- function canonicalError(error, at) {
309
- const path = error.instancePath ? error.instancePath.replaceAll('/', '.') : '';
310
- const property = error.keyword === 'additionalProperties' ? `.${String(error.params.additionalProperty ?? '')}` : '';
311
- return `${at}${path}${property}: ${error.message ?? error.keyword}`;
98
+ const opaqueFieldNames = new Set([
99
+ 'eventId',
100
+ 'taskId',
101
+ 'contextRef',
102
+ 'actionId',
103
+ 'hotelRef',
104
+ 'offerRef',
105
+ 'offerVersionRef',
106
+ 'checkedOfferVersionRef',
107
+ 'currentOfferVersionRef',
108
+ 'verifiedOfferRef',
109
+ 'orderRef',
110
+ 'searchSessionRef',
111
+ 'handoffRef',
112
+ 'questionId',
113
+ 'blockerId',
114
+ 'approvalId',
115
+ 'sourceActionId',
116
+ 'targetActionId',
117
+ 'sourceTurnId',
118
+ 'turnId',
119
+ 'requestKey',
120
+ 'taskHandle',
121
+ 'nonce',
122
+ 'deliveryNonce',
123
+ 'optionDigest',
124
+ 'sourceReceiptDigest',
125
+ 'valueDigest'
126
+ ]);
127
+ function isOpaqueFieldPath(path) {
128
+ const clean = path.replace(/\[\]$/g, '');
129
+ const field = clean.split('.').at(-1);
130
+ return field ? opaqueFieldNames.has(field) || field.endsWith('Ref') || field.endsWith('Refs') : false;
312
131
  }
313
- function appendCanonicalErrors(validate, value, at, errors) {
314
- if (!validate(value)) for (const error of validate.errors ?? [])errors.push(canonicalError(error, at));
132
+ const scan = (v, path = '$')=>typeof v === 'string' ? hasSensitiveText(v, {
133
+ allowOpaqueNumericId: isOpaqueFieldPath(path)
134
+ }) : Array.isArray(v) ? v.some((child)=>scan(child, `${path}[]`)) : !!v && typeof v === 'object' ? Object.entries(v).some(([k, x])=>/cost|price/i.test(k) ? /supplier|internal|wholesale/i.test(String(x)) : scan(x, `${path}.${k}`)) : false;
135
+ export function isSafeBookingRequestKey(value) {
136
+ return safeRequestKey(value);
315
137
  }
316
- export function validateBookingReadActionV1(value) {
317
- const errors = collectBookingReadActionErrors(value, '$');
318
- return errors.length === 0 ? {
319
- ok: true
320
- } : {
321
- ok: false,
322
- errors
323
- };
138
+ function errors(v) {
139
+ return (v.errors ?? []).map((e)=>`${e.instancePath || '$'}: ${e.message ?? e.keyword}`);
324
140
  }
325
- function collectReceiptErrors(value, at) {
326
- const errors = [];
327
- if (!isRecord(value)) return [
328
- `${at}: typed receipt object required`
329
- ];
330
- exactKeys(value, [
331
- 'schemaVersion',
332
- 'kind',
333
- 'actionId',
334
- 'contextRef',
335
- 'status',
336
- 'revision',
337
- 'observation',
338
- 'resultContract',
339
- 'undoToken'
340
- ], [
341
- 'schemaVersion',
342
- 'kind',
343
- 'actionId',
344
- 'contextRef',
345
- 'status',
346
- 'revision',
347
- 'observation',
348
- 'resultContract'
349
- ], at, errors);
350
- if (value.schemaVersion !== 'booking.surface.v1') errors.push(`${at}.schemaVersion: booking.surface.v1 required`);
351
- if (value.kind !== 'action.receipt') errors.push(`${at}.kind: action.receipt required`);
352
- if (!isNonEmptyString(value.actionId)) errors.push(`${at}.actionId: opaque ref required`);
353
- if (!isNonEmptyString(value.contextRef)) errors.push(`${at}.contextRef: opaque ref required`);
354
- if (typeof value.status !== 'string' || !RECEIPT_STATUSES.has(value.status)) errors.push(`${at}.status: unregistered receipt status`);
355
- if (!isRevision(value.revision)) errors.push(`${at}.revision: non-negative integer required`);
356
- if (value.undoToken !== undefined && !isNonEmptyString(value.undoToken)) errors.push(`${at}.undoToken: opaque ref required`);
357
- validateActionObservation(value.observation, `${at}.observation`, errors);
358
- validateResultContract(value.resultContract, `${at}.resultContract`, errors);
359
- return errors;
141
+ function workspaceErrors(workspace, path) {
142
+ const e = [];
143
+ if (!workspace || typeof workspace !== 'object' || Array.isArray(workspace)) return e;
144
+ const loadedOffers = Array.isArray(workspace.loadedOffers) ? workspace.loadedOffers : [];
145
+ const offerRefs = loadedOffers.map((offer)=>offer?.offerRef).filter((ref)=>typeof ref === 'string');
146
+ const offerVersionRefs = loadedOffers.map((offer)=>offer?.offerVersionRef).filter((ref)=>typeof ref === 'string');
147
+ if (new Set(offerRefs).size !== offerRefs.length) e.push(`${path}.loadedOffers: duplicate offerRef`);
148
+ if (new Set(offerVersionRefs).size !== offerVersionRefs.length) e.push(`${path}.loadedOffers: duplicate offerVersionRef`);
149
+ const verified = workspace.verifiedOffer;
150
+ if (verified !== undefined) {
151
+ if (!loadedOffers.some((offer)=>offer?.offerRef === verified.offerRef && offer?.offerVersionRef === verified.offerVersionRef)) e.push(`${path}.verifiedOffer: loaded offer version required`);
152
+ if (!safeOpaque(verified.verifiedOfferRef) || !isBookingDateTime(verified.expiresAt)) e.push(`${path}.verifiedOffer: safe ref and parseable expiry required`);
153
+ }
154
+ return e;
360
155
  }
361
- function validateActionObservation(value, at, errors) {
362
- if (!isRecord(value)) {
363
- errors.push(`${at}: discriminated object required`);
364
- return;
156
+ function cross(value) {
157
+ const e = [];
158
+ if (scan(value)) e.push('$: unsafe PII/payment/supplier-cost/credential text');
159
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return e;
160
+ const x = value;
161
+ const semantic = x.kind === 'action.receipt.continuation' ? x.receipt : x;
162
+ if (x.workspace && x.kind !== 'user.turn.ingress') e.push(...workspaceErrors(x.workspace, '$.workspace'));
163
+ if (x.schemaVersion === 'booking.surface' && Array.isArray(x.loadedOffers)) e.push(...workspaceErrors(x, '$'));
164
+ if (x.kind === 'user.turn.ingress') {
165
+ if (!safeRequestKey(x.requestKey)) e.push('$.requestKey: safe opaque request key required');
166
+ const workspace = x.workspace;
167
+ e.push(...workspaceErrors(workspace, '$.workspace'));
168
+ const visibleHotels = Array.isArray(workspace?.visibleHotels) ? workspace.visibleHotels : [];
169
+ const loadedOffers = Array.isArray(workspace?.loadedOffers) ? workspace.loadedOffers : [];
170
+ if (workspace?.focusedHotelRef !== undefined && !visibleHotels.some((hotel)=>hotel?.hotelRef === workspace.focusedHotelRef)) e.push('$.workspace.focusedHotelRef: visible hotel required');
171
+ const selectedOffer = workspace?.selectedOfferRef === undefined ? undefined : loadedOffers.find((offer)=>offer?.offerRef === workspace.selectedOfferRef);
172
+ if (workspace?.selectedOfferRef !== undefined && !selectedOffer) e.push('$.workspace.selectedOfferRef: loaded offer required');
173
+ if (selectedOffer && !visibleHotels.some((hotel)=>hotel?.hotelRef === selectedOffer.hotelRef)) e.push('$.workspace.selectedOfferRef: offer hotel must be visible');
365
174
  }
366
- switch(value.kind){
367
- case 'search.state':
368
- exactKeys(value, [
369
- 'kind',
370
- 'searchSessionRef',
371
- 'resultCount'
372
- ], [
373
- 'kind'
374
- ], at, errors);
375
- if (value.searchSessionRef !== undefined && !isNonEmptyString(value.searchSessionRef)) errors.push(`${at}.searchSessionRef: opaque ref required`);
376
- if (value.resultCount !== undefined && !isCount(value.resultCount)) errors.push(`${at}.resultCount: non-negative integer required`);
377
- return;
378
- case 'results.state':
379
- exactKeys(value, [
380
- 'kind',
381
- 'matchedHotelRefs',
382
- 'visibleCount'
383
- ], [
384
- 'kind',
385
- 'matchedHotelRefs',
386
- 'visibleCount'
387
- ], at, errors);
388
- if (!isStringArray(value.matchedHotelRefs)) errors.push(`${at}.matchedHotelRefs: opaque-ref array required`);
389
- if (!isCount(value.visibleCount)) errors.push(`${at}.visibleCount: non-negative integer required`);
390
- return;
391
- case 'hotel.focus':
392
- exactKeys(value, [
393
- 'kind',
394
- 'hotelRef'
395
- ], [
396
- 'kind',
175
+ if (semantic !== x && semantic?.resultContract?.hardCriteriaMet === true && semantic.resultContract.blockers?.length) e.push('$.receipt.resultContract: hardCriteriaMet cannot coexist with blockers');
176
+ if (kinds.has(x.kind)) {
177
+ const allowed = {
178
+ 'search.patch': [
179
+ 'patch'
180
+ ],
181
+ 'search.run': [],
182
+ 'results.view.patch': [
183
+ 'patch'
184
+ ],
185
+ 'hotel.focus': [
397
186
  'hotelRef'
398
- ], at, errors);
399
- if (!isNonEmptyString(value.hotelRef)) errors.push(`${at}.hotelRef: opaque ref required`);
400
- return;
401
- case 'hotel.selection':
402
- exactKeys(value, [
403
- 'kind',
187
+ ],
188
+ 'hotel.select': [
404
189
  'hotelRef'
405
- ], [
406
- 'kind',
407
- 'hotelRef'
408
- ], at, errors);
409
- if (!isNonEmptyString(value.hotelRef)) errors.push(`${at}.hotelRef: opaque ref required`);
410
- return;
411
- case 'offers.state':
412
- exactKeys(value, [
413
- 'kind',
414
- 'hotelRefs',
415
- 'offerRefs',
416
- 'loadedHotelCount'
417
- ], [
418
- 'kind',
190
+ ],
191
+ 'offers.query': [
419
192
  'hotelRefs',
193
+ 'criteria'
194
+ ],
195
+ 'offers.view.patch': [
196
+ 'hotelRef',
197
+ 'criteria'
198
+ ],
199
+ 'offers.compare': [
420
200
  'offerRefs',
421
- 'loadedHotelCount'
422
- ], at, errors);
423
- if (!isStringArray(value.hotelRefs)) errors.push(`${at}.hotelRefs: opaque-ref array required`);
424
- if (!isStringArray(value.offerRefs)) errors.push(`${at}.offerRefs: opaque-ref array required`);
425
- if (!isCount(value.loadedHotelCount)) errors.push(`${at}.loadedHotelCount: non-negative integer required`);
426
- return;
427
- case 'offer.selection':
428
- exactKeys(value, [
429
- 'kind',
430
- 'offerRef'
431
- ], [
432
- 'kind',
433
- 'offerRef'
434
- ], at, errors);
435
- if (!isNonEmptyString(value.offerRef)) errors.push(`${at}.offerRef: opaque ref required`);
436
- return;
437
- case 'offer.availability':
438
- exactKeys(value, [
439
- 'kind',
440
- 'offerRef',
441
- 'verifiedOfferRef',
442
- 'available',
443
- 'changedFactRefs'
444
- ], [
445
- 'kind',
201
+ 'requestedCount'
202
+ ],
203
+ 'offer.select': [
446
204
  'offerRef',
447
- 'available',
448
- 'changedFactRefs'
449
- ], at, errors);
450
- if (!isNonEmptyString(value.offerRef)) errors.push(`${at}.offerRef: opaque ref required`);
451
- if (value.verifiedOfferRef !== undefined && !isNonEmptyString(value.verifiedOfferRef)) errors.push(`${at}.verifiedOfferRef: opaque ref required`);
452
- if (typeof value.available !== 'boolean') errors.push(`${at}.available: boolean required`);
453
- if (!isStringArray(value.changedFactRefs)) errors.push(`${at}.changedFactRefs: opaque-ref array required`);
454
- return;
455
- case 'checkout.handoff':
456
- exactKeys(value, [
457
- 'kind',
205
+ 'offerVersionRef'
206
+ ],
207
+ 'offer.check': [
458
208
  'offerRef',
459
- 'verifiedOfferRef',
460
- 'handoffRef'
461
- ], [
462
- 'kind',
209
+ 'offerVersionRef'
210
+ ],
211
+ 'checkout.prepare': [
463
212
  'offerRef',
464
- 'verifiedOfferRef',
465
- 'handoffRef'
466
- ], at, errors);
467
- if (!isNonEmptyString(value.offerRef)) errors.push(`${at}.offerRef: opaque ref required`);
468
- if (!isNonEmptyString(value.verifiedOfferRef)) errors.push(`${at}.verifiedOfferRef: opaque ref required`);
469
- if (!isNonEmptyString(value.handoffRef)) errors.push(`${at}.handoffRef: opaque ref required`);
470
- return;
471
- case 'order.state':
472
- {
473
- exactKeys(value, [
474
- 'kind',
475
- 'orderRef',
476
- 'state'
477
- ], [
478
- 'kind',
479
- 'orderRef',
480
- 'state'
481
- ], at, errors);
482
- const states = new Set([
483
- 'pending',
484
- 'verified',
485
- 'failed',
486
- 'unknown'
487
- ]);
488
- if (!isNonEmptyString(value.orderRef)) errors.push(`${at}.orderRef: opaque ref required`);
489
- if (typeof value.state !== 'string' || !states.has(value.state)) errors.push(`${at}.state: unregistered order state`);
490
- return;
491
- }
492
- case 'gap':
493
- exactKeys(value, [
494
- 'kind',
495
- 'code',
496
- 'factRefs'
497
- ], [
498
- 'kind',
499
- 'code',
500
- 'factRefs'
501
- ], at, errors);
502
- if (!isSafeCode(value.code)) errors.push(`${at}.code: safe identifier required`);
503
- if (!isStringArray(value.factRefs)) errors.push(`${at}.factRefs: opaque-ref array required`);
504
- return;
505
- default:
506
- errors.push(`${at}.kind: unregistered observation kind`);
213
+ 'offerVersionRef',
214
+ 'verifiedOfferRef'
215
+ ],
216
+ 'order.observe': [
217
+ 'orderRef'
218
+ ]
219
+ };
220
+ const input = x.input;
221
+ 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');
507
222
  }
508
- }
509
- function validateResultContract(value, at, errors) {
510
- if (!isRecord(value)) {
511
- errors.push(`${at}: object required`);
512
- return;
223
+ if (x.kind === 'action.receipt' && x.resultContract?.hardCriteriaMet === true && x.resultContract.blockers?.length) e.push('$.resultContract: hardCriteriaMet cannot coexist with blockers');
224
+ if (x.kind === 'user.turn' && Array.isArray(x.request?.approval)) e.push('$.request.approval: at most one approval');
225
+ if (x.kind === 'action.receipt' || x.kind === 'action.receipt.continuation') {
226
+ const r = x.kind === 'action.receipt.continuation' ? x.receipt : x;
227
+ if (r?.resultContract?.gapCodes?.some((code)=>!gapCodes.has(String(code)))) e.push('$.resultContract.gapCodes: unregistered gap code');
228
+ const observation = r?.observation;
229
+ if (observation?.kind === 'offer.availability') {
230
+ 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');
231
+ 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');
232
+ 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');
233
+ 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');
234
+ if (r.status !== 'applied' && observation.verifiedOfferRef) e.push('$.observation: only applied availability may expose verified ref');
235
+ }
513
236
  }
514
- exactKeys(value, [
515
- 'outcome',
516
- 'requestedCount',
517
- 'actualCount',
518
- 'hardCriteriaMet',
519
- 'factRefs',
520
- 'gapCodes'
521
- ], [
522
- 'outcome',
523
- 'hardCriteriaMet',
524
- 'factRefs',
525
- 'gapCodes'
526
- ], at, errors);
527
- const outcomes = new Set([
528
- 'complete',
529
- 'partial',
530
- 'empty'
531
- ]);
532
- if (typeof value.outcome !== 'string' || !outcomes.has(value.outcome)) errors.push(`${at}.outcome: complete, partial, or empty required`);
533
- if (value.requestedCount !== undefined && !isCount(value.requestedCount)) errors.push(`${at}.requestedCount: non-negative integer required`);
534
- if (value.actualCount !== undefined && !isCount(value.actualCount)) errors.push(`${at}.actualCount: non-negative integer required`);
535
- if (typeof value.hardCriteriaMet !== 'boolean') errors.push(`${at}.hardCriteriaMet: boolean required`);
536
- if (!isStringArray(value.factRefs)) errors.push(`${at}.factRefs: opaque-ref array required`);
537
- if (!Array.isArray(value.gapCodes) || !value.gapCodes.every(isSafeCode)) errors.push(`${at}.gapCodes: safe identifier array required`);
538
- }
539
- export function validateActionReceiptV1(value) {
540
- const errors = collectReceiptErrors(value, '$');
541
- if (receiptContainsUnsafeOpaqueText(value)) errors.push('$: receipt contains unsafe non-opaque text');
542
- if (!validateCanonicalReceipt(value)) {
543
- for (const error of validateCanonicalReceipt.errors ?? [])errors.push(canonicalError(error, '$'));
237
+ const blocker = x.blocker ?? x.question?.blocker;
238
+ 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');
239
+ if (blocker?.evidence?.factRefs && new Set(blocker.evidence.factRefs).size !== blocker.evidence.factRefs.length) e.push('$.blocker.evidence.factRefs: unique refs required');
240
+ if (x.kind === 'question' && blocker && Array.isArray(x.question?.approvalOptions)) {
241
+ for (const [i, option] of x.question.approvalOptions.entries()){
242
+ const result = validateApprovalAgainstBlocker(option?.approval, blocker);
243
+ if (!result.ok) e.push(...result.errors.map((error)=>`$.question.approvalOptions[${i}].${error.slice(2)}`));
244
+ if (option?.approval?.taskId !== x.taskId) e.push(`$.question.approvalOptions[${i}].approval.taskId: must match event`);
245
+ if (option?.approval?.contextRef !== x.contextRef) e.push(`$.question.approvalOptions[${i}].approval.contextRef: must match event`);
246
+ }
544
247
  }
545
- return errors.length === 0 ? {
546
- ok: true
547
- } : {
548
- ok: false,
549
- errors
550
- };
248
+ if (kinds.has(String(x.kind)) && x.relaxationApprovalRef) {
249
+ const ref = x.relaxationApprovalRef;
250
+ if (ref.contextRef !== x.contextRef) e.push('$.relaxationApprovalRef.contextRef: must match action');
251
+ if (ref.sourceRevision !== x.expectedRevision) e.push('$.relaxationApprovalRef.sourceRevision: must equal action revision');
252
+ if (ref.targetActionKind !== x.kind) e.push('$.relaxationApprovalRef.targetActionKind: must match action kind');
253
+ if (ref.targetActionId !== x.actionId) e.push('$.relaxationApprovalRef.targetActionId: must match action id');
254
+ if (!safeOpaque(ref.nonce) || !isBookingDateTime(ref.expiresAt)) e.push('$.relaxationApprovalRef: invalid expiry or nonce');
255
+ }
256
+ 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');
257
+ return e;
551
258
  }
552
- export function validateBookingCopilotIngressTurnV1(value) {
553
- const errors = [];
554
- if (!isRecord(value)) return {
259
+ export function validateBookingSurface(value) {
260
+ const ok = validate(value);
261
+ const e = ok ? [] : errors(validate);
262
+ e.push(...cross(value));
263
+ return e.length ? {
555
264
  ok: false,
556
- errors: [
557
- '$: object required'
558
- ]
559
- };
560
- exactKeys(value, [
561
- 'schemaVersion',
562
- 'kind',
563
- 'taskId',
564
- 'contextRef',
565
- 'surfaceHint',
566
- 'workspace',
567
- 'request'
568
- ], [
569
- 'schemaVersion',
570
- 'kind',
571
- 'surfaceHint',
572
- 'workspace',
573
- 'request'
574
- ], '$', errors);
575
- if (value.schemaVersion !== 'booking.surface.v1') errors.push('$.schemaVersion: booking.surface.v1 required');
576
- if (value.kind !== 'user.turn.ingress') errors.push('$.kind: user.turn.ingress required');
577
- if (value.contextRef !== undefined && value.contextRef !== null) errors.push('$.contextRef: bootstrap may only omit it or send null');
578
- if (typeof value.surfaceHint !== 'string' || !SURFACES.has(value.surfaceHint)) errors.push('$.surfaceHint: unsupported surface hint');
579
- validateIngressWorkspace(value.workspace, '$.workspace', errors);
580
- if (!isRecord(value.request)) {
581
- errors.push('$.request: object required');
582
- } else {
583
- exactKeys(value.request, [
584
- 'text'
585
- ], [
586
- 'text'
587
- ], '$.request', errors);
588
- if (!isNonEmptyString(value.request.text)) errors.push('$.request.text: non-empty string required');
589
- }
590
- appendCanonicalErrors(validateCanonicalIngressTurn, value, '$', errors);
591
- return errors.length === 0 ? {
592
- ok: true
265
+ errors: e
593
266
  } : {
594
- ok: false,
595
- errors
267
+ ok: true
596
268
  };
597
269
  }
598
- export function validateBookingCopilotTurnV1(value) {
599
- const errors = [];
600
- if (!isRecord(value)) return {
270
+ function fromCanonical(fn, value) {
271
+ const ok = fn(value);
272
+ const e = ok ? [] : errors(fn);
273
+ e.push(...cross(value));
274
+ return e.length ? {
601
275
  ok: false,
602
- errors: [
603
- '$: object required'
604
- ]
605
- };
606
- if (value.kind === 'user.turn') {
607
- exactKeys(value, [
608
- 'schemaVersion',
609
- 'kind',
610
- 'taskId',
611
- 'workspace',
612
- 'request'
613
- ], [
614
- 'schemaVersion',
615
- 'kind',
616
- 'workspace',
617
- 'request'
618
- ], '$', errors);
619
- if (!isRecord(value.request)) errors.push('$.request: object required');
620
- else {
621
- exactKeys(value.request, [
622
- 'text'
623
- ], [
624
- 'text'
625
- ], '$.request', errors);
626
- if (!isNonEmptyString(value.request.text)) errors.push('$.request.text: non-empty string required');
627
- }
628
- } else if (value.kind === 'action.receipt.continuation') {
629
- exactKeys(value, [
630
- 'schemaVersion',
631
- 'kind',
632
- 'taskId',
633
- 'workspace',
634
- 'receipt'
635
- ], [
636
- 'schemaVersion',
637
- 'kind',
638
- 'taskId',
639
- 'workspace',
640
- 'receipt'
641
- ], '$', errors);
642
- if (!isNonEmptyString(value.taskId)) errors.push('$.taskId: opaque ref required');
643
- errors.push(...collectReceiptErrors(value.receipt, '$.receipt'));
644
- const receiptValidation = validateActionReceiptV1(value.receipt);
645
- if (!receiptValidation.ok) errors.push(...receiptValidation.errors.map((error)=>`$.receipt${error.slice(1)}`));
646
- } else {
647
- errors.push('$.kind: user.turn or action.receipt.continuation required');
648
- }
649
- if (value.schemaVersion !== 'booking.surface.v1') errors.push('$.schemaVersion: booking.surface.v1 required');
650
- validateWorkspace(value.workspace, '$.workspace', errors);
651
- if (value.kind === 'action.receipt.continuation' && isRecord(value.workspace) && isRecord(value.receipt)) {
652
- if (value.receipt.contextRef !== value.workspace.contextRef) errors.push('$.receipt.contextRef: must equal workspace.contextRef');
653
- if (value.receipt.revision !== value.workspace.revision) errors.push('$.receipt.revision: must equal workspace.revision');
654
- }
655
- appendCanonicalErrors(validateCanonicalTurn, value, '$', errors);
656
- return errors.length === 0 ? {
657
- ok: true
276
+ errors: e
658
277
  } : {
659
- ok: false,
660
- errors
278
+ ok: true
661
279
  };
662
280
  }
663
- export function validateBookingSurfaceEventV1(value) {
664
- const errors = [];
665
- if (!isRecord(value)) return {
666
- ok: false,
667
- errors: [
668
- '$: typed event object required'
669
- ]
670
- };
671
- const common = [
672
- 'schemaVersion',
673
- 'eventId',
674
- 'taskId',
675
- 'contextRef',
676
- 'sequence',
677
- 'emittedAt',
678
- 'kind'
281
+ export function validateBookingReadAction(value) {
282
+ return fromCanonical(validateActionCanonical, value);
283
+ }
284
+ export function validateCriterionBlocker(value) {
285
+ return fromCanonical(validateBlockerCanonical, value);
286
+ }
287
+ export function validateRelaxationApproval(value) {
288
+ return fromCanonical(validateApprovalCanonical, value);
289
+ }
290
+ export function validateBookingSurfaceEvent(value) {
291
+ return fromCanonical(validateEventCanonical, value);
292
+ }
293
+ export function validateApprovalAgainstBlocker(approval, blocker) {
294
+ const a = approval;
295
+ const b = blocker;
296
+ const blockerResult = validateCriterionBlocker(b);
297
+ if (!blockerResult.ok) return blockerResult;
298
+ const approvalResult = validateRelaxationApproval(a);
299
+ if (!approvalResult.ok) return approvalResult;
300
+ const fields = [
301
+ 'blockerId',
302
+ 'sourceActionId',
303
+ 'sourceReceiptDigest',
304
+ 'scope',
305
+ 'code',
306
+ 'criterionPath',
307
+ 'valueDigest'
679
308
  ];
680
- const branchKeys = {
681
- status: 'status',
682
- question: 'question',
683
- operation: 'action',
684
- explanation: 'explanation',
685
- terminal: 'terminal',
686
- error: 'error'
687
- };
688
- const branchKey = typeof value.kind === 'string' ? branchKeys[value.kind] : undefined;
689
- exactKeys(value, [
690
- ...common,
691
- ...branchKey ? [
692
- branchKey
693
- ] : []
694
- ], [
695
- ...common,
696
- ...branchKey ? [
697
- branchKey
698
- ] : []
699
- ], '$', errors);
700
- if (value.schemaVersion !== 'booking.surface.v1') errors.push('$.schemaVersion: booking.surface.v1 required');
701
- if (!isNonEmptyString(value.eventId)) errors.push('$.eventId: opaque ref required');
702
- if (!isNonEmptyString(value.taskId)) errors.push('$.taskId: opaque ref required');
703
- if (!isNonEmptyString(value.contextRef)) errors.push('$.contextRef: server-minted opaque ref required');
704
- if (!Number.isInteger(value.sequence) || Number(value.sequence) < 1) errors.push('$.sequence: positive integer required');
705
- if (!isNonEmptyString(value.emittedAt)) errors.push('$.emittedAt: timestamp required');
706
- if (typeof value.kind !== 'string' || !EVENT_KINDS.has(value.kind)) errors.push('$.kind: unregistered event kind');
707
- if (value.kind === 'status') {
708
- const statuses = new Set([
709
- 'submitted',
710
- 'working',
711
- 'waiting_receipt',
712
- 'input_required'
713
- ]);
714
- if (typeof value.status !== 'string' || !statuses.has(value.status)) errors.push('$.status: unregistered task status');
715
- }
716
- if (value.kind === 'question') {
717
- if (!isRecord(value.question)) {
718
- errors.push('$.question: typed object required');
719
- } else {
720
- exactKeys(value.question, [
721
- 'questionId',
722
- 'prompt',
723
- 'missingFields'
724
- ], [
725
- 'questionId',
726
- 'prompt',
727
- 'missingFields'
728
- ], '$.question', errors);
729
- if (!isNonEmptyString(value.question.questionId)) errors.push('$.question.questionId: opaque ref required');
730
- if (!isNonEmptyString(value.question.prompt)) errors.push('$.question.prompt: non-empty string required');
731
- if (!isStringArray(value.question.missingFields)) errors.push('$.question.missingFields: string array required');
732
- }
733
- }
734
- if (value.kind === 'operation') errors.push(...collectBookingReadActionErrors(value.action, '$.action'));
735
- if (value.kind === 'explanation') {
736
- if (!isRecord(value.explanation)) {
737
- errors.push('$.explanation: typed object required');
738
- } else {
739
- exactKeys(value.explanation, [
740
- 'text',
741
- 'factRefs'
742
- ], [
743
- 'text',
744
- 'factRefs'
745
- ], '$.explanation', errors);
746
- if (!isNonEmptyString(value.explanation.text)) errors.push('$.explanation.text: non-empty string required');
747
- if (!isStringArray(value.explanation.factRefs)) errors.push('$.explanation.factRefs: opaque-ref array required');
748
- }
749
- }
750
- if (value.kind === 'terminal') {
751
- if (!isRecord(value.terminal)) {
752
- errors.push('$.terminal: typed object required');
753
- } else {
754
- exactKeys(value.terminal, [
755
- 'status',
756
- 'summary',
757
- 'factRefs'
758
- ], [
759
- 'status',
760
- 'summary',
761
- 'factRefs'
762
- ], '$.terminal', errors);
763
- const statuses = new Set([
764
- 'completed',
765
- 'stopped'
766
- ]);
767
- if (typeof value.terminal.status !== 'string' || !statuses.has(value.terminal.status)) errors.push('$.terminal.status: completed or stopped required');
768
- if (!isNonEmptyString(value.terminal.summary)) errors.push('$.terminal.summary: non-empty string required');
769
- if (!isStringArray(value.terminal.factRefs)) errors.push('$.terminal.factRefs: opaque-ref array required');
770
- }
771
- }
772
- if (value.kind === 'error') {
773
- if (!isRecord(value.error)) {
774
- errors.push('$.error: typed object required');
775
- } else {
776
- exactKeys(value.error, [
777
- 'code',
778
- 'message',
779
- 'retryable'
780
- ], [
781
- 'code',
782
- 'message',
783
- 'retryable'
784
- ], '$.error', errors);
785
- if (!isNonEmptyString(value.error.code)) errors.push('$.error.code: non-empty string required');
786
- if (!isNonEmptyString(value.error.message)) errors.push('$.error.message: non-empty string required');
787
- if (typeof value.error.retryable !== 'boolean') errors.push('$.error.retryable: boolean required');
788
- }
789
- }
790
- return errors.length === 0 ? {
791
- ok: true
792
- } : {
309
+ const mismatches = fields.filter((field)=>a[field] !== b?.[field]);
310
+ return mismatches.length ? {
793
311
  ok: false,
794
- errors
312
+ errors: mismatches.map((field)=>`$.${field}: approval does not match blocker`)
313
+ } : {
314
+ ok: true
795
315
  };
796
316
  }
797
- export function bookingOperationFromEvent(value) {
798
- if (!isRecord(value) || value.kind !== 'operation') return null;
799
- if (!validateBookingSurfaceEventV1(value).ok) return null;
800
- return value.action;
801
- }
802
- void null;
803
- void null;
804
- void null;
805
- void null;
806
- void null;
807
317
 
808
318
 
809
319
  //# sourceURL=ts/src/booking-surface/validation.ts