@danceiny/gotry 0.0.1-rc.16 → 0.0.1-rc.18

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 (229) hide show
  1. package/README.md +161 -116
  2. package/README.zh-CN.md +173 -144
  3. package/bin/gotry-booking-copilot.js +53 -0
  4. package/bin/gotry-bootstrap.js +113 -66
  5. package/bin/gotry-inner.js +436 -70
  6. package/bin/gotry-runtime-resolution.d.ts +27 -0
  7. package/bin/gotry-runtime-resolution.js +50 -0
  8. package/bin/gotry.js +1 -1
  9. package/cordis.gotry-patch.yml +5 -1
  10. package/dist/capabilities/agent-reach-deep.js +1 -1
  11. package/dist/capabilities/agent-reach.js +1 -1
  12. package/dist/capabilities/anything.js +1 -1
  13. package/dist/capabilities/artifacts.js +1 -1
  14. package/dist/capabilities/effect.js +1 -1
  15. package/dist/capabilities/fact-log.js +1 -1
  16. package/dist/capabilities/flyai.js +20 -6
  17. package/dist/capabilities/hbcli.js +1 -1
  18. package/dist/capabilities/incident-log.js +1 -1
  19. package/dist/capabilities/model-override.js +18 -0
  20. package/dist/capabilities/opensky.js +1 -1
  21. package/dist/capabilities/resilience.js +1 -1
  22. package/dist/capabilities/session/action-cache.js +1 -1
  23. package/dist/capabilities/session/adapters/ctrip-flight.js +1 -1
  24. package/dist/capabilities/session/adapters/meituan-local.js +1 -1
  25. package/dist/capabilities/session/benchmark.js +1 -1
  26. package/dist/capabilities/session/extension-bridge.js +19 -6
  27. package/dist/capabilities/session/extension-channel.js +3 -3
  28. package/dist/capabilities/session/extension-distribution.js +234 -0
  29. package/dist/capabilities/session/extract.js +1 -1
  30. package/dist/capabilities/session/golden-score.js +92 -0
  31. package/dist/capabilities/session/health-watch.js +1 -1
  32. package/dist/capabilities/session/read-guard.js +1 -1
  33. package/dist/capabilities/session/static-flight-golden.js +137 -0
  34. package/dist/capabilities/session/transport.js +1 -1
  35. package/dist/capabilities/session/wizard.js +21 -251
  36. package/dist/capabilities/session-consent.js +1 -1
  37. package/dist/capabilities/session-login.js +11 -5
  38. package/dist/capabilities/session-search.js +46 -4
  39. package/dist/capabilities/weather.js +168 -46
  40. package/dist/data/session-golden-20.json +25 -0
  41. package/dist/data/sf-golden-manifest.json +102 -0
  42. package/dist/data/sf-static-routes.json +91 -0
  43. package/dist/scripts/action-cache-tests.js +1 -1
  44. package/dist/scripts/agent-planning-budget-e2e.js +227 -0
  45. package/dist/scripts/agent-planning-budget-tests.js +173 -0
  46. package/dist/scripts/agent-reach-deep-tests.js +1 -1
  47. package/dist/scripts/agent-reach-tests.js +1 -1
  48. package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
  49. package/dist/scripts/anything-tests.js +1 -1
  50. package/dist/scripts/async-collect.js +1 -1
  51. package/dist/scripts/benchmark-environment-bridge-e2e.js +981 -0
  52. package/dist/scripts/benchmark-environment-bridge-tests.js +2544 -0
  53. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +335 -0
  54. package/dist/scripts/booking-copilot-availability-policy-v2-tests.js +1355 -0
  55. package/dist/scripts/booking-copilot-bin-proof-tests.js +89 -0
  56. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +113 -0
  57. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +356 -0
  58. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +557 -0
  59. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +140 -0
  60. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +202 -0
  61. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +102 -0
  62. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +374 -0
  63. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +447 -0
  64. package/dist/scripts/booking-copilot-runtime-proof-tests.js +205 -0
  65. package/dist/scripts/booking-copilot-server-proof-tests.js +317 -0
  66. package/dist/scripts/booking-copilot-startup-proof-tests.js +283 -0
  67. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +3935 -0
  68. package/dist/scripts/booking-saga-tests.js +1 -1
  69. package/dist/scripts/booking-surface-contract-proof-tests.js +393 -0
  70. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +1174 -0
  71. package/dist/scripts/bootstrap-tests.js +45 -39
  72. package/dist/scripts/build-changelog.js +1 -1
  73. package/dist/scripts/changelog-tests.js +1 -1
  74. package/dist/scripts/companion-tests.js +1 -1
  75. package/dist/scripts/diff-test.js +1 -1
  76. package/dist/scripts/dsh-runtime-closure-tests.js +225 -0
  77. package/dist/scripts/dsh-runtime-closure.js +150 -0
  78. package/dist/scripts/effect-tests.js +1 -1
  79. package/dist/scripts/engine-run.js +1 -1
  80. package/dist/scripts/engine-tests.js +1 -1
  81. package/dist/scripts/evaluation-cadence-tests.js +347 -0
  82. package/dist/scripts/evaluation-contract-tests.js +574 -0
  83. package/dist/scripts/extension-distribution-cli.js +35 -0
  84. package/dist/scripts/extension-distribution-tests.js +341 -0
  85. package/dist/scripts/extension-tests.js +128 -23
  86. package/dist/scripts/fact-gate-tests.js +1 -1
  87. package/dist/scripts/flyai-tests.js +1 -1
  88. package/dist/scripts/hbcli-e2e-tests.js +1 -1
  89. package/dist/scripts/hbcli-tests.js +1 -1
  90. package/dist/scripts/health-watch-cli.js +1 -1
  91. package/dist/scripts/i18n-tests.js +1 -1
  92. package/dist/scripts/incident-tests.js +1 -1
  93. package/dist/scripts/journey-tests.js +1 -1
  94. package/dist/scripts/ledger-tests.js +1 -1
  95. package/dist/scripts/ledger-workflow-crash.js +1 -1
  96. package/dist/scripts/memory-capture-tests.js +1 -1
  97. package/dist/scripts/memory-decay-tests.js +1 -1
  98. package/dist/scripts/memory-metrics.js +1 -1
  99. package/dist/scripts/memory-value-report.js +1 -1
  100. package/dist/scripts/model-override-e2e.js +176 -0
  101. package/dist/scripts/nightly-evidence-tests.js +1 -1
  102. package/dist/scripts/nightly-evidence.js +1 -1
  103. package/dist/scripts/nudge-digest.js +1 -1
  104. package/dist/scripts/onboarding-tests.js +21 -53
  105. package/dist/scripts/opensky-check.js +1 -1
  106. package/dist/scripts/opensky-tests.js +1 -1
  107. package/dist/scripts/pnpm-dsh-closure-proof.js +20 -0
  108. package/dist/scripts/price-drift-tests.js +1 -1
  109. package/dist/scripts/price-drift-watch.js +1 -1
  110. package/dist/scripts/probe-poi-tests.js +1 -1
  111. package/dist/scripts/product-metrics.js +1 -1
  112. package/dist/scripts/publish-preverify.js +40 -4
  113. package/dist/scripts/realtime-pricing-tests.js +1 -1
  114. package/dist/scripts/replay-async.js +1 -1
  115. package/dist/scripts/replay-real.js +1 -1
  116. package/dist/scripts/replay.js +1 -1
  117. package/dist/scripts/session-attach-diagnose.js +1 -1
  118. package/dist/scripts/session-attach-poc.js +1 -1
  119. package/dist/scripts/session-benchmark.js +1 -1
  120. package/dist/scripts/session-extract-tests.js +1 -1
  121. package/dist/scripts/session-login.js +1 -1
  122. package/dist/scripts/session-tests.js +70 -20
  123. package/dist/scripts/sf-live-benchmark.js +338 -0
  124. package/dist/scripts/sf-live-cli-tests.js +21 -0
  125. package/dist/scripts/sf-soft-score-tests.js +108 -0
  126. package/dist/scripts/sf-summary.js +93 -0
  127. package/dist/scripts/skeleton-check.js +1 -1
  128. package/dist/scripts/skeleton-integration-test.js +1 -1
  129. package/dist/scripts/skills-contract-tests.js +1 -1
  130. package/dist/scripts/smoke-session-gate-tests.js +29 -0
  131. package/dist/scripts/smoke.js +79 -33
  132. package/dist/scripts/state-cli-tests.js +1 -1
  133. package/dist/scripts/state-cli.js +1 -1
  134. package/dist/scripts/static-golden-tests.js +299 -0
  135. package/dist/scripts/time-eval-tests.js +1 -1
  136. package/dist/scripts/travel-timeline-tests.js +1 -1
  137. package/dist/scripts/unified-tests.js +1 -1
  138. package/dist/scripts/weather-tests.js +694 -44
  139. package/dist/scripts/z3-race-tests.js +1 -1
  140. package/dist/src/artifact-gate.js +1 -1
  141. package/dist/src/benchmark-agent-conformance.js +370 -0
  142. package/dist/src/benchmark-environment-bridge.js +384 -0
  143. package/dist/src/benchmark-headless-child-diagnostics.js +173 -0
  144. package/dist/src/benchmark-tool-isolation.js +124 -0
  145. package/dist/src/bookable-facts.js +1 -1
  146. package/dist/src/booking-saga.js +1 -1
  147. package/dist/src/booking-surface/availability-policy-v2.js +830 -0
  148. package/dist/src/booking-surface/canonical-schema.js +113 -0
  149. package/dist/src/booking-surface/contracts-v2.js +89 -0
  150. package/dist/src/booking-surface/contracts.js +46 -0
  151. package/dist/src/booking-surface/dsh-planner.js +453 -0
  152. package/dist/src/booking-surface/dsh-plugin.js +93 -0
  153. package/dist/src/booking-surface/error-codes.js +94 -0
  154. package/dist/src/booking-surface/index.js +15 -0
  155. package/dist/src/booking-surface/profile.js +68 -0
  156. package/dist/src/booking-surface/runtime-v2.js +1771 -0
  157. package/dist/src/booking-surface/runtime.js +351 -0
  158. package/dist/src/booking-surface/server-v2.js +334 -0
  159. package/dist/src/booking-surface/server.js +302 -0
  160. package/dist/src/booking-surface/startup.js +159 -0
  161. package/dist/src/booking-surface/validation-v2.js +319 -0
  162. package/dist/src/booking-surface/validation.js +809 -0
  163. package/dist/src/bridge.js +1 -1
  164. package/dist/src/companions.js +1 -1
  165. package/dist/src/contracts.js +1 -1
  166. package/dist/src/dsh-llm.js +1 -1
  167. package/dist/src/engine.js +1 -1
  168. package/dist/src/evaluation-cadence.js +234 -0
  169. package/dist/src/evaluation-contracts.js +906 -0
  170. package/dist/src/i18n.js +1 -1
  171. package/dist/src/index.js +50 -19
  172. package/dist/src/journey.js +1 -1
  173. package/dist/src/loop.js +1 -1
  174. package/dist/src/memory-capture.js +1 -1
  175. package/dist/src/memory-decay.js +1 -1
  176. package/dist/src/memory-utility.js +1 -1
  177. package/dist/src/mock-llm.js +1 -1
  178. package/dist/src/model.js +1 -1
  179. package/dist/src/realtime-pricing.js +1 -1
  180. package/dist/src/slot-spec.js +1 -1
  181. package/dist/src/state-ledger.js +2 -1
  182. package/dist/src/time-anchor.js +1 -1
  183. package/dist/src/tool-budget.js +136 -0
  184. package/dist/src/tool-packet.js +1 -1
  185. package/dist/src/travel-slots.js +1 -1
  186. package/dist/src/travel-timeline.js +1 -1
  187. package/dist/src/unified.js +1 -1
  188. package/dist/src/wish-pool.js +1 -1
  189. package/dist/src/z3-shared.js +1 -1
  190. package/extension/README.md +31 -7
  191. package/package.json +286 -11
  192. package/schemas/booking.surface.v1.schema.json +927 -0
  193. package/schemas/booking.surface.v2.schema.json +61 -0
  194. package/ts/capabilities/flyai.ts +16 -3
  195. package/ts/capabilities/session/extension-bridge.ts +37 -14
  196. package/ts/capabilities/session/extension-channel.ts +6 -3
  197. package/ts/capabilities/session/extension-distribution.ts +264 -0
  198. package/ts/capabilities/session/golden-score.ts +139 -0
  199. package/ts/capabilities/session/health-watch.ts +1 -1
  200. package/ts/capabilities/session/static-flight-golden.ts +209 -0
  201. package/ts/capabilities/session/wizard.ts +34 -176
  202. package/ts/capabilities/session-login.ts +15 -5
  203. package/ts/capabilities/session-search.ts +40 -3
  204. package/ts/capabilities/weather.ts +141 -52
  205. package/ts/package.json +3 -3
  206. package/ts/src/benchmark-agent-conformance.ts +448 -0
  207. package/ts/src/benchmark-environment-bridge.ts +348 -0
  208. package/ts/src/benchmark-headless-child-diagnostics.ts +184 -0
  209. package/ts/src/benchmark-tool-isolation.ts +166 -0
  210. package/ts/src/booking-surface/availability-policy-v2.ts +523 -0
  211. package/ts/src/booking-surface/canonical-schema.js +113 -0
  212. package/ts/src/booking-surface/contracts-v2.ts +118 -0
  213. package/ts/src/booking-surface/contracts.ts +380 -0
  214. package/ts/src/booking-surface/dsh-planner.ts +452 -0
  215. package/ts/src/booking-surface/dsh-plugin.js +93 -0
  216. package/ts/src/booking-surface/error-codes.ts +101 -0
  217. package/ts/src/booking-surface/index.ts +12 -0
  218. package/ts/src/booking-surface/profile.ts +42 -0
  219. package/ts/src/booking-surface/runtime-v2.ts +1466 -0
  220. package/ts/src/booking-surface/runtime.ts +483 -0
  221. package/ts/src/booking-surface/server-v2.ts +247 -0
  222. package/ts/src/booking-surface/server.ts +324 -0
  223. package/ts/src/booking-surface/startup.ts +196 -0
  224. package/ts/src/booking-surface/validation-v2.ts +205 -0
  225. package/ts/src/booking-surface/validation.ts +453 -0
  226. package/ts/src/index.ts +64 -11
  227. package/ts/src/state-ledger.ts +1 -0
  228. package/ts/src/tool-budget.ts +165 -0
  229. package/dist/scripts/wizard-bootstrap.js +0 -32
@@ -0,0 +1,523 @@
1
+ import type { ActionReceiptV2, BookingReadActionV2, BookingWorkspaceSnapshotV2 } from './contracts-v2.ts'
2
+ import type { OfferCriteriaV1 } from './contracts.ts'
3
+ import { createHash } from 'node:crypto'
4
+
5
+ /** Typed CheckAvail budget. This module never parses prose or retries a supplier. */
6
+ export const MAX_HOTELS_PER_TASK_V2 = 5
7
+ export const MAX_OFFERS_PER_HOTEL_GENERATION_V2 = 3
8
+ export const MAX_OFFER_CHECKS_PER_HOTEL_V2 = 2
9
+ export const MAX_OFFER_QUERIES_PER_HOTEL_V2 = 2
10
+
11
+ export type AvailabilityHotelStatusV2 = 'unvisited' | 'active' | 'negative' | 'confirmed' | 'inconclusive'
12
+ export type AvailabilityGenerationSourceV2 =
13
+ | { kind: 'query_receipt'; actionId: string; receiptDigest: string; workspaceRevision: number }
14
+ | { kind: 'workspace_snapshot'; workspaceDigest: string; workspaceRevision: number }
15
+ export interface AvailabilityGenerationV2 { generationId: string; source: AvailabilityGenerationSourceV2; offerSetDigest: string; orderedOfferRefs: string[]; evidence: 'complete' | 'partial'; valid: boolean }
16
+ export interface AvailabilityHotelStateV2 {
17
+ hotelRef: string; status: AvailabilityHotelStatusV2; checksIssued: number; offerQueriesIssued: number; generationNo: number
18
+ currentGeneration?: AvailabilityGenerationV2
19
+ /** Compatibility projection for callers needing current refs only. */
20
+ generation: number; currentOfferRefs: string[]; invalidatedOfferRefs: string[]; checkCount: number; freshOffersRequired: boolean
21
+ tombstonedOfferRefs: string[]; tombstonedOfferVersionRefs: string[]
22
+ lastEvidence: 'none' | 'confirmed' | 'unavailable' | 'inconclusive'
23
+ }
24
+ export interface AvailabilityAttemptV2 { actionId: string; hotelRef: string; offerRef: string; offerVersionRef: string; generation: number; ordinal: number; workspaceRevision: number }
25
+ export interface AvailabilityQueryReservationV2 { actionId: string; hotelRefs: string[]; workspaceRevision: number }
26
+ export interface AvailabilityExhaustionV2 {
27
+ code: 'availability_confirmed' | 'availability_exhausted_complete' | 'availability_exhausted_inconclusive'
28
+ hotelRefs: string[]; reason: 'no_current_offers' | 'check_limit_reached' | 'confirmed'; evidence: 'conclusive' | 'inconclusive'
29
+ }
30
+ export interface AvailabilityPolicyStateV2 {
31
+ initialized: boolean; recoveryStarted: boolean
32
+ availabilityPhase: 'need_offers' | 'waiting_offers' | 'need_check' | 'waiting_check' | 'terminal'
33
+ activeHotelOrdinal: number; hotelRefs: string[]; hotels: Record<string, AvailabilityHotelStateV2>; attempts: AvailabilityAttemptV2[]; queryReservations: AvailabilityQueryReservationV2[]; terminal?: AvailabilityExhaustionV2
34
+ recoveryId?: string; criteria?: OfferCriteriaV1; criteriaDigest?: string; candidateSetDigest?: string; lastQueryHotelRefs?: string[]; lastQueryCriteriaDigest?: string
35
+ /** Receipt-authoritative mapping used when a shortlist ref is no longer loaded. */
36
+ lastQueryOfferHotels?: Record<string, string>; lastQuerySourceActionId?: string; lastQuerySourceReceiptDigest?: string; lastQueryWorkspaceRevision?: number
37
+ }
38
+ export type AvailabilityPolicyDecisionV2 =
39
+ | { ok: true; hotelRef: string; offerRef: string; offerVersionRef: string; checkCount: number; generation: number }
40
+ | { ok: false; code: 'hotel_unknown' | 'offer_not_loaded' | 'offers_refresh_required' | 'hotel_check_limit_reached' | 'availability_exhausted' | 'generation_invalid' | 'hotel_not_active' }
41
+
42
+ type AvailabilityActionLikeV2 = { kind: BookingReadActionV2['kind']; actionId: string; input: Record<string, any>; expectedRevision?: number }
43
+
44
+ function unique(values: readonly string[]): string[] { return [...new Set(values)] }
45
+ export function assertWorkspaceLoadedOfferRefsUniqueV2(workspace: BookingWorkspaceSnapshotV2): void {
46
+ if (workspace.loadedOffers.length !== new Set(workspace.loadedOffers.map((offer) => offer.offerRef)).size) throw new Error('availability_duplicate_offer_ref')
47
+ if (workspace.loadedOffers.length !== new Set(workspace.loadedOffers.map((offer) => offer.offerVersionRef)).size) throw new Error('availability_duplicate_offer_version_ref')
48
+ }
49
+ function stable(value: unknown): unknown { if (Array.isArray(value)) return value.map(stable); if (!value || typeof value !== 'object') return value; return Object.fromEntries(Object.keys(value as Record<string, unknown>).sort().map((key) => [key, stable((value as Record<string, unknown>)[key])])); }
50
+ export function digestV2(value: unknown): string { return createHash('sha256').update(JSON.stringify(stable(value))).digest('hex') }
51
+ function workspaceDigestV2(workspace: BookingWorkspaceSnapshotV2): string { const { contextRef: _context, ...bound } = workspace as unknown as Record<string, unknown>; return digestV2(bound) }
52
+ function offerRefsForHotel(workspace: BookingWorkspaceSnapshotV2, hotelRef: string, selectedOfferRef?: string): string[] {
53
+ const refs = unique(workspace.loadedOffers.filter((offer) => offer.hotelRef === hotelRef).map((offer) => offer.offerRef))
54
+ if (selectedOfferRef) return unique([selectedOfferRef, ...refs]).slice(0, MAX_OFFERS_PER_HOTEL_GENERATION_V2)
55
+ if (refs.length > MAX_OFFERS_PER_HOTEL_GENERATION_V2) throw new Error('availability_offer_limit_exceeded')
56
+ return refs
57
+ }
58
+ function loadedOfferVersion(workspace: BookingWorkspaceSnapshotV2, offerRef: string, offerVersionRef: string) { return workspace.loadedOffers.find((offer) => offer.offerRef === offerRef && offer.offerVersionRef === offerVersionRef) }
59
+ function legalOfferRefsForHotel(workspace: BookingWorkspaceSnapshotV2, hotel: AvailabilityHotelStateV2, refs: readonly string[]): string[] {
60
+ const legal = refs.filter((ref) => {
61
+ if (hotel.tombstonedOfferRefs.includes(ref)) return false
62
+ const loaded = workspace.loadedOffers.find((offer) => offer.offerRef === ref && offer.hotelRef === hotel.hotelRef)
63
+ return Boolean(loaded && !hotel.tombstonedOfferVersionRefs.includes(loaded.offerVersionRef))
64
+ })
65
+ if (legal.length > MAX_OFFERS_PER_HOTEL_GENERATION_V2) throw new Error('availability_offer_limit_exceeded')
66
+ return unique(legal)
67
+ }
68
+ function copyHotel(hotel: AvailabilityHotelStateV2): AvailabilityHotelStateV2 {
69
+ return { ...hotel, currentOfferRefs: [...hotel.currentOfferRefs], invalidatedOfferRefs: [...hotel.invalidatedOfferRefs], tombstonedOfferRefs: [...hotel.tombstonedOfferRefs], tombstonedOfferVersionRefs: [...hotel.tombstonedOfferVersionRefs], ...(hotel.currentGeneration ? { currentGeneration: { ...hotel.currentGeneration, orderedOfferRefs: [...hotel.currentGeneration.orderedOfferRefs] } } : {}) }
70
+ }
71
+ function copyState(state: AvailabilityPolicyStateV2): AvailabilityPolicyStateV2 {
72
+ return { ...state, ...(state.criteria ? { criteria: structuredClone(state.criteria) } : {}), hotelRefs: [...state.hotelRefs], hotels: Object.fromEntries(Object.entries(state.hotels).map(([ref, hotel]) => [ref, copyHotel(hotel)])), attempts: state.attempts.map((attempt) => ({ ...attempt })), queryReservations: state.queryReservations.map((query) => ({ ...query, hotelRefs: [...query.hotelRefs] })), ...(state.lastQueryHotelRefs ? { lastQueryHotelRefs: [...state.lastQueryHotelRefs] } : {}), ...(state.lastQueryOfferHotels ? { lastQueryOfferHotels: { ...state.lastQueryOfferHotels } } : {}), ...(state.terminal ? { terminal: { ...state.terminal, hotelRefs: [...state.terminal.hotelRefs] } } : {}) }
73
+ }
74
+
75
+ function isPlainRecord(value: unknown): value is Record<string, unknown> { return Boolean(value && typeof value === 'object' && !Array.isArray(value)) }
76
+ function isCriterion(value: unknown, valueCheck: (candidate: unknown) => boolean): boolean {
77
+ return isPlainRecord(value) && Object.keys(value).length === 2 && (value.strength === 'must' || value.strength === 'prefer') && valueCheck(value.value)
78
+ }
79
+ function isMoney(value: unknown): boolean {
80
+ return isPlainRecord(value) && Object.keys(value).length === 3 && typeof value.amount === 'string' && Boolean(value.amount) && typeof value.currency === 'string' && Boolean(value.currency) && typeof value.sourceFactRef === 'string' && Boolean(value.sourceFactRef)
81
+ }
82
+ function isOfferCriteriaV2(value: unknown): value is OfferCriteriaV1 {
83
+ if (!isPlainRecord(value)) return false
84
+ const allowed = ['roomType', 'bedType', 'meals', 'freeCancellation', 'freeCancellationUntil', 'totalPriceMax', 'roomsAvailableMin', 'payAtProperty', 'mobileRate', 'targetCount', 'sort']
85
+ if (Object.keys(value).some((key) => !allowed.includes(key))) return false
86
+ const strings = (candidate: unknown): boolean => Array.isArray(candidate) && candidate.every((item) => typeof item === 'string' && Boolean(item))
87
+ if (value.roomType !== undefined && !isCriterion(value.roomType, strings)) return false
88
+ if (value.bedType !== undefined && !isCriterion(value.bedType, strings)) return false
89
+ if (value.meals !== undefined && !isCriterion(value.meals, strings)) return false
90
+ if (value.freeCancellation !== undefined && !isCriterion(value.freeCancellation, (candidate) => typeof candidate === 'boolean')) return false
91
+ if (value.freeCancellationUntil !== undefined && !isCriterion(value.freeCancellationUntil, (candidate) => typeof candidate === 'string' && Boolean(candidate))) return false
92
+ if (value.totalPriceMax !== undefined && !isCriterion(value.totalPriceMax, isMoney)) return false
93
+ if (value.roomsAvailableMin !== undefined && !isCriterion(value.roomsAvailableMin, (candidate) => Number.isSafeInteger(candidate) && (candidate as number) >= 0)) return false
94
+ if (value.payAtProperty !== undefined && !isCriterion(value.payAtProperty, (candidate) => typeof candidate === 'boolean')) return false
95
+ if (value.mobileRate !== undefined && !isCriterion(value.mobileRate, (candidate) => typeof candidate === 'boolean')) return false
96
+ if (value.targetCount !== undefined && (!Number.isSafeInteger(value.targetCount) || (value.targetCount as number) < 0)) return false
97
+ if (value.sort !== undefined && !['best_match', 'total_price_asc', 'cancellation_latest'].includes(value.sort as string)) return false
98
+ return true
99
+ }
100
+ function bindCriteria(next: AvailabilityPolicyStateV2, criteriaDigest: string, criteria?: OfferCriteriaV1, required = false): void {
101
+ if (criteria !== undefined) {
102
+ if (!isOfferCriteriaV2(criteria) || digestV2(criteria) !== criteriaDigest) throw new Error('availability_criteria_invalid')
103
+ if (next.criteriaDigest !== undefined && next.criteriaDigest !== criteriaDigest) throw new Error('availability_criteria_changed')
104
+ if (next.criteria && digestV2(next.criteria) !== criteriaDigest) throw new Error('availability_criteria_changed')
105
+ next.criteria = structuredClone(criteria); next.criteriaDigest = criteriaDigest
106
+ } else if (required && !next.criteria) throw new Error('availability_criteria_required')
107
+ else if (next.criteria && next.criteriaDigest !== digestV2(next.criteria)) throw new Error('availability_criteria_mismatch')
108
+ else if (!next.criteria && criteriaDigest) {
109
+ if (next.criteriaDigest !== undefined && next.criteriaDigest !== criteriaDigest) throw new Error('availability_criteria_changed')
110
+ next.criteriaDigest = criteriaDigest
111
+ }
112
+ if (next.criteriaDigest !== undefined && next.criteriaDigest !== criteriaDigest && criteriaDigest) throw new Error('availability_criteria_changed')
113
+ }
114
+ function terminalFor(state: AvailabilityPolicyStateV2): AvailabilityExhaustionV2 | undefined {
115
+ if (!state.recoveryStarted || !state.hotelRefs.length) return undefined
116
+ const hotels = state.hotelRefs.map((ref) => state.hotels[ref]!)
117
+ if (hotels.some((hotel) => hotel.status === 'confirmed')) return { code: 'availability_confirmed', hotelRefs: [...state.hotelRefs], reason: 'confirmed', evidence: 'conclusive' }
118
+ // A negative result is conclusive only when it has no legal refresh left.
119
+ // The first manual check starts with a workspace generation (zero recovery
120
+ // queries), so it must leave a fresh-query transition available.
121
+ if (hotels.some((hotel) =>
122
+ hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL_V2
123
+ && (
124
+ hotel.status === 'active'
125
+ || hotel.status === 'unvisited'
126
+ || (hotel.freshOffersRequired && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL_V2)
127
+ )
128
+ )) return undefined
129
+ const conclusive = hotels.every((hotel) => hotel.status === 'negative')
130
+ return { code: conclusive ? 'availability_exhausted_complete' : 'availability_exhausted_inconclusive', hotelRefs: [...state.hotelRefs], reason: hotels.every((hotel) => !hotel.currentOfferRefs.length) ? 'no_current_offers' : 'check_limit_reached', evidence: conclusive ? 'conclusive' : 'inconclusive' }
131
+ }
132
+ function withTerminal(state: AvailabilityPolicyStateV2): AvailabilityPolicyStateV2 { const terminal = terminalFor(state); return terminal ? { ...state, terminal, availabilityPhase: 'terminal' } : state }
133
+ function newHotel(hotelRef: string): AvailabilityHotelStateV2 { return { hotelRef, status: 'unvisited', checksIssued: 0, offerQueriesIssued: 0, generationNo: 0, generation: 0, currentOfferRefs: [], invalidatedOfferRefs: [], checkCount: 0, freshOffersRequired: false, tombstonedOfferRefs: [], tombstonedOfferVersionRefs: [], lastEvidence: 'none' } }
134
+ function phaseForActiveHotel(state: AvailabilityPolicyStateV2): 'need_offers' | 'need_check' {
135
+ const active = state.hotels[state.hotelRefs[state.activeHotelOrdinal] ?? '']
136
+ return active?.status === 'active' && active.currentGeneration?.valid && active.currentOfferRefs.length > 0 && !active.freshOffersRequired
137
+ ? 'need_check'
138
+ : 'need_offers'
139
+ }
140
+
141
+ /** Initial workspace is only a hint; the bounded recovery starts at typed offers.query. */
142
+ export function createAvailabilityPolicyV2(workspace: BookingWorkspaceSnapshotV2): AvailabilityPolicyStateV2 { assertWorkspaceLoadedOfferRefsUniqueV2(workspace); return { initialized: false, recoveryStarted: false, availabilityPhase: 'need_offers', activeHotelOrdinal: 0, hotelRefs: [], hotels: {}, attempts: [], queryReservations: [] } }
143
+ /** Ordinary search results never get silently truncated into recovery candidates. */
144
+ export function recordVisibleHotelsV2(state: AvailabilityPolicyStateV2, _workspace: BookingWorkspaceSnapshotV2): AvailabilityPolicyStateV2 { return copyState(state) }
145
+ /** Observe a normal offers query without opening an availability recovery. */
146
+ export function recordObservedOffersQueryV2(state: AvailabilityPolicyStateV2, hotelRefs: readonly string[], criteriaDigest: string, workspace?: BookingWorkspaceSnapshotV2, receipt?: ActionReceiptV2, sourceActionId?: string, receiptDigest?: string, criteria?: OfferCriteriaV1): AvailabilityPolicyStateV2 {
147
+ if (workspace) assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
148
+ if (state.recoveryStarted) return copyState(state)
149
+ const next = copyState(state)
150
+ if (!workspace || !receipt) return next
151
+ delete next.lastQueryHotelRefs; delete next.lastQueryCriteriaDigest; delete next.lastQueryOfferHotels; delete next.lastQuerySourceActionId; delete next.lastQuerySourceReceiptDigest; delete next.lastQueryWorkspaceRevision
152
+ const completeReceipt = receipt?.status === 'applied'
153
+ && receipt.resultContract.outcome === 'complete'
154
+ && receipt.resultContract.hardCriteriaMet
155
+ && !receipt.resultContract.gapCodes.length
156
+ && !receipt.resultContract.blockers.length
157
+ && receipt.observation.kind === 'offers.state'
158
+ && !receipt.observation.gapCodes?.length
159
+ if (completeReceipt && receipt.observation.kind === 'offers.state') {
160
+ if (!criteria) throw new Error('availability_criteria_required')
161
+ // Ordinary queries establish the latest recovery seed. Before recovery
162
+ // starts, a newer complete query is allowed to replace an older seed.
163
+ delete next.criteria
164
+ delete next.criteriaDigest
165
+ bindCriteria(next, criteriaDigest, criteria)
166
+ if (!sourceActionId || sourceActionId !== receipt.actionId || !receiptDigest || receiptDigest !== digestV2(receipt)) throw new Error('availability_query_provenance_mismatch')
167
+ next.lastQueryHotelRefs = [...new Set(hotelRefs)]
168
+ next.lastQueryCriteriaDigest = criteriaDigest || undefined
169
+ next.lastQueryOfferHotels = validateOffersReceiptWorkspaceV2(receipt, workspace, hotelRefs)
170
+ next.lastQuerySourceActionId = sourceActionId
171
+ next.lastQuerySourceReceiptDigest = receiptDigest
172
+ next.lastQueryWorkspaceRevision = workspace.revision
173
+ } else {
174
+ // The latest ordinary query is the only candidate provenance. A failed
175
+ // or partial query must not leave an older criteria object usable for a
176
+ // later manual recovery.
177
+ delete next.criteria
178
+ delete next.criteriaDigest
179
+ }
180
+ return next
181
+ }
182
+
183
+ /**
184
+ * Reconcile an offers receipt against the post-action workspace before its
185
+ * provenance can influence recovery. Complete receipts must describe exactly
186
+ * the authoritative loaded offers; partial/gap receipts may only describe an
187
+ * evidenced subset. An unreported offer is never silently admitted.
188
+ */
189
+ export function validateOffersReceiptWorkspaceV2(receipt: ActionReceiptV2, workspace: BookingWorkspaceSnapshotV2, hotelRefs: readonly string[]): Record<string, string> {
190
+ assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
191
+ if (receipt.observation.kind !== 'offers.state') throw new Error('availability_offers_observation_required')
192
+ const requested = unique(hotelRefs)
193
+ if (!sameSet(receipt.observation.hotelRefs, requested)) throw new Error('availability_offers_hotel_mismatch')
194
+ const authoritative = workspace.loadedOffers.filter((offer) => requested.includes(offer.hotelRef))
195
+ const authoritativeRefs = unique(authoritative.map((offer) => offer.offerRef))
196
+ const observedRefs = unique(receipt.observation.offerRefs)
197
+ if (observedRefs.some((offerRef) => !authoritativeRefs.includes(offerRef))) throw new Error('availability_offers_unreported')
198
+ const authoritativeHotels = new Set(authoritative.map((offer) => offer.hotelRef)).size
199
+ const observedOfferHotels = Object.fromEntries(observedRefs.map((offerRef) => [offerRef, authoritative.find((offer) => offer.offerRef === offerRef)!.hotelRef]))
200
+ const observedHotels = new Set(Object.values(observedOfferHotels)).size
201
+ const clean = !receipt.resultContract.gapCodes.length && !receipt.resultContract.blockers.length && !receipt.observation.gapCodes?.length
202
+ if ((receipt.status === 'no_match' || receipt.status === 'unavailable') && (receipt.resultContract.outcome !== 'empty' || observedRefs.length !== 0 || receipt.observation.loadedHotelCount !== 0)) throw new Error('availability_offers_receipt_incoherent')
203
+ const complete = receipt.status === 'applied' && receipt.resultContract.outcome === 'complete' && receipt.resultContract.hardCriteriaMet && clean
204
+ const exactEmpty = clean && receipt.resultContract.outcome === 'empty' && !['changed', 'partial', 'stale', 'failed', 'unsupported'].includes(receipt.status)
205
+ if ((complete || exactEmpty) && (!sameSet(observedRefs, authoritativeRefs) || receipt.observation.loadedHotelCount !== authoritativeHotels)) throw new Error('availability_offers_workspace_mismatch')
206
+ if (receipt.observation.loadedHotelCount !== observedHotels) throw new Error('availability_offers_count_mismatch')
207
+ return observedOfferHotels
208
+ }
209
+
210
+ function sameSet(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && unique(a).sort().every((value, index) => value === unique(b).sort()[index]) }
211
+
212
+ /** A typed offers.query receipt starts/advances a bounded candidate recovery. */
213
+ export function recordOffersQueryIssuedV2(state: AvailabilityPolicyStateV2, hotelRefs: readonly string[], workspace: BookingWorkspaceSnapshotV2, actionId: string, criteriaDigest = '', criteria?: OfferCriteriaV1): AvailabilityPolicyStateV2 {
214
+ assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
215
+ const next = copyState(state); const requested = unique(hotelRefs)
216
+ if (!requested.length || requested.length > MAX_HOTELS_PER_TASK_V2) throw new Error('availability_hotel_limit_exceeded')
217
+ if (!state.recoveryStarted) throw new Error('availability_recovery_not_started')
218
+ if (state.terminal) {
219
+ if (requested.some((ref) => state.hotels[ref]?.offerQueriesIssued >= MAX_OFFER_QUERIES_PER_HOTEL_V2)) throw new Error('availability_offer_query_limit_reached')
220
+ throw new Error('availability_terminal')
221
+ }
222
+ if (requested.some((ref) => !next.hotels[ref])) throw new Error('availability_hotel_unknown')
223
+ else if ((requested.length !== 1 || next.hotelRefs[next.activeHotelOrdinal] !== requested[0]) && (next.attempts.length > 0 || Object.values(next.hotels).some((hotel) => hotel.generationNo > 0))) throw new Error('availability_hotel_not_active')
224
+ bindCriteria(next, criteriaDigest, criteria)
225
+ const existingReservation = next.queryReservations.find((reservation) => reservation.actionId === actionId)
226
+ if (existingReservation) {
227
+ if (!sameSet(existingReservation.hotelRefs, requested) || existingReservation.workspaceRevision !== workspace.revision) throw new Error('availability_query_action_conflict')
228
+ return next
229
+ }
230
+ for (const hotelRef of requested) { const hotel = next.hotels[hotelRef]!; if (hotel.offerQueriesIssued >= MAX_OFFER_QUERIES_PER_HOTEL_V2) throw new Error('availability_offer_query_limit_reached'); hotel.offerQueriesIssued += 1 }
231
+ next.queryReservations.push({ actionId, hotelRefs: [...requested], workspaceRevision: workspace.revision }); next.availabilityPhase = 'waiting_offers'; next.initialized = true; return next
232
+ }
233
+ export function recordOffersGenerationV2(state: AvailabilityPolicyStateV2, hotelRefs: readonly string[], workspace: BookingWorkspaceSnapshotV2, actionId: string, receiptDigest: string, receipt: ActionReceiptV2, criteriaDigest = '', criteria?: OfferCriteriaV1): AvailabilityPolicyStateV2 {
234
+ assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
235
+ if (!state.recoveryStarted) return copyState(state)
236
+ if (state.terminal) throw new Error('availability_terminal')
237
+ const next = copyState(state); const requested = unique(hotelRefs)
238
+ if (!requested.length || requested.length > MAX_HOTELS_PER_TASK_V2) throw new Error('availability_hotel_limit_exceeded')
239
+ const reservation = next.queryReservations.find((query) => query.actionId === actionId)
240
+ if (!reservation || !sameSet(reservation.hotelRefs, requested)) throw new Error('availability_query_not_reserved')
241
+ if (receipt.actionId !== actionId) throw new Error('availability_query_receipt_mismatch')
242
+ if (receiptDigest !== digestV2(receipt)) throw new Error('availability_receipt_digest_mismatch')
243
+ if (requested.some((ref) => !next.hotels[ref])) throw new Error('availability_hotel_unknown')
244
+ else if ((requested.length !== 1 || next.hotelRefs[next.activeHotelOrdinal] !== requested[0]) && (next.attempts.length > 0 || Object.values(next.hotels).some((hotel) => hotel.generationNo > 0))) throw new Error('availability_hotel_not_active')
245
+ bindCriteria(next, criteriaDigest, criteria)
246
+ if (receipt.observation.kind === 'offers.state') validateOffersReceiptWorkspaceV2(receipt, workspace, requested)
247
+ for (const hotelRef of requested) {
248
+ const hotel = next.hotels[hotelRef]!
249
+ const partial = receipt.status === 'changed' || receipt.status === 'partial' || receipt.status === 'failed' || receipt.status === 'stale' || receipt.resultContract.outcome === 'partial' || (receipt.resultContract.outcome === 'complete' && !receipt.resultContract.hardCriteriaMet) || Boolean(receipt.resultContract.blockers.length) || Boolean(receipt.resultContract.gapCodes.length) || Boolean(receipt.observation.kind === 'gap' || (receipt.observation.kind === 'offers.state' && receipt.observation.gapCodes?.length))
250
+ const candidateRefs = partial
251
+ ? receipt.observation.kind === 'offers.state'
252
+ ? receipt.observation.offerRefs.filter((ref) => workspace.loadedOffers.some((offer) => offer.offerRef === ref && offer.hotelRef === hotelRef))
253
+ : []
254
+ : offerRefsForHotel(workspace, hotelRef)
255
+ const refs = legalOfferRefsForHotel(workspace, hotel, candidateRefs)
256
+ const unusable = ['stale', 'failed', 'unsupported'].includes(receipt.status)
257
+ if (unusable) { hotel.currentOfferRefs = []; hotel.invalidatedOfferRefs = [...(hotel.currentGeneration?.orderedOfferRefs ?? [])]; hotel.freshOffersRequired = hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL_V2 && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL_V2; hotel.status = 'inconclusive'; hotel.lastEvidence = 'inconclusive'; continue }
258
+ const filteredAllCandidates = candidateRefs.length > 0 && refs.length === 0
259
+ const noLegalRefsNeedsRefresh = filteredAllCandidates && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL_V2
260
+ hotel.generationNo += 1; hotel.generation = hotel.generationNo; hotel.currentOfferRefs = partial || noLegalRefsNeedsRefresh ? [] : refs; hotel.invalidatedOfferRefs = partial || noLegalRefsNeedsRefresh ? candidateRefs : []; hotel.freshOffersRequired = (partial || noLegalRefsNeedsRefresh) && hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL_V2 && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL_V2; hotel.status = partial || noLegalRefsNeedsRefresh ? 'inconclusive' : refs.length ? 'active' : 'negative'; hotel.lastEvidence = partial || noLegalRefsNeedsRefresh ? 'inconclusive' : refs.length ? 'none' : 'unavailable'
261
+ hotel.currentGeneration = { generationId: `${hotelRef}:generation:${hotel.generationNo}`, source: { kind: 'query_receipt', actionId, receiptDigest, workspaceRevision: workspace.revision }, offerSetDigest: digestV2(refs), orderedOfferRefs: refs, evidence: partial ? 'partial' : 'complete', valid: refs.length > 0 && !partial }
262
+ }
263
+ const firstRequested = next.hotelRefs.indexOf(requested[0]!)
264
+ if (firstRequested >= 0) next.activeHotelOrdinal = firstRequested
265
+ const queried = next.hotels[requested[0]!]!
266
+ if (!queried.currentOfferRefs.length && !queried.freshOffersRequired) {
267
+ const fallback = next.hotelRefs.findIndex((ref) => ['active', 'unvisited'].includes(next.hotels[ref]!.status))
268
+ if (fallback >= 0) next.activeHotelOrdinal = fallback
269
+ }
270
+ next.queryReservations = next.queryReservations.filter((query) => query.actionId !== actionId)
271
+ next.initialized = true; next.availabilityPhase = phaseForActiveHotel(next); delete next.terminal; return withTerminal(next)
272
+ }
273
+
274
+ export function canIssueOfferCheckV2(state: AvailabilityPolicyStateV2, workspace: BookingWorkspaceSnapshotV2, offerRef: string, offerVersionRef: string): AvailabilityPolicyDecisionV2 {
275
+ assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
276
+ // Availability recovery is opt-in at the typed offers.query seam. Existing
277
+ // v2 read flows retain their pre-recovery offer.check behavior.
278
+ if (!state.recoveryStarted) {
279
+ const loaded = loadedOfferVersion(workspace, offerRef, offerVersionRef)
280
+ return loaded
281
+ ? { ok: true, hotelRef: loaded.hotelRef, offerRef, offerVersionRef, checkCount: 0, generation: 0 }
282
+ : { ok: false, code: 'offer_not_loaded' }
283
+ }
284
+ if (state.terminal) return { ok: false, code: 'availability_exhausted' }
285
+ const loaded = loadedOfferVersion(workspace, offerRef, offerVersionRef); if (!loaded) return { ok: false, code: 'offer_not_loaded' }
286
+ const hotel = state.hotels[loaded.hotelRef]; if (!hotel) return { ok: false, code: 'hotel_unknown' }
287
+ if (state.hotelRefs[state.activeHotelOrdinal] !== loaded.hotelRef) return { ok: false, code: 'hotel_not_active' }
288
+ if (hotel.freshOffersRequired) return { ok: false, code: 'offers_refresh_required' }
289
+ if (hotel.tombstonedOfferRefs.includes(offerRef) || hotel.tombstonedOfferVersionRefs.includes(offerVersionRef)) return { ok: false, code: 'generation_invalid' }
290
+ if (!hotel.currentGeneration?.valid || !hotel.currentGeneration.orderedOfferRefs.includes(offerRef) || hotel.invalidatedOfferRefs.includes(offerRef)) return { ok: false, code: 'generation_invalid' }
291
+ if (hotel.checksIssued >= MAX_OFFER_CHECKS_PER_HOTEL_V2) return { ok: false, code: 'hotel_check_limit_reached' }
292
+ return { ok: true, hotelRef: loaded.hotelRef, offerRef, offerVersionRef, checkCount: hotel.checksIssued + 1, generation: hotel.generationNo }
293
+ }
294
+ /** Start the bounded recovery epoch at the first typed offer.check. */
295
+ function startRecoveryAtOfferV2(state: AvailabilityPolicyStateV2, workspace: BookingWorkspaceSnapshotV2, offerRef: string, actionId: string): AvailabilityPolicyStateV2 {
296
+ assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
297
+ const loaded = workspace.loadedOffers.find((offer) => offer.offerRef === offerRef)
298
+ if (!loaded) return copyState(state)
299
+ const shortlistHotels = workspace.shortlistedOfferRefs.map((ref) => workspace.loadedOffers.find((offer) => offer.offerRef === ref)?.hotelRef ?? state.lastQueryOfferHotels?.[ref]).map((ref, index) => {
300
+ if (!ref) throw new Error(`availability_shortlist_unmapped:${workspace.shortlistedOfferRefs[index]}`)
301
+ return ref
302
+ })
303
+ const fallbackHotels = shortlistHotels.length ? shortlistHotels : []
304
+ const candidates = unique([loaded.hotelRef, ...fallbackHotels]).slice(0, MAX_HOTELS_PER_TASK_V2)
305
+ const next = copyState(state); next.recoveryStarted = true; next.recoveryId = `recovery:${actionId}`; next.hotelRefs = candidates; next.candidateSetDigest = digestV2(candidates); next.hotels = Object.fromEntries(candidates.map((ref) => [ref, newHotel(ref)]))
306
+ const nextHotel = next.hotels[loaded.hotelRef]!
307
+ const mappedRefs = state.lastQueryOfferHotels ? Object.entries(state.lastQueryOfferHotels).filter(([, hotel]) => hotel === loaded.hotelRef).map(([ref]) => ref) : []
308
+ const currentRefs = unique(workspace.loadedOffers.filter((offer) => offer.hotelRef === loaded.hotelRef).map((offer) => offer.offerRef))
309
+ const mapped = state.lastQueryOfferHotels?.[offerRef] === loaded.hotelRef
310
+ && state.lastQueryWorkspaceRevision === workspace.revision
311
+ && Boolean(state.lastQuerySourceActionId && state.lastQuerySourceReceiptDigest)
312
+ && sameSet(mappedRefs, currentRefs)
313
+ if (mapped && state.criteria) { next.criteria = structuredClone(state.criteria); next.criteriaDigest = digestV2(next.criteria) }
314
+ const refs = mapped && state.lastQueryOfferHotels
315
+ ? unique([offerRef, ...mappedRefs]).slice(0, MAX_OFFERS_PER_HOTEL_GENERATION_V2)
316
+ : offerRefsForHotel(workspace, loaded.hotelRef, offerRef)
317
+ const source = mapped
318
+ ? { kind: 'query_receipt' as const, actionId: state.lastQuerySourceActionId!, receiptDigest: state.lastQuerySourceReceiptDigest!, workspaceRevision: state.lastQueryWorkspaceRevision! }
319
+ : { kind: 'workspace_snapshot' as const, workspaceDigest: workspaceDigestV2(workspace), workspaceRevision: workspace.revision }
320
+ nextHotel.generationNo = 1; nextHotel.generation = 1; nextHotel.currentOfferRefs = refs; nextHotel.status = refs.length ? 'active' : 'inconclusive'; nextHotel.currentGeneration = { generationId: `${loaded.hotelRef}:generation:1`, source, offerSetDigest: digestV2(refs), orderedOfferRefs: refs, evidence: 'complete', valid: refs.length > 0 }; next.initialized = true; next.availabilityPhase = 'need_check'; return next
321
+ }
322
+ export function recordOfferCheckIssuedV2(state: AvailabilityPolicyStateV2, workspace: BookingWorkspaceSnapshotV2, offerRef: string, offerVersionRef: string, actionId = `offer-check-${offerRef}-${state.attempts.length + 1}`): AvailabilityPolicyStateV2 {
323
+ assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
324
+ if (state.terminal) throw new Error('availability_terminal')
325
+ if (!state.recoveryStarted) {
326
+ if (!loadedOfferVersion(workspace, offerRef, offerVersionRef)) throw new Error('availability_offer_not_loaded')
327
+ const started = startRecoveryAtOfferV2(state, workspace, offerRef, actionId)
328
+ return recordOfferCheckIssuedV2(started, workspace, offerRef, offerVersionRef, actionId)
329
+ }
330
+ const decision = canIssueOfferCheckV2(state, workspace, offerRef, offerVersionRef); if (!decision.ok) throw new Error(`availability_${decision.code}`)
331
+ const next = copyState(state); const hotel = next.hotels[decision.hotelRef]!; hotel.checksIssued += 1; hotel.checkCount = hotel.checksIssued; next.attempts.push({ actionId, hotelRef: decision.hotelRef, offerRef, offerVersionRef, generation: hotel.generationNo, ordinal: hotel.checksIssued, workspaceRevision: workspace.revision }); hotel.currentGeneration!.valid = false; next.availabilityPhase = 'waiting_check'; return next
332
+ }
333
+
334
+ /**
335
+ * Fold the availability portion of an issued action. Keeping this reducer
336
+ * next to the receipt reducers makes ledger replay use precisely the same
337
+ * transition as the write path.
338
+ */
339
+ export function reduceAvailabilityActionV2(
340
+ state: AvailabilityPolicyStateV2,
341
+ workspace: BookingWorkspaceSnapshotV2,
342
+ action: AvailabilityActionLikeV2,
343
+ ): AvailabilityPolicyStateV2 {
344
+ if (action.kind === 'offers.query') {
345
+ if (!isOfferCriteriaV2(action.input.criteria) && (!state.criteria || !isOfferCriteriaV2(state.criteria))) throw new Error('availability_criteria_required')
346
+ if (!isOfferCriteriaV2(action.input.criteria) && state.recoveryStarted) throw new Error('availability_criteria_required')
347
+ if (!isOfferCriteriaV2(action.input.criteria)) throw new Error('availability_criteria_required')
348
+ const criteriaDigest = digestV2(action.input.criteria)
349
+ let next = recordObservedOffersQueryV2(state, action.input.hotelRefs, criteriaDigest)
350
+ if (state.recoveryStarted) next = recordOffersQueryIssuedV2(next, action.input.hotelRefs, workspace, action.actionId, criteriaDigest, action.input.criteria)
351
+ return next
352
+ }
353
+ if (action.kind === 'offer.check') return recordOfferCheckIssuedV2(state, workspace, action.input.offerRef, action.input.offerVersionRef, action.actionId)
354
+ return copyState(state)
355
+ }
356
+
357
+ /** Fold a receipt against the exact durable attempt. Non-confirming results invalidate the whole generation. */
358
+ export function recordOfferCheckReceiptV2(state: AvailabilityPolicyStateV2, workspace: BookingWorkspaceSnapshotV2, receipt: ActionReceiptV2, actionId: string, offerRef: string, offerVersionRef: string, expectedRevision: number): AvailabilityPolicyStateV2 {
359
+ assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
360
+ if (!state.recoveryStarted) return copyState(state)
361
+ const attempt = state.attempts.find((candidate) => candidate.actionId === actionId); if (!attempt) throw new Error('availability_attempt_unknown')
362
+ if (receipt.actionId !== actionId) throw new Error('availability_attempt_mismatch')
363
+ if (receipt.revision !== workspace.revision || receipt.revision < expectedRevision) throw new Error('availability_stale_receipt')
364
+ const targetOfferRef = receipt.observation.kind === 'offer.availability' ? receipt.observation.offerRef : offerRef
365
+ const targetVersionRef = receipt.observation.kind === 'offer.availability' ? receipt.observation.checkedOfferVersionRef : offerVersionRef
366
+ if (attempt.offerRef !== targetOfferRef || attempt.offerVersionRef !== targetVersionRef || offerRef !== targetOfferRef || offerVersionRef !== targetVersionRef || attempt.workspaceRevision !== expectedRevision) throw new Error('availability_attempt_mismatch')
367
+ const next = copyState(state); const hotel = next.hotels[attempt.hotelRef]; if (!hotel || !hotel.currentGeneration || attempt.generation !== hotel.generationNo) throw new Error('availability_generation_mismatch')
368
+ const observation = receipt.observation.kind === 'offer.availability' ? receipt.observation : undefined
369
+ if (['applied', 'changed', 'unavailable', 'no_match'].includes(receipt.status) && !observation) throw new Error('availability_observation_required')
370
+ if (observation?.available && (receipt.resultContract.outcome === 'empty' || ['unavailable', 'no_match', 'failed', 'stale', 'unsupported'].includes(receipt.status))) throw new Error('availability_receipt_incoherent')
371
+ if (observation && !observation.available && (observation.verifiedOfferRef || observation.currentOfferVersionRef)) throw new Error('availability_receipt_incoherent')
372
+ if (['unavailable', 'no_match'].includes(receipt.status) && receipt.resultContract.outcome !== 'empty') throw new Error('availability_receipt_incoherent')
373
+ const confirmed = receipt.status === 'applied' && Boolean(observation?.available && observation.currentOfferVersionRef === offerVersionRef && observation.verifiedOfferRef && workspace.verifiedOffer?.offerRef === offerRef && workspace.verifiedOffer.offerVersionRef === offerVersionRef && workspace.verifiedOffer.verifiedOfferRef === observation.verifiedOfferRef && loadedOfferVersion(workspace, offerRef, offerVersionRef)) && !observation?.changedFactRefs.length && !observation?.gapCodes?.length && receipt.resultContract.outcome === 'complete' && receipt.resultContract.hardCriteriaMet && !receipt.resultContract.blockers.length && !receipt.resultContract.gapCodes.length
374
+ if (confirmed) { hotel.status = 'confirmed'; hotel.lastEvidence = 'confirmed'; hotel.currentGeneration.valid = false; return withTerminal(next) }
375
+ if (receipt.status === 'changed' && observation?.available && observation.currentOfferVersionRef && observation.currentOfferVersionRef !== offerVersionRef && observation.changedFactRefs.length && !observation.verifiedOfferRef && loadedOfferVersion(workspace, offerRef, observation.currentOfferVersionRef)) {
376
+ hotel.tombstonedOfferVersionRefs = unique([...hotel.tombstonedOfferVersionRefs, offerVersionRef])
377
+ hotel.currentGeneration.valid = true; hotel.invalidatedOfferRefs = []; hotel.currentOfferRefs = [offerRef]; hotel.freshOffersRequired = false; hotel.status = 'active'; hotel.lastEvidence = 'none'; next.availabilityPhase = 'need_check'; return withTerminal(next)
378
+ }
379
+ const completeNegative = (receipt.status === 'unavailable' || receipt.status === 'no_match') && receipt.resultContract.outcome === 'empty' && observation?.available === false && receipt.resultContract.gapCodes.length === 0 && !receipt.resultContract.blockers.length && !observation.gapCodes?.length
380
+ if (completeNegative) {
381
+ hotel.tombstonedOfferVersionRefs = unique([...hotel.tombstonedOfferVersionRefs, offerVersionRef])
382
+ hotel.tombstonedOfferRefs = unique([...hotel.tombstonedOfferRefs, offerRef])
383
+ }
384
+ hotel.currentGeneration.valid = false; hotel.invalidatedOfferRefs = [...hotel.currentGeneration.orderedOfferRefs]; hotel.currentOfferRefs = []; hotel.freshOffersRequired = hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL_V2 && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL_V2
385
+ hotel.status = completeNegative ? 'negative' : 'inconclusive'; hotel.lastEvidence = completeNegative ? 'unavailable' : 'inconclusive'
386
+ const currentOrdinal = next.hotelRefs.indexOf(attempt.hotelRef)
387
+ const nextOrdinal = hotel.freshOffersRequired ? currentOrdinal : next.hotelRefs.findIndex((ref) => ['active', 'unvisited'].includes(next.hotels[ref]!.status))
388
+ if (nextOrdinal >= 0) next.activeHotelOrdinal = nextOrdinal
389
+ next.availabilityPhase = phaseForActiveHotel(next); return withTerminal(next)
390
+ }
391
+
392
+ /** Fold the availability portion of a receipt using the same pure reducer as
393
+ * continueWithReceipt and resumeTask. */
394
+ export function reduceAvailabilityReceiptV2(
395
+ state: AvailabilityPolicyStateV2,
396
+ workspace: BookingWorkspaceSnapshotV2,
397
+ receipt: ActionReceiptV2,
398
+ action: AvailabilityActionLikeV2,
399
+ ): AvailabilityPolicyStateV2 {
400
+ let next = recordVisibleHotelsV2(state, workspace)
401
+ if (action.kind === 'offers.query' && !state.recoveryStarted) {
402
+ if (!isOfferCriteriaV2(action.input.criteria)) throw new Error('availability_criteria_required')
403
+ next = recordObservedOffersQueryV2(next, action.input.hotelRefs, digestV2(action.input.criteria), workspace, receipt, action.actionId, digestV2(receipt), action.input.criteria)
404
+ } else if (action.kind === 'offers.query' && state.recoveryStarted) {
405
+ if (!isOfferCriteriaV2(action.input.criteria)) throw new Error('availability_criteria_required')
406
+ next = recordOffersGenerationV2(next, action.input.hotelRefs, workspace, action.actionId, digestV2(receipt), receipt, state.criteriaDigest ?? '', action.input.criteria)
407
+ } else if (action.kind === 'offer.check') {
408
+ if (action.expectedRevision === undefined) throw new Error('availability_expected_revision_missing')
409
+ next = recordOfferCheckReceiptV2(next, workspace, receipt, action.actionId, action.input.offerRef, action.input.offerVersionRef, action.expectedRevision)
410
+ }
411
+ return next
412
+ }
413
+ export function availabilityPolicyIsTerminalV2(state: AvailabilityPolicyStateV2): boolean { return Boolean(state.terminal) }
414
+ export function availabilityPolicyResultV2(state: AvailabilityPolicyStateV2): AvailabilityExhaustionV2 | undefined { return state.terminal ? { ...state.terminal, hotelRefs: [...state.terminal.hotelRefs] } : undefined }
415
+ export function validateAvailabilityPolicyV2(state: AvailabilityPolicyStateV2): boolean {
416
+ try {
417
+ if (!state || typeof state.initialized !== 'boolean' || typeof state.recoveryStarted !== 'boolean') return false
418
+ if (state.recoveryId !== undefined && (typeof state.recoveryId !== 'string' || !state.recoveryId)) return false
419
+ if (state.criteriaDigest !== undefined && (typeof state.criteriaDigest !== 'string' || !/^[0-9a-f]{64}$/.test(state.criteriaDigest))) return false
420
+ if (state.candidateSetDigest !== undefined && (typeof state.candidateSetDigest !== 'string' || !/^[0-9a-f]{64}$/.test(state.candidateSetDigest))) return false
421
+ if (!['need_offers', 'waiting_offers', 'need_check', 'waiting_check', 'terminal'].includes(state.availabilityPhase)) return false
422
+ if (!Number.isSafeInteger(state.activeHotelOrdinal) || !Array.isArray(state.hotelRefs) || state.hotelRefs.length > MAX_HOTELS_PER_TASK_V2) return false
423
+ if (state.hotelRefs.some((ref) => typeof ref !== 'string' || !ref) || new Set(state.hotelRefs).size !== state.hotelRefs.length) return false
424
+ if ((state.hotelRefs.length && (state.activeHotelOrdinal < 0 || state.activeHotelOrdinal >= state.hotelRefs.length)) || (!state.hotelRefs.length && state.activeHotelOrdinal !== 0)) return false
425
+ if (!state.hotels || typeof state.hotels !== 'object' || Object.keys(state.hotels).sort().join('\0') !== [...state.hotelRefs].sort().join('\0')) return false
426
+ if (!Array.isArray(state.attempts) || !Array.isArray(state.queryReservations)) return false
427
+ if (!state.recoveryStarted) {
428
+ if (state.recoveryId !== undefined || state.candidateSetDigest !== undefined || state.hotelRefs.length || Object.keys(state.hotels).length || state.attempts.length || state.queryReservations.length || state.terminal !== undefined) return false
429
+ if (state.criteria !== undefined || state.criteriaDigest !== undefined) {
430
+ if (!isOfferCriteriaV2(state.criteria) || !state.criteriaDigest || state.criteriaDigest !== digestV2(state.criteria)) return false
431
+ }
432
+ if (state.lastQueryCriteriaDigest !== undefined && state.lastQueryCriteriaDigest !== state.criteriaDigest) return false
433
+ if (state.lastQueryHotelRefs !== undefined && (!Array.isArray(state.lastQueryHotelRefs) || new Set(state.lastQueryHotelRefs).size !== state.lastQueryHotelRefs.length || state.lastQueryHotelRefs.some((ref) => typeof ref !== 'string' || !ref))) return false
434
+ if (state.lastQueryOfferHotels !== undefined && (!state.lastQueryHotelRefs || Object.values(state.lastQueryOfferHotels).some((ref) => typeof ref !== 'string' || !state.lastQueryHotelRefs!.includes(ref)) || Object.keys(state.lastQueryOfferHotels).some((ref) => typeof ref !== 'string' || !ref))) return false
435
+ if (state.lastQuerySourceActionId !== undefined && (typeof state.lastQuerySourceActionId !== 'string' || !state.lastQuerySourceActionId || typeof state.lastQuerySourceReceiptDigest !== 'string' || !/^[0-9a-f]{64}$/.test(state.lastQuerySourceReceiptDigest) || !Number.isSafeInteger(state.lastQueryWorkspaceRevision) || state.lastQueryWorkspaceRevision! < 0)) return false
436
+ if (state.lastQuerySourceReceiptDigest !== undefined && state.lastQuerySourceActionId === undefined) return false
437
+ return state.initialized === false && state.availabilityPhase === 'need_offers'
438
+ }
439
+ if (!state.initialized || !state.recoveryId || !state.candidateSetDigest || state.candidateSetDigest !== digestV2(state.hotelRefs)) return false
440
+ const criteriaBound = isOfferCriteriaV2(state.criteria) && Boolean(state.criteriaDigest) && state.criteriaDigest === digestV2(state.criteria)
441
+ const manualCheckWorkspaceSnapshot = state.attempts.length > 0 && state.attempts.every((attempt) => state.hotels[attempt.hotelRef]?.currentGeneration?.source.kind === 'workspace_snapshot')
442
+ const manualCheckMayBindCriteria = !state.criteria && !state.criteriaDigest && state.queryReservations.length === 0 && manualCheckWorkspaceSnapshot && (
443
+ ['waiting_check', 'need_offers'].includes(state.availabilityPhase)
444
+ || state.availabilityPhase === 'need_check'
445
+ || (state.availabilityPhase === 'terminal' && state.terminal?.code === 'availability_confirmed')
446
+ )
447
+ if (!criteriaBound && !manualCheckMayBindCriteria) return false
448
+ if (state.lastQueryCriteriaDigest !== undefined && state.lastQueryCriteriaDigest !== state.criteriaDigest) return false
449
+ if (state.lastQueryHotelRefs !== undefined && (!Array.isArray(state.lastQueryHotelRefs) || new Set(state.lastQueryHotelRefs).size !== state.lastQueryHotelRefs.length || state.lastQueryHotelRefs.some((ref) => typeof ref !== 'string' || !ref))) return false
450
+ if (state.lastQueryOfferHotels !== undefined && (!state.lastQueryHotelRefs || Object.values(state.lastQueryOfferHotels).some((ref) => typeof ref !== 'string' || !state.lastQueryHotelRefs!.includes(ref)) || Object.keys(state.lastQueryOfferHotels).some((ref) => typeof ref !== 'string' || !ref))) return false
451
+ if (state.lastQuerySourceActionId !== undefined && (typeof state.lastQuerySourceActionId !== 'string' || !state.lastQuerySourceActionId || typeof state.lastQuerySourceReceiptDigest !== 'string' || !/^[0-9a-f]{64}$/.test(state.lastQuerySourceReceiptDigest) || !Number.isSafeInteger(state.lastQueryWorkspaceRevision) || state.lastQueryWorkspaceRevision! < 0)) return false
452
+ if (state.lastQuerySourceReceiptDigest !== undefined && state.lastQuerySourceActionId === undefined) return false
453
+ if (state.hotelRefs.length === 0) return false
454
+ const validStatus = new Set<AvailabilityHotelStatusV2>(['unvisited', 'active', 'negative', 'confirmed', 'inconclusive'])
455
+ const validEvidence = new Set<AvailabilityHotelStateV2['lastEvidence']>(['none', 'confirmed', 'unavailable', 'inconclusive'])
456
+ for (const ref of state.hotelRefs) {
457
+ const hotel = state.hotels[ref]
458
+ if (!hotel || hotel.hotelRef !== ref || !validStatus.has(hotel.status) || !validEvidence.has(hotel.lastEvidence)) return false
459
+ if (![hotel.checksIssued, hotel.offerQueriesIssued, hotel.generationNo, hotel.generation, hotel.checkCount].every(Number.isSafeInteger)) return false
460
+ if (hotel.checksIssued < 0 || hotel.checksIssued > MAX_OFFER_CHECKS_PER_HOTEL_V2 || hotel.offerQueriesIssued < 0 || hotel.offerQueriesIssued > MAX_OFFER_QUERIES_PER_HOTEL_V2 || hotel.generationNo < 0 || hotel.generation !== hotel.generationNo || hotel.checkCount !== hotel.checksIssued) return false
461
+ if (typeof hotel.freshOffersRequired !== 'boolean' || !Array.isArray(hotel.currentOfferRefs) || !Array.isArray(hotel.invalidatedOfferRefs) || !Array.isArray(hotel.tombstonedOfferRefs) || !Array.isArray(hotel.tombstonedOfferVersionRefs)) return false
462
+ if (hotel.currentOfferRefs.some((offer) => typeof offer !== 'string' || !offer) || hotel.invalidatedOfferRefs.some((offer) => typeof offer !== 'string' || !offer) || hotel.tombstonedOfferRefs.some((offer) => typeof offer !== 'string' || !offer) || hotel.tombstonedOfferVersionRefs.some((offer) => typeof offer !== 'string' || !offer)) return false
463
+ if (new Set(hotel.currentOfferRefs).size !== hotel.currentOfferRefs.length || new Set(hotel.invalidatedOfferRefs).size !== hotel.invalidatedOfferRefs.length || new Set(hotel.tombstonedOfferRefs).size !== hotel.tombstonedOfferRefs.length || new Set(hotel.tombstonedOfferVersionRefs).size !== hotel.tombstonedOfferVersionRefs.length || hotel.currentOfferRefs.length > MAX_OFFERS_PER_HOTEL_GENERATION_V2 || hotel.invalidatedOfferRefs.length > MAX_OFFERS_PER_HOTEL_GENERATION_V2 || hotel.currentOfferRefs.some((offer) => hotel.invalidatedOfferRefs.includes(offer) || hotel.tombstonedOfferRefs.includes(offer))) return false
464
+ const generation = hotel.currentGeneration
465
+ if (hotel.generationNo === 0) {
466
+ if (generation !== undefined || hotel.currentOfferRefs.length || hotel.invalidatedOfferRefs.length || hotel.tombstonedOfferRefs.length || hotel.tombstonedOfferVersionRefs.length || hotel.status !== 'unvisited' || hotel.lastEvidence !== 'none' || hotel.checksIssued !== 0 || hotel.offerQueriesIssued !== 0 || hotel.freshOffersRequired) return false
467
+ continue
468
+ }
469
+ if (!generation || generation.generationId !== `${ref}:generation:${hotel.generationNo}` || !/^[0-9a-f]{64}$/.test(generation.offerSetDigest) || generation.offerSetDigest !== digestV2(generation.orderedOfferRefs) || !Array.isArray(generation.orderedOfferRefs) || generation.orderedOfferRefs.length > MAX_OFFERS_PER_HOTEL_GENERATION_V2 || generation.orderedOfferRefs.some((offer) => typeof offer !== 'string' || !offer) || new Set(generation.orderedOfferRefs).size !== generation.orderedOfferRefs.length || !['complete', 'partial'].includes(generation.evidence) || typeof generation.valid !== 'boolean') return false
470
+ if (generation.offerSetDigest !== digestV2(generation.orderedOfferRefs) || hotel.currentOfferRefs.some((offer) => !generation.orderedOfferRefs.includes(offer)) || hotel.invalidatedOfferRefs.some((offer) => !generation.orderedOfferRefs.includes(offer))) return false
471
+ const source = generation.source
472
+ if (!source || !['query_receipt', 'workspace_snapshot'].includes(source.kind) || !Number.isSafeInteger(source.workspaceRevision) || source.workspaceRevision < 0) return false
473
+ if (source.kind === 'query_receipt' && (typeof source.actionId !== 'string' || !source.actionId || !/^[0-9a-f]{64}$/.test(source.receiptDigest))) return false
474
+ if (source.kind === 'workspace_snapshot' && (typeof source.workspaceDigest !== 'string' || !/^[0-9a-f]{64}$/.test(source.workspaceDigest))) return false
475
+ if (source.kind === 'query_receipt' && state.lastQuerySourceActionId === source.actionId && (state.lastQuerySourceReceiptDigest !== source.receiptDigest || state.lastQueryWorkspaceRevision !== source.workspaceRevision)) return false
476
+ if (hotel.freshOffersRequired && (hotel.currentOfferRefs.length !== 0 || hotel.checksIssued >= MAX_OFFER_CHECKS_PER_HOTEL_V2 || hotel.offerQueriesIssued >= MAX_OFFER_QUERIES_PER_HOTEL_V2)) return false
477
+ if (hotel.status === 'unvisited' || hotel.status === 'negative') {
478
+ if (hotel.status === 'unvisited' || hotel.lastEvidence !== 'unavailable' || hotel.currentOfferRefs.length || generation.valid) return false
479
+ }
480
+ if (hotel.status === 'active' && (!hotel.currentOfferRefs.length || hotel.lastEvidence !== 'none' || hotel.freshOffersRequired)) return false
481
+ if (hotel.status === 'confirmed' && (hotel.lastEvidence !== 'confirmed' || !hotel.currentOfferRefs.length || generation.valid)) return false
482
+ if (hotel.status === 'inconclusive' && (hotel.lastEvidence !== 'inconclusive' || hotel.currentOfferRefs.length || generation.valid)) return false
483
+ }
484
+ const attempts = state.attempts
485
+ if (new Set(attempts.map((attempt) => attempt.actionId)).size !== attempts.length) return false
486
+ const attemptsByHotel = new Map<string, AvailabilityAttemptV2[]>()
487
+ for (const attempt of attempts) {
488
+ if (typeof attempt.actionId !== 'string' || !attempt.actionId || !state.hotels[attempt.hotelRef] || typeof attempt.offerRef !== 'string' || !attempt.offerRef || typeof attempt.offerVersionRef !== 'string' || !attempt.offerVersionRef || !Number.isSafeInteger(attempt.generation) || !Number.isSafeInteger(attempt.ordinal) || !Number.isSafeInteger(attempt.workspaceRevision) || attempt.workspaceRevision < 0) return false
489
+ const hotel = state.hotels[attempt.hotelRef]!
490
+ const generation = hotel.currentGeneration
491
+ if (!generation || attempt.generation < 1 || attempt.generation > hotel.generationNo || attempt.ordinal < 1 || attempt.ordinal > hotel.checksIssued || (attempt.generation === hotel.generationNo && !generation.orderedOfferRefs.includes(attempt.offerRef))) return false
492
+ const list = attemptsByHotel.get(attempt.hotelRef) ?? []; list.push(attempt); attemptsByHotel.set(attempt.hotelRef, list)
493
+ }
494
+ for (const [ref, attemptsForHotel] of attemptsByHotel) {
495
+ const ordinals = attemptsForHotel.map((attempt) => attempt.ordinal)
496
+ if (new Set(ordinals).size !== ordinals.length || attemptsForHotel.length !== state.hotels[ref]!.checksIssued || new Set(attemptsForHotel.map((attempt) => `${attempt.generation}:${attempt.offerRef}:${attempt.offerVersionRef}`)).size !== attemptsForHotel.length) return false
497
+ }
498
+ for (const ref of state.hotelRefs) if ((attemptsByHotel.get(ref)?.length ?? 0) !== state.hotels[ref]!.checksIssued) return false
499
+ if (new Set(state.queryReservations.map((reservation) => reservation.actionId)).size !== state.queryReservations.length) return false
500
+ const reservedHotels = new Set<string>()
501
+ for (const reservation of state.queryReservations) {
502
+ if (typeof reservation.actionId !== 'string' || !reservation.actionId || !Array.isArray(reservation.hotelRefs) || !reservation.hotelRefs.length || reservation.hotelRefs.length > MAX_HOTELS_PER_TASK_V2 || new Set(reservation.hotelRefs).size !== reservation.hotelRefs.length || reservation.hotelRefs.some((ref) => !state.hotels[ref]) || !Number.isSafeInteger(reservation.workspaceRevision) || reservation.workspaceRevision < 0 || reservation.hotelRefs.some((ref) => reservedHotels.has(ref))) return false
503
+ reservation.hotelRefs.forEach((ref) => reservedHotels.add(ref))
504
+ }
505
+ if (state.availabilityPhase === 'waiting_offers' && state.queryReservations.length === 0) return false
506
+ if (state.availabilityPhase !== 'waiting_offers' && state.queryReservations.length > 0) return false
507
+ if (state.availabilityPhase === 'waiting_check' && !attempts.length) return false
508
+ if (state.availabilityPhase === 'need_check') {
509
+ const active = state.hotels[state.hotelRefs[state.activeHotelOrdinal]!]!
510
+ if (active.status !== 'active' || !active.currentGeneration?.valid || !active.currentOfferRefs.length || active.freshOffersRequired) return false
511
+ }
512
+ if (state.availabilityPhase === 'terminal' && !state.terminal) return false
513
+ if (state.availabilityPhase !== 'terminal' && state.terminal) return false
514
+ const recomputedTerminal = terminalFor(state)
515
+ if (state.terminal) {
516
+ if (!recomputedTerminal || digestV2(state.terminal) !== digestV2(recomputedTerminal)) return false
517
+ } else if (recomputedTerminal && !['waiting_offers', 'waiting_check'].includes(state.availabilityPhase)) return false
518
+ return true
519
+ } catch {
520
+ return false
521
+ }
522
+ }
523
+ export function availabilityPolicyActionIsRelevantV2(action: BookingReadActionV2): boolean { return action.kind === 'offers.query' || action.kind === 'offer.check' }